@aiwayds/dsh-tui-pi 2.18.2 → 2.20.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.
Files changed (106) hide show
  1. package/README.md +12 -43
  2. package/README.zh-CN.md +11 -42
  3. package/lib/agents.js +67 -52
  4. package/lib/agents.js.map +1 -1
  5. package/lib/ask-user.d.ts +122 -3
  6. package/lib/ask-user.js +335 -41
  7. package/lib/ask-user.js.map +1 -1
  8. package/lib/attachments.js +8 -3
  9. package/lib/attachments.js.map +1 -1
  10. package/lib/btw.d.ts +4 -2
  11. package/lib/btw.js +22 -16
  12. package/lib/btw.js.map +1 -1
  13. package/lib/custom-provider.js +34 -28
  14. package/lib/custom-provider.js.map +1 -1
  15. package/lib/footer.d.ts +9 -8
  16. package/lib/footer.js +36 -18
  17. package/lib/footer.js.map +1 -1
  18. package/lib/hard-exit.d.ts +36 -0
  19. package/lib/hard-exit.js +64 -0
  20. package/lib/hard-exit.js.map +1 -0
  21. package/lib/history-fork.d.ts +2 -2
  22. package/lib/history-fork.js +12 -9
  23. package/lib/history-fork.js.map +1 -1
  24. package/lib/history-turns.d.ts +4 -2
  25. package/lib/history-turns.js +7 -3
  26. package/lib/history-turns.js.map +1 -1
  27. package/lib/history.js +57 -45
  28. package/lib/history.js.map +1 -1
  29. package/lib/hotkeys.js +44 -33
  30. package/lib/hotkeys.js.map +1 -1
  31. package/lib/i18n/index.d.ts +54 -0
  32. package/lib/i18n/index.js +98 -0
  33. package/lib/i18n/index.js.map +1 -0
  34. package/lib/i18n/registry.d.ts +65 -0
  35. package/lib/i18n/registry.js +129 -0
  36. package/lib/i18n/registry.js.map +1 -0
  37. package/lib/index.js +219 -109
  38. package/lib/index.js.map +1 -1
  39. package/lib/language-ask.d.ts +98 -0
  40. package/lib/language-ask.js +118 -0
  41. package/lib/language-ask.js.map +1 -0
  42. package/lib/live-widgets.js +9 -8
  43. package/lib/live-widgets.js.map +1 -1
  44. package/lib/login.js +5 -4
  45. package/lib/login.js.map +1 -1
  46. package/lib/messages.d.ts +0 -4
  47. package/lib/messages.js +14 -15
  48. package/lib/messages.js.map +1 -1
  49. package/lib/model-profiles.js +32 -20
  50. package/lib/model-profiles.js.map +1 -1
  51. package/lib/panels.js +18 -17
  52. package/lib/panels.js.map +1 -1
  53. package/lib/preflight-projcache.js +9 -3
  54. package/lib/preflight-projcache.js.map +1 -1
  55. package/lib/preset-dialog.d.ts +3 -3
  56. package/lib/preset-dialog.js +20 -17
  57. package/lib/preset-dialog.js.map +1 -1
  58. package/lib/provider-catalog.d.ts +23 -7
  59. package/lib/provider-catalog.js +90 -68
  60. package/lib/provider-catalog.js.map +1 -1
  61. package/lib/queue-panel.d.ts +2 -2
  62. package/lib/queue-panel.js +13 -9
  63. package/lib/queue-panel.js.map +1 -1
  64. package/lib/repair-dialog.d.ts +4 -4
  65. package/lib/repair-dialog.js +23 -16
  66. package/lib/repair-dialog.js.map +1 -1
  67. package/lib/route-dialog.d.ts +3 -3
  68. package/lib/route-dialog.js +20 -14
  69. package/lib/route-dialog.js.map +1 -1
  70. package/lib/selectors.d.ts +15 -1
  71. package/lib/selectors.js +57 -23
  72. package/lib/selectors.js.map +1 -1
  73. package/lib/sessions.d.ts +47 -14
  74. package/lib/sessions.js +162 -53
  75. package/lib/sessions.js.map +1 -1
  76. package/lib/settings.d.ts +68 -9
  77. package/lib/settings.js +327 -78
  78. package/lib/settings.js.map +1 -1
  79. package/lib/skills-manager.js +27 -21
  80. package/lib/skills-manager.js.map +1 -1
  81. package/lib/skills.js +5 -4
  82. package/lib/skills.js.map +1 -1
  83. package/lib/startup-info.js +9 -3
  84. package/lib/startup-info.js.map +1 -1
  85. package/lib/steer-flow.d.ts +14 -4
  86. package/lib/steer-flow.js +24 -9
  87. package/lib/steer-flow.js.map +1 -1
  88. package/lib/stop-dialog.d.ts +2 -2
  89. package/lib/stop-dialog.js +16 -12
  90. package/lib/stop-dialog.js.map +1 -1
  91. package/lib/subagent-viewer.d.ts +3 -8
  92. package/lib/subagent-viewer.js +38 -62
  93. package/lib/subagent-viewer.js.map +1 -1
  94. package/lib/theme-preview.js +12 -11
  95. package/lib/theme-preview.js.map +1 -1
  96. package/lib/theme-settings.d.ts +44 -2
  97. package/lib/theme-settings.js +97 -54
  98. package/lib/theme-settings.js.map +1 -1
  99. package/lib/tui.js +2 -1
  100. package/lib/tui.js.map +1 -1
  101. package/locales/en.json +646 -0
  102. package/locales/ja.json +646 -0
  103. package/locales/ko.json +646 -0
  104. package/locales/zh-CN.json +646 -0
  105. package/package.json +2 -1
  106. package/skills/dsh-tui-pi-config/SKILL.md +14 -4
