Skip to content
Open
7 changes: 6 additions & 1 deletion tests/tui/render-ink.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export async function renderInk(element, options = {}) {
text() {
return stripAnsi(buffer).replace(/\r/g, "");
},
latestText() {
const cleaned = stripAnsi(buffer).replace(/\r/g, "");
const lastIndex = cleaned.lastIndexOf("Agents:");
return lastIndex >= 0 ? cleaned.slice(lastIndex) : cleaned;
},
Comment on lines +62 to +66
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

latestText() is coupled to the literal string "Agents:" (from StatusHeader row1). This makes the test harness brittle to unrelated header text changes. Consider deriving the “latest frame” boundary from Ink’s clear-screen sequences in the raw buffer, or introducing an explicit frame delimiter, instead of hardcoding a header substring.

Copilot uses AI. Check for mistakes.
async press(chars, waitMs = 40) {
stdin.write(chars);
await delay(waitMs);
Expand All @@ -68,4 +73,4 @@ export async function renderInk(element, options = {}) {
await delay(waitMs);
},
};
}
}
100 changes: 85 additions & 15 deletions tests/tui/screens.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ describe("tui screen rendering", () => {
{ columns: 220 },
);

expect(view.text()).toContain("Runtime Snapshot");
expect(view.text()).toContain("Active Sessions: 1");
expect(view.text()).toContain("Investigate failing build");
expect(view.latestText()).toContain("Runtime Snapshot");
expect(view.latestText()).toContain("Active Sessions: 1");
expect(view.latestText()).toContain("Investigate failing build");

await view.unmount();
});
Expand All @@ -86,10 +86,10 @@ describe("tui screen rendering", () => {
{ columns: 220 },
);

expect(view.text()).toContain("[F]ilter: (title, tag, id)");
expect(view.text()).toContain("TODO (1)");
expect(view.text()).toContain("Review PR #404");
expect(view.text()).toContain("DONE (1)");
expect(view.latestText()).toContain("[F]ilter: (title, tag, id)");
expect(view.latestText()).toContain("TODO (1)");
expect(view.latestText()).toContain("Review PR #404");
expect(view.latestText()).toContain("DONE (1)");

await view.unmount();
});
Expand All @@ -107,14 +107,14 @@ describe("tui screen rendering", () => {
{ columns: 220 },
);

await waitFor(() => view.text().includes("Backoff queue (1)"));
expect(view.text()).toContain("Investigate fa");
await waitFor(() => view.latestText().includes("Backoff queue (1)"));
expect(view.latestText()).toContain("Investigate fa");

await view.press("l");
await waitFor(() => view.text().includes("Logs"));
await waitFor(() => view.latestText().includes("Logs"));

expect(view.text()).toContain("Loaded logs for session-");
expect(view.text()).toContain("assistant");
expect(view.latestText()).toContain("Loaded logs for session-");
expect(view.latestText()).toContain("assistant");

await view.unmount();
});
Expand All @@ -138,16 +138,86 @@ describe("tui screen rendering", () => {
bridge.emit("sessions:update", { sessions: sessionsFixture });
bridge.emit("task:create", tasksFixture[0]);
await view.press(" ", 40);
await waitFor(() => view.text().includes("Runtime Snapshot"));
await waitFor(() => view.latestText().includes("Runtime Snapshot"));

await view.press("2");
await waitFor(() => view.text().includes("[F]ilter: (title, tag, id)"));
await waitFor(() => view.latestText().includes("[F]ilter: (title, tag, id)"));

await view.press("3");
await waitFor(() => view.text().includes("Backoff queue"));
await waitFor(() => view.latestText().includes("Backoff queue"));

await view.unmount();
expect(bridge.connect).toHaveBeenCalled();
expect(bridge.disconnect).toHaveBeenCalled();
});
it("shows the always-visible footer help and toggles the help overlay", async () => {
const bridge = createMockBridge();
const view = await renderInk(
React.createElement(App, {
host: "127.0.0.1",
port: 3080,
connectOnly: true,
initialScreen: "status",
refreshMs: 2000,
wsClient: bridge,
}),
{ columns: 220, rows: 20 },
);

bridge.emit("connect", {});
bridge.emit("stats", monitorStatsFixture);
bridge.emit("sessions:update", { sessions: sessionsFixture });
bridge.emit("task:create", tasksFixture[0]);
await view.press(" ", 40);
await waitFor(() => view.text().includes("? Help"));
expect(view.text()).toContain("[1] Status");

await view.press("?");
await waitFor(() => view.text().includes("Keyboard Shortcuts"), { timeoutMs: 3000 });
expect(view.text()).toContain("Global");
expect(view.text()).toContain("Agents screen");
expect(view.text()).toContain("Modals");

await view.press("?");
await view.press(" ", 40);

await view.unmount();
});

it("updates footer hints when task form focus changes", async () => {
const bridge = createMockBridge();
const view = await renderInk(
React.createElement(App, {
host: "127.0.0.1",
port: 3080,
connectOnly: true,
initialScreen: "tasks",
refreshMs: 2000,
wsClient: bridge,
}),
{ columns: 220, rows: 20 },
);

bridge.emit("connect", {});
bridge.emit("task:create", tasksFixture[0]);
await view.press(" ", 40);
await waitFor(() => view.latestText().includes("[F]ilter: (title, tag, id)"));
expect(view.latestText()).toContain("N New");
expect(view.latestText()).toContain("? Help");

const baseline = view.latestText();
await view.press("n");
await waitFor(() => view.latestText().includes("New Task"));
const updated = view.latestText().slice(baseline.length);
expect(updated).toContain("Ctrl+S Save");
expect(updated).toContain("Esc Cancel");
expect(updated).not.toContain("N New | E Edit | D Delete");

await view.unmount();
});
});





53 changes: 47 additions & 6 deletions tui/app.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import htm from "htm";
import { Box, Text, useApp, useInput } from "ink";
import { Box, Text, useApp, useInput, useStdout } from "ink";

import wsBridgeFactory from "./lib/ws-bridge.mjs";
import { getNextScreenForInput } from "./lib/navigation.mjs";
Expand All @@ -10,6 +10,7 @@ import AgentsScreen from "./screens/agents.mjs";
import StatusScreen from "./screens/status.mjs";
import { readTuiHeaderConfig } from "./lib/header-config.mjs";
import { listTasksFromApi } from "../ui/tui/tasks-screen-helpers.js";
import HelpScreen, { getFooterHints } from "../ui/tui/HelpScreen.js";

const html = htm.bind(React.createElement);

Expand Down Expand Up @@ -52,6 +53,7 @@ function upsertById(items = [], nextItem) {

export default function App({ host, port, connectOnly, initialScreen, refreshMs, wsClient }) {
const { exit } = useApp();
const { stdout } = useStdout();
const [screen, setScreen] = useState(initialScreen || "status");
const [connected, setConnected] = useState(false);
const [connectionState, setConnectionState] = useState("offline");
Expand All @@ -60,6 +62,9 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs,
const [tasks, setTasks] = useState([]);
const [error, setError] = useState(null);
const [screenInputLocked, setScreenInputLocked] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [helpScrollOffset, setHelpScrollOffset] = useState(0);
const [footerHints, setFooterHints] = useState(() => getFooterHints(initialScreen || "status"));
const [refreshCountdownSec, setRefreshCountdownSec] = useState(
Math.max(0, Math.ceil(Number(refreshMs || 2000) / 1000)),
);
Expand Down Expand Up @@ -180,28 +185,53 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs,
};
}, [bridge, refreshMs]);

useEffect(() => {
setFooterHints((current) => (current?.length ? current : getFooterHints(screen)));
}, [screen]);

useEffect(() => {
const intervalId = setInterval(() => {
setRefreshCountdownSec((previous) => Math.max(0, previous - 1));
}, 1000);
return () => clearInterval(intervalId);
}, []);

