@aiwayds/dsh-tui-pi 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,714 @@
1
+ /**
2
+ * Ask the human a question while the model is mid-turn.
3
+ *
4
+ * Wires the upstream `ctx.userQuestions` capability seam (`dsh-user-questions`)
5
+ * with a terminal-side UI: an in-place multi-question overlay that pauses the
6
+ * tool call until the human answers, then feeds the canonical
7
+ * `AskUserQuestionAnswer` envelope back to `dsh-tool-ask-user` as a normal
8
+ * tool result.
9
+ *
10
+ * Layout — one framed overlay, all questions flattened:
11
+ *
12
+ * ● Question 1
13
+ * Where should we deploy? (left = header / right = question text)
14
+ * ▸ staging
15
+ * production
16
+ * Type something. (sentinel row — inline input)
17
+ * Question 2 ··· (continues vertically)
18
+ * ▸ …
19
+ * ⏎ Confirm answers (when ≥ 2 questions, or any multiSelect question)
20
+ *
21
+ * Single-question single-select overlay: Enter on an option (or committing a
22
+ * filled sentinel) submits immediately. A multiSelect question — even a lone
23
+ * one — gets a Confirm row instead of auto-submitting, so the user can pick
24
+ * several options first. Multi-question overlay: Enter on the Confirm row
25
+ * hops to the review page (all answers listed, each editable in place).
26
+ * Esc double-press within 200 ms declines — but terminal key auto-repeat
27
+ * (holding Esc) is ignored below `ESC_REPEAT_GUARD_MS`, so a long press
28
+ * cannot accidentally fire the decline. The provider returns the "declined"
29
+ * envelope and the model reads it as a normal user reply. An aborted
30
+ * request signal (`request.signal`) settles declined too — we resolve the
31
+ * declined envelope instead of rejecting ASK_ABORTED because the upstream
32
+ * service already screens entry-time aborts and an aborted step discards
33
+ * the result anyway.
34
+ *
35
+ * Pure logic lives in the top of this file (initial state, answer envelope,
36
+ * declined envelope, double-Esc state machine, row-layout math) so it can
37
+ * be unit-tested without a TTY. The component below owns the TUI render +
38
+ * keyboard handling; the install function at the bottom registers the
39
+ * provider under `ctx.userQuestions` (a Cordis effect, single active
40
+ * provider in the tree).
41
+ *
42
+ * Inspired by juicesharp/rpiv-ask-user-question
43
+ * (https://github.com/juicesharp/rpiv-ask-user-question).
44
+ */
45
+ import { getKeybindings, matchesKey } from '@earendil-works/pi-tui';
46
+ import { BOLD, RESET, } from "./theme/index.js";
47
+ import { MARKER_W, panelThemeFns, TABLE_SEP, columnWidths, padCell, rowMarker, tableHeaderLine, tableRuleLine, } from "./panels.js";
48
+ import { wrapFramedOverlay } from "./frame.js";
49
+ import { clipToWidth } from "./text.js";
50
+ // ----------------------------------------------------------------- constants --
51
+ /** Maximum time between two Esc presses before the second one re-arms instead of firing. */
52
+ export const DOUBLE_ESC_WINDOW_MS = 200;
53
+ /**
54
+ * Minimum gap between two Esc presses below which the second one is treated as
55
+ * terminal key auto-repeat (the user is HOLDING Esc) rather than a deliberate
56
+ * press: repeats leave the state untouched, so a long press can never fire the
57
+ * decline gesture on its own.
58
+ */
59
+ export const ESC_REPEAT_GUARD_MS = 50;
60
+ /** Sentinel row label appended to every question's option list. */
61
+ export const SENTINEL_LABEL = 'Type something.';
62
+ /** Decline message embedded into the answer envelope when the user bails. */
63
+ export const DECLINE_MESSAGE = 'User declined to answer questions.';
64
+ /** Transient hint when Enter lands on an incomplete confirm/submit row. */
65
+ export const INCOMPLETE_HINT = 'Answer every question first';
66
+ /** Mark left of a question a custom input wrote text into. */
67
+ const CUSTOM_MARK = '✎ ';
68
+ // ---------------------------------------- pure functions (testable) --
69
+ /** Initial state for a `questions` payload — defaults: cursor on Q0/option-0, no edit, no Esc history. */
70
+ export function initialState(questions) {
71
+ return {
72
+ questions,
73
+ perQuestion: questions.map(() => ({ selected: [] })),
74
+ cursorIndex: 0,
75
+ customInputs: questions.map(() => null),
76
+ customEditingFor: null,
77
+ phase: 'questions',
78
+ reviewIndex: 0,
79
+ lastEscAt: null,
80
+ cancelHint: false,
81
+ attentionHint: null,
82
+ };
83
+ }
84
+ /** Toggle/add an option. Single-select replaces; multi-select toggles membership. */
85
+ export function toggleOption(state, questionIndex, optionLabel) {
86
+ const question = state.questions[questionIndex];
87
+ if (question === undefined)
88
+ return state;
89
+ const answer = state.perQuestion[questionIndex];
90
+ if (answer === undefined)
91
+ return state;
92
+ const current = answer.selected;
93
+ const isMulti = question.multiSelect === true;
94
+ const next = isMulti
95
+ ? current.includes(optionLabel) ? current.filter(l => l !== optionLabel) : [...current, optionLabel]
96
+ : current.includes(optionLabel) ? [] : [optionLabel];
97
+ const perQuestion = state.perQuestion.slice();
98
+ perQuestion[questionIndex] = {
99
+ selected: next,
100
+ ...(answer.custom !== undefined ? { custom: answer.custom } : {}),
101
+ };
102
+ return { ...state, perQuestion };
103
+ }
104
+ /** Set the custom answer for a question (clears selected options). */
105
+ export function setCustomAnswer(state, questionIndex, text) {
106
+ const answer = state.perQuestion[questionIndex];
107
+ if (answer === undefined)
108
+ return state;
109
+ const perQuestion = state.perQuestion.slice();
110
+ perQuestion[questionIndex] = { selected: [], custom: text };
111
+ const customInputs = state.customInputs.slice();
112
+ customInputs[questionIndex] = text;
113
+ return { ...state, perQuestion, customInputs };
114
+ }
115
+ /** Mutate the live custom input buffer for a question. */
116
+ export function patchCustomInput(state, questionIndex, mutator) {
117
+ const customInputs = state.customInputs.slice();
118
+ customInputs[questionIndex] = mutator(customInputs[questionIndex] ?? '');
119
+ return { ...state, customInputs };
120
+ }
121
+ /** Enter inline-edit mode for a question (sentinel row got pressed). */
122
+ export function enterCustomEdit(state, questionIndex) {
123
+ const customInputs = state.customInputs.slice();
124
+ if (customInputs[questionIndex] === null)
125
+ customInputs[questionIndex] = '';
126
+ return { ...state, customEditingFor: questionIndex, customInputs, cancelHint: false };
127
+ }
128
+ /** Leave inline-edit mode; commit when `commit === true` and the buffer is non-empty. */
129
+ export function exitCustomEdit(state, commit) {
130
+ if (state.customEditingFor === null)
131
+ return state;
132
+ const qi = state.customEditingFor;
133
+ const next = commit && (state.customInputs[qi] ?? '').trim() !== ''
134
+ ? setCustomAnswer({ ...state, customEditingFor: null }, qi, (state.customInputs[qi] ?? '').trim())
135
+ : { ...state, customEditingFor: null };
136
+ return { ...next, cancelHint: false };
137
+ }
138
+ /**
139
+ * Double-Esc state machine: 1st press arms; 2nd within the window fires
140
+ * (caller reads `lastEscAt===null + cancelHint===false`). Presses closer than
141
+ * `repeatGuardMs` are terminal key auto-repeat (held key) and are ignored
142
+ * entirely — the armed state stays at its original timestamp, so holding Esc
143
+ * neither fires the decline nor refreshes the window.
144
+ */
145
+ export function advanceDoubleEsc(state, now, windowMs = DOUBLE_ESC_WINDOW_MS, repeatGuardMs = ESC_REPEAT_GUARD_MS) {
146
+ const last = state.lastEscAt;
147
+ if (last !== null && now - last < repeatGuardMs) {
148
+ return state;
149
+ }
150
+ if (last === null || now - last > windowMs) {
151
+ return { ...state, lastEscAt: now, cancelHint: true, attentionHint: null };
152
+ }
153
+ return { ...state, lastEscAt: null, cancelHint: false, attentionHint: null };
154
+ }
155
+ /** Was the most recent double-Esc press a "fired" event (the second within the window)? */
156
+ export function didDoubleEscFire(prevState, nextState, now, windowMs = DOUBLE_ESC_WINDOW_MS) {
157
+ if (prevState.lastEscAt === null)
158
+ return false;
159
+ if (nextState.lastEscAt !== null)
160
+ return false;
161
+ if (now - prevState.lastEscAt > windowMs)
162
+ return false;
163
+ return true;
164
+ }
165
+ /** Canonical envelope for a normal submission. The model reads it as a tool result. */
166
+ export function buildAnswerEnvelope(state) {
167
+ const answers = [];
168
+ for (let qi = 0; qi < state.questions.length; qi++) {
169
+ const question = state.questions[qi];
170
+ const answer = state.perQuestion[qi];
171
+ if (question === undefined || answer === undefined)
172
+ continue;
173
+ const item = { id: question.id, selected: [...answer.selected] };
174
+ if (answer.custom !== undefined && answer.custom !== '')
175
+ item.custom = answer.custom;
176
+ answers.push(item);
177
+ }
178
+ return { answers };
179
+ }
180
+ /** Canonical envelope for the decline path: empty selected + custom decline message on every question. */
181
+ export function buildDeclinedEnvelope(questions, message = DECLINE_MESSAGE) {
182
+ return {
183
+ answers: questions.map(question => ({ id: question.id, selected: [], custom: message })),
184
+ };
185
+ }
186
+ /** Build the ordered, flat list of rows for the questions pane. */
187
+ export function buildRowList(questions, perQuestion) {
188
+ const rows = [];
189
+ questions.forEach((question, qi) => {
190
+ rows.push({
191
+ kind: 'question-header',
192
+ questionIndex: qi,
193
+ label: question.header ?? `Question ${qi + 1}`,
194
+ description: question.question,
195
+ ...(question.detail !== undefined ? { detail: question.detail } : {}),
196
+ selectable: false,
197
+ });
198
+ const options = question.options ?? [];
199
+ options.forEach((opt, oi) => {
200
+ rows.push({
201
+ kind: 'option',
202
+ questionIndex: qi,
203
+ optionIndex: oi,
204
+ label: opt.label,
205
+ ...(opt.description !== undefined ? { description: opt.description } : {}),
206
+ selectable: true,
207
+ });
208
+ });
209
+ const customAnswer = perQuestion[qi]?.custom ?? '';
210
+ rows.push({
211
+ kind: 'sentinel',
212
+ questionIndex: qi,
213
+ label: customAnswer !== '' ? `${CUSTOM_MARK}${customAnswer.trim()}` : SENTINEL_LABEL,
214
+ selectable: true,
215
+ });
216
+ });
217
+ if (needsConfirmRow(questions)) {
218
+ rows.push({ kind: 'confirm', questionIndex: -1, label: '⏎ Confirm answers', selectable: true });
219
+ }
220
+ return rows;
221
+ }
222
+ /** Find the next selectable index from `i` going in `direction`, clamped into range. */
223
+ export function nextSelectableIndex(rows, from, direction) {
224
+ const n = rows.length;
225
+ if (n === 0)
226
+ return 0;
227
+ let i = Math.max(0, Math.min(n - 1, from));
228
+ // Two-pass scan: forward in `direction`, then backward.
229
+ for (let pass = 0; pass < 2; pass++) {
230
+ let j = pass === 0 ? i + direction : (direction === 1 ? 0 : n - 1);
231
+ while (j >= 0 && j < n) {
232
+ if (rows[j]?.selectable === true)
233
+ return j;
234
+ j += direction;
235
+ }
236
+ }
237
+ return i;
238
+ }
239
+ /** True when every question has at least one selected option or a non-empty custom answer. */
240
+ export function allQuestionsAnswered(state) {
241
+ return state.perQuestion.every(answer => answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== ''));
242
+ }
243
+ /**
244
+ * Whether the overlay needs an explicit `⏎ Confirm answers` row: any
245
+ * multi-question layout, plus a lone multiSelect question — multiSelect can
246
+ * never auto-submit (the user may want more toggles), so it needs a path to
247
+ * the review page.
248
+ */
249
+ export function needsConfirmRow(questions) {
250
+ return questions.length >= 2 || questions.some(question => question.multiSelect === true);
251
+ }
252
+ /**
253
+ * True when a successful action on THIS state can submit immediately without
254
+ * the review step: exactly one question, single-select (a multiSelect
255
+ * question may want further toggles), and every question answered.
256
+ */
257
+ export function canAutoSubmit(state) {
258
+ if (state.questions.length !== 1)
259
+ return false;
260
+ if (state.questions[0]?.multiSelect === true)
261
+ return false;
262
+ return allQuestionsAnswered(state);
263
+ }
264
+ /**
265
+ * Row index to land on after answering question `answeredQi`: the first
266
+ * selectable row of the nearest LATER unanswered question, or -1 when every
267
+ * later question is already answered (caller keeps the current cursor).
268
+ */
269
+ export function nextUnansweredRow(rows, perQuestion, answeredQi) {
270
+ const answered = (qi) => {
271
+ const answer = perQuestion[qi];
272
+ return answer !== undefined
273
+ && (answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== ''));
274
+ };
275
+ for (let i = 0; i < rows.length; i++) {
276
+ const row = rows[i];
277
+ if (row === undefined || !row.selectable)
278
+ continue;
279
+ if (row.questionIndex <= answeredQi)
280
+ continue;
281
+ if (!answered(row.questionIndex))
282
+ return i;
283
+ }
284
+ return -1;
285
+ }
286
+ // ------------------------------------------------- render: questions phase --
287
+ /** Render width budgets for the questions phase (label flex column + status column). */
288
+ const QUESTIONS_COLUMNS = () => [
289
+ { key: 'label', title: 'Selection', flex: true },
290
+ { key: 'status', title: 'State', width: 14 },
291
+ ];
292
+ /** Status pill text for one row. */
293
+ function rowStatusText(kind, state, row, isMulti) {
294
+ if (kind === 'question-header')
295
+ return '';
296
+ if (kind === 'option') {
297
+ const opt = state.questions[row.questionIndex]?.options?.[row.optionIndex ?? -1];
298
+ if (opt === undefined)
299
+ return '';
300
+ const isSelected = state.perQuestion[row.questionIndex]?.selected.includes(opt.label) ?? false;
301
+ if (!isSelected)
302
+ return '';
303
+ return isMulti ? `[+]` : '[●]';
304
+ }
305
+ if (kind === 'sentinel') {
306
+ return (state.perQuestion[row.questionIndex]?.custom !== undefined
307
+ && state.perQuestion[row.questionIndex]?.custom !== '') ? '[✎]' : '';
308
+ }
309
+ if (kind === 'confirm')
310
+ return allQuestionsAnswered(state) ? '[✓]' : '[—]';
311
+ return '';
312
+ }
313
+ /** Render the questions pane as a flat table line list. */
314
+ export function renderQuestionsView(theme, state, width) {
315
+ const fns = panelThemeFns(theme);
316
+ const wrap = Math.max(2, width - 2);
317
+ const title = state.questions.length === 1
318
+ ? '● Question'
319
+ : `● Questions (${state.questions.length})`;
320
+ const lines = [fns.accent(BOLD + clipToWidth(title, wrap) + RESET)];
321
+ const rows = buildRowList(state.questions, state.perQuestion);
322
+ if (rows.length === 0) {
323
+ lines.push(fns.muted(clipToWidth('(no questions)', wrap)));
324
+ return finalizeQuestionsView(fns, wrap, lines, state);
325
+ }
326
+ const columns = QUESTIONS_COLUMNS();
327
+ const widths = columnWidths(wrap - MARKER_W, columns);
328
+ lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┬'), wrap)));
329
+ lines.push(fns.subtle(clipToWidth(tableHeaderLine(columns, widths), wrap)));
330
+ lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┼'), wrap)));
331
+ for (let i = 0; i < rows.length; i++) {
332
+ const row = rows[i];
333
+ if (row === undefined)
334
+ continue;
335
+ if (row.kind === 'question-header') {
336
+ // Header rows: full-width title, skip the table columns.
337
+ const text = `${row.label} ${row.description ?? ''}`;
338
+ const padded = clipToWidth(BOLD + text + RESET, wrap);
339
+ lines.push(fns.accent(padded));
340
+ // Upstream `detail` is supporting context (mandatory when the question
341
+ // declares an intent) — render it muted below the header, never inside
342
+ // option labels.
343
+ if (row.detail !== undefined && row.detail !== '') {
344
+ lines.push(fns.muted(clipToWidth(row.detail, wrap)));
345
+ }
346
+ continue;
347
+ }
348
+ const isMulti = row.questionIndex >= 0
349
+ ? state.questions[row.questionIndex]?.multiSelect === true
350
+ : false;
351
+ const selected = i === state.cursorIndex && state.customEditingFor === null;
352
+ let displayLabel = row.label;
353
+ if (row.kind === 'option') {
354
+ const opt = state.questions[row.questionIndex]?.options?.[row.optionIndex ?? -1];
355
+ if (opt?.description !== undefined && opt.description !== '') {
356
+ displayLabel = `${opt.label} ${opt.description}`;
357
+ }
358
+ }
359
+ if (row.kind === 'sentinel' && state.customEditingFor === row.questionIndex) {
360
+ displayLabel = `${state.customInputs[row.questionIndex] ?? ''}_`;
361
+ }
362
+ const status = rowStatusText(row.kind, state, row, isMulti);
363
+ const labelCell = padCell(clipToWidth(displayLabel, widths[0]), widths[0]);
364
+ const statusCell = padCell(clipToWidth(status, widths[1]), widths[1]);
365
+ const plain = `${rowMarker(selected)}${labelCell}${TABLE_SEP}${statusCell}`;
366
+ const line = clipToWidth(plain, wrap);
367
+ if (selected) {
368
+ lines.push(fns.accent(BOLD + line + RESET));
369
+ }
370
+ else if (row.kind === 'option'
371
+ && (state.perQuestion[row.questionIndex]?.selected.includes(state.questions[row.questionIndex]?.options?.[row.optionIndex ?? -1]?.label ?? '') ?? false)) {
372
+ lines.push(fns.success(line));
373
+ }
374
+ else if (row.kind === 'sentinel'
375
+ && (state.perQuestion[row.questionIndex]?.custom !== undefined
376
+ && state.perQuestion[row.questionIndex]?.custom !== '')) {
377
+ lines.push(fns.success(line));
378
+ }
379
+ else {
380
+ lines.push(fns.muted(line));
381
+ }
382
+ }
383
+ lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┴'), wrap)));
384
+ return finalizeQuestionsView(fns, wrap, lines, state);
385
+ }
386
+ function finalizeQuestionsView(fns, wrap, lines, state) {
387
+ if (state.attentionHint !== null) {
388
+ lines.push(fns.attention(clipToWidth(state.attentionHint, wrap)));
389
+ }
390
+ if (state.cancelHint) {
391
+ lines.push(fns.attention(clipToWidth('Press Esc again to decline', wrap)));
392
+ }
393
+ lines.push('');
394
+ const footer = state.customEditingFor !== null
395
+ ? 'Type free text · Enter keep · Esc abandon edit'
396
+ : `↑↓ navigate · Enter ${needsConfirmRow(state.questions) ? 'toggle / confirm' : 'select'} · Esc decline`;
397
+ lines.push(fns.subtle(clipToWidth(footer, wrap)));
398
+ return lines;
399
+ }
400
+ // -------------------------------------------------- render: review phase --
401
+ /** Render the review page for multi-question overlays. */
402
+ export function renderReviewView(theme, state, width) {
403
+ const fns = panelThemeFns(theme);
404
+ const wrap = Math.max(2, width - 2);
405
+ const lines = [fns.accent(BOLD + clipToWidth('● Review answers', wrap) + RESET)];
406
+ // Question column with a content cap + answer column flexes to the right edge.
407
+ // (columnWidths only assigns the remainder to ONE flex column; two flex
408
+ // columns would both get the whole remainder and clip the second off.)
409
+ const usable = wrap - MARKER_W;
410
+ const leftCap = Math.max(20, Math.floor(usable * 0.45));
411
+ const columns = [
412
+ { key: 'question', title: 'Question', width: leftCap },
413
+ { key: 'answer', title: 'Your answer', flex: true },
414
+ ];
415
+ const widths = columnWidths(usable, columns);
416
+ lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┬'), wrap)));
417
+ lines.push(fns.subtle(clipToWidth(tableHeaderLine(columns, widths), wrap)));
418
+ lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┼'), wrap)));
419
+ state.questions.forEach((question, qi) => {
420
+ const answer = state.perQuestion[qi];
421
+ const left = `${question.header ?? `Q${qi + 1}`} ${question.question}`;
422
+ const right = formatAnswerForReview(answer);
423
+ const selected = qi === state.reviewIndex;
424
+ const leftCell = padCell(clipToWidth(left, widths[0]), widths[0]);
425
+ const rightCell = padCell(clipToWidth(right, widths[1]), widths[1]);
426
+ const plain = `${rowMarker(selected)}${leftCell}${TABLE_SEP}${rightCell}`;
427
+ const line = clipToWidth(plain, wrap);
428
+ lines.push(selected ? fns.accent(BOLD + line + RESET) : fns.muted(line));
429
+ });
430
+ // Submit row (review pane).
431
+ {
432
+ const selected = state.reviewIndex === state.questions.length;
433
+ const leftCell = padCell(clipToWidth('Submit answers', widths[0]), widths[0]);
434
+ const rightCell = padCell(clipToWidth(allQuestionsAnswered(state) ? '✓ ready' : '— incomplete', widths[1]), widths[1]);
435
+ const plain = `${rowMarker(selected)}${leftCell}${TABLE_SEP}${rightCell}`;
436
+ const line = clipToWidth(plain, wrap);
437
+ lines.push(selected ? fns.accent(BOLD + line + RESET) : fns.muted(line));
438
+ }
439
+ lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┴'), wrap)));
440
+ if (state.attentionHint !== null) {
441
+ lines.push(fns.attention(clipToWidth(state.attentionHint, wrap)));
442
+ }
443
+ lines.push('');
444
+ lines.push(fns.subtle(clipToWidth('↑↓ select · Enter return to edit / submit', wrap)));
445
+ return lines;
446
+ }
447
+ function formatAnswerForReview(answer) {
448
+ if (answer === undefined)
449
+ return '';
450
+ if (answer.custom !== undefined && answer.custom !== '')
451
+ return `${CUSTOM_MARK}${answer.custom}`;
452
+ if (answer.selected.length === 0)
453
+ return '(no answer)';
454
+ return answer.selected.join(', ');
455
+ }
456
+ /**
457
+ * Open the AskUser overlay for one set of questions.
458
+ *
459
+ * `signal` is the caller's abort signal (the tool execution's). Already-aborted
460
+ * settles declined WITHOUT staging an overlay; a live signal closes the
461
+ * overlay and settles declined when it fires. We resolve the declined envelope
462
+ * rather than rejecting with the upstream ASK_ABORTED code on purpose: the
463
+ * upstream service already screens entry-time aborts, and a step aborted after
464
+ * this point discards the tool result anyway — resolving keeps the pending
465
+ * promise from ever hanging either way.
466
+ */
467
+ export function openAskUserPanel(deps, questions, signal) {
468
+ if (signal?.aborted) {
469
+ return Promise.resolve(buildDeclinedEnvelope(questions));
470
+ }
471
+ return new Promise((resolve) => {
472
+ const clock = deps.now ?? Date.now;
473
+ const state = initialState(questions);
474
+ let settled = false;
475
+ const onAbort = () => {
476
+ settle(buildDeclinedEnvelope(questions));
477
+ overlay.hide();
478
+ deps.restoreFocus();
479
+ };
480
+ const settle = (envelope) => {
481
+ if (settled)
482
+ return;
483
+ settled = true;
484
+ signal?.removeEventListener('abort', onAbort);
485
+ resolve(envelope);
486
+ };
487
+ const panel = {
488
+ invalidate() { },
489
+ render(width) {
490
+ const theme = deps.theme();
491
+ if (state.phase === 'review')
492
+ return renderReviewView(theme, state, width);
493
+ return renderQuestionsView(theme, state, width);
494
+ },
495
+ handleInput(data) {
496
+ if (settled)
497
+ return;
498
+ if (state.customEditingFor !== null) {
499
+ handleCustomInput(state, data);
500
+ return;
501
+ }
502
+ const kb = getKeybindings();
503
+ if (kb.matches(data, 'tui.select.up')) {
504
+ moveCursor(state, -1);
505
+ return;
506
+ }
507
+ if (kb.matches(data, 'tui.select.down')) {
508
+ moveCursor(state, 1);
509
+ return;
510
+ }
511
+ if (kb.matches(data, 'tui.select.confirm')) {
512
+ handleConfirm(state);
513
+ return;
514
+ }
515
+ if (kb.matches(data, 'tui.select.cancel')) {
516
+ const now = clock();
517
+ const prevState = { ...state };
518
+ const after = advanceDoubleEsc(state, now);
519
+ Object.assign(state, after);
520
+ if (didDoubleEscFire(prevState, state, now)) {
521
+ settle(buildDeclinedEnvelope(questions));
522
+ overlay.hide();
523
+ deps.restoreFocus();
524
+ }
525
+ return;
526
+ }
527
+ },
528
+ };
529
+ // Stage the overlay through the standard FramedOverlay wrapper. The theme
530
+ // getter is passed through so the frame re-reads it per render too.
531
+ const overlay = deps.tui.showOverlay(wrapFramedOverlay(deps.theme, panel), { width: deps.width ?? '85%', maxHeight: deps.maxHeight ?? '80%' });
532
+ signal?.addEventListener('abort', onAbort, { once: true });
533
+ // When the overlay closes (without an explicit settle first), resolve the
534
+ // promise with the decline envelope — covers /reload, theme swap, agent
535
+ // abort, and any path that closes the overlay out from under us.
536
+ const originalHide = overlay.hide.bind(overlay);
537
+ overlay.hide = () => {
538
+ if (!settled) {
539
+ settle(buildDeclinedEnvelope(questions));
540
+ deps.restoreFocus();
541
+ }
542
+ originalHide();
543
+ };
544
+ // Local mutator helpers — these write into the closed-over state via
545
+ // Object.assign so the keyboard handler can use the immutable reducers.
546
+ function moveCursor(s, direction) {
547
+ if (s.phase === 'review') {
548
+ const max = s.questions.length; // last row is the submit pseudo-row
549
+ s.reviewIndex = Math.max(0, Math.min(max, s.reviewIndex + direction));
550
+ Object.assign(s, { cancelHint: false, attentionHint: null });
551
+ return;
552
+ }
553
+ const rows = buildRowList(s.questions, s.perQuestion);
554
+ const next = nextSelectableIndex(rows, s.cursorIndex + direction, direction);
555
+ Object.assign(s, { cursorIndex: next, cancelHint: false, attentionHint: null });
556
+ }
557
+ function handleConfirm(s) {
558
+ if (s.phase === 'review') {
559
+ if (s.reviewIndex < s.questions.length) {
560
+ const rows = buildRowList(s.questions, s.perQuestion);
561
+ const first = rows.findIndex(r => r.kind !== 'question-header' && r.questionIndex === s.reviewIndex);
562
+ Object.assign(s, {
563
+ phase: 'questions',
564
+ cursorIndex: first >= 0 ? first : s.cursorIndex,
565
+ cancelHint: false,
566
+ attentionHint: null,
567
+ });
568
+ return;
569
+ }
570
+ if (!allQuestionsAnswered(s)) {
571
+ Object.assign(s, { cancelHint: false, attentionHint: INCOMPLETE_HINT });
572
+ return;
573
+ }
574
+ settle(buildAnswerEnvelope(s));
575
+ overlay.hide();
576
+ deps.restoreFocus();
577
+ return;
578
+ }
579
+ // questions phase
580
+ const rows = buildRowList(s.questions, s.perQuestion);
581
+ const row = rows[s.cursorIndex];
582
+ if (row === undefined)
583
+ return;
584
+ if (row.kind === 'option' && row.optionIndex !== undefined) {
585
+ const option = s.questions[row.questionIndex]?.options?.[row.optionIndex];
586
+ if (option === undefined)
587
+ return;
588
+ const next = toggleOption(s, row.questionIndex, option.label);
589
+ Object.assign(s, next);
590
+ // Single-question single-select fast path: a successful selection
591
+ // submits immediately. multiSelect never auto-submits — the user may
592
+ // want more toggles, so it routes through the Confirm row instead.
593
+ if (canAutoSubmit(next)) {
594
+ settle(buildAnswerEnvelope(s));
595
+ overlay.hide();
596
+ deps.restoreFocus();
597
+ }
598
+ return;
599
+ }
600
+ if (row.kind === 'sentinel') {
601
+ const seed = s.perQuestion[row.questionIndex]?.custom ?? '';
602
+ const seeded = patchCustomInput(enterCustomEdit(s, row.questionIndex), row.questionIndex, () => seed);
603
+ Object.assign(s, seeded);
604
+ return;
605
+ }
606
+ if (row.kind === 'confirm') {
607
+ if (allQuestionsAnswered(s)) {
608
+ Object.assign(s, { phase: 'review', reviewIndex: 0, cancelHint: false, attentionHint: null });
609
+ }
610
+ else {
611
+ Object.assign(s, { cancelHint: false, attentionHint: INCOMPLETE_HINT });
612
+ }
613
+ }
614
+ }
615
+ function handleCustomInput(s, data) {
616
+ const qi = s.customEditingFor;
617
+ if (qi === null)
618
+ return;
619
+ const kb = getKeybindings();
620
+ if (kb.matches(data, 'tui.select.confirm')) {
621
+ Object.assign(s, exitCustomEdit(s, true));
622
+ commitCustomAnswer(s, qi);
623
+ return;
624
+ }
625
+ if (kb.matches(data, 'tui.select.cancel')) {
626
+ Object.assign(s, exitCustomEdit(s, false));
627
+ return;
628
+ }
629
+ if (data === '\x7f' || matchesKey(data, 'backspace')) {
630
+ const next = s.customInputs.slice();
631
+ next[qi] = (next[qi] ?? '').slice(0, -1);
632
+ s.customInputs = next;
633
+ return;
634
+ }
635
+ if (data.length === 1 && data.charCodeAt(0) >= 0x20 && data.charCodeAt(0) !== 0x7f) {
636
+ const next = s.customInputs.slice();
637
+ next[qi] = (next[qi] ?? '') + data;
638
+ s.customInputs = next;
639
+ }
640
+ }
641
+ /**
642
+ * After a committed sentinel edit: a lone single-select question whose
643
+ * answer is now complete submits right away (this is the free-text
644
+ * counterpart of the option fast path — without it, typing an answer into
645
+ * a question with no options could never reach `settle`). Otherwise, in a
646
+ * multi-question layout the cursor hops to the next unanswered question's
647
+ * first row to cut confirmation cost.
648
+ */
649
+ function commitCustomAnswer(s, qi) {
650
+ if (s.customEditingFor !== null)
651
+ return; // edit not committed (empty buffer)
652
+ if (s.perQuestion[qi]?.custom === undefined)
653
+ return;
654
+ if (canAutoSubmit(s)) {
655
+ settle(buildAnswerEnvelope(s));
656
+ overlay.hide();
657
+ deps.restoreFocus();
658
+ return;
659
+ }
660
+ if (s.questions.length >= 2) {
661
+ const rows = buildRowList(s.questions, s.perQuestion);
662
+ const target = nextUnansweredRow(rows, s.perQuestion, qi);
663
+ if (target >= 0)
664
+ Object.assign(s, { cursorIndex: target, cancelHint: false, attentionHint: null });
665
+ }
666
+ }
667
+ });
668
+ }
669
+ // ----------------------------------------------------- the provider --
670
+ /**
671
+ * True only for the upstream's documented duplicate-registration failure
672
+ * (`UserQuestionError` with code `DUPLICATE_PROVIDER`) — the one case where
673
+ * yielding the single provider slot to the prior UI is correct. Matched
674
+ * structurally on `name` + `code` so a cross-realm HarnessError instance or a
675
+ * test double classifies identically.
676
+ */
677
+ export function isDuplicateProviderError(error) {
678
+ if (!(error instanceof Error))
679
+ return false;
680
+ if (error.name !== 'UserQuestionError')
681
+ return false;
682
+ return error.code === 'DUPLICATE_PROVIDER';
683
+ }
684
+ /**
685
+ * Wires the provider into `ctx.userQuestions`. Call from inside `ctx.effect`.
686
+ *
687
+ * Failure semantics are deliberate (review round BM):
688
+ * - missing service → warn + no-op disposer. The tool stays mounted by the
689
+ * bundle patch; without a provider its calls fail with the upstream
690
+ * NO_PROVIDER error, which is better than crashing the whole TUI plugin.
691
+ * - DUPLICATE_PROVIDER → silent no-op disposer (a prior UI owns the slot).
692
+ * - anything else → rethrown so the effect fails loudly instead of leaving a
693
+ * mounted tool with no UI and no trace.
694
+ */
695
+ export function registerAskUserProvider(ctx, deps) {
696
+ const userQuestions = ctx.userQuestions;
697
+ if (userQuestions === undefined || typeof userQuestions.registerProvider !== 'function') {
698
+ console.warn('[dsh-tui-pi] ctx.userQuestions not mounted — ask_user_question calls will fail with NO_PROVIDER');
699
+ return () => { };
700
+ }
701
+ try {
702
+ return userQuestions.registerProvider({
703
+ ask: request => openAskUserPanel(deps, request.questions, request.signal),
704
+ });
705
+ }
706
+ catch (error) {
707
+ if (isDuplicateProviderError(error)) {
708
+ // A prior UI is already the provider — yield ownership instead of crashing.
709
+ return () => { };
710
+ }
711
+ throw error;
712
+ }
713
+ }
714
+ //# sourceMappingURL=ask-user.js.map