@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/README.md +12 -9
- package/lib/ask-user.d.ts +86 -17
- package/lib/ask-user.js +290 -63
- package/lib/ask-user.js.map +1 -1
- package/lib/index.js +23 -3
- package/lib/index.js.map +1 -1
- package/lib/notice-bridge.d.ts +66 -0
- package/lib/notice-bridge.js +105 -0
- package/lib/notice-bridge.js.map +1 -0
- package/lib/retention.d.ts +12 -7
- package/lib/retention.js +29 -16
- package/lib/retention.js.map +1 -1
- package/lib/sessions.d.ts +4 -3
- package/lib/sessions.js +10 -8
- package/lib/sessions.js.map +1 -1
- package/lib/theme-settings.js +7 -2
- package/lib/theme-settings.js.map +1 -1
- package/lib/tui.d.ts +12 -0
- package/lib/tui.js +72 -0
- package/lib/tui.js.map +1 -1
- package/package.json +1 -1
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
|
|
12
|
-
* divider between
|
|
13
|
-
* so the cursor row is always visible even when the dock is
|
|
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:
|
|
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
|
|
153
|
+
const perQuestion = questions.map(() => ({ selected: [] }));
|
|
154
|
+
const rows = buildRowList(questions, perQuestion, 0);
|
|
132
155
|
return {
|
|
133
156
|
questions,
|
|
134
|
-
perQuestion
|
|
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
|
-
/**
|
|
257
|
-
|
|
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
|
-
|
|
289
|
+
const question = questions[focusQuestion];
|
|
290
|
+
if (question !== undefined) {
|
|
260
291
|
rows.push({
|
|
261
292
|
kind: 'question-header',
|
|
262
|
-
questionIndex:
|
|
263
|
-
label: question.header ?? `Question ${
|
|
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:
|
|
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[
|
|
310
|
+
const customAnswer = perQuestion[focusQuestion]?.custom ?? '';
|
|
280
311
|
rows.push({
|
|
281
312
|
kind: 'sentinel',
|
|
282
|
-
questionIndex:
|
|
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
|
|
396
|
-
*
|
|
397
|
-
*
|
|
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
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
586
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
906
|
-
*
|
|
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
|
-
|
|
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 →
|
|
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
|
-
|
|
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 {
|