mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
0bbba737a5
commit
8ad9f8bd2c
6 changed files with 54 additions and 30 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.2.55",
|
||||
"version": "0.2.56",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue