@aiwayds/dsh-tui-pi 0.22.0 → 0.23.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/lib/ask-user.js CHANGED
@@ -8,12 +8,17 @@
8
8
  * `AskUserQuestionAnswer` envelope back to `dsh-tool-ask-user` as a normal
9
9
  * tool result.
10
10
  *
11
- * Layout — one bordered dock panel, all questions flattened (numbered rows, a
12
- * divider between each question block and its options, and a scroll window
13
- * so the cursor row is always visible even when the dock is height-capped):
11
+ * Layout — one bordered dock panel showing EXACTLY ONE question at a time
12
+ * (numbered rows, a divider between the question block and its options, and
13
+ * a scroll window so the cursor row is always visible even when the dock is
14
+ * height-capped). With ≥ 2 questions a tab strip under the title carries one
15
+ * tab per question (`[1] · 2✓ · 3` — brackets mark the focused tab, ✓ an
16
+ * answered one); ←/→ (and Tab/Shift-Tab) switch tabs so a question batch
17
+ * never floods the dock:
14
18
  *
15
19
  * ┌────────────────────────────────────┐
16
- * │ ● Questions (2) │
20
+ * │ ● Questions (1/2) │
21
+ * │ [1] · 2 │ (tab strip, ≥ 2 questions only)
17
22
  * │ SELECTION │
18
23
  * │ ───────────────────────────── │ (table chrome)
19
24
  * │ Fruit Where should we deploy? │ (header — not selectable)
@@ -26,6 +31,11 @@
26
31
  * │ ⏎ Confirm answers │ (when ≥ 2 questions, or any multiSelect)
27
32
  * └────────────────────────────────────┘
28
33
  *
34
+ * Ctrl+T folds the whole panel into a 3-line strip (borders + one summary
35
+ * line) so the transcript stays readable while the question pends; the same
36
+ * key unfolds it. While folded only the toggle and the Esc chain do
37
+ * anything — answering keys are inert until the panel is back.
38
+ *
29
39
  * While the panel is open it owns the keyboard: it is mounted into the dock
30
40
  * slot between the live widgets and the editor, takes focus through
31
41
  * `tui.setFocus`, and `setModalActive(true)` routes the app keymap exactly
@@ -45,7 +55,9 @@
45
55
  * Single-question single-select panel: Enter on an option (or committing a
46
56
  * filled sentinel) submits immediately. A multiSelect question — even a lone
47
57
  * one — gets a Confirm row instead of auto-submitting, so the user can pick
48
- * several options first. Multi-question panel: Enter on the Confirm row hops
58
+ * several options first. Multi-question panel: answering a single-select tab
59
+ * auto-advances the focus to the next unanswered tab (or onto the Confirm
60
+ * row once everything is answered). Enter on the Confirm row hops
49
61
  * to the review page (all answers listed, each editable in place). Esc
50
62
  * double-press within 200 ms declines — but terminal key auto-repeat (holding
51
63
  * Esc) is ignored below `ESC_REPEAT_GUARD_MS`, so a long press cannot
@@ -70,6 +82,7 @@ import { borderedRow, panelBottomBorder, panelBoxWidth, panelTopBorder, } from "
70
82
  import { BOLD, RESET, ansiFg, } from "./theme/index.js";
71
83
  import { MARKER_W, panelThemeFns, TABLE_SEP, columnWidths, padCell, rowMarker, tableHeaderLine, tableRuleLine, } from "./panels.js";
72
84
  import { clipToWidth, wrapText } from "./text.js";
85
+ import { emitNotice } from "./notice-bridge.js";
73
86
  // ----------------------------------------------------------------- constants --
74
87
  /** Maximum time between two Esc presses before the second one re-arms instead of firing. */
75
88
  export const DOUBLE_ESC_WINDOW_MS = 200;
@@ -82,6 +95,13 @@ export const DOUBLE_ESC_WINDOW_MS = 200;
82
95
  export const ESC_REPEAT_GUARD_MS = 50;
83
96
  /** Sentinel row label appended to every question's option list. */
84
97
  export const SENTINEL_LABEL = 'Type something.';
