pi-ask-popup 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.
- package/LICENSE +22 -0
- package/README.md +55 -0
- package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
- package/package.json +48 -0
- package/src/ask-user-question.ts +474 -0
- package/src/config.ts +250 -0
- package/src/events.ts +107 -0
- package/src/index.ts +25 -0
- package/src/reconcile.ts +31 -0
- package/src/rpc-fallback.ts +198 -0
- package/src/state/build-questionnaire.ts +346 -0
- package/src/state/external-editor.ts +94 -0
- package/src/state/key-router.ts +378 -0
- package/src/state/questionnaire-session.ts +382 -0
- package/src/state/row-intent.ts +156 -0
- package/src/state/selectors/contract.ts +40 -0
- package/src/state/selectors/derivations.ts +40 -0
- package/src/state/selectors/focus.ts +17 -0
- package/src/state/selectors/projections.ts +111 -0
- package/src/state/state-reducer.ts +421 -0
- package/src/state/state.ts +110 -0
- package/src/tool/format-answer.ts +28 -0
- package/src/tool/response-envelope.ts +123 -0
- package/src/tool/types.ts +193 -0
- package/src/tool/validate-questionnaire.ts +74 -0
- package/src/view/component-binding.ts +51 -0
- package/src/view/components/inline-input.ts +66 -0
- package/src/view/components/multi-select-view.ts +208 -0
- package/src/view/components/option-list-view.ts +77 -0
- package/src/view/components/preview/markdown-content-cache.ts +76 -0
- package/src/view/components/preview/preview-block-renderer.ts +116 -0
- package/src/view/components/preview/preview-box-renderer.ts +88 -0
- package/src/view/components/preview/preview-layout-decider.ts +219 -0
- package/src/view/components/preview/preview-pane.ts +240 -0
- package/src/view/components/submit-picker.ts +66 -0
- package/src/view/components/tab-bar.ts +70 -0
- package/src/view/components/wrapping-select.ts +313 -0
- package/src/view/dialog-builder.ts +325 -0
- package/src/view/props-adapter.ts +124 -0
- package/src/view/stateful-view.ts +20 -0
- package/src/view/tab-components.ts +16 -0
- package/src/view/tab-content-strategy.ts +447 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MULTI_SUBMIT_LABEL,
|
|
3
|
+
type MultiSelectViewProps,
|
|
4
|
+
} from "../../view/components/multi-select-view.js";
|
|
5
|
+
import type { OptionListViewProps } from "../../view/components/option-list-view.js";
|
|
6
|
+
import type { PreviewPaneProps } from "../../view/components/preview/preview-pane.js";
|
|
7
|
+
import type { SubmitPickerProps } from "../../view/components/submit-picker.js";
|
|
8
|
+
import type { TabBarProps } from "../../view/components/tab-bar.js";
|
|
9
|
+
import type { DialogProps } from "../../view/dialog-builder.js";
|
|
10
|
+
import { LABELS_BY_KIND } from "../row-intent.js";
|
|
11
|
+
import { noteForTab } from "../state.js";
|
|
12
|
+
import type { GlobalSelector, PerTabBindingContext, PerTabSelector } from "./contract.js";
|
|
13
|
+
import { selectConfirmedIndicator } from "./derivations.js";
|
|
14
|
+
|
|
15
|
+
/** Header text for a tab, falling back to a position when the author gave none. */
|
|
16
|
+
function tabLabel(header: string | undefined, index: number): string {
|
|
17
|
+
return header !== undefined && header.length > 0 ? header : `Q${index + 1}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function emptyMultiSelectProps(ctx: PerTabBindingContext): MultiSelectViewProps {
|
|
21
|
+
return {
|
|
22
|
+
rows: [],
|
|
23
|
+
other: {
|
|
24
|
+
active: false,
|
|
25
|
+
inputMode: false,
|
|
26
|
+
inputBuffer: ctx.inputBuffer,
|
|
27
|
+
inputCursorOffset: ctx.inputCursorOffset,
|
|
28
|
+
},
|
|
29
|
+
nextActive: false,
|
|
30
|
+
nextLabel: LABELS_BY_KIND.next,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The commit row reads "Next" on every question but the last, where advancing
|
|
36
|
+
* has nowhere to go and the row submits instead. Both strings come from the
|
|
37
|
+
* layer that owns them: the row's own metadata, and the multi-select view's
|
|
38
|
+
* submit label.
|
|
39
|
+
*/
|
|
40
|
+
function nextLabelFor(ctx: PerTabBindingContext): string {
|
|
41
|
+
const isLastQuestion = ctx.i === ctx.questions.length - 1;
|
|
42
|
+
return isLastQuestion ? MULTI_SUBMIT_LABEL : LABELS_BY_KIND.next;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (state, ctx) => {
|
|
46
|
+
const question = ctx.questions[ctx.i];
|
|
47
|
+
if (!question) return emptyMultiSelectProps(ctx);
|
|
48
|
+
const focused = ctx.activeView === "options";
|
|
49
|
+
const rows = question.options.map((_option, i) => ({
|
|
50
|
+
checked: state.multiSelectChecked.has(i),
|
|
51
|
+
active: focused && i === state.optionIndex,
|
|
52
|
+
}));
|
|
53
|
+
return {
|
|
54
|
+
rows,
|
|
55
|
+
other: {
|
|
56
|
+
active: focused && state.optionIndex === question.options.length,
|
|
57
|
+
inputMode: state.inputMode,
|
|
58
|
+
inputBuffer: ctx.inputBuffer,
|
|
59
|
+
inputCursorOffset: ctx.inputCursorOffset,
|
|
60
|
+
},
|
|
61
|
+
nextActive: focused && state.optionIndex === question.options.length + 1,
|
|
62
|
+
nextLabel: nextLabelFor(ctx),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const selectOptionListProps: PerTabSelector<OptionListViewProps> = (state, ctx) => {
|
|
67
|
+
const items = ctx.itemsByTab[ctx.i] ?? [];
|
|
68
|
+
const confirmed = selectConfirmedIndicator(ctx.questions, state.currentTab, state.answers, items);
|
|
69
|
+
return {
|
|
70
|
+
selectedIndex: state.optionIndex,
|
|
71
|
+
focused: ctx.activeView === "options",
|
|
72
|
+
inputBuffer: ctx.inputBuffer,
|
|
73
|
+
inputCursorOffset: ctx.inputCursorOffset,
|
|
74
|
+
...(confirmed ? { confirmed } : {}),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const selectSubmitPickerProps: GlobalSelector<SubmitPickerProps> = (state, ctx) => {
|
|
79
|
+
const focused = ctx.activeView === "submit";
|
|
80
|
+
return {
|
|
81
|
+
rows: [
|
|
82
|
+
{ active: focused && state.submitChoiceIndex === 0 },
|
|
83
|
+
{ active: focused && state.submitChoiceIndex === 1 },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const selectPreviewPaneProps: PerTabSelector<PreviewPaneProps> = (state, ctx) => ({
|
|
89
|
+
notesVisible: state.notesVisible,
|
|
90
|
+
selectedIndex: state.optionIndex,
|
|
91
|
+
focused: ctx.activeView === "options",
|
|
92
|
+
inputMode: state.inputMode,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const selectTabBarProps: GlobalSelector<TabBarProps> = (state, ctx) => ({
|
|
96
|
+
tabs: ctx.questions.map((q, i) => ({
|
|
97
|
+
label: tabLabel(q.header, i),
|
|
98
|
+
answered: state.answers.has(i),
|
|
99
|
+
active: i === state.currentTab,
|
|
100
|
+
noted: noteForTab(state, i).length > 0,
|
|
101
|
+
})),
|
|
102
|
+
submit: {
|
|
103
|
+
active: state.currentTab === ctx.questions.length,
|
|
104
|
+
allAnswered: state.answers.size === ctx.questions.length && ctx.questions.length > 0,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const selectDialogProps: GlobalSelector<DialogProps> = (state, ctx) => ({
|
|
109
|
+
state,
|
|
110
|
+
activePreviewPane: ctx.activePreviewPane,
|
|
111
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
QuestionAnswer,
|
|
3
|
+
QuestionData,
|
|
4
|
+
QuestionnaireResult,
|
|
5
|
+
UnansweredNote,
|
|
6
|
+
} from "../tool/types.js";
|
|
7
|
+
import type { WrappingSelectItem } from "./row-intent.js";
|
|
8
|
+
import type { QuestionnaireAction } from "./key-router.js";
|
|
9
|
+
import { ROW_INTENT_META } from "./row-intent.js";
|
|
10
|
+
import { noteForTab, type QuestionnaireState } from "./state.js";
|
|
11
|
+
|
|
12
|
+
/** Session-lifetime constants. No live-component reads — peripheral values live on canonical state. */
|
|
13
|
+
export interface ApplyContext {
|
|
14
|
+
questions: readonly QuestionData[];
|
|
15
|
+
itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Declarative side-effects emitted by `reduce`. The runtime executes them after
|
|
20
|
+
* committing the new state, then asks the props-adapter to re-project. Closed set —
|
|
21
|
+
* adding an effect requires updating both the union AND the runtime's `runEffect` switch
|
|
22
|
+
* (compiler-enforced exhaustive). No string-keyed escape hatch.
|
|
23
|
+
*/
|
|
24
|
+
export type Effect =
|
|
25
|
+
| { kind: "set_input_buffer"; value: string }
|
|
26
|
+
| { kind: "clear_input_buffer" }
|
|
27
|
+
| { kind: "open_input_editor"; value: string }
|
|
28
|
+
| { kind: "set_notes_value"; value: string }
|
|
29
|
+
| { kind: "set_notes_focused"; focused: boolean }
|
|
30
|
+
| { kind: "forward_notes_keystroke"; data: string }
|
|
31
|
+
/**
|
|
32
|
+
* Tell the session to hide or show its underlying overlay. Emitted by the
|
|
33
|
+
* `toggle_collapsed` action so the runtime can call `OverlayHandle.setHidden(...)`,
|
|
34
|
+
* which lets other overlay-aware consumers (e.g. `pi-station`) see the questionnaire
|
|
35
|
+
* as truly hidden and resume normal chat scroll while the user reads the transcript
|
|
36
|
+
* behind the modal.
|
|
37
|
+
*/
|
|
38
|
+
| { kind: "set_overlay_hidden"; hidden: boolean }
|
|
39
|
+
| { kind: "clear_timer" }
|
|
40
|
+
| { kind: "done"; result: QuestionnaireResult };
|
|
41
|
+
|
|
42
|
+
export interface ApplyResult {
|
|
43
|
+
state: QuestionnaireState;
|
|
44
|
+
effects: readonly Effect[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function orderedAnswers(
|
|
48
|
+
state: QuestionnaireState,
|
|
49
|
+
questions: readonly QuestionData[],
|
|
50
|
+
): QuestionAnswer[] {
|
|
51
|
+
const out: QuestionAnswer[] = [];
|
|
52
|
+
for (let i = 0; i < questions.length; i++) {
|
|
53
|
+
const a = state.answers.get(i);
|
|
54
|
+
if (a) out.push(a);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Notes on questions with no answer behind them.
|
|
61
|
+
*
|
|
62
|
+
* Walks the questions rather than the map, which gives ask order for free and
|
|
63
|
+
* keeps the global note out: it lives at the `questions.length` pseudo-index in
|
|
64
|
+
* `notesByTab`, and this loop never reaches that far.
|
|
65
|
+
*
|
|
66
|
+
* Reads `state.notesByTab` directly rather than `noteForTab`, because the
|
|
67
|
+
* answer-mirror half of that lookup is unreachable here by construction — an
|
|
68
|
+
* index with no entry in `answers` has no mirror to read.
|
|
69
|
+
*/
|
|
70
|
+
function unansweredNotesFor(
|
|
71
|
+
state: QuestionnaireState,
|
|
72
|
+
questions: readonly QuestionData[],
|
|
73
|
+
): UnansweredNote[] {
|
|
74
|
+
const out: UnansweredNote[] = [];
|
|
75
|
+
for (let i = 0; i < questions.length; i++) {
|
|
76
|
+
if (state.answers.has(i)) continue;
|
|
77
|
+
const question = questions[i];
|
|
78
|
+
const note = state.notesByTab.get(i);
|
|
79
|
+
if (!question || note === undefined || note.length === 0) continue;
|
|
80
|
+
out.push({ questionIndex: i, question: question.question, note });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function syncMultiSelectFromAnswers(
|
|
86
|
+
answers: ReadonlyMap<number, QuestionAnswer>,
|
|
87
|
+
questions: readonly QuestionData[],
|
|
88
|
+
tab: number,
|
|
89
|
+
): ReadonlySet<number> {
|
|
90
|
+
const q = questions[tab];
|
|
91
|
+
if (!q?.multiSelect) return new Set();
|
|
92
|
+
const saved = answers.get(tab);
|
|
93
|
+
const labels = saved?.selected ?? [];
|
|
94
|
+
const indices = new Set<number>();
|
|
95
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
96
|
+
if (labels.includes(q.options[i]!.label)) indices.add(i);
|
|
97
|
+
}
|
|
98
|
+
return indices;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function persistMultiSelectAnswer(
|
|
102
|
+
state: QuestionnaireState,
|
|
103
|
+
ctx: ApplyContext,
|
|
104
|
+
): ReadonlyMap<number, QuestionAnswer> {
|
|
105
|
+
const q = ctx.questions[state.currentTab];
|
|
106
|
+
if (!q?.multiSelect) return state.answers;
|
|
107
|
+
const selected: string[] = [];
|
|
108
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
109
|
+
if (state.multiSelectChecked.has(i)) selected.push(q.options[i]!.label);
|
|
110
|
+
}
|
|
111
|
+
const out = new Map(state.answers);
|
|
112
|
+
if (selected.length === 0) {
|
|
113
|
+
out.delete(state.currentTab);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
const pendingNotes = state.notesByTab.get(state.currentTab);
|
|
117
|
+
out.set(state.currentTab, {
|
|
118
|
+
questionIndex: state.currentTab,
|
|
119
|
+
question: q.question,
|
|
120
|
+
kind: "multi",
|
|
121
|
+
answer: null,
|
|
122
|
+
selected,
|
|
123
|
+
...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
|
|
124
|
+
});
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function customDraftValueFor(state: QuestionnaireState, tab: number): string {
|
|
129
|
+
const draft = state.customDraftsByTab.get(tab);
|
|
130
|
+
if (draft !== undefined) return draft;
|
|
131
|
+
const answer = state.answers.get(tab);
|
|
132
|
+
return answer?.kind === "custom" && typeof answer.answer === "string" ? answer.answer : "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setCustomDraft(
|
|
136
|
+
state: QuestionnaireState,
|
|
137
|
+
tab: number,
|
|
138
|
+
value: string,
|
|
139
|
+
): ReadonlyMap<number, string> {
|
|
140
|
+
const drafts = new Map(state.customDraftsByTab);
|
|
141
|
+
drafts.set(tab, value);
|
|
142
|
+
return drafts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function withoutCustomDraft(state: QuestionnaireState, tab: number): ReadonlyMap<number, string> {
|
|
146
|
+
if (!state.customDraftsByTab.has(tab)) return state.customDraftsByTab;
|
|
147
|
+
const drafts = new Map(state.customDraftsByTab);
|
|
148
|
+
drafts.delete(tab);
|
|
149
|
+
return drafts;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function switchTabResult(
|
|
153
|
+
state: QuestionnaireState,
|
|
154
|
+
nextTab: number,
|
|
155
|
+
ctx: ApplyContext,
|
|
156
|
+
): ApplyResult {
|
|
157
|
+
const notesValue = noteForTab(state, nextTab);
|
|
158
|
+
const transitioned: QuestionnaireState = {
|
|
159
|
+
...state,
|
|
160
|
+
currentTab: nextTab,
|
|
161
|
+
optionIndex: 0,
|
|
162
|
+
inputMode: false,
|
|
163
|
+
notesVisible: false,
|
|
164
|
+
submitChoiceIndex: 0,
|
|
165
|
+
multiSelectChecked: syncMultiSelectFromAnswers(state.answers, ctx.questions, nextTab),
|
|
166
|
+
notesDraft: notesValue,
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
state: transitioned,
|
|
170
|
+
effects: [
|
|
171
|
+
{ kind: "set_notes_focused", focused: false },
|
|
172
|
+
{ kind: "set_notes_value", value: notesValue },
|
|
173
|
+
{ kind: "set_input_buffer", value: customDraftValueFor(state, nextTab) },
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolean): ApplyResult {
|
|
179
|
+
// Global note lift: the Submit-tab note lives at the `questions.length` pseudo-index
|
|
180
|
+
// in `notesByTab` — question tabs only occupy 0..questions.length-1, so this can never
|
|
181
|
+
// cross-contaminate a per-question note. Attached regardless of `cancelled` (the
|
|
182
|
+
// reducer is truth; the envelope owns decline presentation), with cancel/submit/confirm
|
|
183
|
+
// sharing this single lift. Conditional spread keeps note-free results byte-identical.
|
|
184
|
+
const globalNote = state.notesByTab.get(ctx.questions.length);
|
|
185
|
+
// Notes on unanswered questions are lifted the same way and for the same
|
|
186
|
+
// reason: they belong to the person who wrote them, not to the answer they
|
|
187
|
+
// never gave, so cancelling must not eat them either.
|
|
188
|
+
const unansweredNotes = unansweredNotesFor(state, ctx.questions);
|
|
189
|
+
const result: QuestionnaireResult = {
|
|
190
|
+
answers: orderedAnswers(state, ctx.questions),
|
|
191
|
+
cancelled,
|
|
192
|
+
...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
|
|
193
|
+
...(unansweredNotes.length > 0 ? { unansweredNotes } : {}),
|
|
194
|
+
};
|
|
195
|
+
return { state, effects: [{ kind: "done", result }] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Per-kind handler signature: action payload narrows to the matching union member
|
|
200
|
+
* via `Extract`, so handlers consume fully-typed actions without `as` casts.
|
|
201
|
+
*/
|
|
202
|
+
type Handler<K extends QuestionnaireAction["kind"]> = (
|
|
203
|
+
state: QuestionnaireState,
|
|
204
|
+
action: Extract<QuestionnaireAction, { kind: K }>,
|
|
205
|
+
ctx: ApplyContext,
|
|
206
|
+
) => ApplyResult;
|
|
207
|
+
|
|
208
|
+
const navHandler: Handler<"nav"> = (state, action, ctx) => {
|
|
209
|
+
const items = ctx.itemsByTab[state.currentTab] ?? [];
|
|
210
|
+
const item = items[action.nextIndex];
|
|
211
|
+
const inputMode = item ? ROW_INTENT_META[item.kind].activatesInputMode : false;
|
|
212
|
+
const customDraftsByTab = state.inputMode
|
|
213
|
+
? setCustomDraft(state, state.currentTab, action.inputValue)
|
|
214
|
+
: state.customDraftsByTab;
|
|
215
|
+
const next: QuestionnaireState = {
|
|
216
|
+
...state,
|
|
217
|
+
optionIndex: action.nextIndex,
|
|
218
|
+
inputMode,
|
|
219
|
+
customDraftsByTab,
|
|
220
|
+
};
|
|
221
|
+
if (!inputMode) return { state: next, effects: [] };
|
|
222
|
+
return {
|
|
223
|
+
state: next,
|
|
224
|
+
effects: [{ kind: "set_input_buffer", value: customDraftValueFor(next, state.currentTab) }],
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const inputClearHandler: Handler<"input_clear"> = (state, _action, _ctx) => ({
|
|
229
|
+
state: { ...state, customDraftsByTab: setCustomDraft(state, state.currentTab, "") },
|
|
230
|
+
effects: [{ kind: "clear_input_buffer" }],
|
|
231
|
+
});
|
|
232
|
+
const inputEditHandler: Handler<"input_edit"> = (state, action, _ctx) => ({
|
|
233
|
+
state,
|
|
234
|
+
effects: [{ kind: "open_input_editor", value: action.value }],
|
|
235
|
+
});
|
|
236
|
+
const inputReplaceHandler: Handler<"input_replace"> = (state, action, _ctx) => ({
|
|
237
|
+
state: { ...state, customDraftsByTab: setCustomDraft(state, state.currentTab, action.value) },
|
|
238
|
+
effects: [{ kind: "set_input_buffer", value: action.value }],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const tabSwitchHandler: Handler<"tab_switch"> = (state, action, ctx) =>
|
|
242
|
+
switchTabResult(state, action.nextTab, ctx);
|
|
243
|
+
|
|
244
|
+
const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
|
|
245
|
+
let answer = action.answer;
|
|
246
|
+
if (answer.kind === "option" && answer.answer) {
|
|
247
|
+
const q = ctx.questions[answer.questionIndex];
|
|
248
|
+
const matched = q?.options.find((o) => o.label === answer.answer);
|
|
249
|
+
if (matched?.preview && matched.preview.length > 0) {
|
|
250
|
+
answer = { ...answer, preview: matched.preview };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const pendingNotes = state.notesByTab.get(answer.questionIndex);
|
|
254
|
+
if (pendingNotes && pendingNotes.length > 0) {
|
|
255
|
+
answer = { ...answer, notes: pendingNotes };
|
|
256
|
+
}
|
|
257
|
+
const answers = new Map(state.answers);
|
|
258
|
+
answers.set(answer.questionIndex, answer);
|
|
259
|
+
// Custom free-text on a multi-select tab is mutually exclusive with checkbox selections:
|
|
260
|
+
// clear the checked set immediately so [✔] glyphs vanish on Enter. (A custom answer
|
|
261
|
+
// carries no `selected` array, so syncMultiSelectFromAnswers keeps it empty on tab-back.)
|
|
262
|
+
const isCustomMulti =
|
|
263
|
+
answer.kind === "custom" && ctx.questions[answer.questionIndex]?.multiSelect === true;
|
|
264
|
+
const customDraftsByTab =
|
|
265
|
+
answer.kind === "custom"
|
|
266
|
+
? withoutCustomDraft(state, answer.questionIndex)
|
|
267
|
+
: state.customDraftsByTab;
|
|
268
|
+
const next: QuestionnaireState = {
|
|
269
|
+
...state,
|
|
270
|
+
answers,
|
|
271
|
+
customDraftsByTab,
|
|
272
|
+
...(isCustomMulti ? { multiSelectChecked: new Set<number>() } : {}),
|
|
273
|
+
};
|
|
274
|
+
if (action.autoAdvanceTab !== undefined) return switchTabResult(next, action.autoAdvanceTab, ctx);
|
|
275
|
+
return doneFor(next, ctx, false);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
|
|
279
|
+
const checked = new Set(state.multiSelectChecked);
|
|
280
|
+
if (checked.has(action.index)) checked.delete(action.index);
|
|
281
|
+
else checked.add(action.index);
|
|
282
|
+
const intermediate: QuestionnaireState = { ...state, multiSelectChecked: checked };
|
|
283
|
+
const answers = persistMultiSelectAnswer(intermediate, ctx);
|
|
284
|
+
return { state: { ...intermediate, answers }, effects: [] };
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const multiConfirmHandler: Handler<"multi_confirm"> = (state, action, ctx) => {
|
|
288
|
+
const q = ctx.questions[state.currentTab];
|
|
289
|
+
if (!q) return { state, effects: [] };
|
|
290
|
+
const pendingNotes = state.notesByTab.get(state.currentTab);
|
|
291
|
+
const answers = new Map(state.answers);
|
|
292
|
+
answers.set(state.currentTab, {
|
|
293
|
+
questionIndex: state.currentTab,
|
|
294
|
+
question: q.question,
|
|
295
|
+
kind: "multi",
|
|
296
|
+
answer: null,
|
|
297
|
+
selected: action.selected,
|
|
298
|
+
...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
|
|
299
|
+
});
|
|
300
|
+
const synced: QuestionnaireState = {
|
|
301
|
+
...state,
|
|
302
|
+
answers,
|
|
303
|
+
multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab),
|
|
304
|
+
};
|
|
305
|
+
if (action.autoAdvanceTab !== undefined)
|
|
306
|
+
return switchTabResult(synced, action.autoAdvanceTab, ctx);
|
|
307
|
+
return doneFor(synced, ctx, false);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const notesEnterHandler: Handler<"notes_enter"> = (state, _action, _ctx) => {
|
|
311
|
+
const value = noteForTab(state, state.currentTab);
|
|
312
|
+
return {
|
|
313
|
+
state: { ...state, notesVisible: true, notesDraft: value },
|
|
314
|
+
effects: [
|
|
315
|
+
{ kind: "set_notes_value", value },
|
|
316
|
+
{ kind: "set_notes_focused", focused: true },
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const notesExitHandler: Handler<"notes_exit"> = (state, _action, _ctx) => {
|
|
322
|
+
const trimmed = state.notesDraft.trim();
|
|
323
|
+
const notes = new Map(state.notesByTab);
|
|
324
|
+
const answers = new Map(state.answers);
|
|
325
|
+
if (trimmed.length === 0) {
|
|
326
|
+
notes.delete(state.currentTab);
|
|
327
|
+
const prev = answers.get(state.currentTab);
|
|
328
|
+
if (prev?.notes) {
|
|
329
|
+
const stripped = { ...prev };
|
|
330
|
+
delete (stripped as { notes?: string }).notes;
|
|
331
|
+
answers.set(state.currentTab, stripped);
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
notes.set(state.currentTab, trimmed);
|
|
335
|
+
const prev = answers.get(state.currentTab);
|
|
336
|
+
if (prev) answers.set(state.currentTab, { ...prev, notes: trimmed });
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
state: { ...state, notesByTab: notes, answers, notesVisible: false },
|
|
340
|
+
effects: [{ kind: "set_notes_focused", focused: false }],
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const cancelHandler: Handler<"cancel"> = (s, _a, c) => doneFor(s, c, true);
|
|
345
|
+
const submitHandler: Handler<"submit"> = (s, _a, c) => doneFor(s, c, false);
|
|
346
|
+
const submitNavHandler: Handler<"submit_nav"> = (s, a, _c) => ({
|
|
347
|
+
state: { ...s, submitChoiceIndex: a.nextIndex },
|
|
348
|
+
effects: [],
|
|
349
|
+
});
|
|
350
|
+
const notesForwardHandler: Handler<"notes_forward"> = (s, a, _c) => ({
|
|
351
|
+
state: s,
|
|
352
|
+
effects: [{ kind: "forward_notes_keystroke", data: a.data }],
|
|
353
|
+
});
|
|
354
|
+
const toggleCollapsedHandler: Handler<"toggle_collapsed"> = (s, _a, _c) => ({
|
|
355
|
+
state: { ...s, collapsed: !s.collapsed },
|
|
356
|
+
effects: [{ kind: "set_overlay_hidden", hidden: !s.collapsed }],
|
|
357
|
+
});
|
|
358
|
+
const tickHandler: Handler<"tick"> = (state, action, ctx) => {
|
|
359
|
+
if (state.timerCancelled || state.deadline === undefined) return { state, effects: [] };
|
|
360
|
+
const remaining = state.deadline - action.now;
|
|
361
|
+
if (remaining > 0) {
|
|
362
|
+
return { state: { ...state, remainingMs: remaining }, effects: [] };
|
|
363
|
+
}
|
|
364
|
+
const globalNote = state.notesByTab.get(ctx.questions.length);
|
|
365
|
+
const result: QuestionnaireResult = {
|
|
366
|
+
answers: orderedAnswers(state, ctx.questions),
|
|
367
|
+
cancelled: true,
|
|
368
|
+
error: "timed_out",
|
|
369
|
+
...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
|
|
370
|
+
};
|
|
371
|
+
return { state, effects: [{ kind: "done", result }] };
|
|
372
|
+
};
|
|
373
|
+
const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: [] });
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Compile-time-exhaustive dispatch table. `{ [K in Kind]: Handler<K> }` requires
|
|
377
|
+
* an entry per union member — adding a new `QuestionnaireAction` variant fails to
|
|
378
|
+
* compile here until a handler is registered, mirroring the `Record<RowKind, …>`
|
|
379
|
+
* pattern used by `ROW_INTENT_META`.
|
|
380
|
+
*/
|
|
381
|
+
const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
|
|
382
|
+
nav: navHandler,
|
|
383
|
+
input_clear: inputClearHandler,
|
|
384
|
+
input_edit: inputEditHandler,
|
|
385
|
+
input_replace: inputReplaceHandler,
|
|
386
|
+
tab_switch: tabSwitchHandler,
|
|
387
|
+
confirm: confirmHandler,
|
|
388
|
+
toggle: toggleHandler,
|
|
389
|
+
multi_confirm: multiConfirmHandler,
|
|
390
|
+
cancel: cancelHandler,
|
|
391
|
+
notes_enter: notesEnterHandler,
|
|
392
|
+
notes_exit: notesExitHandler,
|
|
393
|
+
notes_forward: notesForwardHandler,
|
|
394
|
+
submit: submitHandler,
|
|
395
|
+
submit_nav: submitNavHandler,
|
|
396
|
+
toggle_collapsed: toggleCollapsedHandler,
|
|
397
|
+
tick: tickHandler,
|
|
398
|
+
ignore: ignoreHandler,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Pure reducer: (state, action, ctx) → (state, Effect[]). Mirrors `rpiv-todo`'s `applyTaskMutation`.
|
|
403
|
+
* Delegates to `HANDLERS` — per-kind handlers above are pure, named, and individually testable.
|
|
404
|
+
* `ignore` is also handled outside the reducer by `handleIgnoreInline` in the runtime fast path.
|
|
405
|
+
*/
|
|
406
|
+
export function reduce(
|
|
407
|
+
state: QuestionnaireState,
|
|
408
|
+
action: QuestionnaireAction,
|
|
409
|
+
ctx: ApplyContext,
|
|
410
|
+
): ApplyResult {
|
|
411
|
+
const handler = HANDLERS[action.kind] as Handler<typeof action.kind>;
|
|
412
|
+
const result = handler(state, action as never, ctx);
|
|
413
|
+
const isHumanKeystroke = action.kind !== "tick" && action.kind !== "toggle_collapsed";
|
|
414
|
+
if (isHumanKeystroke && state.deadline !== undefined && !state.timerCancelled) {
|
|
415
|
+
return {
|
|
416
|
+
state: { ...result.state, timerCancelled: true, remainingMs: undefined },
|
|
417
|
+
effects: [...result.effects, { kind: "clear_timer" }],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { QuestionAnswer, QuestionData } from "../tool/types.js";
|
|
2
|
+
import type { WrappingSelectItem } from "./row-intent.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The note attached to a tab, or `""` when it has none.
|
|
6
|
+
*
|
|
7
|
+
* `notesByTab` is authoritative: before an option is confirmed the note lives
|
|
8
|
+
* only there, and `answers[tab].notes` is the mirror written at confirm time.
|
|
9
|
+
* One lookup shared by the reducer, the tab-bar marker and the resting row, so
|
|
10
|
+
* the three cannot disagree about whether a note exists.
|
|
11
|
+
*
|
|
12
|
+
* Question indices only. The global note sits at the `questions.length`
|
|
13
|
+
* pseudo-index, and no caller should reach it through here.
|
|
14
|
+
*/
|
|
15
|
+
export function noteForTab(state: QuestionnaireState, tab: number): string {
|
|
16
|
+
return state.notesByTab.get(tab) ?? state.answers.get(tab)?.notes ?? "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Canonical state for the questionnaire dialog. Single source of truth — both the
|
|
21
|
+
* dispatcher (`routeKey`) and the view layer read this same shape.
|
|
22
|
+
*/
|
|
23
|
+
export interface QuestionnaireState {
|
|
24
|
+
currentTab: number;
|
|
25
|
+
optionIndex: number;
|
|
26
|
+
inputMode: boolean;
|
|
27
|
+
notesVisible: boolean;
|
|
28
|
+
answers: ReadonlyMap<number, QuestionAnswer>;
|
|
29
|
+
multiSelectChecked: ReadonlySet<number>;
|
|
30
|
+
/** In-flight custom answers keyed by tab. A present empty string overrides an older answer. */
|
|
31
|
+
customDraftsByTab: ReadonlyMap<number, string>;
|
|
32
|
+
/**
|
|
33
|
+
* Pre-answer notes side-band, keyed by tab index. Decoupled from `answers` so adding
|
|
34
|
+
* notes does NOT mark a question answered (the Submit-tab missing-check would falsely
|
|
35
|
+
* pass otherwise). Merged into the answer at confirm time.
|
|
36
|
+
*/
|
|
37
|
+
notesByTab: ReadonlyMap<number, string>;
|
|
38
|
+
/** Focused row in the Submit-tab picker (0 = Submit, 1 = Cancel). Reset on tab switch. */
|
|
39
|
+
submitChoiceIndex: number;
|
|
40
|
+
/** Canonical mirror of the in-flight notes editor; runtime mirrors after `forward_notes_keystroke`. */
|
|
41
|
+
notesDraft: string;
|
|
42
|
+
/**
|
|
43
|
+
* Absolute deadline in epoch milliseconds when the questionnaire should auto-dismiss.
|
|
44
|
+
* Set once at initialization when `timeout` is provided; never reset. Undefined when
|
|
45
|
+
* no timeout is configured.
|
|
46
|
+
*/
|
|
47
|
+
deadline?: number | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Milliseconds remaining until `deadline`. Updated on every `tick`; undefined when
|
|
50
|
+
* no timeout is active or after the timer is cancelled by human input.
|
|
51
|
+
*/
|
|
52
|
+
remainingMs?: number | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the countdown has been cancelled by the first human keystroke.
|
|
55
|
+
* Cancelled outright, not reset — once true it stays true.
|
|
56
|
+
*/
|
|
57
|
+
timerCancelled: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Collapsed mode: the questionnaire gets out of the way so the agent transcript behind
|
|
60
|
+
* the bottom-anchored overlay becomes readable. Toggled by the configured collapse key
|
|
61
|
+
* from any state; while true, every keystroke except cancel is swallowed (see
|
|
62
|
+
* `routeKey`). Two renderings, chosen by host capability:
|
|
63
|
+
*
|
|
64
|
+
* - Hosts with an `OverlayHandle` AND a raw `onTerminalInput` listener (real pi-tui):
|
|
65
|
+
* the `set_overlay_hidden` effect fully hides the overlay; the raw listener registered
|
|
66
|
+
* in `execute()` reopens it, because pi-tui routes no input to a hidden overlay.
|
|
67
|
+
* - Hosts without the raw listener (or without a handle): the overlay stays visible and
|
|
68
|
+
* shrinks to a single hint row. The row keeps focus and input routing, so the same key
|
|
69
|
+
* expands it and Esc cancels, and the expand key never falls through to an underlying
|
|
70
|
+
* overlay (e.g. `/btw`). The session gates `set_overlay_hidden` on the listener's
|
|
71
|
+
* existence (`canReopenWhileHidden`) so a handle-bearing host without raw input can
|
|
72
|
+
* never hide the overlay into a state nothing can reopen.
|
|
73
|
+
*/
|
|
74
|
+
collapsed: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Per-tick context the dispatcher needs alongside canonical state. Held separately
|
|
79
|
+
* because `keybindings` / `inputBuffer` must never reach view setProps consumers.
|
|
80
|
+
*/
|
|
81
|
+
export interface QuestionnaireRuntime {
|
|
82
|
+
keybindings: { matches(data: string, name: string): boolean };
|
|
83
|
+
inputBuffer: string;
|
|
84
|
+
canMoveInputUp: boolean;
|
|
85
|
+
canMoveInputDown: boolean;
|
|
86
|
+
questions: readonly QuestionData[];
|
|
87
|
+
isMulti: boolean;
|
|
88
|
+
currentItem: WrappingSelectItem | undefined;
|
|
89
|
+
items: readonly WrappingSelectItem[];
|
|
90
|
+
/**
|
|
91
|
+
* Key spec for the collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. Resolved
|
|
92
|
+
* from `AskUserQuestionConfig.collapseKey` (or the package default). When `"off"`,
|
|
93
|
+
* the collapse shortcut is disabled.
|
|
94
|
+
*/
|
|
95
|
+
collapseKey: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Which of the three focusable surfaces owns the keyboard this tick.
|
|
100
|
+
*
|
|
101
|
+
* Declared here rather than in the view layer because focus is a property of
|
|
102
|
+
* canonical state, not of any renderer: the key router's cascade and the
|
|
103
|
+
* reducer's defensive clears are what enforce mutual exclusion, and components
|
|
104
|
+
* only read the result. Per-component `focused: boolean` flags derive from one
|
|
105
|
+
* equality check against this discriminant instead of parallel boolean reads.
|
|
106
|
+
*
|
|
107
|
+
* Priority order is notes > submit > options, matching the router cascade
|
|
108
|
+
* exactly.
|
|
109
|
+
*/
|
|
110
|
+
export type ActiveView = "notes" | "options" | "submit";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { QuestionAnswer } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Stand-in for an answer with no text. One placeholder for every variant. */
|
|
4
|
+
export const NO_INPUT_PLACEHOLDER = "(no input)";
|
|
5
|
+
|
|
6
|
+
export type FormatAnswerVariant = "summary" | "envelope";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reduce an answer to its scalar string form.
|
|
10
|
+
*
|
|
11
|
+
* `variant` currently changes nothing: the branch that once distinguished the
|
|
12
|
+
* review summary from the model-facing envelope is gone. It stays on the
|
|
13
|
+
* signature because both call sites read better naming which surface they are
|
|
14
|
+
* rendering, and because the distinction is likely to come back.
|
|
15
|
+
*
|
|
16
|
+
* The switch is exhaustive by way of a non-void return: adding a `kind` fails
|
|
17
|
+
* to compile here.
|
|
18
|
+
*/
|
|
19
|
+
export function formatAnswerScalar(a: QuestionAnswer, _variant: FormatAnswerVariant): string {
|
|
20
|
+
switch (a.kind) {
|
|
21
|
+
case "multi":
|
|
22
|
+
return a.selected && a.selected.length > 0 ? a.selected.join(", ") : NO_INPUT_PLACEHOLDER;
|
|
23
|
+
case "custom":
|
|
24
|
+
return a.answer && a.answer.length > 0 ? a.answer : NO_INPUT_PLACEHOLDER;
|
|
25
|
+
case "option":
|
|
26
|
+
return a.answer ?? NO_INPUT_PLACEHOLDER;
|
|
27
|
+
}
|
|
28
|
+
}
|