@aiwayds/dsh-tui-pi 0.16.0 → 0.18.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 +69 -5
- package/README.zh.md +34 -0
- package/cordis.patch.yml +8 -0
- package/lib/ask-user.d.ts +207 -0
- package/lib/ask-user.js +714 -0
- package/lib/ask-user.js.map +1 -0
- package/lib/dsh-events.d.ts +7 -2
- package/lib/frame.d.ts +4 -3
- package/lib/frame.js +11 -8
- package/lib/frame.js.map +1 -1
- package/lib/index.js +35 -0
- package/lib/index.js.map +1 -1
- package/lib/session.js +29 -7
- package/lib/session.js.map +1 -1
- package/lib/subagent-policy.js +37 -7
- package/lib/subagent-policy.js.map +1 -1
- package/lib/subagent-viewer.d.ts +187 -6
- package/lib/subagent-viewer.js +248 -15
- package/lib/subagent-viewer.js.map +1 -1
- package/package.json +10 -1
package/lib/ask-user.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask the human a question while the model is mid-turn.
|
|
3
|
+
*
|
|
4
|
+
* Wires the upstream `ctx.userQuestions` capability seam (`dsh-user-questions`)
|
|
5
|
+
* with a terminal-side UI: an in-place multi-question overlay that pauses the
|
|
6
|
+
* tool call until the human answers, then feeds the canonical
|
|
7
|
+
* `AskUserQuestionAnswer` envelope back to `dsh-tool-ask-user` as a normal
|
|
8
|
+
* tool result.
|
|
9
|
+
*
|
|
10
|
+
* Layout — one framed overlay, all questions flattened:
|
|
11
|
+
*
|
|
12
|
+
* ● Question 1
|
|
13
|
+
* Where should we deploy? (left = header / right = question text)
|
|
14
|
+
* ▸ staging
|
|
15
|
+
* production
|
|
16
|
+
* Type something. (sentinel row — inline input)
|
|
17
|
+
* Question 2 ··· (continues vertically)
|
|
18
|
+
* ▸ …
|
|
19
|
+
* ⏎ Confirm answers (when ≥ 2 questions, or any multiSelect question)
|
|
20
|
+
*
|
|
21
|
+
* Single-question single-select overlay: Enter on an option (or committing a
|
|
22
|
+
* filled sentinel) submits immediately. A multiSelect question — even a lone
|
|
23
|
+
* one — gets a Confirm row instead of auto-submitting, so the user can pick
|
|
24
|
+
* several options first. Multi-question overlay: Enter on the Confirm row
|
|
25
|
+
* hops to the review page (all answers listed, each editable in place).
|
|
26
|
+
* Esc double-press within 200 ms declines — but terminal key auto-repeat
|
|
27
|
+
* (holding Esc) is ignored below `ESC_REPEAT_GUARD_MS`, so a long press
|
|
28
|
+
* cannot accidentally fire the decline. The provider returns the "declined"
|
|
29
|
+
* envelope and the model reads it as a normal user reply. An aborted
|
|
30
|
+
* request signal (`request.signal`) settles declined too — we resolve the
|
|
31
|
+
* declined envelope instead of rejecting ASK_ABORTED because the upstream
|
|
32
|
+
* service already screens entry-time aborts and an aborted step discards
|
|
33
|
+
* the result anyway.
|
|
34
|
+
*
|
|
35
|
+
* Pure logic lives in the top of this file (initial state, answer envelope,
|
|
36
|
+
* declined envelope, double-Esc state machine, row-layout math) so it can
|
|
37
|
+
* be unit-tested without a TTY. The component below owns the TUI render +
|
|
38
|
+
* keyboard handling; the install function at the bottom registers the
|
|
39
|
+
* provider under `ctx.userQuestions` (a Cordis effect, single active
|
|
40
|
+
* provider in the tree).
|
|
41
|
+
*
|
|
42
|
+
* Inspired by juicesharp/rpiv-ask-user-question
|
|
43
|
+
* (https://github.com/juicesharp/rpiv-ask-user-question).
|
|
44
|
+
*/
|
|
45
|
+
import { getKeybindings, matchesKey } from '@earendil-works/pi-tui';
|
|
46
|
+
import { BOLD, RESET, } from "./theme/index.js";
|
|
47
|
+
import { MARKER_W, panelThemeFns, TABLE_SEP, columnWidths, padCell, rowMarker, tableHeaderLine, tableRuleLine, } from "./panels.js";
|
|
48
|
+
import { wrapFramedOverlay } from "./frame.js";
|
|
49
|
+
import { clipToWidth } from "./text.js";
|
|
50
|
+
// ----------------------------------------------------------------- constants --
|
|
51
|
+
/** Maximum time between two Esc presses before the second one re-arms instead of firing. */
|
|
52
|
+
export const DOUBLE_ESC_WINDOW_MS = 200;
|
|
53
|
+
/**
|
|
54
|
+
* Minimum gap between two Esc presses below which the second one is treated as
|
|
55
|
+
* terminal key auto-repeat (the user is HOLDING Esc) rather than a deliberate
|
|
56
|
+
* press: repeats leave the state untouched, so a long press can never fire the
|
|
57
|
+
* decline gesture on its own.
|
|
58
|
+
*/
|
|
59
|
+
export const ESC_REPEAT_GUARD_MS = 50;
|
|
60
|
+
/** Sentinel row label appended to every question's option list. */
|
|
61
|
+
export const SENTINEL_LABEL = 'Type something.';
|
|
62
|
+
/** Decline message embedded into the answer envelope when the user bails. */
|
|
63
|
+
export const DECLINE_MESSAGE = 'User declined to answer questions.';
|
|
64
|
+
/** Transient hint when Enter lands on an incomplete confirm/submit row. */
|
|
65
|
+
export const INCOMPLETE_HINT = 'Answer every question first';
|
|
66
|
+
/** Mark left of a question a custom input wrote text into. */
|
|
67
|
+
const CUSTOM_MARK = '✎ ';
|
|
68
|
+
// ---------------------------------------- pure functions (testable) --
|
|
69
|
+
/** Initial state for a `questions` payload — defaults: cursor on Q0/option-0, no edit, no Esc history. */
|
|
70
|
+
export function initialState(questions) {
|
|
71
|
+
return {
|
|
72
|
+
questions,
|
|
73
|
+
perQuestion: questions.map(() => ({ selected: [] })),
|
|
74
|
+
cursorIndex: 0,
|
|
75
|
+
customInputs: questions.map(() => null),
|
|
76
|
+
customEditingFor: null,
|
|
77
|
+
phase: 'questions',
|
|
78
|
+
reviewIndex: 0,
|
|
79
|
+
lastEscAt: null,
|
|
80
|
+
cancelHint: false,
|
|
81
|
+
attentionHint: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Toggle/add an option. Single-select replaces; multi-select toggles membership. */
|
|
85
|
+
export function toggleOption(state, questionIndex, optionLabel) {
|
|
86
|
+
const question = state.questions[questionIndex];
|
|
87
|
+
if (question === undefined)
|
|
88
|
+
return state;
|
|
89
|
+
const answer = state.perQuestion[questionIndex];
|
|
90
|
+
if (answer === undefined)
|
|
91
|
+
return state;
|
|
92
|
+
const current = answer.selected;
|
|
93
|
+
const isMulti = question.multiSelect === true;
|
|
94
|
+
const next = isMulti
|
|
95
|
+
? current.includes(optionLabel) ? current.filter(l => l !== optionLabel) : [...current, optionLabel]
|
|
96
|
+
: current.includes(optionLabel) ? [] : [optionLabel];
|
|
97
|
+
const perQuestion = state.perQuestion.slice();
|
|
98
|
+
perQuestion[questionIndex] = {
|
|
99
|
+
selected: next,
|
|
100
|
+
...(answer.custom !== undefined ? { custom: answer.custom } : {}),
|
|
101
|
+
};
|
|
102
|
+
return { ...state, perQuestion };
|
|
103
|
+
}
|
|
104
|
+
/** Set the custom answer for a question (clears selected options). */
|
|
105
|
+
export function setCustomAnswer(state, questionIndex, text) {
|
|
106
|
+
const answer = state.perQuestion[questionIndex];
|
|
107
|
+
if (answer === undefined)
|
|
108
|
+
return state;
|
|
109
|
+
const perQuestion = state.perQuestion.slice();
|
|
110
|
+
perQuestion[questionIndex] = { selected: [], custom: text };
|
|
111
|
+
const customInputs = state.customInputs.slice();
|
|
112
|
+
customInputs[questionIndex] = text;
|
|
113
|
+
return { ...state, perQuestion, customInputs };
|
|
114
|
+
}
|
|
115
|
+
/** Mutate the live custom input buffer for a question. */
|
|
116
|
+
export function patchCustomInput(state, questionIndex, mutator) {
|
|
117
|
+
const customInputs = state.customInputs.slice();
|
|
118
|
+
customInputs[questionIndex] = mutator(customInputs[questionIndex] ?? '');
|
|
119
|
+
return { ...state, customInputs };
|
|
120
|
+
}
|
|
121
|
+
/** Enter inline-edit mode for a question (sentinel row got pressed). */
|
|
122
|
+
export function enterCustomEdit(state, questionIndex) {
|
|
123
|
+
const customInputs = state.customInputs.slice();
|
|
124
|
+
if (customInputs[questionIndex] === null)
|
|
125
|
+
customInputs[questionIndex] = '';
|
|
126
|
+
return { ...state, customEditingFor: questionIndex, customInputs, cancelHint: false };
|
|
127
|
+
}
|
|
128
|
+
/** Leave inline-edit mode; commit when `commit === true` and the buffer is non-empty. */
|
|
129
|
+
export function exitCustomEdit(state, commit) {
|
|
130
|
+
if (state.customEditingFor === null)
|
|
131
|
+
return state;
|
|
132
|
+
const qi = state.customEditingFor;
|
|
133
|
+
const next = commit && (state.customInputs[qi] ?? '').trim() !== ''
|
|
134
|
+
? setCustomAnswer({ ...state, customEditingFor: null }, qi, (state.customInputs[qi] ?? '').trim())
|
|
135
|
+
: { ...state, customEditingFor: null };
|
|
136
|
+
return { ...next, cancelHint: false };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Double-Esc state machine: 1st press arms; 2nd within the window fires
|
|
140
|
+
* (caller reads `lastEscAt===null + cancelHint===false`). Presses closer than
|
|
141
|
+
* `repeatGuardMs` are terminal key auto-repeat (held key) and are ignored
|
|
142
|
+
* entirely — the armed state stays at its original timestamp, so holding Esc
|
|
143
|
+
* neither fires the decline nor refreshes the window.
|
|
144
|
+
*/
|
|
145
|
+
export function advanceDoubleEsc(state, now, windowMs = DOUBLE_ESC_WINDOW_MS, repeatGuardMs = ESC_REPEAT_GUARD_MS) {
|
|
146
|
+
const last = state.lastEscAt;
|
|
147
|
+
if (last !== null && now - last < repeatGuardMs) {
|
|
148
|
+
return state;
|
|
149
|
+
}
|
|
150
|
+
if (last === null || now - last > windowMs) {
|
|
151
|
+
return { ...state, lastEscAt: now, cancelHint: true, attentionHint: null };
|
|
152
|
+
}
|
|
153
|
+
return { ...state, lastEscAt: null, cancelHint: false, attentionHint: null };
|
|
154
|
+
}
|
|
155
|
+
/** Was the most recent double-Esc press a "fired" event (the second within the window)? */
|
|
156
|
+
export function didDoubleEscFire(prevState, nextState, now, windowMs = DOUBLE_ESC_WINDOW_MS) {
|
|
157
|
+
if (prevState.lastEscAt === null)
|
|
158
|
+
return false;
|
|
159
|
+
if (nextState.lastEscAt !== null)
|
|
160
|
+
return false;
|
|
161
|
+
if (now - prevState.lastEscAt > windowMs)
|
|
162
|
+
return false;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
/** Canonical envelope for a normal submission. The model reads it as a tool result. */
|
|
166
|
+
export function buildAnswerEnvelope(state) {
|
|
167
|
+
const answers = [];
|
|
168
|
+
for (let qi = 0; qi < state.questions.length; qi++) {
|
|
169
|
+
const question = state.questions[qi];
|
|
170
|
+
const answer = state.perQuestion[qi];
|
|
171
|
+
if (question === undefined || answer === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
const item = { id: question.id, selected: [...answer.selected] };
|
|
174
|
+
if (answer.custom !== undefined && answer.custom !== '')
|
|
175
|
+
item.custom = answer.custom;
|
|
176
|
+
answers.push(item);
|
|
177
|
+
}
|
|
178
|
+
return { answers };
|
|
179
|
+
}
|
|
180
|
+
/** Canonical envelope for the decline path: empty selected + custom decline message on every question. */
|
|
181
|
+
export function buildDeclinedEnvelope(questions, message = DECLINE_MESSAGE) {
|
|
182
|
+
return {
|
|
183
|
+
answers: questions.map(question => ({ id: question.id, selected: [], custom: message })),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Build the ordered, flat list of rows for the questions pane. */
|
|
187
|
+
export function buildRowList(questions, perQuestion) {
|
|
188
|
+
const rows = [];
|
|
189
|
+
questions.forEach((question, qi) => {
|
|
190
|
+
rows.push({
|
|
191
|
+
kind: 'question-header',
|
|
192
|
+
questionIndex: qi,
|
|
193
|
+
label: question.header ?? `Question ${qi + 1}`,
|
|
194
|
+
description: question.question,
|
|
195
|
+
...(question.detail !== undefined ? { detail: question.detail } : {}),
|
|
196
|
+
selectable: false,
|
|
197
|
+
});
|
|
198
|
+
const options = question.options ?? [];
|
|
199
|
+
options.forEach((opt, oi) => {
|
|
200
|
+
rows.push({
|
|
201
|
+
kind: 'option',
|
|
202
|
+
questionIndex: qi,
|
|
203
|
+
optionIndex: oi,
|
|
204
|
+
label: opt.label,
|
|
205
|
+
...(opt.description !== undefined ? { description: opt.description } : {}),
|
|
206
|
+
selectable: true,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
const customAnswer = perQuestion[qi]?.custom ?? '';
|
|
210
|
+
rows.push({
|
|
211
|
+
kind: 'sentinel',
|
|
212
|
+
questionIndex: qi,
|
|
213
|
+
label: customAnswer !== '' ? `${CUSTOM_MARK}${customAnswer.trim()}` : SENTINEL_LABEL,
|
|
214
|
+
selectable: true,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
if (needsConfirmRow(questions)) {
|
|
218
|
+
rows.push({ kind: 'confirm', questionIndex: -1, label: '⏎ Confirm answers', selectable: true });
|
|
219
|
+
}
|
|
220
|
+
return rows;
|
|
221
|
+
}
|
|
222
|
+
/** Find the next selectable index from `i` going in `direction`, clamped into range. */
|
|
223
|
+
export function nextSelectableIndex(rows, from, direction) {
|
|
224
|
+
const n = rows.length;
|
|
225
|
+
if (n === 0)
|
|
226
|
+
return 0;
|
|
227
|
+
let i = Math.max(0, Math.min(n - 1, from));
|
|
228
|
+
// Two-pass scan: forward in `direction`, then backward.
|
|
229
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
230
|
+
let j = pass === 0 ? i + direction : (direction === 1 ? 0 : n - 1);
|
|
231
|
+
while (j >= 0 && j < n) {
|
|
232
|
+
if (rows[j]?.selectable === true)
|
|
233
|
+
return j;
|
|
234
|
+
j += direction;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return i;
|
|
238
|
+
}
|
|
239
|
+
/** True when every question has at least one selected option or a non-empty custom answer. */
|
|
240
|
+
export function allQuestionsAnswered(state) {
|
|
241
|
+
return state.perQuestion.every(answer => answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== ''));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Whether the overlay needs an explicit `⏎ Confirm answers` row: any
|
|
245
|
+
* multi-question layout, plus a lone multiSelect question — multiSelect can
|
|
246
|
+
* never auto-submit (the user may want more toggles), so it needs a path to
|
|
247
|
+
* the review page.
|
|
248
|
+
*/
|
|
249
|
+
export function needsConfirmRow(questions) {
|
|
250
|
+
return questions.length >= 2 || questions.some(question => question.multiSelect === true);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* True when a successful action on THIS state can submit immediately without
|
|
254
|
+
* the review step: exactly one question, single-select (a multiSelect
|
|
255
|
+
* question may want further toggles), and every question answered.
|
|
256
|
+
*/
|
|
257
|
+
export function canAutoSubmit(state) {
|
|
258
|
+
if (state.questions.length !== 1)
|
|
259
|
+
return false;
|
|
260
|
+
if (state.questions[0]?.multiSelect === true)
|
|
261
|
+
return false;
|
|
262
|
+
return allQuestionsAnswered(state);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Row index to land on after answering question `answeredQi`: the first
|
|
266
|
+
* selectable row of the nearest LATER unanswered question, or -1 when every
|
|
267
|
+
* later question is already answered (caller keeps the current cursor).
|
|
268
|
+
*/
|
|
269
|
+
export function nextUnansweredRow(rows, perQuestion, answeredQi) {
|
|
270
|
+
const answered = (qi) => {
|
|
271
|
+
const answer = perQuestion[qi];
|
|
272
|
+
return answer !== undefined
|
|
273
|
+
&& (answer.selected.length > 0 || (answer.custom !== undefined && answer.custom.trim() !== ''));
|
|
274
|
+
};
|
|
275
|
+
for (let i = 0; i < rows.length; i++) {
|
|
276
|
+
const row = rows[i];
|
|
277
|
+
if (row === undefined || !row.selectable)
|
|
278
|
+
continue;
|
|
279
|
+
if (row.questionIndex <= answeredQi)
|
|
280
|
+
continue;
|
|
281
|
+
if (!answered(row.questionIndex))
|
|
282
|
+
return i;
|
|
283
|
+
}
|
|
284
|
+
return -1;
|
|
285
|
+
}
|
|
286
|
+
// ------------------------------------------------- render: questions phase --
|
|
287
|
+
/** Render width budgets for the questions phase (label flex column + status column). */
|
|
288
|
+
const QUESTIONS_COLUMNS = () => [
|
|
289
|
+
{ key: 'label', title: 'Selection', flex: true },
|
|
290
|
+
{ key: 'status', title: 'State', width: 14 },
|
|
291
|
+
];
|
|
292
|
+
/** Status pill text for one row. */
|
|
293
|
+
function rowStatusText(kind, state, row, isMulti) {
|
|
294
|
+
if (kind === 'question-header')
|
|
295
|
+
return '';
|
|
296
|
+
if (kind === 'option') {
|
|
297
|
+
const opt = state.questions[row.questionIndex]?.options?.[row.optionIndex ?? -1];
|
|
298
|
+
if (opt === undefined)
|
|
299
|
+
return '';
|
|
300
|
+
const isSelected = state.perQuestion[row.questionIndex]?.selected.includes(opt.label) ?? false;
|
|
301
|
+
if (!isSelected)
|
|
302
|
+
return '';
|
|
303
|
+
return isMulti ? `[+]` : '[●]';
|
|
304
|
+
}
|
|
305
|
+
if (kind === 'sentinel') {
|
|
306
|
+
return (state.perQuestion[row.questionIndex]?.custom !== undefined
|
|
307
|
+
&& state.perQuestion[row.questionIndex]?.custom !== '') ? '[✎]' : '';
|
|
308
|
+
}
|
|
309
|
+
if (kind === 'confirm')
|
|
310
|
+
return allQuestionsAnswered(state) ? '[✓]' : '[—]';
|
|
311
|
+
return '';
|
|
312
|
+
}
|
|
313
|
+
/** Render the questions pane as a flat table line list. */
|
|
314
|
+
export function renderQuestionsView(theme, state, width) {
|
|
315
|
+
const fns = panelThemeFns(theme);
|
|
316
|
+
const wrap = Math.max(2, width - 2);
|
|
317
|
+
const title = state.questions.length === 1
|
|
318
|
+
? '● Question'
|
|
319
|
+
: `● Questions (${state.questions.length})`;
|
|
320
|
+
const lines = [fns.accent(BOLD + clipToWidth(title, wrap) + RESET)];
|
|
321
|
+
const rows = buildRowList(state.questions, state.perQuestion);
|
|
322
|
+
if (rows.length === 0) {
|
|
323
|
+
lines.push(fns.muted(clipToWidth('(no questions)', wrap)));
|
|
324
|
+
return finalizeQuestionsView(fns, wrap, lines, state);
|
|
325
|
+
}
|
|
326
|
+
const columns = QUESTIONS_COLUMNS();
|
|
327
|
+
const widths = columnWidths(wrap - MARKER_W, columns);
|
|
328
|
+
lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┬'), wrap)));
|
|
329
|
+
lines.push(fns.subtle(clipToWidth(tableHeaderLine(columns, widths), wrap)));
|
|
330
|
+
lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┼'), wrap)));
|
|
331
|
+
for (let i = 0; i < rows.length; i++) {
|
|
332
|
+
const row = rows[i];
|
|
333
|
+
if (row === undefined)
|
|
334
|
+
continue;
|
|
335
|
+
if (row.kind === 'question-header') {
|
|
336
|
+
// Header rows: full-width title, skip the table columns.
|
|
337
|
+
const text = `${row.label} ${row.description ?? ''}`;
|
|
338
|
+
const padded = clipToWidth(BOLD + text + RESET, wrap);
|
|
339
|
+
lines.push(fns.accent(padded));
|
|
340
|
+
// Upstream `detail` is supporting context (mandatory when the question
|
|
341
|
+
// declares an intent) — render it muted below the header, never inside
|
|
342
|
+
// option labels.
|
|
343
|
+
if (row.detail !== undefined && row.detail !== '') {
|
|
344
|
+
lines.push(fns.muted(clipToWidth(row.detail, wrap)));
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const isMulti = row.questionIndex >= 0
|
|
349
|
+
? state.questions[row.questionIndex]?.multiSelect === true
|
|
350
|
+
: false;
|
|
351
|
+
const selected = i === state.cursorIndex && state.customEditingFor === null;
|
|
352
|
+
let displayLabel = row.label;
|
|
353
|
+
if (row.kind === 'option') {
|
|
354
|
+
const opt = state.questions[row.questionIndex]?.options?.[row.optionIndex ?? -1];
|
|
355
|
+
if (opt?.description !== undefined && opt.description !== '') {
|
|
356
|
+
displayLabel = `${opt.label} ${opt.description}`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (row.kind === 'sentinel' && state.customEditingFor === row.questionIndex) {
|
|
360
|
+
displayLabel = `${state.customInputs[row.questionIndex] ?? ''}_`;
|
|
361
|
+
}
|
|
362
|
+
const status = rowStatusText(row.kind, state, row, isMulti);
|
|
363
|
+
const labelCell = padCell(clipToWidth(displayLabel, widths[0]), widths[0]);
|
|
364
|
+
const statusCell = padCell(clipToWidth(status, widths[1]), widths[1]);
|
|
365
|
+
const plain = `${rowMarker(selected)}${labelCell}${TABLE_SEP}${statusCell}`;
|
|
366
|
+
const line = clipToWidth(plain, wrap);
|
|
367
|
+
if (selected) {
|
|
368
|
+
lines.push(fns.accent(BOLD + line + RESET));
|
|
369
|
+
}
|
|
370
|
+
else if (row.kind === 'option'
|
|
371
|
+
&& (state.perQuestion[row.questionIndex]?.selected.includes(state.questions[row.questionIndex]?.options?.[row.optionIndex ?? -1]?.label ?? '') ?? false)) {
|
|
372
|
+
lines.push(fns.success(line));
|
|
373
|
+
}
|
|
374
|
+
else if (row.kind === 'sentinel'
|
|
375
|
+
&& (state.perQuestion[row.questionIndex]?.custom !== undefined
|
|
376
|
+
&& state.perQuestion[row.questionIndex]?.custom !== '')) {
|
|
377
|
+
lines.push(fns.success(line));
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
lines.push(fns.muted(line));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┴'), wrap)));
|
|
384
|
+
return finalizeQuestionsView(fns, wrap, lines, state);
|
|
385
|
+
}
|
|
386
|
+
function finalizeQuestionsView(fns, wrap, lines, state) {
|
|
387
|
+
if (state.attentionHint !== null) {
|
|
388
|
+
lines.push(fns.attention(clipToWidth(state.attentionHint, wrap)));
|
|
389
|
+
}
|
|
390
|
+
if (state.cancelHint) {
|
|
391
|
+
lines.push(fns.attention(clipToWidth('Press Esc again to decline', wrap)));
|
|
392
|
+
}
|
|
393
|
+
lines.push('');
|
|
394
|
+
const footer = state.customEditingFor !== null
|
|
395
|
+
? 'Type free text · Enter keep · Esc abandon edit'
|
|
396
|
+
: `↑↓ navigate · Enter ${needsConfirmRow(state.questions) ? 'toggle / confirm' : 'select'} · Esc decline`;
|
|
397
|
+
lines.push(fns.subtle(clipToWidth(footer, wrap)));
|
|
398
|
+
return lines;
|
|
399
|
+
}
|
|
400
|
+
// -------------------------------------------------- render: review phase --
|
|
401
|
+
/** Render the review page for multi-question overlays. */
|
|
402
|
+
export function renderReviewView(theme, state, width) {
|
|
403
|
+
const fns = panelThemeFns(theme);
|
|
404
|
+
const wrap = Math.max(2, width - 2);
|
|
405
|
+
const lines = [fns.accent(BOLD + clipToWidth('● Review answers', wrap) + RESET)];
|
|
406
|
+
// Question column with a content cap + answer column flexes to the right edge.
|
|
407
|
+
// (columnWidths only assigns the remainder to ONE flex column; two flex
|
|
408
|
+
// columns would both get the whole remainder and clip the second off.)
|
|
409
|
+
const usable = wrap - MARKER_W;
|
|
410
|
+
const leftCap = Math.max(20, Math.floor(usable * 0.45));
|
|
411
|
+
const columns = [
|
|
412
|
+
{ key: 'question', title: 'Question', width: leftCap },
|
|
413
|
+
{ key: 'answer', title: 'Your answer', flex: true },
|
|
414
|
+
];
|
|
415
|
+
const widths = columnWidths(usable, columns);
|
|
416
|
+
lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┬'), wrap)));
|
|
417
|
+
lines.push(fns.subtle(clipToWidth(tableHeaderLine(columns, widths), wrap)));
|
|
418
|
+
lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┼'), wrap)));
|
|
419
|
+
state.questions.forEach((question, qi) => {
|
|
420
|
+
const answer = state.perQuestion[qi];
|
|
421
|
+
const left = `${question.header ?? `Q${qi + 1}`} ${question.question}`;
|
|
422
|
+
const right = formatAnswerForReview(answer);
|
|
423
|
+
const selected = qi === state.reviewIndex;
|
|
424
|
+
const leftCell = padCell(clipToWidth(left, widths[0]), widths[0]);
|
|
425
|
+
const rightCell = padCell(clipToWidth(right, widths[1]), widths[1]);
|
|
426
|
+
const plain = `${rowMarker(selected)}${leftCell}${TABLE_SEP}${rightCell}`;
|
|
427
|
+
const line = clipToWidth(plain, wrap);
|
|
428
|
+
lines.push(selected ? fns.accent(BOLD + line + RESET) : fns.muted(line));
|
|
429
|
+
});
|
|
430
|
+
// Submit row (review pane).
|
|
431
|
+
{
|
|
432
|
+
const selected = state.reviewIndex === state.questions.length;
|
|
433
|
+
const leftCell = padCell(clipToWidth('Submit answers', widths[0]), widths[0]);
|
|
434
|
+
const rightCell = padCell(clipToWidth(allQuestionsAnswered(state) ? '✓ ready' : '— incomplete', widths[1]), widths[1]);
|
|
435
|
+
const plain = `${rowMarker(selected)}${leftCell}${TABLE_SEP}${rightCell}`;
|
|
436
|
+
const line = clipToWidth(plain, wrap);
|
|
437
|
+
lines.push(selected ? fns.accent(BOLD + line + RESET) : fns.muted(line));
|
|
438
|
+
}
|
|
439
|
+
lines.push(fns.subtle(clipToWidth(tableRuleLine(widths, '┴'), wrap)));
|
|
440
|
+
if (state.attentionHint !== null) {
|
|
441
|
+
lines.push(fns.attention(clipToWidth(state.attentionHint, wrap)));
|
|
442
|
+
}
|
|
443
|
+
lines.push('');
|
|
444
|
+
lines.push(fns.subtle(clipToWidth('↑↓ select · Enter return to edit / submit', wrap)));
|
|
445
|
+
return lines;
|
|
446
|
+
}
|
|
447
|
+
function formatAnswerForReview(answer) {
|
|
448
|
+
if (answer === undefined)
|
|
449
|
+
return '';
|
|
450
|
+
if (answer.custom !== undefined && answer.custom !== '')
|
|
451
|
+
return `${CUSTOM_MARK}${answer.custom}`;
|
|
452
|
+
if (answer.selected.length === 0)
|
|
453
|
+
return '(no answer)';
|
|
454
|
+
return answer.selected.join(', ');
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Open the AskUser overlay for one set of questions.
|
|
458
|
+
*
|
|
459
|
+
* `signal` is the caller's abort signal (the tool execution's). Already-aborted
|
|
460
|
+
* settles declined WITHOUT staging an overlay; a live signal closes the
|
|
461
|
+
* overlay and settles declined when it fires. We resolve the declined envelope
|
|
462
|
+
* rather than rejecting with the upstream ASK_ABORTED code on purpose: the
|
|
463
|
+
* upstream service already screens entry-time aborts, and a step aborted after
|
|
464
|
+
* this point discards the tool result anyway — resolving keeps the pending
|
|
465
|
+
* promise from ever hanging either way.
|
|
466
|
+
*/
|
|
467
|
+
export function openAskUserPanel(deps, questions, signal) {
|
|
468
|
+
if (signal?.aborted) {
|
|
469
|
+
return Promise.resolve(buildDeclinedEnvelope(questions));
|
|
470
|
+
}
|
|
471
|
+
return new Promise((resolve) => {
|
|
472
|
+
const clock = deps.now ?? Date.now;
|
|
473
|
+
const state = initialState(questions);
|
|
474
|
+
let settled = false;
|
|
475
|
+
const onAbort = () => {
|
|
476
|
+
settle(buildDeclinedEnvelope(questions));
|
|
477
|
+
overlay.hide();
|
|
478
|
+
deps.restoreFocus();
|
|
479
|
+
};
|
|
480
|
+
const settle = (envelope) => {
|
|
481
|
+
if (settled)
|
|
482
|
+
return;
|
|
483
|
+
settled = true;
|
|
484
|
+
signal?.removeEventListener('abort', onAbort);
|
|
485
|
+
resolve(envelope);
|
|
486
|
+
};
|
|
487
|
+
const panel = {
|
|
488
|
+
invalidate() { },
|
|
489
|
+
render(width) {
|
|
490
|
+
const theme = deps.theme();
|
|
491
|
+
if (state.phase === 'review')
|
|
492
|
+
return renderReviewView(theme, state, width);
|
|
493
|
+
return renderQuestionsView(theme, state, width);
|
|
494
|
+
},
|
|
495
|
+
handleInput(data) {
|
|
496
|
+
if (settled)
|
|
497
|
+
return;
|
|
498
|
+
if (state.customEditingFor !== null) {
|
|
499
|
+
handleCustomInput(state, data);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const kb = getKeybindings();
|
|
503
|
+
if (kb.matches(data, 'tui.select.up')) {
|
|
504
|
+
moveCursor(state, -1);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (kb.matches(data, 'tui.select.down')) {
|
|
508
|
+
moveCursor(state, 1);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (kb.matches(data, 'tui.select.confirm')) {
|
|
512
|
+
handleConfirm(state);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (kb.matches(data, 'tui.select.cancel')) {
|
|
516
|
+
const now = clock();
|
|
517
|
+
const prevState = { ...state };
|
|
518
|
+
const after = advanceDoubleEsc(state, now);
|
|
519
|
+
Object.assign(state, after);
|
|
520
|
+
if (didDoubleEscFire(prevState, state, now)) {
|
|
521
|
+
settle(buildDeclinedEnvelope(questions));
|
|
522
|
+
overlay.hide();
|
|
523
|
+
deps.restoreFocus();
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
// Stage the overlay through the standard FramedOverlay wrapper. The theme
|
|
530
|
+
// getter is passed through so the frame re-reads it per render too.
|
|
531
|
+
const overlay = deps.tui.showOverlay(wrapFramedOverlay(deps.theme, panel), { width: deps.width ?? '85%', maxHeight: deps.maxHeight ?? '80%' });
|
|
532
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
533
|
+
// When the overlay closes (without an explicit settle first), resolve the
|
|
534
|
+
// promise with the decline envelope — covers /reload, theme swap, agent
|
|
535
|
+
// abort, and any path that closes the overlay out from under us.
|
|
536
|
+
const originalHide = overlay.hide.bind(overlay);
|
|
537
|
+
overlay.hide = () => {
|
|
538
|
+
if (!settled) {
|
|
539
|
+
settle(buildDeclinedEnvelope(questions));
|
|
540
|
+
deps.restoreFocus();
|
|
541
|
+
}
|
|
542
|
+
originalHide();
|
|
543
|
+
};
|
|
544
|
+
// Local mutator helpers — these write into the closed-over state via
|
|
545
|
+
// Object.assign so the keyboard handler can use the immutable reducers.
|
|
546
|
+
function moveCursor(s, direction) {
|
|
547
|
+
if (s.phase === 'review') {
|
|
548
|
+
const max = s.questions.length; // last row is the submit pseudo-row
|
|
549
|
+
s.reviewIndex = Math.max(0, Math.min(max, s.reviewIndex + direction));
|
|
550
|
+
Object.assign(s, { cancelHint: false, attentionHint: null });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const rows = buildRowList(s.questions, s.perQuestion);
|
|
554
|
+
const next = nextSelectableIndex(rows, s.cursorIndex + direction, direction);
|
|
555
|
+
Object.assign(s, { cursorIndex: next, cancelHint: false, attentionHint: null });
|
|
556
|
+
}
|
|
557
|
+
function handleConfirm(s) {
|
|
558
|
+
if (s.phase === 'review') {
|
|
559
|
+
if (s.reviewIndex < s.questions.length) {
|
|
560
|
+
const rows = buildRowList(s.questions, s.perQuestion);
|
|
561
|
+
const first = rows.findIndex(r => r.kind !== 'question-header' && r.questionIndex === s.reviewIndex);
|
|
562
|
+
Object.assign(s, {
|
|
563
|
+
phase: 'questions',
|
|
564
|
+
cursorIndex: first >= 0 ? first : s.cursorIndex,
|
|
565
|
+
cancelHint: false,
|
|
566
|
+
attentionHint: null,
|
|
567
|
+
});
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (!allQuestionsAnswered(s)) {
|
|
571
|
+
Object.assign(s, { cancelHint: false, attentionHint: INCOMPLETE_HINT });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
settle(buildAnswerEnvelope(s));
|
|
575
|
+
overlay.hide();
|
|
576
|
+
deps.restoreFocus();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
// questions phase
|
|
580
|
+
const rows = buildRowList(s.questions, s.perQuestion);
|
|
581
|
+
const row = rows[s.cursorIndex];
|
|
582
|
+
if (row === undefined)
|
|
583
|
+
return;
|
|
584
|
+
if (row.kind === 'option' && row.optionIndex !== undefined) {
|
|
585
|
+
const option = s.questions[row.questionIndex]?.options?.[row.optionIndex];
|
|
586
|
+
if (option === undefined)
|
|
587
|
+
return;
|
|
588
|
+
const next = toggleOption(s, row.questionIndex, option.label);
|
|
589
|
+
Object.assign(s, next);
|
|
590
|
+
// Single-question single-select fast path: a successful selection
|
|
591
|
+
// submits immediately. multiSelect never auto-submits — the user may
|
|
592
|
+
// want more toggles, so it routes through the Confirm row instead.
|
|
593
|
+
if (canAutoSubmit(next)) {
|
|
594
|
+
settle(buildAnswerEnvelope(s));
|
|
595
|
+
overlay.hide();
|
|
596
|
+
deps.restoreFocus();
|
|
597
|
+
}
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (row.kind === 'sentinel') {
|
|
601
|
+
const seed = s.perQuestion[row.questionIndex]?.custom ?? '';
|
|
602
|
+
const seeded = patchCustomInput(enterCustomEdit(s, row.questionIndex), row.questionIndex, () => seed);
|
|
603
|
+
Object.assign(s, seeded);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (row.kind === 'confirm') {
|
|
607
|
+
if (allQuestionsAnswered(s)) {
|
|
608
|
+
Object.assign(s, { phase: 'review', reviewIndex: 0, cancelHint: false, attentionHint: null });
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
Object.assign(s, { cancelHint: false, attentionHint: INCOMPLETE_HINT });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function handleCustomInput(s, data) {
|
|
616
|
+
const qi = s.customEditingFor;
|
|
617
|
+
if (qi === null)
|
|
618
|
+
return;
|
|
619
|
+
const kb = getKeybindings();
|
|
620
|
+
if (kb.matches(data, 'tui.select.confirm')) {
|
|
621
|
+
Object.assign(s, exitCustomEdit(s, true));
|
|
622
|
+
commitCustomAnswer(s, qi);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (kb.matches(data, 'tui.select.cancel')) {
|
|
626
|
+
Object.assign(s, exitCustomEdit(s, false));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (data === '\x7f' || matchesKey(data, 'backspace')) {
|
|
630
|
+
const next = s.customInputs.slice();
|
|
631
|
+
next[qi] = (next[qi] ?? '').slice(0, -1);
|
|
632
|
+
s.customInputs = next;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (data.length === 1 && data.charCodeAt(0) >= 0x20 && data.charCodeAt(0) !== 0x7f) {
|
|
636
|
+
const next = s.customInputs.slice();
|
|
637
|
+
next[qi] = (next[qi] ?? '') + data;
|
|
638
|
+
s.customInputs = next;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* After a committed sentinel edit: a lone single-select question whose
|
|
643
|
+
* answer is now complete submits right away (this is the free-text
|
|
644
|
+
* counterpart of the option fast path — without it, typing an answer into
|
|
645
|
+
* a question with no options could never reach `settle`). Otherwise, in a
|
|
646
|
+
* multi-question layout the cursor hops to the next unanswered question's
|
|
647
|
+
* first row to cut confirmation cost.
|
|
648
|
+
*/
|
|
649
|
+
function commitCustomAnswer(s, qi) {
|
|
650
|
+
if (s.customEditingFor !== null)
|
|
651
|
+
return; // edit not committed (empty buffer)
|
|
652
|
+
if (s.perQuestion[qi]?.custom === undefined)
|
|
653
|
+
return;
|
|
654
|
+
if (canAutoSubmit(s)) {
|
|
655
|
+
settle(buildAnswerEnvelope(s));
|
|
656
|
+
overlay.hide();
|
|
657
|
+
deps.restoreFocus();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (s.questions.length >= 2) {
|
|
661
|
+
const rows = buildRowList(s.questions, s.perQuestion);
|
|
662
|
+
const target = nextUnansweredRow(rows, s.perQuestion, qi);
|
|
663
|
+
if (target >= 0)
|
|
664
|
+
Object.assign(s, { cursorIndex: target, cancelHint: false, attentionHint: null });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
// ----------------------------------------------------- the provider --
|
|
670
|
+
/**
|
|
671
|
+
* True only for the upstream's documented duplicate-registration failure
|
|
672
|
+
* (`UserQuestionError` with code `DUPLICATE_PROVIDER`) — the one case where
|
|
673
|
+
* yielding the single provider slot to the prior UI is correct. Matched
|
|
674
|
+
* structurally on `name` + `code` so a cross-realm HarnessError instance or a
|
|
675
|
+
* test double classifies identically.
|
|
676
|
+
*/
|
|
677
|
+
export function isDuplicateProviderError(error) {
|
|
678
|
+
if (!(error instanceof Error))
|
|
679
|
+
return false;
|
|
680
|
+
if (error.name !== 'UserQuestionError')
|
|
681
|
+
return false;
|
|
682
|
+
return error.code === 'DUPLICATE_PROVIDER';
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Wires the provider into `ctx.userQuestions`. Call from inside `ctx.effect`.
|
|
686
|
+
*
|
|
687
|
+
* Failure semantics are deliberate (review round BM):
|
|
688
|
+
* - missing service → warn + no-op disposer. The tool stays mounted by the
|
|
689
|
+
* bundle patch; without a provider its calls fail with the upstream
|
|
690
|
+
* NO_PROVIDER error, which is better than crashing the whole TUI plugin.
|
|
691
|
+
* - DUPLICATE_PROVIDER → silent no-op disposer (a prior UI owns the slot).
|
|
692
|
+
* - anything else → rethrown so the effect fails loudly instead of leaving a
|
|
693
|
+
* mounted tool with no UI and no trace.
|
|
694
|
+
*/
|
|
695
|
+
export function registerAskUserProvider(ctx, deps) {
|
|
696
|
+
const userQuestions = ctx.userQuestions;
|
|
697
|
+
if (userQuestions === undefined || typeof userQuestions.registerProvider !== 'function') {
|
|
698
|
+
console.warn('[dsh-tui-pi] ctx.userQuestions not mounted — ask_user_question calls will fail with NO_PROVIDER');
|
|
699
|
+
return () => { };
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
return userQuestions.registerProvider({
|
|
703
|
+
ask: request => openAskUserPanel(deps, request.questions, request.signal),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
if (isDuplicateProviderError(error)) {
|
|
708
|
+
// A prior UI is already the provider — yield ownership instead of crashing.
|
|
709
|
+
return () => { };
|
|
710
|
+
}
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
//# sourceMappingURL=ask-user.js.map
|