Skip to content

Commit 012ae40

Browse files
committed
perf: refine command UI and simplify terminal overlays
1 parent 1ae38e5 commit 012ae40

10 files changed

Lines changed: 679 additions & 151 deletions

File tree

internal/ui/app.go

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -138,30 +138,6 @@ func (a *App) overlayState(m *tui.Model) *tui.OverlayState {
138138
}
139139
}
140140

141-
// completions returns matching slash command candidates for the given prefix.
142-
func (a *App) completions(prefix string) []tui.CompletionItem {
143-
lower := strings.ToLower(prefix)
144-
var items []tui.CompletionItem
145-
seen := make(map[string]bool)
146-
for _, cmd := range a.registry.All() {
147-
spec := a.registry.EffectiveSpec(cmd)
148-
if spec.Hidden {
149-
continue
150-
}
151-
if strings.HasPrefix(spec.Name, lower) && !seen[spec.Name] {
152-
items = append(items, tui.CompletionItem{Name: spec.Name, Description: spec.Description})
153-
seen[spec.Name] = true
154-
}
155-
for _, alias := range spec.Aliases {
156-
if strings.HasPrefix(alias, lower) && !seen[alias] {
157-
items = append(items, tui.CompletionItem{Name: alias, Description: spec.Description})
158-
seen[alias] = true
159-
}
160-
}
161-
}
162-
return items
163-
}
164-
165141
// onPaste returns a tea.Cmd that asynchronously reads clipboard image data.
166142
func (a *App) onPaste(m *tui.Model) tea.Cmd {
167143
return func() tea.Msg {

internal/ui/cmd_model.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,10 @@ func (c *ModelCommand) View(width int) string {
197197
sb.WriteString(hint)
198198
sb.WriteString("\n")
199199

200-
selectedStyle := lipgloss.NewStyle().Foreground(tui.ColorAccent).Bold(true)
200+
selectedStyle := lipgloss.NewStyle().Foreground(tui.ColorPrimary).Bold(true)
201201
currentMark := lipgloss.NewStyle().Foreground(tui.ColorSuccess)
202202
dimStyle := tui.MutedStyle
203-
headerStyle := lipgloss.NewStyle().Foreground(tui.ColorAccent)
203+
headerStyle := tui.MutedStyle
204204

205205
reg := c.app.Session.Registry()
206206

@@ -209,7 +209,7 @@ func (c *ModelCommand) View(width int) string {
209209
// Render group header when entering a new provider section.
210210
if groupIdx < len(s.groups) && s.groups[groupIdx].startIdx == i {
211211
g := s.groups[groupIdx]
212-
header := fmt.Sprintf("─ %s ", g.name) + strings.Repeat("─", max(0, 30-len(g.name)))
212+
header := " " + g.name
213213
sb.WriteString(headerStyle.Render(header))
214214
sb.WriteString("\n")
215215
groupIdx++
@@ -334,4 +334,3 @@ func buildModelList(providers map[string]config.ProviderConfig) ([]modelSelectEn
334334
}
335335
return entries, groups
336336
}
337-

internal/ui/command.go

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/atotto/clipboard"
99
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/lipgloss"
1011
"github.com/voocel/codebot/internal/config"
1112
"github.com/voocel/codebot/internal/cron"
1213
"github.com/voocel/codebot/internal/policy"
@@ -22,7 +23,7 @@ func (a *App) handleCommand(input string) tea.Cmd {
2223
cmd, ok := a.registry.Lookup(inv.Name)
2324
if !ok {
2425
return tui.SendCommandResult(tui.CommandStyle.Render(
25-
fmt.Sprintf("Unknown command: /%s. Type /help for available commands.", inv.Name)))
26+
fmt.Sprintf("Unknown command: /%s. 输入 / 浏览命令,或用 /help 查看完整列表。", inv.Name)))
2627
}
2728

2829
spec := cmd.Spec()
@@ -143,7 +144,7 @@ func (a *App) builtinCommands() []Command {
143144
NewSimple(CommandSpec{
144145
Name: "loop", Usage: "/loop <interval|cron> <prompt>",
145146
Description: "Schedule recurring prompts",
146-
Risk: policy.RiskLow, Kind: CommandKindBuiltin,
147+
Risk: policy.RiskLow, Kind: CommandKindBuiltin,
147148
}, func(ctx *CommandContext, inv CommandInvocation) tea.Cmd {
148149
return ctx.App.cmdLoop(inv.RawArgs)
149150
}),
@@ -171,31 +172,40 @@ func (a *App) helpText() string {
171172
groups[spec.Kind] = append(groups[spec.Kind], cmd)
172173
}
173174

175+
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("247"))
176+
sectionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("249"))
174177
var sb strings.Builder
175-
sb.WriteString("Available commands:\n")
178+
sb.WriteString(headerStyle.Render("Available commands (/ opens the command palette):"))
176179
a.renderCommandGroup(&sb, "Built-in", groups[CommandKindBuiltin])
177180
a.renderCommandGroup(&sb, "Custom commands", groups[CommandKindCustom])
178181
a.renderCommandGroup(&sb, "Skills", groups[CommandKindSkill])
179182

180-
sb.WriteString(strings.TrimSpace(`
183+
sb.WriteString("\n\n")
184+
sb.WriteString(sectionStyle.Render("Keyboard shortcuts:"))
185+
sb.WriteString("\n")
186+
sb.WriteString(tui.MutedStyle.Render(strings.TrimSpace(`
181187
182-
Keyboard shortcuts:
183188
Enter Send message
184189
Esc Abort running agent
185190
Ctrl+C Quit
186-
`))
191+
`)))
187192

188-
return tui.CommandStyle.Render(sb.String())
193+
return sb.String()
189194
}
190195

