refactor(coding-agent): migrated UI chrome to rounded box corners
- Switch all UI components and tests from sharp box corners (`boxSharp`) to rounded ones (`boxRound`). - Update `Theme` to re-export sharp junction symbols (tees and cross) under `boxRound` to ensure consistent divider rendering in rounded boxes. - Remove outdated architectural notes regarding forced tool-choice queues in documentation.
This commit is contained in:
@@ -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:<tool>:<seq>` 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.
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -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.
|
||||
|
||||
+15
-16
@@ -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:<sourceTool>:<seq>` 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
|
||||
<system-reminder>
|
||||
@@ -44,27 +43,27 @@ This is a preview. Call the `resolve` tool to apply or discard these changes.
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
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: <message>")`.
|
||||
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: <message>")`.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user