diff --git a/src/runtime/local-opentui-bridge.test.ts b/src/runtime/local-opentui-bridge.test.ts
index 387d18e..831ec3f 100644
--- a/src/runtime/local-opentui-bridge.test.ts
+++ b/src/runtime/local-opentui-bridge.test.ts
@@ -130,6 +130,21 @@ describe("LocalOpenTuiTranscriptBridge", () => {
});
describe("streaming order", () => {
+ it("marks Markdown entries as streaming until the final assistant message", () => {
+ const bridge = createBridge();
+ const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput());
+
+ bridge.submitUserTurn({ content: "hello" });
+ ui.onModelStreamReset();
+ ui.onModelTextDelta({ text: "```ts" });
+
+ expect(bridge.getEntries().at(-1)?.streaming).toBe(true);
+
+ ui.onAssistantMessage({ text: "```ts\nconst answer = 42;\n```" });
+
+ expect(bridge.getEntries().at(-1)?.streaming).toBe(false);
+ });
+
it("keeps tool-call entries after the assistant text that triggered them", () => {
const bridge = createBridge();
const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput());
diff --git a/src/runtime/local-opentui-bridge.ts b/src/runtime/local-opentui-bridge.ts
index f8c3502..db6efd4 100644
--- a/src/runtime/local-opentui-bridge.ts
+++ b/src/runtime/local-opentui-bridge.ts
@@ -445,6 +445,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl
role: "assistant",
caption: null,
content: text,
+ streaming: true,
});
return;
}
@@ -452,10 +453,12 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl
this.updateLocalEntry(this.currentAssistantEntryId, (entry) => ({
...entry,
content: `${entry.content}${text}`,
+ streaming: true,
}));
},
onModelToolCall: ({ toolName, rawArgs }) => {
this.discardEmptyAssistantEntry();
+ this.markCurrentAssistantEntryComplete();
// Reset the streaming assistant entry ID so that any text deltas
// arriving *after* this tool call (within the same streaming
// response) create a new assistant entry positioned after the
@@ -485,6 +488,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl
},
onToolStart: (info) => {
this.discardEmptyAssistantEntry();
+ this.markCurrentAssistantEntryComplete();
this.currentAssistantEntryId = null;
const toolName = readString((info as { toolName?: unknown }).toolName);
const entryId = this.findCurrentToolEntryId(toolName);
@@ -578,6 +582,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl
this.updateLocalEntry(this.currentAssistantEntryId, (entry) => ({
...entry,
content: message,
+ streaming: false,
}));
this.currentAssistantEntryId = null;
return;
@@ -592,6 +597,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl
this.updateLocalEntry(lastStreamingId, (entry) => ({
...entry,
content: message,
+ streaming: false,
}));
return;
}
@@ -601,6 +607,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl
role: "assistant",
caption: null,
content: message,
+ streaming: false,
});
}
@@ -614,6 +621,17 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl
});
}
+ private markCurrentAssistantEntryComplete(): void {
+ if (!this.currentAssistantEntryId) {
+ return;
+ }
+
+ this.updateLocalEntry(this.currentAssistantEntryId, (entry) => ({
+ ...entry,
+ streaming: false,
+ }));
+ }
+
private appendLocalEntry(entry: LocalTranscriptEntry): void {
if (
entry.role !== "user" &&
@@ -801,6 +819,8 @@ function stripLocalTranscriptEntry(
role: entry.role,
caption: entry.caption,
content: entry.content,
+ hidden: entry.hidden,
+ streaming: entry.streaming,
};
}
diff --git a/src/tui/app.tsx b/src/tui/app.tsx
index a811536..9a9f9c1 100644
--- a/src/tui/app.tsx
+++ b/src/tui/app.tsx
@@ -3,6 +3,7 @@
import path from "node:path";
import {
decodePasteBytes,
+ type SyntaxStyle,
type KeyEvent,
type PasteEvent,
type ScrollAcceleration,
@@ -43,10 +44,14 @@ import {
type StepCliTuiThemeColors,
type StepCliTuiThemeName,
} 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 {
+ buildTranscriptItems,
+ sliceByDisplayWidth,
+ wrapMultiline,
+ type TranscriptItem,
+} from "./transcript-items.js";
+import { buildSyntaxStyleFromTheme } from "./transcript-syntax-style.js";
import type {
StepCliTuiComposerState,
StepCliTuiComposerHistoryState,
@@ -788,6 +793,10 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) {
),
[theme, transcriptEntries, transcriptWidth, toolOutputExpanded],
);
+ const transcriptSyntaxStyle = useMemo(
+ () => buildSyntaxStyleFromTheme(theme),
+ [theme],
+ );
return (
@@ -1464,6 +1474,7 @@ const TranscriptPane = React.memo(function TranscriptPane(input: {
scrollAcceleration: ScrollAcceleration;
summary: string;
theme: StepCliTuiThemeColors;
+ syntaxStyle: SyntaxStyle;
width: number;
}) {
const allSummaryLines = input.summary.length
@@ -1522,7 +1533,12 @@ const TranscriptPane = React.memo(function TranscriptPane(input: {
) : null}
{input.items.map((item) => (
-
+
))}
@@ -1532,13 +1548,47 @@ const TranscriptPane = React.memo(function TranscriptPane(input: {
const TranscriptEntry = React.memo(function TranscriptEntry(input: {
item: TranscriptItem;
theme: StepCliTuiThemeColors;
+ syntaxStyle: SyntaxStyle;
}) {
const { item } = input;
+ const badgeStyle = resolveTranscriptBadgeStyle(item.tone, input.theme);
+
+ const badgeLine = (
+
+
+ {" "}
+ {item.badge}{" "}
+
+ {item.caption ? (
+ {item.caption}
+ ) : null}
+
+ );
+
const [firstLine = "", ...restLines] =
item.lines.length > 0 ? item.lines : [""];
- const badgeStyle = resolveTranscriptBadgeStyle(item.tone, input.theme);
const isCollapsed = item.collapsible && !item.expanded;
- const body = (
+ const body = item.useMarkdown ? (
+ <>
+ {badgeLine}
+ {item.content.length > 0 ? (
+
+
+
+ ) : null}
+ >
+ ) : (
<>
@@ -1929,67 +1979,6 @@ function SlashCommandPalette(input: {
);
}
-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 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)
- ).flatMap((line) => wrapMultiline(line, contentWidth))
- : wrapMultiline(compactToolTranscriptContent(entry), contentWidth);
- return {
- id:
- entry.id ||
- `message:${index}:${identity.badge}:${identity.caption ?? ""}`,
- ...identity,
- backgroundColor: resolveTranscriptBackground(entry, theme),
- border: false,
- lines,
- truncated: false,
- collapsible,
- expanded: collapsible ? toolOutputExpanded : undefined,
- };
- }),
- ];
-}
-
-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 · 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)));
-
- return {
- id: "welcome",
- badge: "STEP",
- caption: "welcome",
- tone: "brand",
- lines: welcomeLines,
- truncated: false,
- backgroundColor: null,
- border: true,
- };
-}
-
function buildQueuedTurnPreviewLines(
input: StepCliTuiQueuedTurnEntry["input"],
width: number,
@@ -2105,50 +2094,6 @@ function applySlashCommandSelection(
};
}
-function resolveTranscriptIdentity(
- entry: StepCliTuiTranscriptEntry,
-): Pick {
- switch (entry.role) {
- case "assistant":
- return {
- badge: "STEP",
- caption: entry.caption,
- tone: "brand",
- };
- case "user":
- return {
- badge: "YOU",
- caption: entry.caption,
- tone: "accent",
- };
- case "tool":
- return {
- badge: "TOOL",
- caption: entry.caption,
- tone: "success",
- };
- case "reasoning":
- return {
- badge: "THINK",
- caption: entry.caption,
- tone: "muted",
- };
- case "system":
- return {
- badge: "SYSTEM",
- caption: entry.caption,
- tone: "muted",
- };
- }
-}
-
-function resolveTranscriptBackground(
- entry: StepCliTuiTranscriptEntry,
- theme: StepCliTuiThemeColors,
-): string | null {
- return entry.role === "user" ? theme.inputBackground : null;
-}
-
function resolveTranscriptBadgeStyle(
tone: StepCliTuiTone,
theme: StepCliTuiThemeColors,
@@ -2230,26 +2175,6 @@ function resolveComposerEditorMaxHeight(terminalHeight: number): number {
);
}
-function wrapMultiline(text: string, width: number): string[] {
- const lines: string[] = [];
- for (const rawLine of text.split("\n")) {
- if (rawLine.length === 0) {
- lines.push("");
- continue;
- }
-
- let remaining = rawLine;
- while (visibleLength(remaining) > width) {
- const line = sliceByDisplayWidth(remaining, width);
- lines.push(line);
- remaining = remaining.slice(line.length);
- }
- lines.push(remaining);
- }
-
- return lines;
-}
-
function truncateInline(value: string, maxWidth: number): string {
const singleLine = value.replace(/\s+/g, " ").trim();
if (visibleLength(singleLine) <= maxWidth) {
@@ -2259,22 +2184,6 @@ function truncateInline(value: string, maxWidth: number): string {
return `${sliceByDisplayWidth(singleLine, Math.max(1, maxWidth - 1))}…`;
}
-function sliceByDisplayWidth(value: string, width: number): string {
- if (visibleLength(value) <= width) {
- return value;
- }
-
- let end = 0;
- for (const symbol of value) {
- if (visibleLength(value.slice(0, end + symbol.length)) > width) {
- break;
- }
- end += symbol.length;
- }
-
- return value.slice(0, Math.max(1, end));
-}
-
function truncatePath(value: string, maxWidth: number): string {
if (visibleLength(value) <= maxWidth) {
return value;
@@ -2352,21 +2261,6 @@ function cloneComposerState(
return normalizeComposerState(composer);
}
-interface TranscriptItem {
- id: string;
- badge: string;
- caption: string | null;
- tone: StepCliTuiTone;
- backgroundColor: string | null;
- 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 {
visible: boolean;
query: string | null;
diff --git a/src/tui/transcript-items.test.ts b/src/tui/transcript-items.test.ts
new file mode 100644
index 0000000..a18f9d3
--- /dev/null
+++ b/src/tui/transcript-items.test.ts
@@ -0,0 +1,235 @@
+import { describe, it, expect } from "vitest";
+import type { StepCliTuiTranscriptEntry } from "./types.js";
+import type { StepCliTuiThemeColors } from "./theme.js";
+import {
+ buildTranscriptItems,
+ buildWelcomeTranscriptItem,
+ resolveTranscriptIdentity,
+ resolveTranscriptBackground,
+ wrapMultiline,
+ sliceByDisplayWidth,
+} from "./transcript-items.js";
+
+const MOCK_THEME: StepCliTuiThemeColors = {
+ foreground: "#eef6ff",
+ muted: "#7d93ab",
+ accent: "#62d8ff",
+ brand: "#4da3ff",
+ success: "#58d6a6",
+ warning: "#f0c36b",
+ danger: "#ff6f91",
+ canvas: "#07101c",
+ panel: "#0c1625",
+ panelAlt: "#112033",
+ inputBackground: "#1b2431",
+ selection: "#173556",
+ line: "#234462",
+ assistantBadge: "#102846",
+ userBadge: "#0b223d",
+ toolBadge: "#0d2b36",
+ systemBadge: "#17273a",
+};
+
+function entry(
+ role: StepCliTuiTranscriptEntry["role"],
+ content: string,
+ caption: string | null = null,
+ id = "",
+): StepCliTuiTranscriptEntry {
+ return { id, role, content, caption };
+}
+
+describe("buildTranscriptItems", () => {
+ it("returns welcome item as first entry", () => {
+ const items = buildTranscriptItems([], 80, MOCK_THEME, false);
+ expect(items).toHaveLength(1);
+ expect(items[0]!.id).toBe("welcome");
+ expect(items[0]!.useMarkdown).toBe(false);
+ expect(items[0]!.border).toBe(true);
+ });
+
+ it("sets useMarkdown=true for assistant messages", () => {
+ const entries: StepCliTuiTranscriptEntry[] = [
+ entry("assistant", "# Hello\n\nSome **bold** text and `code`"),
+ ];
+ const items = buildTranscriptItems(entries, 80, MOCK_THEME, false);
+ const assistantItem = items[1]!;
+ expect(assistantItem.useMarkdown).toBe(true);
+ expect(assistantItem.content).toBe(
+ "# Hello\n\nSome **bold** text and `code`",
+ );
+ expect(assistantItem.lines).toEqual([]);
+ });
+
+ it("sets useMarkdown=false for user messages with populated lines", () => {
+ const entries: StepCliTuiTranscriptEntry[] = [entry("user", "Hello world")];
+ const items = buildTranscriptItems(entries, 80, MOCK_THEME, false);
+ const userItem = items[1]!;
+ expect(userItem.useMarkdown).toBe(false);
+ expect(userItem.lines.length).toBeGreaterThan(0);
+ expect(userItem.content).toBe("Hello world");
+ });
+
+ it("sets useMarkdown=false for tool messages", () => {
+ const entries: StepCliTuiTranscriptEntry[] = [
+ entry("tool", "bash\nResult:\nsome output"),
+ ];
+ const items = buildTranscriptItems(entries, 80, MOCK_THEME, false);
+ const toolItem = items[1]!;
+ expect(toolItem.useMarkdown).toBe(false);
+ expect(toolItem.lines.length).toBeGreaterThan(0);
+ });
+
+ it("sets useMarkdown=false for system messages", () => {
+ const entries: StepCliTuiTranscriptEntry[] = [
+ entry("system", "Session resumed"),
+ ];
+ const items = buildTranscriptItems(entries, 80, MOCK_THEME, false);
+ const systemItem = items[1]!;
+ expect(systemItem.useMarkdown).toBe(false);
+ expect(systemItem.lines.length).toBeGreaterThan(0);
+ });
+
+ it("preserves original markdown content for assistant entries", () => {
+ const mdContent = [
+ "## Code Example",
+ "",
+ "```typescript",
+ "const x = 42;",
+ "```",
+ "",
+ "| Col A | Col B |",
+ "| ----- | ----- |",
+ "| 1 | 2 |",
+ ].join("\n");
+ const entries: StepCliTuiTranscriptEntry[] = [
+ entry("assistant", mdContent),
+ ];
+ const items = buildTranscriptItems(entries, 80, MOCK_THEME, false);
+ expect(items[1]!.content).toBe(mdContent);
+ });
+});
+
+describe("resolveTranscriptIdentity", () => {
+ it("maps assistant to STEP badge with brand tone", () => {
+ const id = resolveTranscriptIdentity(entry("assistant", "hi"));
+ expect(id.badge).toBe("STEP");
+ expect(id.tone).toBe("brand");
+ });
+
+ it("maps user to YOU badge with accent tone", () => {
+ const id = resolveTranscriptIdentity(entry("user", "hi"));
+ expect(id.badge).toBe("YOU");
+ expect(id.tone).toBe("accent");
+ });
+
+ it("maps tool to TOOL badge with success tone", () => {
+ const id = resolveTranscriptIdentity(entry("tool", "hi"));
+ expect(id.badge).toBe("TOOL");
+ expect(id.tone).toBe("success");
+ });
+
+ it("maps system to SYSTEM badge with muted tone", () => {
+ const id = resolveTranscriptIdentity(entry("system", "hi"));
+ expect(id.badge).toBe("SYSTEM");
+ expect(id.tone).toBe("muted");
+ });
+
+ it("passes through caption", () => {
+ const id = resolveTranscriptIdentity(entry("assistant", "hi", "thinking"));
+ expect(id.caption).toBe("thinking");
+ });
+});
+
+describe("transcript visibility and streaming", () => {
+ it("omits hidden entries", () => {
+ const hidden = { ...entry("system", "internal"), hidden: true };
+ const items = buildTranscriptItems([hidden], 80, MOCK_THEME, false);
+ expect(items).toHaveLength(1);
+ });
+
+ it("keeps assistant Markdown in streaming mode until completion", () => {
+ const streaming = { ...entry("assistant", "```ts"), streaming: true };
+ const items = buildTranscriptItems([streaming], 80, MOCK_THEME, false);
+ expect(items[1]!.useMarkdown).toBe(true);
+ expect(items[1]!.streaming).toBe(true);
+ });
+
+ it("keeps tool and reasoning output collapsible", () => {
+ const entries = [
+ entry("tool", "[completed]\nfirst\nsecond\nthird\nfourth"),
+ entry("reasoning", "first\nsecond\nthird"),
+ ];
+ const items = buildTranscriptItems(entries, 80, MOCK_THEME, false);
+ expect(items[1]!.collapsible).toBe(true);
+ expect(items[1]!.expanded).toBe(false);
+ expect(items[2]!.collapsible).toBe(true);
+ });
+});
+
+describe("resolveTranscriptBackground", () => {
+ it("returns inputBackground for user entries", () => {
+ expect(resolveTranscriptBackground(entry("user", ""), MOCK_THEME)).toBe(
+ MOCK_THEME.inputBackground,
+ );
+ });
+
+ it("returns null for non-user entries", () => {
+ expect(
+ resolveTranscriptBackground(entry("assistant", ""), MOCK_THEME),
+ ).toBeNull();
+ expect(
+ resolveTranscriptBackground(entry("tool", ""), MOCK_THEME),
+ ).toBeNull();
+ expect(
+ resolveTranscriptBackground(entry("system", ""), MOCK_THEME),
+ ).toBeNull();
+ });
+});
+
+describe("buildWelcomeTranscriptItem", () => {
+ it("produces a non-markdown welcome item", () => {
+ const item = buildWelcomeTranscriptItem(80);
+ expect(item.id).toBe("welcome");
+ expect(item.badge).toBe("STEP");
+ expect(item.useMarkdown).toBe(false);
+ expect(item.border).toBe(true);
+ expect(item.lines.length).toBeGreaterThan(0);
+ });
+});
+
+describe("wrapMultiline", () => {
+ it("wraps long lines by width", () => {
+ const lines = wrapMultiline("abcdefghij", 5);
+ expect(lines).toEqual(["abcde", "fghij"]);
+ });
+
+ it("preserves empty lines", () => {
+ const lines = wrapMultiline("a\n\nb", 80);
+ expect(lines).toEqual(["a", "", "b"]);
+ });
+
+ it("handles short text without wrapping", () => {
+ const lines = wrapMultiline("hello", 80);
+ expect(lines).toEqual(["hello"]);
+ });
+
+ it("handles empty string", () => {
+ const lines = wrapMultiline("", 80);
+ expect(lines).toEqual([""]);
+ });
+});
+
+describe("sliceByDisplayWidth", () => {
+ it("returns full string when it fits", () => {
+ expect(sliceByDisplayWidth("hello", 10)).toBe("hello");
+ });
+
+ it("slices ASCII text to exact width", () => {
+ expect(sliceByDisplayWidth("abcdefghij", 5)).toBe("abcde");
+ });
+
+ it("handles empty string", () => {
+ expect(sliceByDisplayWidth("", 5)).toBe("");
+ });
+});
diff --git a/src/tui/transcript-items.ts b/src/tui/transcript-items.ts
new file mode 100644
index 0000000..a6bbd88
--- /dev/null
+++ b/src/tui/transcript-items.ts
@@ -0,0 +1,170 @@
+import { visibleLength } from "@step-cli/utils/display-width.js";
+import type { StepCliTuiThemeColors } from "./theme.js";
+import { compactToolTranscriptContent } from "./transcript-preview.js";
+import { buildReasoningTranscriptLines } from "./reasoning-collapsible.js";
+import { buildToolTranscriptLines } from "./tool-call-collapsible.js";
+import type { StepCliTuiTone, StepCliTuiTranscriptEntry } from "./types.js";
+
+export interface TranscriptItem {
+ id: string;
+ badge: string;
+ caption: string | null;
+ tone: StepCliTuiTone;
+ backgroundColor: string | null;
+ border: boolean;
+ lines: string[];
+ content: string;
+ useMarkdown: boolean;
+ streaming: boolean;
+ truncated: boolean;
+ collapsible?: boolean;
+ expanded?: boolean;
+}
+
+export 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 markdown = entry.role === "assistant";
+ const isTool = entry.role === "tool";
+ const isReasoning = entry.role === "reasoning";
+ const collapsible = isTool || isReasoning;
+ const contentWidth = Math.max(12, width - 4);
+ const lines = markdown
+ ? []
+ : (collapsible
+ ? isTool
+ ? buildToolTranscriptLines(entry, toolOutputExpanded)
+ : buildReasoningTranscriptLines(entry, toolOutputExpanded)
+ : [body]
+ ).flatMap((line) => wrapMultiline(line, contentWidth));
+ return {
+ id:
+ entry.id ||
+ `message:${index}:${identity.badge}:${identity.caption ?? ""}`,
+ ...identity,
+ backgroundColor: resolveTranscriptBackground(entry, theme),
+ border: false,
+ lines,
+ content: body,
+ useMarkdown: markdown,
+ streaming: markdown && entry.streaming === true,
+ truncated: false,
+ collapsible,
+ expanded: collapsible ? toolOutputExpanded : undefined,
+ };
+ }),
+ ];
+}
+
+export 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 · 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)));
+
+ return {
+ id: "welcome",
+ badge: "STEP",
+ caption: "welcome",
+ tone: "brand",
+ lines: welcomeLines,
+ content: "",
+ useMarkdown: false,
+ streaming: false,
+ truncated: false,
+ backgroundColor: null,
+ border: true,
+ };
+}
+
+export function resolveTranscriptIdentity(
+ entry: StepCliTuiTranscriptEntry,
+): Pick {
+ switch (entry.role) {
+ case "assistant":
+ return {
+ badge: "STEP",
+ caption: entry.caption,
+ tone: "brand",
+ };
+ case "user":
+ return {
+ badge: "YOU",
+ caption: entry.caption,
+ tone: "accent",
+ };
+ case "tool":
+ return {
+ badge: "TOOL",
+ caption: entry.caption,
+ tone: "success",
+ };
+ case "reasoning":
+ return {
+ badge: "THINK",
+ caption: entry.caption,
+ tone: "muted",
+ };
+ case "system":
+ return {
+ badge: "SYSTEM",
+ caption: entry.caption,
+ tone: "muted",
+ };
+ }
+}
+
+export function resolveTranscriptBackground(
+ entry: StepCliTuiTranscriptEntry,
+ theme: StepCliTuiThemeColors,
+): string | null {
+ return entry.role === "user" ? theme.inputBackground : null;
+}
+
+export function wrapMultiline(text: string, width: number): string[] {
+ const lines: string[] = [];
+ for (const rawLine of text.split("\n")) {
+ if (rawLine.length === 0) {
+ lines.push("");
+ continue;
+ }
+
+ let remaining = rawLine;
+ while (visibleLength(remaining) > width) {
+ const line = sliceByDisplayWidth(remaining, width);
+ lines.push(line);
+ remaining = remaining.slice(line.length);
+ }
+ lines.push(remaining);
+ }
+
+ return lines;
+}
+
+export function sliceByDisplayWidth(value: string, width: number): string {
+ if (visibleLength(value) <= width) {
+ return value;
+ }
+
+ let end = 0;
+ for (const symbol of value) {
+ if (visibleLength(value.slice(0, end + symbol.length)) > width) {
+ break;
+ }
+ end += symbol.length;
+ }
+
+ return value.slice(0, Math.max(1, end));
+}
diff --git a/src/tui/transcript-syntax-style.ts b/src/tui/transcript-syntax-style.ts
new file mode 100644
index 0000000..a5361e9
--- /dev/null
+++ b/src/tui/transcript-syntax-style.ts
@@ -0,0 +1,19 @@
+import { RGBA, SyntaxStyle } from "@opentui/core";
+import type { StepCliTuiThemeColors } from "./theme.js";
+
+export function buildSyntaxStyleFromTheme(
+ theme: StepCliTuiThemeColors,
+): SyntaxStyle {
+ return SyntaxStyle.fromStyles({
+ keyword: { fg: RGBA.fromHex(theme.brand), bold: true },
+ string: { fg: RGBA.fromHex(theme.success) },
+ comment: { fg: RGBA.fromHex(theme.muted), italic: true },
+ number: { fg: RGBA.fromHex(theme.warning) },
+ function: { fg: RGBA.fromHex(theme.accent) },
+ type: { fg: RGBA.fromHex(theme.brand) },
+ variable: { fg: RGBA.fromHex(theme.foreground) },
+ operator: { fg: RGBA.fromHex(theme.muted) },
+ punctuation: { fg: RGBA.fromHex(theme.muted) },
+ default: { fg: RGBA.fromHex(theme.foreground) },
+ });
+}
diff --git a/src/tui/types.ts b/src/tui/types.ts
index 37eeed7..ba39c4e 100644
--- a/src/tui/types.ts
+++ b/src/tui/types.ts
@@ -89,6 +89,8 @@ export interface StepCliTuiTranscriptEntry {
caption: string | null;
/** Internal message that should not be rendered in the transcript. */
hidden?: boolean;
+ /** Whether an assistant Markdown message is still receiving text deltas. */
+ streaming?: boolean;
}
export interface StepCliTuiQueuedTurnEntry {