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:
@@ -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));
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+1
-1
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user