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 <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-22 15:10:49 -08:00 committed by GitHub
parent 986a6ff371
commit c958d3d41b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 241 additions and 44 deletions

View file

@ -2284,6 +2284,7 @@ async function execDeleteServer(record: SpawnRecord): Promise<boolean> {
}
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<boolean> {
}
}
/** Prompt for delete confirmation and execute */
async function confirmAndDelete(record: SpawnRecord, manifest: Manifest | null): Promise<void> {
/** Prompt for delete confirmation and execute. Returns true if deleted. */
async function confirmAndDelete(record: SpawnRecord, manifest: Manifest | null): Promise<boolean> {
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<void> {
p.log.info(
pc.dim(
`Filter: ${pc.cyan("spawn list -a <agent>")} or ${pc.cyan("spawn list -c <cloud>")} | 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<void> {
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<void> {
@ -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<void> {

View file

@ -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 ───────────────────────────────────────────────────────────
/**