Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/benchmark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"start": "bun src/main.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.6.1"
"@profullstack/hqtui": "^0.6.2"
}
}
10 changes: 10 additions & 0 deletions apps/benchmark/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
10 changes: 9 additions & 1 deletion apps/demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.6.1"
"@profullstack/hqtui": "^0.6.2"
},
"publishConfig": {
"access": "public"
Expand Down
113 changes: 113 additions & 0 deletions apps/demo/src/kill-dialog.ts
Original file line number Diff line number Diff line change
@@ -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<ProcessSample, "pid" | "name" | "command" | "user">;
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");
});
}
34 changes: 12 additions & 22 deletions apps/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -51,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;
Expand All @@ -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)
`);
}

Expand Down Expand Up @@ -185,12 +187,7 @@ async function main(): Promise<void> {
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;
Expand Down Expand Up @@ -246,7 +243,7 @@ async function main(): Promise<void> {
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;
Expand Down Expand Up @@ -319,6 +316,7 @@ async function main(): Promise<void> {
},
{ 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") },
Expand All @@ -330,13 +328,15 @@ async function main(): Promise<void> {
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` +
Expand All @@ -352,17 +352,7 @@ async function main(): Promise<void> {
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({
Expand Down
3 changes: 3 additions & 0 deletions apps/demo/src/state.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -48,6 +49,7 @@ export interface DemoState {
showHelp: boolean;
showPalette: boolean;
showModal: boolean;
killDialog: KillDialog | null;
paletteQuery: string;
paletteIndex: number;
themeIndex: number;
Expand Down Expand Up @@ -163,6 +165,7 @@ export function createState(
showHelp: false,
showPalette: false,
showModal: false,
killDialog: null,
paletteQuery: "",
paletteIndex: 0,
themeIndex: 0,
Expand Down
Loading