diff --git a/packages/sync-client/src/cli/tui/app.tsx b/packages/sync-client/src/cli/tui/app.tsx index 32d3d64e8..c65096f2d 100644 --- a/packages/sync-client/src/cli/tui/app.tsx +++ b/packages/sync-client/src/cli/tui/app.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { render, Text, useInput } from "ink"; +import { render, Text, useApp, useInput } from "ink"; import { DEFAULT_TUI_ACTIONS } from "./actions.js"; import { normalizeTuiError } from "./errors.js"; import { searchTuiActions } from "./palette.js"; @@ -8,6 +8,9 @@ import { getTerminalCapabilities } from "./terminal.js"; import { CommandPalette } from "./views/CommandPalette.js"; import { HomeView } from "./views/HomeView.js"; +const ENTER_ALTERNATE_SCREEN = "\u001B[?1049h\u001B[H\u001B[2J"; +const EXIT_ALTERNATE_SCREEN = "\u001B[?1049l"; + function createSnapshotFailure(error: unknown): TuiStatusSnapshot { const unknownSubsystem = { state: "unknown" as const, label: "unknown" }; return { @@ -25,6 +28,7 @@ function createSnapshotFailure(error: unknown): TuiStatusSnapshot { } export function MatrixTuiApp({ initialSnapshot, noColor = false }: { initialSnapshot?: TuiStatusSnapshot; noColor?: boolean }) { + const { exit } = useApp(); const [snapshot, setSnapshot] = useState(initialSnapshot ?? null); const [paletteOpen, setPaletteOpen] = useState(false); const [paletteQuery, setPaletteQuery] = useState(""); @@ -44,6 +48,10 @@ export function MatrixTuiApp({ initialSnapshot, noColor = false }: { initialSnap setSelectedIndex(0); return; } + if (!paletteOpen && input === "q") { + exit(); + return; + } if (paletteOpen && key.upArrow) { setSelectedIndex((value) => Math.max(0, value - 1)); return; @@ -101,13 +109,48 @@ export function MatrixTuiApp({ initialSnapshot, noColor = false }: { initialSnap } if (paletteOpen) { - return ; + return ( + + ); } - return ; + return ; +} + +export function shouldUseAlternateScreen({ + stdout = process.stdout, + env = process.env, +}: { + stdout?: NodeJS.WriteStream & { isTTY?: boolean }; + env?: NodeJS.ProcessEnv; +} = {}): boolean { + return stdout.isTTY === true && env.TERM !== "dumb" && env.MATRIX_TUI_FULLSCREEN !== "0"; } export async function launchTui(options: { noColor?: boolean } = {}): Promise { + const useAlternateScreen = shouldUseAlternateScreen(); + if (useAlternateScreen) { + process.stdout.write(ENTER_ALTERNATE_SCREEN); + const restoreAlternateScreen = () => { + process.stdout.write(EXIT_ALTERNATE_SCREEN); + }; + process.once("exit", restoreAlternateScreen); + try { + const { waitUntilExit } = render(); + await waitUntilExit(); + } finally { + process.removeListener("exit", restoreAlternateScreen); + restoreAlternateScreen(); + } + return; + } + const { waitUntilExit } = render(); await waitUntilExit(); } diff --git a/packages/sync-client/src/cli/tui/views/CommandPalette.tsx b/packages/sync-client/src/cli/tui/views/CommandPalette.tsx index 72e97dc8f..739059c16 100644 --- a/packages/sync-client/src/cli/tui/views/CommandPalette.tsx +++ b/packages/sync-client/src/cli/tui/views/CommandPalette.tsx @@ -2,27 +2,47 @@ import React from "react"; import { Box, Text } from "ink"; import type { TuiAction } from "../actions.js"; +const MATRIX_GREEN = "#00ff41"; + +function actionDescription(action: TuiAction): string { + return action.intents[0] ?? action.aliases[0] ?? action.group; +} + +function paddedTitle(title: string, targetWidth: number): string { + return title.length >= targetWidth ? `${title} ` : title.padEnd(targetWidth); +} + export function CommandPalette({ results, query, selectedIndex = 0, + columns = 80, noColor = false, }: { results: readonly TuiAction[]; query: string; selectedIndex?: number; + columns?: number; noColor?: boolean; }) { + const width = Math.min(Math.max(1, columns), 76); + const titleColumnWidth = Math.min(34, Math.max(1, Math.floor((width - 4) / 2))); + return ( - - Command Palette - {`/${query}`} + + + MATRIX COMMANDS + esc closes + + {`/${query}`} {results.map((action, index) => ( - - - {index === selectedIndex ? "> " : " "}{action.title} + + + {index === selectedIndex ? "> " : " "}{paddedTitle(action.title, titleColumnWidth)}{action.group} + + + {" "}{actionDescription(action)} - {action.group} ))} {results.length === 0 && No commands found} diff --git a/packages/sync-client/src/cli/tui/views/HomeView.tsx b/packages/sync-client/src/cli/tui/views/HomeView.tsx index ba115752c..b7b0bdd79 100644 --- a/packages/sync-client/src/cli/tui/views/HomeView.tsx +++ b/packages/sync-client/src/cli/tui/views/HomeView.tsx @@ -3,6 +3,8 @@ import { Box, Text } from "ink"; import { Mascot } from "./Mascot.js"; import type { TuiStatusSnapshot } from "../status.js"; +const MATRIX_GREEN = "#00ff41"; + function stateLabel(snapshot: TuiStatusSnapshot): string { if (snapshot.overall === "unauthenticated") { return "login required"; @@ -10,37 +12,79 @@ function stateLabel(snapshot: TuiStatusSnapshot): string { return snapshot.overall; } +function stateColor(_snapshot: TuiStatusSnapshot): string { + return MATRIX_GREEN; +} + export function HomeView({ snapshot, columns = 80, + rows = 24, noColor = false, }: { snapshot: TuiStatusSnapshot; columns?: number; + rows?: number; noColor?: boolean; }) { const narrow = columns < 80; + const wide = columns >= 92; + const extraWide = columns >= 124; + const stageWidth = Math.max(40, Math.min(columns, extraWide ? 120 : wide ? 96 : 76)); + const fullScreenHeight = rows >= 30 ? rows : undefined; const sessionLabel = `${snapshot.sessions.count} ${snapshot.sessions.count === 1 ? "session" : "sessions"}`; const status = `${stateLabel(snapshot)} · ${snapshot.profile.name} · ${snapshot.gateway.label} · ${sessionLabel}`; + const color = noColor ? undefined : stateColor(snapshot); return ( - - - Matrix OS - - - {'Ask Hermes... "review my current PR"'} - Build · Hermes Codex Shell - - - {"/ commands tab agents s sessions"} - {!narrow && } + + + MATRIX OS - - {status} + + {!narrow && !extraWide && ( + + + + )} + + + + + + | + + + {'Ask Matrix... "review my current PR"'} + Build Matrix Codex Shell + / commands tab agents s sessions q quit + + + + + {status} + {snapshot.blockingActions.length > 0 && ( + {`Next: /${snapshot.blockingActions[0]}`} + )} + + + {extraWide && ( + + + + )} - {snapshot.blockingActions.length > 0 && ( - {`Next: /${snapshot.blockingActions[0]}`} + + {narrow && ( + + + )} ); diff --git a/packages/sync-client/src/cli/tui/views/Mascot.tsx b/packages/sync-client/src/cli/tui/views/Mascot.tsx index 521ca1642..abf7f34e4 100644 --- a/packages/sync-client/src/cli/tui/views/Mascot.tsx +++ b/packages/sync-client/src/cli/tui/views/Mascot.tsx @@ -1,8 +1,55 @@ import React from "react"; -import { Text } from "ink"; +import { Box, Text } from "ink"; import type { TuiOverallState } from "../status.js"; -export function Mascot({ state, noColor }: { state: TuiOverallState; noColor?: boolean }) { - const face = state === "healthy" ? "[::]" : state === "unauthenticated" ? "[??]" : "[!!]"; - return {face}; +const MATRIX_GREEN = "#00ff41"; + +const RABBIT_ART = [ + " .@@.", + " .@@@@.", + " @@@@@", + " .@@. @@@@@.", + " .@@@@oo. @@@@.", + " @@@@@@@o. @@@'", + " '@@@@@@@@o. @@.", + " 'o@@@@@o. @@'", + " '@@@o .@@@@.", + " .@@@@@@@@.", + " @@@@@@@@@@", + " '@@@@@@@'", + "", + " .@@@@oo.o@@@. ..", + " .@@@@o. @@@@@@@@@@@@. .@@.", + " @@@@@@@@@@@@@@@' '' .@@@@@.", + " '@@@@'' '@@@' '' '''''", + "", + " .@@@@@@@@@@o. .@@@. .@@@@@@@o.", + " @@@@@@@@@@@@@@o.@@@@@o@@@@@@@@@@@@.", + " '@@@' '@@@' '@@' '@@' '@@@'", + "", + " .@@@@.", + " @@@@@@@. .@@@@@. .o@@@@@@@@@o. .@@@. .@@@.", + " '@@@@' @@@@@@@.@@@@@@@@@@@@@@.'@@@' '@@@'", +]; + +const COMPACT_RABBIT = "rabbit: .@@. @@@"; + +function mascotColor(_state: TuiOverallState): string { + return MATRIX_GREEN; +} + +export function Mascot({ state, noColor, compact = false }: { state: TuiOverallState; noColor?: boolean; compact?: boolean }) { + const color = noColor ? undefined : mascotColor(state); + + if (compact) { + return {COMPACT_RABBIT}; + } + + return ( + + {RABBIT_ART.map((line, index) => ( + {line} + ))} + + ); } diff --git a/packages/sync-client/tests/tui/accessibility.test.tsx b/packages/sync-client/tests/tui/accessibility.test.tsx index e1c74af0f..cc8165b35 100644 --- a/packages/sync-client/tests/tui/accessibility.test.tsx +++ b/packages/sync-client/tests/tui/accessibility.test.tsx @@ -27,7 +27,7 @@ describe("TUI accessibility rendering", () => { expect(home).toContain("degraded"); expect(home).toContain("gateway degraded"); - expect(palette).toContain("Command Palette"); + expect(palette).toContain("MATRIX COMMANDS"); expect(`${home}${palette}`).not.toContain("\u001B["); }); }); diff --git a/packages/sync-client/tests/tui/command-palette.test.tsx b/packages/sync-client/tests/tui/command-palette.test.tsx index 3e533eeeb..fd151d7cf 100644 --- a/packages/sync-client/tests/tui/command-palette.test.tsx +++ b/packages/sync-client/tests/tui/command-palette.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { renderToString } from "ink"; import { describe, expect, it } from "vitest"; -import { DEFAULT_TUI_ACTIONS } from "../../src/cli/tui/actions.js"; +import { DEFAULT_TUI_ACTIONS, type TuiAction } from "../../src/cli/tui/actions.js"; import { searchTuiActions } from "../../src/cli/tui/palette.js"; import { CommandPalette } from "../../src/cli/tui/views/CommandPalette.js"; @@ -15,10 +15,45 @@ describe("command palette", () => { it("renders filtered commands with the selected row highlighted", () => { const results = searchTuiActions(DEFAULT_TUI_ACTIONS, "session", 8); - const output = renderToString(); + const output = renderToString(); - expect(output).toContain("Command Palette"); - expect(output).toContain("Open shell sessions"); + expect(output).toContain("MATRIX COMMANDS"); + expect(output).toContain("> Open shell sessions"); expect(output).toContain("Shell and Remote Run"); + expect(output).toContain("attach to a session"); + }); + + it("keeps no-color palette readable without ANSI escapes", () => { + const results = searchTuiActions(DEFAULT_TUI_ACTIONS, "doctor", 8); + const output = renderToString(); + + expect(output).toContain("/doctor"); + expect(output).toContain("> Run doctor"); + expect(output).not.toContain("\u001B["); + }); + + it("caps palette width to the terminal columns", () => { + const results = searchTuiActions(DEFAULT_TUI_ACTIONS, "session", 8); + const output = renderToString(); + + for (const line of output.split("\n")) { + expect(line.length).toBeLessThanOrEqual(32); + } + }); + + it("uses narrower title padding in compact palettes", () => { + const compactAction: TuiAction = { + id: "test.open", + title: "Open", + group: "Utility", + aliases: ["open"], + intents: ["open command"], + danger: "none", + handler: "view", + }; + + const output = renderToString(); + + expect(output).toContain("> Open Utility"); }); }); diff --git a/packages/sync-client/tests/tui/home-render.test.tsx b/packages/sync-client/tests/tui/home-render.test.tsx index debdef191..0fa83f8ef 100644 --- a/packages/sync-client/tests/tui/home-render.test.tsx +++ b/packages/sync-client/tests/tui/home-render.test.tsx @@ -17,28 +17,52 @@ const baseSnapshot: TuiStatusSnapshot = { }; describe("HomeView", () => { - it("renders prompt-first Matrix OS home with compact status", () => { + it("renders wide Matrix OS launcher with rabbit art, prompt, shortcuts, and status", () => { const output = renderToString(); - expect(output).toContain("Matrix OS"); - expect(output).toContain("Ask Hermes"); + expect(output).toContain("MATRIX OS"); + expect(output).not.toContain("M M A TTTTT"); + expect(output).toContain(".@@@@oo.o@@@."); + expect(output).toContain("@@@@@@@@@@@@@@@"); + expect(output).toContain("Ask Matrix"); + expect(output).toContain("Build Matrix"); + expect(output).toContain("q quit"); expect(output).toContain("cloud"); expect(output).toContain("2 sessions"); - expect(output).toContain("/ commands"); + expect(output).toContain("healthy · cloud · ok · 2 sessions"); }); - it("keeps no-color output understandable", () => { + it("keeps no-color home output understandable without ANSI escapes", () => { const output = renderToString(); + expect(output).toContain("MATRIX OS"); + expect(output).toContain(".@@@@@@@@@@o."); expect(output).toContain("healthy"); expect(output).not.toContain("\u001B["); }); - it("hides mascot and preserves critical text in narrow terminals", () => { + it("fills tall terminals as a fullscreen launcher", () => { + const output = renderToString(); + + expect(output.split("\n").length).toBeGreaterThanOrEqual(40); + expect(output).toContain("Ask Matrix"); + }); + + it("keeps large rabbit art readable on normal-width terminals", () => { + const output = renderToString(); + + expect(output).toContain(".@@@@oo.o@@@."); + expect(output).toContain("Ask Matrix"); + expect(output).toContain("healthy · cloud · ok · 2 sessions"); + }); + + it("hides large art and preserves critical prompt/status text in narrow terminals", () => { const output = renderToString(); - expect(output).toContain("Ask Hermes"); + expect(output).toContain("MATRIX OS"); + expect(output).toContain("Ask Matrix"); expect(output).toContain("cloud"); - expect(output).not.toContain("rabbit"); + expect(output).toContain("rabbit: .@@. @@@"); + expect(output).not.toContain(".@@@@oo.o@@@."); }); }); diff --git a/packages/sync-client/tests/tui/launch.test.ts b/packages/sync-client/tests/tui/launch.test.ts index c90e26301..a1d2fe433 100644 --- a/packages/sync-client/tests/tui/launch.test.ts +++ b/packages/sync-client/tests/tui/launch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { shouldUseAlternateScreen } from "../../src/cli/tui/app.js"; import { resolveTuiLaunchMode } from "../../src/cli/tui/launch.js"; describe("TUI launch routing", () => { @@ -49,4 +50,23 @@ describe("TUI launch routing", () => { expect(resolveTuiLaunchMode({ argv, stdinIsTTY: true, stdoutIsTTY: true }).mode).toBe("direct"); } }); + + it("uses fullscreen alternate screen only for capable TTYs", () => { + expect(shouldUseAlternateScreen({ + stdout: { isTTY: true } as NodeJS.WriteStream & { isTTY: boolean }, + env: { TERM: "xterm-256color" }, + })).toBe(true); + expect(shouldUseAlternateScreen({ + stdout: { isTTY: true } as NodeJS.WriteStream & { isTTY: boolean }, + env: { TERM: "dumb" }, + })).toBe(false); + expect(shouldUseAlternateScreen({ + stdout: { isTTY: true } as NodeJS.WriteStream & { isTTY: boolean }, + env: { TERM: "xterm-256color", MATRIX_TUI_FULLSCREEN: "0" }, + })).toBe(false); + expect(shouldUseAlternateScreen({ + stdout: { isTTY: false } as NodeJS.WriteStream & { isTTY: boolean }, + env: { TERM: "xterm-256color" }, + })).toBe(false); + }); });