diff --git a/cli/src/__tests__/list-filter-suggestions.test.ts b/cli/src/__tests__/list-filter-suggestions.test.ts index 152270d3..51181ee2 100644 --- a/cli/src/__tests__/list-filter-suggestions.test.ts +++ b/cli/src/__tests__/list-filter-suggestions.test.ts @@ -500,8 +500,8 @@ describe("cmdList - filter suggestions", () => { expect(logOutput).toContain("Fix all linter errors"); }); - it("should truncate long prompt in rerun hint to 30 chars", async () => { - const longPrompt = "B".repeat(50); + it("should suggest --prompt-file in rerun hint for very long prompts", async () => { + const longPrompt = "B".repeat(81); writeFileSync( join(testDir, "history.json"), JSON.stringify([{ @@ -516,14 +516,35 @@ describe("cmdList - filter suggestions", () => { const logOutput = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); expect(logOutput).toContain("Rerun last"); - // The rerun hint truncates to 30 chars const rerunLine = consoleMocks.log.mock.calls .map((c: any[]) => c.join(" ")) .find((l: string) => l.includes("Rerun last")); expect(rerunLine).toBeDefined(); - // Should not contain the full 50-char prompt + // Very long prompts (>80 chars) suggest --prompt-file instead + expect(rerunLine!).toContain("--prompt-file"); expect(rerunLine!).not.toContain(longPrompt); - expect(rerunLine!).toContain("..."); + }); + + it("should show full prompt in rerun hint when <= 80 chars", async () => { + const shortPrompt = "B".repeat(50); + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([{ + agent: "claude", + cloud: "sprite", + timestamp: "2026-01-01T00:00:00Z", + prompt: shortPrompt, + }]) + ); + + await cmdList(); + + const rerunLine = consoleMocks.log.mock.calls + .map((c: any[]) => c.join(" ")) + .find((l: string) => l.includes("Rerun last")); + expect(rerunLine).toBeDefined(); + // Short enough prompts are shown in full for valid copy-paste + expect(rerunLine!).toContain(shortPrompt); }); it("should not show --prompt in rerun hint when no prompt was used", async () => { diff --git a/cli/src/__tests__/list-prompt-display.test.ts b/cli/src/__tests__/list-prompt-display.test.ts index 2b32738b..e6053120 100644 --- a/cli/src/__tests__/list-prompt-display.test.ts +++ b/cli/src/__tests__/list-prompt-display.test.ts @@ -331,11 +331,11 @@ describe("cmdList prompt display", () => { const { cmdList } = await import("../commands.js"); await cmdList(); const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); - // Should show first 40 chars + "..." + // Row display should show first 40 chars + "..." expect(allOutput).toContain(longPrompt.slice(0, 40)); expect(allOutput).toContain("..."); - // Should NOT show the full prompt - expect(allOutput).not.toContain(longPrompt); + // Rerun hint shows full prompt (<=80 chars) so it's a valid copyable command + expect(allOutput).toContain(`--prompt "${longPrompt}"`); }); it("should show prompt exactly at 40 chars without truncation", async () => { @@ -416,7 +416,7 @@ describe("cmdList prompt display", () => { expect(allOutput).toContain("Fix bugs"); }); - it("should truncate prompt in rerun hint at 30 chars", async () => { + it("should show full prompt in rerun hint when <= 80 chars", async () => { const longPrompt = "Fix all linter errors and refactor the auth module completely"; writeFileSync( join(testDir, "history.json"), @@ -431,9 +431,26 @@ describe("cmdList prompt display", () => { await cmdList(); const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); expect(allOutput).toContain("Rerun last"); - // Should show first 30 chars + "..." - expect(allOutput).toContain(longPrompt.slice(0, 30)); - expect(allOutput).toContain("..."); + // buildRetryCommand includes full prompt when <= 80 chars for a valid copyable command + expect(allOutput).toContain(`--prompt "${longPrompt}"`); + }); + + it("should suggest --prompt-file in rerun hint when > 80 chars", async () => { + const veryLongPrompt = "A".repeat(81); + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([{ + agent: "claude", + cloud: "sprite", + timestamp: "2026-02-11T10:00:00Z", + prompt: veryLongPrompt, + }]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("Rerun last"); + expect(allOutput).toContain("--prompt-file"); }); it("should not truncate prompt in rerun hint at exactly 30 chars", async () => { diff --git a/cli/src/__tests__/list-table-rendering.test.ts b/cli/src/__tests__/list-table-rendering.test.ts index 1aa0ee2f..dc5c5cd5 100644 --- a/cli/src/__tests__/list-table-rendering.test.ts +++ b/cli/src/__tests__/list-table-rendering.test.ts @@ -298,10 +298,13 @@ describe("cmdList table rendering", () => { await cmdList(); const output = getOutput(); - // Should show first 40 chars + "..." + // Row display should show first 40 chars + "..." expect(output).toContain("A".repeat(40) + "..."); - // Should NOT show the full 50-char prompt - expect(output).not.toContain("A".repeat(50)); + // Rerun hint at footer shows full prompt (<=80 chars) for valid copy-paste + const rerunLine = consoleMocks.log.mock.calls + .map((c: any[]) => c.join(" ")) + .find((l: string) => l.includes("Rerun last")); + expect(rerunLine!).toContain("A".repeat(50)); }); it("should show exactly 40-char prompt without truncation", async () => { @@ -344,17 +347,29 @@ describe("cmdList table rendering", () => { expect(output).toContain('--prompt "Fix bugs"'); }); - it("should truncate long prompt in rerun hint (> 30 chars)", async () => { + it("should show full prompt in rerun hint when <= 80 chars", async () => { await setManifest(mockManifest); - const longPrompt = "C".repeat(35); + const shortPrompt = "C".repeat(35); + writeHistory([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z", prompt: shortPrompt }, + ]); + + await cmdList(); + const output = getOutput(); + // Rerun hint shows full prompt when <= 80 chars (valid copyable command) + expect(output).toContain(`--prompt "${shortPrompt}"`); + }); + + it("should suggest --prompt-file in rerun hint for very long prompts", async () => { + await setManifest(mockManifest); + const longPrompt = "C".repeat(81); writeHistory([ { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z", prompt: longPrompt }, ]); await cmdList(); const output = getOutput(); - // Footer truncates at 30 chars - expect(output).toContain("C".repeat(30) + "..."); + expect(output).toContain("--prompt-file"); }); it("should not include --prompt in rerun hint when latest has no prompt", async () => { diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 238b350f..449756fd 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -8,6 +8,7 @@ import { cloudKeys, matrixStatus, countImplemented, + isStaleCache, RAW_BASE, REPO, type Manifest, @@ -45,7 +46,11 @@ async function withSpinner(msg: string, fn: () => Promise, doneMsg?: strin } export async function loadManifestWithSpinner(): Promise { - return withSpinner("Loading manifest...", loadManifest); + const manifest = await withSpinner("Loading manifest...", loadManifest); + if (isStaleCache()) { + p.log.warn("Using cached manifest (offline). Data may be outdated."); + } + return manifest; } function validateNonEmptyString(value: string, fieldName: string, helpCommand: string): void { diff --git a/cli/src/index.ts b/cli/src/index.ts index e3bbf4e1..7da6a889 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,7 +18,7 @@ import { import pc from "picocolors"; import pkg from "../package.json" with { type: "json" }; import { checkForUpdates } from "./update-check.js"; -import { loadManifest, agentKeys, cloudKeys } from "./manifest.js"; +import { loadManifest, agentKeys, cloudKeys, getCacheAge } from "./manifest.js"; const VERSION = pkg.version; @@ -265,6 +265,14 @@ async function handleNoCommand(prompt: string | undefined, dryRun?: boolean): Pr } } +function formatCacheAge(seconds: number): string { + if (!isFinite(seconds)) return "no cache"; + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + function showVersion(): void { console.log(`spawn v${VERSION}`); const binPath = process.argv[1]; @@ -272,6 +280,8 @@ function showVersion(): void { console.log(pc.dim(` ${binPath}`)); } console.log(pc.dim(` ${process.versions.bun ? "bun" : "node"} ${process.versions.bun ?? process.versions.node} ${process.platform} ${process.arch}`)); + const age = getCacheAge(); + console.log(pc.dim(` manifest cache: ${formatCacheAge(age)}`)); console.log(pc.dim(` Run ${pc.cyan("spawn update")} to check for updates.`)); } diff --git a/cli/src/manifest.ts b/cli/src/manifest.ts index 5b04746e..dbd56cfe 100644 --- a/cli/src/manifest.ts +++ b/cli/src/manifest.ts @@ -109,6 +109,7 @@ async function fetchManifestFromGitHub(): Promise { // ── Public API ───────────────────────────────────────────────────────────────── let _cached: Manifest | null = null; +let _staleCache = false; function tryLoadFromDiskCache(): Manifest | null { if (cacheAge() >= CACHE_TTL) return null; @@ -172,6 +173,7 @@ export async function loadManifest(forceRefresh = false): Promise { const stale = readCache(); if (stale) { _cached = stale; + _staleCache = true; return stale; } @@ -208,9 +210,20 @@ export function countImplemented(m: Manifest): number { return count; } +/** Returns true if the manifest was loaded from a stale (expired) cache as offline fallback */ +export function isStaleCache(): boolean { + return _staleCache; +} + +/** Returns the age of the disk cache in seconds, or Infinity if not available */ +export function getCacheAge(): number { + return cacheAge(); +} + /** Clear the in-memory manifest cache (for testing only) */ export function _resetCacheForTesting(): void { _cached = null; + _staleCache = false; } export { RAW_BASE, REPO, CACHE_DIR };