Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions packages/sync-client/src/cli/tui/app.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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<TuiStatusSnapshot | null>(initialSnapshot ?? null);
const [paletteOpen, setPaletteOpen] = useState(false);
const [paletteQuery, setPaletteQuery] = useState("");
Expand All @@ -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;
Expand Down Expand Up @@ -101,13 +109,48 @@ export function MatrixTuiApp({ initialSnapshot, noColor = false }: { initialSnap
}

if (paletteOpen) {
return <CommandPalette results={paletteResults} query={paletteQuery} selectedIndex={selectedIndex} noColor={capabilities.noColor} />;
return (
<CommandPalette
results={paletteResults}
query={paletteQuery}
selectedIndex={selectedIndex}
columns={capabilities.columns}
noColor={capabilities.noColor}
/>
);
}

return <HomeView snapshot={snapshot} columns={capabilities.columns} noColor={capabilities.noColor} />;
return <HomeView snapshot={snapshot} columns={capabilities.columns} rows={capabilities.rows} noColor={capabilities.noColor} />;
}

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<void> {
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(<MatrixTuiApp noColor={options.noColor} />);
await waitUntilExit();
} finally {
process.removeListener("exit", restoreAlternateScreen);
restoreAlternateScreen();
}
return;
}

const { waitUntilExit } = render(<MatrixTuiApp noColor={options.noColor} />);
await waitUntilExit();
}
Comment on lines 136 to 156
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Alternate screen not restored on process.exit()

async finally blocks are skipped when process.exit() is called — the event loop stops immediately. Ink's default Ctrl+C handling in raw mode calls process.exit(0), so pressing Ctrl+C while the TUI is open will leave the terminal stuck in alternate screen mode (blank screen, no scrollback) until the user runs reset. Adding a synchronous process.on('exit', ...) handler guarantees cleanup regardless of how the process terminates.

