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:
can1357
2026-06-19 07:54:53 +02:00
parent a9c493db13
commit b877464575
30 changed files with 128 additions and 97 deletions
+22 -10
View File
@@ -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.
+10
View File
@@ -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`):
+3 -3
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
+1
View File
@@ -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() {
+4 -4
View File
@@ -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}`;
}
}
+2 -2
View File
@@ -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(
+7 -7
View File
@@ -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 () => {