191196
func (a *App) renderCommandGroup(sb *strings.Builder, title string, commands []Command) {
192197
if len(commands) == 0 {
193198
return
194199
}
195200

201+
sectionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("249"))
202+
usageStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
203+
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("247"))
204+
metaStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
205+
196206
sb.WriteString("\n\n")
197-
sb.WriteString(title)
198-
sb.WriteString(":\n")
207+
sb.WriteString(sectionStyle.Render(title + ":"))
208+
sb.WriteString("\n")
199209

200210
for _, cmd := range commands {
201211
spec := a.registry.EffectiveSpec(cmd)
@@ -224,7 +234,14 @@ func (a *App) renderCommandGroup(sb *strings.Builder, title string, commands []C
224234
if len(spec.Aliases) > 0 {
225235
tags = append(tags, "aliases: "+strings.Join(spec.Aliases, ","))
226236
}
227-
fmt.Fprintf(sb, " %-24s %s [%s]\n", usage, desc, strings.Join(tags, ", "))
237+
238+
sb.WriteString(" ")
239+
sb.WriteString(usageStyle.Render(fmt.Sprintf("%-24s", usage)))
240+
sb.WriteString(" ")
241+
sb.WriteString(descStyle.Render(desc))
242+
sb.WriteString(" ")
243+
sb.WriteString(metaStyle.Render("[" + strings.Join(tags, ", ") + "]"))
244+
sb.WriteString("\n")
228245
}
229246
}
230247

@@ -312,7 +329,6 @@ func (a *App) cmdNew() tea.Cmd {
312329
}
313330
}
314331

