@aiwayds/dsh-tui-pi 2.18.2 → 2.19.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
@@ -140,6 +140,36 @@ export const ASK_COLLAPSE_KEY = 'ctrl+t';
140
140
  export const DECLINE_MESSAGE = 'User declined to answer questions.';
141
141
  /** Transient hint when Enter lands on an incomplete confirm/submit row. */
142
142
  export const INCOMPLETE_HINT = 'Answer every question first';
143
+ // ------------------------------------------------------------- timeouts --
144
+ /** Default no-input window per focused question, in minutes: after this long
145
+ * without a single keypress the question is auto-answered (recommended
146
+ * option, plan-safe) and the panel moves on. */
147
+ export const ASK_USER_IDLE_MINUTES_DEFAULT = 5;
148
+ /** Default hard cap per focused question, in minutes: after this long the
149
+ * question is auto-answered EVEN IF the user keeps interacting — the panel
150
+ * must never hold a run hostage indefinitely (dual rule with the idle
151
+ * window; either firing resolves the question). */
152
+ export const ASK_USER_ABSOLUTE_MINUTES_DEFAULT = 10;
153
+ /** Env override for the no-input window (minutes; <= 0 disables the rule). */
154
+ export const ASK_USER_IDLE_ENV = 'DSH_TUI_ASK_USER_IDLE_MINUTES';
155
+ /** Env override for the hard cap (minutes; <= 0 disables the rule). */
156
+ export const ASK_USER_ABSOLUTE_ENV = 'DSH_TUI_ASK_USER_ABSOLUTE_MINUTES';
157
+ const MINUTE_MS = 60_000;
158
+ /** Note folded into the answer envelope's `custom` field when a question was
159
+ * auto-answered with the recommended option — the model reads the answer as
160
+ * a tool result, so an automatic pick is declared in-band (same spirit as
161
+ * DECLINE_MESSAGE), never silently passed off as a human choice. */
162
+ export const TIMEOUT_RECOMMENDED_NOTE = 'Auto-answered after the no-input timeout: the recommended option was picked.';
163
+ /** Note for a plan-review question auto-answered by timeout: plans are NEVER
164
+ * auto-approved — the first non-approve option is picked instead. */
165
+ export const TIMEOUT_PLAN_DECLINED_NOTE = 'Auto-answered after the no-input timeout: no user input, so the plan was NOT approved.';
166
+ /** Note for a question with no options at all — there is no recommended value
167
+ * to fall back to, so the answer carries the explanation only. */
168
+ export const TIMEOUT_NO_DEFAULT_NOTE = 'Auto-answered after the no-input timeout: the question offers no default option to pick.';
169
+ /** Note appended when the timeout commits a half-typed sentinel buffer: the
170
+ * text is the user's own, but it never went through Enter, so the envelope
171
+ * says so. */
172
+ export const TIMEOUT_CUSTOM_NOTE = 'Auto-committed after the no-input timeout: the text typed so far.';
143
173
  /** Mark left of a question a custom input wrote text into. */
144
174
  const CUSTOM_MARK = '✎ ';
