From 741add441135509c559199f6c05bb98e0e0347c3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Nov 2025 21:55:29 +0100 Subject: [PATCH] Refactor TUI into proper components - Create UserMessageComponent - handles user messages with spacing - Create AssistantMessageComponent - handles complete assistant messages - Create ThinkingSelectorComponent - wraps selector with borders - Add setSelectedIndex to SelectList for preselecting current level - Simplify tui-renderer by using dedicated components - Much cleaner architecture - each message type is now a component --- .../coding-agent/src/tui/assistant-message.ts | 46 ++++++ .../coding-agent/src/tui/thinking-selector.ts | 51 +++++++ packages/coding-agent/src/tui/tui-renderer.ts | 133 +++++------------- packages/coding-agent/src/tui/user-message.ts | 23 +++ packages/tui/src/components/select-list.ts | 4 + 5 files changed, 158 insertions(+), 99 deletions(-) create mode 100644 packages/coding-agent/src/tui/assistant-message.ts create mode 100644 packages/coding-agent/src/tui/thinking-selector.ts create mode 100644 packages/coding-agent/src/tui/user-message.ts diff --git a/packages/coding-agent/src/tui/assistant-message.ts b/packages/coding-agent/src/tui/assistant-message.ts new file mode 100644 index 00000000..2d615277 --- /dev/null +++ b/packages/coding-agent/src/tui/assistant-message.ts @@ -0,0 +1,46 @@ +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import chalk from "chalk"; + +/** + * Component that renders a complete assistant message + */ +export class AssistantMessageComponent extends Container { + private spacer: Spacer; + + constructor(message: AssistantMessage) { + super(); + + // Add spacer before assistant message + this.spacer = new Spacer(1); + this.addChild(this.spacer); + + // Render content in order + for (const content of message.content) { + if (content.type === "text" && content.text.trim()) { + // Assistant text messages with no background - trim the text + // Set paddingY=0 to avoid extra spacing before tool executions + this.addChild(new Markdown(content.text.trim(), undefined, undefined, undefined, 1, 0)); + } else if (content.type === "thinking" && content.thinking.trim()) { + // Thinking traces in dark gray italic + const thinkingText = content.thinking + .split("\n") + .map((line) => chalk.gray.italic(line)) + .join("\n"); + this.addChild(new Text(thinkingText, 1, 0)); + } + } + + // Check if aborted - show after partial content + if (message.stopReason === "aborted") { + this.addChild(new Text(chalk.red("Aborted"))); + return; + } + + if (message.stopReason === "error") { + const errorMsg = message.errorMessage || "Unknown error"; + this.addChild(new Text(chalk.red(`Error: ${errorMsg}`))); + return; + } + } +} diff --git a/packages/coding-agent/src/tui/thinking-selector.ts b/packages/coding-agent/src/tui/thinking-selector.ts new file mode 100644 index 00000000..d8919197 --- /dev/null +++ b/packages/coding-agent/src/tui/thinking-selector.ts @@ -0,0 +1,51 @@ +import type { ThinkingLevel } from "@mariozechner/pi-agent"; +import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; +import chalk from "chalk"; + +/** + * Component that renders a thinking level selector with borders + */ +export class ThinkingSelectorComponent extends Container { + private selectList: SelectList; + + constructor(currentLevel: ThinkingLevel, onSelect: (level: ThinkingLevel) => void, onCancel: () => void) { + super(); + + const thinkingLevels: SelectItem[] = [ + { value: "off", label: "off", description: "No reasoning" }, + { value: "minimal", label: "minimal", description: "Very brief reasoning (~1k tokens)" }, + { value: "low", label: "low", description: "Light reasoning (~2k tokens)" }, + { value: "medium", label: "medium", description: "Moderate reasoning (~8k tokens)" }, + { value: "high", label: "high", description: "Deep reasoning (~16k tokens)" }, + ]; + + // Add top border + this.addChild(new Text(chalk.blue("─".repeat(50)), 0, 0)); + + // Create selector + this.selectList = new SelectList(thinkingLevels, 5); + + // Preselect current level + const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value as ThinkingLevel); + }; + + this.selectList.onCancel = () => { + onCancel(); + }; + + this.addChild(this.selectList); + + // Add bottom border + this.addChild(new Text(chalk.blue("─".repeat(50)), 0, 0)); + } + + getSelectList(): SelectList { + return this.selectList; + } +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index e381b4ce..e5c8391e 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -1,23 +1,15 @@ import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent"; import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; -import { - CombinedAutocompleteProvider, - Container, - Loader, - Markdown, - ProcessTerminal, - type SelectItem, - SelectList, - Spacer, - Text, - TUI, -} from "@mariozechner/pi-tui"; +import { CombinedAutocompleteProvider, Container, Loader, ProcessTerminal, Text, TUI } from "@mariozechner/pi-tui"; import chalk from "chalk"; +import { AssistantMessageComponent } from "./assistant-message.js"; import { CustomEditor } from "./custom-editor.js"; import { FooterComponent } from "./footer.js"; import { StreamingMessageComponent } from "./streaming-message.js"; +import { ThinkingSelectorComponent } from "./thinking-selector.js"; import { ToolExecutionComponent } from "./tool-execution.js"; +import { UserMessageComponent } from "./user-message.js"; /** * TUI renderer for the coding agent @@ -47,7 +39,7 @@ export class TuiRenderer { private deferredStats: { usage: any; toolCallIds: Set } | null = null; // Thinking level selector - private thinkingSelector: SelectList | null = null; + private thinkingSelector: ThinkingSelectorComponent | null = null; // Track if this is the first user message (to skip spacer) private isFirstUserMessage = true; @@ -292,52 +284,16 @@ export class TuiRenderer { const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); const textContent = textBlocks.map((c: any) => c.text).join(""); if (textContent) { - // Add spacer before user message (except first one) - if (!this.isFirstUserMessage) { - this.chatContainer.addChild(new Spacer(1)); - } + const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); + this.chatContainer.addChild(userComponent); this.isFirstUserMessage = false; - - // User messages with dark gray background - this.chatContainer.addChild(new Markdown(textContent, undefined, undefined, { r: 52, g: 53, b: 65 })); } } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; - // Add spacer before assistant message - this.chatContainer.addChild(new Spacer(1)); - - // Render content in order - for (const content of assistantMsg.content) { - if (content.type === "text" && content.text.trim()) { - // Assistant text messages with no background - trim the text - // Set paddingY=0 to avoid extra spacing before tool executions - this.chatContainer.addChild(new Markdown(content.text.trim(), undefined, undefined, undefined, 1, 0)); - } else if (content.type === "thinking" && content.thinking.trim()) { - // Thinking traces in dark gray italic - const thinkingText = content.thinking - .split("\n") - .map((line) => chalk.gray.italic(line)) - .join("\n"); - this.chatContainer.addChild(new Text(thinkingText, 1, 0)); - } - } - - // Check if aborted - show after partial content - if (assistantMsg.stopReason === "aborted") { - // Show red "Aborted" message after partial content - const abortedText = new Text(chalk.red("Aborted")); - this.chatContainer.addChild(abortedText); - return; - } - - if (assistantMsg.stopReason === "error") { - // Show red error message after partial content - const errorMsg = assistantMsg.errorMessage || "Unknown error"; - const errorText = new Text(chalk.red(`Error: ${errorMsg}`)); - this.chatContainer.addChild(errorText); - return; - } + // Add assistant message component + const assistantComponent = new AssistantMessageComponent(assistantMsg); + this.chatContainer.addChild(assistantComponent); // Check if this message has tool calls const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); @@ -502,55 +458,34 @@ export class TuiRenderer { } private showThinkingSelector(): void { - const thinkingLevels: SelectItem[] = [ - { value: "off", label: "off", description: "No reasoning" }, - { value: "minimal", label: "minimal", description: "Very brief reasoning (~1k tokens)" }, - { value: "low", label: "low", description: "Light reasoning (~2k tokens)" }, - { value: "medium", label: "medium", description: "Moderate reasoning (~8k tokens)" }, - { value: "high", label: "high", description: "Deep reasoning (~16k tokens)" }, - ]; + // Create thinking selector with current level + this.thinkingSelector = new ThinkingSelectorComponent( + this.agent.state.thinkingLevel, + (level) => { + // Apply the selected thinking level + this.agent.setThinkingLevel(level); - // Create container for the selector with borders - const selectorContainer = new Container(); + // Show confirmation message with padding and blue color + this.chatContainer.addChild(new Text("", 0, 0)); // Blank line before + const confirmText = new Text(chalk.blue(`Thinking level set to: ${level}`), 0, 0); + this.chatContainer.addChild(confirmText); + this.chatContainer.addChild(new Text("", 0, 0)); // Blank line after - // Add top border - const topBorder = new Text(chalk.blue("─".repeat(50)), 0, 0); - selectorContainer.addChild(topBorder); + // Hide selector and show editor again + this.hideThinkingSelector(); + this.ui.requestRender(); + }, + () => { + // Just hide the selector + this.hideThinkingSelector(); + this.ui.requestRender(); + }, + ); - // Add selector - this.thinkingSelector = new SelectList(thinkingLevels, 5); - this.thinkingSelector.onSelect = (item) => { - // Apply the selected thinking level - const level = item.value as ThinkingLevel; - this.agent.setThinkingLevel(level); - - // Show confirmation message with padding and blue color - this.chatContainer.addChild(new Text("", 0, 0)); // Blank line before - const confirmText = new Text(chalk.blue(`Thinking level set to: ${level}`), 0, 0); - this.chatContainer.addChild(confirmText); - this.chatContainer.addChild(new Text("", 0, 0)); // Blank line after - - // Hide selector and show editor again - this.hideThinkingSelector(); - this.ui.requestRender(); - }; - - this.thinkingSelector.onCancel = () => { - // Just hide the selector - this.hideThinkingSelector(); - this.ui.requestRender(); - }; - - selectorContainer.addChild(this.thinkingSelector); - - // Add bottom border - const bottomBorder = new Text(chalk.blue("─".repeat(50)), 0, 0); - selectorContainer.addChild(bottomBorder); - - // Replace editor with selector container + // Replace editor with selector this.editorContainer.clear(); - this.editorContainer.addChild(selectorContainer); - this.ui.setFocus(this.thinkingSelector); + this.editorContainer.addChild(this.thinkingSelector); + this.ui.setFocus(this.thinkingSelector.getSelectList()); this.ui.requestRender(); } diff --git a/packages/coding-agent/src/tui/user-message.ts b/packages/coding-agent/src/tui/user-message.ts new file mode 100644 index 00000000..d62ab7d4 --- /dev/null +++ b/packages/coding-agent/src/tui/user-message.ts @@ -0,0 +1,23 @@ +import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; + +/** + * Component that renders a user message + */ +export class UserMessageComponent extends Container { + private spacer: Spacer | null = null; + private markdown: Markdown; + + constructor(text: string, isFirst: boolean) { + super(); + + // Add spacer before user message (except first one) + if (!isFirst) { + this.spacer = new Spacer(1); + this.addChild(this.spacer); + } + + // User messages with dark gray background + this.markdown = new Markdown(text, undefined, undefined, { r: 52, g: 53, b: 65 }); + this.addChild(this.markdown); + } +} diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 1be4b1f4..d4f05402 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -30,6 +30,10 @@ export class SelectList implements Component { this.selectedIndex = 0; } + setSelectedIndex(index: number): void { + this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + } + render(width: number): string[] { const lines: string[] = [];