From 32c91bbb250988ee7fa85b4d22d30a535e7cf38a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 06:22:20 +0100 Subject: [PATCH] feat: add tui ui kit --- docs/refactor/tui.md | 2 +- src/tui/components/assistant-message.ts | 19 ++++ src/tui/components/chat-log.ts | 102 +++++++++++++++++++ src/tui/components/custom-editor.ts | 66 ++++++++++++ src/tui/components/selectors.ts | 26 +++++ src/tui/components/tool-execution.ts | 130 ++++++++++++++++++++++++ src/tui/components/user-message.ts | 20 ++++ src/tui/theme/theme.ts | 95 +++++++++++++++++ 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 src/tui/components/assistant-message.ts create mode 100644 src/tui/components/chat-log.ts create mode 100644 src/tui/components/custom-editor.ts create mode 100644 src/tui/components/selectors.ts create mode 100644 src/tui/components/tool-execution.ts create mode 100644 src/tui/components/user-message.ts create mode 100644 src/tui/theme/theme.ts diff --git a/docs/refactor/tui.md b/docs/refactor/tui.md index ceb578bc4..940747119 100644 --- a/docs/refactor/tui.md +++ b/docs/refactor/tui.md @@ -20,7 +20,7 @@ Updated: 2026-01-03 ## Checklist - [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads). - [x] Gateway TUI client: add session/model helpers + stricter typing. -- [ ] TUI UI kit: theme + components (editor, message feed, tool cards, selectors). +- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors). - [ ] TUI controller: keybindings + Clawdis slash commands + history/stream wiring. - [ ] Docs + changelog updated for the new TUI behavior. - [ ] Gate: lint, build, tests, docs list. diff --git a/src/tui/components/assistant-message.ts b/src/tui/components/assistant-message.ts new file mode 100644 index 000000000..04dd7d9db --- /dev/null +++ b/src/tui/components/assistant-message.ts @@ -0,0 +1,19 @@ +import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; +import { markdownTheme, theme } from "../theme/theme.js"; + +export class AssistantMessageComponent extends Container { + private body: Markdown; + + constructor(text: string) { + super(); + this.body = new Markdown(text, 1, 0, markdownTheme, { + color: (line) => theme.fg(line), + }); + this.addChild(new Spacer(1)); + this.addChild(this.body); + } + + setText(text: string) { + this.body.setText(text); + } +} diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts new file mode 100644 index 000000000..700a670a5 --- /dev/null +++ b/src/tui/components/chat-log.ts @@ -0,0 +1,102 @@ +import { Container, Spacer, Text } from "@mariozechner/pi-tui"; +import { AssistantMessageComponent } from "./assistant-message.js"; +import { ToolExecutionComponent } from "./tool-execution.js"; +import { UserMessageComponent } from "./user-message.js"; +import { theme } from "../theme/theme.js"; + +export class ChatLog extends Container { + private toolById = new Map(); + private streamingAssistant: AssistantMessageComponent | null = null; + private streamingRunId: string | null = null; + private toolsExpanded = false; + + clearAll() { + this.clear(); + this.toolById.clear(); + this.streamingAssistant = null; + this.streamingRunId = null; + } + + addSystem(text: string) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.system(text), 1, 0)); + } + + addUser(text: string) { + this.addChild(new UserMessageComponent(text)); + } + + startAssistant(text: string, runId?: string) { + const component = new AssistantMessageComponent(text); + this.streamingAssistant = component; + this.streamingRunId = runId ?? null; + this.addChild(component); + return component; + } + + updateAssistant(text: string, runId?: string) { + if ( + !this.streamingAssistant || + (runId && this.streamingRunId && runId !== this.streamingRunId) + ) { + this.startAssistant(text, runId); + return; + } + this.streamingAssistant.setText(text); + } + + finalizeAssistant(text: string, runId?: string) { + if ( + this.streamingAssistant && + (!runId || runId === this.streamingRunId) + ) { + this.streamingAssistant.setText(text); + } else { + this.startAssistant(text, runId); + } + this.streamingAssistant = null; + this.streamingRunId = null; + } + + startTool(toolCallId: string, toolName: string, args: unknown) { + const existing = this.toolById.get(toolCallId); + if (existing) { + existing.setArgs(args); + return existing; + } + const component = new ToolExecutionComponent(toolName, args); + component.setExpanded(this.toolsExpanded); + this.toolById.set(toolCallId, component); + this.addChild(component); + return component; + } + + updateToolArgs(toolCallId: string, args: unknown) { + const existing = this.toolById.get(toolCallId); + if (!existing) return; + existing.setArgs(args); + } + + updateToolResult( + toolCallId: string, + result: unknown, + opts?: { isError?: boolean; partial?: boolean }, + ) { + const existing = this.toolById.get(toolCallId); + if (!existing) return; + if (opts?.partial) { + existing.setPartialResult(result as Record); + return; + } + existing.setResult(result as Record, { + isError: opts?.isError, + }); + } + + setToolsExpanded(expanded: boolean) { + this.toolsExpanded = expanded; + for (const tool of this.toolById.values()) { + tool.setExpanded(expanded); + } + } +} diff --git a/src/tui/components/custom-editor.ts b/src/tui/components/custom-editor.ts new file mode 100644 index 000000000..f9036080a --- /dev/null +++ b/src/tui/components/custom-editor.ts @@ -0,0 +1,66 @@ +import { + Editor, + isAltEnter, + isCtrlC, + isCtrlD, + isCtrlL, + isCtrlO, + isCtrlP, + isCtrlT, + isEscape, + isShiftTab, +} from "@mariozechner/pi-tui"; + +export class CustomEditor extends Editor { + onEscape?: () => void; + onCtrlC?: () => void; + onCtrlD?: () => void; + onCtrlL?: () => void; + onCtrlO?: () => void; + onCtrlP?: () => void; + onCtrlT?: () => void; + onShiftTab?: () => void; + onAltEnter?: () => void; + + handleInput(data: string): void { + if (isAltEnter(data) && this.onAltEnter) { + this.onAltEnter(); + return; + } + if (isCtrlL(data) && this.onCtrlL) { + this.onCtrlL(); + return; + } + if (isCtrlO(data) && this.onCtrlO) { + this.onCtrlO(); + return; + } + if (isCtrlP(data) && this.onCtrlP) { + this.onCtrlP(); + return; + } + if (isCtrlT(data) && this.onCtrlT) { + this.onCtrlT(); + return; + } + if (isShiftTab(data) && this.onShiftTab) { + this.onShiftTab(); + return; + } + if (isEscape(data) && this.onEscape && !this.isShowingAutocomplete()) { + this.onEscape(); + return; + } + if (isCtrlC(data) && this.onCtrlC) { + this.onCtrlC(); + return; + } + if (isCtrlD(data)) { + if (this.getText().length === 0 && this.onCtrlD) { + this.onCtrlD(); + } + return; + } + super.handleInput(data); + } +} diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts new file mode 100644 index 000000000..e5204d4a8 --- /dev/null +++ b/src/tui/components/selectors.ts @@ -0,0 +1,26 @@ +import { + SelectList, + type SelectItem, + SettingsList, + type SettingItem, +} from "@mariozechner/pi-tui"; +import { selectListTheme, settingsListTheme } from "../theme/theme.js"; + +export function createSelectList(items: SelectItem[], maxVisible = 7) { + return new SelectList(items, maxVisible, selectListTheme); +} + +export function createSettingsList( + items: SettingItem[], + onChange: (id: string, value: string) => void, + onCancel: () => void, + maxVisible = 7, +) { + return new SettingsList( + items, + maxVisible, + settingsListTheme, + onChange, + onCancel, + ); +} diff --git a/src/tui/components/tool-execution.ts b/src/tui/components/tool-execution.ts new file mode 100644 index 000000000..47cab661e --- /dev/null +++ b/src/tui/components/tool-execution.ts @@ -0,0 +1,130 @@ +import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { markdownTheme, theme } from "../theme/theme.js"; + +type ToolResultContent = { + type?: string; + text?: string; + mimeType?: string; + bytes?: number; + omitted?: boolean; +}; + +type ToolResult = { + content?: ToolResultContent[]; + details?: Record; +}; + +const PREVIEW_LINES = 12; + +function formatArgs(toolName: string, args: unknown): string { + if (!args || typeof args !== "object") return ""; + const record = args as Record; + if (toolName === "bash" && typeof record.command === "string") { + return record.command; + } + const path = typeof record.path === "string" ? record.path : undefined; + if (path) return path; + try { + return JSON.stringify(args); + } catch { + return ""; + } +} + +function extractText(result?: ToolResult): string { + if (!result?.content) return ""; + const lines: string[] = []; + for (const entry of result.content) { + if (entry.type === "text" && entry.text) { + lines.push(entry.text); + } else if (entry.type === "image") { + const mime = entry.mimeType ?? "image"; + const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : ""; + const omitted = entry.omitted ? " (omitted)" : ""; + lines.push(`[${mime}${size}${omitted}]`); + } + } + return lines.join("\n").trim(); +} + +export class ToolExecutionComponent extends Container { + private box: Box; + private header: Text; + private argsLine: Text; + private output: Markdown; + private toolName: string; + private args: unknown; + private result?: ToolResult; + private expanded = false; + private isError = false; + private isPartial = true; + + constructor(toolName: string, args: unknown) { + super(); + this.toolName = toolName; + this.args = args; + this.box = new Box(1, 1, (line) => theme.toolPendingBg(line)); + this.header = new Text("", 0, 0); + this.argsLine = new Text("", 0, 0); + this.output = new Markdown("", 0, 0, markdownTheme, { + color: (line) => theme.toolOutput(line), + }); + this.addChild(new Spacer(1)); + this.addChild(this.box); + this.box.addChild(this.header); + this.box.addChild(this.argsLine); + this.box.addChild(this.output); + this.refresh(); + } + + setArgs(args: unknown) { + this.args = args; + this.refresh(); + } + + setExpanded(expanded: boolean) { + this.expanded = expanded; + this.refresh(); + } + + setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) { + this.result = result; + this.isPartial = false; + this.isError = Boolean(opts?.isError); + this.refresh(); + } + + setPartialResult(result: ToolResult | undefined) { + this.result = result; + this.isPartial = true; + this.refresh(); + } + + private refresh() { + const bg = this.isPartial + ? theme.toolPendingBg + : this.isError + ? theme.toolErrorBg + : theme.toolSuccessBg; + this.box.setBgFn((line) => bg(line)); + + const title = `${this.toolName}${this.isPartial ? " (running)" : ""}`; + this.header.setText(theme.toolTitle(theme.bold(title))); + + const argLine = formatArgs(this.toolName, this.args); + this.argsLine.setText(argLine ? theme.dim(argLine) : theme.dim(" ")); + + const raw = extractText(this.result); + const text = raw || (this.isPartial ? "…" : ""); + if (!this.expanded && text) { + const lines = text.split("\n"); + const preview = + lines.length > PREVIEW_LINES + ? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…` + : text; + this.output.setText(preview); + } else { + this.output.setText(text); + } + } +} diff --git a/src/tui/components/user-message.ts b/src/tui/components/user-message.ts new file mode 100644 index 000000000..fad77f42d --- /dev/null +++ b/src/tui/components/user-message.ts @@ -0,0 +1,20 @@ +import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; +import { markdownTheme, theme } from "../theme/theme.js"; + +export class UserMessageComponent extends Container { + private body: Markdown; + + constructor(text: string) { + super(); + this.body = new Markdown(text, 1, 1, markdownTheme, { + bgColor: (line) => theme.userBg(line), + color: (line) => theme.userText(line), + }); + this.addChild(new Spacer(1)); + this.addChild(this.body); + } + + setText(text: string) { + this.body.setText(text); + } +} diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts new file mode 100644 index 000000000..abfce79e3 --- /dev/null +++ b/src/tui/theme/theme.ts @@ -0,0 +1,95 @@ +import chalk from "chalk"; +import type { + EditorTheme, + MarkdownTheme, + SelectListTheme, + SettingsListTheme, +} from "@mariozechner/pi-tui"; + +const palette = { + text: "#E8E3D5", + dim: "#7B7F87", + accent: "#F6C453", + accentSoft: "#F2A65A", + border: "#3C414B", + userBg: "#2B2F36", + userText: "#F3EEE0", + systemText: "#9BA3B2", + toolPendingBg: "#1F2A2F", + toolSuccessBg: "#1E2D23", + toolErrorBg: "#2F1F1F", + toolTitle: "#F6C453", + toolOutput: "#E1DACB", + quote: "#8CC8FF", + quoteBorder: "#3B4D6B", + code: "#F0C987", + codeBlock: "#1E232A", + codeBorder: "#343A45", + link: "#7DD3A5", + error: "#F97066", + success: "#7DD3A5", +}; + +const fg = (hex: string) => (text: string) => chalk.hex(hex)(text); +const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text); + +export const theme = { + fg: fg(palette.text), + dim: fg(palette.dim), + accent: fg(palette.accent), + accentSoft: fg(palette.accentSoft), + success: fg(palette.success), + error: fg(palette.error), + header: (text: string) => chalk.bold(fg(palette.accent)(text)), + system: fg(palette.systemText), + userBg: bg(palette.userBg), + userText: fg(palette.userText), + toolTitle: fg(palette.toolTitle), + toolOutput: fg(palette.toolOutput), + toolPendingBg: bg(palette.toolPendingBg), + toolSuccessBg: bg(palette.toolSuccessBg), + toolErrorBg: bg(palette.toolErrorBg), + border: fg(palette.border), + bold: (text: string) => chalk.bold(text), + italic: (text: string) => chalk.italic(text), +}; + +export const markdownTheme: MarkdownTheme = { + heading: (text) => chalk.bold(fg(palette.accent)(text)), + link: (text) => fg(palette.link)(text), + linkUrl: (text) => chalk.dim(text), + code: (text) => fg(palette.code)(text), + codeBlock: (text) => fg(palette.code)(text), + codeBlockBorder: (text) => fg(palette.codeBorder)(text), + quote: (text) => fg(palette.quote)(text), + quoteBorder: (text) => fg(palette.quoteBorder)(text), + hr: (text) => fg(palette.border)(text), + listBullet: (text) => fg(palette.accentSoft)(text), + bold: (text) => chalk.bold(text), + italic: (text) => chalk.italic(text), + strikethrough: (text) => chalk.strikethrough(text), + underline: (text) => chalk.underline(text), +}; + +export const selectListTheme: SelectListTheme = { + selectedPrefix: (text) => fg(palette.accent)(text), + selectedText: (text) => chalk.bold(fg(palette.accent)(text)), + description: (text) => fg(palette.dim)(text), + scrollInfo: (text) => fg(palette.dim)(text), + noMatch: (text) => fg(palette.dim)(text), +}; + +export const settingsListTheme: SettingsListTheme = { + label: (text, selected) => + selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text), + value: (text, selected) => + selected ? fg(palette.accentSoft)(text) : fg(palette.dim)(text), + description: (text) => fg(palette.systemText)(text), + cursor: fg(palette.accent)("→ "), + hint: (text) => fg(palette.dim)(text), +}; + +export const editorTheme: EditorTheme = { + borderColor: (text) => fg(palette.border)(text), + selectList: selectListTheme, +};