Suggested change
export async function launchTui(options: { noColor?: boolean } = {}): Promise<void> {
const { waitUntilExit } = render(<MatrixTuiApp noColor={options.noColor} />);
await waitUntilExit();
const useAlternateScreen = shouldUseAlternateScreen();
if (useAlternateScreen) {
process.stdout.write(ENTER_ALTERNATE_SCREEN);
}
try {
const { waitUntilExit } = render(<MatrixTuiApp noColor={options.noColor} />);
await waitUntilExit();
} finally {
if (useAlternateScreen) {
process.stdout.write(EXIT_ALTERNATE_SCREEN);
}
}
}
export async function launchTui(options: { noColor?: boolean } = {}): Promise<void> {
const useAlternateScreen = shouldUseAlternateScreen();
if (useAlternateScreen) {
process.stdout.write(ENTER_ALTERNATE_SCREEN);
const restore = () => process.stdout.write(EXIT_ALTERNATE_SCREEN);
process.once("exit", restore);
try {
const { waitUntilExit } = render(<MatrixTuiApp noColor={options.noColor} />);
await waitUntilExit();
} finally {
process.removeListener("exit", restore);
restore();
}
} else {
const { waitUntilExit } = render(<MatrixTuiApp noColor={options.noColor} />);
await waitUntilExit();
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/sync-client/src/cli/tui/app.tsx
Line: 136-149

Comment:
**Alternate screen not restored on `process.exit()`**

`async` `finally` blocks are skipped when `process.exit()` is called — the event loop stops immediately. Ink's default Ctrl+C handling in raw mode calls `process.exit(0)`, so pressing Ctrl+C while the TUI is open will leave the terminal stuck in alternate screen mode (blank screen, no scrollback) until the user runs `reset`. Adding a synchronous `process.on('exit', ...)` handler guarantees cleanup regardless of how the process terminates.

```suggestion
export async function launchTui(options: { noColor?: boolean } = {}): Promise<void> {
  const useAlternateScreen = shouldUseAlternateScreen();
  if (useAlternateScreen) {
    process.stdout.write(ENTER_ALTERNATE_SCREEN);
    const restore = () => process.stdout.write(EXIT_ALTERNATE_SCREEN);
    process.once("exit", restore);
    try {
      const { waitUntilExit } = render(<MatrixTuiApp noColor={options.noColor} />);
      await waitUntilExit();
    } finally {
      process.removeListener("exit", restore);
      restore();
    }
  } else {
    const { waitUntilExit } = render(<MatrixTuiApp noColor={options.noColor} />);
    await waitUntilExit();
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

34 changes: 27 additions & 7 deletions packages/sync-client/src/cli/tui/views/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box borderStyle="single" borderColor={noColor ? undefined : "yellow"} flexDirection="column" paddingX={1}>
<Text bold>Command Palette</Text>
<Text>{`/${query}`}</Text>
<Box borderStyle="single" borderColor={noColor ? undefined : MATRIX_GREEN} flexDirection="column" paddingX={1} paddingY={1} width={width}>
<Box justifyContent="space-between">
<Text bold color={noColor ? undefined : MATRIX_GREEN}>MATRIX COMMANDS</Text>
<Text color={noColor ? undefined : "gray"}>esc closes</Text>
</Box>
<Text color={noColor ? undefined : "gray"}>{`/${query}`}</Text>
{results.map((action, index) => (
<Box key={action.id}>
<Text color={noColor ? undefined : index === selectedIndex ? "yellow" : undefined}>
{index === selectedIndex ? "> " : " "}{action.title}
<Box key={action.id} marginTop={1} flexDirection="column">
<Text color={noColor ? undefined : index === selectedIndex ? MATRIX_GREEN : undefined}>
{index === selectedIndex ? "> " : " "}{paddedTitle(action.title, titleColumnWidth)}{action.group}
</Text>
<Text color={noColor ? undefined : "gray"}>
{" "}{actionDescription(action)}
</Text>
<Text color={noColor ? undefined : "gray"}> {action.group}</Text>
</Box>
))}
{results.length === 0 && <Text>No commands found</Text>}
Expand Down
74 changes: 59 additions & 15 deletions packages/sync-client/src/cli/tui/views/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,88 @@ 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";
}
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 (
<Box flexDirection="column" width={Math.max(40, Math.min(columns, 100))}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={noColor ? undefined : "cyan"}>Matrix OS</Text>
</Box>
<Box borderStyle="single" borderColor={noColor ? undefined : "gray"} flexDirection="column" paddingX={1}>
<Text>{'Ask Hermes... "review my current PR"'}</Text>
<Text color={noColor ? undefined : "blue"}>Build · Hermes Codex Shell</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text>{"/ commands tab agents s sessions"}</Text>
{!narrow && <Mascot state={snapshot.overall} noColor={noColor} />}
<Box
flexDirection="column"
width={stageWidth}
height={fullScreenHeight}
justifyContent={fullScreenHeight ? "center" : undefined}
alignItems="center"
>
<Box flexDirection="column" alignItems="center" marginBottom={1}>
<Text bold color={noColor ? undefined : MATRIX_GREEN}>MATRIX OS</Text>
</Box>
<Box marginTop={1}>
<Text color={noColor ? undefined : snapshot.overall === "healthy" ? "green" : "yellow"}>{status}</Text>

{!narrow && !extraWide && (
<Box marginBottom={1} justifyContent="center">
<Mascot state={snapshot.overall} noColor={noColor} />
</Box>
)}

<Box width="100%" justifyContent="center">
<Box flexDirection="column" width={wide ? 64 : "100%"}>
<Box borderStyle="single" borderColor={noColor ? undefined : "gray"} flexDirection="row">
<Box width={2} flexShrink={0} backgroundColor={noColor ? undefined : MATRIX_GREEN}>
<Text color={noColor ? undefined : MATRIX_GREEN}>|</Text>
</Box>
<Box flexDirection="column" paddingX={1} paddingY={1}>
<Text color={noColor ? undefined : "gray"}>{'Ask Matrix... "review my current PR"'}</Text>
<Text color={noColor ? undefined : MATRIX_GREEN}>Build Matrix Codex Shell</Text>
<Text color={noColor ? undefined : "gray"}>/ commands tab agents s sessions q quit</Text>
</Box>
</Box>

<Box marginTop={1} flexDirection="column">
<Text color={color}>{status}</Text>
{snapshot.blockingActions.length > 0 && (
<Text color={noColor ? undefined : MATRIX_GREEN}>{`Next: /${snapshot.blockingActions[0]}`}</Text>
)}
</Box>
</Box>
{extraWide && (
<Box marginLeft={3}>
<Mascot state={snapshot.overall} noColor={noColor} />
</Box>
)}
</Box>
{snapshot.blockingActions.length > 0 && (
<Text color={noColor ? undefined : "yellow"}>{`Next: /${snapshot.blockingActions[0]}`}</Text>

{narrow && (
<Box marginTop={1}>
<Mascot state={snapshot.overall} noColor={noColor} compact />
</Box>
)}
</Box>
);
Expand Down
55 changes: 51 additions & 4 deletions packages/sync-client/src/cli/tui/views/Mascot.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text color={noColor ? undefined : state === "healthy" ? "green" : "yellow"}>{face}</Text>;
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 <Text color={color}>{COMPACT_RABBIT}</Text>;
}

return (
<Box flexDirection="column" alignItems="center">
{RABBIT_ART.map((line, index) => (
<Text key={index} color={color}>{line}</Text>
))}
</Box>
);
Comment thread
Nima-Naderi marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion packages/sync-client/tests/tui/accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[");
});
});
43 changes: 39 additions & 4 deletions packages/sync-client/tests/tui/command-palette.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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(<CommandPalette results={results} query="session" selectedIndex={0} noColor />);
const output = renderToString(<CommandPalette results={results} query="session" selectedIndex={1} noColor />);

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(<CommandPalette results={results} query="doctor" selectedIndex={0} noColor />);

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(<CommandPalette results={results} query="session" selectedIndex={0} columns={32} noColor />);

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(<CommandPalette results={[compactAction]} query="open" selectedIndex={0} columns={32} noColor />);

expect(output).toContain("> Open Utility");
});
});
Loading