Files
oh-my-pi/packages/coding-agent/src/tools/ask.ts
T
can1357 0fe761dc4b build(deps): refactored TypeScript and package configuration across monorepo
- Added tsconfig.publish.json files to all packages with optimized publish-time configuration.
- Updated all package.json scripts with prepublishOnly hooks for correct type checking during publish.
- Added @oh-my-pi/omp-stats path mappings to root tsconfig.json for consistent imports.
- Added WASM generation script for photon module and integrated into install:dev script.
2026-01-23 13:46:06 +01:00

471 lines
16 KiB
TypeScript

/**
* Ask Tool - Interactive user prompting during execution
*
* Use this tool when you need to ask the user questions during execution.
* This allows you to:
* 1. Gather user preferences or requirements
* 2. Clarify ambiguous instructions
* 3. Get decisions on implementation choices as you work
* 4. Offer choices to the user about what direction to take
*
* Usage notes:
* - Users will always be able to select "Other" to provide custom text input
* - Use multi: true to allow multiple answers to be selected for a question
* - If you recommend a specific option, make that the first option in the list
* and add "(Recommended)" at the end of the label
*/
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates";
import type { RenderResultOptions } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types";
import { type Theme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import askDescription from "@oh-my-pi/pi-coding-agent/prompts/tools/ask.md" with { type: "text" };
import { renderStatusLine } from "@oh-my-pi/pi-coding-agent/tui";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { Type } from "@sinclair/typebox";
import type { ToolSession } from "./index";
import { ToolUIKit } from "./render-utils";
// =============================================================================
// Types
// =============================================================================
const OptionItem = Type.Object({
label: Type.String({ description: "Display label" }),
});
const QuestionItem = Type.Object({
id: Type.String({ description: "Question ID, e.g. 'auth', 'cache'" }),
question: Type.String({ description: "Question text" }),
options: Type.Array(OptionItem, { description: "Available options" }),
multi: Type.Optional(Type.Boolean({ description: "Allow multiple selections" })),
});
const askSchema = Type.Object({
question: Type.Optional(Type.String({ description: "Question to ask" })),
options: Type.Optional(Type.Array(OptionItem, { description: "Available options" })),
multi: Type.Optional(Type.Boolean({ description: "Allow multiple selections (default: false)" })),
questions: Type.Optional(Type.Array(QuestionItem, { description: "Multiple questions in sequence" })),
});
/** Result for a single question */
export interface QuestionResult {
id: string;
question: string;
options: string[];
multi: boolean;
selectedOptions: string[];
customInput?: string;
}
export interface AskToolDetails {
/** Single question mode (backwards compatible) */
question?: string;
options?: string[];
multi?: boolean;
selectedOptions?: string[];
customInput?: string;
/** Multi-part question mode */
results?: QuestionResult[];
}
// =============================================================================
// Constants
// =============================================================================
const OTHER_OPTION = "Other (type your own)";
function getDoneOptionLabel(): string {
return `${theme.status.success} Done selecting`;
}
// =============================================================================
// Question Selection Logic
// =============================================================================
interface SelectionResult {
selectedOptions: string[];
customInput?: string;
}
interface UIContext {
select(prompt: string, options: string[], options_?: { initialIndex?: number }): Promise<string | undefined>;
input(prompt: string): Promise<string | undefined>;
}
async function askSingleQuestion(
ui: UIContext,
question: string,
optionLabels: string[],
multi: boolean,
): Promise<SelectionResult> {
const doneLabel = getDoneOptionLabel();
let selectedOptions: string[] = [];
let customInput: string | undefined;
if (multi) {
const selected = new Set<string>();
let cursorIndex = 0;
while (true) {
const opts: string[] = [];
for (const opt of optionLabels) {
const checkbox = selected.has(opt) ? theme.checkbox.checked : theme.checkbox.unchecked;
opts.push(`${checkbox} ${opt}`);
}
// Done after options, before Other - so cursor stays on options after toggle
if (selected.size > 0) {
opts.push(doneLabel);
}
opts.push(OTHER_OPTION);
const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
const choice = await ui.select(`${prefix}${question}`, opts, { initialIndex: cursorIndex });
if (choice === undefined || choice === doneLabel) break;
if (choice === OTHER_OPTION) {
const input = await ui.input("Enter your response:");
if (input) customInput = input;
break;
}
// Find which index was selected and update cursor position
const selectedIdx = opts.indexOf(choice);
if (selectedIdx >= 0) {
cursorIndex = selectedIdx;
}
const checkedPrefix = `${theme.checkbox.checked} `;
const uncheckedPrefix = `${theme.checkbox.unchecked} `;
let opt: string | undefined;
if (choice.startsWith(checkedPrefix)) {
opt = choice.slice(checkedPrefix.length);
} else if (choice.startsWith(uncheckedPrefix)) {
opt = choice.slice(uncheckedPrefix.length);
}
if (opt) {
if (selected.has(opt)) {
selected.delete(opt);
} else {
selected.add(opt);
}
}
}
selectedOptions = Array.from(selected);
} else {
const choice = await ui.select(question, [...optionLabels, OTHER_OPTION]);
if (choice === OTHER_OPTION) {
const input = await ui.input("Enter your response:");
if (input) customInput = input;
} else if (choice) {
selectedOptions = [choice];
}
}
return { selectedOptions, customInput };
}
function formatQuestionResult(result: QuestionResult): string {
if (result.customInput) {
return `${result.id}: "${result.customInput}"`;
}
if (result.selectedOptions.length > 0) {
return result.multi
? `${result.id}: [${result.selectedOptions.join(", ")}]`
: `${result.id}: ${result.selectedOptions[0]}`;
}
return `${result.id}: (cancelled)`;
}
// =============================================================================
// Tool Class
// =============================================================================
interface AskParams {
question?: string;
options?: Array<{ label: string }>;
multi?: boolean;
questions?: Array<{
id: string;
question: string;
options: Array<{ label: string }>;
multi?: boolean;
}>;
}
/**
* Ask tool for interactive user prompting during execution.
*
* Allows gathering user preferences, clarifying instructions, and getting decisions
* on implementation choices as the agent works.
*/
export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
public readonly name = "ask";
public readonly label = "Ask";
public readonly description: string;
public readonly parameters = askSchema;
constructor(_session: ToolSession) {
this.description = renderPromptTemplate(askDescription);
}
static createIf(session: ToolSession): AskTool | null {
return session.hasUI ? new AskTool(session) : null;
}
public async execute(
_toolCallId: string,
params: AskParams,
_signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<AskToolDetails>,
context?: AgentToolContext,
): Promise<AgentToolResult<AskToolDetails>> {
// Headless fallback
if (!context?.hasUI || !context.ui) {
return {
content: [{ type: "text" as const, text: "Error: User prompt requires interactive mode" }],
details: {},
};
}
const { ui } = context;
// Multi-part questions mode
if (params.questions && params.questions.length > 0) {
const results: QuestionResult[] = [];
for (const q of params.questions) {
const optionLabels = q.options.map((o) => o.label);
const { selectedOptions, customInput } = await askSingleQuestion(
ui,
q.question,
optionLabels,
q.multi ?? false,
);
results.push({
id: q.id,
question: q.question,
options: optionLabels,
multi: q.multi ?? false,
selectedOptions,
customInput,
});
}
const details: AskToolDetails = { results };
const responseLines = results.map(formatQuestionResult);
const responseText = `User answers:\n${responseLines.join("\n")}`;
return { content: [{ type: "text" as const, text: responseText }], details };
}
// Single question mode (backwards compatible)
const question = params.question ?? "";
const options = params.options ?? [];
const multi = params.multi ?? false;
const optionLabels = options.map((o) => o.label);
if (!question || optionLabels.length === 0) {
return {
content: [{ type: "text" as const, text: "Error: question and options are required" }],
details: {},
};
}
const { selectedOptions, customInput } = await askSingleQuestion(ui, question, optionLabels, multi);
const details: AskToolDetails = {
question,
options: optionLabels,
multi,
selectedOptions,
customInput,
};
let responseText: string;
if (customInput) {
responseText = `User provided custom input: ${customInput}`;
} else if (selectedOptions.length > 0) {
responseText = multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`;
} else {
responseText = "User cancelled the selection";
}
return { content: [{ type: "text" as const, text: responseText }], details };
}
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface AskRenderArgs {
question?: string;
options?: Array<{ label: string }>;
multi?: boolean;
questions?: Array<{
id: string;
question: string;
options: Array<{ label: string }>;
multi?: boolean;
}>;
}
export const askToolRenderer = {
renderCall(args: AskRenderArgs, uiTheme: Theme): Component {
const ui = new ToolUIKit(uiTheme);
const label = ui.title("Ask");
// Multi-part questions
if (args.questions && args.questions.length > 0) {
let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`;
for (let i = 0; i < args.questions.length; i++) {
const q = args.questions[i];
const isLastQ = i === args.questions.length - 1;
const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
const continuation = isLastQ ? " " : uiTheme.tree.vertical;
// Question line with metadata
const meta: string[] = [];
if (q.multi) meta.push("multi");
if (q.options?.length) meta.push(`options:${q.options.length}`);
const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`;
// Options under question
if (q.options?.length) {
for (let j = 0; j < q.options.length; j++) {
const opt = q.options[j];
const isLastOpt = j === q.options.length - 1;
const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
}
}
}
return new Text(text, 0, 0);
}
// Single question
if (!args.question) {
return new Text(ui.errorMessage("No question provided"), 0, 0);
}
let text = `${label} ${uiTheme.fg("accent", args.question)}`;
const meta: string[] = [];
if (args.multi) meta.push("multi");
if (args.options?.length) meta.push(`options:${args.options.length}`);
text += ui.meta(meta);
if (args.options?.length) {
for (let i = 0; i < args.options.length; i++) {
const opt = args.options[i];
const isLast = i === args.options.length - 1;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
}
}
return new Text(text, 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: AskToolDetails },
_opts: RenderResultOptions,
uiTheme: Theme,
): Component {
const { details } = result;
if (!details) {
const txt = result.content[0];
const fallback = txt?.type === "text" && txt.text ? txt.text : "";
const header = renderStatusLine({ icon: "warning", title: "Ask" }, uiTheme);
return new Text([header, uiTheme.fg("dim", fallback)].join("\n"), 0, 0);
}
// Multi-part results
if (details.results && details.results.length > 0) {
const lines: string[] = [];
const hasAnySelection = details.results.some(
(r) => r.customInput || (r.selectedOptions && r.selectedOptions.length > 0),
);
const header = renderStatusLine(
{
icon: hasAnySelection ? "success" : "warning",
title: "Ask",
meta: [`${details.results.length} questions`],
},
uiTheme,
);
lines.push(header);
for (let i = 0; i < details.results.length; i++) {
const r = details.results[i];
const isLastQuestion = i === details.results.length - 1;
const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch;
const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
const hasSelection = r.customInput || r.selectedOptions.length > 0;
const statusIcon = hasSelection
? uiTheme.styledSymbol("status.success", "success")
: uiTheme.styledSymbol("status.warning", "warning");
lines.push(
` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`,
);
if (r.customInput) {
lines.push(
`${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`,
);
} else if (r.selectedOptions.length > 0) {
for (let j = 0; j < r.selectedOptions.length; j++) {
const isLast = j === r.selectedOptions.length - 1;
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
lines.push(
`${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`,
);
}
} else {
lines.push(
`${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
);
}
}
return new Text(lines.join("\n"), 0, 0);
}
// Single question result
if (!details.question) {
const txt = result.content[0];
return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0);
}
const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
const header = renderStatusLine(
{ icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question },
uiTheme,
);
let text = header;
if (details.customInput) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
} else if (details.selectedOptions && details.selectedOptions.length > 0) {
for (let i = 0; i < details.selectedOptions.length; i++) {
const isLast = i === details.selectedOptions.length - 1;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`;
}
} else {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
}
return new Text(text, 0, 0);
},
};