From 0b1f8cb5ecce635ddd0750928f996cb16fd3d3d2 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 12 Apr 2026 04:19:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20Tier=201-2=20UX=20improvements=20?= =?UTF-8?q?=E2=80=94=20completions,=20usage,=20provider=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 (launch blockers): - Remove /shell from /help (advertised but unimplemented) - Kill dead _ = closeLen assignment - Cache glamour renderer by width — no longer recreated on every WindowSizeMsg when width hasn't changed Tier 2 (ship-quality UX): - Slash command ghost-text completion with Tab accept. Sources: static command list + dynamic skill names. /permission gets arg completion for the 6 modes. - /compact reports before/after token counts (e.g. "32k → 18k tokens") - /provider shows all registered arms grouped by provider, not just "restart required" - /usage command: input/output/total tokens, context %, provider, turns - Widen Ctrl+C quit window from 1s to 2s - "new content below" indicator when scrolled up during streaming - Permission prompt: inline chat notification when approval needed, so the user notices even if focused on input --- internal/tui/app.go | 149 +++++++++++++++++++++++++------ internal/tui/completions.go | 100 +++++++++++++++++++++ internal/tui/completions_test.go | 54 +++++++++++ 3 files changed, 275 insertions(+), 28 deletions(-) create mode 100644 internal/tui/completions.go create mode 100644 internal/tui/completions_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 571bc98..6b0acb9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -91,7 +91,10 @@ type Model struct { currentRole string input textarea.Model + suggestion string // ghost-text completion (dimmed, accepted with Tab) + completionSrc []string // sorted slash commands for completion mdRenderer *glamour.TermRenderer + mdRendererWidth int // cached width to avoid recreating on same-width resizes expandOutput bool // ctrl+o toggles expanded tool output elfStates map[string]*elf.Progress // active elf states keyed by ID elfOrder []string // insertion-ordered elf IDs for tree rendering @@ -157,15 +160,16 @@ func New(sess session.Session, cfg Config) Model { ) return Model{ - session: sess, - config: cfg, - input: ti, - mdRenderer: mdRenderer, - elfStates: make(map[string]*elf.Progress), - cwd: cwd, - gitBranch: gitBranch, - streamBuf: &strings.Builder{}, - thinkingBuf: &strings.Builder{}, + session: sess, + config: cfg, + input: ti, + completionSrc: completionSource(cfg.Skills), + mdRenderer: mdRenderer, + elfStates: make(map[string]*elf.Progress), + cwd: cwd, + gitBranch: gitBranch, + streamBuf: &strings.Builder{}, + thinkingBuf: &strings.Builder{}, } } @@ -192,11 +196,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.input.SetWidth(m.width - 4) - // Recreate markdown renderer with new width (account for "◆ "/" " prefix) - m.mdRenderer, _ = glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), - glamour.WithWordWrap(m.width-6), - ) + // Only recreate markdown renderer when width actually changes. + wrapWidth := m.width - 6 + if wrapWidth != m.mdRendererWidth { + m.mdRendererWidth = wrapWidth + m.mdRenderer, _ = glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(wrapWidth), + ) + } return m, nil case tea.KeyMsg: @@ -229,10 +237,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - // Ctrl+C = clear input (single) or quit (double within 1s) + // Ctrl+C = clear input (single) or quit (double within 2s) if msg.String() == "ctrl+c" { now := time.Now() - if m.quitHint && now.Sub(m.lastCtrlC) < time.Second { + if m.quitHint && now.Sub(m.lastCtrlC) < 2*time.Second { // Second press within window → clean shutdown if m.permPending { m.permPending = false @@ -250,7 +258,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input.SetValue("") m.lastCtrlC = now m.quitHint = true - return m, tea.Tick(time.Second, func(time.Time) tea.Msg { + return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearQuitHintMsg{} }) } @@ -342,6 +350,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+]": m.copyMode = !m.copyMode return m, nil + case "tab": + if m.suggestion != "" { + m.input.SetValue(m.suggestion) + m.suggestion = "" + return m, nil + } case "pgup", "shift+up": m.scrollOffset += 5 return m, nil @@ -401,6 +415,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.permToolName = msg.ToolName m.permArgs = msg.Args m.scrollOffset = 0 + // Inline notification so the user sees the prompt even if focused on input. + m.messages = append(m.messages, chatMessage{role: "system", + content: fmt.Sprintf("⚠ %s requires approval — press y to allow, n to deny", msg.ToolName)}) return m, nil case streamEventMsg: @@ -532,6 +549,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) + + // Update slash-command ghost completion. + m.suggestion = matchCompletion(m.input.Value(), m.completionSrc) + return m, tea.Batch(cmds...) } @@ -578,11 +599,15 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { case "/compact": if m.config.Engine != nil { if w := m.config.Engine.ContextWindow(); w != nil { + before := w.Tracker().Used() compacted, err := w.ForceCompact() if err != nil { m.messages = append(m.messages, chatMessage{role: "error", content: "compaction failed: " + err.Error()}) } else if compacted { - m.messages = append(m.messages, chatMessage{role: "system", content: "context compacted — older messages summarized"}) + after := w.Tracker().Used() + msg := fmt.Sprintf("context compacted — %dk → %dk tokens (saved %dk)", + before/1000, after/1000, (before-after)/1000) + m.messages = append(m.messages, chatMessage{role: "system", content: msg}) } else { m.messages = append(m.messages, chatMessage{role: "system", content: "no compaction strategy configured"}) } @@ -751,14 +776,43 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { return m, nil case "/provider": - if args == "" { - status := m.session.Status() + if args != "" { m.messages = append(m.messages, chatMessage{role: "system", - content: fmt.Sprintf("current provider: %s\nUsage: /provider (mistral, anthropic, openai, google, ollama)", status.Provider)}) + content: fmt.Sprintf("provider switching requires restart: gnoma --provider %s", args)}) return m, nil } - m.messages = append(m.messages, chatMessage{role: "system", - content: fmt.Sprintf("provider switching requires restart: gnoma --provider %s", args)}) + status := m.session.Status() + var b strings.Builder + b.WriteString(fmt.Sprintf("Active: %s/%s\n", status.Provider, status.Model)) + if m.config.Router != nil { + arms := m.config.Router.Arms() + if len(arms) > 0 { + // Group arms by provider prefix + providers := make(map[string][]string) + for _, arm := range arms { + parts := strings.SplitN(string(arm.ID), "/", 2) + prov := parts[0] + model := string(arm.ID) + if len(parts) == 2 { + model = parts[1] + } + tag := "" + if arm.IsLocal { + tag = " (local)" + } + providers[prov] = append(providers[prov], model+tag) + } + b.WriteString("\nRegistered arms:\n") + for prov, models := range providers { + b.WriteString(fmt.Sprintf(" %s:\n", prov)) + for _, model := range models { + b.WriteString(fmt.Sprintf(" - %s\n", model)) + } + } + } + } + b.WriteString("\nTo switch: gnoma --provider ") + m.messages = append(m.messages, chatMessage{role: "system", content: b.String()}) return m, nil case "/init": @@ -827,7 +881,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { case "/help": m.messages = append(m.messages, chatMessage{role: "system", - content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /provider show current provider\n /resume [id] list or restore saved sessions\n /skills list loaded skills\n /shell interactive shell (coming soon)\n /help show this help\n /quit exit gnoma\n\nSkills (use / [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"}) + content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /provider show current provider\n /resume [id] list or restore saved sessions\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use / [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"}) return m, nil case "/plugins": @@ -864,6 +918,32 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { m.messages = append(m.messages, chatMessage{role: "system", content: b.String()}) return m, nil + case "/usage": + var b strings.Builder + b.WriteString("Session usage:\n") + if m.config.Engine != nil { + u := m.config.Engine.Usage() + b.WriteString(fmt.Sprintf(" Input tokens: %d\n", u.InputTokens)) + b.WriteString(fmt.Sprintf(" Output tokens: %d\n", u.OutputTokens)) + b.WriteString(fmt.Sprintf(" Total tokens: %d\n", u.TotalTokens())) + if u.CacheReadTokens > 0 { + b.WriteString(fmt.Sprintf(" Cache reads: %d\n", u.CacheReadTokens)) + } + if w := m.config.Engine.ContextWindow(); w != nil { + tr := w.Tracker() + pct := float64(0) + if tr.MaxTokens() > 0 { + pct = float64(tr.Used()) / float64(tr.MaxTokens()) * 100 + } + b.WriteString(fmt.Sprintf(" Context: %dk / %dk (%.0f%%)\n", tr.Used()/1000, tr.MaxTokens()/1000, pct)) + } + } + status := m.session.Status() + b.WriteString(fmt.Sprintf(" Provider: %s/%s\n", status.Provider, status.Model)) + b.WriteString(fmt.Sprintf(" Turns: %d\n", status.TurnCount)) + m.messages = append(m.messages, chatMessage{role: "system", content: b.String()}) + return m, nil + default: // Check skill registry before returning unknown command error. if m.config.Skills != nil { @@ -1018,6 +1098,12 @@ func (m Model) View() tea.View { chat := m.renderChat(chatH) + // Show "new content below" indicator when scrolled up during streaming + if m.scrollOffset > 0 && m.streaming { + indicator := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true).Render(" ⬇ new content below") + topLine = indicator + topLine[len(indicator):] + } + v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, chat, topLine, @@ -1471,7 +1557,16 @@ func (m Model) renderSeparators() (string, string) { } func (m Model) renderInput() string { - return m.input.View() + view := m.input.View() + if m.suggestion != "" { + // Show the untyped remainder as dim ghost text. + rest := strings.TrimPrefix(m.suggestion, m.input.Value()) + if rest != "" { + ghost := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(rest + " (tab)") + view += ghost + } + } + return view } func (m Model) renderStatus() string { @@ -1654,7 +1749,7 @@ func filterModelCodeBlocks(closeTag *string, text string) string { } else { // Not filtering — scan for any known open tag. earliest := -1 - var openLen, closeLen int + var openLen int var chosenClose string for _, pair := range modelBlockPairs { idx := strings.Index(text, pair[0]) @@ -1662,8 +1757,6 @@ func filterModelCodeBlocks(closeTag *string, text string) string { earliest = idx openLen = len(pair[0]) chosenClose = pair[1] - closeLen = len(chosenClose) - _ = closeLen } } if earliest < 0 { diff --git a/internal/tui/completions.go b/internal/tui/completions.go new file mode 100644 index 0000000..52694cb --- /dev/null +++ b/internal/tui/completions.go @@ -0,0 +1,100 @@ +package tui + +import ( + "sort" + "strings" + + "somegit.dev/Owlibou/gnoma/internal/skill" +) + +// builtinCommands is the static list of slash commands. +var builtinCommands = []string{ + "/clear", + "/compact", + "/config", + "/exit", + "/help", + "/incognito", + "/init", + "/model", + "/new", + "/perm", + "/permission", + "/plugins", + "/provider", + "/quit", + "/resume", + "/skills", + "/usage", +} + +// permissionModes lists valid modes for /permission completion. +var permissionModes = []string{ + "auto", "default", "accept_edits", "bypass", "deny", "plan", +} + +// completionSource builds a sorted command list from builtins + skills. +func completionSource(skills *skill.Registry) []string { + cmds := make([]string, len(builtinCommands)) + copy(cmds, builtinCommands) + + if skills != nil { + for _, s := range skills.All() { + cmds = append(cmds, "/"+s.Frontmatter.Name) + } + } + sort.Strings(cmds) + return cmds +} + +// matchCompletion finds the best completion for the current input. +// Returns the full command string if a unique prefix match exists, or empty string. +func matchCompletion(input string, commands []string) string { + if !strings.HasPrefix(input, "/") || len(input) < 2 { + return "" + } + + // Don't complete if there are args (space after command). + if strings.Contains(input, " ") { + return matchArgCompletion(input) + } + + lower := strings.ToLower(input) + var match string + for _, cmd := range commands { + if strings.HasPrefix(cmd, lower) { + if match != "" { + return "" // ambiguous — multiple matches, no ghost text + } + match = cmd + } + } + if match == input { + return "" // already complete + } + return match +} + +// matchArgCompletion handles second-level completion for commands with args. +func matchArgCompletion(input string) string { + parts := strings.SplitN(input, " ", 2) + if len(parts) != 2 { + return "" + } + cmd := parts[0] + arg := parts[1] + + switch cmd { + case "/permission", "/perm": + if arg == "" { + return "" + } + lower := strings.ToLower(arg) + for _, mode := range permissionModes { + if strings.HasPrefix(mode, lower) && mode != arg { + return cmd + " " + mode + } + } + } + return "" +} diff --git a/internal/tui/completions_test.go b/internal/tui/completions_test.go new file mode 100644 index 0000000..6132f9b --- /dev/null +++ b/internal/tui/completions_test.go @@ -0,0 +1,54 @@ +package tui + +import "testing" + +func TestMatchCompletion(t *testing.T) { + cmds := []string{"/clear", "/compact", "/config", "/help", "/model", "/permission", "/quit"} + + tests := []struct { + input string + want string + }{ + {"/h", "/help"}, + {"/he", "/help"}, + {"/help", ""}, // already complete + {"/cl", "/clear"}, // unambiguous prefix + {"/co", ""}, // ambiguous: /compact, /config + {"/com", "/compact"}, + {"/con", "/config"}, + {"/q", "/quit"}, + {"/model ", ""}, // has args — no command completion + {"hello", ""}, // not a slash command + {"/", ""}, // too short + {"/x", ""}, // no match + } + + for _, tt := range tests { + got := matchCompletion(tt.input, cmds) + if got != tt.want { + t.Errorf("matchCompletion(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestMatchArgCompletion(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/permission a", "/permission auto"}, + {"/permission au", "/permission auto"}, + {"/permission auto", ""}, // already complete + {"/permission d", "/permission default"}, // first match + {"/perm b", "/perm bypass"}, + {"/perm p", "/perm plan"}, + {"/model foo", ""}, // no arg completion for /model yet + } + + for _, tt := range tests { + got := matchArgCompletion(tt.input) + if got != tt.want { + t.Errorf("matchArgCompletion(%q) = %q, want %q", tt.input, got, tt.want) + } + } +}