From 4bb00cea5e76716b9e22c17578e29ac89618cdd5 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 18 Jun 2026 20:52:43 +0800 Subject: [PATCH] feat(tui): collapse tool-call output by default, toggle with Ctrl+O - Add tool-call-collapsible.ts with buildCollapsedToolSummary and buildToolTranscriptLines helpers. - Update buildTranscriptItems to render tool entries collapsed by default, showing a one-line summary and a '... (ctrl-o to expand)' hint. - Add toolOutputExpanded state to StepCliTuiScreen and bind Ctrl+O to toggle expansion for all tool entries. - Update welcome hint to mention Ctrl+O. - Add unit tests for the collapsible logic. Closes #63 --- src/tui/app.tsx | 66 +++++++++++++++---- src/tui/tool-call-collapsible.test.ts | 93 +++++++++++++++++++++++++++ src/tui/tool-call-collapsible.ts | 74 +++++++++++++++++++++ 3 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 src/tui/tool-call-collapsible.test.ts create mode 100644 src/tui/tool-call-collapsible.ts diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 2d7a5347..dcde73fb 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -45,6 +45,10 @@ 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 type { StepCliTuiComposerState, StepCliTuiComposerHistoryState, @@ -147,6 +151,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 +609,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 +781,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 +1539,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 +1547,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,13 +1935,22 @@ function buildTranscriptItems( entries: StepCliTuiTranscriptEntry[], width: number, theme: StepCliTuiThemeColors, + toolOutputExpanded: boolean, ): TranscriptItem[] { return [ buildWelcomeTranscriptItem(width), ...entries.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 lines = isTool + ? buildToolTranscriptLines(entry, toolOutputExpanded) + : wrapMultiline( + compactToolTranscriptContent(entry), + Math.max(12, width - 4), + ); + const collapsedSummary = isTool + ? buildCollapsedToolSummary(entry) + : undefined; return { id: entry.id || @@ -1927,6 +1960,9 @@ function buildTranscriptItems( border: false, lines, truncated: false, + collapsible: isTool, + expanded: isTool ? toolOutputExpanded : undefined, + expandHint: collapsedSummary?.expandHint, }; }), ]; @@ -1936,7 +1972,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 output · Esc quit", "/goal /attach /copy /detach /status /refresh /theme [name] /resume /exit", ].flatMap((line) => wrapMultiline(line, Math.max(12, width - 4))); @@ -2317,6 +2353,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/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; +}