98
+ /**
99
+ * Key that folds the whole panel into a 3-line strip (and unfolds it again).
100
+ * A control key on purpose: it must never read as free-text input while the
101
+ * sentinel editor is engaged (folding commits the buffer, exactly like the
102
+ * ↑↓ arrow-exit path does).
103
+ */
104
+ export const ASK_COLLAPSE_KEY = 'ctrl+t';
85
105
  /** Decline message embedded into the answer envelope when the user bails. */
86
106
  export const DECLINE_MESSAGE = 'User declined to answer questions.';
87
107
  /** Transient hint when Enter lands on an incomplete confirm/submit row. */
@@ -115,12 +135,14 @@ const ASK_DOCK_RESERVED_ROWS = 8;
115
135
  * panel budget is the terminal height minus the reserved dock rows, minus
116
136
  * the panel's own chrome. At the 24-row e2e floor this yields exactly
117
137
  * ASK_USER_MAX_VISIBLE (6); larger terminals scale up. Without a usable row
118
- * count it degrades to ASK_USER_MAX_VISIBLE.
138
+ * count it degrades to ASK_USER_MAX_VISIBLE. `extraChromeLines` covers
139
+ * optional interior lines the default budget does not count (the tab strip
140
+ * of a multi-question panel).
119
141
  */
