mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-10 12:20:07 +00:00
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:
parent
986a6ff371
commit
c958d3d41b
2 changed files with 241 additions and 44 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue