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.
This commit is contained in:
can1357
2026-08-20 04:44:39 +02:00
parent 072f1a8034
commit 2d56a37334
32 changed files with 395 additions and 409 deletions
+1 -1
View File
@@ -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.
@@ -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());
}
}
@@ -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();
}
@@ -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();
}
@@ -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. */
@@ -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 {
@@ -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 {
@@ -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<string> {
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();
}
@@ -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));
}
}
@@ -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();
}
@@ -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<string> {
}
/**
* 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<CredentialOriginKind, string> = {
@@ -50,7 +50,7 @@ const ORIGIN_LABELS: Record<CredentialOriginKind, string> = {
/**
* 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 {
@@ -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());
}
}
@@ -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;
}
}
@@ -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<string>,
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 {
@@ -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<PluginListEntry>, 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<PluginListEntry>,
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 <package>"), 0, 0));
this.addChild(new Text(theme.fg("dim", "Install npm plugins: omp plugin install <package>"), 0, 0));
this.addChild(
new Text(theme.fg("dim", " Install marketplace plugins: omp plugin install <name>@<marketplace>"), 0, 0),
new Text(theme.fg("dim", "Install marketplace plugins: omp plugin install <name>@<marketplace>"), 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
@@ -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 {
@@ -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));
}
}
@@ -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<string, SessionPinAccount>();
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);
}
}
@@ -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<boolean>;
#onRequestRender?: () => void;
readonly #loadAllSessions?: () => Promise<SessionInfo[]>;
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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);
@@ -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) {
+3 -3
View File
@@ -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 {
+20 -21
View File
@@ -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);
});
});
@@ -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.",
@@ -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);
@@ -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", () => {
@@ -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
@@ -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", () => {