fix(tui): offer local command completion before kernel replies

This commit is contained in:
xiexian committed 2026-10-08 11:46:31 +08:00
1 parent 44e9492be4
commit 041f40672d
2 files changed
+98 -5

No files matched your search

+14 -5
View File
@@ -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
@@ -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")
}
}