315-
316332
func (a *App) cmdSettings() tea.Cmd {
317333
s := a.Session.Settings()
318334
baseURL := a.Session.BaseURL()
@@ -325,10 +341,42 @@ func (a *App) cmdSettings() tea.Cmd {
325341
}
326342
apiKey := a.Session.APIKey()
327343
masked := maskKey(apiKey)
328-
info := fmt.Sprintf("Provider: %s\nModel: %s\nAPI Key: %s\nBase URL: %s\nThinking level: %s\nContext window: %d\nAuto compaction: %v\nMax turns: %d\nConfig: %s",
329-
s.Provider, a.Session.ModelName(), masked, baseURL,
330-
thinking, s.ContextWindow, s.AutoCompaction, s.MaxTurns, config.SettingsPath(a.Cwd))
331-
return tui.SendCommandResult(tui.CommandStyle.Render(info))
344+
345+
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
346+
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("247"))
347+
metaStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("248"))
348+
349+
autoCompact := "off"
350+
if s.AutoCompaction {
351+
autoCompact = "on"
352+
}
353+
354+
var sb strings.Builder
355+
renderRow := func(label, value string) {
356+
sb.WriteString(labelStyle.Render(fmt.Sprintf("%-16s", label)))
357+
sb.WriteString(" ")
358+
sb.WriteString(valueStyle.Render(value))
359+
sb.WriteString("\n")
360+
}
361+
renderMetaRow := func(label, value string) {
362+
sb.WriteString(labelStyle.Render(fmt.Sprintf("%-16s", label)))
363+
sb.WriteString(" ")
364+
sb.WriteString(metaStyle.Render(value))
365+
sb.WriteString("\n")
366+
}
367+
368+
renderRow("Provider", s.Provider)
369+
renderRow("Model", a.Session.ModelName())
370+
renderRow("API key", masked)
371+
renderRow("Base URL", baseURL)
372+
sb.WriteString("\n")
373+
renderRow("Thinking", thinking)
374+
renderRow("Context", tui.FormatTokens(s.ContextWindow))
375+
renderRow("Auto compact", autoCompact)
376+
renderRow("Max turns", fmt.Sprintf("%d", s.MaxTurns))
377+
renderMetaRow("Config", config.SettingsPath(a.Cwd))
378+
379+
return tui.SendCommandResult(strings.TrimRight(sb.String(), "\n"))
332380
}
333381

