feat: add tui ui kit

This commit is contained in:
Peter Steinberger
2026-01-03 06:22:20 +01:00
parent aee13507f9
commit 32c91bbb25
8 changed files with 459 additions and 1 deletions

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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<string, ToolExecutionComponent>();
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<string, unknown>);
return;
}
existing.setResult(result as Record<string, unknown>, {
isError: opts?.isError,
});
}
setToolsExpanded(expanded: boolean) {
this.toolsExpanded = expanded;
for (const tool of this.toolById.values()) {
tool.setExpanded(expanded);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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,
);
}

View File

@@ -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<string, unknown>;
};
const PREVIEW_LINES = 12;
function formatArgs(toolName: string, args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record<string, unknown>;
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);
}
}
}

View File

@@ -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);
}
}

95
src/tui/theme/theme.ts Normal file
View File

@@ -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,
};