diff --git a/docs/resolve-tool-runtime.md b/docs/resolve-tool-runtime.md index 298dc9953..d7a692863 100644 --- a/docs/resolve-tool-runtime.md +++ b/docs/resolve-tool-runtime.md @@ -1,6 +1,6 @@ # Resolve tool runtime internals -This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the tool-choice queue and `pushPendingAction`. +This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the pending-invoker registry and `pushPendingAction`. (Pending previews live in a separate non-forcing registry inside `ToolChoiceQueue`; only genuine hard forces use the consuming directive queue.) ## Scope and key files @@ -24,20 +24,32 @@ If no pending action exists, `resolve(action="apply")` fails with: `resolve(action="discard")` with no pending action succeeds instead, returning `Nothing to discard; no pending action remains.` — the desired end-state (no staged change) already holds. -## Pending actions use the tool-choice queue +## Pending previews use a non-forcing soft tool requirement -Preview producers call `queueResolveHandler(...)`, which pushes a one-shot forced `resolve` directive onto the session tool-choice queue and adds a `resolve-reminder` steering message. +Preview producers call `queueResolveHandler(...)`, which registers a non-forcing +pending invoker on the session (a stack keyed by a unique +`pending-action::` id — never clobbered by label). It does NOT force +`tool_choice` and does NOT inject a steering reminder. + +While a preview is pending, the session's `getToolChoice` callback +(`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`) +carrying the resolve reminder, as a non-consuming peek. The agent runtime owns the +lifecycle: it injects the reminder once, runs with `tool_choice` unchanged, and +escalates to a one-turn forced `resolve` choice ONLY if the model fails to call +`resolve` that turn (skipping any detour tool batch first). A model that resolves +on the reminder pays no message-cache invalidation — the previous design forced +`tool_choice` on every preview, busting the provider message cache twice per cycle. Runtime behavior: -- the queued handler owns the pending `apply`/`reject` callbacks, -- `resolve` looks up the current queue invoker with `session.peekQueueInvoker()`, -- if the model rejects the forced tool choice, the queue directive is requeued, -- `resolve` does not maintain a separate pending-action stack. +- the pending invoker owns the `apply`/`reject` callbacks, +- `resolve` dispatches via `peekQueueInvoker() ?? peekPendingInvoker() ?? peekStandingResolveHandler()`, +- a genuine hard forced tool choice (queued via `nextToolChoice`) preempts the soft requirement, +- if an apply callback throws, the helper re-registers the same pending invoker (same id) so the preview can still be discarded or retried. -`resolve` also checks a standing resolve handler after the queue invoker; this is used by long-lived approval flows that are not ordinary preview tool calls. +`resolve` also checks a standing resolve handler after the invokers; this is used by long-lived approval flows that are not ordinary preview tool calls. -Multiple pending previews therefore follow the active tool-choice queue ordering, not an independent pending-action store. If an apply callback throws, the queued helper re-pushes the same resolve directive and reminder so the preview can still be discarded or retried. +Multiple pending previews stack as unique-keyed invokers and resolve independently (head-first), not through forced tool-choice ordering. ## Built-in producer example (`ast_edit`) @@ -130,4 +142,4 @@ When `queueResolveHandler(...)` registers a preview, the agent runtime forces a - Keep `label` concise and specific; it is shown in resolve renderer output. - Ensure `apply(reason)` is deterministic and idempotent enough for one-shot execution; `reason` is informational and should not change behavior. - Implement `reject(reason)` when the discard needs cleanup (temp state, locks, notifications); omit it for stateless previews where the default message suffices. -- If your tool can stage multiple previews, remember they are mediated by the tool-choice queue rather than a separate pending-action stack. +- If your tool can stage multiple previews, remember they stack as unique-keyed pending invokers (resolved head-first), not a forced tool-choice sequence and not a separate `pushPendingAction` stack. diff --git a/docs/theme.md b/docs/theme.md index 18fb4aebb..d26ba8039 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -95,6 +95,16 @@ Runtime precedence: Invalid override keys are ignored and logged (`logger.debug`). +#### Box-drawing borders + +All outlined chrome — tool-result frames, overlays, code fences, the editor, the welcome banner — draws with the `boxRound.*` tokens: rounded corners (`╭╮╰╯`) plus tee/cross junctions (`├┤┬┴┼`, which have no rounded Unicode form, so they are sourced from the `boxSharp.*` tokens). Markdown tables are the sole exception and keep the fully sharp `boxSharp.*` set (`┌┐└┘`). + +Override behavior follows from that split: + +- `boxRound.{topLeft,topRight,bottomLeft,bottomRight,horizontal,vertical}` restyle every border's corners and edges. +- `boxSharp.{cross,teeDown,teeUp,teeRight,teeLeft}` restyle dividers/junctions everywhere (rounded frames and tables alike). +- `boxSharp.{topLeft,topRight,bottomLeft,bottomRight}` now affect markdown table corners only. + ## Built-in vs custom theme sources Theme lookup order (`loadThemeJson`): diff --git a/docs/tools/ast-edit.md b/docs/tools/ast-edit.md index d2b417816..c027aeaeb 100644 --- a/docs/tools/ast-edit.md +++ b/docs/tools/ast-edit.md @@ -61,7 +61,7 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md# - compiles every rewrite pattern for that language, - parses each file, skips files with syntax-error trees, collects `replace_by(...)` edits for every match, enforces replacement and file caps, and returns textual before/after slices plus source ranges. 7. The TS wrapper deduplicates and caps parse errors, groups changes by file, and renders preview diff lines. -8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a forced `resolve` action and injects a `resolve-reminder` steering message. +8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a non-forcing pending `resolve` invoker. While it is pending the session surfaces a `SoftToolRequirement` carrying the resolve reminder; the agent runtime injects the reminder and forces `resolve` only if the model declines that turn (no per-preview `tool_choice` cache bust). 9. On `resolve(action: "apply")`, the queued callback reruns the same rewrite set with `dryRun: false`, recomputes counts, and returns an error result if the live result no longer matches the preview (`stalePreview`). The current implementation compares replacement totals and per-file counts after the rerun; if the new run has already written different counts, the result is marked error. 10. On a non-stale apply, the callback returns `Applied N replacements in M files.` (in hashline mode followed by fresh `[path#tag]` snapshot headers re-recorded from the post-apply content); on discard, `resolve` returns a discard message without mutating files. @@ -79,8 +79,8 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md# - Preview reads files and scans directories. - Apply rewrites files in place with `std::fs::write(...)`, but only when the computed output differs from the original source. - Session state (transcript, memory, jobs, checkpoints, registries) - - Queues a one-shot forced `resolve` tool choice through `queueResolveHandler(...)`. - - Adds a `resolve-reminder` steering message. + - Registers a non-forcing pending `resolve` invoker through `queueResolveHandler(...)`. + - Surfaces a `SoftToolRequirement` (with the resolve reminder) while pending; the agent runtime forces `resolve` only on non-compliance — no steering message and no per-preview forced tool choice. - User-visible prompts / interactive UI - Direct `ast_edit` results are previews. - Follow-up apply/discard is exposed through the hidden `resolve` tool. diff --git a/docs/tools/resolve.md b/docs/tools/resolve.md index 6f733ae68..1027fa43a 100644 --- a/docs/tools/resolve.md +++ b/docs/tools/resolve.md @@ -34,9 +34,8 @@ ## Flow 1. Preview-producing code can call `queueResolveHandler(...)` with a label, source tool name, `apply(reason, extra?)` callback, and optional `reject(reason, extra?)` callback. 2. Modes can also register a standing resolve handler through `session.setStandingResolveHandler(...)`; `resolve.execute()` consults it only when no queued invoker is active. -3. `queueResolveHandler(...)` asks the session for a forced `resolve` tool choice and pushes it into the tool-choice queue with `pushOnce(...)`. -4. The queued entry is marked `now: true`; if the model rejects that forced tool choice, `onRejected` returns `requeue`, so the reminder comes back. -5. `queueResolveHandler(...)` also injects a `resolve-reminder` steering message: +3. `queueResolveHandler(...)` registers a non-forcing pending invoker on the session's tool-choice queue under a unique `pending-action::` id. It does NOT force a tool choice and does NOT steer a reminder. +4. While a preview is pending, the session's `getToolChoice` (`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`) carrying the resolve reminder — a non-consuming peek. The agent runtime injects the reminder once and forces `tool_choice: resolve` for one turn only if the model declines (see `docs/resolve-tool-runtime.md`). The reminder text is: ```text @@ -44,27 +43,27 @@ This is a preview. Call the `resolve` tool to apply or discard these changes. ``` -6. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and fetches `session.peekQueueInvoker?.() ?? session.peekStandingResolveHandler?.()`. -7. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds. -8. Otherwise it invokes the current handler with the full params object. -9. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`. -10. For `apply`, it calls the producer's `apply(reason, extra)` callback. -11. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The queued preview integration uses this to re-push the resolve directive and steering reminder so the action remains pending. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: ")`. -12. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message. -13. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool. +5. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and dispatches via `session.peekQueueInvoker?.() ?? session.peekPendingInvoker?.() ?? session.peekStandingResolveHandler?.()`. +6. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds. +7. Otherwise it invokes the current handler with the full params object. +8. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`. +9. For `apply`, it calls the producer's `apply(reason, extra)` callback. +10. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The pending-preview integration uses this to re-register the same pending invoker (same id) so the action remains pending for discard or retry. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: ")`. +11. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message. +12. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool. ## Modes / Variants - `apply`: runs the pending action's `apply(reason, extra?)` callback and returns its content. - `discard` with reject callback: runs `reject(reason, extra?)` and returns that callback's content when non-`undefined`. - `discard` without reject callback, or with a reject callback returning `undefined`: returns the built-in `Discarded: ...` text payload. - `discard` with no pending action at all: returns `Nothing to discard; no pending action remains.` as a success result. -- Queued handler: one in-flight tool-choice queue invoker, used by preview producers such as `ast_edit`. +- Pending invoker: a non-forcing preview invoker in the pending-invoker registry (separate from the consuming directive queue), used by preview producers such as `ast_edit`. - Standing handler: long-lived mode-owned handler, used as a fallback when no queue invoker is active. ## Side Effects - Session state - - Consumes or invokes the current pending action through the session tool-choice queue or standing handler; `resolve` does not maintain its own stack. - - Adds a `resolve-reminder` steering message when a queued preview is registered. + - Consumes or invokes the current pending action through the pending invoker, tool-choice queue, or standing handler; `resolve` does not maintain its own stack. + - Does not steer a reminder or force a tool choice for previews — the reminder rides a non-forcing `SoftToolRequirement` and the agent runtime forces `resolve` only on non-compliance. - On queued apply failure, requeues the same pending action before rethrowing so the model can discard or retry instead of losing the pending preview. - User-visible prompts / interactive UI - The visible effect depends on the preview-producing tool and the resolve renderer. @@ -74,8 +73,8 @@ This is a preview. Call the `resolve` tool to apply or discard these changes. ## Limits & Caps - Hidden tool: `ResolveTool.hidden = true`, and normal requested-tool filtering removes `resolve`; `createTools(...)` adds it separately as a hidden tool. -- Exactly one active queue invoker is consulted per call via `session.peekQueueInvoker()`; if none exists, one standing handler may be consulted via `session.peekStandingResolveHandler()`. -- There is no independent queue depth cap in this tool; ordering follows the shared tool-choice queue and mode-owned standing handler lifecycle. +- Per call, `resolve` consults the in-flight hard-directive queue invoker (`session.peekQueueInvoker()`), then the non-forcing pending-preview invoker (`session.peekPendingInvoker()`), then a standing handler (`session.peekStandingResolveHandler()`). +- There is no independent depth cap in this tool; pending previews stack as unique-keyed invokers (resolved head-first), separate from the consuming directive queue and the mode-owned standing handler lifecycle. ## Errors - `apply` with no pending action or standing handler: throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`. `discard` in the same situation succeeds with `Nothing to discard; no pending action remains.` instead of erroring. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e65a0c2fb..4659152f3 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Added -- Added `SoftToolRequirement` support to `getToolChoice` for non-invasive tool enforcement +- Added `SoftToolRequirement` support to `getToolChoice`: a host can require a tool by returning a soft requirement instead of a hard `ToolChoice`. The loop injects the supplied reminder once (leaving `tool_choice` on auto), and escalates to a one-turn forced choice — skipping any detour tool batch — only if the model fails to call the required tool, avoiding the provider message-cache invalidation of forcing every turn. - Added `pruneToolDescriptions` option to reduce token usage by stripping tool descriptions from provider-bound specs ### Fixed diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a29a98c40..ef9628c92 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - Refreshed the branch-summary transcript message to render as the same slim divider banner as `/compact`, handoff, and snapcompact (`⑂ branch · ctrl+o`, summary revealed on expand) instead of a `[branch]` box. - Optimized network traffic by stripping tool descriptions from provider tool schemas - Snapcompact compaction summaries now reach the model as ordered history blocks instead of one lead-in text block plus appended images: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This matches the new text-first snapcompact archive layout and preserves chronological order in the provider prompt. +- Unified TUI border corners on the rounded style: tool-result frames, overlays, code fences, debug frames, and the interactive bash box now draw rounded corners (`╭╮╰╯`) to match the editor and message cards, instead of mixing rounded boxes with sharp (`┌┐└┘`) ones. `boxRound` now carries the sharp tee/cross junction glyphs (no rounded variant exists), so dividers still honor `boxSharp.tee*`/`cross` theme overrides. Markdown tables intentionally keep the fully sharp `boxSharp` set; its corner tokens now affect tables only. ## [16.0.11] - 2026-06-19 diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index eb612a31c..4e45d37df 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -866,21 +866,21 @@ export class DebugLogViewerComponent implements Component { } #frameTop(innerWidth: number): string { - return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`; + return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`; } #frameSeparator(innerWidth: number): string { - return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`; + return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`; } #frameBottom(innerWidth: number): string { - return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`; + return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`; } #frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth); const remaining = Math.max(0, innerWidth - visibleWidth(truncated)); - return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`; + return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`; } #copySelected() { diff --git a/packages/coding-agent/src/debug/raw-sse.ts b/packages/coding-agent/src/debug/raw-sse.ts index b5c406a77..f1ba280f1 100644 --- a/packages/coding-agent/src/debug/raw-sse.ts +++ b/packages/coding-agent/src/debug/raw-sse.ts @@ -273,20 +273,20 @@ export class RawSseViewerComponent implements Component { } #frameTop(innerWidth: number): string { - return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`; + return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`; } #frameSeparator(innerWidth: number): string { - return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`; + return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`; } #frameBottom(innerWidth: number): string { - return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`; + return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`; } #frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth); const remaining = Math.max(0, innerWidth - visibleWidth(truncated)); - return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`; + return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`; } } diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 63bc42663..e8fab3e0c 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -275,8 +275,8 @@ function formatEditDescription( } function editHeaderLabelBudget(width: number, uiTheme: Theme): number { - const leftGlyphs = `${uiTheme.boxSharp.topLeft}${uiTheme.boxSharp.horizontal.repeat(3)}`; - return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxSharp.topRight) - 2); + const leftGlyphs = `${uiTheme.boxRound.topLeft}${uiTheme.boxRound.horizontal.repeat(3)}`; + return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxRound.topRight) - 2); } function renderEditHeader( diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index dbe21945b..8953cafc3 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -223,10 +223,10 @@ function renderHover( const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : ""; if (expanded) { - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; - const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`; - const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; + const top = `${theme.boxRound.topLeft}${h.repeat(3)}`; + const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`; let output = `${icon}${langLabel}`; if (beforeCode) { for (const line of beforeCode.split("\n")) { @@ -254,9 +254,9 @@ function renderHover( const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE); output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`; } - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; - const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; + const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`; output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`; if (codeLines.length > 1) { diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index 1a9ad6c1a..87837ddcd 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -321,7 +321,7 @@ class TwoColumnBody implements Component { const rightLines = this.rightPane.render(rightWidth); const lineCount = this.maxHeight; const out: string[] = []; - const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `); + const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); for (let i = 0; i < lineCount; i++) { const left = truncateToWidth(leftLines[i] ?? "", leftWidth); diff --git a/packages/coding-agent/src/modes/components/dynamic-border.ts b/packages/coding-agent/src/modes/components/dynamic-border.ts index 17dd0adf0..fb70166eb 100644 --- a/packages/coding-agent/src/modes/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/components/dynamic-border.ts @@ -26,7 +26,7 @@ export class DynamicBorder implements Component { if (this.#cachedLines && this.#cachedWidth === width) { return this.#cachedLines; } - const lines = [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))]; + const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))]; this.#cachedWidth = width; this.#cachedLines = lines; return lines; diff --git a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts index 0b94d6018..b2dc63640 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts @@ -380,7 +380,7 @@ class TwoColumnBody implements Component { // Fill the full body height so the dashboard reads as a full-screen view. const numLines = this.maxHeight; const combined: string[] = []; - const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `); + const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); for (let i = 0; i < numLines; i++) { const left = truncateToWidth(leftLines[i] ?? "", leftWidth); diff --git a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts index ed2185e6c..4211d719b 100644 --- a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts +++ b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts @@ -107,7 +107,7 @@ export class InspectorPanel implements Component { #renderFilePreview(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Preview:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); const content = this.#getContextFileContent(raw); if (!content) { @@ -165,7 +165,7 @@ export class InspectorPanel implements Component { #renderToolArgs(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Arguments:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const tool = raw as any; @@ -207,7 +207,7 @@ export class InspectorPanel implements Component { #renderSkillContent(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Instruction:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const skill = raw as any; @@ -236,7 +236,7 @@ export class InspectorPanel implements Component { #renderMcpDetails(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Connection:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const mcp = raw as any; @@ -275,7 +275,7 @@ export class InspectorPanel implements Component { // Show trigger pattern if present if (ext.trigger) { lines.push(theme.fg("muted", "Trigger:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); lines.push(` ${theme.fg("accent", ext.trigger)}`); lines.push(""); } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index d79a61bda..600907290 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -123,7 +123,7 @@ class OutlinedList extends Container { render(width: number): readonly string[] { const borderColor = (text: string) => theme.fg("border", text); - const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width))); + const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width))); const innerWidth = Math.max(1, width - 2); const content: string[] = []; for (const line of this.#lines) { @@ -134,7 +134,7 @@ class OutlinedList extends Container { const wrappedLine = `${indent}${wrappedBody}`; const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine)); content.push( - `${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`, + `${borderColor(theme.boxRound.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxRound.vertical)}`, ); } } diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 5200b6945..184559a06 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -1112,7 +1112,7 @@ export class ModelSelectorComponent extends Container { const menuWidth = contentWidth + (needsScroll ? 1 : 0); this.#menuContainer.addChild(new Spacer(1)); - this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0)); if (showingThinking && this.#menuSelectedRole) { this.#menuContainer.addChild( new Text( @@ -1152,7 +1152,7 @@ export class ModelSelectorComponent extends Container { this.#menuContainer.addChild(new Spacer(1)); this.#menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0)); - this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0)); } #getMenuVisibleCount(optionCount: number): number { diff --git a/packages/coding-agent/src/modes/components/overlay-box.ts b/packages/coding-agent/src/modes/components/overlay-box.ts index e427e6a25..57bac05b4 100644 --- a/packages/coding-agent/src/modes/components/overlay-box.ts +++ b/packages/coding-agent/src/modes/components/overlay-box.ts @@ -1,7 +1,8 @@ /** * Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the - * plan-review overlay, …). Every helper paints with `theme.boxSharp` glyphs and - * the `border`/`accent` theme colors so all outlined overlays read identically. + * plan-review overlay, …). Every helper paints with `theme.boxRound` glyphs + * (rounded corners, sharp tee/cross junctions) and the `border`/`accent` theme + * colors so all outlined overlays read identically. */ import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { theme } from "../theme/theme"; @@ -23,7 +24,7 @@ function paint(s: string): string { /** Top border with an optional accent-colored title inset into the rule. */ export function topBorder(width: number, title: string): string { - const box = theme.boxSharp; + const box = theme.boxRound; const inner = Math.max(0, width - 2); if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight); const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2)); @@ -37,18 +38,18 @@ export function topBorder(width: number, title: string): string { /** A horizontal rule with left/right tees, splitting overlay sections. */ export function divider(width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft); } export function bottomBorder(width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight); } /** Wrap pre-styled content in vertical borders with single-column insets. */ export function row(content: string, width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`; } @@ -70,7 +71,7 @@ export function splitBodyWidth(width: number, sidebarWidth: number): number { /** Top border carrying the title, split by a `┬` over the column divider. */ export function topBorderSplit(width: number, title: string, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const dividerCol = splitDividerCol(sidebarWidth); const leftLen = Math.max(0, dividerCol - 1); const rightLen = Math.max(0, width - 2 - dividerCol); @@ -90,7 +91,7 @@ export function topBorderSplit(width: number, title: string, sidebarWidth: numbe /** Section rule that closes the sidebar column with a `┴` over the divider. */ export function dividerSplit(width: number, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const dividerCol = splitDividerCol(sidebarWidth); const leftLen = Math.max(0, dividerCol - 1); const rightLen = Math.max(0, width - 2 - dividerCol); @@ -101,7 +102,7 @@ export function dividerSplit(width: number, sidebarWidth: number): string { /** A two-column content row: `│ sidebar │ body │`, each inset by one column. */ export function splitRow(sidebar: string, body: string, width: number, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const bodyWidth = splitBodyWidth(width, sidebarWidth); const bar = paint(box.vertical); return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`; diff --git a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts index 4a5683182..9109e73bb 100644 --- a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts +++ b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts @@ -74,7 +74,7 @@ export class TinyTitleDownloadProgressComponent implements Component { render(width: number): readonly string[] { width = Math.max(1, width); const spec = getTinyTitleModelSpec(this.#modelKey); - const border = theme.fg("border", theme.boxSharp.horizontal.repeat(width)); + const border = theme.fg("border", theme.boxRound.horizontal.repeat(width)); const status = statusLabel(this.#event); const file = currentFile(this.#event); const pct = diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 557de7ad1..0c9a2b2d2 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -308,7 +308,7 @@ export class WelcomeComponent implements Component { } // Bottom border if (showRightColumn) { - lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br); + lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br); } else { lines.push(bl + h.repeat(leftCol) + br); } diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 0a923a97e..faa70af0f 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1716,6 +1716,14 @@ export class Theme { bottomRight: this.#symbols["boxRound.bottomRight"], horizontal: this.#symbols["boxRound.horizontal"], vertical: this.#symbols["boxRound.vertical"], + // Junctions have no rounded Unicode variant, so a rounded box reuses the + // sharp tee/cross glyphs. Sourcing them from the boxSharp.* tokens keeps a + // theme's `boxSharp.tee*` overrides effective for rounded-box dividers. + cross: this.#symbols["boxSharp.cross"], + teeDown: this.#symbols["boxSharp.teeDown"], + teeUp: this.#symbols["boxSharp.teeUp"], + teeRight: this.#symbols["boxSharp.teeRight"], + teeLeft: this.#symbols["boxSharp.teeLeft"], }; } diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index fc34c7296..3c1a57e6c 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -274,16 +274,16 @@ class BashInteractiveOverlayComponent implements Component { : truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth); const visibleLines = this.#readViewport(innerWidth, maxContentRows); const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)]; - const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxSharp.horizontal.repeat(innerWidth)); - const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxSharp.vertical); + const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxRound.horizontal.repeat(innerWidth)); + const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxRound.vertical); const boxLine = (line: string) => `${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`; return [ - `${this.uiTheme.fg("border", this.uiTheme.boxSharp.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.topRight)}`, + `${this.uiTheme.fg("border", this.uiTheme.boxRound.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.topRight)}`, boxLine(header), ...content.map(boxLine), boxLine(footer), - `${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomRight)}`, + `${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomRight)}`, ]; } diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index c08b0792e..970a96f14 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -48,8 +48,8 @@ function normalizeContentPaddingLeft(value: number | undefined): number { export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] { const { header, headerMeta, state, sections = [], width, applyBg = true } = options; - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; const cap = h.repeat(3); const lineWidth = Math.max(0, width); // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors @@ -84,8 +84,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const rows: BlockRow[] = []; rows.push({ kind: "bar", - leftChar: theme.boxSharp.topLeft, - rightChar: theme.boxSharp.topRight, + leftChar: theme.boxRound.topLeft, + rightChar: theme.boxRound.topRight, label: header, meta: headerMeta, }); @@ -99,15 +99,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st if (section.label) { rows.push({ kind: "bar", - leftChar: theme.boxSharp.teeRight, - rightChar: theme.boxSharp.teeLeft, + leftChar: theme.boxRound.teeRight, + rightChar: theme.boxRound.teeLeft, label: section.label, }); } else if (section.separator && sectionIndex > 0) { rows.push({ kind: "bar", - leftChar: theme.boxSharp.teeRight, - rightChar: theme.boxSharp.teeLeft, + leftChar: theme.boxRound.teeRight, + rightChar: theme.boxRound.teeLeft, }); } const allLines = section.lines.flatMap(l => l.split("\n")); @@ -126,7 +126,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st } } - rows.push({ kind: "bottom", leftChar: theme.boxSharp.bottomLeft, rightChar: theme.boxSharp.bottomRight }); + rows.push({ kind: "bottom", leftChar: theme.boxRound.bottomLeft, rightChar: theme.boxRound.bottomRight }); const H = rows.length; diff --git a/packages/coding-agent/test/gallery-cli.test.ts b/packages/coding-agent/test/gallery-cli.test.ts index c88eeaf11..9eaf0b5d7 100644 --- a/packages/coding-agent/test/gallery-cli.test.ts +++ b/packages/coding-agent/test/gallery-cli.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { GALLERY_STATES, renderGalleryState, resolveFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-cli"; import type { GalleryFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-fixtures"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers"; beforeAll(async () => { @@ -63,7 +63,7 @@ describe("gallery harness", () => { const lines = await renderGalleryState("task", task, "error", 100); const stripped = lines.map(line => Bun.stripANSI(line).trim()); // The framed result header carries the label inside the box border... - expect(stripped.some(line => line.startsWith("┌") && line.includes("Task"))).toBe(true); + expect(stripped.some(line => line.startsWith(theme.boxRound.topLeft) && line.includes("Task"))).toBe(true); // ...but no standalone "Task" label line precedes it. expect(stripped).not.toContain("Task"); }); diff --git a/packages/coding-agent/test/modes/components/copy-selector.test.ts b/packages/coding-agent/test/modes/components/copy-selector.test.ts index 7b09f17be..83cf0e94f 100644 --- a/packages/coding-agent/test/modes/components/copy-selector.test.ts +++ b/packages/coding-agent/test/modes/components/copy-selector.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun: import { stripVTControlCharacters } from "node:util"; import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { CopySelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/copy-selector"; -import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { CopyTarget } from "@oh-my-pi/pi-coding-agent/modes/utils/copy-targets"; import { setKeybindings } from "@oh-my-pi/pi-tui"; @@ -77,7 +77,7 @@ describe("CopySelectorComponent", () => { it("renders an outlined tree with code blocks nested under their message", () => { const out = render(new CopySelectorComponent(makeRoots(), { onPick: vi.fn(), onCancel: vi.fn() })); - expect(out).toContain("┌"); + expect(out).toContain(theme.boxRound.topLeft); expect(out).toContain("│"); expect(out).toContain("Copy to clipboard"); // Messages and their nested blocks are all visible (always expanded), diff --git a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts index 609a17111..ef9841fd4 100644 --- a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts +++ b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts @@ -58,9 +58,9 @@ describe("PlanReviewOverlay", () => { for (const option of APPROVAL_OPTIONS) expect(out).toContain(option); expect(out).toContain("esc cancel"); // Outlined like the /copy overlay. - expect(out).toContain("┌"); + expect(out).toContain(theme.boxRound.topLeft); expect(out).toContain("│"); - expect(out).toContain("└"); + expect(out).toContain(theme.boxRound.bottomLeft); }); it("confirms the highlighted option on Enter", () => { diff --git a/packages/coding-agent/test/streaming-preview-height.test.ts b/packages/coding-agent/test/streaming-preview-height.test.ts index a30867f5b..2c73301dd 100644 --- a/packages/coding-agent/test/streaming-preview-height.test.ts +++ b/packages/coding-agent/test/streaming-preview-height.test.ts @@ -386,11 +386,11 @@ describe("streaming tool call preview height (bounded across renderers)", () => const width = 80; const { lines } = renderPending("bash", { command: "echo hi" }); const strippedLines = lines.map(line => Bun.stripANSI(line)); - const topBorder = strippedLines.find(line => line.includes(activeTheme.boxSharp.topLeft)); + const topBorder = strippedLines.find(line => line.includes(activeTheme.boxRound.topLeft)); expect(topBorder).toBeDefined(); - expect(topBorder?.[0]).toBe(activeTheme.boxSharp.topLeft); - expect(topBorder?.endsWith(activeTheme.boxSharp.topRight)).toBe(true); + expect(topBorder?.[0]).toBe(activeTheme.boxRound.topLeft); + expect(topBorder?.endsWith(activeTheme.boxRound.topRight)).toBe(true); expect(visibleWidth(topBorder ?? "")).toBe(width); }); diff --git a/packages/coding-agent/test/task/render-call.test.ts b/packages/coding-agent/test/task/render-call.test.ts index 4a0c03425..b0d05118c 100644 --- a/packages/coding-agent/test/task/render-call.test.ts +++ b/packages/coding-agent/test/task/render-call.test.ts @@ -124,7 +124,7 @@ describe("task renderer: streaming call preview", () => { expect(out).toContain("Shared brief."); // Interior divider bars start with the tee glyph; only the header (top) // and bottom border may exist. - const tee = theme.boxSharp.teeRight; + const tee = theme.boxRound.teeRight; expect(lines.filter(line => line.trimStart().startsWith(tee))).toHaveLength(0); }); diff --git a/packages/coding-agent/test/tools/eval-agent-progress.test.ts b/packages/coding-agent/test/tools/eval-agent-progress.test.ts index 0b2e4c134..3b95e81f1 100644 --- a/packages/coding-agent/test/tools/eval-agent-progress.test.ts +++ b/packages/coding-agent/test/tools/eval-agent-progress.test.ts @@ -51,7 +51,7 @@ describe("eval renderer: agent() progress below the cell box", () => { /** Index of the box's closing border (bottom-right corner glyph). */ function boxBottomIndex(lines: string[]): number { - return lines.findIndex(line => line.includes(theme.boxSharp.bottomRight)); + return lines.findIndex(line => line.includes(theme.boxRound.bottomRight)); } it("draws a running subagent below the box with its current tool and intent", () => { diff --git a/packages/coding-agent/test/tools/read-renderer.test.ts b/packages/coding-agent/test/tools/read-renderer.test.ts index 44f7acc15..c87678a17 100644 --- a/packages/coding-agent/test/tools/read-renderer.test.ts +++ b/packages/coding-agent/test/tools/read-renderer.test.ts @@ -122,10 +122,10 @@ describe("read ToolExecutionComponent framing", () => { try { const lines = component.render(80).map(line => Bun.stripANSI(line)); const topBorderIndex = lines.findIndex( - line => line.includes(activeTheme.boxSharp.topLeft) && line.includes("Read"), + line => line.includes(activeTheme.boxRound.topLeft) && line.includes("Read"), ); const bottomBorderIndex = lines.findIndex( - (line, index) => index > topBorderIndex && line.includes(activeTheme.boxSharp.bottomLeft), + (line, index) => index > topBorderIndex && line.includes(activeTheme.boxRound.bottomLeft), ); expect(topBorderIndex).toBeGreaterThanOrEqual(0); diff --git a/packages/coding-agent/test/tools/resolve.test.ts b/packages/coding-agent/test/tools/resolve.test.ts index 5df4bc042..46c1f84ad 100644 --- a/packages/coding-agent/test/tools/resolve.test.ts +++ b/packages/coding-agent/test/tools/resolve.test.ts @@ -147,7 +147,7 @@ it("renders a highlighted apply summary", async () => { expect(rendered).toContain("All replacements are correct"); expect(rendered).not.toContain("Applied 2 replacements in 1 file."); expect(rendered).not.toContain("Decision"); - expect(rendered).not.toContain("┌"); + expect(rendered).not.toContain(uiTheme.boxRound.topLeft); }); it("keeps the inverse block color across the full line (no mid-line fg reset)", async () => {