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
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)
}
+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)
}
})
}
}