feat: add tui ui kit
This commit is contained in:
@@ -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.
|
||||
|
||||
19
src/tui/components/assistant-message.ts
Normal file
19
src/tui/components/assistant-message.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
102
src/tui/components/chat-log.ts
Normal file
102
src/tui/components/chat-log.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/tui/components/custom-editor.ts
Normal file
66
src/tui/components/custom-editor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
src/tui/components/selectors.ts
Normal file
26
src/tui/components/selectors.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
130
src/tui/components/tool-execution.ts
Normal file
130
src/tui/components/tool-execution.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/tui/components/user-message.ts
Normal file
20
src/tui/components/user-message.ts
Normal 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
95
src/tui/theme/theme.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user