From d3458a4fc36ea19d7d18118afb2b22fa15bda697 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 06:27:38 +0100 Subject: [PATCH] feat: overhaul tui controller --- docs/refactor/tui.md | 2 +- src/tui/commands.ts | 88 +++++ src/tui/gateway-chat.ts | 4 + src/tui/layout.ts | 46 --- src/tui/message-list.ts | 83 ----- src/tui/theme.ts | 28 -- src/tui/tui.ts | 753 ++++++++++++++++++++++++++++++---------- 7 files changed, 659 insertions(+), 345 deletions(-) create mode 100644 src/tui/commands.ts delete mode 100644 src/tui/layout.ts delete mode 100644 src/tui/message-list.ts delete mode 100644 src/tui/theme.ts diff --git a/docs/refactor/tui.md b/docs/refactor/tui.md index 940747119..ef976f57e 100644 --- a/docs/refactor/tui.md +++ b/docs/refactor/tui.md @@ -21,6 +21,6 @@ Updated: 2026-01-03 - [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] TUI UI kit: theme + components (editor, message feed, tool cards, selectors). -- [ ] TUI controller: keybindings + Clawdis slash commands + history/stream wiring. +- [x] 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/commands.ts b/src/tui/commands.ts new file mode 100644 index 000000000..2c1665e4f --- /dev/null +++ b/src/tui/commands.ts @@ -0,0 +1,88 @@ +import type { SlashCommand } from "@mariozechner/pi-tui"; + +const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"]; +const VERBOSE_LEVELS = ["on", "off"]; +const ACTIVATION_LEVELS = ["mention", "always"]; +const TOGGLE = ["on", "off"]; + +export type ParsedCommand = { + name: string; + args: string; +}; + +export function parseCommand(input: string): ParsedCommand { + const trimmed = input.replace(/^\//, "").trim(); + if (!trimmed) return { name: "", args: "" }; + const [name, ...rest] = trimmed.split(/\s+/); + return { name: name.toLowerCase(), args: rest.join(" ").trim() }; +} + +export function getSlashCommands(): SlashCommand[] { + return [ + { name: "help", description: "Show slash command help" }, + { name: "status", description: "Show gateway status summary" }, + { name: "session", description: "Switch session (or open picker)" }, + { name: "sessions", description: "Open session picker" }, + { + name: "model", + description: "Set model (or open picker)", + }, + { name: "models", description: "Open model picker" }, + { + name: "think", + description: "Set thinking level", + getArgumentCompletions: (prefix) => + THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, + { + name: "verbose", + description: "Set verbose on/off", + getArgumentCompletions: (prefix) => + VERBOSE_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, + { + name: "activation", + description: "Set group activation", + getArgumentCompletions: (prefix) => + ACTIVATION_LEVELS.filter((v) => + v.startsWith(prefix.toLowerCase()), + ).map((value) => ({ value, label: value })), + }, + { + name: "deliver", + description: "Toggle delivery of assistant replies", + getArgumentCompletions: (prefix) => + TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, + { name: "abort", description: "Abort active run" }, + { name: "new", description: "Reset the session" }, + { name: "reset", description: "Reset the session" }, + { name: "settings", description: "Open settings" }, + { name: "exit", description: "Exit the TUI" }, + { name: "quit", description: "Exit the TUI" }, + ]; +} + +export function helpText(): string { + return [ + "Slash commands:", + "/help", + "/status", + "/session (or /sessions)", + "/model (or /models)", + "/think ", + "/verbose ", + "/activation ", + "/deliver ", + "/new or /reset", + "/abort", + "/settings", + "/exit", + ].join("\n"); +} diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 3ca9b86f5..4638aa835 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -157,6 +157,10 @@ export class GatewayChatClient { return await this.client.request("sessions.reset", { key }); } + async getStatus() { + return await this.client.request("status"); + } + async listModels(): Promise< Array<{ id: string; diff --git a/src/tui/layout.ts b/src/tui/layout.ts deleted file mode 100644 index b3f61d059..000000000 --- a/src/tui/layout.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Component } from "@mariozechner/pi-tui"; - -export class ChatLayout implements Component { - constructor( - private header: Component, - private messages: Component, - private status: Component, - private input: Component, - ) {} - - invalidate(): void { - this.header.invalidate?.(); - this.messages.invalidate?.(); - this.status.invalidate?.(); - this.input.invalidate?.(); - } - - render(width: number): string[] { - const rows = process.stdout.rows ?? 24; - const headerLines = this.header.render(width); - const statusLines = this.status.render(width); - const inputLines = this.input.render(width); - - const reserved = - headerLines.length + statusLines.length + inputLines.length; - const available = Math.max(rows - reserved, 0); - - const messageLines = this.messages.render(width); - const slicedMessages = - available > 0 - ? messageLines.slice(Math.max(0, messageLines.length - available)) - : []; - - const lines = [ - ...headerLines, - ...slicedMessages, - ...statusLines, - ...inputLines, - ]; - if (lines.length < rows) { - const padding = Array.from({ length: rows - lines.length }, () => ""); - return [...lines, ...padding]; - } - return lines.slice(0, rows); - } -} diff --git a/src/tui/message-list.ts b/src/tui/message-list.ts deleted file mode 100644 index e9d7b9646..000000000 --- a/src/tui/message-list.ts +++ /dev/null @@ -1,83 +0,0 @@ -import crypto from "node:crypto"; -import type { DefaultTextStyle, MarkdownTheme } from "@mariozechner/pi-tui"; -import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import { theme } from "./theme.js"; - -type MessageRole = "user" | "system" | "tool"; - -export class MessageList extends Container { - private assistantById = new Map(); - - constructor( - private markdownTheme: MarkdownTheme, - private styles: { - user: DefaultTextStyle; - assistant: DefaultTextStyle; - system: DefaultTextStyle; - tool: DefaultTextStyle; - }, - ) { - super(); - } - - clearAll(): void { - this.assistantById.clear(); - this.clear(); - } - - addSystem(text: string): void { - this.addMessage("system", text, this.styles.system); - } - - addTool(text: string): void { - this.addMessage("tool", text, this.styles.tool); - } - - addUser(text: string): void { - this.addMessage("user", text, this.styles.user); - } - - addAssistant(text: string, id?: string): string { - const messageId = id ?? crypto.randomUUID(); - const label = new Text(theme.assistant("clawd"), 1, 0); - const body = new Markdown( - text, - 1, - 0, - this.markdownTheme, - this.styles.assistant, - ); - const group = new Container(); - group.addChild(label); - group.addChild(body); - this.addChild(group); - this.addChild(new Spacer(1)); - - this.assistantById.set(messageId, body); - return messageId; - } - - updateAssistant(id: string, text: string): void { - const component = this.assistantById.get(id); - if (!component) return; - component.setText(text); - } - - private addMessage(role: MessageRole, text: string, style: DefaultTextStyle) { - const label = new Text( - role === "user" - ? theme.user("you") - : role === "system" - ? theme.system("system") - : theme.dim("tool"), - 1, - 0, - ); - const body = new Markdown(text, 1, 0, this.markdownTheme, style); - const group = new Container(); - group.addChild(label); - group.addChild(body); - this.addChild(group); - this.addChild(new Spacer(1)); - } -} diff --git a/src/tui/theme.ts b/src/tui/theme.ts deleted file mode 100644 index 198ece5c7..000000000 --- a/src/tui/theme.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { MarkdownTheme } from "@mariozechner/pi-tui"; -import chalk from "chalk"; - -export const markdownTheme: MarkdownTheme = { - heading: (text) => chalk.bold.cyan(text), - link: (text) => chalk.blue(text), - linkUrl: (text) => chalk.gray(text), - code: (text) => chalk.yellow(text), - codeBlock: (text) => chalk.yellow(text), - codeBlockBorder: (text) => chalk.gray(text), - quote: (text) => chalk.gray(text), - quoteBorder: (text) => chalk.gray(text), - hr: (text) => chalk.gray(text), - listBullet: (text) => chalk.cyan(text), - bold: (text) => chalk.bold(text), - italic: (text) => chalk.italic(text), - strikethrough: (text) => chalk.strikethrough(text), - underline: (text) => chalk.underline(text), -}; - -export const theme = { - header: (text: string) => chalk.bold.cyan(text), - dim: (text: string) => chalk.gray(text), - user: (text: string) => chalk.cyan(text), - assistant: (text: string) => chalk.green(text), - system: (text: string) => chalk.magenta(text), - error: (text: string) => chalk.red(text), -}; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 03b396637..e83ab8f4c 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1,17 +1,18 @@ import { - type Component, - Input, - isCtrlC, - isEscape, + CombinedAutocompleteProvider, + Container, ProcessTerminal, Text, TUI, + type Component, } from "@mariozechner/pi-tui"; import { loadConfig } from "../config/config.js"; +import { ChatLog } from "./components/chat-log.js"; +import { CustomEditor } from "./components/custom-editor.js"; +import { createSelectList, createSettingsList } from "./components/selectors.js"; +import { getSlashCommands, helpText, parseCommand } from "./commands.js"; import { GatewayChatClient } from "./gateway-chat.js"; -import { ChatLayout } from "./layout.js"; -import { MessageList } from "./message-list.js"; -import { markdownTheme, theme } from "./theme.js"; +import { editorTheme, theme } from "./theme/theme.js"; export type TuiOptions = { url?: string; @@ -32,110 +33,79 @@ type ChatEvent = { errorMessage?: string; }; -class InputWrapper implements Component { - constructor( - private input: Input, - private onAbort: () => void, - private onExit: () => void, - ) {} +type AgentEvent = { + runId: string; + stream: string; + data?: Record; +}; - handleInput(data: string): void { - if (isCtrlC(data)) { - this.onExit(); - return; +type SessionInfo = { + thinkingLevel?: string; + verboseLevel?: string; + model?: string; + contextTokens?: number | null; + totalTokens?: number | null; + updatedAt?: number | null; + displayName?: string; +}; + +function extractTextBlocks( + content: unknown, + opts?: { includeThinking?: boolean }, +): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const record = block as Record; + if (record.type === "text" && typeof record.text === "string") { + parts.push(record.text); } - if (isEscape(data)) { - this.onAbort(); - return; + if ( + opts?.includeThinking && + record.type === "thinking" && + typeof record.thinking === "string" + ) { + parts.push(`[thinking]\n${record.thinking}`); } - this.input.handleInput(data); } - - render(width: number): string[] { - return this.input.render(width); - } - - invalidate(): void { - this.input.invalidate(); - } -} - -function extractText(message?: unknown): string { - if (!message || typeof message !== "object") return ""; - const record = message as Record; - const content = Array.isArray(record.content) ? record.content : []; - const parts = content - .map((block) => { - if (!block || typeof block !== "object") return ""; - const b = block as Record; - if (b.type === "text" && typeof b.text === "string") return b.text; - return ""; - }) - .filter(Boolean); return parts.join("\n").trim(); } -function renderHistoryEntry( - entry: unknown, -): { role: "user" | "assistant"; text: string } | null { - if (!entry || typeof entry !== "object") return null; - const record = entry as Record; - const role = - record.role === "user" || record.role === "assistant" ? record.role : null; - if (!role) return null; - const text = extractText(record); - if (!text) return null; - return { role, text }; +function extractTextFromMessage( + message: unknown, + opts?: { includeThinking?: boolean }, +): string { + if (!message || typeof message !== "object") return ""; + const record = message as Record; + return extractTextBlocks(record.content, opts); +} + +function formatTokens(total?: number | null, context?: number | null) { + if (!total && !context) return "tokens ?"; + if (!context) return `tokens ${total ?? 0}`; + const pct = + typeof total === "number" && context > 0 + ? Math.min(999, Math.round((total / context) * 100)) + : null; + return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`; } export async function runTui(opts: TuiOptions) { const config = loadConfig(); const defaultSession = (opts.session ?? config.session?.mainKey ?? "main").trim() || "main"; - let currentSession = defaultSession; - let activeRunId: string | null = null; - let streamingMessageId: string | null = null; + let currentSessionKey = defaultSession; + let currentSessionId: string | null = null; + let activeChatRunId: string | null = null; let historyLoaded = false; - - const messages = new MessageList(markdownTheme, { - user: { color: theme.user }, - assistant: { color: theme.assistant }, - system: { color: theme.system, italic: true }, - tool: { color: theme.dim, italic: true }, - }); - - const header = new Text("", 1, 0); - const status = new Text("", 1, 0); - const input = new Input(); - - const tui = new TUI(new ProcessTerminal()); - const inputWrapper = new InputWrapper( - input, - async () => { - if (!activeRunId) return; - try { - await client.abortChat({ - sessionKey: currentSession, - runId: activeRunId, - }); - } catch (err) { - messages.addSystem(`Abort failed: ${String(err)}`); - } - activeRunId = null; - streamingMessageId = null; - setStatus("aborted"); - tui.requestRender(); - }, - () => { - client.stop(); - tui.stop(); - process.exit(0); - }, - ); - - const layout = new ChatLayout(header, messages, status, inputWrapper); - tui.addChild(layout); - tui.setFocus(inputWrapper); + let isConnected = false; + let toolsExpanded = false; + let showThinking = false; + let deliverDefault = Boolean(opts.deliver); + let sessionInfo: SessionInfo = {}; + let lastCtrlCAt = 0; const client = new GatewayChatClient({ url: opts.url, @@ -143,10 +113,28 @@ export async function runTui(opts: TuiOptions) { password: opts.password, }); + const header = new Text("", 1, 0); + const status = new Text("", 1, 0); + const footer = new Text("", 1, 0); + const chatLog = new ChatLog(); + const editor = new CustomEditor(editorTheme); + const overlay = new Container(); + const root = new Container(); + root.addChild(header); + root.addChild(overlay); + root.addChild(chatLog); + root.addChild(status); + root.addChild(footer); + root.addChild(editor); + + const tui = new TUI(new ProcessTerminal()); + tui.addChild(root); + tui.setFocus(editor); + const updateHeader = () => { header.setText( theme.header( - `clawdis tui - ${client.connection.url} - session ${currentSession}`, + `clawdis tui - ${client.connection.url} - session ${currentSessionKey}`, ), ); }; @@ -155,121 +143,462 @@ export async function runTui(opts: TuiOptions) { status.setText(theme.dim(text)); }; + const updateFooter = () => { + const connection = isConnected ? "connected" : "disconnected"; + const sessionLabel = sessionInfo.displayName + ? `${currentSessionKey} (${sessionInfo.displayName})` + : currentSessionKey; + const modelLabel = sessionInfo.model ?? "unknown"; + const tokens = formatTokens( + sessionInfo.totalTokens ?? null, + sessionInfo.contextTokens ?? null, + ); + const think = sessionInfo.thinkingLevel ?? "off"; + const verbose = sessionInfo.verboseLevel ?? "off"; + const deliver = deliverDefault ? "on" : "off"; + footer.setText( + theme.dim( + `${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | ${tokens} | deliver ${deliver}`, + ), + ); + }; + + const closeOverlay = () => { + overlay.clear(); + tui.setFocus(editor); + }; + + const openOverlay = (component: Component) => { + overlay.clear(); + overlay.addChild(component); + tui.setFocus(component); + }; + + const refreshSessionInfo = async () => { + try { + const result = await client.listSessions({ + includeGlobal: false, + includeUnknown: false, + }); + const entry = result.sessions.find((row) => row.key === currentSessionKey); + sessionInfo = { + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + model: entry?.model ?? result.defaults?.model ?? undefined, + contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens, + totalTokens: entry?.totalTokens ?? null, + updatedAt: entry?.updatedAt ?? null, + displayName: entry?.displayName, + }; + } catch (err) { + chatLog.addSystem(`sessions list failed: ${String(err)}`); + } + updateFooter(); + tui.requestRender(); + }; + const loadHistory = async () => { try { const history = await client.loadHistory({ - sessionKey: currentSession, + sessionKey: currentSessionKey, limit: opts.historyLimit ?? 200, }); - const historyRecord = history as { messages?: unknown[] } | undefined; - messages.clearAll(); - messages.addSystem(`session ${currentSession}`); - for (const entry of historyRecord?.messages ?? []) { - const parsed = renderHistoryEntry(entry); - if (!parsed) continue; - if (parsed.role === "user") messages.addUser(parsed.text); - if (parsed.role === "assistant") messages.addAssistant(parsed.text); + const record = history as { + messages?: unknown[]; + sessionId?: string; + thinkingLevel?: string; + }; + currentSessionId = + typeof record.sessionId === "string" ? record.sessionId : null; + sessionInfo.thinkingLevel = record.thinkingLevel ?? sessionInfo.thinkingLevel; + chatLog.clearAll(); + chatLog.addSystem(`session ${currentSessionKey}`); + for (const entry of record.messages ?? []) { + if (!entry || typeof entry !== "object") continue; + const message = entry as Record; + if (message.role === "user") { + const text = extractTextFromMessage(message); + if (text) chatLog.addUser(text); + continue; + } + if (message.role === "assistant") { + const text = extractTextFromMessage(message, { + includeThinking: showThinking, + }); + if (text) chatLog.finalizeAssistant(text); + continue; + } + if (message.role === "toolResult") { + const toolCallId = String(message.toolCallId ?? ""); + const toolName = String(message.toolName ?? "tool"); + const component = chatLog.startTool(toolCallId, toolName, {}); + component.setResult( + { + content: Array.isArray(message.content) + ? (message.content as Record[]) + : [], + details: + typeof message.details === "object" && message.details + ? (message.details as Record) + : undefined, + }, + { isError: Boolean(message.isError) }, + ); + } } historyLoaded = true; - tui.requestRender(); } catch (err) { - messages.addSystem(`history failed: ${String(err)}`); - tui.requestRender(); + chatLog.addSystem(`history failed: ${String(err)}`); } + await refreshSessionInfo(); + tui.requestRender(); + }; + + const setSession = async (key: string) => { + currentSessionKey = key; + activeChatRunId = null; + currentSessionId = null; + historyLoaded = false; + updateHeader(); + await loadHistory(); + }; + + const abortActive = async () => { + if (!activeChatRunId) { + chatLog.addSystem("no active run"); + tui.requestRender(); + return; + } + try { + await client.abortChat({ + sessionKey: currentSessionKey, + runId: activeChatRunId, + }); + setStatus("aborted"); + } catch (err) { + chatLog.addSystem(`abort failed: ${String(err)}`); + setStatus("abort failed"); + } + tui.requestRender(); }; const handleChatEvent = (payload: unknown) => { if (!payload || typeof payload !== "object") return; const evt = payload as ChatEvent; - if (evt.sessionKey !== currentSession) return; - + if (evt.sessionKey !== currentSessionKey) return; if (evt.state === "delta") { - const text = extractText(evt.message); + const text = extractTextFromMessage(evt.message, { + includeThinking: showThinking, + }); if (!text) return; - if (!streamingMessageId || activeRunId !== evt.runId) { - streamingMessageId = messages.addAssistant(text, evt.runId); - activeRunId = evt.runId; - } else { - messages.updateAssistant(streamingMessageId, text); - } + chatLog.updateAssistant(text, evt.runId); setStatus("streaming"); } - if (evt.state === "final") { - const text = extractText(evt.message); - if (streamingMessageId && activeRunId === evt.runId) { - messages.updateAssistant(streamingMessageId, text || "(no output)"); - } else if (text) { - messages.addAssistant(text, evt.runId); - } - activeRunId = null; - streamingMessageId = null; + const text = extractTextFromMessage(evt.message, { + includeThinking: showThinking, + }); + chatLog.finalizeAssistant(text || "(no output)", evt.runId); + activeChatRunId = null; setStatus("idle"); } - if (evt.state === "aborted") { - messages.addSystem("run aborted"); - activeRunId = null; - streamingMessageId = null; + chatLog.addSystem("run aborted"); + activeChatRunId = null; setStatus("aborted"); } - if (evt.state === "error") { - messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`); - activeRunId = null; - streamingMessageId = null; + chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`); + activeChatRunId = null; setStatus("error"); } + tui.requestRender(); + }; + const handleAgentEvent = (payload: unknown) => { + if (!payload || typeof payload !== "object") return; + const evt = payload as AgentEvent; + if (!currentSessionId || evt.runId !== currentSessionId) return; + if (evt.stream === "tool") { + const data = evt.data ?? {}; + const phase = String(data.phase ?? ""); + const toolCallId = String(data.toolCallId ?? ""); + const toolName = String(data.name ?? "tool"); + if (!toolCallId) return; + if (phase === "start") { + chatLog.startTool(toolCallId, toolName, data.args); + } else if (phase === "update") { + chatLog.updateToolResult(toolCallId, data.partialResult, { + partial: true, + }); + } else if (phase === "result") { + chatLog.updateToolResult(toolCallId, data.result, { + isError: Boolean(data.isError), + }); + } + tui.requestRender(); + return; + } + if (evt.stream === "job") { + const state = typeof evt.data?.state === "string" ? evt.data.state : ""; + if (state === "started") setStatus("running"); + if (state === "done") setStatus("idle"); + if (state === "error") setStatus("error"); + tui.requestRender(); + } + }; + + const openModelSelector = async () => { + try { + const models = await client.listModels(); + if (models.length === 0) { + chatLog.addSystem("no models available"); + tui.requestRender(); + return; + } + const items = models.map((model) => ({ + value: `${model.provider}/${model.id}`, + label: `${model.provider}/${model.id}`, + description: model.name && model.name !== model.id ? model.name : "", + })); + const selector = createSelectList(items, 9); + selector.onSelect = (item) => { + void (async () => { + try { + await client.patchSession({ + key: currentSessionKey, + model: item.value, + }); + chatLog.addSystem(`model set to ${item.value}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`model set failed: ${String(err)}`); + } + closeOverlay(); + tui.requestRender(); + })(); + }; + selector.onCancel = () => { + closeOverlay(); + tui.requestRender(); + }; + openOverlay(selector); + tui.requestRender(); + } catch (err) { + chatLog.addSystem(`model list failed: ${String(err)}`); + tui.requestRender(); + } + }; + + const openSessionSelector = async () => { + try { + const result = await client.listSessions({ + includeGlobal: false, + includeUnknown: false, + }); + const items = result.sessions.map((session) => ({ + value: session.key, + label: session.displayName ?? session.key, + description: session.updatedAt + ? new Date(session.updatedAt).toLocaleString() + : "", + })); + const selector = createSelectList(items, 9); + selector.onSelect = (item) => { + void (async () => { + closeOverlay(); + await setSession(item.value); + tui.requestRender(); + })(); + }; + selector.onCancel = () => { + closeOverlay(); + tui.requestRender(); + }; + openOverlay(selector); + tui.requestRender(); + } catch (err) { + chatLog.addSystem(`sessions list failed: ${String(err)}`); + tui.requestRender(); + } + }; + + const openSettings = () => { + const items = [ + { + id: "deliver", + label: "Deliver replies", + currentValue: deliverDefault ? "on" : "off", + values: ["off", "on"], + }, + { + id: "tools", + label: "Tool output", + currentValue: toolsExpanded ? "expanded" : "collapsed", + values: ["collapsed", "expanded"], + }, + { + id: "thinking", + label: "Show thinking", + currentValue: showThinking ? "on" : "off", + values: ["off", "on"], + }, + ]; + const settings = createSettingsList( + items, + (id, value) => { + if (id === "deliver") { + deliverDefault = value === "on"; + updateFooter(); + } + if (id === "tools") { + toolsExpanded = value === "expanded"; + chatLog.setToolsExpanded(toolsExpanded); + } + if (id === "thinking") { + showThinking = value === "on"; + void loadHistory(); + } + tui.requestRender(); + }, + () => { + closeOverlay(); + tui.requestRender(); + }, + ); + openOverlay(settings); tui.requestRender(); }; const handleCommand = async (raw: string) => { - const [command, ...rest] = raw.slice(1).trim().split(/\s+/); - const arg = rest.join(" ").trim(); - switch (command) { - case "help": { - messages.addSystem("/help /session /abort /exit"); + const { name, args } = parseCommand(raw); + if (!name) return; + switch (name) { + case "help": + chatLog.addSystem(helpText()); break; - } - case "session": { - if (!arg) { - messages.addSystem("missing session key"); + case "status": + try { + const status = await client.getStatus(); + chatLog.addSystem( + typeof status === "string" + ? status + : JSON.stringify(status, null, 2), + ); + } catch (err) { + chatLog.addSystem(`status failed: ${String(err)}`); + } + break; + case "session": + if (!args) { + await openSessionSelector(); + } else { + await setSession(args); + } + break; + case "sessions": + await openSessionSelector(); + break; + case "model": + if (!args) { + await openModelSelector(); + } else { + try { + await client.patchSession({ + key: currentSessionKey, + model: args, + }); + chatLog.addSystem(`model set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`model set failed: ${String(err)}`); + } + } + break; + case "models": + await openModelSelector(); + break; + case "think": + if (!args) { + chatLog.addSystem("usage: /think "); break; } - currentSession = arg; - activeRunId = null; - streamingMessageId = null; - historyLoaded = false; - updateHeader(); - await loadHistory(); + try { + await client.patchSession({ + key: currentSessionKey, + thinkingLevel: args, + }); + chatLog.addSystem(`thinking set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`think failed: ${String(err)}`); + } break; - } - case "abort": { - if (!activeRunId) { - messages.addSystem("no active run"); + case "verbose": + if (!args) { + chatLog.addSystem("usage: /verbose "); break; } - await client.abortChat({ - sessionKey: currentSession, - runId: activeRunId, - }); + try { + await client.patchSession({ + key: currentSessionKey, + verboseLevel: args, + }); + chatLog.addSystem(`verbose set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`verbose failed: ${String(err)}`); + } break; - } - case "exit": { + case "activation": + if (!args) { + chatLog.addSystem("usage: /activation "); + break; + } + try { + await client.patchSession({ + key: currentSessionKey, + groupActivation: args === "always" ? "always" : "mention", + }); + chatLog.addSystem(`activation set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`activation failed: ${String(err)}`); + } + break; + case "deliver": + if (!args) { + chatLog.addSystem("usage: /deliver "); + break; + } + deliverDefault = args === "on"; + updateFooter(); + chatLog.addSystem(`deliver ${deliverDefault ? "on" : "off"}`); + break; + case "new": + case "reset": + try { + await client.resetSession(currentSessionKey); + chatLog.addSystem(`session ${currentSessionKey} reset`); + await loadHistory(); + } catch (err) { + chatLog.addSystem(`reset failed: ${String(err)}`); + } + break; + case "abort": + await abortActive(); + break; + case "settings": + openSettings(); + break; + case "exit": + case "quit": client.stop(); tui.stop(); process.exit(0); break; - } - case "quit": { - client.stop(); - tui.stop(); - process.exit(0); - break; - } default: - messages.addSystem(`unknown command: /${command}`); + chatLog.addSystem(`unknown command: /${name}`); break; } tui.requestRender(); @@ -277,63 +606,112 @@ export async function runTui(opts: TuiOptions) { const sendMessage = async (text: string) => { try { - messages.addUser(text); + chatLog.addUser(text); tui.requestRender(); setStatus("sending"); const { runId } = await client.sendChat({ - sessionKey: currentSession, + sessionKey: currentSessionKey, message: text, thinking: opts.thinking, - deliver: opts.deliver, + deliver: deliverDefault, timeoutMs: opts.timeoutMs, }); - activeRunId = runId; - streamingMessageId = null; + activeChatRunId = runId; setStatus("waiting"); } catch (err) { - messages.addSystem(`send failed: ${String(err)}`); + chatLog.addSystem(`send failed: ${String(err)}`); setStatus("error"); } tui.requestRender(); }; - input.onSubmit = (value) => { - const text = value.trim(); - input.setValue(""); - if (!text) return; - if (text.startsWith("/")) { - void handleCommand(text); + editor.setAutocompleteProvider( + new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()), + ); + editor.onSubmit = (text) => { + const value = text.trim(); + editor.setText(""); + if (!value) return; + if (value.startsWith("/")) { + void handleCommand(value); return; } - void sendMessage(text); + void sendMessage(value); + }; + + editor.onEscape = () => { + void abortActive(); + }; + editor.onCtrlC = () => { + const now = Date.now(); + if (editor.getText().trim().length > 0) { + editor.setText(""); + setStatus("cleared input"); + tui.requestRender(); + return; + } + if (now - lastCtrlCAt < 1000) { + client.stop(); + tui.stop(); + process.exit(0); + } + lastCtrlCAt = now; + setStatus("press ctrl+c again to exit"); + tui.requestRender(); + }; + editor.onCtrlD = () => { + client.stop(); + tui.stop(); + process.exit(0); + }; + editor.onCtrlO = () => { + toolsExpanded = !toolsExpanded; + chatLog.setToolsExpanded(toolsExpanded); + setStatus(toolsExpanded ? "tools expanded" : "tools collapsed"); + tui.requestRender(); + }; + editor.onCtrlL = () => { + void openModelSelector(); + }; + editor.onCtrlP = () => { + void openSessionSelector(); + }; + editor.onCtrlT = () => { + showThinking = !showThinking; + void loadHistory(); }; client.onEvent = (evt) => { if (evt.event === "chat") handleChatEvent(evt.payload); + if (evt.event === "agent") handleAgentEvent(evt.payload); }; client.onConnected = () => { + isConnected = true; setStatus("connected"); updateHeader(); if (!historyLoaded) { void loadHistory().then(() => { - messages.addSystem("gateway connected"); + chatLog.addSystem("gateway connected"); tui.requestRender(); }); } else { - messages.addSystem("gateway reconnected"); + chatLog.addSystem("gateway reconnected"); } + updateFooter(); tui.requestRender(); }; client.onDisconnected = (reason) => { - messages.addSystem(`gateway disconnected: ${reason || "closed"}`); + isConnected = false; + chatLog.addSystem(`gateway disconnected: ${reason || "closed"}`); setStatus("disconnected"); + updateFooter(); tui.requestRender(); }; client.onGap = (info) => { - messages.addSystem( + chatLog.addSystem( `event gap: expected ${info.expected}, got ${info.received}`, ); tui.requestRender(); @@ -341,7 +719,8 @@ export async function runTui(opts: TuiOptions) { updateHeader(); setStatus("connecting"); - messages.addSystem("connecting..."); + updateFooter(); + chatLog.addSystem("connecting..."); tui.start(); client.start(); }