pi-cc-extensions 0.8.8 → 0.8.9

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.
Files changed (42) hide show
  1. package/README.md +21 -4
  2. package/extensions/ask-user-question/LICENSE +21 -0
  3. package/extensions/ask-user-question/README.md +63 -0
  4. package/extensions/ask-user-question/ask-user-question.ts +244 -0
  5. package/extensions/ask-user-question/config.ts +75 -0
  6. package/extensions/ask-user-question/events.ts +55 -0
  7. package/extensions/ask-user-question/index.ts +17 -0
  8. package/extensions/ask-user-question/reconcile.ts +47 -0
  9. package/extensions/ask-user-question/rpc-fallback.ts +160 -0
  10. package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
  11. package/extensions/ask-user-question/state/dialog-height.ts +16 -0
  12. package/extensions/ask-user-question/state/key-router.ts +269 -0
  13. package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
  14. package/extensions/ask-user-question/state/row-intent.ts +143 -0
  15. package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
  16. package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
  17. package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
  18. package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
  19. package/extensions/ask-user-question/state/state-reducer.ts +282 -0
  20. package/extensions/ask-user-question/state/state.ts +49 -0
  21. package/extensions/ask-user-question/tool/format-answer.ts +29 -0
  22. package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
  23. package/extensions/ask-user-question/tool/types.ts +145 -0
  24. package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
  25. package/extensions/ask-user-question/view/component-binding.ts +45 -0
  26. package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
  27. package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
  28. package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
  29. package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
  30. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
  31. package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
  32. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
  33. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
  34. package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
  35. package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
  36. package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
  37. package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
  38. package/extensions/ask-user-question/view/props-adapter.ts +123 -0
  39. package/extensions/ask-user-question/view/stateful-view.ts +24 -0
  40. package/extensions/ask-user-question/view/tab-components.ts +16 -0
  41. package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
  42. package/package.json +10 -4
