From e9a58dc36349f20a8c4abe7ee33259b3dd0c221a Mon Sep 17 00:00:00 2001 From: JiXieShi Date: Sun, 24 May 2026 03:30:43 +0800 Subject: [PATCH] fix: insert CSI u handler into model.go + add tui tests The previous commit defined parseCSIuBytes but failed to insert the handler into Update(). Now properly inserted before the textinput fallback. Add 10 test cases for parseCSIuBytes covering ctrl+alt+f/c/ m/p/h, ctrl+shift+c, alt+c, and invalid sequences. Co-Authored-By: Claude Opus 4.7 --- internal/tui/model.go | 594 ++++++++++++++++++++------------------- internal/tui/tui_test.go | 33 +++ 2 files changed, 341 insertions(+), 286 deletions(-) create mode 100644 internal/tui/tui_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 34abaeb..c43e3fa 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,286 +1,308 @@ -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app" - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin" -) - -type doneMsg struct{} - -type modeItem struct { - key string - label string - value string - rawValue string -} - -type panelLine struct { - text string - selected bool -} - -type Model struct { - App *app.App - - viewport viewport.Model - input textinput.Model - - ready bool - width int - height int - statusLine string - suggestions []string - content strings.Builder - followTail bool - - showModal bool - modalTitle string - modalBody string - - panelKind event.UIPanelKind - panelIndex int - panelError string - - forwardItems []forward.Snapshot - pluginItems []luaplugin.Snapshot - modeItems []modeItem - - promptActive bool - promptTitle string - promptHint string - promptInput textinput.Model - promptSubmit func(string) - - formActive bool - formTitle string - formFields []textinput.Model - formLabels []string - formFocus int - formSubmit func([]string) - - completionActive bool - completionBase string - completionCandidates []string - completionIndex int -} - -func New(application *app.App) *Model { - in := textinput.New() - in.Placeholder = "Type to send to remote, use .help for commands" - in.Focus() - in.CharLimit = 0 - in.Prompt = "> " - in.Width = 80 - - return &Model{App: application, input: in, followTail: true} -} - -func (m *Model) Init() tea.Cmd { - return tea.Batch(waitUIEvent(m.App.UIEvents()), waitDone(m.App.WaitDone()), textinput.Blink) -} - -func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd { - return func() tea.Msg { - ev, ok := <-ch - if !ok { - return doneMsg{} - } - return ev - } -} - -func waitDone(ch <-chan struct{}) tea.Cmd { - return func() tea.Msg { - <-ch - return doneMsg{} - } -} - -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case doneMsg: - return m, tea.Quit - - case event.UIEvent: - switch msg.Kind { - case event.UIEventOutput, event.UIEventStatus: - if msg.Kind == event.UIEventOutput { - m.appendOutput(msg.Text) - } else { - m.statusLine = msg.Text - } - case event.UIEventModal: - m.showModal = true - m.panelKind = event.UIPanelNone - m.modalTitle = msg.Title - m.modalBody = msg.Text - m.promptActive = false - case event.UIEventPanel: - m.openPanel(msg.Panel) - } - return m, waitUIEvent(m.App.UIEvents()) - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - inputHeight := 3 - statusHeight := 2 - viewportHeight := msg.Height - inputHeight - statusHeight - if viewportHeight < 3 { - viewportHeight = 3 - } - - if !m.ready { - m.viewport = viewport.New(msg.Width, viewportHeight) - m.viewport.YPosition = 0 - m.viewport.SetContent(m.content.String()) - m.ready = true - } else { - m.viewport.Width = msg.Width - m.viewport.Height = viewportHeight - } - - m.input.Width = msg.Width - 4 - m.viewport.GotoBottom() - m.followTail = true - return m, nil - - case tea.KeyMsg: - keyStr := strings.ToLower(msg.String()) - if m.handleViewportKey(msg) { - return m, nil - } - if keyStr != "tab" && keyStr != "shift+tab" { - m.resetCompletion() - } - - if m.showModal { - handled, cmd := m.handleModalKey(msg) - if handled { - return m, cmd - } - } - - if m.isLocalHotkey(keyStr, "c") { - m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))) - m.App.Close() - return m, tea.Quit - } - - if handleLocalHotkey(m, keyStr) { - return m, nil - } - - if keyStr == "ctrl+h" { - handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h")) - return m, nil - } - - if letter, ok := parseCtrlKey(keyStr); ok { - if err := m.App.SendCtrl(letter); err != nil { - m.App.Notifyf("[remote] ctrl send failed: %v", err) - } - return m, nil - } - - switch keyStr { - case "f1": - handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h")) - return m, nil - - case "tab", "shift+tab": - direction := 1 - if keyStr == "shift+tab" { - direction = -1 - } - - if m.completionActive && len(m.completionCandidates) > 0 { - m.stepCompletion(direction) - return m, nil - } - - line, cands := m.App.Dispatcher().Complete(m.input.Value()) - m.suggestions = cands - if len(cands) == 0 { - return m, nil - } - if len(cands) == 1 { - m.input.SetValue(line) - return m, nil - } - - m.completionActive = true - m.completionBase = completionBase(m.input.Value()) - m.completionCandidates = append([]string(nil), cands...) - if direction < 0 { - m.completionIndex = len(cands) - 1 - } else { - m.completionIndex = 0 - } - m.applyCompletion() - return m, nil - - case "enter": - line := m.input.Value() - m.input.SetValue("") - m.suggestions = nil - m.followTail = true - m.App.HandleLine(line) - return m, nil - } - } - - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd -} - -func (m *Model) View() string { - if !m.ready { - return "Initializing..." - } - - suggest := "Tab: no candidates" - if len(m.suggestions) > 1 { - suggest = "Tab candidates: " + strings.Join(m.suggestions, " ") - } else if len(m.suggestions) == 1 { - suggest = "Tab: " + m.suggestions[0] - } - modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)) - hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help" - hotkeys = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("244")).Render(hotkeys) - status := m.statusLine - if status == "" { - status = "Ready" - } - status = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render(status) - suggest = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(suggest) - base := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", m.viewport.View(), suggest, status, m.input.View(), hotkeys) - if !m.showModal { - return fillScreen(m.width, m.height, base) - } - - if m.formActive { - return renderCenteredModalContent(m.width, m.height, m.renderForm()) - } - - if m.promptActive { - return renderCenteredModalContent(m.width, m.height, m.renderPrompt()) - } - - if m.panelKind != event.UIPanelNone { - return renderCenteredModalContent(m.width, m.height, m.renderPanel()) - } - - return renderCenteredModal(m.width, m.height, m.modalTitle, m.modalBody) -} +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app" + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" + "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" + "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin" +) + +type doneMsg struct{} + +type modeItem struct { + key string + label string + value string + rawValue string +} + +type panelLine struct { + text string + selected bool +} + +type Model struct { + App *app.App + + viewport viewport.Model + input textinput.Model + + ready bool + width int + height int + statusLine string + suggestions []string + content strings.Builder + followTail bool + + showModal bool + modalTitle string + modalBody string + + panelKind event.UIPanelKind + panelIndex int + panelError string + + forwardItems []forward.Snapshot + pluginItems []luaplugin.Snapshot + modeItems []modeItem + + promptActive bool + promptTitle string + promptHint string + promptInput textinput.Model + promptSubmit func(string) + + formActive bool + formTitle string + formFields []textinput.Model + formLabels []string + formFocus int + formSubmit func([]string) + + completionActive bool + completionBase string + completionCandidates []string + completionIndex int +} + +func New(application *app.App) *Model { + in := textinput.New() + in.Placeholder = "Type to send to remote, use .help for commands" + in.Focus() + in.CharLimit = 0 + in.Prompt = "> " + in.Width = 80 + + return &Model{App: application, input: in, followTail: true} +} + +func (m *Model) Init() tea.Cmd { + return tea.Batch(waitUIEvent(m.App.UIEvents()), waitDone(m.App.WaitDone()), textinput.Blink) +} + +func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd { + return func() tea.Msg { + ev, ok := <-ch + if !ok { + return doneMsg{} + } + return ev + } +} + +func waitDone(ch <-chan struct{}) tea.Cmd { + return func() tea.Msg { + <-ch + return doneMsg{} + } +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case doneMsg: + return m, tea.Quit + + case event.UIEvent: + switch msg.Kind { + case event.UIEventOutput, event.UIEventStatus: + if msg.Kind == event.UIEventOutput { + m.appendOutput(msg.Text) + } else { + m.statusLine = msg.Text + } + case event.UIEventModal: + m.showModal = true + m.panelKind = event.UIPanelNone + m.modalTitle = msg.Title + m.modalBody = msg.Text + m.promptActive = false + case event.UIEventPanel: + m.openPanel(msg.Panel) + } + return m, waitUIEvent(m.App.UIEvents()) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + inputHeight := 3 + statusHeight := 2 + viewportHeight := msg.Height - inputHeight - statusHeight + if viewportHeight < 3 { + viewportHeight = 3 + } + + if !m.ready { + m.viewport = viewport.New(msg.Width, viewportHeight) + m.viewport.YPosition = 0 + m.viewport.SetContent(m.content.String()) + m.ready = true + } else { + m.viewport.Width = msg.Width + m.viewport.Height = viewportHeight + } + + m.input.Width = msg.Width - 4 + m.viewport.GotoBottom() + m.followTail = true + return m, nil + + case tea.KeyMsg: + keyStr := strings.ToLower(msg.String()) + if m.handleViewportKey(msg) { + return m, nil + } + if keyStr != "tab" && keyStr != "shift+tab" { + m.resetCompletion() + } + + if m.showModal { + handled, cmd := m.handleModalKey(msg) + if handled { + return m, cmd + } + } + + if m.isLocalHotkey(keyStr, "c") { + m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))) + m.App.Close() + return m, tea.Quit + } + + if handleLocalHotkey(m, keyStr) { + return m, nil + } + + if keyStr == "ctrl+h" { + handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h")) + return m, nil + } + + if letter, ok := parseCtrlKey(keyStr); ok { + if err := m.App.SendCtrl(letter); err != nil { + m.App.Notifyf("[remote] ctrl send failed: %v", err) + } + return m, nil + } + + switch keyStr { + case "f1": + handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h")) + return m, nil + + case "tab", "shift+tab": + direction := 1 + if keyStr == "shift+tab" { + direction = -1 + } + + if m.completionActive && len(m.completionCandidates) > 0 { + m.stepCompletion(direction) + return m, nil + } + + line, cands := m.App.Dispatcher().Complete(m.input.Value()) + m.suggestions = cands + if len(cands) == 0 { + return m, nil + } + if len(cands) == 1 { + m.input.SetValue(line) + return m, nil + } + + m.completionActive = true + m.completionBase = completionBase(m.input.Value()) + m.completionCandidates = append([]string(nil), cands...) + if direction < 0 { + m.completionIndex = len(cands) - 1 + } else { + m.completionIndex = 0 + } + m.applyCompletion() + return m, nil + + case "enter": + line := m.input.Value() + m.input.SetValue("") + m.suggestions = nil + m.followTail = true + m.App.HandleLine(line) + return m, nil + } + } + + + // Handle CSI u sequences that bubbletea does not parse into KeyMsg + if b, ok := msg.([]byte); ok { + if key, ok2 := parseCSIuBytes(b); ok2 { + keyStr := strings.ToLower(key) + if m.showModal { + last := rune(key[len(key)-1]) + fake := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{last}, Alt: strings.Contains(key, "alt+")} + if handled, _ := m.handleModalKey(fake); handled { + return m, nil + } + } + if keyStr == normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)+"+c" { + m.App.Close() + return m, tea.Quit + } + if handleLocalHotkey(m, keyStr) { + return m, nil + } + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m *Model) View() string { + if !m.ready { + return "Initializing..." + } + + suggest := "Tab: no candidates" + if len(m.suggestions) > 1 { + suggest = "Tab candidates: " + strings.Join(m.suggestions, " ") + } else if len(m.suggestions) == 1 { + suggest = "Tab: " + m.suggestions[0] + } + modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)) + hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help" + hotkeys = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("244")).Render(hotkeys) + status := m.statusLine + if status == "" { + status = "Ready" + } + status = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render(status) + suggest = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(suggest) + base := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", m.viewport.View(), suggest, status, m.input.View(), hotkeys) + if !m.showModal { + return fillScreen(m.width, m.height, base) + } + + if m.formActive { + return renderCenteredModalContent(m.width, m.height, m.renderForm()) + } + + if m.promptActive { + return renderCenteredModalContent(m.width, m.height, m.renderPrompt()) + } + + if m.panelKind != event.UIPanelNone { + return renderCenteredModalContent(m.width, m.height, m.renderPanel()) + } + + return renderCenteredModal(m.width, m.height, m.modalTitle, m.modalBody) +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..2b76e2e --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,33 @@ +package tui + +import "testing" + +func TestParseCSIuBytes(t *testing.T) { + tests := []struct { + name string + seq []byte + want string + ok bool + }{ + {name: "ctrl+alt+f", seq: []byte{0x1b, '[', '1', '0', '2', ';', '6', 'u'}, want: "ctrl+alt+f", ok: true}, + {name: "ctrl+alt+c", seq: []byte{0x1b, '[', '9', '9', ';', '6', 'u'}, want: "ctrl+alt+c", ok: true}, + {name: "ctrl+alt+m", seq: []byte{0x1b, '[', '1', '0', '9', ';', '6', 'u'}, want: "ctrl+alt+m", ok: true}, + {name: "ctrl+alt+p", seq: []byte{0x1b, '[', '1', '1', '2', ';', '6', 'u'}, want: "ctrl+alt+p", ok: true}, + {name: "ctrl+alt+h", seq: []byte{0x1b, '[', '1', '0', '4', ';', '6', 'u'}, want: "ctrl+alt+h", ok: true}, + {name: "ctrl+shift+c", seq: []byte{0x1b, '[', '9', '9', ';', '5', 'u'}, want: "ctrl+shift+c", ok: true}, + {name: "alt+c (no ctrl)", seq: []byte{0x1b, '[', '9', '9', ';', '2', 'u'}, want: "alt+c", ok: true}, + {name: "plain c", seq: []byte{0x1b, '[', '9', '9', ';', '0', 'u'}, want: "c", ok: true}, + {name: "not CSI u", seq: []byte{0x1b, '[', 'A'}, want: "", ok: false}, + {name: "empty", seq: []byte{}, want: "", ok: false}, + {name: "no escape", seq: []byte("hello"), want: "", ok: false}, + {name: "ESC [ A (arrow up)", seq: []byte{0x1b, '[', 'A'}, want: "", ok: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseCSIuBytes(tt.seq) + if ok != tt.ok || got != tt.want { + t.Fatalf("parseCSIuBytes(%v): got=(%q,%v) want=(%q,%v)", tt.seq, got, ok, tt.want, tt.ok) + } + }) + } +}