fix(tui): render CoT once per turn, stop clipping tool output (#415, #414)

The reasoning trace was rendered twice: once as its own "thinking" row on
inference.completed, and again as a ✼ block on the following tool row, whose
Reasoning was copied from Model.lastReasoning. The fallback branch was worse —
lastReasoning is current model state, so every tool row with no Reasoning of its
own (all non-diff rows, all snapshot-restored rows) got the newest trace stamped
on it retroactively. Drop the block, the copy, and the now-dead
RouterEntry.Reasoning / Model.lastReasoning; the standalone row already covers
tool turns.

actionToolText clipped every summary to 48 columns, which cut tool output and —
worse — harness coach text: read-before-write rejections, write blocks, gate
feedback, exactly the messages that say whether the agent was steered or
silently blocked. The action renderer already wraps to panel width, so the clip
was the only single-line ceiling; raise it to 4000 chars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 23:46:25 +04:00
parent 45a9fe3369
commit 5ed8ebd0c5
5 changed files with 58 additions and 31 deletions
+2 -1
View File
@@ -218,7 +218,8 @@ func PreviewFrame(kind string, w, h int) string {
m.routerMessages["04a546aa"] = []RouterEntry{ m.routerMessages["04a546aa"] = []RouterEntry{
{Role: "user", Content: "fix the healthcheck script and search for all usages"}, {Role: "user", Content: "fix the healthcheck script and search for all usages"},
{Role: "router", Content: "I'll read the current file, grep for references, then write the fix."}, {Role: "router", Content: "I'll read the current file, grep for references, then write the fix."},
{Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n+exit 0\n", Reasoning: "The user wants a script that checks a health endpoint and reports the status."}, {Role: "thinking", Content: "The user wants a script that checks a health endpoint and reports the status."},
{Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n+exit 0\n"},
{Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+12 0)"}, {Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+12 0)"},
{Role: "action", Icon: "✓", Content: "ReadFile (path=/etc/hosts, offset=0, limit=200) · 28 lines"}, {Role: "action", Icon: "✓", Content: "ReadFile (path=/etc/hosts, offset=0, limit=200) · 28 lines"},
{Role: "action", Icon: "✓", Content: "ListDir (path=/home/kami, pattern=*.sh, recursive=false) · 3 entries"}, {Role: "action", Icon: "✓", Content: "ListDir (path=/home/kami, pattern=*.sh, recursive=false) · 3 entries"},
-5
View File
@@ -75,7 +75,6 @@ type RouterEntry struct {
Content string Content string
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟) Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
Metrics *TurnMetrics Metrics *TurnMetrics
Reasoning string // model reasoning/CoT that preceded this entry (tool/artifact turns)
} }
// TurnMetrics carries optional latency + token cost for a ROUTER chat turn. // TurnMetrics carries optional latency + token cost for a ROUTER chat turn.
@@ -442,10 +441,6 @@ type Model struct {
// (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back. // (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back.
outputScroll int outputScroll int
// lastReasoning holds the model's reasoning/CoT trace from the most recent
// inference.completed, surfaced on the following tool-call and artifact rows.
lastReasoning string
// event-inspector filter (OverlayEventInspector): narrows the event list by a substring // event-inspector filter (OverlayEventInspector): narrows the event list by a substring
// of type/detail. eventFilterTyping is true while the operator is editing the query after /. // of type/detail. eventFilterTyping is true while the operator is editing the query after /.
eventFilter string eventFilter string
@@ -0,0 +1,42 @@
package app
import (
"strings"
"testing"
)
// #414: tool output and harness coach text (read-before-write, write blocks) must survive
// into the transcript in full and wrap across rows — not get clipped to a single line.
func TestActionToolTextKeepsFullSummary(t *testing.T) {
coach := "write rejected: read apps/server/src/main/kotlin/Dtos.kt before editing it — " +
"the file is not in this stage's read manifest, so the edit would be blind."
got := actionToolText("file_write blocked", coach)
if !strings.Contains(got, "read manifest") {
t.Fatalf("coach text was clipped: %q", got)
}
m := inSessionModel(120, 30)
m.routerMessages[m.selectedID] = []RouterEntry{{Role: "action", Icon: "✕", Content: got}}
w, _ := m.outputViewport()
rows, _ := m.buildTranscriptRows(w)
if len(rows) < 2 {
t.Fatalf("long action content rendered in %d row(s), want it wrapped over several", len(rows))
}
}
// #415: the reasoning trace renders once per turn (its own "thinking" row) — the following
// tool row must not repeat it.
func TestReasoningRendersOncePerTurn(t *testing.T) {
const cot = "unique-cot-marker: check the health endpoint first"
m := inSessionModel(120, 30)
m.thinkingShown = true
m.routerMessages[m.selectedID] = []RouterEntry{
{Role: "thinking", Content: cot},
{Role: "tool", Content: "--- a/x.sh\n+++ b/x.sh\n@@ -0,0 +1 @@\n+echo ok\n"},
}
w, _ := m.outputViewport()
rows, _ := m.buildTranscriptRows(w)
if n := strings.Count(stripANSI(strings.Join(rows, "\n")), "unique-cot-marker"); n != 1 {
t.Fatalf("reasoning rendered %d times, want 1", n)
}
}
+11 -8
View File
@@ -194,7 +194,6 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
} }
} }
case protocol.TypeInferenceStarted: case protocol.TypeInferenceStarted:
m.lastReasoning = ""
if s := m.session(msg.SessionID); s != nil { if s := m.session(msg.SessionID); s != nil {
s.Active = true s.Active = true
s.addEvent(nowMillis(), "InferenceStarted", msg.StageID) s.addEvent(nowMillis(), "InferenceStarted", msg.StageID)
@@ -214,7 +213,6 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
// row (revealed via the palette). Skipped when the model emits no separate channel. // row (revealed via the palette). Skipped when the model emits no separate channel.
if strings.TrimSpace(msg.Reasoning) != "" { if strings.TrimSpace(msg.Reasoning) != "" {
m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning}) m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning})
m.lastReasoning = msg.Reasoning
} }
} }
case protocol.TypeInferenceTimeout: case protocol.TypeInferenceTimeout:
@@ -255,7 +253,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
// existing collapsed diff row (^x opens the full diff). // existing collapsed diff row (^x opens the full diff).
path, add, del := diffSummary(*msg.Diff) path, add, del := diffSummary(*msg.Diff)
m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del)) m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff, Reasoning: m.lastReasoning}) m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
} else { } else {
label := msg.ToolName label := msg.ToolName
// Prefer the actual call args (path=…, command="…") over the affected-entities // Prefer the actual call args (path=…, command="…") over the affected-entities
@@ -598,16 +596,21 @@ func paramSuffix(params []string) string {
return " (" + clip(strings.Join(params, " · "), 200) + ")" return " (" + clip(strings.Join(params, " · "), 200) + ")"
} }
// actionToolText joins a tool label with a short, clipped result summary. // actionToolText joins a tool label with its result summary. The action row wraps its
// content to the panel width, so the summary is kept whole rather than clipped to one
// line — tool output and harness coach text (read-before-write, write blocks, gate
// feedback) are the payload, not decoration.
func actionToolText(label, summary string) string { func actionToolText(label, summary string) string {
// Collapse to a single line first: tool summaries (dir listings, file heads) // Newlines are normalised away because the renderer re-wraps to panel width anyway;
// often carry newlines, and a multi-line action row paints a background stripe // leaving them in would paint a background stripe per raw line.
// per line with the raw content leaking underneath.
s := strings.Join(strings.Fields(summary), " ") s := strings.Join(strings.Fields(summary), " ")
if s == "" { if s == "" {
return label return label
} }
return label + " · " + clip(s, 48) // ponytail: flat 4000-char ceiling so one recursive list_dir can't flood the
// transcript. Swap for expand-on-select against the diff/preview surface if that
// ceiling starts cutting real output.
return label + " · " + clip(s, 4000)
} }
func approvalIcon(outcome string) string { func approvalIcon(outcome string) string {
+3 -17
View File
@@ -823,23 +823,9 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) {
rows = append(rows, t.span(s, t.P.Faint)) rows = append(rows, t.span(s, t.P.Faint))
} }
case "tool": case "tool":
if e.Reasoning != "" { // No reasoning block here: the trace already renders as its own "thinking" row
if m.thinkingShown { // (appended on inference.completed), and repeating it on the following tool row
for _, ln := range strings.Split(strings.TrimRight(e.Reasoning, "\n"), "\n") { // showed the same CoT twice per turn.
rows = append(rows, t.span(" ✼ "+ln, t.P.Faint))
}
} else {
rows = append(rows, t.span(" ✼ reasoning — palette: thinking", t.P.Faint))
}
} else if m.lastReasoning != "" {
if m.thinkingShown {
for _, ln := range strings.Split(strings.TrimRight(m.lastReasoning, "\n"), "\n") {
rows = append(rows, t.span(" ✼ "+ln, t.P.Faint))
}
} else {
rows = append(rows, t.span(" ✼ reasoning — palette: thinking", t.P.Faint))
}
}
gutter := t.span(" ┈", t.P.Faint) gutter := t.span(" ┈", t.P.Faint)
summary := toolRowSummary(e.Content) summary := toolRowSummary(e.Content)
avail := w - 4 avail := w - 4