package/lib/ask-user.js CHANGED
@@ -84,6 +84,7 @@ import { BOLD, RESET, ansiFg, } from "./theme/index.js";
84
84
  import { MARKER_W, panelThemeFns, TABLE_SEP, columnWidths, padCell, rowMarker, tableHeaderLine, tableRuleLine, } from "./panels.js";
85
85
  import { clipToWidth, wrapText } from "./text.js";
86
86
  import { emitNotice } from "./notice-bridge.js";
87
+ import { t } from "./i18n/index.js";
87
88
  // ----------------------------------------------------------------- constants --
88
89
  /** Maximum time between two Esc presses before the second one re-arms instead of firing. */
89
90
  export const DOUBLE_ESC_WINDOW_MS = 200;
@@ -140,6 +141,36 @@ export const ASK_COLLAPSE_KEY = 'ctrl+t';
140
141
  export const DECLINE_MESSAGE = 'User declined to answer questions.';
141
142
  /** Transient hint when Enter lands on an incomplete confirm/submit row. */
142
143
  export const INCOMPLETE_HINT = 'Answer every question first';
144
+ // ------------------------------------------------------------- timeouts --
145
+ /** Default no-input window per focused question, in minutes: after this long
146
+ * without a single keypress the question is auto-answered (recommended
147
+ * option, plan-safe) and the panel moves on. */
148
+ export const ASK_USER_IDLE_MINUTES_DEFAULT = 5;
149
+ /** Default hard cap per focused question, in minutes: after this long the
150
+ * question is auto-answered EVEN IF the user keeps interacting — the panel
151
+ * must never hold a run hostage indefinitely (dual rule with the idle
152
+ * window; either firing resolves the question). */
153
+ export const ASK_USER_ABSOLUTE_MINUTES_DEFAULT = 10;
154
+ /** Env override for the no-input window (minutes; <= 0 disables the rule). */
155
+ export const ASK_USER_IDLE_ENV = 'DSH_TUI_ASK_USER_IDLE_MINUTES';
156
+ /** Env override for the hard cap (minutes; <= 0 disables the rule). */
157
+ export const ASK_USER_ABSOLUTE_ENV = 'DSH_TUI_ASK_USER_ABSOLUTE_MINUTES';
158
+ const MINUTE_MS = 60_000;
159
+ /** Note folded into the answer envelope's `custom` field when a question was
160
+ * auto-answered with the recommended option — the model reads the answer as
161
+ * a tool result, so an automatic pick is declared in-band (same spirit as
162
+ * DECLINE_MESSAGE), never silently passed off as a human choice. */
163
+ export const TIMEOUT_RECOMMENDED_NOTE = 'Auto-answered after the no-input timeout: the recommended option was picked.';
164
+ /** Note for a plan-review question auto-answered by timeout: plans are NEVER
165
+ * auto-approved — the first non-approve option is picked instead. */
166
+ export const TIMEOUT_PLAN_DECLINED_NOTE = 'Auto-answered after the no-input timeout: no user input, so the plan was NOT approved.';
167
+ /** Note for a question with no options at all — there is no recommended value
168
+ * to fall back to, so the answer carries the explanation only. */
169
+ export const TIMEOUT_NO_DEFAULT_NOTE = 'Auto-answered after the no-input timeout: the question offers no default option to pick.';
170
+ /** Note appended when the timeout commits a half-typed sentinel buffer: the
171
+ * text is the user's own, but it never went through Enter, so the envelope
172
+ * says so. */
173
+ export const TIMEOUT_CUSTOM_NOTE = 'Auto-committed after the no-input timeout: the text typed so far.';
143
174
  /** Mark left of a question a custom input wrote text into. */
