fix: improve CLI UX for list filters, dry-run URLs, history, and matrix display (#537)

- Resolve display names in `spawn list -a/-c` filters (e.g., "Claude Code" -> "claude")
- Fix dry-run preview to show GitHub raw URL instead of non-existent openrouter.ai/lab URL
- Cap history at 100 entries to prevent unbounded growth
- Rename compact matrix "Missing" column to "Not yet available" for clarity
- Escape double quotes in rerun prompt suggestions to produce valid shell commands

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-11 14:59:31 -08:00 committed by GitHub
parent 0bbba737a5
commit 8ad9f8bd2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 54 additions and 30 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -224,10 +224,10 @@ describe("Compact List View", () => {
await cmdMatrix();
const output = getOutput();
// Compact view has "Agent", "Clouds", "Missing" header columns
// Compact view has "Agent", "Clouds", "Not yet available" header columns
expect(output).toContain("Agent");
expect(output).toContain("Clouds");
expect(output).toContain("Missing");
expect(output).toContain("Not yet available");
});
it("should use grid view when terminal is wide enough for small manifest", async () => {
@ -241,8 +241,8 @@ describe("Compact List View", () => {
expect(output).toContain("+");
expect(output).toContain("Sprite");
expect(output).toContain("Hetzner Cloud");
// Grid view should NOT have the "Missing" header column
expect(output).not.toContain("Missing");
// Grid view should NOT have the "Not yet available" header column
expect(output).not.toContain("Not yet available");
});
it("should default to 80 columns when process.stdout.columns is undefined", async () => {
@ -255,14 +255,14 @@ describe("Compact List View", () => {
// With 7 clouds at ~10+ chars each, the grid would be ~100+ chars
// which exceeds the 80-column default, so compact view should trigger
expect(output).toContain("Agent");
expect(output).toContain("Missing");
expect(output).toContain("Not yet available");
});
});
// ── Compact view header and structure ─────────────────────────────
describe("compact view header", () => {
it("should show three column headers: Agent, Clouds, Missing", async () => {
it("should show three column headers: Agent, Clouds, Not yet available", async () => {
await setManifest(wideManifest);
process.stdout.columns = 60;
@ -270,7 +270,7 @@ describe("Compact List View", () => {
const output = getOutput();
expect(output).toContain("Agent");
expect(output).toContain("Clouds");
expect(output).toContain("Missing");
expect(output).toContain("Not yet available");
});
it("should include a separator line with dashes", async () => {

View file

@ -391,12 +391,12 @@ describe("Dry-run preview (showDryRunPreview via cmdRun)", () => {
expect(getLogText()).toContain("sprite/claude.sh");
});
it("should use openrouter.ai lab URL", async () => {
it("should use GitHub raw URL", async () => {
setupManifest(standardManifest);
await loadManifest(true);
await cmdRun("claude", "sprite", undefined, true);
expect(getLogText()).toContain("openrouter.ai/lab/spawn");
expect(getLogText()).toContain("raw.githubusercontent.com/OpenRouterTeam/spawn/main");
});
});

View file

@ -266,7 +266,7 @@ describe("cmdList - filter suggestions", () => {
expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("claude"))).toBe(true);
});
it("should suggest agent when filter matches display name case-insensitively", async () => {
it("should resolve display name to key and find matching records", async () => {
writeFileSync(
join(testDir, "history.json"),
JSON.stringify([{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T00:00:00Z" }])
@ -274,10 +274,13 @@ describe("cmdList - filter suggestions", () => {
await setManifest(mockManifest);
// "Claude Code" resolves to "claude" via resolveAgentKey display name match
// so records should be found directly without needing a suggestion
await cmdList("Claude Code");
const infoCalls = getAllClackInfo();
expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("claude"))).toBe(true);
const logCalls = consoleMocks.log.mock.calls.map((c: any[]) => String(c[0] ?? "")).join("\n");
// Should find the record and show it (table header visible)
expect(logCalls).toContain("AGENT");
expect(logCalls).toContain("claude");
});
it("should not suggest when agent filter is completely unrelated", async () => {
@ -325,7 +328,7 @@ describe("cmdList - filter suggestions", () => {
expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("sprite"))).toBe(true);
});
it("should suggest cloud when filter matches display name", async () => {
it("should resolve cloud display name to key and find matching records", async () => {
writeFileSync(
join(testDir, "history.json"),
JSON.stringify([{ agent: "claude", cloud: "hetzner", timestamp: "2026-01-01T00:00:00Z" }])
@ -333,10 +336,13 @@ describe("cmdList - filter suggestions", () => {
await setManifest(mockManifest);
// "Hetzner Cloud" resolves to "hetzner" via resolveCloudKey display name match
// so records should be found directly without needing a suggestion
await cmdList(undefined, "Hetzner Cloud");
const infoCalls = getAllClackInfo();
expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("hetzner"))).toBe(true);
const logCalls = consoleMocks.log.mock.calls.map((c: any[]) => String(c[0] ?? "")).join("\n");
// Should find the record and show it (table header visible)
expect(logCalls).toContain("AGENT");
expect(logCalls).toContain("hetzner");
});
it("should not suggest cloud when filter is completely unrelated", async () => {
@ -620,10 +626,10 @@ describe("cmdMatrix - compact vs grid view", () => {
await cmdMatrix();
const output = getOutput();
// Compact view shows "all clouds supported" or "Missing" column
// Compact view shows "all clouds supported" or "Not yet available" column
expect(output).toContain("Agent");
expect(output).toContain("Clouds");
expect(output).toContain("Missing");
expect(output).toContain("Not yet available");
});
it("should show green for fully-supported agents in compact view", async () => {

View file

@ -386,7 +386,7 @@ function showDryRunPreview(manifest: Manifest, agent: string, cloud: string, pro
printDryRunSection("Agent", buildAgentLines(manifest.agents[agent]));
printDryRunSection("Cloud", buildCloudLines(manifest.clouds[cloud]));
printDryRunSection("Script", [` URL: https://openrouter.ai/lab/spawn/${cloud}/${agent}.sh`]);
printDryRunSection("Script", [` URL: ${RAW_BASE}/${cloud}/${agent}.sh`]);
const env = manifest.agents[agent].env;
if (env) {
@ -706,7 +706,7 @@ function renderCompactList(manifest: Manifest, agents: string[], clouds: string[
const totalClouds = clouds.length;
console.log();
console.log(pc.bold("Agent".padEnd(COMPACT_NAME_WIDTH)) + pc.bold("Clouds".padEnd(COMPACT_COUNT_WIDTH)) + pc.bold("Missing"));
console.log(pc.bold("Agent".padEnd(COMPACT_NAME_WIDTH)) + pc.bold("Clouds".padEnd(COMPACT_COUNT_WIDTH)) + pc.bold("Not yet available"));
console.log(pc.dim("-".repeat(COMPACT_NAME_WIDTH + COMPACT_COUNT_WIDTH + 30)));
for (const a of agents) {
@ -849,7 +849,9 @@ function showListFooter(records: SpawnRecord[], agentFilter?: string, cloudFilte
const latest = records[0];
if (latest.prompt) {
const shortPrompt = latest.prompt.length > 30 ? latest.prompt.slice(0, 30) + "..." : latest.prompt;
console.log(`Rerun last: ${pc.cyan(`spawn ${latest.agent} ${latest.cloud} --prompt "${shortPrompt}"`)}`);
// Escape double quotes so the suggested command is valid shell
const safePrompt = shortPrompt.replace(/"/g, '\\"');
console.log(`Rerun last: ${pc.cyan(`spawn ${latest.agent} ${latest.cloud} --prompt "${safePrompt}"`)}`);
} else {
console.log(`Rerun last: ${pc.cyan(`spawn ${latest.agent} ${latest.cloud}`)}`);
}
@ -916,14 +918,7 @@ function buildRecordHint(r: SpawnRecord): string {
}
export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
const records = filterHistory(agentFilter, cloudFilter);
if (records.length === 0) {
await showEmptyListMessage(agentFilter, cloudFilter);
return;
}
// Try to load manifest for display names (fall back to raw keys if unavailable)
// Try to load manifest early so we can resolve display names in filters
let manifest: Manifest | null = null;
try {
manifest = await loadManifest();
@ -931,6 +926,23 @@ export async function cmdList(agentFilter?: string, cloudFilter?: string): Promi
// Manifest unavailable -- show raw keys
}
// Resolve display names to keys (e.g., "Claude Code" -> "claude")
if (manifest && agentFilter) {
const resolved = resolveAgentKey(manifest, agentFilter);
if (resolved) agentFilter = resolved;
}
if (manifest && cloudFilter) {
const resolved = resolveCloudKey(manifest, cloudFilter);
if (resolved) cloudFilter = resolved;
}
const records = filterHistory(agentFilter, cloudFilter);
if (records.length === 0) {
await showEmptyListMessage(agentFilter, cloudFilter);
return;
}
// Interactive mode: show a select picker so user can choose a spawn to rerun
if (isInteractiveTTY()) {
const options = records.map((r, i) => ({

View file

@ -29,13 +29,19 @@ export function loadHistory(): SpawnRecord[] {
}
}
const MAX_HISTORY_ENTRIES = 100;
export function saveSpawnRecord(record: SpawnRecord): void {
const dir = getSpawnDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const history = loadHistory();
let history = loadHistory();
history.push(record);
// Trim to most recent entries to prevent unbounded growth
if (history.length > MAX_HISTORY_ENTRIES) {
history = history.slice(history.length - MAX_HISTORY_ENTRIES);
}
writeFileSync(getHistoryPath(), JSON.stringify(history, null, 2) + "\n");
}