feat(tui): Tier 1-2 UX improvements — completions, usage, provider status

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
This commit is contained in:
2026-04-12 04:19:55 +02:00
parent e5a1d21f53
commit 0b1f8cb5ec
3 changed files with 275 additions and 28 deletions

View File

@@ -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 <name> (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 <name>")
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 /<name> [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 /<name> [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 {

100
internal/tui/completions.go Normal file
View File

@@ -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 ""
}

View File

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