From 86e35fbfa38ce48503d33ab5949797d198a25d1b Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 19 Jun 2026 14:43:46 +0800 Subject: [PATCH 1/2] feat(tui): collapse tool-call and reasoning output, toggle with Ctrl+O - Add tool-call-collapsible.ts with buildCollapsedToolSummary and buildToolTranscriptLines helpers. Render tool entries collapsed by default, showing a one-line summary and a '... (ctrl-o to expand)' hint. - Add reasoning-collapsible.ts with buildCollapsedReasoningSummary and buildReasoningTranscriptLines helpers. Render reasoning entries with a THINK badge and a 2-line preview by default. - Add a 'reasoning' role to StepCliTuiTranscriptEntry. Split assistant messages that contain reasoning into separate reasoning and assistant entries in LocalOpenTuiTranscriptBridge. - Share a single global Ctrl+O toggle in StepCliTuiScreen for both tool and reasoning output. - Update welcome hint to mention Ctrl+O expands/collapses both tool and reasoning output. - Add unit tests for tool-call collapsing, reasoning collapsing, and the bridge splitting behavior. Closes #63 Closes #64 --- src/runtime/local-opentui-bridge.test.ts | 86 ++++++++++++++++++++++ src/runtime/local-opentui-bridge.ts | 84 ++++++++++++--------- src/tui/app.tsx | 82 ++++++++++++++++++--- src/tui/reasoning-collapsible.test.ts | 70 ++++++++++++++++++ src/tui/reasoning-collapsible.ts | 48 ++++++++++++ src/tui/tool-call-collapsible.test.ts | 93 ++++++++++++++++++++++++ src/tui/tool-call-collapsible.ts | 74 +++++++++++++++++++ src/tui/types.ts | 2 +- 8 files changed, 492 insertions(+), 47 deletions(-) create mode 100644 src/runtime/local-opentui-bridge.test.ts create mode 100644 src/tui/reasoning-collapsible.test.ts create mode 100644 src/tui/reasoning-collapsible.ts create mode 100644 src/tui/tool-call-collapsible.test.ts create mode 100644 src/tui/tool-call-collapsible.ts diff --git a/src/runtime/local-opentui-bridge.test.ts b/src/runtime/local-opentui-bridge.test.ts new file mode 100644 index 00000000..0bb325c4 --- /dev/null +++ b/src/runtime/local-opentui-bridge.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { LocalOpenTuiTranscriptBridge } from "./local-opentui-bridge.js"; +import type { ChatMessage } from "@step-cli/protocol"; + +function createBridge(): LocalOpenTuiTranscriptBridge { + return new LocalOpenTuiTranscriptBridge(); +} + +describe("LocalOpenTuiTranscriptBridge", () => { + describe("reconcileWithSessionMessages with reasoning", () => { + it("splits assistant messages with reasoning into two entries", () => { + const bridge = createBridge(); + const messages: ChatMessage[] = [ + { + role: "assistant", + content: "Final answer.", + reasoning: "First I thought about X.\nThen I considered Y.", + }, + ]; + + bridge.reconcileWithSessionMessages(messages); + const entries = bridge.getEntries(); + + expect(entries).toHaveLength(2); + expect(entries[0]?.role).toBe("reasoning"); + expect(entries[0]?.content).toBe( + "First I thought about X.\nThen I considered Y.", + ); + expect(entries[1]?.role).toBe("assistant"); + expect(entries[1]?.content).toBe("Final answer."); + }); + + it("keeps a single assistant entry when there is no reasoning", () => { + const bridge = createBridge(); + const messages: ChatMessage[] = [ + { + role: "assistant", + content: "Just the answer.", + }, + ]; + + bridge.reconcileWithSessionMessages(messages); + const entries = bridge.getEntries(); + + expect(entries).toHaveLength(1); + expect(entries[0]?.role).toBe("assistant"); + expect(entries[0]?.content).toBe("Just the answer."); + }); + + it("uses reasoning_content over reasoning when both are present", () => { + const bridge = createBridge(); + const messages: ChatMessage[] = [ + { + role: "assistant", + content: "Answer.", + reasoning: "Old reasoning.", + reasoning_content: "New reasoning.\nMore details.", + }, + ]; + + bridge.reconcileWithSessionMessages(messages); + const entries = bridge.getEntries(); + + expect(entries).toHaveLength(2); + expect(entries[0]?.role).toBe("reasoning"); + expect(entries[0]?.content).toBe("New reasoning.\nMore details."); + }); + + it("ignores empty reasoning fields", () => { + const bridge = createBridge(); + const messages: ChatMessage[] = [ + { + role: "assistant", + content: "Answer.", + reasoning: " ", + }, + ]; + + bridge.reconcileWithSessionMessages(messages); + const entries = bridge.getEntries(); + + expect(entries).toHaveLength(1); + expect(entries[0]?.role).toBe("assistant"); + }); + }); +}); diff --git a/src/runtime/local-opentui-bridge.ts b/src/runtime/local-opentui-bridge.ts index 6374be43..a57246ba 100644 --- a/src/runtime/local-opentui-bridge.ts +++ b/src/runtime/local-opentui-bridge.ts @@ -159,7 +159,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl messages: ChatMessage[], settledTurnId?: string, ): void { - const nextSessionEntries = messages.map((message) => + const nextSessionEntries = messages.flatMap((message) => mapChatMessageToTranscriptEntry(message), ); const appendedSessionEntries = nextSessionEntries.slice( @@ -379,7 +379,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl this.sessionEntries = [ ...this.sessionEntries, - ...(messages as ChatMessage[]).map((message) => + ...(messages as ChatMessage[]).flatMap((message) => mapChatMessageToTranscriptEntry(message), ), ]; @@ -868,58 +868,74 @@ function matchCoveredOptimisticUserTurnIds( function mapChatMessageToTranscriptEntry( message: ChatMessage, -): StepCliTuiTranscriptEntry { +): StepCliTuiTranscriptEntry[] { switch (message.role) { - case "assistant": - return { + case "assistant": { + const reasoning = extractAssistantReasoning(message); + const assistantEntry: StepCliTuiTranscriptEntry = { id: randomUUID(), role: "assistant", caption: null, - content: formatAssistantContent(message), + content: message.content.trim(), }; - case "user": - return { + if (!reasoning) { + return [assistantEntry]; + } + const reasoningEntry: StepCliTuiTranscriptEntry = { id: randomUUID(), - role: "user", + role: "reasoning", caption: null, - content: formatUserTurnContent({ - content: message.content, - attachments: message.attachments, - }), + content: reasoning, }; + return [reasoningEntry, assistantEntry]; + } + case "user": + return [ + { + id: randomUUID(), + role: "user", + caption: null, + content: formatUserTurnContent({ + content: message.content, + attachments: message.attachments, + }), + }, + ]; case "tool": - return { - id: randomUUID(), - role: "tool", - caption: message.name, - content: formatStoredToolMessageContent(message), - }; + return [ + { + id: randomUUID(), + role: "tool", + caption: message.name, + content: formatStoredToolMessageContent(message), + }, + ]; case "system": - return { - id: randomUUID(), - role: "system", - caption: null, - content: message.content, - hidden: message.hidden, - }; + return [ + { + id: randomUUID(), + role: "system", + caption: null, + content: message.content, + hidden: message.hidden, + }, + ]; } } -function formatAssistantContent( +function extractAssistantReasoning( message: Extract, -): string { +): string | null { const reasoning = message.reasoning_content ?? message.reasoning ?? message.thinking ?? message.analysis ?? message.redacted_thinking; - const reasoningBlock = - typeof reasoning === "string" && reasoning.trim().length > 0 - ? `\n[reasoning] ${reasoning}` - : ""; - - return `${message.content}${reasoningBlock}`.trim(); + if (typeof reasoning !== "string" || reasoning.trim().length === 0) { + return null; + } + return reasoning.trim(); } function formatLocalHookEntry( diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 82880d8f..a28bb1dd 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -45,6 +45,14 @@ import { } from "./theme.js"; import { compactToolTranscriptContent } from "./transcript-preview.js"; import { buildTranscriptClipboardText } from "./transcript-export.js"; +import { + buildCollapsedToolSummary, + buildToolTranscriptLines, +} from "./tool-call-collapsible.js"; +import { + buildCollapsedReasoningSummary, + buildReasoningTranscriptLines, +} from "./reasoning-collapsible.js"; import type { StepCliTuiComposerState, StepCliTuiComposerHistoryState, @@ -147,6 +155,7 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { const [activeRunCount, setActiveRunCount] = useState(0); const [spinnerFrameIndex, setSpinnerFrameIndex] = useState(0); const [slashSelectionIndex, setSlashSelectionIndex] = useState(0); + const [toolOutputExpanded, setToolOutputExpanded] = useState(false); const submitting = activeRunCount > 0; // Voice mode state. The host passes a loadVoiceRuntime factory; we cache @@ -604,6 +613,12 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { return; } + if (key.ctrl && key.name === "o") { + key.preventDefault(); + setToolOutputExpanded((current) => !current); + return; + } + if (key.name === "up") { if (slashPaletteState.visible && slashPaletteState.matches.length > 0) { key.preventDefault(); @@ -770,8 +785,14 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { [props.scrollConfig, terminal.height], ); const transcriptItems = useMemo( - () => buildTranscriptItems(transcriptEntries, transcriptWidth, theme), - [theme, transcriptEntries, transcriptWidth], + () => + buildTranscriptItems( + transcriptEntries, + transcriptWidth, + theme, + toolOutputExpanded, + ), + [theme, transcriptEntries, transcriptWidth, toolOutputExpanded], ); return ( @@ -1522,6 +1543,7 @@ const TranscriptEntry = React.memo(function TranscriptEntry(input: { const [firstLine = "", ...restLines] = item.lines.length > 0 ? item.lines : [""]; const badgeStyle = resolveTranscriptBadgeStyle(item.tone, input.theme); + const isCollapsed = item.collapsible && !item.expanded; const body = ( <> @@ -1529,17 +1551,23 @@ const TranscriptEntry = React.memo(function TranscriptEntry(input: { {" "} {item.badge}{" "} - {item.caption ? ( + {item.caption && !isCollapsed ? ( {item.caption} ) : null} {firstLine.length > 0 ? {firstLine} : null} - {restLines.map((line, index) => ( - - - {line.length > 0 ? line : " "} - - ))} + {isCollapsed + ? restLines.map((line, index) => ( + + {line.length > 0 ? line : " "} + + )) + : restLines.map((line, index) => ( + + + {line.length > 0 ? line : " "} + + ))} {item.truncated ? ( … @@ -1911,6 +1939,7 @@ function buildTranscriptItems( entries: StepCliTuiTranscriptEntry[], width: number, theme: StepCliTuiThemeColors, + toolOutputExpanded: boolean, ): TranscriptItem[] { return [ buildWelcomeTranscriptItem(width), @@ -1918,8 +1947,22 @@ function buildTranscriptItems( .filter((entry) => !entry.hidden) .map((entry, index) => { const identity = resolveTranscriptIdentity(entry); - const body = compactToolTranscriptContent(entry); - const lines = wrapMultiline(body, Math.max(12, width - 4)); + const isTool = entry.role === "tool"; + const isReasoning = entry.role === "reasoning"; + const collapsible = isTool || isReasoning; + const lines = collapsible + ? isTool + ? buildToolTranscriptLines(entry, toolOutputExpanded) + : buildReasoningTranscriptLines(entry, toolOutputExpanded) + : wrapMultiline( + compactToolTranscriptContent(entry), + Math.max(12, width - 4), + ); + const collapsedSummary = isTool + ? buildCollapsedToolSummary(entry) + : isReasoning + ? buildCollapsedReasoningSummary(entry) + : undefined; return { id: entry.id || @@ -1929,6 +1972,9 @@ function buildTranscriptItems( border: false, lines, truncated: false, + collapsible, + expanded: collapsible ? toolOutputExpanded : undefined, + expandHint: collapsedSummary?.expandHint, }; }), ]; @@ -1938,7 +1984,7 @@ function buildWelcomeTranscriptItem(width: number): TranscriptItem { const welcomeLines = [ "Welcome to STEP.", "Start with a prompt, or use /attach to queue an image.", - "Enter send · Shift+Enter newline · Ctrl+Y or /copy copy selection/full transcript · Esc quit", + "Enter send · Shift+Enter newline · Ctrl+Y or /copy copy selection/full transcript · Ctrl+O expand/collapse tool & reasoning output · Esc quit", "/goal /attach /copy /detach /status /refresh /theme [name] /resume /exit", ].flatMap((line) => wrapMultiline(line, Math.max(12, width - 4))); @@ -2091,6 +2137,12 @@ function resolveTranscriptIdentity( caption: entry.caption, tone: "success", }; + case "reasoning": + return { + badge: "THINK", + caption: entry.caption, + tone: "muted", + }; case "system": return { badge: "SYSTEM", @@ -2319,6 +2371,12 @@ interface TranscriptItem { border: boolean; lines: string[]; truncated: boolean; + /** When true, the entry can be expanded/collapsed with Ctrl+O. */ + collapsible?: boolean; + /** Current expansion state (only meaningful when collapsible is true). */ + expanded?: boolean; + /** Hint shown when collapsed. */ + expandHint?: string; } interface SlashPaletteState { diff --git a/src/tui/reasoning-collapsible.test.ts b/src/tui/reasoning-collapsible.test.ts new file mode 100644 index 00000000..d16901d3 --- /dev/null +++ b/src/tui/reasoning-collapsible.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + buildCollapsedReasoningSummary, + buildReasoningTranscriptLines, +} from "./reasoning-collapsible.js"; +import type { StepCliTuiTranscriptEntry } from "./types.js"; + +function makeReasoningEntry(content: string): StepCliTuiTranscriptEntry { + return { + id: "test-reasoning-id", + role: "reasoning", + caption: null, + content, + }; +} + +describe("buildCollapsedReasoningSummary", () => { + it("shows all lines when content is within preview limit", () => { + const entry = makeReasoningEntry("line 1\nline 2"); + const summary = buildCollapsedReasoningSummary(entry); + expect(summary.previewLines).toEqual(["line 1", "line 2"]); + expect(summary.hiddenLineCount).toBe(0); + }); + + it("reports hidden line count when content exceeds preview", () => { + const entry = makeReasoningEntry("line 1\nline 2\nline 3\nline 4"); + const summary = buildCollapsedReasoningSummary(entry); + expect(summary.previewLines).toEqual(["line 1", "line 2"]); + expect(summary.hiddenLineCount).toBe(2); + expect(summary.expandHint).toBe("... (2 more lines, ctrl-o to expand)"); + }); + + it("uses singular hint when exactly one line is hidden", () => { + const entry = makeReasoningEntry("line 1\nline 2\nline 3"); + const summary = buildCollapsedReasoningSummary(entry); + expect(summary.hiddenLineCount).toBe(1); + expect(summary.expandHint).toBe("... (1 more line, ctrl-o to expand)"); + }); + + it("ignores empty lines when counting hidden lines", () => { + const entry = makeReasoningEntry("line 1\nline 2\n\n\nline 3"); + const summary = buildCollapsedReasoningSummary(entry); + expect(summary.previewLines).toEqual(["line 1", "line 2"]); + expect(summary.hiddenLineCount).toBe(1); + }); +}); + +describe("buildReasoningTranscriptLines", () => { + it("collapses reasoning entries by default with preview lines and hint", () => { + const entry = makeReasoningEntry("line 1\nline 2\nline 3\nline 4"); + const lines = buildReasoningTranscriptLines(entry, false); + expect(lines).toEqual([ + "line 1", + "line 2", + "... (2 more lines, ctrl-o to expand)", + ]); + }); + + it("expands reasoning entries when requested", () => { + const entry = makeReasoningEntry("line 1\nline 2\nline 3"); + const lines = buildReasoningTranscriptLines(entry, true); + expect(lines).toEqual(["line 1", "line 2", "line 3"]); + }); + + it("does not show expand hint when everything fits in preview", () => { + const entry = makeReasoningEntry("line 1\nline 2"); + const lines = buildReasoningTranscriptLines(entry, false); + expect(lines).toEqual(["line 1", "line 2"]); + }); +}); diff --git a/src/tui/reasoning-collapsible.ts b/src/tui/reasoning-collapsible.ts new file mode 100644 index 00000000..d40d52b2 --- /dev/null +++ b/src/tui/reasoning-collapsible.ts @@ -0,0 +1,48 @@ +import type { StepCliTuiTranscriptEntry } from "./types.js"; + +export const REASONING_PREVIEW_LINES = 2; + +export interface CollapsedReasoningSummary { + previewLines: string[]; + hiddenLineCount: number; + expandHint: string; +} + +export function buildCollapsedReasoningSummary( + entry: StepCliTuiTranscriptEntry, +): CollapsedReasoningSummary { + const lines = entry.content + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); + + const previewLines = lines.slice(0, REASONING_PREVIEW_LINES); + const hiddenLineCount = Math.max(0, lines.length - REASONING_PREVIEW_LINES); + + const expandHint = + hiddenLineCount === 1 + ? "... (1 more line, ctrl-o to expand)" + : `... (${hiddenLineCount} more lines, ctrl-o to expand)`; + + return { + previewLines, + hiddenLineCount, + expandHint, + }; +} + +export function buildReasoningTranscriptLines( + entry: StepCliTuiTranscriptEntry, + expanded: boolean, +): string[] { + if (expanded) { + return entry.content.split("\n"); + } + + const summary = buildCollapsedReasoningSummary(entry); + const collapsedLines = [...summary.previewLines]; + if (summary.hiddenLineCount > 0) { + collapsedLines.push(summary.expandHint); + } + return collapsedLines; +} diff --git a/src/tui/tool-call-collapsible.test.ts b/src/tui/tool-call-collapsible.test.ts new file mode 100644 index 00000000..04d6108d --- /dev/null +++ b/src/tui/tool-call-collapsible.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + buildCollapsedToolSummary, + buildToolTranscriptLines, +} from "./tool-call-collapsible.js"; +import type { StepCliTuiTranscriptEntry } from "./types.js"; + +function makeToolEntry( + content: string, + caption: string | null = "Bash", +): StepCliTuiTranscriptEntry { + return { + id: "test-tool-id", + role: "tool", + caption, + content, + }; +} + +describe("buildCollapsedToolSummary", () => { + it("returns status and first detail in headline", () => { + const entry = makeToolEntry( + "[completed] completed\nargs ls -la\nresult line 1\nresult line 2", + ); + const summary = buildCollapsedToolSummary(entry); + expect(summary.headline).toBe("Bash · [completed] args ls -la"); + expect(summary.previewLines).toEqual(["result line 1", "result line 2"]); + expect(summary.hiddenLineCount).toBe(0); + }); + + it("reports hidden line count when content exceeds preview", () => { + const entry = makeToolEntry( + "[completed] completed\nargs ls -la\nline 1\nline 2\nline 3\nline 4", + ); + const summary = buildCollapsedToolSummary(entry); + expect(summary.previewLines).toEqual(["line 1", "line 2"]); + expect(summary.hiddenLineCount).toBe(2); + expect(summary.expandHint).toBe("... (2 more lines, ctrl-o to expand)"); + }); + + it("truncates very long first detail lines", () => { + const longCommand = "x".repeat(120); + const entry = makeToolEntry(`[completed] completed\n${longCommand}`); + const summary = buildCollapsedToolSummary(entry); + expect(summary.headline).toBe(`Bash · [completed] ${"x".repeat(77)}...`); + expect(summary.previewLines).toEqual([]); + expect(summary.hiddenLineCount).toBe(0); + }); + + it("does not show expand hint for a single-line result", () => { + const entry = makeToolEntry("[completed] completed"); + const summary = buildCollapsedToolSummary(entry); + expect(summary.headline).toBe("Bash · [completed]"); + expect(summary.previewLines).toEqual([]); + expect(summary.hiddenLineCount).toBe(0); + }); + + it("falls back to 'completed' when no status tag is present", () => { + const entry = makeToolEntry("plain output\nmore output\nthird line"); + const summary = buildCollapsedToolSummary(entry); + expect(summary.headline).toBe("Bash · [completed] plain output"); + expect(summary.previewLines).toEqual(["more output", "third line"]); + expect(summary.hiddenLineCount).toBe(0); + }); +}); + +describe("buildToolTranscriptLines", () => { + it("collapses tool entries by default with preview lines and hint", () => { + const entry = makeToolEntry( + "[completed] completed\nargs ls -la\nline 1\nline 2\nline 3", + ); + const lines = buildToolTranscriptLines(entry, false); + expect(lines).toEqual([ + "Bash · [completed] args ls -la", + "line 1", + "line 2", + "... (1 more line, ctrl-o to expand)", + ]); + }); + + it("expands tool entries when requested", () => { + const entry = makeToolEntry("[completed] completed\nline 1\nline 2"); + const lines = buildToolTranscriptLines(entry, true); + expect(lines.join("\n")).toContain("line 1"); + expect(lines.join("\n")).toContain("line 2"); + }); + + it("does not show expand hint when everything fits in preview", () => { + const entry = makeToolEntry("[completed] completed\nargs ls -la\nline 1"); + const lines = buildToolTranscriptLines(entry, false); + expect(lines).toEqual(["Bash · [completed] args ls -la", "line 1"]); + }); +}); diff --git a/src/tui/tool-call-collapsible.ts b/src/tui/tool-call-collapsible.ts new file mode 100644 index 00000000..b6769daa --- /dev/null +++ b/src/tui/tool-call-collapsible.ts @@ -0,0 +1,74 @@ +import type { StepCliTuiTranscriptEntry } from "./types.js"; + +const COLLAPSED_TOOL_DETAIL_MAX_LENGTH = 80; +const TOOL_PREVIEW_LINES = 2; +const STATUS_LINE_RE = /^\[(\w+)\]\s*(.*)$/; + +export interface CollapsedToolSummary { + headline: string; + previewLines: string[]; + hiddenLineCount: number; + expandHint: string; +} + +export function buildCollapsedToolSummary( + entry: StepCliTuiTranscriptEntry, +): CollapsedToolSummary { + const caption = entry.caption ?? "tool"; + const lines = entry.content.split("\n"); + const firstLine = lines[0]?.trim() ?? ""; + const statusMatch = STATUS_LINE_RE.exec(firstLine); + const status = statusMatch?.[1] ?? "completed"; + + // Detail lines are everything after the status line, skipping empty lines. + const detailStartIndex = statusMatch ? 1 : 0; + const detailLines = lines + .slice(detailStartIndex) + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); + + const firstDetail = detailLines[0]?.trim() ?? ""; + const trimmedFirstDetail = + firstDetail.length > COLLAPSED_TOOL_DETAIL_MAX_LENGTH + ? `${firstDetail.slice(0, COLLAPSED_TOOL_DETAIL_MAX_LENGTH - 3)}...` + : firstDetail; + + const headline = trimmedFirstDetail + ? `${caption} · [${status}] ${trimmedFirstDetail}` + : `${caption} · [${status}]`; + + // Show the next N detail lines after the one already used in the headline. + const previewLines = detailLines.slice(1, 1 + TOOL_PREVIEW_LINES); + const hiddenLineCount = Math.max( + 0, + detailLines.length - 1 - previewLines.length, + ); + + const expandHint = + hiddenLineCount === 1 + ? "... (1 more line, ctrl-o to expand)" + : `... (${hiddenLineCount} more lines, ctrl-o to expand)`; + + return { + headline, + previewLines, + hiddenLineCount, + expandHint, + }; +} + +export function buildToolTranscriptLines( + entry: StepCliTuiTranscriptEntry, + expanded: boolean, +): string[] { + if (expanded) { + return entry.content.split("\n"); + } + + const summary = buildCollapsedToolSummary(entry); + const collapsedLines = [summary.headline, ...summary.previewLines]; + if (summary.hiddenLineCount > 0) { + collapsedLines.push(summary.expandHint); + } + return collapsedLines; +} diff --git a/src/tui/types.ts b/src/tui/types.ts index adf838f9..37eeed7e 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -84,7 +84,7 @@ export interface StepCliTuiSessionData { export interface StepCliTuiTranscriptEntry { id: string; - role: "assistant" | "user" | "tool" | "system"; + role: "assistant" | "user" | "tool" | "system" | "reasoning"; content: string; caption: string | null; /** Internal message that should not be rendered in the transcript. */ From 4ffd1c43af871276c06e1d247133ca26364647ef Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Fri, 17 Jul 2026 14:50:53 +0800 Subject: [PATCH 2/2] fix(tui): wrap collapsible transcript lines and skip empty assistant entries - Run tool/reasoning collapsed and expanded lines through wrapMultiline like every other transcript line: OpenTUI does not wrap by itself and scroll heights are computed from wrapped line counts. - Normalize CRLF/CR line endings in both collapsible modules so Windows tool output carries no stray \r into the renderer. - Reasoning-only assistant messages (empty content, e.g. intermediate tool-call steps) now map to just the reasoning entry instead of also emitting an empty ASSISTANT badge row. - Drop the unused TranscriptItem.expandHint field (the hint text already lives inside lines). - Add unit tests for the empty-content split and CRLF normalization. --- src/runtime/local-opentui-bridge.test.ts | 18 +++++++++++++ src/runtime/local-opentui-bridge.ts | 6 +++++ src/tui/app.tsx | 34 ++++++++---------------- src/tui/reasoning-collapsible.test.ts | 6 +++++ src/tui/reasoning-collapsible.ts | 11 +++++--- src/tui/tool-call-collapsible.test.ts | 15 +++++++++++ src/tui/tool-call-collapsible.ts | 10 +++++-- 7 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/runtime/local-opentui-bridge.test.ts b/src/runtime/local-opentui-bridge.test.ts index 0bb325c4..eebdbc45 100644 --- a/src/runtime/local-opentui-bridge.test.ts +++ b/src/runtime/local-opentui-bridge.test.ts @@ -82,5 +82,23 @@ describe("LocalOpenTuiTranscriptBridge", () => { expect(entries).toHaveLength(1); expect(entries[0]?.role).toBe("assistant"); }); + + it("returns only the reasoning entry when assistant content is empty", () => { + const bridge = createBridge(); + const messages: ChatMessage[] = [ + { + role: "assistant", + content: "", + reasoning: "Planning the next tool call.", + }, + ]; + + bridge.reconcileWithSessionMessages(messages); + const entries = bridge.getEntries(); + + expect(entries).toHaveLength(1); + expect(entries[0]?.role).toBe("reasoning"); + expect(entries[0]?.content).toBe("Planning the next tool call."); + }); }); }); diff --git a/src/runtime/local-opentui-bridge.ts b/src/runtime/local-opentui-bridge.ts index a57246ba..47a9d3f4 100644 --- a/src/runtime/local-opentui-bridge.ts +++ b/src/runtime/local-opentui-bridge.ts @@ -887,6 +887,12 @@ function mapChatMessageToTranscriptEntry( caption: null, content: reasoning, }; + // Reasoning-only messages (e.g. intermediate tool-call steps with an + // empty content field) must not produce an empty assistant entry, which + // would render as a bare ASSISTANT badge row. + if (assistantEntry.content.length === 0) { + return [reasoningEntry]; + } return [reasoningEntry, assistantEntry]; } case "user": diff --git a/src/tui/app.tsx b/src/tui/app.tsx index a28bb1dd..a811536e 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -45,14 +45,8 @@ import { } from "./theme.js"; import { compactToolTranscriptContent } from "./transcript-preview.js"; import { buildTranscriptClipboardText } from "./transcript-export.js"; -import { - buildCollapsedToolSummary, - buildToolTranscriptLines, -} from "./tool-call-collapsible.js"; -import { - buildCollapsedReasoningSummary, - buildReasoningTranscriptLines, -} from "./reasoning-collapsible.js"; +import { buildToolTranscriptLines } from "./tool-call-collapsible.js"; +import { buildReasoningTranscriptLines } from "./reasoning-collapsible.js"; import type { StepCliTuiComposerState, StepCliTuiComposerHistoryState, @@ -1950,19 +1944,16 @@ function buildTranscriptItems( const isTool = entry.role === "tool"; const isReasoning = entry.role === "reasoning"; const collapsible = isTool || isReasoning; + const contentWidth = Math.max(12, width - 4); + // Collapsible lines must go through wrapMultiline like every other + // transcript line: OpenTUI does not wrap by itself and scroll + // heights are computed from wrapped line counts. const lines = collapsible - ? isTool - ? buildToolTranscriptLines(entry, toolOutputExpanded) - : buildReasoningTranscriptLines(entry, toolOutputExpanded) - : wrapMultiline( - compactToolTranscriptContent(entry), - Math.max(12, width - 4), - ); - const collapsedSummary = isTool - ? buildCollapsedToolSummary(entry) - : isReasoning - ? buildCollapsedReasoningSummary(entry) - : undefined; + ? (isTool + ? buildToolTranscriptLines(entry, toolOutputExpanded) + : buildReasoningTranscriptLines(entry, toolOutputExpanded) + ).flatMap((line) => wrapMultiline(line, contentWidth)) + : wrapMultiline(compactToolTranscriptContent(entry), contentWidth); return { id: entry.id || @@ -1974,7 +1965,6 @@ function buildTranscriptItems( truncated: false, collapsible, expanded: collapsible ? toolOutputExpanded : undefined, - expandHint: collapsedSummary?.expandHint, }; }), ]; @@ -2375,8 +2365,6 @@ interface TranscriptItem { collapsible?: boolean; /** Current expansion state (only meaningful when collapsible is true). */ expanded?: boolean; - /** Hint shown when collapsed. */ - expandHint?: string; } interface SlashPaletteState { diff --git a/src/tui/reasoning-collapsible.test.ts b/src/tui/reasoning-collapsible.test.ts index d16901d3..cb7bf4c6 100644 --- a/src/tui/reasoning-collapsible.test.ts +++ b/src/tui/reasoning-collapsible.test.ts @@ -62,6 +62,12 @@ describe("buildReasoningTranscriptLines", () => { expect(lines).toEqual(["line 1", "line 2", "line 3"]); }); + it("normalizes CRLF line endings when expanded", () => { + const entry = makeReasoningEntry("line 1\r\nline 2\r\nline 3"); + const lines = buildReasoningTranscriptLines(entry, true); + expect(lines).toEqual(["line 1", "line 2", "line 3"]); + }); + it("does not show expand hint when everything fits in preview", () => { const entry = makeReasoningEntry("line 1\nline 2"); const lines = buildReasoningTranscriptLines(entry, false); diff --git a/src/tui/reasoning-collapsible.ts b/src/tui/reasoning-collapsible.ts index d40d52b2..125a4628 100644 --- a/src/tui/reasoning-collapsible.ts +++ b/src/tui/reasoning-collapsible.ts @@ -2,6 +2,12 @@ import type { StepCliTuiTranscriptEntry } from "./types.js"; export const REASONING_PREVIEW_LINES = 2; +// Reasoning content can carry CRLF/CR line endings; normalize before +// splitting so no stray \r reaches the renderer. +function splitContentLines(content: string): string[] { + return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); +} + export interface CollapsedReasoningSummary { previewLines: string[]; hiddenLineCount: number; @@ -11,8 +17,7 @@ export interface CollapsedReasoningSummary { export function buildCollapsedReasoningSummary( entry: StepCliTuiTranscriptEntry, ): CollapsedReasoningSummary { - const lines = entry.content - .split("\n") + const lines = splitContentLines(entry.content) .map((line) => line.trimEnd()) .filter((line) => line.trim().length > 0); @@ -36,7 +41,7 @@ export function buildReasoningTranscriptLines( expanded: boolean, ): string[] { if (expanded) { - return entry.content.split("\n"); + return splitContentLines(entry.content); } const summary = buildCollapsedReasoningSummary(entry); diff --git a/src/tui/tool-call-collapsible.test.ts b/src/tui/tool-call-collapsible.test.ts index 04d6108d..2a82bd3e 100644 --- a/src/tui/tool-call-collapsible.test.ts +++ b/src/tui/tool-call-collapsible.test.ts @@ -62,6 +62,15 @@ describe("buildCollapsedToolSummary", () => { expect(summary.previewLines).toEqual(["more output", "third line"]); expect(summary.hiddenLineCount).toBe(0); }); + + it("normalizes CRLF line endings so previews carry no stray \\r", () => { + const entry = makeToolEntry( + "[completed] completed\r\nargs dir\r\nline 1\r\nline 2", + ); + const summary = buildCollapsedToolSummary(entry); + expect(summary.headline).toBe("Bash · [completed] args dir"); + expect(summary.previewLines).toEqual(["line 1", "line 2"]); + }); }); describe("buildToolTranscriptLines", () => { @@ -85,6 +94,12 @@ describe("buildToolTranscriptLines", () => { expect(lines.join("\n")).toContain("line 2"); }); + it("normalizes CRLF line endings when expanded", () => { + const entry = makeToolEntry("[completed] completed\r\nline 1\r\nline 2"); + const lines = buildToolTranscriptLines(entry, true); + expect(lines).toEqual(["[completed] completed", "line 1", "line 2"]); + }); + it("does not show expand hint when everything fits in preview", () => { const entry = makeToolEntry("[completed] completed\nargs ls -la\nline 1"); const lines = buildToolTranscriptLines(entry, false); diff --git a/src/tui/tool-call-collapsible.ts b/src/tui/tool-call-collapsible.ts index b6769daa..c0130bef 100644 --- a/src/tui/tool-call-collapsible.ts +++ b/src/tui/tool-call-collapsible.ts @@ -4,6 +4,12 @@ const COLLAPSED_TOOL_DETAIL_MAX_LENGTH = 80; const TOOL_PREVIEW_LINES = 2; const STATUS_LINE_RE = /^\[(\w+)\]\s*(.*)$/; +// Tool output on Windows can carry CRLF/CR line endings; normalize before +// splitting so no stray \r reaches the renderer. +function splitContentLines(content: string): string[] { + return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); +} + export interface CollapsedToolSummary { headline: string; previewLines: string[]; @@ -15,7 +21,7 @@ export function buildCollapsedToolSummary( entry: StepCliTuiTranscriptEntry, ): CollapsedToolSummary { const caption = entry.caption ?? "tool"; - const lines = entry.content.split("\n"); + const lines = splitContentLines(entry.content); const firstLine = lines[0]?.trim() ?? ""; const statusMatch = STATUS_LINE_RE.exec(firstLine); const status = statusMatch?.[1] ?? "completed"; @@ -62,7 +68,7 @@ export function buildToolTranscriptLines( expanded: boolean, ): string[] { if (expanded) { - return entry.content.split("\n"); + return splitContentLines(entry.content); } const summary = buildCollapsedToolSummary(entry);