diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 5ea18c937..44fa62739 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -45,6 +45,10 @@ function expectVerificationGuidance(prompt: string): void { expect(prompt).not.toMatch(/relevant checks .*when practical/i); } +// Module-scope snapshot of the repeated no-arg discipline builder. The block +// is static text for absent input, so the no-arg calls below share one value. +const PROMPT_DISCIPLINE_BLOCK = buildPromptDisciplineBlock(); + describe("buildPromptDisciplineBlock", () => { it("references only tool names that exist in the registration source", () => { for (const name of REFERENCED_TOOL_NAMES) { @@ -53,18 +57,18 @@ describe("buildPromptDisciplineBlock", () => { }); it("is tight: roughly 15-25 lines", () => { - const lines = buildPromptDisciplineBlock().split("\n"); + const lines = PROMPT_DISCIPLINE_BLOCK.split("\n"); expect(lines.length).toBeGreaterThanOrEqual(15); expect(lines.length).toBeLessThanOrEqual(30); }); it("uses prohibition form, not preference form", () => { - const block = buildPromptDisciplineBlock(); + const block = PROMPT_DISCIPLINE_BLOCK; expect(block).not.toMatch(/\bprefer\b/i); }); it("contains the load-bearing prohibitions", () => { - const block = buildPromptDisciplineBlock(); + const block = PROMPT_DISCIPLINE_BLOCK; // Dedicated tools over shell. expect(block).toContain("run_shell"); expect(block).toContain("cat/head/tail"); diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 4bf7fe513..968ffb0b6 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -23,27 +23,37 @@ import { const minimalToolDefinitions = [manageTasksDefinition, submitOutputDefinition]; +// Module-scope snapshots of the repeated no-arg prompt builders. Each builder +// is deterministic for absent input (the only per-call variance is the +// current-date/cwd context line, which no assertion pins exactly), so the +// no-arg calls below share one value instead of rebuilding it each time. +// Arg-taking variants keep calling the builders directly. +const CHAT_SYSTEM_PROMPT = buildChatSystemPrompt(); +const CHAT_ROLE = buildChatRole(); +const HARNESS_FACTS = buildHarnessFacts(); +const GUIDELINES = buildGuidelines(); +const REPORT_CONTRACT = buildSubAgentReportContract(); +const SUBAGENT_SYSTEM_PROMPT = buildSubAgentSystemPrompt(); + test("buildChatSystemPrompt wires into createChatDirector without error", () => { - const prompt = buildChatSystemPrompt(); expect(() => - createChatDirector(prompt, minimalToolDefinitions, {}), + createChatDirector(CHAT_SYSTEM_PROMPT, minimalToolDefinitions, {}), ).not.toThrow(); }); test("chat prompt orders base, then tools, then context", () => { - const prompt = buildChatSystemPrompt(); - expect(prompt.indexOf(buildChatRole())).toBe(0); - expect(prompt.indexOf(buildChatRole())).toBeLessThan( - prompt.indexOf(buildHarnessFacts()), + expect(CHAT_SYSTEM_PROMPT.indexOf(CHAT_ROLE)).toBe(0); + expect(CHAT_SYSTEM_PROMPT.indexOf(CHAT_ROLE)).toBeLessThan( + CHAT_SYSTEM_PROMPT.indexOf(HARNESS_FACTS), ); - expect(prompt.indexOf(buildHarnessFacts())).toBeLessThan( - prompt.indexOf(buildGuidelines()), + expect(CHAT_SYSTEM_PROMPT.indexOf(HARNESS_FACTS)).toBeLessThan( + CHAT_SYSTEM_PROMPT.indexOf(GUIDELINES), ); - expect(prompt.indexOf(buildGuidelines())).toBeLessThan( - prompt.indexOf("Tools:"), + expect(CHAT_SYSTEM_PROMPT.indexOf(GUIDELINES)).toBeLessThan( + CHAT_SYSTEM_PROMPT.indexOf("Tools:"), ); - expect(prompt.indexOf("Tools:")).toBeLessThan( - prompt.indexOf("Active context:"), + expect(CHAT_SYSTEM_PROMPT.indexOf("Tools:")).toBeLessThan( + CHAT_SYSTEM_PROMPT.indexOf("Active context:"), ); }); @@ -56,45 +66,47 @@ test("agent identity is Skywalker orchestrator", () => { expect(orchestrator).toContain("Delegate"); expect(orchestrator).toContain("Match operator tone"); // Mode arg is ignored — product is orchestrator-only (CL-5814). - expect(buildChatRole()).toContain("You are Skywalker"); + expect(CHAT_ROLE).toContain("You are Skywalker"); }); test("harness facts state only the non-derivable tool and safety rules", () => { - const facts = buildHarnessFacts(); - expect(facts).toContain("write_file/edit_file"); - expect(facts).toContain("tiny/single-file/one-route"); - expect(facts).toContain("Spawn builder"); - expect(facts).not.toContain("not mounted on the primary Skywalker session"); - expect(facts).toContain("blocked"); - expect(facts).toContain("120s foreground timeout"); - expect(facts).toContain("no default timeout"); - expect(facts).toContain("find, rg, and grep -r"); - expect(facts).toMatch(/OOM the host/); - expect(facts).toMatch(/Prefer the bounded grep\/search_files tools/); - expect(facts).toMatch( + expect(HARNESS_FACTS).toContain("write_file/edit_file"); + expect(HARNESS_FACTS).toContain("tiny/single-file/one-route"); + expect(HARNESS_FACTS).toContain("Spawn builder"); + expect(HARNESS_FACTS).not.toContain( + "not mounted on the primary Skywalker session", + ); + expect(HARNESS_FACTS).toContain("blocked"); + expect(HARNESS_FACTS).toContain("120s foreground timeout"); + expect(HARNESS_FACTS).toContain("no default timeout"); + expect(HARNESS_FACTS).toContain("find, rg, and grep -r"); + expect(HARNESS_FACTS).toMatch(/OOM the host/); + expect(HARNESS_FACTS).toMatch(/Prefer the bounded grep\/search_files tools/); + expect(HARNESS_FACTS).toMatch( /not substitute another unbounded walk \(fd, ls -R, scripted os\.walk\)/, ); - expect(facts).not.toMatch(/Use grep, search_files, and list_dir\.$/m); - expect(facts).toContain("operator approval"); - expect(facts).toContain("tool_search"); - expect(facts).toContain("plugins or integrations"); - expect(facts).toContain("slash-command steps"); - expect(facts).toContain(".corbits/MEMORY.md"); - expect(facts).toContain("Attached images are native multimodal input"); - expect(facts).toContain("parent tool.boundary"); - expect(facts).toContain("session-idle"); - expect(facts).not.toContain("Tool results already render richly"); + expect(HARNESS_FACTS).not.toMatch(/Use grep, search_files, and list_dir\.$/m); + expect(HARNESS_FACTS).toContain("operator approval"); + expect(HARNESS_FACTS).toContain("tool_search"); + expect(HARNESS_FACTS).toContain("plugins or integrations"); + expect(HARNESS_FACTS).toContain("slash-command steps"); + expect(HARNESS_FACTS).toContain(".corbits/MEMORY.md"); + expect(HARNESS_FACTS).toContain( + "Attached images are native multimodal input", + ); + expect(HARNESS_FACTS).toContain("parent tool.boundary"); + expect(HARNESS_FACTS).toContain("session-idle"); + expect(HARNESS_FACTS).not.toContain("Tool results already render richly"); }); test("harness facts gate tool-output URI reads on a named truncation notice", () => { - const facts = buildHarnessFacts(); - expect(facts).toContain("read_file"); - expect(facts).toMatch(/filesystem path/i); - expect(facts).toMatch(/tool-output:\/\//); - expect(facts).toContain("truncation notice on that result named one"); - expect(facts).toContain("do not re-read a complete inline result"); - expect(facts).not.toMatch(/prefer the URI/i); - expect(facts).not.toMatch(/re-reading huge blobs/i); + expect(HARNESS_FACTS).toContain("read_file"); + expect(HARNESS_FACTS).toMatch(/filesystem path/i); + expect(HARNESS_FACTS).toMatch(/tool-output:\/\//); + expect(HARNESS_FACTS).toContain("truncation notice on that result named one"); + expect(HARNESS_FACTS).toContain("do not re-read a complete inline result"); + expect(HARNESS_FACTS).not.toMatch(/prefer the URI/i); + expect(HARNESS_FACTS).not.toMatch(/re-reading huge blobs/i); }); test("read_file catalog summary gates tool-output URI reads on truncation", () => { @@ -107,13 +119,12 @@ test("read_file catalog summary gates tool-output URI reads on truncation", () = }); test("harness facts name skill_search as a resident catalog tool", () => { - const facts = buildHarnessFacts(); - expect(facts).toMatch( + expect(HARNESS_FACTS).toMatch( /advertised catalog \(including skill_search\) are resident/, ); - expect(facts).not.toContain("Only the core tools below are loaded"); + expect(HARNESS_FACTS).not.toContain("Only the core tools below are loaded"); // skill_search is catalog-advertised and excluded from tool_search results. - expect(facts).not.toMatch(/only the core tools[\s\S]*tool_search/i); + expect(HARNESS_FACTS).not.toMatch(/only the core tools[\s\S]*tool_search/i); }); test("leaf harness facts advertise product write tools", () => { @@ -136,20 +147,19 @@ test("leaf harness facts state no-budget report completion behavior", () => { }); test("guidelines cover response style, tool choice, ask vs proceed, and scope", () => { - const guidelines = buildGuidelines(); - expect(guidelines).toContain("Response style:"); - expect(guidelines).toContain("Tool choice:"); - expect(guidelines).toContain("Ask vs proceed:"); - expect(guidelines).toContain("Scope and conventions:"); - expect(guidelines).toContain("grep or search_files"); - expect(guidelines).toContain("ask_operator only when permission blocks you"); - expect(guidelines).toContain("skill_search when choosing"); - expect(guidelines).toContain( + expect(GUIDELINES).toContain("Response style:"); + expect(GUIDELINES).toContain("Tool choice:"); + expect(GUIDELINES).toContain("Ask vs proceed:"); + expect(GUIDELINES).toContain("Scope and conventions:"); + expect(GUIDELINES).toContain("grep or search_files"); + expect(GUIDELINES).toContain("ask_operator only when permission blocks you"); + expect(GUIDELINES).toContain("skill_search when choosing"); + expect(GUIDELINES).toContain( "use_skill style and philosophy when starting repo work", ); - expect(guidelines).toContain("DIY tiny/single-file/one-route"); - expect(guidelines).toContain("never shell-write (echo/heredoc/sed/rm)"); - expect(guidelines).not.toContain("not mounted on Skywalker"); + expect(GUIDELINES).toContain("DIY tiny/single-file/one-route"); + expect(GUIDELINES).toContain("never shell-write (echo/heredoc/sed/rm)"); + expect(GUIDELINES).not.toContain("not mounted on Skywalker"); expect(buildGuidelines({ subAgent: true })).not.toContain( "use_skill style and philosophy when starting repo work", ); @@ -176,7 +186,6 @@ test("orchestrator guidelines teach the typed task spawn contract", () => { }); test("primary chat prompt classifies fail-path successor vs interrupt resume vs operator-cancel wait", () => { - const prompt = buildChatSystemPrompt(); const guidelines = buildGuidelines({ sessionMode: "orchestrator" }); expect(guidelines).toContain("MAY spawn one successor with a changed brief"); expect(guidelines).toContain("wait for the operator"); @@ -186,28 +195,26 @@ test("primary chat prompt classifies fail-path successor vs interrupt resume vs expect(guidelines).toContain("still-live worker"); expect(guidelines).not.toContain("interrupted-incomplete"); expect(guidelines).not.toContain("start the next worker"); - expect(prompt).toContain("MAY `spawn_agent` **one** successor"); - expect(prompt).toContain("wait for the operator"); - expect(prompt).toContain( + expect(CHAT_SYSTEM_PROMPT).toContain("MAY `spawn_agent` **one** successor"); + expect(CHAT_SYSTEM_PROMPT).toContain("wait for the operator"); + expect(CHAT_SYSTEM_PROMPT).toContain( "Identical re-dispatch of the same brief stays refused", ); - expect(prompt).not.toContain("Then start the next worker"); - expect(prompt).not.toContain("if the job still needs doing"); + expect(CHAT_SYSTEM_PROMPT).not.toContain("Then start the next worker"); + expect(CHAT_SYSTEM_PROMPT).not.toContain("if the job still needs doing"); }); test("primary guidelines advise against early-stop from compaction token fear", () => { - const guidelines = buildGuidelines(); - expect(guidelines).toContain("compacted automatically"); - expect(guidelines).toContain("do not stop tasks early due to token fear"); - expect(guidelines).toContain("manage_tasks and worker reports"); + expect(GUIDELINES).toContain("compacted automatically"); + expect(GUIDELINES).toContain("do not stop tasks early due to token fear"); + expect(GUIDELINES).toContain("manage_tasks and worker reports"); // Leaf guidelines omit primary orchestration compaction guidance. expect(buildGuidelines({ subAgent: true })).not.toContain("token fear"); }); test("chat system prompt satisfies system prompt quality markers", () => { - const prompt = buildChatSystemPrompt(); for (const marker of CHAT_PROMPT_QUALITY_MARKERS) { - expect(prompt).toContain(marker); + expect(CHAT_SYSTEM_PROMPT).toContain(marker); } }); @@ -238,12 +245,11 @@ test("default session lists split fleet tools and search_agents", () => { }); test("chat prompt advertises core tools but never enumerates MCP integrations", () => { - const prompt = buildChatSystemPrompt(); - expect(prompt).toContain("read_file"); - expect(prompt).toContain("tool_search"); - expect(prompt).not.toContain("mcp__"); + expect(CHAT_SYSTEM_PROMPT).toContain("read_file"); + expect(CHAT_SYSTEM_PROMPT).toContain("tool_search"); + expect(CHAT_SYSTEM_PROMPT).not.toContain("mcp__"); // No static catalog dump — discovery is via tool_search, not a listed catalog. - expect(prompt).not.toContain("Discoverable tools"); + expect(CHAT_SYSTEM_PROMPT).not.toContain("Discoverable tools"); }); test("lists skill names without descriptions and points at skill_search then use_skill", () => { @@ -274,14 +280,14 @@ test("buildSkillsSection for a synthetic 10-name roster stays under a few hundre }); test("omits the skills section when no skills are available", () => { - expect(buildChatSystemPrompt()).not.toContain("Skills ("); + expect(CHAT_SYSTEM_PROMPT).not.toContain("Skills ("); }); test("a SYSTEM.md base override replaces the static base but keeps tools and context", () => { const override = "You are a custom agent with project-specific rules."; const prompt = buildChatSystemPrompt(undefined, undefined, override); expect(prompt).toContain(override); - expect(prompt).not.toContain(buildChatRole()); + expect(prompt).not.toContain(CHAT_ROLE); expect(prompt).toContain("## Session mode"); expect(prompt).toContain("Orchestration:"); // Tools and context still attach. @@ -304,8 +310,8 @@ test("SYSTEM.md override still appends orchestrator harness rules", () => { test("an empty base override falls back to the default base", () => { const prompt = buildChatSystemPrompt(undefined, undefined, " "); - expect(prompt).toContain(buildChatRole()); - expect(prompt).toContain(buildHarnessFacts()); + expect(prompt).toContain(CHAT_ROLE); + expect(prompt).toContain(HARNESS_FACTS); }); test("extensions are appended after the base, tools, and context", () => { @@ -326,9 +332,8 @@ test("buildActiveContext includes the current date in DD/MM/YYYY and the memory }); test("without an env, the chat prompt ends with the static active context", () => { - const prompt = buildChatSystemPrompt(); - expect(prompt.trim()).toMatch(/\.corbits\/MEMORY\.md/); - expect(prompt).toMatch( + expect(CHAT_SYSTEM_PROMPT.trim()).toMatch(/\.corbits\/MEMORY\.md/); + expect(CHAT_SYSTEM_PROMPT).toMatch( /Current Date: \d{2}\/\d{2}\/\d{4} \(prompt cache survives for <=24hr\)/, ); }); @@ -436,21 +441,19 @@ test("when ask_director is in toolNames, the worker prompt mentions ask_director }); test("sub-agent report contract does not claim the worker cannot receive answers", () => { - const withoutAsk = buildSubAgentReportContract(); const withAsk = buildSubAgentReportContract({ askDirector: true }); - expect(withoutAsk).not.toContain("you cannot receive answers"); - expect(withoutAsk).not.toContain("Do not ask the parent questions"); + expect(REPORT_CONTRACT).not.toContain("you cannot receive answers"); + expect(REPORT_CONTRACT).not.toContain("Do not ask the parent questions"); expect(withAsk).not.toContain("you cannot receive answers"); expect(withAsk).not.toContain("You cannot reach the operator"); }); test("sub-agent report contract treats Success criteria as completion gate", () => { - const contract = buildSubAgentReportContract(); - expect(contract).toContain("Success criteria"); - expect(contract).toContain("done-definition"); - expect(contract).toContain("stop calling tools"); - expect(contract).toContain("Do not"); - expect(contract).toContain("Intent / Do not"); + expect(REPORT_CONTRACT).toContain("Success criteria"); + expect(REPORT_CONTRACT).toContain("done-definition"); + expect(REPORT_CONTRACT).toContain("stop calling tools"); + expect(REPORT_CONTRACT).toContain("Do not"); + expect(REPORT_CONTRACT).toContain("Intent / Do not"); }); // Pins the only real report-envelope mechanism (buildSubAgentReportContract's @@ -458,9 +461,7 @@ test("sub-agent report contract treats Success criteria as completion gate", () // since director packages no longer declare their own requiredSections // (CL-6969: that field was inert and enforced nothing). test("sub-agent report contract's headings satisfy hasReportEnvelope", () => { - const contract = buildSubAgentReportContract(); - const headingsOnly = contract - .split("\n") + const headingsOnly = REPORT_CONTRACT.split("\n") .filter((line) => line.startsWith("## ")) .join("\n"); expect(hasReportEnvelope(headingsOnly)).toBe(true); @@ -468,16 +469,13 @@ test("sub-agent report contract's headings satisfy hasReportEnvelope", () => { }); test("sub-agent prompt does not advertise tool_search (it gets the full toolset)", () => { - const prompt = buildSubAgentSystemPrompt(); - expect(prompt).not.toContain("tool_search"); - expect(prompt).toContain("your full toolset"); + expect(SUBAGENT_SYSTEM_PROMPT).not.toContain("tool_search"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("your full toolset"); }); test("worker prompt does not advertise archive:///; primary chat prompt does", () => { - const worker = buildSubAgentSystemPrompt(); - expect(worker).not.toContain("archive:///"); - const primary = buildChatSystemPrompt(); - expect(primary).toContain("archive:///"); + expect(SUBAGENT_SYSTEM_PROMPT).not.toContain("archive:///"); + expect(CHAT_SYSTEM_PROMPT).toContain("archive:///"); expect( buildAvailableTools(["read_file", "grep", "search_files"]), ).not.toContain("archive:///"); @@ -510,11 +508,10 @@ test("sub-agent prompt always appends Corbits Code notes, even with a JS-plugin- // concrete report instead of spawning further agents. This is the rule that // stops a fan-out of sub-agents each fanning out further. test("default sub-agent prompt forbids recursion", () => { - const prompt = buildSubAgentSystemPrompt(); - expect(prompt).toContain( + expect(SUBAGENT_SYSTEM_PROMPT).toContain( "Only the primary Corbits Code session (or a built-in orchestrator director) may call `spawn_agent`", ); - expect(prompt).toContain("You are a worker"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("You are a worker"); }); // Built-in orchestrator directors are the documented exception to the @@ -538,18 +535,18 @@ test("orchestrator sub-agent prompt grants the spawn_agent recursion exception", }); test("sub-agent prompt requires structured report envelope and stick-to-brief", () => { - const prompt = buildSubAgentSystemPrompt(); - expect(prompt).toContain("## Summary"); - expect(prompt).toContain("## Findings"); - expect(prompt).toContain("## Blockers"); - expect(prompt).toContain("## Paths"); - expect(prompt).toContain("Stick to the dispatch brief"); - expect(prompt).toContain("manage_tasks checklist"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("## Summary"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("## Findings"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("## Blockers"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("## Paths"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("Stick to the dispatch brief"); + expect(SUBAGENT_SYSTEM_PROMPT).toContain("manage_tasks checklist"); }); test("default sub-agent prompt omits Grok anti-thrash residual", () => { - const prompt = buildSubAgentSystemPrompt(); - expect(prompt).not.toContain("Finish bias (xAI / Grok worker)"); + expect(SUBAGENT_SYSTEM_PROMPT).not.toContain( + "Finish bias (xAI / Grok worker)", + ); }); test("grokAntiThrash opts appends tiny finish-bias note before appendix", () => { diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 85d1eb256..9ebf84fa6 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -41,32 +41,38 @@ function event( return { type, seq: 0, data } as unknown as ReactorEmittedEvent; } +// One render-capture pass composing captureOutput + createRenderer + one +// render per event. Every render test below funnels through here so the +// capture/create/render/restore sequence lives in exactly one place. +function renderCapture( + events: readonly ReactorEmittedEvent[], + modelId?: string, + pricingCache?: PricingCache | null, +): { stdout: string[]; stderr: string[] } { + const cap = captureOutput(); + const renderer = createRenderer(Date.now(), modelId, pricingCache); + for (const e of events) renderer.render(e); + cap.restore(); + return cap; +} + describe("renderer — status bar", () => { test("every event updates the status bar on stderr", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render(event("reactor.start")); - cap.restore(); + const cap = renderCapture([event("reactor.start")]); expect(cap.stderr.join("")).toContain("interchange"); }); test("status bar uses \\r not \\n", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render(event("reactor.start")); - cap.restore(); + const cap = renderCapture([event("reactor.start")]); const bar = cap.stderr.join(""); expect(bar).toContain("\r"); expect(bar).not.toMatch(/interchange.*\n/); }); test("status bar shows current op in amber for tool_call.start", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.start", { callId: "c1", name: "read_file" }), - ); - cap.restore(); + ]); // amber escape before the op text expect(cap.stderr.join("")).toContain("\x1b[38;5;214m"); }); @@ -74,9 +80,7 @@ describe("renderer — status bar", () => { describe("renderer — manage_tasks journal block", () => { test("manage_tasks tool.done writes nothing to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c1", name: "manage_tasks", @@ -84,29 +88,21 @@ describe("renderer — manage_tasks journal block", () => { tasks: [{ id: "1", title: "Add function", status: "in_progress" }], }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c1", content: "ok" } }), - ); - cap.restore(); + ]); expect(cap.stdout.join("")).toBe(""); }); }); describe("renderer — write_file journal block", () => { test("write_file tool.done writes a write block with line count to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c2", name: "write_file", arguments: { path: "src/foo.ts", content: "line1\nline2\nline3" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c2", content: "ok" } }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("write"); expect(out).toContain("src/foo.ts"); @@ -116,9 +112,7 @@ describe("renderer — write_file journal block", () => { describe("renderer — edit_file journal block", () => { test("edit_file tool.done writes an edit block with +/- counts to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c3", name: "edit_file", @@ -128,11 +122,8 @@ describe("renderer — edit_file journal block", () => { new_string: "line1\nline2\nline3\nline4", }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c3", content: "ok" } }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("edit"); expect(out).toContain("src/bar.ts"); @@ -143,19 +134,14 @@ describe("renderer — edit_file journal block", () => { describe("renderer — run_shell journal block", () => { test("run_shell success writes collapsed block with checkmark to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c4", name: "run_shell", arguments: { command: "bun test" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c4", content: "14 passed" } }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("shell"); expect(out).toContain("bun test"); @@ -163,21 +149,16 @@ describe("renderer — run_shell journal block", () => { }); test("run_shell failure writes expanded block with cross to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c5", name: "run_shell", arguments: { command: "bun test" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c5", content: "2 failed", isError: true }, }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("✗"); expect(out).toContain("2 failed"); @@ -186,19 +167,14 @@ describe("renderer — run_shell journal block", () => { describe("renderer — submit_output / reactor.done journal block", () => { test("submit_output tool.done writes done block with summary in green", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c6", name: "submit_output", arguments: { summary: "Task complete" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c6", content: "ok" } }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("done"); expect(out).toContain("Task complete"); @@ -206,19 +182,14 @@ describe("renderer — submit_output / reactor.done journal block", () => { }); test("reactor.done writes done block to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render(event("reactor.done")); - cap.restore(); + const cap = renderCapture([event("reactor.done")]); expect(cap.stdout.join("")).toContain("done"); }); }); describe("renderer — error blocks", () => { test("inference.error writes an actionable provider diagnostic in red to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.error", { error: { category: "protocol_mismatch", @@ -227,8 +198,7 @@ describe("renderer — error blocks", () => { }, partial: {}, }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("error"); expect(out).toContain("\x1b[31m"); // red @@ -239,9 +209,7 @@ describe("renderer — error blocks", () => { }); test("inference.error surfaces Codex usage_limit_reached with reset ETA", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.error", { error: { category: "quota_exhausted", @@ -260,8 +228,7 @@ describe("renderer — error blocks", () => { }, partial: {}, }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("Codex usage limit reached"); expect(out).toMatch(/Resets in ~/); @@ -270,12 +237,9 @@ describe("renderer — error blocks", () => { }); test("reactor.error writes error block in red to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("reactor.error", { error: "fatal crash", fatal: true }), - ); - cap.restore(); + ]); const out = cap.stdout.join(""); expect(out).toContain("error"); expect(out).toContain("fatal crash"); @@ -284,13 +248,11 @@ describe("renderer — error blocks", () => { describe("renderer — miniDiff truncation", () => { test("edit_file with more than 10 lines shows truncation marker", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); const longContent = Array.from( { length: 12 }, (_, i) => `line ${i + 1}`, ).join("\n"); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c-trunc", name: "edit_file", @@ -300,45 +262,34 @@ describe("renderer — miniDiff truncation", () => { new_string: longContent, }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c-trunc", content: "ok" } }), - ); - cap.restore(); + ]); expect(cap.stdout.join("")).toContain("more"); }); }); describe("renderer — formatOp fallback", () => { test("unknown tool name is used as-is for op display", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.start", { name: "custom_tool", callId: "c-custom", }), - ); - cap.restore(); + ]); expect(cap.stderr.join("")).toContain("custom_tool"); }); }); describe("renderer — inference.done clears op", () => { test("inference.done resets currentOp in status bar", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.start", { name: "run_shell", callId: "c-x" }), - ); - renderer.render( event("inference.done", { turn: { role: "assistant", content: [], model: "test", timestamp: 0 }, usage: { input: 0, output: 0 }, source: "test", }), - ); - cap.restore(); + ]); // After inference.done the status bar should no longer contain the op name const lastStderr = cap.stderr[cap.stderr.length - 1] ?? ""; expect(lastStderr).not.toContain("running"); @@ -347,10 +298,7 @@ describe("renderer — inference.done clears op", () => { describe("renderer — inference.usage updates cost display", () => { test("inference.usage causes the status bar to update", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - const stderrBefore = cap.stderr.length; - renderer.render( + const cap = renderCapture([ event("inference.usage", { usage: { input: 100, @@ -360,102 +308,77 @@ describe("renderer — inference.usage updates cost display", () => { thinking: 0, }, }), - ); - cap.restore(); - expect(cap.stderr.length).toBeGreaterThan(stderrBefore); + ]); + expect(cap.stderr.length).toBeGreaterThan(0); }); }); describe("renderer — tool.start updates op", () => { test("tool.start sets op to the tool name", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("tool.start", { call: { name: "run_shell", id: "c-ts" } }), - ); - cap.restore(); + ]); expect(cap.stderr.join("")).toContain("running"); }); }); describe("renderer — connector.reply clears op", () => { test("connector.reply does not write to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render(event("connector.reply", { messageId: "msg-1" })); - cap.restore(); + const cap = renderCapture([ + event("connector.reply", { messageId: "msg-1" }), + ]); expect(cap.stdout.join("")).toBe(""); }); }); describe("renderer — search_files and grep produce no journal block", () => { test("search_files tool.done writes nothing to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c-sf", name: "search_files", arguments: { pattern: "foo" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c-sf", content: "results" } }), - ); - cap.restore(); + ]); expect(cap.stdout.join("")).toBe(""); }); test("grep tool.done writes nothing to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c-grep", name: "grep", arguments: { pattern: "bar" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c-grep", content: "matches" } }), - ); - cap.restore(); + ]); expect(cap.stdout.join("")).toBe(""); }); }); describe("renderer — read-only tools produce no journal block", () => { test("read_file tool.done writes nothing to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c7", name: "read_file", arguments: { path: "src/foo.ts" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c7", content: "file content" } }), - ); - cap.restore(); + ]); expect(cap.stdout.join("")).toBe(""); }); test("list_dir tool.done writes nothing to stdout", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now()); - renderer.render( + const cap = renderCapture([ event("inference.tool_call.end", { callId: "c8", name: "list_dir", arguments: { path: "src/" }, }), - ); - renderer.render( event("tool.done", { result: { callId: "c8", content: "src/foo.ts" } }), - ); - cap.restore(); + ]); expect(cap.stdout.join("")).toBe(""); }); }); @@ -519,15 +442,17 @@ describe("renderer — mixed vs hidden-only session cost", () => { } test("Codex then metered shows the metered portion only, not a live-model recast", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now(), "glm-5.1", pricingCache); - renderer.render( - event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE }), - ); - renderer.render( - event("inference.done", { usage: METERED_USAGE, source: METERED_SOURCE }), + const cap = renderCapture( + [ + event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE }), + event("inference.done", { + usage: METERED_USAGE, + source: METERED_SOURCE, + }), + ], + "glm-5.1", + pricingCache, ); - cap.restore(); const meteredOnly = createFaremeter({ modelId: "glm-5.1", pricingCache }); meteredOnly.addUsage(METERED_USAGE); @@ -544,12 +469,11 @@ describe("renderer — mixed vs hidden-only session cost", () => { }); test("hidden-only Codex still uses subscription copy", () => { - const cap = captureOutput(); - const renderer = createRenderer(Date.now(), "gpt-5.6-luna", pricingCache); - renderer.render( - event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE }), + const cap = renderCapture( + [event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE })], + "gpt-5.6-luna", + pricingCache, ); - cap.restore(); const bar = cap.stderr[cap.stderr.length - 1] ?? ""; expect(bar).toContain( diff --git a/src/tui/shell/row-retext.ts b/src/tui/shell/row-retext.ts index 2438e6bc7..4276429fc 100644 --- a/src/tui/shell/row-retext.ts +++ b/src/tui/shell/row-retext.ts @@ -16,7 +16,8 @@ import { } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; -import { splitLinkSpans, paintLinkLine } from "../url-links.js"; +import { paintLinkLine } from "../url-links.js"; +import { splitLinkSpans } from "../link-spans.js"; import { splitTrailingArrow, expandedRowLines, diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 05889520f..1d89f02f5 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -12,14 +12,9 @@ import { } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; -import { - armLinkLine, - buildLinkLine, - findLinks, - paintLinkLine, - splitLinkSpans, - splitWrappedLinkSpans, -} from "../url-links.js"; +import { armLinkLine, buildLinkLine, paintLinkLine } from "../url-links.js"; +import { findLinks, splitLinkSpans } from "../link-spans.js"; +import { splitWrappedLinkSpans } from "../link-wrap.js"; import { splitAtSettledHeading, withholdIncompleteHeading, diff --git a/src/tui/tool-formatter.ts b/src/tui/tool-formatter.ts index 4a0ceef7e..73a27b29f 100644 --- a/src/tui/tool-formatter.ts +++ b/src/tui/tool-formatter.ts @@ -21,19 +21,29 @@ export interface ToolCallDescriptor { isShell: boolean; } -const TOOL_DISPLAY_NAMES: Record = { - read_file: "Read", - write_file: "Write", - edit_file: "Edit", - run_shell: "Shell", - search_files: "Search", - grep: "Grep", - list_dir: "List", - web_search: "Web Search", - web_fetch: "Web Fetch", - manage_tasks: "Manage tasks", - submit_output: "Submit", - ask_operator: "Ask operator", +/** + * One label table for both tool-name readers: the present-tense display name + * and the settled head of a tool lane in past tense (keyed by the raw + * identifier, the lane grouping key). Entries that only existed in one map + * carry the other reader's old fallback value, so both fallbacks below stay + * byte-identical: display-only keys repeat the display string as past tense, + * and past-only keys use the title-cased fallback as display. + */ +const TOOL_LABELS: Record = { + read_file: { display: "Read", past: "Read" }, + write_file: { display: "Write", past: "Wrote" }, + edit_file: { display: "Edit", past: "Edited" }, + run_shell: { display: "Shell", past: "Ran" }, + search_files: { display: "Search", past: "Searched" }, + grep: { display: "Grep", past: "Grepped" }, + list_dir: { display: "List", past: "Listed" }, + web_search: { display: "Web Search", past: "Searched" }, + web_fetch: { display: "Web Fetch", past: "Fetched" }, + manage_tasks: { display: "Manage tasks", past: "Manage tasks" }, + submit_output: { display: "Submit", past: "Submitted" }, + ask_operator: { display: "Ask operator", past: "Asked operator" }, + delete_file: { display: "Delete File", past: "Deleted" }, + use_skill: { display: "Use Skill", past: "Loaded skill" }, }; // Brand of the active web plugin (e.g. "Exa"), set at startup when a web plugin @@ -46,29 +56,8 @@ export function setActiveWebProviderBrand(brand: string | undefined): void { brand !== undefined && brand.length > 0 ? brand : undefined; } -/** - * Settled head of a tool lane: the raw tool name in past tense. Keyed by the - * raw identifier (the lane grouping key), falling back to the display name - * for anything unmapped. - */ -const TOOL_PAST_TENSE: Record = { - grep: "Grepped", - read_file: "Read", - write_file: "Wrote", - edit_file: "Edited", - run_shell: "Ran", - list_dir: "Listed", - search_files: "Searched", - web_search: "Searched", - web_fetch: "Fetched", - delete_file: "Deleted", - submit_output: "Submitted", - ask_operator: "Asked operator", - use_skill: "Loaded skill", -}; - export function pastTenseToolLabel(toolName: string): string { - return TOOL_PAST_TENSE[toolName] ?? humanizeToolName(toolName); + return TOOL_LABELS[toolName]?.past ?? humanizeToolName(toolName); } export function humanizeToolName(toolName: string): string { @@ -76,7 +65,7 @@ export function humanizeToolName(toolName: string): string { if (toolName === "web_search") return `${activeWebProviderBrand} Search`; if (toolName === "web_fetch") return `${activeWebProviderBrand} Fetch`; } - const known = TOOL_DISPLAY_NAMES[toolName]; + const known = TOOL_LABELS[toolName]?.display; if (known !== undefined) return known; // MCP tools read as "Server: tool name" rather than the raw mcp__ identifier. if (isMcpToolName(toolName)) return humanizeMcpTool(toolName); @@ -247,7 +236,12 @@ function scalarToString(value: unknown): string { return Array.isArray(value) ? `[${value.length} items]` : "{…}"; } -function abbreviate(value: string, max: number): string { +/** + * Collapse a value to a single line and clip it to `max` columns with an + * ellipsis. Shared by the result preview and the lane member subjects so the + * limit lives at each call site but the collapse-and-clip logic lives once. + */ +export function abbreviate(value: string, max: number): string { const oneLine = value.replace(/\s+/g, " ").trim(); return oneLine.length <= max ? oneLine : oneLine.slice(0, max - 1) + "…"; } diff --git a/src/tui/tool-rows.ts b/src/tui/tool-rows.ts index 193845cce..8e4856551 100644 --- a/src/tui/tool-rows.ts +++ b/src/tui/tool-rows.ts @@ -20,6 +20,7 @@ import { type ToolResultRowInput, } from "./mcp-view.js"; import { extractMcpRecords } from "./mcp-result-format.js"; +import { abbreviate } from "./tool-formatter.js"; import type { StreamRow, StyledBodyLine } from "./stream.js"; import { UI } from "./theme.js"; @@ -79,13 +80,6 @@ const LANE_MEMBER_KEYS = [ const LANE_MEMBER_SUBJECT_MAX = 48; -function clipMemberSubject(value: string): string { - const oneLine = value.replace(/\s+/g, " ").trim(); - return oneLine.length <= LANE_MEMBER_SUBJECT_MAX - ? oneLine - : `${oneLine.slice(0, LANE_MEMBER_SUBJECT_MAX - 1)}…`; -} - function memberArgs(raw: string): Record | null { let parsed: unknown; try { @@ -110,13 +104,13 @@ function laneMemberLabel(row: StreamRow): string { if (recorded.length > 0) return recorded; } const summary = row.summary?.trim() ?? ""; - if (summary.length > 0) return clipMemberSubject(summary); + if (summary.length > 0) return abbreviate(summary, LANE_MEMBER_SUBJECT_MAX); const args = memberArgs(row.text); if (args !== null) { for (const key of LANE_MEMBER_KEYS) { const value = args[key]; if (typeof value === "string" && value.trim().length > 0) { - return clipMemberSubject(value); + return abbreviate(value, LANE_MEMBER_SUBJECT_MAX); } if (typeof value === "number" || typeof value === "boolean") { return String(value); @@ -125,7 +119,8 @@ function laneMemberLabel(row: StreamRow): string { const first = Object.values(args).find( (value) => typeof value === "string" && value.trim().length > 0, ); - if (typeof first === "string") return clipMemberSubject(first); + if (typeof first === "string") + return abbreviate(first, LANE_MEMBER_SUBJECT_MAX); } return row.verb ?? row.meta ?? row.toolName ?? ""; } diff --git a/src/tui/url-click.test.ts b/src/tui/url-click.test.ts index aa2c19e0e..62cf8ffc3 100644 --- a/src/tui/url-click.test.ts +++ b/src/tui/url-click.test.ts @@ -16,15 +16,9 @@ import { defined } from "../../tests/helpers/defined.js"; import { withTestRenderer } from "./harness"; import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome"; import { createAppShell } from "./shell/index"; -import { - isOpenableUrl, - isUnderlined, - markdownLinkAt, - paintLinkLine, - resetUrlOpener, - setUrlOpener, - splitLinkSpans, -} from "./url-links"; +import { isUnderlined, markdownLinkAt, paintLinkLine } from "./url-links"; +import { isOpenableUrl, resetUrlOpener, setUrlOpener } from "./link-open"; +import { splitLinkSpans } from "./link-spans"; import { type StreamRow } from "./stream"; const CALL: StreamRow = { diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index 8857028b6..ecac3bbf0 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -28,18 +28,6 @@ import { } from "./link-spans.js"; import { UI } from "./theme.js"; -/** Moved siblings, re-exported so existing url-links importers keep working. */ -export { - isOpenableUrl, - isUrlOpenClick, - openUrl, - platformUrlCommand, - resetUrlOpener, - setUrlOpener, -} from "./link-open.js"; -export { findLinks, splitLinkSpans, type LinkSpan } from "./link-spans.js"; -export { splitWrappedLinkSpans } from "./link-wrap.js"; - /** Native chunks for one span: link spans carry OSC-8 metadata. */ export function linkSpanChunks( span: LinkSpan, diff --git a/src/tui/view/validate.ts b/src/tui/view/validate.ts index 59d9aa1aa..e0e194b11 100644 --- a/src/tui/view/validate.ts +++ b/src/tui/view/validate.ts @@ -51,6 +51,26 @@ interface Counter { nodes: number; } +// Shared child-list validator: validates an array of child values into nodes, +// preserving node-path error strings (`[]`). The array +// itself must fail with "expected an array" at its own path, so grid rows keep +// their distinct "expected an array of nodes" check outside this helper. +function validateChildren( + values: unknown, + path: string, + depth: number, + counter: Counter, +): { ok: true; nodes: ViewNode[] } | { ok: false; error: string } { + if (!Array.isArray(values)) return fail(path, "expected an array"); + const nodes: ViewNode[] = []; + for (let i = 0; i < values.length; i++) { + const child = validateNode(values[i], `${path}[${i}]`, depth + 1, counter); + if (!child.ok) return child; + nodes.push(child.node); + } + return { ok: true, nodes }; +} + function validateNode( value: unknown, path: string, @@ -88,19 +108,14 @@ function validateNode( case "stack": case "row": { - if (!Array.isArray(value.children)) - return fail(`${path}.children`, "expected an array"); - const children: ViewNode[] = []; - for (let i = 0; i < value.children.length; i++) { - const child = validateNode( - value.children[i], - `${path}.children[${i}]`, - depth + 1, - counter, - ); - if (!child.ok) return child; - children.push(child.node); - } + const kids = validateChildren( + value.children, + `${path}.children`, + depth, + counter, + ); + if (!kids.ok) return kids; + const children = kids.nodes; const g = value.gap; const gap = g === 0 || g === 1 ? (g as 0 | 1) : undefined; return { @@ -114,19 +129,14 @@ function validateNode( } case "box": { - if (!Array.isArray(value.children)) - return fail(`${path}.children`, "expected an array"); - const children: ViewNode[] = []; - for (let i = 0; i < value.children.length; i++) { - const child = validateNode( - value.children[i], - `${path}.children[${i}]`, - depth + 1, - counter, - ); - if (!child.ok) return child; - children.push(child.node); - } + const kids = validateChildren( + value.children, + `${path}.children`, + depth, + counter, + ); + if (!kids.ok) return kids; + const children = kids.nodes; const border = value.border === true ? true : undefined; const p = value.padding; const padding = p === 0 || p === 1 ? (p as 0 | 1) : undefined; @@ -149,12 +159,9 @@ function validateNode( const r = value.rows[i]; const at = `${path}.rows[${i}]`; if (!Array.isArray(r)) return fail(at, "expected an array of nodes"); - const row: ViewNode[] = []; - for (let j = 0; j < r.length; j++) { - const cell = validateNode(r[j], `${at}[${j}]`, depth + 1, counter); - if (!cell.ok) return cell; - row.push(cell.node); - } + const cells = validateChildren(r, at, depth, counter); + if (!cells.ok) return cells; + const row = cells.nodes; rows.push(row); } let columns: { align?: "left" | "right" | "center" }[] | undefined; diff --git a/tests/unit/tui/url-links.test.ts b/tests/unit/tui/url-links.test.ts index ed1c5265e..c7a1cb493 100644 --- a/tests/unit/tui/url-links.test.ts +++ b/tests/unit/tui/url-links.test.ts @@ -1,17 +1,15 @@ import { describe, expect, test, afterEach } from "bun:test"; +import { hitUrlAt, linkColumnHits } from "../../../src/tui/url-links.js"; import { - findLinks, - hitUrlAt, isOpenableUrl, isUrlOpenClick, - linkColumnHits, openUrl, platformUrlCommand, setUrlOpener, resetUrlOpener, - splitLinkSpans, - splitWrappedLinkSpans, -} from "../../../src/tui/url-links.js"; +} from "../../../src/tui/link-open.js"; +import { findLinks, splitLinkSpans } from "../../../src/tui/link-spans.js"; +import { splitWrappedLinkSpans } from "../../../src/tui/link-wrap.js"; afterEach(() => { resetUrlOpener();