6bb10ca40e
- Added external editor callback support to extension UI dialogs and hook selector for opening plans in external editor. - Added `matchesAppExternalEditor()` keybinding matcher to detect external editor shortcuts (ctrl+g) across components. - Added `trimTrailingNewline` option to `openInEditor()` function to preserve trailing newlines when editing externally. - Modified plan review flow to re-read plan content from disk before approval to reflect external editor changes. - Refactored plan preview rendering to cache and reuse UI container instead of recreating children on each render. - Refactored Promise creation in external editor utility to use `Promise.withResolvers()` for cleaner async handling.
191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
/**
|
|
* Generic selector component for hooks.
|
|
* Displays a list of string options with keyboard navigation.
|
|
*/
|
|
import {
|
|
Container,
|
|
Markdown,
|
|
matchesKey,
|
|
padding,
|
|
renderInlineMarkdown,
|
|
replaceTabs,
|
|
Spacer,
|
|
Text,
|
|
type TUI,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
import { matchesAppExternalEditor, matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
|
import { CountdownTimer } from "./countdown-timer";
|
|
import { DynamicBorder } from "./dynamic-border";
|
|
|
|
export interface HookSelectorOptions {
|
|
tui?: TUI;
|
|
timeout?: number;
|
|
onTimeout?: () => void;
|
|
initialIndex?: number;
|
|
outline?: boolean;
|
|
maxVisible?: number;
|
|
onLeft?: () => void;
|
|
onRight?: () => void;
|
|
onExternalEditor?: () => void;
|
|
helpText?: string;
|
|
}
|
|
|
|
class OutlinedList extends Container {
|
|
#lines: string[] = [];
|
|
|
|
setLines(lines: string[]): void {
|
|
this.#lines = lines;
|
|
this.invalidate();
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
const borderColor = (text: string) => theme.fg("border", text);
|
|
const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
|
|
const innerWidth = Math.max(1, width - 2);
|
|
const content = this.#lines.map(line => {
|
|
const normalized = replaceTabs(line);
|
|
const fitted = truncateToWidth(normalized, innerWidth);
|
|
const pad = Math.max(0, innerWidth - visibleWidth(fitted));
|
|
return `${borderColor(theme.boxSharp.vertical)}${fitted}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`;
|
|
});
|
|
return [horizontal, ...content, horizontal];
|
|
}
|
|
}
|
|
|
|
export class HookSelectorComponent extends Container {
|
|
#options: string[];
|
|
#selectedIndex: number;
|
|
#maxVisible: number;
|
|
#listContainer: Container | undefined;
|
|
#outlinedList: OutlinedList | undefined;
|
|
#onSelectCallback: (option: string) => void;
|
|
#onCancelCallback: () => void;
|
|
#titleComponent: Markdown;
|
|
#baseTitle: string;
|
|
#countdown: CountdownTimer | undefined;
|
|
#onLeftCallback: (() => void) | undefined;
|
|
#onRightCallback: (() => void) | undefined;
|
|
#onExternalEditorCallback: (() => void) | undefined;
|
|
constructor(
|
|
title: string,
|
|
options: string[],
|
|
onSelect: (option: string) => void,
|
|
onCancel: () => void,
|
|
opts?: HookSelectorOptions,
|
|
) {
|
|
super();
|
|
|
|
this.#options = options;
|
|
this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1);
|
|
this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
|
|
this.#onSelectCallback = onSelect;
|
|
this.#onCancelCallback = onCancel;
|
|
this.#baseTitle = title;
|
|
this.#onLeftCallback = opts?.onLeft;
|
|
this.#onRightCallback = opts?.onRight;
|
|
this.#onExternalEditorCallback = opts?.onExternalEditor;
|
|
|
|
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)`),
|
|
() => {
|
|
opts?.onTimeout?.();
|
|
// Auto-select current option on timeout (typically the first/recommended option)
|
|
const selected = this.#options[this.#selectedIndex];
|
|
if (selected) {
|
|
this.#onSelectCallback(selected);
|
|
} else {
|
|
this.#onCancelCallback();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
if (opts?.outline) {
|
|
this.#outlinedList = new OutlinedList();
|
|
this.addChild(this.#outlinedList);
|
|
} else {
|
|
this.#listContainer = new Container();
|
|
this.addChild(this.#listContainer);
|
|
}
|
|
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 Spacer(1));
|
|
this.addChild(new DynamicBorder());
|
|
|
|
this.#updateList();
|
|
}
|
|
|
|
#updateList(): void {
|
|
const lines: string[] = [];
|
|
const startIndex = Math.max(
|
|
0,
|
|
Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#options.length - this.#maxVisible),
|
|
);
|
|
const endIndex = Math.min(startIndex + this.#maxVisible, this.#options.length);
|
|
|
|
const mdTheme = getMarkdownTheme();
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const isSelected = i === this.#selectedIndex;
|
|
const label = isSelected
|
|
? renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("accent", t))
|
|
: renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("text", t));
|
|
const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
|
|
lines.push(prefix + label);
|
|
}
|
|
|
|
if (startIndex > 0 || endIndex < this.#options.length) {
|
|
lines.push(theme.fg("dim", ` (${this.#selectedIndex + 1}/${this.#options.length})`));
|
|
}
|
|
if (this.#outlinedList) {
|
|
this.#outlinedList.setLines(lines);
|
|
return;
|
|
}
|
|
this.#listContainer?.clear();
|
|
for (const line of lines) {
|
|
this.#listContainer?.addChild(new Text(line, 1, 0));
|
|
}
|
|
}
|
|
|
|
handleInput(keyData: string): void {
|
|
// Reset countdown on any interaction
|
|
this.#countdown?.reset();
|
|
|
|
if (matchesKey(keyData, "up") || keyData === "k") {
|
|
this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
|
|
this.#updateList();
|
|
} else if (matchesKey(keyData, "down") || keyData === "j") {
|
|
this.#selectedIndex = Math.min(this.#options.length - 1, this.#selectedIndex + 1);
|
|
this.#updateList();
|
|
} else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
|
const selected = this.#options[this.#selectedIndex];
|
|
if (selected) this.#onSelectCallback(selected);
|
|
} else if (matchesKey(keyData, "left")) {
|
|
this.#onLeftCallback?.();
|
|
} else if (matchesKey(keyData, "right")) {
|
|
this.#onRightCallback?.();
|
|
} else if (this.#onExternalEditorCallback && matchesAppExternalEditor(keyData)) {
|
|
this.#onExternalEditorCallback();
|
|
} else if (matchesSelectCancel(keyData)) {
|
|
this.#onCancelCallback();
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
this.#countdown?.dispose();
|
|
}
|
|
}
|