mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 10:02:23 +00:00
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
This commit is contained in:
parent
e2649341f0
commit
741add4411
5 changed files with 158 additions and 99 deletions
46
packages/coding-agent/src/tui/assistant-message.ts
Normal file
46
packages/coding-agent/src/tui/assistant-message.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
packages/coding-agent/src/tui/thinking-selector.ts
Normal file
51
packages/coding-agent/src/tui/thinking-selector.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent";
|
import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent";
|
||||||
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
||||||
import type { SlashCommand } from "@mariozechner/pi-tui";
|
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||||
import {
|
import { CombinedAutocompleteProvider, Container, Loader, ProcessTerminal, Text, TUI } from "@mariozechner/pi-tui";
|
||||||
CombinedAutocompleteProvider,
|
|
||||||
Container,
|
|
||||||
Loader,
|
|
||||||
Markdown,
|
|
||||||
ProcessTerminal,
|
|
||||||
type SelectItem,
|
|
||||||
SelectList,
|
|
||||||
Spacer,
|
|
||||||
Text,
|
|
||||||
TUI,
|
|
||||||
} from "@mariozechner/pi-tui";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||||
import { CustomEditor } from "./custom-editor.js";
|
import { CustomEditor } from "./custom-editor.js";
|
||||||
import { FooterComponent } from "./footer.js";
|
import { FooterComponent } from "./footer.js";
|
||||||
import { StreamingMessageComponent } from "./streaming-message.js";
|
import { StreamingMessageComponent } from "./streaming-message.js";
|
||||||
|
import { ThinkingSelectorComponent } from "./thinking-selector.js";
|
||||||
import { ToolExecutionComponent } from "./tool-execution.js";
|
import { ToolExecutionComponent } from "./tool-execution.js";
|
||||||
|
import { UserMessageComponent } from "./user-message.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TUI renderer for the coding agent
|
* TUI renderer for the coding agent
|
||||||
|
|
@ -47,7 +39,7 @@ export class TuiRenderer {
|
||||||
private deferredStats: { usage: any; toolCallIds: Set<string> } | null = null;
|
private deferredStats: { usage: any; toolCallIds: Set<string> } | null = null;
|
||||||
|
|
||||||
// Thinking level selector
|
// Thinking level selector
|
||||||
private thinkingSelector: SelectList | null = null;
|
private thinkingSelector: ThinkingSelectorComponent | null = null;
|
||||||
|
|
||||||
// Track if this is the first user message (to skip spacer)
|
// Track if this is the first user message (to skip spacer)
|
||||||
private isFirstUserMessage = true;
|
private isFirstUserMessage = true;
|
||||||
|
|
@ -292,52 +284,16 @@ export class TuiRenderer {
|
||||||
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
||||||
const textContent = textBlocks.map((c: any) => c.text).join("");
|
const textContent = textBlocks.map((c: any) => c.text).join("");
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
// Add spacer before user message (except first one)
|
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
||||||
if (!this.isFirstUserMessage) {
|
this.chatContainer.addChild(userComponent);
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
}
|
|
||||||
this.isFirstUserMessage = false;
|
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") {
|
} else if (message.role === "assistant") {
|
||||||
const assistantMsg = message as AssistantMessage;
|
const assistantMsg = message as AssistantMessage;
|
||||||
|
|
||||||
// Add spacer before assistant message
|
// Add assistant message component
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
const assistantComponent = new AssistantMessageComponent(assistantMsg);
|
||||||
|
this.chatContainer.addChild(assistantComponent);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this message has tool calls
|
// Check if this message has tool calls
|
||||||
const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall");
|
const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall");
|
||||||
|
|
@ -502,55 +458,34 @@ export class TuiRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private showThinkingSelector(): void {
|
private showThinkingSelector(): void {
|
||||||
const thinkingLevels: SelectItem[] = [
|
// Create thinking selector with current level
|
||||||
{ value: "off", label: "off", description: "No reasoning" },
|
this.thinkingSelector = new ThinkingSelectorComponent(
|
||||||
{ value: "minimal", label: "minimal", description: "Very brief reasoning (~1k tokens)" },
|
this.agent.state.thinkingLevel,
|
||||||
{ value: "low", label: "low", description: "Light reasoning (~2k tokens)" },
|
(level) => {
|
||||||
{ value: "medium", label: "medium", description: "Moderate reasoning (~8k tokens)" },
|
// Apply the selected thinking level
|
||||||
{ value: "high", label: "high", description: "Deep reasoning (~16k tokens)" },
|
this.agent.setThinkingLevel(level);
|
||||||
];
|
|
||||||
|
|
||||||
// Create container for the selector with borders
|
// Show confirmation message with padding and blue color
|
||||||
const selectorContainer = new Container();
|
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
|
// Hide selector and show editor again
|
||||||
const topBorder = new Text(chalk.blue("─".repeat(50)), 0, 0);
|
this.hideThinkingSelector();
|
||||||
selectorContainer.addChild(topBorder);
|
this.ui.requestRender();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Just hide the selector
|
||||||
|
this.hideThinkingSelector();
|
||||||
|
this.ui.requestRender();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Add selector
|
// Replace editor with 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
|
|
||||||
this.editorContainer.clear();
|
this.editorContainer.clear();
|
||||||
this.editorContainer.addChild(selectorContainer);
|
this.editorContainer.addChild(this.thinkingSelector);
|
||||||
this.ui.setFocus(this.thinkingSelector);
|
this.ui.setFocus(this.thinkingSelector.getSelectList());
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
23
packages/coding-agent/src/tui/user-message.ts
Normal file
23
packages/coding-agent/src/tui/user-message.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,10 @@ export class SelectList implements Component {
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelectedIndex(index: number): void {
|
||||||
|
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue