From a8511f081ee118446781ab6298cd38a372585396 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Sun, 13 Sep 2026 06:55:48 +0000
Subject: [PATCH 1/2] Fix process termination dialog navigation and add
optional SIGKILL
---
apps/benchmark/src/main.ts | 10 ++
apps/demo/README.md | 10 +-
apps/demo/src/kill-dialog.ts | 113 +++++++++++++
apps/demo/src/main.ts | 32 ++--
apps/demo/src/state.ts | 3 +
apps/demo/test/kill-dialog.test.ts | 213 ++++++++++++++++++++++++
examples/modal.ts | 21 ++-
packages/hqtui/src/app.ts | 51 +++++-
packages/hqtui/src/testing.ts | 5 +-
packages/hqtui/src/ui.ts | 21 ++-
packages/hqtui/src/widgets/controls.ts | 5 +-
packages/hqtui/test/modal-input.test.ts | 145 ++++++++++++++++
12 files changed, 592 insertions(+), 37 deletions(-)
create mode 100644 apps/demo/src/kill-dialog.ts
create mode 100644 apps/demo/test/kill-dialog.test.ts
create mode 100644 packages/hqtui/test/modal-input.test.ts
diff --git a/apps/benchmark/src/main.ts b/apps/benchmark/src/main.ts
index aa7791f..bda53b6 100644
--- a/apps/benchmark/src/main.ts
+++ b/apps/benchmark/src/main.ts
@@ -138,6 +138,16 @@ const results: Result[] = [];
renderToScreen(({ ui }) => ui.table({ rows, columns, zebra: true }), { width: 100, height: 40 });
}, "60 rows x 4 columns"));
+ results.push(bench("widgets.modal.controls", 500, (i) => {
+ renderToScreen(({ ui }) => {
+ ui.button({ label: "Background" });
+ ui.modal({
+ title: "Terminate Process", height: 10,
+ buttons: [{ label: "Yes", onPress: () => {} }, { label: "No", onPress: () => {} }],
+ }, (body) => body.checkbox({ label: "Force kill (-9)", checked: false, onToggle: () => {} }));
+ }, { width: 80, height: 24, focus: i % 3 });
+ }, "2 buttons, checkbox, isolated focus"));
+
results.push(bench("widgets.dashboard", 200, () => {
renderToScreen(({ ui }) => {
ui.grid({ columns: 3, rows: 2, gap: 1 }, (grid) => {
diff --git a/apps/demo/README.md b/apps/demo/README.md
index 714f015..cdbc37c 100644
--- a/apps/demo/README.md
+++ b/apps/demo/README.md
@@ -76,11 +76,19 @@ npx --yes @profullstack/hqtui-demo@latest # Node 22.6+ works too
| `Ctrl+K` | Command palette |
| `Space` | Pause updates |
| `↑` `↓` `PgUp` `PgDn` `Home` `End` | Move selection |
-| `Enter` | Confirmation dialog |
+| `Enter` | Terminate the selected dashboard process |
| `q`, `Ctrl+C` | Quit |
Mouse works too: click the tabs and buttons, scroll the process list.
+The termination dialog sends **SIGTERM** by default, allowing the process to
+clean up. **Force kill (-9 / SIGKILL)** starts unchecked each time. Use Tab,
+Shift+Tab or the arrow keys to move between Yes, No and the checkbox; Space
+toggles the checkbox and Enter activates the focused control. `y` confirms;
+`n`, Escape or a click outside the dialog cancels. The selected PID stays fixed
+while metrics refresh. Signal errors appear in the dialog, and simulation mode
+never signals a real process.
+
## Screens
### dashboard
diff --git a/apps/demo/src/kill-dialog.ts b/apps/demo/src/kill-dialog.ts
new file mode 100644
index 0000000..c11abb3
--- /dev/null
+++ b/apps/demo/src/kill-dialog.ts
@@ -0,0 +1,113 @@
+import type { Container, KeyEvent } from "@profullstack/hqtui";
+import type { DemoState } from "./state.ts";
+import type { ProcessSample } from "./simulation.ts";
+import { visibleProcesses } from "./screens/dashboard.ts";
+
+export interface KillDialog {
+ /** Capture the row once: live CPU sorting must not change the target. */
+ target: Pick;
+ force: boolean;
+ result?: string;
+}
+
+export type SendSignal = (pid: number, signal: "SIGTERM" | "SIGKILL") => unknown;
+
+/** Only a selected process can open a real process action. */
+export function openKillDialog(state: DemoState): void {
+ if (state.screen !== "dashboard" || state.focused.dashboard !== "dashboard.processes") return;
+ const process = visibleProcesses(state)[state.panes["dashboard.processes"]?.selected ?? 0];
+ if (!process) return;
+ const { pid, name, command, user } = process;
+ state.killDialog = { target: { pid, name, command, user }, force: false };
+ state.showModal = true;
+}
+
+export function closeKillDialog(state: DemoState): void {
+ state.showModal = false;
+ state.killDialog = null;
+}
+
+/** A successful send requests termination; the process may still be cleaning up. */
+export function confirmKillDialog(state: DemoState, send: SendSignal = process.kill): void {
+ const dialog = state.killDialog;
+ if (!state.showModal || !dialog || dialog.result) return;
+ const { pid, command, user } = dialog.target;
+ const signal = dialog.force ? "SIGKILL" : "SIGTERM";
+ if (state.source.startsWith("simulated")) {
+ dialog.result = `Simulation: would send ${signal} to process ${pid}. No real process was signalled.`;
+ return;
+ }
+ // A zero/negative PID is a process group, never a selected process.
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
+ dialog.result = "Cannot signal an invalid process ID.";
+ return;
+ }
+ const current = state.sample.processes.find((p) => p.pid === pid);
+ if (!current || current.command !== command || current.user !== user) {
+ dialog.result = `Process ${pid} has exited or changed since the dialog opened. Select it again.`;
+ return;
+ }
+ try {
+ send(pid, signal);
+ dialog.result = `Sent ${signal} to process ${pid}.` + (dialog.force ? "" : " It may take time to finish cleaning up.");
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ dialog.result = code === "ESRCH" ? `Process ${pid} has already exited.`
+ : code === "EPERM" || code === "EACCES" ? `Permission denied: cannot send ${signal} to process ${pid}.`
+ : `Could not send ${signal} to process ${pid}: ${error instanceof Error ? error.message : String(error)}`;
+ }
+}
+
+export function renderKillDialog(ui: Container, state: DemoState, send?: SendSignal): void {
+ if (!state.showModal) return;
+ const close = () => closeKillDialog(state);
+ const dialog = state.killDialog;
+ // The Components screen uses the same modal to demonstrate its buttons.
+ if (!dialog) {
+ ui.modal({
+ title: "Confirm Action",
+ message: "This is an example confirmation dialog.",
+ buttons: [{ label: "Yes", variant: "success", onPress: close }, { label: "No", variant: "ghost", onPress: close }],
+ onDismiss: close,
+ onKey: (event) => { if (event.key === "y" || event.key === "n") close(); },
+ });
+ return;
+ }
+ if (dialog.result) {
+ ui.modal({
+ title: "Process Action",
+ message: dialog.result,
+ width: 64,
+ buttons: [{ label: "Close", onPress: close }],
+ onDismiss: close,
+ onKey: (event) => { if (event.key === "n") close(); },
+ });
+ return;
+ }
+ const confirm = () => confirmKillDialog(state, send);
+ const key = (event: KeyEvent) => {
+ if (event.key === "n") close();
+ else if (event.key === "y") confirm();
+ };
+ ui.modal({
+ title: "Terminate Process",
+ width: 64,
+ height: 13,
+ buttons: [
+ { label: "Yes", variant: dialog.force ? "danger" : "success", onPress: confirm },
+ { label: "No", variant: "ghost", onPress: close },
+ ],
+ onDismiss: close,
+ onKey: key,
+ }, (body) => {
+ body.text(`Terminate process ${dialog.target.pid} (${dialog.target.name})?`, { size: 2 });
+ body.label(dialog.force ? "SIGKILL (9): stop immediately, without cleanup." : "SIGTERM (15): request a graceful shutdown.", { size: 2 });
+ body.checkbox({
+ label: "Force kill (-9 / SIGKILL)",
+ checked: dialog.force,
+ onToggle: () => { if (!dialog.result) dialog.force = !dialog.force; },
+ });
+ body.spacer(1);
+ body.label("Tab/arrows move · Space toggles · Enter selects");
+ });
+}
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index d5b421f..266c1ce 100755
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -10,10 +10,11 @@
import { createApp, themeList, themes, type KeyEvent } from "@profullstack/hqtui";
import { createCollector } from "./system/index.ts";
import { intervalMs } from "./options.ts";
-import { createState, focusedPane, moveSelection, SCREENS, SCREEN_KEYS, type ScreenName } from "./state.ts";
+import { createState, moveSelection, SCREENS, SCREEN_KEYS, type ScreenName } from "./state.ts";
+import { openKillDialog, renderKillDialog } from "./kill-dialog.ts";
import {
componentsScreen, dashboardScreen, graphicsScreen, inputScreen, networkScreen, servicesScreen,
- sessionsScreen, stressScreen, themesScreen, trafficScreen, visibleProcesses, worldScreen,
+ sessionsScreen, stressScreen, themesScreen, trafficScreen, worldScreen,
} from "./screens/index.ts";
import { clock, num } from "./format.ts";
@@ -77,6 +78,7 @@ Options:
Keys:
1-0/w / Tab screens F2 theme F3 filter F6 sort Ctrl+K palette
↑/↓ select Space pause F1 help q quit
+ Enter terminate selected process (SIGTERM; optional Force -9 in dialog)
`);
}
@@ -185,12 +187,7 @@ async function main(): Promise {
else if (event.char) state.paletteQuery += event.char;
return;
}
- if (state.showModal) {
- if (event.name === "escape" || event.name === "enter" || event.name === "n" || event.name === "y") {
- state.showModal = false;
- }
- return;
- }
+ if (state.showModal) return;
if (state.showHelp) {
state.showHelp = false;
return;
@@ -246,7 +243,7 @@ async function main(): Promise {
case "pagedown": moveSelection(state, 10); break;
case "home": moveSelection(state, -Number.MAX_SAFE_INTEGER); break;
case "end": moveSelection(state, Number.MAX_SAFE_INTEGER); break;
- case "enter": state.showModal = true; return;
+ case "enter": openKillDialog(state); return;
case "left":
if (state.screen === "themes") {
state.themeIndex = (state.themeIndex - 1 + themeList.length) % themeList.length;
@@ -319,6 +316,7 @@ async function main(): Promise {
},
{ key: "c", label: "Collapse", active: app.collapseBorders, onPress: () => press("c") },
{ key: "F6", label: `Sort: ${state.sort}`, onPress: () => press("f6") },
+ ...(state.screen === "dashboard" ? [{ key: "Enter", label: "Kill", onPress: () => openKillDialog(state) }] : []),
{ key: "^K", label: "Palette", onPress: () => press("ctrl+k") },
{ key: "Tab", label: "Screen", onPress: () => press("tab") },
{ key: "q", label: "Quit", onPress: () => press("q") },
@@ -330,13 +328,15 @@ async function main(): Promise {
ui.modal({
title: "HQTUI Demo — Help",
width: 62,
- height: 18,
+ height: 20,
message:
"1-0, w or Tab switch screens; w is the clickable world map.\n" +
"F2 cycles themes, F3 filters processes, F6 changes sort.\n" +
"c collapses adjacent panel borders into shared lines.\n" +
"Ctrl+K opens the command palette, Space pauses updates.\n" +
"Arrows, PageUp/PageDown, Home/End move the selection.\n" +
+ "Enter opens process termination: SIGTERM by default.\n" +
+ "Tab/arrows select Yes, No or Force (-9); Space toggles.\n" +
"Mouse: click tabs and buttons, scroll the process list.\n\n" +
(state.unavailable.length
? `Unavailable here: ${state.unavailable.join(", ")}.\n` +
@@ -352,17 +352,7 @@ async function main(): Promise {
onDismiss: () => { state.showHelp = false; },
});
}
- if (state.showModal) {
- ui.modal({
- title: "Confirm Action",
- message: `Are you sure you want to terminate process ${visibleProcesses(state)[focusedPane(state)?.selected ?? 0]?.pid ?? "—"} (${visibleProcesses(state)[focusedPane(state)?.selected ?? 0]?.name ?? "—"})?`,
- buttons: [
- { label: "Yes", variant: "success", focused: true, onPress: () => { state.showModal = false; } },
- { label: "No", variant: "ghost", onPress: () => { state.showModal = false; } },
- ],
- onDismiss: () => { state.showModal = false; },
- });
- }
+ renderKillDialog(ui, state);
if (state.showPalette) {
const matches = PALETTE_COMMANDS.filter((c) => c.label.toLowerCase().includes(state.paletteQuery.toLowerCase()));
ui.commandPalette({
diff --git a/apps/demo/src/state.ts b/apps/demo/src/state.ts
index 9217d57..4cab93b 100644
--- a/apps/demo/src/state.ts
+++ b/apps/demo/src/state.ts
@@ -1,5 +1,6 @@
import type { Theme } from "@profullstack/hqtui";
import type { SystemSample } from "./system/index.ts";
+import type { KillDialog } from "./kill-dialog.ts";
export type ScreenName =
| "dashboard" | "traffic" | "sessions" | "network" | "services"
@@ -48,6 +49,7 @@ export interface DemoState {
showHelp: boolean;
showPalette: boolean;
showModal: boolean;
+ killDialog: KillDialog | null;
paletteQuery: string;
paletteIndex: number;
themeIndex: number;
@@ -163,6 +165,7 @@ export function createState(
showHelp: false,
showPalette: false,
showModal: false,
+ killDialog: null,
paletteQuery: "",
paletteIndex: 0,
themeIndex: 0,
diff --git a/apps/demo/test/kill-dialog.test.ts b/apps/demo/test/kill-dialog.test.ts
new file mode 100644
index 0000000..da8aab3
--- /dev/null
+++ b/apps/demo/test/kill-dialog.test.ts
@@ -0,0 +1,213 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import { PassThrough } from "node:stream";
+import { App } from "@profullstack/hqtui";
+import { renderToScreen } from "@profullstack/hqtui/testing";
+import { createSystemSimulation, type ProcessSample } from "../src/simulation.ts";
+import { createState, pane, type DemoState } from "../src/state.ts";
+import { closeKillDialog, confirmKillDialog, openKillDialog, renderKillDialog } from "../src/kill-dialog.ts";
+
+function state(source = "linux /proc"): DemoState {
+ const sample = createSystemSimulation({ seed: 1 }).current();
+ const row: ProcessSample = {
+ pid: 12345, name: "worker", command: "worker --test", user: "tester",
+ cpu: 90, mem: 1, rss: 1024, threads: 1, state: "S",
+ };
+ sample.processes = [row, { ...row, pid: 12346, name: "other", cpu: 10 }];
+ const demo = createState(sample, source, []);
+ pane(demo, "dashboard.processes", sample.processes.length);
+ return demo;
+}
+
+test("termination defaults to SIGTERM and retains the selected process across refresh sorting", () => {
+ const demo = state();
+ openKillDialog(demo);
+ assert.equal(demo.killDialog?.force, false);
+ const screen = renderToScreen(({ ui }) => renderKillDialog(ui, demo));
+ assert.match(screen.text(), /\[ \] Force kill \(-9 \/ SIGKILL\)/);
+ assert.match(screen.text(), /SIGTERM \(15\)/);
+ demo.sample.processes[0].cpu = 0;
+ demo.sample.processes[1].cpu = 99;
+ const sent: unknown[] = [];
+ confirmKillDialog(demo, (...args) => sent.push(args));
+ confirmKillDialog(demo, (...args) => sent.push(args));
+ assert.deepEqual(sent, [[12345, "SIGTERM"]], "redraws/repeated activation must not send twice");
+ assert.match(demo.killDialog!.result!, /Sent SIGTERM/);
+});
+
+test("the force checkbox selects SIGKILL and resets when reopened", () => {
+ const demo = state();
+ openKillDialog(demo);
+ const sent: unknown[] = [];
+ const render = () => renderToScreen(({ ui }) => renderKillDialog(ui, demo, (...args) => sent.push(args)));
+ let screen = render();
+ const force = screen.find("Force kill")!;
+ screen.click(force.x, force.y);
+ assert.equal(demo.killDialog?.force, true);
+ screen = render();
+ assert.match(screen.text(), /\[✓\]/);
+ const yes = screen.find("Yes")!;
+ screen.click(yes.x, yes.y);
+ assert.deepEqual(sent, [[12345, "SIGKILL"]]);
+ closeKillDialog(demo);
+ openKillDialog(demo);
+ assert.equal(demo.killDialog?.force, false);
+});
+
+test("mouse No and backdrop cancel without sending a signal", () => {
+ const demo = state();
+ const send = () => assert.fail("cancel sent a signal");
+ for (const cancel of ["No", "backdrop"]) {
+ openKillDialog(demo);
+ const screen = renderToScreen(({ ui }) => renderKillDialog(ui, demo, send));
+ const at = cancel === "No" ? screen.find("No")! : { x: 0, y: 0 };
+ screen.click(at.x, at.y);
+ assert.equal(demo.showModal, false);
+ confirmKillDialog(demo, send);
+ }
+});
+
+test("simulation and fallback simulation never signal host PIDs", () => {
+ for (const source of ["simulated", "simulated (freebsd not supported)"]) {
+ for (const force of [false, true]) {
+ const demo = state(source);
+ openKillDialog(demo);
+ demo.killDialog!.force = force;
+ confirmKillDialog(demo, () => assert.fail("simulation sent a signal"));
+ assert.match(demo.killDialog!.result!, /No real process was signalled/);
+ }
+ }
+});
+
+test("other screens, other panes and empty process lists do not open termination", () => {
+ for (const setup of [
+ (demo: DemoState) => { demo.screen = "services"; },
+ (demo: DemoState) => { demo.focused.dashboard = "dashboard.logs"; },
+ (demo: DemoState) => { demo.sample.processes = []; },
+ (demo: DemoState) => { demo.filter = "no matching process"; },
+ ]) {
+ const demo = state();
+ setup(demo);
+ openKillDialog(demo);
+ assert.equal(demo.showModal, false);
+ }
+});
+
+test("invalid, vanished and changed process targets are rejected", () => {
+ for (const pid of [0, -1, 1.5, NaN]) {
+ const demo = state();
+ demo.sort = "name";
+ demo.sample.processes = [{ ...demo.sample.processes[0], pid }];
+ openKillDialog(demo);
+ confirmKillDialog(demo, () => assert.fail("invalid PID was signalled"));
+ assert.match(demo.killDialog!.result!, /invalid process ID/);
+ }
+ for (const change of [
+ (demo: DemoState) => { demo.sample.processes = []; },
+ (demo: DemoState) => { demo.sample.processes[0].command = "different worker"; },
+ ]) {
+ const demo = state();
+ openKillDialog(demo);
+ change(demo);
+ confirmKillDialog(demo, () => assert.fail("stale target was signalled"));
+ assert.match(demo.killDialog!.result!, /exited or changed/);
+ }
+});
+
+test("signal failures stay visible in the dialog", () => {
+ for (const [code, message] of [["EPERM", /Permission denied/], ["ESRCH", /already exited/], ["EINVAL", /Could not send SIGTERM/]] as const) {
+ const demo = state();
+ openKillDialog(demo);
+ confirmKillDialog(demo, () => { throw Object.assign(new Error("signal failed"), { code }); });
+ assert.equal(demo.showModal, true);
+ const screen = renderToScreen(({ ui }) => renderKillDialog(ui, demo));
+ assert.match(screen.text(), message);
+ assert.ok(screen.find("Close"));
+ }
+});
+
+test("keyboard No, Force, Yes and Escape work through the demo's real modal", async (t) => {
+ const demo = state();
+ const input = new PassThrough();
+ const output = new PassThrough();
+ output.resume();
+ Object.assign(output, { columns: 80, rows: 24 });
+ const app = new App({
+ input: input as unknown as NodeJS.ReadStream,
+ output: output as unknown as NodeJS.WriteStream,
+ installExitHandlers: false,
+ quitKeys: [],
+ });
+ t.after(() => app.stop());
+ const sent: unknown[] = [];
+ app.on("key", (event) => {
+ if (event.key === "enter") openKillDialog(demo);
+ if (event.key === "space") demo.paused = !demo.paused;
+ if (event.key === "tab") demo.screen = "services";
+ });
+ app.render(({ ui }) => {
+ ui.button({ label: "Background", onPress: () => assert.fail("background activated") });
+ renderKillDialog(ui, demo, (...args) => sent.push(args));
+ });
+ openKillDialog(demo);
+ void app.start();
+ const key = async (bytes: string) => {
+ input.write(bytes);
+ await new Promise((resolve) => setTimeout(resolve, bytes === "\x1b" ? 60 : 0));
+ app.frame();
+ };
+ await key("\x1b[C");
+ await key("\r");
+ assert.equal(demo.showModal, false);
+ assert.deepEqual(sent, []);
+ openKillDialog(demo);
+ app.frame();
+ await key("\x1b[Z"); // Yes -> Force
+ await key(" ");
+ assert.equal(demo.killDialog?.force, true);
+ assert.equal(demo.paused, false);
+ await key("\t"); // Force -> Yes
+ assert.equal(demo.screen, "dashboard");
+ await key("\r");
+ assert.deepEqual(sent, [[12345, "SIGKILL"]]);
+ await key("\r"); // close result
+ assert.equal(demo.showModal, false);
+ openKillDialog(demo);
+ app.frame();
+ assert.equal(demo.killDialog?.force, false);
+ await key("n");
+ assert.equal(demo.showModal, false);
+ openKillDialog(demo);
+ app.frame();
+ await key("\x1b");
+ assert.equal(demo.showModal, false);
+ assert.deepEqual(sent, [[12345, "SIGKILL"]]);
+});
+
+test("the process dialog survives a 1x1 terminal", () => {
+ const demo = state();
+ openKillDialog(demo);
+ assert.doesNotThrow(() => renderToScreen(({ ui }) => renderKillDialog(ui, demo), { width: 1, height: 1 }));
+});
+
+for (const force of [false, true]) {
+ test(`a disposable child receives ${force ? "SIGKILL" : "SIGTERM and cleans up"}`, { skip: process.platform === "win32", timeout: 5000 }, async (t) => {
+ const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => { console.log('cleaned'); process.exit(0); }); console.log('ready'); setInterval(() => {}, 1000);"], { stdio: ["ignore", "pipe", "pipe"] });
+ t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); });
+ let output = "";
+ child.stdout.on("data", (data) => { output += data; });
+ await once(child.stdout, "data");
+ const demo = state();
+ demo.sample.processes[0].pid = child.pid!;
+ openKillDialog(demo);
+ demo.killDialog!.force = force;
+ const exited = once(child, "exit");
+ confirmKillDialog(demo);
+ const [code, signal] = await exited;
+ assert.equal(code, force ? null : 0);
+ assert.equal(signal, force ? "SIGKILL" : null);
+ assert.equal(output.includes("cleaned"), !force);
+ });
+}
diff --git a/examples/modal.ts b/examples/modal.ts
index d9c8653..9250616 100644
--- a/examples/modal.ts
+++ b/examples/modal.ts
@@ -2,24 +2,37 @@
import { createApp } from "@profullstack/hqtui";
let open = false;
+let result = "No action taken yet.";
+function answer(accepted: boolean): void {
+ result = accepted ? "Confirmed." : "Cancelled.";
+ open = false;
+}
const app = await createApp({ quitKeys: ["ctrl+c"] });
app.on("key", (event) => {
if (event.name === "enter") open = true;
- else if (event.name === "escape" || event.name === "y" || event.name === "n") open = false;
else if (event.name === "q" && !open) app.quit();
});
app.render(({ ui }) => {
ui.panel({ title: "Background" }, (p) => {
p.text("Press Enter to open the dialog.");
- p.label("Esc, y or n closes it. q quits.");
+ p.label("Tab/arrows select, Enter activates, Esc cancels. q quits.");
+ p.text(result);
});
if (open) {
ui.modal({
title: "Confirm Action",
- message: "Are you sure you want to terminate process 48231 (bun)?",
- buttons: [{ label: "Yes", variant: "success", focused: true }, { label: "No", variant: "ghost" }],
+ message: "Apply this change?",
+ buttons: [
+ { label: "Yes", variant: "success", onPress: () => answer(true) },
+ { label: "No", variant: "ghost", onPress: () => answer(false) },
+ ],
+ onDismiss: () => answer(false),
+ onKey: (event) => {
+ if (event.key === "y") answer(true);
+ else if (event.key === "n") answer(false);
+ },
});
}
});
diff --git a/packages/hqtui/src/app.ts b/packages/hqtui/src/app.ts
index 7f08475..696f348 100644
--- a/packages/hqtui/src/app.ts
+++ b/packages/hqtui/src/app.ts
@@ -5,7 +5,7 @@ import { Terminal, type TerminalOptions, emergencyRestore } from "./terminal.ts"
import type { Capabilities } from "./capabilities.ts";
import { type Theme, type ThemeName, resolveTheme, themes } from "./theme.ts";
import { Surface, createSurface } from "./surface.ts";
-import { Container, countClicks, dispatchHit, type RenderContext, type HitRegion, type FocusRegistration } from "./ui.ts";
+import { Container, countClicks, dispatchHit, type RenderContext, type HitRegion, type FocusRegistration, type OverlayOptions } from "./ui.ts";
import type { InputEvent, KeyEvent, MouseEvent, PasteEvent, FocusEvent } from "./input.ts";
import { matchKey } from "./input.ts";
@@ -102,7 +102,9 @@ export class App {
private focusCount = 0;
private focusActions: (() => void)[] = [];
private hits: HitRegion[] = [];
- private overlays: ((root: Surface) => void)[] = [];
+ private overlays: { draw: (root: Surface) => void; options?: OverlayOptions }[] = [];
+ private modal: OverlayOptions | undefined;
+ private backgroundFocusIndex = 0;
private lastStats: FrameStats = { frame: 0, renderMs: 0, changedCells: 0, dirtyRows: 0, bytes: 0, fps: 0 };
constructor(options: AppOptions = {}) {
@@ -338,10 +340,28 @@ export class App {
if (this.options.focusNavigation !== false) {
if (event.name === "tab") {
this.focusNext(event.shift ? -1 : 1);
+ if (this.modal) return;
+ } else if (this.modal && ["left", "right", "up", "down"].includes(event.name)) {
+ this.focusNext(event.name === "left" || event.name === "up" ? -1 : 1);
+ return;
} else if (event.name === "enter" || event.name === "space") {
+ const consumed = this.modal && this.focusActions[this.focusIndex];
this.activateFocused();
+ // A callback may have closed the dialog. Its Enter must not then
+ // reach the app's key handler and open it again (or pause a demo).
+ if (consumed) return;
}
}
+ if (this.modal && event.name === "escape" && this.modal.onDismiss) {
+ this.modal.onDismiss();
+ this.dirty = true;
+ return;
+ }
+ if (this.modal?.onKey) {
+ this.modal.onKey(event);
+ this.dirty = true;
+ return;
+ }
this.emit("key", event);
this.dirty = true;
return;
@@ -393,6 +413,11 @@ export class App {
this.overlays = [];
this.focusActions = [];
let focusCursor = 0;
+ const wasModal = this.modal !== undefined;
+ const modalFocusIndex = this.focusIndex;
+ if (wasModal) this.focusIndex = this.backgroundFocusIndex;
+ else this.backgroundFocusIndex = this.focusIndex;
+ this.modal = undefined;
const ctx: RenderContext = {
theme: this.theme,
@@ -410,7 +435,7 @@ export class App {
return { index, focused: index === this.focusIndex };
},
hit: (region) => this.hits.push(region),
- overlay: (draw) => this.overlays.push(draw),
+ overlay: (draw, options) => this.overlays.push({ draw, options }),
invalidate: () => this.invalidate(),
};
@@ -428,10 +453,26 @@ export class App {
app: this,
});
container.flush();
- for (const overlay of this.overlays) overlay(root);
+ for (const overlay of this.overlays) {
+ if (overlay.options?.modal) {
+ // Built-in buttons and custom controls form one focus order. Reset
+ // for each modal so only the topmost dialog can receive activation.
+ this.focusIndex = wasModal ? modalFocusIndex : 0;
+ ctx.focusIndex = this.focusIndex;
+ focusCursor = 0;
+ this.focusActions = [];
+ this.modal = overlay.options;
+ }
+ overlay.draw(root);
+ }
this.focusCount = Math.max(focusCursor, 0);
- if (this.focusCount > 0 && this.focusIndex >= this.focusCount) this.focusIndex = 0;
+ if (this.focusCount > 0 && this.focusIndex >= this.focusCount) {
+ this.focusIndex = 0;
+ // The controls were already painted with the old index. Repaint the
+ // new focus even when no further input or metric update arrives.
+ this.dirty = true;
+ }
const result = this.encoder.encode(this.previous, this.current, this.forceRepaint);
this.forceRepaint = false;
diff --git a/packages/hqtui/src/testing.ts b/packages/hqtui/src/testing.ts
index 54d10a1..f65b666 100644
--- a/packages/hqtui/src/testing.ts
+++ b/packages/hqtui/src/testing.ts
@@ -116,7 +116,10 @@ export function renderToScreen(
return { index, focused: index === (options.focus ?? 0) };
},
hit: (region) => regions.push(region),
- overlay: (draw) => overlays.push(draw),
+ overlay: (draw, overlayOptions) => overlays.push((root) => {
+ if (overlayOptions?.modal) focusCursor = 0;
+ draw(root);
+ }),
invalidate: () => {},
};
diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts
index 85b1b98..cab51b7 100644
--- a/packages/hqtui/src/ui.ts
+++ b/packages/hqtui/src/ui.ts
@@ -3,6 +3,7 @@ import type { Style } from "./buffer.ts";
import type { Color } from "./color.ts";
import type { Theme } from "./theme.ts";
import type { Capabilities } from "./capabilities.ts";
+import type { KeyEvent } from "./input.ts";
import {
type Constraint, type Justify, type Rect, type Padding, type Size, inset, stack, solve, isEmpty,
} from "./layout.ts";
@@ -81,6 +82,13 @@ export interface FocusRegistration {
focused: boolean;
}
+export interface OverlayOptions {
+ /** Give this overlay its own keyboard focus, excluding background controls. */
+ modal?: boolean;
+ onDismiss?: () => void;
+ onKey?: (event: KeyEvent) => void;
+}
+
/** Per-frame services the builder needs from the app. */
export interface RenderContext {
theme: Theme;
@@ -105,7 +113,7 @@ export interface RenderContext {
reducedMotion: boolean;
registerFocus(action?: () => void): FocusRegistration;
hit(region: HitRegion): void;
- overlay(draw: (root: Surface) => void): void;
+ overlay(draw: (root: Surface) => void, options?: OverlayOptions): void;
invalidate(): void;
}
@@ -728,10 +736,15 @@ export class Container {
// -------------------------------------------------------------- overlays
- /** A centred dialog drawn above everything else this frame. */
+ /** A centred dialog. Tab/arrows move focus; Enter/Space activate; Esc dismisses. */
modal(options: W.ModalOptions, build?: (modal: Container) => void): this {
this.ctx.overlay((root) => {
- const inner = W.drawModal(root, options);
+ const buttons = options.buttons?.map((button) => {
+ if (!button.onPress) return button;
+ const focus = this.ctx.registerFocus(button.onPress);
+ return { ...button, focused: button.focused ?? focus.focused };
+ });
+ const inner = W.drawModal(root, { ...options, buttons });
// The whole screen belongs to the dialog while it is up. The backdrop
// takes every click outside it (and dismisses, if asked to), the dialog
// takes every click inside it, and only then do the buttons and whatever
@@ -757,7 +770,7 @@ export class Container {
build(container);
container.flush();
}
- });
+ }, { modal: true, onDismiss: options.onDismiss, onKey: options.onKey });
return this;
}
diff --git a/packages/hqtui/src/widgets/controls.ts b/packages/hqtui/src/widgets/controls.ts
index 11b310e..e84a405 100644
--- a/packages/hqtui/src/widgets/controls.ts
+++ b/packages/hqtui/src/widgets/controls.ts
@@ -3,6 +3,7 @@ import { Attr, type Style } from "../buffer.ts";
import { type Color, mix } from "../color.ts";
import { fit, stringWidth, truncate, wrap } from "../unicode.ts";
import { elevate } from "../theme.ts";
+import type { KeyEvent } from "../input.ts";
export interface ButtonOptions {
label: string;
@@ -193,11 +194,13 @@ export interface ModalOptions {
color?: Color;
align?: Align;
/**
- * A click on the backdrop, outside the dialog. Without it the click is
+ * Escape or a click on the backdrop, outside the dialog. Without it the click is
* swallowed: a dialog owns the screen while it is up, and whatever is drawn
* underneath must not act on a click aimed at the dialog and missed.
*/
onDismiss?: () => void;
+ /** Other keys while open, e.g. y/n shortcuts. Navigation stays in the dialog. */
+ onKey?: (event: KeyEvent) => void;
}
/** Where a modal's buttons go, relative to its interior. */
diff --git a/packages/hqtui/test/modal-input.test.ts b/packages/hqtui/test/modal-input.test.ts
new file mode 100644
index 0000000..b46a4cb
--- /dev/null
+++ b/packages/hqtui/test/modal-input.test.ts
@@ -0,0 +1,145 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { PassThrough } from "node:stream";
+import { App } from "../src/app.ts";
+import { renderToScreen } from "../src/testing.ts";
+import { themes } from "../src/theme.ts";
+
+function harness() {
+ const input = new PassThrough();
+ const output = new PassThrough();
+ output.resume();
+ Object.assign(output, { columns: 80, rows: 24 });
+ const app = new App({
+ input: input as unknown as NodeJS.ReadStream,
+ output: output as unknown as NodeJS.WriteStream,
+ installExitHandlers: false,
+ quitKeys: [],
+ });
+ return {
+ app,
+ async key(bytes: string) {
+ input.write(bytes);
+ await new Promise((resolve) => setTimeout(resolve, bytes === "\x1b" ? 60 : 0));
+ app.frame();
+ },
+ };
+}
+
+test("dialog arrows and Tab navigate buttons and a checkbox without activating the background", async (t) => {
+ const { app, key } = harness();
+ t.after(() => app.stop());
+ const pressed: string[] = [];
+ let checked = false;
+ const leaked: string[] = [];
+ app.on("key", (event) => leaked.push(event.key));
+ app.render(({ ui }) => {
+ ui.button({ label: "Background", onPress: () => pressed.push("background") });
+ ui.modal({
+ height: 10,
+ buttons: [
+ { label: "Yes", onPress: () => pressed.push("yes") },
+ { label: "No", onPress: () => pressed.push("no") },
+ ],
+ }, (body) => body.checkbox({ label: "Force", checked, onToggle: () => { checked = !checked; } }));
+ });
+ void app.start();
+ await key("\x1b[C"); // right: No
+ await key("\r");
+ assert.deepEqual(pressed, ["no"]);
+ await key("\t"); // checkbox
+ await key(" ");
+ assert.equal(checked, true);
+ await key("\x1b[Z"); // Shift+Tab: No
+ await key("\x1b[D"); // left: Yes
+ await key("\r");
+ await key("\x1b[A"); // up wraps to checkbox
+ await key(" ");
+ assert.equal(checked, false);
+ await key("\x1b[B"); // down wraps to Yes
+ await key("\r");
+ assert.deepEqual(pressed, ["no", "yes", "yes"]);
+ assert.deepEqual(leaked, []);
+});
+
+test("dismissal does not re-open a dialog and restores the previous background focus", async (t) => {
+ const { app, key } = harness();
+ t.after(() => app.stop());
+ let open = false;
+ const pressed: string[] = [];
+ app.on("key", (event) => { if (event.name === "enter") open = true; });
+ app.render(({ ui }) => {
+ ui.buttons([
+ { label: "First", onPress: () => pressed.push("first") },
+ { label: "Second", onPress: () => pressed.push("second") },
+ ]);
+ if (open) ui.modal({
+ buttons: [{ label: "Close", onPress: () => { open = false; } }],
+ onDismiss: () => { open = false; },
+ });
+ });
+ void app.start();
+ await key("\t"); // remember the second background button
+ open = true;
+ app.frame();
+ await key("\r");
+ assert.equal(open, false, "the closing Enter leaked to the opener");
+ assert.deepEqual(pressed, []);
+ await key(" ");
+ assert.deepEqual(pressed, ["second"]);
+ open = true;
+ app.frame();
+ await key("\x1b");
+ assert.equal(open, false);
+});
+
+test("only the top modal activates and its custom shortcuts stay out of app key handlers", async (t) => {
+ const { app, key } = harness();
+ t.after(() => app.stop());
+ const calls: string[] = [];
+ app.on("key", () => calls.push("app"));
+ app.render(({ ui }) => {
+ ui.modal({ buttons: [{ label: "Lower", onPress: () => calls.push("lower") }] });
+ ui.modal({
+ buttons: [{ label: "Upper", onPress: () => calls.push("upper") }],
+ onKey: (event) => calls.push(event.key),
+ });
+ });
+ void app.start();
+ await key("\r");
+ await key("n");
+ assert.deepEqual(calls, ["upper", "n"]);
+});
+
+test("modal focus highlights follow the dialog's order even with controls underneath", () => {
+ const draw = (focus: number) => renderToScreen(({ ui }) => {
+ ui.button({ label: "Background" });
+ ui.modal({ buttons: [
+ { label: "Yes", variant: "success", onPress: () => {} },
+ { label: "No", variant: "ghost", onPress: () => {} },
+ ] });
+ }, { width: 80, height: 24, focus, theme: themes.dark });
+ const yes = draw(0);
+ const no = draw(1);
+ const yesAt = yes.find("Yes")!;
+ const noAt = no.find("No")!;
+ assert.equal(yes.cell(yesAt.x, yesAt.y).bg, themes.dark.success);
+ assert.equal(no.cell(noAt.x, noAt.y).bg, themes.dark.muted);
+ assert.notEqual(no.cell(yesAt.x, yesAt.y).bg, themes.dark.success);
+});
+
+test("replacing a dialog with fewer controls repaints its clamped focus without more input", async (t) => {
+ const { app, key } = harness();
+ t.after(() => app.stop());
+ let result = false;
+ app.render(({ ui }) => ui.modal({
+ buttons: (result ? ["Close"] : ["Yes", "No", "Force"]).map((label) => ({ label, onPress: () => {} })),
+ }));
+ void app.start();
+ await key("\x1b[Z"); // Force, index 2
+ result = true;
+ app.frame();
+ const frame = app.stats.frame;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ assert.ok(app.stats.frame > frame, "Close stayed unfocused until another event");
+});
From 9b5c63b43a5e77f2c217f12103c53f6f293efaf2 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Sun, 13 Sep 2026 07:37:51 +0000
Subject: [PATCH 2/2] Release hqtui and demo 0.6.2
---
apps/benchmark/package.json | 2 +-
apps/demo/package.json | 4 ++--
apps/demo/src/main.ts | 2 +-
apps/web/app/page.tsx | 2 +-
apps/web/package.json | 2 +-
bun.lock | 6 +++---
examples/package.json | 2 +-
packages/hqtui/package.json | 2 +-
packages/hqtui/src/cli.ts | 2 +-
ports/cobol/adapter/package.json | 2 +-
10 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json
index f8226ce..016005b 100644
--- a/apps/benchmark/package.json
+++ b/apps/benchmark/package.json
@@ -7,6 +7,6 @@
"start": "bun src/main.ts"
},
"dependencies": {
- "@profullstack/hqtui": "^0.6.1"
+ "@profullstack/hqtui": "^0.6.2"
}
}
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 2bb17ba..403fc9b 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui-demo",
- "version": "0.6.1",
+ "version": "0.6.2",
"description": "The HQTUI reference dashboard: a btop-grade terminal system monitor. Runs on real system metrics or a deterministic simulation.",
"license": "MIT",
"type": "module",
@@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
- "@profullstack/hqtui": "^0.6.1"
+ "@profullstack/hqtui": "^0.6.2"
},
"publishConfig": {
"access": "public"
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index 266c1ce..cb88015 100755
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -52,7 +52,7 @@ function parseArgs(argv: string[]): Options {
case "-h":
case "--help": printHelp(); process.exit(0);
case "-v":
- case "--version": console.log("hqtui-demo 0.6.1"); process.exit(0);
+ case "--version": console.log("hqtui-demo 0.6.2"); process.exit(0);
}
}
return options;
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 15e0cdf..7333f73 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -157,7 +157,7 @@ export default async function Home() {
High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++
- v0.6.1 · {COUNT} language demos · MIT
+ v0.6.2 · {COUNT} language demos · MIT
Terminal dashboards that
diff --git a/apps/web/package.json b/apps/web/package.json
index 9f17ccd..12dbc2e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,7 +9,7 @@
},
"dependencies": {
"@base-ui/react": "1.7.0",
- "@profullstack/hqtui": "^0.6.1",
+ "@profullstack/hqtui": "^0.6.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.37.0",
diff --git a/bun.lock b/bun.lock
index fc7fa6d..7205eed 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,7 +21,7 @@
},
"apps/demo": {
"name": "@profullstack/hqtui-demo",
- "version": "0.6.1",
+ "version": "0.6.2",
"bin": {
"hqtui-demo": "./src/main.ts",
},
@@ -63,14 +63,14 @@
},
"packages/hqtui": {
"name": "@profullstack/hqtui",
- "version": "0.6.1",
+ "version": "0.6.2",
"bin": {
"hqtui": "./bin/hqtui.mjs",
},
},
"ports/cobol/adapter": {
"name": "@profullstack/hqtui-cobol-adapter",
- "version": "0.6.1",
+ "version": "0.6.2",
"dependencies": {
"@profullstack/hqtui": "workspace:*",
},
diff --git a/examples/package.json b/examples/package.json
index 7bd7deb..413a19f 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -4,6 +4,6 @@
"version": "0.1.0",
"type": "module",
"dependencies": {
- "@profullstack/hqtui": "^0.6.1"
+ "@profullstack/hqtui": "^0.6.2"
}
}
diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json
index eb20461..18f86bd 100644
--- a/packages/hqtui/package.json
+++ b/packages/hqtui/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui",
- "version": "0.6.1",
+ "version": "0.6.2",
"description": "High Quality Terminal UI for TypeScript. btop-grade dashboards with a one-import API, dark by default, zero runtime dependencies.",
"license": "MIT",
"type": "module",
diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts
index 2416929..e0a40c0 100644
--- a/packages/hqtui/src/cli.ts
+++ b/packages/hqtui/src/cli.ts
@@ -13,7 +13,7 @@ import { detectCapabilities } from "./capabilities.ts";
import { themeList, themes } from "./theme.ts";
import { BrailleCanvas } from "./graphics/braille.ts";
-const VERSION = "0.6.1";
+const VERSION = "0.6.2";
function help(): void {
console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript
diff --git a/ports/cobol/adapter/package.json b/ports/cobol/adapter/package.json
index 2a9ee06..4d1c992 100644
--- a/ports/cobol/adapter/package.json
+++ b/ports/cobol/adapter/package.json
@@ -1,7 +1,7 @@
{
"name": "@profullstack/hqtui-cobol-adapter",
"private": true,
- "version": "0.6.1",
+ "version": "0.6.2",
"type": "module",
"description": "Reads 80-column COBOL scene records and draws them with HQTUI.",
"dependencies": {