diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 3056baab..02d6bc6f 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -218,7 +218,8 @@ func PreviewFrame(kind string, w, h int) string { m.routerMessages["04a546aa"] = []RouterEntry{ {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: "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: "ReadFile (path=/etc/hosts, offset=0, limit=200) · 28 lines"}, {Role: "action", Icon: "✓", Content: "ListDir (path=/home/kami, pattern=*.sh, recursive=false) · 3 entries"}, diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index a384591b..d2d6b6b9 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -75,7 +75,6 @@ type RouterEntry struct { Content string Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟) 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. @@ -442,10 +441,6 @@ type Model struct { // (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back. 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 // of type/detail. eventFilterTyping is true while the operator is editing the query after /. eventFilter string diff --git a/apps/tui-go/internal/app/output_content_test.go b/apps/tui-go/internal/app/output_content_test.go new file mode 100644 index 00000000..4fa2e85d --- /dev/null +++ b/apps/tui-go/internal/app/output_content_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 8d6ec29e..2a6f31eb 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -194,7 +194,6 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } } case protocol.TypeInferenceStarted: - m.lastReasoning = "" if s := m.session(msg.SessionID); s != nil { s.Active = true 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. if strings.TrimSpace(msg.Reasoning) != "" { m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning}) - m.lastReasoning = msg.Reasoning } } case protocol.TypeInferenceTimeout: @@ -255,7 +253,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { // existing collapsed diff row (^x opens the full diff). path, add, del := diffSummary(*msg.Diff) 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 { label := msg.ToolName // 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) + ")" } -// 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 { - // Collapse to a single line first: tool summaries (dir listings, file heads) - // often carry newlines, and a multi-line action row paints a background stripe - // per line with the raw content leaking underneath. + // Newlines are normalised away because the renderer re-wraps to panel width anyway; + // leaving them in would paint a background stripe per raw line. s := strings.Join(strings.Fields(summary), " ") if s == "" { 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 { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index d7942b33..2f608781 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -823,23 +823,9 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) { rows = append(rows, t.span(s, t.P.Faint)) } case "tool": - if e.Reasoning != "" { - if m.thinkingShown { - for _, ln := range strings.Split(strings.TrimRight(e.Reasoning, "\n"), "\n") { - 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)) - } - } + // No reasoning block here: the trace already renders as its own "thinking" row + // (appended on inference.completed), and repeating it on the following tool row + // showed the same CoT twice per turn. gutter := t.span(" ┈", t.P.Faint) summary := toolRowSummary(e.Content) avail := w - 4