diff --git a/README.md b/README.md index a8cabc1..cf4e3dc 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ bodek --notify # desktop notifications (OSC 9 bodek --theme ember-light # start with a theme (/theme switches live) bodek --verbosity quiet # calmer start: info notes hidden bodek --plain # linear mode: transcript to scrollback (a11y, pipes) +bodek --reduce-motion # calmer transcript: clock lane at 2s, no accent pulses bodek -- --prompt-caching # pass extra flags through to `odek serve` bodek version # print the bodek version bodek upgrade # download and install the latest release diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index d2e3f29..507f394 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -44,6 +44,7 @@ type config struct { thinking string // startup reasoning depth (empty = inherit / seed from serve) fresh bool // --new: skip last-session resume resume bool // --resume: opt back in to last-session resume (default false) + reduceMot bool // --reduce-motion: calmer transcript (slower clock lane, no accent pulses) extraArgs []string persist settings.Settings // the loaded file, re-saved when /theme switches @@ -82,6 +83,7 @@ func parseConfig(args []string, output io.Writer) (config, error) { fs.StringVar(&cfg.verbosity, "verbosity", verbDefault, "noise dial: quiet (info notes hidden, compact steps), normal, detailed (steps expand) — /verbosity switches at runtime and persists") fs.StringVar(&cfg.thinking, "thinking", st.Thinking, "reasoning depth: disabled, low, medium, high — /thinking and ^T switch at runtime and persist") fs.BoolVar(&cfg.resume, "resume", st.Bool(st.Resume, false), "resume this directory's last session on start (--resume=false disables; off by default)") + fs.BoolVar(&cfg.reduceMot, "reduce-motion", st.Bool(st.ReduceMotion, false), "calm transcript for motion-sensitive readers: clock lane ticks at 2s, no accent pulses") fs.BoolVar(&cfg.fresh, "new", false, "start a fresh session (always skips last-session resume)") fs.Usage = func() { _, _ = fmt.Fprintf(fs.Output(), "Usage: bodek [options] [-- ]\n\n") @@ -215,19 +217,20 @@ func run() error { setupSignalHandler(srv, cl) model := tui.New(cl, tui.Options{ - Sandbox: cfg.sandbox, - CWD: cwd, - LogPath: logPath, - OdekVersion: srv.Version, - Version: currentVersion(), - Bell: cfg.bel, - Notify: cfg.notify, - Plain: cfg.plain, - Theme: cfg.theme, - Verbosity: cfg.verbosity, - Thinking: cfg.thinking, - Workspace: workspace.Open(), - Fresh: cfg.fresh || !cfg.resume, + Sandbox: cfg.sandbox, + CWD: cwd, + LogPath: logPath, + OdekVersion: srv.Version, + Version: currentVersion(), + Bell: cfg.bel, + Notify: cfg.notify, + Plain: cfg.plain, + ReduceMotion: cfg.reduceMot, + Theme: cfg.theme, + Verbosity: cfg.verbosity, + Thinking: cfg.thinking, + Workspace: workspace.Open(), + Fresh: cfg.fresh || !cfg.resume, OnThemeChange: func(name string) error { cfg.persist.Theme = name return settings.Save(cfg.persist) diff --git a/internal/settings/settings.go b/internal/settings/settings.go index ad80262..7c9b5c2 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -26,6 +26,9 @@ type Settings struct { Verbosity string `json:"verbosity,omitempty"` Thinking string `json:"thinking,omitempty"` Resume *bool `json:"resume,omitempty"` + // ReduceMotion dials the transcript's live-motion cadence down + // (clock lane >= 2s, no accent pulse on the new-output row). + ReduceMotion *bool `json:"reduceMotion,omitempty"` } // Path returns the settings file location: $BODEK_CONFIG if set, else diff --git a/internal/tui/approval_expiry.go b/internal/tui/approval_expiry.go index 232b66b..da619f0 100644 --- a/internal/tui/approval_expiry.go +++ b/internal/tui/approval_expiry.go @@ -39,6 +39,7 @@ func approvalTTL(ev client.Event) time.Duration { // the closest client-side approximation (single-digit ms skew locally). func (m *Model) stampApprovalDeadline(ev client.Event) { m.apprDeadlines = append(m.apprDeadlines, time.Now().Add(approvalTTL(ev))) + m.apprBellFired = false // a fresh request re-arms the urgent-window BEL } // apprSecondsLeft is the queue head's remaining lifetime in whole seconds @@ -100,14 +101,31 @@ func (m *Model) handleApprovalExpiry(now time.Time) tea.Cmd { m.approvals = kept m.apprDeadlines = keptDL + // (A3) the countdown entering its urgent window (< 10s left) rings the + // bell exactly once per request — a tick inside the window must not + // re-fire, and expiry pruning below stays silent. + var cmds []tea.Cmd + if !m.apprBellFired && len(m.approvals) > 0 { + if secs := m.apprSecondsLeft(); secs > 0 && secs <= approvalUrgentSecs { + m.apprBellFired = true + if a := m.attentionFor(attentionApproval); !a.empty() { + a.title, a.notify = "", "" // the card is already on screen — bell only + cmds = append(cmds, m.attentionCmd(a)) + } + } + } + if dropped == 0 { - return nil + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) } // Operator-facing: the card they were staring at just vanished. Use // transientNoteCmd so quiet does not swallow it and so the sweep // arms from Update (addTransientNote would leave a sticky idle note). const note = "approval expired · odek will find an alternative" - cmds := []tea.Cmd{m.transientNoteCmd(note)} + cmds = append(cmds, m.transientNoteCmd(note)) if len(m.approvals) > 0 { m.setRunStatus("approval required") } else if m.busy { diff --git a/internal/tui/attention.go b/internal/tui/attention.go index dcb2185..8ae7acb 100644 --- a/internal/tui/attention.go +++ b/internal/tui/attention.go @@ -15,6 +15,7 @@ const ( attentionApproval // an approval is waiting (approval_request) attentionJobDone // a background job exited cleanly (jobs watcher) attentionJobFailed // a background job failed / timed out / was killed + attentionFailed // a turn ended in error (error event) ) // attention is the plan of terminal-attention effects for one state change. @@ -64,6 +65,8 @@ func (m *Model) attentionFor(kind attentionKind) attention { prefix, note = "✓ bg job done", "bodek: background job finished" case attentionJobFailed: prefix, note = "✗ bg job failed", "bodek: background job failed" + case attentionFailed: + prefix, note = "✗ turn failed", "bodek: turn failed" default: return attention{} } diff --git a/internal/tui/chrome.go b/internal/tui/chrome.go index 7cdecb5..10b9497 100644 --- a/internal/tui/chrome.go +++ b/internal/tui/chrome.go @@ -121,12 +121,10 @@ func (m *Model) shelfView() string { } chips = append(chips, th.headerKey.Render("📎 "+truncate(strings.Join(names, " · "), max(m.width/3, 12)))) } - if n := len(m.queue); n > 0 && !m.qfocus && m.curApproval() == nil && m.panel != panelQueue { + if n := len(m.queue); n > 0 && !m.qfocus && m.panel != panelQueue { chips = append(chips, th.scroll.Render(fmt.Sprintf("▸ %d queued", n))) } - if m.busy && !m.vp.AtBottom() { - chips = append(chips, th.scroll.Render("↓ new output")) - } + // (F3) new-output lives on one steady footer row — no shelf duplicate. if m.skillSuggest != nil { name := m.skillSuggest.SkillName if name == "" { diff --git a/internal/tui/chrome_test.go b/internal/tui/chrome_test.go index 4f51795..a4839fc 100644 --- a/internal/tui/chrome_test.go +++ b/internal/tui/chrome_test.go @@ -137,7 +137,7 @@ func TestSessionHomeAfterClear(t *testing.T) { if strings.Contains(out, "fix the flaky test") == false { t.Errorf("session home missing last prompt:\n%s", out) } - if !strings.Contains(out, "touched") { + if !strings.Contains(out, "✎") { t.Errorf("session home missing receipt:\n%s", out) } if !strings.Contains(out, "session") { diff --git a/internal/tui/cosmetics_test.go b/internal/tui/cosmetics_test.go index 15e413e..2b73f01 100644 --- a/internal/tui/cosmetics_test.go +++ b/internal/tui/cosmetics_test.go @@ -138,8 +138,10 @@ func TestApprovalHeadDropsQueuedChip(t *testing.T) { if strings.Contains(body, "queued") { t.Errorf("approval card head still carries the queued count:\n%s", body) } + // F1: the shelf chip is the single owner of the PROMPT-queue count — the + // footer must not repeat it (the approval head's own queue hint stays). foot := plain(m.footer()) - if !strings.Contains(foot, "1 queued") { - t.Errorf("footer lost the queued count:\n%s", foot) + if strings.Contains(foot, "▸") && strings.Contains(foot, "queued") { + t.Errorf("footer still carries the prompt-queue count:\n%s", foot) } } diff --git a/internal/tui/events.go b/internal/tui/events.go index f3220b5..f7679ac 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -58,6 +58,7 @@ func (m *Model) ingestWireEvent(ev client.Event) (tea.Model, tea.Cmd) { // listen inside another Batch left execBatchMsg waiting on a nested listen // and the header's ready badge never came back after Hi. func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) { + m.lastEvent = time.Now() // (R5) the head's last-event age resets on each batch var model tea.Model = m for _, ev := range events { var cmd tea.Cmd @@ -382,6 +383,13 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // classified card renders below a partial reply, or as the // turn's only content. No side-note degradation. setTurnMarker(&m.msgs[i], m.errorCard(ev.Message)) + m.msgs[i].failed = true // ✗ marks the head for this session; a resumed transcript re-derives nothing + // (A3) one BEL per failed turn: the guard latches until the next + // turn opens, so trailing retries cannot re-ring it. + if !m.failBellFired { + m.failBellFired = true + attn = m.attentionCmd(m.attentionFor(attentionFailed)) + } } } else if !cancelled { m.addNote("error: " + ev.Message) @@ -651,9 +659,10 @@ func (m *Model) beginWireTurn(wake bool) { m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: wake}) m.curIdx = len(m.msgs) - 1 m.busy = true - m.cancelAck = false // a wake run's errors are real errors again - m.skillSuggest = nil // the suggestion's window closed with the last turn - m.wakeArmed = false // consumed: the marker lives on the card now + m.cancelAck = false // a wake run's errors are real errors again + m.failBellFired = false // a fresh turn re-arms the failure BEL + m.skillSuggest = nil // the suggestion's window closed with the last turn + m.wakeArmed = false // consumed: the marker lives on the card now if wake { m.status = "waking for bg job" } else { @@ -1167,11 +1176,18 @@ func (m *Model) transientNoteCmd(s string) tea.Cmd { func (m *Model) pushNote(s string, exp time.Time) { // Provider errors routinely embed full 4xx bodies — cap the size so one // verbose notice cannot flood the transcript tail (count caps below). + // Tier rides the deadline: alert dwell ⇒ alert tier (errors, + // disconnects) outranking newer transients in the one-line pick. The + // threshold sits between noticeTTL and alertTTL so scheduling jitter + // cannot misfile either tier. + alert := time.Until(exp) > noticeTTL m.notices = append(m.notices, truncate(sanitize(s), 400)) m.noticeExp = append(m.noticeExp, exp) + m.noticeAlert = append(m.noticeAlert, alert) if len(m.notices) > 6 { m.notices = m.notices[len(m.notices)-6:] m.noticeExp = m.noticeExp[len(m.noticeExp)-6:] + m.noticeAlert = m.noticeAlert[len(m.noticeAlert)-6:] } } @@ -1179,14 +1195,17 @@ func (m *Model) pushNote(s string, exp time.Time) { func (m *Model) pruneNotices(now time.Time) { kept := m.notices[:0] keptExp := m.noticeExp[:0] + keptAlert := m.noticeAlert[:0] for i, n := range m.notices { if exp := m.noticeExp[i]; exp.IsZero() || now.Before(exp) { kept = append(kept, n) keptExp = append(keptExp, exp) + keptAlert = append(keptAlert, m.noticeAlert[i]) } } m.notices = kept m.noticeExp = keptExp + m.noticeAlert = keptAlert } // noticeSweep schedules the next expiry sweep at the earliest pending diff --git a/internal/tui/input.go b/internal/tui/input.go index 42e4588..fefd017 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -474,9 +474,10 @@ func (m *Model) sendPrompt(text string) tea.Cmd { m.ta.Reset() m.closeAC() m.busy = true - m.cancelAck = false // a fresh run's errors are real errors again - m.skillSuggest = nil // the suggestion's window closed with the turn - m.wakeArmed = false // a local send is never a wake turn + m.cancelAck = false // a fresh run's errors are real errors again + m.failBellFired = false // a fresh local turn re-arms the failure BEL + m.skillSuggest = nil // the suggestion's window closed with the turn + m.wakeArmed = false // a local send is never a wake turn m.status = "thinking" m.runStart = time.Now() if m.sessionStart.IsZero() { diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index a11c368..7eac09c 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -737,19 +737,15 @@ func agentStatusGlyph(phase, status string) string { if phase == "queued" { return "◔" // ◔ not ◌: the lamp glyphs belong to the connection state } - return "⟳" + return "▸" } switch status { case "success": return "✓" case "partial", "budget_exhausted": return "◐" - case "error": + case "error", "cancelled", "timeout": return "✗" - case "cancelled": - return "⊘" - case "timeout": - return "⏱" } return "•" } diff --git a/internal/tui/model.go b/internal/tui/model.go index e2feafc..524b52f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -120,6 +120,7 @@ type message struct { sentAt time.Time // user turns: when the prompt was submitted (drives the head's age) 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) } // Options carries startup display info into the model. @@ -137,6 +138,10 @@ type Options struct { Bell bool Notify bool + // ReduceMotion slows the transcript's live-motion surfaces (clock lane + // cadence, accent pulses) for motion-sensitive readers (--reduce-motion). + ReduceMotion bool + // Theme names the startup palette (ember-dark, ember-light, // high-contrast, classic). Empty defers to BODEK_THEME, then the // settings file — the same order /theme persists into. @@ -182,14 +187,15 @@ type Options struct { // Model is the Bubble Tea model for bodek. type Model struct { - cl *client.Client - events <-chan client.Event - opts Options - th theme - tokens *tokens.Store - bell bool // terminal bell on done / approval (--bel) - notify bool // OSC 9 desktop notifications (--notify) - plain bool // linear mode: scrollback transcript, minimal chrome (--plain) + cl *client.Client + events <-chan client.Event + opts Options + th theme + tokens *tokens.Store + bell bool // terminal bell on done / approval (--bel) + notify bool // OSC 9 desktop notifications (--notify) + plain bool // linear mode: scrollback transcript, minimal chrome (--plain) + reduceMotion bool // calmer transcript: slower clock lane, no accent pulses width, height int ready bool @@ -204,9 +210,13 @@ type Model struct { busy bool wakeArmed bool // bg_wake seen but its turn not carded yet: arms the lazy wake marker runStart time.Time + lastEvent time.Time // (R5) last eventBatchMsg arrival; drives the stale-age head segment lastTool string lastArg string + failBellFired bool // (A3) the failure BEL rang for this turn — guard against double-fire + apprBellFired bool // (A3) the urgent-window BEL rang for this approval head + approvals []client.Event // pending approval queue — odek runs parallel tools, so requests FIFO apprDeadlines []time.Time // per-approval expiry, stamped on arrival (parallel to approvals) apprSel int // highlighted option in the approval panel @@ -361,13 +371,15 @@ type Model struct { planReqSeq int // fetch request sequence planPollSeq int // armed poll tick sequence - status string - notices []string - noticeExp []time.Time // parallel to notices; when each one fades - hintsShown map[string]bool // JIT hints already delivered (hints.go) - verbosity int // noise dial: 0 normal · 1 quiet · 2 detailed - disconn bool - quitting bool + status string + notices []string + noticeExp []time.Time // parallel to notices; when each one fades + noticeAlert []bool // parallel to notices; true = alert tier (addNote) + hintsShown map[string]bool // JIT hints already delivered (hints.go) + verbosity int // noise dial: 0 normal · 1 quiet · 2 detailed + disconn bool + reconnAttempt int // current redial attempt index (drives the status-line backoff readout) + quitting bool gradRule string // cached full-width gradient rule gradRuleW int @@ -452,6 +464,7 @@ func New(cl *client.Client, opts Options) *Model { bodekVersion: opts.Version, bell: opts.Bell, notify: opts.Notify, + reduceMotion: opts.ReduceMotion, plain: opts.Plain, } m.restoreWorkspace() @@ -1437,10 +1450,37 @@ func (m *Model) elapsed() string { return "" } d := time.Since(m.runStart) + var s string if d < time.Minute { - return fmt.Sprintf("running %ds", int(d.Seconds())) + s = fmt.Sprintf("running %ds", int(d.Seconds())) + } else { + s = fmt.Sprintf("running %dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + } + // (R5) the last-event age rides the same segment: whole seconds since + // the last event batch, only while busy and past the staleness threshold. + // Rides the slow tail-clock lane with the elapsed counter (no extra row). + if a := m.staleAge(); a != "" { + s += " " + a + } + return s +} + +// staleEventThreshold is how long a busy turn may stay silent before the +// head admits it: below this the stream reads as merely between tokens. +const staleEventThreshold = 5 * time.Second + +// staleAge renders the '· Ns' last-event age for the streaming head: the +// whole seconds since the last eventBatchMsg landed, only when the turn is +// busy and the gap exceeds staleEventThreshold. Empty otherwise. +func (m *Model) staleAge() string { + if !m.busy || m.lastEvent.IsZero() { + return "" + } + d := time.Since(m.lastEvent) + if d < staleEventThreshold { + return "" } - return fmt.Sprintf("running %dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + return fmt.Sprintf("· %ds", int(d.Seconds())) } // sanitize strips terminal-hostile content from untrusted text before it is diff --git a/internal/tui/narrative.go b/internal/tui/narrative.go index 68faad4..53ba2c7 100644 --- a/internal/tui/narrative.go +++ b/internal/tui/narrative.go @@ -221,13 +221,13 @@ func scanReceipt(msg message) receipt { func formatReceipt(r receipt) string { var parts []string if r.files > 0 { - parts = append(parts, fmt.Sprintf("touched %d", r.files)) + parts = append(parts, fmt.Sprintf("✎ %d", r.files)) } if r.hasDiff { parts = append(parts, fmt.Sprintf("+%d −%d", r.adds, r.dels)) } if r.tests != "" { - parts = append(parts, "tests "+r.tests) + parts = append(parts, r.tests+" tests") } return strings.Join(parts, " · ") } diff --git a/internal/tui/narrative_test.go b/internal/tui/narrative_test.go index 02399b0..159438a 100644 --- a/internal/tui/narrative_test.go +++ b/internal/tui/narrative_test.go @@ -83,7 +83,7 @@ func TestTurnReceipt(t *testing.T) { t.Errorf("tests = %q, want ✓", r.tests) } got := formatReceipt(r) - for _, want := range []string{"touched 2", "+", "−", "tests ✓"} { + for _, want := range []string{"✎ 2", "+", "−", "✓ tests"} { if !strings.Contains(got, want) { t.Errorf("receipt %q missing %q", got, want) } @@ -97,7 +97,7 @@ func TestCollapseSummaryPrefersReceipt(t *testing.T) { steps: []step{{name: "write_file", arg: "events.go", done: true, result: "ok"}}, } got := m.collapseSummary(msg) - if !strings.Contains(got, "touched 1") { + if !strings.Contains(got, "✎ 1") { t.Errorf("folded card should use the receipt: %q", got) } if strings.Contains(got, "reply:") { @@ -239,7 +239,7 @@ func TestReceiptRidesTurnHead(t *testing.T) { } out := plain(func() string { s, _ := m.renderMessage(msg, 0, 0); return s }()) head := strings.Split(out, "\n")[0] - if !strings.Contains(head, "touched 1") { + if !strings.Contains(head, "✎ 1") { t.Errorf("turn head missing receipt: %q", head) } } diff --git a/internal/tui/promptflow_test.go b/internal/tui/promptflow_test.go index 56f8b5a..92fcbbd 100644 --- a/internal/tui/promptflow_test.go +++ b/internal/tui/promptflow_test.go @@ -170,8 +170,11 @@ func TestSubmitWhileBusyQueues(t *testing.T) { if len(m.msgs) != 2 { t.Error("queued prompt must not enter the transcript before it is sent") } - if foot := plain(m.footer()); !strings.Contains(foot, "1 queued") { - t.Errorf("footer missing queued indicator: %q", foot) + if foot := plain(m.footer()); strings.Contains(foot, "queued") { + t.Errorf("footer must not repeat the queue count: %q", foot) + } + if shelf := plain(m.shelfView()); !strings.Contains(shelf, "1 queued") { + t.Errorf("shelf chip missing queued count: %q", shelf) } } diff --git a/internal/tui/queue_visibility_test.go b/internal/tui/queue_visibility_test.go index 530c817..6f21343 100644 --- a/internal/tui/queue_visibility_test.go +++ b/internal/tui/queue_visibility_test.go @@ -7,10 +7,10 @@ import ( "github.com/BackendStack21/bodek/internal/client" ) -// TestQueuedCountOnStatusLine verifies the in-flight status row — the line -// the eyes are on while a turn runs — carries the queue depth, and that the -// count clears once the turn ends and the queue drains into the next turn. -func TestQueuedCountOnStatusLine(t *testing.T) { +// TestQueuedCountOnShelfChip verifies the queue depth has a single owner: +// the shelf chip above the composer carries it while a turn runs, the count +// steps down as turns drain the queue, and the chip disappears when empty. +func TestQueuedCountOnShelfChip(t *testing.T) { m := newTestModel() busyTurn(m) @@ -18,11 +18,11 @@ func TestQueuedCountOnStatusLine(t *testing.T) { m.ta.SetValue(p) m.submit() } - if line := plain(m.statusLine()); !strings.Contains(line, "2 queued") { - t.Errorf("status line missing queued count: %q", line) + if line := plain(m.statusLine()); strings.Contains(line, "queued") { + t.Errorf("status line must not repeat the queue count: %q", line) } - if foot := plain(m.footer()); !strings.Contains(foot, "2 queued") { - t.Errorf("footer missing queued count: %q", foot) + if shelf := plain(m.shelfView()); !strings.Contains(shelf, "2 queued") { + t.Errorf("shelf chip missing queued count: %q", shelf) } // Each turn-end drains exactly one queued prompt: the count steps down @@ -31,15 +31,15 @@ func TestQueuedCountOnStatusLine(t *testing.T) { if len(m.queue) != 1 { t.Fatalf("one done should drain one prompt, queue = %v", m.queue) } - if line := plain(m.statusLine()); !strings.Contains(line, "1 queued") { - t.Errorf("status line should show the remaining prompt: %q", line) + if shelf := plain(m.shelfView()); !strings.Contains(shelf, "1 queued") { + t.Errorf("shelf chip should show the remaining prompt: %q", shelf) } m.handleEvent(client.Event{Type: "done", Latency: 1}) if len(m.queue) != 0 { t.Fatalf("queue should be empty now, got %v", m.queue) } - if line := plain(m.statusLine()); strings.Contains(line, "queued") { - t.Errorf("status line still shows a queue after the drain: %q", line) + if shelf := plain(m.shelfView()); strings.Contains(shelf, "queued") { + t.Errorf("shelf chip still shows a queue after the drain: %q", shelf) } } diff --git a/internal/tui/realtime_tabs_test.go b/internal/tui/realtime_tabs_test.go index 0e0dadf..9aad576 100644 --- a/internal/tui/realtime_tabs_test.go +++ b/internal/tui/realtime_tabs_test.go @@ -238,7 +238,7 @@ func TestAgentGlyphFollowsCard(t *testing.T) { {TaskID: "t1", Phase: "active", Status: "running", Goal: "g"}, }}) rows := m.agentRowsRender(120) - if strings.Contains(strings.Join(rows, " "), "⟳") { + if strings.Contains(strings.Join(rows, " "), "▸") { t.Errorf("running glyph shown for a finished card: %q", rows) } } diff --git a/internal/tui/reconnect.go b/internal/tui/reconnect.go index a0c6130..6e18be9 100644 --- a/internal/tui/reconnect.go +++ b/internal/tui/reconnect.go @@ -35,6 +35,7 @@ func (m *Model) scheduleReconnect(attempt int) tea.Cmd { if hook == nil { return nil } + m.reconnAttempt = attempt // the status line's backoff readout follows the chain return tea.Tick(reconnectBackoff(attempt), func(time.Time) tea.Msg { cl, err := hook() return reconnectMsg{attempt: attempt, cl: cl, err: err} diff --git a/internal/tui/refinements_r1_r5_test.go b/internal/tui/refinements_r1_r5_test.go new file mode 100644 index 0000000..5fc63c7 --- /dev/null +++ b/internal/tui/refinements_r1_r5_test.go @@ -0,0 +1,219 @@ +package tui + +// R1–R5 transcript-signal refinements, written RED-first. Each test pins one +// refinement: generalized verdict chips (build/vet/race), collapsed dot +// tallies, per-child agent state glyphs, chip-style receipts, and the +// last-event age stamp on the streaming head. + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ── R1: verdict chips for build / vet / lint / race ───────────────────────── + +func TestBuildVerdictChips(t *testing.T) { + th := newTheme() + cases := []struct { + name, arg, result, want string + }{ + {"build pass (silent)", "go build ./...", "", "built"}, + {"build no output marker", "go build ./...", "(no output)", "built"}, + {"build fail", "go build ./...", + "# github.com/x/y\ny.go:9:2: undefined: Foo\nexit status 1", "build failed"}, + {"build fail rust", "cargo build", "error[E0432]: unresolved import `x`", "build failed"}, + {"build tsc fail", "tsc", "src/a.ts(3,7): error TS2322: Type '1' is not assignable", "build failed"}, + {"build fail exit only", "make build", "some noise\nexit status 2", "build failed"}, + {"no fabricated success", "go build ./...", "0 issues.", ""}, + {"build needs gate", "cat build.log", "y.go:9:2: undefined: Foo", ""}, + {"vet pass", "go vet ./...", "", "vet"}, + {"vet fail", "go vet ./...", + "# github.com/x/y\ny.go:5:2: Printf call has arguments but no formatting directives", "vet failed"}, + {"vet needs gate", "grep vet notes.txt", "", ""}, + } + for _, tc := range cases { + got := plain(stepHeadSuffix("shell", tc.arg, tc.result, th)) + if tc.want == "" { + if got != "" { + t.Errorf("%s: chip = %q, want none", tc.name, got) + } + continue + } + if !strings.Contains(got, tc.want) { + t.Errorf("%s: chip %q missing %q", tc.name, got, tc.want) + } + } +} + +func TestRaceVerdictChips(t *testing.T) { + th := newTheme() + if got := plain(stepHeadSuffix("shell", "go test -race ./...", + "WARNING: DATA RACE\nWrite at 0x00c000 by goroutine 7:", th)); got != "race detected" { + t.Errorf("race fail chip = %q, want race detected", got) + } + if got := plain(stepHeadSuffix("shell", "go test -race ./...", + "ok \texample.com/pkg\t1.5s", th)); got != "✓ tests pass · race" { + t.Errorf("race pass chip = %q, want ✓ tests pass · race", got) + } + // Plain test runs stay untouched — the race suffix is flag-gated. + if got := plain(stepHeadSuffix("shell", "go test ./...", + "ok \texample.com/pkg\t1.5s", th)); got != "✓ tests pass" { + t.Errorf("plain pass chip = %q", got) + } +} + +func TestLintChipVocabulary(t *testing.T) { + th := newTheme() + if got := plain(stepHeadSuffix("shell", "golangci-lint run", "0 issues.", th)); got != "✓ lint" { + t.Errorf("lint clean = %q, want ✓ lint", got) + } + if got := plain(stepHeadSuffix("shell", "make lint", "2 issues.", th)); got != "lint 2" { + t.Errorf("lint issues = %q, want lint 2", got) + } +} + +// ── R2: collapsed dot tallies ─────────────────────────────────────────────── + +func TestCollapseTallyDots(t *testing.T) { + m := newTestModel() + msg := message{ + role: roleAsst, collapsed: true, + steps: []step{ + {name: "read_file", arg: "a.go", done: true, result: "ok"}, + {name: "read_file", arg: "b.go", done: true, result: "ok"}, + {name: "read_file", arg: "c.go", done: true, result: "boom", isErr: true}, + }, + } + got := m.collapseSummary(msg) + if !strings.Contains(got, "3 tool steps · ··✗") { + t.Errorf("collapsed summary missing dot tally: %q", got) + } + // Sanity: all-fine turn shows no ✗. + ok := message{role: roleAsst, collapsed: true, + steps: []step{{name: "read_file", arg: "a.go", done: true, result: "ok"}}} + if !strings.Contains(m.collapseSummary(ok), "1 tool step · ·") { + t.Errorf("single-step tally missing: %q", m.collapseSummary(ok)) + } + // Capped width: 40 steps collapse to cap glyphs + ellipsis head, not 40. + var many []step + for i := 0; i < 40; i++ { + many = append(many, step{name: "read_file", arg: "x.go", done: true, result: "ok"}) + } + tal := stepTally(message{steps: many}) + if w := lipgloss.Width(tal); w > 26 { + t.Errorf("tally width %d exceeds cap", w) + } + if !strings.Contains(tal, "…") { + t.Errorf("capped tally should carry an ellipsis head: %q", tal) + } +} + +// ── R3: per-child agent state glyphs ──────────────────────────────────────── + +func TestAgentStateGlyphVocabulary(t *testing.T) { + cases := []struct { + card agentCard + glyph string + }{ + {agentCard{phase: "queued", status: "queued"}, "◔"}, + {agentCard{phase: "active", status: "running"}, "▸"}, + {agentCard{phase: "finished", status: "success"}, "✓"}, + {agentCard{phase: "finished", status: "error"}, "✗"}, + {agentCard{phase: "finished", status: "cancelled"}, "✗"}, + {agentCard{phase: "finished", status: "timeout"}, "✗"}, + {agentCard{phase: "active", status: "running", lost: true}, "✗"}, + } + for _, tc := range cases { + a := tc.card + if got := a.glyph(); got != tc.glyph { + t.Errorf("phase %q status %q glyph = %q, want %q", a.phase, a.status, got, tc.glyph) + } + } + // The chip strip passes the lost card through with its dim marker. + s := &step{subagent: true, agents: []*agentCard{ + {taskID: "t1", idx: 0, phase: "active", status: "running", lost: true}, + }} + chips := s.agentChips() + if len(chips) != 1 || !chips[0].dim || chips[0].glyph != "✗" { + t.Errorf("lost card chip = %#v, want dim ✗", chips) + } +} + +// ── R4: chip-style turn receipt ───────────────────────────────────────────── + +func TestReceiptChips(t *testing.T) { + r := receipt{files: 2, adds: 3, dels: 1, hasDiff: true, tests: "✓"} + got := formatReceipt(r) + if got != "✎ 2 · +3 −1 · ✓ tests" { + t.Errorf("receipt = %q, want chip form ✎ 2 · +3 −1 · ✓ tests", got) + } + if got := formatReceipt(receipt{}); got != "" { + t.Errorf("empty receipt should render empty, got %q", got) + } +} + +// ── R5: last-event age on the streaming head ──────────────────────────────── + +func staleFixture() (*Model, func(time.Time)) { + m := newTestModel() + m.ta.Focus() + m.msgs = append(m.msgs, + message{role: roleUser, content: "hi"}, + message{role: roleAsst, streaming: true}, + ) + m.curIdx = 1 + m.busy = true + return m, func(last time.Time) { + m.runStart = time.Now().Add(-10 * time.Second) + m.lastEvent = last + m.handleEvent(client.Event{Type: "thinking", Content: "hmm"}) + } +} + +func TestLastEventAgeStaleHead(t *testing.T) { + m, set := staleFixture() + set(time.Now().Add(-9 * time.Second)) + rendered, _ := m.renderMessage(m.msgs[1], 1, 0) + head := strings.Split(plain(rendered), "\n")[0] + if !strings.Contains(head, "· 9s") { + t.Errorf("stale head missing last-event age: %q", head) + } + + // Fresh: no age segment rides the head. + set(time.Now()) + rendered, _ = m.renderMessage(m.msgs[1], 1, 0) + head = strings.Split(plain(rendered), "\n")[0] + if strings.Contains(head, "· ") { + t.Errorf("fresh head must not carry an age segment: %q", head) + } + + // Idle: the age never renders when not busy. + m.busy = false + m.lastEvent = time.Now().Add(-30 * time.Second) + rendered, _ = m.renderMessage(m.msgs[1], 1, 0) + head = strings.Split(plain(rendered), "\n")[0] + if strings.Contains(head, "· ") { + t.Errorf("idle head must not carry an age segment: %q", head) + } +} + +func TestLastEventStampsOnBatch(t *testing.T) { + m, set := staleFixture() + set(time.Time{}) + if !m.lastEvent.IsZero() { + t.Fatal("fixture should start with a zero stamp") + } + m.ingestWireBatch([]client.Event{{Type: "thinking", Content: "x"}}) + if m.lastEvent.IsZero() { + t.Error("eventBatchMsg must stamp lastEvent") + } + // Under the stale threshold the age stays hidden even when busy. + if m.staleAge() != "" { + t.Errorf("fresh stamp must not report an age: %q", m.staleAge()) + } +} diff --git a/internal/tui/renderers.go b/internal/tui/renderers.go index 6d366ad..1084376 100644 --- a/internal/tui/renderers.go +++ b/internal/tui/renderers.go @@ -292,14 +292,21 @@ var ( gitNewRefRe = regexp.MustCompile(`\[(?:new branch|new tag)\][ \t]+(\S+)[ \t]+->`) lintIssuesRe = regexp.MustCompile(`^(\d+) issues?\.?:?$`) eslintProblemsRe = regexp.MustCompile(`✖ (\d+) problems`) - warnEmittedRe = regexp.MustCompile(`^warning: (\d+) warnings? emitted\.?$`) - warnGeneratedRe = regexp.MustCompile(`^(\d+) warnings? generated\.?$`) - httpStatusRe = regexp.MustCompile(`(?i)^HTTP/[\d.]+ (\d{3})`) - wgetStatusRe = regexp.MustCompile(`awaiting response\.\.\.?[ \t]?(\d{3})`) - searchHitsRe = regexp.MustCompile(`found (\d+) matches`) - planHeaderRe = regexp.MustCompile(`^\[Current plan:\s*v(\d+)\s+—\s+(\d+)/(\d+) done,\s+(\d+) blocked\.`) - planCompleteRe = regexp.MustCompile(`^\[Current plan:\s*v(\d+)\s+—\s+all\s+(\d+)\s+steps?\s+complete\.`) - planStepRe = regexp.MustCompile(`^(\S+)\s+\[([^\]]+)\]\s*(.*)$`) + // Build / vet verdict patterns: compiler errors (go/rust/tsc shapes) and + // failed exits mark a failed build; vet diagnostics are file:line: col. + // Success rides the silent-output convention instead of a pattern. + // '# pkg' headers only count when a file:line diagnostic follows — a + // markdown heading alone must not paint 'build failed'. + buildFailRe = regexp.MustCompile(`(?m)^(?:#\s+\S[^\n]*\n[^\n]*:\d+:\d+: |error\[E\d+\]|ERROR:|exit status \d+)|(?:^|\n)[^\n]*:\d+:\d+: [^\n]*\berror\b|(?:^|\n)[^\n]*\(\d+,\d+\): error TS`) + vetFailRe = regexp.MustCompile(`(?m)^[^\n]*\.go:\d+:\d+: `) + warnEmittedRe = regexp.MustCompile(`^warning: (\d+) warnings? emitted\.?$`) + warnGeneratedRe = regexp.MustCompile(`^(\d+) warnings? generated\.?$`) + httpStatusRe = regexp.MustCompile(`(?i)^HTTP/[\d.]+ (\d{3})`) + wgetStatusRe = regexp.MustCompile(`awaiting response\.\.\.?[ \t]?(\d{3})`) + searchHitsRe = regexp.MustCompile(`found (\d+) matches`) + planHeaderRe = regexp.MustCompile(`^\[Current plan:\s*v(\d+)\s+—\s+(\d+)/(\d+) done,\s+(\d+) blocked\.`) + planCompleteRe = regexp.MustCompile(`^\[Current plan:\s*v(\d+)\s+—\s+all\s+(\d+)\s+steps?\s+complete\.`) + planStepRe = regexp.MustCompile(`^(\S+)\s+\[([^\]]+)\]\s*(.*)$`) ) // structuredToolItem is the small, display-oriented subset shared by odek's @@ -1051,8 +1058,16 @@ func stepDetail(name, result string, width int, th theme) []string { // stepHeadSuffix renders the typed chip a step line gains from its result: // a diffstat for diffs; a pass/fail summary for test runs; arg-gated git, // lint, warning, and HTTP hints for shell steps; a hit count for searches. -// At most one chip per step, in that precedence. +// At most one chip per step, in that precedence. isErr carries the step's +// raw failure state: a failed step never paints a success-flavored chip — +// success is only claimed when the result's own metadata says so. +// stepHeadSuffix is the isErr=false convenience for callers without raw +// failure state; the chip logic lives in stepHeadSuffixFor. func stepHeadSuffix(name, arg, result string, th theme) string { + return stepHeadSuffixFor(name, arg, result, false, th) +} + +func stepHeadSuffixFor(name, arg, result string, isErr bool, th theme) string { if strings.EqualFold(strings.TrimSpace(name), "plan") { if chip := planHeadSuffix(result, th); chip != "" { return chip @@ -1075,17 +1090,26 @@ func stepHeadSuffix(name, arg, result string, th theme) string { } return "" } - if s, ok := testSummary(result); ok { + if raceFlagged(arg) && strings.Contains(result, "WARNING: DATA RACE") { + return th.stepErr.Render("race detected") + } + if s, ok := testSummary(result); ok && (!isErr || strings.HasPrefix(s, "✗")) { + // isErr suppresses pass verdicts: a failed step never claims a pass. if strings.HasPrefix(s, "✗") { // The step's status icon already flags the failure — the chip // names what failed, without a second ✗. return th.stepErr.Render(strings.TrimPrefix(s, "✗ ")) } + if raceFlagged(arg) { + s += " · race" + } return th.stepDone.Render(s) } for _, chip := range []string{ + buildChip(arg, result, isErr, th), + vetChip(arg, result, isErr, th), gitChip(arg, result, th), - lintChip(arg, result, th), + lintChip(arg, result, isErr, th), warnChip(result, th), httpChip(arg, result, th), } { @@ -1172,10 +1196,102 @@ func gitChip(arg, result string, th theme) string { return "" } -// lintChip reports the linter outcome: "✓ lint clean" or a red issue -// count. Gated on linter-sounding commands, so ruff's "All checks passed" +// raceFlagged reports whether the command asked for race detection +// (go test -race and friends). +func raceFlagged(arg string) bool { + for _, w := range shellWords(arg) { + if w == "-race" || w == "--race" || strings.HasPrefix(w, "-race=") { + return true + } + } + return false +} + +// buildGate reports whether the command's job is compiling code. Build +// systems (make/cargo/gradle/mvn) qualify unless the run names another +// concern (lint/test/vet/check); toolchain verbs (go build, npm run build, +// tsc) qualify on the verb. git never does — a commit message may quote +// the word "build". +func buildGate(arg string) bool { + words := shellWords(arg) + if hasWord(words, "git") { + return false + } + for _, w := range words { + switch w { + case "lint", "test", "vet", "check": + return false + } + } + for i, w := range words { + switch w { + case "make", "cmake", "cargo", "gradle", "mvn": + return true + case "build", "tsc", "rustc", "gcc", "clang": + if i == 0 || words[0] != "git" { + return true + } + } + } + return false +} + +// silentBuildOutput reports output that the compile-success convention +// produces: nothing at all, or the normalized no-output placeholder. +func silentBuildOutput(result string) bool { + t := strings.TrimSpace(result) + return t == "" || t == "(no output)" +} + +// buildChip reports the build verdict: a neutral 'built' for a compile gate +// with the silent success convention (silent output carries no exit +// metadata, so success is never claimed — no ✓), 'build failed' on +// recognized compiler errors or a failed exit. A step already marked failed +// yields no success-flavored chip at all. Unrecognized non-silent output +// yields no chip. +func buildChip(arg, result string, isErr bool, th theme) string { + if !buildGate(arg) { + return "" + } + if buildFailRe.MatchString(result) { + return th.stepErr.Render("build failed") + } + if silentBuildOutput(result) && !isErr { + return th.statsDim.Render("built") + } + return "" +} + +// vetChip reports the go vet verdict with the same silent-success rule as +// buildChip: vet prints nothing when clean and file:line diagnostics when +// not. Gated on the vet verb (go vet ./...). +func vetChip(arg, result string, isErr bool, th theme) string { + words := shellWords(arg) + vet := false + for i, w := range words { + if w == "vet" { + // vet heads the command (vet ./...) or rides its toolchain (go + // vet ./...) — never a bare argument of another verb. + vet = i == 0 || words[i-1] == "go" + break + } + } + if !vet { + return "" + } + if vetFailRe.MatchString(result) { + return th.stepErr.Render("vet failed") + } + if silentBuildOutput(result) && !isErr { + // Same neutral convention as buildChip: no ✓ without exit metadata. + return th.statsDim.Render("vet") + } + return "" +} + +// lintChip reports the linter outcome: "✓ lint" or a red issue count. Gated on linter-sounding commands, so ruff's "All checks passed" // cannot leak into arbitrary output. -func lintChip(arg, result string, th theme) string { +func lintChip(arg, result string, isErr bool, th theme) string { words := shellWords(arg) linters := []string{"lint", "golangci-lint", "ruff", "eslint", "clippy"} gate := false @@ -1194,18 +1310,27 @@ func lintChip(arg, result string, th theme) string { t := strings.TrimSpace(ln) if m := lintIssuesRe.FindStringSubmatch(t); m != nil { if n, _ := strconv.Atoi(m[1]); n == 0 { - return th.stepDone.Render("✓ lint clean") + if isErr { + return "" // a failed step never paints a ✓ verdict + } + return th.stepDone.Render("✓ lint") } - return th.stepErr.Render(m[1] + " issues") + return th.stepErr.Render("lint " + m[1]) } if strings.HasPrefix(t, "All checks passed") { - return th.stepDone.Render("✓ lint clean") + if isErr { + return "" + } + return th.stepDone.Render("✓ lint") } if m := eslintProblemsRe.FindStringSubmatch(t); m != nil { if n, _ := strconv.Atoi(m[1]); n == 0 { - return th.stepDone.Render("✓ lint clean") + if isErr { + return "" + } + return th.stepDone.Render("✓ lint") } - return th.stepErr.Render(m[1] + " issues") + return th.stepErr.Render("lint " + m[1]) } } return "" diff --git a/internal/tui/renderers_test.go b/internal/tui/renderers_test.go index b079b87..fc6c23b 100644 --- a/internal/tui/renderers_test.go +++ b/internal/tui/renderers_test.go @@ -189,9 +189,9 @@ func TestStepHeadSuffix(t *testing.T) { "* [new branch] feat/x -> feat/x", "↑ feat/x"}, {"push up to date", "shell", "git push", "Everything up-to-date", "↑ up to date"}, {"push needs git arg", "shell", "echo pushing", " 7c0a0dc..8cefa19 main -> main", ""}, - {"lint clean", "shell", "golangci-lint run", "0 issues.", "✓ lint clean"}, - {"lint issues", "shell", "make lint", "2 issues.", "2 issues"}, - {"lint ruff", "shell", "ruff check .", "All checks passed!", "✓ lint clean"}, + {"lint clean", "shell", "golangci-lint run", "0 issues.", "✓ lint"}, + {"lint issues", "shell", "make lint", "2 issues.", "lint 2"}, + {"lint ruff", "shell", "ruff check .", "All checks passed!", "✓ lint"}, {"lint needs lint arg", "shell", "go build ./...", "0 issues.", ""}, {"warnings emitted", "shell", "cargo build", "warning: unused variable\nwarning: 2 warnings emitted", "⚠ 2 warnings"}, {"warnings generated", "shell", "make", "lib.c:3:5: warning: unused var\n3 warnings generated.", "⚠ 3 warnings"}, @@ -212,14 +212,14 @@ func TestStepHeadSuffix(t *testing.T) { {"pytest default fail", "shell", "pytest", "================================= FAILURES ==========================\n======= 1 failed, 2 passed in 0.3s ========" + "====", "1 failing"}, - {"golangci colon issues", "shell", "golangci-lint run", "2 issues:\n- x.go:1:1: boom", "2 issues"}, + {"golangci colon issues", "shell", "golangci-lint run", "2 issues:\n- x.go:1:1: boom", "lint 2"}, {"prose counts stay silent", "shell", "./validate.sh", "10 files failed validation, 5 passed", ""}, {"prose counts stay silent 2", "shell", "./validate.sh", "5 passed, 10 failed validation", ""}, {"lint word gate", "shell", "git commit -m fix-lint", "0 issues.", ""}, {"warnings prose anchored", "shell", "grep -rn TODO .", "42 warnings generated during the scan", ""}, {"warnings singular", "shell", "cargo build", "warning: 1 warning emitted", "⚠ 1 warning"}, - {"eslint issues red", "shell", "eslint .", "✖ 2 problems (2 errors, 0 warnings)", "2 issues"}, - {"eslint zero", "shell", "eslint .", "✖ 0 problems", "✓ lint clean"}, + {"eslint issues red", "shell", "eslint .", "✖ 2 problems (2 errors, 0 warnings)", "lint 2"}, + {"eslint zero", "shell", "eslint .", "✖ 0 problems", "✓ lint"}, {"jest duplicated summaries", "shell", "npm test", "Test Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\nTests: 3 passed, 3 total", "✓ 3 passed"}, {"jest suites only", "shell", "npm test", "Test Suites: 1 passed, 1 total", "✓ 1 passed"}, @@ -234,7 +234,7 @@ func TestStepHeadSuffix(t *testing.T) { } } // Severity styling: lint issues and 5xx run red, warnings run amber. - if got := stepHeadSuffix("shell", "make lint", "2 issues.", th); got != th.stepErr.Render("2 issues") { + if got := stepHeadSuffix("shell", "make lint", "2 issues.", th); got != th.stepErr.Render("lint 2") { t.Errorf("lint issues style = %q", plain(got)) } if got := stepHeadSuffix("shell", "cargo build", "warning: 2 warnings emitted", th); got != th.badgeWarn.Render("⚠ 2 warnings") { diff --git a/internal/tui/signal_fixes_test.go b/internal/tui/signal_fixes_test.go new file mode 100644 index 0000000..dc00369 --- /dev/null +++ b/internal/tui/signal_fixes_test.go @@ -0,0 +1,116 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// FIX-1: the failure BEL guard re-arms on a fresh LOCAL send, so two +// consecutive locally-submitted failed turns each ring once. +func TestFailureBellRearmsOnLocalSend(t *testing.T) { + m := newTestModel() + m.bell = true + busyTurn(m) + m.handleEvent(client.Event{Type: "error", Message: "boom"}) + if !m.failBellFired { + t.Fatal("precondition: the first failed turn latched the bell guard") + } + // A fresh local prompt must re-arm the guard for the new turn. + m.sendPrompt("try again") + if m.failBellFired { + t.Error("sendPrompt must re-arm the failure bell for the new turn") + } + // And the second failed turn rings again. + m.handleEvent(client.Event{Type: "error", Message: "boom again"}) + if !m.failBellFired { + t.Error("second failed turn must fire the bell again after re-arm") + } +} + +// FIX-2: verdict chips never claim success without confirmed metadata. +func TestSilentBuildChipIsNeutral(t *testing.T) { + th := newTheme() + // Silent output (empty or '(no output)') carries no exit metadata — + // the chip is a neutral 'built', never a ✓ success claim. + for _, res := range []string{"", "(no output)"} { + got := plain(stepHeadSuffixFor("shell", "go build ./...", res, false, th)) + if strings.Contains(got, "✓") { + t.Errorf("silent build output must not render ✓: %q", got) + } + if !strings.Contains(got, "built") { + t.Errorf("silent build output should keep a neutral built chip: %q", got) + } + } + if got := plain(stepHeadSuffixFor("shell", "go vet ./...", "", false, th)); strings.Contains(got, "✓") { + t.Errorf("silent vet output must not render ✓: %q", got) + } + // A step that failed (isErr) never paints any success-flavored chip — + // not even the neutral one. + if got := plain(stepHeadSuffixFor("shell", "go build ./...", "", true, th)); got != "" { + t.Errorf("isErr step with silent output must render no build chip: %q", got) + } + if got := plain(stepHeadSuffixFor("shell", "go vet ./...", "", true, th)); got != "" { + t.Errorf("isErr step with silent output must render no vet chip: %q", got) + } +} + +// FIX-3: a resumed turn whose steps all carry dur 0 omits the duration +// segment instead of inventing '<1s'. +func TestFoldTallyOmitsZeroDuration(t *testing.T) { + msg := message{role: roleAsst, streaming: false} + msg.steps = append(msg.steps, + step{name: "read_file", arg: "a.go", done: true, dur: 0}, + step{name: "shell", arg: "ls", done: true, dur: 0}, + ) + msg.items = append(msg.items, turnItem{stepIdx: 0}, turnItem{stepIdx: 1}) + got := foldTally(msg) + if strings.Contains(got, "<1s") { + t.Errorf("all-zero durs must not render '<1s': %q", got) + } + if strings.Contains(got, "·") && strings.Count(got, "·") > 0 && !strings.HasPrefix(got, "2 tools") { + t.Errorf("tally must still lead with the tools count: %q", got) + } + if got != "2 tools" { + t.Errorf("zero-duration tally = %q, want %q", got, "2 tools") + } + // A sub-second live turn still shows '<1s' — only the zero total drops. + msg.steps[0].dur = 300 * time.Millisecond + if got := foldTally(msg); !strings.Contains(got, "<1s") { + t.Errorf("sub-second total must still show '<1s': %q", got) + } +} + +// FIX-5: a markdown heading alone must not paint 'build failed'. +func TestBuildFailMarkdownHeadingNotFailure(t *testing.T) { + th := newTheme() + if got := plain(stepHeadSuffixFor("shell", "make build", + "# Introduction\n\nSome prose about the build system.", false, th)); got != "" { + t.Errorf("markdown heading must not render a build chip: %q", got) + } + // A real go-build header followed by a file:line diagnostic still fails. + if got := plain(stepHeadSuffixFor("shell", "make build", + "# github.com/x/y\ny.go:9:2: undefined: Foo", false, th)); got != "build failed" { + t.Errorf("go-build header + diagnostic must render build failed: %q", got) + } +} + +// FIX-6: renderNotices prefers an unexpired alert-tier notice over a newer +// transient one — the alert renders, the transient folds into the count. +func TestNoticeAlertTierWins(t *testing.T) { + m := newTestModel() + m.addNote("error: provider down") + m.transientNoteCmd("skill · loaded") + out := plain(m.renderNotices()) + if !strings.Contains(out, "error: provider down") { + t.Errorf("alert-tier notice must render over a newer transient: %q", out) + } + if strings.Contains(out, "skill · loaded") { + t.Errorf("newer transient must fold into the count: %q", out) + } + if !strings.Contains(out, "1 note") { + t.Errorf("folded transient must be counted: %q", out) + } +} diff --git a/internal/tui/subagent_chips_test.go b/internal/tui/subagent_chips_test.go index ba18031..322a646 100644 --- a/internal/tui/subagent_chips_test.go +++ b/internal/tui/subagent_chips_test.go @@ -115,7 +115,7 @@ func TestSelectAgentChipToggles(t *testing.T) { func TestPackChipRowsNeverSplitsAChip(t *testing.T) { chips := []agentChip{ - {idx: 0, glyph: "⟳", label: "SA1 explore"}, + {idx: 0, glyph: "▸", label: "SA1 explore"}, {idx: 1, glyph: "✓", label: "SA2 a-very-long-goal-label"}, } rows := packChipRows(chips, 20) diff --git a/internal/tui/subagent_state_test.go b/internal/tui/subagent_state_test.go index ffbd7c6..6bd309a 100644 --- a/internal/tui/subagent_state_test.go +++ b/internal/tui/subagent_state_test.go @@ -80,8 +80,8 @@ func TestSubagentStateGlyphs(t *testing.T) { {"success", "✓", false}, {"partial", "◐", false}, {"error", "✗", true}, - {"cancelled", "⊘", true}, - {"timeout", "⏱", true}, + {"cancelled", "✗", true}, + {"timeout", "✗", true}, } for _, tc := range cases { a := &agentCard{taskID: "t", phase: "finished", status: tc.status} @@ -92,7 +92,7 @@ func TestSubagentStateGlyphs(t *testing.T) { t.Errorf("status %q failed = %v", tc.status, a.failed()) } } - if got := (&agentCard{phase: "active", status: "running"}).glyph(); got != "⟳" { + if got := (&agentCard{phase: "active", status: "running"}).glyph(); got != "▸" { t.Errorf("live glyph = %q", got) } } @@ -132,7 +132,7 @@ func TestSubagentStateRollup(t *testing.T) { } s.expanded = true out, _ := renderStepsForTest(m, m.msgs[0], 0, 0) - if !strings.Contains(out, "1/2 agents") || !strings.Contains(out, "⟳ SA2") { + if !strings.Contains(out, "1/2 agents") || !strings.Contains(out, "▸ SA2") { t.Errorf("render missing rollup or live card: %q", out) } } diff --git a/internal/tui/subagent_wire_v2_test.go b/internal/tui/subagent_wire_v2_test.go index 225f409..45e5d25 100644 --- a/internal/tui/subagent_wire_v2_test.go +++ b/internal/tui/subagent_wire_v2_test.go @@ -74,7 +74,7 @@ func TestQueuedPhase(t *testing.T) { m.handleEvent(client.Event{Type: "subagent_state", TaskID: "a", TaskIdx: 0, Phase: "started", Status: "running", Step: 1}) s = stateStep(t, m) - if s.agents[0].glyph() != "⟳" { + if s.agents[0].glyph() != "▸" { t.Errorf("started card still queued glyph: %q", s.agents[0].glyph()) } if r := agentRollup(s); r != "0/3 agents · 2 queued" { diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index 2279a2d..3843bc7 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -47,29 +47,27 @@ type agentCard struct { // finished reports whether the card reached a terminal state. func (a *agentCard) finished() bool { return a.phase == "finished" } -// glyph picks the status glyph: live ⟳, then the terminal set mirroring -// odek's status framing (user cancel and deadline timeout never conflate). +// glyph picks the status glyph: live ▸, then the terminal set mirroring +// the chip vocabulary (queued ○; running ▸; done ✓; every failed terminal +// state ✗ — cancel, deadline, and lost included; the dim style separates a +// lost card from a hard failure). func (a *agentCard) glyph() string { if !a.finished() { switch { case a.lost: - return "×" // orphaned by a disconnect: dead, not spinning + return "✗" // orphaned by a disconnect: dead, not spinning case a.phase == "queued": - return "◔" // ◔ not ◌: the lamp glyphs belong to the connection state + return "◔" // lamp glyphs (◌ ○ ●) belong to the connection state } - return "⟳" + return "▸" } switch a.status { case "success": return "✓" case "partial", "budget_exhausted": return "◐" - case "error": + case "error", "cancelled", "timeout": return "✗" - case "cancelled": - return "⊘" - case "timeout": - return "⏱" default: return "•" } @@ -269,6 +267,7 @@ type agentChip struct { idx int pending bool failed bool + dim bool // lost card: same ✗ glyph, muted style glyph string label string // "SA1 explore repo" — goal first after the id } @@ -300,7 +299,7 @@ func (s *step) agentChips() []agentChip { } } out = append(out, agentChip{ - idx: a.idx, failed: a.failed(), glyph: a.glyph(), + idx: a.idx, failed: a.failed(), dim: a.lost, glyph: a.glyph(), label: fmt.Sprintf("SA%d %s", a.idx+1, label), }) seen[a.idx] = true diff --git a/internal/tui/subagents_panel_test.go b/internal/tui/subagents_panel_test.go index e017c9c..4f37dcf 100644 --- a/internal/tui/subagents_panel_test.go +++ b/internal/tui/subagents_panel_test.go @@ -28,7 +28,7 @@ func TestAgentsTabFlow(t *testing.T) { } rows := m.agentRowsRender(120) joined := strings.Join(rows, "\n") - for _, want := range []string{"✓", "SA1", "explore the repo", "1.5k tok", "⟳", "SA2", "read"} { + for _, want := range []string{"✓", "SA1", "explore the repo", "1.5k tok", "▸", "SA2", "read"} { if !strings.Contains(joined, want) { t.Errorf("rows missing %q: %q", want, joined) } diff --git a/internal/tui/transcript_additions_test.go b/internal/tui/transcript_additions_test.go new file mode 100644 index 0000000..5eab19a --- /dev/null +++ b/internal/tui/transcript_additions_test.go @@ -0,0 +1,157 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ── A1: reduced-motion mode ───────────────────────────────────────────────── + +// TestReduceMotionClockInterval: with reduceMotion on, the transcript clock +// lane must tick at >= 2s instead of the default 250ms — a 1s advance must +// not repaint the head counter, a 2s advance must. +func TestReduceMotionClockInterval(t *testing.T) { + m := newTestModel() + if d := m.tailClockTick(); d != tailClockInterval { + t.Errorf("default cadence must stay %v, got %v", tailClockInterval, d) + } + m.reduceMotion = true + if d := m.tailClockTick(); d < 2*time.Second { + t.Errorf("reduceMotion cadence must be >= 2s, got %v", d) + } +} + +// TestReduceMotionSteadyOutputRow: reduceMotion disables the new-output row's +// busy accent — it renders the same dim style as the idle placeholder. +func TestReduceMotionSteadyOutputRow(t *testing.T) { + m := newTestModel() + m.busy = true + m.reduceMotion = true + tallTranscript(m) + m.vp.GotoTop() + if m.vp.AtBottom() { + t.Fatal("precondition: scrolled away from the bottom") + } + if m.outputRowAccent() { + t.Error("reduceMotion must keep the new-output row steady (no accent)") + } + if foot := plain(m.footer()); !strings.Contains(foot, "↓ new output") { + t.Errorf("new-output row missing its dim render under reduceMotion: %q", foot) + } + // The busy accent returns once the mode is off. + m.reduceMotion = false + if !m.outputRowAccent() { + t.Error("busy turns outside reduceMotion must accent the row") + } +} + +// ── A2: folded turn summary tally ─────────────────────────────────────────── + +// TestFoldedTurnTally: a finalized assistant head carries the compact +// 'N tools · M agents · T' tally; zero-step turns drop the tools segment. +func TestFoldedTurnTally(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleUser, content: "go"}, + message{role: roleAsst, streaming: false}, + ) + i := 1 + msg := &m.msgs[i] + msg.steps = append(msg.steps, + step{name: "read_file", done: true, dur: 1200 * time.Millisecond}, + step{name: "shell", done: true, dur: 800 * time.Millisecond}, + ) + msg.steps[0].agents = append(msg.steps[0].agents, &agentCard{}) + msg.items = append(msg.items, + turnItem{stepIdx: 0}, + turnItem{stepIdx: 1}, + ) + + rendered, _ := m.renderMessage(m.msgs[i], i, 0) + head := plain(rendered) + if !strings.Contains(head, "2 tools") { + t.Errorf("folded head missing tool tally: %q", head) + } + if !strings.Contains(head, "1 agent") { + t.Errorf("folded head missing agent tally: %q", head) + } + if !strings.Contains(head, "2.0s") { + t.Errorf("folded head missing duration segment: %q", head) + } + + // Sub-1s sealed total renders as '<1s'. + msg.steps[0].dur = 300 * time.Millisecond + msg.steps[1].dur = 400 * time.Millisecond + rendered, _ = m.renderMessage(m.msgs[i], i, 0) + if !strings.Contains(plain(rendered), "<1s") { + t.Errorf("sub-1s tally must render '<1s': %q", plain(rendered)) + } + + // Zero steps: no tools segment at all. + msg.steps = nil + msg.items = nil + rendered, _ = m.renderMessage(m.msgs[i], i, 0) + head = plain(rendered) + if strings.Contains(head, "tools") { + t.Errorf("zero-step turn must not show a tools segment: %q", head) + } +} + +// ── A3: terminal bell on failure and approval urgency ────────────────────── + +// TestFailureBellOnce: a failed turn rings the bell exactly once — the guard +// flag latches and a second error event does not re-fire. +func TestFailureBellOnce(t *testing.T) { + m := newTestModel() + m.bell = true + busyTurn(m) + m.handleEvent(client.Event{Type: "error", Message: "boom"}) + if !m.failBellFired { + t.Fatal("failure must latch the bell guard (fired once)") + } + if !m.msgs[1].failed { + t.Fatal("precondition: the error marked the turn failed") + } + a := m.attentionFor(attentionFailed) + if !a.bell { + t.Error("failure attention plan must carry the bell") + } + // Second failure on the same turn must not re-fire. + m.handleEvent(client.Event{Type: "error", Message: "boom again"}) + if !m.failBellFired { + t.Error("bell guard must stay latched for the same turn") + } +} + +// TestApprovalUrgentBellOnce: crossing into the urgent window (<10s) rings +// once per approval; ticks inside the window do not re-fire, and a new +// approval re-arms. +func TestApprovalUrgentBellOnce(t *testing.T) { + m := newTestModel() + m.bell = true + ev := client.Event{Type: "approval_request", ID: "a1"} + m.approvals = append(m.approvals, ev) + m.stampApprovalDeadline(client.Event{ID: "a1", TimeoutSeconds: 60}) + // Shrink the deadline into the urgent window. + m.apprDeadlines[0] = time.Now().Add(9 * time.Second) + + m.handleApprovalExpiry(time.Now()) + if !m.apprBellFired { + t.Fatal("urgent countdown must latch the bell guard (fired once)") + } + // A further tick in the same window does not re-fire. + m.handleApprovalExpiry(time.Now()) + if !m.apprBellFired { + t.Error("bell guard must stay latched inside the window") + } + // A fresh approval re-arms the transition. + m.approvals = append(m.approvals, client.Event{Type: "approval_request", ID: "a2"}) + m.apprDeadlines = append(m.apprDeadlines, time.Now().Add(9*time.Second)) + m.stampApprovalDeadline(client.Event{ID: "a2", TimeoutSeconds: 60}) + if m.apprBellFired { + t.Error("a new approval must reset the urgent-bell guard") + } +} diff --git a/internal/tui/transcript_fixes_test.go b/internal/tui/transcript_fixes_test.go new file mode 100644 index 0000000..0fd4bab --- /dev/null +++ b/internal/tui/transcript_fixes_test.go @@ -0,0 +1,175 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ── F1: the queue count has a single owner — the shelf chip ──────────────── + +// TestQueueCountSingleOwner: with prompts queued mid-turn, the shelf chip is +// the ONLY surface carrying the count — the status line and the footer must +// not repeat it. +func TestQueueCountSingleOwner(t *testing.T) { + m := newTestModel() + busyTurn(m) + for _, p := range []string{"first follow-up", "second follow-up"} { + m.ta.SetValue(p) + m.submit() + } + if shelf := plain(m.shelfView()); !strings.Contains(shelf, "2 queued") { + t.Errorf("shelf chip missing the queue count: %q", shelf) + } + if line := plain(m.statusLine()); strings.Contains(line, "queued") { + t.Errorf("status line must not repeat the queue count: %q", line) + } + if foot := plain(m.footer()); strings.Contains(foot, "queued") { + t.Errorf("footer must not repeat the queue count: %q", foot) + } +} + +// ── F2: the status line never hides on disconnect ────────────────────────── + +// TestStatusLineReconnectState: while disconnected the status line renders +// the reconnect state in-place instead of vanishing — and never shows the +// normal busy spinner label. +func TestStatusLineReconnectState(t *testing.T) { + m := newTestModel() + m.busy = true + m.disconn = true + m.status = "reconnecting…" + m.reconnAttempt = 0 + + line := plain(m.statusLine()) + if !strings.Contains(line, "reconnecting") || !strings.Contains(line, "backoff") { + t.Errorf("disconnected status line missing reconnect state: %q", line) + } + for _, banned := range []string{"reasoning", "composing"} { + if strings.Contains(line, banned) { + t.Errorf("disconnected status line shows busy label %q: %q", banned, line) + } + } + if !m.statusLineVisible() { + t.Error("status line must keep its row while disconnected") + } + + // Budget spent: the row keeps the terminal disconnected state. + m.status = "disconnected" + line = plain(m.statusLine()) + if !strings.Contains(line, "disconnected") { + t.Errorf("terminal-disconnect status line missing state: %q", line) + } + if m.statusLine() != "" && !strings.HasPrefix(m.statusLine(), "\n") { + t.Error("status line must keep its leading separator row") + } +} + +// ── F3: one steady new-output row — no insert/remove reflow ──────────────── + +// TestNewOutputRowSteady: the new-output indicator lives on ONE footer row +// that never inserts or removes a line — the layout height must not change +// when the busy state toggles while scrolled up. +func TestNewOutputRowSteady(t *testing.T) { + m := newTestModel() + tallTranscript(m) + m.vp.GotoTop() + + m.busy = true + if foot := plain(m.footer()); !strings.Contains(foot, "new output") { + t.Errorf("busy scrolled-up footer missing new-output: %q", foot) + } + if shelf := plain(m.shelfView()); strings.Contains(shelf, "new output") { + t.Errorf("shelf must not duplicate the new-output row: %q", shelf) + } + busyFoot := m.footer() + busyShelf := m.shelfHeight() + + m.busy = false + if !strings.Contains(plain(m.footer()), "new output") { + t.Errorf("idle scrolled-up footer dropped the steady row placeholder") + } + // No layout change on the toggle: the row persists in-place (color-only + // pulse), so the footer stays a single row and the shelf never grows. + if lineCount(m.footer()) != lineCount(busyFoot) { + t.Errorf("footer row count changed on toggle: %d → %d", lineCount(busyFoot), lineCount(m.footer())) + } + if m.shelfHeight() != busyShelf { + t.Errorf("shelf height changed on new-output toggle: %d → %d", busyShelf, m.shelfHeight()) + } +} + +// ── F4: a failed turn marks its head ─────────────────────────────────────── + +// TestFailedTurnHeadMarked: an error event on the streaming turn sets a +// sanitized failed flag that paints ✗ on the turn head and survives +// finalization within this session. (Replay does not restore it: the +// persisted transcript carries no error marker record, so a resumed +// session's history does not re-derive the flag — an in-session contract.) +func TestFailedTurnHeadMarked(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleUser, content: "do it"}, + message{role: roleAsst, content: "partial", streaming: true}, + ) + m.curIdx = 1 + m.busy = true + m.runStart = time.Now() + + m.handleEvent(client.Event{Type: "error", Message: "boom"}) + if !m.msgs[1].failed { + t.Fatal("error event must mark the turn message failed") + } + out, _ := m.renderMessage(m.msgs[1], 1, 0) + if !strings.Contains(plain(out), "✗") { + t.Errorf("failed turn head missing ✗:\n%s", plain(out)) + } + + // Finalized (history) rendering keeps the mark. + m.msgs[1].streaming = false + m.busy = false + out, _ = m.renderMessage(m.msgs[1], 1, 0) + if !strings.Contains(plain(out), "✗") { + t.Errorf("finalized failed turn head lost ✗:\n%s", plain(out)) + } + + // A healthy turn never carries it. + m.msgs[1].failed = false + out, _ = m.renderMessage(m.msgs[1], 1, 0) + if strings.Contains(plain(out), "✗") { + t.Errorf("healthy turn head carries ✗:\n%s", plain(out)) + } +} + +// ── F5: visible notices are capped to one line ───────────────────────────── + +// TestNoticeCapOneLine: only the latest unexpired notice renders, folded into +// a single line; older ones collapse into a count instead of stacking. +func TestNoticeCapOneLine(t *testing.T) { + m := newTestModel() + m.addNote("first problem") + m.addNote("second problem") + m.addNote("latest problem") + + out := plain(m.renderNotices()) + if !strings.Contains(out, "latest problem") { + t.Errorf("latest notice must render: %q", out) + } + if strings.Contains(out, "first problem") || strings.Contains(out, "second problem") { + t.Errorf("older notices must not stack: %q", out) + } + if !strings.Contains(out, "2 notes") { + t.Errorf("folded notice count missing: %q", out) + } + if n := lineCount(out); n != 1 { + t.Errorf("notice strip must be one line, got %d", n) + } + + // Expiry still sweeps under the cap. + m.pruneNotices(time.Now().Add(alertTTL + time.Minute)) + if len(m.notices) != 0 { + t.Errorf("sweep must prune expired notices, got %v", m.notices) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 2379b66..d8891fe 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -331,6 +331,19 @@ func (m *Model) statusLine() string { return "" } th := m.th + // F2: a dropped socket is exactly when the reader needs this row — + // instead of hiding, the status line owns the reconnect state in-place. + if m.disconn { + label := "◌ disconnected · ⏎ retry" + if strings.HasPrefix(m.status, "reconnecting") { + label = fmt.Sprintf("◌ reconnecting · backoff %ds", int(reconnectBackoff(m.reconnAttempt).Seconds())) + } + row := th.badgeDanger.Render(label) + if w := lipgloss.Width(row); w > m.width { + row = ansi.Truncate(row, m.width-1, "") + "…" + } + return "\n" + row + } var label string switch { case m.lastTool != "": @@ -345,12 +358,9 @@ func (m *Model) statusLine() string { if e := m.elapsed(); e != "" { el = th.headerMeta.Render(" · " + e) } - // Held prompts ride the same row: mid-turn ⏎ queues invisibly, so the - // count shows where the eyes already are (mirrors the footer indicator). + // Held prompts ride the footer's queue chip alone (F1: single owner) + // — the shelf shows the count while the strip is folded. q := "" - if n := len(m.queue); n > 0 { - q = th.acDetail.Render(fmt.Sprintf(" · %d queued", n)) - } // Live plan strip: rides the same row, // silent unless a run is active AND a plan exists — absence costs zero // pixels. Bounded to a short label so small terminals keep the row sane. @@ -370,11 +380,11 @@ func (m *Model) statusLine() string { } // statusLineVisible reports whether the status line occupies a row, keeping -// View and inputAreaHeight in agreement. While an approval card is up or -// the socket is down, the header badge carries the busy state and the row -// stays hidden. +// View and inputAreaHeight in agreement. While an approval card is up the +// header badge carries the busy state and the row stays hidden; a dropped +// socket instead KEEPS the row — it renders the reconnect state in-place. func (m *Model) statusLineVisible() bool { - return m.busy && m.curApproval() == nil && !m.disconn + return (m.busy || m.disconn) && m.curApproval() == nil } // ── transcript ─────────────────────────────────────────────────────────── @@ -389,6 +399,20 @@ const streamRenderInterval = 80 * time.Millisecond // refreshes running step clocks without rebuilding on every spinner frame. const tailClockInterval = 250 * time.Millisecond +// reduceMotionClockInterval is the calmer cadence for --reduce-motion: +// transcript clock repaints (head counter, step timers) drop to one per +// 2s so live numbers barely move. +const reduceMotionClockInterval = 2 * time.Second + +// tailClockTick resolves the transcript clock lane's interval for the +// current motion mode. +func (m *Model) tailClockTick() time.Duration { + if m.reduceMotion { + return reduceMotionClockInterval + } + return tailClockInterval +} + // renderFlushMsg fires streamRenderInterval after the first coalesced // streaming event; a stale seq means a newer flush superseded it. type renderFlushMsg struct { @@ -438,7 +462,7 @@ func (m *Model) queueTailClock() tea.Cmd { m.tailClockPending = true m.tailClockSeq++ seq := m.tailClockSeq - return tea.Tick(tailClockInterval, func(time.Time) tea.Msg { + return tea.Tick(m.tailClockTick(), func(time.Time) tea.Msg { return tailClockFlushMsg{seq: seq} }) } @@ -649,12 +673,28 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st // is the card's identity. label += th.asstLabel.Render(" · wake") } + if msg.failed { + // (F4) a failed run marks the head — the flag is model-owned + // state, never wire text — and persists through finalization. + label += " " + th.badgeDanger.Render(lampError) + } if rec := formatReceipt(scanReceipt(msg)); rec != "" { room := m.vp.Width - lipgloss.Width(label) - 4 if room > 8 { label += " " + th.statsDim.Render(truncate(rec, room)) } } + if !msg.streaming { + // (A2) Sealed-turn tally on the head: 'N tools · M agents · Ts' in + // the same dim secondary style and width budget as the receipt — + // model-owned counts only, never wire text. + if tal := foldTally(msg); tal != "" { + room := m.vp.Width - lipgloss.Width(label) - 4 + if room > 8 { + label += " " + th.statsDim.Render(truncate(tal, room)) + } + } + } if msg.collapsed { summary := th.statsDim.Render(m.collapseSummary(msg)) start := lineOffset + turnHeadGap @@ -830,7 +870,13 @@ func (m *Model) collapseSummary(msg message) string { } parts := []string{"⋯ collapsed"} if n := len(msg.steps); n > 0 { - parts = append(parts, fmt.Sprintf("%d tool steps", n)) + plural := "steps" + if n == 1 { + plural = "step" + } + // Compact dot tally (· per step, ✗ failed) rides the numeric count — + // the count stays for screen readers / copy, the dots for scanning. + parts = append(parts, fmt.Sprintf("%d tool %s · %s", n, plural, stepTally(msg))) } if strings.TrimSpace(msg.thinking) != "" { parts = append(parts, "reasoning") @@ -841,6 +887,81 @@ func (m *Model) collapseSummary(msg message) string { return strings.Join(parts, " · ") } +// stepTallyMax caps the collapsed dot tally's width: 23 glyphs plus the +// ellipsis head that stands for the steps cut off the front. +const stepTallyMax = 24 + +// stepTally renders the collapsed-turn dot tally — one glyph per step, ✗ for +// failed steps, · otherwise. Model-owned constants, sanitized like every +// other rendered string; long turns keep the tail and lead with …. +func stepTally(msg message) string { + n := len(msg.steps) + if n == 0 { + return "" + } + var b strings.Builder + steps := msg.steps + if n > stepTallyMax { + b.WriteString("…") + steps = steps[n-(stepTallyMax-1):] + } + for i := range steps { + if steps[i].isErr { + b.WriteString("✗") + } else { + b.WriteString("·") + } + } + return sanitize(b.String()) +} + +// foldTally builds the sealed-turn head tally 'N tools · M agents · T': +// N counts the chronological tool steps on items[], M the sub-agent +// children across steps, and T the sealed duration — the sum of per-step +// durs (parallel steps make wall time under-report the work). '<1s' when +// the total is under a second; empty pieces stay off the string, and a +// zero-step turn yields no tool segment at all. +func foldTally(msg message) string { + n := 0 + for _, it := range msg.items { + if !it.thinking && !it.reply { + n++ + } + } + if n == 0 { + return "" // a reply-only turn keeps a quiet head + } + agents := 0 + var total time.Duration + for i := range msg.steps { + agents += len(msg.steps[i].agents) + total += msg.steps[i].dur + } + parts := []string{fmt.Sprintf("%d tools", n)} + if agents > 0 { + plural := "agents" + if agents == 1 { + plural = "agent" + } + parts = append(parts, fmt.Sprintf("%d %s", agents, plural)) + } + d := "<1s" + if total >= time.Second { + d = formatDuration(total) + } + if total > 0 { + parts = append(parts, d) // resumed history (all durs 0) shows none + } + return strings.Join(parts, " · ") +} + +// outputRowAccent reports whether the new-output row carries its busy +// accent (scroll style). Reduced motion keeps the steady dim render — +// color pulses are exactly what the mode strips. +func (m *Model) outputRowAccent() bool { + return m.busy && !m.reduceMotion +} + // renderIntentRail paints a reasoning block as a whispered plan: a faint // left rail, the excerpt (or full text when opened), and a meta line. func (m *Model) renderIntentRail(body string, itemIdx int, msg message) string { @@ -1076,7 +1197,7 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in right := "" live := !s.done && streaming if s.done { - right = stepHeadSuffix(s.name, s.arg, stepDetailResult(s), th) + right = stepHeadSuffixFor(s.name, s.arg, stepDetailResult(s), s.isErr, th) // The sealed duration keeps the live clock's slot — “how long did // this tool take” survives completion instead of vanishing with // the running timer. Resumed history (dur 0) shows none. @@ -1281,7 +1402,9 @@ func (m *Model) renderChipStrip(chips []agentChip, focus, width, msgIdx, stepIdx txt := cell.text() w := lipgloss.Width(txt) styled := th.stepArg.Render(txt) - if c.failed { + if c.dim { + styled = th.statsDim.Render(txt) // lost card: muted ✗, distinct from a hard failure + } else if c.failed { styled = th.stepErr.Render(txt) } else if !c.pending && c.glyph == "✓" { styled = th.stepDone.Render(txt) @@ -1322,14 +1445,39 @@ func resultExcerpt(result string) []string { func (m *Model) renderNotices() string { th := m.th now := time.Now() - lines := make([]string, 0, len(m.notices)) - for i, n := range m.notices { + // (F5) one visible line: the latest unexpired notice wins and older ones + // fold into a count — a notice burst must not stack rows over the tail. + latest := -1 + older := 0 + for i := range m.notices { if exp := m.noticeExp[i]; !exp.IsZero() && !now.Before(exp) { continue // expired transient, pending the next sweep } - lines = append(lines, th.noticeStyle.Render("· "+n)) + older++ + latest = i } - return strings.Join(lines, "\n") + if latest < 0 { + return "" + } + // Alert-tier notices (errors, disconnects) outrank transients: a newer + // benign transient never buries a still-live alert — scan back for the + // latest unexpired alert when the newest entry is transient-tier. + if latest >= len(m.noticeAlert) || !m.noticeAlert[latest] { + for i := latest - 1; i >= 0 && i < len(m.noticeAlert); i-- { + if exp := m.noticeExp[i]; !exp.IsZero() && !now.Before(exp) { + continue + } + if m.noticeAlert[i] { + latest = i + break + } + } + } + line := th.noticeStyle.Render("· " + m.notices[latest]) + if older > 1 { + line += th.acDetail.Render(fmt.Sprintf(" ⓘ %d notes", older-1)) + } + return line } // ── input / approval area ────────────────────────────────────────────────── @@ -1765,9 +1913,6 @@ func (m *Model) footerContent() string { left := m.modePrefix() if m.busy { left += th.footerKey.Render("^X") + th.footer.Render(" stop") - if n := len(m.queue); n > 0 { - left += th.footerSep.Render(" · ") + th.scroll.Render(fmt.Sprintf("▸ %d queued", n)) - } } 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 @@ -1797,9 +1942,14 @@ func (m *Model) footerContent() string { segs = append(segs, seg) } if !m.vp.AtBottom() { + // (F3) One steady row: the indicator never inserts/removes a segment — + // accent while a run streams fresh output, a dim placeholder otherwise, + // so the layout never reflows on the toggle. seg := "" - if m.busy { + if m.outputRowAccent() { seg = th.scroll.Render("↓ new output") + th.footerSep.Render(" · ") + } else { + seg = th.footer.Render("↓ new output") + th.footerSep.Render(" · ") } seg += th.footerKey.Render("PgUp") + th.footer.Render(" more") + th.footerSep.Render(" · ") +