Improve ask option rendering

This commit is contained in:
rimless-casualty
2026-06-01 17:37:26 +08:00
parent 8b619a2f40
commit 3c7d50d292
12 changed files with 431 additions and 73 deletions
+8
View File
@@ -2,6 +2,14 @@
## [Unreleased]
### Added
- Added `ask` option descriptions so agents can keep short labels and render explanatory text as separate muted rows in the selector.
### Fixed
- Fixed long outlined `ask` selector options wrapping instead of truncating their tails.
## [15.7.4] - 2026-05-31
### Removed
@@ -90,6 +90,17 @@ export type { AgentToolResult, AgentToolUpdateCallback };
// UI Context
// ============================================================================
export interface ExtensionUISelectOption {
label: string;
description?: string;
}
export type ExtensionUISelectItem = string | ExtensionUISelectOption;
export function getExtensionUISelectOptionLabel(option: ExtensionUISelectItem): string {
return typeof option === "string" ? option : option.label;
}
/**
* UI dialog options for extensions.
*/
@@ -135,8 +146,12 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un
// and may be invoked from event handlers that have already taken the agent
// loop's lock — hooks intentionally cannot.
export interface ExtensionUIContext {
/** Show a selector and return the user's choice. */
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
/** Show a selector and return the selected label, even when an option also includes a description. */
select(
title: string,
options: ExtensionUISelectItem[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined>;
/** Show a confirmation dialog. */
confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise<boolean>;
@@ -47,7 +47,11 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils";
import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
import { Settings } from "../../config/settings";
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
import {
type ExtensionUIContext,
type ExtensionUIDialogOptions,
getExtensionUISelectOptionLabel,
} from "../../extensibility/extensions";
import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
@@ -302,7 +306,7 @@ export function createAcpExtensionUiContext(
getSessionId(),
"select",
title,
{ type: "string", enum: options },
{ type: "string", enum: options.map(getExtensionUISelectOptionLabel) },
dialogOptions,
);
return typeof value === "string" ? value : undefined;
@@ -14,8 +14,8 @@ import {
Spacer,
Text,
type TUI,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, type ThemeColor, theme } from "../../modes/theme/theme";
import {
@@ -69,6 +69,34 @@ export interface HookSelectorOptions {
slider?: HookSelectorSlider;
}
export interface HookSelectorOption {
label: string;
description?: string;
}
export type HookSelectorOptionInput = string | HookSelectorOption;
function normalizeHookSelectorOption(option: HookSelectorOptionInput): HookSelectorOption {
if (typeof option === "string") return { label: option };
if (option.description?.trim()) {
return { label: option.label, description: option.description.trim() };
}
return { label: option.label };
}
function splitLeadingSpacesForWrap(line: string, width: number): { indent: string; body: string } {
let indentLength = 0;
while (indentLength < line.length && line.charCodeAt(indentLength) === 32) {
indentLength += 1;
}
const maxIndentLength = Math.max(0, width - 1);
const clampedIndentLength = Math.min(indentLength, maxIndentLength);
return {
indent: line.slice(0, clampedIndentLength),
body: line.slice(indentLength),
};
}
class OutlinedList extends Container {
#lines: string[] = [];
@@ -81,19 +109,26 @@ class OutlinedList extends Container {
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 content: string[] = [];
for (const line of this.#lines) {
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)}`;
});
const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
for (const wrappedBody of wrapped.length > 0 ? wrapped : [""]) {
const wrappedLine = `${indent}${wrappedBody}`;
const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine));
content.push(
`${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`,
);
}
}
return [horizontal, ...content, horizontal];
}
}
export class HookSelectorComponent extends Container {
#options: string[];
#filteredOptions: string[];
#options: HookSelectorOption[];
#filteredOptions: HookSelectorOption[];
#searchQuery = "";
#selectedIndex: number;
#maxVisible: number;
@@ -112,15 +147,15 @@ export class HookSelectorComponent extends Container {
#sliderComponent: Text | undefined;
constructor(
title: string,
options: string[],
options: HookSelectorOptionInput[],
onSelect: (option: string) => void,
onCancel: () => void,
opts?: HookSelectorOptions,
) {
super();
this.#options = options;
this.#filteredOptions = options;
this.#options = options.map(normalizeHookSelectorOption);
this.#filteredOptions = this.#options;
this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, this.#filteredOptions.length - 1);
this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
this.#onSelectCallback = onSelect;
@@ -156,7 +191,7 @@ export class HookSelectorComponent extends Container {
opts?.onTimeout?.();
const selected = this.#filteredOptions[this.#selectedIndex];
if (selected) {
this.#onSelectCallback(selected);
this.#onSelectCallback(selected.label);
} else {
this.#onCancelCallback();
}
@@ -195,10 +230,14 @@ export class HookSelectorComponent extends Container {
if (option === undefined) continue;
const isSelected = i === this.#selectedIndex;
const label = isSelected
? renderInlineMarkdown(option, mdTheme, t => theme.fg("accent", t))
: renderInlineMarkdown(option, mdTheme, t => theme.fg("text", t));
? renderInlineMarkdown(option.label, mdTheme, t => theme.fg("accent", t))
: renderInlineMarkdown(option.label, mdTheme, t => theme.fg("text", t));
const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
lines.push(prefix + label);
if (option.description) {
const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg("muted", t));
lines.push(` ${description}`);
}
}
if (total === 0) {
@@ -273,7 +312,9 @@ export class HookSelectorComponent extends Container {
#setSearchQuery(query: string): void {
this.#searchQuery = query;
this.#filteredOptions = query.trim() ? fuzzyFilter(this.#options, query, option => option) : this.#options;
this.#filteredOptions = query.trim()
? fuzzyFilter(this.#options, query, option => `${option.label} ${option.description ?? ""}`)
: this.#options;
this.#selectedIndex = 0;
this.#updateList();
}
@@ -322,7 +363,7 @@ export class HookSelectorComponent extends Container {
}
} else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
const selected = this.#filteredOptions[this.#selectedIndex];
if (selected) this.#onSelectCallback(selected);
if (selected) this.#onSelectCallback(selected.label);
} else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) {
if (this.#slider) this.#moveSlider(-1);
else this.#onLeftCallback?.();
@@ -10,6 +10,7 @@ import type {
ExtensionError,
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionUISelectItem,
ExtensionUiComponent,
ExtensionWidgetContent,
ExtensionWidgetOptions,
@@ -483,7 +484,7 @@ export class ExtensionUiController {
createBackgroundUiContext(): ExtensionUIContext {
return {
select: async (_title: string, _options: string[], _dialogOptions) => undefined,
select: async (_title: string, _options: ExtensionUISelectItem[], _dialogOptions) => undefined,
confirm: async (_title: string, _message: string, _dialogOptions) => false,
input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined,
notify: () => {},
@@ -581,7 +582,7 @@ export class ExtensionUiController {
*/
showHookSelector(
title: string,
options: string[],
options: ExtensionUISelectItem[],
dialogOptions?: ExtensionUIDialogOptions,
extra?: { slider?: HookSelectorSlider },
): Promise<string | undefined> {
@@ -40,6 +40,7 @@ import { isSettingsInitialized, Settings, settings } from "../config/settings";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionUISelectItem,
ExtensionWidgetContent,
ExtensionWidgetOptions,
} from "../extensibility/extensions";
@@ -2896,7 +2897,7 @@ export class InteractiveMode implements InteractiveModeContext {
showHookSelector(
title: string,
options: string[],
options: ExtensionUISelectItem[],
dialogOptions?: ExtensionUIDialogOptions,
extra?: { slider?: HookSelectorSlider },
): Promise<string | undefined> {
@@ -12,10 +12,12 @@
*/
import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
import {
type ExtensionUIContext,
type ExtensionUIDialogOptions,
type ExtensionUISelectItem,
type ExtensionWidgetOptions,
getExtensionUISelectOptionLabel,
} from "../../extensibility/extensions";
import { type Theme, theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
@@ -256,11 +258,20 @@ export async function runRpcMode(
return promise;
}
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined> {
select(
title: string,
options: ExtensionUISelectItem[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return this.#createDialogPromise(
dialogOptions,
undefined,
{ method: "select", title, options, timeout: dialogOptions?.timeout },
{
method: "select",
title,
options: options.map(getExtensionUISelectOptionLabel),
timeout: dialogOptions?.timeout,
},
response => parseValueDialogResponse(response, dialogOptions),
);
}
+2 -1
View File
@@ -7,6 +7,7 @@ import type { Settings } from "../config/settings";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionUISelectItem,
ExtensionWidgetContent,
ExtensionWidgetOptions,
} from "../extensibility/extensions";
@@ -297,7 +298,7 @@ export interface InteractiveModeContext {
setHookStatus(key: string, text: string | undefined): void;
showHookSelector(
title: string,
options: string[],
options: ExtensionUISelectItem[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined>;
hideHookSelector(): void;
@@ -8,6 +8,7 @@ Asks user when you need clarification or input during task execution.
- Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
- Use `questions` for multiple related questions instead of asking one at a time
- Set `multi: true` on question to allow multiple selections
- Use short option labels; put explanatory tradeoffs in `description` instead of merging them into the label
</instruction>
<caution>
@@ -22,7 +23,7 @@ Asks user when you need clarification or input during task execution.
<examples>
# Single question
questions: [{"id": "auth_method", "question": "Which authentication method should this API use?", "options": [{"label": "JWT"}, {"label": "OAuth2"}, {"label": "Session cookies"}], "recommended": 0}]
questions: [{"id": "auth_method", "question": "Which authentication method should this API use?", "options": [{"label": "JWT", "description": "Bearer tokens for stateless API clients."}, {"label": "OAuth2", "description": "Delegated authorization with external identity providers."}, {"label": "Session cookies", "description": "Browser-first authentication backed by server-side sessions."}], "recommended": 0}]
# Multiple questions
questions: [{"id": "storage_type", "question": "Which storage backend?", "options": [{"label": "SQLite"}, {"label": "PostgreSQL"}]}, {"id": "auth_method", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "Session cookies"}]}]
+74 -32
View File
@@ -20,6 +20,7 @@ import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Te
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { ExtensionUISelectItem } from "../extensibility/extensions";
import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
import askDescription from "../prompts/tools/ask.md" with { type: "text" };
import { renderStatusLine } from "../tui";
@@ -33,6 +34,7 @@ import { ToolAbortError } from "./tool-errors";
const OptionItem = z.object({
label: z.string().describe("display label"),
description: z.string().describe("optional explanatory text displayed below the label").optional(),
});
const QuestionItem = z.object({
@@ -69,6 +71,23 @@ export interface AskToolDetails {
results?: QuestionResult[];
}
interface AskOption {
label: string;
description?: string;
}
function getAskOptionLabel(option: AskOption): string {
return option.label;
}
function getSelectOptionLabel(option: ExtensionUISelectItem): string {
return typeof option === "string" ? option : option.label;
}
function toSelectOption(option: AskOption, label = option.label): ExtensionUISelectItem {
return option.description ? { label, description: option.description } : label;
}
// =============================================================================
// Constants
// =============================================================================
@@ -81,24 +100,25 @@ function getDoneOptionLabel(): string {
}
/** Add "(Recommended)" suffix to the option at the given index if not already present */
function addRecommendedSuffix(labels: string[], recommendedIndex?: number): string[] {
if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= labels.length) {
return labels;
function addRecommendedSuffix(options: AskOption[], recommendedIndex?: number): ExtensionUISelectItem[] {
if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= options.length) {
return options.map(option => toSelectOption(option));
}
return labels.map((label, i) => {
if (i === recommendedIndex && !label.endsWith(RECOMMENDED_SUFFIX)) {
return label + RECOMMENDED_SUFFIX;
}
return label;
return options.map((option, i) => {
const label =
i === recommendedIndex && !option.label.endsWith(RECOMMENDED_SUFFIX)
? option.label + RECOMMENDED_SUFFIX
: option.label;
return toSelectOption(option, label);
});
}
function getAutoSelectionOnTimeout(optionLabels: string[], recommended?: number): string[] {
if (optionLabels.length === 0) return [];
if (typeof recommended === "number" && recommended >= 0 && recommended < optionLabels.length) {
return [optionLabels[recommended]];
function getAutoSelectionOnTimeout(options: AskOption[], recommended?: number): string[] {
if (options.length === 0) return [];
if (typeof recommended === "number" && recommended >= 0 && recommended < options.length) {
return [options[recommended]!.label];
}
return [optionLabels[0]];
return [options[0]!.label];
}
/** Strip "(Recommended)" suffix from a label */
@@ -134,7 +154,7 @@ interface AskSingleQuestionOptions {
interface UIContext {
select(
prompt: string,
options: string[],
options: ExtensionUISelectItem[],
options_?: {
initialIndex?: number;
timeout?: number;
@@ -157,7 +177,7 @@ interface UIContext {
async function askSingleQuestion(
ui: UIContext,
question: string,
optionLabels: string[],
questionOptions: AskOption[],
multi: boolean,
options: AskSingleQuestionOptions = {},
): Promise<SelectionResult> {
@@ -169,7 +189,7 @@ async function askSingleQuestion(
const selectOption = async (
prompt: string,
optionsToShow: string[],
optionsToShow: ExtensionUISelectItem[],
initialIndex?: number,
): Promise<{ choice: string | undefined; timedOut: boolean; navigation?: "back" | "forward" }> => {
let timeoutTriggered = false;
@@ -218,18 +238,19 @@ async function askSingleQuestion(
const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question;
if (multi) {
const selected = new Set<string>(selectedOptions);
let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), Math.max(optionLabels.length - 1, 0));
let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), Math.max(questionOptions.length - 1, 0));
const firstSelected = selectedOptions[0];
if (firstSelected) {
const selectedIndex = optionLabels.indexOf(firstSelected);
const selectedIndex = questionOptions.findIndex(option => option.label === firstSelected);
if (selectedIndex >= 0) cursorIndex = selectedIndex;
}
while (true) {
const opts: string[] = [];
const opts: ExtensionUISelectItem[] = [];
for (const opt of optionLabels) {
const checkbox = selected.has(opt) ? theme.checkbox.checked : theme.checkbox.unchecked;
opts.push(`${checkbox} ${opt}`);
for (const opt of questionOptions) {
const checkbox = selected.has(opt.label) ? theme.checkbox.checked : theme.checkbox.unchecked;
const displayLabel = `${checkbox} ${opt.label}`;
opts.push(toSelectOption(opt, displayLabel));
}
if (!navigation?.allowForward && selected.size > 0) {
@@ -269,7 +290,7 @@ async function askSingleQuestion(
break;
}
const selectedIdx = opts.indexOf(choice);
const selectedIdx = opts.findIndex(opt => getSelectOptionLabel(opt) === choice);
if (selectedIdx >= 0) {
cursorIndex = selectedIdx;
}
@@ -297,16 +318,16 @@ async function askSingleQuestion(
}
selectedOptions = Array.from(selected);
} else {
const displayLabels = addRecommendedSuffix(optionLabels, recommended);
const optionsWithNavigation = [...displayLabels, OTHER_OPTION];
const displayOptions = addRecommendedSuffix(questionOptions, recommended);
const optionsWithNavigation: ExtensionUISelectItem[] = [...displayOptions, OTHER_OPTION];
let initialIndex = recommended;
const previouslySelected = selectedOptions[0];
if (previouslySelected) {
const selectedIndex = optionLabels.indexOf(previouslySelected);
const selectedIndex = questionOptions.findIndex(option => option.label === previouslySelected);
if (selectedIndex >= 0) initialIndex = selectedIndex;
} else if (customInput !== undefined) {
initialIndex = displayLabels.length;
initialIndex = displayOptions.length;
}
if (initialIndex !== undefined) {
const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
@@ -346,7 +367,7 @@ async function askSingleQuestion(
}
if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
selectedOptions = getAutoSelectionOnTimeout(optionLabels, recommended);
selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
}
return { selectedOptions, customInput, timedOut };
@@ -442,12 +463,16 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
q: AskParams["questions"][number],
options?: { previous?: QuestionResult; navigation?: NavigationControls },
) => {
const optionLabels = q.options.map(o => o.label);
const questionOptions = q.options.map(option => ({
label: option.label,
...(option.description?.trim() ? { description: option.description.trim() } : {}),
}));
const optionLabels = questionOptions.map(getAskOptionLabel);
try {
const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion(
ui,
q.question,
optionLabels,
questionOptions,
q.multi ?? false,
{
recommended: q.recommended,
@@ -568,14 +593,19 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
// TUI Renderer
// =============================================================================
interface AskRenderOption {
label: string;
description?: string;
}
interface AskRenderArgs {
question?: string;
options?: Array<{ label: string }>;
options?: AskRenderOption[];
multi?: boolean;
questions?: Array<{
id: string;
question: string;
options: Array<{ label: string }>;
options: AskRenderOption[];
multi?: boolean;
}>;
}
@@ -634,6 +664,13 @@ export const askToolRenderer = {
const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
if (opt.description?.trim()) {
const optContinuation = isLastOpt ? " " : uiTheme.tree.vertical;
const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t =>
uiTheme.fg("dim", t),
);
optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optContinuation)} ${uiTheme.fg("dim", "↳")} ${description}`;
}
}
container.addChild(new Text(optText, 0, 0));
}
@@ -661,6 +698,11 @@ export const askToolRenderer = {
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
if (opt.description?.trim()) {
const continuation = isLast ? " " : uiTheme.tree.vertical;
const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t => uiTheme.fg("dim", t));
optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", "↳")} ${description}`;
}
}
container.addChild(new Text(optText, 0, 0));
}
@@ -31,4 +31,94 @@ describe("HookSelectorComponent", () => {
expect(visibleWidth(Bun.stripANSI(line))).toBeLessThanOrEqual(width);
}
});
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.",
"Option B: Keep dual support by migrating canonical instructions into .omp while also maintaining a root .github/copilot-instructions.md compatibility bridge for editors that do not understand OMP resources yet.",
];
const component = new HookSelectorComponent(
"Which migration stance should be used?",
options,
() => {},
() => {},
{ outline: true, initialIndex: 0 },
);
const width = 72;
const lines = component.render(width);
const plain = lines.map(line => Bun.stripANSI(line)).join("\n");
const normalizedPlain = plain.replace(/[\u2500-\u257f]/g, " ").replace(/\s+/g, " ");
expect(normalizedPlain).toContain("not creating a root .github directory");
expect(normalizedPlain).toContain("do not understand OMP resources yet");
for (const line of lines) {
expect(visibleWidth(Bun.stripANSI(line))).toBeLessThanOrEqual(width);
}
});
it("renders option descriptions as separate wrapped rows", () => {
const options = [
{
label: "Use existing local credentials",
description:
"Authenticate via the provider keys and OAuth state already configured under ~/.omp without opening a new browser-based setup flow.",
},
{
label: "Set up Oh My Pi in terminal",
description:
"Launch the local terminal UI to add provider keys, select models, and keep the current editor session waiting for the configured credentials.",
},
];
const component = new HookSelectorComponent(
"How should authentication continue?",
options,
() => {},
() => {},
{ outline: true, initialIndex: 0 },
);
const width = 76;
const lines = component.render(width);
const plainLines = lines.map(line => Bun.stripANSI(line));
const normalizedPlain = plainLines
.join("\n")
.replace(/[\u2500-\u257f]/g, " ")
.replace(/\s+/g, " ");
const labelLineIndex = plainLines.findIndex(line => line.includes("Use existing local credentials"));
const descriptionLineIndex = plainLines.findIndex(line => line.includes("Authenticate via the provider keys"));
expect(labelLineIndex).toBeGreaterThanOrEqual(0);
expect(descriptionLineIndex).toBeGreaterThan(labelLineIndex);
expect(normalizedPlain).toContain("without opening a new browser-based setup flow");
expect(normalizedPlain).toContain("keep the current editor session waiting for the configured credentials");
for (const line of lines) {
expect(visibleWidth(Bun.stripANSI(line))).toBeLessThanOrEqual(width);
}
});
it("filters options by description text", () => {
const component = new HookSelectorComponent(
"Which setup path should be used?",
[
{ label: "Path A", description: "Reuse the credentials already available in the environment." },
{ label: "Path B", description: "Launch a browser flow to authorize a new provider account." },
{ label: "Path C", description: "Open the local settings file and edit provider keys manually." },
{ label: "Path D", description: "Skip provider setup and continue with offline-only tools." },
],
() => {},
() => {},
{ outline: true, maxVisible: 3 },
);
for (const key of "browser") {
component.handleInput(key);
}
const plain = component
.render(76)
.map(line => Bun.stripANSI(line))
.join("\n");
expect(plain).toContain("Path B");
expect(plain).toContain("Launch a browser flow");
expect(plain).not.toContain("Path A");
});
});
+153 -10
View File
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it, vi } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ExtensionUISelectItem } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { AskTool, askToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ask";
@@ -21,7 +22,7 @@ function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
function createContext(args: {
select: (
prompt: string,
options: string[],
options: ExtensionUISelectItem[],
dialogOptions?: {
initialIndex?: number;
timeout?: number;
@@ -60,6 +61,10 @@ function stripAnsi(text: string): string {
return stripVTControlCharacters(text);
}
function selectItemLabel(option: ExtensionUISelectItem | undefined): string | undefined {
return typeof option === "string" ? option : option?.label;
}
beforeAll(async () => {
await initTheme(false);
});
@@ -98,8 +103,11 @@ describe("AskTool cancellation", () => {
// deliberate indefinitely. The dialog timeout is opt-in via the `ask.timeout` setting.
const tool = new AskTool(createSession());
const select = vi.fn(
async (_prompt: string, options: string[], _dialogOptions?: { initialIndex?: number; timeout?: number }) =>
options[0],
async (
_prompt: string,
options: ExtensionUISelectItem[],
_dialogOptions?: { initialIndex?: number; timeout?: number },
) => (typeof options[0] === "string" ? options[0] : options[0]?.label),
);
const context = createContext({ select });
@@ -164,13 +172,14 @@ describe("AskTool cancellation", () => {
const select = vi.fn(
async (
_prompt: string,
options: string[],
options: ExtensionUISelectItem[],
dialogOptions?: { initialIndex?: number; timeout?: number; onTimeout?: () => void },
) => {
const timeout = dialogOptions?.timeout ?? 1;
await Bun.sleep(timeout + 5);
dialogOptions?.onTimeout?.();
return options[dialogOptions?.initialIndex ?? 0];
const selected = options[dialogOptions?.initialIndex ?? 0];
return typeof selected === "string" ? selected : selected?.label;
},
);
const context = createContext({
@@ -387,6 +396,140 @@ describe("AskTool cancellation", () => {
});
});
describe("AskTool option descriptions", () => {
it("passes descriptions to the selector while returning selected labels", async () => {
const tool = new AskTool(createSession());
const select = vi.fn(async (_prompt: string, options: ExtensionUISelectItem[]) => {
expect(options[0]).toEqual({
label: "Use local credentials",
description: "Authenticate with provider keys already configured under ~/.omp.",
});
expect(options[1]).toEqual({
label: "Set up in terminal",
description: "Launch the terminal setup flow to add credentials before continuing.",
});
const selected = options[1];
return typeof selected === "string" ? selected : selected?.label;
});
const context = createContext({ select });
const result = await tool.execute(
"call-option-descriptions",
{
questions: [
{
id: "auth",
question: "How should authentication continue?",
options: [
{
label: "Use local credentials",
description: "Authenticate with provider keys already configured under ~/.omp.",
},
{
label: "Set up in terminal",
description: "Launch the terminal setup flow to add credentials before continuing.",
},
],
},
],
},
undefined,
undefined,
context,
);
expect(result.content[0]?.type).toBe("text");
if (result.content[0]?.type !== "text") {
throw new Error("Expected text result");
}
expect(result.content[0].text).toContain("User selected: Set up in terminal");
expect(result.details?.selectedOptions).toEqual(["Set up in terminal"]);
expect(result.content[0].text).not.toContain("Launch the terminal setup flow");
expect(result.details?.options).toEqual(["Use local credentials", "Set up in terminal"]);
});
it("renders descriptions under labels in ask call previews", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const rendered = askToolRenderer.renderCall(
{
question: "How should authentication continue?",
options: [
{
label: "Use local credentials",
description: "Authenticate with provider keys already configured under ~/.omp.",
},
{
label: "Set up in terminal",
description: "Launch the terminal setup flow to add credentials before continuing.",
},
],
},
{ expanded: true, isPartial: false },
theme!,
);
const renderedLines = stripAnsi(rendered.render(120).join("\n")).split("\n");
const labelLine = renderedLines.findIndex(line => line.includes("Use local credentials"));
const descriptionLine = renderedLines.findIndex(line =>
line.includes("Authenticate with provider keys already configured"),
);
expect(labelLine).toBeGreaterThanOrEqual(0);
expect(descriptionLine).toBeGreaterThan(labelLine);
});
it("forwards descriptions through multi-select and returns bare labels", async () => {
const tool = new AskTool(createSession());
let step = 0;
let firstOptions: ExtensionUISelectItem[] = [];
const editor = vi.fn(async () => undefined);
const context = createContext({
select: async (_prompt, options) => {
if (step === 0) {
firstOptions = options;
step += 1;
return selectItemLabel(options.find(o => selectItemLabel(o)?.endsWith("alpha")));
}
if (step === 1) {
step += 1;
return selectItemLabel(options.find(o => selectItemLabel(o)?.endsWith("beta")));
}
return "Other (type your own)";
},
editor,
});
const result = await tool.execute(
"call-multi-desc",
{
questions: [
{
id: "multi",
question: "Pick answers",
options: [
{ label: "alpha", description: "First choice detail." },
{ label: "beta", description: "Second choice detail." },
],
multi: true,
},
],
},
undefined,
undefined,
context,
);
expect(result.details?.selectedOptions).toEqual(["alpha", "beta"]);
expect(result.content[0]?.type).toBe("text");
if (result.content[0]?.type !== "text") {
throw new Error("Expected text result");
}
expect(result.content[0].text).toContain("User selected: alpha, beta");
expect(result.content[0].text).not.toContain("First choice detail");
const alphaOption = firstOptions.find(o => selectItemLabel(o)?.endsWith("alpha"));
expect(typeof alphaOption === "object" ? alphaOption.description : undefined).toBe("First choice detail.");
});
});
describe("AskTool custom input", () => {
it("routes custom input through editor and preserves raw multiline strings", async () => {
const tool = new AskTool(createSession());
@@ -556,9 +699,9 @@ describe("AskTool custom input", () => {
select: async (_prompt, options) => {
if (step === 0) {
step += 1;
const alphaOption = options.find(option => option.endsWith("alpha"));
const alphaOption = options.find(option => selectItemLabel(option)?.endsWith("alpha"));
if (!alphaOption) throw new Error("Missing alpha option");
return alphaOption;
return selectItemLabel(alphaOption);
}
return "Other (type your own)";
},
@@ -607,9 +750,9 @@ describe("AskTool custom input", () => {
select: async (_prompt, options) => {
if (step === 0) {
step += 1;
const alphaOption = options.find(option => option.endsWith("alpha"));
const alphaOption = options.find(option => selectItemLabel(option)?.endsWith("alpha"));
if (!alphaOption) throw new Error("Missing alpha option");
return alphaOption;
return selectItemLabel(alphaOption);
}
return "Other (type your own)";
},
@@ -758,7 +901,7 @@ describe("AskTool multi-question navigation", () => {
it("keeps back unavailable on the first question and supports returning from later questions", async () => {
const tool = new AskTool(createSession());
const firstQuestionOptions: string[][] = [];
const firstQuestionOptions: ExtensionUISelectItem[][] = [];
let firstVisits = 0;
let secondVisits = 0;
const context = createContext({