feat(tui): add vitest kill + memory-pressure auto-kill to Utilities

[k] kills all running vitest processes via pgrep -f. [v] toggles a
sampler-driven guard that SIGKILLs vitest when system memory exceeds
90%, throttled to once per 30s. Auto-kill defaults ON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-25 07:45:36 -07:00
parent d281cb2c1e
commit 43d630114b
5 changed files with 148 additions and 16 deletions

View File

@@ -589,11 +589,14 @@ function ExpandedLog({ entry, index, total }: { entry: LogEntry; index: number;
// ── Utilities panel ───────────────────────────────────────────────────────────
function UtilitiesPanel({ isFocused }: { isFocused: boolean }) {
const actions = [
function UtilitiesPanel({ state, isFocused }: { state: DashboardState; isFocused: boolean }) {
const autoKill = state.autoKillVitestOnPressure;
const actions: Array<{ key: string; label: string }> = [
{ key: "r", label: "Refresh Stats" },
{ key: "c", label: "Clear Logs" },
{ key: "t", label: "Toggle Engine Pause" },
{ key: "k", label: "Kill Vitest Processes" },
{ key: "v", label: `Auto-Kill Vitest >90% Mem: ${autoKill ? "ON" : "OFF"}` },
{ key: "?", label: "Help" },
];
return (
@@ -628,6 +631,8 @@ function HelpOverlay() {
["[←] / [p]", "Previous panel (Main)"],
["[r]", "Refresh stats (Utilities)"],
["[c]", "Clear logs (Utilities)"],
["[k]", "Kill all vitest processes (Utilities)"],
["[v]", "Toggle auto-kill vitest >90% mem (Utilities)"],
["[↑/↓/k/j]", "Navigate list / log entries"],
["[Home / G]", "First / last log entry (Logs)"],
["[Enter/Space]", "Expand log entry (Logs)"],
@@ -707,7 +712,7 @@ function StatusModeGrid({
/>
<Box flexDirection="row" overflow="hidden">
<Box flexDirection="column" flexGrow={1} overflow="hidden">
<UtilitiesPanel isFocused={focused === "utilities"} />
<UtilitiesPanel state={state} isFocused={focused === "utilities"} />
</Box>
<Box flexDirection="column" flexGrow={1} overflow="hidden">
<SettingsPanel state={state} isFocused={focused === "settings"} />
@@ -736,7 +741,7 @@ function StatusModeSingle({
switch (focused) {
case "system": return <SystemPanel state={state} isFocused />;
case "logs": return <LogsPanel state={state} isFocused availableRows={Math.max(4, (process.stdout.rows ?? 24) - 8)} />;
case "utilities": return <UtilitiesPanel isFocused />;
case "utilities": return <UtilitiesPanel state={state} isFocused />;
case "stats": return <StatsPanel state={state} isFocused />;
case "settings": return <SettingsPanel state={state} isFocused />;
}
@@ -764,7 +769,7 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
if (activeSection === "logs") {
hotkeys.push("↑↓ navigate", "w wrap", "f filter", "Enter expand");
} else if (activeSection === "utilities") {
hotkeys.push("r refresh", "c clear logs", "t toggle pause");
hotkeys.push("r refresh", "c clear logs", "t toggle pause", "k kill vitest", "v auto-kill");
} else {
hotkeys.push("Tab cycle panel", "1-5 jump");
}

View File

@@ -50,6 +50,12 @@ export class DashboardTUI {
logsViewportStart = 0;
loadingStatus = "Starting…";
mode: "status" | "interactive" = "status";
// When true, sampleSystemStats() kills any running vitest processes if
// system memory usage crosses 90%. Toggled by [v] in the Utilities panel.
autoKillVitestOnPressure = true;
// Throttle so we don't spam kills while the sampler keeps firing during
// sustained pressure (sampler runs every 2s).
private lastAutoKillAt = 0;
interactiveData: InteractiveData | null = null;
interactiveView: InteractiveView = "board";
@@ -110,6 +116,7 @@ export class DashboardTUI {
mode: this.mode,
interactiveData: this.interactiveData,
interactiveView: this.interactiveView,
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
};
return this.cachedSnapshot;
}
@@ -195,6 +202,68 @@ export class DashboardTUI {
nodeVersion: process.version,
platform: `${process.platform}/${process.arch}`,
});
if (this.autoKillVitestOnPressure) {
const total = os.totalmem();
const free = os.freemem();
if (total > 0) {
const usedRatio = (total - free) / total;
// 30s minimum gap between auto-kills — vitest restart and OS reclaim
// both take a few seconds; firing every 2s would flap.
if (usedRatio > 0.9 && now - this.lastAutoKillAt > 30_000) {
this.lastAutoKillAt = now;
const result = this.killVitestProcesses();
if (result.killed > 0) {
this.warn(
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%)`,
"memory-guard",
);
}
}
}
}
}
/**
* Find and SIGKILL any running vitest processes, excluding this dashboard
* itself. Returns a count of pids signalled (best-effort — a pid may be
* gone by the time we send the signal).
*/
killVitestProcesses(): { killed: number; pids: number[] } {
const selfPid = process.pid;
let pids: number[] = [];
try {
// pgrep -f matches against the full command line. -a would include the
// command, but we only need pids. macOS and Linux both support -f.
const out = execSync("pgrep -f vitest", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
pids = out
.split("\n")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
} catch {
// pgrep exits non-zero when no matches — treat as "nothing to kill".
return { killed: 0, pids: [] };
}
let killed = 0;
for (const pid of pids) {
try {
process.kill(pid, "SIGKILL");
killed += 1;
} catch {
// Process already exited or we lack permission — skip.
}
}
return { killed, pids };
}
toggleAutoKillVitest(): boolean {
this.autoKillVitestOnPressure = !this.autoKillVitestOnPressure;
if (!this.autoKillVitestOnPressure) {
this.lastAutoKillAt = 0;
}
this.notify();
return this.autoKillVitestOnPressure;
}
setSettings(settings: SettingsValues): void {
@@ -335,6 +404,26 @@ export class DashboardTUI {
this.setSettings(newSettings);
}
break;
case "k": {
const result = this.killVitestProcesses();
if (result.killed === 0) {
this.log("No vitest processes found.", "kill-vitest");
} else {
this.warn(
`Killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"}: ${result.pids.join(", ")}`,
"kill-vitest",
);
}
break;
}
case "v": {
const enabled = this.toggleAutoKillVitest();
this.log(
`Auto-kill vitest on memory pressure (>90%): ${enabled ? "ON" : "OFF"}`,
"memory-guard",
);
break;
}
}
}

View File

@@ -279,6 +279,7 @@ export interface DashboardState {
mode: AppMode;
interactiveData: InteractiveData | null;
interactiveView: InteractiveView;
autoKillVitestOnPressure: boolean;
}
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
@@ -302,5 +303,6 @@ export function createInitialState(): DashboardState {
mode: "status",
interactiveData: null,
interactiveView: "board",
autoKillVitestOnPressure: true,
};
}

View File

@@ -313,16 +313,18 @@ export function AgentLogViewer({
if (entry.type === "thinking") {
return (
<span key={rowKey} className="agent-log-thinking">
<div key={rowKey} className="agent-log-thinking">
{agentBadge}
{renderMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
</ReactMarkdown>
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
</ReactMarkdown>
</div>
) : (
entry.text
)}
</span>
</div>
);
}
@@ -346,16 +348,18 @@ export function AgentLogViewer({
// Default: text entries
return (
<span key={rowKey} className="agent-log-text">
<div key={rowKey} className="agent-log-text">
{agentBadge}
{renderMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
</ReactMarkdown>
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
</ReactMarkdown>
</div>
) : (
entry.text
)}
</span>
</div>
);
})}

View File

@@ -876,6 +876,32 @@ describe("AgentLogViewer", () => {
expect(textSpans[0].textContent).toContain("Hello world, this is plain text.");
});
it("renders text entries inside markdown-body in markdown mode", () => {
const entries = [
makeEntry({ text: "Paragraph one\n\nParagraph two" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const textRow = container.querySelector(".agent-log-text") as HTMLElement;
expect(textRow).toBeTruthy();
const proseContainer = textRow.querySelector(".markdown-body") as HTMLElement;
expect(proseContainer).toBeTruthy();
expect(proseContainer.querySelectorAll("p")).toHaveLength(2);
});
it("renders thinking entries inside markdown-body in markdown mode", () => {
const entries = [
makeEntry({ text: "Considering:\n\n- option A\n- option B", type: "thinking" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const thinkingRow = container.querySelector(".agent-log-thinking") as HTMLElement;
expect(thinkingRow).toBeTruthy();
const proseContainer = thinkingRow.querySelector(".markdown-body") as HTMLElement;
expect(proseContainer).toBeTruthy();
expect(proseContainer.querySelector("ul")).toBeTruthy();
});
it("renders inline markdown elements (bold, italic, inline code)", () => {
const entries = [
makeEntry({ text: "This is **bold** and *italic* with `inline code`." }),
@@ -1002,6 +1028,11 @@ describe("AgentLogViewer", () => {
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
// Markdown mode starts with prose container + rendered markdown
const markdownModeTextRow = container.querySelector(".agent-log-text") as HTMLElement;
expect(markdownModeTextRow.querySelector(".markdown-body")).toBeTruthy();
expect(markdownModeTextRow.querySelector("strong")?.textContent).toBe("bold");
// Click to switch to plain text mode
fireEvent.click(toggle);
@@ -1014,7 +1045,8 @@ describe("AgentLogViewer", () => {
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1);
expect(textSpans[0].textContent).toContain("**bold** and *italic*");
// No markdown elements should be present
// Plain mode should remove markdown rendering/prose container
expect(textSpans[0].querySelector(".markdown-body")).toBeNull();
expect(textSpans[0].querySelector("strong")).toBeNull();
expect(textSpans[0].querySelector("em")).toBeNull();
});