const handleInput = useCallback((input) => {
const handleInput = useCallback((input, key) => {
if (input === "?") {
setHelpOpen((current) => !current);
return;
}
if (helpOpen) {
if (key?.escape) {
setHelpOpen(false);
return;
}
if (key?.upArrow) {
setHelpScrollOffset((current) => Math.max(0, current - 1));
return;
}
if (key?.downArrow) {
setHelpScrollOffset((current) => current + 1);
return;
}
Comment on lines +205 to +217
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Help overlay scrolling isn’t bounded: down-arrow always increments helpScrollOffset, so it can grow beyond the number of help rows and render an empty/blank help list. Compute a max scroll offset (based on the help content + available terminal rows) and clamp both up/down updates to keep the overlay usable.

Copilot uses AI. Check for mistakes.
return;
}
if (input === "q") {
exit();
return;
}
setScreen((current) => getNextScreenForInput(current, input));
}, [exit]);
}, [exit, helpOpen]);

useInput((input) => {
if (screenInputLocked) return;
handleInput(input);
useInput((input, key) => {
if (screenInputLocked && !helpOpen) return;
handleInput(input, key);
});

const ScreenComponent = SCREENS[screen] || StatusScreen;
const screenStats = screen === "status" ? stats : undefined;
const footerText = (footerHints || []).map(([keysLabel, description]) => `${keysLabel} ${description}`).join(" | ");
const helpRows = Math.max(6, (stdout?.rows || 24) - 5);

return html`
<${Box} flexDirection="column" minHeight=${0}>
Expand Down Expand Up @@ -233,7 +263,18 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs,
refreshMs=${refreshMs}
onTasksChange=${setTasks}
onInputCaptureChange=${setScreenInputLocked}
onFooterHintsChange=${setFooterHints}
/>
${helpOpen
? html`
<${Box} flexDirection="column" marginTop=${1}>
<${HelpScreen} scrollOffset=${helpScrollOffset} maxRows=${helpRows} />
<//>
`
: null}
<//>
<${Box} paddingX=${1}>
<${Text} dimColor>${footerText}<//>
<//>
<//>
`;
Expand Down
23 changes: 14 additions & 9 deletions tui/screens/agents.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import htm from "htm";
import { Box, Text, useInput, useStdout } from "ink";
import { getFooterHints } from "../../ui/tui/HelpScreen.js";

import {
buildOsc52CopySequence,
Expand Down Expand Up @@ -88,7 +89,7 @@ function detailLines(sessionPayload) {
];
}

export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080, sessions, stats = null }) {
export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080, sessions, stats = null, onFooterHintsChange }) {
const resolvedHost = wsBridge?.host || host;
const resolvedPort = wsBridge?.port || port;
const { stdout } = useStdout();
Expand Down Expand Up @@ -326,6 +327,16 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
}
});

React.useEffect(() => {
if (typeof onFooterHintsChange !== "function") return;
onFooterHintsChange(getFooterHints("agents", {
confirmKill,
detailOpen: Boolean(detailView),
logsOpen: logLines.length > 0,
diffOpen: Boolean(diffView),
}));
}, [confirmKill, detailView, diffView, logLines.length, onFooterHintsChange]);

const eventWidth = Math.max(12, (stdout?.columns || 120) - FIXED_TABLE_WIDTH);
const backoffMessageWidth = Math.max(20, (stdout?.columns || 120) - 34);

Expand Down Expand Up @@ -433,14 +444,7 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
: html`<${Text} dimColor>No changed files<//>`}
<//>
`
: null}

<${Box} marginTop=${1} borderStyle="single" paddingX=${1}>
<${Text} dimColor>
[K]ill session [P]ause [R]esume [L]ogs [D]iff [C]opy ID [Enter] Detail
<//>
<//>
${statusLine
: null}${statusLine
? html`
<${Box} marginTop=${1}>
<${Text} color="yellow">${statusLine}<//>
Expand All @@ -454,3 +458,4 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080




Loading
Loading