Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 54 additions & 12 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -1522,24 +1539,31 @@ 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 = (
<>
<text fg={input.theme.foreground}>
<span bg={badgeStyle.backgroundColor} fg={badgeStyle.textColor}>
{" "}
{item.badge}{" "}
</span>
{item.caption ? (
{item.caption && !isCollapsed ? (
<span fg={input.theme.muted}> {item.caption}</span>
) : null}
{firstLine.length > 0 ? <span> {firstLine}</span> : null}
</text>
{restLines.map((line, index) => (
<text key={`${item.id}:${index}`} fg={input.theme.foreground}>
<span fg={badgeStyle.railColor}>│ </span>
{line.length > 0 ? line : " "}
</text>
))}
{isCollapsed
? restLines.map((line, index) => (
<text key={`${item.id}:${index}`} fg={input.theme.muted}>
{line.length > 0 ? line : " "}
</text>
))
: restLines.map((line, index) => (
<text key={`${item.id}:${index}`} fg={input.theme.foreground}>
<span fg={badgeStyle.railColor}>│ </span>
{line.length > 0 ? line : " "}
</text>
))}
{item.truncated ? (
<text fg={input.theme.muted}>
<span fg={badgeStyle.railColor}>│ </span>…
Expand Down Expand Up @@ -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 ||
Expand All @@ -1927,6 +1960,9 @@ function buildTranscriptItems(
border: false,
lines,
truncated: false,
collapsible: isTool,
expanded: isTool ? toolOutputExpanded : undefined,
expandHint: collapsedSummary?.expandHint,
};
}),
];
Expand All @@ -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 <session_id> /exit",
].flatMap((line) => wrapMultiline(line, Math.max(12, width - 4)));

Expand Down Expand Up @@ -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 {
Expand Down
93 changes: 93 additions & 0 deletions src/tui/tool-call-collapsible.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
74 changes: 74 additions & 0 deletions src/tui/tool-call-collapsible.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading