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 <noreply@anthropic.com>
This commit is contained in:
JiXieShi
2026-05-24 03:30:43 +08:00
parent b1c499b340
commit e9a58dc363
2 changed files with 341 additions and 286 deletions
+308 -286
View File
@@ -1,286 +1,308 @@
package tui package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin" "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
) )
type doneMsg struct{} type doneMsg struct{}
type modeItem struct { type modeItem struct {
key string key string
label string label string
value string value string
rawValue string rawValue string
} }
type panelLine struct { type panelLine struct {
text string text string
selected bool selected bool
} }
type Model struct { type Model struct {
App *app.App App *app.App
viewport viewport.Model viewport viewport.Model
input textinput.Model input textinput.Model
ready bool ready bool
width int width int
height int height int
statusLine string statusLine string
suggestions []string suggestions []string
content strings.Builder content strings.Builder
followTail bool followTail bool
showModal bool showModal bool
modalTitle string modalTitle string
modalBody string modalBody string
panelKind event.UIPanelKind panelKind event.UIPanelKind
panelIndex int panelIndex int
panelError string panelError string
forwardItems []forward.Snapshot forwardItems []forward.Snapshot
pluginItems []luaplugin.Snapshot pluginItems []luaplugin.Snapshot
modeItems []modeItem modeItems []modeItem
promptActive bool promptActive bool
promptTitle string promptTitle string
promptHint string promptHint string
promptInput textinput.Model promptInput textinput.Model
promptSubmit func(string) promptSubmit func(string)
formActive bool formActive bool
formTitle string formTitle string
formFields []textinput.Model formFields []textinput.Model
formLabels []string formLabels []string
formFocus int formFocus int
formSubmit func([]string) formSubmit func([]string)
completionActive bool completionActive bool
completionBase string completionBase string
completionCandidates []string completionCandidates []string
completionIndex int completionIndex int
} }
func New(application *app.App) *Model { func New(application *app.App) *Model {
in := textinput.New() in := textinput.New()
in.Placeholder = "Type to send to remote, use .help for commands" in.Placeholder = "Type to send to remote, use .help for commands"
in.Focus() in.Focus()
in.CharLimit = 0 in.CharLimit = 0
in.Prompt = "> " in.Prompt = "> "
in.Width = 80 in.Width = 80
return &Model{App: application, input: in, followTail: true} return &Model{App: application, input: in, followTail: true}
} }
func (m *Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
return tea.Batch(waitUIEvent(m.App.UIEvents()), waitDone(m.App.WaitDone()), textinput.Blink) return tea.Batch(waitUIEvent(m.App.UIEvents()), waitDone(m.App.WaitDone()), textinput.Blink)
} }
func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd { func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
ev, ok := <-ch ev, ok := <-ch
if !ok { if !ok {
return doneMsg{} return doneMsg{}
} }
return ev return ev
} }
} }
func waitDone(ch <-chan struct{}) tea.Cmd { func waitDone(ch <-chan struct{}) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
<-ch <-ch
return doneMsg{} return doneMsg{}
} }
} }
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case doneMsg: case doneMsg:
return m, tea.Quit return m, tea.Quit
case event.UIEvent: case event.UIEvent:
switch msg.Kind { switch msg.Kind {
case event.UIEventOutput, event.UIEventStatus: case event.UIEventOutput, event.UIEventStatus:
if msg.Kind == event.UIEventOutput { if msg.Kind == event.UIEventOutput {
m.appendOutput(msg.Text) m.appendOutput(msg.Text)
} else { } else {
m.statusLine = msg.Text m.statusLine = msg.Text
} }
case event.UIEventModal: case event.UIEventModal:
m.showModal = true m.showModal = true
m.panelKind = event.UIPanelNone m.panelKind = event.UIPanelNone
m.modalTitle = msg.Title m.modalTitle = msg.Title
m.modalBody = msg.Text m.modalBody = msg.Text
m.promptActive = false m.promptActive = false
case event.UIEventPanel: case event.UIEventPanel:
m.openPanel(msg.Panel) m.openPanel(msg.Panel)
} }
return m, waitUIEvent(m.App.UIEvents()) return m, waitUIEvent(m.App.UIEvents())
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
inputHeight := 3 inputHeight := 3
statusHeight := 2 statusHeight := 2
viewportHeight := msg.Height - inputHeight - statusHeight viewportHeight := msg.Height - inputHeight - statusHeight
if viewportHeight < 3 { if viewportHeight < 3 {
viewportHeight = 3 viewportHeight = 3
} }
if !m.ready { if !m.ready {
m.viewport = viewport.New(msg.Width, viewportHeight) m.viewport = viewport.New(msg.Width, viewportHeight)
m.viewport.YPosition = 0 m.viewport.YPosition = 0
m.viewport.SetContent(m.content.String()) m.viewport.SetContent(m.content.String())
m.ready = true m.ready = true
} else { } else {
m.viewport.Width = msg.Width m.viewport.Width = msg.Width
m.viewport.Height = viewportHeight m.viewport.Height = viewportHeight
} }
m.input.Width = msg.Width - 4 m.input.Width = msg.Width - 4
m.viewport.GotoBottom() m.viewport.GotoBottom()
m.followTail = true m.followTail = true
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
keyStr := strings.ToLower(msg.String()) keyStr := strings.ToLower(msg.String())
if m.handleViewportKey(msg) { if m.handleViewportKey(msg) {
return m, nil return m, nil
} }
if keyStr != "tab" && keyStr != "shift+tab" { if keyStr != "tab" && keyStr != "shift+tab" {
m.resetCompletion() m.resetCompletion()
} }
if m.showModal { if m.showModal {
handled, cmd := m.handleModalKey(msg) handled, cmd := m.handleModalKey(msg)
if handled { if handled {
return m, cmd return m, cmd
} }
} }
if m.isLocalHotkey(keyStr, "c") { if m.isLocalHotkey(keyStr, "c") {
m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))) m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)))
m.App.Close() m.App.Close()
return m, tea.Quit return m, tea.Quit
} }
if handleLocalHotkey(m, keyStr) { if handleLocalHotkey(m, keyStr) {
return m, nil return m, nil
} }
if keyStr == "ctrl+h" { if keyStr == "ctrl+h" {
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h")) handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
return m, nil return m, nil
} }
if letter, ok := parseCtrlKey(keyStr); ok { if letter, ok := parseCtrlKey(keyStr); ok {
if err := m.App.SendCtrl(letter); err != nil { if err := m.App.SendCtrl(letter); err != nil {
m.App.Notifyf("[remote] ctrl send failed: %v", err) m.App.Notifyf("[remote] ctrl send failed: %v", err)
} }
return m, nil return m, nil
} }
switch keyStr { switch keyStr {
case "f1": case "f1":
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h")) handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
return m, nil return m, nil
case "tab", "shift+tab": case "tab", "shift+tab":
direction := 1 direction := 1
if keyStr == "shift+tab" { if keyStr == "shift+tab" {
direction = -1 direction = -1
} }
if m.completionActive && len(m.completionCandidates) > 0 { if m.completionActive && len(m.completionCandidates) > 0 {
m.stepCompletion(direction) m.stepCompletion(direction)
return m, nil return m, nil
} }
line, cands := m.App.Dispatcher().Complete(m.input.Value()) line, cands := m.App.Dispatcher().Complete(m.input.Value())
m.suggestions = cands m.suggestions = cands
if len(cands) == 0 { if len(cands) == 0 {
return m, nil return m, nil
} }
if len(cands) == 1 { if len(cands) == 1 {
m.input.SetValue(line) m.input.SetValue(line)
return m, nil return m, nil
} }
m.completionActive = true m.completionActive = true
m.completionBase = completionBase(m.input.Value()) m.completionBase = completionBase(m.input.Value())
m.completionCandidates = append([]string(nil), cands...) m.completionCandidates = append([]string(nil), cands...)
if direction < 0 { if direction < 0 {
m.completionIndex = len(cands) - 1 m.completionIndex = len(cands) - 1
} else { } else {
m.completionIndex = 0 m.completionIndex = 0
} }
m.applyCompletion() m.applyCompletion()
return m, nil return m, nil
case "enter": case "enter":
line := m.input.Value() line := m.input.Value()
m.input.SetValue("") m.input.SetValue("")
m.suggestions = nil m.suggestions = nil
m.followTail = true m.followTail = true
m.App.HandleLine(line) m.App.HandleLine(line)
return m, nil return m, nil
} }
} }
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg) // Handle CSI u sequences that bubbletea does not parse into KeyMsg
return m, cmd if b, ok := msg.([]byte); ok {
} if key, ok2 := parseCSIuBytes(b); ok2 {
keyStr := strings.ToLower(key)
func (m *Model) View() string { if m.showModal {
if !m.ready { last := rune(key[len(key)-1])
return "Initializing..." fake := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{last}, Alt: strings.Contains(key, "alt+")}
} if handled, _ := m.handleModalKey(fake); handled {
return m, nil
suggest := "Tab: no candidates" }
if len(m.suggestions) > 1 { }
suggest = "Tab candidates: " + strings.Join(m.suggestions, " ") if keyStr == normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)+"+c" {
} else if len(m.suggestions) == 1 { m.App.Close()
suggest = "Tab: " + m.suggestions[0] return m, tea.Quit
} }
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)) if handleLocalHotkey(m, keyStr) {
hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help" return m, nil
hotkeys = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("244")).Render(hotkeys) }
status := m.statusLine }
if status == "" { }
status = "Ready"
} var cmd tea.Cmd
status = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render(status) m.input, cmd = m.input.Update(msg)
suggest = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(suggest) return m, cmd
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) func (m *Model) View() string {
} if !m.ready {
return "Initializing..."
if m.formActive { }
return renderCenteredModalContent(m.width, m.height, m.renderForm())
} suggest := "Tab: no candidates"
if len(m.suggestions) > 1 {
if m.promptActive { suggest = "Tab candidates: " + strings.Join(m.suggestions, " ")
return renderCenteredModalContent(m.width, m.height, m.renderPrompt()) } else if len(m.suggestions) == 1 {
} suggest = "Tab: " + m.suggestions[0]
}
if m.panelKind != event.UIPanelNone { modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))
return renderCenteredModalContent(m.width, m.height, m.renderPanel()) 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
return renderCenteredModal(m.width, m.height, m.modalTitle, m.modalBody) 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)
}
+33
View File
@@ -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)
}
})
}
}