From 87c8919b54a96d90f561a889f3053bfeb0840c31 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:45:14 +0200 Subject: [PATCH 01/18] fix(tui): drop dangling footer separators and name the slash popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode name carried its own trailing separator, so an idle footer read 'composer · ' and panel hints that baked ' · ' into their text rendered empty segments. Segments now join once after the mode name, and the slash popup reports 'commands' instead of 'attach'. Co-Authored-By: Claude Opus 5.5 --- internal/tui/chrome.go | 4 ++- internal/tui/view.go | 40 ++++++++++++++----------- internal/tui/visual_polish_test.go | 48 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 internal/tui/visual_polish_test.go diff --git a/internal/tui/chrome.go b/internal/tui/chrome.go index 10b9497..777f839 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 ──────────────────────────────────────────────────────────── diff --git a/internal/tui/view.go b/internal/tui/view.go index 9a9c33c..00d6db7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1851,8 +1851,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 +1913,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 +1931,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 +1942,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 +2020,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 +2104,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..02b4777 --- /dev/null +++ b/internal/tui/visual_polish_test.go @@ -0,0 +1,48 @@ +package tui + +import ( + "strings" + "testing" +) + +// 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) + } +} + From 7c0736b328a342fe4a46684a074126018a4e8fde Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:45:27 +0200 Subject: [PATCH 02/18] fix(tui): keep reply text in the rendered column before first flush A reply segment painted before its first glamour flush sat two columns left of the rendered card, so text jumped sideways when markdown landed. Indent the raw body to glamour's document margin. Co-Authored-By: Claude Opus 5.5 --- internal/tui/view.go | 22 +++++++++++++++++++--- internal/tui/visual_polish_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/internal/tui/view.go b/internal/tui/view.go index 00d6db7..4d6e4fe 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -809,9 +809,9 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st 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 +865,22 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st } } +// 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 diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go index 02b4777..5e7a78b 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -1,6 +1,7 @@ package tui import ( + "github.com/BackendStack21/bodek/internal/client" "strings" "testing" ) @@ -46,3 +47,27 @@ func TestFooterNamesSlashPopupAsCommands(t *testing.T) { } } +// 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) + } +} From 6c359e2b72082593d1af74483353b3927c346be9 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:44:17 +0200 Subject: [PATCH 03/18] fix(tui): fit cockpit sheet to content and drop duplicated rows The cockpit card forced its box to the full transcript height, leaving about ten empty rows under a short card; the height is now a maximum. Server, budget, and lifetime labels share one column across sections, and the session block drops the duplicated sandbox row and the rule around it. The /stats sheet keeps its sandbox row and rules. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cockpit.go | 78 ++++++++++++++++++++---------- internal/tui/cockpit_test.go | 61 ++++++++++++++++++++++- internal/tui/commands.go | 31 +++++++++--- internal/tui/subagent_cost_test.go | 2 +- 4 files changed, 137 insertions(+), 35 deletions(-) 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..288858f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -410,8 +410,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 +615,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 +640,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/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) } From ca33383cf2a6b923259daf320c993beacad4cf00 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:45:15 +0200 Subject: [PATCH 04/18] fix(tui): show drawer position instead of a bare ellipsis tab strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the ten-tab strip does not fit, the collapsed form read '… · 7 memory' beside the '❖ memory' title, which looked like a broken count. It now reads 'tab 7/10'; the full strip still shows when wide. Co-Authored-By: Claude Opus 5.5 --- internal/tui/drawer.go | 13 ++++++++----- internal/tui/drawer_test.go | 33 ++++++++++++++++++++++++++++++++- internal/tui/jobs_tab_test.go | 2 ++ 3 files changed, 42 insertions(+), 6 deletions(-) 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/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())) From 1a9d5f264e5c8e4743c3b69c31b443f289e6f2e4 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:45:28 +0200 Subject: [PATCH 05/18] feat(tui): group help card into sections with two-column layout The F1 help card listed every key in one flat run, taller than the transcript on short terminals. Keys are now grouped under compose, navigate, inspect & copy, session, and general headings, and sit in two columns at 100+ columns. Narrower terminals keep a single column. Co-Authored-By: Claude Opus 5.5 --- internal/tui/commands.go | 151 +++++++++++++++++++++++-------- internal/tui/help_layout_test.go | 73 +++++++++++++++ 2 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 internal/tui/help_layout_test.go diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 288858f..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. 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) + } + } +} From eb0386aa3281d05993f6f210726137594d7786c8 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:48:43 +0200 Subject: [PATCH 06/18] feat(tui): paint the header rule as a quiet hairline The full-width amber gradient under the header was the loudest element on screen and cost one SGR per cell each frame. Amber now stays on the wordmark and focus; the rule uses the theme hairline. Co-Authored-By: Claude Opus 5.5 --- internal/tui/model.go | 3 --- internal/tui/view.go | 13 +++++-------- internal/tui/visual_polish_test.go | 21 ++++++++++++++++++++- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 6ebdd6a..e494969 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -401,9 +401,7 @@ type Model struct { reconnAttempt int // current redial attempt index (drives the status-line backoff readout) quitting bool - 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 @@ -1372,7 +1370,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/view.go b/internal/tui/view.go index 4d6e4fe..b2d4922 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -63,7 +63,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])) } @@ -290,14 +290,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 diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go index 5e7a78b..e9f166c 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -1,9 +1,12 @@ package tui import ( - "github.com/BackendStack21/bodek/internal/client" "strings" "testing" + + "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 @@ -71,3 +74,19 @@ func TestReplyColumnStableAcrossRenderFlush(t *testing.T) { 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)) + } +} From 5a3c00e5ded43db074830712140cd9ec375e9f17 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:50:12 +0200 Subject: [PATCH 07/18] feat(tui): separate warning hue from the brand accent Ember warning yellow sat within 2-9 degrees of the amber accent, so approval borders and budget warnings read as ordinary chrome. Dark moves to lemon, light to burnt orange, high-contrast to orange. Co-Authored-By: Claude Opus 5.5 --- internal/tui/styles.go | 7 +++--- internal/tui/visual_polish_test.go | 39 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f7ef058..d9e4f53 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", diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go index e9f166c..ba31976 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + "math" "strings" "testing" @@ -90,3 +92,40 @@ func TestHeaderRuleIsSingleHairline(t *testing.T) { 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) + } + } +} From 160702f3b28ba30342be6c87c3dbc771921b4b2a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:51:26 +0200 Subject: [PATCH 08/18] feat(tui): drop the emoji from just-in-time tips The tip note was the only emoji in the UI, and its cell width varies by terminal. The 'tip:' label already identifies it. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- internal/tui/hints.go | 2 +- internal/tui/hints_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1dce42e..afa3145 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,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. 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. From d75a5b4f481f8100c3cdb38b782f5b1623302952 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:50:36 +0200 Subject: [PATCH 09/18] feat(tui): in-card approval actions and code-styled command Co-Authored-By: Claude Opus 5.5 --- internal/tui/approval_card_polish_test.go | 119 ++++++++++++++++++++++ internal/tui/styles.go | 13 +++ internal/tui/view.go | 109 +++++++++++++++++--- 3 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 internal/tui/approval_card_polish_test.go diff --git a/internal/tui/approval_card_polish_test.go b/internal/tui/approval_card_polish_test.go new file mode 100644 index 0000000..1d35865 --- /dev/null +++ b/internal/tui/approval_card_polish_test.go @@ -0,0 +1,119 @@ +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}) + row, ok := rowContaining(approvalCardRows(m), "type approve") + if !ok { + t.Fatalf("friction card has no typed-confirmation row:\n%s", plain(m.approvalBody())) + } + if !strings.Contains(row, "Alt+D deny") || strings.Contains(row, "trust") { + t.Fatalf("friction action 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/styles.go b/internal/tui/styles.go index d9e4f53..f7b9ef4 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -288,6 +288,7 @@ type theme struct { apprHead lipgloss.Style apprBody lipgloss.Style apprKey lipgloss.Style + apprCode lipgloss.Style apprUrgent lipgloss.Style statusReady lipgloss.Style @@ -402,6 +403,7 @@ func themeFrom(p palette) theme { 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), @@ -456,6 +458,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/view.go b/internal/tui/view.go index b2d4922..8c678d6 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 @@ -1695,29 +1697,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) } @@ -1728,7 +1743,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, ""))) @@ -1740,9 +1755,73 @@ 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 + if a.Friction { + hints = []hint{{"type approve + ⏎", ""}, {"Alt+D", "deny"}} + } else { + 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), "") From e4ef0c34aac109e561675f44d3ffdbc4ed7a19ce Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:51:18 +0200 Subject: [PATCH 10/18] feat(tui): anchor first-run home above the composer The first-run home sat at the top of the transcript, far from the input it asks the user to type into. It now hugs the composer with one blank row between, falling back to top placement when the viewport is too short. The duplicated "type a task" line is dropped, and the tip names only the composer keys and @ references since the footer owns ^K. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- internal/tui/banner.go | 8 +++-- internal/tui/banner_test.go | 48 +++++++++++++++++++++++++- internal/tui/chrome.go | 15 +++++++- internal/tui/chrome_test.go | 4 +-- internal/tui/cosmetics_narrow_test.go | 4 +-- internal/tui/cosmetics_test.go | 8 ++--- internal/tui/responsive_chrome_test.go | 2 +- internal/tui/theme_test.go | 4 +-- 9 files changed, 78 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index afa3145..35050d5 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ 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` / …). 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/chrome.go b/internal/tui/chrome.go index 777f839..1f69e22 100644 --- a/internal/tui/chrome.go +++ b/internal/tui/chrome.go @@ -274,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/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/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/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) } From d6e8b4aa176aef8f42a1ae205e584347f8d843ff Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:53:23 +0200 Subject: [PATCH 11/18] fix(tui): match the friction action row to the two-step confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the editor opens, a friction card's 'a' starts the confirm editor rather than approving, so the in-card row now says so; the typed 'approve + ⏎' row shows only while editing. Co-Authored-By: Claude Opus 5.5 --- internal/tui/approval_card_polish_test.go | 16 +++++++++++++--- internal/tui/view.go | 9 +++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/tui/approval_card_polish_test.go b/internal/tui/approval_card_polish_test.go index 1d35865..a239c59 100644 --- a/internal/tui/approval_card_polish_test.go +++ b/internal/tui/approval_card_polish_test.go @@ -60,12 +60,22 @@ func TestApprovalActionRowFrictionPath(t *testing.T) { busyTurn(m) m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Name: "shell", Risk: "shell_exec", Command: "make", AllowTrust: true, Friction: true, FrictionApprovals: 3}) - row, ok := rowContaining(approvalCardRows(m), "type approve") + // 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 typed-confirmation row:\n%s", plain(m.approvalBody())) + 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 action row = %q, want Alt+D deny and no trust", row) + t.Fatalf("friction editor row = %q, want Alt+D deny and no trust", row) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 8c678d6..8754623 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1796,9 +1796,14 @@ func (m *Model) approvalActionRow(a *client.Event, budget int) string { th := m.th type hint struct{ key, text string } var hints []hint - if a.Friction { + switch { + case a.Friction && m.apprEditing: hints = []hint{{"type approve + ⏎", ""}, {"Alt+D", "deny"}} - } else { + 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"}) From fc3e701b6b47acda55059585c0471724a8cad144 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:52:11 +0200 Subject: [PATCH 12/18] feat(tui): rest the composer at one row and grow with content The empty composer occupied three text rows; it now rests at one and grows per line or wrapped row as before, returning two rows to the transcript. Opening a job's output detail now relayouts so the viewport follows the drawer sheet split. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- internal/tui/bughunt_regress_test.go | 2 +- internal/tui/composer_grow_test.go | 43 +++++++++++++++++++++++----- internal/tui/input.go | 4 +-- internal/tui/jobs_tab.go | 1 + internal/tui/model.go | 2 +- internal/tui/model_smoke_test.go | 2 +- internal/tui/styles.go | 2 +- 8 files changed, 44 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 35050d5..c3b480d 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ own front-end settings are separate; see [Configuration](#configuration). 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 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/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/input.go b/internal/tui/input.go index 4631234..2fccba0 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 ) 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/model.go b/internal/tui/model.go index e494969..59df041 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -444,7 +444,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 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/styles.go b/internal/tui/styles.go index f7b9ef4..e8ee44a 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -246,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 ) From 5efc04c20235d616a9992be360a1abee594ffb6c Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:56:10 +0200 Subject: [PATCH 13/18] feat(tui): carry step status in a single glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step heads stacked a disclosure chevron, a status mark and a tool icon (▶ ✓ ◰ read_file). The tool icon now carries the state: its own glyph on success, ✗ on failure, the static ▸ while live. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- internal/tui/view.go | 19 ++++++++++++------- internal/tui/visual_polish_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c3b480d..34ff221 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,9 @@ own front-end settings are separate; see [Configuration](#configuration). 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 +- **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 diff --git a/internal/tui/view.go b/internal/tui/view.go index 8754623..d0eaf22 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1241,16 +1241,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 { @@ -1280,7 +1285,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 @@ -1304,7 +1309,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) diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go index ba31976..9773fef 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -129,3 +129,27 @@ func TestWarningHueDistinctFromAccent(t *testing.T) { } } } + +// 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) + } +} From b1f49e76aee736f14ba522719ab5b054cbe673d9 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:57:28 +0200 Subject: [PATCH 14/18] feat(tui): mark the sandbox with a diamond, not a second green dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header paired '● sandboxed' with the green ● connection lamp, so two dots carried unrelated meanings. Dots now belong to the lamp alone. The README also still described the pre-glyph emoji badge. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- internal/tui/view.go | 9 +++++---- internal/tui/visual_polish_test.go | 12 ++++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 34ff221..e282f3a 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,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/tui/view.go b/internal/tui/view.go index d0eaf22..6397169 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -249,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") } diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go index 9773fef..1cd13fb 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -153,3 +153,15 @@ func TestStepHeadSingleStatusGlyph(t *testing.T) { 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) + } + } +} From cf8d0c1470cf3ffa2d2b55b6be08d11950603b32 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 18:58:49 +0200 Subject: [PATCH 15/18] feat(tui): escalate high-risk approval borders to red Every approval shared the warning border, so a high-risk request looked like any other. Risk 'high' now paints the card border in the danger color; class risks keep the warning border. Co-Authored-By: Claude Opus 5.5 --- internal/tui/styles.go | 2 ++ internal/tui/view.go | 6 +++++- internal/tui/visual_polish_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index e8ee44a..be1b6ab 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -285,6 +285,7 @@ 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 @@ -400,6 +401,7 @@ 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), diff --git a/internal/tui/view.go b/internal/tui/view.go index 6397169..acd3d81 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1657,7 +1657,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 diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go index 1cd13fb..020ed03 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -165,3 +165,27 @@ func TestSandboxBadgeIsNotADot(t *testing.T) { } } } + +// 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")) + } +} From 542062c2ab9d7ea5682ae79d1bd628a190a4e222 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 20:24:57 +0200 Subject: [PATCH 16/18] feat(client): decode answer_superseded, done.verified and runtime_event odek v2.33 signals superseded drafts and the verification outcome. The Event union gains reason/cycle/verified, persisted messages gain superseded/superseded_reason, and runtime_event frames now decode through a dedicated path: their object-valued 'event' key collided with the string SubType, so every such frame was silently dropped. Co-Authored-By: Claude Opus 5.5 --- internal/client/answer_events_test.go | 74 +++++++++++++++++++++++++++ internal/client/client.go | 37 +++++++++++++- internal/client/rest.go | 5 ++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 internal/client/answer_events_test.go 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). From d6a195d56a9a1203693fa1d409c45b7f56406ad2 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 8 Oct 2026 20:24:57 +0200 Subject: [PATCH 17/18] feat(tui): fold superseded drafts and flag unverified answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When odek re-asks the model after an answer already streamed, the turn showed two near-identical answers. answer_superseded now folds the draft into a '⋯ draft revised' row that opens like reasoning, and msg.content keeps only the replacement. done.verified=fail (or the persisted/bulk marker) shows '✗ unverified' on the head and outcome row, the status line reads 'verifying answer' during the check, replay folds persisted drafts, and plain mode notes the revision. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 13 ++ README.md | 8 +- internal/tui/clipboard.go | 4 +- internal/tui/events.go | 103 ++++++++++++- internal/tui/find.go | 4 +- internal/tui/input.go | 2 +- internal/tui/inspect.go | 6 +- internal/tui/model.go | 19 +++ internal/tui/panels.go | 18 ++- internal/tui/plain.go | 19 +++ internal/tui/superseded_test.go | 249 ++++++++++++++++++++++++++++++++ internal/tui/view.go | 39 ++++- 12 files changed, 470 insertions(+), 14 deletions(-) create mode 100644 internal/tui/superseded_test.go 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 e282f3a..ffdd58a 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,13 @@ 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)). + ([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 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/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/input.go b/internal/tui/input.go index 2fccba0..3314865 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -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/model.go b/internal/tui/model.go index 59df041..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,6 +417,8 @@ 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 glamWrap int // wrap width m.glam was built for; -1 forces rebuild (theme switch) logoCache string // cached gradient logo (width-independent) 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/superseded_test.go b/internal/tui/superseded_test.go new file mode 100644 index 0000000..2a6fdcb --- /dev/null +++ b/internal/tui/superseded_test.go @@ -0,0 +1,249 @@ +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) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index acd3d81..f9058ba 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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,6 +811,12 @@ 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) == "" { @@ -865,6 +878,27 @@ 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. @@ -966,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++ } } @@ -1081,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" } From 6160e683f7a33034c8111749f5dc33ac061ddc84 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 9 Oct 2026 07:15:59 +0200 Subject: [PATCH 18/18] test(tui): cover draft, help and cockpit edge paths Exercise the remaining branch paths: a superseded event with no streamed draft, the unknown-reason label, the selected folded draft hint and its copy span, narrow help rows, and the cockpit start-time line. Co-Authored-By: Claude Opus 5.5 --- internal/tui/superseded_test.go | 49 ++++++++++++++++++++++++++++++ internal/tui/visual_polish_test.go | 21 +++++++++++++ 2 files changed, 70 insertions(+) diff --git a/internal/tui/superseded_test.go b/internal/tui/superseded_test.go index 2a6fdcb..7903481 100644 --- a/internal/tui/superseded_test.go +++ b/internal/tui/superseded_test.go @@ -247,3 +247,52 @@ func TestPlainModeSupersededDraft(t *testing.T) { 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/visual_polish_test.go b/internal/tui/visual_polish_test.go index 020ed03..08e6e9a 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -5,6 +5,7 @@ import ( "math" "strings" "testing" + "time" "github.com/BackendStack21/bodek/internal/client" "github.com/charmbracelet/lipgloss" @@ -189,3 +190,23 @@ func TestHighRiskApprovalBorderIsRed(t *testing.T) { 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) + } +}