pi-ask-popup 0.1.0 → 0.1.1
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/README.md +95 -32
- package/docs/configuration.md +93 -0
- package/docs/hosts.md +71 -0
- package/docs/keyboard.md +65 -0
- package/docs/tool-schema.md +124 -0
- package/package.json +12 -2
- package/preview/popup-submit.webp +0 -0
- package/preview/popup-with-notes.webp +0 -0
- package/preview/popup-with-tab.webp +0 -0
- package/src/ask-user-question.ts +50 -24
- package/src/config.ts +66 -23
- package/src/rpc-fallback.ts +30 -15
- package/src/state/build-questionnaire.ts +3 -1
- package/src/state/external-editor.ts +6 -2
- package/src/state/key-router.ts +129 -41
- package/src/state/questionnaire-session.ts +35 -13
- package/src/state/row-intent.ts +6 -2
- package/src/state/selectors/derivations.ts +18 -6
- package/src/state/selectors/focus.ts +6 -2
- package/src/state/selectors/projections.ts +3 -1
- package/src/state/state-reducer.ts +99 -33
- package/src/tool/response-envelope.ts +32 -20
- package/src/tool/types.ts +13 -2
- package/src/view/component-binding.ts +3 -1
- package/src/view/components/inline-input.ts +3 -1
- package/src/view/components/multi-select-view.ts +15 -5
- package/src/view/components/preview/markdown-content-cache.ts +21 -6
- package/src/view/components/preview/preview-block-renderer.ts +8 -1
- package/src/view/components/preview/preview-box-renderer.ts +4 -5
- package/src/view/components/preview/preview-layout-decider.ts +20 -15
- package/src/view/components/preview/preview-pane.ts +54 -18
- package/src/view/components/wrapping-select.ts +15 -5
- package/src/view/dialog-builder.ts +24 -8
- package/src/view/props-adapter.ts +21 -7
- package/src/view/tab-content-strategy.ts +60 -20
|
@@ -8,6 +8,17 @@ import type { WrappingSelectItem } from "./row-intent.js";
|
|
|
8
8
|
import type { QuestionnaireAction } from "./key-router.js";
|
|
9
9
|
import { ROW_INTENT_META } from "./row-intent.js";
|
|
10
10
|
import { noteForTab, type QuestionnaireState } from "./state.js";
|
|
11
|
+
type JsonValue =
|
|
12
|
+
| string
|
|
13
|
+
| number
|
|
14
|
+
| boolean
|
|
15
|
+
| null
|
|
16
|
+
| JsonValue[]
|
|
17
|
+
| { readonly [key: string]: JsonValue };
|
|
18
|
+
|
|
19
|
+
function isString(value: JsonValue | undefined): value is string {
|
|
20
|
+
return typeof value === "string";
|
|
21
|
+
}
|
|
11
22
|
|
|
12
23
|
/** Session-lifetime constants. No live-component reads — peripheral values live on canonical state. */
|
|
13
24
|
export interface ApplyContext {
|
|
@@ -51,7 +62,9 @@ function orderedAnswers(
|
|
|
51
62
|
const out: QuestionAnswer[] = [];
|
|
52
63
|
for (let i = 0; i < questions.length; i++) {
|
|
53
64
|
const a = state.answers.get(i);
|
|
54
|
-
if (a)
|
|
65
|
+
if (a) {
|
|
66
|
+
out.push(a);
|
|
67
|
+
}
|
|
55
68
|
}
|
|
56
69
|
return out;
|
|
57
70
|
}
|
|
@@ -73,10 +86,14 @@ function unansweredNotesFor(
|
|
|
73
86
|
): UnansweredNote[] {
|
|
74
87
|
const out: UnansweredNote[] = [];
|
|
75
88
|
for (let i = 0; i < questions.length; i++) {
|
|
76
|
-
if (state.answers.has(i))
|
|
89
|
+
if (state.answers.has(i)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
77
92
|
const question = questions[i];
|
|
78
93
|
const note = state.notesByTab.get(i);
|
|
79
|
-
if (!question || note === undefined || note.length === 0)
|
|
94
|
+
if (!question || note === undefined || note.length === 0) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
80
97
|
out.push({ questionIndex: i, question: question.question, note });
|
|
81
98
|
}
|
|
82
99
|
return out;
|
|
@@ -88,12 +105,16 @@ function syncMultiSelectFromAnswers(
|
|
|
88
105
|
tab: number,
|
|
89
106
|
): ReadonlySet<number> {
|
|
90
107
|
const q = questions[tab];
|
|
91
|
-
if (!q?.multiSelect)
|
|
108
|
+
if (!q?.multiSelect) {
|
|
109
|
+
return new Set();
|
|
110
|
+
}
|
|
92
111
|
const saved = answers.get(tab);
|
|
93
112
|
const labels = saved?.selected ?? [];
|
|
94
113
|
const indices = new Set<number>();
|
|
95
114
|
for (let i = 0; i < q.options.length; i++) {
|
|
96
|
-
if (labels.includes(q.options[i]!.label))
|
|
115
|
+
if (labels.includes(q.options[i]!.label)) {
|
|
116
|
+
indices.add(i);
|
|
117
|
+
}
|
|
97
118
|
}
|
|
98
119
|
return indices;
|
|
99
120
|
}
|
|
@@ -103,10 +124,14 @@ function persistMultiSelectAnswer(
|
|
|
103
124
|
ctx: ApplyContext,
|
|
104
125
|
): ReadonlyMap<number, QuestionAnswer> {
|
|
105
126
|
const q = ctx.questions[state.currentTab];
|
|
106
|
-
if (!q?.multiSelect)
|
|
127
|
+
if (!q?.multiSelect) {
|
|
128
|
+
return state.answers;
|
|
129
|
+
}
|
|
107
130
|
const selected: string[] = [];
|
|
108
131
|
for (let i = 0; i < q.options.length; i++) {
|
|
109
|
-
if (state.multiSelectChecked.has(i))
|
|
132
|
+
if (state.multiSelectChecked.has(i)) {
|
|
133
|
+
selected.push(q.options[i]!.label);
|
|
134
|
+
}
|
|
110
135
|
}
|
|
111
136
|
const out = new Map(state.answers);
|
|
112
137
|
if (selected.length === 0) {
|
|
@@ -114,22 +139,28 @@ function persistMultiSelectAnswer(
|
|
|
114
139
|
return out;
|
|
115
140
|
}
|
|
116
141
|
const pendingNotes = state.notesByTab.get(state.currentTab);
|
|
117
|
-
|
|
142
|
+
const entry: QuestionAnswer = {
|
|
118
143
|
questionIndex: state.currentTab,
|
|
119
144
|
question: q.question,
|
|
120
145
|
kind: "multi",
|
|
121
146
|
answer: null,
|
|
122
147
|
selected,
|
|
123
|
-
|
|
124
|
-
|
|
148
|
+
};
|
|
149
|
+
if (pendingNotes && pendingNotes.length > 0) {
|
|
150
|
+
// SAFETY: notes is an optional string per QuestionAnswer contract; adding when present preserves the shape.
|
|
151
|
+
(entry as QuestionAnswer & { notes: string }).notes = pendingNotes;
|
|
152
|
+
}
|
|
153
|
+
out.set(state.currentTab, entry);
|
|
125
154
|
return out;
|
|
126
155
|
}
|
|
127
156
|
|
|
128
157
|
function customDraftValueFor(state: QuestionnaireState, tab: number): string {
|
|
129
158
|
const draft = state.customDraftsByTab.get(tab);
|
|
130
|
-
if (draft !== undefined)
|
|
159
|
+
if (draft !== undefined) {
|
|
160
|
+
return draft;
|
|
161
|
+
}
|
|
131
162
|
const answer = state.answers.get(tab);
|
|
132
|
-
return answer?.kind === "custom" &&
|
|
163
|
+
return answer?.kind === "custom" && isString(answer.answer) ? answer.answer : "";
|
|
133
164
|
}
|
|
134
165
|
|
|
135
166
|
function setCustomDraft(
|
|
@@ -143,7 +174,9 @@ function setCustomDraft(
|
|
|
143
174
|
}
|
|
144
175
|
|
|
145
176
|
function withoutCustomDraft(state: QuestionnaireState, tab: number): ReadonlyMap<number, string> {
|
|
146
|
-
if (!state.customDraftsByTab.has(tab))
|
|
177
|
+
if (!state.customDraftsByTab.has(tab)) {
|
|
178
|
+
return state.customDraftsByTab;
|
|
179
|
+
}
|
|
147
180
|
const drafts = new Map(state.customDraftsByTab);
|
|
148
181
|
drafts.delete(tab);
|
|
149
182
|
return drafts;
|
|
@@ -189,9 +222,16 @@ function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolea
|
|
|
189
222
|
const result: QuestionnaireResult = {
|
|
190
223
|
answers: orderedAnswers(state, ctx.questions),
|
|
191
224
|
cancelled,
|
|
192
|
-
...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
|
|
193
|
-
...(unansweredNotes.length > 0 ? { unansweredNotes } : {}),
|
|
194
225
|
};
|
|
226
|
+
if (globalNote && globalNote.length > 0) {
|
|
227
|
+
// SAFETY: globalNote is optional per QuestionnaireResult; present only when non-empty.
|
|
228
|
+
(result as QuestionnaireResult & { globalNote: string }).globalNote = globalNote;
|
|
229
|
+
}
|
|
230
|
+
if (unansweredNotes.length > 0) {
|
|
231
|
+
// SAFETY: unansweredNotes is optional per QuestionnaireResult; present only when non-empty.
|
|
232
|
+
(result as QuestionnaireResult & { unansweredNotes: UnansweredNote[] }).unansweredNotes =
|
|
233
|
+
unansweredNotes;
|
|
234
|
+
}
|
|
195
235
|
return { state, effects: [{ kind: "done", result }] };
|
|
196
236
|
}
|
|
197
237
|
|
|
@@ -218,7 +258,9 @@ const navHandler: Handler<"nav"> = (state, action, ctx) => {
|
|
|
218
258
|
inputMode,
|
|
219
259
|
customDraftsByTab,
|
|
220
260
|
};
|
|
221
|
-
if (!inputMode)
|
|
261
|
+
if (!inputMode) {
|
|
262
|
+
return { state: next, effects: [] };
|
|
263
|
+
}
|
|
222
264
|
return {
|
|
223
265
|
state: next,
|
|
224
266
|
effects: [{ kind: "set_input_buffer", value: customDraftValueFor(next, state.currentTab) }],
|
|
@@ -269,16 +311,23 @@ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
|
|
|
269
311
|
...state,
|
|
270
312
|
answers,
|
|
271
313
|
customDraftsByTab,
|
|
272
|
-
...(isCustomMulti ? { multiSelectChecked: new Set<number>() } : {}),
|
|
273
314
|
};
|
|
274
|
-
if (
|
|
315
|
+
if (isCustomMulti) {
|
|
316
|
+
next.multiSelectChecked = new Set<number>();
|
|
317
|
+
}
|
|
318
|
+
if (action.autoAdvanceTab !== undefined) {
|
|
319
|
+
return switchTabResult(next, action.autoAdvanceTab, ctx);
|
|
320
|
+
}
|
|
275
321
|
return doneFor(next, ctx, false);
|
|
276
322
|
};
|
|
277
323
|
|
|
278
324
|
const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
|
|
279
325
|
const checked = new Set(state.multiSelectChecked);
|
|
280
|
-
if (checked.has(action.index))
|
|
281
|
-
|
|
326
|
+
if (checked.has(action.index)) {
|
|
327
|
+
checked.delete(action.index);
|
|
328
|
+
} else {
|
|
329
|
+
checked.add(action.index);
|
|
330
|
+
}
|
|
282
331
|
const intermediate: QuestionnaireState = { ...state, multiSelectChecked: checked };
|
|
283
332
|
const answers = persistMultiSelectAnswer(intermediate, ctx);
|
|
284
333
|
return { state: { ...intermediate, answers }, effects: [] };
|
|
@@ -286,24 +335,31 @@ const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
|
|
|
286
335
|
|
|
287
336
|
const multiConfirmHandler: Handler<"multi_confirm"> = (state, action, ctx) => {
|
|
288
337
|
const q = ctx.questions[state.currentTab];
|
|
289
|
-
if (!q)
|
|
338
|
+
if (!q) {
|
|
339
|
+
return { state, effects: [] };
|
|
340
|
+
}
|
|
290
341
|
const pendingNotes = state.notesByTab.get(state.currentTab);
|
|
291
342
|
const answers = new Map(state.answers);
|
|
292
|
-
|
|
343
|
+
const multiAnswer: QuestionAnswer = {
|
|
293
344
|
questionIndex: state.currentTab,
|
|
294
345
|
question: q.question,
|
|
295
346
|
kind: "multi",
|
|
296
347
|
answer: null,
|
|
297
348
|
selected: action.selected,
|
|
298
|
-
|
|
299
|
-
|
|
349
|
+
};
|
|
350
|
+
if (pendingNotes && pendingNotes.length > 0) {
|
|
351
|
+
// SAFETY: notes is optional per QuestionAnswer; adding when present preserves the shape.
|
|
352
|
+
(multiAnswer as QuestionAnswer & { notes: string }).notes = pendingNotes;
|
|
353
|
+
}
|
|
354
|
+
answers.set(state.currentTab, multiAnswer);
|
|
300
355
|
const synced: QuestionnaireState = {
|
|
301
356
|
...state,
|
|
302
357
|
answers,
|
|
303
358
|
multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab),
|
|
304
359
|
};
|
|
305
|
-
if (action.autoAdvanceTab !== undefined)
|
|
360
|
+
if (action.autoAdvanceTab !== undefined) {
|
|
306
361
|
return switchTabResult(synced, action.autoAdvanceTab, ctx);
|
|
362
|
+
}
|
|
307
363
|
return doneFor(synced, ctx, false);
|
|
308
364
|
};
|
|
309
365
|
|
|
@@ -327,13 +383,16 @@ const notesExitHandler: Handler<"notes_exit"> = (state, _action, _ctx) => {
|
|
|
327
383
|
const prev = answers.get(state.currentTab);
|
|
328
384
|
if (prev?.notes) {
|
|
329
385
|
const stripped = { ...prev };
|
|
330
|
-
|
|
331
|
-
|
|
386
|
+
const { notes: _removed, ...withoutNotes } = stripped;
|
|
387
|
+
// SAFETY: withoutNotes preserves all required QuestionAnswer fields; notes is optional and removed intentionally.
|
|
388
|
+
answers.set(state.currentTab, withoutNotes as QuestionAnswer);
|
|
332
389
|
}
|
|
333
390
|
} else {
|
|
334
391
|
notes.set(state.currentTab, trimmed);
|
|
335
392
|
const prev = answers.get(state.currentTab);
|
|
336
|
-
if (prev)
|
|
393
|
+
if (prev) {
|
|
394
|
+
answers.set(state.currentTab, { ...prev, notes: trimmed });
|
|
395
|
+
}
|
|
337
396
|
}
|
|
338
397
|
return {
|
|
339
398
|
state: { ...state, notesByTab: notes, answers, notesVisible: false },
|
|
@@ -356,7 +415,9 @@ const toggleCollapsedHandler: Handler<"toggle_collapsed"> = (s, _a, _c) => ({
|
|
|
356
415
|
effects: [{ kind: "set_overlay_hidden", hidden: !s.collapsed }],
|
|
357
416
|
});
|
|
358
417
|
const tickHandler: Handler<"tick"> = (state, action, ctx) => {
|
|
359
|
-
if (state.timerCancelled || state.deadline === undefined)
|
|
418
|
+
if (state.timerCancelled || state.deadline === undefined) {
|
|
419
|
+
return { state, effects: [] };
|
|
420
|
+
}
|
|
360
421
|
const remaining = state.deadline - action.now;
|
|
361
422
|
if (remaining > 0) {
|
|
362
423
|
return { state: { ...state, remainingMs: remaining }, effects: [] };
|
|
@@ -366,8 +427,11 @@ const tickHandler: Handler<"tick"> = (state, action, ctx) => {
|
|
|
366
427
|
answers: orderedAnswers(state, ctx.questions),
|
|
367
428
|
cancelled: true,
|
|
368
429
|
error: "timed_out",
|
|
369
|
-
...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
|
|
370
430
|
};
|
|
431
|
+
if (globalNote && globalNote.length > 0) {
|
|
432
|
+
// SAFETY: globalNote is optional per QuestionnaireResult; present only when non-empty.
|
|
433
|
+
(result as QuestionnaireResult & { globalNote: string }).globalNote = globalNote;
|
|
434
|
+
}
|
|
371
435
|
return { state, effects: [{ kind: "done", result }] };
|
|
372
436
|
};
|
|
373
437
|
const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: [] });
|
|
@@ -378,7 +442,7 @@ const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: []
|
|
|
378
442
|
* compile here until a handler is registered, mirroring the `Record<RowKind, …>`
|
|
379
443
|
* pattern used by `ROW_INTENT_META`.
|
|
380
444
|
*/
|
|
381
|
-
const HANDLERS
|
|
445
|
+
const HANDLERS = {
|
|
382
446
|
nav: navHandler,
|
|
383
447
|
input_clear: inputClearHandler,
|
|
384
448
|
input_edit: inputEditHandler,
|
|
@@ -396,10 +460,10 @@ const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
|
|
|
396
460
|
toggle_collapsed: toggleCollapsedHandler,
|
|
397
461
|
tick: tickHandler,
|
|
398
462
|
ignore: ignoreHandler,
|
|
399
|
-
};
|
|
463
|
+
} satisfies { [K in QuestionnaireAction["kind"]]: Handler<K> };
|
|
400
464
|
|
|
401
465
|
/**
|
|
402
|
-
* Pure reducer: (state, action, ctx) → (state, Effect[]).
|
|
466
|
+
* Pure reducer: (state, action, ctx) → (state, Effect[]).
|
|
403
467
|
* Delegates to `HANDLERS` — per-kind handlers above are pure, named, and individually testable.
|
|
404
468
|
* `ignore` is also handled outside the reducer by `handleIgnoreInline` in the runtime fast path.
|
|
405
469
|
*/
|
|
@@ -408,7 +472,9 @@ export function reduce(
|
|
|
408
472
|
action: QuestionnaireAction,
|
|
409
473
|
ctx: ApplyContext,
|
|
410
474
|
): ApplyResult {
|
|
475
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
411
476
|
const handler = HANDLERS[action.kind] as Handler<typeof action.kind>;
|
|
477
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
412
478
|
const result = handler(state, action as never, ctx);
|
|
413
479
|
const isHumanKeystroke = action.kind !== "tick" && action.kind !== "toggle_collapsed";
|
|
414
480
|
if (isHumanKeystroke && state.deadline !== undefined && !state.timerCancelled) {
|
|
@@ -37,32 +37,38 @@ export function buildQuestionnaireResponse(
|
|
|
37
37
|
params: QuestionParams,
|
|
38
38
|
): ToolResult {
|
|
39
39
|
if (result?.error === "timed_out") {
|
|
40
|
-
|
|
40
|
+
const details: QuestionnaireResult = {
|
|
41
41
|
answers: result.answers,
|
|
42
42
|
cancelled: true,
|
|
43
43
|
error: "timed_out",
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
};
|
|
45
|
+
if (result.globalNote && result.globalNote.length > 0) {
|
|
46
|
+
(details as { globalNote: string }).globalNote = result.globalNote;
|
|
47
|
+
}
|
|
48
|
+
if (result.unansweredNotes && result.unansweredNotes.length > 0) {
|
|
49
|
+
(details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
|
|
50
|
+
result.unansweredNotes;
|
|
51
|
+
}
|
|
52
|
+
return buildToolResult(TIMED_OUT_MESSAGE, details);
|
|
51
53
|
}
|
|
52
54
|
if (!result || result.cancelled) {
|
|
53
55
|
// The decline text stays canonical even when a global note rides a
|
|
54
56
|
// cancelled result. The note survives in `details`, like partial answers.
|
|
55
|
-
|
|
57
|
+
const details: QuestionnaireResult = {
|
|
56
58
|
answers: result?.answers ?? [],
|
|
57
59
|
cancelled: true,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
};
|
|
61
|
+
if (result?.error) {
|
|
62
|
+
(details as { error: typeof result.error }).error = result.error;
|
|
63
|
+
}
|
|
64
|
+
if (result?.globalNote && result.globalNote.length > 0) {
|
|
65
|
+
(details as { globalNote: string }).globalNote = result.globalNote;
|
|
66
|
+
}
|
|
67
|
+
if (result?.unansweredNotes && result.unansweredNotes.length > 0) {
|
|
68
|
+
(details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
|
|
69
|
+
result.unansweredNotes;
|
|
70
|
+
}
|
|
71
|
+
return buildToolResult(DECLINE_MESSAGE, details);
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
const segments: string[] = [];
|
|
@@ -79,7 +85,9 @@ export function buildQuestionnaireResponse(
|
|
|
79
85
|
// before the "nothing to report" check below, a questionnaire submitted
|
|
80
86
|
// with nothing but such a note counts as answered rather than declined.
|
|
81
87
|
const n = result.unansweredNotes?.find((x) => x.questionIndex === i);
|
|
82
|
-
if (n)
|
|
88
|
+
if (n) {
|
|
89
|
+
segments.push(buildUnansweredNoteSegment(n));
|
|
90
|
+
}
|
|
83
91
|
}
|
|
84
92
|
if (result.globalNote && result.globalNote.length > 0) {
|
|
85
93
|
// Raw multiline echo, no reformatting, trailing period matching the shape
|
|
@@ -98,8 +106,12 @@ export function buildQuestionnaireResponse(
|
|
|
98
106
|
*/
|
|
99
107
|
export function buildAnswerSegment(a: QuestionAnswer): string {
|
|
100
108
|
const parts: string[] = [`"${a.question}"="${formatAnswerScalar(a, "envelope")}"`];
|
|
101
|
-
if (a.preview && a.preview.length > 0)
|
|
102
|
-
|
|
109
|
+
if (a.preview && a.preview.length > 0) {
|
|
110
|
+
parts.push(`selected preview: ${a.preview}`);
|
|
111
|
+
}
|
|
112
|
+
if (a.notes && a.notes.length > 0) {
|
|
113
|
+
parts.push(`user notes: ${a.notes}`);
|
|
114
|
+
}
|
|
103
115
|
return `${parts.join(". ")}.`;
|
|
104
116
|
}
|
|
105
117
|
|
package/src/tool/types.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { type Static, Type } from "typebox";
|
|
2
2
|
import { LABELS_BY_KIND, ROW_INTENT_META } from "../state/row-intent.js";
|
|
3
3
|
|
|
4
|
+
type JsonValue =
|
|
5
|
+
| string
|
|
6
|
+
| number
|
|
7
|
+
| boolean
|
|
8
|
+
| null
|
|
9
|
+
| JsonValue[]
|
|
10
|
+
| { readonly [key: string]: JsonValue };
|
|
11
|
+
|
|
4
12
|
export const MAX_QUESTIONS = 4;
|
|
5
13
|
export const MIN_OPTIONS = 2;
|
|
6
14
|
export const MAX_OPTIONS = 4;
|
|
@@ -187,7 +195,10 @@ export interface QuestionnaireResult {
|
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
|
|
190
|
-
if (!value || typeof value !== "object")
|
|
191
|
-
|
|
198
|
+
if (!value || typeof value !== "object") {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
// SAFETY: boundary check for QuestionnaireResult; value is validated as object with required fields before use.
|
|
202
|
+
const v = value as Record<string, JsonValue>;
|
|
192
203
|
return Array.isArray(v.answers) && typeof v.cancelled === "boolean";
|
|
193
204
|
}
|
|
@@ -44,7 +44,9 @@ export function globalBinding<P>(spec: ComponentBinding<P>): BoundGlobalBinding
|
|
|
44
44
|
export function perTabBinding<P>(spec: PerTabBinding<P>): BoundPerTabBinding {
|
|
45
45
|
return {
|
|
46
46
|
apply: (state, ctx) => {
|
|
47
|
-
if (spec.predicate && !spec.predicate(state, ctx))
|
|
47
|
+
if (spec.predicate && !spec.predicate(state, ctx)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
48
50
|
spec.resolve(ctx.tab)?.setProps(spec.select(state, ctx));
|
|
49
51
|
},
|
|
50
52
|
};
|
|
@@ -27,7 +27,9 @@ export interface RenderInlineInputOptions {
|
|
|
27
27
|
* Mirrors the original wrapping-select.resolveOffset exactly.
|
|
28
28
|
*/
|
|
29
29
|
function resolveCursorOffset(buffer: string, requested: number | undefined): number {
|
|
30
|
-
if (requested !== undefined && requested >= 0 && requested <= buffer.length)
|
|
30
|
+
if (requested !== undefined && requested >= 0 && requested <= buffer.length) {
|
|
31
|
+
return requested;
|
|
32
|
+
}
|
|
31
33
|
return buffer.length;
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -96,7 +96,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
private layout(width: number): MultiSelectLayout {
|
|
99
|
-
if (this.cachedLayout?.width === width)
|
|
99
|
+
if (this.cachedLayout?.width === width) {
|
|
100
|
+
return this.cachedLayout.value;
|
|
101
|
+
}
|
|
100
102
|
|
|
101
103
|
const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
|
|
102
104
|
const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
|
|
@@ -106,7 +108,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
106
108
|
|
|
107
109
|
const otherStart = build.lines.length;
|
|
108
110
|
build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
|
|
109
|
-
if (this.props.other.active)
|
|
111
|
+
if (this.props.other.active) {
|
|
112
|
+
build.focusedRange = [otherStart, build.lines.length];
|
|
113
|
+
}
|
|
110
114
|
|
|
111
115
|
this.appendNextRow(build, width);
|
|
112
116
|
|
|
@@ -124,7 +128,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
124
128
|
for (let i = 0; i < this.question.options.length; i++) {
|
|
125
129
|
const opt = this.question.options[i];
|
|
126
130
|
const row = this.props.rows[i];
|
|
127
|
-
if (!opt || !row)
|
|
131
|
+
if (!opt || !row) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
128
134
|
const start = build.lines.length;
|
|
129
135
|
const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
130
136
|
// Checked and active rows share the accent hue, matching the dialog's selection rhythm.
|
|
@@ -146,7 +152,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
146
152
|
build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
|
-
if (row.active)
|
|
155
|
+
if (row.active) {
|
|
156
|
+
build.focusedRange = [start, build.lines.length];
|
|
157
|
+
}
|
|
150
158
|
}
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -159,7 +167,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
159
167
|
? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
|
|
160
168
|
: this.props.nextLabel;
|
|
161
169
|
build.lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
|
|
162
|
-
if (this.props.nextActive)
|
|
170
|
+
if (this.props.nextActive) {
|
|
171
|
+
build.focusedRange = [nextStart, build.lines.length];
|
|
172
|
+
}
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
|
|
@@ -3,6 +3,8 @@ import { Markdown, type MarkdownTheme, visibleWidth } from "@earendil-works/pi-t
|
|
|
3
3
|
import type { QuestionData } from "../../../tool/types.js";
|
|
4
4
|
import { stripFenceMarkers } from "./preview-box-renderer.js";
|
|
5
5
|
|
|
6
|
+
export type MarkdownFactory = (text: string, markdownTheme: MarkdownTheme) => Markdown;
|
|
7
|
+
|
|
6
8
|
/** CC parity in side-by-side layout. */
|
|
7
9
|
export const MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
|
|
8
10
|
/** Preserves narrow-terminal protection in stacked layout. */
|
|
@@ -22,18 +24,27 @@ export const NOTES_AFFORDANCE_OVERHEAD = 2;
|
|
|
22
24
|
*/
|
|
23
25
|
export class MarkdownContentCache {
|
|
24
26
|
private readonly previewTexts: Map<number, string>;
|
|
25
|
-
private readonly markdownCache: Map<number,
|
|
27
|
+
private readonly markdownCache: Map<number, ReturnType<MarkdownFactory>>;
|
|
26
28
|
private cachedWidth: number | undefined;
|
|
27
29
|
private readonly theme: Theme;
|
|
28
30
|
private readonly markdownTheme: MarkdownTheme;
|
|
31
|
+
private readonly markdownFactory: MarkdownFactory;
|
|
29
32
|
|
|
30
|
-
constructor(
|
|
33
|
+
constructor(
|
|
34
|
+
question: QuestionData,
|
|
35
|
+
theme: Theme,
|
|
36
|
+
markdownTheme: MarkdownTheme,
|
|
37
|
+
markdownFactory: MarkdownFactory = (text, mt) => new Markdown(text, 0, 0, mt),
|
|
38
|
+
) {
|
|
31
39
|
this.theme = theme;
|
|
32
40
|
this.markdownTheme = markdownTheme;
|
|
41
|
+
this.markdownFactory = markdownFactory;
|
|
33
42
|
this.previewTexts = new Map();
|
|
34
43
|
for (let i = 0; i < question.options.length; i++) {
|
|
35
44
|
const raw = question.options[i]?.preview;
|
|
36
|
-
if (raw && raw.length > 0)
|
|
45
|
+
if (raw && raw.length > 0) {
|
|
46
|
+
this.previewTexts.set(i, raw);
|
|
47
|
+
}
|
|
37
48
|
}
|
|
38
49
|
this.markdownCache = new Map();
|
|
39
50
|
}
|
|
@@ -52,7 +63,9 @@ export class MarkdownContentCache {
|
|
|
52
63
|
*/
|
|
53
64
|
bodyFor(optionIndex: number, innerWidth: number): string[] {
|
|
54
65
|
if (this.cachedWidth !== innerWidth) {
|
|
55
|
-
for (const md of this.markdownCache.values())
|
|
66
|
+
for (const md of this.markdownCache.values()) {
|
|
67
|
+
md.invalidate();
|
|
68
|
+
}
|
|
56
69
|
this.cachedWidth = innerWidth;
|
|
57
70
|
}
|
|
58
71
|
const text = this.previewTexts.get(optionIndex);
|
|
@@ -63,14 +76,16 @@ export class MarkdownContentCache {
|
|
|
63
76
|
}
|
|
64
77
|
let md = this.markdownCache.get(optionIndex);
|
|
65
78
|
if (!md) {
|
|
66
|
-
md =
|
|
79
|
+
md = this.markdownFactory(text, this.markdownTheme);
|
|
67
80
|
this.markdownCache.set(optionIndex, md);
|
|
68
81
|
}
|
|
69
82
|
return stripFenceMarkers(md.render(innerWidth));
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
invalidate(): void {
|
|
73
|
-
for (const md of this.markdownCache.values())
|
|
86
|
+
for (const md of this.markdownCache.values()) {
|
|
87
|
+
md.invalidate();
|
|
88
|
+
}
|
|
74
89
|
this.cachedWidth = undefined;
|
|
75
90
|
}
|
|
76
91
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE,
|
|
6
6
|
MAX_PREVIEW_HEIGHT_STACKED,
|
|
7
7
|
MarkdownContentCache,
|
|
8
|
+
type MarkdownFactory,
|
|
8
9
|
NOTES_AFFORDANCE_OVERHEAD,
|
|
9
10
|
} from "./markdown-content-cache.js";
|
|
10
11
|
import {
|
|
@@ -38,6 +39,7 @@ export interface PreviewBlockRendererConfig {
|
|
|
38
39
|
question: QuestionData;
|
|
39
40
|
theme: Theme;
|
|
40
41
|
markdownTheme: MarkdownTheme;
|
|
42
|
+
markdownFactory?: MarkdownFactory;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -56,7 +58,12 @@ export class PreviewBlockRenderer {
|
|
|
56
58
|
|
|
57
59
|
constructor(config: PreviewBlockRendererConfig) {
|
|
58
60
|
this.theme = config.theme;
|
|
59
|
-
this.cache = new MarkdownContentCache(
|
|
61
|
+
this.cache = new MarkdownContentCache(
|
|
62
|
+
config.question,
|
|
63
|
+
config.theme,
|
|
64
|
+
config.markdownTheme,
|
|
65
|
+
config.markdownFactory,
|
|
66
|
+
);
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
hasAnyPreview(): boolean {
|
|
@@ -73,14 +73,13 @@ export function renderBorderedBox(
|
|
|
73
73
|
* `Markdown.render(width)` pads every line to `width`, which would otherwise force
|
|
74
74
|
* the box to fill the whole column allocation.
|
|
75
75
|
*/
|
|
76
|
-
export function computeBoxDimensions(
|
|
77
|
-
contentLines: readonly string[],
|
|
78
|
-
maxInnerWidth: number,
|
|
79
|
-
): { innerWidth: number; boxWidth: number } {
|
|
76
|
+
export function computeBoxDimensions(contentLines: readonly string[], maxInnerWidth: number) {
|
|
80
77
|
let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
|
|
81
78
|
for (const line of contentLines) {
|
|
82
79
|
const w = visibleWidth(line.replace(/\s+$/, ""));
|
|
83
|
-
if (w > widest)
|
|
80
|
+
if (w > widest) {
|
|
81
|
+
widest = w;
|
|
82
|
+
}
|
|
84
83
|
}
|
|
85
84
|
const innerWidth = Math.min(widest, maxInnerWidth);
|
|
86
85
|
const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
|
|
@@ -61,7 +61,9 @@ export function adaptiveLeftWidth(
|
|
|
61
61
|
let maxLabel = 0;
|
|
62
62
|
for (const item of items) {
|
|
63
63
|
const w = visibleWidth(item.label);
|
|
64
|
-
if (w > maxLabel)
|
|
64
|
+
if (w > maxLabel) {
|
|
65
|
+
maxLabel = w;
|
|
66
|
+
}
|
|
65
67
|
}
|
|
66
68
|
const desired = maxLabel + prefixW + confirmedOverhead;
|
|
67
69
|
const ratioCapped = Math.min(desired, Math.floor(paneWidth * MAX_LEFT_RATIO));
|
|
@@ -88,7 +90,9 @@ export function crossTabMaxLeftWidth(
|
|
|
88
90
|
const items = itemsByTab[i] ?? [];
|
|
89
91
|
const totalForNumbering = items.length;
|
|
90
92
|
const tabWidth = adaptiveLeftWidth(items, totalForNumbering, paneWidth);
|
|
91
|
-
if (tabWidth > max)
|
|
93
|
+
if (tabWidth > max) {
|
|
94
|
+
max = tabWidth;
|
|
95
|
+
}
|
|
92
96
|
}
|
|
93
97
|
return max;
|
|
94
98
|
}
|
|
@@ -108,10 +112,14 @@ export function previewSourceWidth(question: QuestionData): number {
|
|
|
108
112
|
let max = 0;
|
|
109
113
|
for (const option of question.options) {
|
|
110
114
|
const text = option.preview;
|
|
111
|
-
if (!text)
|
|
115
|
+
if (!text) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
112
118
|
for (const line of text.split("\n")) {
|
|
113
119
|
const w = visibleWidth(line);
|
|
114
|
-
if (w > max)
|
|
120
|
+
if (w > max) {
|
|
121
|
+
max = w;
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
return max;
|
|
@@ -138,7 +146,9 @@ export function crossTabPreviewBudget(
|
|
|
138
146
|
BORDER_HORIZONTAL_OVERHEAD +
|
|
139
147
|
2 * BORDER_INNER_PADDING_HORIZONTAL +
|
|
140
148
|
PREVIEW_PADDING_LEFT;
|
|
141
|
-
if (budget > max)
|
|
149
|
+
if (budget > max) {
|
|
150
|
+
max = budget;
|
|
151
|
+
}
|
|
142
152
|
}
|
|
143
153
|
return max;
|
|
144
154
|
}
|
|
@@ -193,10 +203,7 @@ export function crossTabLeftWidthWithDonation(
|
|
|
193
203
|
* cross-tab aggregated). The Math.max(1, ...) calls keep both columns >= 1 col on
|
|
194
204
|
* extreme inputs.
|
|
195
205
|
*/
|
|
196
|
-
export function columnWidths(
|
|
197
|
-
paneWidth: number,
|
|
198
|
-
adaptiveLeft: number,
|
|
199
|
-
): { leftWidth: number; rightWidth: number; gap: number } {
|
|
206
|
+
export function columnWidths(paneWidth: number, adaptiveLeft: number) {
|
|
200
207
|
const gap = PREVIEW_COLUMN_GAP;
|
|
201
208
|
const leftWidth = Math.min(adaptiveLeft, Math.max(1, paneWidth - gap - 1));
|
|
202
209
|
const rightWidth = Math.max(1, paneWidth - leftWidth - gap);
|
|
@@ -208,12 +215,10 @@ export function columnWidths(
|
|
|
208
215
|
* `render()`. Stacked uses the full pane width for both; side-by-side splits via
|
|
209
216
|
* `columnWidths`, with the preview column offset by `PREVIEW_PADDING_LEFT`.
|
|
210
217
|
*/
|
|
211
|
-
export function bodyWidths(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
): { optionsWidth: number; previewWidth: number } {
|
|
216
|
-
if (mode === "stacked") return { optionsWidth: paneWidth, previewWidth: paneWidth };
|
|
218
|
+
export function bodyWidths(paneWidth: number, mode: PreviewLayoutMode, adaptiveLeft: number) {
|
|
219
|
+
if (mode === "stacked") {
|
|
220
|
+
return { optionsWidth: paneWidth, previewWidth: paneWidth };
|
|
221
|
+
}
|
|
217
222
|
const { leftWidth, rightWidth } = columnWidths(paneWidth, adaptiveLeft);
|
|
218
223
|
return { optionsWidth: leftWidth, previewWidth: Math.max(1, rightWidth - PREVIEW_PADDING_LEFT) };
|
|
219
224
|
}
|