144
175
  const CUSTOM_MARK = '✎ ';
145
176
  /**
@@ -325,7 +356,7 @@ export function buildRowList(questions, perQuestion, focusQuestion = 0) {
325
356
  rows.push({
326
357
  kind: 'question-header',
327
358
  questionIndex: focusQuestion,
328
- label: question.header ?? `Question ${focusQuestion + 1}`,
359
+ label: question.header ?? t('ask.questionFallback', { n: focusQuestion + 1 }),
329
360
  description: question.question,
330
361
  ...(question.detail !== undefined ? { detail: question.detail } : {}),
331
362
  selectable: false,
@@ -345,12 +376,12 @@ export function buildRowList(questions, perQuestion, focusQuestion = 0) {
345
376
  rows.push({
346
377
  kind: 'sentinel',
347
378
  questionIndex: focusQuestion,
348
- label: customAnswer !== '' ? `${CUSTOM_MARK}${customAnswer.trim()}` : SENTINEL_LABEL,
379
+ label: customAnswer !== '' ? `${CUSTOM_MARK}${customAnswer.trim()}` : t('ask.sentinel'),
349
380
  selectable: true,
350
381
  });
351
382
  }
352
383
  if (needsConfirmRow(questions)) {
353
- rows.push({ kind: 'confirm', questionIndex: -1, label: '⏎ Confirm answers', selectable: true });
384
+ rows.push({ kind: 'confirm', questionIndex: -1, label: t('ask.confirmRow'), selectable: true });
354
385
  }
355
386
  return rows;
356
387
  }
@@ -456,6 +487,141 @@ export function canAutoSubmit(state) {
456
487
  return false;
457
488
  return allQuestionsAnswered(state);
458
489
  }
490
+ /** Both rules off — the panel waits for the human exactly as before. */
491
+ export const ASK_USER_TIMEOUTS_DISABLED = { idleMs: 0, absoluteMs: 0 };
492
+ /** Minutes → ms; negatives clamp to 0 (a non-positive knob is the
493
+ * documented per-rule off switch, mirroring retention's maxCount). */
494
+ function minutesToMs(minutes) {
495
+ return Math.max(0, minutes) * MINUTE_MS;
496
+ }
497
+ /**
498
+ * Narrow one explicit settings field: a finite number, or undefined
499
+ * (absent, or present-but-invalid). An invalid value emits one notice
500
+ * through the shared bridge naming the field and its raw value — same
501
+ * contract as retention's `explicitSetting` — and the caller falls to the
502
+ * next precedence level. Every finite value is accepted here: the <= 0
503
+ * case is the documented disable switch, not garbage.
504
+ */
505
+ function explicitMinutes(section, key) {
506
+ const raw = section?.[key];
507
+ if (raw === undefined)
508
+ return undefined;
509
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
510
+ emitNotice(t('ask.settings.invalid', { key, value: JSON.stringify(raw) }));
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
@@ -563,7 +729,7 @@ export function toggleCollapse(state) {
563
729
  // ------------------------------------------------- render: questions phase --
564
730
  /** Render width budget for the questions phase (single flex label column). */
565
731
  const QUESTIONS_COLUMNS = () => [
566
- { key: 'label', title: 'Selection', flex: true },
732
+ { key: 'label', title: t('ask.col.selection'), flex: true },
567
733
  ];
568
734
  /** Inline marker shown before a selected option's number (unselected: `○`). */
569
735
  const OPTION_SELECTED_MARK = '●';
@@ -611,20 +777,20 @@ 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
618
- ? '● Question'
619
- : `● Questions (${state.focusQuestion + 1}/${state.questions.length})`;
784
+ ? t('ask.title.single')
785
+ : t('ask.title.multi', { index: state.focusQuestion + 1, total: state.questions.length });
620
786
  const lines = [fns.accent(BOLD + clipToWidth(title, wrap) + RESET)];
621
787
  if (state.questions.length >= 2) {
622
788
  lines.push(renderTabStrip(fns, wrap, state));
623
789
  }
624
790
  const rows = buildRowList(state.questions, state.perQuestion, state.focusQuestion);
625
791
  if (rows.length === 0) {
626
- lines.push(fns.muted(clipToWidth('(no questions)', wrap)));
627
- return finalizeQuestionsView(fns, wrap, lines, state);
792
+ lines.push(fns.muted(clipToWidth(t('ask.empty'), wrap)));
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,14 +914,14 @@ 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
  }
757
923
  if (state.cancelHint) {
758
- lines.push(fns.attention(clipToWidth('Press Esc again to decline', wrap)));
924
+ lines.push(fns.attention(clipToWidth(t('ask.escHint'), wrap)));
759
925
  }
760
926
  lines.push('');
761
927
  const multiTab = state.questions.length >= 2;
@@ -764,27 +930,28 @@ function finalizeQuestionsView(fns, wrap, lines, state, scrollInfo = '') {
764
930
  // scroll readout rides FIRST so narrow terminals clip the tail hints,
765
931
  // never the position.
766
932
  const footer = state.customEditingFor !== null
767
- ? 'Type free text · Enter keep · ↑↓ move · Ctrl+T fold · Esc abandon'
768
- : `${multiTab ? '←→ tabs · ' : ''}↑↓ move · Enter ${needsConfirmRow(state.questions) ? 'toggle' : 'select'} · 1-9 pick · Ctrl+T fold · Esc decline`;
933
+ ? t('ask.footer.editing')
934
+ : `${multiTab ? t('ask.footer.tabs') : ''}${needsConfirmRow(state.questions) ? t('ask.footer.navToggle') : t('ask.footer.navSelect')}`;
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
- const lines = [fns.accent(BOLD + clipToWidth('● Review answers', wrap) + RESET)];
946
+ const lines = [fns.accent(BOLD + clipToWidth(t('ask.review.title'), wrap) + RESET)];
780
947
  // Question column with a content cap + answer column flexes to the right edge.
781
948
  // (columnWidths only assigns the remainder to ONE flex column; two flex
782
949
  // columns would both get the whole remainder and clip the second off.)
783
950
  const usable = wrap - MARKER_W;
784
951
  const leftCap = Math.max(20, Math.floor(usable * 0.45));
785
952
  const columns = [
786
- { key: 'question', title: 'Question', width: leftCap },
787
- { key: 'answer', title: 'Your answer', flex: true },
953
+ { key: 'question', title: t('ask.review.colQuestion'), width: leftCap },
954
+ { key: 'answer', title: t('ask.review.colAnswer'), flex: true },
788
955
  ];
789
956
  const widths = columnWidths(usable, columns);
790
957
  lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┬'), wrap)));
@@ -807,8 +974,8 @@ export function renderReviewView(theme, state, width, maxVisible = ASK_USER_MAX_
807
974
  // Submit row (review pane).
808
975
  {
809
976
  const selected = state.reviewIndex === state.questions.length;
810
- const leftCell = padCell(clipToWidth('Submit answers', widths[0]), widths[0]);
811
- const rightCell = padCell(clipToWidth(allQuestionsAnswered(state) ? 'ready' : 'incomplete', widths[1]), widths[1]);
977
+ const leftCell = padCell(clipToWidth(t('ask.review.submit'), widths[0]), widths[0]);
978
+ const rightCell = padCell(clipToWidth(allQuestionsAnswered(state) ? t('ask.review.ready') : t('ask.review.incomplete'), widths[1]), widths[1]);
812
979
  const plain = `${rowMarker(selected)}${leftCell}${TABLE_SEP}${rightCell}`;
813
980
  const line = clipToWidth(plain, wrap);
814
981
  body.push(selected ? fns.accent(BOLD + line + RESET) : fns.muted(line));
@@ -822,9 +989,9 @@ export function renderReviewView(theme, state, width, maxVisible = ASK_USER_MAX_
822
989
  lines.push(fns.attention(clipToWidth(state.attentionHint, wrap)));
823
990
  }
824
991
  lines.push('');
825
- const footer = '↑↓ select · Enter return to edit / submit · Ctrl+T fold';
992
+ const footer = t('ask.review.footer');
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) {
@@ -835,7 +1002,7 @@ function formatAnswerForReview(answer) {
835
1002
  if (answer.custom !== undefined && answer.custom !== '')
836
1003
  return foldText(`${CUSTOM_MARK}${answer.custom}`);
837
1004
  if (answer.selected.length === 0)
838
- return '(no answer)';
1005
+ return t('ask.review.noAnswer');
839
1006
  return foldText(answer.selected.join(', '));
840
1007
  }
841
1008
  // ------------------------------------------------- render: folded strip --
@@ -850,16 +1017,16 @@ export function renderCollapsedLine(theme, state, width) {
850
1017
  const fns = panelThemeFns(theme);
851
1018
  const wrap = Math.max(2, width - 2);
852
1019
  if (state.cancelHint) {
853
- return fns.attention(clipToWidth('Press Esc again to decline · Ctrl+T expand', wrap));
1020
+ return fns.attention(clipToWidth(t('ask.collapsed.esc'), wrap));
854
1021
  }
855
1022
  if (state.phase === 'review') {
856
- return fns.accent(BOLD + clipToWidth('● Review answers pending · Ctrl+T expand', wrap) + RESET);
1023
+ return fns.accent(BOLD + clipToWidth(t('ask.collapsed.review'), wrap) + RESET);
857
1024
  }
858
1025
  const answered = state.perQuestion.filter(answer => answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== '')).length;
859
1026
  const label = state.questions.length === 1
860
- ? '● Question pending'
861
- : `● Questions (${state.focusQuestion + 1}/${state.questions.length} · ${answered} answered)`;
862
- return fns.accent(BOLD + clipToWidth(`${label} · Ctrl+T expand`, wrap) + RESET);
1027
+ ? t('ask.collapsed.question')
1028
+ : t('ask.collapsed.questions', { index: state.focusQuestion + 1, total: state.questions.length, answered });
1029
+ return fns.accent(BOLD + clipToWidth(`${label} · ${t('ask.collapsed.expand')}`, wrap) + RESET);
863
1030
  }
864
1031
  // ---------------------------------- paste sanitiser + clipboard helpers --
865
1032
  /**
@@ -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(t('ask.notice.timeoutReview'));
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(t('ask.notice.timeoutAuto'));
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 : t('ask.autoIn', { time: 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
- Object.assign(s, { cancelHint: false, attentionHint: INCOMPLETE_HINT });
1507
+ Object.assign(s, { cancelHint: false, attentionHint: t('ask.incomplete') });
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;
@@ -1272,7 +1556,7 @@ export function openAskUserPanel(deps, questions, signal) {
1272
1556
  Object.assign(s, { phase: 'review', reviewIndex: 0, cancelHint: false, attentionHint: null });
1273
1557
  }
1274
1558
  else {
1275
- Object.assign(s, { cancelHint: false, attentionHint: INCOMPLETE_HINT });
1559
+ Object.assign(s, { cancelHint: false, attentionHint: t('ask.incomplete') });
1276
1560
  }
1277
1561
  }
1278
1562
  }
@@ -1320,7 +1604,7 @@ export function openAskUserPanel(deps, questions, signal) {
1320
1604
  // hint slot so the "Copied" line rides the same render path
1321
1605
  // every other transient hint already uses.
1322
1606
  writeClipboard(buffer).catch(() => { });
1323
- s.attentionHint = 'Copied';
1607
+ s.attentionHint = t('ask.copied');
1324
1608
  }
1325
1609
  return;
1326
1610
  }
@@ -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