@@ -0,0 +1,99 @@
1
+ import { MULTI_SUBMIT_LABEL, type MultiSelectViewProps } from "../../view/components/multi-select-view.js";
2
+ import type { OptionListViewProps } from "../../view/components/option-list-view.js";
3
+ import type { PreviewPaneProps } from "../../view/components/preview/preview-pane.js";
4
+ import type { SubmitPickerProps } from "../../view/components/submit-picker.js";
5
+ import type { TabBarProps } from "../../view/components/tab-bar.js";
6
+ import type { DialogProps } from "../../view/dialog-builder.js";
7
+ import { ROW_INTENT_META } from "../row-intent.js";
8
+ import type { GlobalSelector, PerTabSelector } from "./contract.js";
9
+ import { selectConfirmedIndicator } from "./derivations.js";
10
+
11
+ export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (state, ctx) => {
12
+ const question = ctx.questions[ctx.i];
13
+ if (!question) {
14
+ return {
15
+ rows: [],
16
+ other: {
17
+ active: false,
18
+ inputMode: false,
19
+ inputBuffer: ctx.inputBuffer,
20
+ inputCursorOffset: ctx.inputCursorOffset,
21
+ },
22
+ nextActive: false,
23
+ nextLabel: ROW_INTENT_META.next.label,
24
+ };
25
+ }
26
+ const focused = ctx.activeView === "options";
27
+ const rows: { checked: boolean; active: boolean }[] = [];
28
+ for (let i = 0; i < question.options.length; i++) {
29
+ rows.push({
30
+ checked: state.multiSelectChecked.has(i),
31
+ active: focused && i === state.optionIndex,
32
+ });
33
+ }
34
+ const otherActive = focused && state.optionIndex === question.options.length;
35
+ const nextActive = focused && state.optionIndex === question.options.length + 1;
36
+ const isLastQuestion = ctx.i === ctx.questions.length - 1;
37
+ const nextLabel = isLastQuestion ? MULTI_SUBMIT_LABEL : ROW_INTENT_META.next.label;
38
+ return {
39
+ rows,
40
+ other: {
41
+ active: otherActive,
42
+ inputMode: state.inputMode,
43
+ inputBuffer: ctx.inputBuffer,
44
+ inputCursorOffset: ctx.inputCursorOffset,
45
+ },
46
+ nextActive,
47
+ nextLabel,
48
+ };
49
+ };
50
+
51
+ export const selectOptionListProps: PerTabSelector<OptionListViewProps> = (state, ctx) => {
52
+ const items = ctx.itemsByTab[ctx.i] ?? [];
53
+ const focused = ctx.activeView === "options";
54
+ const confirmed = selectConfirmedIndicator(ctx.questions, state.currentTab, state.answers, items);
55
+ return {
56
+ selectedIndex: state.optionIndex,
57
+ focused,
58
+ inputBuffer: ctx.inputBuffer,
59
+ inputCursorOffset: ctx.inputCursorOffset,
60
+ ...(confirmed ? { confirmed } : {}),
61
+ };
62
+ };
63
+
64
+ export const selectSubmitPickerProps: GlobalSelector<SubmitPickerProps> = (state, ctx) => {
65
+ const focused = ctx.activeView === "submit";
66
+ return {
67
+ rows: [
68
+ { active: focused && state.submitChoiceIndex === 0 },
69
+ { active: focused && state.submitChoiceIndex === 1 },
70
+ ],
71
+ };
72
+ };
73
+
74
+ export const selectPreviewPaneProps: PerTabSelector<PreviewPaneProps> = (state, ctx) => ({
75
+ notesVisible: state.notesVisible,
76
+ selectedIndex: state.optionIndex,
77
+ focused: ctx.activeView === "options",
78
+ inputMode: state.inputMode,
79
+ });
80
+
81
+ export const selectTabBarProps: GlobalSelector<TabBarProps> = (state, ctx) => {
82
+ const tabs = ctx.questions.map((q, i) => ({
83
+ label: q.header && q.header.length > 0 ? q.header : `Q${i + 1}`,
84
+ answered: state.answers.has(i),
85
+ active: i === state.currentTab,
86
+ }));
87
+ return {
88
+ tabs,
89
+ submit: {
90
+ active: state.currentTab === ctx.questions.length,
91
+ allAnswered: state.answers.size === ctx.questions.length && ctx.questions.length > 0,
92
+ },
93
+ };
94
+ };
95
+
96
+ export const selectDialogProps: GlobalSelector<DialogProps> = (state, ctx) => ({
97
+ state,
98
+ activePreviewPane: ctx.activePreviewPane,
99
+ });
@@ -0,0 +1,282 @@
1
+ import type { QuestionAnswer, QuestionData, QuestionnaireResult } from "../tool/types.js";
2
+ import type { WrappingSelectItem } from "../view/components/wrapping-select.js";
3
+ import type { QuestionnaireAction } from "./key-router.js";
4
+ import { ROW_INTENT_META } from "./row-intent.js";
5
+ import type { QuestionnaireState } from "./state.js";
6
+
7
+ /** Session-lifetime constants. No live-component reads — peripheral values live on canonical state. */
8
+ export interface ApplyContext {
9
+ questions: readonly QuestionData[];
10
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
11
+ }
12
+
13
+ /**
14
+ * Declarative side-effects emitted by `reduce`. The runtime executes them after
15
+ * committing the new state, then asks the props-adapter to re-project. Closed set —
16
+ * adding an effect requires updating both the union AND the runtime's `runEffect` switch
17
+ * (compiler-enforced exhaustive). No string-keyed escape hatch.
18
+ */
19
+ export type Effect =
20
+ | { kind: "set_input_buffer"; value: string }
21
+ | { kind: "clear_input_buffer" }
22
+ | { kind: "set_notes_value"; value: string }
23
+ | { kind: "set_notes_focused"; focused: boolean }
24
+ | { kind: "forward_notes_keystroke"; data: string }
25
+ | { kind: "done"; result: QuestionnaireResult };
26
+
27
+ export interface ApplyResult {
28
+ state: QuestionnaireState;
29
+ effects: readonly Effect[];
30
+ }
31
+
32
+ function orderedAnswers(state: QuestionnaireState, questions: readonly QuestionData[]): QuestionAnswer[] {
33
+ const out: QuestionAnswer[] = [];
34
+ for (let i = 0; i < questions.length; i++) {
35
+ const a = state.answers.get(i);
36
+ if (a) out.push(a);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function syncMultiSelectFromAnswers(
42
+ answers: ReadonlyMap<number, QuestionAnswer>,
43
+ questions: readonly QuestionData[],
44
+ tab: number,
45
+ ): ReadonlySet<number> {
46
+ const q = questions[tab];
47
+ if (!q?.multiSelect) return new Set();
48
+ const saved = answers.get(tab);
49
+ const labels = saved?.selected ?? [];
50
+ const indices = new Set<number>();
51
+ for (let i = 0; i < q.options.length; i++) {
52
+ if (labels.includes(q.options[i]!.label)) indices.add(i);
53
+ }
54
+ return indices;
55
+ }
56
+
57
+ function persistMultiSelectAnswer(state: QuestionnaireState, ctx: ApplyContext): ReadonlyMap<number, QuestionAnswer> {
58
+ const q = ctx.questions[state.currentTab];
59
+ if (!q?.multiSelect) return state.answers;
60
+ const selected: string[] = [];
61
+ for (let i = 0; i < q.options.length; i++) {
62
+ if (state.multiSelectChecked.has(i)) selected.push(q.options[i]!.label);
63
+ }
64
+ const out = new Map(state.answers);
65
+ if (selected.length === 0) {
66
+ out.delete(state.currentTab);
67
+ return out;
68
+ }
69
+ const pendingNotes = state.notesByTab.get(state.currentTab);
70
+ out.set(state.currentTab, {
71
+ questionIndex: state.currentTab,
72
+ question: q.question,
73
+ kind: "multi",
74
+ answer: null,
75
+ selected,
76
+ ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
77
+ });
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Note text to seed the editor/draft with for a tab. The in-flight side-band store
83
+ * (`notesByTab`) is authoritative — before an option is confirmed the note lives ONLY
84
+ * there; `answer.notes` is a mirror written on exit/confirm.
85
+ */
86
+ function notesValueFor(state: QuestionnaireState, tab: number): string {
87
+ return state.notesByTab.get(tab) ?? state.answers.get(tab)?.notes ?? "";
88
+ }
89
+
90
+ function switchTabResult(state: QuestionnaireState, nextTab: number, ctx: ApplyContext): ApplyResult {
91
+ const notesValue = notesValueFor(state, nextTab);
92
+ const transitioned: QuestionnaireState = {
93
+ ...state,
94
+ currentTab: nextTab,
95
+ optionIndex: 0,
96
+ inputMode: false,
97
+ notesVisible: false,
98
+ submitChoiceIndex: 0,
99
+ multiSelectChecked: syncMultiSelectFromAnswers(state.answers, ctx.questions, nextTab),
100
+ notesDraft: notesValue,
101
+ };
102
+ return {
103
+ state: transitioned,
104
+ effects: [
105
+ { kind: "set_notes_focused", focused: false },
106
+ { kind: "set_notes_value", value: notesValue },
107
+ ],
108
+ };
109
+ }
110
+
111
+ function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolean): ApplyResult {
112
+ const result: QuestionnaireResult = { answers: orderedAnswers(state, ctx.questions), cancelled };
113
+ return { state, effects: [{ kind: "done", result }] };
114
+ }
115
+
116
+ /**
117
+ * Per-kind handler signature: action payload narrows to the matching union member
118
+ * via `Extract`, so handlers consume fully-typed actions without `as` casts.
119
+ */
120
+ type Handler<K extends QuestionnaireAction["kind"]> = (
121
+ state: QuestionnaireState,
122
+ action: Extract<QuestionnaireAction, { kind: K }>,
123
+ ctx: ApplyContext,
124
+ ) => ApplyResult;
125
+
126
+ const navHandler: Handler<"nav"> = (state, action, ctx) => {
127
+ const items = ctx.itemsByTab[state.currentTab] ?? [];
128
+ const item = items[action.nextIndex];
129
+ const inputMode = item ? ROW_INTENT_META[item.kind].activatesInputMode : false;
130
+ const next = { ...state, optionIndex: action.nextIndex, inputMode };
131
+ if (!inputMode) {
132
+ return { state: next, effects: [{ kind: "clear_input_buffer" }] };
133
+ }
134
+ const prior = state.answers.get(state.currentTab);
135
+ if (prior?.kind === "custom" && typeof prior.answer === "string") {
136
+ return { state: next, effects: [{ kind: "set_input_buffer", value: prior.answer }] };
137
+ }
138
+ return { state: next, effects: [] };
139
+ };
140
+
141
+ const tabSwitchHandler: Handler<"tab_switch"> = (state, action, ctx) => switchTabResult(state, action.nextTab, ctx);
142
+
143
+ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
144
+ let answer = action.answer;
145
+ if (answer.kind === "option" && answer.answer) {
146
+ const q = ctx.questions[answer.questionIndex];
147
+ const matched = q?.options.find((o) => o.label === answer.answer);
148
+ if (matched?.preview && matched.preview.length > 0) {
149
+ answer = { ...answer, preview: matched.preview };
150
+ }
151
+ }
152
+ const pendingNotes = state.notesByTab.get(answer.questionIndex);
153
+ if (pendingNotes && pendingNotes.length > 0) {
154
+ answer = { ...answer, notes: pendingNotes };
155
+ }
156
+ const answers = new Map(state.answers);
157
+ answers.set(answer.questionIndex, answer);
158
+ // Custom free-text on a multi-select tab is mutually exclusive with checkbox selections:
159
+ // clear the checked set immediately so [✔] glyphs vanish on Enter. (A custom answer
160
+ // carries no `selected` array, so syncMultiSelectFromAnswers keeps it empty on tab-back.)
161
+ const isCustomMulti = answer.kind === "custom" && ctx.questions[answer.questionIndex]?.multiSelect === true;
162
+ const next: QuestionnaireState = {
163
+ ...state,
164
+ answers,
165
+ ...(isCustomMulti ? { multiSelectChecked: new Set<number>() } : {}),
166
+ };
167
+ if (action.autoAdvanceTab !== undefined) return switchTabResult(next, action.autoAdvanceTab, ctx);
168
+ return doneFor(next, ctx, false);
169
+ };
170
+
171
+ const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
172
+ const checked = new Set(state.multiSelectChecked);
173
+ if (checked.has(action.index)) checked.delete(action.index);
174
+ else checked.add(action.index);
175
+ const intermediate: QuestionnaireState = { ...state, multiSelectChecked: checked };
176
+ const answers = persistMultiSelectAnswer(intermediate, ctx);
177
+ return { state: { ...intermediate, answers }, effects: [] };
178
+ };
179
+
180
+ const multiConfirmHandler: Handler<"multi_confirm"> = (state, action, ctx) => {
181
+ const q = ctx.questions[state.currentTab];
182
+ if (!q) return { state, effects: [] };
183
+ const pendingNotes = state.notesByTab.get(state.currentTab);
184
+ const answers = new Map(state.answers);
185
+ answers.set(state.currentTab, {
186
+ questionIndex: state.currentTab,
187
+ question: q.question,
188
+ kind: "multi",
189
+ answer: null,
190
+ selected: action.selected,
191
+ ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
192
+ });
193
+ const synced: QuestionnaireState = {
194
+ ...state,
195
+ answers,
196
+ multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab),
197
+ };
198
+ if (action.autoAdvanceTab !== undefined) return switchTabResult(synced, action.autoAdvanceTab, ctx);
199
+ return doneFor(synced, ctx, false);
200
+ };
201
+
202
+ const notesEnterHandler: Handler<"notes_enter"> = (state, _action, _ctx) => {
203
+ const value = notesValueFor(state, state.currentTab);
204
+ return {
205
+ state: { ...state, notesVisible: true, notesDraft: value },
206
+ effects: [
207
+ { kind: "set_notes_value", value },
208
+ { kind: "set_notes_focused", focused: true },
209
+ ],
210
+ };
211
+ };
212
+
213
+ const notesExitHandler: Handler<"notes_exit"> = (state, _action, _ctx) => {
214
+ const trimmed = state.notesDraft.trim();
215
+ const notes = new Map(state.notesByTab);
216
+ const answers = new Map(state.answers);
217
+ if (trimmed.length === 0) {
218
+ notes.delete(state.currentTab);
219
+ const prev = answers.get(state.currentTab);
220
+ if (prev?.notes) {
221
+ const stripped = { ...prev };
222
+ delete (stripped as { notes?: string }).notes;
223
+ answers.set(state.currentTab, stripped);
224
+ }
225
+ } else {
226
+ notes.set(state.currentTab, trimmed);
227
+ const prev = answers.get(state.currentTab);
228
+ if (prev) answers.set(state.currentTab, { ...prev, notes: trimmed });
229
+ }
230
+ return {
231
+ state: { ...state, notesByTab: notes, answers, notesVisible: false },
232
+ effects: [{ kind: "set_notes_focused", focused: false }],
233
+ };
234
+ };
235
+
236
+ const cancelHandler: Handler<"cancel"> = (s, _a, c) => doneFor(s, c, true);
237
+ const submitHandler: Handler<"submit"> = (s, _a, c) => doneFor(s, c, false);
238
+ const submitNavHandler: Handler<"submit_nav"> = (s, a, _c) => ({
239
+ state: { ...s, submitChoiceIndex: a.nextIndex },
240
+ effects: [],
241
+ });
242
+ const notesForwardHandler: Handler<"notes_forward"> = (s, a, _c) => ({
243
+ state: s,
244
+ effects: [{ kind: "forward_notes_keystroke", data: a.data }],
245
+ });
246
+ const toggleCollapsedHandler: Handler<"toggle_collapsed"> = (s, _a, _c) => ({
247
+ state: { ...s, collapsed: !s.collapsed },
248
+ effects: [],
249
+ });
250
+ const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: [] });
251
+
252
+ /**
253
+ * Compile-time-exhaustive dispatch table. `{ [K in Kind]: Handler<K> }` requires
254
+ * an entry per union member — adding a new `QuestionnaireAction` variant fails to
255
+ * compile here until a handler is registered, mirroring the `Record<RowKind, …>`
256
+ * pattern used by `ROW_INTENT_META`.
257
+ */
258
+ const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
259
+ nav: navHandler,
260
+ tab_switch: tabSwitchHandler,
261
+ confirm: confirmHandler,
262
+ toggle: toggleHandler,
263
+ multi_confirm: multiConfirmHandler,
264
+ cancel: cancelHandler,
265
+ notes_enter: notesEnterHandler,
266
+ notes_exit: notesExitHandler,
267
+ notes_forward: notesForwardHandler,
268
+ submit: submitHandler,
269
+ submit_nav: submitNavHandler,
270
+ toggle_collapsed: toggleCollapsedHandler,
271
+ ignore: ignoreHandler,
272
+ };
273
+
274
+ /**
275
+ * Pure reducer: (state, action, ctx) → (state, Effect[]). Mirrors `rpiv-todo`'s `applyTaskMutation`.
276
+ * Delegates to `HANDLERS` — per-kind handlers above are pure, named, and individually testable.
277
+ * `ignore` is also handled outside the reducer by `handleIgnoreInline` in the runtime fast path.
278
+ */
279
+ export function reduce(state: QuestionnaireState, action: QuestionnaireAction, ctx: ApplyContext): ApplyResult {
280
+ const handler = HANDLERS[action.kind] as Handler<typeof action.kind>;
281
+ return handler(state, action as never, ctx);
282
+ }
@@ -0,0 +1,49 @@
1
+ import type { QuestionAnswer, QuestionData } from "../tool/types.js";
2
+ import type { WrappingSelectItem } from "../view/components/wrapping-select.js";
3
+
4
+ /**
5
+ * Canonical state for the questionnaire dialog. Single source of truth — both the
6
+ * dispatcher (`routeKey`) and the view layer read this same shape.
7
+ */
8
+ export interface QuestionnaireState {
9
+ currentTab: number;
10
+ optionIndex: number;
11
+ inputMode: boolean;
12
+ notesVisible: boolean;
13
+ answers: ReadonlyMap<number, QuestionAnswer>;
14
+ multiSelectChecked: ReadonlySet<number>;
15
+ /**
16
+ * Pre-answer notes side-band, keyed by tab index. Decoupled from `answers` so adding
17
+ * notes does NOT mark a question answered (the Submit-tab missing-check would falsely
18
+ * pass otherwise). Merged into the answer at confirm time.
19
+ */
20
+ notesByTab: ReadonlyMap<number, string>;
21
+ /** Focused row in the Submit-tab picker (0 = Submit, 1 = Cancel). Reset on tab switch. */
22
+ submitChoiceIndex: number;
23
+ /** Canonical mirror of the in-flight notes editor; runtime mirrors after `forward_notes_keystroke`. */
24
+ notesDraft: string;
25
+ /**
26
+ * Collapsed mode: the questionnaire shrinks to one focused hint row in the
27
+ * temporary editor slot, returning the remaining space to the transcript.
28
+ */
29
+ collapsed: boolean;
30
+ }
31
+
32
+ /**
33
+ * Per-tick context the dispatcher needs alongside canonical state. Held separately
34
+ * because `keybindings` / `inputBuffer` must never reach view setProps consumers.
35
+ */
36
+ export interface QuestionnaireRuntime {
37
+ keybindings: { matches(data: string, name: string): boolean };
38
+ inputBuffer: string;
39
+ questions: readonly QuestionData[];
40
+ isMulti: boolean;
41
+ currentItem: WrappingSelectItem | undefined;
42
+ items: readonly WrappingSelectItem[];
43
+ /**
44
+ * Key spec for the collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. Resolved
45
+ * from `AskUserQuestionConfig.collapseKey` (or the package default). When `"off"`,
46
+ * the collapse shortcut is disabled.
47
+ */
48
+ collapseKey: string;
49
+ }
@@ -0,0 +1,29 @@
1
+ import type { QuestionAnswer } from "./types.js";
2
+
3
+ /**
4
+ * Placeholder for empty / null answer text. Used uniformly across both variants — the
5
+ * earlier `(no answer)` fallback in the dialog summary was accidental drift; tests pin
6
+ * `(no input)` only.
7
+ */
8
+ export const NO_INPUT_PLACEHOLDER = "(no input)";
9
+
10
+ export type FormatAnswerVariant = "summary" | "envelope";
11
+
12
+ /**
13
+ * Format a `QuestionAnswer` to its scalar string form. `variant` is currently unused
14
+ * across all branches (the chat branch that once distinguished `envelope` from
15
+ * `summary` has been removed); it is retained on the signature for stability.
16
+ * The `kind: "custom"` empty-string handling and the option fallback both unify on
17
+ * `NO_INPUT_PLACEHOLDER`. Switch is exhaustive — non-`void` return enforces every
18
+ * variant is handled.
19
+ */
20
+ export function formatAnswerScalar(a: QuestionAnswer, _variant: FormatAnswerVariant): string {
21
+ switch (a.kind) {
22
+ case "multi":
23
+ return a.selected && a.selected.length > 0 ? a.selected.join(", ") : NO_INPUT_PLACEHOLDER;
24
+ case "custom":
25
+ return a.answer && a.answer.length > 0 ? a.answer : NO_INPUT_PLACEHOLDER;
26
+ case "option":
27
+ return a.answer ?? NO_INPUT_PLACEHOLDER;
28
+ }
29
+ }
@@ -0,0 +1,47 @@
1
+ import { formatAnswerScalar } from "./format-answer.js";
2
+ import type { QuestionAnswer, QuestionnaireResult, QuestionParams } from "./types.js";
3
+
4
+ export const DECLINE_MESSAGE = "User declined to answer questions";
5
+ export const ENVELOPE_PREFIX = "User has answered your questions:";
6
+ export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
7
+
8
+ /**
9
+ * Map a `QuestionnaireResult` (or null/cancelled) to the LLM-facing tool envelope.
10
+ * Pure of `(result, params)`; cancelled and "no segments" both fall to `DECLINE_MESSAGE`
11
+ * so the model sees a single canonical "didn't answer" signal regardless of why.
12
+ */
13
+ export function buildQuestionnaireResponse(result: QuestionnaireResult | null | undefined, params: QuestionParams) {
14
+ if (!result || result.cancelled) {
15
+ return buildToolResult(DECLINE_MESSAGE, {
16
+ answers: result?.answers ?? [],
17
+ cancelled: true,
18
+ });
19
+ }
20
+ const segments: string[] = [];
21
+ for (let i = 0; i < params.questions.length; i++) {
22
+ const a = result.answers.find((x) => x.questionIndex === i);
23
+ if (a) segments.push(buildAnswerSegment(a));
24
+ }
25
+ if (segments.length === 0) {
26
+ return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
27
+ }
28
+ return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
29
+ }
30
+
31
+ /**
32
+ * Format a single answer segment for the envelope. Pure of `a`. The `"Q"="A"` shape and
33
+ * the optional `selected preview:` / `user notes:` suffixes are pinned by envelope tests.
34
+ */
35
+ export function buildAnswerSegment(a: QuestionAnswer): string {
36
+ const parts: string[] = [`"${a.question}"="${formatAnswerScalar(a, "envelope")}"`];
37
+ if (a.preview && a.preview.length > 0) parts.push(`selected preview: ${a.preview}`);
38
+ if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
39
+ return `${parts.join(". ")}.`;
40
+ }
41
+
42
+ export function buildToolResult(text: string, details: QuestionnaireResult) {
43
+ return {
44
+ content: [{ type: "text" as const, text }],
45
+ details,
46
+ };
47
+ }
@@ -0,0 +1,145 @@
1
+ import { type Static, Type } from "typebox";
2
+ import { LABELS_BY_KIND, ROW_INTENT_META } from "../state/row-intent.js";
3
+
4
+ export const MAX_QUESTIONS = 4;
5
+ export const MIN_OPTIONS = 2;
6
+ export const MAX_OPTIONS = 4;
7
+ export const MAX_HEADER_LENGTH = 16;
8
+ export const MAX_LABEL_LENGTH = 60;
9
+
10
+ /**
11
+ * User-facing labels for the three runtime sentinel rows, keyed by their
12
+ * `WrappingSelectItem.kind` discriminator. Sourced from
13
+ * `ROW_INTENT_META` via `LABELS_BY_KIND` (`row-intent.ts`) — single source of
14
+ * truth. Adding a new sentinel requires extending the `WrappingSelectItem`
15
+ * union AND adding an entry to `ROW_INTENT_META`; this map then auto-extends.
16
+ */
17
+ export const SENTINEL_LABELS = LABELS_BY_KIND;
18
+
19
+ export type SentinelKind = keyof typeof SENTINEL_LABELS;
20
+ export type SentinelLabel = (typeof SENTINEL_LABELS)[SentinelKind];
21
+
22
+ /**
23
+ * Labels reserved for Pi-internal sentinels — authoring an option with any
24
+ * of these labels triggers the `reserved_label` runtime guard. Two of the
25
+ * three come from `ROW_INTENT_META` (the runtime kinds); `"Other"` is
26
+ * reserved for CC parity only (the model is conditioned to reach for
27
+ * "Other" in CC; we reject it so the runtime sentinel is the single source
28
+ * of truth) and has no runtime kind.
29
+ *
30
+ * Reserved unconditionally — every question mode rejects these labels, even
31
+ * when a given runtime sentinel is not appended in that mode.
32
+ *
33
+ * Order is pinned by `types.test.ts:292` — keep the explicit
34
+ * `["Other", other, next]` literal so consumers using
35
+ * `RESERVED_LABELS[i]` indexing or `Set` membership see no behavior change.
36
+ */
37
+ export const RESERVED_LABELS = ["Other", ROW_INTENT_META.other.label, ROW_INTENT_META.next.label] as const;
38
+ export type ReservedLabel = (typeof RESERVED_LABELS)[number];
39
+
40
+ export const OptionSchema = Type.Object({
41
+ label: Type.String({
42
+ maxLength: MAX_LABEL_LENGTH,
43
+ description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS — hard limit, requests over the limit are rejected. The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.`,
44
+ }),
45
+ description: Type.String({
46
+ description:
47
+ "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.",
48
+ }),
49
+ preview: Type.Optional(
50
+ Type.String({
51
+ description:
52
+ "Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.",
53
+ }),
54
+ ),
55
+ });
56
+
57
+ export const QuestionSchema = Type.Object({
58
+ question: Type.String({
59
+ description:
60
+ 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"',
61
+ }),
62
+ header: Type.String({
63
+ maxLength: MAX_HEADER_LENGTH,
64
+ description: `MAX ${MAX_HEADER_LENGTH} CHARACTERS — hard limit, requests over the limit are rejected. Very short chip/tag shown next to the question. Examples: "Auth method", "Library", "Approach".`,
65
+ }),
66
+ options: Type.Array(OptionSchema, {
67
+ minItems: MIN_OPTIONS,
68
+ maxItems: MAX_OPTIONS,
69
+ description:
70
+ "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). The 'Type something.' row is appended automatically — do NOT author it.",
71
+ }),
72
+ multiSelect: Type.Optional(
73
+ Type.Boolean({
74
+ default: false,
75
+ description:
76
+ "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.",
77
+ }),
78
+ ),
79
+ });
80
+
81
+ export const QuestionsSchema = Type.Array(QuestionSchema, {
82
+ minItems: 1,
83
+ maxItems: MAX_QUESTIONS,
84
+ description: "Questions to ask the user (1-4 questions)",
85
+ });
86
+
87
+ export const QuestionParamsSchema = Type.Object({
88
+ questions: QuestionsSchema,
89
+ });
90
+
91
+ export type OptionData = Static<typeof OptionSchema>;
92
+ export type QuestionData = Static<typeof QuestionSchema>;
93
+ export type QuestionParams = Static<typeof QuestionParamsSchema>;
94
+
95
+ /**
96
+ * Answer-intent discriminated union. `kind` is the single discriminator —
97
+ * pre-1.0.3 boolean flags have been removed (see `banned-flags.test.ts`).
98
+ * Mirrors the row-side `WrappingSelectItem.kind` vocabulary where possible;
99
+ * `multi` is the multi-select variant (no row-side analog).
100
+ *
101
+ * Variant semantics:
102
+ * - `option`: user picked one of the author-defined options. `answer` is the option's label.
103
+ * - `custom`: user typed free-text via the "Type something." row. `answer` is the typed text or null.
104
+ * - `multi`: user committed multi-select choices. `selected` carries chosen labels; `answer` is null.
105
+ */
106
+ export interface QuestionAnswer {
107
+ questionIndex: number;
108
+ question: string;
109
+ kind: "option" | "custom" | "multi";
110
+ answer: string | null;
111
+ selected?: string[];
112
+ notes?: string;
113
+ /**
114
+ * Markdown text from the matched option's `preview` field, populated only
115
+ * when the user lands on a single-select option carrying a `preview`.
116
+ * Used by `buildQuestionnaireResponse` to echo `selected preview: <preview>`
117
+ * into the LLM-facing envelope. Undefined for multi-select and custom-text
118
+ * (`kind: "custom"`) answers.
119
+ */
120
+ preview?: string;
121
+ }
122
+
123
+ export type QuestionnaireError =
124
+ | "no_ui"
125
+ | "no_custom_ui"
126
+ | "no_questions"
127
+ | "empty_options"
128
+ | "too_many_questions"
129
+ | "duplicate_question"
130
+ | "duplicate_option_label"
131
+ | "reserved_label"
132
+ | "session_load_failed"
133
+ | "stale_module_cache";
134
+
135
+ export interface QuestionnaireResult {
136
+ answers: QuestionAnswer[];
137
+ cancelled: boolean;
138
+ error?: QuestionnaireError;
139
+ }
140
+
141
+ export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
142
+ if (!value || typeof value !== "object") return false;
143
+ const v = value as Record<string, unknown>;
144
+ return Array.isArray(v.answers) && typeof v.cancelled === "boolean";
145
+ }