@pi9/ask 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ import type { MessageRenderOptions, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ import { parseAskReplayDetails, type AskReplayDetails } from "./replay.js";
5
+
6
+ interface RenderMessage {
7
+ content?: unknown;
8
+ details?: unknown;
9
+ }
10
+
11
+ type Selection = AskReplayDetails["answer"]["selections"][number];
12
+ type RecordValue = Record<string, unknown>;
13
+
14
+ /**
15
+ * Render a completed ask replay for registration with
16
+ * `pi.registerMessageRenderer("ask:reanswer", renderAskReanswerMessage)`.
17
+ *
18
+ * The renderer deliberately returns a Text component rather than pre-wrapped
19
+ * strings. Text performs wrapping using terminal column widths, including
20
+ * when the theme has added ANSI styling to the label.
21
+ */
22
+ export const renderAskReanswerMessage = (
23
+ message: RenderMessage,
24
+ options: Pick<MessageRenderOptions, "expanded">,
25
+ theme: Pick<Theme, "fg"> | undefined,
26
+ ): Text => renderMessage(message, options, theme);
27
+
28
+ function renderMessage(
29
+ message: RenderMessage,
30
+ options: Pick<MessageRenderOptions, "expanded">,
31
+ theme: Pick<Theme, "fg"> | undefined,
32
+ ): Text {
33
+ const details = parseAskReplayDetails(message.details);
34
+ const content = details
35
+ ? formatDetails(details, options.expanded, theme)
36
+ : textualContent(message.content);
37
+ return new Text(content, 0, 0);
38
+ }
39
+
40
+ function formatDetails(details: AskReplayDetails, expanded: boolean, theme: Pick<Theme, "fg"> | undefined): string {
41
+ const label = colorLabel("Revised answer", theme);
42
+ const selections = details.answer.selections;
43
+ const freeform = details.answer.freeform;
44
+
45
+ if (!expanded) {
46
+ const lines: string[] = [label];
47
+ if (selections.length > 0) lines.push(`Selected: ${selections.map(selection => selection.label).join(", ")}`);
48
+ if (freeform) lines.push(`Freeform: ${freeform}`);
49
+ if (selections.length === 0 && !freeform) lines.push("No answer provided.");
50
+ return lines.join(" · ");
51
+ }
52
+
53
+ const lines: string[] = [label, `Question: ${details.question}`];
54
+ if (details.context) lines.push(`Context: ${details.context}`);
55
+ if (selections.length > 0) {
56
+ lines.push("Selections:");
57
+ for (const selection of selections) {
58
+ let line = `- ${selection.label}`;
59
+ if (selection.description) line += ` — ${selection.description}`;
60
+ if (selection.comment) line += ` (${selection.comment})`;
61
+ lines.push(line);
62
+ }
63
+ }
64
+ if (freeform) lines.push(`Freeform: ${freeform}`);
65
+ if (selections.length === 0 && !freeform) lines.push("No answer provided.");
66
+ return lines.join("\n");
67
+ }
68
+
69
+ function textualContent(content: unknown): string {
70
+ if (typeof content === "string") return content;
71
+ if (!Array.isArray(content)) return "";
72
+ return content
73
+ .filter(isRecord)
74
+ .filter(item => item.type === "text" && typeof item.text === "string")
75
+ .map(item => item.text as string)
76
+ .join("\n");
77
+ }
78
+
79
+ function colorLabel(label: string, theme: Pick<Theme, "fg"> | undefined): string {
80
+ return typeof theme?.fg === "function" ? theme.fg("customMessageLabel", label) : label;
81
+ }
82
+
83
+ function isRecord(value: unknown): value is RecordValue {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
package/src/replay.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { SessionEntry, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
2
+ import { Check } from "typebox/value";
3
+
4
+ import { AskAnswerSchema, AskParamsSchema, AskReplayDetailsSchema } from "./schema.js";
5
+ import { formatAskAnswer } from "./response.js";
6
+ import type { AskAnswer, AskParams, AskReplayDetails, ValidatedAskParams } from "./types.js";
7
+ import { validateAskParams } from "./validation.js";
8
+
9
+ export const ASK_REPLAY_CUSTOM_TYPE = "ask:reanswer" as const;
10
+
11
+ export type { AskReplayDetails } from "./types.js";
12
+
13
+ export type AskReplayMessage = {
14
+ customType: typeof ASK_REPLAY_CUSTOM_TYPE;
15
+ content: string;
16
+ display: false;
17
+ details: AskReplayDetails;
18
+ };
19
+
20
+ /** Parse the canonical answer shape used by native results and replay details. */
21
+ export function parseAskAnswer(value: unknown): AskAnswer | undefined {
22
+ return Check(AskAnswerSchema, value) ? value : undefined;
23
+ }
24
+
25
+ /** Parse canonical replay details, without validating the containing message. */
26
+ export function parseAskReplayDetails(value: unknown): AskReplayDetails | undefined {
27
+ return Check(AskReplayDetailsSchema, value) ? value : undefined;
28
+ }
29
+
30
+ export type AskReplayResolution =
31
+ | { status: "resolved"; toolCallId: string; params: ValidatedAskParams }
32
+ | {
33
+ status: "not-replayable";
34
+ reason: "no-entry" | "not-assistant" | "not-ask" | "multiple-tool-calls" | "mixed-tools" | "invalid-arguments";
35
+ };
36
+
37
+ export function buildAskReplayMessage(toolCallId: string, params: ValidatedAskParams, answer: AskAnswer): AskReplayMessage {
38
+ return {
39
+ customType: ASK_REPLAY_CUSTOM_TYPE,
40
+ content: formatAskAnswer(answer).replaceAll("\n", " · "),
41
+ display: false,
42
+ details: {
43
+ toolCallId,
44
+ question: params.question,
45
+ ...(params.context !== undefined ? { context: params.context } : {}),
46
+ allowMultiple: params.allowMultiple,
47
+ answer,
48
+ },
49
+ };
50
+ }
51
+
52
+ /** Resolve only the selected leaf, or its immediate parent when Pi created a branch summary. */
53
+ export function resolveAskReplayTarget(
54
+ event: Pick<SessionTreeEvent, "newLeafId" | "summaryEntry">,
55
+ getEntry: (id: string) => SessionEntry | undefined | null,
56
+ ): AskReplayResolution {
57
+ if (!event.newLeafId) return rejected("no-entry");
58
+
59
+ let entry = getEntry(event.newLeafId);
60
+ const summary = event.summaryEntry?.id === event.newLeafId
61
+ ? event.summaryEntry
62
+ : entry?.type === "branch_summary" ? entry : undefined;
63
+ if (summary) entry = summary.parentId ? getEntry(summary.parentId) : undefined;
64
+ if (!entry) return rejected("no-entry");
65
+
66
+ let selectedToolCallId: string | undefined;
67
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "ask") {
68
+ selectedToolCallId = entry.message.toolCallId;
69
+ entry = entry.parentId ? getEntry(entry.parentId) : undefined;
70
+ }
71
+ if (!entry) return rejected("no-entry");
72
+ if (entry.type !== "message" || entry.message.role !== "assistant") return rejected("not-assistant");
73
+
74
+ const calls = entry.message.content.filter((item) => item.type === "toolCall");
75
+ const askCalls = calls.filter((call) => call.name === "ask");
76
+ if (calls.length !== 1) {
77
+ if (askCalls.length > 0 && askCalls.length < calls.length) return rejected("mixed-tools");
78
+ if (askCalls.length > 1) return rejected("multiple-tool-calls");
79
+ return rejected("not-ask");
80
+ }
81
+ const call = calls[0];
82
+ if (call.name !== "ask" || (selectedToolCallId !== undefined && call.id !== selectedToolCallId)) return rejected("not-ask");
83
+
84
+ const params = validateStoredArgs(call.arguments);
85
+ if (!params) return rejected("invalid-arguments");
86
+ return { status: "resolved", toolCallId: call.id, params };
87
+ }
88
+
89
+ export function validateStoredArgs(value: unknown): ValidatedAskParams | undefined {
90
+ if (!Check(AskParamsSchema, value)) return undefined;
91
+ try {
92
+ return validateAskParams(value as AskParams);
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ function rejected(reason: Exclude<AskReplayResolution, { status: "resolved" }>["reason"]): AskReplayResolution {
99
+ return { status: "not-replayable", reason };
100
+ }
@@ -0,0 +1,31 @@
1
+ import type { AskAnswer, AskResponse, AskToolDetails } from "./types.js";
2
+
3
+ export function formatAskAnswer(answer: AskAnswer): string {
4
+ const lines = answer.selections.map((selection) => {
5
+ const description = selection.description ? ` — ${selection.description}` : "";
6
+ const comment = selection.comment ? ` (${selection.comment})` : "";
7
+ return `Selected: ${selection.label}${description}${comment}`;
8
+ });
9
+ if (answer.freeform) lines.push(`Freeform: ${answer.freeform}`);
10
+ return lines.join("\n") || "No answer provided.";
11
+ }
12
+
13
+ export function buildAnsweredResponse(question: string, answer: AskAnswer): AskResponse {
14
+ return buildResponse(formatAskAnswer(answer), { status: "answered", question, answer });
15
+ }
16
+
17
+ export function buildUnansweredResponse(question: string): AskResponse {
18
+ return buildResponse("The question timed out without an answer.", { status: "unanswered", question });
19
+ }
20
+
21
+ export function buildCancelledResponse(question: string): AskResponse {
22
+ return buildResponse("User cancelled the question.", { status: "cancelled", question });
23
+ }
24
+
25
+ export function buildUiUnavailableResponse(question: string): AskResponse {
26
+ return buildResponse("Interactive UI is unavailable.", { status: "ui_unavailable", question });
27
+ }
28
+
29
+ function buildResponse(text: string, details: AskToolDetails): AskResponse {
30
+ return { content: [{ type: "text", text }], details };
31
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { AskAnswer, AskOption, ValidatedAskParams } from "./types.js";
2
+
3
+ export interface AskDialogUI {
4
+ select(title: string, options: string[], dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
5
+ input(title: string, placeholder?: string, dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
6
+ }
7
+
8
+ type AskSelection = AskAnswer["selections"][number];
9
+
10
+ const FREEFORM_CHOICE = "Type a response…";
11
+
12
+ export async function askWithRpc(
13
+ ui: AskDialogUI,
14
+ params: ValidatedAskParams,
15
+ signal?: AbortSignal,
16
+ ): Promise<AskAnswer | null> {
17
+ const prompt = params.context ? `${params.context}\n\n${params.question}` : params.question;
18
+ if (signal?.aborted) return null;
19
+
20
+ return params.allowMultiple
21
+ ? runMultiSelect(ui, prompt, params.options, params.allowFreeform, signal)
22
+ : runSingleSelect(ui, prompt, params.options, params.allowFreeform, signal);
23
+ }
24
+
25
+ async function runSingleSelect(
26
+ ui: AskDialogUI,
27
+ prompt: string,
28
+ options: AskOption[],
29
+ allowFreeform: boolean,
30
+ signal?: AbortSignal,
31
+ ): Promise<AskAnswer | null> {
32
+ const choices = options.map((option, index) => `${index + 1}. ${formatOption(option)}`);
33
+ if (allowFreeform) choices.push(`${options.length + 1}. ${FREEFORM_CHOICE}`);
34
+
35
+ const selected = await selectDialog(ui, prompt, choices, signal);
36
+ if (selected == null || signal?.aborted) return null;
37
+
38
+ const selectedIndex = choices.indexOf(selected);
39
+ if (selectedIndex === -1) return null;
40
+ if (selectedIndex === options.length) {
41
+ const freeform = await readInput(ui, prompt, signal);
42
+ return freeform === null ? null : makeAnswer([], freeform);
43
+ }
44
+
45
+ const selectedOption = options[selectedIndex];
46
+ if (!selectedOption) return null;
47
+ const selections = await collectComments(ui, prompt, [selectedOption], signal);
48
+ return selections === null ? null : makeAnswer(selections);
49
+ }
50
+
51
+ async function runMultiSelect(
52
+ ui: AskDialogUI,
53
+ prompt: string,
54
+ options: AskOption[],
55
+ allowFreeform: boolean,
56
+ signal?: AbortSignal,
57
+ ): Promise<AskAnswer | null> {
58
+ const numberedOptions = options
59
+ .map((option, index) => `${index + 1}. ${formatOption(option)}`)
60
+ .join("\n");
61
+ const selectionPrompt = `${prompt}\n\n${numberedOptions}\n\nEnter option numbers separated by commas:`;
62
+ const rawSelection = await readInput(ui, selectionPrompt, signal);
63
+ if (rawSelection === null) return null;
64
+
65
+ const selectedOptions = parseSelections(rawSelection, options);
66
+ const freeform = allowFreeform
67
+ ? await readInput(ui, `${prompt}\n\nAdditional response (optional):`, signal)
68
+ : undefined;
69
+ if (freeform === null) return null;
70
+
71
+ const selections = await collectComments(ui, prompt, selectedOptions, signal);
72
+ return selections === null ? null : makeAnswer(selections, freeform);
73
+ }
74
+
75
+ function parseSelections(raw: string, options: AskOption[]): AskOption[] {
76
+ const indices = new Set<number>();
77
+ for (const token of raw.split(",")) {
78
+ const value = token.trim();
79
+ if (!/^\d+$/.test(value)) continue;
80
+
81
+ const index = Number(value) - 1;
82
+ if (Number.isSafeInteger(index) && index >= 0 && index < options.length) indices.add(index);
83
+ }
84
+ return options.filter((_option, index) => indices.has(index));
85
+ }
86
+
87
+ async function collectComments(
88
+ ui: AskDialogUI,
89
+ prompt: string,
90
+ options: AskOption[],
91
+ signal?: AbortSignal,
92
+ ): Promise<AskSelection[] | null> {
93
+ const selections: AskSelection[] = options.map(toSelection);
94
+ for (let index = 0; index < options.length; index++) {
95
+ const option = options[index];
96
+ const selection = selections[index];
97
+ if (!option || !selection) continue;
98
+
99
+ const comment = await readInput(ui, `${prompt}\n\nComment for \"${option.label}\" (optional):`, signal);
100
+ if (comment === null) return null;
101
+ if (comment) selection.comment = comment;
102
+ }
103
+ return selections;
104
+ }
105
+
106
+ async function readInput(ui: AskDialogUI, title: string, signal?: AbortSignal): Promise<string | null> {
107
+ if (signal?.aborted) return null;
108
+ const value = await inputDialog(ui, title, signal);
109
+ if (value == null || signal?.aborted) return null;
110
+ return value.trim();
111
+ }
112
+
113
+ function toSelection(option: AskOption): AskSelection {
114
+ const selection: AskSelection = { label: option.label };
115
+ if (option.description !== undefined) selection.description = option.description;
116
+ return selection;
117
+ }
118
+
119
+ function makeAnswer(selections: AskSelection[], freeform?: string): AskAnswer {
120
+ return freeform ? { selections, freeform } : { selections };
121
+ }
122
+
123
+ function formatOption(option: AskOption): string {
124
+ return option.description ? `${option.label} — ${option.description}` : option.label;
125
+ }
126
+
127
+ function selectDialog(ui: AskDialogUI, title: string, options: string[], signal?: AbortSignal) {
128
+ return signal ? ui.select(title, options, { signal }) : ui.select(title, options);
129
+ }
130
+
131
+ function inputDialog(ui: AskDialogUI, title: string, signal?: AbortSignal) {
132
+ return signal ? ui.input(title, undefined, { signal }) : ui.input(title);
133
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { Type } from "typebox";
2
+
3
+ import { MAX_TIMEOUT_MS } from "./config.js";
4
+
5
+ export const AskOptionSchema = Type.Object({
6
+ label: Type.String({ minLength: 1, description: "Short option title; keep distinct across options." }),
7
+ description: Type.Optional(Type.String({ description: "Brief explanation when the label alone is ambiguous." })),
8
+ preview: Type.Optional(Type.String({ description: "Markdown shown to the user, e.g. code, plans, or comparisons; not returned in the answer." })),
9
+ }, { additionalProperties: false });
10
+
11
+ export const AskParamsSchema = Type.Object({
12
+ question: Type.String({ minLength: 1 }),
13
+ context: Type.Optional(Type.String({ description: "Optional background shown above the question, e.g. what prompted the ask." })),
14
+ options: Type.Array(AskOptionSchema, { minItems: 1, description: "Suggested answers." }),
15
+ allowMultiple: Type.Optional(Type.Boolean({ default: false, description: "Allow selecting multiple options. Defaults to false." })),
16
+ allowFreeform: Type.Optional(Type.Boolean({ default: true, description: "Allow a typed response. Defaults to true." })),
17
+ timeout: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_TIMEOUT_MS, description: "Timeout in ms for answers that would go stale; the call returns unanswered on expiry. Zero disables any default." })),
18
+ }, { additionalProperties: false });
19
+
20
+ export const AskSelectionSchema = Type.Object({
21
+ label: Type.String({ minLength: 1 }),
22
+ description: Type.Optional(Type.String({ minLength: 1 })),
23
+ comment: Type.Optional(Type.String({ minLength: 1 })),
24
+ }, { additionalProperties: false });
25
+
26
+ export const AskAnswerSchema = Type.Object({
27
+ selections: Type.Array(AskSelectionSchema),
28
+ freeform: Type.Optional(Type.String({ minLength: 1 })),
29
+ }, { additionalProperties: false });
30
+
31
+ export const AskReplayDetailsSchema = Type.Object({
32
+ toolCallId: Type.String({ minLength: 1 }),
33
+ question: Type.String({ minLength: 1 }),
34
+ context: Type.Optional(Type.String({ minLength: 1 })),
35
+ allowMultiple: Type.Boolean(),
36
+ answer: AskAnswerSchema,
37
+ }, { additionalProperties: false });
38
+
39
+ export const AskAnsweredDetailsSchema = Type.Object({
40
+ status: Type.Literal("answered"),
41
+ question: Type.String({ minLength: 1 }),
42
+ answer: AskAnswerSchema,
43
+ }, { additionalProperties: false });
package/src/state.ts ADDED
@@ -0,0 +1,203 @@
1
+ import type { AskAnswer, AskOption, ValidatedAskParams } from "./types.js";
2
+
3
+ export type QuestionnaireOptionRow = { kind: "option"; option: AskOption };
4
+ export type QuestionnaireFreeformRow = { kind: "freeform" };
5
+ export type QuestionnaireSubmitRow = { kind: "submit" };
6
+ export type QuestionnaireRow =
7
+ | QuestionnaireOptionRow
8
+ | QuestionnaireFreeformRow
9
+ | QuestionnaireSubmitRow;
10
+
11
+ export type QuestionnaireEditor =
12
+ | { kind: "select" }
13
+ | { kind: "comment"; target: QuestionnaireOptionRow; draft: string }
14
+ | { kind: "freeform"; target: QuestionnaireFreeformRow; draft: string };
15
+
16
+ type QuestionnaireConfig = Pick<ValidatedAskParams, "allowMultiple" | "allowFreeform">;
17
+ type QuestionnaireInput = QuestionnaireConfig & Pick<ValidatedAskParams, "options">;
18
+
19
+ export interface QuestionnaireState {
20
+ config: QuestionnaireConfig;
21
+ rows: readonly QuestionnaireRow[];
22
+ highlightedRow: number;
23
+ checked: Set<string>;
24
+ comments: Map<string, string>;
25
+ freeformDraft: string;
26
+ freeformChecked: boolean;
27
+ editor: QuestionnaireEditor;
28
+ answer: AskAnswer | null;
29
+ }
30
+
31
+ export type QuestionnaireEvent =
32
+ | { type: "move"; delta: number }
33
+ | { type: "activate" }
34
+ | { type: "toggle" }
35
+ | { type: "openComment" }
36
+ | { type: "edit"; value: string }
37
+ | { type: "saveEditor" }
38
+ | { type: "cancelEditor" }
39
+ | { type: "submit" };
40
+
41
+ export function createQuestionnaireState(input: QuestionnaireInput): QuestionnaireState {
42
+ if (input.options.length === 0) throw new Error("Questionnaire requires at least one option");
43
+
44
+ return {
45
+ config: {
46
+ allowMultiple: input.allowMultiple,
47
+ allowFreeform: input.allowFreeform,
48
+ },
49
+ rows: createRows(input),
50
+ highlightedRow: 0,
51
+ checked: new Set(),
52
+ comments: new Map(),
53
+ freeformDraft: "",
54
+ freeformChecked: false,
55
+ editor: { kind: "select" },
56
+ answer: null,
57
+ };
58
+ }
59
+
60
+ export function transitionQuestionnaire(state: QuestionnaireState, event: QuestionnaireEvent): QuestionnaireState {
61
+ if (state.answer) return state;
62
+ const next = clone(state);
63
+
64
+ switch (event.type) {
65
+ case "move":
66
+ if (next.editor.kind === "select") {
67
+ next.highlightedRow = wrapRow(next.highlightedRow + event.delta, next.rows.length);
68
+ }
69
+ break;
70
+ case "activate":
71
+ if (next.editor.kind === "select") activateRow(next);
72
+ break;
73
+ case "toggle":
74
+ if (next.editor.kind === "select") toggleRow(next);
75
+ break;
76
+ case "openComment": {
77
+ if (next.editor.kind !== "select") break;
78
+ const row = currentRow(next);
79
+ if (row.kind !== "option") break;
80
+ next.editor = {
81
+ kind: "comment",
82
+ target: row,
83
+ draft: next.comments.get(row.option.label) ?? "",
84
+ };
85
+ break;
86
+ }
87
+ case "edit":
88
+ if (next.editor.kind !== "select") next.editor = { ...next.editor, draft: event.value };
89
+ break;
90
+ case "saveEditor": {
91
+ if (next.editor.kind === "select") break;
92
+ const editor = next.editor;
93
+ const saved = editor.draft.trim();
94
+ if (editor.kind === "comment") {
95
+ const label = editor.target.option.label;
96
+ if (saved) next.comments.set(label, saved);
97
+ else next.comments.delete(label);
98
+ } else {
99
+ next.freeformDraft = saved;
100
+ if (next.config.allowMultiple) next.freeformChecked = saved.length > 0;
101
+ }
102
+ next.editor = { kind: "select" };
103
+ if (editor.kind === "freeform" && !next.config.allowMultiple && saved) {
104
+ next.answer = finalAnswer(next);
105
+ }
106
+ break;
107
+ }
108
+ case "cancelEditor":
109
+ if (next.editor.kind !== "select") next.editor = { kind: "select" };
110
+ break;
111
+ case "submit":
112
+ next.answer = finalAnswer(next);
113
+ break;
114
+ }
115
+ return next;
116
+ }
117
+
118
+ function createRows(input: QuestionnaireInput): QuestionnaireRow[] {
119
+ return [
120
+ ...input.options.map((option): QuestionnaireOptionRow => ({ kind: "option", option })),
121
+ ...(input.allowFreeform ? [{ kind: "freeform" } as const] : []),
122
+ ...(input.allowMultiple ? [{ kind: "submit" } as const] : []),
123
+ ];
124
+ }
125
+
126
+ function currentRow(state: QuestionnaireState): QuestionnaireRow {
127
+ return state.rows[state.highlightedRow]!;
128
+ }
129
+
130
+ function activateRow(state: QuestionnaireState): void {
131
+ const row = currentRow(state);
132
+ switch (row.kind) {
133
+ case "option":
134
+ toggleOption(state, row.option);
135
+ break;
136
+ case "freeform":
137
+ openFreeform(state, row);
138
+ break;
139
+ case "submit":
140
+ state.answer = finalAnswer(state);
141
+ break;
142
+ }
143
+ }
144
+
145
+ function toggleRow(state: QuestionnaireState): void {
146
+ const row = currentRow(state);
147
+ switch (row.kind) {
148
+ case "option":
149
+ toggleOption(state, row.option);
150
+ break;
151
+ case "freeform":
152
+ if (state.config.allowMultiple) state.freeformChecked = !state.freeformChecked;
153
+ else openFreeform(state, row);
154
+ break;
155
+ case "submit":
156
+ state.answer = finalAnswer(state);
157
+ break;
158
+ }
159
+ }
160
+
161
+ function toggleOption(state: QuestionnaireState, option: AskOption): void {
162
+ if (!state.config.allowMultiple) {
163
+ state.checked = new Set([option.label]);
164
+ state.answer = finalAnswer(state);
165
+ } else if (state.checked.has(option.label)) {
166
+ state.checked.delete(option.label);
167
+ } else {
168
+ state.checked.add(option.label);
169
+ }
170
+ }
171
+
172
+ function openFreeform(state: QuestionnaireState, target: QuestionnaireFreeformRow): void {
173
+ state.editor = { kind: "freeform", target, draft: state.freeformDraft };
174
+ }
175
+
176
+ function finalAnswer(state: QuestionnaireState): AskAnswer {
177
+ const selections = state.rows.flatMap((row) => {
178
+ if (row.kind !== "option" || !state.checked.has(row.option.label)) return [];
179
+ const comment = state.comments.get(row.option.label);
180
+ return [{
181
+ label: row.option.label,
182
+ ...(row.option.description === undefined ? {} : { description: row.option.description }),
183
+ ...(comment ? { comment } : {}),
184
+ }];
185
+ });
186
+ const freeform = state.config.allowMultiple && !state.freeformChecked ? "" : state.freeformDraft.trim();
187
+ return {
188
+ selections,
189
+ ...(freeform ? { freeform } : {}),
190
+ };
191
+ }
192
+
193
+ function wrapRow(row: number, rowCount: number): number {
194
+ return ((row % rowCount) + rowCount) % rowCount;
195
+ }
196
+
197
+ function clone(state: QuestionnaireState): QuestionnaireState {
198
+ return {
199
+ ...state,
200
+ checked: new Set(state.checked),
201
+ comments: new Map(state.comments),
202
+ };
203
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { Static } from "typebox";
2
+
3
+ import type {
4
+ AskAnswerSchema,
5
+ AskAnsweredDetailsSchema,
6
+ AskOptionSchema,
7
+ AskParamsSchema,
8
+ AskReplayDetailsSchema,
9
+ AskSelectionSchema,
10
+ } from "./schema.js";
11
+
12
+ export type AskOption = Static<typeof AskOptionSchema>;
13
+ export type AskParams = Static<typeof AskParamsSchema>;
14
+ export type AskSelection = Static<typeof AskSelectionSchema>;
15
+ export type AskAnswer = Static<typeof AskAnswerSchema>;
16
+ export type AskReplayDetails = Static<typeof AskReplayDetailsSchema>;
17
+ export type AskAnsweredDetails = Static<typeof AskAnsweredDetailsSchema>;
18
+
19
+ export type ValidatedAskParams = Omit<AskParams, "allowMultiple" | "allowFreeform"> & {
20
+ allowMultiple: boolean;
21
+ allowFreeform: boolean;
22
+ };
23
+
24
+ export type AskToolDetails =
25
+ | AskAnsweredDetails
26
+ | { status: "unanswered"; question: string }
27
+ | { status: "cancelled"; question: string }
28
+ | { status: "ui_unavailable"; question: string };
29
+
30
+ export type AskResponse = {
31
+ content: Array<{ type: "text"; text: string }>;
32
+ details: AskToolDetails;
33
+ };
@@ -0,0 +1,48 @@
1
+ import type { AskOption, AskParams, ValidatedAskParams } from "./types.js";
2
+
3
+ export function validateAskParams(params: AskParams): ValidatedAskParams {
4
+ const question = params.question.trim();
5
+ if (!question) throw new Error("Ask question must not be empty.");
6
+
7
+ const context = trimOptional(params.context);
8
+ const options = params.options.map(normalizeOption);
9
+ const labels = new Set<string>();
10
+ for (const option of options) {
11
+ if (labels.has(option.label)) throw new Error(`Ask options contain duplicate label: ${option.label}.`);
12
+ labels.add(option.label);
13
+ }
14
+
15
+ const allowFreeform = params.allowFreeform ?? true;
16
+ if (options.length === 0) throw new Error("Ask needs at least one option.");
17
+
18
+ return {
19
+ question,
20
+ ...(context === undefined ? {} : { context }),
21
+ options,
22
+ allowMultiple: params.allowMultiple ?? false,
23
+ allowFreeform,
24
+ ...(params.timeout === undefined ? {} : { timeout: params.timeout }),
25
+ };
26
+ }
27
+
28
+ function normalizeOption(option: AskOption): AskOption {
29
+ const label = option.label.trim();
30
+ if (!label) throw new Error("Ask option label must not be empty.");
31
+ const description = trimOptional(option.description);
32
+ const preview = preserveOptional(option.preview);
33
+ return {
34
+ label,
35
+ ...(description === undefined ? {} : { description }),
36
+ ...(preview === undefined ? {} : { preview }),
37
+ };
38
+ }
39
+
40
+ function trimOptional(value: string | undefined): string | undefined {
41
+ if (value === undefined) return undefined;
42
+ const trimmed = value.trim();
43
+ return trimmed || undefined;
44
+ }
45
+
46
+ function preserveOptional(value: string | undefined): string | undefined {
47
+ return value?.trim() ? value : undefined;
48
+ }