pi config update
This commit is contained in:
65
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-note.ts
Normal file
65
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-note.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
||||
|
||||
const INLINE_NOTE_SEPARATOR = " — note: ";
|
||||
const INLINE_EDIT_CURSOR = "▍";
|
||||
|
||||
export const INLINE_NOTE_WRAP_PADDING = 2;
|
||||
|
||||
function sanitizeNoteForInlineDisplay(rawNote: string): string {
|
||||
return rawNote.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
||||
}
|
||||
|
||||
function truncateTextKeepingTail(text: string, maxLength: number): string {
|
||||
if (maxLength <= 0) return "";
|
||||
if (text.length <= maxLength) return text;
|
||||
if (maxLength === 1) return "…";
|
||||
return `…${text.slice(-(maxLength - 1))}`;
|
||||
}
|
||||
|
||||
function truncateTextKeepingHead(text: string, maxLength: number): string {
|
||||
if (maxLength <= 0) return "";
|
||||
if (text.length <= maxLength) return text;
|
||||
if (maxLength === 1) return "…";
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function buildOptionLabelWithInlineNote(
|
||||
baseOptionLabel: string,
|
||||
rawNote: string,
|
||||
isEditingNote: boolean,
|
||||
maxInlineLabelLength?: number,
|
||||
): string {
|
||||
const sanitizedNote = sanitizeNoteForInlineDisplay(rawNote);
|
||||
if (!isEditingNote && sanitizedNote.trim().length === 0) {
|
||||
return baseOptionLabel;
|
||||
}
|
||||
|
||||
const labelPrefix = `${baseOptionLabel}${INLINE_NOTE_SEPARATOR}`;
|
||||
const inlineNote = isEditingNote ? `${sanitizedNote}${INLINE_EDIT_CURSOR}` : sanitizedNote.trim();
|
||||
const inlineLabel = `${labelPrefix}${inlineNote}`;
|
||||
|
||||
if (maxInlineLabelLength == null) {
|
||||
return inlineLabel;
|
||||
}
|
||||
|
||||
return isEditingNote
|
||||
? truncateTextKeepingTail(inlineLabel, maxInlineLabelLength)
|
||||
: truncateTextKeepingHead(inlineLabel, maxInlineLabelLength);
|
||||
}
|
||||
|
||||
export function buildWrappedOptionLabelWithInlineNote(
|
||||
baseOptionLabel: string,
|
||||
rawNote: string,
|
||||
isEditingNote: boolean,
|
||||
maxInlineLabelLength: number,
|
||||
wrapPadding = INLINE_NOTE_WRAP_PADDING,
|
||||
): string[] {
|
||||
const inlineLabel = buildOptionLabelWithInlineNote(baseOptionLabel, rawNote, isEditingNote);
|
||||
const sanitizedWrapPadding = Number.isFinite(wrapPadding) ? Math.max(0, Math.floor(wrapPadding)) : 0;
|
||||
const sanitizedMaxInlineLabelLength = Number.isFinite(maxInlineLabelLength)
|
||||
? Math.max(1, Math.floor(maxInlineLabelLength))
|
||||
: 1;
|
||||
const wrapWidth = Math.max(1, sanitizedMaxInlineLabelLength - sanitizedWrapPadding);
|
||||
const wrappedLines = wrapTextWithAnsi(inlineLabel, wrapWidth);
|
||||
return wrappedLines.length > 0 ? wrappedLines : [""];
|
||||
}
|
||||
223
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-ui.ts
Normal file
223
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-ui.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
OTHER_OPTION,
|
||||
appendRecommendedTagToOptionLabels,
|
||||
buildSingleSelectionResult,
|
||||
type AskOption,
|
||||
type AskSelection,
|
||||
} from "./ask-logic";
|
||||
import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
|
||||
|
||||
interface SingleQuestionInput {
|
||||
question: string;
|
||||
options: AskOption[];
|
||||
recommended?: number;
|
||||
}
|
||||
|
||||
interface InlineSelectionResult {
|
||||
cancelled: boolean;
|
||||
selectedOption?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
function resolveInitialCursorIndexFromRecommendedOption(
|
||||
recommendedOptionIndex: number | undefined,
|
||||
optionCount: number,
|
||||
): number {
|
||||
if (recommendedOptionIndex == null) return 0;
|
||||
if (recommendedOptionIndex < 0 || recommendedOptionIndex >= optionCount) return 0;
|
||||
return recommendedOptionIndex;
|
||||
}
|
||||
|
||||
export async function askSingleQuestionWithInlineNote(
|
||||
ui: ExtensionUIContext,
|
||||
questionInput: SingleQuestionInput,
|
||||
): Promise<AskSelection> {
|
||||
const baseOptionLabels = questionInput.options.map((option) => option.label);
|
||||
const optionLabelsWithRecommendedTag = appendRecommendedTagToOptionLabels(
|
||||
baseOptionLabels,
|
||||
questionInput.recommended,
|
||||
);
|
||||
const selectableOptionLabels = [...optionLabelsWithRecommendedTag, OTHER_OPTION];
|
||||
const initialCursorIndex = resolveInitialCursorIndexFromRecommendedOption(
|
||||
questionInput.recommended,
|
||||
optionLabelsWithRecommendedTag.length,
|
||||
);
|
||||
|
||||
const result = await ui.custom<InlineSelectionResult>((tui, theme, _keybindings, done) => {
|
||||
let cursorOptionIndex = initialCursorIndex;
|
||||
let isNoteEditorOpen = false;
|
||||
let cachedRenderedLines: string[] | undefined;
|
||||
const noteByOptionIndex = new Map<number, string>();
|
||||
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => theme.fg("accent", text),
|
||||
selectList: {
|
||||
selectedPrefix: (text) => theme.fg("accent", text),
|
||||
selectedText: (text) => theme.fg("accent", text),
|
||||
description: (text) => theme.fg("muted", text),
|
||||
scrollInfo: (text) => theme.fg("dim", text),
|
||||
noMatch: (text) => theme.fg("warning", text),
|
||||
},
|
||||
};
|
||||
const noteEditor = new Editor(tui, editorTheme);
|
||||
|
||||
const requestUiRerender = () => {
|
||||
cachedRenderedLines = undefined;
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const getRawNoteForOption = (optionIndex: number): string => noteByOptionIndex.get(optionIndex) ?? "";
|
||||
const getTrimmedNoteForOption = (optionIndex: number): string => getRawNoteForOption(optionIndex).trim();
|
||||
|
||||
const loadCurrentNoteIntoEditor = () => {
|
||||
noteEditor.setText(getRawNoteForOption(cursorOptionIndex));
|
||||
};
|
||||
|
||||
const saveCurrentNoteFromEditor = (value: string) => {
|
||||
noteByOptionIndex.set(cursorOptionIndex, value);
|
||||
};
|
||||
|
||||
const submitCurrentSelection = (selectedOptionLabel: string, note: string) => {
|
||||
done({
|
||||
cancelled: false,
|
||||
selectedOption: selectedOptionLabel,
|
||||
note,
|
||||
});
|
||||
};
|
||||
|
||||
noteEditor.onChange = (value) => {
|
||||
saveCurrentNoteFromEditor(value);
|
||||
requestUiRerender();
|
||||
};
|
||||
|
||||
noteEditor.onSubmit = (value) => {
|
||||
saveCurrentNoteFromEditor(value);
|
||||
const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex];
|
||||
const trimmedNote = value.trim();
|
||||
|
||||
if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
submitCurrentSelection(selectedOptionLabel, trimmedNote);
|
||||
};
|
||||
|
||||
const render = (width: number): string[] => {
|
||||
if (cachedRenderedLines) return cachedRenderedLines;
|
||||
|
||||
const renderedLines: string[] = [];
|
||||
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
|
||||
|
||||
addLine(theme.fg("accent", "─".repeat(width)));
|
||||
for (const questionLine of wrapTextWithAnsi(questionInput.question, Math.max(1, width - 1))) {
|
||||
addLine(` ${theme.fg("text", questionLine)}`);
|
||||
}
|
||||
renderedLines.push("");
|
||||
|
||||
for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) {
|
||||
const optionLabel = selectableOptionLabels[optionIndex];
|
||||
const isCursorOption = optionIndex === cursorOptionIndex;
|
||||
const isEditingThisOption = isNoteEditorOpen && isCursorOption;
|
||||
const cursorPrefixText = isCursorOption ? "→ " : " ";
|
||||
const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
|
||||
const bullet = isCursorOption ? "●" : "○";
|
||||
const markerText = `${bullet} `;
|
||||
const optionColor = isCursorOption ? "accent" : "text";
|
||||
const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
|
||||
const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
|
||||
optionLabel,
|
||||
getRawNoteForOption(optionIndex),
|
||||
isEditingThisOption,
|
||||
Math.max(1, width - prefixWidth),
|
||||
INLINE_NOTE_WRAP_PADDING,
|
||||
);
|
||||
const continuationPrefix = " ".repeat(prefixWidth);
|
||||
addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
|
||||
for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
|
||||
addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
|
||||
}
|
||||
}
|
||||
|
||||
renderedLines.push("");
|
||||
|
||||
if (isNoteEditorOpen) {
|
||||
addLine(theme.fg("dim", " Typing note inline • Enter submit • Tab/Esc stop editing"));
|
||||
} else if (getTrimmedNoteForOption(cursorOptionIndex).length > 0) {
|
||||
addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab edit note • Esc cancel"));
|
||||
} else {
|
||||
addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab add note • Esc cancel"));
|
||||
}
|
||||
|
||||
addLine(theme.fg("accent", "─".repeat(width)));
|
||||
cachedRenderedLines = renderedLines;
|
||||
return renderedLines;
|
||||
};
|
||||
|
||||
const handleInput = (data: string) => {
|
||||
if (isNoteEditorOpen) {
|
||||
if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
|
||||
isNoteEditorOpen = false;
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
noteEditor.handleInput(data);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.up)) {
|
||||
cursorOptionIndex = Math.max(0, cursorOptionIndex - 1);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
cursorOptionIndex = Math.min(selectableOptionLabels.length - 1, cursorOptionIndex + 1);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.tab)) {
|
||||
isNoteEditorOpen = true;
|
||||
loadCurrentNoteIntoEditor();
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex];
|
||||
const trimmedNote = getTrimmedNoteForOption(cursorOptionIndex);
|
||||
|
||||
if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
|
||||
isNoteEditorOpen = true;
|
||||
loadCurrentNoteIntoEditor();
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
submitCurrentSelection(selectedOptionLabel, trimmedNote);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done({ cancelled: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cachedRenderedLines = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.cancelled || !result.selectedOption) {
|
||||
return { selectedOptions: [] };
|
||||
}
|
||||
|
||||
return buildSingleSelectionResult(result.selectedOption, result.note);
|
||||
}
|
||||
98
pi/.pi/agent/extensions/pi-ask-tool/ask-logic.ts
Normal file
98
pi/.pi/agent/extensions/pi-ask-tool/ask-logic.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export const OTHER_OPTION = "Other (type your own)";
|
||||
const RECOMMENDED_OPTION_TAG = " (Recommended)";
|
||||
|
||||
export interface AskOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AskQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options: AskOption[];
|
||||
multi?: boolean;
|
||||
recommended?: number;
|
||||
}
|
||||
|
||||
export interface AskSelection {
|
||||
selectedOptions: string[];
|
||||
customInput?: string;
|
||||
}
|
||||
|
||||
export function appendRecommendedTagToOptionLabels(
|
||||
optionLabels: string[],
|
||||
recommendedOptionIndex?: number,
|
||||
): string[] {
|
||||
if (
|
||||
recommendedOptionIndex == null ||
|
||||
recommendedOptionIndex < 0 ||
|
||||
recommendedOptionIndex >= optionLabels.length
|
||||
) {
|
||||
return optionLabels;
|
||||
}
|
||||
|
||||
return optionLabels.map((optionLabel, optionIndex) => {
|
||||
if (optionIndex !== recommendedOptionIndex) return optionLabel;
|
||||
if (optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) return optionLabel;
|
||||
return `${optionLabel}${RECOMMENDED_OPTION_TAG}`;
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecommendedTagFromOptionLabel(optionLabel: string): string {
|
||||
if (!optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) {
|
||||
return optionLabel;
|
||||
}
|
||||
return optionLabel.slice(0, -RECOMMENDED_OPTION_TAG.length);
|
||||
}
|
||||
|
||||
export function buildSingleSelectionResult(selectedOptionLabel: string, note?: string): AskSelection {
|
||||
const normalizedSelectedOption = removeRecommendedTagFromOptionLabel(selectedOptionLabel);
|
||||
const normalizedNote = note?.trim();
|
||||
|
||||
if (normalizedSelectedOption === OTHER_OPTION) {
|
||||
if (normalizedNote) {
|
||||
return { selectedOptions: [], customInput: normalizedNote };
|
||||
}
|
||||
return { selectedOptions: [] };
|
||||
}
|
||||
|
||||
if (normalizedNote) {
|
||||
return { selectedOptions: [`${normalizedSelectedOption} - ${normalizedNote}`] };
|
||||
}
|
||||
|
||||
return { selectedOptions: [normalizedSelectedOption] };
|
||||
}
|
||||
|
||||
export function buildMultiSelectionResult(
|
||||
optionLabels: string[],
|
||||
selectedOptionIndexes: number[],
|
||||
optionNotes: string[],
|
||||
otherOptionIndex: number,
|
||||
): AskSelection {
|
||||
const selectedOptionSet = new Set(selectedOptionIndexes);
|
||||
const selectedOptions: string[] = [];
|
||||
let customInput: string | undefined;
|
||||
|
||||
for (let optionIndex = 0; optionIndex < optionLabels.length; optionIndex++) {
|
||||
if (!selectedOptionSet.has(optionIndex)) continue;
|
||||
|
||||
const optionLabel = removeRecommendedTagFromOptionLabel(optionLabels[optionIndex]);
|
||||
const optionNote = optionNotes[optionIndex]?.trim();
|
||||
|
||||
if (optionIndex === otherOptionIndex) {
|
||||
if (optionNote) customInput = optionNote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionNote) {
|
||||
selectedOptions.push(`${optionLabel} - ${optionNote}`);
|
||||
} else {
|
||||
selectedOptions.push(optionLabel);
|
||||
}
|
||||
}
|
||||
|
||||
if (customInput) {
|
||||
return { selectedOptions, customInput };
|
||||
}
|
||||
|
||||
return { selectedOptions };
|
||||
}
|
||||
514
pi/.pi/agent/extensions/pi-ask-tool/ask-tabs-ui.ts
Normal file
514
pi/.pi/agent/extensions/pi-ask-tool/ask-tabs-ui.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
OTHER_OPTION,
|
||||
appendRecommendedTagToOptionLabels,
|
||||
buildMultiSelectionResult,
|
||||
buildSingleSelectionResult,
|
||||
type AskQuestion,
|
||||
type AskSelection,
|
||||
} from "./ask-logic";
|
||||
import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
|
||||
|
||||
interface PreparedQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
tabLabel: string;
|
||||
multi: boolean;
|
||||
otherOptionIndex: number;
|
||||
}
|
||||
|
||||
interface TabsUIState {
|
||||
cancelled: boolean;
|
||||
selectedOptionIndexesByQuestion: number[][];
|
||||
noteByQuestionByOption: string[][];
|
||||
}
|
||||
|
||||
export function formatSelectionForSubmitReview(selection: AskSelection, isMulti: boolean): string {
|
||||
const hasSelectedOptions = selection.selectedOptions.length > 0;
|
||||
const hasCustomInput = Boolean(selection.customInput);
|
||||
|
||||
if (hasSelectedOptions && hasCustomInput) {
|
||||
const selectedPart = isMulti
|
||||
? `[${selection.selectedOptions.join(", ")}]`
|
||||
: selection.selectedOptions[0];
|
||||
return `${selectedPart} + Other: ${selection.customInput}`;
|
||||
}
|
||||
|
||||
if (hasCustomInput) {
|
||||
return `Other: ${selection.customInput}`;
|
||||
}
|
||||
|
||||
if (hasSelectedOptions) {
|
||||
return isMulti ? `[${selection.selectedOptions.join(", ")}]` : selection.selectedOptions[0];
|
||||
}
|
||||
|
||||
return "(not answered)";
|
||||
}
|
||||
|
||||
function clampIndex(index: number | undefined, maxExclusive: number): number {
|
||||
if (index == null || Number.isNaN(index) || maxExclusive <= 0) return 0;
|
||||
if (index < 0) return 0;
|
||||
if (index >= maxExclusive) return maxExclusive - 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function normalizeTabLabel(id: string, fallback: string): string {
|
||||
const normalized = id.trim().replace(/[_-]+/g, " ");
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function buildSelectionForQuestion(
|
||||
question: PreparedQuestion,
|
||||
selectedOptionIndexes: number[],
|
||||
noteByOptionIndex: string[],
|
||||
): AskSelection {
|
||||
if (selectedOptionIndexes.length === 0) {
|
||||
return { selectedOptions: [] };
|
||||
}
|
||||
|
||||
if (question.multi) {
|
||||
return buildMultiSelectionResult(question.options, selectedOptionIndexes, noteByOptionIndex, question.otherOptionIndex);
|
||||
}
|
||||
|
||||
const selectedOptionIndex = selectedOptionIndexes[0];
|
||||
const selectedOptionLabel = question.options[selectedOptionIndex] ?? OTHER_OPTION;
|
||||
const note = noteByOptionIndex[selectedOptionIndex] ?? "";
|
||||
return buildSingleSelectionResult(selectedOptionLabel, note);
|
||||
}
|
||||
|
||||
function isQuestionSelectionValid(
|
||||
question: PreparedQuestion,
|
||||
selectedOptionIndexes: number[],
|
||||
noteByOptionIndex: string[],
|
||||
): boolean {
|
||||
if (selectedOptionIndexes.length === 0) return false;
|
||||
if (!selectedOptionIndexes.includes(question.otherOptionIndex)) return true;
|
||||
const otherNote = noteByOptionIndex[question.otherOptionIndex]?.trim() ?? "";
|
||||
return otherNote.length > 0;
|
||||
}
|
||||
|
||||
function createTabsUiStateSnapshot(
|
||||
cancelled: boolean,
|
||||
selectedOptionIndexesByQuestion: number[][],
|
||||
noteByQuestionByOption: string[][],
|
||||
): TabsUIState {
|
||||
return {
|
||||
cancelled,
|
||||
selectedOptionIndexesByQuestion: selectedOptionIndexesByQuestion.map((indexes) => [...indexes]),
|
||||
noteByQuestionByOption: noteByQuestionByOption.map((notes) => [...notes]),
|
||||
};
|
||||
}
|
||||
|
||||
function addIndexToSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
|
||||
if (selectedOptionIndexes.includes(optionIndex)) return selectedOptionIndexes;
|
||||
return [...selectedOptionIndexes, optionIndex].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function removeIndexFromSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
|
||||
return selectedOptionIndexes.filter((index) => index !== optionIndex);
|
||||
}
|
||||
|
||||
export async function askQuestionsWithTabs(
|
||||
ui: ExtensionUIContext,
|
||||
questions: AskQuestion[],
|
||||
): Promise<{ cancelled: boolean; selections: AskSelection[] }> {
|
||||
const preparedQuestions: PreparedQuestion[] = questions.map((question, questionIndex) => {
|
||||
const baseOptionLabels = question.options.map((option) => option.label);
|
||||
const optionLabels = [...appendRecommendedTagToOptionLabels(baseOptionLabels, question.recommended), OTHER_OPTION];
|
||||
return {
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
options: optionLabels,
|
||||
tabLabel: normalizeTabLabel(question.id, `Q${questionIndex + 1}`),
|
||||
multi: question.multi === true,
|
||||
otherOptionIndex: optionLabels.length - 1,
|
||||
};
|
||||
});
|
||||
|
||||
const initialCursorOptionIndexByQuestion = preparedQuestions.map((preparedQuestion, questionIndex) =>
|
||||
clampIndex(questions[questionIndex].recommended, preparedQuestion.options.length),
|
||||
);
|
||||
|
||||
const result = await ui.custom<TabsUIState>((tui, theme, _keybindings, done) => {
|
||||
let activeTabIndex = 0;
|
||||
let isNoteEditorOpen = false;
|
||||
let cachedRenderedLines: string[] | undefined;
|
||||
const cursorOptionIndexByQuestion = [...initialCursorOptionIndexByQuestion];
|
||||
const selectedOptionIndexesByQuestion = preparedQuestions.map(() => [] as number[]);
|
||||
const noteByQuestionByOption = preparedQuestions.map((preparedQuestion) =>
|
||||
Array(preparedQuestion.options.length).fill("") as string[],
|
||||
);
|
||||
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => theme.fg("accent", text),
|
||||
selectList: {
|
||||
selectedPrefix: (text) => theme.fg("accent", text),
|
||||
selectedText: (text) => theme.fg("accent", text),
|
||||
description: (text) => theme.fg("muted", text),
|
||||
scrollInfo: (text) => theme.fg("dim", text),
|
||||
noMatch: (text) => theme.fg("warning", text),
|
||||
},
|
||||
};
|
||||
const noteEditor = new Editor(tui, editorTheme);
|
||||
|
||||
const submitTabIndex = preparedQuestions.length;
|
||||
|
||||
const requestUiRerender = () => {
|
||||
cachedRenderedLines = undefined;
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const getActiveQuestionIndex = (): number | null => {
|
||||
if (activeTabIndex >= preparedQuestions.length) return null;
|
||||
return activeTabIndex;
|
||||
};
|
||||
|
||||
const getQuestionNote = (questionIndex: number, optionIndex: number): string =>
|
||||
noteByQuestionByOption[questionIndex]?.[optionIndex] ?? "";
|
||||
|
||||
const getTrimmedQuestionNote = (questionIndex: number, optionIndex: number): string =>
|
||||
getQuestionNote(questionIndex, optionIndex).trim();
|
||||
|
||||
const isAllQuestionSelectionsValid = (): boolean =>
|
||||
preparedQuestions.every((preparedQuestion, questionIndex) =>
|
||||
isQuestionSelectionValid(
|
||||
preparedQuestion,
|
||||
selectedOptionIndexesByQuestion[questionIndex],
|
||||
noteByQuestionByOption[questionIndex],
|
||||
),
|
||||
);
|
||||
|
||||
const openNoteEditorForActiveOption = () => {
|
||||
const questionIndex = getActiveQuestionIndex();
|
||||
if (questionIndex == null) return;
|
||||
|
||||
isNoteEditorOpen = true;
|
||||
const optionIndex = cursorOptionIndexByQuestion[questionIndex];
|
||||
noteEditor.setText(getQuestionNote(questionIndex, optionIndex));
|
||||
requestUiRerender();
|
||||
};
|
||||
|
||||
const advanceToNextTabOrSubmit = () => {
|
||||
activeTabIndex = Math.min(submitTabIndex, activeTabIndex + 1);
|
||||
};
|
||||
|
||||
noteEditor.onChange = (value) => {
|
||||
const questionIndex = getActiveQuestionIndex();
|
||||
if (questionIndex == null) return;
|
||||
const optionIndex = cursorOptionIndexByQuestion[questionIndex];
|
||||
noteByQuestionByOption[questionIndex][optionIndex] = value;
|
||||
requestUiRerender();
|
||||
};
|
||||
|
||||
noteEditor.onSubmit = (value) => {
|
||||
const questionIndex = getActiveQuestionIndex();
|
||||
if (questionIndex == null) return;
|
||||
|
||||
const preparedQuestion = preparedQuestions[questionIndex];
|
||||
const optionIndex = cursorOptionIndexByQuestion[questionIndex];
|
||||
noteByQuestionByOption[questionIndex][optionIndex] = value;
|
||||
const trimmedNote = value.trim();
|
||||
|
||||
if (preparedQuestion.multi) {
|
||||
if (trimmedNote.length > 0) {
|
||||
selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(
|
||||
selectedOptionIndexesByQuestion[questionIndex],
|
||||
optionIndex,
|
||||
);
|
||||
}
|
||||
if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
isNoteEditorOpen = false;
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedOptionIndexesByQuestion[questionIndex] = [optionIndex];
|
||||
if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
isNoteEditorOpen = false;
|
||||
advanceToNextTabOrSubmit();
|
||||
requestUiRerender();
|
||||
};
|
||||
|
||||
const renderTabs = (): string => {
|
||||
const tabParts: string[] = ["← "];
|
||||
for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
|
||||
const preparedQuestion = preparedQuestions[questionIndex];
|
||||
const isActiveTab = questionIndex === activeTabIndex;
|
||||
const isQuestionValid = isQuestionSelectionValid(
|
||||
preparedQuestion,
|
||||
selectedOptionIndexesByQuestion[questionIndex],
|
||||
noteByQuestionByOption[questionIndex],
|
||||
);
|
||||
const statusIcon = isQuestionValid ? "■" : "□";
|
||||
const tabLabel = ` ${statusIcon} ${preparedQuestion.tabLabel} `;
|
||||
const styledTabLabel = isActiveTab
|
||||
? theme.bg("selectedBg", theme.fg("text", tabLabel))
|
||||
: theme.fg(isQuestionValid ? "success" : "muted", tabLabel);
|
||||
tabParts.push(`${styledTabLabel} `);
|
||||
}
|
||||
|
||||
const isSubmitTabActive = activeTabIndex === submitTabIndex;
|
||||
const canSubmit = isAllQuestionSelectionsValid();
|
||||
const submitLabel = " ✓ Submit ";
|
||||
const styledSubmitLabel = isSubmitTabActive
|
||||
? theme.bg("selectedBg", theme.fg("text", submitLabel))
|
||||
: theme.fg(canSubmit ? "success" : "dim", submitLabel);
|
||||
tabParts.push(`${styledSubmitLabel} →`);
|
||||
return tabParts.join("");
|
||||
};
|
||||
|
||||
const renderSubmitTab = (width: number, renderedLines: string[]): void => {
|
||||
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
|
||||
|
||||
addLine(theme.fg("accent", theme.bold(" Review answers")));
|
||||
renderedLines.push("");
|
||||
|
||||
for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
|
||||
const preparedQuestion = preparedQuestions[questionIndex];
|
||||
const selection = buildSelectionForQuestion(
|
||||
preparedQuestion,
|
||||
selectedOptionIndexesByQuestion[questionIndex],
|
||||
noteByQuestionByOption[questionIndex],
|
||||
);
|
||||
const value = formatSelectionForSubmitReview(selection, preparedQuestion.multi);
|
||||
const isValid = isQuestionSelectionValid(
|
||||
preparedQuestion,
|
||||
selectedOptionIndexesByQuestion[questionIndex],
|
||||
noteByQuestionByOption[questionIndex],
|
||||
);
|
||||
const statusIcon = isValid ? theme.fg("success", "●") : theme.fg("warning", "○");
|
||||
addLine(` ${statusIcon} ${theme.fg("muted", `${preparedQuestion.tabLabel}:`)} ${theme.fg("text", value)}`);
|
||||
}
|
||||
|
||||
renderedLines.push("");
|
||||
if (isAllQuestionSelectionsValid()) {
|
||||
addLine(theme.fg("success", " Press Enter to submit"));
|
||||
} else {
|
||||
const missingQuestions = preparedQuestions
|
||||
.filter((preparedQuestion, questionIndex) =>
|
||||
!isQuestionSelectionValid(
|
||||
preparedQuestion,
|
||||
selectedOptionIndexesByQuestion[questionIndex],
|
||||
noteByQuestionByOption[questionIndex],
|
||||
),
|
||||
)
|
||||
.map((preparedQuestion) => preparedQuestion.tabLabel)
|
||||
.join(", ");
|
||||
addLine(theme.fg("warning", ` Complete required answers: ${missingQuestions}`));
|
||||
}
|
||||
addLine(theme.fg("dim", " ←/→ switch tabs • Esc cancel"));
|
||||
};
|
||||
|
||||
const renderQuestionTab = (width: number, renderedLines: string[], questionIndex: number): void => {
|
||||
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
|
||||
const preparedQuestion = preparedQuestions[questionIndex];
|
||||
const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex];
|
||||
const selectedOptionIndexes = selectedOptionIndexesByQuestion[questionIndex];
|
||||
|
||||
for (const questionLine of wrapTextWithAnsi(preparedQuestion.question, Math.max(1, width - 1))) {
|
||||
addLine(` ${theme.fg("text", questionLine)}`);
|
||||
}
|
||||
renderedLines.push("");
|
||||
|
||||
for (let optionIndex = 0; optionIndex < preparedQuestion.options.length; optionIndex++) {
|
||||
const optionLabel = preparedQuestion.options[optionIndex];
|
||||
const isCursorOption = optionIndex === cursorOptionIndex;
|
||||
const isOptionSelected = selectedOptionIndexes.includes(optionIndex);
|
||||
const isEditingThisOption = isNoteEditorOpen && isCursorOption;
|
||||
const cursorPrefixText = isCursorOption ? "→ " : " ";
|
||||
const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
|
||||
const markerText = preparedQuestion.multi
|
||||
? `${isOptionSelected ? "[x]" : "[ ]"} `
|
||||
: `${isOptionSelected ? "●" : "○"} `;
|
||||
const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
|
||||
const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
|
||||
const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
|
||||
optionLabel,
|
||||
getQuestionNote(questionIndex, optionIndex),
|
||||
isEditingThisOption,
|
||||
Math.max(1, width - prefixWidth),
|
||||
INLINE_NOTE_WRAP_PADDING,
|
||||
);
|
||||
const continuationPrefix = " ".repeat(prefixWidth);
|
||||
addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
|
||||
for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
|
||||
addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
|
||||
}
|
||||
}
|
||||
|
||||
renderedLines.push("");
|
||||
if (isNoteEditorOpen) {
|
||||
addLine(theme.fg("dim", " Typing note inline • Enter save note • Tab/Esc stop editing"));
|
||||
} else {
|
||||
if (preparedQuestion.multi) {
|
||||
addLine(
|
||||
theme.fg(
|
||||
"dim",
|
||||
" ↑↓ move • Enter toggle/select • Tab add note • ←/→ switch tabs • Esc cancel",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
addLine(
|
||||
theme.fg("dim", " ↑↓ move • Enter select • Tab add note • ←/→ switch tabs • Esc cancel"),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const render = (width: number): string[] => {
|
||||
if (cachedRenderedLines) return cachedRenderedLines;
|
||||
|
||||
const renderedLines: string[] = [];
|
||||
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
|
||||
|
||||
addLine(theme.fg("accent", "─".repeat(width)));
|
||||
addLine(` ${renderTabs()}`);
|
||||
renderedLines.push("");
|
||||
|
||||
if (activeTabIndex === submitTabIndex) {
|
||||
renderSubmitTab(width, renderedLines);
|
||||
} else {
|
||||
renderQuestionTab(width, renderedLines, activeTabIndex);
|
||||
}
|
||||
|
||||
addLine(theme.fg("accent", "─".repeat(width)));
|
||||
cachedRenderedLines = renderedLines;
|
||||
return renderedLines;
|
||||
};
|
||||
|
||||
const handleInput = (data: string) => {
|
||||
if (isNoteEditorOpen) {
|
||||
if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
|
||||
isNoteEditorOpen = false;
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
noteEditor.handleInput(data);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.left)) {
|
||||
activeTabIndex = (activeTabIndex - 1 + preparedQuestions.length + 1) % (preparedQuestions.length + 1);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.right)) {
|
||||
activeTabIndex = (activeTabIndex + 1) % (preparedQuestions.length + 1);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTabIndex === submitTabIndex) {
|
||||
if (matchesKey(data, Key.enter) && isAllQuestionSelectionsValid()) {
|
||||
done(createTabsUiStateSnapshot(false, selectedOptionIndexesByQuestion, noteByQuestionByOption));
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const questionIndex = activeTabIndex;
|
||||
const preparedQuestion = preparedQuestions[questionIndex];
|
||||
|
||||
if (matchesKey(data, Key.up)) {
|
||||
cursorOptionIndexByQuestion[questionIndex] = Math.max(0, cursorOptionIndexByQuestion[questionIndex] - 1);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.down)) {
|
||||
cursorOptionIndexByQuestion[questionIndex] = Math.min(
|
||||
preparedQuestion.options.length - 1,
|
||||
cursorOptionIndexByQuestion[questionIndex] + 1,
|
||||
);
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.tab)) {
|
||||
openNoteEditorForActiveOption();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex];
|
||||
|
||||
if (preparedQuestion.multi) {
|
||||
const currentlySelected = selectedOptionIndexesByQuestion[questionIndex];
|
||||
if (currentlySelected.includes(cursorOptionIndex)) {
|
||||
selectedOptionIndexesByQuestion[questionIndex] = removeIndexFromSelection(currentlySelected, cursorOptionIndex);
|
||||
} else {
|
||||
selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(currentlySelected, cursorOptionIndex);
|
||||
}
|
||||
|
||||
if (
|
||||
cursorOptionIndex === preparedQuestion.otherOptionIndex &&
|
||||
selectedOptionIndexesByQuestion[questionIndex].includes(cursorOptionIndex) &&
|
||||
getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
|
||||
) {
|
||||
openNoteEditorForActiveOption();
|
||||
return;
|
||||
}
|
||||
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedOptionIndexesByQuestion[questionIndex] = [cursorOptionIndex];
|
||||
if (
|
||||
cursorOptionIndex === preparedQuestion.otherOptionIndex &&
|
||||
getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
|
||||
) {
|
||||
openNoteEditorForActiveOption();
|
||||
return;
|
||||
}
|
||||
|
||||
advanceToNextTabOrSubmit();
|
||||
requestUiRerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cachedRenderedLines = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
return {
|
||||
cancelled: true,
|
||||
selections: preparedQuestions.map(() => ({ selectedOptions: [] } satisfies AskSelection)),
|
||||
};
|
||||
}
|
||||
|
||||
const selections = preparedQuestions.map((preparedQuestion, questionIndex) =>
|
||||
buildSelectionForQuestion(
|
||||
preparedQuestion,
|
||||
result.selectedOptionIndexesByQuestion[questionIndex] ?? [],
|
||||
result.noteByQuestionByOption[questionIndex] ?? Array(preparedQuestion.options.length).fill(""),
|
||||
),
|
||||
);
|
||||
|
||||
return { cancelled: result.cancelled, selections };
|
||||
}
|
||||
237
pi/.pi/agent/extensions/pi-ask-tool/index.ts
Normal file
237
pi/.pi/agent/extensions/pi-ask-tool/index.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
import { OTHER_OPTION, type AskQuestion } from "./ask-logic";
|
||||
import { askSingleQuestionWithInlineNote } from "./ask-inline-ui";
|
||||
import { askQuestionsWithTabs } from "./ask-tabs-ui";
|
||||
|
||||
const OptionItemSchema = Type.Object({
|
||||
label: Type.String({ description: "Display label" }),
|
||||
});
|
||||
|
||||
const QuestionItemSchema = Type.Object({
|
||||
id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
|
||||
question: Type.String({ description: "Question text" }),
|
||||
options: Type.Array(OptionItemSchema, {
|
||||
description: "Available options. Do not include 'Other'.",
|
||||
minItems: 1,
|
||||
}),
|
||||
multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
|
||||
recommended: Type.Optional(
|
||||
Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
|
||||
),
|
||||
});
|
||||
|
||||
const AskParamsSchema = Type.Object({
|
||||
questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
|
||||
});
|
||||
|
||||
type AskParams = Static<typeof AskParamsSchema>;
|
||||
|
||||
interface QuestionResult {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
multi: boolean;
|
||||
selectedOptions: string[];
|
||||
customInput?: string;
|
||||
}
|
||||
|
||||
interface AskToolDetails {
|
||||
id?: string;
|
||||
question?: string;
|
||||
options?: string[];
|
||||
multi?: boolean;
|
||||
selectedOptions?: string[];
|
||||
customInput?: string;
|
||||
results?: QuestionResult[];
|
||||
}
|
||||
|
||||
function sanitizeForSessionText(value: string): string {
|
||||
return value
|
||||
.replace(/[\r\n\t]/g, " ")
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeOptionForSessionText(option: string): string {
|
||||
const sanitizedOption = sanitizeForSessionText(option);
|
||||
return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
|
||||
}
|
||||
|
||||
function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
|
||||
const selectedOptions = result.selectedOptions
|
||||
.map((selectedOption) => sanitizeForSessionText(selectedOption))
|
||||
.filter((selectedOption) => selectedOption.length > 0);
|
||||
|
||||
const rawCustomInput = result.customInput;
|
||||
const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
|
||||
|
||||
return {
|
||||
id: sanitizeForSessionText(result.id) || "(unknown)",
|
||||
question: sanitizeForSessionText(result.question) || "(empty question)",
|
||||
options: result.options.map(sanitizeOptionForSessionText),
|
||||
multi: result.multi,
|
||||
selectedOptions,
|
||||
customInput: customInput && customInput.length > 0 ? customInput : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSelectionForSummary(result: QuestionResult): string {
|
||||
const hasSelectedOptions = result.selectedOptions.length > 0;
|
||||
const hasCustomInput = Boolean(result.customInput);
|
||||
|
||||
if (!hasSelectedOptions && !hasCustomInput) {
|
||||
return "(cancelled)";
|
||||
}
|
||||
|
||||
if (hasSelectedOptions && hasCustomInput) {
|
||||
const selectedPart = result.multi
|
||||
? `[${result.selectedOptions.join(", ")}]`
|
||||
: result.selectedOptions[0];
|
||||
return `${selectedPart} + Other: "${result.customInput}"`;
|
||||
}
|
||||
|
||||
if (hasCustomInput) {
|
||||
return `"${result.customInput}"`;
|
||||
}
|
||||
|
||||
if (result.multi) {
|
||||
return `[${result.selectedOptions.join(", ")}]`;
|
||||
}
|
||||
|
||||
return result.selectedOptions[0];
|
||||
}
|
||||
|
||||
function formatQuestionResult(result: QuestionResult): string {
|
||||
return `${result.id}: ${formatSelectionForSummary(result)}`;
|
||||
}
|
||||
|
||||
function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
|
||||
const lines: string[] = [
|
||||
`Question ${questionIndex + 1} (${result.id})`,
|
||||
`Prompt: ${result.question}`,
|
||||
"Options:",
|
||||
...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`),
|
||||
"Response:",
|
||||
];
|
||||
|
||||
const hasSelectedOptions = result.selectedOptions.length > 0;
|
||||
const hasCustomInput = Boolean(result.customInput);
|
||||
|
||||
if (!hasSelectedOptions && !hasCustomInput) {
|
||||
lines.push(" Selected: (cancelled)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (hasSelectedOptions) {
|
||||
const selectedText = result.multi
|
||||
? `[${result.selectedOptions.join(", ")}]`
|
||||
: result.selectedOptions[0];
|
||||
lines.push(` Selected: ${selectedText}`);
|
||||
}
|
||||
|
||||
if (hasCustomInput) {
|
||||
if (!hasSelectedOptions) {
|
||||
lines.push(` Selected: ${OTHER_OPTION}`);
|
||||
}
|
||||
lines.push(` Custom input: ${result.customInput}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildAskSessionContent(results: QuestionResult[]): string {
|
||||
const safeResults = results.map(toSessionSafeQuestionResult);
|
||||
const summaryLines = safeResults.map(formatQuestionResult).join("\n");
|
||||
const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
|
||||
return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
|
||||
}
|
||||
|
||||
const ASK_TOOL_DESCRIPTION = `
|
||||
Ask the user for clarification when a choice materially affects the outcome.
|
||||
|
||||
- Use when multiple valid approaches have different trade-offs.
|
||||
- Prefer 2-5 concise options.
|
||||
- Use multi=true when multiple answers are valid.
|
||||
- Use recommended=<index> (0-indexed) to mark the default option.
|
||||
- You can ask multiple related questions in one call using questions[].
|
||||
- Do NOT include an 'Other' option; UI adds it automatically.
|
||||
`.trim();
|
||||
|
||||
export default function askExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "ask",
|
||||
label: "Ask",
|
||||
description: ASK_TOOL_DESCRIPTION,
|
||||
parameters: AskParamsSchema,
|
||||
|
||||
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: ask tool requires interactive mode" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (params.questions.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: questions must not be empty" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (params.questions.length === 1) {
|
||||
const [q] = params.questions;
|
||||
const selection = q.multi
|
||||
? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
|
||||
: await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
|
||||
const optionLabels = q.options.map((option) => option.label);
|
||||
|
||||
const result: QuestionResult = {
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
options: optionLabels,
|
||||
multi: q.multi ?? false,
|
||||
selectedOptions: selection.selectedOptions,
|
||||
customInput: selection.customInput,
|
||||
};
|
||||
|
||||
const details: AskToolDetails = {
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
options: optionLabels,
|
||||
multi: q.multi ?? false,
|
||||
selectedOptions: selection.selectedOptions,
|
||||
customInput: selection.customInput,
|
||||
results: [result],
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: buildAskSessionContent([result]) }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
const results: QuestionResult[] = [];
|
||||
const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
|
||||
for (let i = 0; i < params.questions.length; i++) {
|
||||
const q = params.questions[i];
|
||||
const selection = tabResult.selections[i] ?? { selectedOptions: [] };
|
||||
results.push({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
options: q.options.map((option) => option.label),
|
||||
multi: q.multi ?? false,
|
||||
selectedOptions: selection.selectedOptions,
|
||||
customInput: selection.customInput,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: buildAskSessionContent(results) }],
|
||||
details: { results } satisfies AskToolDetails,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user