From 041f40672d8f73cade288fabea155e3e2eb6d601 Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Thu, 8 Oct 2026 11:46:31 +0800 Subject: [PATCH] fix(tui): offer local command completion before kernel replies --- internal/frontend/tui/complete.go | 19 +++-- internal/frontend/tui/complete_local_test.go | 84 ++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 internal/frontend/tui/complete_local_test.go diff --git a/internal/frontend/tui/complete.go b/internal/frontend/tui/complete.go index e0f6244143..cfee4f55a0 100644 --- a/internal/frontend/tui/complete.go +++ b/internal/frontend/tui/complete.go @@ -1,6 +1,7 @@ package tui import ( + "slices" "strings" "unicode/utf16" "unicode/utf8" @@ -85,14 +86,17 @@ func (m *model) refreshMenu() tea.Cmd { m.menu = nil return nil } + m.onCompletion(completionMsg{line: line}) return m.fetchCompletion() } func (m *model) onCompletion(msg completionMsg) { - if msg.err != nil || msg.line != m.composer.Value() { - m.menu = nil + if msg.line != m.composer.Value() { return } + if msg.err != nil { + msg.c = Completion{} + } c := msg.c c.Items = append(m.localCommands(msg.line), c.Items...) if c.Typed != nil { @@ -105,7 +109,11 @@ func (m *model) onCompletion(msg completionMsg) { if len(msg.c.Items) == 0 { c.From, c.To = 0, utf16At(msg.line, len(msg.line)) } - m.menu = &menu{line: msg.line, c: c} + sel := 0 + if m.menu != nil && m.menu.line == msg.line { + sel = max(0, slices.Index(c.Items, m.menu.c.Items[m.menu.sel])) + } + m.menu = &menu{line: msg.line, c: c, sel: sel} } // localCommands are the slash commands this screen answers itself, offered @@ -145,7 +153,8 @@ func (m *model) localCommands(line string) []CompletionItem { // menuKey takes the keys an open menu owns. func (m *model) menuKey(k string) (tea.Cmd, bool) { - if m.menu == nil { + if m.menu == nil || m.menu.line != m.composer.Value() { + m.menu = nil return nil, false } n := len(m.menu.c.Items) @@ -201,7 +210,7 @@ func (m *model) acceptCompletion() tea.Cmd { } func (m *model) menuLines() []string { - if m.menu == nil { + if m.menu == nil || m.menu.line != m.composer.Value() { return nil } items := m.menu.c.Items diff --git a/internal/frontend/tui/complete_local_test.go b/internal/frontend/tui/complete_local_test.go new file mode 100644 index 0000000000..6f62543553 --- /dev/null +++ b/internal/frontend/tui/complete_local_test.go @@ -0,0 +1,84 @@ +package tui + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestTabCompletesLocalCommandsBeforeAnyKernelReply(t *testing.T) { + for _, tc := range []struct{ typed, want string }{ + {"/resu", "/resume"}, + {"/ver", "/version"}, + {"/cop", "/copy"}, + } { + t.Run(tc.typed, func(t *testing.T) { + m, k := testModel(t) + typeText(m, tc.typed) + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := m.composer.Value(); got != tc.want { + t.Fatalf("composer after immediate Tab = %q, want %q", got, tc.want) + } + if calledWith(k, "GET /complete") { + t.Fatal("local completion waited for a kernel request") + } + }) + } +} + +func TestStaleKernelCompletionKeepsTheCurrentLocalMenu(t *testing.T) { + m, _ := testModel(t) + _, old := m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + typeText(m, "resu") + run(m, old) + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := m.composer.Value(); got != "/resume" { + t.Fatalf("composer after stale reply and Tab = %q, want /resume", got) + } +} + +func TestKernelReplyKeepsTheHighlightedLocalCommand(t *testing.T) { + m, _ := testModel(t) + _, pending := m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + press(m, "down") + run(m, pending) + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := m.composer.Value(); got != "/queue" { + t.Fatalf("composer after Down, reply, Tab = %q, want /queue", got) + } +} + +func TestLocalCompletionSurvivesAKernelFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + m := newModel(context.Background(), Options{Client: &Client{HTTP: srv.Client(), Base: srv.URL}}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + typeText(m, "/res") + _, cmd := m.Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + run(m, cmd) + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := m.composer.Value(); got != "/resume" { + t.Fatalf("composer after failed lookup and Tab = %q, want /resume", got) + } +} + +func TestTabAfterClearingDoesNotAcceptThePreviousMenu(t *testing.T) { + m, _ := testModel(t) + typeText(m, "/resu") + press(m, "ctrl+c") + if rows := m.menuLines(); len(rows) != 0 { + t.Fatalf("the cleared input still shows its old menu: %v", rows) + } + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := m.composer.Value(); got != "" { + t.Fatalf("Tab restored the cleared command: %q", got) + } + if cmd == nil { + t.Fatal("Tab did not request completions for the cleared input") + } +}