diff --git a/README.md b/README.md index 5c8f19f..f2a2e4e 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,46 @@ shell_status # list every background command this se shell_kill { id: "bg-2" } # stop it and its whole process tree ``` -`/shell-bg` lists the jobs; `/shell-bg kill bg-2` stops one. +### `/shell-bg` + +| Command | What it does | +|---|---| +| `/shell-bg tui` or `/shell-bg status` | Opens the job browser: a centered overlay listing every job with its status, searchable by id or command. | +| `/shell-bg status bg-2` | Opens that job's output directly. | +| `/shell-bg kill` | Picks a running job to stop. | +| `/shell-bg kill bg-2` | Stops that job and its whole process tree. | +| `/shell-bg list` | Shows the job list as a notification. | + +A bare `/shell-bg` opens nothing; while you type it, the completion list shows the subcommands. + +The arguments autocomplete. Subcommands come first (`tui`, `status`, `kill`, `list`), then job ids: `kill` offers only jobs that are still running, and `status` offers every job, with its status icon and command. Accepting `kill` or `status` from the list opens the job list straight away, and Tab after them lists jobs, not file paths. + +In the browser, each job's row is colored by its status, and a legend under the list names the colors: running, done, failed, killed, orphaned (its session ended). Each row ends with how long the job ran. ↑↓ moves the selection, typing filters, Enter opens a job, and Esc or the ✕ in the top-right corner closes. + +A job's view shows its log history, up to the last 2 MiB, which is more than the tail the model sees. ↑↓, PgUp/PgDn and Home/End scroll it, and it follows new lines while you are at the bottom. ← or Backspace goes back to the list, and `k` kills a running job. + +A kill from the overlay always asks first. This covers `k`, `[✗ kill]`, and Enter or a click in `/shell-bg kill`'s picker. The bottom row turns into `[✗ Kill bg-2] [Cancel]` with the job's command. Enter, `y` or the Kill button stops the job. Esc, `n` or Cancel keeps it, and Esc here does not close the overlay. A `/shell-bg kill bg-2` typed with its id is already deliberate, so it stops the job without asking. + +The overlay's size follows the terminal. Its width is a share of the terminal's width. The list is as tall as its jobs, up to about 70% of the terminal rows. A job's view is always exactly that 70%, however short or long its log, so the window does not jump while output streams in. A resize changes both. + +In pi's fullscreen mode (the default) the mouse works too: + +- The wheel scrolls. +- A click opens a job or presses `[‹ back]` / `[✗ kill]`, and a click on the ✕ closes the overlay. +- A click on a job row in the widget above the editor opens that job's output. +- Dragging over the log selects text. Double-click selects a word, triple-click a row. + +A selection is copied the way pi copies one, through pi's clipboard path with its "Copied!" flash. It follows pi's `fullscreenCopyOnSelect` setting: on release, or with pi's copy key (`app.message.copy`, `ctrl+x` by default) when that setting is off. The overlay draws its own selection, so a selection spanning rows copies only the log text, without the window's border or the screen behind it. pi passes mouse events only in fullscreen mode, so every action except selecting text also has a key. ## Delivery, and the headless caveat When a backgrounded command finishes in an **interactive** session, its result is pushed into the conversation as the next turn — you do not have to poll. Under headless `pi -p` there is nothing to deliver into (the session tears down when the prompt resolves), so **auto-background is disabled there** and only explicit `background: true` applies; collect it within the same turn with `shell_status { id, wait: N }`, which blocks (up to `N` seconds, 0–300) until the command finishes rather than returning immediately. This is the same delivery rule the rest of the suite lives by. A job's result is pushed only at the end of the current turn, not the instant the command exits, so a job you collect during the turn with `shell_status {id}` (with or without `wait`) is never also delivered as a follow-up — the model never spends a turn acknowledging a result it has just reported. A job nobody collects is delivered once when the turn ends, and a job that finishes while the agent is idle wakes it right away. +A kill is never news to whoever asked for it: + +- **`shell_kill`** waits for the job to exit, up to 3.5s, past the 3s SIGTERM→SIGKILL escalation. It then reports the final status and output in its own result, and the job is not delivered again. A job still alive after the wait is delivered when it does exit. +- **A job you stop from `/shell-bg`** (`kill`, or `k` / `[✗ kill]` in the browser) does not wake the model. Its result says the user stopped it, so the model does not take it for a crash and restart it. The message is handed to the model with your next message, using pi's `nextTurn` delivery. + ## How it works Each command is spawned with its stdout and stderr piped into a single log file, drained on every chunk so nothing is lost no matter how much it prints, and finalized only after the pipes end (with a short grace so a daemonized grandchild that holds a handle open cannot truncate the tail). The process is spawned detached (POSIX) and `unref`'d so a running job never holds the host open, and killed as a whole process tree — `taskkill /T` on Windows, a process-group signal on POSIX — on timeout, abort, `shell_kill`, or session shutdown. diff --git a/extensions/shell-background.ts b/extensions/shell-background.ts index a75844d..4290580 100644 --- a/extensions/shell-background.ts +++ b/extensions/shell-background.ts @@ -26,13 +26,21 @@ * byte-identical shell behaviour. Zero runtime dependencies. */ import { + copyToClipboard, createBashToolDefinition, getAgentDir, getShellConfig, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; -import { Text } from "@earendil-works/pi-tui"; +import { + truncateToWidth, + type AutocompleteProvider, + type Component, + type TUI, + type TuiMouseEvent, + type TuiMouseEventResult, +} from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -50,7 +58,15 @@ import { formatResult, formatList, header } from "../src/format.ts"; import { sanitizeOutput } from "../src/sanitize.ts"; import { readFull } from "../src/tail.ts"; import { STRUCTURED_OUTPUT_MAX_BYTES, bashOutputSchema, buildStructured, type BashStructured } from "../src/structured.ts"; -import { buildWidgetLines } from "../src/widget.ts"; +import { buildWidget } from "../src/widget.ts"; +import { JobBrowser, type BrowserOptions } from "../src/browser.ts"; +import { + SHELL_BG_USAGE, + completeShellBg, + parseShellBgArgs, + shellBgArgument, + wantsFollowUpCompletion, +} from "../src/commands.ts"; import { isFinished } from "../src/types.ts"; import type { Job } from "../src/types.ts"; @@ -65,6 +81,29 @@ type ToolResult = { }; const WIDGET = "shell-bg"; +const TUI_PROBE = "shell-bg-tui-probe"; + +/** + * The aboveEditor box as a clickable component (fullscreen mode routes mouse + * events to components): a job row opens that job's output, any other row the + * job browser. A click only fires where the press was, so its row is the one. + */ +function clickableWidget(lines: string[], rowIds: Array, onOpen: (jobId?: string) => void): Component { + return { + render: (width) => lines.map((l) => truncateToWidth(l, width)), + handleMouse: (e: TuiMouseEvent): TuiMouseEventResult | undefined => { + if (e.button !== "left" || e.y < 0 || e.y >= lines.length) return undefined; + if (e.type === "press") return { handled: true, render: false }; + if (e.type !== "click") return undefined; + onOpen(rowIds[e.y] ?? undefined); + return { handled: true }; + }, + invalidate: () => {}, + }; +} + +/** Terminal UI available (the overlay and the editor hooks are TUI-only; RPC gets text). */ +const isTui = (ctx: UiContext): boolean => ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui"); export default function shellBackground(pi: ExtensionAPI) { let settings: ShellBgSettings = DEFAULT_SETTINGS; @@ -85,6 +124,8 @@ export default function shellBackground(pi: ExtensionAPI) { // inside an in-flight tool call, so the timer must actually resolve rather than // let an otherwise-idle loop exit out from under it. const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + /** How long shell_kill waits for the exit: past killTree's 3s SIGKILL escalation, so a SIGTERM-ignoring job is still reported once. */ + const KILL_WAIT_MS = 3500; /** * The one auto-background threshold, computed once so the bash guideline and @@ -172,16 +213,33 @@ export default function shellBackground(pi: ExtensionAPI) { } function renderWidget(ctx: UiContext | null = lastUiCtx): void { - if (!ctx || !ctx.hasUI || !registry) return; + // Called from timers too: a ctx made stale by a reload or session switch + // throws on access, and an exception in a timer would crash pi. + try { + renderWidgetWith(ctx); + } catch { + stopWidgetTimer(); + } + } + + function renderWidgetWith(ctx: UiContext | null): void { + if (handedOff || !ctx || !ctx.hasUI || !registry) return; lastUiCtx = ctx; const now = Date.now(); - const lines = buildWidgetLines(registry.all(), ctx.ui.theme as never, now); + const { lines, rowIds } = buildWidget(registry.all(), ctx.ui.theme as never, now); if (lines.length === 0) { ctx.ui.setWidget(WIDGET, undefined); stopWidgetTimer(); return; } - ctx.ui.setWidget(WIDGET, (_tui: unknown) => new Text(lines.join("\n"), 0, 0), { placement: "aboveEditor" }); + ctx.ui.setWidget( + WIDGET, + (tui: unknown) => { + captureTui(tui); + return clickableWidget(lines, rowIds, (jobId) => void openBrowser(lastUiCtx, { jobId })); + }, + { placement: "aboveEditor" }, + ); // Keep the box live between events: tick the elapsed clock while jobs run and // clear a finished job once it falls out of its 15s window, even if nothing // else fires a render. Unref'd, so a lingering box never holds the host open. @@ -191,6 +249,160 @@ export default function shellBackground(pi: ExtensionAPI) { } } + // ── job browser ──────────────────────────────────────────────────── + + let browserOpen = false; + const BROWSER_HISTORY_BYTES = 2 * 1024 * 1024; + + /** + * Copy browser-selected text the way pi copies a fullscreen selection: its + * renderer's clipboard path (native clipboard, then OSC 52) with its + * "Copied!" flash. Without that renderer, pi's exported clipboard helper. + */ + function copyText(tui: TUI, ctx: UiContext, text: string): void { + const viewport = tui as unknown as { copyTextToClipboard?: (t: string) => Promise }; + const done = + typeof viewport.copyTextToClipboard === "function" + ? viewport.copyTextToClipboard(text) + : copyToClipboard(text).then( + () => ctx.ui.notify("Copied!", "info"), + (err: unknown) => ctx.ui.notify(`Copy failed: ${err instanceof Error ? err.message : String(err)}`, "error"), + ); + done.catch(() => {}); // e.g. a ctx gone stale while copying — never an unhandled rejection + } + + // ── /shell-bg autocomplete ───────────────────────────────────────────── + + // The TUI, kept from any factory pi hands it to, so an accepted completion + // can ask the editor to open the next list. + type FocusTracker = { getFocusedComponent(): unknown }; + let tuiRef: FocusTracker | null = null; + function captureTui(tui: unknown): void { + if (tui && typeof (tui as FocusTracker).getFocusedComponent === "function") tuiRef = tui as FocusTracker; + } + + /** + * pi's editor closes the completion list when an item is accepted and only + * reopens it on the next letter typed — so after `/shell-bg kill ` the jobs + * did not show until a key was pressed. Reopen it once the editor is done + * with the keypress. pi has no public call for this; the editor's own + * trigger is used when present, and without it Tab still opens the list. + */ + function reopenCompletions(): void { + setTimeout(() => { + type Editor = { tryTriggerAutocomplete?(): void; isShowingAutocomplete?(): boolean; getText?(): string }; + const editor = tuiRef?.getFocusedComponent() as Editor | null | undefined; + if (!editor?.tryTriggerAutocomplete || editor.isShowingAutocomplete?.()) return; + if (wantsFollowUpCompletion(editor.getText?.() ?? "")) editor.tryTriggerAutocomplete(); + }, 0); + } + + /** + * Wrap pi's autocomplete so `/shell-bg` arguments are always ours: the base + * provider only consults a command's completions on typing, and a Tab after + * `/shell-bg kill ` fell through to file paths. Everything else is passed on. + */ + function wrapAutocomplete(current: AutocompleteProvider): AutocompleteProvider { + // A slash command lives on the editor's first line. + const argAt = (lines: string[], line: number, col: number) => (line === 0 ? shellBgArgument((lines[0] ?? "").slice(0, col)) : null); + const ours: Partial = { + async getSuggestions(lines, line, col, options) { + const arg = argAt(lines, line, col); + if (arg === null) return current.getSuggestions(lines, line, col, options); + const items = completeShellBg(arg, registry?.all() ?? []); + return items ? { items, prefix: arg } : null; + }, + applyCompletion(lines, line, col, item, prefix) { + const result = current.applyCompletion(lines, line, col, item, prefix); + const after = result.cursorLine === 0 ? (result.lines[0] ?? "").slice(0, result.cursorCol) : ""; + if (wantsFollowUpCompletion(after)) reopenCompletions(); + return result; + }, + shouldTriggerFileCompletion(lines, line, col) { + if (argAt(lines, line, col) !== null) return true; + return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true; + }, + }; + return new Proxy(current, { + get(target, prop) { + if (Object.prototype.hasOwnProperty.call(ours, prop)) return (ours as Record)[prop]; + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + } + + /** Stop a running job from the UI (command, browser), saying so. */ + function killFromUi(job: Job, ctx: UiContext): void { + if (job.status !== "running") { + ctx.ui.notify(`${job.id} already ${job.status}.`, "warning"); + return; + } + job.killedByUs = true; + job.stoppedByUser = true; + registry?.persist(job); // so an instance adopting it after /reload knows too + killTree(job.pid); + ctx.ui.notify(`Killing ${job.id}.`, "info"); + } + + /** + * What the browser shows for a job: the cleaned log history — far more than + * the model-facing tail, so scrolling up reaches old output — with a note + * when even that is not the whole log. + */ + function browserOutput(job: Job): string { + const tail = readTail(job.logPath, Math.max(settings.tailBytes, BROWSER_HISTORY_BYTES)); + if (tail.missing) return `(log unavailable: ${job.logPath} could not be read)`; + const text = sanitizeOutput(tail.text); + if (!tail.truncated) return text; + const kb = Math.round(Buffer.byteLength(tail.text, "utf8") / 1024); + return `… the last ${kb}KB of a ${Math.round(tail.bytes / 1024)}KB log — full log: ${job.logPath}\n${text}`; + } + + /** + * Open the centered job browser overlay (TUI only). Width is a share of the + * terminal picked from its size at open — so a resize keeps the proportion — + * and the component sizes its height from the terminal rows. + */ + async function openBrowser(ctx: UiContext | null, options: BrowserOptions = {}): Promise { + if (!ctx || !isTui(ctx) || !registry || browserOpen) return; + browserOpen = true; + try { + let columns = 100; + await ctx.ui.custom( + (tui, theme, keybindings, done) => { + captureTui(tui); + columns = tui.terminal?.columns ?? columns; + // pi copies a selection on release, or — with fullscreenCopyOnSelect off — with its app.message.copy key. + const copyAction = "app.message.copy" as never; + const viewport = tui as unknown as { getCopyOnSelect?: () => boolean }; + return new JobBrowser(theme as never, { + jobs: () => registry?.all() ?? [], + output: browserOutput, + kill: (job) => killFromUi(job, ctx), + rows: () => tui.terminal?.rows ?? 30, + requestRender: () => tui.requestRender(), + close: () => done(undefined), + copy: (text) => copyText(tui, ctx, text), + copyOnSelect: () => viewport.getCopyOnSelect?.() ?? true, + isCopyKey: (data) => keybindings.matches?.(data, copyAction) ?? false, + copyKeyLabel: String(keybindings.getKeys?.(copyAction)?.[0] ?? "ctrl+x"), + }, options); + }, + { + overlay: true, + overlayOptions: () => { + // ~160 columns where there is room (at least 70% of the width), nearly all of it where there is not. + const pct = Math.max(70, Math.min(96, Math.round((160 / Math.max(1, columns)) * 100))); + return { anchor: "center", width: `${pct}%`, minWidth: Math.min(60, columns), maxHeight: "80%", margin: 1 }; + }, + }, + ); + } finally { + browserOpen = false; + } + } + // ── run ──────────────────────────────────────────────────────────── function snapshot(job: Job): ToolResult { @@ -219,14 +431,17 @@ export default function shellBackground(pi: ExtensionAPI) { function deliver(job: Job): void { renderWidget(); if (!settings.deliverResults) return; + const stoppedByUser = job.stoppedByUser === true; pi.sendMessage( { customType: DELIVERY_TYPE, - content: deliveryMessage(job.id, formatResult(job, settings.tailBytes, settings.tailLines)), + content: deliveryMessage(job.id, formatResult(job, settings.tailBytes, settings.tailLines), { stoppedByUser }), display: true, details: { id: job.id, status: job.status, exitCode: job.exitCode }, }, - { deliverAs: "followUp", triggerTurn: true }, + // A job the user stopped by hand needs no answer: it rides along with the + // user's next message instead of waking the model for a turn (issue #3). + stoppedByUser ? { deliverAs: "nextTurn" } : { deliverAs: "followUp", triggerTurn: true }, ); } @@ -609,7 +824,7 @@ export default function shellBackground(pi: ExtensionAPI) { promptSnippet: "Stop a backgrounded command", description: "Terminate a background command and its whole process tree by id.", parameters: Type.Object({ id: Type.String({ description: "Job id, e.g. bg-1" }) }), - async execute(_id: string, params: { id: string }): Promise { + async execute(_id: string, params: { id: string }, sig?: AbortSignal): Promise { if (!registry) return { content: [{ type: "text", text: "shell-background not initialized" }], details: {}, isError: true }; const job = registry.get(params.id?.trim()); if (!job) return { content: [{ type: "text", text: `No job "${params.id}".` }], details: {}, isError: true }; @@ -618,27 +833,84 @@ export default function shellBackground(pi: ExtensionAPI) { } job.killedByUs = true; killTree(job.pid); - return { content: [{ type: "text", text: `Killing ${job.id} (pid ${job.pid ?? "?"}) and its process tree.` }], details: { id: job.id } }; + // The model asked for this kill, so it must not also get the result as a + // follow-up that wakes it for a turn of acknowledging it (issue #3). Wait + // for the exit, holding delivery back like a shell_status wait, and report + // it here. A poll of job.status, so an adopted job (no settle) works too. + waiters.set(job.id, (waiters.get(job.id) ?? 0) + 1); + try { + const deadline = Date.now() + KILL_WAIT_MS; + while (job.status === "running" && Date.now() < deadline && !sig?.aborted) await sleep(100); + } finally { + waiters.set(job.id, Math.max(0, (waiters.get(job.id) ?? 1) - 1)); + } + if (isFinished(job)) { + job.delivered = true; + pending.delete(job); + registry.persist(job); + return { + content: [{ type: "text", text: `${formatResult(job, settings.tailBytes, settings.tailLines)}\n\n[stopped with shell_kill]` }], + details: { id: job.id, status: job.status, exitCode: job.exitCode }, + structuredContent: structured(job), + }; + } + // Still alive: its exit is news the model does not have, so delivery stays on. + return { + content: [ + { + type: "text", + text: `Kill signal sent to ${job.id} (pid ${job.pid ?? "?"}) and its process tree, but it had not exited after ${KILL_WAIT_MS / 1000}s. Its result is delivered when it exits; shell_status ${job.id} checks it.`, + }, + ], + details: { id: job.id, status: job.status }, + }; }, }); pi.registerCommand("shell-bg", { - description: "Background shell jobs: /shell-bg [kill ]", + description: "Background shell jobs: /shell-bg [tui | status [id] | kill [id] | list]", + // `kill` completes only running jobs; `status` every job of this session. + getArgumentCompletions: (prefix: string) => completeShellBg(prefix, registry?.all() ?? []), handler: async (args, ctx: UiContext) => { if (!ctx.hasUI || !registry) return; - const [verb, id] = (args ?? "").trim().split(/\s+/); - if (verb === "kill" && id) { - const job = registry.get(id); - if (job && job.status === "running") { - job.killedByUs = true; - killTree(job.pid); - ctx.ui.notify(`Killing ${job.id}.`, "info"); - } else { - ctx.ui.notify(job ? `${id} already ${job.status}.` : `No job "${id}".`, "warning"); + lastUiCtx = ctx; + const tui = isTui(ctx); // an RPC client gets the text form instead of the overlay + const action = parseShellBgArgs(args); + switch (action.kind) { + case "none": + // A bare /shell-bg opens nothing; the completion list shows the subcommands. + return; + case "list": + ctx.ui.notify(formatList(registry.all()), "info"); + return; + case "browse": + if (tui) await openBrowser(ctx); + else ctx.ui.notify(formatList(registry.all()), "info"); + return; + case "status": { + const job = registry.get(action.id); + if (!job) { + ctx.ui.notify(`No job "${action.id}". Known: ${registry.all().map((j) => j.id).join(", ") || "(none)"}`, "warning"); + return; + } + if (tui) await openBrowser(ctx, { jobId: job.id }); + else ctx.ui.notify(formatResult(job, settings.tailBytes, settings.tailLines), "info"); + return; } - return; + case "pick-kill": + if (registry.running().length === 0) ctx.ui.notify("No running background jobs.", "info"); + else if (tui) await openBrowser(ctx, { mode: "kill" }); + else ctx.ui.notify(`${SHELL_BG_USAGE} — running: ${registry.running().map((j) => j.id).join(", ")}`, "info"); + return; + case "kill": { + const job = registry.get(action.id); + if (!job) ctx.ui.notify(`No job "${action.id}".`, "warning"); + else killFromUi(job, ctx); + return; + } + default: + ctx.ui.notify(`Unknown subcommand "${action.verb}". ${SHELL_BG_USAGE}`, "warning"); } - ctx.ui.notify(formatList(registry.all()), "info"); }, }); @@ -657,7 +929,10 @@ export default function shellBackground(pi: ExtensionAPI) { if (adopted.length === 0) return; lastUiCtx = ctx; const timer = setInterval(() => { - if (!registry) { + // Handed off on /reload: the next instance adopts these jobs itself, and + // this instance's ctx is stale (touching it throws inside a timer, which + // takes pi down) — so this poller stops here. + if (!registry || handedOff) { clearInterval(timer); return; } @@ -666,7 +941,11 @@ export default function shellBackground(pi: ExtensionAPI) { if (job.status === "running") { const disk = registry.readSidecar(job.id); if (disk && disk.status !== "running") { - job.status = disk.status; + // The old instance settled it without knowing this one killed it. + // On POSIX the signal still reads as killed; on Windows taskkill + // leaves only exit code 1, which it records as failed. A kill sent + // from here is a kill, as settle() decides for its own jobs. + job.status = job.killedByUs && disk.status !== "orphaned" ? "killed" : disk.status; job.exitCode = disk.exitCode; job.signal = disk.signal; job.endedAt = disk.endedAt ?? Date.now(); @@ -706,6 +985,15 @@ export default function shellBackground(pi: ExtensionAPI) { registry = new JobRegistry(join(ROOT, key)); registry.load(); registerBash(ctx.cwd, ctx.hasUI); + if (isTui(ctx)) { + // pi drops wrappers whenever it rebuilds the session's UI, and session_start follows, so add it here. + ctx.ui.addAutocompleteProvider?.(wrapAutocomplete); + // Borrow the TUI from a widget factory (pi calls it at once), then drop the empty widget. + ctx.ui.setWidget(TUI_PROBE, (tui: unknown) => (captureTui(tui), { render: () => [], invalidate: () => {} }), { + placement: "belowEditor", + }); + ctx.ui.setWidget(TUI_PROBE, undefined); + } renderWidget(ctx); adoptRunning(ctx); if (warnings.length > 0 && ctx.hasUI) ctx.ui.notify(`shell-background settings: ${warnings.join("; ")}`, "warning"); diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..b806b05 --- /dev/null +++ b/src/browser.ts @@ -0,0 +1,885 @@ +/** + * The `/shell-bg` job browser: a centered overlay with two views. + * + * list every job (or, picking a job to kill, only running ones), newest + * first, filtered live by a search box that matches the id or the + * command. Each row is colored by its status and ends with how long + * the job ran. ↑↓ / wheel select, enter / click opens, and a scrollbar + * on the right edge drags the view when the list is taller than it. + * detail one job: its command, status and its log history (cleaned), + * scrollable with ↑↓ / PgUp / PgDn / Home / End, the wheel, or the + * scrollbar on the right edge, and following new output while + * scrolled to the bottom. Dragging the mouse selects log text + * (double-click a word, triple-click a line) and copies it through + * pi's own clipboard path. + * + * Esc closes the overlay from either view; ← / backspace / "‹ back" returns + * from a job to the list. Every mouse action has a keyboard path, since pi + * only routes mouse events to components in fullscreen mode. + * + * Sizing follows the terminal: width is a share of it (chosen by the + * extension). The list is as tall as its jobs, up to ~70% of the rows; a job's + * detail is always exactly that 70%, however short or long its log, so the + * window does not jump while output streams in. Both are re-measured on every + * render, so a resize just works. + */ +import { + Input, + matchesKey, + sliceByColumn, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Focusable, + type TuiMouseEvent, + type TuiMouseEventResult, +} from "@earendil-works/pi-tui"; +import type { Job } from "./types.ts"; +import { matchesJob, sortJobs } from "./commands.ts"; +import { SHELL_BG_ICON, STATUS_ORDER, elapsed, oneLine, statusColor, statusIcon, type WidgetTheme } from "./widget.ts"; + +export type BrowserTheme = WidgetTheme & { bg(color: string, text: string): string }; + +export interface BrowserSource { + /** The live job list; read on every render, so status changes show up. */ + jobs(): Job[]; + /** The job's cleaned output (its log history). */ + output(job: Job): string; + /** Stop a running job (the caller notifies). */ + kill(job: Job): void; + /** Terminal height, for sizing. */ + rows(): number; + requestRender(): void; + /** Close the overlay. */ + close(): void; + /** Put text on the clipboard (pi's path). Absent: selection still works, nothing is copied. */ + copy?(text: string): void; + /** pi's "copy on select" setting; when false, a selection waits for pi's copy key (or y). */ + copyOnSelect?(): boolean; + /** Is this keypress pi's copy key (app.message.copy, ctrl+x by default)? */ + isCopyKey?(data: string): boolean; + /** That key, for the hint line. */ + copyKeyLabel?: string; +} + +export interface BrowserOptions { + /** "kill": list only running jobs, and enter/click stops the chosen one. */ + mode?: "view" | "kill"; + /** Open straight into this job's detail. */ + jobId?: string; +} + +type Button = { x0: number; x1: number; action: () => void }; + +/** One display row of the log: a hard-wrapped slice of a logical line. */ +interface BodyLine { + text: string; + /** True when this row continues the previous one's logical line (a wrap, not a newline). */ + cont: boolean; +} + +/** A position in the wrapped log: row index and visible column. */ +interface Pos { + line: number; + col: number; +} + +const REFRESH_MS = 1000; +const AUTOSCROLL_MS = 60; +/** Left edge of content inside the frame: "│ ". */ +const CONTENT_X = 2; +/** Height share of the terminal the overlay may grow to. */ +const HEIGHT_SHARE = 0.7; + +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +function statusLabel(job: Job): string { + if (job.status === "running") return "running"; + if (job.status === "orphaned") return "orphaned"; + if (job.signal) return `${job.status} (${job.signal})`; + if (job.status === "killed") return "killed"; + return `${job.status} (exit ${job.exitCode ?? "?"})`; +} + +function clock(ms: number): string { + const d = new Date(ms); + const p = (n: number) => String(n).padStart(2, "0"); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +/** + * Hard-wrap one logical line to `width` columns. Exact (no word breaking, no + * dropped spaces), so a selection across a wrap copies back the original line. + */ +export function hardWrap(line: string, width: number): string[] { + if (line === "") return [""]; + const w = Math.max(1, width); + // eslint-disable-next-line no-control-regex + if (/^[\x20-\x7e]*$/.test(line)) { + const out: string[] = []; + for (let i = 0; i < line.length; i += w) out.push(line.slice(i, i + w)); + return out; + } + const out: string[] = []; + let cur = ""; + let curW = 0; + for (const { segment } of segmenter.segment(line)) { + const gw = visibleWidth(segment); + if (curW + gw > w && cur !== "") { + out.push(cur); + cur = ""; + curW = 0; + } + cur += segment; + curW += gw; + } + out.push(cur); + return out; +} + +export class JobBrowser implements Focusable { + private readonly theme: BrowserTheme; + private readonly src: BrowserSource; + private readonly mode: "view" | "kill"; + private readonly input: Input; + private view: "list" | "detail" = "list"; + private isFocused = false; + + // list state + private selectedId: string | null = null; + private listOffset = 0; + private listRows = 1; + + // detail state + private detailId: string | null = null; + private scroll = 0; + private follow = true; + private bodyRows = 1; + private bodyTop = 0; + private textW = 1; + /** `final`: read after the job ended, so the log will not change again. */ + private outputCache: { id: string; text: string; final: boolean; stale: boolean } | null = null; + private body: BodyLine[] = []; + private bodyKey: { id: string; text: string; width: number; lastStart: number; lastRow: number } | null = null; + + // selection state (detail view) + private sel: { anchor: Pos; focus: Pos } | null = null; + private selecting = false; + private dragged = false; + private autoScrollDir = 0; + private autoScrollTimer: NodeJS.Timeout | null = null; + + // scrollbar state (both views share the one column at the right edge; + // geometry is rebuilt by every render, in overlay-local coordinates) + private barX = -1; // first column that counts as the bar: the bar cell, the inner pad and the border after it + private barTop = 0; // the track's first row + private barRows = 0; // the track's height — 0 while the view fits, so its edge stays plain rows + private barThumbY = 0; // the thumb's first row + private barThumbH = 0; + /** A scrollbar drag in progress: where inside the thumb the press landed. */ + private barDrag: { grabOffset: number } | null = null; + + /** A kill waiting for a second yes: the job id. Every kill from the overlay asks first. */ + private confirmId: string | null = null; + + // mouse layout, rebuilt by every render (overlay-local rows) + private rowIds = new Map(); + private buttons = new Map(); + + private readonly timer: NodeJS.Timeout; + + constructor(theme: BrowserTheme, src: BrowserSource, options: BrowserOptions = {}) { + this.theme = theme; + this.src = src; + this.mode = options.mode ?? "view"; + this.input = new Input({ prompt: "", placeholder: "type to filter by id or command" }); + if (options.jobId && this.mode === "view") { + this.selectedId = options.jobId; + this.openDetail(options.jobId); + } + // Keep elapsed clocks, statuses and a growing log live while open. + this.timer = setInterval(() => { + if (this.outputCache) this.outputCache.stale = true; // outputOf() re-reads a log that can still grow + this.src.requestRender(); + }, REFRESH_MS); + this.timer.unref?.(); + } + + get focused(): boolean { + return this.isFocused; + } + + set focused(value: boolean) { + this.isFocused = value; + // Only the list shows the search box; its cursor must follow our focus (IME placement). + this.input.focused = value && this.view === "list"; + } + + /** Which view is showing, and for detail which job — for the extension and tests. */ + state(): { view: "list" | "detail"; jobId: string | null; query: string } { + return { view: this.view, jobId: this.view === "detail" ? this.detailId : this.selectedId, query: this.input.getValue() }; + } + + /** The job a kill is waiting to be confirmed for, if any. */ + pendingKill(): string | null { + return this.confirmId; + } + + /** The currently selected log text, if any. */ + selectedText(): string | undefined { + const range = this.selRange(); + if (!range) return undefined; + const { s, e } = range; + let out = ""; + for (let i = s.line; i <= e.line; i++) { + const row = this.body[i]; + if (!row) break; + const rowW = visibleWidth(row.text); + const a = i === s.line ? Math.min(s.col, rowW) : 0; + const b = i === e.line ? Math.min(e.col + 1, rowW) : rowW; + if (i > s.line && !row.cont) out += "\n"; + if (b > a) out += sliceByColumn(row.text, a, b - a, true); + } + return out === "" ? undefined : out; + } + + dispose(): void { + clearInterval(this.timer); + this.stopAutoScroll(); + } + + invalidate(): void { + this.bodyKey = null; + this.input.invalidate(); + } + + // ── data ─────────────────────────────────────────────────────────── + + private filtered(): Job[] { + const all = this.src.jobs(); + const pool = this.mode === "kill" ? all.filter((j) => j.status === "running") : all; + const q = this.input.getValue(); + return sortJobs(pool).filter((j) => matchesJob(j, q)); + } + + private job(id: string | null): Job | undefined { + return id ? this.src.jobs().find((j) => j.id === id) : undefined; + } + + /** The log, re-read each tick until a read made after the job ended. */ + private outputOf(job: Job): string { + const c = this.outputCache; + if (c && c.id === job.id && (c.final || !c.stale)) return c.text; + const text = this.src.output(job); + this.outputCache = { id: job.id, text, final: job.status !== "running", stale: false }; + return text; + } + + /** Wrap the log to rows, incrementally when it only grew (a running job). */ + private wrapBody(id: string, text: string, width: number): BodyLine[] { + const k = this.bodyKey; + if (k && k.id === id && k.width === width && k.text === text) return this.body; + let from = 0; + if (k && k.id === id && k.width === width && text.startsWith(k.text)) { + // Appended output: re-wrap only from the start of the last logical line. + from = k.lastStart; + this.body.length = k.lastRow; + } else { + this.body = []; + this.sel = null; // rows shifted (log rotated past the window, resize, other job) + } + let lastStart = from; + let lastRow = this.body.length; + const tail = text.slice(from); + const logical = tail.split("\n"); + let offset = from; + logical.forEach((raw, idx) => { + if (idx === logical.length - 1) { + lastStart = offset; + lastRow = this.body.length; + } + const rows = hardWrap(raw.replace(/\t/g, " "), width); + rows.forEach((r, j) => this.body.push({ text: r, cont: j > 0 })); + offset += raw.length + 1; + }); + this.bodyKey = { id, text, width, lastStart, lastRow }; + return this.body; + } + + // ── actions ──────────────────────────────────────────────────────── + + private openDetail(id: string): void { + this.view = "detail"; + this.detailId = id; + this.scroll = 0; + this.follow = true; // land on the newest output + this.outputCache = null; + this.bodyKey = null; + this.body = []; + this.sel = null; + this.barDrag = null; + this.input.focused = false; + } + + private backToList(): void { + this.view = "list"; + if (this.detailId) this.selectedId = this.detailId; + this.detailId = null; + this.sel = null; + this.selecting = false; + this.stopAutoScroll(); + this.barDrag = null; + this.input.focused = this.isFocused; + } + + private activate(job: Job | undefined): void { + if (!job) return; + if (this.mode === "kill") this.askKill(job); + else this.openDetail(job.id); + } + + /** A kill from the overlay is never one keypress or click away: ask first. */ + private askKill(job: Job | undefined): void { + if (job && job.status === "running") this.confirmId = job.id; + } + + private confirmKill(): void { + const job = this.job(this.confirmId); + this.confirmId = null; + if (job && job.status === "running") this.src.kill(job); + if (this.mode === "kill") this.src.close(); + } + + private moveSelection(delta: number): void { + const list = this.filtered(); + if (list.length === 0) return; + let i = list.findIndex((j) => j.id === this.selectedId); + if (i < 0) i = 0; + i = Math.max(0, Math.min(list.length - 1, i + delta)); + this.selectedId = list[i]!.id; + } + + private scrollBy(delta: number): void { + this.scroll = Math.max(0, this.scroll + delta); + this.follow = false; // render() clamps, and follows again once back at the bottom + } + + private copySelection(): void { + const text = this.selectedText(); + if (text) this.src.copy?.(text); + } + + // ── selection ────────────────────────────────────────────────────── + + private selRange(): { s: Pos; e: Pos } | null { + if (!this.sel) return null; + const { anchor: a, focus: f } = this.sel; + if (a.line === f.line && a.col === f.col && !this.dragged && this.selecting) return null; + const before = a.line < f.line || (a.line === f.line && a.col <= f.col); + return before ? { s: a, e: f } : { s: f, e: a }; + } + + /** Map overlay-local mouse coordinates to a log position (clamped to the body). */ + private posAt(x: number, y: number): Pos { + const rowInView = Math.max(0, Math.min(this.bodyRows - 1, y - this.bodyTop)); + const line = Math.max(0, Math.min(this.body.length - 1, this.scroll + rowInView)); + const col = Math.max(0, Math.min(this.textW - 1, x - CONTENT_X)); + return { line, col }; + } + + private inBody(x: number, y: number): boolean { + return ( + this.view === "detail" && + this.body.length > 0 && + y >= this.bodyTop && + y < this.bodyTop + this.bodyRows && + x >= CONTENT_X && + x < CONTENT_X + this.textW + ); + } + + private wordAt(p: Pos): { s: Pos; e: Pos } { + const text = this.body[p.line]?.text ?? ""; + const cells: Array<{ seg: string; start: number; end: number }> = []; + let x = 0; + for (const { segment } of segmenter.segment(text)) { + const w = visibleWidth(segment); + cells.push({ seg: segment, start: x, end: x + w }); + x += w; + } + const isWord = (s: string) => /[\p{L}\p{N}_\-./:@~]/u.test(s); + let i = cells.findIndex((c) => p.col >= c.start && p.col < c.end); + if (i < 0 || !isWord(cells[i]!.seg)) return { s: p, e: p }; + let a = i; + let b = i; + while (a > 0 && isWord(cells[a - 1]!.seg)) a--; + while (b < cells.length - 1 && isWord(cells[b + 1]!.seg)) b++; + return { s: { line: p.line, col: cells[a]!.start }, e: { line: p.line, col: cells[b]!.end - 1 } }; + } + + private startAutoScroll(dir: number): void { + this.autoScrollDir = dir; + if (this.autoScrollTimer) return; + this.autoScrollTimer = setInterval(() => { + if (!this.selecting || this.autoScrollDir === 0 || !this.sel) { + this.stopAutoScroll(); + return; + } + this.scroll = Math.max(0, this.scroll + this.autoScrollDir); + const edge = this.autoScrollDir < 0 ? this.bodyTop : this.bodyTop + this.bodyRows - 1; + this.sel.focus = this.posAt(CONTENT_X + this.sel.focus.col, edge); + this.src.requestRender(); + }, AUTOSCROLL_MS); + this.autoScrollTimer.unref?.(); + } + + private stopAutoScroll(): void { + this.autoScrollDir = 0; + if (this.autoScrollTimer) { + clearInterval(this.autoScrollTimer); + this.autoScrollTimer = null; + } + } + + // ── scrollbar ────────────────────────────────────────────────────── + + /** Is (x, y) on the scrollbar? Only while the view is taller than its window. */ + private onScrollbar(x: number, y: number): boolean { + return this.barRows > 0 && x >= this.barX && y >= this.barTop && y < this.barTop + this.barRows; + } + + /** + * A press on the bar starts a drag — pi's own gesture: grabbing the thumb + * keeps the grabbed spot under the pointer, and pressing the track jumps + * the thumb so its middle lands on the press (and the drag goes on from + * there). + */ + private pressScrollbar(y: number): void { + const onThumb = y >= this.barThumbY && y < this.barThumbY + this.barThumbH; + const grabOffset = onThumb ? y - this.barThumbY : Math.floor(this.barThumbH / 2); + if (!onThumb) this.scrollThumbTo(y, grabOffset); + this.barDrag = { grabOffset }; + } + + /** Scroll so the thumb point grabbed at `grabOffset` sits at row y. */ + private scrollThumbTo(y: number, grabOffset: number): void { + const maxThumb = Math.max(0, this.barRows - this.barThumbH); + const thumbOffset = Math.max(0, Math.min(maxThumb, y - this.barTop - grabOffset)); + const share = maxThumb === 0 ? 0 : thumbOffset / maxThumb; + if (this.view === "detail") { + const maxScroll = Math.max(0, this.body.length - this.bodyRows); + this.scroll = Math.round(share * maxScroll); + this.follow = false; // render() follows again once dragged back to the bottom + } else { + // The list has no free scroll: the view follows the selection, so moving + // it means moving the selection with it — keeping the selected row on + // the same screen row. + const list = this.filtered(); + if (list.length === 0) return; + const maxOffset = Math.max(0, list.length - this.listRows); + const offset = Math.round(share * maxOffset); + const sel = Math.max(0, list.findIndex((j) => j.id === this.selectedId)); + const screenRow = sel - this.listOffset; + this.listOffset = offset; + const next = Math.max(0, Math.min(list.length - 1, offset + screenRow)); + this.selectedId = list[next]!.id; + } + } + + // ── input ────────────────────────────────────────────────────────── + + handleInput(data: string): void { + if (this.confirmId) { + // While a kill waits for its yes, only yes or no count — and Esc means no, not "close". + if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "y" || data === "Y") this.confirmKill(); + else if (matchesKey(data, "escape") || data === "n" || data === "N") this.confirmId = null; + this.src.requestRender(); + return; + } + if (matchesKey(data, "escape")) { + this.src.close(); + return; + } + if (this.view === "list") this.handleListKey(data); + else this.handleDetailKey(data); + this.src.requestRender(); + } + + private handleListKey(data: string): void { + if (matchesKey(data, "up")) this.moveSelection(-1); + else if (matchesKey(data, "down")) this.moveSelection(1); + else if (matchesKey(data, "pageUp")) this.moveSelection(-Math.max(1, this.listRows - 1)); + else if (matchesKey(data, "pageDown")) this.moveSelection(Math.max(1, this.listRows - 1)); + else if (matchesKey(data, "enter") || matchesKey(data, "return")) { + const list = this.filtered(); + this.activate(list.find((j) => j.id === this.selectedId) ?? list[0]); + } else { + const before = this.input.getValue(); + this.input.handleInput(data); + if (this.input.getValue() !== before) { + // A new query starts at the top match. + this.listOffset = 0; + this.selectedId = this.filtered()[0]?.id ?? null; + } + } + } + + private handleDetailKey(data: string): void { + const page = Math.max(1, this.bodyRows - 1); + if (matchesKey(data, "up")) this.scrollBy(-1); + else if (matchesKey(data, "down")) this.scrollBy(1); + else if (matchesKey(data, "pageUp")) this.scrollBy(-page); + else if (matchesKey(data, "pageDown") || data === " ") this.scrollBy(page); + else if (matchesKey(data, "home") || data === "g") { + this.scroll = 0; + this.follow = false; + } else if (matchesKey(data, "end") || data === "G") this.follow = true; + else if (matchesKey(data, "left") || matchesKey(data, "backspace") || data === "q") this.backToList(); + else if (data === "k") this.askKill(this.job(this.detailId)); + else if (data === "y" || this.src.isCopyKey?.(data) || matchesKey(data, "ctrl+c")) this.copySelection(); + } + + handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { + if (event.type === "move") return undefined; + if (event.type === "wheel") { + const delta = event.wheelDelta ?? 0; + if (delta === 0) return { handled: true, render: false }; + if (this.view === "list") this.moveSelection(delta < 0 ? -1 : 1); + else this.scrollBy(delta); + return { handled: true }; + } + + // A text selection in progress owns the pointer until release. + if (this.selecting && this.sel) { + if (event.type === "drag") { + this.dragged = true; + this.sel.focus = this.posAt(event.x, event.y); + if (event.y < this.bodyTop) this.startAutoScroll(-1); + else if (event.y >= this.bodyTop + this.bodyRows) this.startAutoScroll(1); + else this.stopAutoScroll(); + return { handled: true }; + } + if (event.type === "release") { + this.selecting = false; + this.stopAutoScroll(); + if (this.dragged && this.src.copyOnSelect?.() !== false) this.copySelection(); + if (!this.dragged) this.sel = null; + return { handled: true }; + } + } + + // A scrollbar drag owns the pointer until release, like a selection does. + if (this.barDrag) { + if (event.type === "drag") { + this.scrollThumbTo(event.y, this.barDrag.grabOffset); + return { handled: true }; + } + if (event.type === "release" || event.type === "click") { + this.barDrag = null; + return { handled: true, render: false }; + } + return { handled: true, render: false }; + } + + if (event.button !== "left") return { handled: true, render: false }; + + if (this.confirmId) { + // Only the buttons (yes, cancel, close) respond until the kill is answered. + if (event.type === "click") this.buttons.get(event.y)?.find((b) => event.x >= b.x0 && event.x < b.x1)?.action(); + return { handled: true }; + } + + if (event.type === "press") { + if (this.onScrollbar(event.x, event.y)) { + this.pressScrollbar(event.y); + return { handled: true, capture: true }; + } + if (this.inBody(event.x, event.y)) { + const p = this.posAt(event.x, event.y); + this.sel = { anchor: p, focus: p }; + this.selecting = true; // render() stops following, so the text stays under the pointer + this.dragged = false; + this.follow = false; + return { handled: true, capture: true }; + } + const id = this.view === "list" ? this.rowIds.get(event.y) : undefined; + if (id) this.selectedId = id; + return { handled: true }; + } + if (event.type === "click") { + const button = this.buttons.get(event.y)?.find((b) => event.x >= b.x0 && event.x < b.x1); + if (button) { + button.action(); + this.src.requestRender(); + return { handled: true }; + } + // A click on the bar (press + release in place after its press) is a + // scroll, never a row open or a text click. + if (this.onScrollbar(event.x, event.y)) return { handled: true, render: false }; + if (this.view === "detail") { + // Double-click selects a word, triple-click a row — and copies, like pi. + const count = event.clickCount ?? 1; + if (this.inBody(event.x, event.y) && count >= 2) { + const p = this.posAt(event.x, event.y); + const range = count === 2 ? this.wordAt(p) : { s: { line: p.line, col: 0 }, e: { line: p.line, col: Math.max(0, visibleWidth(this.body[p.line]?.text ?? "") - 1) } }; + this.sel = { anchor: range.s, focus: range.e }; + this.dragged = true; + if (this.src.copyOnSelect?.() !== false) this.copySelection(); + } + return { handled: true }; + } + // A click only fires where the press was, so the row under it is the one pressed. + const id = this.rowIds.get(event.y); + if (id) this.activate(this.job(id)); + return { handled: true }; + } + return { handled: true, render: false }; + } + + // ── render ───────────────────────────────────────────────────────── + + /** The overlay's height budget: ~70% of the terminal rows (all of a tiny one). */ + private maxHeight(): number { + const rows = Math.max(1, this.src.rows()); + return Math.max(Math.min(8, rows), Math.floor(rows * HEIGHT_SHARE)); + } + + render(width: number): string[] { + this.rowIds.clear(); + this.buttons.clear(); + this.barX = -1; + this.barRows = 0; // each view sets its own below + if (this.confirmId && this.job(this.confirmId)?.status !== "running") this.confirmId = null; // it ended meanwhile + const w = Math.max(24, width); + if (this.view === "detail") { + const job = this.job(this.detailId); + if (job) return this.renderDetail(job, w); + this.backToList(); // the job vanished (session reset) — fall back to the list + } + return this.renderList(w); + } + + private frame(width: number) { + const th = this.theme; + const border = (s: string) => th.fg("border", s); + const inner = width - 2; + const contentW = Math.max(1, width - 4); + const fit = (s: string, n: number) => { + const t = truncateToWidth(s, n); + return t + " ".repeat(Math.max(0, n - visibleWidth(t))); + }; + return { + contentW, + row: (content: string, bg?: string) => { + const body = fit(content, contentW); + return `${border("│")}${bg ? th.bg(bg, ` ${body} `) : ` ${body} `}${border("│")}`; + }, + // "╭─ title ─────── ✕ ─╮": the ✕ closes the overlay. It is clickable a cell either side. + top: (left: string) => { + const l = truncateToWidth(` ${left} `, Math.max(1, width - 8)); + const fill = Math.max(0, width - 7 - visibleWidth(l)); + this.buttons.set(0, [{ x0: width - 5, x1: width - 2, action: () => this.src.close() }]); + return border("╭─") + l + border("─".repeat(fill)) + ` ${th.fg("error", "✕")} ` + border("─╮"); + }, + sep: () => border(`├${"─".repeat(inner)}┤`), + bottom: () => border(`╰${"─".repeat(inner)}╯`), + }; + } + + /** Lay buttons out left to right in a hint row, recording their columns for clicks. */ + private buttonRow(y: number, items: Array<{ label: string; action: () => void; color?: string }>, rest: string): string { + let x = CONTENT_X; + const parts: string[] = []; + const hits: Button[] = []; + for (const it of items) { + const label = `[${it.label}]`; + hits.push({ x0: x, x1: x + visibleWidth(label), action: it.action }); + parts.push(this.theme.fg(it.color ?? "accent", label)); + x += visibleWidth(label) + 1; + } + this.buttons.set(y, hits); + return [...parts, rest].join(" "); + } + + /** The row that asks "kill this job?" in place of a view's hint row. */ + private confirmRow(y: number): string { + const job = this.job(this.confirmId)!; + const th = this.theme; + const question = th.fg("warning", `Stop ${job.id} (${oneLine(job.command, 40)}) and its process tree?`); + const buttons = [ + { label: `✗ Kill ${job.id}`, action: () => this.confirmKill(), color: "error" }, + { label: "Cancel", action: () => (this.confirmId = null) }, + ]; + return this.buttonRow(y, buttons, `${question} ${th.fg("dim", "enter/y kill · esc/n cancel")}`); + } + + /** "⟳ running ✓ done ✗ failed ◼ killed ◼ orphaned", each in its own color. */ + private legend(): string { + return STATUS_ORDER.map((s) => this.theme.fg(statusColor({ status: s }), `${statusIcon({ status: s })} ${s}`)).join(" "); + } + + private renderList(width: number): string[] { + const th = this.theme; + const f = this.frame(width); + const now = Date.now(); + const all = this.src.jobs(); + const list = this.filtered(); + const running = all.filter((j) => j.status === "running").length; + + if (!list.some((j) => j.id === this.selectedId)) this.selectedId = list[0]?.id ?? null; + const selIndex = Math.max(0, list.findIndex((j) => j.id === this.selectedId)); + + const title = + this.mode === "kill" + ? `${SHELL_BG_ICON} ${th.bold("shell-bg")} · kill a running job` + : `${SHELL_BG_ICON} ${th.bold("shell-bg")} · ${running} running · ${all.length} total`; + const lines = [f.top(th.fg("accent", title))]; + + const label = th.fg("muted", "Search: "); + const field = this.input.render(Math.max(1, f.contentW - visibleWidth(label)))[0] ?? ""; + lines.push(f.row(label + field)); + lines.push(f.sep()); + + const chrome = 7; // top, search, sep, sep, legend, hint, bottom + this.listRows = Math.max(1, Math.min(Math.max(1, list.length), this.maxHeight() - chrome)); + if (selIndex < this.listOffset) this.listOffset = selIndex; + if (selIndex >= this.listOffset + this.listRows) this.listOffset = selIndex - this.listRows + 1; + this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, list.length - this.listRows))); + + if (list.length === 0) { + const empty = all.length === 0 ? "No background commands this session." : this.mode === "kill" && running === 0 ? "No running jobs." : "No job matches."; + lines.push(f.row(th.fg("dim", empty))); + } else { + const idW = Math.max(...list.map((j) => j.id.length)); + const stW = Math.max(...list.map((j) => statusLabel(j).length)); + const times = new Map(list.map((j) => [j.id, elapsed(j, now)])); + const timeW = Math.max(...[...times.values()].map((t) => t.length)); + // A one-column scrollbar when the list is taller than the view: the same + // column the detail uses for its log, and it drags just the same. + const scrollable = list.length > this.listRows; + const maxOffset = Math.max(0, list.length - this.listRows); + const thumbH = scrollable ? Math.max(1, Math.round((this.listRows * this.listRows) / list.length)) : 0; + const thumbTop = scrollable && maxOffset > 0 ? Math.round(((this.listRows - thumbH) * this.listOffset) / maxOffset) : 0; + this.barX = width - 3; + this.barTop = lines.length; + this.barRows = scrollable ? this.listRows : 0; + this.barThumbY = this.barTop + thumbTop; + this.barThumbH = thumbH; + const shown = list.slice(this.listOffset, this.listOffset + this.listRows); + for (let i = 0; i < shown.length; i++) { + const j = shown[i]!; + const selected = j.id === this.selectedId; + const c = statusColor(j); + const left = `${selected ? "›" : " "} ${statusIcon(j)} ${j.id.padEnd(idW)} ${statusLabel(j).padEnd(stW)} `; + const time = (times.get(j.id) ?? "").padStart(timeW); + const cmdW = Math.max(1, f.contentW - 2 - visibleWidth(left) - timeW - 2); + const cmd = truncateToWidth(oneLine(j.command), cmdW); + const gap = " ".repeat(Math.max(0, cmdW - visibleWidth(cmd)) + 2); + const content = + th.fg(selected ? "accent" : c, left.slice(0, 2)) + + th.fg(c, left.slice(2)) + + (selected ? th.fg("text", cmd) : th.fg("muted", cmd)) + + gap + + th.fg(c, time); + const bar = !scrollable ? " " : i >= thumbTop && i < thumbTop + thumbH ? th.fg("accent", "█") : th.fg("dim", "░"); + this.rowIds.set(lines.length, j.id); + lines.push(f.row(`${content} ${bar}`, selected ? "selectedBg" : undefined)); + } + } + + lines.push(f.sep()); + lines.push(f.row(this.legend())); + const pos = list.length > this.listRows ? ` ${selIndex + 1}/${list.length}` : ""; + const hint = + this.mode === "kill" + ? `↑↓ select · enter/click kill · type to search · esc close${pos}` + : `↑↓ select · enter/click open · type to search · esc close${pos}`; + lines.push(f.row(this.confirmId ? this.confirmRow(lines.length) : th.fg("dim", hint))); + lines.push(f.bottom()); + return lines; + } + + private renderDetail(job: Job, width: number): string[] { + const th = this.theme; + const f = this.frame(width); + const now = Date.now(); + const c = statusColor(job); + const running = job.status === "running"; + + const title = `${th.fg(c, `${statusIcon(job)} ${job.id}`)} ${th.fg("dim", "·")} ${th.fg(c, statusLabel(job))} ${th.fg("dim", `· ${elapsed(job, now)}`)}`; + const lines = [f.top(`${SHELL_BG_ICON} ${title}`)]; + + const cmdLines = wrapTextWithAnsi(oneLine(job.command), Math.max(1, f.contentW - 2)).slice(0, 3); + cmdLines.forEach((l, i) => lines.push(f.row(`${th.fg("accent", i === 0 ? "$ " : " ")}${l}`))); + const meta = [ + `started ${clock(job.startedAt)}`, + job.endedAt ? `ended ${clock(job.endedAt)}` : null, + job.pid ? `pid ${job.pid}` : null, + job.cwd ? `cwd ${job.cwd}` : null, + ].filter(Boolean); + lines.push(f.row(th.fg("dim", meta.join(" · ")))); + lines.push(f.sep()); + + const text = this.outputOf(job).replace(/\n+$/, ""); + this.textW = Math.max(1, f.contentW - 2); // a space and a one-column scrollbar on the right + const body = text.trim() === "" ? [] : this.wrapBody(job.id, text, this.textW); + if (body.length === 0) this.body = []; + const chrome = 1 + cmdLines.length + 1 + 1 + 1 + 1 + 1; + // Fixed by the terminal, not the log: the window keeps its size as output arrives. + this.bodyRows = Math.max(1, this.maxHeight() - chrome); + const maxScroll = Math.max(0, body.length - this.bodyRows); + if (this.follow) this.scroll = maxScroll; + this.scroll = Math.max(0, Math.min(this.scroll, maxScroll)); + this.follow = !this.selecting && this.scroll >= maxScroll; + + this.bodyTop = lines.length; + if (body.length === 0) { + lines.push(f.row(th.fg("dim", running ? "(no output yet)" : "(no output)"))); + for (let i = 1; i < this.bodyRows; i++) lines.push(f.row("")); + } else { + const range = this.selRange(); + // A one-column scrollbar when the output is taller than the view; it + // takes presses and drags like pi's own scrollbars. + const scrollable = body.length > this.bodyRows; + const thumbH = scrollable ? Math.max(1, Math.round((this.bodyRows * this.bodyRows) / body.length)) : 0; + const thumbTop = scrollable && maxScroll > 0 ? Math.round(((this.bodyRows - thumbH) * this.scroll) / maxScroll) : 0; + this.barX = width - 3; // the last content column, where `bar` is drawn + this.barTop = this.bodyTop; + this.barRows = scrollable ? this.bodyRows : 0; + this.barThumbY = this.barTop + thumbTop; + this.barThumbH = thumbH; + for (let i = 0; i < this.bodyRows; i++) { + const idx = this.scroll + i; + const raw = body[idx]?.text ?? ""; + const rawW = visibleWidth(raw); + let shown = raw; + if (range && idx >= range.s.line && idx <= range.e.line) { + const a = idx === range.s.line ? Math.min(range.s.col, rawW) : 0; + const b = idx === range.e.line ? Math.min(range.e.col + 1, rawW) : rawW; + if (b > a) { + shown = + sliceByColumn(raw, 0, a, true) + + `\x1b[7m${sliceByColumn(raw, a, b - a, true)}\x1b[27m` + + sliceByColumn(raw, b, rawW - b, true); + } + } + const pad = " ".repeat(Math.max(0, this.textW - rawW)); + const bar = !scrollable ? " " : i >= thumbTop && i < thumbTop + thumbH ? th.fg("accent", "█") : th.fg("dim", "░"); + lines.push(f.row(`${shown}${pad} ${bar}`)); + } + } + + lines.push(f.sep()); + const pos = + body.length > this.bodyRows + ? `${this.scroll + 1}-${Math.min(body.length, this.scroll + this.bodyRows)}/${body.length}${this.follow && running ? " · following" : ""}` + : `${body.length} line${body.length === 1 ? "" : "s"}`; + const buttons = [{ label: "‹ back", action: () => this.backToList() }]; + if (running) buttons.push({ label: "✗ kill", action: () => this.askKill(job) }); + const copyHint = this.src.copyOnSelect?.() === false ? `drag to select, ${this.src.copyKeyLabel ?? "y"} copy` : "drag to copy"; + const keys = `↑↓ scroll · ${copyHint} · ← back${running ? " · k kill" : ""} · esc close`; + lines.push(f.row(this.confirmId ? this.confirmRow(lines.length) : this.buttonRow(lines.length, buttons, th.fg("dim", `${keys} · ${pos}`)))); + lines.push(f.bottom()); + return lines; + } +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..beeb89c --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,141 @@ +/** + * `/shell-bg` argument parsing and autocomplete. Pure: given the typed + * argument text and the session's jobs, produce the parsed action or the + * completion items — the extension wires them to pi's command API. + * + * /shell-bg nothing (the completion list shows what there is) + * /shell-bg tui open the job browser + * /shell-bg status open the job browser + * /shell-bg status open one job's output + * /shell-bg kill pick a running job to stop + * /shell-bg kill stop one job + * /shell-bg list the old text roster, as a notification + */ +import type { Job } from "./types.ts"; +import { oneLine, statusIcon } from "./widget.ts"; + +/** Same shape as pi-tui's AutocompleteItem, kept local so src/ stays free of pi imports. */ +export interface CompletionItem { + value: string; + label: string; + description?: string; +} + +export const SHELL_BG_VERBS = [ + { name: "tui", description: "Open the job browser" }, + { name: "status", description: "Browse jobs and view a job's output — status [id]" }, + { name: "kill", description: "Stop a running job and its process tree — kill [id]" }, + { name: "list", description: "Print the job roster as a notification" }, +] as const; + +export type ShellBgVerb = (typeof SHELL_BG_VERBS)[number]["name"]; + +export type ShellBgAction = + | { kind: "none" } + | { kind: "browse" } + | { kind: "status"; id: string } + | { kind: "pick-kill" } + | { kind: "kill"; id: string } + | { kind: "list" } + | { kind: "unknown"; verb: string }; + +export const SHELL_BG_USAGE = "Usage: /shell-bg [tui | status [id] | kill [id] | list]"; + +export function parseShellBgArgs(args: string | undefined): ShellBgAction { + const [verb = "", id = ""] = (args ?? "").trim().split(/\s+/); + switch (verb) { + case "": + return { kind: "none" }; + case "tui": + return { kind: "browse" }; + case "status": + return id ? { kind: "status", id } : { kind: "browse" }; + case "kill": + return id ? { kind: "kill", id } : { kind: "pick-kill" }; + case "list": + return { kind: "list" }; + default: + // A bare id is read as "show me that job". + return /^bg-\d+$/.test(verb) ? { kind: "status", id: verb } : { kind: "unknown", verb }; + } +} + +/** Newest first, the order every roster here uses. */ +export function sortJobs(jobs: Job[]): Job[] { + return jobs.slice().sort((a, b) => b.startedAt - a.startedAt || idNum(b) - idNum(a)); +} + +function idNum(job: Job): number { + const n = Number(job.id.replace(/^bg-/, "")); + return Number.isFinite(n) ? n : 0; +} + +/** + * Does a job match a search query? By id (`bg-12`, `12`, `bg-1`) or by any + * part of its command, case-insensitively. An empty query matches everything. + */ +export function matchesJob(job: Job, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + if (job.id.toLowerCase().includes(q)) return true; + return job.command.toLowerCase().includes(q); +} + +const MAX_JOB_ITEMS = 50; + +/** + * The argument text of a `/shell-bg ` line, given the text before the cursor + * on the editor's first line — or null when the cursor is not in one. + */ +export function shellBgArgument(textBeforeCursor: string): string | null { + const m = /^\s*\/shell-bg (.*)$/s.exec(textBeforeCursor); + return m ? m[1]! : null; +} + +/** + * After a completion is accepted, should the list open again right away? + * Yes when the line is left waiting for the next argument: `/shell-bg ` + * (pick a subcommand) or `/shell-bg kill ` / `/shell-bg status ` (pick a job). + */ +export function wantsFollowUpCompletion(textBeforeCursor: string): boolean { + return /^\s*\/shell-bg\s+((kill|status)\s+)?$/.test(textBeforeCursor); +} + +/** + * Completions for the text typed after `/shell-bg `. pi replaces the whole + * argument text with an item's `value`, so a job item carries its verb. + * `kill` offers only running jobs — nothing else can be killed. + */ +export function completeShellBg(prefix: string, jobs: Job[]): CompletionItem[] | null { + const text = prefix.replace(/^\s+/, ""); + const space = text.search(/\s/); + + if (space === -1) { + const verbs = SHELL_BG_VERBS.filter((v) => v.name.startsWith(text.toLowerCase())).map((v) => ({ + value: `${v.name} `, + label: v.name, + description: v.description, + })); + return verbs.length > 0 ? verbs : null; + } + + const verb = text.slice(0, space); + const rest = text.slice(space).replace(/^\s+/, ""); + // An id is already complete once something follows it. + if (/\s/.test(rest)) return null; + + let candidates: Job[]; + if (verb === "kill") candidates = jobs.filter((j) => j.status === "running"); + else if (verb === "status") candidates = jobs; + else return null; + + const items = sortJobs(candidates) + .filter((j) => matchesJob(j, rest)) + .slice(0, MAX_JOB_ITEMS) + .map((j) => ({ + value: `${verb} ${j.id}`, + label: `${statusIcon(j)} ${j.id}`, + description: `${j.status} · ${oneLine(j.command, 60)}`, + })); + return items.length > 0 ? items : null; +} diff --git a/src/format.ts b/src/format.ts index d0224e3..0fdc3c6 100644 --- a/src/format.ts +++ b/src/format.ts @@ -29,11 +29,13 @@ export function header(job: Job): string { const verdict = job.status === "orphaned" ? "orphaned (another session)" - : job.signal - ? `signal ${job.signal}` - : job.status === "killed" - ? "killed" - : `exit ${job.exitCode ?? "?"}`; + : job.stoppedByUser + ? "stopped by the user" + : job.signal + ? `signal ${job.signal}` + : job.status === "killed" + ? "killed" + : `exit ${job.exitCode ?? "?"}`; return `[${job.id} · ${job.status} · ${verdict} · ${duration(job)}]`; } diff --git a/src/pending.ts b/src/pending.ts index 22a6e6d..b32c110 100644 --- a/src/pending.ts +++ b/src/pending.ts @@ -79,16 +79,22 @@ export function backgroundedResult(input: BackgroundedInput): BackgroundedResult }; } -/** How a finished background job introduces itself when it arrives unasked. */ -export function deliveryMessage(id: string, body: string): string { - return [ - ``, - body.trim(), - ``, - "", - `This is ${id}, a command you sent to the background; it has just finished and this is its result.`, - "Fold it into what you are doing. If you had already moved on, say what it changes — or that it changes nothing.", - ].join("\n"); +/** + * How a finished background job introduces itself when it arrives unasked. + * A job the user stopped by hand says so: without that, a bare "killed" reads + * like a crash, and the model may restart what the user just shut down. + */ +export function deliveryMessage(id: string, body: string, opts: { stoppedByUser?: boolean } = {}): string { + const note = opts.stoppedByUser + ? [ + `This is ${id}, a command you sent to the background. The user stopped it themselves (from /shell-bg); this is its final output.`, + "It did not crash. Do not restart it unless the user asks; just take into account that it is no longer running.", + ] + : [ + `This is ${id}, a command you sent to the background; it has just finished and this is its result.`, + "Fold it into what you are doing. If you had already moved on, say what it changes — or that it changes nothing.", + ]; + return [``, body.trim(), ``, "", ...note].join("\n"); } /** The custom-message type a delivered result travels under. */ diff --git a/src/spawn.ts b/src/spawn.ts index e53a285..776abf2 100644 --- a/src/spawn.ts +++ b/src/spawn.ts @@ -30,6 +30,8 @@ export interface Spawned { } const DRAIN_GRACE_MS = 150; +/** Upper bound on waiting for the log to reach the disk, should the stream never report back. */ +const FLUSH_TIMEOUT_MS = 2000; export function spawnToFile( shell: string, @@ -84,12 +86,25 @@ export function spawnToFile( const finish = () => { if (settled || !info) return; settled = true; + const exitInfo = info; + // Resolve only once the log is on disk. out.write() is asynchronous, so a + // burst of output just before the exit can still be queued here, and a + // result read the moment the job settles would miss its tail (measured: 4 + // of 20 `seq 1 50000; echo END` runs lost the end). end()'s callback fires + // on 'finish', after every queued write; an error or a stream that never + // answers must not hang the job, hence the listener and the timeout. + const done = () => { + clearTimeout(timer); + resolve(exitInfo); + }; + const timer = setTimeout(done, FLUSH_TIMEOUT_MS); + timer.unref?.(); + out.once("error", done); try { - out.end(); + out.end(done); } catch { - // already closed + done(); // already closed } - resolve(info); }; const maybeFinish = () => { if (info && stdoutEnded && stderrEnded) finish(); diff --git a/src/types.ts b/src/types.ts index 8a41196..034b016 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,12 @@ export interface Job { delivered: boolean; /** Set when we killed it (timeout/abort/shell_kill), so exit reads as killed. */ killedByUs?: boolean; + /** + * Set when the user stopped it from the UI (/shell-bg kill, the job + * browser) rather than the model: its result then says so, and is handed to + * the model with the user's next message instead of waking it for a turn. + */ + stoppedByUser?: boolean; } export function isRecord(value: unknown): value is Record { diff --git a/src/widget.ts b/src/widget.ts index 6d979a1..334a9b0 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -1,7 +1,9 @@ /** * The aboveEditor box listing background jobs — running ones, and any that * finished in the last 15s so a completion is visible before it disappears. - * Pure string builder; the extension wraps it in a pi-tui Text. + * Pure string builder; the extension wraps it in a small clickable component + * (a click on a job row opens that job's detail, a click elsewhere opens the + * job browser), so the builder also reports which job each row shows. */ import type { Job } from "./types.ts"; @@ -13,7 +15,8 @@ export interface WidgetTheme { bold(text: string): string; } -function icon(job: Job): string { +/** The status glyph used everywhere a job is shown: widget, completions, browser. */ +export function statusIcon(job: Pick): string { switch (job.status) { case "running": return "⟳"; @@ -26,7 +29,12 @@ function icon(job: Job): string { } } -function color(job: Job): string { +/** + * The theme color that goes with {@link statusIcon} — one per status, so a + * roster tells running, finished, failed, stopped (killed) and closed-with-its- + * session (orphaned) apart at a glance. + */ +export function statusColor(job: Pick): string { switch (job.status) { case "running": return "warning"; @@ -34,44 +42,67 @@ function color(job: Job): string { return "success"; case "failed": return "error"; + case "killed": + return "accent"; default: return "dim"; } } -function elapsed(job: Job, now: number): string { +/** Every status in display order, for legends. */ +export const STATUS_ORDER = ["running", "done", "failed", "killed", "orphaned"] as const; + +/** The extension's own glyph, shown in the widget title and the browser frame. */ +export const SHELL_BG_ICON = "⚙"; + +export function elapsed(job: Job, now: number): string { const end = job.endedAt ?? now; const s = Math.max(0, Math.round((end - job.startedAt) / 1000)); - return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; } -function clip(text: string, max: number): string { +/** Collapse whitespace to single spaces and clip to `max` characters. */ +export function oneLine(text: string, max = Infinity): string { const one = text.replace(/\s+/g, " ").trim(); return one.length <= max ? one : `${one.slice(0, max - 1)}…`; } -export function buildWidgetLines(jobs: Job[], theme: WidgetTheme, now: number): string[] { +export interface WidgetModel { + lines: string[]; + /** One entry per line: the id of the job that line shows, or null for chrome (title, border, "+N more"). */ + rowIds: Array; +} + +export function buildWidget(jobs: Job[], theme: WidgetTheme, now: number): WidgetModel { const dim = (s: string) => theme.fg("dim", s); const visible = jobs .filter((j) => j.status === "running" || (j.endedAt ?? 0) > now - 15_000) .sort((a, b) => a.startedAt - b.startedAt); - if (visible.length === 0) return []; + if (visible.length === 0) return { lines: [], rowIds: [] }; const running = visible.filter((j) => j.status === "running").length; - const title = ` ⚙ shell-bg · ${running} running `; + const title = ` ${SHELL_BG_ICON} shell-bg · ${running} running `; const hint = " /shell-bg "; const pad = Math.max(1, WIDTH - title.length - hint.length); const lines = [dim(`╭${title}${"─".repeat(pad)}${hint}╮`)]; + const rowIds: Array = [null]; const shown = visible.slice(-MAX_ROWS); const hidden = visible.length - shown.length; for (const j of shown) { - const c = color(j); + const c = statusColor(j); lines.push( - `${theme.fg(c, icon(j))} ${theme.fg(c, j.id)} ${dim(`· ${elapsed(j, now)}`)} ${dim(clip(j.command, 28))}`, + `${theme.fg(c, statusIcon(j))} ${theme.fg(c, j.id)} ${dim(`· ${elapsed(j, now)}`)} ${dim(oneLine(j.command, 28))}`, ); + rowIds.push(j.id); + } + if (hidden > 0) { + lines.push(dim(`│ … +${hidden} more`)); + rowIds.push(null); } - if (hidden > 0) lines.push(dim(`│ … +${hidden} more`)); lines.push(dim(`╰${"─".repeat(WIDTH)}╯`)); - return lines; + rowIds.push(null); + return { lines, rowIds }; } diff --git a/test/browser.test.ts b/test/browser.test.ts new file mode 100644 index 0000000..e4a734c --- /dev/null +++ b/test/browser.test.ts @@ -0,0 +1,557 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui"; +import { JobBrowser, hardWrap, type BrowserSource } from "../src/browser.ts"; +import type { Job, JobStatus } from "../src/types.ts"; + +const theme = { fg: (_c: string, t: string) => t, bg: (_c: string, t: string) => t, bold: (t: string) => t }; + +const KEY = { up: "\x1b[A", down: "\x1b[B", left: "\x1b[D", enter: "\r", esc: "\x1b", backspace: "\x7f" }; + +function job(id: string, status: JobStatus, command: string, startedAt: number): Job { + return { + id, + command, + cwd: "/work", + pid: 4242, + status, + exitCode: status === "done" ? 0 : status === "failed" ? 1 : null, + signal: null, + logPath: `/tmp/${id}.log`, + startedAt, + endedAt: status === "running" ? null : startedAt + 2000, + auto: false, + delivered: false, + }; +} + +function setup(opts: { jobs?: Job[]; output?: (j: Job) => string; rows?: number; copyOnSelect?: boolean } = {}) { + const jobs = opts.jobs ?? [ + job("bg-1", "done", "ls -la", Date.now() - 50_000), + job("bg-2", "running", "npm run dev", Date.now() - 40_000), + job("bg-3", "failed", "npm test", Date.now() - 30_000), + ]; + const killed: string[] = []; + const copied: string[] = []; + let closed = 0; + const src: BrowserSource = { + jobs: () => jobs, + output: opts.output ?? ((j) => `output of ${j.id}\nsecond line`), + kill: (j) => killed.push(j.id), + rows: () => opts.rows ?? 40, + requestRender: () => {}, + close: () => closed++, + copy: (t) => copied.push(t), + copyOnSelect: () => opts.copyOnSelect ?? true, + }; + return { jobs, killed, copied, closed: () => closed, src }; +} + +function mouse(type: TuiMouseEvent["type"], x: number, y: number, extra: Partial = {}): TuiMouseEvent { + return { type, button: "left", x, y, screenX: x, screenY: y, width: 80, height: 20, shift: false, alt: false, ctrl: false, ...extra }; +} + +function rowOf(lines: string[], needle: string): number { + const i = lines.findIndex((l) => l.includes(needle)); + assert.ok(i >= 0, `no row contains ${needle}`); + return i; +} + +test("the list shows every job, newest first, each with its status and icon, within the width", () => { + const { src } = setup(); + const b = new JobBrowser(theme, src); + try { + const lines = b.render(80); + assert.ok(lines.every((l) => visibleWidth(l) === 80), "every line fits the overlay width exactly"); + assert.match(lines[0]!, /⚙ shell-bg · 1 running · 3 total/); + const order = ["bg-3", "bg-2", "bg-1"].map((id) => rowOf(lines, id)); + assert.deepEqual([...order].sort((a, b) => a - b), order, "newest first"); + assert.match(lines[rowOf(lines, "bg-2")]!, /⟳ bg-2 +running/); + assert.match(lines[rowOf(lines, "bg-3")]!, /✗ bg-3 +failed \(exit 1\)/); + assert.match(lines[rowOf(lines, "bg-1")]!, /✓ bg-1 +done \(exit 0\)/); + } finally { + b.dispose(); + } +}); + +test("arrow keys move the selection and enter opens the job's detail", () => { + const { src } = setup(); + const b = new JobBrowser(theme, src); + try { + b.render(80); + assert.equal(b.state().jobId, "bg-3", "the newest job starts selected"); + b.handleInput(KEY.down); + assert.equal(b.state().jobId, "bg-2"); + b.handleInput(KEY.enter); + assert.deepEqual(b.state(), { view: "detail", jobId: "bg-2", query: "" }); + const lines = b.render(80); + assert.match(lines[0]!, /⟳ bg-2 · running/); + assert.ok(lines.some((l) => l.includes("$ npm run dev"))); + assert.ok(lines.some((l) => l.includes("output of bg-2"))); + // ← goes back to the list with the same job still selected. + b.handleInput(KEY.left); + assert.deepEqual(b.state(), { view: "list", jobId: "bg-2", query: "" }); + } finally { + b.dispose(); + } +}); + +test("typing filters by command or by id", () => { + const { src } = setup(); + const b = new JobBrowser(theme, src); + try { + for (const ch of "npm") b.handleInput(ch); + let lines = b.render(80); + assert.ok(!lines.some((l) => l.includes("bg-1")), "ls -la is filtered out"); + assert.ok(lines.some((l) => l.includes("bg-2")) && lines.some((l) => l.includes("bg-3"))); + for (let i = 0; i < 3; i++) b.handleInput(KEY.backspace); + for (const ch of "bg-1") b.handleInput(ch); + lines = b.render(80); + assert.deepEqual(["bg-1", "bg-2", "bg-3"].map((id) => lines.some((l) => l.includes(`${id} `))), [true, false, false]); + b.handleInput(KEY.enter); + assert.equal(b.state().jobId, "bg-1"); + for (const ch of "zzz") b.handleInput(ch); + } finally { + b.dispose(); + } +}); + +test("escape closes from both views", () => { + const s = setup(); + const b = new JobBrowser(theme, s.src, { jobId: "bg-1" }); + try { + assert.equal(b.state().view, "detail", "jobId opens straight into the detail"); + b.handleInput(KEY.esc); + assert.equal(s.closed(), 1); + } finally { + b.dispose(); + } + const s2 = setup(); + const b2 = new JobBrowser(theme, s2.src); + b2.handleInput(KEY.esc); + b2.dispose(); + assert.equal(s2.closed(), 1); +}); + +test("clicking a row opens it; wheel moves the selection", () => { + const { src } = setup(); + const b = new JobBrowser(theme, src); + try { + let lines = b.render(80); + b.handleMouse(mouse("wheel", 5, 4, { wheelDelta: 3 })); + assert.equal(b.state().jobId, "bg-2"); + lines = b.render(80); + const y = rowOf(lines, "bg-1"); + assert.deepEqual(b.handleMouse(mouse("press", 10, y)), { handled: true }); + assert.equal(b.state().jobId, "bg-1", "press selects"); + b.handleMouse(mouse("click", 10, y)); + assert.deepEqual(b.state(), { view: "detail", jobId: "bg-1", query: "" }); + + // The [‹ back] button in the detail returns to the list. + lines = b.render(80); + const by = rowOf(lines, "[‹ back]"); + const bx = lines[by]!.indexOf("[‹ back]"); + b.handleMouse(mouse("press", bx + 1, by)); + b.handleMouse(mouse("click", bx + 1, by)); + assert.equal(b.state().view, "list"); + } finally { + b.dispose(); + } +}); + +test("a long log scrolls, follows the tail, and the detail height is fixed by the terminal", () => { + const long = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`).join("\n"); + const { src } = setup({ output: () => long, rows: 30 }); + const b = new JobBrowser(theme, src, { jobId: "bg-2" }); + try { + let lines = b.render(80); + assert.ok(lines.length <= Math.floor(30 * 0.7), `height ${lines.length} fits the terminal`); + assert.ok(lines.some((l) => l.includes("line 200")), "opens on the newest output"); + assert.ok(!lines.some((l) => l.includes("line 1 "))); + b.handleInput("g"); + lines = b.render(80); + assert.ok(lines.some((l) => l.includes("line 1 ")), "home/g jumps to the top"); + b.handleMouse(mouse("wheel", 5, 8, { wheelDelta: 5 })); + lines = b.render(80); + assert.ok(lines.some((l) => l.includes("line 6 ")), "wheel scrolls the output"); + b.handleInput("G"); + lines = b.render(80); + assert.ok(lines.some((l) => l.includes("line 200")), "end/G returns to the tail"); + + // The detail's size is fixed by the terminal, not by the log: a one-line log + // gets the same window as a 200-line one, and a resize changes both. + const short = setup({ output: () => "one line", rows: 30 }); + const b2 = new JobBrowser(theme, short.src, { jobId: "bg-1" }); + const small = b2.render(80); + b2.dispose(); + assert.equal(small.length, lines.length, `a short log keeps the fixed height (${small.length} vs ${lines.length})`); + assert.equal(lines.length, Math.floor(30 * 0.7), "exactly 70% of the terminal rows"); + const empty = setup({ output: () => "", rows: 30 }); + const b3 = new JobBrowser(theme, empty.src, { jobId: "bg-1" }); + assert.equal(b3.render(80).length, lines.length, "no output still fills the window"); + b3.dispose(); + const tall = setup({ output: () => "one line", rows: 50 }); + const b4 = new JobBrowser(theme, tall.src, { jobId: "bg-1" }); + assert.equal(b4.render(80).length, Math.floor(50 * 0.7), "a taller terminal makes a taller window"); + b4.dispose(); + } finally { + b.dispose(); + } +}); + +test("kill mode lists only running jobs, and enter asks before it kills the chosen one", () => { + const s = setup(); + const b = new JobBrowser(theme, s.src, { mode: "kill" }); + try { + let lines = b.render(80); + assert.ok(lines.some((l) => l.includes("bg-2"))); + assert.ok(!lines.some((l) => l.includes("bg-1 ") || l.includes("bg-3 ")), "finished jobs are not offered"); + b.handleInput(KEY.enter); + assert.deepEqual(s.killed, [], "the first enter only asks"); + assert.equal(b.pendingKill(), "bg-2"); + lines = b.render(80); + assert.ok(lines.some((l) => l.includes("[✗ Kill bg-2] [Cancel]") && l.includes("Stop bg-2 (npm run dev)"))); + b.handleInput(KEY.enter); + assert.deepEqual(s.killed, ["bg-2"]); + assert.equal(s.closed(), 1); + } finally { + b.dispose(); + } +}); + +test("k kills a running job from its detail after a yes, and does nothing for a finished one", () => { + const s = setup(); + const b = new JobBrowser(theme, s.src, { jobId: "bg-1" }); + try { + b.render(80); + b.handleInput("k"); + assert.deepEqual(s.killed, []); + b.handleInput(KEY.left); + b.handleInput(KEY.up); // back keeps bg-1 selected (last row); ↑ → bg-2 + b.handleInput(KEY.enter); + assert.equal(b.state().jobId, "bg-2"); + b.handleInput("k"); + assert.deepEqual(s.killed, [], "k only asks"); + b.handleInput("y"); + assert.deepEqual(s.killed, ["bg-2"]); + assert.equal(b.state().view, "detail", "the detail stays open after the kill"); + assert.equal(s.closed(), 0); + } finally { + b.dispose(); + } +}); + +test("each status has its own color, the run time ends the row, and a legend explains the colors", () => { + // Real SGR codes (zero width), one per theme token, so rows keep their true width. + const codes: Record = { warning: 33, success: 32, error: 31, accent: 36, dim: 90, muted: 37, text: 39, border: 34 }; + const colored = { fg: (c: string, t: string) => `\x1b[${codes[c] ?? 35}m${t}\x1b[39m`, bg: (_c: string, t: string) => t, bold: (t: string) => t }; + const tag = (l: string) => l.replace(/\x1b\[(\d+)m/g, (_m, n) => (n === "39" ? "" : `<${Object.keys(codes).find((k) => codes[k] === Number(n)) ?? n}>`)); + const jobs = [ + job("bg-1", "done", "ls", Date.now() - 65_000), + job("bg-2", "running", "npm run dev", Date.now() - 40_000), + job("bg-3", "failed", "npm test", Date.now() - 30_000), + job("bg-4", "killed", "sleep 99", Date.now() - 20_000), + job("bg-5", "orphaned", "tail -f x", Date.now() - 10_000), + ]; + const { src } = setup({ jobs }); + const b = new JobBrowser(colored, src); + try { + const raw = b.render(100); + assert.ok(raw.every((l) => visibleWidth(l) === 100), "rows keep the overlay width"); + const lines = raw.map(tag); + const row = (id: string) => lines.find((l) => l.includes(`${id} `))!; + assert.match(row("bg-2"), /⟳ bg-2 running/); + assert.match(row("bg-1"), /✓ bg-1 done \(exit 0\)/); + assert.match(row("bg-3"), /✗ bg-3 failed \(exit 1\)/); + assert.match(row("bg-4"), /◼ bg-4 killed/); + assert.match(row("bg-5"), /◼ bg-5 orphaned/); + // The run time ends the row's text, in the row's color, before the (empty) scrollbar column. + assert.match(row("bg-1"), /\s*2s<\/> +│<\/>$/); + assert.match(row("bg-2"), /\s*40s<\/> +│<\/>$/); + const legend = lines.find((l) => l.includes("running") && l.includes("orphaned") && l.includes("killed"))!; + assert.match(legend, /⟳ running<\/>.*✓ done<\/>.*✗ failed<\/>.*◼ killed<\/>.*◼ orphaned<\/>/); + } finally { + b.dispose(); + } +}); + +test("hardWrap splits exactly at the width, wide characters included", () => { + assert.deepEqual(hardWrap("abcdefgh", 3), ["abc", "def", "gh"]); + assert.deepEqual(hardWrap("", 3), [""]); + assert.deepEqual(hardWrap("中文字符", 5), ["中文", "字符"]); + assert.equal(hardWrap("x".repeat(10), 4).join(""), "x".repeat(10)); +}); + +function bodyRowOf(lines: string[], needle: string) { + const y = rowOf(lines, needle); + return { y, x: lines[y]!.indexOf(needle) }; +} + +test("dragging over the log selects text and copies it on release, across wrapped and scrolled rows", () => { + const long = Array.from({ length: 100 }, (_, i) => `row ${String(i + 1).padStart(3, "0")} ${"x".repeat(10)}`).join("\n"); + const s = setup({ output: () => long, rows: 30 }); + const b = new JobBrowser(theme, s.src, { jobId: "bg-1" }); + try { + let lines = b.render(80); + const a = bodyRowOf(lines, "row 095"); + const z = bodyRowOf(lines, "row 097"); + assert.deepEqual(b.handleMouse(mouse("press", a.x, a.y)), { handled: true, capture: true }); + b.handleMouse(mouse("drag", z.x + 6, z.y)); + lines = b.render(80); + assert.ok(lines.some((l) => l.includes("\x1b[7m")), "the selection is highlighted"); + b.handleMouse(mouse("release", z.x + 6, z.y)); + assert.deepEqual(s.copied, [`row 095 ${"x".repeat(10)}\nrow 096 ${"x".repeat(10)}\nrow 097`]); + assert.equal(b.selectedText(), s.copied[0]); + + // A plain click clears the selection; nothing more is copied. + b.handleMouse(mouse("press", a.x, a.y)); + b.handleMouse(mouse("release", a.x, a.y)); + b.handleMouse(mouse("click", a.x, a.y)); + assert.equal(b.selectedText(), undefined); + assert.equal(s.copied.length, 1); + + // Double-click copies one word. + b.render(80); + b.handleMouse(mouse("click", a.x + 1, a.y, { clickCount: 2 })); + assert.equal(s.copied.at(-1), "row"); + } finally { + b.dispose(); + } +}); + +test("a selection over a wrapped line copies it back as one line", () => { + const line = "A".repeat(70) + "B".repeat(30); + const s = setup({ output: () => line }); + const b = new JobBrowser(theme, s.src, { jobId: "bg-1" }); + try { + const lines = b.render(60); // text width 54: the line wraps over two rows + const start = bodyRowOf(lines, "AAAA"); + const end = bodyRowOf(lines, "BBBB"); + b.handleMouse(mouse("press", start.x, start.y)); + b.handleMouse(mouse("drag", end.x + 29, end.y + 1)); + b.handleMouse(mouse("release", end.x + 29, end.y + 1)); + assert.equal(s.copied[0], line, "no newline where the row merely wrapped"); + } finally { + b.dispose(); + } +}); + +test("with pi's copy-on-select off, a selection waits for y", () => { + const s = setup({ copyOnSelect: false }); + const b = new JobBrowser(theme, s.src, { jobId: "bg-1" }); + try { + const lines = b.render(80); + const p = bodyRowOf(lines, "output of bg-1"); + b.handleMouse(mouse("press", p.x, p.y)); + b.handleMouse(mouse("drag", p.x + 5, p.y)); + b.handleMouse(mouse("release", p.x + 5, p.y)); + assert.deepEqual(s.copied, []); + b.handleInput("y"); + assert.deepEqual(s.copied, ["output"]); + } finally { + b.dispose(); + } +}); + +test("a growing log is appended to the wrapped view, and the view keeps following it", () => { + let text = "first\nsecond"; + const s = setup({ output: () => text, rows: 30 }); + const b = new JobBrowser(theme, s.src, { jobId: "bg-2" }); // running: re-read each tick + try { + b.render(80); + text += " continued\n" + Array.from({ length: 50 }, (_, i) => `new ${i}`).join("\n"); + (b as any).outputCache.stale = true; + const lines = b.render(80); + assert.ok(lines.some((l) => l.includes("new 49")), "follows to the newest line"); + b.handleInput("g"); + const top = b.render(80); + assert.ok(top.some((l) => l.includes("second continued")), "the grown last line was re-wrapped"); + } finally { + b.dispose(); + } +}); + +test("a job that finishes while its detail is open shows its final output on the next refresh", () => { + const j = job("bg-1", "running", "build", Date.now() - 5000); + let text = "step 1\nstep 2"; + const s = setup({ jobs: [j], output: () => text }); + const b = new JobBrowser(theme, s.src, { jobId: "bg-1" }); + try { + assert.ok(b.render(80).some((l) => l.includes("step 2"))); + // The job prints its last lines and exits between two refreshes. + text += "\nstep 3\nDONE"; + j.status = "done"; + j.exitCode = 0; + j.endedAt = Date.now(); + (b as any).outputCache.stale = true; // the 1s refresh tick + let lines = b.render(80); + assert.ok(lines.some((l) => l.includes("DONE")), "the final output is read once the job has ended"); + assert.match(lines[0]!, /✓ bg-1 · done/); + // Read after the end, the log is final: later ticks do not read it again. + let reads = 0; + s.src.output = () => (reads++, text); + (b as any).outputCache.stale = true; + lines = b.render(80); + assert.equal(reads, 0); + } finally { + b.dispose(); + } +}); + +test("a kill waiting for its yes: esc and n cancel without closing, and clicks only reach the buttons", () => { + const s = setup(); + const b = new JobBrowser(theme, s.src, { jobId: "bg-2" }); + try { + b.render(80); + b.handleInput("k"); + b.handleInput(KEY.esc); + assert.equal(b.pendingKill(), null, "esc cancels the kill"); + assert.equal(s.closed(), 0, "and does not close the overlay"); + b.handleInput("k"); + b.handleInput("n"); + assert.equal(b.pendingKill(), null); + b.handleInput("x"); // any other key while nothing is pending is just a key + assert.deepEqual(s.killed, []); + + // The [✗ kill] button asks too; [Cancel] cancels, a click elsewhere does nothing. + let lines = b.render(80); + let y = rowOf(lines, "[✗ kill]"); + let x = lines[y]!.indexOf("[✗ kill]"); + b.handleMouse(mouse("press", x + 1, y)); + b.handleMouse(mouse("click", x + 1, y)); + assert.equal(b.pendingKill(), "bg-2"); + lines = b.render(80); + y = rowOf(lines, "[Cancel]"); + b.handleMouse(mouse("click", 10, 5)); // the log: ignored while asking + assert.equal(b.pendingKill(), "bg-2"); + b.handleMouse(mouse("click", lines[y]!.indexOf("[Cancel]") + 1, y)); + assert.equal(b.pendingKill(), null); + + // ...and [✗ Kill bg-2] confirms. + b.handleInput("k"); + lines = b.render(80); + y = rowOf(lines, "[✗ Kill bg-2]"); + b.handleMouse(mouse("click", lines[y]!.indexOf("[✗ Kill bg-2]") + 1, y)); + assert.deepEqual(s.killed, ["bg-2"]); + } finally { + b.dispose(); + } +}); + +test("a pending kill is dropped if the job ends on its own first", () => { + const j = job("bg-7", "running", "make", Date.now() - 1000); + const s = setup({ jobs: [j] }); + const b = new JobBrowser(theme, s.src, { jobId: "bg-7" }); + try { + b.handleInput("k"); + assert.equal(b.pendingKill(), "bg-7"); + j.status = "done"; + j.endedAt = Date.now(); + const lines = b.render(80); + assert.equal(b.pendingKill(), null); + assert.ok(!lines.some((l) => l.includes("[Cancel]"))); + b.handleInput(KEY.enter); + assert.deepEqual(s.killed, []); + } finally { + b.dispose(); + } +}); + +test("the ✕ in the top-right corner closes the overlay, from the list and from a job", () => { + for (const opts of [{}, { jobId: "bg-1" }]) { + const s = setup(); + const b = new JobBrowser(theme, s.src, opts); + try { + const lines = b.render(80); + assert.equal(visibleWidth(lines[0]!), 80, "the top border keeps its width"); + assert.match(lines[0]!, / ✕ ─╮$/); + const x = lines[0]!.indexOf("✕"); + b.handleMouse(mouse("press", x, 0)); + b.handleMouse(mouse("click", x, 0)); + assert.equal(s.closed(), 1, `closed from ${JSON.stringify(opts)}`); + // A click a cell to its left counts too; the title does not. + b.handleMouse(mouse("click", x - 1, 0)); + assert.equal(s.closed(), 2); + b.handleMouse(mouse("click", 5, 0)); + assert.equal(s.closed(), 2); + } finally { + b.dispose(); + } + } +}); + +test("the list has a scrollbar that drags its view and jumps on a track press, never opening a row", () => { + const jobs = Array.from({ length: 40 }, (_, i) => job(`bg-${i + 1}`, "done", `cmd ${i + 1}`, Date.now() - (40 - i) * 10_000)); + const { src } = setup({ jobs, rows: 20 }); + const b = new JobBrowser(theme, src); + try { + let lines = b.render(80); + const top = rowOf(lines, "bg-40"); // the first job row: the track starts there + const bottom = rowOf(lines, "bg-34"); // 7 rows fit, so bg-34 ends the first page + assert.equal(lines[top]![77], "█", "the thumb starts at the top of the track"); + assert.equal(lines[top + 1]![77], "░"); + assert.equal(lines[bottom]![77], "░"); + + // Drag the thumb to the bottom of the track: the view jumps to the oldest + // jobs, keeping the selection on the same screen row. + assert.deepEqual(b.handleMouse(mouse("press", 77, top)), { handled: true, capture: true }); + b.handleMouse(mouse("drag", 77, bottom)); + b.handleMouse(mouse("release", 77, bottom)); + lines = b.render(80); + assert.equal(b.state().jobId, "bg-7", "the selection kept its screen row at the end of the list"); + assert.ok(lines.some((l) => l.includes("bg-1 ")), "the last job scrolled into view"); + assert.equal(lines[rowOf(lines, "bg-1")]![77], "█", "the thumb sits at the bottom"); + + // A press on the track above the thumb jumps there (and is not a row + // press); the click it produces opens nothing. + b.handleMouse(mouse("press", 77, top)); + b.handleMouse(mouse("release", 77, top)); + b.handleMouse(mouse("click", 77, top)); + assert.equal(b.state().view, "list", "clicking the bar never opens the row behind it"); + assert.equal(b.state().jobId, "bg-40"); + lines = b.render(80); + assert.equal(lines[top]![77], "█", "the track press scrolled back to the top"); + } finally { + b.dispose(); + } +}); + +test("the detail's scrollbar drags the log and jumps on a track press, never selecting text", () => { + const long = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`).join("\n"); + const s = setup({ output: () => long, rows: 30 }); + const b = new JobBrowser(theme, s.src, { jobId: "bg-2" }); + try { + let lines = b.render(80); + assert.ok(lines.some((l) => l.includes("line 200")), "opens on the newest output"); + const bodyTop = rowOf(lines, "line 187"); // the first log row: the track's top + const bodyBottom = rowOf(lines, "line 200"); // and its bottom + assert.equal(lines[bodyTop]![77], "░"); + assert.equal(lines[bodyBottom]![77], "█", "following puts the thumb at the bottom"); + + // Grab the thumb and drag it to the top: the log is back at its start. + assert.deepEqual(b.handleMouse(mouse("press", 77, bodyBottom)), { handled: true, capture: true }); + b.handleMouse(mouse("drag", 77, bodyTop)); + lines = b.render(80); + assert.ok(lines.some((l) => l.includes("line 1 ")), "the drag scrolled to the top"); + assert.equal(lines[bodyTop]![77], "█"); + b.handleMouse(mouse("release", 77, bodyTop)); + + // A press on the track jumps the thumb there; still dragging after it. + b.handleMouse(mouse("press", 77, bodyTop + 6)); + lines = b.render(80); + assert.ok(!lines.some((l) => l.includes("line 1 ")) && !lines.some((l) => l.includes("line 200")), "jumped to the middle"); + b.handleMouse(mouse("drag", 77, bodyBottom)); + b.handleMouse(mouse("release", 77, bodyBottom)); + lines = b.render(80); + assert.ok(lines.some((l) => l.includes("line 200")), "dragged back to the tail"); + + // A click on the bar selects nothing and copies nothing. + b.handleMouse(mouse("press", 77, bodyTop)); + b.handleMouse(mouse("release", 77, bodyTop)); + b.handleMouse(mouse("click", 77, bodyTop, { clickCount: 2 })); + assert.equal(b.selectedText(), undefined); + assert.deepEqual(s.copied, []); + } finally { + b.dispose(); + } +}); diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 0000000..989e612 --- /dev/null +++ b/test/commands.test.ts @@ -0,0 +1,101 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { completeShellBg, matchesJob, parseShellBgArgs, shellBgArgument, sortJobs, wantsFollowUpCompletion } from "../src/commands.ts"; +import type { Job, JobStatus } from "../src/types.ts"; + +function job(id: string, status: JobStatus, command: string, startedAt: number): Job { + return { + id, + command, + cwd: "/tmp", + pid: 1, + status, + exitCode: status === "done" ? 0 : null, + signal: null, + logPath: `/tmp/${id}.log`, + startedAt, + endedAt: status === "running" ? null : startedAt + 1000, + auto: false, + delivered: false, + }; +} + +const jobs = [ + job("bg-1", "done", "ls -la", 1000), + job("bg-2", "running", "npm run dev", 2000), + job("bg-3", "failed", "npm test", 3000), + job("bg-12", "running", "cargo build --release", 4000), +]; + +test("an empty argument offers every subcommand", () => { + const items = completeShellBg("", jobs)!; + assert.deepEqual( + items.map((i) => i.label), + ["tui", "status", "kill", "list"], + ); + assert.ok(items.every((i) => i.value.endsWith(" ")), "a verb completes with a trailing space, ready for the id"); +}); + +test("a partial subcommand narrows the verbs", () => { + assert.deepEqual(completeShellBg("st", jobs)!.map((i) => i.value), ["status "]); + assert.deepEqual(completeShellBg("t", jobs)!.map((i) => i.value), ["tui "]); + assert.deepEqual(completeShellBg("k", jobs)!.map((i) => i.value), ["kill "]); + assert.equal(completeShellBg("zzz", jobs), null); +}); + +test("kill completes only running jobs, newest first", () => { + const items = completeShellBg("kill ", jobs)!; + assert.deepEqual(items.map((i) => i.value), ["kill bg-12", "kill bg-2"]); + assert.match(items[0]!.label, /⟳ bg-12/); + assert.match(items[0]!.description!, /running · cargo build/); +}); + +test("status completes every job, with its status icon", () => { + const items = completeShellBg("status ", jobs)!; + assert.deepEqual(items.map((i) => i.value), ["status bg-12", "status bg-3", "status bg-2", "status bg-1"]); + assert.deepEqual(items.map((i) => i.label.split(" ")[0]), ["⟳", "✗", "⟳", "✓"]); +}); + +test("a job completion filters by id or by command", () => { + assert.deepEqual(completeShellBg("status bg-1", jobs)!.map((i) => i.value), ["status bg-12", "status bg-1"]); + assert.deepEqual(completeShellBg("status npm", jobs)!.map((i) => i.value), ["status bg-3", "status bg-2"]); + assert.deepEqual(completeShellBg("kill npm", jobs)!.map((i) => i.value), ["kill bg-2"], "kill never offers a finished job"); + assert.equal(completeShellBg("kill ls", jobs), null); +}); + +test("nothing is offered once the id is complete, or for an unknown verb", () => { + assert.equal(completeShellBg("status bg-1 ", jobs), null); + assert.equal(completeShellBg("list ", jobs), null); + assert.equal(completeShellBg("nope ", jobs), null); +}); + +test("parseShellBgArgs maps every form to its action", () => { + assert.deepEqual(parseShellBgArgs(""), { kind: "none" }); + assert.deepEqual(parseShellBgArgs(undefined), { kind: "none" }); + assert.deepEqual(parseShellBgArgs(" tui "), { kind: "browse" }); + assert.deepEqual(parseShellBgArgs("status"), { kind: "browse" }); + assert.deepEqual(parseShellBgArgs(" status bg-4 "), { kind: "status", id: "bg-4" }); + assert.deepEqual(parseShellBgArgs("bg-4"), { kind: "status", id: "bg-4" }); + assert.deepEqual(parseShellBgArgs("kill"), { kind: "pick-kill" }); + assert.deepEqual(parseShellBgArgs("kill bg-2"), { kind: "kill", id: "bg-2" }); + assert.deepEqual(parseShellBgArgs("list"), { kind: "list" }); + assert.deepEqual(parseShellBgArgs("frob"), { kind: "unknown", verb: "frob" }); +}); + +test("matchesJob and sortJobs", () => { + assert.ok(matchesJob(jobs[3]!, "CARGO")); + assert.ok(matchesJob(jobs[3]!, "12")); + assert.ok(!matchesJob(jobs[0]!, "npm")); + assert.ok(matchesJob(jobs[0]!, " ")); + assert.deepEqual(sortJobs(jobs).map((j) => j.id), ["bg-12", "bg-3", "bg-2", "bg-1"]); +}); + +test("shellBgArgument and wantsFollowUpCompletion read the editor line", () => { + assert.equal(shellBgArgument("/shell-bg kill b"), "kill b"); + assert.equal(shellBgArgument("/shell-bg "), ""); + assert.equal(shellBgArgument("/shell-bg"), null, "still typing the command name"); + assert.equal(shellBgArgument("/shell-bgx kill"), null); + assert.equal(shellBgArgument("say /shell-bg kill"), null); + for (const t of ["/shell-bg ", "/shell-bg kill ", "/shell-bg status "]) assert.ok(wantsFollowUpCompletion(t), t); + for (const t of ["/shell-bg kill bg-1", "/shell-bg list ", "/shell-bg kill", "/shell-bg"]) assert.ok(!wantsFollowUpCompletion(t), t); +}); diff --git a/test/extension.test.ts b/test/extension.test.ts index 9abf4e7..7c45eda 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -550,3 +550,404 @@ test("issue #2: a job nobody collects is delivered once at the turn boundary, no await rmDir(regDir); } }); + +test("/shell-bg: completions offer only running jobs for kill, and the widget and command open the browser", async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-ext-cmd-")); + const { id: sid, regDir } = uniqueSession(); + const h = harness(); + const commands = new Map(); + (h.pi as any).registerCommand = (name: string, opts: unknown) => commands.set(name, opts); + let widget: any; + const notes: string[] = []; + const opened: any[] = []; + const base = makeCtx(cwd, true, sid) as any; + const ctx = { + ...base, + mode: "tui", + ui: { + ...base.ui, + theme: { fg: (_c: string, t: string) => t, bg: (_c: string, t: string) => t, bold: (t: string) => t }, + setWidget: (k: string, factory: any) => { + if (k === "shell-bg") widget = factory?.({}); + }, + notify: (m: string) => notes.push(m), + // Build the component the way pi does and record the overlay options; close at once. + custom: async (factory: any, options: any) => { + let done = false; + const tui = { terminal: { columns: 200, rows: 50 }, requestRender: () => {} }; + const c = factory(tui, ctx.ui.theme, {}, () => (done = true)); + opened.push({ state: c.state(), options: options.overlayOptions(), lines: c.render(100) }); + c.dispose(); + assert.equal(done, false); + }, + }, + } as unknown as ExtensionContext; + try { + shellBackground(h.pi); + await h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx); + const bash = h.tools.get("bash")!; + const quick = await bash.execute("t1", { command: "echo hi", background: true }, undefined, undefined, ctx); + const slow = await bash.execute("t2", { command: "sleep 30", background: true }, undefined, undefined, ctx); + const quickId = quick.details.id as string; + const slowId = slow.details.id as string; + await waitFor(() => h.tools.get("shell_status")!.execute("p", { id: quickId }).then((r: any) => r.details.status === "done"), 5000); + + const cmd = commands.get("shell-bg"); + assert.ok(cmd, "the command is registered"); + assert.deepEqual((await cmd.getArgumentCompletions("")).map((i: any) => i.label), ["tui", "status", "kill", "list"]); + assert.deepEqual((await cmd.getArgumentCompletions("kill ")).map((i: any) => i.value), [`kill ${slowId}`]); + assert.deepEqual( + (await cmd.getArgumentCompletions("status ")).map((i: any) => i.value).sort(), + [`status ${quickId}`, `status ${slowId}`].sort(), + ); + + // /shell-bg status opens that job's detail, centered and sized to the terminal. + await cmd.handler(`status ${quickId}`, ctx); + assert.equal(opened.length, 1); + assert.deepEqual(opened[0].state, { view: "detail", jobId: quickId, query: "" }); + assert.equal(opened[0].options.anchor, "center"); + assert.equal(opened[0].options.width, "80%", "160 of 200 columns"); + assert.ok(opened[0].lines.some((l: string) => l.includes("hi")), "the detail shows the job's output"); + + // /shell-bg tui and /shell-bg status open the list; a bare /shell-bg opens nothing. + const notesBefore = notes.length; + await cmd.handler("", ctx); + assert.equal(opened.length, 1, "bare /shell-bg opens no window"); + assert.equal(notes.length, notesBefore, "and says nothing"); + await cmd.handler("tui", ctx); + await cmd.handler("status", ctx); + assert.deepEqual(opened.slice(1).map((o) => o.state.view), ["list", "list"]); + + // An unknown id is a warning, not an empty window. + await cmd.handler("status bg-999", ctx); + assert.equal(opened.length, 3); + assert.match(notes.at(-1)!, /No job "bg-999"/); + + // Clicking a job row in the widget opens that job. + assert.ok(widget, "the widget is a component"); + const rows: string[] = widget.render(80); + const y = rows.findIndex((l) => l.includes(slowId)); + assert.ok(y > 0, "the running job has a widget row"); + const ev = (type: string) => ({ type, button: "left", x: 3, y, screenX: 3, screenY: y, width: 80, height: rows.length, shift: false, alt: false, ctrl: false }); + assert.deepEqual(widget.handleMouse(ev("press")), { handled: true, render: false }); + widget.handleMouse(ev("click")); + await waitFor(() => opened.length === 4, 2000); + assert.deepEqual(opened[3].state, { view: "detail", jobId: slowId, query: "" }); + + // kill stops it. + await cmd.handler(`kill ${slowId}`, ctx); + assert.match(notes.at(-1)!, new RegExp(`Killing ${slowId}`)); + } finally { + await teardown(h, ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +test("/shell-bg autocomplete: arguments are served on Tab too, and accepting a subcommand reopens the list", async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-ext-ac-")); + const { id: sid, regDir } = uniqueSession(); + const h = harness(); + let wrapperFactory: any; + let triggered = 0; + let editorText = ""; + const editor = { + tryTriggerAutocomplete: () => triggered++, + isShowingAutocomplete: () => false, + getText: () => editorText, + }; + const fakeTui = { getFocusedComponent: () => editor, terminal: { columns: 120, rows: 40 }, requestRender: () => {} }; + const base = makeCtx(cwd, true, sid) as any; + const ctx = { + ...base, + mode: "tui", + ui: { + ...base.ui, + setWidget: (_k: string, factory: any) => factory?.(fakeTui), + addAutocompleteProvider: (f: any) => (wrapperFactory = f), + }, + } as unknown as ExtensionContext; + try { + shellBackground(h.pi); + await h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx); + assert.ok(wrapperFactory, "an autocomplete wrapper is registered"); + + const bash = h.tools.get("bash")!; + const slow = await bash.execute("t1", { command: "sleep 30", background: true }, undefined, undefined, ctx); + const slowId = slow.details.id as string; + + // The base provider: file paths for anything forced, and a plain line edit to apply. + const baseCalls: string[] = []; + const baseProvider = { + triggerCharacters: ["@"], + getSuggestions: async (lines: string[]) => { + baseCalls.push(lines[0]!); + return { items: [{ value: "some/file", label: "file" }], prefix: "" }; + }, + applyCompletion: (lines: string[], line: number, col: number, item: any, prefix: string) => { + const cur = lines[line]!; + const next = cur.slice(0, col - prefix.length) + item.value + cur.slice(col); + return { lines: [next], cursorLine: 0, cursorCol: col - prefix.length + item.value.length }; + }, + shouldTriggerFileCompletion: () => false, + }; + const p = wrapperFactory(baseProvider); + const signal = new AbortController().signal; + assert.deepEqual(p.triggerCharacters, ["@"], "other properties pass through"); + + // A forced (Tab) request after "/shell-bg kill " gets the running jobs, not file paths. + const line = "/shell-bg kill "; + assert.equal(p.shouldTriggerFileCompletion([line], 0, line.length), true); + const s = await p.getSuggestions([line], 0, line.length, { signal, force: true }); + assert.deepEqual(s.items.map((i: any) => i.value), [`kill ${slowId}`]); + assert.equal(s.prefix, "kill "); + assert.deepEqual(baseCalls, [], "the base provider is not asked about /shell-bg arguments"); + + // Anything else goes to the base provider. + await p.getSuggestions(["hello @src"], 0, 10, { signal }); + assert.deepEqual(baseCalls, ["hello @src"]); + + // Accepting the "kill " subcommand reopens the list once the editor is done with the key. + const typed = "/shell-bg k"; + const verb = (await p.getSuggestions([typed], 0, typed.length, { signal })).items[0]; + const applied = p.applyCompletion([typed], 0, typed.length, verb, "k"); + assert.equal(applied.lines[0], "/shell-bg kill "); + editorText = applied.lines[0]; + await sleep(20); + assert.equal(triggered, 1, "the editor's completion list was reopened"); + + // Accepting a job id does not reopen anything. + p.applyCompletion(["/shell-bg kill b"], 0, 16, { value: `kill ${slowId}`, label: slowId }, "kill b"); + editorText = `/shell-bg kill ${slowId}`; + await sleep(20); + assert.equal(triggered, 1); + } finally { + await teardown(h, ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +test("a second /reload while an adopted job runs does not touch the stale ctx (crash: 'ctx is stale')", async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-reload2-")); + const { id: sid, regDir } = uniqueSession(); + const [a, b, c] = [harness(), harness(), harness()]; + // pi's ctx throws on any access once its runtime was replaced; model that for B. + let bStale = false; + const ctxB = new Proxy(makeCtx(cwd, false, sid) as any, { + get(target, prop, recv) { + if (bStale) throw new Error("This extension ctx is stale after session replacement or reload."); + return Reflect.get(target, prop, recv); + }, + }) as ExtensionContext; + const ctxA = makeCtx(cwd, false, sid); + const ctxC = makeCtx(cwd, false, sid); + const errors: unknown[] = []; + const onError = (e: unknown) => errors.push(e); + process.on("uncaughtException", onError); + try { + shellBackground(a.pi); + await a.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctxA); + await a.tools.get("bash")!.execute("t1", { command: "sleep 4", background: true }, undefined, undefined, ctxA); + + await a.handlers.get("session_shutdown")!({ type: "session_shutdown", reason: "reload" }, ctxA); + shellBackground(b.pi); + await b.handlers.get("session_start")!({ type: "session_start", reason: "reload" }, ctxB); // B adopts, starts its poller + + await b.handlers.get("session_shutdown")!({ type: "session_shutdown", reason: "reload" }, ctxB); + bStale = true; + shellBackground(c.pi); + await c.handlers.get("session_start")!({ type: "session_start", reason: "reload" }, ctxC); + + await sleep(2500); // B's adoption poller ticks every second + assert.deepEqual(errors, [], "nothing threw from the old instance's timers"); + } finally { + process.off("uncaughtException", onError); + await teardown(c, ctxC); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +// ── issue #3: a kill is reported once, and a manual kill never wakes the model ── + +/** A harness that also records sendMessage options, plus the /shell-bg command. */ +function killHarness(cwd: string, sid: string) { + const h = harness(); + const opts: any[] = []; + (h.pi as any).sendMessage = (m: unknown, o: unknown) => { + h.sent.push(m); + opts.push(o); + }; + const commands = new Map(); + (h.pi as any).registerCommand = (n: string, o: unknown) => commands.set(n, o); + const ctx = { ...(makeCtx(cwd, true, sid) as any), mode: "tui" } as ExtensionContext; + return { h, opts, commands, ctx }; +} + +const allStopped = (h: Harness) => async () => + /^0 running/.test(String((await h.tools.get("shell_status")!.execute("p", {})).content[0].text)); + +test("issue #3: shell_kill reports the kill in its result, and nothing is delivered after the turn", async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-kill3-")); + const { id: sid, regDir } = uniqueSession(); + const { h, ctx } = killHarness(cwd, sid); + try { + shellBackground(h.pi); + await h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx); + await h.handlers.get("agent_start")!({ type: "agent_start" }, ctx); + const r = await h.tools.get("bash")!.execute("t1", { command: "sleep 60", background: true }, undefined, undefined, ctx); + const id = r.details.id as string; + const k = await h.tools.get("shell_kill")!.execute("t2", { id }); + assert.equal(k.details.status, "killed", "shell_kill waited for the exit"); + assert.match(k.content[0].text, new RegExp(`\\[${id} · killed`)); + assert.match(k.content[0].text, /\[stopped with shell_kill\]/); + assert.equal(k.structuredContent?.status, "killed"); + await h.handlers.get("agent_settled")!({ type: "agent_settled" }, ctx); + await sleep(50); + assert.equal(h.sent.length, 0, "the kill is not delivered again as a follow-up"); + } finally { + await teardown(h, ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +test("issue #3: a job that ignores SIGTERM is still reported once, after the SIGKILL escalation", { skip: process.platform === "win32" }, async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-kill3-term-")); + const { id: sid, regDir } = uniqueSession(); + const { h, ctx } = killHarness(cwd, sid); + try { + shellBackground(h.pi); + await h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx); + await h.handlers.get("agent_start")!({ type: "agent_start" }, ctx); + const r = await h.tools + .get("bash")! + .execute("t1", { command: "trap '' TERM; echo ready; while :; do sleep 0.1; done", background: true }, undefined, undefined, ctx); + // Kill only once the trap is set, or the shell dies to SIGTERM before it ever ignores it. + await waitFor(async () => /ready/.test(String((await h.tools.get("shell_status")!.execute("p", { id: r.details.id })).content[0].text)), 5000); + const started = Date.now(); + const k = await h.tools.get("shell_kill")!.execute("t2", { id: r.details.id }); + assert.equal(k.details.status, "killed"); + assert.ok(Date.now() - started >= 2500, `it really ignored SIGTERM and died to the SIGKILL (${Date.now() - started}ms)`); + await h.handlers.get("agent_settled")!({ type: "agent_settled" }, ctx); + await sleep(50); + assert.equal(h.sent.length, 0); + } finally { + await teardown(h, ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +test("issue #3: shell_kill on a job adopted after /reload is reported once too", async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-kill3-adopt-")); + const { id: sid, regDir } = uniqueSession(); + const a = killHarness(cwd, sid); + const b = killHarness(cwd, sid); + try { + shellBackground(a.h.pi); + await a.h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, a.ctx); + const r = await a.h.tools.get("bash")!.execute("t1", { command: "sleep 60", background: true }, undefined, undefined, a.ctx); + const id = r.details.id as string; + await a.h.handlers.get("session_shutdown")!({ type: "session_shutdown", reason: "reload" }, a.ctx); + shellBackground(b.h.pi); + await b.h.handlers.get("session_start")!({ type: "session_start", reason: "reload" }, b.ctx); + + await b.h.handlers.get("agent_start")!({ type: "agent_start" }, b.ctx); + const k = await b.h.tools.get("shell_kill")!.execute("t2", { id }); + assert.equal(k.details.status, "killed", "the adoption poller's status flip is seen within the wait"); + await b.h.handlers.get("agent_settled")!({ type: "agent_settled" }, b.ctx); + await sleep(1200); // one more adoption tick: it must not deliver either + assert.equal(b.h.sent.length + a.h.sent.length, 0); + } finally { + await teardown(b.h, b.ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +test("issue #3: a manual /shell-bg kill while idle tells the model with the next message, without starting a turn", async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-kill3-user-")); + const { id: sid, regDir } = uniqueSession(); + const { h, opts, commands, ctx } = killHarness(cwd, sid); + try { + shellBackground(h.pi); + await h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx); + await h.handlers.get("agent_start")!({ type: "agent_start" }, ctx); + const r = await h.tools.get("bash")!.execute("t1", { command: "sleep 60", background: true }, undefined, undefined, ctx); + await h.handlers.get("agent_settled")!({ type: "agent_settled" }, ctx); // the turn is over: idle + const id = r.details.id as string; + + await commands.get("shell-bg").handler(`kill ${id}`, ctx); + await waitFor(allStopped(h), 5000); + await waitFor(() => h.sent.length > 0, 2000); + assert.equal(h.sent.length, 1); + assert.deepEqual(opts[0], { deliverAs: "nextTurn" }, "queued for the next user message; no triggerTurn"); + assert.match(h.sent[0].content, /stopped by the user/); + assert.match(h.sent[0].content, /The user stopped it themselves/); + assert.match(h.sent[0].content, /Do not restart it/); + assert.doesNotMatch(h.sent[0].content, /it has just finished/); + + // shell_status says so too, should the model look. + const s = await h.tools.get("shell_status")!.execute("s", { id }); + assert.match(s.content[0].text, new RegExp(`\\[${id} · killed · stopped by the user`)); + } finally { + await teardown(h, ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +test("issue #3: a manual kill during a turn is held to the turn's end, then queued without a new turn", async () => { + const cwd = mkdtempSync(join(tmpdir(), "sbg-kill3-user-turn-")); + const { id: sid, regDir } = uniqueSession(); + const { h, opts, commands, ctx } = killHarness(cwd, sid); + try { + shellBackground(h.pi); + await h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx); + await h.handlers.get("agent_start")!({ type: "agent_start" }, ctx); + const r = await h.tools.get("bash")!.execute("t1", { command: "sleep 60", background: true }, undefined, undefined, ctx); + await commands.get("shell-bg").handler(`kill ${r.details.id}`, ctx); + await waitFor(allStopped(h), 5000); + await sleep(50); + assert.equal(h.sent.length, 0, "nothing mid-turn"); + await h.handlers.get("agent_settled")!({ type: "agent_settled" }, ctx); + assert.equal(h.sent.length, 1); + assert.deepEqual(opts[0], { deliverAs: "nextTurn" }); + } finally { + await teardown(h, ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); + +test("a job adopted after /reload and killed there reads as killed, even when it exits with a plain code (as on Windows)", { skip: process.platform === "win32" }, async () => { + // On Windows taskkill leaves exit code 1 and no signal. The old instance, + // which settles the job, never saw the kill and recorded "failed". This shell + // exits the same way on SIGTERM, so POSIX runs the same path. + const cwd = mkdtempSync(join(tmpdir(), "sbg-kill3-code-")); + const { id: sid, regDir } = uniqueSession(); + const a = killHarness(cwd, sid); + const b = killHarness(cwd, sid); + try { + shellBackground(a.h.pi); + await a.h.handlers.get("session_start")!({ type: "session_start", reason: "startup" }, a.ctx); + const r = await a.h.tools + .get("bash")! + .execute("t1", { command: "trap 'exit 1' TERM; echo ready; while :; do sleep 0.1; done", background: true }, undefined, undefined, a.ctx); + const id = r.details.id as string; + await waitFor(async () => /ready/.test(String((await a.h.tools.get("shell_status")!.execute("p", { id })).content[0].text)), 5000); + await a.h.handlers.get("session_shutdown")!({ type: "session_shutdown", reason: "reload" }, a.ctx); + shellBackground(b.h.pi); + await b.h.handlers.get("session_start")!({ type: "session_start", reason: "reload" }, b.ctx); + + const k = await b.h.tools.get("shell_kill")!.execute("t2", { id }); + assert.equal(k.details.status, "killed", "not failed: the kill came from this instance"); + assert.equal(k.details.exitCode, 1); + } finally { + await teardown(b.h, b.ctx); + await rmDir(cwd); + await rmDir(regDir); + } +}); diff --git a/test/pending.test.ts b/test/pending.test.ts index 91c52e9..736ae10 100644 --- a/test/pending.test.ts +++ b/test/pending.test.ts @@ -45,3 +45,12 @@ test("delivery message wraps the body and explains itself", () => { test("delivery type is stable", () => { assert.equal(DELIVERY_TYPE, "pify-shell-background-result"); }); + +test("a job the user stopped says so, and asks the model not to restart it", () => { + const m = deliveryMessage("bg-3", "[bg-3 · killed · stopped by the user · 4s]", { stoppedByUser: true }); + assert.match(m, //); + assert.match(m, /The user stopped it themselves/); + assert.match(m, /did not crash/); + assert.match(m, /Do not restart it unless the user asks/); + assert.ok(!/it has just finished/.test(m)); +}); diff --git a/test/spawn.test.ts b/test/spawn.test.ts index dcfb842..0248f93 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -72,3 +72,21 @@ test("killTree is a safe no-op on invalid pids", () => { killTree(-5); }); }); + +test("the log is complete the moment exit resolves, even after a burst of output", async () => { + const dir = mkdtempSync(join(tmpdir(), "sbg-flush-")); + try { + const { shell: sh, args } = shell(); + // Before the fix, about 1 run in 5 read here lost its tail: exit resolved + // while the last writes were still queued in the log's write stream. + for (let i = 0; i < 20; i++) { + const log = join(dir, `l${i}.log`); + const s = spawnToFile(sh, args, "seq 1 50000; echo END-MARKER", dir, process.env, log); + await s.exit; // no sleep: whatever reads the log at settle time must see it all + const text = readTail(log, 1024 * 1024).text; + assert.ok(text.trimEnd().endsWith("END-MARKER"), `run ${i}: log ends with ${JSON.stringify(text.slice(-20))}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});