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 {