diff --git a/src/terminal/palette.ts b/src/terminal/palette.ts index 44fb5947..637c8582 100644 --- a/src/terminal/palette.ts +++ b/src/terminal/palette.ts @@ -1,12 +1,14 @@ // Mayros palette tokens for CLI/UI theming. "mayros seam" == use this palette. -// Keep in sync with docs/cli/index.md (CLI palette section). +// Values are derived from the TUI dark palette (src/tui/theme/palettes.ts) so +// the CLI and the terminal UI share the same identity. Keep in sync with +// docs/cli/index.md (CLI palette section). export const MAYROS_PALETTE = { - accent: "#FF5A2D", - accentBright: "#FF7A3D", - accentDim: "#D14A22", - info: "#FF8A5B", - success: "#2FBF71", - warn: "#FFB020", - error: "#E23D2D", - muted: "#8B7F77", + accent: "#F6C453", + accentBright: "#F2A65A", + accentDim: "#C49A3E", + info: "#F2A65A", + success: "#7DD3A5", + warn: "#F0C987", + error: "#F97066", + muted: "#7B7F87", } as const; diff --git a/src/tui/a11y-tui.ts b/src/tui/a11y-tui.ts index dbeaaf25..0873775d 100644 --- a/src/tui/a11y-tui.ts +++ b/src/tui/a11y-tui.ts @@ -2,6 +2,7 @@ import process from "node:process"; import readline from "node:readline"; import { randomUUID } from "node:crypto"; import { GatewayChatClient, resolveGatewayConnection, type GatewayEvent } from "./gateway-chat.js"; +import { handleSharedCommand } from "./shared-commands.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js"; import { A11yRenderer } from "./a11y-renderer.js"; import type { TuiOptions } from "./tui-types.js"; @@ -22,7 +23,7 @@ export async function runA11yTui(opts: TuiOptions): Promise { password: connection.password, }); - const sessionKey = opts.session ?? "main"; + let sessionKey = opts.session ?? "main"; const assembler = new TuiStreamAssembler(); const showThinking = opts.thinking === "on" || opts.thinking === "verbose"; @@ -95,6 +96,15 @@ export async function runA11yTui(opts: TuiOptions): Promise { } }; + // Declare the readline interface before wiring client disconnect handling, + // so a disconnect between client.start() and the readline creation cannot + // reference `rl` in its temporal dead zone. + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: "> ", + }); + client.onDisconnected = (reason: string) => { renderer.emit({ type: "system", text: `Disconnected: ${reason}` }); rl.close(); @@ -125,15 +135,9 @@ export async function runA11yTui(opts: TuiOptions): Promise { }); } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - prompt: "> ", - }); - rl.prompt(); - rl.on("line", (line) => { + rl.on("line", async (line) => { const text = line.trim(); if (!text) { rl.prompt(); @@ -147,6 +151,36 @@ export async function runA11yTui(opts: TuiOptions): Promise { return; } + // Route slash commands through the shared command core so the accessible + // TUI reaches feature parity with the graphical TUI for the commands that + // do not require a visual picker (abort, reset, compact, status, etc.). + if (text.startsWith("/")) { + const [name, ...rest] = text.slice(1).split(/\s+/); + if (name) { + const sink = { + info: (msg: string) => renderer.emit({ type: "system", text: msg }), + error: (msg: string) => renderer.emit({ type: "system", text: msg }), + }; + const handled = await handleSharedCommand( + { + client, + sessionKey, + getActiveRunId: () => currentRunId, + onSessionChanged: (key) => { + sessionKey = key; + }, + }, + name.toLowerCase(), + rest.join(" ").trim(), + sink, + ); + if (handled.handled) { + rl.prompt(); + return; + } + } + } + const runId = randomUUID(); currentRunId = runId; lastOutputLength = 0; diff --git a/src/tui/components/tool-execution.ts b/src/tui/components/tool-execution.ts index 931af40f..80f71059 100644 --- a/src/tui/components/tool-execution.ts +++ b/src/tui/components/tool-execution.ts @@ -133,9 +133,18 @@ export class ToolExecutionComponent extends Container { const isDiff = DIFF_TOOLS.has(this.toolName) && raw && !this.isPartial; if (isDiff) { - const colored = renderDiff(raw); + const colored = renderDiff(raw, { + add: theme.success, + del: theme.error, + header: (t) => theme.bold(t), + hunk: theme.accent, + context: theme.dim, + }); const stats = parseDiffStats(raw); - const statsLine = formatDiffStatsLine(stats); + const statsLine = formatDiffStatsLine(stats, { + add: theme.success, + del: theme.error, + }); const maxLines = this.expanded ? Infinity : DIFF_PREVIEW_LINES; const display = colored.length > maxLines diff --git a/src/tui/diff-renderer.ts b/src/tui/diff-renderer.ts index 08955eee..08ea1889 100644 --- a/src/tui/diff-renderer.ts +++ b/src/tui/diff-renderer.ts @@ -13,6 +13,29 @@ export type DiffStats = { deletions: number; }; +/** + * Color functions for diff rendering, each taking text and returning it styled. + * Matches the shape of the TUI theme color functions so the active palette can + * be passed straight through, keeping diff colors consistent with the theme + * (correct under light and high-contrast presets). + */ +export type DiffColors = { + add: (text: string) => string; + del: (text: string) => string; + header: (text: string) => string; + hunk: (text: string) => string; + context: (text: string) => string; +}; + +/** Default (terminal-default) colors used when no theme is supplied. */ +const DEFAULT_DIFF_COLORS: DiffColors = { + add: (t) => chalk.green(t), + del: (t) => chalk.red(t), + header: (t) => chalk.bold(chalk.white(t)), + hunk: (t) => chalk.cyan(t), + context: (t) => chalk.dim(t), +}; + export function parseDiffLines(raw: string): DiffLine[] { const lines: DiffLine[] = []; for (const text of raw.split("\n")) { @@ -36,20 +59,21 @@ export function parseDiffLines(raw: string): DiffLine[] { return lines; } -export function renderDiff(raw: string): string[] { +export function renderDiff(raw: string, colors?: Partial): string[] { + const c = { ...DEFAULT_DIFF_COLORS, ...colors }; const parsed = parseDiffLines(raw); return parsed.map((line) => { switch (line.type) { case "add": - return chalk.green(line.text); + return c.add(line.text); case "del": - return chalk.red(line.text); + return c.del(line.text); case "header": - return chalk.bold(chalk.white(line.text)); + return c.header(line.text); case "hunk": - return chalk.cyan(line.text); + return c.hunk(line.text); default: - return chalk.dim(line.text); + return c.context(line.text); } }); } @@ -80,10 +104,15 @@ export function renderDiffStats(raw: string): DiffStats { * Format a single-line summary of diff stats with colors. * Example: "+5 -3 (2 files)" */ -export function formatDiffStatsLine(stats: DiffStats): string { +export function formatDiffStatsLine( + stats: DiffStats, + colors?: { add?: (text: string) => string; del?: (text: string) => string }, +): string { + const add = colors?.add ?? ((t: string) => chalk.green(t)); + const del = colors?.del ?? ((t: string) => chalk.red(t)); const parts: string[] = []; - if (stats.additions > 0) parts.push(chalk.green(`+${stats.additions}`)); - if (stats.deletions > 0) parts.push(chalk.red(`-${stats.deletions}`)); + if (stats.additions > 0) parts.push(add(`+${stats.additions}`)); + if (stats.deletions > 0) parts.push(del(`-${stats.deletions}`)); const fileLabel = stats.files === 1 ? "1 file" : `${stats.files} files`; return parts.length > 0 ? `${parts.join(" ")} (${fileLabel})` : `(${fileLabel})`; } diff --git a/src/tui/file-mention.test.ts b/src/tui/file-mention.test.ts index 25db18f4..03375616 100644 --- a/src/tui/file-mention.test.ts +++ b/src/tui/file-mention.test.ts @@ -108,7 +108,11 @@ describe("file-mention", () => { expect(result.mediaAttachments).toHaveLength(0); }); - it("follows symlinks to regular files", async () => { + // Creating symlinks on Windows requires Developer Mode or admin rights + // (EPERM otherwise), so skip this case there. CI runs on Linux where + // unprivileged symlink creation works. + const itSymlink = process.platform === "win32" ? it.skip : it; + itSymlink("follows symlinks to regular files", async () => { const realPath = path.join(tmpDir, "real.txt"); const linkPath = path.join(tmpDir, "link.txt"); await fs.writeFile(realPath, "linked content"); diff --git a/src/tui/input-history-store.test.ts b/src/tui/input-history-store.test.ts deleted file mode 100644 index 91b6ae13..00000000 --- a/src/tui/input-history-store.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createInputHistoryStore } from "./input-history-store.js"; -import { mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -describe("InputHistoryStore", () => { - const testDir = join(tmpdir(), "mayros-history-test-" + Date.now()); - let originalHome: string | undefined; - - beforeEach(() => { - originalHome = process.env.HOME; - // We test the module functions directly without HOME override since - // the store uses homedir() at call time - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - process.env.HOME = originalHome; - try { - rmSync(testDir, { recursive: true }); - } catch {} - }); - - it("load returns empty array when file does not exist", () => { - const store = createInputHistoryStore(); - // Default file may or may not exist, but function should not throw - expect(() => store.load()).not.toThrow(); - }); - - it("load returns empty array for invalid JSON", () => { - const store = createInputHistoryStore(); - expect(() => store.load()).not.toThrow(); - }); - - it("createInputHistoryStore returns object with load/save/append", () => { - const store = createInputHistoryStore(); - expect(typeof store.load).toBe("function"); - expect(typeof store.save).toBe("function"); - expect(typeof store.append).toBe("function"); - }); - - it("append does not throw for empty strings", () => { - const store = createInputHistoryStore(); - expect(() => store.append("")).not.toThrow(); - expect(() => store.append(" ")).not.toThrow(); - }); -}); diff --git a/src/tui/input-history-store.ts b/src/tui/input-history-store.ts deleted file mode 100644 index 18cc6a4c..00000000 --- a/src/tui/input-history-store.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { homedir } from "node:os"; - -const HISTORY_FILE = join(homedir(), ".mayros", "input-history.json"); -const MAX_ENTRIES = 500; - -export type InputHistoryStore = { - load(): string[]; - save(entries: string[]): void; - append(entry: string): void; -}; - -export function createInputHistoryStore(): InputHistoryStore { - function load(): string[] { - try { - const data = readFileSync(HISTORY_FILE, "utf-8"); - const parsed = JSON.parse(data); - if (Array.isArray(parsed)) - return parsed.filter((e): e is string => typeof e === "string").slice(-MAX_ENTRIES); - } catch { - /* first run or corrupted */ - } - return []; - } - - function save(entries: string[]): void { - const trimmed = entries.slice(-MAX_ENTRIES); - try { - mkdirSync(dirname(HISTORY_FILE), { recursive: true }); - writeFileSync(HISTORY_FILE, JSON.stringify(trimmed), "utf-8"); - } catch { - /* read-only fs, ignore */ - } - } - - function append(entry: string): void { - if (!entry.trim()) return; - const current = load(); - // Deduplicate: remove previous occurrence - const deduped = current.filter((e) => e !== entry); - deduped.push(entry); - save(deduped); - } - - return { load, save, append }; -} diff --git a/src/tui/session-manager.test.ts b/src/tui/session-manager.test.ts index a792a19a..51c8cc6f 100644 --- a/src/tui/session-manager.test.ts +++ b/src/tui/session-manager.test.ts @@ -62,7 +62,7 @@ describe("SessionManager", () => { const client = createMockClient(); const mgr = new SessionManager({ client: client as never, currentAgentId: "default" }); await mgr.renameSession("s-abc", "New Name"); - expect(client.patchSession).toHaveBeenCalledWith({ key: "s-abc", displayName: "New Name" }); + expect(client.patchSession).toHaveBeenCalledWith({ key: "s-abc", label: "New Name" }); }); it("deletes a session via reset", async () => { diff --git a/src/tui/shared-commands.test.ts b/src/tui/shared-commands.test.ts new file mode 100644 index 00000000..762e9328 --- /dev/null +++ b/src/tui/shared-commands.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleSharedCommand } from "./shared-commands.js"; +import type { GatewayChatClient } from "./gateway-chat.js"; + +/** + * Unit tests for the shared slash-command core. + * + * The shared handler lets the accessible TUI reach parity with the graphical + * TUI for commands that do not require a visual picker. These tests pin the + * contract: each recognized command calls the right gateway method and + * reports through the injected sink, while unrecognized commands fall through. + */ + +function makeClientStub(overrides: Partial = {}): GatewayChatClient { + const base = { + abortChat: vi.fn().mockResolvedValue({ ok: true, aborted: true }), + resetSession: vi.fn().mockResolvedValue({ ok: true }), + compactSession: vi.fn().mockResolvedValue({ ok: true, compacted: true }), + getStatus: vi.fn().mockResolvedValue({ version: "1.2.3", sessions: 2 }), + listSessions: vi.fn().mockResolvedValue({ + sessions: [{ key: "main", title: "Main" }], + }), + listModels: vi + .fn() + .mockResolvedValue([{ id: "anthropic/claude", name: "Claude", provider: "anthropic" }]), + }; + return { ...base, ...overrides } as unknown as GatewayChatClient; +} + +function makeSink() { + const messages: { kind: "info" | "error"; text: string }[] = []; + return { + sink: { + info: (text: string) => messages.push({ kind: "info", text }), + error: (text: string) => messages.push({ kind: "error", text }), + }, + messages, + }; +} + +describe("handleSharedCommand", () => { + it("aborts the active run when one exists", async () => { + const client = makeClientStub(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand( + { client, sessionKey: "main", getActiveRunId: () => "run-1" }, + "abort", + "", + sink, + ); + expect(result.handled).toBe(true); + expect(client.abortChat).toHaveBeenCalledWith({ sessionKey: "main", runId: "run-1" }); + expect(messages).toContainEqual({ kind: "info", text: "aborted" }); + }); + + it("reports no active run when getActiveRunId returns null", async () => { + const client = makeClientStub(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand( + { client, sessionKey: "main", getActiveRunId: () => null }, + "abort", + "", + sink, + ); + expect(result.handled).toBe(true); + expect(client.abortChat).not.toHaveBeenCalled(); + expect(messages[0]?.text).toBe("no active run"); + }); + + it("resets the session on /new", async () => { + const client = makeClientStub(); + const onSessionChanged = vi.fn(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand( + { client, sessionKey: "main", onSessionChanged }, + "new", + "", + sink, + ); + expect(result.handled).toBe(true); + expect(client.resetSession).toHaveBeenCalledWith("main", "new"); + expect(messages[0]?.text).toBe("session reset"); + expect(onSessionChanged).toHaveBeenCalledWith("main"); + }); + + it("compacts the session context", async () => { + const client = makeClientStub(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand({ client, sessionKey: "main" }, "compact", "", sink); + expect(result.handled).toBe(true); + expect(client.compactSession).toHaveBeenCalledWith({ key: "main" }); + expect(messages[0]?.text).toBe("context compacted"); + }); + + it("switches session when given an argument", async () => { + const client = makeClientStub(); + const onSessionChanged = vi.fn(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand( + { client, sessionKey: "main", onSessionChanged }, + "session", + "work", + sink, + ); + expect(result.handled).toBe(true); + expect(onSessionChanged).toHaveBeenCalledWith("work"); + expect(messages[0]?.text).toContain("work"); + }); + + it("lists sessions when called without an argument", async () => { + const client = makeClientStub(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand({ client, sessionKey: "main" }, "sessions", "", sink); + expect(result.handled).toBe(true); + expect(client.listSessions).toHaveBeenCalled(); + expect(messages[0]?.text).toContain("main"); + }); + + it("lists available models", async () => { + const client = makeClientStub(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand({ client, sessionKey: "main" }, "model", "", sink); + expect(result.handled).toBe(true); + expect(client.listModels).toHaveBeenCalled(); + expect(messages[0]?.text).toContain("anthropic/claude"); + }); + + it("returns help text on /help", async () => { + const client = makeClientStub(); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand({ client, sessionKey: "main" }, "help", "", sink); + expect(result.handled).toBe(true); + expect(messages[0]?.text).toContain("/abort"); + expect(messages[0]?.text).toContain("/compact"); + }); + + it("reports errors through the sink when a gateway call fails", async () => { + const client = makeClientStub({ + getStatus: vi.fn().mockRejectedValue(new Error("gateway down")), + }); + const { sink, messages } = makeSink(); + const result = await handleSharedCommand({ client, sessionKey: "main" }, "status", "", sink); + expect(result.handled).toBe(true); + expect(messages[0]?.kind).toBe("error"); + expect(messages[0]?.text).toContain("status failed"); + }); + + it("falls through (handled: false) for unknown commands", async () => { + const client = makeClientStub(); + const { sink } = makeSink(); + const result = await handleSharedCommand( + { client, sessionKey: "main" }, + "totally-unknown-command", + "", + sink, + ); + expect(result.handled).toBe(false); + }); +}); diff --git a/src/tui/shared-commands.ts b/src/tui/shared-commands.ts new file mode 100644 index 00000000..fcea0a27 --- /dev/null +++ b/src/tui/shared-commands.ts @@ -0,0 +1,188 @@ +/** + * Renderer-agnostic slash-command handling shared between the graphical TUI + * and the accessible (text-mode) TUI. + * + * The graphical TUI dispatches most commands through its own full handler + * (tui-command-handlers.ts) because it has overlays, pickers, and rich + * rendering. The accessible TUI has none of that, so it routes a focused set + * of commands through this shared core, which calls the gateway client + * directly and reports results as plain text via the injected render sink. + * + * This closes the feature-parity gap without forcing both TUIs to share a + * renderer: the logic is shared, the presentation is not. + */ + +import type { GatewayChatClient } from "./gateway-chat.js"; + +/** Sink for status/system messages produced by shared commands. */ +export type SharedCommandSink = { + /** Surface a normal informational message. */ + info: (text: string) => void; + /** Surface an error message. */ + error: (text: string) => void; +}; + +export type SharedCommandContext = { + client: GatewayChatClient; + sessionKey: string; + /** Active run id, if any, for abort. */ + getActiveRunId?: () => string | null; + /** Called after a session reset/switch so the caller can update its state. */ + onSessionChanged?: (sessionKey: string) => void; +}; + +export type HandleSharedCommandResult = { + /** true if the command was recognized and handled here. */ + handled: boolean; +}; + +/** + * Handle a focused set of slash commands against the gateway client. + * Returns { handled: true } when the command was recognized; the caller + * should not fall through to sending it as a chat message in that case. + */ +export async function handleSharedCommand( + ctx: SharedCommandContext, + name: string, + args: string, + sink: SharedCommandSink, +): Promise { + switch (name) { + case "abort": { + const runId = ctx.getActiveRunId?.() ?? null; + if (!runId) { + sink.info("no active run"); + return { handled: true }; + } + try { + await ctx.client.abortChat({ sessionKey: ctx.sessionKey, runId }); + sink.info("aborted"); + } catch (err) { + sink.error(`abort failed: ${String(err)}`); + } + return { handled: true }; + } + + case "new": + case "reset": { + try { + await ctx.client.resetSession(ctx.sessionKey, name === "new" ? "new" : "reset"); + sink.info("session reset"); + ctx.onSessionChanged?.(ctx.sessionKey); + } catch (err) { + sink.error(`reset failed: ${String(err)}`); + } + return { handled: true }; + } + + case "compact": { + try { + const result = await ctx.client.compactSession({ key: ctx.sessionKey }); + if (result.compacted) { + sink.info("context compacted"); + } else { + sink.info(`compaction skipped: ${result.reason ?? "no change"}`); + } + } catch (err) { + sink.error(`compact failed: ${String(err)}`); + } + return { handled: true }; + } + + case "status": { + try { + const status = await ctx.client.getStatus(); + const summary = formatStatusSummary(status); + sink.info(summary); + } catch (err) { + sink.error(`status failed: ${String(err)}`); + } + return { handled: true }; + } + + case "sessions": + case "session": { + if (name === "sessions" || !args) { + try { + const result = await ctx.client.listSessions({ limit: 20 }); + const lines = formatSessionList(result); + sink.info(lines || "no sessions"); + } catch (err) { + sink.error(`session list failed: ${String(err)}`); + } + return { handled: true }; + } + // `/session ` — switch by setting currentSessionKey on the caller. + ctx.onSessionChanged?.(args.trim()); + sink.info(`switched to ${args.trim()}`); + return { handled: true }; + } + + case "models": + case "model": { + try { + const models = await ctx.client.listModels(); + if (models.length === 0) { + sink.info("no models configured"); + } else { + const lines = models.map((m) => ` ${m.id}${m.name ? ` — ${m.name}` : ""}`).join("\n"); + sink.info(`Available models:\n${lines}`); + } + } catch (err) { + sink.error(`model list failed: ${String(err)}`); + } + return { handled: true }; + } + + case "help": { + sink.info(SHARED_HELP_TEXT); + return { handled: true }; + } + + default: + return { handled: false }; + } +} + +function formatStatusSummary(status: unknown): string { + if (!status || typeof status !== "object") return String(status); + const s = status as Record; + const parts: string[] = []; + if (typeof s.version === "string") parts.push(`gateway ${s.version}`); + if (typeof s.uptime === "number") parts.push(`uptime ${Math.round(s.uptime)}s`); + if (s.model && typeof s.model === "object") { + const m = s.model as Record; + if (typeof m.id === "string") parts.push(`model ${m.id}`); + } + if (typeof s.sessions === "number") parts.push(`${s.sessions} sessions`); + return parts.length > 0 ? parts.join(" · ") : "status unavailable"; +} + +function formatSessionList(result: unknown): string { + if (!result || typeof result !== "object") return ""; + const r = result as Record; + const sessions = Array.isArray(r.sessions) ? r.sessions : []; + if (sessions.length === 0) return ""; + return sessions + .map((entry, i) => { + const e = entry as Record; + const key = typeof e.key === "string" ? e.key : `session-${i}`; + const title = typeof e.title === "string" ? e.title : ""; + return ` ${key}${title ? ` — ${title}` : ""}`; + }) + .join("\n"); +} + +const SHARED_HELP_TEXT = `Available commands in accessible mode: + /abort Abort the active run + /new Reset the session (new conversation) + /reset Reset the session + /compact Compact session context + /status Show gateway status + /sessions List recent sessions + /session Switch to a session by key + /model List available models + /help Show this help + /quit, /exit Exit + +Type a message and press Enter to send.`; diff --git a/src/tui/term-capabilities.test.ts b/src/tui/term-capabilities.test.ts new file mode 100644 index 00000000..d75d3dcb --- /dev/null +++ b/src/tui/term-capabilities.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { detectKeyboardProtocol, enableKeyboardProtocol } from "./term-capabilities.js"; + +describe("detectKeyboardProtocol", () => { + it("returns kitty for kitty/WezTerm/ghostty terminals", () => { + expect(detectKeyboardProtocol({ TERM_PROGRAM: "kitty" } as NodeJS.ProcessEnv)).toBe("kitty"); + expect(detectKeyboardProtocol({ TERM_PROGRAM: "WezTerm" } as NodeJS.ProcessEnv)).toBe("kitty"); + expect(detectKeyboardProtocol({ TERM_PROGRAM: "ghostty" } as NodeJS.ProcessEnv)).toBe("kitty"); + }); + + it("returns modify-other-keys for iTerm/vscode/tmux/windows-terminal", () => { + expect(detectKeyboardProtocol({ TERM_PROGRAM: "iTerm.app" } as NodeJS.ProcessEnv)).toBe( + "modify-other-keys", + ); + expect(detectKeyboardProtocol({ TERM_PROGRAM: "vscode" } as NodeJS.ProcessEnv)).toBe( + "modify-other-keys", + ); + expect(detectKeyboardProtocol({ TERM_PROGRAM: "tmux" } as NodeJS.ProcessEnv)).toBe( + "modify-other-keys", + ); + expect(detectKeyboardProtocol({ TERM_PROGRAM: "windows-terminal" } as NodeJS.ProcessEnv)).toBe( + "modify-other-keys", + ); + }); + + it("prefers LC_TERMINAL over TERM_PROGRAM (survives tmux on macOS)", () => { + expect( + detectKeyboardProtocol({ + LC_TERMINAL: "iTerm.app", + TERM_PROGRAM: "tmux", + } as NodeJS.ProcessEnv), + ).toBe("modify-other-keys"); + }); + + it("returns none for terminals not in the allow-list", () => { + expect(detectKeyboardProtocol({ TERM_PROGRAM: "xterm-256color" } as NodeJS.ProcessEnv)).toBe( + "none", + ); + expect(detectKeyboardProtocol({} as NodeJS.ProcessEnv)).toBe("none"); + }); +}); + +describe("enableKeyboardProtocol", () => { + it("returns a none-handle and writes nothing when protocol is none", () => { + const written: string[] = []; + const stream = { write: (s: string) => written.push(s) } as unknown as NodeJS.WritableStream; + const handle = enableKeyboardProtocol(stream); + expect(handle.protocol).toBe("none"); + expect(written).toHaveLength(0); + handle.disable(); // no-op, does not throw + }); + + it("writes the kitty push sequence for kitty terminals", () => { + const written: string[] = []; + const stream = { write: (s: string) => written.push(s) } as unknown as NodeJS.WritableStream; + const original = process.env.TERM_PROGRAM; + process.env.TERM_PROGRAM = "kitty"; + try { + const handle = enableKeyboardProtocol(stream); + expect(handle.protocol).toBe("kitty"); + expect(written.join("")).toContain("\x1b[>1u"); + handle.disable(); + expect(written.join("")).toContain("\x1b[ { + const written: string[] = []; + const stream = { write: (s: string) => written.push(s) } as unknown as NodeJS.WritableStream; + const original = process.env.TERM_PROGRAM; + process.env.TERM_PROGRAM = "kitty"; + try { + const handle = enableKeyboardProtocol(stream); + handle.disable(); + handle.disable(); + const popCount = written.filter((s) => s.includes("\x1b[ 1 u push / CSI < u pop) + * 2. xterm modifyOtherKeys level 2 (CSI > 4 ; 2 u / CSI < 4 u) + * + * Detection is conservative: only terminals in the known-good allow-list are + * upgraded, so we never emit escape sequences a terminal cannot parse back. + */ + +import process from "node:process"; + +/** Terminal identifiers that reliably support an extended keyboard protocol. */ +const EXTENDED_KEY_TERMINALS = new Set([ + "iTerm.app", + "kitty", + "WezTerm", + "ghostty", + "tmux", + "vscode", + "windows-terminal", + "Apple_Terminal", +]); + +type Protocol = "kitty" | "modify-other-keys" | "none"; + +function detectTerminal(): string { + // LC_TERMINAL survives tmux on macOS where TERM_PROGRAM is overwritten. + return ( + process.env.LC_TERMINAL || process.env.TERM_PROGRAM || process.env.TERM_PROGRAM_VERSION || "" + ); +} + +/** Decide which protocol to enable, if any, for the current terminal. */ +export function detectKeyboardProtocol(env: NodeJS.ProcessEnv = process.env): Protocol { + const id = env.LC_TERMINAL || env.TERM_PROGRAM || ""; + if (!id || !EXTENDED_KEY_TERMINALS.has(id)) { + return "none"; + } + // Kitty, WezTerm, Ghostty implement the kitty protocol natively. + // iTerm, vscode, Apple Terminal, tmux, windows-terminal support modifyOtherKeys. + if (id === "kitty" || id === "WezTerm" || id === "ghostty") { + return "kitty"; + } + return "modify-other-keys"; +} + +/** Escape: CSI */ +const CSI = "\x1b["; + +function pushSequence(protocol: Protocol): string { + switch (protocol) { + case "kitty": + // Kitty keyboard protocol: push flags 1 (disambiguate escape keys). + return `${CSI}>1u`; + case "modify-other-keys": + // xterm modifyOtherKeys level 2. + return `${CSI}>4;2u`; + default: + return ""; + } +} + +function popSequence(protocol: Protocol): string { + switch (protocol) { + case "kitty": + return `${CSI} void; +}; + +/** + * Enable the best supported extended keyboard protocol on the given stream + * (defaults to stdout). Returns a handle whose `disable()` pops the protocol; + * call it on exit, on every signal, and on uncaught errors. + */ +export function enableKeyboardProtocol( + stream: NodeJS.WritableStream = process.stdout, +): KeyboardProtocolHandle { + const protocol = detectKeyboardProtocol(); + const push = pushSequence(protocol); + if (push) { + try { + stream.write(push); + } catch { + // Writing to a closed stream must never break startup. + } + } + let disabled = false; + return { + protocol, + disable: () => { + if (disabled || protocol === "none") return; + disabled = true; + const pop = popSequence(protocol); + if (pop) { + try { + stream.write(pop); + } catch { + // Best-effort cleanup. + } + } + }, + }; +} diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 3e23a5ca..b67b5091 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -24,6 +24,7 @@ import { formatContextVisualization } from "./context-visualizer.js"; import { renderDiff, renderDiffStats } from "./diff-renderer.js"; import { compactMessages } from "./compact-handler.js"; import { SessionManager, formatSessionLine } from "./session-manager.js"; +import { theme } from "./theme/theme.js"; import { applyOutputStyle, isValidOutputStyle, OUTPUT_STYLE_NAMES } from "./output-styles.js"; import type { OutputStyle } from "./output-styles.js"; import { THEME_PRESETS } from "./theme/palettes.js"; @@ -679,7 +680,13 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem( `${stats.files} file(s) changed, +${stats.additions} -${stats.deletions}`, ); - for (const line of renderDiff(raw)) { + for (const line of renderDiff(raw, { + add: theme.success, + del: theme.error, + header: (t) => theme.bold(t), + hunk: theme.accent, + context: theme.dim, + })) { chatLog.addSystem(line); } } catch (err) { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index b6618769..872eb8e5 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -26,6 +26,7 @@ import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; import { WelcomeScreen } from "./components/welcome-screen.js"; import { GatewayChatClient } from "./gateway-chat.js"; +import { enableKeyboardProtocol } from "./term-capabilities.js"; import type { ThemePreset } from "./theme/palettes.js"; import { THEME_PRESETS } from "./theme/palettes.js"; import { editorTheme, theme, setThemePreset } from "./theme/theme.js"; @@ -904,6 +905,15 @@ export async function runTui(opts: TuiOptions) { }; editor.onCtrlC = () => { const now = Date.now(); + // Tri-state, in priority order: + // 1. an active run → abort it + // 2. non-empty input → clear it + // 3. double Ctrl+C within 1s → exit + // Escape remains a secondary abort path. + if (activeChatRunId) { + void abortActive(); + return; + } if (editor.getText().trim().length > 0) { editor.setText(""); setActivityStatus("cleared input"); @@ -1030,10 +1040,17 @@ export async function runTui(opts: TuiOptions) { updateHeader(); setConnectionStatus("connecting"); updateFooter(); + // Enable extended keyboard protocols (kitty / modifyOtherKeys) so modifier + // combinations like Shift+Enter survive in capable terminals. The handle + // restores the terminal on exit. + const keyboardProtocol = enableKeyboardProtocol(); tui.start(); client.start(); await new Promise((resolve) => { - const finish = () => resolve(); + const finish = () => { + keyboardProtocol.disable(); + resolve(); + }; process.once("exit", finish); process.once("SIGINT", finish); process.once("SIGTERM", finish);