diff --git a/cli/package.json b/cli/package.json index db316128..348976ff 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.48", + "version": "0.2.49", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/list-display.test.ts b/cli/src/__tests__/list-display.test.ts index 79e602eb..a4a41be3 100644 --- a/cli/src/__tests__/list-display.test.ts +++ b/cli/src/__tests__/list-display.test.ts @@ -403,7 +403,7 @@ describe("cmdList output", () => { await cmdList("claude"); const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); expect(allOutput).toContain("claude"); - expect(allOutput).toContain("1 spawn recorded"); + expect(allOutput).toContain("Showing 1 of 2 spawns"); }); it("should filter by cloud when cloudFilter is provided", async () => { @@ -418,7 +418,7 @@ describe("cmdList output", () => { await cmdList(undefined, "hetzner"); const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); expect(allOutput).toContain("hetzner"); - expect(allOutput).toContain("1 spawn recorded"); + expect(allOutput).toContain("Showing 1 of 2 spawns"); }); it("should show records in newest-first order", async () => { @@ -453,4 +453,65 @@ describe("cmdList output", () => { const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); expect(allOutput).not.toContain("AGENT"); }); + + it("should not show table when filtered results are empty", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-11T11:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList("nonexistent"); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + // Should not show table header when nothing matches + expect(allOutput).not.toContain("AGENT"); + expect(allOutput).not.toContain("CLOUD"); + }); + + it("should show 'Showing X of Y' when filter matches some results", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + { agent: "claude", cloud: "hetzner", timestamp: "2026-02-11T11:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-11T12:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList("claude"); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("Showing 2 of 3 spawns"); + }); + + it("should show 'Clear filter' hint when filtering", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-11T11:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList("claude"); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("Clear filter"); + expect(allOutput).toContain("spawn list"); + }); + + it("should not show 'Clear filter' hint when not filtering", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).not.toContain("Clear filter"); + // Should show normal filter hint instead + expect(allOutput).toContain("spawn list -a"); + }); }); diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 38ea6272..21ffc540 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -748,6 +748,40 @@ export async function cmdList(agentFilter?: string, cloudFilter?: string): Promi if (agentFilter) parts.push(`agent=${pc.bold(agentFilter)}`); if (cloudFilter) parts.push(`cloud=${pc.bold(cloudFilter)}`); p.log.info(`No spawns found matching ${parts.join(", ")}.`); + + // Suggest corrections for filter values using manifest data + try { + const manifest = await loadManifest(); + if (agentFilter) { + const resolved = resolveAgentKey(manifest, agentFilter); + if (resolved && resolved !== agentFilter) { + p.log.info(`Did you mean ${pc.cyan(`spawn list -a ${resolved}`)}?`); + } else if (!resolved) { + const match = findClosestKeyByNameOrKey(agentFilter, agentKeys(manifest), (k) => manifest.agents[k].name); + if (match) { + p.log.info(`Did you mean ${pc.cyan(`spawn list -a ${match}`)}?`); + } + } + } + if (cloudFilter) { + const resolved = resolveCloudKey(manifest, cloudFilter); + if (resolved && resolved !== cloudFilter) { + p.log.info(`Did you mean ${pc.cyan(`spawn list -c ${resolved}`)}?`); + } else if (!resolved) { + const match = findClosestKeyByNameOrKey(cloudFilter, cloudKeys(manifest), (k) => manifest.clouds[k].name); + if (match) { + p.log.info(`Did you mean ${pc.cyan(`spawn list -c ${match}`)}?`); + } + } + } + } catch { + // Manifest unavailable -- skip suggestions + } + + const totalRecords = filterHistory(); + if (totalRecords.length > 0) { + p.log.info(`Run ${pc.cyan("spawn list")} to see all ${totalRecords.length} recorded spawn${totalRecords.length !== 1 ? "s" : ""}.`); + } } else { p.log.info("No spawns recorded yet."); p.log.info(`Run ${pc.cyan("spawn ")} to launch your first agent.`); @@ -783,8 +817,14 @@ export async function cmdList(agentFilter?: string, cloudFilter?: string): Promi console.log(`Rerun last: ${pc.cyan(`spawn ${latest.agent} ${latest.cloud}`)}`); } - console.log(pc.dim(`${records.length} spawn${records.length !== 1 ? "s" : ""} recorded`)); - console.log(pc.dim(`Filter: ${pc.cyan("spawn list -a ")} or ${pc.cyan("spawn list -c ")}`)); + if (agentFilter || cloudFilter) { + const totalRecords = filterHistory(); + console.log(pc.dim(`Showing ${records.length} of ${totalRecords.length} spawn${totalRecords.length !== 1 ? "s" : ""}`)); + console.log(pc.dim(`Clear filter: ${pc.cyan("spawn list")}`)); + } else { + console.log(pc.dim(`${records.length} spawn${records.length !== 1 ? "s" : ""} recorded`)); + console.log(pc.dim(`Filter: ${pc.cyan("spawn list -a ")} or ${pc.cyan("spawn list -c ")}`)); + } console.log(); }