From 3ef820c3803c88906f257b6cb0715b011cfbe205 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Sat, 18 Jul 2026 00:27:25 +0200 Subject: [PATCH 1/2] Improve terminal input status layout --- internal/agent/agent.go | 15 +- internal/agent/cc_agent.go | 36 +++++ internal/agent/cerebras_agent.go | 1 + internal/api/models.go | 15 ++ internal/api/models_test.go | 15 ++ internal/repl/repl.go | 55 ++++++- internal/tui/input.go | 260 ++++++++++++++++++++++++++----- internal/tui/input_test.go | 56 +++++++ internal/tui/terminal.go | 15 +- 9 files changed, 427 insertions(+), 41 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ecff927..0eaf194 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -63,10 +63,14 @@ type Agent struct { AppConfig *api.Config // persistent user preferences History []api.Message Usage api.TokenUsage - Logger *sysutil.Logger - Ollama *OllamaClient // optional self-hosted LLM - Mode OllamaMode // off, chat, or full - Cerebras *CerebrasClient // optional Cerebras backend; when set, owns the whole turn + // LastContextTokens is the input-token count reported by the most recent + // model request. Unlike Usage.InputTokens, it is not cumulative and can be + // used to estimate the currently occupied context window in the TUI. + LastContextTokens int + Logger *sysutil.Logger + Ollama *OllamaClient // optional self-hosted LLM + Mode OllamaMode // off, chat, or full + Cerebras *CerebrasClient // optional Cerebras backend; when set, owns the whole turn tools []api.ToolDef client *http.Client @@ -126,6 +130,7 @@ func NewAgent(cfg AgentConfig) *Agent { // ClearHistory resets conversation history. func (a *Agent) ClearHistory() { a.History = []api.Message{} + a.LastContextTokens = 0 } // CancelCurrent cancels the current streaming request if one is in progress. @@ -654,6 +659,7 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten var ev sseMessageStart if err := json.Unmarshal([]byte(rawData), &ev); err == nil { a.Usage.InputTokens += ev.Message.Usage.InputTokens + a.LastContextTokens = ev.Message.Usage.InputTokens a.Usage.Requests++ if a.Cfg.Verbose { fmt.Printf("[SSE] message_start: input_tokens=%d\n", ev.Message.Usage.InputTokens) @@ -813,6 +819,7 @@ func (a *Agent) callAPI() (*api.APIResponse, error) { // Track usage a.Usage.InputTokens += apiResp.Usage.InputTokens + a.LastContextTokens = apiResp.Usage.InputTokens a.Usage.OutputTokens += apiResp.Usage.OutputTokens a.Usage.Requests++ diff --git a/internal/agent/cc_agent.go b/internal/agent/cc_agent.go index bf3a0e6..0bf7e28 100644 --- a/internal/agent/cc_agent.go +++ b/internal/agent/cc_agent.go @@ -29,6 +29,14 @@ type CLIAgent interface { Cleanup() } +// TurnStatsProvider is optionally implemented by CLI agents that can report +// token usage for their most recent Run. The REPL folds these numbers into +// the session totals shown in the input status bar; backends whose stream +// carries no usage information simply don't implement it (or return ok=false). +type TurnStatsProvider interface { + LastTurnStats() (inputTokens, outputTokens int, ok bool) +} + // CCAgent orchestrates a Claude Code CLI subprocess for LLM inference. // Inference runs through the user's Claude Code login, so qmax-code does not // need a QM-held Anthropic API key. Because this agent uses `claude --print`, @@ -55,6 +63,9 @@ type CCAgent struct { mcpConfigInfo os.FileInfo sctx *api.SessionContext lastToolName string // track last tool name for result display + lastTurnIn int // token usage of the most recent turn (from CC's result event) + lastTurnOut int + lastTurnOK bool // true once a result event carried usage this turn mu sync.Mutex runMu sync.Mutex runCancel context.CancelFunc // non-nil while Run() is active @@ -347,6 +358,7 @@ func sanitizeCCUserPrompt(prompt string) (string, error) { // CC's subscription handles inference; qmax handles tools via MCP. func (a *CCAgent) Run(userMsg string, term *tui.Terminal) (string, error) { a.mu.Lock() + a.lastTurnIn, a.lastTurnOut, a.lastTurnOK = 0, 0, false if !sameMCPConfigFile(a.mcpConfigPath, a.mcpConfigInfo) { a.mu.Unlock() if err := a.WriteMCPConfig(); err != nil { @@ -500,6 +512,14 @@ type ccEvent struct { Message *ccEventMsg `json:"message,omitempty"` Result string `json:"result,omitempty"` IsError bool `json:"is_error,omitempty"` + Usage *ccUsage `json:"usage,omitempty"` +} + +// ccUsage is emitted on Claude Code's final result event. Keep the fields we +// surface in the UI typed, while allowing CC to add other usage fields. +type ccUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` } type ccEventMsg struct { @@ -591,6 +611,13 @@ func (a *CCAgent) parseStream(stdout interface{ Read([]byte) (int, error) }, ter case "result": finalResult = event.Result + if event.Usage != nil { + a.mu.Lock() + a.lastTurnIn = event.Usage.InputTokens + a.lastTurnOut = event.Usage.OutputTokens + a.lastTurnOK = event.Usage.InputTokens > 0 || event.Usage.OutputTokens > 0 + a.mu.Unlock() + } if event.IsError && event.Result != "" { term.PrintError("CC error: " + event.Result) } @@ -601,6 +628,15 @@ func (a *CCAgent) parseStream(stdout interface{ Read([]byte) (int, error) }, ter return finalResult } +// LastTurnStats returns Claude Code's usage from the most recently completed +// turn. It is intentionally optional so other CLI backends need not invent +// token figures when their protocol does not expose them. +func (a *CCAgent) LastTurnStats() (inputTokens, outputTokens int, ok bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastTurnIn, a.lastTurnOut, a.lastTurnOK +} + // parseCCBlocks unmarshals a CC message content field (string or []block). func parseCCBlocks(raw json.RawMessage) []ccBlock { if len(raw) == 0 { diff --git a/internal/agent/cerebras_agent.go b/internal/agent/cerebras_agent.go index ab11492..bf237e8 100644 --- a/internal/agent/cerebras_agent.go +++ b/internal/agent/cerebras_agent.go @@ -68,6 +68,7 @@ func (a *Agent) RunCerebrasAgent(term *tui.Terminal) (string, bool) { choice := resp.Choices[0] a.Usage.InputTokens += resp.Usage.PromptTokens + a.LastContextTokens = resp.Usage.PromptTokens a.Usage.OutputTokens += resp.Usage.CompletionTokens a.Usage.Requests++ diff --git a/internal/api/models.go b/internal/api/models.go index 96d3349..0360679 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -40,3 +40,18 @@ func IsValidClaudeModelName(m string) bool { func ValidClaudeModelsHelp() string { return "auto, sonnet, opus, haiku, " + ModelFable + ", " + ModelSonnet5 + ", " + ModelSonnet + ", " + ModelOpus + ", " + ModelOpus1M + ", " + ModelOpus47 + ", " + ModelHaiku } + +// ContextWindow returns the assumed context-window size in tokens for a model +// ID across all backends. It feeds the status-bar fill estimate only, so a +// conservative 200k default for unknown (non-Anthropic) models is acceptable. +func ContextWindow(model string) int { + m := strings.ToLower(model) + switch { + case strings.Contains(m, "[1m]"): + return 1_000_000 + case m == ModelFable, m == ModelSonnet5: + return 1_000_000 + default: + return 200_000 + } +} diff --git a/internal/api/models_test.go b/internal/api/models_test.go index 72dd327..34bd21a 100644 --- a/internal/api/models_test.go +++ b/internal/api/models_test.go @@ -15,3 +15,18 @@ func TestIsValidClaudeModelNameIncludesFableAndSonnet5(t *testing.T) { } } } + +func TestContextWindow(t *testing.T) { + for _, tc := range []struct { + model string + want int + }{ + {ModelSonnet5, 1_000_000}, + {"claude-opus-4-6[1m]", 1_000_000}, + {"unknown-model", 200_000}, + } { + if got := ContextWindow(tc.model); got != tc.want { + t.Errorf("ContextWindow(%q) = %d, want %d", tc.model, got, tc.want) + } + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 4db7997..edce708 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -162,6 +162,42 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin var inputHistory []string var lastCtrlC time.Time + sessionStarted := time.Now() + var lastTask string + var lastTurnDur time.Duration + var lastContextTokens int + + inputStatus := func() *tui.StatusInfo { + backend := "api" + if ag.Cfg.Context != nil && ag.Cfg.Context.Backend != "" { + backend = ag.Cfg.Context.Backend + } + model := ag.Cfg.Model + effort, permissionMode := "", "" + if ag.AppConfig != nil { + if ag.AppConfig.ModelOverride != "" { + model = ag.AppConfig.ModelOverride + } + effort = ag.AppConfig.Effort + if backend != "api" { + permissionMode = ag.AppConfig.OrchPermissionMode + } + } + return &tui.StatusInfo{ + Backend: backend, + Model: model, + Effort: effort, + PermissionMode: permissionMode, + OutputVerbose: ag.Cfg.OutputVerbose, + Task: lastTask, + TokensIn: ag.Usage.InputTokens, + TokensOut: ag.Usage.OutputTokens, + ContextUsed: lastContextTokens, + ContextWindow: api.ContextWindow(model), + LastTurnDur: lastTurnDur, + SessionStarted: sessionStarted, + } + } // Prompt consent and open cloud session at startup (idempotent — safe to call again later). startCloudSession() @@ -184,7 +220,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin fmt.Println() inputHistory = append(inputHistory, input) } else { - result := tui.ReadInput(term.Prompt(), inputHistory, ag.Cfg.OutputVerbose) + result := tui.ReadInput(term.Prompt(), inputHistory, ag.Cfg.OutputVerbose, inputStatus()) if result.OutputToggle { toggleOutputVerbose(ag, cliAgent, term) @@ -222,6 +258,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin continue case input == "/clear": ag.ClearHistory() + lastContextTokens = 0 if oc, ok := cliAgent.(*agent.OpenCodeAgent); ok { oc.ClearSession() } @@ -1058,6 +1095,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // Normal mode: use direct Anthropic API. var llmResult string var err error + turnStarted := time.Now() // In CC/Codex mode, start a pre-connect goroutine that watches for a // live_browser_url in the side-channel file every second. When one @@ -1123,6 +1161,14 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin llmResult, err = cliAgent.Run(cleanInput, term) term.StopThinking() if err == nil { + if stats, ok := cliAgent.(agent.TurnStatsProvider); ok { + if inputTokens, outputTokens, reported := stats.LastTurnStats(); reported { + ag.Usage.InputTokens += inputTokens + ag.Usage.OutputTokens += outputTokens + ag.Usage.Requests++ + lastContextTokens = inputTokens + } + } // Mirror the turn into ag.History so autoSave records it. // agent.CCAgent/agent.CodexAgent manage their own subprocess state; qmax's // history would otherwise stay empty and autoSave would no-op. @@ -1144,6 +1190,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } else { llmResult, err = ag.RunStreaming(input, term) } + lastTurnDur = time.Since(turnStarted) + if err == nil { + lastTask = cleanInput + if cliAgent == nil { + lastContextTokens = ag.LastContextTokens + } + } // Stop queue reader and wait for the goroutine to exit before we // touch stdin again (either via the queue loop or tui.ReadInput). diff --git a/internal/tui/input.go b/internal/tui/input.go index f24fa33..388ef6e 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "unicode" tea "github.com/charmbracelet/bubbletea" @@ -11,6 +12,26 @@ import ( "golang.org/x/term" ) +// StatusInfo carries session metrics rendered around the input box: token +// counts, context-window fill, timing, and the mode/task bottom bar. Zero +// values are omitted from the display, so backends that can't report a given +// metric simply don't show it. +type StatusInfo struct { + Backend string // "cc" | "codex" | "opencode" | "cerebras" | "ollama" | "" (direct API) + Model string // active model ID; "" = backend default + Effort string // "low" | "medium" | "high" (CLI backends) + PermissionMode string // "standard" | "unattended" (CLI backends) + OutputVerbose bool + Task string // what the agent is working on (latest user prompt) + TokensIn int // session input tokens + TokensOut int // session output tokens + ContextUsed int // tokens occupying the model context after the last turn + ContextWindow int // assumed context window of the active model + LastTurnDur time.Duration // wall-clock duration of the previous agent turn + SessionStarted time.Time // used to keep the session timer live while typing + SessionDur time.Duration // fallback when SessionStarted is unavailable +} + // SlashMenuItem represents a selectable command. type SlashMenuItem struct { Cmd string @@ -77,6 +98,7 @@ type inputModel struct { prompt string history []string histIdx int + status *StatusInfo // optional metrics/mode/task display; nil hides it } func newInputModel(prompt string, history []string) inputModel { @@ -97,13 +119,26 @@ func newInputModelWithOutputMode(prompt string, history []string, outputVerbose } } -func (m inputModel) Init() tea.Cmd { return nil } +type statusTickMsg time.Time + +func (m inputModel) Init() tea.Cmd { + if m.status == nil || m.status.SessionStarted.IsZero() { + return nil + } + return nextStatusTick() +} + +func nextStatusTick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return statusTickMsg(t) }) +} func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width return m, nil + case statusTickMsg: + return m, nextStatusTick() case tea.KeyMsg: if msg.Type == tea.KeyCtrlO { m.result = "" @@ -373,10 +408,23 @@ var ( menuDescSelSty = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("69")) menuHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) filterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + + // Input box + status bar styles. The bordered box visually separates the + // prompt from the agent stream above it; the bottom bar mirrors the + // pickerStatusBar look (light text on a raised background). + inputBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(0, 1) + + statusMetricsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")) + statusBarModeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Background(lipgloss.Color("236")).Bold(true) + statusBarDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Background(lipgloss.Color("236")) ) func (m inputModel) View() string { - // When done, show only the final input — no menu residue. + // When done, show only the final input — no menu/box residue. if m.done { if m.result == "" { return "" @@ -386,6 +434,11 @@ func (m inputModel) View() string { var b strings.Builder + w := m.width + if w <= 0 { + w = 80 + } + if m.mode == modeTyping { runes := []rune(m.text) @@ -395,34 +448,7 @@ func (m inputModel) View() string { display = append(display, '█') display = append(display, runes[m.cursor:]...) - b.WriteString(m.prompt) - - w := m.width - if w <= 0 { - w = 80 - } - promptW := lipgloss.Width(m.prompt) - availW := w - promptW - if availW < 10 { - availW = 10 - } - - if len(display) <= availW { - b.WriteString(string(display)) - } else { - // Wrap at availW; indent continuation lines to align with text start. - indent := strings.Repeat(" ", promptW) - col := 0 - for _, r := range display { - if col >= availW { - b.WriteString("\n") - b.WriteString(indent) - col = 0 - } - b.WriteRune(r) - col++ - } - } + b.WriteString(m.renderInputBox(string(display), w)) mode := "compact" if m.outputVerbose { @@ -430,14 +456,18 @@ func (m inputModel) View() string { } b.WriteString("\n") b.WriteString(menuHintStyle.Render(fmt.Sprintf(" Ctrl+O output: %s • ↑↓ history • Ctrl+X×3 clear • Opt+←/→ words • Ctrl+C cancel • / commands", mode))) + if status := m.renderStatus(w); status != "" { + b.WriteString("\n") + b.WriteString(status) + } } else { // Menu mode - b.WriteString(m.prompt) - b.WriteString("/") + filterLine := "/" if m.filter != "" { - b.WriteString(filterStyle.Render(m.filter)) + filterLine += filterStyle.Render(m.filter) } - b.WriteString("█") + filterLine += "█" + b.WriteString(m.renderInputBox(filterLine, w)) b.WriteString("\n") filtered := m.filteredMenuItems() @@ -457,11 +487,169 @@ func (m inputModel) View() string { } } b.WriteString(menuHintStyle.Render(" ↑↓ navigate • enter select • esc cancel • type to filter")) + if status := m.renderStatus(w); status != "" { + b.WriteString("\n") + b.WriteString(status) + } } return b.String() } +// renderInputBox draws the prompt + content inside a full-width rounded +// border so the input field reads as a distinct panel below the agent stream. +func (m inputModel) renderInputBox(content string, w int) string { + // Inner width: total minus 2 border columns minus 2 padding columns. + innerW := w - 4 + if innerW < 20 { + innerW = 20 + } + + promptW := lipgloss.Width(m.prompt) + availW := innerW - promptW + if availW < 10 { + availW = 10 + } + + var text strings.Builder + text.WriteString(m.prompt) + runes := []rune(content) + if len(runes) <= availW { + text.WriteString(content) + } else { + // Wrap at availW; indent continuation lines to align with text start. + indent := strings.Repeat(" ", promptW) + col := 0 + for _, r := range runes { + if col >= availW { + text.WriteString("\n") + text.WriteString(indent) + col = 0 + } + text.WriteRune(r) + col++ + } + } + + return inputBoxStyle.Width(w - 2).Render(text.String()) +} + +// renderStatus produces the metrics line (context window, tokens, timings) +// and the bottom bar (mode + task). Returns "" when no status was provided. +func (m inputModel) renderStatus(w int) string { + s := m.status + if s == nil { + return "" + } + + var lines []string + + // ── Metrics line: token window · tokens · time ────────────────────── + var metrics []string + if s.ContextUsed > 0 && s.ContextWindow > 0 { + pct := s.ContextUsed * 100 / s.ContextWindow + metrics = append(metrics, fmt.Sprintf("ctx %s/%s (%d%%)", + compactTokens(s.ContextUsed), compactTokens(s.ContextWindow), pct)) + } + if s.TokensIn+s.TokensOut > 0 { + metrics = append(metrics, fmt.Sprintf("tokens %s in / %s out", + compactTokens(s.TokensIn), compactTokens(s.TokensOut))) + } + if s.LastTurnDur > 0 { + metrics = append(metrics, "last turn "+compactDuration(s.LastTurnDur)) + } + if sessionDur := s.sessionDuration(); sessionDur > 0 { + metrics = append(metrics, "session "+compactDuration(sessionDur)) + } + if len(metrics) > 0 { + lines = append(lines, statusMetricsStyle.Render(" "+strings.Join(metrics, " · "))) + } + + // ── Bottom bar: mode (left) + task (right) ────────────────────────── + mode := s.PermissionMode + if mode == "" { + mode = "api" + } + left := " " + statusBarModeStyle.Render("-- "+strings.ToUpper(mode)+" --") + statusBarStyle.Render(" "+m.describeBackend()) + + task := s.Task + if task == "" { + task = "idle" + } + leftW := lipgloss.Width(left) + // " task: " + text + trailing space must fit in what's left of the row. + taskAvail := w - leftW - 9 + if taskAvail < 8 { + taskAvail = 8 + } + taskRunes := []rune(strings.ReplaceAll(task, "\n", " ")) + if len(taskRunes) > taskAvail { + task = string(taskRunes[:taskAvail-1]) + "…" + } + right := statusBarDimStyle.Render("task: ") + statusBarStyle.Render(task+" ") + + gap := w - leftW - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + bar := left + statusBarStyle.Render(strings.Repeat(" ", gap)) + right + lines = append(lines, bar) + + return strings.Join(lines, "\n") +} + +func (s *StatusInfo) sessionDuration() time.Duration { + if !s.SessionStarted.IsZero() { + return time.Since(s.SessionStarted) + } + return s.SessionDur +} + +// describeBackend renders "backend · model · effort" from whichever parts are set. +func (m inputModel) describeBackend() string { + s := m.status + backend := s.Backend + if backend == "" { + backend = "api" + } + parts := []string{backend} + if s.Model != "" { + parts = append(parts, s.Model) + } + if s.Effort != "" { + parts = append(parts, s.Effort+" effort") + } + if s.OutputVerbose { + parts = append(parts, "verbose") + } + return strings.Join(parts, " · ") +} + +// compactTokens formats a token count as "950", "12.3k", or "1.2M". +func compactTokens(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.1fk", float64(n)/1_000) + default: + return fmt.Sprintf("%d", n) + } +} + +// compactDuration formats a duration as "42s", "2m10s", or "1h03m". +func compactDuration(d time.Duration) string { + d = d.Round(time.Second) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + default: + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + } +} + // InputResult holds the result of a ReadInput call. type InputResult struct { Text string @@ -471,8 +659,10 @@ type InputResult struct { } // ReadInput runs the bubbletea input widget and returns the submitted text. -func ReadInput(prompt string, history []string, outputVerbose bool) InputResult { +// status is optional session metrics rendered below the input box (nil hides them). +func ReadInput(prompt string, history []string, outputVerbose bool, status *StatusInfo) InputResult { m := newInputModelWithOutputMode(prompt, history, outputVerbose) + m.status = status p := tea.NewProgram(m) result, err := p.Run() if err != nil { diff --git a/internal/tui/input_test.go b/internal/tui/input_test.go index 64128cc..cdbb1dd 100644 --- a/internal/tui/input_test.go +++ b/internal/tui/input_test.go @@ -3,6 +3,7 @@ package tui import ( "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -148,6 +149,61 @@ func TestInputFooterShowsOutputModeAndHotkeys(t *testing.T) { } } +func TestInputSeparatesPromptAndRendersSessionStatus(t *testing.T) { + m := newInputModelWithOutputMode("qmax > ", nil, false) + m.width = 100 + m.status = &StatusInfo{ + Backend: "cc", + Model: "claude-sonnet-5", + Effort: "high", + PermissionMode: "standard", + Task: "fix the spacing around the input panel", + TokensIn: 12_300, + TokensOut: 4_500, + ContextUsed: 16_800, + ContextWindow: 200_000, + LastTurnDur: 42 * time.Second, + SessionDur: 2*time.Minute + 10*time.Second, + } + + view := m.View() + for _, want := range []string{ + "╭", "╮", "╰", "╯", // a distinct bordered input field + "ctx 16.8k/200.0k (8%)", + "tokens 12.3k in / 4.5k out", + "last turn 42s", + "session 2m10s", + "-- STANDARD --", + "cc · claude-sonnet-5 · high effort", + "task: fix the spacing around the input panel", + } { + if !strings.Contains(view, want) { + t.Fatalf("input status missing %q in %q", want, view) + } + } +} + +func TestInputRendersStatusInSlashMenu(t *testing.T) { + m := newInputModel("qmax > ", nil) + m.width = 100 + m.mode = modeMenu + m.status = &StatusInfo{Backend: "cc", PermissionMode: "standard", Task: "review the diff"} + + view := m.View() + for _, want := range []string{"↑↓ navigate", "-- STANDARD --", "task: review the diff"} { + if !strings.Contains(view, want) { + t.Fatalf("slash menu status missing %q in %q", want, view) + } + } +} + +func TestStatusUsesLiveSessionStart(t *testing.T) { + s := &StatusInfo{SessionStarted: time.Now().Add(-2 * time.Minute)} + if got := s.sessionDuration(); got < 2*time.Minute || got > 2*time.Minute+time.Second { + t.Fatalf("live session duration = %s, want about 2m", got) + } +} + func TestInputMarksBracketedPaste(t *testing.T) { m := newInputModel("qmax > ", nil) m = applyInputKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("large pasted body"), Paste: true}) diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index e2f1336..ba12cf1 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -157,6 +157,7 @@ type Terminal struct { currentPrompt string // track prompt for readline recreation thinking *spinner // active thinking spinner, if any userTyping atomic.Bool // true while StartQueueReader has the user typing + toolStreak bool // last printed line was a tool call; keeps consecutive tools tight } // SetUserTyping tells the active spinner to pause (true) or resume (false) its @@ -369,6 +370,7 @@ func (t *Terminal) StreamText(text string) { if !t.streaming { t.StopThinking() // erase spinner before first token t.streaming = true + t.toolStreak = false t.streamBuf.Reset() // Hide readline prompt during streaming to prevent input overlap if t.rl != nil { @@ -384,6 +386,7 @@ func (t *Terminal) StreamText(text string) { // FinishMarkdown is called when a text block is complete. // Re-renders the streamed text with glamour for syntax highlighting. func (t *Terminal) FinishMarkdown(fullText string) { + t.toolStreak = false if t.streaming { t.streaming = false @@ -417,6 +420,7 @@ func (t *Terminal) FinishMarkdown(fullText string) { // PrintAssistant prints the agent's text response with markdown rendering. // Used in non-streaming mode. func (t *Terminal) PrintAssistant(text string) { + t.toolStreak = false if t.renderer != nil { rendered, err := t.renderer.Render(text) if err == nil { @@ -428,6 +432,8 @@ func (t *Terminal) PrintAssistant(text string) { } // PrintToolIcon shows a tool icon when a tool_use block starts streaming. +// Consecutive tool calls render as a tight group: only the first tool after +// text/system output gets a separating blank line. func (t *Terminal) PrintToolIcon(name string) { t.StopThinking() // erase spinner before tool display if t.streaming { @@ -439,7 +445,11 @@ func (t *Terminal) PrintToolIcon(name string) { icon = "🔧" } displayName := strings.ReplaceAll(name, "_", " ") - fmt.Printf("\n %s %s", icon, styleTool.Render(displayName)) + if !t.toolStreak { + fmt.Println() + } + t.toolStreak = true + fmt.Printf(" %s %s", icon, styleTool.Render(displayName)) } // PrintToolStart shows a tool invocation with its input summary. @@ -545,6 +555,7 @@ func (t *Terminal) PrintPlan(steps []PlanStep) { t.streaming = false fmt.Println() } + t.toolStreak = false defer t.StartThinking() done := 0 @@ -582,11 +593,13 @@ func (t *Terminal) PrintPlan(steps []PlanStep) { // PrintSystem prints a system message. func (t *Terminal) PrintSystem(msg string) { + t.toolStreak = false fmt.Printf(" %s %s\n", styleSystem.Render("●"), msg) } // PrintError prints an error message. func (t *Terminal) PrintError(msg string) { + t.toolStreak = false fmt.Printf(" %s\n", styleError.Render("✗ "+msg)) } From a5f776685521717f2326859cc814341cd5d6d898 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Sat, 18 Jul 2026 06:55:48 +0200 Subject: [PATCH 2/2] Address context metric review feedback --- internal/agent/agent.go | 8 ++++++++ internal/repl/repl.go | 5 +++++ internal/tui/input.go | 14 ++++---------- internal/tui/input_test.go | 4 ++-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 0eaf194..a3cf2a7 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -146,6 +146,9 @@ func (a *Agent) CancelCurrent() { // Run executes a prompt through the agent loop and returns the final text response. // Used for non-interactive (one-shot) mode. func (a *Agent) Run(prompt string) (string, error) { + // This is a new top-level turn; do not show a prior request's context size + // if this request fails before the provider reports usage. + a.LastContextTokens = 0 a.History = append(a.History, api.Message{ Role: "user", Content: prompt, @@ -332,6 +335,11 @@ func (a *Agent) RunStreaming(prompt string, term *tui.Terminal) (string, error) } func (a *Agent) runStreamingLoop(term *tui.Terminal) (string, error) { + // All backends share this top-level turn boundary, including Ollama. Ollama's + // streaming protocol does not expose usage, so leaving this at zero hides the + // context-fill metric instead of carrying a stale value from another backend. + a.LastContextTokens = 0 + // Cerebras backend owns the entire turn via its own native function-calling // loop (full tool set). No Claude fallback — the user has no Anthropic key. if a.Cerebras != nil { diff --git a/internal/repl/repl.go b/internal/repl/repl.go index edce708..abda178 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -1095,6 +1095,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // Normal mode: use direct Anthropic API. var llmResult string var err error + // Clear the UI's per-turn estimate before starting any backend. This also + // covers CLI backends, whose usage is reported outside Agent.RunStreaming. + lastContextTokens = 0 + ag.LastContextTokens = 0 turnStarted := time.Now() // In CC/Codex mode, start a pre-connect goroutine that watches for a @@ -1167,6 +1171,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin ag.Usage.OutputTokens += outputTokens ag.Usage.Requests++ lastContextTokens = inputTokens + ag.LastContextTokens = inputTokens } } // Mirror the turn into ag.History so autoSave records it. diff --git a/internal/tui/input.go b/internal/tui/input.go index 388ef6e..3ded896 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -29,7 +29,6 @@ type StatusInfo struct { ContextWindow int // assumed context window of the active model LastTurnDur time.Duration // wall-clock duration of the previous agent turn SessionStarted time.Time // used to keep the session timer live while typing - SessionDur time.Duration // fallback when SessionStarted is unavailable } // SlashMenuItem represents a selectable command. @@ -558,8 +557,10 @@ func (m inputModel) renderStatus(w int) string { if s.LastTurnDur > 0 { metrics = append(metrics, "last turn "+compactDuration(s.LastTurnDur)) } - if sessionDur := s.sessionDuration(); sessionDur > 0 { - metrics = append(metrics, "session "+compactDuration(sessionDur)) + if !s.SessionStarted.IsZero() { + if sessionDur := time.Since(s.SessionStarted); sessionDur > 0 { + metrics = append(metrics, "session "+compactDuration(sessionDur)) + } } if len(metrics) > 0 { lines = append(lines, statusMetricsStyle.Render(" "+strings.Join(metrics, " · "))) @@ -598,13 +599,6 @@ func (m inputModel) renderStatus(w int) string { return strings.Join(lines, "\n") } -func (s *StatusInfo) sessionDuration() time.Duration { - if !s.SessionStarted.IsZero() { - return time.Since(s.SessionStarted) - } - return s.SessionDur -} - // describeBackend renders "backend · model · effort" from whichever parts are set. func (m inputModel) describeBackend() string { s := m.status diff --git a/internal/tui/input_test.go b/internal/tui/input_test.go index cdbb1dd..6794c92 100644 --- a/internal/tui/input_test.go +++ b/internal/tui/input_test.go @@ -163,7 +163,7 @@ func TestInputSeparatesPromptAndRendersSessionStatus(t *testing.T) { ContextUsed: 16_800, ContextWindow: 200_000, LastTurnDur: 42 * time.Second, - SessionDur: 2*time.Minute + 10*time.Second, + SessionStarted: time.Now().Add(-2*time.Minute - 10*time.Second), } view := m.View() @@ -199,7 +199,7 @@ func TestInputRendersStatusInSlashMenu(t *testing.T) { func TestStatusUsesLiveSessionStart(t *testing.T) { s := &StatusInfo{SessionStarted: time.Now().Add(-2 * time.Minute)} - if got := s.sessionDuration(); got < 2*time.Minute || got > 2*time.Minute+time.Second { + if got := time.Since(s.SessionStarted); got < 2*time.Minute || got > 2*time.Minute+time.Second { t.Fatalf("live session duration = %s, want about 2m", got) } }