120
- export function askUserMaxVisibleForRows(termRows) {
142
+ export function askUserMaxVisibleForRows(termRows, extraChromeLines = 0) {
121
143
  if (termRows === undefined || !Number.isFinite(termRows) || termRows <= 0)
122
144
  return ASK_USER_MAX_VISIBLE;
123
- return Math.max(1, Math.floor(termRows) - ASK_DOCK_RESERVED_ROWS - ASK_USER_VIEW_OVERHEAD);
145
+ return Math.max(1, Math.floor(termRows) - ASK_DOCK_RESERVED_ROWS - ASK_USER_VIEW_OVERHEAD - Math.max(0, extraChromeLines));
124
146
  }
125
147
  // ---------------------------------------- pure functions (testable) --
126
148
  /** Initial state for a `questions` payload — defaults: cursor on Q0/option-0, no edit, no Esc history. */
@@ -128,11 +150,14 @@ export function initialState(questions) {
128
150
  // Snap the starting cursor onto the first SELECTABLE row: index 0 is the
129
151
  // question header (unselectable), and a cursor parked there would render
130
152
  // no ▸ marker at all and ignore Enter/digits.
131
- const rows = buildRowList(questions, questions.map(() => ({ selected: [] })));
153
+ const perQuestion = questions.map(() => ({ selected: [] }));
154
+ const rows = buildRowList(questions, perQuestion, 0);
132
155
  return {
133
156
  questions,
134
- perQuestion: questions.map(() => ({ selected: [] })),
157
+ perQuestion,
135
158
  cursorIndex: nextSelectableIndex(rows, 0, 1),
159
+ focusQuestion: 0,
160
+ collapsed: false,
136
161
  customInputs: questions.map(() => null),
137
162
  customEditingFor: null,
138
163
  phase: 'questions',
@@ -253,14 +278,20 @@ export function buildDeclinedEnvelope(questions, message = DECLINE_MESSAGE) {
253
278
  answers: questions.map(question => ({ id: question.id, selected: [], custom: message })),
254
279
  };
255
280
  }
256
- /** Build the ordered, flat list of rows for the questions pane. */
257
- export function buildRowList(questions, perQuestion) {
281
+ /**
282
+ * Build the ordered row list for the questions pane: the FOCUSED question's
283
+ * block only (header + options + sentinel), followed by the panel-wide
284
+ * Confirm pseudo-row when `needsConfirmRow` says so — the pane shows one
285
+ * question at a time; ←/→ swaps `focusQuestion` and rebuilds this list.
286
+ */
287
+ export function buildRowList(questions, perQuestion, focusQuestion = 0) {
258
288
  const rows = [];
259
- questions.forEach((question, qi) => {
289
+ const question = questions[focusQuestion];
290
+ if (question !== undefined) {
260
291
  rows.push({
261
292
  kind: 'question-header',
262
- questionIndex: qi,
263
- label: question.header ?? `Question ${qi + 1}`,
293
+ questionIndex: focusQuestion,
294
+ label: question.header ?? `Question ${focusQuestion + 1}`,
264
295
  description: question.question,
265
296
  ...(question.detail !== undefined ? { detail: question.detail } : {}),
266
297
  selectable: false,
@@ -269,21 +300,21 @@ export function buildRowList(questions, perQuestion) {
269
300
  options.forEach((opt, oi) => {
270
301
  rows.push({
271
302
  kind: 'option',
272
- questionIndex: qi,
303
+ questionIndex: focusQuestion,
273
304
  optionIndex: oi,
274
305
  label: opt.label,
275
306
  ...(opt.description !== undefined ? { description: opt.description } : {}),
276
307
  selectable: true,
277
308
  });
278
309
  });
279
- const customAnswer = perQuestion[qi]?.custom ?? '';
310
+ const customAnswer = perQuestion[focusQuestion]?.custom ?? '';
280
311
  rows.push({
281
312
  kind: 'sentinel',
282
- questionIndex: qi,
313
+ questionIndex: focusQuestion,
283
314
  label: customAnswer !== '' ? `${CUSTOM_MARK}${customAnswer.trim()}` : SENTINEL_LABEL,
284
315
  selectable: true,
285
316
  });
286
- });
317
+ }
287
318
  if (needsConfirmRow(questions)) {
288
319
  rows.push({ kind: 'confirm', questionIndex: -1, label: '⏎ Confirm answers', selectable: true });
289
320
  }
@@ -392,27 +423,109 @@ export function canAutoSubmit(state) {
392
423
  return allQuestionsAnswered(state);
393
424
  }
394
425
  /**
395
- * Row index to land on after answering question `answeredQi`: the first
396
- * selectable row of the nearest LATER unanswered question, or -1 when every
397
- * later question is already answered (caller keeps the current cursor).
426
+ * Row index to land on after a single-select answer on question `answeredQi`
427
+ * (option toggle or committed custom text): advance the tab focus to the
428
+ * nearest LATER unanswered question (cursor on its answer), or — once every
429
+ * question is answered — park the cursor on the Confirm row of the current
430
+ * tab so submission is one Enter away. Returns the state unchanged when no
431
+ * Confirm row exists (the lone single-select fast path submits instead).
398
432
  */
399
- export function nextUnansweredRow(rows, perQuestion, answeredQi) {
433
+ export function advanceAfterAnswer(state, answeredQi) {
434
+ if (!needsConfirmRow(state.questions))
435
+ return state;
436
+ const next = nextUnansweredQuestion(state.questions, state.perQuestion, answeredQi);
437
+ if (next >= 0) {
438
+ const rows = buildRowList(state.questions, state.perQuestion, next);
439
+ return {
440
+ ...state,
441
+ focusQuestion: next,
442
+ cursorIndex: focusCursor(rows, state.perQuestion, next),
443
+ // The scroll window is tab-local in effect: a stale offset from the
444
+ // previous tab would scroll the new tab's header out of view. The
445
+ // render clamp re-centers onto the cursor from a clean top.
446
+ questionsScroll: 0,
447
+ cancelHint: false,
448
+ attentionHint: null,
449
+ };
450
+ }
451
+ const rows = buildRowList(state.questions, state.perQuestion, state.focusQuestion);
452
+ const confirmIndex = rows.findIndex(row => row.kind === 'confirm');
453
+ if (confirmIndex < 0)
454
+ return state;
455
+ return { ...state, cursorIndex: confirmIndex, cancelHint: false, attentionHint: null };
456
+ }
457
+ /** Index of the nearest question AFTER `answeredQi` with no answer yet, or -1. */
458
+ export function nextUnansweredQuestion(questions, perQuestion, answeredQi) {
400
459
  const answered = (qi) => {
401
460
  const answer = perQuestion[qi];
402
461
  return answer !== undefined
403
462
  && (answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== ''));
404
463
  };
405
- for (let i = 0; i < rows.length; i++) {
406
- const row = rows[i];
407
- if (row === undefined || !row.selectable)
408
- continue;
409
- if (row.questionIndex <= answeredQi)
410
- continue;
411
- if (!answered(row.questionIndex))
412
- return i;
464
+ for (let qi = answeredQi + 1; qi < questions.length; qi++) {
465
+ if (!answered(qi))
466
+ return qi;
413
467
  }
414
468
  return -1;
415
469
  }
470
+ /**
471
+ * Cursor position for a freshly focused question tab: its first selected
472
+ * option when one exists, else the sentinel when a custom answer exists,
473
+ * else the first selectable row — so revisiting an answered tab lands on
474
+ * the answer, not on option 1.
475
+ */
476
+ export function focusCursor(rows, perQuestion, questionIndex) {
477
+ const answer = perQuestion[questionIndex];
478
+ const selected = answer?.selected ?? [];
479
+ if (selected.length > 0) {
480
+ const bySelection = rows.findIndex(row => row.kind === 'option' && selected.includes(row.label));
481
+ if (bySelection >= 0)
482
+ return bySelection;
483
+ }
484
+ if (answer?.custom !== undefined && answer.custom.trim() !== '') {
485
+ const sentinel = rows.findIndex(row => row.kind === 'sentinel' && row.questionIndex === questionIndex);
486
+ if (sentinel >= 0)
487
+ return sentinel;
488
+ }
489
+ return nextSelectableIndex(rows, 0, 1);
490
+ }
491
+ /**
492
+ * Move the tab focus by `direction` (clamped at the ends — no wrap-around:
493
+ * predictable ends beat flourish mid-questionnaire) and land the cursor on
494
+ * the tab's answer per `focusCursor`. Clears the transient hints like any
495
+ * navigation. The component must exit an engaged sentinel edit BEFORE
496
+ * switching (committing the buffer), same as the ↑↓ arrow-exit path.
497
+ */
498
+ export function switchFocus(state, direction) {
499
+ const next = Math.max(0, Math.min(state.questions.length - 1, state.focusQuestion + direction));
500
+ if (next === state.focusQuestion) {
501
+ return { ...state, cancelHint: false, attentionHint: null };
502
+ }
503
+ const rows = buildRowList(state.questions, state.perQuestion, next);
504
+ return {
505
+ ...state,
506
+ focusQuestion: next,
507
+ cursorIndex: focusCursor(rows, state.perQuestion, next),
508
+ // Fresh window for the fresh tab (a stale offset scrolls the new tab's
509
+ // header out of view); the render clamp re-centers onto the cursor.
510
+ questionsScroll: 0,
511
+ cancelHint: false,
512
+ attentionHint: null,
513
+ };
514
+ }
515
+ /**
516
+ * Flip the Ctrl+T fold. Folding while a sentinel edit is engaged first
517
+ * commits the buffer (the arrow-exit semantics — the text stays visible on
518
+ * the sentinel's ✎ mark), then collapses; transient hints clear on both
519
+ * directions. The double-Esc clock is deliberately untouched so an armed
520
+ * decline keeps its hint when the panel unfolds.
521
+ */
522
+ export function toggleCollapse(state) {
523
+ let next = state;
524
+ if (!state.collapsed && state.customEditingFor !== null) {
525
+ next = exitCustomEdit(state, true);
526
+ }
527
+ return { ...next, collapsed: !next.collapsed, attentionHint: null };
528
+ }
416
529
  // ------------------------------------------------- render: questions phase --
417
530
  /** Render width budget for the questions phase (single flex label column). */
418
531
  const QUESTIONS_COLUMNS = () => [
@@ -436,15 +549,45 @@ function inlineStateSlot(row, isSelectedOption, allAnswered) {
436
549
  return allAnswered ? '✓ ' : ' ';
437
550
  return ' ';
438
551
  }
439
- /** Render the questions pane as a flat table line list behind a scroll window. */
552
+ /**
553
+ * One muted line under the title summarizing every question tab:
554
+ * `[1] · 2✓ · 3` — the focused tab renders bracketed (accent + bold; the
555
+ * brackets distinguish it from the row cursor's ▸ marker), an answered tab
556
+ * carries ✓ (success color), the rest stay plain numbers. Clip-only (no
557
+ * wrap): a too-narrow terminal drops trailing tabs, and the (n/N) in the
558
+ * title keeps the full count readable regardless.
559
+ */
560
+ function renderTabStrip(fns, wrap, state) {
561
+ const parts = [];
562
+ state.questions.forEach((question, qi) => {
563
+ const answer = state.perQuestion[qi];
564
+ const answered = answer !== undefined
565
+ && (answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== ''));
566
+ const label = `${qi + 1}${answered ? '✓' : ''}`;
567
+ if (qi === state.focusQuestion) {
568
+ parts.push(fns.accent(BOLD + `[${label}]` + RESET));
569
+ }
570
+ else if (answered) {
571
+ parts.push(fns.success(label));
572
+ }
573
+ else {
574
+ parts.push(fns.muted(label));
575
+ }
576
+ });
577
+ return fns.muted(clipToWidth(parts.join(' · '), wrap));
578
+ }
579
+ /** Render the questions pane (one focused question tab) as a flat table line list behind a scroll window. */
440
580
  export function renderQuestionsView(theme, state, width, maxVisible = ASK_USER_MAX_VISIBLE) {
441
581
  const fns = panelThemeFns(theme);
442
582
  const wrap = Math.max(2, width - 2);
443
583
  const title = state.questions.length === 1
444
584
  ? '● Question'
445
- : `● Questions (${state.questions.length})`;
585
+ : `● Questions (${state.focusQuestion + 1}/${state.questions.length})`;
446
586
  const lines = [fns.accent(BOLD + clipToWidth(title, wrap) + RESET)];
447
- const rows = buildRowList(state.questions, state.perQuestion);
587
+ if (state.questions.length >= 2) {
588
+ lines.push(renderTabStrip(fns, wrap, state));
589
+ }
590
+ const rows = buildRowList(state.questions, state.perQuestion, state.focusQuestion);
448
591
  if (rows.length === 0) {
449
592
  lines.push(fns.muted(clipToWidth('(no questions)', wrap)));
450
593
  return finalizeQuestionsView(fns, wrap, lines, state);
@@ -581,9 +724,14 @@ function finalizeQuestionsView(fns, wrap, lines, state, scrollInfo = '') {
581
724
  lines.push(fns.attention(clipToWidth('Press Esc again to decline', wrap)));
582
725
  }
583
726
  lines.push('');
727
+ const multiTab = state.questions.length >= 2;
728
+ // Compact on purpose: at the common 80-column terminal the panel's inner
729
+ // wrap is 74 columns, so every hint (fold key included) must fit; the
730
+ // scroll readout rides FIRST so narrow terminals clip the tail hints,
731
+ // never the position.
584
732
  const footer = state.customEditingFor !== null
585
- ? 'Type free text · Enter keep · ↑↓ move · Esc abandon edit'
586
- : `↑↓ navigate · Enter ${needsConfirmRow(state.questions) ? 'toggle / confirm' : 'select'} · 1-9 pick · Esc decline`;
733
+ ? 'Type free text · Enter keep · ↑↓ move · Ctrl+T fold · Esc abandon'
734
+ : `${multiTab ? '←→ tabs · ' : ''}↑↓ move · Enter ${needsConfirmRow(state.questions) ? 'toggle' : 'select'} · 1-9 pick · Ctrl+T fold · Esc decline`;
587
735
  // The (n/m) readout goes FIRST so narrow terminals clip the hint, never
588
736
  // the scroll info.
589
737
  lines.push(fns.subtle(clipToWidth(scrollInfo + footer, wrap)));
@@ -640,7 +788,7 @@ export function renderReviewView(theme, state, width, maxVisible = ASK_USER_MAX_
640
788
  lines.push(fns.attention(clipToWidth(state.attentionHint, wrap)));
641
789
  }
642
790
  lines.push('');
643
- const footer = '↑↓ select · Enter return to edit / submit';
791
+ const footer = '↑↓ select · Enter return to edit / submit · Ctrl+T fold';
644
792
  const scrollInfo = body.length > visible ? ` (${state.reviewIndex + 1}/${body.length})` : '';
645
793
  lines.push(fns.subtle(clipToWidth(scrollInfo + footer, wrap)));
646
794
  return lines;
@@ -656,6 +804,29 @@ function formatAnswerForReview(answer) {
656
804
  return '(no answer)';
657
805
  return foldText(answer.selected.join(', '));
658
806
  }
807
+ // ------------------------------------------------- render: folded strip --
808
+ /**
809
+ * The ONE interior line of the folded (Ctrl+T) panel. The panel is a hard
810
+ * modal that owns the keyboard while it pends, so the strip keeps the
811
+ * state machine discoverable: which phase, how far along, how to unfold —
812
+ * and an armed decline replaces the summary so the 200 ms window is not
813
+ * silently ticking off-screen.
814
+ */
815
+ export function renderCollapsedLine(theme, state, width) {
816
+ const fns = panelThemeFns(theme);
817
+ const wrap = Math.max(2, width - 2);
818
+ if (state.cancelHint) {
819
+ return fns.attention(clipToWidth('Press Esc again to decline · Ctrl+T expand', wrap));
820
+ }
821
+ if (state.phase === 'review') {
822
+ return fns.accent(BOLD + clipToWidth('● Review answers pending · Ctrl+T expand', wrap) + RESET);
823
+ }
824
+ const answered = state.perQuestion.filter(answer => answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== '')).length;
825
+ const label = state.questions.length === 1
826
+ ? '● Question pending'
827
+ : `● Questions (${state.focusQuestion + 1}/${state.questions.length} · ${answered} answered)`;
828
+ return fns.accent(BOLD + clipToWidth(`${label} · Ctrl+T expand`, wrap) + RESET);
829
+ }
659
830
  /**
660
831
  * Open the AskUser docked panel for one set of questions.
661
832
  *
@@ -701,16 +872,26 @@ export function openAskUserPanel(deps, questions, signal) {
701
872
  invalidate() { },
702
873
  render(width) {
703
874
  const theme = deps.theme();
704
- // Terminal-height-adaptive scroll window: the dock stacks with the
705
- // editor/footer/transcript, so the budget subtracts the reserved rows
706
- // (see askUserMaxVisibleForRows). Fake TUIs without a `terminal`
707
- // degrade to ASK_USER_MAX_VISIBLE.
708
- const maxVisible = askUserMaxVisibleForRows(deps.tui.terminal?.rows);
709
875
  // Todos-panel box language: full-width bordered panel pinned above
710
876
  // the chat input. The inner views clip to the box's inner budget.
711
877
  const boxWidth = panelBoxWidth(width);
712
878
  const borderFg = ansiFg(theme.palette.borderDefault);
713
879
  const innerWidth = Math.max(2, boxWidth - 4);
880
+ // Folded strip (Ctrl+T): borders + ONE interior line — the transcript
881
+ // below stays readable while the question pends.
882
+ if (state.collapsed) {
883
+ return [
884
+ panelTopBorder(boxWidth, borderFg),
885
+ borderedRow(boxWidth, borderFg, renderCollapsedLine(theme, state, innerWidth)),
886
+ panelBottomBorder(boxWidth, borderFg),
887
+ ];
888
+ }
889
+ // Terminal-height-adaptive scroll window: the dock stacks with the
890
+ // editor/footer/transcript, so the budget subtracts the reserved rows
891
+ // (see askUserMaxVisibleForRows); the tab strip of a multi-question
892
+ // panel claims one extra interior line. Fake TUIs without a
893
+ // `terminal` degrade to ASK_USER_MAX_VISIBLE.
894
+ const maxVisible = askUserMaxVisibleForRows(deps.tui.terminal?.rows, state.phase === 'questions' && state.questions.length >= 2 ? 1 : 0);
714
895
  const inner = state.phase === 'review'
715
896
  ? renderReviewView(theme, state, innerWidth, maxVisible)
716
897
  : renderQuestionsView(theme, state, innerWidth, maxVisible);
@@ -723,11 +904,38 @@ export function openAskUserPanel(deps, questions, signal) {
723
904
  handleInput(data) {
724
905
  if (settled)
725
906
  return;
907
+ const kb = getKeybindings();
908
+ // The fold toggle outranks everything — including the sentinel edit:
909
+ // folding commits the buffer first (the ↑↓ arrow-exit semantics), so
910
+ // a control key never types into the free-text buffer.
911
+ if (matchesKey(data, ASK_COLLAPSE_KEY)) {
912
+ Object.assign(state, toggleCollapse(state));
913
+ return;
914
+ }
915
+ // While folded the panel is a passive strip: only the Esc chain stays
916
+ // live, so the user can still decline while reading the transcript.
917
+ if (state.collapsed) {
918
+ if (kb.matches(data, 'tui.select.cancel')) {
919
+ runCancelChain();
920
+ }
921
+ return;
922
+ }
726
923
  if (state.customEditingFor !== null) {
727
924
  handleCustomInput(state, data);
728
925
  return;
729
926
  }
730
- const kb = getKeybindings();
927
+ // Question tabs (questions phase, multi-question only): ←/→ and
928
+ // Tab/Shift-Tab hop between the per-question tabs.
929
+ if (state.phase === 'questions' && state.questions.length >= 2) {
930
+ if (matchesKey(data, 'right') || matchesKey(data, 'tab')) {
931
+ Object.assign(state, switchFocus(state, 1));
932
+ return;
933
+ }
934
+ if (matchesKey(data, 'left') || matchesKey(data, 'shift+tab')) {
935
+ Object.assign(state, switchFocus(state, -1));
936
+ return;
937
+ }
938
+ }
731
939
  if (kb.matches(data, 'tui.select.up')) {
732
940
  moveCursor(state, -1);
733
941
  return;
@@ -748,14 +956,7 @@ export function openAskUserPanel(deps, questions, signal) {
748
956
  return;
749
957
  }
750
958
  if (kb.matches(data, 'tui.select.cancel')) {
751
- const now = clock();
752
- const prevState = { ...state };
753
- const after = advanceDoubleEsc(state, now);
754
- Object.assign(state, after);
755
- if (didDoubleEscFire(prevState, state, now)) {
756
- settle(buildDeclinedEnvelope(questions));
757
- close();
758
- }
959
+ runCancelChain();
759
960
  return;
760
961
  }
761
962
  },
@@ -772,6 +973,16 @@ export function openAskUserPanel(deps, questions, signal) {
772
973
  signal?.addEventListener('abort', onAbort, { once: true });
773
974
  // Local mutator helpers — these write into the closed-over state via
774
975
  // Object.assign so the keyboard handler can use the immutable reducers.
976
+ function runCancelChain() {
977
+ const now = clock();
978
+ const prevState = { ...state };
979
+ const after = advanceDoubleEsc(state, now);
980
+ Object.assign(state, after);
981
+ if (didDoubleEscFire(prevState, state, now)) {
982
+ settle(buildDeclinedEnvelope(questions));
983
+ close();
984
+ }
985
+ }
775
986
  function moveCursor(s, direction) {
776
987
  if (s.phase === 'review') {
777
988
  const max = s.questions.length; // last row is the submit pseudo-row
@@ -779,7 +990,7 @@ export function openAskUserPanel(deps, questions, signal) {
779
990
  Object.assign(s, { cancelHint: false, attentionHint: null });
780
991
  return;
781
992
  }
782
- const rows = buildRowList(s.questions, s.perQuestion);
993
+ const rows = buildRowList(s.questions, s.perQuestion, s.focusQuestion);
783
994
  // nextSelectableIndex scans from `from + direction`, so it must receive
784
995
  // the raw cursor — passing cursor+direction double-stepped every press
785
996
  // (masked for years by the initial cursor parking on the header row).
@@ -798,7 +1009,7 @@ export function openAskUserPanel(deps, questions, signal) {
798
1009
  // cursor invariants.
799
1010
  if (s.phase !== 'questions')
800
1011
  return;
801
- const rows = buildRowList(s.questions, s.perQuestion);
1012
+ const rows = buildRowList(s.questions, s.perQuestion, s.focusQuestion);
802
1013
  const anchor = rows[s.cursorIndex];
803
1014
  if (anchor === undefined || anchor.kind === 'confirm' || anchor.questionIndex < 0)
804
1015
  return;
@@ -811,11 +1022,14 @@ export function openAskUserPanel(deps, questions, signal) {
811
1022
  function handleConfirm(s) {
812
1023
  if (s.phase === 'review') {
813
1024
  if (s.reviewIndex < s.questions.length) {
814
- const rows = buildRowList(s.questions, s.perQuestion);
1025
+ // Jump back into the questions pane ON the reviewed question's tab.
1026
+ const rows = buildRowList(s.questions, s.perQuestion, s.reviewIndex);
815
1027
  const first = rows.findIndex(r => r.kind !== 'question-header' && r.questionIndex === s.reviewIndex);
816
1028
  Object.assign(s, {
817
1029
  phase: 'questions',
1030
+ focusQuestion: s.reviewIndex,
818
1031
  cursorIndex: first >= 0 ? first : s.cursorIndex,
1032
+ questionsScroll: 0,
819
1033
  cancelHint: false,
820
1034
  attentionHint: null,
821
1035
  });
@@ -830,7 +1044,7 @@ export function openAskUserPanel(deps, questions, signal) {
830
1044
  return;
831
1045
  }
832
1046
  // questions phase
833
- const rows = buildRowList(s.questions, s.perQuestion);
1047
+ const rows = buildRowList(s.questions, s.perQuestion, s.focusQuestion);
834
1048
  const row = rows[s.cursorIndex];
835
1049
  if (row === undefined)
836
1050
  return;
@@ -847,6 +1061,19 @@ export function openAskUserPanel(deps, questions, signal) {
847
1061
  settle(buildAnswerEnvelope(s));
848
1062
  close();
849
1063
  }
1064
+ else {
1065
+ // Answering a single-select tab hops the focus to the next
1066
+ // unanswered tab (or onto the Confirm row when everything is
1067
+ // answered). multiSelect stays put for further toggles, and a
1068
+ // deselect (toggle off) leaves the question unanswered, so it
1069
+ // stays put too.
1070
+ const answer = next.perQuestion[row.questionIndex];
1071
+ const answered = answer !== undefined
1072
+ && (answer.selected.length > 0 || (answer.custom ?? '').trim() !== '');
1073
+ if (answered && s.questions[row.questionIndex]?.multiSelect !== true) {
1074
+ Object.assign(s, advanceAfterAnswer(s, row.questionIndex));
1075
+ }
1076
+ }
850
1077
  return;
851
1078
  }
852
1079
  if (row.kind === 'sentinel') {
@@ -902,8 +1129,8 @@ export function openAskUserPanel(deps, questions, signal) {
902
1129
  * answer is now complete submits right away (this is the free-text
903
1130
  * counterpart of the option fast path — without it, typing an answer into
904
1131
  * a question with no options could never reach `settle`). Otherwise, in a
905
- * multi-question layout the cursor hops to the next unanswered question's
906
- * first row to cut confirmation cost.
1132
+ * multi-question layout the tab focus hops to the next unanswered
1133
+ * question (or onto the Confirm row) to cut confirmation cost.
907
1134
  */
908
1135
  function commitCustomAnswer(s, qi) {
909
1136
  if (s.customEditingFor !== null)
@@ -916,10 +1143,7 @@ export function openAskUserPanel(deps, questions, signal) {
916
1143
  return;
917
1144
  }
918
1145
  if (s.questions.length >= 2) {
919
- const rows = buildRowList(s.questions, s.perQuestion);
920
- const target = nextUnansweredRow(rows, s.perQuestion, qi);
921
- if (target >= 0)
922
- Object.assign(s, { cursorIndex: target, cancelHint: false, attentionHint: null });
1146
+ Object.assign(s, advanceAfterAnswer(s, qi));
923
1147
  }
924
1148
  }
925
1149
  });
@@ -943,7 +1167,7 @@ export function isDuplicateProviderError(error) {
943
1167
  * Wires the provider into `ctx.userQuestions`. Call from inside `ctx.effect`.
944
1168
  *
945
1169
  * Failure semantics are deliberate (review round BM):
946
- * - missing service → warn + no-op disposer. The tool stays mounted by the
1170
+ * - missing service → one notice + no-op disposer. The tool stays mounted by the
947
1171
  * bundle patch; without a provider its calls fail with the upstream
948
1172
  * NO_PROVIDER error, which is better than crashing the whole TUI plugin.
949
1173
  * - DUPLICATE_PROVIDER → silent no-op disposer (a prior UI owns the slot).
@@ -953,7 +1177,10 @@ export function isDuplicateProviderError(error) {
953
1177
  export function registerAskUserProvider(ctx, deps) {
954
1178
  const userQuestions = ctx.userQuestions;
955
1179
  if (userQuestions === undefined || typeof userQuestions.registerProvider !== 'function') {
956
- console.warn('[dsh-tui-pi] ctx.userQuestions not mounted — ask_user_question calls will fail with NO_PROVIDER');
1180
+ // Through the shared notice bridge (src/notice-bridge.ts), never raw
1181
+ // stderr: the TUI owns the terminal, and this registers during
1182
+ // startup — possibly before the first frame.
1183
+ emitNotice('ctx.userQuestions not mounted — ask_user_question calls will fail with NO_PROVIDER');
957
1184
  return () => { };
958
1185
  }
959
1186
  try {