From b9ecbc6cb4d1fa2cdadd1f01e68450dade946057 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:04:19 +0200 Subject: [PATCH 1/8] fix(tui): resolve readline temporal-dead-zone in accessible mode The accessible TUI created its readline interface after wiring the gateway client's disconnect handler and after client.start(). If the client disconnected in that window, the handler referenced the readline binding before its initialization, throwing a ReferenceError. Move the readline creation before the disconnect wiring so the reference is always initialized. --- src/tui/a11y-tui.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/tui/a11y-tui.ts b/src/tui/a11y-tui.ts index dbeaaf25..c7e16879 100644 --- a/src/tui/a11y-tui.ts +++ b/src/tui/a11y-tui.ts @@ -95,6 +95,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,12 +134,6 @@ export async function runA11yTui(opts: TuiOptions): Promise { }); } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - prompt: "> ", - }); - rl.prompt(); rl.on("line", (line) => { From 4e38423e55799cfc2e4ed09407ef118f80bc81dd Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:06:07 +0200 Subject: [PATCH 2/8] feat(tui): make Ctrl+C abort the active run Ctrl+C now follows a three-state priority: abort an active run, clear non-empty input, or exit on a second press within one second. Escape remains a secondary abort path. This unblocks users of modal editors (where Escape is consumed to switch modes) and aligns the interrupt behavior with terminal conventions. --- src/tui/tui.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tui/tui.ts b/src/tui/tui.ts index b6618769..9269904d 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -904,6 +904,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"); From d0be2b5f7f88a8acfb04dc01680e10de8580f366 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:11:18 +0200 Subject: [PATCH 3/8] feat(tui): share slash-command handling between graphical and accessible modes The accessible TUI previously supported only /quit and /exit. Introduce a renderer-agnostic shared command core (shared-commands.ts) that calls the gateway client directly and reports results through an injected text sink. The accessible TUI now routes /abort, /new, /reset, /compact, /status, /sessions, /session , /model, and /help through this core, reaching feature parity with the graphical TUI for every command that does not require a visual picker. The graphical TUI continues to use its full handler for overlay-based commands. Both TUIs share logic (the gateway calls and command parsing) without sharing presentation. --- src/tui/a11y-tui.ts | 35 +++++- src/tui/shared-commands.test.ts | 159 +++++++++++++++++++++++++++ src/tui/shared-commands.ts | 188 ++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 src/tui/shared-commands.test.ts create mode 100644 src/tui/shared-commands.ts diff --git a/src/tui/a11y-tui.ts b/src/tui/a11y-tui.ts index c7e16879..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"; @@ -136,7 +137,7 @@ export async function runA11yTui(opts: TuiOptions): Promise { rl.prompt(); - rl.on("line", (line) => { + rl.on("line", async (line) => { const text = line.trim(); if (!text) { rl.prompt(); @@ -150,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/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.`; From fe91c390cf68d43f36d85d3bb17bedab9486288e Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:15:00 +0200 Subject: [PATCH 4/8] fix(tui): render diffs with theme colors The diff renderer hardcoded chalk.green/red/cyan/white, so added and removed lines were hard to read under light or high-contrast themes. Thread optional DiffColors through renderDiff and formatDiffStatsLine; when the TUI calls them it passes the active theme's success/error/ accent/dim functions, keeping diff colors consistent across every theme preset. Callers without a theme fall back to the previous defaults. --- src/tui/components/tool-execution.ts | 13 ++++++-- src/tui/diff-renderer.ts | 47 ++++++++++++++++++++++------ src/tui/tui-command-handlers.ts | 9 +++++- 3 files changed, 57 insertions(+), 12 deletions(-) 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/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) { From 6187fe5c1692762e3c514fdfa5f626f7448ca2d0 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:19:53 +0200 Subject: [PATCH 5/8] refactor(terminal): align CLI palette with the TUI dark palette The CLI and the terminal UI used different accent colors (orange in the CLI, gold in the TUI), so running `mayros` then `mayros code` showed two different identities. Derive the CLI palette tokens from the TUI dark palette so both surfaces share accent, success, warn, and error values. The CLI's accentBright maps to the TUI accentSoft; accentDim is a darkened accent; info reuses accentSoft. The visual identity is now consistent across every Mayros surface. --- src/terminal/palette.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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; From e3f92a6f3bd46d99607b472ddb77e6b191c4e74e Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:22:31 +0200 Subject: [PATCH 6/8] feat(tui): enable extended keyboard protocols where supported Add term-capabilities.ts to detect and manage extended keyboard protocols. Capable terminals (kitty, WezTerm, ghostty get the kitty keyboard protocol; iTerm, vscode, tmux, windows-terminal, Apple Terminal get xterm modifyOtherKeys level 2) are upgraded at TUI startup so modifier combinations like Shift+Enter and Ctrl+Shift survive instead of being collapsed by plain raw-mode stdin. The protocol is popped on exit/SIGINT/SIGTERM so the terminal is restored for the shell. Detection is conservative: terminals outside the allow-list get no upgrade, so escape sequences are never emitted to a terminal that cannot parse them back. --- src/tui/term-capabilities.test.ts | 86 +++++++++++++++++++++ src/tui/term-capabilities.ts | 122 ++++++++++++++++++++++++++++++ src/tui/tui.ts | 10 ++- 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/tui/term-capabilities.test.ts create mode 100644 src/tui/term-capabilities.ts 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.ts b/src/tui/tui.ts index 9269904d..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"; @@ -1039,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); From 7269ebe112c4aeabe72de2441f69bcc53dd976b2 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:23:22 +0200 Subject: [PATCH 7/8] chore(tui): remove unused input-history store The input-history store was never wired into the TUI main loop; it was dead code that looked like a feature. Remove it and its test to reduce confusion. Cross-session input history can be reintroduced as a real feature later, but shipping a dormant implementation invites false confidence. --- src/tui/input-history-store.test.ts | 48 ----------------------------- src/tui/input-history-store.ts | 47 ---------------------------- 2 files changed, 95 deletions(-) delete mode 100644 src/tui/input-history-store.test.ts delete mode 100644 src/tui/input-history-store.ts 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 }; -} From 9033572095bcb58f18df254e869c2c2402a3389a Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 19:36:09 +0200 Subject: [PATCH 8/8] test(tui): align session rename test with the new API and skip symlink on Windows The session rename API field changed from displayName to label in the 0.80 surface; update the assertion to match the implementation. The symlink-follows test requires Developer Mode or admin rights to create a symlink on Windows (EPERM otherwise), so skip it on win32; CI on Linux exercises it normally. --- src/tui/file-mention.test.ts | 6 +++++- src/tui/session-manager.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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 () => {