Files
oh-my-pi/packages/coding-agent/src/modes/components/hook-selector.ts
T
can1357 cac628ccce feat(coding-agent): added git isolation and keybinding utilities for autoresearch
- Added git branch isolation for autoresearch sessions with automatic branch creation, reuse, and worktree safety checks.
- Added scope definition sections (Files in Scope, Off Limits, Constraints) to autoresearch template for explicit session boundaries.
- Added keybinding matcher utilities for consistent escape/cancel key handling across interactive components.
- Added ASI metadata validation (hypothesis and rollback context) in log_experiment tool for experiment tracking.
- Refactored keybinding logic across 13 components to use centralized matcher functions instead of inline key checks.
2026-03-22 21:47:48 +01:00

186 lines
5.6 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 { 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;
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;
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.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 (matchesSelectCancel(keyData)) {
this.#onCancelCallback();
}
}
dispose(): void {
this.#countdown?.dispose();
}
}