From f149a21e7736038c71eb9687fd900741bc1c0a29 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 22 Aug 2026 08:51:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20render=20think=E2=86=92reply=20pai?= =?UTF-8?q?rs=20as=20independent=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long agentic turns merged every response segment into one trailing card, decoupling prose from the reasoning that produced it. Response text is now a first-class timeline kind: each token stream opens (or extends) a reply segment, so every think→reply cycle renders its own raised card in arrival order. - appendReply keeps msg.content as the \n\n-joined blob for export, stats, and hand-built messages; setTurnMarker attaches cancel / interrupt / error markers to the last reply segment - closeTurn (shared by finalize and session replay) caches a glamour render per segment; resize re-renders them - replayTranscript builds one thinking+reply pair per persisted record, making resumed sessions identical to live turns - turn body assembly switched to an exact line slice: mouse hit-test refs now land on true header rows (fixes latent misalignment for steps after multiple work items) --- AGENTS.md | 12 +- README.md | 4 +- internal/tui/events.go | 102 +++++++++++----- internal/tui/integration_test.go | 22 +++- internal/tui/model.go | 20 ++- internal/tui/pairs_test.go | 202 +++++++++++++++++++++++++++++++ internal/tui/panels.go | 26 ++-- internal/tui/view.go | 114 ++++++++++------- 8 files changed, 396 insertions(+), 106 deletions(-) create mode 100644 internal/tui/pairs_test.go diff --git a/AGENTS.md b/AGENTS.md index e14eae4..3d3ba61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,10 +89,14 @@ feat(tui): compact tool steps with Ctrl+E details toggle wrapped in untrusted-content markers: decode the envelope and fold the wrappers away before display — don't render either verbatim. - The transcript model in `internal/tui`: each assistant `message` keeps a - chronological `items []turnItem` timeline (reasoning blocks and step - references interleaved). Preserve arrival order; don't regress to a - single per-turn reasoning blob, and don't reintroduce a separate - in-transcript thinking placeholder alongside it. + chronological `items []turnItem` timeline (reasoning blocks, step + references, and reply segments interleaved — one think→reply cycle per + segment pair, each rendered independently). Preserve arrival order; don't + regress to a single per-turn reasoning blob or a single trailing answer + card, and don't reintroduce a separate in-transcript thinking placeholder + alongside it. `msg.content` stays the "\n\n"-joined blob of all reply + segments (appendReply maintains it) for export, stats, and hand-built + messages; turn markers (`**Cancelled.**` etc.) attach to the last reply. - Events arrive from `internal/client` already in chronological order — keep ingestion order-dependent and idempotent. - `internal/tui` is split by responsibility: `model.go` holds the core diff --git a/README.md b/README.md index d0e85ee..e0161e4 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,9 @@ one `Esc`. - **Security approvals** — odek's `danger` engine prompts surface as an inline panel; your answer is sent straight back over the socket. - **Live reasoning** — the model's pre-tool thinking streams in dimmed text, - with a running elapsed timer and cycling status while it works. + with a running elapsed timer and cycling status while it works. Long turns + keep every think→reply pair intact: each reasoning block is followed by its + own answer card, in arrival order. - **Command palette (`/`)** and **file attachments (`@`)** — live, navigable popups. - **Context-aware progress** — while the agent works, a status line just above the input (right below your last message) shows what it's actually diff --git a/internal/tui/events.go b/internal/tui/events.go index c346274..49d7b30 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -62,9 +62,12 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "token", "token_delta": // token_delta is the live fragment stream (server --stream); with // fragments delivered the server suppresses the bulk token re-send, - // so both event types must accumulate the same way. + // so both event types must accumulate the same way. Prose lands on + // the timeline as its own reply segment — appended to the open one, + // or opened fresh after reasoning/tools — so each think→reply cycle + // renders independently (appendReply keeps msg.content in sync). if i := m.cur(); i >= 0 { - m.msgs[i].content += sanitize(ev.Content) + appendReply(&m.msgs[i], sanitize(ev.Content)) m.msgs[i].streaming = true } m.status = "responding" @@ -210,7 +213,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { if cancelled { markCancel(&m.msgs[i]) } else if m.msgs[i].content == "" { - m.msgs[i].content = "**Error:** " + ev.Message + setTurnMarker(&m.msgs[i], "**Error:** "+ev.Message) } else { m.addNote("error: " + ev.Message) } @@ -286,11 +289,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // forever. Idempotent — with no open turn this is a no-op, so a // later resume is untouched. if i := m.cur(); i >= 0 { - if m.msgs[i].content == "" { - m.msgs[i].content = "**Interrupted:** connection lost" - } else { - m.msgs[i].content += "\n\n**Interrupted:** connection lost" - } + setTurnMarker(&m.msgs[i], "**Interrupted:** connection lost") } m.finalize() m.relayout() // the busy status line is gone with the socket @@ -448,6 +447,44 @@ func (m *Model) cur() int { return -1 } +// appendReply appends answer text to the turn's open reply segment, or +// starts a new one after a reasoning block or tool call — so every +// think→reply cycle renders independently. msg.content stays in sync for +// export, stats, and hand-built messages without a timeline; distinct +// cycles join it with a blank line so copied/exported prose stays readable. +func appendReply(msg *message, s string) { + if s == "" { + return + } + if n := len(msg.items); n > 0 && msg.items[n-1].reply { + msg.items[n-1].text += s + msg.content += s + return + } + if msg.content != "" { + msg.content += "\n\n" + } + msg.content += s + msg.items = append(msg.items, turnItem{reply: true, text: s}) +} + +// 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 +// marker always renders attached to the final card. +func setTurnMarker(msg *message, marker string) { + if msg.content == "" { + appendReply(msg, marker) + return + } + msg.content += "\n\n" + marker + if n := len(msg.items); n > 0 && msg.items[n-1].reply { + msg.items[n-1].text += "\n\n" + marker + return + } + msg.items = append(msg.items, turnItem{reply: true, text: marker}) +} + // isContextCanceled reports whether an error message is the abort a run's // context returns once a cancel is honored ("context canceled", possibly // wrapped by the provider client on its way out). @@ -458,35 +495,42 @@ func isContextCanceled(msg string) bool { // markCancel closes out a streaming turn as cancelled — the deliberate // sibling of the interrupted marker a disconnect leaves behind. func markCancel(msg *message) { - if msg.content == "" { - msg.content = "**Cancelled.**" - } else { - msg.content += "\n\n**Cancelled.**" - } + setTurnMarker(msg, "**Cancelled.**") } -// finalize closes out the streaming assistant message, rendering its markdown. -// Reasoning blocks the renderer auto-opened for the live stream collapse here -// (the WebUI's accordion rule): the next turn starts with history folded, and -// only blocks the user opened themselves stay open. +// finalize closes out the streaming assistant message and drops the cursor. func (m *Model) finalize() { if i := m.cur(); i >= 0 { - m.msgs[i].streaming = false - m.msgs[i].rendered = m.render(m.msgs[i].content) - var thoughts []string - for j := range m.msgs[i].items { - if m.msgs[i].items[j].thinking { - thoughts = append(thoughts, m.msgs[i].items[j].text) - m.msgs[i].items[j].open = false - } - } - // Keep the turn's reasoning concatenated on the message for - // compatibility; the timeline (items) drives the actual rendering. - m.msgs[i].thinking = strings.Join(thoughts, "\n") + m.closeTurn(&m.msgs[i]) } m.curIdx = -1 } +// closeTurn renders finalized markdown for one assistant turn: each reply +// segment goes through glamour individually (cached on the item, re-rendered +// only on resize), reasoning folds into msg.thinking, and reasoning blocks +// the renderer auto-opened for the live stream collapse here (the WebUI's +// accordion rule): the next turn starts with history folded, and only blocks +// the user opened themselves stay open. Shared by finalize() and the +// session-replay flush. +func (m *Model) closeTurn(msg *message) { + msg.streaming = false + var thoughts []string + for j := range msg.items { + switch { + case msg.items[j].thinking: + thoughts = append(thoughts, msg.items[j].text) + msg.items[j].open = false + case msg.items[j].reply: + msg.items[j].rendered = m.render(msg.items[j].text) + } + } + // Keep the turn's reasoning concatenated on the message for + // compatibility; the timeline (items) drives the actual rendering. + msg.thinking = strings.Join(thoughts, "\n") + msg.rendered = m.render(msg.content) +} + // addNote appends a sticky notice (errors, disconnects) that stays until // pushed out by newer ones. func (m *Model) addNote(s string) { diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index f7f4db8..6b73210 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -624,11 +624,12 @@ func TestSessionDetailReplay(t *testing.T) { if asst.role != roleAsst { t.Fatalf("second message role = %v, want assistant", asst.role) } - // Both assistant text parts join into the reply. + // Both assistant text parts join into the reply blob… if asst.content != "I will inspect the file.\n\nFixed the bug." { t.Errorf("assistant content = %q", asst.content) } - // Reasoning folds into msg.thinking like finalize() does. + // …and each persisted record becomes its own reply segment on the + // timeline, mirroring live think→reply ingestion. if asst.thinking != "let me look at the code\nfound it" { t.Errorf("assistant thinking = %q", asst.thinking) } @@ -651,19 +652,28 @@ func TestSessionDetailReplay(t *testing.T) { t.Errorf("step 1 result = %q (frame not stripped?)", s1.result) } - // The timeline interleaves thinking → step → step → thinking, in order. + // The timeline interleaves think→reply pairs with tool steps in arrival + // order; reply segments cache their glamour render at flush time. want := []turnItem{ {thinking: true, text: "let me look at the code"}, + {reply: true, text: "I will inspect the file."}, {stepIdx: 0}, {stepIdx: 1}, {thinking: true, text: "found it"}, + {reply: true, text: "Fixed the bug."}, } if len(asst.items) != len(want) { - t.Fatalf("assistant items = %v, want %v", asst.items, want) + t.Fatalf("assistant items = %+v, want %+v", asst.items, want) } for i, w := range want { - if asst.items[i] != w { - t.Errorf("item %d = %+v, want %+v", i, asst.items[i], w) + got := asst.items[i] + if got.thinking != w.thinking || got.reply != w.reply || got.text != w.text || got.stepIdx != w.stepIdx { + t.Errorf("item %d = %+v, want %+v", i, got, w) + } + } + for i, it := range asst.items { + if it.reply && it.rendered == "" { + t.Errorf("reply segment %d has no cached render", i) } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 1a3b787..cfca0d5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -47,13 +47,16 @@ type stepRef struct { line int } -// turnItem is one entry in a turn's chronological timeline: either a -// reasoning block or a tool call, in arrival order. +// turnItem is one entry in a turn's chronological timeline: a reasoning +// block, a tool call, or a response text segment (one think→reply cycle), +// in arrival order. type turnItem struct { - thinking bool // false = tool step - text string // thinking excerpt when thinking (capped per block) - stepIdx int // index into msg.steps when !thinking + thinking bool // true = reasoning block + reply bool // true = response text segment + text string // thinking / reply text (stored in full) + stepIdx int // index into msg.steps when a tool call open bool // reasoning: user wants the full block (live turns auto-open) + rendered string // cached glamour render (finalized reply segments) } // turnStats is the telemetry of one finalized assistant turn, captured from the @@ -350,7 +353,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // keeps a phantom streaming assistant message with no reply. Same // inline styling as a server-side error event. if i := m.cur(); i >= 0 && m.msgs[i].content == "" { - m.msgs[i].content = "**Error:** " + msg.err.Error() + setTurnMarker(&m.msgs[i], "**Error:** "+msg.err.Error()) } m.finalize() m.relayout() // the busy status line releases its row @@ -851,6 +854,11 @@ func (m *Model) resize(w, h int) tea.Cmd { for i := range m.msgs { if m.msgs[i].role == roleAsst && !m.msgs[i].streaming && !m.msgs[i].raw { m.msgs[i].rendered = m.render(m.msgs[i].content) + for j := range m.msgs[i].items { + if m.msgs[i].items[j].reply { + m.msgs[i].items[j].rendered = m.render(m.msgs[i].items[j].text) + } + } } } } diff --git a/internal/tui/pairs_test.go b/internal/tui/pairs_test.go new file mode 100644 index 0000000..06c0800 --- /dev/null +++ b/internal/tui/pairs_test.go @@ -0,0 +1,202 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestThinkReplyPairsRenderIndependently pins the segmented-turn contract: a +// long agentic turn with several think→say→tool cycles keeps every cycle on +// the timeline and renders each reasoning block with its own response card, +// in arrival order — instead of merging all prose into one trailing card. +func TestThinkReplyPairsRenderIndependently(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleUser, content: "scan the repo"}, + message{role: roleAsst, streaming: true}, + ) + m.curIdx = 1 + m.busy = true + + feed := []client.Event{ + {Type: "thinking", Content: "first thought"}, + {Type: "token", Content: "Starting the scan."}, + {Type: "tool_call", Name: "search_files", Data: `{"pattern":"TODO"}`}, + {Type: "tool_result", Name: "search_files", Data: "main.go:1"}, + {Type: "thinking", Content: "second thought"}, + {Type: "token", Content: "Found one TODO."}, + } + for _, ev := range feed { + m.handleEvent(ev) + } + + msg := &m.msgs[1] + want := []struct { + think bool + reply bool + text string + }{ + {true, false, "first thought"}, + {false, true, "Starting the scan."}, + {false, false, ""}, // tool step + {true, false, "second thought"}, + {false, true, "Found one TODO."}, + } + if len(msg.items) != len(want) { + t.Fatalf("timeline = %+v", msg.items) + } + for i, w := range want { + it := msg.items[i] + switch { + case w.think && (!it.thinking || it.text != w.text), + w.reply && (!it.reply || it.text != w.text), + !w.think && !w.reply && it.thinking: + t.Errorf("item %d = %+v, want %+v", i, it, w) + } + } + + // Rendered order: thought → its card → work → next thought → its card. + rendered, _ := m.renderMessage(*msg, 1, 0) + out := plain(rendered) + first := strings.Index(out, "first thought") + card1 := strings.Index(out, "Starting the scan.") + work := strings.Index(out, "search_files") + second := strings.Index(out, "second thought") + card2 := strings.Index(out, "Found one TODO.") + for _, idx := range []int{first, card1, work, second, card2} { + if idx < 0 { + t.Fatalf("missing segment in:\n%s", out) + } + } + if first >= card1 || card1 >= work || work >= second || second >= card2 { + t.Errorf("think→reply pairs not interleaved chronologically:\n%s", out) + } + + // The compat blob joins distinct cycles with a blank line. + m.handleEvent(client.Event{Type: "done"}) + if got := m.msgs[1].content; got != "Starting the scan.\n\nFound one TODO." { + t.Errorf("content blob = %q", got) + } + + // Finalized: every reply segment carries its own glamour cache. + for i, it := range m.msgs[1].items { + if it.reply && it.rendered == "" { + t.Errorf("finalized reply %d has no cached render", i) + } + } + rendered, _ = m.renderMessage(m.msgs[1], 1, 0) + if out := plain(rendered); !strings.Contains(out, "Found one TODO.") { + t.Errorf("finalized render lost the last reply:\n%s", out) + } +} + +// TestTurnMarkerJoinsLastReply verifies cancel / interrupt / error markers +// land on the FINAL reply segment — attached to the last answer card — while +// the raw blob keeps the legacy "previous\n\nmarker" shape. A turn with no +// prose at all carries the marker as its only reply segment. +func TestTurnMarkerJoinsLastReply(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + + m.handleEvent(client.Event{Type: "thinking", Content: "hmm"}) + m.handleEvent(client.Event{Type: "token", Content: "partial answer"}) + m.handleEvent(client.Event{Type: "cancelled"}) + m.handleEvent(client.Event{Type: "error", Message: "context canceled"}) + + msg := &m.msgs[0] + var replies []turnItem + for _, it := range msg.items { + if it.reply { + replies = append(replies, it) + } + } + if len(replies) != 1 || !strings.HasSuffix(replies[0].text, "\n\n**Cancelled.**") { + t.Fatalf("marker not attached to the open reply: %+v", replies) + } + if got := msg.content; got != "partial answer\n\n**Cancelled.**" { + t.Errorf("content blob = %q", got) + } + rendered, _ := m.renderMessage(*msg, 0, 0) + out := plain(rendered) + if strings.Index(out, "partial answer") > strings.Index(out, "Cancelled.") { + t.Errorf("marker should render at the end of the final card:\n%s", out) + } + + // No prose yet: the marker stands alone as the turn's only segment. + m2 := newTestModel() + m2.msgs = append(m2.msgs, message{role: roleAsst, streaming: true}) + m2.curIdx = 0 + setTurnMarker(&m2.msgs[0], "**Error:** boom") + if n := len(m2.msgs[0].items); n != 1 || m2.msgs[0].items[0].text != "**Error:** boom" { + t.Fatalf("marker-only turn timeline = %+v", m2.msgs[0].items) + } + if got := m2.msgs[0].content; got != "**Error:** boom" { + t.Errorf("marker-only blob = %q", got) + } +} + +// TestStepRefsTrackInterleavedCards verifies mouse hit-testing stays exact +// when answer cards interleave with work items: each recorded ref line must +// be the step's own header row in the assembled transcript. +func TestStepRefsTrackInterleavedCards(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, + content: "halfway report", + items: []turnItem{ + {thinking: true, text: "plan"}, + {stepIdx: 0}, + {reply: true, text: "halfway report"}, + {stepIdx: 1}, + }, + steps: []step{ + {name: "read_file", done: true, result: "ok"}, + {name: "shell", done: true, result: "done"}, + }, + }) + _ = m.conversation() + if len(m.stepLineIndex) != 2 { + t.Fatalf("expected 2 step refs, got %+v", m.stepLineIndex) + } + lines := strings.Split(plain(m.conversation()), "\n") + for i, ref := range m.stepLineIndex { + want := []string{"read_file", "shell"}[i] + actual := -1 + for j, ln := range lines { + if strings.Contains(ln, want) && strings.Contains(ln, "▶") { + actual = j + break + } + } + if actual < 0 { + t.Fatalf("step %s header not found in transcript", want) + } + if ref.line != actual { + t.Errorf("step %d (%s): ref points at line %d, header actually at %d", + i, want, ref.line, actual) + } + } +} + +// TestResidualBlobNeverLost covers the hybrid shape a hand-built message can +// reach (blob text set before any event): the timeline cards must render AND +// the pre-existing blob must not vanish behind them. +func TestResidualBlobNeverLost(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, content: "preset answer", + streaming: true}) + m.curIdx = 0 + + m.handleEvent(client.Event{Type: "token", Content: " streamed tail"}) + m.handleEvent(client.Event{Type: "done"}) + + out := plain(m.conversation()) + for _, want := range []string{"preset answer", "streamed tail"} { + if !strings.Contains(out, want) { + t.Errorf("rendered transcript lost %q:\n%s", want, out) + } + } +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go index aec3284..99ef08e 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -871,7 +871,7 @@ func (m *Model) handleSessionSwitch(msg sessionSwitchMsg) tea.Cmd { // replayTranscript rebuilds a saved transcript turn by turn so a resumed // session renders identically to a live one: a user message opens a turn, // and everything up to the next user message accumulates into a single -// assistant message — reasoning blocks, tool calls/results, and reply text +// assistant message — reasoning blocks, reply segments, tool calls/results // interleaved in arrival order, mirroring live event ingestion. System // messages are dropped; blank assistant messages (no reply, reasoning, or // steps) are skipped. @@ -879,20 +879,13 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) { var cur *message // current turn's assistant message, not yet flushed stepByCallID := map[string]int{} - // flush closes the current assistant message: it folds the timeline's - // thinking items into msg.thinking (like finalize) and renders the reply. + // flush closes the current assistant message like finalize() does and + // flushes it into the transcript. flush := func() { if cur == nil { return } - var thoughts []string - for _, it := range cur.items { - if it.thinking { - thoughts = append(thoughts, it.text) - } - } - cur.thinking = strings.Join(thoughts, "\n") - cur.rendered = m.render(cur.content) + m.closeTurn(cur) if strings.TrimSpace(cur.content) != "" || len(cur.items) > 0 { m.msgs = append(m.msgs, *cur) } @@ -916,6 +909,11 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) { // at render time, expandAll unfolds the whole block. 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) + } for _, tc := range mm.ToolCalls { name := tc.Function.Name cur.steps = append(cur.steps, step{ @@ -926,12 +924,6 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) { stepByCallID[tc.ID] = len(cur.steps) - 1 cur.items = append(cur.items, turnItem{stepIdx: len(cur.steps) - 1}) } - if c := sanitize(mm.Content); strings.TrimSpace(c) != "" { - if cur.content != "" { - cur.content += "\n\n" - } - cur.content += c - } case "tool": if cur == nil { continue // a tool result with no assistant message to attach to diff --git a/internal/tui/view.go b/internal/tui/view.go index 1434f45..19f9368 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -426,17 +426,20 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st if msg.collapsed { return label + "\n" + th.statsDim.Render(m.collapseSummary(msg)), nil } - // Resolve the markdown body (finalized or streaming) first. + // Resolve the markdown body for messages without timeline replies + // (hand-built or resumed transcripts): finalized turns show the + // cached glamour render. content := msg.content if !msg.streaming && msg.rendered != "" { content = msg.rendered } // Compose the turn body from the chronological timeline: reasoning - // blocks and tool steps interleaved in arrival order. + // blocks, tool steps, and reply segments interleaved in arrival + // order — each think→reply pair renders independently. items := msg.items if len(items) == 0 { - // Messages without a timeline (hand-built or resumed transcripts) - // fall back to the old fixed order: thinking, then steps. + // Messages without a timeline (hand-built) fall back to the old + // fixed order: thinking, steps, then the reply as one card. if strings.TrimSpace(msg.thinking) != "" { items = append(items, turnItem{thinking: true, text: msg.thinking}) } @@ -444,9 +447,23 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st items = append(items, turnItem{stepIdx: i}) } } - var b strings.Builder + var lines []string // turn-body rows below the label, in render order var refs []stepRef - line := lineOffset + 1 // body starts one line below the label + prevCard := false // previous emitted block was an answer card + var replied []string // reply texts emitted from the timeline + // addBlock stacks one rendered block onto the body. Work stays + // tightly packed under the previous work item; any block touching a + // card is separated by a blank row so each raised card reads as its + // own unit. Returns the row the block starts at. + addBlock := func(block string, card bool) int { + if len(lines) > 0 && (card || prevCard) { + lines = append(lines, "") + } + start := lineOffset + 1 + len(lines) + lines = append(lines, strings.Split(strings.TrimRight(block, "\n"), "\n")...) + prevCard = card + return start + } for it := range items { if items[it].thinking { t := strings.TrimSpace(items[it].text) @@ -462,58 +479,69 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st body = t } excerpt := th.thinkStyle.Width(max(m.vp.Width-4, 8)).Render("… " + body) - if b.Len() > 0 { - b.WriteString("\n") + addBlock(th.asstWork.Render(excerpt), false) + continue + } + if items[it].reply { + t := items[it].text + if strings.TrimSpace(t) == "" { + continue + } + body := t + if !msg.streaming && items[it].rendered != "" { + body = items[it].rendered } - b.WriteString(excerpt) - line += lineCount(excerpt) + card, _ := m.answerCardBody(body) + addBlock(card, true) + replied = append(replied, t) continue } if items[it].stepIdx < 0 || items[it].stepIdx >= len(msg.steps) { continue } - block, ref, n := m.renderStep(msg.steps[items[it].stepIdx], msg.streaming, msgIdx, items[it].stepIdx, line) - if b.Len() > 0 { - b.WriteString("\n") - } - b.WriteString(block) - refs = append(refs, ref) - line += n + block, _, _ := m.renderStep(msg.steps[items[it].stepIdx], msg.streaming, msgIdx, items[it].stepIdx, 0) + start := addBlock(th.asstWork.Render(block), false) + refs = append(refs, stepRef{msgIdx: msgIdx, stepIdx: items[it].stepIdx, line: start}) + } + // Residual prose: hand-built messages carry their reply only in + // msg.content (or msg.rendered) — render it as one trailing card. + // Live and replayed turns keep the blob in sync with the timeline + // (appendReply), so the per-cycle cards already carry everything. + trail := content + if len(replied) > 0 && msg.content == strings.Join(replied, "\n\n") { + trail = "" + } + if strings.TrimSpace(trail) != "" { + card, _ := m.answerCardBody(trail) + addBlock(card, true) } - // The work section (reasoning + steps) and the final answer are - // separate blocks: the answer renders bare at column zero — full - // brightness, and copy-paste-clean — separated from the dimmer, - // indented work items by a blank line. var out strings.Builder out.WriteString(label) - if b.Len() > 0 { + if len(lines) > 0 { out.WriteString("\n") - out.WriteString(th.asstWork.Render(strings.TrimRight(b.String(), "\n"))) - } - if strings.TrimSpace(content) != "" { - if b.Len() > 0 { - out.WriteString("\n\n") - } else { - out.WriteString("\n") - } - // The answer renders as one raised card: the deliverable of the - // turn, visually distinct from the dimmed work above it. Glamour - // wraps at vp-6, so the card's padding still fits; high-contrast - // skips the surface entirely. - if th.answerCard.GetBackground() == nil { - out.WriteString(content) - } else { - // Glamour resets styling after each span; without re-asserting - // the surface after every reset, the text would sit on the - // terminal's own background instead of the card. - card := th.answerCard.Width(m.vp.Width - 2) - out.WriteString(card.Render(weaveSurface(content, surfaceSGR(th.answerCard)))) - } + out.WriteString(strings.Join(lines, "\n")) } return out.String(), refs } } +// 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 +// still fits; high-contrast skips the surface entirely. Returns the styled +// card and its line count. +func (m *Model) answerCardBody(body string) (string, int) { + if m.th.answerCard.GetBackground() == nil { + return body, lineCount(body) + } + // Glamour resets styling after each span; without re-asserting the + // surface after every reset, the text would sit on the terminal's own + // background instead of the card. + card := m.th.answerCard.Width(m.vp.Width - 2) + styled := card.Render(weaveSurface(body, surfaceSGR(m.th.answerCard))) + return styled, lineCount(styled) +} + // collapseSummary describes what a folded turn card hides, so the collapsed // form stays informative: steps, reasoning, and a reply preview. func (m *Model) collapseSummary(msg message) string {