Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog

## [Unreleased]
### Added

- Added search filtering to the `/review` PR-style base branch selector so large branch lists can be narrowed before selection.

### Fixed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { prompt } from "@oh-my-pi/pi-utils";
import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types";
import type { HookCommandContext } from "../../../../extensibility/hooks/types";
import { SearchableStringSelectorComponent } from "../../../../modes/components/searchable-string-selector";
import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
import * as git from "../../../../utils/git";

Expand Down Expand Up @@ -269,7 +270,15 @@ export class ReviewCommand implements CustomCommand {
return undefined;
}

const baseBranch = await ctx.ui.select("Select base branch to compare against", branches);
const baseBranch = await ctx.ui.custom<string | undefined>(
(_tui, _theme, _keybindings, done) =>
new SearchableStringSelectorComponent(
"Select base branch to compare against",
branches,
branch => done(branch),
() => done(undefined),
),
);
if (!baseBranch) return undefined;

const currentBranch = await getCurrentBranch(this.api);
Expand Down
8 changes: 5 additions & 3 deletions packages/coding-agent/src/extensibility/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { Component, TUI } from "@oh-my-pi/pi-tui";
import type { Rule } from "../../capability/rule";
import type { KeybindingsManager } from "../../config/keybindings";
import type { ModelRegistry } from "../../config/model-registry";
import type { EditToolDetails } from "../../edit";
import type { ExecOptions, ExecResult } from "../../exec/exec";
Expand Down Expand Up @@ -70,22 +71,22 @@ export interface HookUIContext {

/**
* Show a custom component with keyboard focus.
* The factory receives TUI, theme, and a done() callback to close the component.
* The factory receives TUI, theme, keybindings, and a done() callback to close the component.
* Can be async for fire-and-forget work (don't await the work, just start it).
*
* @param factory - Function that creates the component. Call done() when finished.
* @returns Promise that resolves with the value passed to done()
*
* @example
* // Sync factory
* const result = await ctx.ui.custom((tui, theme, done) => {
* const result = await ctx.ui.custom((tui, theme, keybindings, done) => {
* const component = new MyComponent(tui, theme);
* component.onFinish = (value) => done(value);
* return component;
* });
*
* // Async factory with fire-and-forget work
* const result = await ctx.ui.custom(async (tui, theme, done) => {
* const result = await ctx.ui.custom(async (tui, theme, keybindings, done) => {
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done); // Don't await - fire and forget
Expand All @@ -96,6 +97,7 @@ export interface HookUIContext {
factory: (
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/modes/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./model-selector";
export * from "./oauth-selector";
export * from "./queue-mode-selector";
export * from "./read-tool-group";
export * from "./searchable-string-selector";
export * from "./session-selector";
export * from "./settings-selector";
export * from "./show-images-selector";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
Container,
getKeybindings,
Input,
padding,
replaceTabs,
Spacer,
TruncatedText,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { fuzzyFilter } from "../../utils/fuzzy";
import { DynamicBorder } from "./dynamic-border";

export interface SearchableStringSelectorOptions {
maxVisible?: number;
helpText?: string;
}

/**
* Selector for string options with an inline search field.
*/
export class SearchableStringSelectorComponent extends Container {
#searchInput = new Input();
#listContainer = new Container();
#filteredOptions: string[];
#selectedIndex = 0;
#maxVisible: number;
#helpText: string;

constructor(
readonly title: string,
readonly options: string[],
readonly onSelect: (option: string) => void,
readonly onCancel: () => void,
selectorOptions: SearchableStringSelectorOptions = {},
) {
super();
this.#filteredOptions = options;
this.#maxVisible = Math.max(3, selectorOptions.maxVisible ?? 12);
this.#helpText = selectorOptions.helpText ?? "type to search up/down navigate enter select esc cancel";

this.#searchInput.onSubmit = () => {
this.#selectCurrent();
};

this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new TruncatedText(theme.fg("accent", title), 1, 0));
this.addChild(new Spacer(1));
this.addChild(this.#searchInput);
this.addChild(new Spacer(1));
this.addChild(this.#listContainer);
this.addChild(new Spacer(1));
this.addChild(new TruncatedText(theme.fg("dim", this.#helpText), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());

this.#updateList();
}

#selectCurrent(): void {
const selected = this.#filteredOptions[this.#selectedIndex];
if (selected) {
this.onSelect(selected);
}
}

#filterOptions(query: string): void {
this.#filteredOptions = fuzzyFilter(this.options, query, option => option);
this.#selectedIndex = 0;
this.#updateList();
}

#updateList(): void {
this.#listContainer.clear();

if (this.#filteredOptions.length === 0) {
const query = this.#searchInput.getValue().trim();
const message = query ? `No matches for "${replaceTabs(query)}"` : "No options available";
this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 1, 0));
return;
}

const startIndex = Math.max(
0,
Math.min(
this.#selectedIndex - Math.floor(this.#maxVisible / 2),
this.#filteredOptions.length - this.#maxVisible,
),
);
const endIndex = Math.min(startIndex + this.#maxVisible, this.#filteredOptions.length);

for (let i = startIndex; i < endIndex; i++) {
const option = this.#filteredOptions[i];
if (!option) continue;

const isSelected = i === this.#selectedIndex;
const cursorSymbol = `${theme.nav.cursor} `;
const cursorWidth = visibleWidth(cursorSymbol);
const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth);
const label = replaceTabs(option);
const line = cursor + (isSelected ? theme.fg("accent", label) : theme.fg("text", label));
this.#listContainer.addChild(new TruncatedText(line, 1, 0));
}

if (startIndex > 0 || endIndex < this.#filteredOptions.length) {
const countText = ` (${this.#selectedIndex + 1}/${this.#filteredOptions.length})`;
this.#listContainer.addChild(new TruncatedText(theme.fg("muted", countText), 1, 0));
}
}

handleInput(keyData: string): void {
const keybindings = getKeybindings();
const hasOptions = this.#filteredOptions.length > 0;
if (keybindings.matches(keyData, "tui.select.up")) {
if (!hasOptions) return;
this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
this.#updateList();
return;
}

if (keybindings.matches(keyData, "tui.select.down")) {
if (!hasOptions) return;
this.#selectedIndex = Math.min(this.#filteredOptions.length - 1, this.#selectedIndex + 1);
this.#updateList();
return;
}

if (keybindings.matches(keyData, "tui.select.pageUp")) {
if (!hasOptions) return;
this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible);
this.#updateList();
return;
}

if (keybindings.matches(keyData, "tui.select.pageDown")) {
if (!hasOptions) return;
this.#selectedIndex = Math.min(this.#filteredOptions.length - 1, this.#selectedIndex + this.#maxVisible);
this.#updateList();
return;
}

if (keybindings.matches(keyData, "tui.select.confirm") || keyData === "\n") {
this.#selectCurrent();
return;
}

if (keybindings.matches(keyData, "tui.select.cancel")) {
this.onCancel();
return;
}

this.#searchInput.handleInput(keyData);
this.#filterOptions(this.#searchInput.getValue());
}
}
Loading
Loading