From c958d3d41bd2fb758f27df8f8c797b1482eaf326 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:10:49 -0800 Subject: [PATCH] feat: unify list/delete commands with inline delete picker (#1762) Both `spawn list` and `spawn delete` now share a single interactive picker (`activeServerPicker`) backed by `getActiveServers()`. Pressing `d` in the picker triggers inline delete-and-refresh without leaving the list. Failed deletions now mark entries as deleted so users aren't stuck with phantom servers they can't clear. Co-authored-by: Claude Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands.ts | 114 +++++++++++++++++------------ cli/src/picker.ts | 171 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 44 deletions(-) diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 630fa24a..d0998dc2 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -2284,6 +2284,7 @@ async function execDeleteServer(record: SpawnRecord): Promise { } p.log.error(`Delete failed: ${errMsg}`); p.log.info("The server may still be running. Check your cloud provider dashboard."); + markRecordDeleted(record); return false; } }; @@ -2350,8 +2351,8 @@ async function execDeleteServer(record: SpawnRecord): Promise { } } -/** Prompt for delete confirmation and execute */ -async function confirmAndDelete(record: SpawnRecord, manifest: Manifest | null): Promise { +/** Prompt for delete confirmation and execute. Returns true if deleted. */ +async function confirmAndDelete(record: SpawnRecord, manifest: Manifest | null): Promise { const conn = record.connection!; const label = conn.server_name || conn.server_id || conn.ip; const cloudLabel = manifest?.clouds[conn.cloud!]?.name || conn.cloud; @@ -2363,7 +2364,7 @@ async function confirmAndDelete(record: SpawnRecord, manifest: Manifest | null): if (p.isCancel(confirmed) || !confirmed) { p.log.info("Delete cancelled."); - return; + return false; } const s = p.spinner(); @@ -2376,6 +2377,7 @@ async function confirmAndDelete(record: SpawnRecord, manifest: Manifest | null): } else { s.stop("Delete failed."); } + return success; } /** Handle reconnect or rerun action for a selected spawn record */ @@ -2487,30 +2489,52 @@ async function handleRecordAction(selected: SpawnRecord, manifest: Manifest | nu await cmdRun(selected.agent, selected.cloud, selected.prompt); } -/** Show interactive picker to select and reconnect/rerun a previous spawn */ -async function interactiveListPicker(records: SpawnRecord[], manifest: Manifest | null): Promise { - p.log.info( - pc.dim( - `Filter: ${pc.cyan("spawn list -a ")} or ${pc.cyan("spawn list -c ")} | Clear: ${pc.cyan("spawn list --clear")}`, - ), - ); +/** Interactive picker with inline delete support. + * Pressing 'd' triggers delete; Enter triggers handleRecordAction. */ +async function activeServerPicker(records: SpawnRecord[], manifest: Manifest | null): Promise { + const { pickToTTYWithActions } = await import("./picker.js"); - const options = records.map((r, i) => ({ - value: i, - label: buildRecordLabel(r, manifest), - hint: buildRecordHint(r), - })); + let remaining = [...records]; - const choice = await p.select({ - message: `Select a spawn (${records.length} recorded)`, - options, - }); - if (p.isCancel(choice)) { - handleCancel(); + while (remaining.length > 0) { + const options = remaining.map((r) => ({ + value: r.timestamp, + label: buildRecordLabel(r, manifest), + hint: buildRecordHint(r), + })); + + const result = pickToTTYWithActions({ + message: `Select a spawn (${remaining.length} server${remaining.length !== 1 ? "s" : ""})`, + options, + deleteKey: true, + }); + + if (result.action === "cancel") { + return; + } + + const picked = remaining[result.index]; + + if (result.action === "delete") { + const conn = picked.connection; + const canDelete = conn?.cloud && conn.cloud !== "local" && !conn.deleted && (conn.server_id || conn.server_name); + if (!canDelete) { + p.log.warn("This server cannot be deleted (no cloud connection info)."); + continue; + } + const deleted = await confirmAndDelete(picked, manifest); + if (deleted) { + remaining.splice(result.index, 1); + } + continue; + } + + // action === "select" + await handleRecordAction(picked, manifest); + return; } - const selected = records[choice]; - await handleRecordAction(selected, manifest); + p.log.info("No servers remaining."); } export async function cmdListClear(): Promise { @@ -2540,15 +2564,32 @@ export async function cmdList(agentFilter?: string, cloudFilter?: string): Promi agentFilter = resolved.agentFilter; cloudFilter = resolved.cloudFilter; - const records = filterHistory(agentFilter, cloudFilter); + if (isInteractiveTTY()) { + // Interactive mode: show active servers with inline delete + const servers = getActiveServers(); + let filtered = servers; + if (agentFilter) { + const lower = agentFilter.toLowerCase(); + filtered = filtered.filter((r) => r.agent.toLowerCase() === lower); + } + if (cloudFilter) { + const lower = cloudFilter.toLowerCase(); + filtered = filtered.filter((r) => r.cloud.toLowerCase() === lower); + } - if (records.length === 0) { - await showEmptyListMessage(agentFilter, cloudFilter); + if (filtered.length === 0) { + await showEmptyListMessage(agentFilter, cloudFilter); + return; + } + + await activeServerPicker(filtered, manifest); return; } - if (isInteractiveTTY()) { - await interactiveListPicker(records, manifest); + // Non-interactive: show full history table + const records = filterHistory(agentFilter, cloudFilter); + if (records.length === 0) { + await showEmptyListMessage(agentFilter, cloudFilter); return; } @@ -2595,22 +2636,7 @@ export async function cmdDelete(agentFilter?: string, cloudFilter?: string): Pro process.exit(1); } - const options = filtered.map((r, i) => ({ - value: i, - label: buildRecordLabel(r, manifest), - hint: buildRecordHint(r), - })); - - const choice = await p.select({ - message: `Select a server to delete (${filtered.length} active)`, - options, - }); - if (p.isCancel(choice)) { - handleCancel(); - } - - const selected = filtered[choice]; - await confirmAndDelete(selected, manifest); + await activeServerPicker(filtered, manifest); } export async function cmdLast(): Promise { diff --git a/cli/src/picker.ts b/cli/src/picker.ts index c7187969..a288afb9 100644 --- a/cli/src/picker.ts +++ b/cli/src/picker.ts @@ -30,6 +30,13 @@ export interface PickConfig { message: string; options: PickOption[]; defaultValue?: string; + deleteKey?: boolean; +} + +export interface PickResult { + action: "select" | "delete" | "cancel"; + value: string | null; + index: number; } /** @@ -264,6 +271,170 @@ export function pickToTTY(config: PickConfig): string | null { return result; } +/** + * Like pickToTTY but returns a PickResult with action discrimination. + * When deleteKey is enabled, pressing 'd' returns { action: "delete" }. + */ +export function pickToTTYWithActions(config: PickConfig): PickResult { + const cancel: PickResult = { action: "cancel", value: null, index: -1 }; + + if (config.options.length === 0) { + return config.defaultValue ? { action: "select", value: config.defaultValue, index: 0 } : cancel; + } + + // ── open /dev/tty ────────────────────────────────────────────────────────── + let ttyFd: number; + try { + ttyFd = fs.openSync("/dev/tty", "r+"); + } catch { + // Fall back to basic pickFallback which returns a value + const val = pickFallback(config); + return val ? { action: "select", value: val, index: config.options.findIndex((o) => o.value === val) } : cancel; + } + + // ── save terminal settings ──────────────────────────────────────────────── + const savedRes = spawnSync("stty", ["-g"], { stdio: [ttyFd, "pipe", "pipe"] }); + if (savedRes.status !== 0 || !savedRes.stdout) { + fs.closeSync(ttyFd); + const val = pickFallback(config); + return val ? { action: "select", value: val, index: config.options.findIndex((o) => o.value === val) } : cancel; + } + const savedSettings = savedRes.stdout.toString().trim(); + + // ── enable raw / no-echo mode ───────────────────────────────────────────── + const rawRes = spawnSync("stty", ["raw", "-echo"], { stdio: [ttyFd, "pipe", "pipe"] }); + if (rawRes.status !== 0) { + fs.closeSync(ttyFd); + const val = pickFallback(config); + return val ? { action: "select", value: val, index: config.options.findIndex((o) => o.value === val) } : cancel; + } + + // ── helpers ─────────────────────────────────────────────────────────────── + const w = (s: string) => { + try { + fs.writeSync(ttyFd, s); + } catch {} + }; + + const restore = () => { + try { + spawnSync("stty", [savedSettings], { stdio: [ttyFd, "pipe", "pipe"] }); + } catch {} + w(A.showC); + try { + fs.closeSync(ttyFd); + } catch {} + }; + + // ── initial state ───────────────────────────────────────────────────────── + let selected = 0; + if (config.defaultValue) { + const idx = config.options.findIndex((o) => o.value === config.defaultValue); + if (idx >= 0) { + selected = idx; + } + } + + const footerHint = config.deleteKey + ? "\u2191/\u2193 move \u23ce select d delete Ctrl-C cancel" + : "\u2191/\u2193 move \u23ce select Ctrl-C cancel"; + + // header line + one line per option + footer line + const pickerHeight = config.options.length + 2; + + const render = (first: boolean) => { + if (!first) { + w(A.up(pickerHeight) + A.col1 + A.clearBelow); + } + w(`${A.bold}${A.cyan}? ${config.message}${A.reset}\r\n`); + for (let i = 0; i < config.options.length; i++) { + const opt = config.options[i]; + if (i === selected) { + w(`${A.green}${A.bold}> ${opt.label}${A.reset}`); + if (opt.hint) { + w(` ${A.dim}${opt.hint}${A.reset}`); + } + } else { + w(` ${A.dim}${opt.label}${A.reset}`); + } + w("\r\n"); + } + w(`${A.dim} ${footerHint}${A.reset}\r\n`); + }; + + // ── render & key loop ───────────────────────────────────────────────────── + w(A.hideC); + render(true); + + const buf = Buffer.alloc(8); + let result: PickResult = cancel; + + try { + outer: while (true) { + let n: number; + try { + n = fs.readSync(ttyFd, buf, 0, 8); + } catch { + break; + } + if (n === 0) { + continue; + } + + const key = buf.slice(0, n).toString("binary"); + + switch (key) { + // ── cancel ───────────────────────────────────────────────────────── + case "\x03": // Ctrl-C + case "\x1b": // standalone Escape + break outer; + + // ── confirm ──────────────────────────────────────────────────────── + case "\r": + case "\n": { + result = { action: "select", value: config.options[selected].value, index: selected }; + // Replace picker with a one-line confirmation + w(A.up(pickerHeight) + A.col1 + A.clearBelow); + const opt = config.options[selected]; + w(`${A.green}${A.bold}> ${config.message}:${A.reset} ${A.cyan}${opt.label}${A.reset}\r\n`); + break outer; + } + + // ── delete ─────────────────────────────────────────────────────── + case "d": + if (config.deleteKey) { + result = { action: "delete", value: config.options[selected].value, index: selected }; + w(A.up(pickerHeight) + A.col1 + A.clearBelow); + break outer; + } + break; + + // ── navigation ───────────────────────────────────────────────────── + case "\x1b[A": // Up (CSI) + case "\x1bOA": // Up (SS3, some terminals) + case "k": // vim-style + selected = (selected - 1 + config.options.length) % config.options.length; + render(false); + break; + + case "\x1b[B": // Down (CSI) + case "\x1bOB": // Down (SS3) + case "j": // vim-style + selected = (selected + 1) % config.options.length; + render(false); + break; + + default: + break; + } + } + } finally { + restore(); + } + + return result; +} + // ── fallback picker ─────────────────────────────────────────────────────────── /**