334382
func maskKey(key string) string {

internal/ui/command_palette.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package ui
2+
3+
import (
4+
"sort"
5+
"strings"
6+
7+
"github.com/voocel/codebot/internal/ui/tui"
8+
)
9+
10+
type commandPaletteMatch struct {
11+
item tui.CompletionItem
12+
score int
13+
}
14+
15+
func (a *App) completions(prefix string) []tui.CompletionItem {
16+
query := strings.TrimSpace(strings.ToLower(prefix))
17+
var matches []commandPaletteMatch
18+
19+
for _, cmd := range a.registry.All() {
20+
spec := a.registry.EffectiveSpec(cmd)
21+
if spec.Hidden {
22+
continue
23+
}
24+
item, score, ok := buildCommandPaletteItem(spec, query)
25+
if !ok {
26+
continue
27+
}
28+
matches = append(matches, commandPaletteMatch{item: item, score: score})
29+
}
30+
31+
sort.SliceStable(matches, func(i, j int) bool {
32+
if matches[i].score != matches[j].score {
33+
return matches[i].score > matches[j].score
34+
}
35+
kindI := commandKindOrder[CommandKind(matches[i].item.Kind)]
36+
kindJ := commandKindOrder[CommandKind(matches[j].item.Kind)]
37+
if kindI != kindJ {
38+
return kindI < kindJ
39+
}
40+
return strings.Compare(matches[i].item.Name, matches[j].item.Name) < 0
41+
})
42+
43+
items := make([]tui.CompletionItem, 0, len(matches))
44+
for _, match := range matches {
45+
items = append(items, match.item)
46+
}
47+
return items
48+
}
49+
50+
func buildCommandPaletteItem(spec CommandSpec, query string) (tui.CompletionItem, int, bool) {
51+
item := tui.CompletionItem{
52+
Name: spec.Name,
53+
Description: spec.Description,
54+
Usage: spec.Usage,
55+
Kind: string(spec.Kind),
56+
Risk: string(spec.Risk),
57+
NeedsIdle: spec.NeedsIdle,
58+
Source: spec.Source,
59+
Aliases: append([]string(nil), spec.Aliases...),
60+
AutoExecute: commandPaletteAutoExecute(spec),
61+
}
62+
63+
if item.Description == "" {
64+
item.Description = "(no description)"
65+
}
66+
if item.Usage == "" {
67+
item.Usage = "/" + item.Name
68+
}
69+
if item.Risk == "" {
70+
item.Risk = "low"
71+
}
72+
73+
score, ok := scoreCommandPaletteItem(spec, query)
74+
return item, score, ok
75+
}
76+
77+
func scoreCommandPaletteItem(spec CommandSpec, query string) (int, bool) {
78+
if query == "" {
79+
return 100, true
80+
}
81+
82+
name := strings.ToLower(spec.Name)
83+
description := strings.ToLower(spec.Description)
84+
usage := strings.ToLower(spec.Usage)
85+
86+
bestScore := 0
87+
if score := scorePaletteField(name, query, 1200, 950, 700); score > bestScore {
88+
bestScore = score
89+
}
90+
for _, alias := range spec.Aliases {
91+
if score := scorePaletteField(strings.ToLower(alias), query, 1100, 900, 650); score > bestScore {
92+
bestScore = score
93+
}
94+
}
95+
if score := scorePaletteField(description, query, 520, 420, 320); score > bestScore {
96+
bestScore = score
97+
}
98+
if score := scorePaletteField(usage, query, 480, 380, 280); score > bestScore {
99+
bestScore = score
100+
}
101+
102+
return bestScore, bestScore > 0
103+
}
104+
105+
func scorePaletteField(field, query string, exact, prefix, contains int) int {
106+
if field == "" {
107+
return 0
108+
}
109+
switch {
110+
case field == query:
111+
return exact
112+
case strings.HasPrefix(field, query):
113+
return prefix - min(len(field)-len(query), 40)
114+
case strings.Contains(field, query):
115+
return contains
116+
default:
117+
return 0
118+
}
119+
}
120+
121+
func commandPaletteAutoExecute(spec CommandSpec) bool {
122+
usage := strings.TrimSpace(spec.Usage)
123+
if usage == "" {
124+
return true
125+
}
126+
return usage == "/"+spec.Name
127+
}

internal/ui/command_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,52 @@ func TestRegistryReassignsAliasAndHidesOldOwnerAlias(t *testing.T) {
127127
t.Fatalf("expected new owner alias to remain active, got %v", aliases)
128128
}
129129
}
130+
131+
func TestCommandPaletteMatchesAliasAndDescription(t *testing.T) {
132+
t.Parallel()
133+
134+
app := &App{
135+
Commands: []config.FileCommand{
136+
{
137+
Name: "deploy",
138+
Aliases: []string{"ship"},
139+
Description: "Deploy project to staging",
140+
Usage: "/deploy [env]",
141+
},
142+
},
143+
}
144+
app.rebuildRegistry()
145+
146+
aliasItems := app.completions("ship")
147+
if len(aliasItems) == 0 || aliasItems[0].Name != "deploy" {
148+
t.Fatalf("expected alias query to resolve deploy, got %#v", aliasItems)
149+
}
150+
if aliasItems[0].Usage != "/deploy [env]" {
151+
t.Fatalf("expected usage metadata to be preserved, got %q", aliasItems[0].Usage)
152+
}
153+
154+
descItems := app.completions("staging")
155+
if len(descItems) == 0 || descItems[0].Name != "deploy" {
156+
t.Fatalf("expected description query to resolve deploy, got %#v", descItems)
157+
}
158+
}
159+
160+
func TestCommandPaletteAutoExecuteDependsOnUsage(t *testing.T) {
161+
t.Parallel()
162+
163+
noArgs, _, _ := buildCommandPaletteItem(CommandSpec{
164+
Name: "help",
165+
Usage: "/help",
166+
}, "")
167+
if !noArgs.AutoExecute {
168+
t.Fatal("expected no-arg command to auto execute")
169+
}
170+
171+
withArgs, _, _ := buildCommandPaletteItem(CommandSpec{
172+
Name: "model",
173+
Usage: "/model [name]",
174+
}, "")
175+
if withArgs.AutoExecute {
176+
t.Fatal("expected arg command to only fill input")
177+
}
178+
}

0 commit comments

Comments
 (0)