feat: add tui ui kit
This commit is contained in:
@@ -20,7 +20,7 @@ Updated: 2026-01-03
|
|||||||
## Checklist
|
## Checklist
|
||||||
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
|
- [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.
|
- [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.
|
- [ ] TUI controller: keybindings + Clawdis slash commands + history/stream wiring.
|
||||||
- [ ] Docs + changelog updated for the new TUI behavior.
|
- [ ] Docs + changelog updated for the new TUI behavior.
|
||||||
- [ ] Gate: lint, build, tests, docs list.
|
- [ ] 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