Skip to content
Merged
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
104 changes: 104 additions & 0 deletions src/runtime/local-opentui-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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");
});

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.");
});
});
});
90 changes: 56 additions & 34 deletions src/runtime/local-opentui-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
),
];
Expand Down Expand Up @@ -868,58 +868,80 @@ 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,
};
// 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":
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<ChatMessage, { role: "assistant" }>,
): 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(
Expand Down
70 changes: 58 additions & 12 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
} from "./theme.js";
import { compactToolTranscriptContent } from "./transcript-preview.js";
import { buildTranscriptClipboardText } from "./transcript-export.js";
import { buildToolTranscriptLines } from "./tool-call-collapsible.js";
import { buildReasoningTranscriptLines } from "./reasoning-collapsible.js";
import type {
StepCliTuiComposerState,
StepCliTuiComposerHistoryState,
Expand Down Expand Up @@ -147,6 +149,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 +607,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 +779,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 +1537,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,15 +1933,27 @@ function buildTranscriptItems(
entries: StepCliTuiTranscriptEntry[],
width: number,
theme: StepCliTuiThemeColors,
toolOutputExpanded: boolean,
): TranscriptItem[] {
return [
buildWelcomeTranscriptItem(width),
...entries
.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 contentWidth = Math.max(12, width - 4);
// Collapsible lines must go through wrapMultiline like every other
// transcript line: OpenTUI <text> does not wrap by itself and scroll
// heights are computed from wrapped line counts.
const lines = collapsible
? (isTool
? buildToolTranscriptLines(entry, toolOutputExpanded)
: buildReasoningTranscriptLines(entry, toolOutputExpanded)
).flatMap((line) => wrapMultiline(line, contentWidth))
: wrapMultiline(compactToolTranscriptContent(entry), contentWidth);
return {
id:
entry.id ||
Expand All @@ -1929,6 +1963,8 @@ function buildTranscriptItems(
border: false,
lines,
truncated: false,
collapsible,
expanded: collapsible ? toolOutputExpanded : undefined,
};
}),
];
Expand All @@ -1938,7 +1974,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 <session_id> /exit",
].flatMap((line) => wrapMultiline(line, Math.max(12, width - 4)));

Expand Down Expand Up @@ -2091,6 +2127,12 @@ function resolveTranscriptIdentity(
caption: entry.caption,
tone: "success",
};
case "reasoning":
return {
badge: "THINK",
caption: entry.caption,
tone: "muted",
};
case "system":
return {
badge: "SYSTEM",
Expand Down Expand Up @@ -2319,6 +2361,10 @@ 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;
}

interface SlashPaletteState {
Expand Down
Loading
Loading