@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/README.md +5 -0
- package/README.zh-CN.md +5 -0
- package/lib/ask-user.d.ts +122 -3
- package/lib/ask-user.js +310 -16
- package/lib/ask-user.js.map +1 -1
- package/lib/index.js +5 -3
- package/lib/index.js.map +1 -1
- package/lib/subagent-viewer.d.ts +2 -4
- package/lib/subagent-viewer.js +2 -28
- package/lib/subagent-viewer.js.map +1 -1
- package/lib/theme-settings.d.ts +21 -0
- package/lib/theme-settings.js +36 -0
- package/lib/theme-settings.js.map +1 -1
- package/package.json +1 -1
- package/skills/dsh-tui-pi-config/SKILL.md +6 -3
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|