feat(ask): add initial ask tool prototype and project setup

This commit is contained in:
Erik Kotsiuba
2026-04-16 17:56:01 +02:00
commit 133a0548ba
16 changed files with 4039 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import {
Editor,
type EditorTheme,
Key,
matchesKey,
truncateToWidth,
} from "@mariozechner/pi-tui";
import {
allRequiredAnswered,
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
createInitialState,
getAnswer,
getCurrentQuestion,
getRenderableOptions,
isQuestionAnswered,
isSubmitTab,
moveOption,
moveTab,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
} from "./state.ts";
import type { AskParams, AskResult, AskState } from "./types.ts";
export async function runAskFlow(
ctx: ExtensionContext,
params: AskParams,
): Promise<AskResult> {
return ctx.ui.custom<AskResult>((tui, theme, _keybindings, done) => {
let state: AskState = createInitialState(params);
let cachedLines: string[] | undefined;
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (value) => {
state = submitCustomAnswer(state, value);
editor.setText("");
refresh();
maybeFinish();
};
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function maybeFinish() {
if (state.completed) {
done(toAskResult(state));
}
}
function handleInput(data: string) {
if (state.mode === "input") {
if (matchesKey(data, Key.escape)) {
state = cancelFlow(state);
if (!state.completed) {
refresh();
return;
}
maybeFinish();
return;
}
editor.handleInput(data);
refresh();
return;
}
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
state = moveTab(state, 1);
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
state = moveTab(state, -1);
refresh();
return;
}
if (matchesKey(data, Key.up)) {
state = moveOption(state, -1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
state = moveOption(state, 1);
refresh();
return;
}
if (matchesKey(data, Key.space)) {
const question = getCurrentQuestion(state);
if (question?.type === "multi") {
state = toggleCurrentMultiOption(state);
refresh();
return;
}
}
if (matchesKey(data, Key.enter)) {
state = confirmCurrentSelection(state);
refresh();
maybeFinish();
return;
}
if (matchesKey(data, Key.escape)) {
state = cancelFlow(state);
refresh();
maybeFinish();
return;
}
const digit = parseDigit(data);
if (digit !== null) {
state = applyNumberShortcut(state, digit);
refresh();
return;
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (text = "") => lines.push(truncateToWidth(text, width));
const question = getCurrentQuestion(state);
const options = getRenderableOptions(question);
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
if (state.title) {
add(` ${theme.fg("accent", theme.bold(state.title))}`);
add();
}
add(renderTabs(state, theme, width));
add();
if (isSubmitTab(state)) {
renderSubmit(lines, state, theme, width);
} else if (question) {
add(` ${theme.fg("text", question.prompt)}`);
add();
const isMulti = question.type === "multi";
for (let i = 0; i < options.length; i++) {
const option = options[i];
const selected = i === state.optionIndex;
const checked = !!getAnswer(state, question.id)?.values.includes(
option.value,
);
const pointer = selected ? theme.fg("accent", "❯ ") : " ";
const prefix =
isMulti && !option.isOther ? `[${checked ? "x" : " "}] ` : "";
const optionColor = selected ? "accent" : "text";
add(
`${pointer}${theme.fg(optionColor, `${i + 1}. ${prefix}${option.label}`)}`,
);
if (option.description) {
add(` ${theme.fg("muted", option.description)}`);
}
if (question.type === "code-review" && selected && option.preview) {
add();
for (const previewLine of option.preview.split("\n")) {
add(` ${theme.fg("dim", previewLine)}`);
}
add();
}
}
if (state.mode === "input") {
add();
add(` ${theme.fg("muted", "Your answer:")}`);
for (const editorLine of editor.render(Math.max(1, width - 2))) {
add(` ${editorLine}`);
}
add();
}
}
add();
add(renderFooter(state, theme));
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
cachedLines = lines;
return lines;
}
return {
render,
invalidate() {
cachedLines = undefined;
},
handleInput,
};
});
}
function renderTabs(
state: AskState,
theme: ExtensionContext["ui"]["theme"],
width: number,
): string {
const segments: string[] = [theme.fg("dim", "← ")];
for (let index = 0; index < state.questions.length; index++) {
const question = state.questions[index];
const active = state.currentTab === index;
const answered = isQuestionAnswered(state, question.id);
const marker = answered ? "☒" : "☐";
const text = ` ${marker} ${question.label} `;
segments.push(
active
? theme.bg("selectedBg", theme.fg("text", text))
: theme.fg(answered ? "success" : "muted", text),
);
}
const submitActive = isSubmitTab(state);
const submitText = " ✔ Submit ";
segments.push(
submitActive
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg(allRequiredAnswered(state) ? "success" : "dim", submitText),
);
segments.push(theme.fg("dim", " →"));
return truncateToWidth(` ${segments.join(" ")}`, width);
}
function renderSubmit(
lines: string[],
state: AskState,
theme: ExtensionContext["ui"]["theme"],
width: number,
) {
const add = (text = "") => lines.push(truncateToWidth(text, width));
add(` ${theme.fg("accent", theme.bold("Review your answers"))}`);
add();
for (const question of state.questions) {
const answer = getAnswer(state, question.id);
add(` ${theme.fg("muted", `● ${question.prompt}`)}`);
add(
` ${theme.fg("text", `→ ${answer ? answer.labels.join(", ") : "(unanswered)"}`)}`,
);
}
add();
add(
` ${allRequiredAnswered(state) ? theme.fg("success", "Ready to submit your answers?") : theme.fg("warning", "Answer all required questions before submitting.")}`,
);
add();
const options = ["Submit answers", "Cancel"];
for (let i = 0; i < options.length; i++) {
const selected = i === state.submitIndex;
const prefix = selected ? theme.fg("accent", "❯ ") : " ";
add(
`${prefix}${theme.fg(selected ? "accent" : "text", `${i + 1}. ${options[i]}`)}`,
);
}
}
function renderFooter(
state: AskState,
theme: ExtensionContext["ui"]["theme"],
): string {
if (state.mode === "input") {
return theme.fg("dim", " Enter submit · Esc go back");
}
const question = getCurrentQuestion(state);
if (isSubmitTab(state)) {
return theme.fg("dim", " ↑↓ select · Enter confirm · Esc cancel");
}
if (question?.type === "multi") {
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · space toggle · enter continue · esc dismiss · 1-9 quick toggle",
);
}
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · enter confirm · esc dismiss · 1-9 quick select",
);
}
function parseDigit(data: string): number | null {
return /^[1-9]$/.test(data) ? Number(data) : null;
}
+95
View File
@@ -0,0 +1,95 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { runAskFlow } from "./ask-component.ts";
import { renderResultText } from "./result.ts";
import { AskParamsSchema } from "./schema.ts";
import { createInitialState, summarizeResult, toAskResult } from "./state.ts";
import type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
export function registerAskTool(pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user",
label: "Ask User",
description:
"Ask the user one or more structured questions. Supports single-select, multi-select, and typed custom answers. Use this when clarification is required before proceeding.",
parameters: AskParamsSchema,
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
const details = errorResultDetails(params, true);
return {
content: [
{
type: "text",
text: "Error: UI not available (running in non-interactive mode)",
},
],
details,
};
}
if (!params.questions?.length) {
const details = errorResultDetails(params, true);
return {
content: [{ type: "text", text: "Error: No questions provided" }],
details,
};
}
const result = await runAskFlow(ctx, params);
return {
content: [{ type: "text", text: summarizeResult(result) }],
details: result,
};
},
renderCall(args, theme) {
const params = args as AskParams;
const labels = Array.isArray(params.questions)
? params.questions
.map(
(question: AskQuestionInput, index) =>
question.label || `Q${index + 1}`,
)
.join(", ")
: "";
let text = theme.fg("toolTitle", theme.bold("ask_user "));
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
if (labels) {
text += theme.fg("dim", ` (${truncateToWidth(labels, 50)})`);
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as AskResult | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.cancelled) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
return new Text(renderResultText(details), 0, 0);
},
});
}
function errorResultDetails(params: AskParams, cancelled: boolean): AskResult {
try {
const state = createInitialState(params);
return {
...toAskResult(state),
cancelled,
};
} catch {
return {
title: params.title,
cancelled,
questions: [],
answers: {},
};
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { registerAskTool } from "./ask-tool.ts";
export default function askExtension(pi: ExtensionAPI) {
registerAskTool(pi);
}
+18
View File
@@ -0,0 +1,18 @@
import type { AskResult } from "./types.ts";
export function renderResultText(result: AskResult): string {
if (result.cancelled) {
return "Cancelled";
}
const lines: string[] = [];
for (const question of result.questions) {
const answer = result.answers[question.id];
if (!answer) continue;
const value = answer.customText
? `(wrote) ${answer.customText}`
: answer.labels.join(", ");
lines.push(`✓ ${question.label}: ${value}`);
}
return lines.join("\n") || "Submitted";
}
+54
View File
@@ -0,0 +1,54 @@
import { Type } from "@sinclair/typebox";
export const AskOptionSchema = Type.Object({
value: Type.String({
description: "Machine-readable value returned for this option",
}),
label: Type.String({ description: "Short option title shown in the list" }),
description: Type.Optional(
Type.String({ description: "Optional subtitle/description" }),
),
preview: Type.Optional(
Type.String({
description: "Optional preview text, used by richer question variants",
}),
),
});
export const AskQuestionSchema = Type.Object({
id: Type.String({ description: "Unique question identifier" }),
label: Type.Optional(
Type.String({ description: "Short tab label, e.g. Scope or Runtime" }),
),
prompt: Type.String({ description: "Full question prompt" }),
type: Type.Optional(
Type.Union(
[
Type.Literal("single"),
Type.Literal("multi"),
Type.Literal("code-review"),
],
{ description: "Question type" },
),
),
allowOther: Type.Optional(
Type.Boolean({ description: "Include a fixed 'Type something.' option" }),
),
required: Type.Optional(
Type.Boolean({
description: "Whether an answer is required before submit",
}),
),
options: Type.Array(AskOptionSchema, {
description: "Available answer options",
}),
});
export const AskParamsSchema = Type.Object({
title: Type.Optional(
Type.String({ description: "Optional title shown above the interview" }),
),
questions: Type.Array(AskQuestionSchema, {
description: "Questions to ask the user",
}),
});
+370
View File
@@ -0,0 +1,370 @@
import type {
AskAnswer,
AskParams,
AskQuestion,
AskQuestionInput,
AskResult,
AskState,
RenderOption,
} from "./types.ts";
const OTHER_VALUE = "__other__";
export function normalizeQuestions(params: AskParams): AskQuestion[] {
return params.questions.map((question, index) =>
normalizeQuestion(question, index),
);
}
function normalizeQuestion(
question: AskQuestionInput,
index: number,
): AskQuestion {
return {
...question,
label: question.label?.trim() || `Q${index + 1}`,
type: question.type ?? "single",
allowOther: question.allowOther !== false,
required: question.required !== false,
};
}
export function createInitialState(params: AskParams): AskState {
return {
title: params.title,
questions: normalizeQuestions(params),
currentTab: 0,
optionIndex: 0,
submitIndex: 0,
mode: "navigate",
inputQuestionId: null,
answers: {},
completed: false,
cancelled: false,
};
}
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
return state.questions[state.currentTab];
}
export function isSubmitTab(state: AskState): boolean {
return state.currentTab >= state.questions.length;
}
export function getRenderableOptions(question?: AskQuestion): RenderOption[] {
if (!question) return [];
const options: RenderOption[] = [...question.options];
if (question.allowOther) {
options.push({
value: OTHER_VALUE,
label: "Type something.",
isOther: true,
});
}
return options;
}
export function getAnswer(
state: AskState,
questionId: string,
): AskAnswer | undefined {
return state.answers[questionId];
}
export function isQuestionAnswered(
state: AskState,
questionId: string,
): boolean {
const answer = state.answers[questionId];
return !!answer && answer.labels.length > 0;
}
export function allRequiredAnswered(state: AskState): boolean {
return state.questions.every(
(question) => !question.required || isQuestionAnswered(state, question.id),
);
}
export function moveTab(state: AskState, delta: number): AskState {
if (state.questions.length <= 1 && delta !== 0) {
return state;
}
const totalTabs = state.questions.length + 1;
const currentTab = (state.currentTab + delta + totalTabs) % totalTabs;
return {
...state,
currentTab,
optionIndex: 0,
submitIndex: 0,
mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate",
inputQuestionId: null,
};
}
export function moveOption(state: AskState, delta: number): AskState {
if (isSubmitTab(state)) {
return {
...state,
submitIndex: clamp(state.submitIndex + delta, 0, 1),
};
}
const options = getRenderableOptions(getCurrentQuestion(state));
return {
...state,
optionIndex: clamp(
state.optionIndex + delta,
0,
Math.max(0, options.length - 1),
),
};
}
export function enterInputMode(state: AskState, questionId: string): AskState {
return {
...state,
mode: "input",
inputQuestionId: questionId,
};
}
export function exitInputMode(state: AskState): AskState {
return {
...state,
mode: isSubmitTab(state) ? "submit" : "navigate",
inputQuestionId: null,
};
}
export function toggleCurrentMultiOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question || question.type === "single") return state;
const options = getRenderableOptions(question);
const option = options[state.optionIndex];
if (!option) return state;
if (option.isOther) {
return enterInputMode(state, question.id);
}
return toggleAnswer(state, question.id, option, state.optionIndex);
}
export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) {
if (state.submitIndex === 1) {
return { ...state, cancelled: true, completed: true };
}
if (allRequiredAnswered(state)) {
return { ...state, completed: true };
}
return state;
}
const question = getCurrentQuestion(state);
if (!question) return state;
if (question.type === "multi") {
const currentOption = getRenderableOptions(question)[state.optionIndex];
if (currentOption?.isOther) {
return enterInputMode(state, question.id);
}
return advanceToNextTab(state);
}
const option = getRenderableOptions(question)[state.optionIndex];
if (!option) return state;
if (option.isOther) {
return enterInputMode(state, question.id);
}
const nextState = setSingleAnswer(
state,
question.id,
option,
state.optionIndex,
);
return advanceToNextTab(nextState);
}
export function applyNumberShortcut(state: AskState, digit: number): AskState {
if (digit <= 0 || isSubmitTab(state)) return state;
const question = getCurrentQuestion(state);
if (!question) return state;
const options = getRenderableOptions(question);
const index = digit - 1;
const option = options[index];
if (!option) return state;
const selectedState = { ...state, optionIndex: index };
if (option.isOther) {
return enterInputMode(selectedState, question.id);
}
if (question.type === "multi") {
return toggleAnswer(selectedState, question.id, option, index);
}
const nextState = setSingleAnswer(selectedState, question.id, option, index);
return advanceToNextTab(nextState);
}
export function submitCustomAnswer(
state: AskState,
rawValue: string,
): AskState {
const question = state.questions.find(
(item) => item.id === state.inputQuestionId,
);
if (!question) return exitInputMode(state);
const trimmed = rawValue.trim();
if (!trimmed) {
return exitInputMode(state);
}
const answer: AskAnswer = {
values: [trimmed],
labels: [trimmed],
indices: [],
customText: trimmed,
};
return advanceToNextTab({
...exitInputMode(state),
answers: {
...state.answers,
[question.id]: answer,
},
});
}
export function cancelFlow(state: AskState): AskState {
if (state.mode === "input") {
return exitInputMode(state);
}
return {
...state,
cancelled: true,
completed: true,
};
}
export function toAskResult(state: AskState): AskResult {
return {
title: state.title,
cancelled: state.cancelled,
questions: state.questions.map((question) => ({
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
})),
answers: state.answers,
};
}
export function summarizeResult(result: AskResult): string {
if (result.cancelled) {
return "User cancelled the ask flow";
}
const lines = result.questions.flatMap((question) => {
const answer = result.answers[question.id];
if (!answer) return [];
if (answer.customText) {
return [`${question.label}: ${answer.customText}`];
}
return [`${question.label}: ${answer.labels.join(", ")}`];
});
return lines.join("\n") || "User submitted the ask flow";
}
function toggleAnswer(
state: AskState,
questionId: string,
option: RenderOption,
index: number,
): AskState {
const existing = state.answers[questionId] ?? emptyAnswer();
const next = cloneAnswer(existing);
const foundIndex = next.values.indexOf(option.value);
if (foundIndex >= 0) {
next.values.splice(foundIndex, 1);
next.labels.splice(foundIndex, 1);
next.indices.splice(foundIndex, 1);
} else {
next.values.push(option.value);
next.labels.push(option.label);
next.indices.push(index + 1);
}
return {
...state,
answers: {
...state.answers,
[questionId]: next,
},
};
}
function setSingleAnswer(
state: AskState,
questionId: string,
option: RenderOption,
index: number,
): AskState {
return {
...state,
answers: {
...state.answers,
[questionId]: {
values: [option.value],
labels: [option.label],
indices: [index + 1],
},
},
};
}
function advanceToNextTab(state: AskState): AskState {
const totalQuestions = state.questions.length;
if (totalQuestions === 1) {
return {
...state,
currentTab: totalQuestions,
mode: "submit",
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
};
}
const nextTab = Math.min(state.currentTab + 1, totalQuestions);
return {
...state,
currentTab: nextTab,
mode: nextTab === totalQuestions ? "submit" : "navigate",
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
};
}
function emptyAnswer(): AskAnswer {
return { values: [], labels: [], indices: [] };
}
function cloneAnswer(answer: AskAnswer): AskAnswer {
return {
values: [...answer.values],
labels: [...answer.labels],
indices: [...answer.indices],
customText: answer.customText,
note: answer.note,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+70
View File
@@ -0,0 +1,70 @@
export type AskQuestionType = "single" | "multi" | "code-review";
export interface AskOption {
value: string;
label: string;
description?: string;
preview?: string;
}
export interface AskQuestionInput {
id: string;
label?: string;
prompt: string;
type?: AskQuestionType;
allowOther?: boolean;
required?: boolean;
options: AskOption[];
}
export interface AskParams {
title?: string;
questions: AskQuestionInput[];
}
export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "allowOther" | "required" | "label"> {
label: string;
type: AskQuestionType;
allowOther: boolean;
required: boolean;
}
export interface AskAnswer {
values: string[];
labels: string[];
indices: number[];
customText?: string;
note?: string;
}
export interface AskResult {
title?: string;
cancelled: boolean;
questions: Array<{
id: string;
label: string;
prompt: string;
type: AskQuestionType;
}>;
answers: Record<string, AskAnswer>;
}
export type ViewMode = "navigate" | "input" | "submit";
export interface AskState {
title?: string;
questions: AskQuestion[];
currentTab: number;
optionIndex: number;
submitIndex: number;
mode: ViewMode;
inputQuestionId: string | null;
answers: Record<string, AskAnswer>;
completed: boolean;
cancelled: boolean;
}
export interface RenderOption extends AskOption {
isOther?: boolean;
}