diff --git a/AGENTS.md b/AGENTS.md index 8665e72..febb65a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,6 +258,19 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T` on completion, error or disconnect. Batch ingestion captures each event's linear output before later frames mutate the turn, returning one ordered print command alongside the flat listener and refresh commands. +- Superseded drafts (odek ≥ v2.33): `answer_superseded` turns the reply + segments streamed since the last tool call into `draft` items — a folded + `⋯ draft revised · ` row that opens like reasoning (inspect + + Enter, `^E`) — and rebuilds `msg.content` from the surviving replies, so + a turn never shows two answers. Use `turnItem.isStep()` / `foldable()`; + never treat "not reply, not reasoning" as a step. Replay folds records + persisted with `superseded`. `done.verified == "fail"` (or a persisted / + bulk verification-failed marker, stripped from the prose) sets + `unverified`: `✗ unverified` on the head and outcome row. Verification + `runtime_event` frames only drive the `verifying answer` status label; + the client decodes `runtime_event` separately because its object `event` + key collides with the string `SubType`. Plain mode prints the draft + remainder once plus a `[draft revised · …]` note. - Streaming renders are coalesced into one flush per 80ms for performance; batching new redraw paths the same way keeps the TUI responsive. Live reply segments glamour-render on that flush (not only diff --git a/README.md b/README.md index 1dce42e..ffdd58a 100644 --- a/README.md +++ b/README.md @@ -171,9 +171,16 @@ own front-end settings are separate; see [Configuration](#configuration). Missing success metadata stays neutral rather than claiming that a command passed. - **Streaming answers** rendered as Markdown - ([glamour](https://github.com/charmbracelet/glamour)). -- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph - per tool and a static live mark (`▸`); result bodies wait behind `^E` or a + ([glamour](https://github.com/charmbracelet/glamour)). When odek re-asks + the model after an answer already streamed (completion check or + verification retry, odek ≥ v2.33), the draft folds into a + `⋯ draft revised` row (open it with `^E` or inspect + `⏎`) and only the + replacement shows as the answer. While odek verifies the answer, the status + line reads `verifying answer`; an answer that fails verification carries + `✗ unverified` on its turn heading and outcome row. +- **Tool activity** — every `tool_call`/`tool_result` shown live with one + status glyph per step: a static live mark (`▸`) while running, the tool's + own glyph once it finishes, `✗` when it fails; result bodies wait behind `^E` or a 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 @@ -261,11 +268,11 @@ own front-end settings are separate; see [Configuration](#configuration). `--resume`; `--new` still wins). When bodek exits, it prints the exact line for the session that was just closed, so one paste brings it back. - **Session home** — first-run shows the working directory, the last - session title when one exists, and `type a task · ^K`. After `^L`, the + session title when one exists, and `⏎ send · ⇧⏎ newline · @ attach a file`, anchored just above the composer. After `^L`, the cleared transcript keeps the last prompt and coding receipt so the session is still oriented; `/new` returns to the first-run splash. The footer leads with a mode pill (`composer` / `approval` / `question` / `jobs` / …). -- **Auto-fitting composer** — the input box rests at three rows and grows +- **Auto-fitting composer** — the input box rests at one text row (three with its border) and grows with your prompt (multi-line or a single long line, wide-char aware) up to twelve rows or what the terminal can spare; `⇧⏎` inserts a newline (`ctrl+enter`, `alt+enter`, or `^J` on terminals that cannot tell @@ -285,7 +292,7 @@ own front-end settings are separate; see [Configuration](#configuration). trims, tool execution times) stays silent. Info traces fade after 3s; errors, warnings, and disconnect notes autoclose after 10s. - **Just-in-time hints** — the first time a state appears (a held prompt, - a sub-agent swarm, a tool call), a one-time 💡 tip teaches its key + a sub-agent swarm, a tool call), a one-time tip teaches its key and dwells 8s (5s longer than info traces), then stays silent for the run. Features surface the moment they matter; no keybinding table required. @@ -389,7 +396,7 @@ own front-end settings are separate; see [Configuration](#configuration). set the terminal window title (`✓ done — ` / `⚠ approval needed — `) and ring the bell (`--bel=false` mutes); `--notify` adds OSC 9 desktop notifications. Fires only on terminal states — never per token. -- **Sandbox aware** — the header shows `🛡 sandboxed` or `⚠ host access`; +- **Sandbox aware** — the header shows `◆ sandboxed` or `▲ host access`; pass `--sandbox` to run tool calls inside odek's Docker isolation. - **Wake turns** — when a background job finishes while the session is idle (odek ≥ v1.40), the engine wakes the model on its own; bodek opens diff --git a/internal/client/answer_events_test.go b/internal/client/answer_events_test.go new file mode 100644 index 0000000..12f2a04 --- /dev/null +++ b/internal/client/answer_events_test.go @@ -0,0 +1,74 @@ +package client + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + ws "golang.org/x/net/websocket" +) + +func TestDecodeAnswerSupersededAndVerified(t *testing.T) { + var ev Event + if err := json.Unmarshal([]byte(`{"type":"answer_superseded","reason":"verify_retry","cycle":2,"turn_id":"t_1"}`), &ev); err != nil { + t.Fatal(err) + } + if ev.Type != "answer_superseded" || ev.Reason != "verify_retry" || ev.Cycle != 2 { + t.Fatalf("bad answer_superseded decode: %+v", ev) + } + ev = Event{} + if err := json.Unmarshal([]byte(`{"type":"done","verified":"fail"}`), &ev); err != nil { + t.Fatal(err) + } + if ev.Verified != "fail" { + t.Fatalf("done.verified = %q, want fail", ev.Verified) + } +} + +func TestDecodeRuntimeFrame(t *testing.T) { + ev, ok := decodeRuntimeFrame([]byte(`{"type":"runtime_event","turn_id":"t_1","event":{"schema":"odek.event/v1","type":"verification_completed","data":{"verdict":"fail","cycles_used":1}}}`)) + if !ok || ev.Type != "runtime_event" || ev.TurnID != "t_1" || ev.Runtime == nil { + t.Fatalf("bad runtime decode: %+v ok=%v", ev, ok) + } + if ev.Runtime.Type != "verification_completed" || ev.Runtime.Data["verdict"] != "fail" { + t.Fatalf("bad runtime payload: %+v", ev.Runtime) + } + for _, bad := range []string{ + `{"type":"subagent_log","event":{"x":1}}`, // object event on a non-runtime frame stays malformed + `{"type":"runtime_event"}`, // no record + `not json`, + } { + if _, ok := decodeRuntimeFrame([]byte(bad)); ok { + t.Errorf("decodeRuntimeFrame(%s) accepted a malformed frame", bad) + } + } +} + +// A runtime_event frame used to fail the Event decode (its object "event" +// key collides with the string SubType) and was silently dropped. +func TestRuntimeEventFrameReachesEvents(t *testing.T) { + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + _ = ws.Message.Send(c, `{"type":"runtime_event","event":{"type":"verification_started"}}`) + _ = ws.Message.Send(c, `{"type":"subagent_log","event":"spawned","name":"SA1"}`) + time.Sleep(200 * time.Millisecond) + })) + cl, _ := newTestServer(t, mux) + for i, want := range []string{"runtime_event", "subagent_log"} { + select { + case ev := <-cl.Events: + if ev.Type != want { + t.Fatalf("event %d = %q, want %q", i, ev.Type, want) + } + if want == "runtime_event" && (ev.Runtime == nil || ev.Runtime.Type != "verification_started") { + t.Fatalf("runtime payload = %+v", ev.Runtime) + } + if want == "subagent_log" && ev.SubType != "spawned" { + t.Fatalf("subagent_log SubType = %q", ev.SubType) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for %s", want) + } + } +} diff --git a/internal/client/client.go b/internal/client/client.go index ec958e3..10a797b 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -32,6 +32,22 @@ type Event struct { // token / thinking Content string `json:"content"` + // answer_superseded (odek ≥ v2.33): the reply streamed since the last + // tool call was a draft and the next reply replaces it. Reason is + // completion_nudge | verify_retry; Cycle counts re-asks of that kind + // within the run, from 1. + Reason string `json:"reason,omitempty"` + Cycle int `json:"cycle,omitempty"` + + // done (odek ≥ v2.33): the final answer's verification outcome — pass | + // fail | uncertain | skipped; absent when verification did not run. + Verified string `json:"verified,omitempty"` + + // runtime_event: the odek.event/v1 record. Decoded separately because + // the frame's "event" key is an object, while sub-agent frames carry a + // string there (SubType). + Runtime *RuntimeEvent `json:"-"` + // error Message string `json:"message"` @@ -214,6 +230,21 @@ type ResultDenial struct { Reason string `json:"reason"` } +// decodeRuntimeFrame decodes a runtime_event frame, whose object-valued +// "event" key cannot unmarshal into Event.SubType. Any other frame that +// failed the plain decode stays malformed. +func decodeRuntimeFrame(data []byte) (Event, bool) { + var f struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Event *RuntimeEvent `json:"event"` + } + if err := json.Unmarshal(data, &f); err != nil || f.Type != "runtime_event" || f.Event == nil { + return Event{}, false + } + return Event{Type: f.Type, TurnID: f.TurnID, Runtime: f.Event}, true +} + // EventDisconnected is a synthetic Type emitted on the Events channel when the // socket closes, so the TUI can react instead of hanging. const EventDisconnected = "_disconnected" @@ -429,7 +460,11 @@ func (c *Client) readLoop() { _ = c.conn.SetReadDeadline(time.Time{}) // received: drop the deadline while decoding var ev Event if err := json.Unmarshal(data, &ev); err != nil { - continue // ignore malformed frames + rt, ok := decodeRuntimeFrame(data) + if !ok { + continue // ignore malformed frames + } + ev = rt } mu.Lock() if ev.Type == "thinking_delta" { diff --git a/internal/client/rest.go b/internal/client/rest.go index ffa86a4..41e878b 100644 --- a/internal/client/rest.go +++ b/internal/client/rest.go @@ -18,6 +18,11 @@ type SessionMessage struct { ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []SessionToolCall `json:"tool_calls,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + // Superseded marks a draft final answer the loop replaced by re-asking + // the model (odek ≥ v2.33); SupersededReason is completion_nudge | + // verify_retry. + Superseded bool `json:"superseded,omitempty"` + SupersededReason string `json:"superseded_reason,omitempty"` } // SessionToolCall is one persisted tool invocation (OpenAI wire format). diff --git a/internal/tui/approval_card_polish_test.go b/internal/tui/approval_card_polish_test.go new file mode 100644 index 0000000..a239c59 --- /dev/null +++ b/internal/tui/approval_card_polish_test.go @@ -0,0 +1,129 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "github.com/BackendStack21/bodek/internal/client" +) + +// approvalCardRows returns the card body rows as plain text. +func approvalCardRows(m *Model) []string { + return strings.Split(plain(m.approvalBody()), "\n") +} + +func rowContaining(rows []string, want string) (string, bool) { + for _, r := range rows { + if strings.Contains(r, want) { + return r, true + } + } + return "", false +} + +// TestApprovalActionRowListsDecisions checks the in-card decision row keeps +// allow-once and deny at both narrow and wide widths, and offers trust only +// when the server allows it and the row has room. +func TestApprovalActionRowListsDecisions(t *testing.T) { + for _, width := range []int{40, 100} { + m := newTestModel() + m.resize(width, 30) + busyTurn(m) + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Name: "shell", + Risk: "shell_exec", Command: "rm -rf build/", Description: "clean build", AllowTrust: true}) + row, ok := rowContaining(approvalCardRows(m), "allow once") + if !ok { + t.Fatalf("%d cols: card has no action row:\n%s", width, plain(m.approvalBody())) + } + if !strings.Contains(row, "d deny") { + t.Fatalf("%d cols: action row lost deny: %q", width, row) + } + if width == 100 && !strings.Contains(row, "t trust class") { + t.Fatalf("100 cols: trust offered but missing from action row: %q", row) + } + if width == 40 && strings.Contains(row, "trust") { + t.Fatalf("40 cols: trust must drop before allow-once or deny: %q", row) + } + if lipgloss.Width(row) > m.cardInner() { + t.Fatalf("%d cols: action row %d cells exceeds card inner %d: %q", width, lipgloss.Width(row), m.cardInner(), row) + } + } +} + +// TestApprovalActionRowFrictionPath checks friction cards advertise the typed +// confirmation path and Alt+D rather than trust. +func TestApprovalActionRowFrictionPath(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + busyTurn(m) + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Name: "shell", + Risk: "shell_exec", Command: "make", AllowTrust: true, Friction: true, FrictionApprovals: 3}) + // Before the editor opens, 'a' starts the two-step confirm — the row must + // not claim typing alone approves. + row, ok := rowContaining(approvalCardRows(m), "confirm → type approve") + if !ok { + t.Fatalf("friction card has no confirm row:\n%s", plain(m.approvalBody())) + } + if !strings.Contains(row, "d deny") || strings.Contains(row, "trust") { + t.Fatalf("friction action row = %q, want d deny and no trust", row) + } + m.apprEditing = true + row, ok = rowContaining(approvalCardRows(m), "type approve + ⏎") + if !ok { + t.Fatalf("friction editor has no typed-confirmation row:\n%s", plain(m.approvalBody())) + } + if !strings.Contains(row, "Alt+D deny") || strings.Contains(row, "trust") { + t.Fatalf("friction editor row = %q, want Alt+D deny and no trust", row) + } +} + +// TestApprovalReasonHiddenWhenRepeated checks a reason that only restates the +// tool name or the Action text is dropped, while a distinct reason stays. +func TestApprovalReasonHiddenWhenRepeated(t *testing.T) { + cases := []struct { + name, reason string + expanded bool + wantShown bool + }{ + {"equals tool name", "shell", false, false}, + {"equals tool name case-insensitively", "SHELL", false, false}, + {"equals action text", "shell commands", false, false}, + {"distinct reason shown", "clean build artifacts", false, true}, + {"equals tool name expanded", "shell", true, false}, + {"distinct reason expanded", "clean build artifacts", true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Name: "shell", + Risk: "shell_exec", Command: "rm -rf build/", Description: tc.reason}) + m.apprExpanded = tc.expanded + body := plain(m.approvalBody()) + if got := strings.Contains(body, "Reason:"); got != tc.wantShown { + t.Fatalf("Reason shown = %v, want %v:\n%s", got, tc.wantShown, body) + } + }) + } +} + +// TestApprovalCardFitsTerminalHeight checks the approval card, with its new +// action row, never pushes View past the terminal height. +func TestApprovalCardFitsTerminalHeight(t *testing.T) { + for _, sz := range []struct{ w, h int }{{80, 16}, {40, 12}} { + m := newTestModel() + m.resize(sz.w, sz.h) + 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()) + if rows := strings.Count(view, "\n") + 1; rows > sz.h { + t.Fatalf("%dx%d: View uses %d rows with approval pending:\n%s", sz.w, sz.h, rows, view) + } + if !strings.Contains(view, "allow once") { + t.Fatalf("%dx%d: approval action row not visible:\n%s", sz.w, sz.h, view) + } + } +} diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 7a385a6..ee82769 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -9,7 +9,8 @@ import ( ) // welcome is the empty-session home: where the agent will work, and one -// next action. Branding lives in the header; F1 / /help hold the rest. +// next action. Branding lives in the header; F1 / /help hold the rest. The +// block is top-aligned here; home() anchors it above the composer. func welcome(th theme, width int, cwd, lastSession string) string { var b strings.Builder if dir := shortenHome(cwd); dir != "" { @@ -22,8 +23,9 @@ func welcome(th theme, width int, cwd, lastSession string) string { b.WriteString(th.tipText.Render("/new starts fresh") + "\n") b.WriteByte('\n') } - b.WriteString(th.tipKey.Render("type a task") + "\n") - hints := []string{"⏎ send", "⇧⏎ newline", "^K commands"} + // One next-action tip: the composer placeholder already invites a task and + // the footer owns ^K, so the home only names what the keys do. + hints := []string{"⏎ send", "⇧⏎ newline", "@ attach a file"} inner := max(1, width-2) line := "" for _, hint := range hints { diff --git a/internal/tui/banner_test.go b/internal/tui/banner_test.go index 484c1f1..2aad629 100644 --- a/internal/tui/banner_test.go +++ b/internal/tui/banner_test.go @@ -16,9 +16,12 @@ func TestWelcomeFitsWidth(t *testing.T) { if !strings.Contains(out, "/tmp/project") { t.Errorf("welcome missing cwd:\n%s", out) } - if !strings.Contains(out, "type a task") || !strings.Contains(out, "^K") { + if !strings.Contains(out, "⏎ send") || !strings.Contains(out, "@ attach a file") { t.Errorf("welcome missing the next-action tip:\n%s", out) } + if strings.Contains(out, "type a task") || strings.Contains(out, "^K") { + t.Errorf("welcome repeats the composer placeholder or footer key:\n%s", out) + } if strings.Contains(out, "⬡ bodek") { t.Error("first-run must not repeat the header wordmark") } @@ -30,3 +33,46 @@ func TestWelcomeFitsWidth(t *testing.T) { } } } + +// TestFirstRunHomeAnchorsAboveComposer: at 100x34 the home hugs the composer +// (cwd in the lower half of the transcript, one blank row above the input +// box), and at 40x12 the full view still fits the terminal. +func TestFirstRunHomeAnchorsAboveComposer(t *testing.T) { + m := newTestModel() + m.opts.CWD = "/workspace/payments" + m.resize(100, 34) + lines := strings.Split(plain(m.View()), "\n") + if len(lines) != 34 { + t.Fatalf("view = %d rows, want 34", len(lines)) + } + cwd, tip, box := -1, -1, -1 + for i, ln := range lines { + switch { + case cwd < 0 && strings.Contains(ln, "/workspace/payments"): + cwd = i + case strings.Contains(ln, "⏎ send"): + tip = i + case box < 0 && strings.HasPrefix(strings.TrimSpace(ln), "╭"): + box = i + } + } + if cwd < 0 || tip < 0 || box < 0 { + t.Fatalf("home or composer missing (cwd=%d tip=%d box=%d):\n%s", cwd, tip, box, strings.Join(lines, "\n")) + } + if top := headerHeight + m.vp.Height/2; cwd < top { + t.Errorf("cwd on row %d, want the lower half of the transcript (row >= %d)", cwd, top) + } + if box-tip != 2 || strings.TrimSpace(lines[tip+1]) != "" { + t.Errorf("last home line at row %d, composer box at row %d: want exactly one blank row between", tip, box) + } + + m.resize(40, 12) + if got := viewRows(m); got != 12 { + t.Errorf("40x12 view = %d rows, want 12", got) + } + for i, ln := range strings.Split(plain(m.View()), "\n") { + if w := lipgloss.Width(ln); w > 40 { + t.Errorf("40x12 line %d is %d cols: %q", i, w, ln) + } + } +} diff --git a/internal/tui/bughunt_regress_test.go b/internal/tui/bughunt_regress_test.go index 634f03c..9f0c730 100644 --- a/internal/tui/bughunt_regress_test.go +++ b/internal/tui/bughunt_regress_test.go @@ -223,7 +223,7 @@ func TestRegressDrawerDetailWidthBound(t *testing.T) { m.panel = panelJobs m.panelSel = 0 m.jobs = []client.Job{{ID: "j1", Command: "one line", Status: "running"}} - m.panelDetail = true + m.openJobDetail() if n := strings.Count(m.View(), "\n") + 1; n > 20 { t.Fatalf("baseline detail view already %d rows", n) } diff --git a/internal/tui/chrome.go b/internal/tui/chrome.go index 10b9497..1f69e22 100644 --- a/internal/tui/chrome.go +++ b/internal/tui/chrome.go @@ -194,6 +194,8 @@ func (m *Model) modeName() string { return "cockpit" case m.find.open: return "find" + case m.ac.open && m.ac.mode == acCmd: + return "commands" case m.ac.open: return "attach" case m.qfocus: @@ -225,7 +227,7 @@ func panelModeName(p panelMode) string { } func (m *Model) modePrefix() string { - return " " + m.th.footerKey.Render(m.modeName()) + m.th.footerSep.Render(" · ") + return " " + m.th.footerKey.Render(m.modeName()) } // ── session home ──────────────────────────────────────────────────────────── @@ -272,7 +274,20 @@ func (m *Model) home() string { if m.homePrompt != "" || (m.sessionID != "" && m.lastPrompt != "") { return m.sessionHome() } - return welcome(m.th, m.cardSpan(), m.opts.CWD, m.resumeTitle) + return anchorAboveComposer(welcome(m.th, m.cardSpan(), m.opts.CWD, m.resumeTitle), m.vp.Height) +} + +// anchorAboveComposer bottom-anchors a home block in the transcript viewport +// of the given height: the block's last line sits one blank row above the +// composer. A viewport too short for that keeps the top placement, and a +// block taller than the viewport is left for the viewport to clip. +func anchorAboveComposer(block string, height int) string { + lines := lineCount(block) + 1 + pad := height - lines + if pad <= 0 { + return block + } + return strings.Repeat("\n", pad) + block } func (m *Model) sessionHome() string { diff --git a/internal/tui/chrome_test.go b/internal/tui/chrome_test.go index a4839fc..e297b27 100644 --- a/internal/tui/chrome_test.go +++ b/internal/tui/chrome_test.go @@ -29,7 +29,7 @@ func TestDrawerSheetKeepsTranscript(t *testing.T) { t.Errorf("view = %d rows, terminal = %d", viewRows(m), m.height) } out := plain(m.View()) - if !strings.Contains(out, "type a task") && !strings.Contains(out, "bodek") { + if !strings.Contains(out, "⏎ send") && !strings.Contains(out, "bodek") { t.Errorf("transcript home missing above the sheet:\n%s", out) } if !strings.Contains(strings.ToLower(out), "queue") { @@ -149,7 +149,7 @@ func TestSessionHomeAfterClear(t *testing.T) { if strings.Contains(out, "fix the flaky test") { t.Errorf("/new must drop the session home:\n%s", out) } - if !strings.Contains(out, "type a task") { + if !strings.Contains(out, "⏎ send") { t.Errorf("fresh session should show first-run home:\n%s", out) } } diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index e093907..0f390bb 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -296,7 +296,7 @@ func (m *Model) focusedCopyText() string { } msg := m.msgs[idx] for i := len(msg.items) - 1; i >= 0; i-- { - if msg.items[i].thinking && msg.items[i].open && msg.items[i].text != "" { + if msg.items[i].foldable() && msg.items[i].open { return sanitize(msg.items[i].text) } } @@ -321,7 +321,7 @@ func (m *Model) currentCopySpan() copySpan { return copySpan{} } for i := len(msg.items) - 1; i >= 0; i-- { - if msg.items[i].thinking && msg.items[i].open { + if msg.items[i].foldable() && msg.items[i].open { return copySpan{msgIdx: idx, kind: copyThink, idx: i, set: true} } } diff --git a/internal/tui/cockpit.go b/internal/tui/cockpit.go index 7379e1b..bb2a3e2 100644 --- a/internal/tui/cockpit.go +++ b/internal/tui/cockpit.go @@ -85,7 +85,8 @@ func (m *Model) handlePopoverKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// popoverView renders the cockpit card sized to the transcript area. +// popoverView renders the cockpit card sized to the transcript area. The card +// hugs its content; h is only a maximum, past which the body scrolls. func (m *Model) popoverView(w, h int) string { th := m.th var b strings.Builder @@ -94,16 +95,24 @@ func (m *Model) popoverView(w, h int) string { // term-4 of text — the rule fills it so the right edge stays flush. b.WriteString("\n" + th.rule.Render(strings.Repeat("─", boxInner(w)))) - b.WriteString("\n" + m.cockpitServerSection()) - b.WriteString("\n" + m.cockpitBudgetSection()) + // Every label/value section shares one label column so values line up + // across the server, budget, and lifetime cards. + sections := []cockpitSection{ + {"server", m.cockpitServerRows()}, + {"budget", m.cockpitBudgetRows()}, + } if m.usageSnap != nil { - b.WriteString("\n" + m.cockpitLifetimeSection()) + sections = append(sections, cockpitSection{"lifetime", m.cockpitLifetimeRows()}) + } + gutter := cockpitGutter(sections) + for _, s := range sections { + b.WriteString("\n" + m.cockpitRows(s.title, s.rows, gutter)) } // The session section renders un-boxed — no nested card inside a card. - b.WriteString("\n\n" + m.statsBody()) + b.WriteString("\n\n" + m.sessionBlock(true, gutter+cockpitValueOffset)) // Window the card to the transcript area (the box height is only a - // minimum): the hint row stays pinned, the rest scrolls. + // maximum): the hint row stays pinned, the rest scrolls. lines := strings.Split(b.String(), "\n") visible := max(h-4, 1) m.popScroll = min(max(m.popScroll, 0), max(len(lines)-visible, 0)) @@ -112,17 +121,27 @@ func (m *Model) popoverView(w, h int) string { } lines = append(lines, "", th.acDetail.Render("r refresh · esc close")) - return th.acBox.Width(boxWidth(w)).Height(h - 2).MaxHeight(h).Render(strings.Join(lines, "\n")) + return th.acBox.Width(boxWidth(w)).MaxHeight(h).Render(strings.Join(lines, "\n")) } // popoverPage is the half-page step for the cockpit card. func popoverPage(h int) int { return max((h-4)/2, 1) } -// cockpitServerSection is the server/link card: identity and liveness from -// the server_info/pong snapshot plus the heartbeat round-trip. -func (m *Model) cockpitServerSection() string { - // The engine version row lives in the session stats sheet below (⬢ engine) - // — rendering it here too duplicated it inside the same cockpit. +// cockpitSection is one titled label→value card inside the cockpit. +type cockpitSection struct { + title string + rows [][2]string +} + +// cockpitValueOffset is the distance from a section's label column to its +// value column: the two-space indent plus the gutter space. +const cockpitValueOffset = 3 + +// cockpitServerRows is the server/link card: identity and liveness from the +// server_info/pong snapshot plus the heartbeat round-trip. +func (m *Model) cockpitServerRows() [][2]string { + // The engine version row lives in the session block below (⬢ engine) — + // rendering it here too duplicated it inside the same cockpit. rows := [][2]string{ {"model", orDash(m.model)}, {"stream", boolDash(m.serverStream, "» live deltas", "buffered")}, @@ -143,12 +162,12 @@ func (m *Model) cockpitServerSection() string { if m.healthSnap != nil && !m.healthSnap.StartedAt.IsZero() { rows = append(rows, [2]string{"since", m.healthSnap.StartedAt.Format("Jan 2 15:04")}) } - return m.cockpitRows("server", rows) + return rows } -// cockpitBudgetSection is the budget card: the server's configured execution +// cockpitBudgetRows is the budget card: the server's configured execution // caps and this session's spend against them. -func (m *Model) cockpitBudgetSection() string { +func (m *Model) cockpitBudgetRows() [][2]string { l := m.limits inPrice, outPrice := m.prices() rows := [][2]string{} @@ -166,13 +185,13 @@ func (m *Model) cockpitBudgetSection() string { rows = append(rows, [2]string{"prices", fmt.Sprintf("$%.2f in · $%.2f out /M", inPrice, outPrice)}) } if len(rows) == 0 { - return m.cockpitRows("budget", [][2]string{{"caps", "none configured"}}) + return [][2]string{{"caps", "none configured"}} } - return m.cockpitRows("budget", rows) + return rows } -// cockpitLifetimeSection is the server-lifetime card from /api/usage. -func (m *Model) cockpitLifetimeSection() string { +// cockpitLifetimeRows is the server-lifetime card from /api/usage. +func (m *Model) cockpitLifetimeRows() [][2]string { u := m.usageSnap rows := [][2]string{ {"prompts", fmt.Sprintf("%d started · %d completed", u.PromptsStarted, u.PromptsCompleted)}, @@ -190,18 +209,25 @@ func (m *Model) cockpitLifetimeSection() string { if u.RunsActive > 0 { rows = append(rows, [2]string{"active runs", fmt.Sprintf("%d", u.RunsActive)}) } - return m.cockpitRows("lifetime", rows) + return rows } -// cockpitRows renders one titled section as aligned label→value rows. -func (m *Model) cockpitRows(title string, rows [][2]string) string { - th := m.th +// cockpitGutter is the widest label across every section: the shared label +// column that keeps values aligned from card to card. +func cockpitGutter(sections []cockpitSection) int { gutter := 0 - for _, r := range rows { - if w := lipgloss.Width(r[0]); w > gutter { - gutter = w + for _, s := range sections { + for _, r := range s.rows { + gutter = max(gutter, lipgloss.Width(r[0])) } } + return gutter +} + +// cockpitRows renders one titled section as label→value rows, padding every +// label to the shared gutter. +func (m *Model) cockpitRows(title string, rows [][2]string, gutter int) string { + th := m.th var b strings.Builder b.WriteString(th.statsLabel.Render(title)) for _, r := range rows { diff --git a/internal/tui/cockpit_test.go b/internal/tui/cockpit_test.go index a4b4262..2909cef 100644 --- a/internal/tui/cockpit_test.go +++ b/internal/tui/cockpit_test.go @@ -6,6 +6,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) // TestCockpitPopover verifies /server (and a header click) open the cockpit, @@ -59,11 +60,69 @@ func TestCockpitPopover(t *testing.T) { // configured" instead of empty rows. func TestCockpitBudgetEmpty(t *testing.T) { m := newTestModel() - if out := plain(m.cockpitBudgetSection()); !strings.Contains(out, "none configured") { + if out := plain(m.cockpitRows("budget", m.cockpitBudgetRows(), 0)); !strings.Contains(out, "none configured") { t.Errorf("empty budget card = %q", out) } } +// TestCockpitHugsContent verifies a sparse cockpit renders only the rows it +// has: the card no longer pads to the full transcript height, and the +// session block no longer repeats the sandbox state as a lone row. +func TestCockpitHugsContent(t *testing.T) { + m := newTestModel() + m.sandbox = true + m.model = "deepseek-v4-flash" + m.odekVersion = "v2.14.0" + m.popover = true + + const avail = 34 + card := plain(m.popoverView(100, avail)) + lines := strings.Split(card, "\n") + if len(lines) >= avail { + t.Errorf("cockpit rendered %d lines, want fewer than the %d available:\n%s", len(lines), avail, card) + } + if n := strings.Count(card, "sandboxed"); n != 0 { + t.Errorf("cockpit repeats the sandbox state %d times, want none:\n%s", n, card) + } + if n := strings.Count(card, "v2.14.0"); n != 1 { + t.Errorf("engine version rendered %d times, want 1:\n%s", n, card) + } + // The full view keeps the header's single sandbox badge and nothing more. + if n := strings.Count(plain(m.View()), "sandboxed"); n > 1 { + t.Errorf("full view shows sandbox state %d times, want at most the header's:\n%s", n, plain(m.View())) + } +} + +// TestCockpitValuesAlign verifies every label/value row in the cockpit puts +// its value in one shared column, including the session block's engine row. +func TestCockpitValuesAlign(t *testing.T) { + m := newTestModel() + m.sandbox = true + m.model = "deepseek-v4-flash" + m.odekVersion = "v2.14.0" + m.popover = true + m.serverStream = true + m.limits.MaxCostUSD = 0.5 + m.usageSnap = nil + + col := func(needle string) int { + t.Helper() + for _, line := range strings.Split(plain(m.popoverView(100, 34)), "\n") { + if i := strings.Index(line, needle); i >= 0 { + return lipgloss.Width(line[:i]) + } + } + t.Fatalf("cockpit has no row containing %q", needle) + return -1 + } + want := col("deepseek-v4-flash") + for _, needle := range []string{"» live deltas", "isolated", "v2.14.0"} { + if got := col(needle); got != want { + t.Errorf("value %q at column %d, want the shared column %d", needle, got, want) + } + } +} + // TestCockpitRefresh verifies r inside the popover re-fires the live // health+usage fetch — the cockpit is not a one-shot snapshot. func TestCockpitRefresh(t *testing.T) { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 3428431..543ad11 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -328,47 +328,126 @@ func (m *Model) buildHelpCard() string { b.WriteString("\n" + th.tipKey.Render(padRight("/"+c.name, cmdW)) + " " + th.tipText.Render(c.desc)) } b.WriteString(rule) - b.WriteString("\n" + th.statsLabel.Render("keys")) - const keyW = 8 // clears the widest chord in the table (alt+↑↓) - for _, k := range [][2]string{ - {"⏎", "send · queue mid-turn · run a /command"}, - {"⇧⏎", "newline in the input"}, - {"@", "attach files"}, - {"↑↓", "scroll the transcript"}, - {"alt+↑↓", "jump to the previous/next turn"}, - {"alt+y", "copy the focused surface (reply, step, or reasoning)"}, - {"alt+i", "copy the inspected tool invocation"}, - {"alt+m", "mark a copy span · alt+y yanks from the mark"}, - {"^Y", "copy the latest reply"}, - {"alt+r", "re-send the last prompt (/retry)"}, - {"^F", "fold/unfold the latest turn card"}, - {"↑↓ (inspecting)", "focus the previous/next item"}, - {"Pg↑↓", "page the transcript"}, - {"^P^N", "recall prompts"}, - {"^G", "jump to the latest output"}, - {"^R", "browse & resume sessions"}, - {"^Q", "unfold the queue strip (full manager: /queue)"}, - {"^O", "switch model"}, - {"^K", "command palette"}, - {"^T", "cycle reasoning depth"}, - {"^S", "stop the running sub-agent"}, - {"^X", "arm turn cancellation from any state (y confirms)"}, - {"^L", "clear the conversation"}, - {"^E", "toggle details (reasoning + tool invocation/result)"}, - {"alt+f", "find in the transcript"}, - {"esc", "close overlay · cancel the running turn (y confirms)"}, - {"/server", "cockpit — server, link, budget, session"}, - {"F1", "this help card"}, - {"^C", "quit"}, - {"wheel", "scroll · click a reply to copy · tool rows & turn heads"}, - } { - b.WriteString("\n" + th.tipKey.Render(padRight(k[0], keyW)) + " " + th.tipText.Render(k[1])) - } + b.WriteString("\n" + m.helpKeyBlock(innerW)) card := th.acBox.Width(m.cardWidth()).Render(b.String()) return card } +// helpKeyW clears the widest chord in the key table (alt+↑↓). +const helpKeyW = 8 + +// helpColMin is the terminal width at which the key groups sit side by side; +// narrower terminals keep one column. +const helpColMin = 100 + +// helpColGap separates the two key columns. +const helpColGap = 4 + +// helpGroup is one titled section of the /help key table. +type helpGroup struct { + title string + rows [][2]string +} + +// helpKeyGroups is the /help key table, grouped by intent. Every binding must +// stay on the card; grouping and column placement only decide where it sits. +func helpKeyGroups() []helpGroup { + return []helpGroup{ + {"compose", [][2]string{ + {"⏎", "send · queue mid-turn · run a /command"}, + {"⇧⏎", "newline in the input"}, + {"@", "attach files"}, + {"^P^N", "recall prompts"}, + {"alt+r", "re-send the last prompt (/retry)"}, + }}, + {"navigate", [][2]string{ + {"↑↓", "scroll the transcript"}, + {"alt+↑↓", "jump to the previous/next turn"}, + {"Pg↑↓", "page the transcript"}, + {"^G", "jump to the latest output"}, + {"^F", "fold/unfold the latest turn card"}, + {"wheel", "scroll · click a reply to copy · tool rows & turn heads"}, + }}, + {"inspect & copy", [][2]string{ + {"alt+y", "copy the focused surface (reply, step, or reasoning)"}, + {"alt+i", "copy the inspected tool invocation"}, + {"alt+m", "mark a copy span · alt+y yanks from the mark"}, + {"^Y", "copy the latest reply"}, + {"↑↓ (inspecting)", "focus the previous/next item"}, + {"^E", "toggle details (reasoning + tool invocation/result)"}, + {"alt+f", "find in the transcript"}, + }}, + {"session", [][2]string{ + {"^R", "browse & resume sessions"}, + {"^Q", "unfold the queue strip (full manager: /queue)"}, + {"^O", "switch model"}, + {"^T", "cycle reasoning depth"}, + {"^S", "stop the running sub-agent"}, + {"^X", "arm turn cancellation from any state (y confirms)"}, + {"^L", "clear the conversation"}, + {"/server", "cockpit — server, link, budget, session"}, + }}, + {"general", [][2]string{ + {"^K", "command palette"}, + {"esc", "close overlay · cancel the running turn (y confirms)"}, + {"F1", "this help card"}, + {"^C", "quit"}, + }}, + } +} + +// helpKeyBlock lays the key groups out inside innerW columns: one column +// below helpColMin, two side by side at or above it. +func (m *Model) helpKeyBlock(innerW int) string { + th := m.th + groups := helpKeyGroups() + if m.termWidth() < helpColMin { + return renderHelpGroups(th, groups, innerW) + } + colW := (innerW - helpColGap) / 2 + left := lipgloss.NewStyle().Width(colW).Render( + renderHelpGroups(th, []helpGroup{groups[0], groups[1], groups[4]}, colW)) + right := renderHelpGroups(th, groups[2:4], colW) + return lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", helpColGap), right) +} + +// renderHelpGroups stacks the groups in one column of w cells: a muted title +// per group, then its bindings, with a blank line between groups. +func renderHelpGroups(th theme, groups []helpGroup, w int) string { + var lines []string + for i, g := range groups { + if i > 0 { + lines = append(lines, "") + } + lines = append(lines, th.statsLabel.Render(g.title)) + for _, r := range g.rows { + lines = append(lines, helpRowLines(th, r, w)...) + } + } + return strings.Join(lines, "\n") +} + +// helpRowLines renders one binding within w cells. Long descriptions wrap +// under the description column, so no row is cut or pushed past its column. +func helpRowLines(th theme, row [2]string, w int) []string { + dw := w - helpKeyW - 1 + if dw < 12 { + dw = 12 + } + wrapped := strings.Split(lipgloss.NewStyle().Width(dw).Render(row[1]), "\n") + out := make([]string, 0, len(wrapped)) + for i, line := range wrapped { + line = strings.TrimRight(line, " ") + if i == 0 { + out = append(out, th.tipKey.Render(padRight(row[0], helpKeyW))+" "+th.tipText.Render(line)) + continue + } + out = append(out, strings.Repeat(" ", helpKeyW+1)+th.tipText.Render(line)) + } + return out +} + // runExport saves the current session transcript next to the user — // /export [md|json], markdown by default. The server renders the document; // the write is local, owner-only, and never overwrites. @@ -410,8 +489,14 @@ func (m *Model) showStats() { } // statsBody renders the session dashboard content without a frame — the -// cockpit embeds it directly; the /stats sheet wraps it in the drawer box. -func (m *Model) statsBody() string { +// /stats sheet wraps it in the drawer box. +func (m *Model) statsBody() string { return m.sessionBlock(false, 0) } + +// sessionBlock renders the frameless session dashboard. In cockpit mode the +// values align to valueCol (the cockpit's shared value column), and the +// sandbox row goes: the header and the server card already carry it, and a +// lone row between two rules read as padding. +func (m *Model) sessionBlock(cockpit bool, valueCol int) string { th := m.th // Same inner column as every other framed card. innerW := m.cardInner() @@ -609,6 +694,9 @@ func (m *Model) statsBody() string { } } gutter++ // one space before the value column + if cockpit && gutter < valueCol { + gutter = valueCol + } var b strings.Builder if m.panel != panelStats { @@ -631,12 +719,20 @@ func (m *Model) statsBody() string { } b.WriteString("\n" + styled + strings.Repeat(" ", pad) + r.value) } - idline := m.sandboxBadge() - if !m.sessionStart.IsZero() { - idline += th.statsDim.Render(" · started " + ago(m.sessionStart)) + if cockpit { + // No second rule and no sandbox badge: only the start time, + // when known, trails the rows. + if !m.sessionStart.IsZero() { + b.WriteString("\n" + th.statsDim.Render("started "+ago(m.sessionStart))) + } + } else { + idline := m.sandboxBadge() + if !m.sessionStart.IsZero() { + idline += th.statsDim.Render(" · started " + ago(m.sessionStart)) + } + b.WriteString("\n" + th.rule.Render(strings.Repeat("─", innerW))) + b.WriteString("\n" + idline) } - b.WriteString("\n" + th.rule.Render(strings.Repeat("─", innerW))) - b.WriteString("\n" + idline) } return b.String() diff --git a/internal/tui/composer_grow_test.go b/internal/tui/composer_grow_test.go index efe3c1f..cab338b 100644 --- a/internal/tui/composer_grow_test.go +++ b/internal/tui/composer_grow_test.go @@ -46,7 +46,7 @@ func TestUserPromptWrapsUnbrokenToken(t *testing.T) { } // ── composer: the input box must auto-fit its content ─────────────────────── -// Constants pinned as literals on purpose: the box rests at 3 rows and grows +// Constants pinned as literals on purpose: the box rests at 1 row and grows // to at most 12, and the tests must catch a silent constant change. func TestShiftEnterInsertsNewline(t *testing.T) { @@ -161,8 +161,8 @@ func TestFilterModifyOtherKeys(t *testing.T) { func TestComposerGrowsWithNewlines(t *testing.T) { m := newTestModel() - if m.ta.Height() != 3 { - t.Fatalf("initial composer height = %d, want 3", m.ta.Height()) + if m.ta.Height() != 1 { + t.Fatalf("initial composer height = %d, want 1", m.ta.Height()) } for i := 0; i < 6; i++ { m.handleKey(key("ctrl+j")) @@ -180,6 +180,35 @@ func TestComposerGrowsWithNewlines(t *testing.T) { } } +// The resting composer is one text row (3 rows with its border), not the old +// 3-row box (5 with border), so the transcript gets the two rows back. Growth +// then adds one row per line from there. +func TestComposerRestsAtOneRowAndGrows(t *testing.T) { + m := newTestModel() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + boxRows := func() int { + return lipgloss.Height(m.th.inputBox.Width(m.cardWidth()).Render(m.ta.View())) + } + + if got := boxRows(); got != 3 { + t.Fatalf("empty composer box = %d rows, want 3 (border + 1 + border)", got) + } + // 30 - header(2) - footer(1) - box(3) = 24; the old 5-row box gave 22. + if m.vp.Height != 24 { + t.Fatalf("viewport at rest = %d rows, want 24", m.vp.Height) + } + + m.handleKey(key("a")) + m.handleKey(key("shift+enter")) + m.handleKey(key("b")) + if got := boxRows(); got != 4 { + t.Fatalf("two-line composer box = %d rows, want 4", got) + } + if m.vp.Height != 23 { + t.Fatalf("viewport with two lines = %d rows, want 23", m.vp.Height) + } +} + func TestComposerGrowsForLongLine(t *testing.T) { m := newTestModel() // 300 cells in a 96-cell box → ceil(300/96) = 4 wrapped rows. @@ -221,8 +250,8 @@ func TestComposerShrinksOnHistoryRecall(t *testing.T) { } m.history = []string{"short prompt"} m.historyPrev() - if m.ta.Height() != 3 { - t.Fatalf("composer height after history recall = %d, want 3", m.ta.Height()) + if m.ta.Height() != 1 { + t.Fatalf("composer height after history recall = %d, want 1", m.ta.Height()) } } @@ -235,8 +264,8 @@ func TestComposerResetsAfterQueueSubmit(t *testing.T) { t.Fatalf("precondition: composer height = %d, want 11", m.ta.Height()) } m.submit() - if m.ta.Height() != 3 { - t.Fatalf("composer height after queued submit = %d, want 3", m.ta.Height()) + if m.ta.Height() != 1 { + t.Fatalf("composer height after queued submit = %d, want 1", m.ta.Height()) } if m.ta.Value() != "" { t.Fatalf("composer not cleared after queued submit: %q", m.ta.Value()) diff --git a/internal/tui/cosmetics_narrow_test.go b/internal/tui/cosmetics_narrow_test.go index b5b6ef6..10cb2b7 100644 --- a/internal/tui/cosmetics_narrow_test.go +++ b/internal/tui/cosmetics_narrow_test.go @@ -16,7 +16,7 @@ func TestWelcomeTipFitsNarrowTerminal(t *testing.T) { t.Errorf("welcome line %d is %d cols (>80): %q", i+1, w, line) } } - if !strings.Contains(out, "^K commands") { - t.Errorf("narrow welcome lost the command discovery:\n%s", out) + if !strings.Contains(out, "⏎ send · ⇧⏎ newline · @ attach a file") { + t.Errorf("narrow welcome tip wrapped or lost an action:\n%s", out) } } diff --git a/internal/tui/cosmetics_test.go b/internal/tui/cosmetics_test.go index e44a9fa..38017f1 100644 --- a/internal/tui/cosmetics_test.go +++ b/internal/tui/cosmetics_test.go @@ -15,14 +15,14 @@ import ( // core composer interactions and command discovery. func TestWelcomeTipSignpostsCoreInteractions(t *testing.T) { out := plain(welcome(newTheme(), 120, "/somewhere", "")) - for _, want := range []string{"⏎ send", "⇧⏎ newline", "^K commands"} { + for _, want := range []string{"⏎ send", "⇧⏎ newline", "@ attach a file"} { if !strings.Contains(out, want) { t.Errorf("welcome tip missing %q:\n%s", want, out) } } - // the ^K microcopy reads as a verb, not jargon. - if !strings.Contains(out, "^K commands") { - t.Errorf("welcome tip should identify the command palette:\n%s", out) + // The footer owns ^K; the home must not repeat it. + if strings.Contains(out, "^K") { + t.Errorf("welcome tip repeats the footer's ^K:\n%s", out) } if strings.Contains(out, "^K everything") { t.Errorf("welcome tip still carries the cryptic '^K everything':\n%s", out) diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index bb5a02f..9bc8d3a 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -19,12 +19,14 @@ import ( // tabBar renders the drawer's tab strip after the panel title: the active // tab in accent, the rest muted, with the digit shortcuts taught inline. -// When the strip doesn't fit the given width, it collapses to the -// active tab behind an ellipsis — cycling and digits still reach the rest. +// When the strip doesn't fit the given width, it collapses to a position +// readout ("tab 7/10") — the title already names the active tab, and cycling +// and digits still reach the rest. func (m *Model) tabBar(maxw int) string { var parts []string - active := "" - for i, t := range drawerTabs() { + active, pos := "", 0 + tabs := drawerTabs() + for i, t := range tabs { // The tenth tab teaches "0" — the strip renders the shortcut the // digit jump actually accepts, never a "10" no key can type. shot := fmt.Sprintf("%d", i+1) @@ -34,6 +36,7 @@ func (m *Model) tabBar(maxw int) string { label := fmt.Sprintf("%s %s", shot, t.name) if t.mode == m.panel { active = m.th.acSel.Render(label) + pos = i + 1 parts = append(parts, active) } else { parts = append(parts, m.th.acDetail.Render(label)) @@ -42,7 +45,7 @@ func (m *Model) tabBar(maxw int) string { sep := m.th.footerSep.Render(" · ") full := " " + strings.Join(parts, sep) if maxw > 0 && lipgloss.Width(full) > maxw { - return " " + m.th.acDetail.Render("…") + sep + active + return " " + m.th.acDetail.Render(fmt.Sprintf("tab %d/%d", pos, len(tabs))) } return full } diff --git a/internal/tui/drawer_test.go b/internal/tui/drawer_test.go index 99073f9..816dfb5 100644 --- a/internal/tui/drawer_test.go +++ b/internal/tui/drawer_test.go @@ -14,7 +14,7 @@ import ( func TestDrawerRunsTab(t *testing.T) { m := wired(t) // Ten tabs: widen the window so the full tab strip renders (it collapses - // behind an ellipsis when it doesn't fit — by design). + // to a position readout when it doesn't fit — by design). m.Update(tea.WindowSizeMsg{Width: 240, Height: 30}) m.Update(exec(m.openRuns())) if m.panel != panelRuns { @@ -221,3 +221,34 @@ func TestRunsApprovalsRefresh(t *testing.T) { t.Fatalf("approvals refresh = %+v, want ap-1", m.runs[0].PendingApprovals) } } + +// TestDrawerTitleCollapsedStrip guards the drawer title line on narrow +// terminals: the collapsed tab strip must read as a position ("tab 7/10"), +// never a bare ellipsis or a repeat of the active tab name beside the title. +// The full strip still renders when there is room. +func TestDrawerTitleCollapsedStrip(t *testing.T) { + m := newTestModel() + m.resize(80, 40) + m.runCommandLine("/memory") + narrow := plain(m.renderPanel(80, 20)) + title := strings.SplitN(narrow, "\n", 3) + if len(title) < 2 { + t.Fatalf("panel has no title line: %q", narrow) + } + line := title[1] + if strings.Contains(line, "…") { + t.Errorf("collapsed title has a bare ellipsis: %q", line) + } + if strings.Contains(line, "7 memory") { + t.Errorf("collapsed title repeats the active tab: %q", line) + } + if !strings.Contains(line, "tab 7/10") { + t.Errorf("collapsed title missing position readout: %q", line) + } + + m.resize(240, 40) + wide := plain(m.renderPanel(240, 20)) + if !strings.Contains(wide, "7 memory") || strings.Contains(wide, "tab 7/10") { + t.Errorf("wide title should render the full strip: %q", wide) + } +} diff --git a/internal/tui/events.go b/internal/tui/events.go index 0b68797..71621a8 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -176,12 +176,46 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // renders independently (appendReply keeps msg.content in sync). m.ensureWireTurn() if i := m.cur(); i >= 0 { - appendReply(&m.msgs[i], sanitize(ev.Content)) + text := sanitize(ev.Content) + // A bulk (non-streamed) final answer that failed strict + // verification arrives prefixed with odek's fixed marker; the + // turn's ✗ unverified chip carries it instead of the prose. + if rest, ok := stripVerifyMarker(text); ok { + text = rest + m.msgs[i].unverified = true + } + appendReply(&m.msgs[i], text) m.msgs[i].streaming = true } m.setRunStatus("responding") stream = true + case "answer_superseded": + // The reply streamed since the last tool call was a draft: odek is + // re-asking the model and the next reply replaces it. Fold it now + // so the turn never shows two answers. + if i := m.cur(); i >= 0 { + rest := supersedeDraft(&m.msgs[i], collapse(ev.Reason)) + if m.plain { + m.plainDraft += rest + } + m.invalidateMsgBlock(i) + } + stream = true + + case "runtime_event": + // Only the final-answer verification lifecycle has a surface; other + // runtime records (iteration, budget) live in the events tab. + if ev.Runtime != nil { + switch ev.Runtime.Type { + case "verification_started": + m.verifying = true + case "verification_completed": + m.verifying = false + } + } + stream = true + case "tool_call": arg := argPreview(ev.Data) callArgs, argsOmitted := retainToolArgs(ev.Data) @@ -309,6 +343,9 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { llmDurMs: ev.LLMDurationMs, } m.msgs[i].stats = &ts + if ev.Verified == "fail" { + m.msgs[i].unverified = true + } m.turnStats = append(m.turnStats, ts) m.toolTotal += ts.toolCount } @@ -979,6 +1016,69 @@ func appendReply(msg *message, s string) { msg.items = append(msg.items, turnItem{reply: true, text: s}) } +// verifyFailedMarker is the fixed header odek puts on a final answer that +// failed strict verification (or exhausted its corrective cycles). +const verifyFailedMarker = "[Verification failed — answer returned unverified]" + +// stripVerifyMarker removes a leading verification-failed marker, reporting +// whether one was present. +func stripVerifyMarker(s string) (string, bool) { + if !strings.HasPrefix(s, verifyFailedMarker) { + return s, false + } + return strings.TrimLeft(s[len(verifyFailedMarker):], "\n"), true +} + +// supersedeDraft folds the reply segments streamed since the last tool call +// into draft items: odek is re-asking the model and the next reply replaces +// them. msg.content is rebuilt from the surviving replies, so export, copy, +// and stats carry only the answer. Returns the draft text linear mode had +// not printed yet, and pulls its cursor back so the replacement prints whole. +func supersedeDraft(msg *message, reason string) string { + start := len(msg.items) + for start > 0 && !msg.items[start-1].isStep() { + start-- + } + found := false + for j := start; j < len(msg.items); j++ { + if it := &msg.items[j]; it.reply { + it.reply, it.draft, it.draftReason = false, true, reason + it.rendered, it.stepIdx = "", -1 + found = true + } + } + if !found { + return "" + } + old := msg.content + var parts []string + for _, it := range msg.items { + if it.reply { + parts = append(parts, it.text) + } + } + msg.content = strings.Join(parts, "\n\n") + msg.rendered = "" + unprinted := "" + if from := max(msg.plainPrinted, len(msg.content)); from < len(old) { + unprinted = strings.TrimPrefix(old[from:], "\n\n") + } + msg.plainPrinted = min(msg.plainPrinted, len(msg.content)) + return unprinted +} + +// draftReasonLabel names why odek re-asked the model, for the folded row. +func draftReasonLabel(reason string) string { + switch reason { + case "completion_nudge": + return "completion check" + case "verify_retry": + return "verification" + default: + return "re-asked" + } +} + // setTurnMarker closes out a turn with a bold status line ("**Cancelled.**", // "**Interrupted:** …", "**Error:** …"): below the existing reply when the // turn already produced prose, or as its only reply segment otherwise — the @@ -1073,6 +1173,7 @@ func (m *Model) applyCtxWindow(ev client.Event) { // the swarm verdict (when the turn delegated sub-agents) lands as the turn // marker. func (m *Model) finalize() { + m.verifying = false if i := m.cur(); i >= 0 { m.closeTurn(&m.msgs[i]) m.invalidateMsgBlock(i) diff --git a/internal/tui/find.go b/internal/tui/find.go index 4d2a77e..b745b83 100644 --- a/internal/tui/find.go +++ b/internal/tui/find.go @@ -120,7 +120,7 @@ func findMsgMatch(msg message, q string) bool { return true } for _, it := range msg.items { - if (it.thinking || it.reply) && strings.Contains(strings.ToLower(it.text), q) { + if (it.thinking || it.reply || it.draft) && strings.Contains(strings.ToLower(it.text), q) { return true } } @@ -149,7 +149,7 @@ func (m *Model) revealFindHit(msgIdx int) { } msg := &m.msgs[msgIdx] for i := range msg.items { - if msg.items[i].thinking && strings.Contains(strings.ToLower(msg.items[i].text), q) { + if msg.items[i].foldable() && strings.Contains(strings.ToLower(msg.items[i].text), q) { msg.items[i].open = true } } diff --git a/internal/tui/help_layout_test.go b/internal/tui/help_layout_test.go new file mode 100644 index 0000000..827b95b --- /dev/null +++ b/internal/tui/help_layout_test.go @@ -0,0 +1,73 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +// helpBindingKeys is every key the /help card taught before it was grouped. +// Grouping and the two-column layout must keep each one on the card. +var helpBindingKeys = []string{ + "⏎", "⇧⏎", "@", "↑↓", "alt+↑↓", "alt+y", "alt+i", "alt+m", "^Y", "alt+r", + "^F", "↑↓ (inspecting)", "Pg↑↓", "^P^N", "^G", "^R", "^Q", "^O", "^K", "^T", + "^S", "^X", "^L", "^E", "alt+f", "esc", "/server", "F1", "^C", "wheel", +} + +// helpCardAt renders the /help card on a terminal of the given width. +func helpCardAt(t *testing.T, width int) string { + t.Helper() + m := newTestModel() + m.width = width + m.showHelp() + return plain(m.msgs[len(m.msgs)-1].content) +} + +func TestHelpCardTwoColumnsWhenWide(t *testing.T) { + wide := helpCardAt(t, 120) + narrow := helpCardAt(t, 80) + + rows := func(s string) int { return strings.Count(s, "\n") + 1 } + if rows(wide) >= rows(narrow) { + t.Errorf("help card at 120 cols has %d rows, want fewer than %d at 80 cols", + rows(wide), rows(narrow)) + } + for _, card := range []string{wide, narrow} { + for _, k := range helpBindingKeys { + if !strings.Contains(card, k) { + t.Errorf("help card omits binding %q", k) + } + } + for _, h := range []string{"compose", "navigate", "inspect & copy", "session", "general"} { + if !strings.Contains(card, h) { + t.Errorf("help card omits section %q", h) + } + } + } + for _, line := range strings.Split(wide, "\n") { + if w := lipgloss.Width(line); w > 120 { + t.Errorf("wide help line is %d cells, exceeds terminal width 120: %q", w, line) + } + } + // Side by side: the first column's title and the second column's title + // share a row. + shared := false + for _, line := range strings.Split(wide, "\n") { + if strings.Contains(line, "compose") && strings.Contains(line, "inspect & copy") { + shared = true + } + } + if !shared { + t.Errorf("wide card does not place sections side by side:\n%s", wide) + } +} + +func TestHelpCardSingleColumnNarrow(t *testing.T) { + narrow := helpCardAt(t, 80) + for _, line := range strings.Split(narrow, "\n") { + if strings.Contains(line, "compose") && strings.Contains(line, "navigate") { + t.Errorf("narrow card should stack sections, found a shared row: %q", line) + } + } +} diff --git a/internal/tui/hints.go b/internal/tui/hints.go index d4acd89..fae36e2 100644 --- a/internal/tui/hints.go +++ b/internal/tui/hints.go @@ -50,7 +50,7 @@ func (m *Model) teach(key, text string) { if m.width < 24 { text = "tip: F1 help" } - label := "💡 " + text + label := text if m.width > 4 { label = truncate(label, m.width-4) // note rail adds its own prefix } diff --git a/internal/tui/hints_test.go b/internal/tui/hints_test.go index 8b8aa32..307d3ce 100644 --- a/internal/tui/hints_test.go +++ b/internal/tui/hints_test.go @@ -26,7 +26,7 @@ func countNotices(m *Model, marker string) int { // than an operational note — tests that demand silence for a state use it // to ignore the teaching layer. func isHintNote(s string) bool { - return strings.HasPrefix(s, "💡") + return strings.HasPrefix(s, "tip: ") } // liveTurnModel mirrors sendPrompt's local state without touching a client. diff --git a/internal/tui/input.go b/internal/tui/input.go index 4631234..3314865 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -342,10 +342,10 @@ func writeEnhancedKeys(seq string) { // ── composer auto-fit ─────────────────────────────────────────────────────── -// Composer growth bounds: the box rests at three rows, grows with its +// Composer growth bounds: the box rests at one row, grows with its // content, and never eats the screen (syncComposer). const ( - composerMinRows = 3 + composerMinRows = 1 composerMaxRows = 12 ) @@ -802,7 +802,7 @@ func (m *Model) dismissChrome() (bool, tea.Cmd) { } for i := len(m.msgs) - 1; i >= 0; i-- { for j := len(m.msgs[i].items) - 1; j >= 0; j-- { - if m.msgs[i].items[j].thinking && m.msgs[i].items[j].open { + if m.msgs[i].items[j].foldable() && m.msgs[i].items[j].open { m.msgs[i].items[j].open = false m.invalidateMsgBlock(i) m.refresh() diff --git a/internal/tui/inspect.go b/internal/tui/inspect.go index 5ecd336..0eb5221 100644 --- a/internal/tui/inspect.go +++ b/internal/tui/inspect.go @@ -20,9 +20,9 @@ func (m *Model) inspectTargets() []inspectTarget { } seen := make(map[int]bool) for j, item := range msg.items { - if item.thinking && strings.TrimSpace(item.text) != "" { + if item.foldable() { out = append(out, inspectTarget{i, -1, j}) - } else if !item.reply && !item.thinking && item.stepIdx >= 0 && item.stepIdx < len(msg.steps) { + } else if item.isStep() && item.stepIdx >= 0 && item.stepIdx < len(msg.steps) { out = append(out, inspectTarget{i, item.stepIdx, -1}) seen[item.stepIdx] = true } @@ -45,7 +45,7 @@ func (m *Model) validInspect() bool { if p.stepIdx >= 0 { return p.stepIdx < len(msg.steps) } - return p.itemIdx >= 0 && p.itemIdx < len(msg.items) && msg.items[p.itemIdx].thinking + return p.itemIdx >= 0 && p.itemIdx < len(msg.items) && msg.items[p.itemIdx].foldable() } // Inspection borrows the composer rows so a short terminal can show the diff --git a/internal/tui/jobs_tab.go b/internal/tui/jobs_tab.go index 5dab0a0..23b73b0 100644 --- a/internal/tui/jobs_tab.go +++ b/internal/tui/jobs_tab.go @@ -296,6 +296,7 @@ func (m *Model) openJobDetail() tea.Cmd { m.jobsOut = "" m.jobsOutCursor = 0 m.jobsOutID = m.jobs[m.panelSel].ID + m.relayout() // detail mode changes the sheet split; the viewport must follow m.refresh() return m.fetchJobOutput(0) } diff --git a/internal/tui/jobs_tab_test.go b/internal/tui/jobs_tab_test.go index ef17c20..a2112c5 100644 --- a/internal/tui/jobs_tab_test.go +++ b/internal/tui/jobs_tab_test.go @@ -125,6 +125,8 @@ func TestJobsTabDigitZeroAndRenumbering(t *testing.T) { } } // The tab strip must teach the "0" shortcut for the 10th tab, not "10". + // Widen first: a narrow strip collapses to a position readout instead. + m.Update(tea.WindowSizeMsg{Width: 240, Height: 30}) m.Update(exec(m.openConfig())) if !strings.Contains(plain(m.View()), "0 config") { t.Errorf("tab strip does not teach 0 for config:\n%s", plain(m.View())) diff --git a/internal/tui/model.go b/internal/tui/model.go index 6ebdd6a..eeb2e30 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -84,6 +84,22 @@ type turnItem struct { rendered string // cached glamour render (finalized reply segments) started time.Time // reasoning: when the block opened; zero on replay dur time.Duration // reasoning: sealed when the cycle yields; 0 while live + + // draft marks a reply the engine superseded by re-asking the model + // (answer_superseded): it folds to one row and opens like reasoning. + // draftReason is the wire reason (completion_nudge | verify_retry). + draft bool + draftReason string +} + +// isStep reports whether the item references a tool step rather than +// reasoning, a reply segment, or a superseded draft. +func (it turnItem) isStep() bool { return !it.thinking && !it.reply && !it.draft } + +// foldable reports whether the item opens on demand (Enter while +// inspecting, ^E): non-empty reasoning blocks and superseded drafts. +func (it turnItem) foldable() bool { + return (it.thinking || it.draft) && strings.TrimSpace(it.text) != "" } // turnStats is the telemetry of one finalized assistant turn, captured from the @@ -123,6 +139,7 @@ type message struct { collapsed bool // turn card folded to its head + summary line (c) systemWake bool // server-initiated turn (background-job wake): marker on the card failed bool // the run ended in error — ✗ marks the turn head (in-session state; replay does not restore it) + unverified bool // the final answer failed odek's verification (done.verified, or a persisted marker on replay) plainPrinted int // reply bytes already emitted to linear scrollback } @@ -400,10 +417,10 @@ type Model struct { disconn bool reconnAttempt int // current redial attempt index (drives the status-line backoff readout) quitting bool + verifying bool // odek's final-answer verification side call is running (status line label) + plainDraft string // plain mode: superseded draft text not yet printed to scrollback - gradRule string // cached full-width gradient rule glamWrap int // wrap width m.glam was built for; -1 forces rebuild (theme switch) - gradRuleW int logoCache string // cached gradient logo (width-independent) canvasFG, canvasBG string // cached paintCanvas probe colors for the active theme @@ -446,7 +463,7 @@ func New(cl *client.Client, opts Options) *Model { ta.Prompt = " " ta.ShowLineNumbers = false ta.CharLimit = 0 - ta.SetHeight(3) + ta.SetHeight(composerMinRows) ta.FocusedStyle.CursorLine = th.taCursorLine ta.FocusedStyle.Text = th.inputText ta.BlurredStyle.Text = th.inputText @@ -1372,7 +1389,6 @@ func (m *Model) resize(w, h int) tea.Cmd { } m.ta.SetWidth(w - 4) m.syncComposer() // the new width re-wraps content — refit the box - m.gradRule = "" // invalidate cached rule for the new width m.invalidateAllMsgBlocks() for i := range m.msgs { for j := range m.msgs[i].steps { diff --git a/internal/tui/model_smoke_test.go b/internal/tui/model_smoke_test.go index 00045fa..03e7928 100644 --- a/internal/tui/model_smoke_test.go +++ b/internal/tui/model_smoke_test.go @@ -24,7 +24,7 @@ func plain(s string) string { return ansiRe.ReplaceAllString(s, "") } // newTestModel builds a Model without a live client/TTY for rendering tests. func newTestModel() *Model { ta := textarea.New() - ta.SetHeight(3) // match New(), so inputHeight row math holds + ta.SetHeight(composerMinRows) // match New(), so inputHeight row math holds // Mirror New()'s composer configuration so previews and layout tests // render the production input box. ta.Prompt = " " diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 7900d34..578fcda 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -1150,9 +1150,21 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) { cur.items = append(cur.items, turnItem{thinking: true, text: rc}) } if c := sanitize(mm.Content); strings.TrimSpace(c) != "" { - // One reply segment per persisted assistant record — the same - // think→reply pairing live turns build from token events. - appendReply(cur, c) + switch { + case mm.Superseded: + // A draft odek replaced by re-asking: folded, like the + // live answer_superseded path. + sealThinking(cur) + cur.items = append(cur.items, turnItem{draft: true, draftReason: collapse(mm.SupersededReason), text: c, stepIdx: -1}) + default: + if rest, ok := stripVerifyMarker(c); ok { + c = rest + cur.unverified = true + } + // One reply segment per persisted assistant record — the + // same think→reply pairing live turns build from tokens. + appendReply(cur, c) + } } for _, tc := range mm.ToolCalls { name := collapse(tc.Function.Name) diff --git a/internal/tui/plain.go b/internal/tui/plain.go index a257c5e..6037194 100644 --- a/internal/tui/plain.go +++ b/internal/tui/plain.go @@ -44,6 +44,17 @@ func plainLinesCmd(lines []string) tea.Cmd { // segments keep their formatting and print before the next reasoning/tool // boundary, with only the remaining text emitted on completion or failure. func (m *Model) plainEventLines(ev client.Event) []string { + if ev.Type == "answer_superseded" { + // The draft already reached (or is about to reach) scrollback, which + // cannot be unprinted: print what is left of it, then say plainly + // that the next answer replaces it. + out := m.plainReplyLines() + if d := strings.TrimSpace(m.plainDraft); d != "" { + out = append(out, d) + } + m.plainDraft = "" + return append(out, "[draft revised · "+draftReasonLabel(collapse(ev.Reason))+"] the next answer replaces the text above") + } var reply []string switch ev.Type { case "thinking", "thinking_delta", "tool_call", "approval_request", "clarify_request", "done", "error", client.EventDisconnected: @@ -143,6 +154,14 @@ func (m *Model) plainStatusLines(ev client.Event) []string { // turnStats entry is this turn's). func (m *Model) plainDoneSummary() string { s := "✓ done" + for i := len(m.msgs) - 1; i >= 0; i-- { + if m.msgs[i].role == roleAsst { + if m.msgs[i].unverified { + s = "✗ unverified" + } + break + } + } if n := len(m.turnStats); n > 0 { ts := m.turnStats[n-1] s += fmt.Sprintf(" · %d tools · %.1fs · %d tok", ts.toolCount, ts.wall.Seconds(), ts.outTok) diff --git a/internal/tui/responsive_chrome_test.go b/internal/tui/responsive_chrome_test.go index 1052f31..7fe3438 100644 --- a/internal/tui/responsive_chrome_test.go +++ b/internal/tui/responsive_chrome_test.go @@ -35,7 +35,7 @@ func TestWelcomeFitsCellsAndKeepsKeyboardHints(t *testing.T) { t.Errorf("%d: line overflow: %q", width, line) } } - if width >= 24 && (!strings.Contains(plain(out), "⇧⏎ newline") || !strings.Contains(plain(out), "^K commands")) { + if width >= 24 && (!strings.Contains(plain(out), "⇧⏎ newline") || !strings.Contains(plain(out), "@ attach a file")) { t.Errorf("%d: missing keyboard affordances", width) } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f7ef058..be1b6ab 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -55,7 +55,8 @@ var ( accentLo: "#FF8A3D", steel: "#98AAC8", green: "#34D399", - yellow: "#FBBF24", + // Lemon, not amber: warnings must not read as brand accent. + yellow: "#FDE047", red: "#F87171", text: "#E7E9EE", muted: "#A8B0C0", @@ -79,7 +80,7 @@ var ( accentLo: "#8F5E00", steel: "#536B8B", green: "#08754F", - yellow: "#924B00", + yellow: "#B54708", // burnt orange — distinct from the olive-amber accent red: "#C22B2B", text: "#22252C", muted: "#5A5F6D", @@ -98,7 +99,7 @@ var ( accentLo: "#FFB224", steel: "#B8C4E0", green: "#4ADE80", - yellow: "#FBBF24", + yellow: "#FF9F43", // orange — the accent already owns yellow here red: "#FF6B6B", text: "#FFFFFF", muted: "#C0C0C0", @@ -245,7 +246,7 @@ func weaveSurface(s, bgSeq string) string { // Layout — fixed heights for the chrome around the scrollable transcript. const ( headerHeight = 2 // cockpit bar + hairline rule - inputHeight = 5 // composer at rest: 3 rows + top/bottom border (grows with content) + inputHeight = 3 // composer at rest: 1 row + top/bottom border (grows with content) footerHeight = 1 // status bar ) @@ -284,9 +285,11 @@ type theme struct { thinkStyle lipgloss.Style apprBox lipgloss.Style + apprBoxHi lipgloss.Style // high-risk approvals escalate the border to red apprHead lipgloss.Style apprBody lipgloss.Style apprKey lipgloss.Style + apprCode lipgloss.Style apprUrgent lipgloss.Style statusReady lipgloss.Style @@ -398,9 +401,11 @@ func themeFrom(p palette) theme { thinkStyle: lipgloss.NewStyle().Foreground(p.bodyText).Italic(true), apprBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.yellow).Padding(0, 1), + apprBoxHi: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.red).Padding(0, 1), apprHead: lipgloss.NewStyle().Foreground(p.yellow).Bold(true), apprBody: lipgloss.NewStyle().Foreground(p.text), apprKey: lipgloss.NewStyle().Foreground(p.green).Bold(true), + apprCode: codeValueStyle(p.surface, p.text), apprUrgent: lipgloss.NewStyle().Foreground(p.red).Bold(true), statusReady: lipgloss.NewStyle().Foreground(p.green), @@ -455,6 +460,17 @@ func themeFrom(p palette) theme { } } +// codeValueStyle paints an approval's command or resource value as code: the +// input text color on the card surface, so it reads as typed input rather +// than prose. An empty surface (high-contrast) leaves the background unset. +func codeValueStyle(bg, fg lipgloss.Color) lipgloss.Style { + st := lipgloss.NewStyle().Foreground(fg) + if bg != "" { + st = st.Background(bg) + } + return st +} + // surfaceStyle builds the answer card style; empty means no surface. The // padding is symmetric — one line above and below, one column each side — // because glamour's document newlines are stripped in answerGlamourStyle; diff --git a/internal/tui/subagent_cost_test.go b/internal/tui/subagent_cost_test.go index f631216..4bd1a9b 100644 --- a/internal/tui/subagent_cost_test.go +++ b/internal/tui/subagent_cost_test.go @@ -81,7 +81,7 @@ func TestStatsCardIncludesSubagentCost(t *testing.T) { func TestCockpitCapRowIncludesSubagentCost(t *testing.T) { m := subCostFixture(t) m.limits.MaxCostUSD = 0.5 - out := plain(m.cockpitBudgetSection()) + out := plain(m.cockpitRows("budget", m.cockpitBudgetRows(), 0)) if !strings.Contains(out, "$0.0335") || !strings.Contains(out, "$0.50") { t.Errorf("cap row missing sub-agent-inclusive spend:\n%s", out) } diff --git a/internal/tui/superseded_test.go b/internal/tui/superseded_test.go new file mode 100644 index 0000000..7903481 --- /dev/null +++ b/internal/tui/superseded_test.go @@ -0,0 +1,298 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// draftTurn streams a draft answer, supersedes it, then streams the +// replacement — the shape odek sends when it re-asks the model. +func draftTurn(m *Model, reason string) { + busyTurn(m) + for _, ev := range []client.Event{ + {Type: "tool_call", Name: "shell", Data: `{"command":"git log"}`}, + {Type: "tool_result", Name: "shell", Data: "abc123 fix"}, + {Type: "token", Content: "DRAFT says the branch has 14 commits."}, + {Type: "answer_superseded", Reason: reason, Cycle: 1}, + {Type: "token", Content: "FINAL says the branch has 15 commits."}, + } { + m.handleEvent(ev) + } +} + +func itemKinds(msg message) (replies, drafts int) { + for _, it := range msg.items { + if it.reply { + replies++ + } + if it.draft { + drafts++ + } + } + return replies, drafts +} + +// A superseded draft folds to one row: the turn shows a single answer, and +// msg.content (export, copy, stats) carries only the replacement. +func TestAnswerSupersededFoldsDraft(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + draftTurn(m, "completion_nudge") + m.handleEvent(client.Event{Type: "done"}) + m.refresh() + + msg := m.msgs[len(m.msgs)-1] + if r, d := itemKinds(msg); r != 1 || d != 1 { + t.Fatalf("items: %d replies, %d drafts; want 1 and 1", r, d) + } + if msg.content != "FINAL says the branch has 15 commits." { + t.Fatalf("content = %q, want only the replacement answer", msg.content) + } + view := plain(m.View()) + if !strings.Contains(view, "⋯ draft revised · completion check") { + t.Errorf("no folded draft row:\n%s", view) + } + if strings.Contains(view, "DRAFT says") { + t.Errorf("draft text painted while folded:\n%s", view) + } + if strings.Count(view, "FINAL says") != 1 { + t.Errorf("replacement answer should paint once:\n%s", view) + } + if got := foldTally(msg); got != "" && !strings.HasPrefix(got, "1 tool") { + t.Errorf("foldTally = %q, a draft must not count as a tool", got) + } +} + +// Prose before the last tool call is a progress note, not the draft — only +// the reply streamed since the last tool call folds. +func TestAnswerSupersededKeepsEarlierNotes(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + busyTurn(m) + for _, ev := range []client.Event{ + {Type: "token", Content: "Checking the log first."}, + {Type: "tool_call", Name: "shell", Data: `{"command":"git log"}`}, + {Type: "tool_result", Name: "shell", Data: "abc123 fix"}, + {Type: "token", Content: "DRAFT"}, + {Type: "answer_superseded", Reason: "verify_retry"}, + {Type: "token", Content: "FINAL"}, + } { + m.handleEvent(ev) + } + msg := m.msgs[m.cur()] + if msg.content != "Checking the log first.\n\nFINAL" { + t.Fatalf("content = %q, want the note and the replacement", msg.content) + } + if r, d := itemKinds(msg); r != 2 || d != 1 { + t.Fatalf("items: %d replies, %d drafts; want 2 and 1", r, d) + } +} + +// A draft opens like reasoning: ^E shows it, and inspection lands on it and +// expands it with Enter. +func TestDraftOpensOnDemand(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + draftTurn(m, "verify_retry") + m.handleEvent(client.Event{Type: "done"}) + + m.expandAll = true + m.invalidateAllMsgBlocks() + m.refresh() + if !strings.Contains(plain(m.View()), "DRAFT says") { + t.Fatalf("^E does not reveal the draft:\n%s", plain(m.View())) + } + m.expandAll = false + m.invalidateAllMsgBlocks() + + var target *inspectTarget + for _, p := range m.inspectTargets() { + if p.itemIdx >= 0 && m.msgs[p.msgIdx].items[p.itemIdx].draft { + p := p + target = &p + } + } + if target == nil { + t.Fatal("draft is not an inspect target") + } + m.inspect = target + if !m.validInspect() { + t.Fatal("inspecting a draft is not valid") + } + m.Update(key("enter")) + if !m.msgs[target.msgIdx].items[target.itemIdx].open { + t.Fatal("Enter did not open the inspected draft") + } + if got := m.focusedCopyText(); !strings.Contains(got, "DRAFT says") { + t.Fatalf("copying the inspected draft = %q", got) + } +} + +// done.verified == "fail" marks the turn head and its outcome row. +func TestVerifiedFailMarksTurn(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + draftTurn(m, "verify_retry") + m.handleEvent(client.Event{Type: "done", Verified: "fail"}) + m.refresh() + msg := m.msgs[len(m.msgs)-1] + if !msg.unverified { + t.Fatal("verified=fail did not mark the turn") + } + view := plain(m.View()) + if strings.Count(view, "✗ unverified") < 2 { + t.Fatalf("want the chip on the head and the outcome row:\n%s", view) + } + + m = newTestModel() + m.resize(100, 30) + draftTurn(m, "verify_retry") + m.handleEvent(client.Event{Type: "done", Verified: "pass"}) + if m.msgs[len(m.msgs)-1].unverified || strings.Contains(plain(m.View()), "unverified") { + t.Fatal("verified=pass must not mark the turn") + } +} + +// A bulk final answer carries odek's marker as text; the chip replaces it. +func TestBulkVerifyMarkerStripped(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + busyTurn(m) + m.handleEvent(client.Event{Type: "token", Content: verifyFailedMarker + "\n\nThe answer."}) + msg := m.msgs[m.cur()] + if msg.content != "The answer." || !msg.unverified { + t.Fatalf("content = %q unverified=%v; want marker stripped and flagged", msg.content, msg.unverified) + } +} + +// Verification progress rides the status line and clears with the turn. +func TestVerifyingStatusLabel(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + busyTurn(m) + m.handleEvent(client.Event{Type: "runtime_event", Runtime: &client.RuntimeEvent{Type: "verification_started"}}) + if !strings.Contains(plain(m.statusLine()), "verifying answer") { + t.Fatalf("status line = %q, want verifying answer", plain(m.statusLine())) + } + m.handleEvent(client.Event{Type: "runtime_event", Runtime: &client.RuntimeEvent{Type: "verification_completed", Data: map[string]any{"verdict": "pass"}}}) + if m.verifying { + t.Fatal("verification_completed left the label on") + } + m.handleEvent(client.Event{Type: "runtime_event", Runtime: &client.RuntimeEvent{Type: "verification_started"}}) + m.handleEvent(client.Event{Type: "done"}) + if m.verifying { + t.Fatal("done left the verifying label on") + } + // Other runtime records have no transcript surface. + m.handleEvent(client.Event{Type: "runtime_event", Runtime: &client.RuntimeEvent{Type: "iteration_completed"}}) + m.handleEvent(client.Event{Type: "runtime_event"}) +} + +// Replay folds persisted drafts and turns a persisted marker into the chip. +func TestReplayFoldsSupersededDraft(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + m.replayTranscript([]client.SessionMessage{ + {Role: "user", Content: "Describe branch changes?"}, + {Role: "assistant", Content: "DRAFT answer", Superseded: true, SupersededReason: "completion_nudge"}, + {Role: "assistant", Content: verifyFailedMarker + "\n\nFINAL answer"}, + }) + var msg message + for _, mm := range m.msgs { + if mm.role == roleAsst { + msg = mm + } + } + if r, d := itemKinds(msg); r != 1 || d != 1 { + t.Fatalf("items: %d replies, %d drafts; want 1 and 1", r, d) + } + if msg.content != "FINAL answer" || !msg.unverified { + t.Fatalf("content = %q unverified=%v", msg.content, msg.unverified) + } + m.refresh() + view := plain(m.View()) + if !strings.Contains(view, "draft revised · completion check") || strings.Contains(view, "DRAFT answer") { + t.Fatalf("replayed draft not folded:\n%s", view) + } +} + +// Linear mode cannot unprint: the draft prints once, a note says it was +// revised, and the replacement prints whole. +func TestPlainModeSupersededDraft(t *testing.T) { + m := newTestModel() + m.plain = true + m.resize(100, 30) + busyTurn(m) + var out []string + feed := func(ev client.Event) { + m.handleEvent(ev) + out = append(out, m.plainEventLines(ev)...) + } + feed(client.Event{Type: "token", Content: "DRAFT answer"}) + feed(client.Event{Type: "answer_superseded", Reason: "verify_retry"}) + feed(client.Event{Type: "token", Content: "FINAL answer"}) + feed(client.Event{Type: "done", Verified: "fail"}) + log := strings.Join(out, "\n") + for _, want := range []string{"DRAFT answer", "[draft revised · verification]", "FINAL answer", "✗ unverified"} { + if !strings.Contains(log, want) { + t.Errorf("plain log missing %q:\n%s", want, log) + } + } + if strings.Count(log, "DRAFT answer") != 1 || strings.Count(log, "FINAL answer") != 1 { + t.Errorf("each answer must print exactly once:\n%s", log) + } + if strings.Index(log, "DRAFT answer") > strings.Index(log, "[draft revised") { + t.Errorf("revision note must follow the draft:\n%s", log) + } +} + +// An answer_superseded with no reply since the last tool call (a buffered +// draft never reached the client) folds nothing and leaves content alone. +func TestAnswerSupersededWithoutDraftIsNoop(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + busyTurn(m) + m.handleEvent(client.Event{Type: "token", Content: "note"}) + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"ls"}`}) + m.handleEvent(client.Event{Type: "answer_superseded", Reason: "verify_retry"}) + msg := m.msgs[m.cur()] + if _, d := itemKinds(msg); d != 0 || msg.content != "note" { + t.Fatalf("drafts=%d content=%q; want no fold before the tool call", d, msg.content) + } +} + +// Unknown reasons still read as a revision rather than an empty label. +func TestDraftReasonLabelFallback(t *testing.T) { + if got := draftReasonLabel("future_reason"); got != "re-asked" { + t.Fatalf("label = %q, want re-asked", got) + } +} + +// A selected but folded draft teaches Enter, and an opened draft becomes +// the copy span for alt+y. +func TestDraftSelectedHintAndCopySpan(t *testing.T) { + m := newTestModel() + m.resize(100, 30) + draftTurn(m, "completion_nudge") + m.handleEvent(client.Event{Type: "done"}) + i := len(m.msgs) - 1 + j := -1 + for k, it := range m.msgs[i].items { + if it.draft { + j = k + } + } + m.inspect = &inspectTarget{msgIdx: i, stepIdx: -1, itemIdx: j} + if got := plain(m.renderDraft(m.msgs[i].items[j], j, i)); !strings.Contains(got, "› ⋯ draft revised") || !strings.Contains(got, "Enter expand") { + t.Fatalf("selected folded draft = %q", got) + } + m.inspect = nil + m.msgs[i].items[j].open = true + m.focusIdx = i + span := m.currentCopySpan() + if !span.set || span.kind != copyThink || span.idx != j { + t.Fatalf("copy span = %+v, want the open draft", span) + } +} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go index fec22d9..be8e092 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -19,7 +19,7 @@ func TestThemeVariants(t *testing.T) { if got := plain(th.logo.Render("⬡ bodek")); !strings.Contains(got, "bodek") { t.Errorf("%s: logo render broken: %q", name, got) } - if out := plain(welcome(th, 100, "/tmp", "")); !strings.Contains(out, "type a task") { + if out := plain(welcome(th, 100, "/tmp", "")); !strings.Contains(out, "⏎ send") { t.Errorf("%s: welcome card missing the next-action tip", name) } } @@ -46,7 +46,7 @@ func TestThemeSelection(t *testing.T) { // TestWelcomeTeachesNextAction guards the first-run teaching surface. func TestWelcomeTeachesNextAction(t *testing.T) { out := plain(welcome(newTheme(), 100, "/somewhere", "")) - for _, want := range []string{"type a task", "^K"} { + for _, want := range []string{"⏎ send", "@ attach a file"} { if !strings.Contains(out, want) { t.Errorf("welcome missing %q:\n%s", want, out) } diff --git a/internal/tui/view.go b/internal/tui/view.go index 9a9c33c..f9058ba 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -8,6 +8,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + + "github.com/BackendStack21/bodek/internal/client" ) // View composes the full screen: header, scrollable transcript (plus the @@ -63,7 +65,7 @@ func (m *Model) plainView() string { func (m *Model) header() string { th := m.th // The logo gradient is width-independent, so render it once and cache it - // (like gradRule) instead of re-interpolating every frame. + // instead of re-interpolating every frame. if m.logoCache == "" { m.logoCache = th.logo.Render(gradient("⬡ bodek", th.grad[0], th.grad[1])) } @@ -247,12 +249,13 @@ func (m *Model) gaugeColor(ratio float64) lipgloss.Style { } // sandboxBadge renders the agent's isolation state with the monochrome glyph -// vocabulary (width-stable, unlike emoji): a green ● when sandboxed, an amber ▲ -// when it has host access. Shared by the header and the /stats card so the two -// never drift. +// vocabulary (width-stable, unlike emoji): a green ◆ when sandboxed, an amber ▲ +// when it has host access. Never a dot — dots are the connection lamp's +// vocabulary (◉ ● ◌ ○), and two green dots in one header read as one signal. +// Shared by the header and the /stats card so the two never drift. func (m *Model) sandboxBadge() string { if m.sandbox { - return m.th.badgeOK.Render("● sandboxed") + return m.th.badgeOK.Render("◆ sandboxed") } return m.th.badgeWarn.Render("▲ host access") } @@ -290,14 +293,11 @@ func gaugeGlyph(r float64) string { return bar } -// rule returns a full-width gradient hairline, cached per width. +// rule returns the full-width header hairline. It is structure, not brand: +// a quiet hairline keeps amber reserved for the wordmark and focus, and one +// SGR span replaces a per-cell gradient on every frame. func (m *Model) rule() string { - w := max(m.width, 1) - if m.gradRule == "" || m.gradRuleW != w { - m.gradRule = gradient(strings.Repeat("─", w), m.th.grad[0], m.th.grad[1]) - m.gradRuleW = w - } - return m.gradRule + return m.th.rule.Render(strings.Repeat("─", max(m.width, 1))) } // statusBadge is the header's session-state lamp. Turn progress lives on @@ -348,6 +348,8 @@ func (m *Model) statusLine() string { } var label string switch { + case m.verifying: + label = "verifying answer" case m.lastTool != "": // Context-aware message derived from the running tool + its args. label = toolProgress(m.lastTool, m.lastArg) @@ -694,6 +696,11 @@ 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 msg.unverified { + // odek's verification rejected the shipped answer: the chip + // replaces the marker odek prepends, so the prose stays clean. + label += " " + th.badgeDanger.Render("✗ unverified") + } rec := formatReceipt(scanReceipt(msg)) tallyShown := false if msg.collapsed && !msg.streaming { @@ -804,14 +811,20 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st addBlock(th.asstWork.Render(m.renderIntentRail(body, it, msg)), false) continue } + if items[it].draft { + if strings.TrimSpace(items[it].text) != "" { + addBlock(th.asstWork.Render(m.renderDraft(items[it], it, msgIdx)), false) + } + continue + } if items[it].reply { t := items[it].text if strings.TrimSpace(t) == "" { continue } - body := t - if items[it].rendered != "" { - body = items[it].rendered + body := items[it].rendered + if body == "" { + body = rawReplyBody(t) } card, n := m.answerCardBody(body) start := addBlock(card, true) @@ -865,6 +878,43 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st } } +// renderDraft paints a superseded draft as one folded row; opening it +// (Enter while inspecting, ^E) shows the draft text dimmed beneath, so the +// replacement answer stays the only card. +func (m *Model) renderDraft(it turnItem, itemIdx, msgIdx int) string { + th := m.th + label := "⋯ draft revised · " + draftReasonLabel(it.draftReason) + sel := m.inspect != nil && m.inspect.msgIdx == msgIdx && m.inspect.itemIdx == itemIdx && m.inspect.stepIdx < 0 + head := th.statsDim.Render(label) + if sel { + head = th.acSel.Render("› " + label) + } + if !it.open && !m.expandAll { + if sel { + head += th.acSel.Render(" · Enter expand") + } + return head + } + body := ansi.Wrap(strings.TrimSpace(it.text), max(m.cardInner()-2, 8), "") + return head + "\n" + th.thinkStyle.Render(body) +} + +// rawReplyMargin matches glamour's document margin, so a reply segment +// painted before its first render flush sits in the same column the +// rendered card will — the text never jumps sideways when markdown lands. +const rawReplyMargin = " " + +// rawReplyBody indents a not-yet-rendered reply to glamour's margin. +func rawReplyBody(t string) string { + lines := strings.Split(t, "\n") + for i, ln := range lines { + if ln != "" { + lines[i] = rawReplyMargin + ln + } + } + return strings.Join(lines, "\n") +} + // answerCardBody styles one reply segment as its raised card — the // deliverable of a think→reply cycle, visually distinct from the dimmed, // indented work around it. Glamour wraps at vp-6, so the card's padding @@ -950,7 +1000,7 @@ func stepTally(msg message) string { func foldTally(msg message) string { n := 0 for _, it := range msg.items { - if !it.thinking && !it.reply { + if it.isStep() { n++ } } @@ -1065,6 +1115,9 @@ func (m *Model) turnStatFoot(msg message) string { } ts := *msg.stats outcome := "✓ done" + if msg.unverified { + outcome = "✗ unverified" + } if msg.failed { outcome = "✗ failed" } @@ -1226,16 +1279,21 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in return st.blockCache, refs, lineCount(st.blockCache) } } + // One status-bearing glyph per row: a clean finish shows the tool's own + // icon, a failure replaces it with ✗ (shape, not only color), and live + // and pending share a static ▸ — the status line is the only spinner, + // so a fast tool swarm cannot strobe the transcript. + glyph := toolGlyph(s.name) var icon string switch { case s.done && s.isErr: - icon = th.stepErr.Render("✗") + glyph = "✗" + icon = th.stepErr.Render(glyph) case s.done: - icon = th.stepDone.Render("✓") + icon = th.toolIcon.Render(glyph) default: - // Live and pending share a static glyph — the status line is the - // only spinner, so a fast tool swarm cannot strobe the transcript. - icon = th.stepRun.Render("▸") + glyph = "▸" + icon = th.stepRun.Render(glyph) } chevron := th.stepTree.Render("▶") if expanded { @@ -1265,7 +1323,7 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in } } rightW := lipgloss.Width(right) - pre := chevron + " " + icon + " " + th.toolIcon.Render(toolGlyph(s.name)) + " " + pre := chevron + " " + icon + " " chips := s.agentChips() nameBudget := max(m.vp.Width-4-rightW-lipgloss.Width(pre)-8, 4) var left string @@ -1289,7 +1347,7 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in } budget := max(m.vp.Width-4-rightW-2, 4) if s.arg != "" && len(chips) == 0 { - left += th.stepArg.Render(" " + truncate(s.arg, budget-lipgloss.Width(chevron+" "+icon+" "+toolGlyph(s.name)+" "+s.name)-2)) + left += th.stepArg.Render(" " + truncate(s.arg, budget-lipgloss.Width(chevron+" "+glyph+" "+s.name)-2)) } } gap := max(m.vp.Width-4-lipgloss.Width(left)-rightW, 1) @@ -1636,7 +1694,11 @@ func (m *Model) approvalPanel() string { // Compact terminals spend their rows on the command and controls. return m.approvalBody() } - return m.th.apprBox.Width(m.cardWidth()).Render(m.approvalBody()) + box := m.th.apprBox + if a := m.curApproval(); a != nil && a.Risk == "high" { + box = m.th.apprBoxHi + } + return box.Width(m.cardWidth()).Render(m.approvalBody()) } // approvalBody builds the panel's inner content: head, the command (one @@ -1682,29 +1744,42 @@ func (m *Model) approvalBody() string { if target == "" { command = targetLabel + " not supplied by odek" } - var body []string + // A reason that only repeats the tool name or the Action text adds + // nothing, so its row is dropped. + reason := "" + if a.Description != "" { + if shown := visibleInvocation(a.Description); !approvalReasonRepeats(shown, a.Name, approvalRiskLabel(a.Risk)) { + reason = shown + } + } + hasCmd := target != "" + var body []apprLine if m.apprExpanded { - appendWrapped := func(line string) { - body = append(body, strings.Split(ansi.Hardwrap(line, budget, true), "\n")...) + appendWrapped := func(line string, code bool) { + for _, seg := range strings.Split(ansi.Hardwrap(line, budget, true), "\n") { + body = append(body, apprLine{text: seg, code: code}) + } } - appendWrapped(command) - appendWrapped(action) - appendWrapped("Working directory: not supplied by odek") - if a.Description != "" { - appendWrapped("Reason: " + visibleInvocation(a.Description)) + appendWrapped(command, hasCmd) + appendWrapped(action, false) + appendWrapped("Working directory: not supplied by odek", false) + if reason != "" { + appendWrapped("Reason: "+reason, false) } if a.AllowTrust && !a.Friction { - appendWrapped("Trust: allow " + approvalRiskLabel(a.Risk) + " until this connection ends") + appendWrapped("Trust: allow "+approvalRiskLabel(a.Risk)+" until this connection ends", false) } } else { 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("Reason: "+collapse(visibleInvocation(a.Description)), budget)) + body = append(body, apprLine{text: truncate(collapse(preview), budget), code: hasCmd}) + body = append(body, apprLine{text: truncate(action, budget)}) + if reason != "" { + body = append(body, apprLine{text: truncate("Reason: "+collapse(reason), budget)}) } } - limit := max(1, min(8, m.height-m.desiredComposerHeight()-headerHeight-footerHeight-8)) + // The in-card action row always takes one more row, so the body gives + // that row back to keep the card inside the terminal. + limit := max(1, min(8, m.height-m.desiredComposerHeight()-headerHeight-footerHeight-9)) if a.Friction { limit = max(1, limit-2) } @@ -1715,7 +1790,7 @@ func (m *Model) approvalBody() string { m.apprOffset = offset end := min(len(body), offset+limit) for _, line := range body[offset:end] { - lines = append(lines, th.apprBody.Render(ansi.Truncate(line, budget, ""))) + lines = append(lines, m.renderApprLine(line, budget)) } if len(body) > limit && m.apprExpanded { lines = append(lines, th.noticeStyle.Render(ansi.Truncate(fmt.Sprintf("%d–%d/%d · Alt+PgUp/PgDn", offset+1, end, len(body)), budget, ""))) @@ -1727,9 +1802,78 @@ func (m *Model) approvalBody() string { lines = append(lines, th.apprKey.Render(ansi.Truncate(typed+"▏", budget, ""))) } } + lines = append(lines, m.approvalActionRow(a, budget)) return strings.Join(lines, "\n") } +// apprLine is one body row of the approval card. When code is set, the +// value after a "Command: " or "Resource: " label paints as code. +type apprLine struct { + text string + code bool +} + +// renderApprLine paints one pre-wrapped body row, clamped to the card width. +func (m *Model) renderApprLine(l apprLine, budget int) string { + th := m.th + text := ansi.Truncate(l.text, budget, "") + if !l.code { + return th.apprBody.Render(text) + } + for _, label := range []string{"Command: ", "Resource: "} { + if rest, ok := strings.CutPrefix(text, label); ok { + return th.apprBody.Render(label) + th.apprCode.Render(rest) + } + } + return th.apprCode.Render(text) +} + +// approvalReasonRepeats reports whether a reason only restates the tool name +// or the Action text, compared case-insensitively. +func approvalReasonRepeats(reason, name, action string) bool { + r := strings.TrimSpace(reason) + return strings.EqualFold(r, strings.TrimSpace(name)) || strings.EqualFold(r, action) +} + +// approvalActionRow lists the decisions at the foot of the card, mirroring the +// footer keys. Trust is dropped first when the row is too wide for the card; +// allow-once and deny always stay. Friction cards show the typed-confirmation +// path, since a bare 'a' only opens the editor there. +func (m *Model) approvalActionRow(a *client.Event, budget int) string { + th := m.th + type hint struct{ key, text string } + var hints []hint + switch { + case a.Friction && m.apprEditing: + hints = []hint{{"type approve + ⏎", ""}, {"Alt+D", "deny"}} + case a.Friction: + // Same two-step contract as the friction line above: 'a' opens the + // confirm editor, it never approves on its own. + hints = []hint{{"a", "confirm → type approve"}, {"d", "deny"}} + default: + hints = []hint{{"a", "allow once"}, {"d", "deny"}} + if a.AllowTrust { + hints = append(hints, hint{"t", "trust class"}) + } + } + render := func(hs []hint) string { + parts := make([]string, 0, len(hs)) + for _, h := range hs { + part := th.footerKey.Render(h.key) + if h.text != "" { + part += th.footer.Render(" " + h.text) + } + parts = append(parts, part) + } + return strings.Join(parts, th.footer.Render(" · ")) + } + row := render(hints) + if len(hints) > 2 && lipgloss.Width(row) > budget { + row = render(hints[:2]) + } + return ansi.Truncate(row, budget, "…") +} + // ── footer ──────────────────────────────────────────────────────────────── func (m *Model) footer() string { return ansi.Truncate(m.footerContent(), max(1, m.width), "") @@ -1851,8 +1995,8 @@ func (m *Model) footerContent() string { if m.panel == panelRuns { return m.panelFooter( th.footer.Render("↑↓ select · ]/[ tabs"), - th.footerKey.Render("A")+th.footer.Render("pprove · "), - th.footerKey.Render("D")+th.footer.Render("eny · "), + th.footerKey.Render("A")+th.footer.Render("pprove"), + th.footerKey.Render("D")+th.footer.Render("eny"), th.footerKey.Render("T")+th.footer.Render("rust"), th.footerKey.Render("c")+th.footer.Render(" cancel"), th.footerKey.Render("e")+th.footer.Render(" events"), @@ -1913,12 +2057,12 @@ func (m *Model) footerContent() string { ) } return m.panelFooter( - th.footer.Render("⏎ detail · "), - th.footerKey.Render("a")+th.footer.Render(" add user · "), + th.footer.Render("⏎ detail"), + th.footerKey.Render("a")+th.footer.Render(" add user"), th.footerKey.Render("A")+th.footer.Render(" add env"), th.footerKey.Render("d")+th.footer.Render(" delete fact → y confirm"), th.footerKey.Render("p")+th.footer.Render(" promote episode"), - th.footerKey.Render("c")+th.footer.Render(" consolidate user · "), + th.footerKey.Render("c")+th.footer.Render(" consolidate user"), th.footerKey.Render("E")+th.footer.Render(" env"), th.footer.Render("]/[ tabs · esc close"), ) @@ -1931,10 +2075,10 @@ func (m *Model) footerContent() string { } if m.panel == panelQueue { return m.panelFooter( - th.footerKey.Render("↑↓")+th.footer.Render(" select · "), - th.footerKey.Render("←→")+th.footer.Render(" priority · "), - th.footerKey.Render("⏎")+th.footer.Render(" send now · "), - th.footerKey.Render("d")+th.footer.Render(" delete → y · "), + th.footerKey.Render("↑↓")+th.footer.Render(" select"), + th.footerKey.Render("←→")+th.footer.Render(" priority"), + th.footerKey.Render("⏎")+th.footer.Render(" send now"), + th.footerKey.Render("d")+th.footer.Render(" delete → y"), th.footer.Render("esc close"), ) } @@ -1942,7 +2086,7 @@ func (m *Model) footerContent() string { if m.panelDetail { return m.panelFooter( th.footer.Render("↑↓ scroll"), - th.footerKey.Render("p")+th.footer.Render(" promote · "), + th.footerKey.Render("p")+th.footer.Render(" promote"), th.footerKey.Render("P")+th.footer.Render(" force-promote"), th.footer.Render("esc back"), ) @@ -2020,26 +2164,30 @@ func (m *Model) footerContent() string { // 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 // the left, and latency / scroll position on the right. - left := m.modePrefix() + var lefts []string if m.busy { - left += th.footerKey.Render("^X") + th.footer.Render(" stop") + lefts = append(lefts, th.footerKey.Render("^X")+th.footer.Render(" stop")) } else if m.status == "error" && m.ta.Value() == "" && m.lastPrompt != "" { // A failed turn with an empty input: ⏎ resends the preserved // prompt — the same contract the error card states. Hidden while a // draft exists so typing is never hijacked by the hint. - left += th.footerKey.Render("⏎") + th.footer.Render(" retry last prompt") + lefts = append(lefts, th.footerKey.Render("⏎")+th.footer.Render(" retry last prompt")) } // Persistent expandAll indicator — while the global toggle holds every // step open, per-step toggles look dead unless the chrome says why. if m.expandAll { - ind := th.footerKey.Render("▼") + th.footer.Render(" details") - left += th.footerSep.Render(" · ") + ind + lefts = append(lefts, th.footerKey.Render("▼")+th.footer.Render(" details")) } // Click / ^Y / alt+y copy ack — footer only, so it stays visible // when the reader is up in history. if m.copyFlashing() { - ind := th.badgeOK.Render("✓") + th.footer.Render(" Copied") - left += th.footerSep.Render(" · ") + ind + lefts = append(lefts, th.badgeOK.Render("✓")+th.footer.Render(" Copied")) + } + // Segments join after the mode name — an idle composer shows the bare + // name, never a dangling separator. + left := m.modePrefix() + if len(lefts) > 0 { + left += th.footerSep.Render(" · ") + strings.Join(lefts, th.footerSep.Render(" · ")) } var segs []string @@ -2100,8 +2248,8 @@ func (m *Model) footerContent() string { // panelFooter joins pre-styled hint segments for an open panel (pre-styled so // destructive hints can carry the danger tint). func (m *Model) panelFooter(hints ...string) string { - prefix := m.modePrefix() sep := m.th.footerSep.Render(" · ") + prefix := m.modePrefix() + sep full := prefix + strings.Join(hints, sep) if lipgloss.Width(full) <= m.width { return full diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go new file mode 100644 index 0000000..08e6e9a --- /dev/null +++ b/internal/tui/visual_polish_test.go @@ -0,0 +1,212 @@ +package tui + +import ( + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// Footer hints join through one separator: an idle mode name never trails a +// dangling "·", and no hint segment bakes its own separator into the text. +func TestFooterHasNoDanglingSeparators(t *testing.T) { + cases := map[string]func(m *Model){ + "composer": func(*Model) {}, + "memory": func(m *Model) { m.panel = panelMemory }, + "queue": func(m *Model) { m.panel = panelQueue }, + "runs": func(m *Model) { m.panel = panelRuns }, + "skills": func(m *Model) { m.panel = panelSkills; m.panelDetail = true }, + "busy": func(m *Model) { busyTurn(m) }, + "expanded": func(m *Model) { m.expandAll = true }, + } + for name, setup := range cases { + t.Run(name, func(t *testing.T) { + m := newTestModel() + m.resize(200, 30) + setup(m) + got := strings.TrimRight(plain(m.footer()), " ") + if strings.HasSuffix(got, "·") { + t.Errorf("footer ends with a separator: %q", got) + } + if strings.Contains(got, "· ·") || strings.Contains(got, "· ·") { + t.Errorf("footer has an empty segment: %q", got) + } + }) + } +} + +// The slash popup names its own mode; "attach" belongs to @-references. +func TestFooterNamesSlashPopupAsCommands(t *testing.T) { + m := newTestModel() + m.ac.open, m.ac.mode = true, acCmd + if got := m.modeName(); got != "commands" { + t.Fatalf("modeName = %q, want commands", got) + } + m.ac.mode = acRef + if got := m.modeName(); got != "attach" { + t.Fatalf("modeName = %q, want attach", got) + } +} + +// A reply painted before its first render flush starts in the column the +// glamour-rendered card will use, so finalizing a turn never shifts text. +func TestReplyColumnStableAcrossRenderFlush(t *testing.T) { + col := func(view string) int { + for _, ln := range strings.Split(plain(view), "\n") { + if i := strings.Index(ln, "cart survives"); i >= 0 { + return i + } + } + return -1 + } + m := newTestModel() + m.resize(100, 30) + busyTurn(m) + m.handleEvent(client.Event{Type: "token", Content: "The cart survives payment failure."}) + m.refresh() + live := col(m.View()) + m.handleEvent(client.Event{Type: "done"}) + m.refresh() + done := col(m.View()) + if live < 0 || live != done { + t.Fatalf("reply column live=%d done=%d, want equal", live, done) + } +} + +// The header rule is one hairline span, not a per-cell brand gradient. +func TestHeaderRuleIsSingleHairline(t *testing.T) { + old := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(old) + m := newTestModel() + m.resize(120, 30) + r := m.rule() + if got := strings.Count(r, "\x1b["); got > 2 { + t.Fatalf("rule carries %d SGR sequences, want one span", got) + } + if plain(r) != strings.Repeat("─", 120) { + t.Fatalf("rule = %q, want a full-width hairline", plain(r)) + } +} + +// Warning color must not share the brand accent's hue, or a warning reads as +// ordinary chrome (the approval border looked like branding). +func TestWarningHueDistinctFromAccent(t *testing.T) { + hue := func(c lipgloss.Color) float64 { + var r, g, b int + if _, err := fmt.Sscanf(string(c), "#%02x%02x%02x", &r, &g, &b); err != nil { + t.Fatalf("bad color %q", c) + } + R, G, B := float64(r)/255, float64(g)/255, float64(b)/255 + mx, mn := math.Max(R, math.Max(G, B)), math.Min(R, math.Min(G, B)) + d := mx - mn + if d == 0 { + return 0 + } + var h float64 + switch mx { + case R: + h = math.Mod((G-B)/d, 6) + case G: + h = (B-R)/d + 2 + default: + h = (R-G)/d + 4 + } + return math.Mod(h*60+360, 360) + } + for _, name := range []string{"ember-dark", "ember-light", "high-contrast", "classic"} { + p := paletteByName(name) + d := math.Abs(hue(p.yellow) - hue(p.accent)) + if d > 180 { + d = 360 - d + } + if d < 10 { + t.Errorf("%s: warning hue %.0f° within %.0f° of accent", name, hue(p.yellow), d) + } + } +} + +// A step head carries one status-bearing glyph after the disclosure chevron: +// the tool icon on success, ✗ on failure, a static ▸ while live. +func TestStepHeadSingleStatusGlyph(t *testing.T) { + head := func(s step) string { + m := newTestModel() + m.resize(100, 30) + out, _, _ := m.renderStep(s, !s.done, -1, -1, 0) + return plain(strings.Split(out, "\n")[0]) + } + shell := toolGlyph("shell") + ok := head(step{name: "shell", arg: "go test", done: true, result: "ok"}) + if strings.Contains(ok, "✓") || !strings.Contains(ok, shell+" shell") { + t.Errorf("done head = %q, want the tool icon without a ✓", ok) + } + bad := head(step{name: "shell", arg: "go test", done: true, isErr: true, result: "boom"}) + if !strings.Contains(bad, "✗ shell") || strings.Contains(bad, shell) { + t.Errorf("failed head = %q, want ✗ in place of the tool icon", bad) + } + live := head(step{name: "shell", arg: "go test"}) + if !strings.Contains(live, "▸") || strings.Contains(live, shell) { + t.Errorf("live head = %q, want a static ▸ in place of the tool icon", live) + } +} + +// The sandbox chip never borrows the connection lamp's dot vocabulary. +func TestSandboxBadgeIsNotADot(t *testing.T) { + m := newTestModel() + m.sandbox = true + got := plain(m.sandboxBadge()) + for _, dot := range []string{lampReady, lampLive, lampReconnect, lampDown} { + if strings.Contains(got, dot) { + t.Fatalf("sandbox badge %q reuses lamp glyph %q", got, dot) + } + } +} + +// A high-risk approval escalates its border to the danger color; other +// risks keep the warning border. +func TestHighRiskApprovalBorderIsRed(t *testing.T) { + old := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(old) + border := func(risk string) string { + m := newTestModel() + m.th = themeFrom(emberDark) + m.resize(100, 30) + busyTurn(m) + m.handleEvent(client.Event{Type: "approval_request", ID: "a", Risk: risk, Command: "make"}) + return strings.Split(m.approvalPanel(), "\n")[0] + } + red := surfaceSGR(lipgloss.NewStyle().Background(emberDark.red)) + red = strings.Replace(red, "[48;", "[38;", 1) + if !strings.Contains(border("high"), red) { + t.Errorf("high-risk border is not red: %q", border("high")) + } + if strings.Contains(border("shell_exec"), red) { + t.Errorf("shell_exec border escalated to red: %q", border("shell_exec")) + } +} + +// Help rows keep a usable description column on very narrow cards. +func TestHelpRowLinesNarrowFloor(t *testing.T) { + m := newTestModel() + lines := helpRowLines(m.th, [2]string{"^K", "command palette with a long description"}, 10) + if len(lines) < 2 { + t.Fatalf("narrow help row did not wrap: %q", lines) + } +} + +// The cockpit's session block trails the start time when it is known. +func TestCockpitSessionStartLine(t *testing.T) { + m := newTestModel() + m.resize(100, 34) + m.odekVersion = "v2.33.3" // a row, so the block renders past its empty state + m.sessionStart = time.Now().Add(-3 * time.Minute) + if got := plain(m.sessionBlock(true, 12)); !strings.Contains(got, "started ") { + t.Fatalf("cockpit session block lacks start time:\n%s", got) + } +}