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;
+}