145
175
  /**
@@ -456,6 +486,142 @@ export function canAutoSubmit(state) {
456
486
  return false;
457
487
  return allQuestionsAnswered(state);
458
488
  }
489
+ /** Both rules off — the panel waits for the human exactly as before. */
490
+ export const ASK_USER_TIMEOUTS_DISABLED = { idleMs: 0, absoluteMs: 0 };
491
+ /** Minutes → ms; negatives clamp to 0 (a non-positive knob is the
492
+ * documented per-rule off switch, mirroring retention's maxCount). */
493
+ function minutesToMs(minutes) {
494
+ return Math.max(0, minutes) * MINUTE_MS;
495
+ }
496
+ /**
497
+ * Narrow one explicit settings field: a finite number, or undefined
498
+ * (absent, or present-but-invalid). An invalid value emits one notice
499
+ * through the shared bridge naming the field and its raw value — same
500
+ * contract as retention's `explicitSetting` — and the caller falls to the
501
+ * next precedence level. Every finite value is accepted here: the <= 0
502
+ * case is the documented disable switch, not garbage.
503
+ */
504
+ function explicitMinutes(section, key) {
505
+ const raw = section?.[key];
506
+ if (raw === undefined)
507
+ return undefined;
508
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
509
+ emitNotice(`settings dsh-tui.askUser.${key}: invalid value `
510
+ + `${JSON.stringify(raw)} — falling back to environment/default`);
511
+ return undefined;
512
+ }
513
+ return raw;
514
+ }
515
+ /** One env slot: a finite number, or undefined when absent/garbage — env
516
+ * garbage falls to the default silently (retention's finiteEnv contract). */
517
+ function finiteEnvMinutes(raw) {
518
+ if (raw === undefined || raw.trim() === '')
519
+ return undefined;
520
+ const value = Number(raw);
521
+ return Number.isFinite(value) ? value : undefined;
522
+ }
523
+ /**
524
+ * Resolve the ask-user timeout knobs through the repo's standard precedence
525
+ * chain — explicit settings.yaml values (`dsh-tui.askUser.*`) outrank the
526
+ * `DSH_TUI_ASK_USER_*` environment variables, which outrank the defaults
527
+ * (5 min idle / 10 min absolute). Invalid settings values emit one notice
528
+ * each (notice bridge) and fall to the next level; invalid env values fall
529
+ * back silently. Pure; `process.env` and the settings section are passed
530
+ * explicitly so tests can pin them.
531
+ */
532
+ export function resolveAskUserTimeouts(settings, env = process.env) {
533
+ const idle = explicitMinutes(settings, 'idleMinutes') ?? finiteEnvMinutes(env[ASK_USER_IDLE_ENV]) ?? ASK_USER_IDLE_MINUTES_DEFAULT;
534
+ const absolute = explicitMinutes(settings, 'absoluteMinutes') ?? finiteEnvMinutes(env[ASK_USER_ABSOLUTE_ENV]) ?? ASK_USER_ABSOLUTE_MINUTES_DEFAULT;
535
+ return {
536
+ idleMs: minutesToMs(idle),
537
+ absoluteMs: minutesToMs(absolute),
538
+ };
539
+ }
540
+ /**
541
+ * Earliest deadline at which the panel's timeout should fire, or null when
542
+ * no rule is armed. The idle deadline runs from `lastInputAt` (any
543
+ * keypress); the absolute deadline runs from `focusEnteredAt` (focus entry
544
+ * into the current question) and applies in BOTH phases — a review page
545
+ * entered late inherits its question's remaining budget, so the total wait
546
+ * per question stays bounded. Pure; the panel arms a timer on this.
547
+ */
548
+ export function nextTimeoutDeadline(timeouts, now, focusEnteredAt, lastInputAt) {
549
+ if (timeouts.idleMs <= 0 && timeouts.absoluteMs <= 0)
550
+ return null;
551
+ let deadline = Number.POSITIVE_INFINITY;
552
+ if (timeouts.idleMs > 0)
553
+ deadline = lastInputAt + timeouts.idleMs;
554
+ if (timeouts.absoluteMs > 0)
555
+ deadline = Math.min(deadline, focusEnteredAt + timeouts.absoluteMs);
556
+ return Number.isFinite(deadline) ? deadline : null;
557
+ }
558
+ /** Whether a per-question pending answer counts as answered. */
559
+ function isAnswered(answer) {
560
+ return answer !== undefined
561
+ && (answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== ''));
562
+ }
563
+ /**
564
+ * The timeout auto-answer for ONE unanswered question — what the panel
565
+ * writes into `perQuestion` when a timer fires on it. Priority:
566
+ *
567
+ * 1. a non-empty live sentinel buffer is committed as the custom answer
568
+ * (the panel's own ↑↓ arrow-exit semantics commit non-empty buffers, so
569
+ * typed-but-uncommitted text is honored, not discarded);
570
+ * 2. a `plan-review` question NEVER auto-approves — the first option that
571
+ * is not the intent's `approve` label is picked (decline by omission);
572
+ * 3. otherwise the FIRST option — the dsh tool contract declares the
573
+ * recommended choice by putting it first ("put it first and append
574
+ * '(Recommended)' to that label").
575
+ *
576
+ * The companion envelope note is returned alongside so the panel can fold
577
+ * it into the answer at settle time; the note never enters panel state
578
+ * (it would leak into the sentinel row / review rendering).
579
+ */
580
+ export function timeoutAnswerFor(question, liveBuffer) {
581
+ const buffer = (liveBuffer ?? '').trim();
582
+ if (buffer !== '')
583
+ return { answer: { selected: [], custom: buffer }, note: TIMEOUT_CUSTOM_NOTE };
584
+ const intent = question.intent;
585
+ if (intent?.kind === 'plan-review') {
586
+ const declineOption = (question.options ?? []).find(option => option.label !== intent.approve);
587
+ if (declineOption !== undefined)
588
+ return { answer: { selected: [declineOption.label] }, note: TIMEOUT_PLAN_DECLINED_NOTE };
589
+ return { answer: { selected: [] }, note: TIMEOUT_PLAN_DECLINED_NOTE };
590
+ }
591
+ const first = question.options?.[0];
592
+ if (first !== undefined)
593
+ return { answer: { selected: [first.label] }, note: TIMEOUT_RECOMMENDED_NOTE };
594
+ return { answer: { selected: [] }, note: TIMEOUT_NO_DEFAULT_NOTE };
595
+ }
596
+ /**
597
+ * `buildAnswerEnvelope` with the timeout notes folded in: `notes` maps a
598
+ * question index to the note appended to that answer item's `custom` field
599
+ * (created when absent, concatenated when the answer already carries custom
600
+ * text — the buffer-commit case). The model reads the envelope as the tool
601
+ * result, so every automatic pick is declared in-band. Omitting `notes`
602
+ * reproduces `buildAnswerEnvelope` exactly.
603
+ */
604
+ export function buildAnswerEnvelopeWithNotes(state, notes) {
605
+ const envelope = buildAnswerEnvelope(state);
606
+ if (notes === undefined || notes.size === 0)
607
+ return envelope;
608
+ envelope.answers.forEach((item, i) => {
609
+ const note = notes.get(i);
610
+ if (note === undefined)
611
+ return;
612
+ item.custom = item.custom !== undefined && item.custom !== '' ? `${item.custom} (${note})` : note;
613
+ });
614
+ return envelope;
615
+ }
616
+ /**
617
+ * The idle countdown for the panel footer: `m:ss` until the next deadline,
618
+ * floored at 0:00. Pure so tests pin it without a clock.
619
+ */
620
+ export function formatCountdown(msRemaining) {
621
+ const totalSeconds = Math.max(0, Math.ceil(msRemaining / 1000));
622
+ const minutes = Math.floor(totalSeconds / 60);
623
+ return `${minutes}:${String(totalSeconds % 60).padStart(2, '0')}`;
624
+ }
459
625
  /**
460
626
  * Row index to land on after a single-select answer on question `answeredQi`
461
627
  * (option toggle or committed custom text): advance the tab focus to the
@@ -611,7 +777,7 @@ function renderTabStrip(fns, wrap, state) {
611
777
  return fns.muted(clipToWidth(parts.join(' · '), wrap));
612
778
  }
613
779
  /** Render the questions pane (one focused question tab) as a flat table line list behind a scroll window. */
