From 4bca1a20f296ebb9c54a450303f21ecc939aebd3 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 26 Sep 2026 10:50:45 +0200 Subject: [PATCH] feat(tui): improve inspection and decision clarity Keep command details visible during focused inspection, clarify approval scope, and place turn status at the right edge. Add narrow-terminal and interaction coverage. --- AGENTS.md | 13 + README.md | 32 +- internal/tui/approval.go | 23 +- internal/tui/approval_plainkeys_test.go | 2 +- internal/tui/calm_default_test.go | 4 +- internal/tui/cost_test.go | 4 +- internal/tui/design_improvements_test.go | 384 ++++++++++++++++++++++ internal/tui/hints.go | 21 +- internal/tui/hints_test.go | 4 +- internal/tui/inspect.go | 112 ++++++- internal/tui/model.go | 13 +- internal/tui/stats_test.go | 29 +- internal/tui/terminal_preview_test.go | 88 ++--- internal/tui/transcript_additions_test.go | 1 + internal/tui/turns_test.go | 7 +- internal/tui/view.go | 157 +++++++-- 16 files changed, 767 insertions(+), 127 deletions(-) create mode 100644 internal/tui/design_improvements_test.go diff --git a/AGENTS.md b/AGENTS.md index c2ac93c..745bbb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,6 +352,19 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T` Wrap invocation lines by display cells before paging. Escape control and invisible characters visibly, and label any argument retention limit. Replay must offer the same inspection path as live ingestion. +- Deliberate step inspection shows one expanded tool at a time and borrows the + composer rows on short terminals without losing the draft. Keep the selected + tool identifiable and the active detail page visible while paging; sub-agent + children remain chips in the transcript. The pager names whether it shows + invocation or result. Global `^E` still expands all details after leaving + inspection. At 40 columns, a one-time hint must fit completely and the + inspect footer must teach `Alt+I`. +- Approval cards put the exact display-safe command or operation resource first, visibly escape + controls and invisible characters, and state when odek supplied no command + or working directory. Explain that class trust lasts until the current + connection ends; keep allow-once and deny visible at narrow widths. Default + turn footers right-align outcome, elapsed time, tool count, and known cost; full + telemetry remains under `^E` and `/stats`. - Keep normalized `step.result` for copying/error compatibility and bounded `step.detailResult` for structured display. Preserve sanitized command/path identity through live and history ingestion; never infer item boundaries from diff --git a/README.md b/README.md index 6cb13dd..821cef0 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,13 @@ own front-end settings are separate; see [Configuration](#configuration). and the first-run hints wrap by display cells (including wide characters). - **The palette (`^K`)** — every command, session, model, and drawer tab one fuzzy search away; every row teaches its chord. -- **Turn cards** — telemetry sits under the assistant reply, a coding receipt +- **Turn cards** — a short outcome/elapsed/tool/cost line sits under the assistant reply, a coding receipt (`touched 4 · +82 −19 · tests ✓`) scans what the turn changed, `^F` folds noisy turns to that receipt, `alt+↑`/`alt+↓` jump turn-to-turn, and the calm default keeps the transcript still: reasoning previews and - tool responses stay hidden until `^E` (details) reveals the intent rail - (with its `beat N/M` labels) and every step's full output. While the + tool responses stay hidden until deliberate inspection or `^E` (details) + reveals the intent rail (with its `beat N/M` labels) and every step's + full output and turn telemetry. While the turn streams, its head line carries the run's elapsed counter at the right edge — the one live clock in the transcript. - **Typed tool renderers** — diffs tint with a `+N −M` chip, file reads get @@ -178,7 +179,9 @@ own front-end settings are separate; see [Configuration](#configuration). click so a finished step reads as one calm line. An opened step shows its invocation before its result, including while the tool is running. Tool arguments are retained up to 256 KiB, with an explicit omission marker if - larger. The + larger. Selecting one step opens a focused inspection path that gives the + transcript the composer rows on short terminals; the draft returns unchanged + on Escape. The status line is the only spinner. Running steps speak the same progress copy as the status line (`🧪 running tests`) and tick their own elapsed clock — and the sealed duration stays on the head after the call @@ -327,11 +330,10 @@ own front-end settings are separate; see [Configuration](#configuration). count. `▶ N jobs` / `✗ job` instruments ride the same bar when a background job is active; plan progress rides the busy line while a turn runs and the `/plan` tab otherwise. -- **Per-turn footers & `/stats`** — token counts and latency ride every turn - head (`⚡` latency, `⌂` context, `↳` output tokens, `↗` tok/s, `⚒` tools); - `/stats` opens a sheet that rolls up the session (speed, TTFT, LLM time, - cost, cache, context). The `⎇` glyph is reserved for git commits in the - transcript. +- **Per-turn footers & `/stats`** — a right-aligned row under the reply shows turn outcome, elapsed time, + tool count, and cost when prices are known. `^E` reveals full per-turn + telemetry; `/stats` rolls up the session (speed, TTFT, LLM time, cost, + cache, context). The `⎇` glyph is reserved for git commits in the transcript. - **Generation speed** — live `↗ tok/s` in the cockpit stats sheet (`/server`) from `usage` frames (prefers `generationTokensPerSecond` when the stream measured TTFT; @@ -445,6 +447,8 @@ Click a tool header or reasoning block to inspect it; while inspecting, `↑`/ appears first, even while a tool is running; the result follows when it arrives. Long invocation lines wrap by display cells. Details display at most eight rows plus a paging indicator, with fewer rows in short terminals. +The pager names the visible section, and a compact inspection line keeps the +selected tool identifiable when a long sub-agent chip strip scrolls above it. Use `PgUp`/`PgDn` to page, `alt+i` to copy the displayed invocation, `alt+y` to copy the retained response, and Escape to return to typing. The global `^E` details toggle uses the same page limits. Control and @@ -584,9 +588,9 @@ can encode them: | Key | Action | |-----|--------| -| `a` / `Alt+A` | Approve (plain key: empty composer only); in friction mode, first open the confirmation editor | +| `a` / `Alt+A` | Allow once (plain key: empty composer only); in friction mode, first open the confirmation editor | | `d` / `Alt+D` | Deny (plain key: empty composer only) | -| `t` / `Alt+T` | Trust the class, only when the server offers it and friction is off (plain key: empty composer only) | +| `t` / `Alt+T` | Trust this risk class until the current connection ends, only when the server offers it and friction is off (plain key: empty composer only) | | `Tab` | Expand/collapse command and description details | | `Alt+PgUp` / `Alt+PgDn` | Page expanded approval details | | `Esc` | Return from confirmation editing, or fold details; otherwise arm turn cancellation when busy | @@ -607,6 +611,12 @@ focus to the latest transcript message (a still-queued successor keeps scrollback), so a stale form can never collect an approval for a prompt the engine already abandoned. +The card leads with the exact command (or operation resource) or an explicit +note if odek did not supply one. Expanded details visibly escape control and invisible characters, +show the engine's action class and reason, and say when odek did not supply a +working directory. Trust's duration is tied to the current WebSocket +connection; reconnecting resets it. + --- ## Configuration diff --git a/internal/tui/approval.go b/internal/tui/approval.go index 3da4073..989d364 100644 --- a/internal/tui/approval.go +++ b/internal/tui/approval.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -20,15 +21,33 @@ type approvalOption struct { // class-trust shortcut (mirrors the TTY approver policy). func (m *Model) approvalOptions() []approvalOption { opts := []approvalOption{ - {"approve", "approve"}, + {"allow once", "approve"}, {"deny", "deny"}, } if a := m.curApproval(); a != nil && a.AllowTrust && !a.Friction { - opts = append(opts, approvalOption{"always allow", "trust"}) + opts = append(opts, approvalOption{"allow class until disconnect", "trust"}) } return opts } +func approvalRiskLabel(risk string) string { + switch risk { + case "shell_exec": + return "shell commands" + case "network_egress": + return "network access" + case "local_write": + return "local writes" + case "low", "medium", "high": + return risk + "-risk tool action" + case "": + return "this tool action" + default: + label := visibleInvocation(strings.ReplaceAll(risk, "_", " ")) + return strings.ReplaceAll(label, "\n", `\n`) + } +} + // handleApprovalKey keeps an approval card from hijacking the composer. Only // explicit Alt chords decide; ordinary text, paste, cursor movement, and // Enter continue to operate on the draft underneath the card. diff --git a/internal/tui/approval_plainkeys_test.go b/internal/tui/approval_plainkeys_test.go index 02d864b..6b45dfb 100644 --- a/internal/tui/approval_plainkeys_test.go +++ b/internal/tui/approval_plainkeys_test.go @@ -152,7 +152,7 @@ func TestApprovalFooterShowsPlainHints(t *testing.T) { busyTurn(m) m.handleEvent(client.Event{Type: "approval_request", ID: "apr", AllowTrust: true}) foot := plain(m.footer()) - for _, want := range []string{"a approve", "d deny", "t trust"} { + for _, want := range []string{"a allow once", "d deny", "t trust class"} { if !strings.Contains(foot, want) { t.Errorf("approval footer missing %q: %q", want, foot) } diff --git a/internal/tui/calm_default_test.go b/internal/tui/calm_default_test.go index 93452dc..c13f2b3 100644 --- a/internal/tui/calm_default_test.go +++ b/internal/tui/calm_default_test.go @@ -175,7 +175,7 @@ func TestTurnHeadElapsedCounter(t *testing.T) { if strings.Contains(head, "⚡") { t.Errorf("finalized head must not carry sealed telemetry:\n%s", head) } - if !strings.Contains(lines[len(lines)-1], "⚡") { - t.Errorf("finalized turn missing telemetry foot:\n%s", plainOut) + if !strings.Contains(lines[len(lines)-1], "3.0s") { + t.Errorf("finalized turn missing concise elapsed foot:\n%s", plainOut) } } diff --git a/internal/tui/cost_test.go b/internal/tui/cost_test.go index fa1ffde..6ec780e 100644 --- a/internal/tui/cost_test.go +++ b/internal/tui/cost_test.go @@ -91,7 +91,7 @@ func TestTurnFooterShowsCost(t *testing.T) { SessionContextTokens: 10_000, SessionOutputTokens: 2_000, }) out := plain(drive.View()) - if !strings.Contains(out, "$ $0.016") { + if !strings.Contains(out, "3.0s · 1 tool · $0.016") { t.Errorf("footer missing turn cost in:\n%s", out) } } @@ -112,7 +112,7 @@ func TestTurnFooterUsesModelOverride(t *testing.T) { }) out := plain(drive.View()) // 10k input @ $2/M + 2k output @ $3/M = $0.02 + $0.006. - if !strings.Contains(out, "$ $0.026") { + if !strings.Contains(out, "3.0s · 1 tool · $0.026") { t.Errorf("footer missing override cost in:\n%s", out) } } diff --git a/internal/tui/design_improvements_test.go b/internal/tui/design_improvements_test.go new file mode 100644 index 0000000..89dbb71 --- /dev/null +++ b/internal/tui/design_improvements_test.go @@ -0,0 +1,384 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/BackendStack21/bodek/internal/client" +) + +func TestFocusedToolInspectionKeepsPageVisibleAtTwelveRows(t *testing.T) { + m := newTestModel() + m.resize(40, 12) + m.ta.SetValue("draft stays here") + m.msgs = []message{{role: roleAsst, steps: []step{{ + name: "shell", arg: "go test ./...", done: true, + callArgs: `{"command":"go test ./...","workdir":"/workspace","description":"` + strings.Repeat("inspect every argument ", 10) + `"}`, + result: "ok\ncoverage: 99%", + }}}} + m.refresh() + m.openInspectStep(0, 0) + first := plain(m.View()) + if strings.Contains(first, "draft stays here") || !strings.Contains(first, "inspect shell") { + t.Fatalf("inspection must preserve the draft behind a named compact line:\n%s", first) + } + if !strings.Contains(first, "invocation") || !strings.Contains(first, "Alt+I") || !strings.Contains(first, "PgUp/Dn") { + t.Fatalf("selected action, copy, and page hints must be visible:\n%s", first) + } + if rows := strings.Count(first, "\n") + 1; rows > m.height { + t.Fatalf("focused view uses %d rows in a %d-row terminal:\n%s", rows, m.height, first) + } + m.handleInspectKey(key("pgdown")) + second := plain(m.View()) + if second == first || !strings.Contains(second, "inspect shell") { + t.Fatalf("paging must visibly change the detail page and keep identity:\n%s", second) + } + m.clearInspect() + if got := m.ta.Value(); got != "draft stays here" { + t.Fatalf("inspection changed draft: %q", got) + } +} + +func TestFocusedInspectionReplacesGlobalDetails(t *testing.T) { + m := newTestModel() + m.resize(40, 16) + m.msgs = []message{{role: roleAsst, steps: []step{ + {name: "read_file", expanded: true, callArgs: `{"path":"one.go"}`}, + {name: "delegate_tasks", subagent: true, expanded: true, agentSel: 1, + callArgs: `{"tasks":[{"goal":"review"}]}`, + agents: []*agentCard{{idx: 0, phase: "finished", status: "success", goal: "review"}}}, + }}} + m.expandAll = true + m.refresh() + m.openInspectStep(0, 1) + if m.expandAll || m.msgs[0].steps[0].expanded || !m.msgs[0].steps[1].expanded { + t.Fatal("focused inspection must leave exactly the selected step expanded") + } + if m.msgs[0].steps[1].focusedIdx() != -1 { + t.Fatal("opening the parent step must show its invocation before child focus") + } + if view := plain(m.View()); !strings.Contains(view, "invocation") { + t.Fatalf("focused parent invocation is not visible:\n%s", view) + } + m.handleInspectKey(key("up")) + if m.msgs[0].steps[1].expanded || m.inspect == nil || m.inspect.stepIdx != 0 { + t.Fatal("moving inspection must close the old body and select the prior step") + } +} + +func TestInspectorTraversesReasoningAndReturnsToDraft(t *testing.T) { + m := newTestModel() + m.resize(40, 16) + m.ta.SetValue("draft") + m.msgs = []message{{role: roleAsst, + steps: []step{{name: "shell", callArgs: `{"command":"go test ./..."}`}}, + items: []turnItem{{thinking: true, text: "Check the tests."}, {stepIdx: 0}}, + }} + m.refresh() + m.openInspectStep(0, 0) + m.handleInspectKey(key("up")) + if m.inspect == nil || m.inspect.stepIdx != -1 || m.msgs[0].steps[0].expanded { + t.Fatal("Up did not move from the tool to the prior reasoning block") + } + m.handleInspectKey(key("enter")) + if !m.msgs[0].items[0].open || !strings.Contains(plain(m.View()), "Check the tests.") { + t.Fatal("Enter did not open the selected reasoning block") + } + m.handleInspectKey(key("esc")) + if m.inspect != nil || m.ta.Value() != "draft" { + t.Fatal("Escape did not return to the preserved draft") + } +} + +func TestFocusedSubagentInspectionKeepsChipsAndPage(t *testing.T) { + m := newTestModel() + m.resize(40, 12) + s := step{name: "delegate_tasks", subagent: true, done: true, + callArgs: `{"tasks":[{"goal":"inspect auth"},{"goal":"verify tests"}]}`, + result: "agents finished"} + for i := 0; i < 4; i++ { + s.agents = append(s.agents, &agentCard{idx: i, phase: "finished", status: "success", goal: fmt.Sprintf("review area %d", i)}) + } + m.msgs = []message{{role: roleAsst, steps: []step{s}}} + m.refresh() + m.openInspectStep(0, 0) + transcript := plain(m.conversation()) + for i := 1; i <= 4; i++ { + if !strings.Contains(transcript, fmt.Sprintf("SA%d", i)) { + t.Fatalf("sub-agent chip SA%d disappeared from transcript:\n%s", i, transcript) + } + } + view := plain(m.View()) + if !strings.Contains(view, "inspect delegate_tasks") || !strings.Contains(view, `"tasks"`) { + t.Fatalf("short inspector lost identity or substantive invocation content:\n%s", view) + } + if rows := strings.Count(view, "\n") + 1; rows > m.height { + t.Fatalf("sub-agent inspection uses %d rows in a %d-row terminal", rows, m.height) + } + m.handleInspectKey(key("right")) + if got := m.msgs[0].steps[0].focusedIdx(); got != 0 { + t.Fatalf("Right did not focus the first sub-agent chip: %d", got) + } + m.handleInspectKey(key("right")) + if got := m.msgs[0].steps[0].focusedIdx(); got != 1 { + t.Fatalf("Right did not advance to the next sub-agent chip: %d", got) + } + m.handleInspectKey(key("enter")) + if m.msgs[0].steps[0].expanded { + t.Fatal("Enter did not close the focused step") + } + m.handleInspectKey(key("enter")) + if !m.msgs[0].steps[0].expanded || m.msgs[0].steps[0].focusedIdx() != -1 { + t.Fatal("reopening the step did not restore its parent invocation") + } +} + +func TestNarrowApprovalShowsCommandAndChoices(t *testing.T) { + m := newTestModel() + m.resize(40, 12) + busyTurn(m) + m.handleEvent(client.Event{Type: "approval_request", ID: "approval", Name: "shell", Risk: "shell_exec", + Command: "rm -rf build/", Description: "clean build artifacts", AllowTrust: true}) + view := plain(m.View()) + for _, want := range []string{"Command: rm -rf build/", "a once", "d deny", "t trust class", "Tab"} { + if !strings.Contains(view, want) { + t.Fatalf("40-column approval missing %q:\n%s", want, view) + } + } + if rows := strings.Count(view, "\n") + 1; rows > m.height { + t.Fatalf("approval uses %d rows in a %d-row terminal", rows, m.height) + } +} + +func TestOperationApprovalNamesResource(t *testing.T) { + m := newTestModel() + m.resize(40, 16) + busyTurn(m) + m.handleEvent(client.Event{Type: "approval_request", ID: "operation", IsOperation: true, + Risk: "local_write", Command: "config/settings.json"}) + view := plain(m.View()) + if !strings.Contains(view, "Resource: config/settings.json") || strings.Contains(view, "Command: config/settings.json") { + t.Fatalf("operation approval must identify its resource:\n%s", view) + } +} + +func TestApprovalDetailsExposeUnknownScopeAndInvisibleCommand(t *testing.T) { + m := newTestModel() + m.resize(40, 24) + m.approvals = []client.Event{{Type: "approval_request", Name: "shell", Risk: "shell_exec", + Command: "echo safe\x1b[31m\u202Ebad", AllowTrust: true}} + m.apprExpanded = true + var pages strings.Builder + for offset := 0; offset < 20; offset++ { + m.apprOffset = offset + pages.WriteString(plain(m.approvalBody())) + pages.WriteByte('\n') + } + all := pages.String() + for _, want := range []string{`\x1B`, `\u202E`, "Working directory: not supplied by", "connection ends"} { + if !strings.Contains(all, want) { + t.Fatalf("expanded approval did not expose %q across pages:\n%s", want, all) + } + } + if strings.ContainsRune(all, '\x1b') || strings.ContainsRune(all, '\u202e') { + t.Fatal("raw control or bidi character reached approval display") + } + m.approvals[0].Command = "" + m.apprOffset = 0 + if got := plain(m.approvalBody()); !strings.Contains(got, "Command not supplied by odek") { + t.Fatalf("tool name must not be presented as a missing command: %q", got) + } + m.approvals[0].Risk = "custom\nDeny disabled" + if got := approvalRiskLabel(m.approvals[0].Risk); strings.ContainsRune(got, '\n') || !strings.Contains(got, `\n`) { + t.Fatalf("risk label must show an escaped newline on one row: %q", got) + } +} + +func TestFrictionApprovalInspectionPreservesDraftAndDecision(t *testing.T) { + m := newTestModel() + m.resize(40, 16) + m.ta.SetValue("follow-up draft") + m.approvals = []client.Event{{Type: "approval_request", ID: "pending", Name: "shell", + Risk: "shell_exec", Command: "rm -rf build/", Friction: true}} + m.refresh() + m.handleApprovalKey(key("tab")) + if !m.apprExpanded || !strings.Contains(plain(m.View()), "Command: rm -rf build/") { + t.Fatal("Tab did not reveal the pending command") + } + m.handleApprovalKey(key("pgdown")) + m.handleApprovalKey(key("ctrl+g")) + m.handleApprovalKey(key("esc")) + if m.apprExpanded || m.apprEditing || len(m.approvals) != 1 || m.ta.Value() != "follow-up draft" { + t.Fatal("inspecting a friction approval changed the draft or decision state") + } +} + +func TestToolHintFitsFortyColumns(t *testing.T) { + m := newTestModel() + m.resize(40, 32) + busyTurn(m) + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"go test ./..."}`}) + view := plain(m.View()) + if !strings.Contains(view, "tip: click a step to inspect") { + t.Fatalf("one-time tool hint was cut off at 40 columns:\n%s", view) + } + for _, line := range strings.Split(m.View(), "\n") { + if got := lipgloss.Width(line); got > 40 { + t.Fatalf("hint view line uses %d columns: %q", got, plain(line)) + } + } +} + +func TestResponsiveHintsKeepActionsReadable(t *testing.T) { + for _, tc := range []struct { + key, full string + }{ + {hintQueue, "^Q opens the queue"}, + {hintSwarm, "/agents shows all agents"}, + {hintSteps, "click a step to inspect"}, + {hintCtx, "ctx = context in use"}, + } { + m := newTestModel() + m.resize(40, 20) + m.teach(tc.key, "an intentionally long hint that must not be shown at this width") + got := m.notices[len(m.notices)-1] + if !strings.Contains(got, tc.full) || lipgloss.Width(got) > 36 { + t.Errorf("%s hint incomplete at 40 columns: %q", tc.key, got) + } + } + m := newTestModel() + m.resize(20, 20) + m.teach(hintSteps, "long hint") + if got := m.notices[len(m.notices)-1]; !strings.Contains(got, "F1 help") || lipgloss.Width(got) > 16 { + t.Errorf("tiny terminal hint incomplete: %q", got) + } +} + +func TestConciseTurnFootShowsOutcomeAndKeepsDiagnostics(t *testing.T) { + m := newTestModel() + m.resize(40, 20) + msg := message{role: roleAsst, stats: &turnStats{latency: 2.5, toolCount: 2, ctxTok: 1000, outTok: 200}} + if got := plain(m.turnStatFoot(msg)); !strings.Contains(got, "✓ done · 2.5s · 2 tools") || strings.Contains(got, "⌂") { + t.Fatalf("default receipt is not outcome-first and concise: %q", got) + } else if lipgloss.Width(got) != m.vp.Width || !strings.HasPrefix(got, " ") { + t.Fatalf("default receipt is not aligned to the right edge: %q", got) + } + msg.failed = true + if got := plain(m.turnStatFoot(msg)); !strings.Contains(got, "✗ failed") { + t.Fatalf("failed turn lacks an outcome: %q", got) + } + m.expandAll = true + if got := plain(m.turnStatFoot(msg)); !strings.Contains(got, "⌂ 1k") || !strings.Contains(got, "↳ 200") { + t.Fatalf("global details lost full turn telemetry: %q", got) + } else if lipgloss.Width(got) != m.vp.Width { + t.Fatalf("detailed receipt is not aligned to the right edge: %q", got) + } + m.expandAll = false + msg.collapsed = true + msg.steps = []step{{name: "shell", done: true}} + msg.items = []turnItem{{stepIdx: 0}} + if got := plain(m.turnStatFoot(msg)); !strings.Contains(got, "✗ failed") { + t.Fatalf("folded turn needs a footer when its head cannot show the tally: %q", got) + } +} + +func TestFoldedTurnStatsSurviveCrowdedHead(t *testing.T) { + for _, width := range []int{40, 80, 120} { + m := newTestModel() + m.resize(width, 20) + msg := message{role: roleAsst, collapsed: true, content: "Finished.", + stats: &turnStats{latency: 12, toolCount: 2}, + steps: []step{ + {name: "apply_patch", arg: "internal/tui/view.go", done: true, dur: 6 * time.Second, + result: "--- a\n+++ b\n@@\n-old\n+new\n+newer\n"}, + {name: "shell", done: true, dur: 6 * time.Second, + result: "ok \tgithub.com/BackendStack21/bodek/internal/tui\t0.54s"}, + }, + items: []turnItem{{stepIdx: 0}, {stepIdx: 1}}, + } + rendered, _ := m.renderMessage(msg, 0, 0) + lines := strings.Split(plain(rendered), "\n") + if !strings.Contains(lines[0], "2 tools · 12.0s") { + t.Fatalf("width %d: folded stats disappeared:\n%s", width, plain(rendered)) + } + if lipgloss.Width(lines[0]) != m.vp.Width { + t.Fatalf("width %d: folded tally is not right-aligned: %q", width, lines[0]) + } + if strings.Contains(lines[0], "✓ tests") || !strings.Contains(plain(rendered), "✓ tests") { + t.Fatalf("width %d: coding receipt should remain in folded summary:\n%s", width, plain(rendered)) + } + m.expandAll = true + detailed, _ := m.renderMessage(msg, 0, 0) + if !strings.Contains(plain(detailed), "⌂") || !strings.Contains(plain(detailed), "↳") { + t.Fatalf("width %d: global details hid folded-turn telemetry:\n%s", width, plain(detailed)) + } + } +} + +func TestFoldedTurnFallsBackToFooterWhenTallyCannotFit(t *testing.T) { + m := newTestModel() + m.resize(24, 20) + msg := message{role: roleAsst, collapsed: true, content: "Finished.", + stats: &turnStats{latency: 12, toolCount: 1}, + steps: []step{{name: "delegate_tasks", done: true, dur: 12 * time.Second, + agents: make([]*agentCard, 10)}}, + items: []turnItem{{stepIdx: 0}}, + } + if got := foldTally(msg); !strings.HasPrefix(got, "1 tool · 10 agents") { + t.Fatalf("singular tool tally is incorrect: %q", got) + } + rendered, _ := m.renderMessage(msg, 0, 0) + lines := strings.Split(plain(rendered), "\n") + if strings.Contains(lines[0], "10 agents") || !strings.Contains(lines[len(lines)-1], "✓ done") { + t.Fatalf("unfittable tally should use the right-aligned footer:\n%s", plain(rendered)) + } + if lipgloss.Width(lines[len(lines)-1]) != m.vp.Width { + t.Fatalf("fallback footer does not reach right edge: %q", lines[len(lines)-1]) + } +} + +func TestTurnStatsEndAtRightEdgeInRenderedCard(t *testing.T) { + for _, width := range []int{40, 80, 120} { + m := newTestModel() + m.resize(width, 20) + msg := message{role: roleAsst, content: "Work complete.", stats: &turnStats{latency: 1.2, toolCount: 1}} + for _, details := range []bool{false, true} { + m.expandAll = details + rendered, _ := m.renderMessage(msg, 0, 0) + lines := strings.Split(plain(rendered), "\n") + foot := lines[len(lines)-1] + if lipgloss.Width(foot) != m.vp.Width { + t.Errorf("width %d, details %t: stat row ends at %d:\n%s", width, details, lipgloss.Width(foot), plain(rendered)) + } + } + } +} + +func TestClickOpensFocusedStepAtShortHeight(t *testing.T) { + m := newTestModel() + m.resize(40, 12) + m.msgs = []message{{role: roleAsst, steps: []step{{name: "shell", arg: "go test", callArgs: `{"command":"go test ./..."}`}}}} + m.refresh() + var line int + for _, ref := range m.stepLineIndex { + if ref.msgIdx == 0 && ref.stepIdx == 0 && ref.x1 <= ref.x0 { + line = ref.line + break + } + } + m.vp.SetYOffset(line) + y := headerHeight + line - m.vp.YOffset + m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 8, Y: y}) + if !m.inspectChrome() { + t.Fatal("click did not open the focused inspector") + } + view := plain(m.View()) + if !strings.Contains(view, "inspect shell") || !strings.Contains(view, "invocation") { + t.Fatalf("clicked command page is not visible:\n%s", view) + } +} diff --git a/internal/tui/hints.go b/internal/tui/hints.go index 714bc3c..d4acd89 100644 --- a/internal/tui/hints.go +++ b/internal/tui/hints.go @@ -35,5 +35,24 @@ func (m *Model) teach(key, text string) { return } m.hintsShown[key] = true - m.pushNote("💡 "+text, time.Now().Add(hintTTL)) + if m.width < 50 { + switch key { + case hintQueue: + text = "tip: ^Q opens the queue" + case hintSwarm: + text = "tip: /agents shows all agents" + case hintSteps: + text = "tip: click a step to inspect" + case hintCtx: + text = "tip: ctx = context in use" + } + } + if m.width < 24 { + text = "tip: F1 help" + } + label := "💡 " + text + if m.width > 4 { + label = truncate(label, m.width-4) // note rail adds its own prefix + } + m.pushNote(label, time.Now().Add(hintTTL)) } diff --git a/internal/tui/hints_test.go b/internal/tui/hints_test.go index fd95651..8b8aa32 100644 --- a/internal/tui/hints_test.go +++ b/internal/tui/hints_test.go @@ -157,7 +157,7 @@ func TestArrowsSelectToolsAndReasoning(t *testing.T) { if !m.msgs[last].items[ti].open { t.Fatal("reasoning must remain individually reachable") } - if !m.msgs[last].steps[1].expanded { - t.Fatal("opening reasoning must preserve existing tool state") + if m.msgs[last].steps[1].expanded { + t.Fatal("moving to reasoning must close the previously inspected tool") } } diff --git a/internal/tui/inspect.go b/internal/tui/inspect.go index 3ac08a5..5ecd336 100644 --- a/internal/tui/inspect.go +++ b/internal/tui/inspect.go @@ -48,6 +48,15 @@ func (m *Model) validInspect() bool { return p.itemIdx >= 0 && p.itemIdx < len(msg.items) && msg.items[p.itemIdx].thinking } +// Inspection borrows the composer rows so a short terminal can show the +// selected step's heading, details, and pager together. The draft is kept in +// the textarea and returns unchanged on Escape. +func (m *Model) inspectChrome() bool { + return m.validInspect() && m.curApproval() == nil && m.clarify == nil && + m.panel == panelNone && !m.pal.open && !m.find.open && !m.ac.open && + !m.qfocus && !m.popover +} + func (m *Model) invalidateInspect() { if !m.validInspect() { return @@ -62,7 +71,40 @@ func (m *Model) invalidateInspect() { func (m *Model) clearInspect() { m.invalidateInspect() m.inspect = nil + m.relayout() + m.refresh() +} + +// openInspectStep makes deliberate inspection a single-step view. Global +// details remain available with ^E after leaving inspection. +func (m *Model) openInspectStep(msgIdx, stepIdx int) { + if msgIdx < 0 || msgIdx >= len(m.msgs) || stepIdx < 0 || stepIdx >= len(m.msgs[msgIdx].steps) { + return + } + m.invalidateInspect() + if m.expandAll { + m.expandAll = false + m.invalidateAllMsgBlocks() + } + for i := range m.msgs { + for j := range m.msgs[i].steps { + s := &m.msgs[i].steps[j] + open := i == msgIdx && j == stepIdx + focusChanged := open && s.clearAgentFocus() + if s.expanded != open || (open && s.detailOffset != 0) || focusChanged { + s.expanded = open + s.detailOffset = 0 + clearStepBlockCache(s) + m.invalidateMsgBlock(i) + } + } + } + m.inspect = &inspectTarget{msgIdx: msgIdx, stepIdx: stepIdx, itemIdx: -1} + m.focusIdx = msgIdx + m.msgs[msgIdx].collapsed = false + m.relayout() m.refresh() + m.revealInspect() } func (m *Model) moveInspect(back bool) { @@ -109,12 +151,22 @@ func (m *Model) moveInspect(back bool) { } else { index = (index + 1) % len(targets) } + if m.validInspect() && m.inspect.stepIdx >= 0 && !m.expandAll { + old := &m.msgs[m.inspect.msgIdx].steps[m.inspect.stepIdx] + if old.expanded { + old.expanded = false + old.detailOffset = 0 + clearStepBlockCache(old) + m.invalidateMsgBlock(m.inspect.msgIdx) + } + } m.invalidateInspect() p := targets[index] m.inspect = &p m.focusIdx = p.msgIdx m.msgs[p.msgIdx].collapsed = false m.invalidateInspect() + m.relayout() m.refresh() m.revealInspect() } @@ -127,7 +179,16 @@ func (m *Model) revealInspect() { if p.stepIdx >= 0 { for _, r := range m.stepLineIndex { if r.msgIdx == p.msgIdx && r.stepIdx == p.stepIdx && r.x1 <= r.x0 { - m.vp.SetYOffset(max(0, r.line-1)) + top := r.line - 1 + if m.inspectChrome() { + top = r.line + s := &m.msgs[p.msgIdx].steps[p.stepIdx] + chips := len(packChipRows(s.agentChips(), max(m.cardInner()-2, 8))) + if s.expanded && 1+chips+m.toolDetailRows()+1 > m.vp.Height { + top += 1 + chips // keep the active detail page visible + } + } + m.vp.SetYOffset(max(0, top)) m.relayout() return } @@ -151,16 +212,11 @@ func (m *Model) handleInspectKey(msg tea.KeyMsg) bool { if p.stepIdx >= 0 { s := &m.msgs[p.msgIdx].steps[p.stepIdx] wasExpanded := s.expanded || m.expandAll - if m.expandAll { - m.expandAll = false - m.invalidateAllMsgBlocks() - for i := range m.msgs { - for j := range m.msgs[i].steps { - clearStepBlockCache(&m.msgs[i].steps[j]) - } - } + if !wasExpanded || m.expandAll { + m.openInspectStep(p.msgIdx, p.stepIdx) + return true } - s.expanded = !wasExpanded + s.expanded = false s.detailOffset = 0 } else { it := &m.msgs[p.msgIdx].items[p.itemIdx] @@ -216,15 +272,37 @@ func (m *Model) handleInspectKey(msg tea.KeyMsg) bool { return true } -func (m *Model) toolDetailRows() int { return max(1, min(8, (m.height-12)/2)) } +func (m *Model) toolDetailRows() int { + if m.inspectChrome() { + room := m.vp.Height - 2 // heading and pager + if m.inspect.stepIdx >= 0 { + s := &m.msgs[m.inspect.msgIdx].steps[m.inspect.stepIdx] + if len(s.agentChips()) > 0 { + // The chip strip remains in the transcript. When it pushes the + // detail page below the viewport, revealInspect scrolls to the + // page and the compact input line keeps the tool identity visible. + room = m.vp.Height - 1 + } + } + return max(1, min(8, room)) + } + return max(1, min(8, (m.height-12)/2)) +} // toolDetailPage bounds every detail body by display rows, including embedded -// newlines from renderers. Paging changes the slice, never the screen geometry. -func (m *Model) toolDetailPage(s *step, details []string, width int) []string { +// newlines from renderers. The pager names the visible section, so even a +// one-row page has context when its section heading has scrolled away. +func (m *Model) toolDetailPage(s *step, details []string, width, sectionBreak int, firstSection, nextSection string) []string { var rows []string - for _, d := range details { + var sections []string + for i, d := range details { + section := nextSection + if i < sectionBreak { + section = firstSection + } for _, line := range strings.Split(d, "\n") { rows = append(rows, ansi.Truncate(line, max(1, width), "")) + sections = append(sections, section) } } limit := m.toolDetailRows() @@ -235,7 +313,11 @@ func (m *Model) toolDetailPage(s *step, details []string, width int) []string { s.detailOffset = offset end := min(len(rows), offset+limit) out := append([]string(nil), rows[offset:end]...) - label := fmt.Sprintf("%d–%d/%d · PgUp PgDn page", offset+1, end, len(rows)) + section := sections[offset] + if sections[end-1] != section { + section += " → " + sections[end-1] + } + label := fmt.Sprintf("%s · %d–%d/%d · PgUp PgDn page", section, offset+1, end, len(rows)) out = append(out, m.th.stepArg.Render(ansi.Truncate(label, max(1, width), ""))) return out } diff --git a/internal/tui/model.go b/internal/tui/model.go index eae1cb5..3193478 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -887,11 +887,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if msgIdx, stepIdx, ok := m.stepAtLine(line); ok { - m.invalidateInspect() - m.inspect = &inspectTarget{msgIdx: msgIdx, stepIdx: stepIdx, itemIdx: -1} - m.focusIdx = msgIdx - m.toggleStep(msgIdx, stepIdx) - m.refresh() + m.openInspectStep(msgIdx, stepIdx) return m, nil } // Answer cards (and collapsed summaries) copy the turn's @@ -1442,6 +1438,13 @@ func (m *Model) relayout() { // input area plus the busy status line when it shows — so the viewport // shrinks by exactly the right amount and the footer never moves. func (m *Model) inputAreaHeight() int { + if m.inspectChrome() { + h := 1 // one-row return hint in place of the composer box + if m.statusLineVisible() { + h += 2 + } + return h + } h := m.ta.Height() + 2 // composer box: text rows + top/bottom border if m.curApproval() != nil && !m.pal.open { h += lineCount(m.approvalPanel()) // boxed card sits above the composer diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index 30dca5b..66220d4 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -67,14 +67,23 @@ func TestTurnStatLine(t *testing.T) { } out := plain(m.View()) - for _, want := range []string{"⚡ 2.5s", "⌂ 1.2k", "↳ 340", "⚒ 1", "✳"} { + for _, want := range []string{"3.0s · 1 tool"} { if !strings.Contains(out, want) { - t.Errorf("stat line missing %q in:\n%s", want, out) + t.Errorf("concise turn foot missing %q in:\n%s", want, out) } } if strings.Contains(out, "↗") || strings.Contains(out, "tok/s") { t.Errorf("stat line invented tok/s from cumulative output/latency:\n%s", out) } + m.expandAll = true + m.invalidateAllMsgBlocks() + m.refresh() + out = plain(m.View()) + for _, want := range []string{"⚡ 2.5s", "⌂ 1.2k", "↳ 340", "⚒ 1", "✳"} { + if !strings.Contains(out, want) { + t.Errorf("expanded telemetry missing %q in:\n%s", want, out) + } + } } // A streaming (not-yet-done) turn must render no stat line. @@ -616,8 +625,12 @@ func TestTurnStatLineShowsTokPerSec(t *testing.T) { t.Fatalf("timing = ttft %d call %d llm %d", ts.ttftMs, ts.callDurMs, ts.llmDurMs) } foot := plain(m.turnStatFoot(m.msgs[1])) - if !strings.Contains(foot, "↗") || !strings.Contains(foot, "25.2 tok/s") { - t.Errorf("turn foot missing generation tok/s: %q", foot) + if strings.Contains(foot, "tok/s") { + t.Errorf("default turn foot should keep rate in diagnostics: %q", foot) + } + m.expandAll = true + if detailed := plain(m.turnStatFoot(m.msgs[1])); !strings.Contains(detailed, "25.2 tok/s") { + t.Errorf("expanded turn telemetry missing generation tok/s: %q", detailed) } // The header no longer carries tok/s — the cockpit owns the live rate. if strings.Contains(plain(m.header()), "tok/s") { @@ -634,7 +647,7 @@ func TestChromeFooterOmitsTokPerSec(t *testing.T) { m.sendPrompt("next") m.handleEvent(client.Event{Type: "usage", TokensPerSecond: 9.6}) // The header no longer carries the in-flight rate — the cockpit owns it; - // the sealed turn foot keeps the last sealed rate. + // expanded per-turn telemetry keeps the last sealed rate. if strings.Contains(plain(m.header()), "tok/s") { t.Errorf("header must not show tok/s:\n%s", plain(m.header())) } @@ -642,8 +655,12 @@ func TestChromeFooterOmitsTokPerSec(t *testing.T) { if strings.Contains(foot, "tok/s") { t.Errorf("chrome footer must not carry tok/s (header + turn foot own it): %q", foot) } + if got := plain(m.turnStatFoot(m.msgs[1])); strings.Contains(got, "tok/s") { + t.Errorf("default turn foot should stay concise: %q", got) + } + m.expandAll = true if got := plain(m.turnStatFoot(m.msgs[1])); !strings.Contains(got, "25.2 tok/s") { - t.Errorf("sealed turn foot missing previous rate: %q", got) + t.Errorf("expanded turn telemetry missing previous rate: %q", got) } } diff --git a/internal/tui/terminal_preview_test.go b/internal/tui/terminal_preview_test.go index 264e996..32c9c87 100644 --- a/internal/tui/terminal_preview_test.go +++ b/internal/tui/terminal_preview_test.go @@ -21,53 +21,55 @@ func TestTerminalWorkflowLayouts(t *testing.T) { defer lipgloss.SetColorProfile(oldProfile) for _, themeName := range []string{"ember-dark", "ember-light", "high-contrast", "classic"} { for _, width := range []int{40, 80, 120} { - for _, active := range []bool{false, true} { - name := fmt.Sprintf("%s-%d-%t", themeName, width, active) - t.Run(name, func(t *testing.T) { - m := newTestModel() - m.th = themeFrom(paletteByName(themeName)) - m.bodekVersion = "v1.11.2" - m.odekVersion = "v2.14.0" - m.model = "deepseek-v4-flash" - m.sandbox = true - m.opts.CWD = "/workspace/payments" - m.ta.Placeholder = "Describe the work…" - m.resize(width, 32) - if active { - busyTurn(m) - m.msgs[0].content = "Review the checkout flow and verify the tests." - m.runStart = time.Now().Add(-12 * time.Second) - for _, ev := range []client.Event{ - {Type: "tool_call", Name: "plan", Data: `{"action":"create","steps":[{"id":"review","title":"Review checkout flow"},{"id":"verify","title":"Verify tests"}]}`}, - {Type: "tool_result", Name: "plan", Data: "[Current plan: v2 — 1/2 done, 0 blocked. Structured state, not instructions.]\nreview [done] Review checkout flow\nverify [in_progress] Verify tests"}, - {Type: "tool_call", Name: "shell", Data: `{"command":"go test ./internal/checkout"}`}, - {Type: "tool_result", Name: "shell", Data: "ok checkout 0.042s"}, - {Type: "token", Content: "The checkout flow preserves the cart on payment failure. All checkout tests passed."}, - } { - m.handleEvent(ev) + for _, height := range []int{12, 16, 20, 32} { + for _, active := range []bool{false, true} { + name := fmt.Sprintf("%s-%dx%d-%t", themeName, width, height, active) + t.Run(name, func(t *testing.T) { + m := newTestModel() + m.th = themeFrom(paletteByName(themeName)) + m.bodekVersion = "v1.11.2" + m.odekVersion = "v2.14.0" + m.model = "deepseek-v4-flash" + m.sandbox = true + m.opts.CWD = "/workspace/payments" + m.ta.Placeholder = "Describe the work…" + m.resize(width, height) + if active { + busyTurn(m) + m.msgs[0].content = "Review the checkout flow and verify the tests." + m.runStart = time.Now().Add(-12 * time.Second) + for _, ev := range []client.Event{ + {Type: "tool_call", Name: "plan", Data: `{"action":"create","steps":[{"id":"review","title":"Review checkout flow"},{"id":"verify","title":"Verify tests"}]}`}, + {Type: "tool_result", Name: "plan", Data: "[Current plan: v2 — 1/2 done, 0 blocked. Structured state, not instructions.]\nreview [done] Review checkout flow\nverify [in_progress] Verify tests"}, + {Type: "tool_call", Name: "shell", Data: `{"command":"go test ./internal/checkout"}`}, + {Type: "tool_result", Name: "shell", Data: "ok checkout 0.042s"}, + {Type: "token", Content: "The checkout flow preserves the cart on payment failure. All checkout tests passed."}, + } { + m.handleEvent(ev) + } + m.expandAll = true + m.relayout() + m.refresh() } - m.expandAll = true - m.relayout() - m.refresh() - } - out := m.View() - if rows := strings.Count(out, "\n") + 1; rows > m.height { - t.Errorf("screen has %d rows, terminal has %d", rows, m.height) - } - for n, line := range strings.Split(out, "\n") { - if got := lipgloss.Width(line); got > width { - t.Errorf("line %d is %d cells, terminal has %d: %q", n, got, width, plain(line)) + out := m.View() + if rows := strings.Count(out, "\n") + 1; rows > m.height { + t.Errorf("screen has %d rows, terminal has %d", rows, m.height) } - } - if dir := os.Getenv("BODEK_RENDER_PREVIEW_DIR"); dir != "" { - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) + for n, line := range strings.Split(out, "\n") { + if got := lipgloss.Width(line); got > width { + t.Errorf("line %d is %d cells, terminal has %d: %q", n, got, width, plain(line)) + } } - if err := os.WriteFile(filepath.Join(dir, name+".ansi"), []byte(out), 0600); err != nil { - t.Fatal(err) + if dir := os.Getenv("BODEK_RENDER_PREVIEW_DIR"); dir != "" { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name+".ansi"), []byte(out), 0600); err != nil { + t.Fatal(err) + } } - } - }) + }) + } } } } diff --git a/internal/tui/transcript_additions_test.go b/internal/tui/transcript_additions_test.go index 3940fc9..b77b4ee 100644 --- a/internal/tui/transcript_additions_test.go +++ b/internal/tui/transcript_additions_test.go @@ -69,6 +69,7 @@ func TestFoldedTurnTally(t *testing.T) { turnItem{stepIdx: 0}, turnItem{stepIdx: 1}, ) + msg.collapsed = true rendered, _ := m.renderMessage(m.msgs[i], i, 0) head := plain(rendered) diff --git a/internal/tui/turns_test.go b/internal/tui/turns_test.go index a7ab0d7..247269c 100644 --- a/internal/tui/turns_test.go +++ b/internal/tui/turns_test.go @@ -41,11 +41,16 @@ func TestTurnFootCarriesTelemetry(t *testing.T) { t.Errorf("turn head missing identity:\n%s", head) } foot := lines[len(lines)-1] - for _, want := range []string{"⚡", "2.0s", "900", "120", "⚒"} { + for _, want := range []string{"2.0s", "2 tools"} { if !strings.Contains(foot, want) { t.Errorf("turn foot missing %q:\n%s", want, foot) } } + for _, hidden := range []string{"⚡", "⌂", "↳", "⚒"} { + if strings.Contains(foot, hidden) { + t.Errorf("default turn foot contains dense telemetry %q:\n%s", hidden, foot) + } + } } // TestTurnCollapseFold verifies ^F folds the last turn to its head + summary diff --git a/internal/tui/view.go b/internal/tui/view.go index 573c281..c13fb14 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -679,27 +679,34 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st // state, never wire text — and persists through finalization. label += " " + th.badgeDanger.Render(lampError) } - if rec := formatReceipt(scanReceipt(msg)); rec != "" { + rec := formatReceipt(scanReceipt(msg)) + tallyShown := false + if msg.collapsed && !msg.streaming { + // (A2) Sealed-turn tally on a folded head: 'N tools · M agents · Ts' in + // the right margin. The folded summary already carries the receipt, + // so keep the head clear for the tally. + if tal := foldTally(msg); tal != "" { + room := m.vp.Width - lipgloss.Width(label) - 1 + if lipgloss.Width(tal) <= room { + label += strings.Repeat(" ", room-lipgloss.Width(tal)+1) + th.statsDim.Render(tal) + tallyShown = true + } + } + } + if !tallyShown && rec != "" { room := m.vp.Width - lipgloss.Width(label) - 4 if room > 8 { label += " " + th.statsDim.Render(truncate(rec, room)) } } - if !msg.streaming { - // (A2) Sealed-turn tally on the head: 'N tools · M agents · Ts' in - // the same dim secondary style and width budget as the receipt — - // model-owned counts only, never wire text. - if tal := foldTally(msg); tal != "" { - room := m.vp.Width - lipgloss.Width(label) - 4 - if room > 8 { - label += " " + th.statsDim.Render(truncate(tal, room)) - } - } - } if msg.collapsed { summary := th.statsDim.Render(m.collapseSummary(msg)) start := lineOffset + turnHeadGap - return stackTurn(label, summary, m.turnStatFoot(msg)), []stepRef{replyRefAt(msgIdx, start, lineCount(summary))} + foot := m.turnStatFoot(msg) + if tallyShown && !m.expandAll { + foot = "" + } + return stackTurn(label, summary, foot), []stepRef{replyRefAt(msgIdx, start, lineCount(summary))} } // Live turn clock: a streaming head carries the run's elapsed // counter at the right edge — the calm default hides the rail and @@ -941,7 +948,7 @@ func foldTally(msg message) string { agents += len(msg.steps[i].agents) total += msg.steps[i].dur } - parts := []string{fmt.Sprintf("%d tools", n)} + parts := []string{plural(n, "tool", "tools")} if agents > 0 { plural := "agents" if agents == 1 { @@ -1032,13 +1039,37 @@ func (m *Model) clampLines(s string) string { return strings.Join(lines, "\n") } -// turnStatFoot is the sealed telemetry row under a finalized assistant -// reply. Empty while the turn is still streaming. +// turnStatFoot is the right-aligned sealed telemetry row under a finalized +// assistant reply. Empty while the turn is still streaming. func (m *Model) turnStatFoot(msg message) string { if msg.stats == nil || msg.streaming { return "" } - return m.statLine(*msg.stats) + if m.expandAll { + return m.alignTurnStat(m.statLine(*msg.stats)) + } + ts := *msg.stats + outcome := "✓ done" + if msg.failed { + outcome = "✗ failed" + } + parts := []string{outcome} + if ts.wall > 0 { + parts = append(parts, formatDuration(ts.wall)) + } else if ts.latency > 0 { + parts = append(parts, fmt.Sprintf("%.1fs", ts.latency)) + } + if ts.toolCount > 0 { + parts = append(parts, plural(ts.toolCount, "tool", "tools")) + } + if inPrice, outPrice := m.prices(); inPrice > 0 && outPrice > 0 { + parts = append(parts, formatUSD(costUSD(ts.ctxTok, ts.outTok, inPrice, outPrice))) + } + return m.alignTurnStat(m.th.statsDim.Render(truncate(strings.Join(parts, " · "), m.vp.Width-2))) +} + +func (m *Model) alignTurnStat(line string) string { + return strings.Repeat(" ", max(0, m.vp.Width-lipgloss.Width(line))) + line } // statSeg is one telemetry segment of the turn foot; drop orders which @@ -1263,7 +1294,16 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in showFocus := focus >= 0 if s.subagent && (showFocus || showAll || expanded) { var details []string + sectionBreak := 0 + firstSection, nextSection := "agent / result", "invocation" if showAll || (expanded && m.expandAll) { + invocation := invocationDetailLines(s, detailBudget, th) + focusedParent := m.inspectChrome() && m.inspect.msgIdx == msgIdx && m.inspect.stepIdx == stepIdx + if focusedParent { + details = append(details, invocation...) + sectionBreak = len(details) + firstSection, nextSection = "invocation", "agent / result" + } for _, a := range s.agents { details = append(details, m.agentCardDetails(a, &s, true, detailBudget)...) } @@ -1282,6 +1322,10 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in } else if s.result != "" { details = append(details, stepDetail(s.name, stepDetailResult(s), m.vp.Width, th)...) } + if !focusedParent { + sectionBreak = len(details) + details = append(details, invocation...) + } } else if showFocus { if a := s.cardByIdx(focus); a != nil { details = append(details, m.agentCardDetails(a, &s, expanded, detailBudget)...) @@ -1292,12 +1336,7 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in details = append(details, th.stepArg.Render(truncate(pendingChipLine(focus, s.manifest[focus]), detailBudget))) } } - if showAll { - // Keep the focused agent and the result card first. The parent - // invocation remains available on later pages of the full tree. - details = append(details, invocationDetailLines(s, detailBudget, th)...) - } - for i, d := range m.toolDetailPage(&s, details, detailBudget) { + for i, d := range m.toolDetailPage(&s, details, detailBudget, sectionBreak, firstSection, nextSection) { conn := " " if i == 0 { conn = " ⎿ " @@ -1307,6 +1346,7 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in } } else if expanded { details := invocationDetailLines(s, detailBudget, th) + invocationRows := len(details) if len(details) > 0 && s.done { details = append(details, th.stepArg.Render("result")) } @@ -1315,7 +1355,7 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in } else { details = append(details, stepDetail(s.name, stepDetailResult(s), m.vp.Width, th)...) } - for i, d := range m.toolDetailPage(&s, details, detailBudget) { + for i, d := range m.toolDetailPage(&s, details, detailBudget, invocationRows, "invocation", "result") { conn := " " if i == 0 { conn = " ⎿ " @@ -1494,6 +1534,17 @@ func (m *Model) renderNotices() string { // ── input / approval area ───────────────────────────────────────────────── func (m *Model) inputArea() string { + if m.inspectChrome() { + label := "reasoning" + if m.inspect.stepIdx >= 0 { + s := m.msgs[m.inspect.msgIdx].steps[m.inspect.stepIdx] + label = sanitize(s.name) + if s.arg != "" { + label += " · " + sanitize(s.arg) + } + } + return m.th.footer.Render(truncate(" inspect "+label+" · Esc draft", m.width)) + } box := m.th.inputBox.Width(m.cardWidth()).Render(m.ta.View()) var above []string if m.curApproval() != nil && !m.pal.open { @@ -1584,7 +1635,7 @@ func (m *Model) approvalBody() string { if a == nil { return "" } - head := th.apprHead.Render(fmt.Sprintf("⚠ approval required · risk: %s", orDash(collapse(a.Risk)))) + head := th.apprHead.Render("⚠ approval required") // The queue count lives in the footer alone ("N more queued") — the // card head does not repeat it. if a.IsOperation { @@ -1604,22 +1655,38 @@ func (m *Model) approvalBody() string { } target := a.Command - if a.Name != "" { - target = a.Name + ": " + target - } budget := max(1, m.cardInner()) lines := []string{ansi.Truncate(head, budget, "…")} + action := "Action: " + approvalRiskLabel(a.Risk) + targetLabel := "Command" + if a.IsOperation { + targetLabel = "Resource" + } + command := targetLabel + ": " + visibleInvocation(target) + if target == "" { + command = targetLabel + " not supplied by odek" + } var body []string if m.apprExpanded { - body = append(body, strings.Split(ansi.Hardwrap(sanitize(target), budget, true), "\n")...) + appendWrapped := func(line string) { + body = append(body, strings.Split(ansi.Hardwrap(line, budget, true), "\n")...) + } + appendWrapped(command) + appendWrapped(action) + appendWrapped("Working directory: not supplied by odek") if a.Description != "" { - body = append(body, strings.Split(ansi.Hardwrap(sanitize(a.Description), budget, true), "\n")...) + appendWrapped("Reason: " + visibleInvocation(a.Description)) + } + if a.AllowTrust && !a.Friction { + appendWrapped("Trust: allow " + approvalRiskLabel(a.Risk) + " until this connection ends") } } else { - body = append(body, truncate(collapse(target), budget)) + preview := strings.ReplaceAll(command, "\n", "↵") + body = append(body, truncate(collapse(preview), budget)) + body = append(body, truncate(action, budget)) if a.Description != "" { - body = append(body, truncate(collapse(a.Description), budget)) + body = append(body, truncate("Reason: "+collapse(visibleInvocation(a.Description)), budget)) } } limit := max(1, min(8, m.height-m.desiredComposerHeight()-headerHeight-footerHeight-8)) @@ -1679,9 +1746,18 @@ func (m *Model) footerContent() string { if a.Friction && m.apprEditing { return m.panelFooter("approve + ⏎", "Alt+D deny", "esc compose") } - hints := []string{"a approve", "d deny"} + if m.width < 56 { + if m.width < 35 { + return ansi.Truncate(" a once · d deny · Tab", max(1, m.width), "") + } + if a.AllowTrust && !a.Friction { + return ansi.Truncate("a once · d deny · t trust class · Tab", max(1, m.width), "") + } + return ansi.Truncate(" a allow once · d deny · Tab details", max(1, m.width), "") + } + hints := []string{"a allow once", "d deny"} if a.AllowTrust && !a.Friction { - hints = append(hints, "t trust") + hints = append(hints, "t trust class") } if len(m.approvals) > 1 { hints = append(hints, fmt.Sprintf("%d queued", len(m.approvals)-1)) @@ -1915,7 +1991,16 @@ func (m *Model) footerContent() string { ) } if m.validInspect() { - return m.panelFooter("inspect", "↑↓ next", "⏎ expand", "Pg↑↓ page", "esc compose") + if m.inspect.stepIdx >= 0 { + if m.width < 56 { + if m.msgs[m.inspect.msgIdx].steps[m.inspect.stepIdx].expanded || m.expandAll { + return ansi.Truncate(" Alt+I copy · PgUp/Dn page · Esc", max(1, m.width), "") + } + return ansi.Truncate(" Alt+I copy · Enter open · Esc", max(1, m.width), "") + } + return m.panelFooter("↑↓ next", "⏎ expand", "Pg↑↓ page", "Alt+I copy call", "esc compose") + } + return m.panelFooter("↑↓ next", "⏎ expand", "esc compose") } // The status bar carries no static key cheatsheet (the welcome splash and // /help cover that) — only the live run state: a cancel hint while busy on @@ -2018,7 +2103,7 @@ func (m *Model) panelFooter(hints ...string) string { if out != "" { next = out + sep + hint } - if lipgloss.Width(next+sep+last) > m.width { + if lipgloss.Width(prefix+next+sep+last) > m.width { continue } out = next