From 2d56a37334f94c9565d5243ce10661cada88b5d9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 04:44:39 +0200 Subject: [PATCH] refactor(coding-agent): standardized ui dialogs to use overlay panel base - Introduce OverlayPanel and PanelDivider classes to standardize rounded-box inline overlays and section rules. - Migrate various UI dialog, selector, and panel components to inherit from OverlayPanel instead of Container. - Remove manual DynamicBorder elements and custom title/header components across migrated components. - Update test helpers and assertions to match the new overlay panel structure and rendering methods. --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/components/btw-panel.ts | 23 ++- .../src/modes/components/cleanse-panel.ts | 24 ++-- .../src/modes/components/history-search.ts | 15 +- .../src/modes/components/hook-editor.ts | 28 ++-- .../src/modes/components/hook-input.ts | 21 +-- .../src/modes/components/hook-selector.ts | 28 ++-- .../src/modes/components/login-dialog.ts | 42 +++--- .../components/logout-account-selector.ts | 18 +-- .../src/modes/components/mcp-add-wizard.ts | 26 +--- .../src/modes/components/oauth-selector.ts | 29 ++-- .../src/modes/components/omfg-panel.ts | 24 ++-- .../src/modes/components/overlay-box.ts | 135 +++++++++++++++++- .../src/modes/components/plugin-selector.ts | 11 +- .../src/modes/components/plugin-settings.ts | 77 ++++------ .../modes/components/queue-mode-selector.ts | 14 +- .../modes/components/reset-usage-selector.ts | 20 +-- .../components/session-account-selector.ts | 21 +-- .../src/modes/components/session-selector.ts | 68 ++++----- .../modes/components/show-images-selector.ts | 14 +- .../src/modes/components/theme-selector.ts | 14 +- .../src/modes/components/thinking-selector.ts | 14 +- .../src/modes/components/tree-selector.ts | 50 +++---- .../modes/components/user-message-selector.ts | 16 +-- .../coding-agent/src/prompts/tools/yield.md | 2 +- packages/coding-agent/src/tools/ask.ts | 6 +- .../coding-agent/test/hook-editor.test.ts | 41 +++--- .../test/hook-selector-overflow.test.ts | 13 ++ .../modes/components/login-dialog.test.ts | 2 +- .../tree-selector-chain-gutter-2298.test.ts | 2 +- ...e-selector-last-branch-gutter-2325.test.ts | 2 +- .../components/tree-selector-overflow.test.ts | 2 +- 32 files changed, 395 insertions(+), 409 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 093e05af8..3fec9e5a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added the `ps` command and CLI action for inspecting, monitoring, and controlling background daemon processes +- Added `omp ps` for inspecting and controlling daemon-broker supervised processes from outside the harness: an interactive alt-screen monitor on TTYs (live table, info/logs views, stop/kill/restart, all-scopes toggle) plus static `--plain`/`--json` listings and `info`/`logs`/`stop`/`kill`/`restart` subactions with `--all`, `--dir`, and `--global` scope selectors. Brokers now record their project directory in `scope.json` so runtime scopes can be mapped back to projects offline. - Added `qwenTemplateReasoningEffort` to the `models.yml` `compat` schema, so the auto-enabled Qwen 3.8+ template effort dialect (`chat_template_kwargs.reasoning_effort`) can be switched off per provider/model for strict local servers that reject unknown `chat_template_kwargs`. - Added `tokenizer` to custom model and `modelOverrides` configuration. It overrides the catalog-resolved local tokenizer family for a model when a proxy serves a known model id with a different tokenizer. - Added `extendedContext` setting (`/settings` → Context → General, default on). When off, models with a premium long-context price tier (OpenAI GPT-5.6 Sol/Terra/Luna bill 2x input / 1.5x output above 272K input tokens, on both the API and subscription Codex) are capped at the standard-pricing threshold — they appear as 272K again and compaction fires before a request crosses into premium billing. Toggling mid-session re-clamps or restores the active model's window immediately. Anthropic Claude 4.6+ serves its full 1M window at standard pricing, so no Anthropic model is affected. diff --git a/packages/coding-agent/src/modes/components/btw-panel.ts b/packages/coding-agent/src/modes/components/btw-panel.ts index 36209a4ed..b2f0e48cf 100644 --- a/packages/coding-agent/src/modes/components/btw-panel.ts +++ b/packages/coding-agent/src/modes/components/btw-panel.ts @@ -1,7 +1,7 @@ -import { type Component, Container, Markdown, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { type Component, Markdown, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { replaceTabs } from "../../tools/render-utils"; import { getMarkdownTheme, theme } from "../theme/theme"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; type BtwPanelState = "running" | "complete" | "branching" | "aborted" | "error"; @@ -24,14 +24,13 @@ class BtwFooter implements Component { const line = this.#getLine(); if (line !== this.#line || !this.#text) { this.#line = line; - this.#text = new Text(line, 1, 0); + this.#text = new Text(line, 0, 0); } return this.#text.render(width); } } -export class BtwPanelComponent extends Container { - #question: string; +export class BtwPanelComponent extends OverlayPanel { #tui: TUI; #canBranch: (() => boolean) | undefined; #state: BtwPanelState = "running"; @@ -41,8 +40,7 @@ export class BtwPanelComponent extends Container { #closed = false; constructor(options: BtwPanelComponentOptions) { - super(); - this.#question = options.question; + super(`/btw ${replaceTabs(options.question)}`); this.#tui = options.tui; this.#canBranch = options.canBranch; this.#rebuild(); @@ -110,15 +108,10 @@ export class BtwPanelComponent extends Container { #rebuild(): void { this.clear(); - this.addChild(new DynamicBorder(str => theme.fg("dim", str))); - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("accent", replaceTabs(this.#question)), 1, 0)); this.addChild(new Spacer(1)); this.addChild(this.#contentComponent()); this.addChild(new Spacer(1)); this.addChild(new BtwFooter(() => this.#footerLine())); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder(str => theme.fg("dim", str))); // Component-scoped: a rebuild replaces only this panel's own children // (streaming deltas arrive per token, and a full compose would re-walk // the whole transcript each time). Before the panel is mounted the TUI @@ -148,14 +141,14 @@ export class BtwPanelComponent extends Container { #contentComponent(): Component { if (this.#state === "error") { - return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 1, 0); + return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 0, 0); } const text = this.#visibleAnswer; if (!text) { const waiting = this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned."; - return new Text(theme.fg("dim", waiting), 1, 0); + return new Text(theme.fg("dim", waiting), 0, 0); } - return new Markdown(text, 1, 0, getMarkdownTheme()); + return new Markdown(text, 0, 0, getMarkdownTheme()); } } diff --git a/packages/coding-agent/src/modes/components/cleanse-panel.ts b/packages/coding-agent/src/modes/components/cleanse-panel.ts index e591a1778..6f73f780f 100644 --- a/packages/coding-agent/src/modes/components/cleanse-panel.ts +++ b/packages/coding-agent/src/modes/components/cleanse-panel.ts @@ -5,7 +5,7 @@ * checker/wave/agent rows from {@link CleanseBoardModel} animate in place while * permanent log lines accumulate above them. */ -import { Container, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { CleanseBoardModel, type CleanseStatusBoard } from "../../cleanse/board"; import type { CleanseCheckerDescriptor } from "../../cleanse/checkers"; import type { CleanseAgentOutcome, CleanseAssignment, CleanseCheckResult, CleanseRunStatus } from "../../cleanse/types"; @@ -13,7 +13,7 @@ import { SPINNER_FRAMES } from "../../cli/live-board"; import type { AgentProgress } from "../../task/types"; import { replaceTabs } from "../../tools/render-utils"; import { theme } from "../theme/theme"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; const SPINNER_INTERVAL_MS = 80; const MAX_LOG_LINES = 14; @@ -27,10 +27,9 @@ interface CleansePanelComponentOptions { /** Terminal state of the run, mirrored into the footer once the core settles. */ type CleansePanelOutcome = CleanseRunStatus | "error"; -export class CleansePanelComponent extends Container implements CleanseStatusBoard { +export class CleansePanelComponent extends OverlayPanel implements CleanseStatusBoard { readonly interactive = true; - readonly #request: string | undefined; readonly #tui: TUI; readonly #model = new CleanseBoardModel(); readonly #logLines: string[] = []; @@ -41,8 +40,7 @@ export class CleansePanelComponent extends Container implements CleanseStatusBoa #liveClosed = false; constructor(options: CleansePanelComponentOptions) { - super(); - this.#request = options.request; + super(options.request ? `/cleanse ${replaceTabs(options.request)}` : "/cleanse"); this.#tui = options.tui; this.#timer = setInterval(() => { this.#frame = (this.#frame + 1) % SPINNER_FRAMES.length; @@ -134,29 +132,23 @@ export class CleansePanelComponent extends Container implements CleanseStatusBoa #rebuild(): void { this.clear(); - this.addChild(new DynamicBorder(str => theme.fg("dim", str))); this.addChild(new Spacer(1)); - const header = this.#request ? `/cleanse ${this.#request}` : "/cleanse"; - this.addChild(new Text(theme.fg("accent", replaceTabs(header)), 1, 0)); if (this.#logLines.length > 0) { + for (const line of this.#logLines) this.addChild(new Text(replaceTabs(line), 0, 0)); this.addChild(new Spacer(1)); - for (const line of this.#logLines) this.addChild(new Text(replaceTabs(line), 1, 0)); } if (!this.#liveClosed) { const liveLines = this.#model.renderLive(SPINNER_FRAMES[this.#frame] ?? SPINNER_FRAMES[0]); if (liveLines.length > 0) { + for (const line of liveLines) this.addChild(new Text(replaceTabs(line), 0, 0)); this.addChild(new Spacer(1)); - for (const line of liveLines) this.addChild(new Text(replaceTabs(line), 1, 0)); } } if (this.#errorMessage) { + this.addChild(new Text(theme.fg("error", replaceTabs(this.#errorMessage)), 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("error", replaceTabs(this.#errorMessage)), 1, 0)); } - this.addChild(new Spacer(1)); - this.addChild(new Text(this.#footerLine(), 1, 0)); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder(str => theme.fg("dim", str))); + this.addChild(new Text(this.#footerLine(), 0, 0)); this.#tui.requestRender(); } diff --git a/packages/coding-agent/src/modes/components/history-search.ts b/packages/coding-agent/src/modes/components/history-search.ts index ca01bfb6f..efefc11eb 100644 --- a/packages/coding-agent/src/modes/components/history-search.ts +++ b/packages/coding-agent/src/modes/components/history-search.ts @@ -1,6 +1,5 @@ import { type Component, - Container, Ellipsis, Input, matchesKey, @@ -19,8 +18,8 @@ import { matchesSelectUp, } from "../../modes/utils/keybinding-matchers"; import type { HistoryEntry, HistoryStorage } from "../../session/history-storage"; -import { DynamicBorder } from "./dynamic-border"; import { rawKeyHint } from "./keybinding-hints"; +import { OverlayPanel } from "./overlay-box"; import { centeredWindow, contentRowWidth, renderScrollableList } from "./selector-helpers"; /** Visible result rows; also the jump distance for PageUp/PageDown. */ @@ -148,7 +147,7 @@ class HistoryResultsList implements Component { } } -export class HistorySearchComponent extends Container { +export class HistorySearchComponent extends OverlayPanel { #historyStorage: HistoryStorage; #searchInput: Input; #results: HistoryEntry[] = []; @@ -159,7 +158,7 @@ export class HistorySearchComponent extends Container { #resultLimit = 100; constructor(historyStorage: HistoryStorage, onSelect: (prompt: string) => void, onCancel: () => void) { - super(); + super("History"); this.#historyStorage = historyStorage; this.#onSelect = onSelect; this.#onCancel = onCancel; @@ -177,22 +176,16 @@ export class HistorySearchComponent extends Container { this.#resultsList = new HistoryResultsList(); - const title = theme.bold(theme.fg("accent", `${theme.icon.rewind} Search History`)); const dot = theme.fg("dim", theme.sep.dot); const hint = [rawKeyHint("↑↓", "navigate"), rawKeyHint("enter", "select"), rawKeyHint("esc", "cancel")].join(dot); - this.addChild(new Spacer(1)); - this.addChild(new Text(title, 1, 0)); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); this.addChild(this.#searchInput); this.addChild(new Spacer(1)); this.addChild(this.#resultsList); this.addChild(new Spacer(1)); - this.addChild(new Text(hint, 1, 0)); + this.addChild(new Text(hint, 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); this.#updateResults(); } diff --git a/packages/coding-agent/src/modes/components/hook-editor.ts b/packages/coding-agent/src/modes/components/hook-editor.ts index b7a6564d7..ae2394746 100644 --- a/packages/coding-agent/src/modes/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/components/hook-editor.ts @@ -7,7 +7,7 @@ * (Ctrl+Q / Ctrl+Enter) submits, bordered popup * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome */ -import { Container, Editor, type Focusable, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { Editor, type Focusable, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { getEditorTheme, theme } from "../../modes/theme/theme"; import { matchesAppExternalEditor, @@ -15,7 +15,7 @@ import { matchesAppInterrupt, } from "../../modes/utils/keybinding-matchers"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; export interface HookEditorOptions { /** When true, use prompt-style keybindings with the legacy ask prompt chrome. */ @@ -30,7 +30,7 @@ export interface HookEditorOptions { } /** Interactive multiline dialog used by hooks and the ask tool's Other response. */ -export class HookEditorComponent extends Container implements Focusable { +export class HookEditorComponent extends OverlayPanel implements Focusable { #editor: Editor; #onSubmitCallback: (value: string) => void; #onCancelCallback: () => void; @@ -47,22 +47,22 @@ export class HookEditorComponent extends Container implements Focusable { onCancel: () => void, options?: HookEditorOptions, ) { - super(); + // First title line insets into the panel border; remaining lines (e.g. the + // bounded ask question under "◆ Other (type your own)") stay as body rows + // so they are never truncated into the one-row border. + const [titleLine = "", ...detailLines] = title.split("\n"); + super(titleLine); this.#tui = tui; this.#onSubmitCallback = onSubmit; this.#onCancelCallback = onCancel; this.#promptStyle = options?.promptStyle ?? false; - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - - // Title. Prompt-style renders the borderless editor's `> ` gutter at - // column 0, so pad the title to match; hook-style keeps the 1-col indent - // that lines up with its bordered editor body (#5313). - const chromePadX = this.#promptStyle ? 0 : 1; - this.addChild(new Text(theme.fg("accent", title), chromePadX, 0)); this.addChild(new Spacer(1)); + if (detailLines.length > 0) { + for (const line of detailLines) this.addChild(new Text(theme.fg("accent", line), 0, 0)); + this.addChild(new Spacer(1)); + } // Editor this.#editor = new Editor(getEditorTheme()); @@ -87,10 +87,8 @@ export class HookEditorComponent extends Container implements Focusable { const hint = this.#promptStyle ? "enter or ctrl+q submit esc cancel ctrl+g external editor" : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor"; - this.addChild(new Text(theme.fg("dim", hint), chromePadX, 0)); - + this.addChild(new Text(theme.fg("dim", hint), 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); } /** Keep the nested editor's software/hardware cursor mode aligned with the dialog focus target. */ diff --git a/packages/coding-agent/src/modes/components/hook-input.ts b/packages/coding-agent/src/modes/components/hook-input.ts index 203c13b1f..6b3c6d92d 100644 --- a/packages/coding-agent/src/modes/components/hook-input.ts +++ b/packages/coding-agent/src/modes/components/hook-input.ts @@ -1,11 +1,11 @@ /** * Simple text input component for hooks. */ -import { Container, Input, Markdown, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +import { Input, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { theme } from "../../modes/theme/theme"; import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import { CountdownTimer } from "./countdown-timer"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; export interface HookInputOptions { tui?: TUI; @@ -13,11 +13,10 @@ export interface HookInputOptions { onTimeout?: () => void; } -export class HookInputComponent extends Container { +export class HookInputComponent extends OverlayPanel { #input: Input; #onSubmitCallback: (value: string) => void; #onCancelCallback: () => void; - #titleComponent: Markdown; #baseTitle: string; #countdown: CountdownTimer | undefined; @@ -28,24 +27,19 @@ export class HookInputComponent extends Container { onCancel: () => void, opts?: HookInputOptions, ) { - super(); + super(title); this.#onSubmitCallback = onSubmit; this.#onCancelCallback = onCancel; this.#baseTitle = title; - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - - this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) }); - this.addChild(this.#titleComponent); this.addChild(new Spacer(1)); if (opts?.timeout && opts.timeout > 0 && opts.tui) { this.#countdown = new CountdownTimer( opts.timeout, opts.tui, - s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`), + s => (this.title = `${this.#baseTitle} (${s}s)`), () => { opts.onTimeout?.(); this.#onCancelCallback(); @@ -56,9 +50,8 @@ export class HookInputComponent extends Container { this.#input = new Input(); this.addChild(this.#input); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0)); + this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); } handleInput(keyData: string): void { diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 2833cb99a..4473ec003 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -7,7 +7,6 @@ import { Ellipsis, extractPrintableText, fuzzyFilter, - Markdown, type MarkdownTheme, matchesKey, padding, @@ -28,7 +27,7 @@ import { matchesSelectUp, } from "../../modes/utils/keybinding-matchers"; import { CountdownTimer } from "./countdown-timer"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; import { renderSegmentTrack } from "./segment-track"; /** One segment of a {@link HookSelectorSlider} — a label and an optional @@ -160,7 +159,7 @@ class OutlinedList extends Container { * disabled-index lookups survive fuzzy filtering and reordering. */ type FilteredOption = { option: HookSelectorOption; index: number }; -export class HookSelectorComponent extends Container { +export class HookSelectorComponent extends OverlayPanel { #options: HookSelectorOption[]; #filteredOptions: FilteredOption[]; #searchQuery = ""; @@ -174,7 +173,6 @@ export class HookSelectorComponent extends Container { #outlinedList: OutlinedList | undefined; #onSelectCallback: (option: string) => void; #onCancelCallback: () => void; - #titleComponent: Markdown; #baseTitle: string; #countdown: CountdownTimer | undefined; #onLeftCallback: (() => void) | undefined; @@ -192,7 +190,7 @@ export class HookSelectorComponent extends Container { onCancel: () => void, opts?: HookSelectorOptions, ) { - super(); + super(title.split(/\r?\n/, 1)[0] ?? ""); this.#options = options.map(normalizeHookSelectorOption); this.#filteredOptions = this.#options.map((option, index) => ({ option, index })); @@ -212,7 +210,7 @@ export class HookSelectorComponent extends Container { this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12); this.#onSelectCallback = onSelect; this.#onCancelCallback = onCancel; - this.#baseTitle = title; + this.#baseTitle = this.title; this.#onLeftCallback = opts?.onLeft; this.#onRightCallback = opts?.onRight; this.#onExternalEditorCallback = opts?.onExternalEditor; @@ -222,15 +220,14 @@ export class HookSelectorComponent extends Container { this.#sliderIndex = Math.max(0, Math.min(opts.slider.index, opts.slider.segments.length - 1)); } - this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - - this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) }); - this.addChild(this.#titleComponent); + for (const line of title.split(/\r?\n/).slice(1)) { + this.addChild(new Text(theme.fg("accent", line), 0, 0)); + } this.addChild(new Spacer(1)); if (this.#slider) { - this.#sliderComponent = new Text(this.#renderSliderLine(), 1, 0); + this.#sliderComponent = new Text(this.#renderSliderLine(), 0, 0); this.addChild(this.#sliderComponent); this.addChild(new Spacer(1)); } @@ -240,7 +237,7 @@ export class HookSelectorComponent extends Container { this.#countdown = new CountdownTimer( opts.timeout, opts.tui, - s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`), + s => (this.title = `${this.#baseTitle} (${s}s)`), () => { opts?.onTimeout?.(); // Auto-select current option on timeout (typically the first/recommended option) @@ -263,9 +260,8 @@ export class HookSelectorComponent extends Container { } this.addChild(new Spacer(1)); const controlsHint = opts?.helpText ?? "up/down navigate enter select esc cancel"; - this.addChild(new Text(theme.fg("dim", controlsHint), 1, 0)); + this.addChild(new Text(theme.fg("dim", controlsHint), 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); this.#updateList(); } @@ -677,12 +673,12 @@ export class HookSelectorComponent extends Container { } override render(width: number): readonly string[] { - const renderWidth = Math.max(1, width); + const renderWidth = Math.max(1, width - 4); if (this.#lastRenderWidth !== renderWidth) { this.#lastRenderWidth = renderWidth; this.#updateList(renderWidth); } - return super.render(renderWidth); + return super.render(width); } override dispose(): void { diff --git a/packages/coding-agent/src/modes/components/login-dialog.ts b/packages/coding-agent/src/modes/components/login-dialog.ts index d60322043..44861bbda 100644 --- a/packages/coding-agent/src/modes/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/components/login-dialog.ts @@ -3,12 +3,12 @@ import { Container, getKeybindings, Input, Spacer, Text, type TUI, wrapTextWithA import { theme } from "../../modes/theme/theme"; import { urlHyperlinkAlways, WidthAwareText } from "../../tui"; import { openPath } from "../../utils/open"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; /** * Login dialog component - replaces editor during OAuth login flow */ -export class LoginDialogComponent extends Container { +export class LoginDialogComponent extends OverlayPanel { #contentContainer: Container; #input: Input; #tui: TUI; @@ -21,17 +21,10 @@ export class LoginDialogComponent extends Container { providerId: string, private onComplete: (success: boolean, message?: string) => void, ) { - super(); - this.#tui = tui; - const providerInfo = getOAuthProviders().find(p => p.id === providerId); const providerName = providerInfo?.name || providerId; - - // Top border - this.addChild(new DynamicBorder()); - - // Title - this.addChild(new Text(theme.fg("warning", `Login to ${providerName}`), 1, 0)); + super(`Login to ${providerName}`); + this.#tui = tui; // Dynamic content area this.#contentContainer = new Container(); @@ -49,9 +42,6 @@ export class LoginDialogComponent extends Container { this.#input.onEscape = () => { this.#cancel(); }; - - // Bottom border - this.addChild(new DynamicBorder()); } get signal(): AbortSignal { @@ -88,24 +78,24 @@ export class LoginDialogComponent extends Container { wrapTextWithAnsi(url, contentWidth) .map(row => theme.fg("accent", urlHyperlinkAlways(url, row))) .join("\n"), - 1, + 0, 0, ), ); const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`; - this.#contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 0, 0)); if (launchUrl && launchUrl !== url) { this.#contentContainer.addChild( - new Text(theme.fg("dim", `Local shortcut (this machine only): ${launchUrl}`), 1, 0), + new Text(theme.fg("dim", `Local shortcut (this machine only): ${launchUrl}`), 0, 0), ); } if (instructions) { this.#contentContainer.addChild(new Spacer(1)); - this.#contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("warning", instructions), 0, 0)); } // Open browser (best-effort) @@ -123,9 +113,9 @@ export class LoginDialogComponent extends Container { // hint lines beneath the dialog. Reset the value so each retry starts clean. if (!this.#contentContainer.children.includes(this.#input)) { this.#contentContainer.addChild(new Spacer(1)); - this.#contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", prompt), 0, 0)); this.#contentContainer.addChild(this.#input); - this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 0, 0)); } this.#input.setValue(""); this.#tui.requestRender(); @@ -142,14 +132,14 @@ export class LoginDialogComponent extends Container { */ showPrompt(message: string, placeholder?: string): Promise { this.#contentContainer.addChild(new Spacer(1)); - this.#contentContainer.addChild(new Text(theme.fg("text", message), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("text", message), 0, 0)); if (placeholder) { - this.#contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 0, 0)); } if (!this.#contentContainer.children.includes(this.#input)) { this.#contentContainer.addChild(this.#input); } - this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 0, 0)); this.#input.setValue(""); this.#tui.requestRender(); @@ -165,8 +155,8 @@ export class LoginDialogComponent extends Container { */ showWaiting(message: string): void { this.#contentContainer.addChild(new Spacer(1)); - this.#contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); - this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", message), 0, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 0, 0)); this.#tui.requestRender(); } @@ -174,7 +164,7 @@ export class LoginDialogComponent extends Container { * Called by onProgress callback */ showProgress(message: string): void { - this.#contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", message), 0, 0)); this.#tui.requestRender(); } diff --git a/packages/coding-agent/src/modes/components/logout-account-selector.ts b/packages/coding-agent/src/modes/components/logout-account-selector.ts index 6e3d90272..4d1b69893 100644 --- a/packages/coding-agent/src/modes/components/logout-account-selector.ts +++ b/packages/coding-agent/src/modes/components/logout-account-selector.ts @@ -2,12 +2,12 @@ import { Container, matchesKey, ScrollView, Spacer, TruncatedText } from "@oh-my import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { LogoutAccount } from "../../slash-commands/helpers/logout"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; const LOGOUT_SELECTOR_MAX_VISIBLE = 10; /** Account picker for `/logout` after the provider has been selected. */ -export class LogoutAccountSelectorComponent extends Container { +export class LogoutAccountSelectorComponent extends OverlayPanel { #listContainer: Container; #accounts: LogoutAccount[]; #selectedIndex = 0; @@ -21,21 +21,15 @@ export class LogoutAccountSelectorComponent extends Container { onSelect: (account: LogoutAccount) => void, onCancel: () => void, ) { - super(); + super(`Select ${providerName} account to log out`); this.#accounts = accounts; this.#onSelectCallback = onSelect; this.#onCancelCallback = onCancel; const activeIndex = accounts.findIndex(account => account.active); this.#selectedIndex = activeIndex >= 0 ? activeIndex : 0; - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - this.addChild(new TruncatedText(theme.bold(`Select ${providerName} account to log out:`))); - this.addChild(new Spacer(1)); this.#listContainer = new Container(); this.addChild(this.#listContainer); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); this.#updateList(); } @@ -75,16 +69,16 @@ export class LogoutAccountSelectorComponent extends Container { } if (total === 0) { - this.#listContainer.addChild(new TruncatedText(theme.fg("muted", " No stored accounts to log out"), 0, 0)); + this.#listContainer.addChild(new TruncatedText(theme.fg("muted", "No stored accounts to log out"), 0, 0)); } this.#listContainer.addChild( - new TruncatedText(theme.fg("muted", " ↑/↓ select · ↵ log out account · Esc cancel"), 0, 0), + new TruncatedText(theme.fg("muted", "↑/↓ select · ↵ log out account · Esc cancel"), 0, 0), ); if (this.#statusMessage) { this.#listContainer.addChild(new Spacer(1)); - this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0)); + this.#listContainer.addChild(new TruncatedText(theme.fg("warning", this.#statusMessage), 0, 0)); } } diff --git a/packages/coding-agent/src/modes/components/mcp-add-wizard.ts b/packages/coding-agent/src/modes/components/mcp-add-wizard.ts index eb20e510c..2ebf18eb4 100644 --- a/packages/coding-agent/src/modes/components/mcp-add-wizard.ts +++ b/packages/coding-agent/src/modes/components/mcp-add-wizard.ts @@ -3,16 +3,7 @@ * * Interactive multi-step wizard for adding MCP servers. */ -import { - Container, - Input, - matchesKey, - replaceTabs, - Spacer, - Text, - TruncatedText, - truncateToWidth, -} from "@oh-my-pi/pi-tui"; +import { Container, Input, matchesKey, replaceTabs, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui"; import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils"; import { validateServerName } from "../../mcp/config-writer"; import { analyzeAuthError, discoverOAuthEndpoints, fetchResourceMetadataScopes } from "../../mcp/oauth-discovery"; @@ -20,7 +11,7 @@ import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdio import { shortenPath } from "../../tools/render-utils"; import { theme } from "../theme/theme"; import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; type TransportType = "stdio" | "http" | "sse"; type AuthMethod = "none" | "oauth" | "manual"; @@ -104,7 +95,7 @@ function sanitize(text: string): string { return truncateToWidth(replaceTabs(text), MAX_DISPLAY_WIDTH); } -export class MCPAddWizard extends Container { +export class MCPAddWizard extends OverlayPanel { #currentStep: WizardStep = "name"; #state: WizardState = { name: "", @@ -168,7 +159,7 @@ export class MCPAddWizard extends Container { onRender?: () => void, initialName?: string, ) { - super(); + super("Add MCP Server"); this.#onCompleteCallback = onComplete; this.#onCancelCallback = onCancel; this.#onOAuthCallback = onOAuth ?? null; @@ -179,12 +170,6 @@ export class MCPAddWizard extends Container { this.#currentStep = "transport"; } - // Add border - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - - // Add title - this.addChild(new TruncatedText(theme.bold("Add MCP Server"))); this.addChild(new Spacer(1)); // Content container for step-specific content @@ -193,9 +178,6 @@ export class MCPAddWizard extends Container { this.addChild(new Spacer(1)); - // Add bottom border - this.addChild(new DynamicBorder()); - // Render first step this.#renderStep(); } diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index 4c8bef7bd..6fecad0f5 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -14,7 +14,7 @@ import { settings } from "../../config/settings"; import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { AuthStorage, CredentialOriginKind } from "../../session/auth-storage"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; const OAUTH_SELECTOR_MAX_VISIBLE = 10; @@ -33,10 +33,10 @@ function getDisabledProviderIds(): ReadonlySet { } /** - * Rendered lines before the provider rows: top border, spacer, title, spacer + * Rendered lines before the provider rows: top border * (must mirror the constructor's addChild order). */ -const LIST_ROW_OFFSET = 4; +const LIST_ROW_OFFSET = 1; /** Compact, human-readable tag for each credential-origin leg. */ const ORIGIN_LABELS: Record = { @@ -50,7 +50,7 @@ const ORIGIN_LABELS: Record = { /** * Component that renders an OAuth provider selector. */ -export class OAuthSelectorComponent extends Container { +export class OAuthSelectorComponent extends OverlayPanel { #listContainer: Container; #allProviders: OAuthProviderInfo[] = []; #filteredProviders: OAuthProviderInfo[] = []; @@ -83,7 +83,7 @@ export class OAuthSelectorComponent extends Container { requestRender?: () => void; }, ) { - super(); + super(mode === "login" ? "Select provider to login" : "Select provider to logout"); this.#mode = mode; this.#authStorage = authStorage; this.#onSelectCallback = onSelect; @@ -92,18 +92,9 @@ export class OAuthSelectorComponent extends Container { this.#requestRenderCallback = options?.requestRender; // Load all OAuth providers this.#loadProviders(); - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - // Add title - const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:"; - this.addChild(new TruncatedText(theme.bold(title))); - this.addChild(new Spacer(1)); // Create list container this.#listContainer = new Container(); this.addChild(this.#listContainer); - this.addChild(new Spacer(1)); - // Add bottom border - this.addChild(new DynamicBorder()); // Initial render this.#updateList(); this.#startValidation(); @@ -122,8 +113,8 @@ export class OAuthSelectorComponent extends Container { * (clipped by the host) before dropping below three visible rows. */ setMaxHeight(lines: number): void { - // Above the rows: LIST_ROW_OFFSET; below: search status + spacer + border. - const strict = lines - LIST_ROW_OFFSET - 3; + // Above the rows: LIST_ROW_OFFSET; below: search status + border. + const strict = lines - LIST_ROW_OFFSET - 2; // Keeps only the rows + search status inside `lines`. const relaxed = lines - LIST_ROW_OFFSET - 1; const rows = Math.min(OAUTH_SELECTOR_MAX_VISIBLE, Math.max(1, strict, Math.min(relaxed, 3))); @@ -257,7 +248,7 @@ export class OAuthSelectorComponent extends Container { #renderStatusLine(_total: number): string { const query = this.#searchQuery.trim(); const suffix = query ? `Search: ${this.#searchQuery}` : "Type to search"; - return theme.fg("muted", ` ${suffix}`); + return theme.fg("muted", suffix); } #getProviderSearchText(provider: OAuthProviderInfo): string { @@ -361,11 +352,11 @@ export class OAuthSelectorComponent extends Container { ? "No OAuth providers available" : "No stored provider credentials to log out" : "No matching providers"; - this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0)); + this.#listContainer.addChild(new TruncatedText(theme.fg("muted", message), 0, 0)); } if (this.#statusMessage) { this.#listContainer.addChild(new Spacer(1)); - this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0)); + this.#listContainer.addChild(new TruncatedText(theme.fg("warning", this.#statusMessage), 0, 0)); } } handleInput(keyData: string): void { diff --git a/packages/coding-agent/src/modes/components/omfg-panel.ts b/packages/coding-agent/src/modes/components/omfg-panel.ts index a29482df5..40f657bcf 100644 --- a/packages/coding-agent/src/modes/components/omfg-panel.ts +++ b/packages/coding-agent/src/modes/components/omfg-panel.ts @@ -1,7 +1,7 @@ -import { type Component, Container, Markdown, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { type Component, Markdown, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { replaceTabs } from "../../tools/render-utils"; import { getMarkdownTheme, theme } from "../theme/theme"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; export type OmfgPanelState = | "generating" @@ -18,8 +18,7 @@ interface OmfgPanelComponentOptions { tui: TUI; } -export class OmfgPanelComponent extends Container { - #complaint: string; +export class OmfgPanelComponent extends OverlayPanel { #tui: TUI; #state: OmfgPanelState = "generating"; #status = "Generating TTSR rule…"; @@ -29,8 +28,7 @@ export class OmfgPanelComponent extends Container { #closed = false; constructor(options: OmfgPanelComponentOptions) { - super(); - this.#complaint = options.complaint; + super(`/omfg ${replaceTabs(options.complaint)}`); this.#tui = options.tui; this.#rebuild(); } @@ -94,16 +92,12 @@ export class OmfgPanelComponent extends Container { #rebuild(): void { this.clear(); - this.addChild(new DynamicBorder(str => theme.fg("dim", str))); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("accent", replaceTabs(`/omfg ${this.#complaint}`)), 1, 0)); - this.addChild(new Text(theme.fg("muted", replaceTabs(this.#status)), 1, 0)); + this.addChild(new Text(theme.fg("muted", replaceTabs(this.#status)), 0, 0)); this.addChild(new Spacer(1)); this.addChild(this.#contentComponent()); this.addChild(new Spacer(1)); - this.addChild(new Text(this.#footerLine(), 1, 0)); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder(str => theme.fg("dim", str))); + this.addChild(new Text(this.#footerLine(), 0, 0)); this.#tui.requestRender(); } @@ -130,12 +124,12 @@ export class OmfgPanelComponent extends Container { #contentComponent(): Component { if (this.#state === "error") { - return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 1, 0); + return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 0, 0); } const text = replaceTabs(this.#preview).trim(); if (!text) { - return new Text(theme.fg("dim", `${theme.status.pending} Waiting for candidate rule…`), 1, 0); + return new Text(theme.fg("dim", `${theme.status.pending} Waiting for candidate rule…`), 0, 0); } - return new Markdown(text, 1, 0, getMarkdownTheme()); + return new Markdown(text, 0, 0, getMarkdownTheme()); } } diff --git a/packages/coding-agent/src/modes/components/overlay-box.ts b/packages/coding-agent/src/modes/components/overlay-box.ts index 57bac05b4..ad1b6f9a8 100644 --- a/packages/coding-agent/src/modes/components/overlay-box.ts +++ b/packages/coding-agent/src/modes/components/overlay-box.ts @@ -1,10 +1,12 @@ /** - * Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the - * plan-review overlay, …). Every helper paints with `theme.boxRound` glyphs + * Shared box-drawing chrome for overlays — string helpers for fullscreen + * surfaces (the `/copy` picker, the plan-review overlay, …) and the + * {@link OverlayPanel} container for inline overlays hosted in the editor slot + * or an anchored container. Everything 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 { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { theme } from "../theme/theme"; /** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */ @@ -107,3 +109,130 @@ export function splitRow(sidebar: string, body: string, width: number, sidebarWi const bar = paint(box.vertical); return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`; } + +/** Sentinel child rendered by {@link OverlayPanel} as a `├───┤` section rule. */ +export class PanelDivider implements Component { + render(): readonly string[] { + return []; + } +} + +const NO_LINES: readonly string[] = []; + +interface OverlayPanelMemo { + width: number; + title: string; + children: Component[]; + childLines: (readonly string[])[]; + result: string[]; +} + +/** Titles inset into a single border row must never carry line breaks. */ +function collapseTitle(title: string): string { + return title.replace(/\s+/g, " ").trim(); +} + +/** + * Rounded-box container for inline overlays (selectors, run panels). Children + * render inside `│ … │` rows between a titled top border and a bottom border, + * so inline overlays share the chrome of fullscreen overlays. The top border + * is exactly one row — `routeMouse` offsets written for a one-line top rule + * stay valid — and content is inset two columns on each side. + */ +export class OverlayPanel implements Component { + children: Component[] = []; + #title: string; + #memo: OverlayPanelMemo | undefined; + + constructor(title = "") { + this.#title = collapseTitle(title); + } + + get title(): string { + return this.#title; + } + + set title(value: string) { + const next = collapseTitle(value); + if (next === this.#title) return; + this.#title = next; + this.#memo = undefined; + } + + addChild(component: Component): void { + this.children.push(component); + this.#memo = undefined; + } + + removeChild(component: Component): void { + const index = this.children.indexOf(component); + if (index === -1) return; + this.children.splice(index, 1); + this.#memo = undefined; + } + + clear(): void { + this.children = []; + this.#memo = undefined; + } + + invalidate(): void { + this.#memo = undefined; + for (const child of this.children) child.invalidate?.(); + } + + dispose(): void { + for (const child of this.children) child.dispose?.(); + } + + setIgnoreTight(ignore: boolean): this { + for (const child of this.children) child.setIgnoreTight?.(ignore); + return this; + } + + /** + * Body rows at the given content width, without border chrome — + * {@link render} draws exactly these (4 columns narrower) inside `│ … │` + * rows. Lets callers and tests assert on component-content coordinates + * instead of reverse-parsing box glyphs. `PanelDivider` children contribute + * no rows here (their rule is border chrome). + */ + renderContent(width: number): string[] { + const result: string[] = []; + for (const child of this.children) { + if (child instanceof PanelDivider) continue; + result.push(...child.render(width)); + } + return result; + } + + render(width: number): readonly string[] { + const innerWidth = Math.max(1, width - 4); + // Children render every frame (renders may carry side effects); the memo + // only skips re-wrapping unchanged rows in border chrome. + const childLines = this.children.map(child => + child instanceof PanelDivider ? NO_LINES : child.render(innerWidth), + ); + const memo = this.#memo; + if ( + memo !== undefined && + memo.width === width && + memo.title === this.#title && + memo.children.length === this.children.length && + this.children.every((child, i) => memo.children[i] === child && memo.childLines[i] === childLines[i]) + ) { + return memo.result; + } + const result: string[] = [topBorder(width, this.#title)]; + for (let i = 0; i < this.children.length; i++) { + if (this.children[i] instanceof PanelDivider) { + result.push(divider(width)); + continue; + } + for (const line of childLines[i] ?? NO_LINES) result.push(row(line, width)); + } + result.push(bottomBorder(width)); + this.#memo = { width, title: this.#title, children: [...this.children], childLines, result }; + return result; + } +} diff --git a/packages/coding-agent/src/modes/components/plugin-selector.ts b/packages/coding-agent/src/modes/components/plugin-selector.ts index 36da35277..03e245eb0 100644 --- a/packages/coding-agent/src/modes/components/plugin-selector.ts +++ b/packages/coding-agent/src/modes/components/plugin-selector.ts @@ -4,9 +4,9 @@ * Shows available plugins from all configured marketplaces in a SelectList. * Selecting a plugin triggers installation. Esc cancels. */ -import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; +import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import { getSelectListTheme } from "../theme/theme"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing"; export interface PluginSelectorCallbacks { @@ -21,7 +21,7 @@ export interface PluginItem { scope?: "user" | "project"; } -export class PluginSelectorComponent extends Container { +export class PluginSelectorComponent extends OverlayPanel { #selectList: SelectList; constructor( @@ -30,7 +30,7 @@ export class PluginSelectorComponent extends Container { installedIds: Set, callbacks: PluginSelectorCallbacks, ) { - super(); + super("Plugins"); const items: SelectItem[] = plugins.map(({ plugin, marketplace, scope }) => { // Encode scope into the value so onSelect can recover it without a parallel Map. @@ -60,8 +60,6 @@ export class PluginSelectorComponent extends Container { }); } - this.addChild(new DynamicBorder()); - this.#selectList = new SelectList(items, Math.min(items.length, 20), getSelectListTheme()); this.#selectList.onSelect = item => { @@ -77,7 +75,6 @@ export class PluginSelectorComponent extends Container { }; this.addChild(this.#selectList); - this.addChild(new DynamicBorder()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/modes/components/plugin-settings.ts b/packages/coding-agent/src/modes/components/plugin-settings.ts index 89f60bba9..a63aed15d 100644 --- a/packages/coding-agent/src/modes/components/plugin-settings.ts +++ b/packages/coding-agent/src/modes/components/plugin-settings.ts @@ -9,6 +9,7 @@ * - Config value editor */ import { + type Component, Container, Input, matchesKey, @@ -33,7 +34,7 @@ import { import type { InstalledPlugin, PluginSettingSchema } from "../../extensibility/plugins/types"; import { getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme"; import { shortenPath } from "../../tools/render-utils"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; /** * Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape. @@ -101,29 +102,24 @@ function findEntryByValue(entries: ReadonlyArray, value: string * enable/disable status, scope tag, and shadow indicator. Selecting an entry * fans out to the kind-specific detail callback. */ -export class PluginListComponent extends Container { +export class PluginListComponent extends OverlayPanel { readonly #selectList: SelectList; constructor( private readonly entries: ReadonlyArray, callbacks: PluginListCallbacks, ) { - super(); - - // Title - this.addChild(new DynamicBorder()); - this.addChild(new Text(theme.bold(theme.fg("accent", " Plugins")), 0, 0)); + super("Plugins"); this.addChild(new Spacer(1)); if (entries.length === 0) { - this.addChild(new Text(theme.fg("muted", " No plugins installed"), 0, 0)); + this.addChild(new Text(theme.fg("muted", "No plugins installed"), 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Install npm plugins: omp plugin install "), 0, 0)); + this.addChild(new Text(theme.fg("dim", "Install npm plugins: omp plugin install "), 0, 0)); this.addChild( - new Text(theme.fg("dim", " Install marketplace plugins: omp plugin install @"), 0, 0), + new Text(theme.fg("dim", "Install marketplace plugins: omp plugin install @"), 0, 0), ); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); // Empty list still handles Escape so the user can leave the panel. this.#selectList = new SelectList([], 1, getSelectListTheme()); @@ -152,8 +148,7 @@ export class PluginListComponent extends Container { this.addChild(this.#selectList); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to configure · Esc to go back"), 0, 0)); - this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.fg("dim", "Enter to configure · Esc to go back"), 0, 0)); } #renderItem(entry: PluginListEntry): SelectItem { @@ -220,7 +215,7 @@ export interface PluginDetailCallbacks { * - Feature toggles * - Config settings */ -export class PluginDetailComponent extends Container { +export class PluginDetailComponent extends OverlayPanel { #settingsList!: SettingsList; constructor( @@ -228,7 +223,7 @@ export class PluginDetailComponent extends Container { private readonly manager: PluginManager, private readonly callbacks: PluginDetailCallbacks, ) { - super(); + super(plugin.name); void this.#rebuild(); } @@ -239,11 +234,9 @@ export class PluginDetailComponent extends Container { const plugin = this.plugin; const manifest = plugin.manifest; - // Header - this.addChild(new DynamicBorder()); - this.addChild(new Text(theme.bold(theme.fg("accent", ` ${plugin.name}`)), 0, 0)); + this.title = plugin.name; if (manifest.description) { - this.addChild(new Text(theme.fg("muted", ` ${manifest.description}`), 0, 0)); + this.addChild(new Text(theme.fg("muted", manifest.description), 0, 0)); } this.addChild(new Spacer(1)); @@ -371,8 +364,7 @@ export class PluginDetailComponent extends Container { this.addChild(this.#settingsList); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to edit · Esc to go back"), 0, 0)); - this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.fg("dim", "Enter to edit · Esc to go back"), 0, 0)); } handleInput(data: string): void { @@ -395,25 +387,23 @@ export interface MarketplacePluginDetailCallbacks { * features or settings, so the panel exposes a single enable/disable toggle * plus the read-only metadata from the installed-plugins registry. */ -export class MarketplacePluginDetailComponent extends Container { +export class MarketplacePluginDetailComponent extends OverlayPanel { #settingsList: SettingsList; constructor( private plugin: InstalledPluginSummary, private readonly callbacks: MarketplacePluginDetailCallbacks, ) { - super(); + super(plugin.id); const entry = plugin.entries[0]; const enabled = marketplaceEnabled(plugin); - // Header - this.addChild(new DynamicBorder()); - this.addChild(new Text(theme.bold(theme.fg("accent", ` ${plugin.id}`)), 0, 0)); + this.title = plugin.id; const subtitleParts = [`[${plugin.scope}]`]; if (plugin.shadowedBy) subtitleParts.push(`${theme.status.shadowed} shadowed by ${plugin.shadowedBy}`); - this.addChild(new Text(theme.fg("muted", ` ${subtitleParts.join(" ")}`), 0, 0)); + this.addChild(new Text(theme.fg("muted", subtitleParts.join(" ")), 0, 0)); this.addChild(new Spacer(1)); const items: SettingItem[] = [ @@ -448,24 +438,23 @@ export class MarketplacePluginDetailComponent extends Container { // Read-only metadata. SettingsList rejects items without `values`/`submenu`, // so we render the metadata as plain text rows beneath the toggle. - this.addChild(new Text(theme.fg("dim", ` version ${entry?.version ?? "(unknown)"}`), 0, 0)); - this.addChild(new Text(theme.fg("dim", ` scope ${plugin.scope}`), 0, 0)); + this.addChild(new Text(theme.fg("dim", `version ${entry?.version ?? "(unknown)"}`), 0, 0)); + this.addChild(new Text(theme.fg("dim", `scope ${plugin.scope}`), 0, 0)); this.addChild( new Text( - theme.fg("dim", ` install path ${entry?.installPath ? shortenPath(entry.installPath) : "(unknown)"}`), + theme.fg("dim", `install path ${entry?.installPath ? shortenPath(entry.installPath) : "(unknown)"}`), 0, 0, ), ); - this.addChild(new Text(theme.fg("dim", ` installed at ${entry?.installedAt ?? "(unknown)"}`), 0, 0)); - this.addChild(new Text(theme.fg("dim", ` last updated ${entry?.lastUpdated ?? "(unknown)"}`), 0, 0)); + this.addChild(new Text(theme.fg("dim", `installed at ${entry?.installedAt ?? "(unknown)"}`), 0, 0)); + this.addChild(new Text(theme.fg("dim", `last updated ${entry?.lastUpdated ?? "(unknown)"}`), 0, 0)); if (entry?.gitCommitSha) { - this.addChild(new Text(theme.fg("dim", ` git sha ${entry.gitCommitSha}`), 0, 0)); + this.addChild(new Text(theme.fg("dim", `git sha ${entry.gitCommitSha}`), 0, 0)); } this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to toggle · Esc to go back"), 0, 0)); - this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.fg("dim", "Enter to toggle · Esc to go back"), 0, 0)); } handleInput(data: string): void { @@ -480,7 +469,7 @@ export class MarketplacePluginDetailComponent extends Container { /** * Submenu for enum config values. */ -class ConfigEnumSubmenu extends Container { +class ConfigEnumSubmenu extends OverlayPanel { #selectList: SelectList; constructor( @@ -491,9 +480,7 @@ class ConfigEnumSubmenu extends Container { onSelect: (value: string) => void, onCancel: () => void, ) { - super(); - - this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0)); + super(key); if (description) { this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("muted", description), 0, 0)); @@ -513,7 +500,7 @@ class ConfigEnumSubmenu extends Container { this.addChild(this.#selectList); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to cancel"), 0, 0)); + this.addChild(new Text(theme.fg("dim", "Enter to select · Esc to cancel"), 0, 0)); } handleInput(data: string): void { @@ -524,7 +511,7 @@ class ConfigEnumSubmenu extends Container { /** * Submenu for string/number config values with text input. */ -class ConfigInputSubmenu extends Container { +class ConfigInputSubmenu extends OverlayPanel { #input: Input; constructor( @@ -534,9 +521,7 @@ class ConfigInputSubmenu extends Container { private readonly onSubmit: (value: string) => void, private readonly onCancel: () => void, ) { - super(); - - this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0)); + super(key); if (schema.description) { this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("muted", schema.description), 0, 0)); @@ -571,7 +556,7 @@ class ConfigInputSubmenu extends Container { this.addChild(this.#input); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to save · Esc to cancel"), 0, 0)); + this.addChild(new Text(theme.fg("dim", "Enter to save · Esc to cancel"), 0, 0)); } handleInput(data: string): void { @@ -600,7 +585,7 @@ interface InputHandler { export class PluginSettingsComponent extends Container { #cwd: string; #manager: PluginManager; - #viewComponent: (Container & InputHandler) | null = null; + #viewComponent: (Component & InputHandler) | null = null; // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management #currentView: "list" | "npm-detail" | "marketplace-detail" = "list"; // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management diff --git a/packages/coding-agent/src/modes/components/queue-mode-selector.ts b/packages/coding-agent/src/modes/components/queue-mode-selector.ts index cb7d43cdd..d70d4d23b 100644 --- a/packages/coding-agent/src/modes/components/queue-mode-selector.ts +++ b/packages/coding-agent/src/modes/components/queue-mode-selector.ts @@ -1,12 +1,12 @@ -import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; +import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import { getSelectListTheme } from "../../modes/theme/theme"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing"; /** * Component that renders a queue mode selector with borders */ -export class QueueModeSelectorComponent extends Container { +export class QueueModeSelectorComponent extends OverlayPanel { #selectList: SelectList; constructor( @@ -14,7 +14,7 @@ export class QueueModeSelectorComponent extends Container { onSelect: (mode: "all" | "one-at-a-time") => void, onCancel: () => void, ) { - super(); + super("Queue Mode"); const queueModes: SelectItem[] = [ { @@ -25,9 +25,6 @@ export class QueueModeSelectorComponent extends Container { { value: "all", label: "all", description: "Process all queued messages at once" }, ]; - // Add top border - this.addChild(new DynamicBorder()); - // Create selector this.#selectList = new SelectList(queueModes, 2, getSelectListTheme()); @@ -46,9 +43,6 @@ export class QueueModeSelectorComponent extends Container { }; this.addChild(this.#selectList); - - // Add bottom border - this.addChild(new DynamicBorder()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/modes/components/reset-usage-selector.ts b/packages/coding-agent/src/modes/components/reset-usage-selector.ts index a176bb32b..1012cd495 100644 --- a/packages/coding-agent/src/modes/components/reset-usage-selector.ts +++ b/packages/coding-agent/src/modes/components/reset-usage-selector.ts @@ -2,7 +2,7 @@ import { Container, matchesKey, ScrollView, Spacer, TruncatedText } from "@oh-my import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { ResetUsageAccount } from "../../slash-commands/helpers/reset-usage"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; const RESET_SELECTOR_MAX_VISIBLE = 10; @@ -11,7 +11,7 @@ const RESET_SELECTOR_MAX_VISIBLE = 10; * rate-limit reset counts; selecting one redeems a reset. Because a reset is a * scarce, irreversible credit, Enter requires a second press to confirm. */ -export class ResetUsageSelectorComponent extends Container { +export class ResetUsageSelectorComponent extends OverlayPanel { #listContainer: Container; #accounts: ResetUsageAccount[]; #selectedIndex = 0; @@ -21,21 +21,15 @@ export class ResetUsageSelectorComponent extends Container { #onCancelCallback: () => void; constructor(accounts: ResetUsageAccount[], onSelect: (account: ResetUsageAccount) => void, onCancel: () => void) { - super(); + super("Spend a saved rate-limit reset"); this.#accounts = accounts; this.#onSelectCallback = onSelect; this.#onCancelCallback = onCancel; const firstRedeemable = accounts.findIndex(account => account.availableCount > 0); this.#selectedIndex = firstRedeemable >= 0 ? firstRedeemable : 0; - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - this.addChild(new TruncatedText(theme.bold("Spend a saved rate-limit reset:"))); - this.addChild(new Spacer(1)); this.#listContainer = new Container(); this.addChild(this.#listContainer); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); this.#updateList(); } @@ -87,19 +81,19 @@ export class ResetUsageSelectorComponent extends Container { if (total === 0) { this.#listContainer.addChild( - new TruncatedText(theme.fg("muted", " No Codex accounts with saved resets"), 0, 0), + new TruncatedText(theme.fg("muted", "No Codex accounts with saved resets"), 0, 0), ); } const pending = this.#pendingIndex !== null ? this.#accounts[this.#pendingIndex] : undefined; const hint = pending - ? theme.fg("warning", ` Press Enter again to spend 1 reset for ${pending.label}, Esc to cancel`) - : theme.fg("muted", " ↑/↓ select · ↵ spend a reset · Esc cancel"); + ? theme.fg("warning", `Press Enter again to spend 1 reset for ${pending.label}, Esc to cancel`) + : theme.fg("muted", "↑/↓ select · ↵ spend a reset · Esc cancel"); this.#listContainer.addChild(new TruncatedText(hint, 0, 0)); if (this.#statusMessage) { this.#listContainer.addChild(new Spacer(1)); - this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0)); + this.#listContainer.addChild(new TruncatedText(theme.fg("warning", this.#statusMessage), 0, 0)); } } diff --git a/packages/coding-agent/src/modes/components/session-account-selector.ts b/packages/coding-agent/src/modes/components/session-account-selector.ts index 67deebc27..4b854639f 100644 --- a/packages/coding-agent/src/modes/components/session-account-selector.ts +++ b/packages/coding-agent/src/modes/components/session-account-selector.ts @@ -1,13 +1,13 @@ -import { Container, type SelectItem, SelectList, type SgrMouseEvent, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; -import { getSelectListTheme, theme } from "../../modes/theme/theme"; +import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; +import { getSelectListTheme } from "../../modes/theme/theme"; import type { SessionPinAccount } from "../../slash-commands/helpers/session-pin"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; +import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing"; const ACCOUNT_SELECTOR_MAX_VISIBLE = 10; -const ACCOUNT_LIST_ROW_OFFSET = 4; /** Account picker opened by `/session pin` for the current model provider. */ -export class SessionAccountSelectorComponent extends Container { +export class SessionAccountSelectorComponent extends OverlayPanel { #selectList: SelectList; constructor( @@ -16,7 +16,7 @@ export class SessionAccountSelectorComponent extends Container { onSelect: (account: SessionPinAccount) => void, onCancel: () => void, ) { - super(); + super(`Select a ${providerName} account for this session`); const accountsByValue = new Map(); const items: SelectItem[] = accounts.map(account => { const value = String(account.credentialId); @@ -28,11 +28,6 @@ export class SessionAccountSelectorComponent extends Container { }; }); - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - this.addChild(new TruncatedText(theme.bold(`Select a ${providerName} account for this session:`))); - this.addChild(new Spacer(1)); - this.#selectList = new SelectList( items, Math.min(Math.max(items.length, 1), ACCOUNT_SELECTOR_MAX_VISIBLE), @@ -46,8 +41,6 @@ export class SessionAccountSelectorComponent extends Container { }; this.#selectList.onCancel = onCancel; this.addChild(this.#selectList); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); } /** Forward keyboard navigation and cancellation when the wrapper owns focus. */ @@ -57,6 +50,6 @@ export class SessionAccountSelectorComponent extends Container { /** Route mouse selection through the title rows into the account list. */ routeMouse(event: SgrMouseEvent, line: number, col: number): void { - this.#selectList.routeMouse(event, line - ACCOUNT_LIST_ROW_OFFSET, col); + routeSelectListMouseWithTopBorder(this.#selectList, event, line, col); } } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index ccad9309c..15f4d81de 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -18,8 +18,8 @@ import { theme } from "../../modes/theme/theme"; import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { SessionInfo, SessionStatus } from "../../session/session-listing"; import { shortenPath } from "../../tools/render-utils"; -import { DynamicBorder } from "./dynamic-border"; import { HookSelectorComponent } from "./hook-selector"; +import { bottomBorder, OverlayPanel, row, topBorder } from "./overlay-box"; /** * Themed glyph + colored label for a session's lifecycle status, or `undefined` @@ -333,14 +333,15 @@ class SessionList implements Component { * current viewport instead of pushing its header/search off the top. * * Budget = rows − chrome − reserve, divided by the worst-case per-session - * height. Chrome (12) is the surrounding spacers/borders/header (7) plus the - * list's search line, blank, scroll indicator, blank, and hint (5). A titled - * session is the tallest item at 4 lines (title + preview + metadata + - * blank); budgeting for that guarantees no overflow even when every visible - * entry has a title. The reserve covers below-editor hook widgets / cursor. + * height. Chrome (11) is the rounded panel and surrounding spacers/footer + * (6) plus the list's search line, blank, scroll indicator, blank, and hint + * (5). A titled session is the tallest item at 4 lines (title + preview + + * metadata + blank); budgeting for that guarantees no overflow even when + * every visible entry has a title. The reserve covers below-editor hook + * widgets / cursor. */ #visibleCount(): number { - const CHROME = 12; + const CHROME = 11; const PER_SESSION = 4; const RESERVE = 1; const budget = this.#getTerminalRows() - CHROME - RESERVE; @@ -528,12 +529,11 @@ class SessionList implements Component { if (this.#filteredSessions.length === 0) { if (this.#showCwd) { - // "All" scope - no sessions anywhere that match filter - lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width)); + lines.push(truncateToWidth(theme.fg("muted", "No sessions found"), width)); } else { // "Current folder" scope - hint to try "all" lines.push( - truncateToWidth(theme.fg("muted", " No sessions in current folder. Press Tab to view all."), width), + truncateToWidth(theme.fg("muted", "No sessions in current folder. Press Tab to view all."), width), ); } return lines; @@ -747,7 +747,7 @@ export interface SessionSelectorOptions { /** * Component that renders a session selector with optional confirmation dialog */ -export class SessionSelectorComponent extends Container { +export class SessionSelectorComponent extends OverlayPanel { #sessionList: SessionList; #confirmationDialog: HookSelectorComponent | null = null; // Hosts whichever of `#sessionList` / `#confirmationDialog` is live this @@ -758,7 +758,6 @@ export class SessionSelectorComponent extends Container { // scrollback, stranding it above the viewport once the dialog closed). #contentSlot: Container; #messageContainer: Container; - #headerText: Text; #onDelete?: (session: SessionInfo) => Promise; #onRequestRender?: () => void; readonly #loadAllSessions?: () => Promise; @@ -778,7 +777,6 @@ export class SessionSelectorComponent extends Container { #footerStart = 0; readonly #getTerminalRows: () => number; readonly #fillHeight: boolean; - readonly #bottomBorder = new DynamicBorder(); readonly #title: string; readonly #scopeLabel: string | false | undefined; @@ -789,7 +787,7 @@ export class SessionSelectorComponent extends Container { onExit: () => void, options: SessionSelectorOptions = {}, ) { - super(); + super(options.title ?? "Resume Session"); this.#messageContainer = new Container(); this.#onDelete = options.onDelete; @@ -800,12 +798,11 @@ export class SessionSelectorComponent extends Container { this.#fillHeight = options.fillHeight ?? false; this.#title = options.title ?? "Resume Session"; this.#scopeLabel = options.scopeLabel; - // Add header + this.title = this.#headerLabel(); + // Keep the existing breathing room; OverlayPanel supplies the two outer + // border rows and the horizontal inset. this.addChild(new Spacer(1)); - this.#headerText = new Text(this.#headerLabel(), 1, 0); - this.addChild(this.#headerText); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); this.addChild(this.#messageContainer); // Create session list in folder scope; the empty-state hint invites the @@ -846,9 +843,9 @@ export class SessionSelectorComponent extends Container { } #headerLabel(): string { - if (this.#scopeLabel === false) return theme.bold(this.#title); + if (this.#scopeLabel === false) return this.#title; const scopeLabel = this.#scopeLabel ?? (this.#scope === "all" ? "all projects" : "current folder"); - return `${theme.bold(this.#title)} ${theme.fg("muted", `(${scopeLabel})`)}`; + return `${this.#title} (${scopeLabel})`; } /** @@ -864,7 +861,7 @@ export class SessionSelectorComponent extends Container { if (!this.#loadAllSessions) return; this.#toggling = true; this.#messageContainer.clear(); - this.#messageContainer.addChild(new Text(theme.fg("muted", " Loading all projects…"), 1, 0)); + this.#messageContainer.addChild(new Text(theme.fg("muted", "Loading all projects…"), 0, 0)); this.#onRequestRender?.(); try { global = await this.#loadAllSessions(); @@ -884,7 +881,7 @@ export class SessionSelectorComponent extends Container { this.#scope = "folder"; this.#sessionList.setSessions(this.#folderSessions, false); } - this.#headerText.setText(this.#headerLabel()); + this.title = this.#headerLabel(); this.#onRequestRender?.(); } @@ -916,7 +913,7 @@ export class SessionSelectorComponent extends Container { #showError(message: string): void { this.#messageContainer.clear(); - this.#messageContainer.addChild(new Text(theme.fg("error", `Error: ${replaceTabs(message)}`), 1, 0)); + this.#messageContainer.addChild(new Text(theme.fg("error", `Error: ${replaceTabs(message)}`), 0, 0)); this.#messageContainer.addChild(new Spacer(1)); } @@ -961,28 +958,23 @@ export class SessionSelectorComponent extends Container { } /** - * Concatenate the children's renders (like {@link Container}) while recording - * the line where the session list begins, so the fullscreen picker can hit- - * test mouse rows against the live list window. SessionList rebuilds its lines - * every frame, so Container's reference-memoization never applied here. - * - * In fill-height mode the body is padded (or, on a cramped terminal, trimmed) - * to leave exactly enough room for the footer at the screen bottom, so the - * footer is always visible and never drifts as the list window resizes. The - * in-editor selector just appends the footer directly. + * Render the panel directly so fill-height mode can keep its footer pinned + * while sharing OverlayPanel's exact rounded-box chrome. Children receive + * the panel's inner width before their rows are wrapped. */ override render(width: number): readonly string[] { - const lines: string[] = []; + const innerWidth = Math.max(1, width - 4); + const lines: string[] = [topBorder(width, this.title)]; for (const child of this.children) { - const childLines = child.render(width); + const childLines = child.render(innerWidth); if (child === this.#contentSlot) this.#listLineOffset = lines.length; - for (const line of childLines) lines.push(line); + for (const line of childLines) lines.push(row(line, width)); } const footer = this.#footerLines(width); if (this.#fillHeight) { const target = Math.max(0, this.#getTerminalRows() - footer.length); if (lines.length > target) lines.length = target; - else for (let i = lines.length; i < target; i++) lines.push(""); + else for (let i = lines.length; i < target; i++) lines.push(row("", width)); } this.#footerStart = lines.length; for (const line of footer) lines.push(line); @@ -992,8 +984,8 @@ export class SessionSelectorComponent extends Container { /** Blank · keybinding hint · bottom border. Rendered by {@link render}. */ #footerLines(width: number): string[] { const scopeHint = this.#scope === "all" ? "current folder" : "all projects"; - const hint = theme.fg("muted", ` [Del/⌫ delete · Enter select · Tab ${scopeHint} · Esc cancel]`); - return ["", hint, "", ...this.#bottomBorder.render(width)]; + const hint = theme.fg("muted", `[Del/⌫ delete · Enter select · Tab ${scopeHint} · Esc cancel]`); + return [row("", width), row(hint, width), row("", width), bottomBorder(width)]; } handleInput(keyData: string): void { diff --git a/packages/coding-agent/src/modes/components/show-images-selector.ts b/packages/coding-agent/src/modes/components/show-images-selector.ts index 2a1060080..4d43f4560 100644 --- a/packages/coding-agent/src/modes/components/show-images-selector.ts +++ b/packages/coding-agent/src/modes/components/show-images-selector.ts @@ -1,25 +1,22 @@ -import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; +import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import { getSelectListTheme } from "../../modes/theme/theme"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing"; /** * Component that renders a show images selector with borders */ -export class ShowImagesSelectorComponent extends Container { +export class ShowImagesSelectorComponent extends OverlayPanel { #selectList: SelectList; constructor(currentValue: boolean, onSelect: (show: boolean) => void, onCancel: () => void) { - super(); + super("Show Images"); const items: SelectItem[] = [ { value: "yes", label: "Yes", description: "Show images inline in terminal" }, { value: "no", label: "No", description: "Show text placeholder instead" }, ]; - // Add top border - this.addChild(new DynamicBorder()); - // Create selector this.#selectList = new SelectList(items, 5, getSelectListTheme()); @@ -35,9 +32,6 @@ export class ShowImagesSelectorComponent extends Container { }; this.addChild(this.#selectList); - - // Add bottom border - this.addChild(new DynamicBorder()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/modes/components/theme-selector.ts b/packages/coding-agent/src/modes/components/theme-selector.ts index 77165e62e..b455980c4 100644 --- a/packages/coding-agent/src/modes/components/theme-selector.ts +++ b/packages/coding-agent/src/modes/components/theme-selector.ts @@ -1,13 +1,13 @@ -import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; +import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import { getSelectListTheme } from "../../modes/theme/theme"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing"; /** * Component that renders a theme selector. * Themes must be pre-loaded and passed to the constructor. */ -export class ThemeSelectorComponent extends Container { +export class ThemeSelectorComponent extends OverlayPanel { #selectList: SelectList; #onPreview: (themeName: string) => void; @@ -18,7 +18,7 @@ export class ThemeSelectorComponent extends Container { onCancel: () => void, onPreview: (themeName: string) => void, ) { - super(); + super("Theme"); this.#onPreview = onPreview; // Create select items from provided themes @@ -28,9 +28,6 @@ export class ThemeSelectorComponent extends Container { description: name === currentTheme ? "(current)" : undefined, })); - // Add top border - this.addChild(new DynamicBorder()); - // Create selector this.#selectList = new SelectList(themeItems, 10, getSelectListTheme()); @@ -53,9 +50,6 @@ export class ThemeSelectorComponent extends Container { }; this.addChild(this.#selectList); - - // Add bottom border - this.addChild(new DynamicBorder()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/modes/components/thinking-selector.ts b/packages/coding-agent/src/modes/components/thinking-selector.ts index fe33959ac..1615e56c0 100644 --- a/packages/coding-agent/src/modes/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/components/thinking-selector.ts @@ -1,14 +1,14 @@ import type { Effort } from "@oh-my-pi/pi-ai"; -import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; +import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import { getSelectListTheme } from "../../modes/theme/theme"; import { getThinkingLevelMetadata } from "../../thinking"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing"; /** * Component that renders a thinking level selector with borders */ -export class ThinkingSelectorComponent extends Container { +export class ThinkingSelectorComponent extends OverlayPanel { #selectList: SelectList; constructor( @@ -17,13 +17,10 @@ export class ThinkingSelectorComponent extends Container { onSelect: (level: Effort) => void, onCancel: () => void, ) { - super(); + super("Thinking Level"); const thinkingLevels: SelectItem[] = availableLevels.map(getThinkingLevelMetadata); - // Add top border - this.addChild(new DynamicBorder()); - // Create selector this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme()); @@ -42,9 +39,6 @@ export class ThinkingSelectorComponent extends Container { }; this.addChild(this.#selectList); - - // Add bottom border - this.addChild(new DynamicBorder()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index 33a8c00cc..0509bfce2 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -7,7 +7,6 @@ import { Input, matchesKey, Spacer, - Text, TruncatedText, truncateToWidth, } from "@oh-my-pi/pi-tui"; @@ -25,7 +24,7 @@ import { toPathList } from "../../tools/path-utils"; import { shortenPath } from "../../tools/render-utils"; import { canonicalizeMessage } from "../../utils/thinking-display"; import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel, PanelDivider } from "./overlay-box"; import { centeredWindow, contentRowWidth, renderScrollableList } from "./selector-helpers"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ @@ -477,25 +476,25 @@ class TreeList implements Component { // `model_change` + `thinking_level_change` (both hidden by the default filter) // read as "broken /tree" — see #1909. if (this.#flatNodes.length === 0) { - lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width)); - lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.#getFilterLabel()}`), width)); + lines.push(truncateToWidth(theme.fg("muted", "No entries found"), width)); + lines.push(truncateToWidth(theme.fg("muted", `(0/0)${this.#getFilterLabel()}`), width)); } else if (this.#searchQuery.length > 0) { - lines.push(truncateToWidth(theme.fg("muted", ` No entries match search "${this.#searchQuery}"`), width)); - lines.push(truncateToWidth(theme.fg("muted", " Press Backspace to clear the search"), width)); + lines.push(truncateToWidth(theme.fg("muted", `No entries match search "${this.#searchQuery}"`), width)); + lines.push(truncateToWidth(theme.fg("muted", "Press Backspace to clear the search"), width)); lines.push( - truncateToWidth(theme.fg("muted", ` (0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width), + truncateToWidth(theme.fg("muted", `(0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width), ); } else { const filterLabel = this.#getFilterLabel().trim() || "[default]"; lines.push( truncateToWidth( - theme.fg("muted", ` ${this.#flatNodes.length} entries hidden by the current filter ${filterLabel}`), + theme.fg("muted", `${this.#flatNodes.length} entries hidden by the current filter ${filterLabel}`), width, ), ); - lines.push(truncateToWidth(theme.fg("muted", " Press Alt+A to show all, Alt+D for default"), width)); + lines.push(truncateToWidth(theme.fg("muted", "Press Alt+A to show all, Alt+D for default"), width)); lines.push( - truncateToWidth(theme.fg("muted", ` (0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width), + truncateToWidth(theme.fg("muted", `(0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width), ); } return lines; @@ -605,7 +604,7 @@ class TreeList implements Component { const filterLabel = this.#getFilterLabel(); if (filterLabel) { - lines.push(truncateToWidth(theme.fg("muted", ` ${filterLabel.trim()}`), width)); + lines.push(truncateToWidth(theme.fg("muted", filterLabel.trim()), width)); } return lines; @@ -930,9 +929,9 @@ class SearchLine implements Component { render(width: number): readonly string[] { const query = this.treeList.getSearchQuery(); if (query) { - return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)]; + return [truncateToWidth(`${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)]; } - return [truncateToWidth(` ${theme.fg("muted", "Search:")}`, width)]; + return [truncateToWidth(theme.fg("muted", "Search:"), width)]; } handleInput(_keyData: string): void {} @@ -958,11 +957,9 @@ class LabelInput implements Component { render(width: number): readonly string[] { const lines: string[] = []; - const indent = " "; - const availableWidth = width - indent.length; - lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width)); - lines.push(...this.#input.render(availableWidth).map(line => truncateToWidth(`${indent}${line}`, width))); - lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width)); + lines.push(truncateToWidth(theme.fg("muted", "Label (empty to remove):"), width)); + lines.push(...this.#input.render(width)); + lines.push(truncateToWidth(theme.fg("dim", "enter: save esc: cancel"), width)); return lines; } @@ -981,7 +978,7 @@ class LabelInput implements Component { /** * Component that renders a session tree selector for navigation */ -export class TreeSelectorComponent extends Container { +export class TreeSelectorComponent extends OverlayPanel { #treeList: TreeList; #labelInput: LabelInput | null = null; #labelInputContainer: Container; @@ -996,8 +993,14 @@ export class TreeSelectorComponent extends Container { private readonly onLabelChangeCallback?: (entryId: string, label: string | undefined) => void, initialFilterMode: FilterMode = "default", ) { - super(); - const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); + super("Session Tree"); + // The outer panel has eight fixed rows around the tree list: top/bottom + // borders, the two spacers, help, search, and section divider. + const PANEL_CHROME_ROWS = 8; + const maxVisibleLines = Math.max( + 1, + Math.min(Math.max(5, Math.floor(terminalHeight / 2)), terminalHeight - PANEL_CHROME_ROWS), + ); this.#treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialFilterMode); this.#treeList.onSelect = onSelect; @@ -1010,8 +1013,6 @@ export class TreeSelectorComponent extends Container { this.#labelInputContainer = new Container(); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); - this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild( new TruncatedText( theme.fg( @@ -1023,12 +1024,11 @@ export class TreeSelectorComponent extends Container { ), ); this.addChild(new SearchLine(this.#treeList)); - this.addChild(new DynamicBorder()); + this.addChild(new PanelDivider()); this.addChild(new Spacer(1)); this.addChild(this.#treeContainer); this.addChild(this.#labelInputContainer); this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); if (tree.length === 0) { setTimeout(() => onCancel(), 100); diff --git a/packages/coding-agent/src/modes/components/user-message-selector.ts b/packages/coding-agent/src/modes/components/user-message-selector.ts index 9de367c6b..0951062c4 100644 --- a/packages/coding-agent/src/modes/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/components/user-message-selector.ts @@ -1,6 +1,5 @@ import { type Component, - Container, extractPrintableText, fuzzyFilter, matchesKey, @@ -11,7 +10,7 @@ import { } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; -import { DynamicBorder } from "./dynamic-border"; +import { OverlayPanel } from "./overlay-box"; interface UserMessageItem { id: string; // Entry ID in the session @@ -190,18 +189,13 @@ class UserMessageList implements Component { /** * Component that renders a user message selector for branching */ -export class UserMessageSelectorComponent extends Container { +export class UserMessageSelectorComponent extends OverlayPanel { #messageList: UserMessageList; constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) { - super(); + super("Branch from Message"); - // Add header - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold("Branch from Message"), 1, 0)); - this.addChild(new Text(theme.fg("muted", "Select a message to create a new branch from that point"), 1, 0)); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.fg("muted", "Select a message to create a new branch from that point"), 0, 0)); this.addChild(new Spacer(1)); // Create message list @@ -211,9 +205,7 @@ export class UserMessageSelectorComponent extends Container { this.addChild(this.#messageList); - // Add bottom border this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); // Auto-cancel if no messages if (messages.length === 0) { diff --git a/packages/coding-agent/src/prompts/tools/yield.md b/packages/coding-agent/src/prompts/tools/yield.md index c09991cc0..3eed16084 100644 --- a/packages/coding-agent/src/prompts/tools/yield.md +++ b/packages/coding-agent/src/prompts/tools/yield.md @@ -5,4 +5,4 @@ Omit `type` for the usual single terminal structured result. Pass `type: ["secti This task declares an output schema: the terminal `result.data` MUST be the full object matching it. A data-less `type: "result"` finalizes previously submitted incremental sections; it is invalid when no sections were submitted — prose in your last turn can never satisfy the schema. {{else}} Pass `type: "result"` to finalize; when `data` is omitted, your last assistant turn becomes the raw final result. -{{/if}} \ No newline at end of file +{{/if}} diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 41dd943d2..68facfb07 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -205,9 +205,9 @@ interface CustomInputContext { const MAX_CUSTOM_INPUT_OPTION_ROWS = 8; const MAX_CUSTOM_INPUT_TITLE_ROWS = 16; const MIN_CUSTOM_INPUT_CONTENT_WIDTH = 20; -/** Subtracted from the terminal width to leave room for the surrounding - * `Text(... padX=1)` padding + DynamicBorder vertical chrome. */ -const CUSTOM_INPUT_CHROME_COLUMNS = 4; +/** Subtracted from the terminal width to leave room for `Text` padding and + * surrounding {@link OverlayPanel} chrome. */ +const CUSTOM_INPUT_CHROME_COLUMNS = 8; const CUSTOM_INPUT_DESCRIPTION_INDENT = " "; function customInputContentWidth(): number { diff --git a/packages/coding-agent/test/hook-editor.test.ts b/packages/coding-agent/test/hook-editor.test.ts index f55c33531..b5f8f5502 100644 --- a/packages/coding-agent/test/hook-editor.test.ts +++ b/packages/coding-agent/test/hook-editor.test.ts @@ -348,7 +348,7 @@ describe("HookEditorComponent prompt-style mode", () => { expect(onCancel).not.toHaveBeenCalled(); }); - it("renders prompt-style editor with legacy ask chrome", () => { + it("renders prompt-style editor with rounded overlay chrome", () => { const component = new HookEditorComponent(createTui(), "Prompt", undefined, vi.fn(), vi.fn(), { promptStyle: true, }); @@ -356,9 +356,9 @@ describe("HookEditorComponent prompt-style mode", () => { const rendered = renderText(component); const lines = renderLines(component); - expect(lines[0]).toMatch(/^─+$/); - expect(lines.at(-1)).toMatch(/^─+$/); - expect(lines[4]?.startsWith("> ")).toBe(true); + expect(lines[0]).toMatch(/^╭─ Prompt .*╮$/); + expect(lines.at(-1)).toMatch(/^╰.*╯$/); + expect(lines.some(line => line.includes("> "))).toBe(true); expect(rendered).toContain("enter or ctrl+q submit esc cancel"); expect(rendered).not.toContain("shift+enter newline"); expect(rendered).toContain("ctrl+g external editor"); @@ -386,8 +386,9 @@ describe("HookEditorComponent prompt-style mode", () => { } const lines = renderLines(component); - expect(lines[4]?.startsWith("> hello")).toBe(true); - expect(lines[4]?.startsWith("hello")).toBe(false); + + expect(lines.some(line => line.includes("> hello"))).toBe(true); + expect(lines.some(line => line.includes("hello") && !line.includes(">"))).toBe(false); }); it("aligns wrapped prompt-style continuation rows under the text column", () => { @@ -396,9 +397,11 @@ describe("HookEditorComponent prompt-style mode", () => { }); const lines = renderLines(component, 12); - expect(lines[4]).toBe("> abcdefghij"); - expect(lines[5]?.startsWith(" klm")).toBe(true); - expect(lines[5]?.startsWith(">")).toBe(false); + + expect(lines.some(line => line.includes("> abcdef"))).toBe(true); + const continuation = lines.find(line => line.includes("ghijkl")); + expect(continuation).toBeDefined(); + expect(continuation).not.toContain(">"); }); it("cancels on Escape", () => { @@ -432,24 +435,20 @@ describe("HookEditorComponent prompt-style mode", () => { expect(onSubmit).not.toHaveBeenCalled(); }); - it("aligns the title and hint with the editor prompt gutter at column zero (#5313)", () => { + it("renders the title in the border, detail lines, hint, and prompt gutter", () => { const title = "◆ Other (type your own)\nEnter your response:"; const component = new HookEditorComponent(createTui(), title, "不太清楚,", vi.fn(), vi.fn(), { promptStyle: true, }); const lines = renderLines(component); - const titleRow = lines.find(line => line.includes("Enter your response:")); - const gutterRow = lines.find(line => line.startsWith("> ")); - const hintRow = lines.find(line => line.includes("esc cancel")); - - expect(titleRow).toBeDefined(); - expect(gutterRow).toBeDefined(); - expect(hintRow).toBeDefined(); - // The borderless prompt-style editor renders `> ` starting at column 0, so - // the surrounding title/hint chrome must not carry a leading indent. - expect(titleRow!.startsWith("Enter your response:")).toBe(true); - expect(hintRow!.startsWith(" ")).toBe(false); + // First title line insets into the top border row. + expect(lines[0]).toContain("Other (type your own)"); + // Remaining title lines, gutter, and hint are body rows. + const content = component.renderContent(80).map(line => Bun.stripANSI(line)); + expect(content.some(line => line.startsWith("Enter your response:"))).toBe(true); + expect(content.some(line => line.startsWith("> "))).toBe(true); + expect(content.some(line => line.includes("esc cancel"))).toBe(true); }); }); diff --git a/packages/coding-agent/test/hook-selector-overflow.test.ts b/packages/coding-agent/test/hook-selector-overflow.test.ts index 0b2e0d9bd..c30dfb792 100644 --- a/packages/coding-agent/test/hook-selector-overflow.test.ts +++ b/packages/coding-agent/test/hook-selector-overflow.test.ts @@ -32,6 +32,19 @@ describe("HookSelectorComponent", () => { } }); + it("renders a multiline title in the border and body without dropping detail", () => { + const component = new HookSelectorComponent( + "Delete session?\nsession-2026-08-20", + ["Delete", "Cancel"], + () => {}, + () => {}, + ); + + const lines = component.render(80).map(line => Bun.stripANSI(line)); + expect(lines[0]).toContain("Delete session?"); + expect(lines.some(line => line.includes("session-2026-08-20"))).toBe(true); + }); + it("wraps outlined option text without omitting the tail", () => { const options = [ "Option A: Move to OMP-native only by migrating reusable shared AI instructions into .omp/AGENTS.md, .omp/rules, .omp/skills, and .omp/agents while deliberately not creating a root .github directory.", diff --git a/packages/coding-agent/test/modes/components/login-dialog.test.ts b/packages/coding-agent/test/modes/components/login-dialog.test.ts index c3bcc32d0..9efca9261 100644 --- a/packages/coding-agent/test/modes/components/login-dialog.test.ts +++ b/packages/coding-agent/test/modes/components/login-dialog.test.ts @@ -32,7 +32,7 @@ describe("LoginDialogComponent", () => { dialog.showAuth(authorizationUrl); const linkTarget = `${authorizationUrl}\x07`; const urlRows = dialog - .render(40) + .renderContent(40) .filter(line => line.includes(linkTarget) && !Bun.stripANSI(line).includes("click to open")); expect(urlRows.length).toBeGreaterThan(1); diff --git a/packages/coding-agent/test/modes/components/tree-selector-chain-gutter-2298.test.ts b/packages/coding-agent/test/modes/components/tree-selector-chain-gutter-2298.test.ts index 79d0abcae..7c91697f1 100644 --- a/packages/coding-agent/test/modes/components/tree-selector-chain-gutter-2298.test.ts +++ b/packages/coding-agent/test/modes/components/tree-selector-chain-gutter-2298.test.ts @@ -34,7 +34,7 @@ function renderStripped(tree: SessionTreeNode[], leafId: string, width = 120): s () => {}, () => {}, ); - return selector.render(width).map(line => Bun.stripANSI(line)); + return selector.renderContent(width).map(line => Bun.stripANSI(line)); } describe("issue #7332: linear branch continuations stay compact", () => { diff --git a/packages/coding-agent/test/modes/components/tree-selector-last-branch-gutter-2325.test.ts b/packages/coding-agent/test/modes/components/tree-selector-last-branch-gutter-2325.test.ts index b2fac9820..bcc14722d 100644 --- a/packages/coding-agent/test/modes/components/tree-selector-last-branch-gutter-2325.test.ts +++ b/packages/coding-agent/test/modes/components/tree-selector-last-branch-gutter-2325.test.ts @@ -44,7 +44,7 @@ function renderStripped(tree: SessionTreeNode[], leafId: string, width = 120): s () => {}, () => {}, ); - return selector.render(width).map(line => Bun.stripANSI(line)); + return selector.renderContent(width).map(line => Bun.stripANSI(line)); } // A terminal branch whose linear chain branches again must keep every row at diff --git a/packages/coding-agent/test/modes/components/tree-selector-overflow.test.ts b/packages/coding-agent/test/modes/components/tree-selector-overflow.test.ts index 9b5e1d0f4..feffa6f41 100644 --- a/packages/coding-agent/test/modes/components/tree-selector-overflow.test.ts +++ b/packages/coding-agent/test/modes/components/tree-selector-overflow.test.ts @@ -39,7 +39,7 @@ function renderSelector(tree: SessionTreeNode, leafId: string, width: number): s () => {}, () => {}, ); - return selector.render(width).map(line => Bun.stripANSI(line)); + return selector.renderContent(width).map(line => Bun.stripANSI(line)); } describe("TreeSelectorComponent deep branching overflow", () => {