@aiwayds/dsh-tui-pi 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -201,12 +201,13 @@ There's no slash command to toggle the feature — it's always on, controlled by
201
201
 
202
202
  While the model is mid-turn it can pause and ask you structured questions via the `ask_user_question` tool (`@deepseek-ai/dsh-tool-ask-user`, mounted by this profile's bundle patch). The TUI hosts the answering side: a bordered panel pins itself directly above the chat input (the Todos-panel slot — no floating popup), takes the keyboard while open, the tool call stays pending until you answer, and your answers flow back to the model as a normal tool result.
203
203
 
204
- - **One panel, all questions flattened** — every question renders as a header row followed by its supporting `detail` text (when the model supplies any), option rows, plus a `Type something.` sentinel row for free text. Single-select replaces on Enter; multi-select toggles (`[+]` marks).
204
+ - **One question at a time, tabs for the rest** — with several questions the panel shows exactly ONE question block (header row + supporting `detail` text, option rows, plus a `Type something.` sentinel row for free text); a tab strip under the title (`[1] · 2✓ · 3` — brackets mark the focused tab, ✓ an answered one) folds the other questions away. `←`/`→` (and Tab/Shift-Tab) switch tabs; answering a single-select tab auto-advances to the next unanswered one (or onto the Confirm row once everything is answered). Single-select replaces on Enter; multi-select toggles (`●`/`○` marks) and never auto-advances.
205
+ - **Ctrl+T folds the panel to a 3-line strip** — the questions panel can block the transcript it stacks on while you think; Ctrl+T collapses it to borders + one summary line (phase, tab position, answered count, how to expand) and the same key unfolds it. While folded only the toggle and the Esc chain act; folding mid-edit commits the buffer like the ↑↓ arrow-exit does.
205
206
  - **Single-question fast path** — a lone single-select question submits immediately on Enter: picking an option or committing typed free text both submit right away (a question without options is answered by typing alone). A lone multiSelect question instead gets a `⏎ Confirm answers` row so you can pick several options before submitting.
206
- - **Multi-question review page** — with ≥ 2 questions a `⏎ Confirm answers` row hops to a review listing every answer, each row editable in place; `Submit answers` commits (Enter on it while an answer is missing flashes a hint instead of failing silently). After committing free text on one question the cursor hops to the next unanswered one.
207
+ - **Multi-question review page** — with ≥ 2 questions a `⏎ Confirm answers` row hops to a review listing every answer, each row editable in place (jumping back re-focuses that question's tab); `Submit answers` commits (Enter on it while an answer is missing flashes a hint instead of failing silently).
207
208
  - **Double-Esc declines** — two Esc presses within 200 ms return a declined envelope (the model reads it as a normal reply that no answer was given); holding Esc does not accidentally fire (key auto-repeat below a minimum gap is ignored), and the tool call being aborted settles as declined too. While the panel is open it owns the keyboard exactly like an open overlay: Esc never arms the running-task stop, and app keys (Ctrl+L/G/O, Tab) yield to the panel.
208
209
  - **Conservative-use guidance** — a system-prompt section nudges the model to ask only when it genuinely needs you (1–3 questions, 2–4 options each), so the TUI doesn't turn into a questionnaire.
209
- - **Keyboard** — `↑↓` navigate · `Enter` select/toggle/confirm · type into the sentinel for free text · `Esc` twice to decline.
210
+ - **Keyboard** — `←→` switch question tabs · `↑↓` navigate · `Enter` select/toggle/confirm · type into the sentinel for free text · `Ctrl+T` fold/unfold the panel · `Esc` twice to decline.
210
211
 
211
212
  Inspired by [juicesharp/rpiv-ask-user-question](https://github.com/juicesharp/rpiv-ask-user-question).
212
213
 
@@ -304,8 +305,9 @@ Precedence per field: an explicit value in settings.yaml > the
304
305
  `DSH_TUI_RETENTION_MAX_COUNT` / `DSH_TUI_RETENTION_MAX_AGE_DAYS` /
305
306
  `DSH_TUI_RETENTION_MIN_IDLE_HOURS` and `DSH_TUI_RESUME_MAX_AGE_DAYS` /
306
307
  `DSH_TUI_RESUME_MIN_BYTES` environment variables > the defaults above.
307
- An invalid settings value warns once on stderr and falls to the next
308
- level; an invalid env value falls back silently to the default a typo
308
+ An invalid settings value surfaces a transient notice via the shared
309
+ notice bridge (silently dropped when no TUI sink is registeredheadless
310
+ runs never print it) and falls to the next level; an invalid env value — a typo
309
311
  never widens or guts the policy. `maxCount` and `minBytes` must be
310
312
  integers at every layer (a fractional cap or byte floor is garbage, not a
311
313
  window).
@@ -500,7 +502,8 @@ See [CHANGELOG.md](CHANGELOG.md) for the release history.
500
502
 
501
503
  - [Ask User Question](#ask-user-question) is inspired by
502
504
  [juicesharp/rpiv-ask-user-question](https://github.com/juicesharp/rpiv-ask-user-question) —
503
- the interaction design (flattened option list with a free-text sentinel,
504
- multi-question review page, decline gesture) was adapted to this TUI's
505
- framed-overlay and dsh `userQuestions` provider architecture. All code here
506
- is original.
505
+ the interaction design (numbered option list with a free-text sentinel,
506
+ multi-question review page, decline gesture; since reworked into a
507
+ one-question-at-a-time tab view with a fold-away strip) was adapted to this
508
+ TUI's docked-panel and dsh `userQuestions` provider architecture. All code
509
+ here is original.
package/lib/ask-user.d.ts CHANGED
@@ -8,12 +8,17 @@
8
8
  * `AskUserQuestionAnswer` envelope back to `dsh-tool-ask-user` as a normal
9
9
  * tool result.
10
10
  *
11
- * Layout — one bordered dock panel, all questions flattened (numbered rows, a
12
- * divider between each question block and its options, and a scroll window
13
- * so the cursor row is always visible even when the dock is height-capped):
11
+ * Layout — one bordered dock panel showing EXACTLY ONE question at a time
12
+ * (numbered rows, a divider between the question block and its options, and
13
+ * a scroll window so the cursor row is always visible even when the dock is
14
+ * height-capped). With ≥ 2 questions a tab strip under the title carries one
15
+ * tab per question (`[1] · 2✓ · 3` — brackets mark the focused tab, ✓ an
16
+ * answered one); ←/→ (and Tab/Shift-Tab) switch tabs so a question batch
17
+ * never floods the dock:
14
18
  *
15
19
  * ┌────────────────────────────────────┐
16
- * │ ● Questions (2)
20
+ * │ ● Questions (1/2)
21
+ * │ [1] · 2 │ (tab strip, ≥ 2 questions only)
17
22
  * │ SELECTION │
18
23
  * │ ───────────────────────────── │ (table chrome)
19
24
  * │ Fruit Where should we deploy? │ (header — not selectable)
@@ -26,6 +31,11 @@
26
31
  * │ ⏎ Confirm answers │ (when ≥ 2 questions, or any multiSelect)
27
32
  * └────────────────────────────────────┘
28
33
  *
34
+ * Ctrl+T folds the whole panel into a 3-line strip (borders + one summary
35
+ * line) so the transcript stays readable while the question pends; the same
36
+ * key unfolds it. While folded only the toggle and the Esc chain do
37
+ * anything — answering keys are inert until the panel is back.
38
+ *
29
39
  * While the panel is open it owns the keyboard: it is mounted into the dock
30
40
  * slot between the live widgets and the editor, takes focus through
31
41
  * `tui.setFocus`, and `setModalActive(true)` routes the app keymap exactly
@@ -45,7 +55,9 @@
45
55
  * Single-question single-select panel: Enter on an option (or committing a
46
56
  * filled sentinel) submits immediately. A multiSelect question — even a lone
47
57
  * one — gets a Confirm row instead of auto-submitting, so the user can pick
48
- * several options first. Multi-question panel: Enter on the Confirm row hops
58
+ * several options first. Multi-question panel: answering a single-select tab
59
+ * auto-advances the focus to the next unanswered tab (or onto the Confirm
60
+ * row once everything is answered). Enter on the Confirm row hops
49
61
  * to the review page (all answers listed, each editable in place). Esc
50
62
  * double-press within 200 ms declines — but terminal key auto-repeat (holding
51
63
  * Esc) is ignored below `ESC_REPEAT_GUARD_MS`, so a long press cannot
@@ -65,7 +77,7 @@
65
77
  * Inspired by juicesharp/rpiv-ask-user-question
66
78
  * (https://github.com/juicesharp/rpiv-ask-user-question).
67
79
  */
68
- import { type Component, type TUI } from '@earendil-works/pi-tui';
80
+ import { type Component, type KeyId, type TUI } from '@earendil-works/pi-tui';
69
81
  import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionOption } from '@deepseek-ai/dsh-user-questions';
70
82
  import type { Context } from '@deepseek-ai/cordis';
71
83
  import { type TuiTheme } from './theme/index.ts';
@@ -80,6 +92,13 @@ export declare const DOUBLE_ESC_WINDOW_MS = 200;
80
92
  export declare const ESC_REPEAT_GUARD_MS = 50;
81
93
  /** Sentinel row label appended to every question's option list. */
82
94
  export declare const SENTINEL_LABEL = "Type something.";
95
+ /**
96
+ * Key that folds the whole panel into a 3-line strip (and unfolds it again).
97
+ * A control key on purpose: it must never read as free-text input while the
98
+ * sentinel editor is engaged (folding commits the buffer, exactly like the
99
+ * ↑↓ arrow-exit path does).
100
+ */
101
+ export declare const ASK_COLLAPSE_KEY: KeyId;
83
102
  /** Decline message embedded into the answer envelope when the user bails. */
84
103
  export declare const DECLINE_MESSAGE = "User declined to answer questions.";
85
104
  /** Transient hint when Enter lands on an incomplete confirm/submit row. */
@@ -99,9 +118,11 @@ export declare const ASK_USER_MAX_VISIBLE = 6;
99
118
  * panel budget is the terminal height minus the reserved dock rows, minus
100
119
  * the panel's own chrome. At the 24-row e2e floor this yields exactly
101
120
  * ASK_USER_MAX_VISIBLE (6); larger terminals scale up. Without a usable row
102
- * count it degrades to ASK_USER_MAX_VISIBLE.
121
+ * count it degrades to ASK_USER_MAX_VISIBLE. `extraChromeLines` covers
122
+ * optional interior lines the default budget does not count (the tab strip
123
+ * of a multi-question panel).
103
124
  */
104
- export declare function askUserMaxVisibleForRows(termRows: number | undefined): number;
125
+ export declare function askUserMaxVisibleForRows(termRows: number | undefined, extraChromeLines?: number): number;
105
126
  /** A row in the multi-question view. */
106
127
  export interface FlatRow {
107
128
  kind: 'question-header' | 'option' | 'sentinel' | 'confirm';
@@ -133,8 +154,15 @@ export interface AskUserState {
133
154
  questions: readonly AskUserQuestionItem[];
134
155
  /** Per-question pending answer. */
135
156
  perQuestion: PendingAnswer[];
136
- /** Cursor position in the flat row list (see `buildRowList`). */
157
+ /** Cursor position in the focused question's row list (see `buildRowList`). */
137
158
  cursorIndex: number;
159
+ /**
160
+ * Which question tab the questions pane shows. The pane renders exactly
161
+ * one question at a time; ←/→ (and Tab/Shift-Tab) move this index.
162
+ */
163
+ focusQuestion: number;
164
+ /** Panel folded to the 3-line strip (Ctrl+T). While folded only the toggle key and the Esc chain act. */
165
+ collapsed: boolean;
138
166
  /** Live inline-edit text per question; non-null when the sentinel is engaged for that question. */
139
167
  customInputs: (string | null)[];
140
168
  /** Live inline edit owner (question index); null when not editing. */
@@ -187,8 +215,13 @@ export declare function didDoubleEscFire(prevState: AskUserState, nextState: Ask
187
215
  export declare function buildAnswerEnvelope(state: AskUserState): AskUserQuestionAnswer;
188
216
  /** Canonical envelope for the decline path: empty selected + custom decline message on every question. */
189
217
  export declare function buildDeclinedEnvelope(questions: readonly AskUserQuestionItem[], message?: string): AskUserQuestionAnswer;
190
- /** Build the ordered, flat list of rows for the questions pane. */
191
- export declare function buildRowList(questions: readonly AskUserQuestionItem[], perQuestion: readonly PendingAnswer[]): FlatRow[];
218
+ /**
219
+ * Build the ordered row list for the questions pane: the FOCUSED question's
220
+ * block only (header + options + sentinel), followed by the panel-wide
221
+ * Confirm pseudo-row when `needsConfirmRow` says so — the pane shows one
222
+ * question at a time; ←/→ swaps `focusQuestion` and rebuilds this list.
223
+ */
224
+ export declare function buildRowList(questions: readonly AskUserQuestionItem[], perQuestion: readonly PendingAnswer[], focusQuestion?: number): FlatRow[];
192
225
  /** Find the next selectable index from `i` going in `direction`, clamped into range. */
193
226
  export declare function nextSelectableIndex(rows: readonly FlatRow[], from: number, direction: 1 | -1): number;
194
227
  /** True when every question has at least one selected option or a non-empty custom answer. */
@@ -233,15 +266,51 @@ export declare function needsConfirmRow(questions: readonly AskUserQuestionItem[
233
266
  */
234
267
  export declare function canAutoSubmit(state: AskUserState): boolean;
235
268
  /**
236
- * Row index to land on after answering question `answeredQi`: the first
237
- * selectable row of the nearest LATER unanswered question, or -1 when every
238
- * later question is already answered (caller keeps the current cursor).
269
+ * Row index to land on after a single-select answer on question `answeredQi`
270
+ * (option toggle or committed custom text): advance the tab focus to the
271
+ * nearest LATER unanswered question (cursor on its answer), or once every
272
+ * question is answered — park the cursor on the Confirm row of the current
273
+ * tab so submission is one Enter away. Returns the state unchanged when no
274
+ * Confirm row exists (the lone single-select fast path submits instead).
239
275
  */
240
- export declare function nextUnansweredRow(rows: readonly FlatRow[], perQuestion: readonly PendingAnswer[], answeredQi: number): number;
241
- /** Render the questions pane as a flat table line list behind a scroll window. */
276
+ export declare function advanceAfterAnswer(state: AskUserState, answeredQi: number): AskUserState;
277
+ /** Index of the nearest question AFTER `answeredQi` with no answer yet, or -1. */
278
+ export declare function nextUnansweredQuestion(questions: readonly AskUserQuestionItem[], perQuestion: readonly PendingAnswer[], answeredQi: number): number;
279
+ /**
280
+ * Cursor position for a freshly focused question tab: its first selected
281
+ * option when one exists, else the sentinel when a custom answer exists,
282
+ * else the first selectable row — so revisiting an answered tab lands on
283
+ * the answer, not on option 1.
284
+ */
285
+ export declare function focusCursor(rows: readonly FlatRow[], perQuestion: readonly PendingAnswer[], questionIndex: number): number;
286
+ /**
287
+ * Move the tab focus by `direction` (clamped at the ends — no wrap-around:
288
+ * predictable ends beat flourish mid-questionnaire) and land the cursor on
289
+ * the tab's answer per `focusCursor`. Clears the transient hints like any
290
+ * navigation. The component must exit an engaged sentinel edit BEFORE
291
+ * switching (committing the buffer), same as the ↑↓ arrow-exit path.
292
+ */
293
+ export declare function switchFocus(state: AskUserState, direction: 1 | -1): AskUserState;
294
+ /**
295
+ * Flip the Ctrl+T fold. Folding while a sentinel edit is engaged first
296
+ * commits the buffer (the arrow-exit semantics — the text stays visible on
297
+ * the sentinel's ✎ mark), then collapses; transient hints clear on both
298
+ * directions. The double-Esc clock is deliberately untouched so an armed
299
+ * decline keeps its hint when the panel unfolds.
300
+ */
301
+ export declare function toggleCollapse(state: AskUserState): AskUserState;
302
+ /** Render the questions pane (one focused question tab) as a flat table line list behind a scroll window. */
242
303
  export declare function renderQuestionsView(theme: TuiTheme, state: AskUserState, width: number, maxVisible?: number): string[];
243
304
  /** Render the review page for multi-question overlays (scroll-windowed). */
244
305
  export declare function renderReviewView(theme: TuiTheme, state: AskUserState, width: number, maxVisible?: number): string[];
306
+ /**
307
+ * The ONE interior line of the folded (Ctrl+T) panel. The panel is a hard
308
+ * modal that owns the keyboard while it pends, so the strip keeps the
309
+ * state machine discoverable: which phase, how far along, how to unfold —
310
+ * and an armed decline replaces the summary so the 200 ms window is not
311
+ * silently ticking off-screen.
312
+ */
313
+ export declare function renderCollapsedLine(theme: TuiTheme, state: AskUserState, width: number): string;
245
314
  /** Options for assembling the panel + provider function. */
246
315
  export interface AskUserPanelDeps {
247
316
  tui: TUI;
@@ -291,7 +360,7 @@ export declare function isDuplicateProviderError(error: unknown): boolean;
291
360
  * Wires the provider into `ctx.userQuestions`. Call from inside `ctx.effect`.
292
361
  *
293
362
  * Failure semantics are deliberate (review round BM):
294
- * - missing service → warn + no-op disposer. The tool stays mounted by the
363
+ * - missing service → one notice + no-op disposer. The tool stays mounted by the
295
364
  * bundle patch; without a provider its calls fail with the upstream
296
365
  * NO_PROVIDER error, which is better than crashing the whole TUI plugin.
297
366
  * - DUPLICATE_PROVIDER → silent no-op disposer (a prior UI owns the slot).