614
- export function renderQuestionsView(theme, state, width, maxVisible = ASK_USER_MAX_VISIBLE) {
780
+ export function renderQuestionsView(theme, state, width, maxVisible = ASK_USER_MAX_VISIBLE, countdown) {
615
781
  const fns = panelThemeFns(theme);
616
782
  const wrap = Math.max(2, width - 2);
617
783
  const title = state.questions.length === 1
@@ -624,7 +790,7 @@ export function renderQuestionsView(theme, state, width, maxVisible = ASK_USER_M
624
790
  const rows = buildRowList(state.questions, state.perQuestion, state.focusQuestion);
625
791
  if (rows.length === 0) {
626
792
  lines.push(fns.muted(clipToWidth('(no questions)', wrap)));
627
- return finalizeQuestionsView(fns, wrap, lines, state);
793
+ return finalizeQuestionsView(fns, wrap, lines, state, '', countdown);
628
794
  }
629
795
  const columns = QUESTIONS_COLUMNS();
630
796
  const widths = columnWidths(wrap - MARKER_W, columns);
@@ -748,9 +914,9 @@ export function renderQuestionsView(theme, state, width, maxVisible = ASK_USER_M
748
914
  lines.push(...body.slice(offset, offset + visible));
749
915
  const overflow = body.length > visible;
750
916
  lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┴'), wrap)));
751
- return finalizeQuestionsView(fns, wrap, lines, state, overflow ? ` (${cursorRank}/${selectableTotal})` : '');
917
+ return finalizeQuestionsView(fns, wrap, lines, state, overflow ? ` (${cursorRank}/${selectableTotal})` : '', countdown);
752
918
  }
753
- function finalizeQuestionsView(fns, wrap, lines, state, scrollInfo = '') {
919
+ function finalizeQuestionsView(fns, wrap, lines, state, scrollInfo = '', countdown) {
754
920
  if (state.attentionHint !== null) {
755
921
  lines.push(fns.attention(clipToWidth(state.attentionHint, wrap)));
756
922
  }
@@ -767,13 +933,14 @@ function finalizeQuestionsView(fns, wrap, lines, state, scrollInfo = '') {
767
933
  ? 'Type free text · Enter keep · ↑↓ move · Ctrl+T fold · Esc abandon'
768
934
  : `${multiTab ? '←→ tabs · ' : ''}↑↓ move · Enter ${needsConfirmRow(state.questions) ? 'toggle' : 'select'} · 1-9 pick · Ctrl+T fold · Esc decline`;
769
935
  // The (n/m) readout goes FIRST so narrow terminals clip the hint, never
770
- // the scroll info.
771
- lines.push(fns.subtle(clipToWidth(scrollInfo + footer, wrap)));
936
+ // the scroll info. The auto-answer countdown trails — clipped first on a
937
+ // narrow terminal, and it re-renders every second with the footer clock.
938
+ lines.push(fns.subtle(clipToWidth(scrollInfo + footer + (countdown !== undefined ? ` · ${countdown}` : ''), wrap)));
772
939
  return lines;
773
940
  }
774
941
  // -------------------------------------------------- render: review phase --
775
942
  /** Render the review page for multi-question overlays (scroll-windowed). */
776
- export function renderReviewView(theme, state, width, maxVisible = ASK_USER_MAX_VISIBLE) {
943
+ export function renderReviewView(theme, state, width, maxVisible = ASK_USER_MAX_VISIBLE, countdown) {
777
944
  const fns = panelThemeFns(theme);
778
945
  const wrap = Math.max(2, width - 2);
779
946
  const lines = [fns.accent(BOLD + clipToWidth('● Review answers', wrap) + RESET)];
@@ -824,7 +991,7 @@ export function renderReviewView(theme, state, width, maxVisible = ASK_USER_MAX_
824
991
  lines.push('');
825
992
  const footer = '↑↓ select · Enter return to edit / submit · Ctrl+T fold';
826
993
  const scrollInfo = body.length > visible ? ` (${state.reviewIndex + 1}/${body.length})` : '';
827
- lines.push(fns.subtle(clipToWidth(scrollInfo + footer, wrap)));
994
+ lines.push(fns.subtle(clipToWidth(scrollInfo + footer + (countdown !== undefined ? ` · ${countdown}` : ''), wrap)));
828
995
  return lines;
829
996
  }
830
997
  function formatAnswerForReview(answer) {
@@ -1016,8 +1183,17 @@ export function consumeRightClickPaste(data, tui, clipboardImpl) {
1016
1183
  * service already screens entry-time aborts, and a step aborted after this
1017
1184
  * point discards the tool result anyway — resolving keeps the pending promise
1018
1185
  * from ever hanging either way.
1186
+ *
1187
+ * `timeouts` arms the auto-answer rules (see {@link AskUserTimeouts}): the
1188
+ * FOCUSED question's idle window and hard cap. A firing timer auto-answers
1189
+ * the focused question (recommended option, plan-safe —
1190
+ * {@link timeoutAnswerFor}), hops to the next unanswered question with fresh
1191
+ * budgets, and — once nothing is left unanswered — settles the envelope
1192
+ * directly (the review page exists for a human double-check an absent human
1193
+ * cannot do). Defaults to disabled: callers opt in through the provider's
1194
+ * `resolveTimeouts`.
1019
1195
  */
1020
- export function openAskUserPanel(deps, questions, signal) {
1196
+ export function openAskUserPanel(deps, questions, signal, timeouts = ASK_USER_TIMEOUTS_DISABLED) {
1021
1197
  if (signal?.aborted) {
1022
1198
  return Promise.resolve(buildDeclinedEnvelope(questions));
1023
1199
  }
@@ -1025,6 +1201,90 @@ export function openAskUserPanel(deps, questions, signal) {
1025
1201
  const clock = deps.now ?? Date.now;
1026
1202
  const state = initialState(questions);
1027
1203
  let settled = false;
1204
+ // ---- timeout bookkeeping (per focused question) ----
1205
+ // focusEnteredAt is re-stamped whenever the focused question changes
1206
+ // (open, tab switch, auto/manual advance, review jump-back); lastInputAt
1207
+ // on every keypress. The two deadlines (idle from lastInputAt, absolute
1208
+ // from focusEnteredAt) arm ONE timer for the earlier of the two.
1209
+ // The clock is NEVER read while the rules are disabled — tests inject a
1210
+ // queue clock whose exact consumption order the double-Esc timings
1211
+ // depend on, so every read here is gated behind `timeoutEnabled` and
1212
+ // both stamps initialize on the first arm().
1213
+ const timeoutEnabled = timeouts.idleMs > 0 || timeouts.absoluteMs > 0;
1214
+ let focusEnteredAt = 0;
1215
+ let lastInputAt = 0;
1216
+ let stampedFocus = -1;
1217
+ let stampedInput = false;
1218
+ let timer = null;
1219
+ const timeoutNotes = new Map();
1220
+ const disarm = () => {
1221
+ if (timer !== null) {
1222
+ clearTimeout(timer);
1223
+ timer = null;
1224
+ }
1225
+ };
1226
+ /** (Re)arm the timeout timer; cheap + idempotent, call after every
1227
+ * keypress and every focus/phase mutation. */
1228
+ const arm = () => {
1229
+ if (!timeoutEnabled || settled)
1230
+ return;
1231
+ if (state.focusQuestion !== stampedFocus) {
1232
+ stampedFocus = state.focusQuestion;
1233
+ focusEnteredAt = clock();
1234
+ }
1235
+ if (!stampedInput) {
1236
+ stampedInput = true;
1237
+ lastInputAt = clock();
1238
+ }
1239
+ disarm();
1240
+ const deadline = nextTimeoutDeadline(timeouts, clock(), focusEnteredAt, lastInputAt);
1241
+ if (deadline === null)
1242
+ return;
1243
+ timer = setTimeout(() => {
1244
+ timer = null;
1245
+ onTimeoutFire();
1246
+ }, Math.max(0, deadline - clock()));
1247
+ timer.unref?.();
1248
+ };
1249
+ /** A deadline fired on the focused question: auto-answer it if still
1250
+ * unanswered, then settle (everything answered) or hop to the next
1251
+ * unanswered question with fresh budgets. */
1252
+ const onTimeoutFire = () => {
1253
+ if (settled)
1254
+ return;
1255
+ if (state.phase === 'review') {
1256
+ // The review page only opens with every question answered, so this
1257
+ // submits the answers already given — nothing is picked for the user.
1258
+ settle(buildAnswerEnvelopeWithNotes(state, timeoutNotes));
1259
+ emitNotice('ask_user_question: timed out — submitted the answers already given');
1260
+ close();
1261
+ return;
1262
+ }
1263
+ const qi = state.focusQuestion;
1264
+ const question = state.questions[qi];
1265
+ if (question !== undefined && !isAnswered(state.perQuestion[qi])) {
1266
+ const { answer, note } = timeoutAnswerFor(question, state.customInputs[qi]);
1267
+ if (note !== undefined)
1268
+ timeoutNotes.set(qi, note);
1269
+ if (answer.custom !== undefined) {
1270
+ Object.assign(state, setCustomAnswer(state, qi, answer.custom));
1271
+ }
1272
+ else {
1273
+ const perQuestion = state.perQuestion.slice();
1274
+ perQuestion[qi] = answer;
1275
+ Object.assign(state, { ...state, perQuestion });
1276
+ }
1277
+ }
1278
+ if (allQuestionsAnswered(state)) {
1279
+ settle(buildAnswerEnvelopeWithNotes(state, timeoutNotes));
1280
+ emitNotice('ask_user_question: timed out — unanswered questions took the recommended options (noted in the answers)');
1281
+ close();
1282
+ return;
1283
+ }
1284
+ Object.assign(state, advanceAfterAnswer(state, qi));
1285
+ lastInputAt = clock();
1286
+ arm();
1287
+ };
1028
1288
  /**
1029
1289
  * Terminal close: unmount the dock panel, clear the modal flag (BEFORE
1030
1290
  * restoreFocus so refocusEditor's guard sees the modal gone), and hand
@@ -1032,6 +1292,7 @@ export function openAskUserPanel(deps, questions, signal) {
1032
1292
  * dismissed then (see below), so focus lands on the current editor.
1033
1293
  */
1034
1294
  const close = () => {
1295
+ disarm();
1035
1296
  livePanels.delete(panelEntry);
1036
1297
  unmount();
1037
1298
  deps.setModalActive(false);
@@ -1072,9 +1333,18 @@ export function openAskUserPanel(deps, questions, signal) {
1072
1333
  // panel claims one extra interior line. Fake TUIs without a
1073
1334
  // `terminal` degrade to ASK_USER_MAX_VISIBLE.
1074
1335
  const maxVisible = askUserMaxVisibleForRows(deps.tui.terminal?.rows, state.phase === 'questions' && state.questions.length >= 2 ? 1 : 0);
1336
+ // Auto-answer countdown for the focused question's next deadline —
1337
+ // recomputed per render; the footer clock's 1s requestRender keeps it
1338
+ // fresh without a timer of its own.
1339
+ const countdown = timeoutEnabled && !settled
1340
+ ? (() => {
1341
+ const deadline = nextTimeoutDeadline(timeouts, clock(), focusEnteredAt, lastInputAt);
1342
+ return deadline === null ? undefined : `auto in ${formatCountdown(deadline - clock())}`;
1343
+ })()
1344
+ : undefined;
1075
1345
  const inner = state.phase === 'review'
1076
- ? renderReviewView(theme, state, innerWidth, maxVisible)
1077
- : renderQuestionsView(theme, state, innerWidth, maxVisible);
1346
+ ? renderReviewView(theme, state, innerWidth, maxVisible, countdown)
1347
+ : renderQuestionsView(theme, state, innerWidth, maxVisible, countdown);
1078
1348
  return [
1079
1349
  panelTopBorder(boxWidth, borderFg),
1080
1350
  ...inner.map(line => borderedRow(boxWidth, borderFg, line)),
@@ -1084,6 +1354,13 @@ export function openAskUserPanel(deps, questions, signal) {
1084
1354
  handleInput(data) {
1085
1355
  if (settled)
1086
1356
  return;
1357
+ // Every keypress is presence: restart the focused question's idle
1358
+ // window, then re-arm below at each focus-changing branch (arm is
1359
+ // idempotent and re-stamps focusEnteredAt when the focus moved).
1360
+ if (timeoutEnabled) {
1361
+ lastInputAt = clock();
1362
+ arm();
1363
+ }
1087
1364
  const kb = getKeybindings();
1088
1365
  // The fold toggle outranks everything — including the sentinel edit:
1089
1366
  // folding commits the buffer first (the ↑↓ arrow-exit semantics), so
@@ -1109,10 +1386,12 @@ export function openAskUserPanel(deps, questions, signal) {
1109
1386
  if (state.phase === 'questions' && state.questions.length >= 2) {
1110
1387
  if (matchesKey(data, 'right') || matchesKey(data, 'tab')) {
1111
1388
  Object.assign(state, switchFocus(state, 1));
1389
+ arm();
1112
1390
  return;
1113
1391
  }
1114
1392
  if (matchesKey(data, 'left') || matchesKey(data, 'shift+tab')) {
1115
1393
  Object.assign(state, switchFocus(state, -1));
1394
+ arm();
1116
1395
  return;
1117
1396
  }
1118
1397
  }
@@ -1151,6 +1430,9 @@ export function openAskUserPanel(deps, questions, signal) {
1151
1430
  deps.tui.setFocus(panel);
1152
1431
  deps.setModalActive(true);
1153
1432
  signal?.addEventListener('abort', onAbort, { once: true });
1433
+ // Arm the timeout rules (no-op when disabled): the focused question's
1434
+ // idle window + hard cap start at mount.
1435
+ arm();
1154
1436
  // Register the live panel so the right-click → paste hook (installed
1155
1437
  // at the TUI level in tui.ts) can find an editing panel to feed. The
1156
1438
  // entry is removed in `close()` above.
@@ -1218,13 +1500,14 @@ export function openAskUserPanel(deps, questions, signal) {
1218
1500
  cancelHint: false,
1219
1501
  attentionHint: null,
1220
1502
  });
1503
+ arm();
1221
1504
  return;
1222
1505
  }
1223
1506
  if (!allQuestionsAnswered(s)) {
1224
1507
  Object.assign(s, { cancelHint: false, attentionHint: INCOMPLETE_HINT });
1225
1508
  return;
1226
1509
  }
1227
- settle(buildAnswerEnvelope(s));
1510
+ settle(buildAnswerEnvelopeWithNotes(s, timeoutNotes));
1228
1511
  close();
1229
1512
  return;
1230
1513
  }
@@ -1243,7 +1526,7 @@ export function openAskUserPanel(deps, questions, signal) {
1243
1526
  // submits immediately. multiSelect never auto-submits — the user may
1244
1527
  // want more toggles, so it routes through the Confirm row instead.
1245
1528
  if (canAutoSubmit(next)) {
1246
- settle(buildAnswerEnvelope(s));
1529
+ settle(buildAnswerEnvelopeWithNotes(s, timeoutNotes));
1247
1530
  close();
1248
1531
  }
1249
1532
  else {
@@ -1257,6 +1540,7 @@ export function openAskUserPanel(deps, questions, signal) {
1257
1540
  && (answer.selected.length > 0 || (answer.custom ?? '').trim() !== '');
1258
1541
  if (answered && s.questions[row.questionIndex]?.multiSelect !== true) {
1259
1542
  Object.assign(s, advanceAfterAnswer(s, row.questionIndex));
1543
+ arm();
1260
1544
  }
1261
1545
  }
1262
1546
  return;
@@ -1350,12 +1634,13 @@ export function openAskUserPanel(deps, questions, signal) {
1350
1634
  if (s.perQuestion[qi]?.custom === undefined)
1351
1635
  return;
1352
1636
  if (canAutoSubmit(s)) {
1353
- settle(buildAnswerEnvelope(s));
1637
+ settle(buildAnswerEnvelopeWithNotes(s, timeoutNotes));
1354
1638
  close();
1355
1639
  return;
1356
1640
  }
1357
1641
  if (s.questions.length >= 2) {
1358
1642
  Object.assign(s, advanceAfterAnswer(s, qi));
1643
+ arm();
1359
1644
  }
1360
1645
  }
1361
1646
  });
@@ -1400,7 +1685,13 @@ export function registerAskUserProvider(ctx, deps) {
1400
1685
  const controller = new AbortController();
1401
1686
  controllers.set(request, controller);
1402
1687
  request.signal?.addEventListener('abort', () => controller.abort(), { once: true });
1403
- return openAskUserPanel(deps, request.questions, controller.signal);
1688
+ const open = (timeouts) => openAskUserPanel(deps, request.questions, controller.signal, timeouts);
1689
+ if (deps.resolveTimeouts === undefined)
1690
+ return open(ASK_USER_TIMEOUTS_DISABLED);
1691
+ // Resolved fresh per ask so a committed settings change applies to
1692
+ // the NEXT question without a reload; a resolution failure degrades
1693
+ // to the disabled panel rather than failing the ask.
1694
+ return deps.resolveTimeouts().then(open, () => open(ASK_USER_TIMEOUTS_DISABLED));
1404
1695
  },
1405
1696
  settled: request => {
1406
1697
  // Another surface answered first — close the panel through the
@@ -1432,7 +1723,10 @@ export function registerAskUserProvider(ctx, deps) {
1432
1723
  if (asking !== undefined && String(asking) !== mine)
1433
1724
  return next();
1434
1725
  }
1435
- return openAskUserPanel(deps, request.questions, request.signal);
1726
+ const open = (timeouts) => openAskUserPanel(deps, request.questions, request.signal, timeouts);
1727
+ if (deps.resolveTimeouts === undefined)
1728
+ return open(ASK_USER_TIMEOUTS_DISABLED);
1729
+ return deps.resolveTimeouts().then(open, () => open(ASK_USER_TIMEOUTS_DISABLED));
1436
1730
  });
1437
1731
  }
1438
1732
  //# sourceMappingURL=ask-user.js.map