@herbertgao/pi-extensions 2026.8.7 → 2026.8.8

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 (99) hide show
  1. package/README.md +7 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  10. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  11. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  16. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  17. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  18. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  19. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  20. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  22. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  23. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  24. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  28. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  29. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  30. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  31. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  32. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  33. package/node_modules/pi-lens/README.md +106 -0
  34. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  35. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  36. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  37. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  38. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  39. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  40. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  41. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  58. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  59. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  60. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  61. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  62. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  63. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  64. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  65. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  66. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  67. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  68. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  69. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  70. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  71. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  72. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  73. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  74. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  75. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  76. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  77. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  78. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  79. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  80. package/node_modules/pi-lens/dist/index.js +1224 -393
  81. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  82. package/node_modules/pi-lens/package.json +18 -5
  83. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  84. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  85. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  86. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  87. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  88. package/node_modules/pi-web-access/README.md +6 -5
  89. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  90. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  91. package/node_modules/pi-web-access/extract.ts +12 -2
  92. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  93. package/node_modules/pi-web-access/github-extract.ts +65 -36
  94. package/node_modules/pi-web-access/index.ts +19 -4
  95. package/node_modules/pi-web-access/openai-search.ts +17 -6
  96. package/node_modules/pi-web-access/package.json +1 -1
  97. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  98. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  99. package/package.json +9 -9
@@ -150,6 +150,135 @@ function prevNavOnUp(state: QuestionnaireState, runtime: QuestionnaireRuntime):
150
150
  };
151
151
  }
152
152
 
153
+ // Collapsed-mode lockout: while collapsed, swallow every keystroke except cancel so
154
+ // the user can read the now-uncovered transcript without accidentally mutating
155
+ // answers or notes. The collapse toggle itself is already handled above.
156
+ function routeCollapsed(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
157
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
158
+ return { kind: "ignore" };
159
+ }
160
+
161
+ function routeNotesMode(kb: QuestionnaireKeybindings, data: string): QuestionnaireAction {
162
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "notes_exit" };
163
+ if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "notes_forward", data };
164
+ if (isConfirm(kb, data)) return { kind: "notes_exit" };
165
+ return { kind: "notes_forward", data };
166
+ }
167
+
168
+ function routeInputMode(
169
+ kb: QuestionnaireKeybindings,
170
+ data: string,
171
+ state: QuestionnaireState,
172
+ runtime: QuestionnaireRuntime,
173
+ ): QuestionnaireAction {
174
+ // Newline takes precedence over confirmation if a user configuration binds
175
+ // the same physical key to both semantic actions.
176
+ if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "ignore" };
177
+ if (isConfirm(kb, data)) {
178
+ const answer = buildSingleSelectAnswer(state, runtime);
179
+ if (!answer) return { kind: "ignore" };
180
+ return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
181
+ }
182
+ // Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear,
183
+ // independent of the current cursor position.
184
+ if (kb.matches(data, KEYBIND_CLEAR)) return { kind: "input_clear" };
185
+ if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR)) return { kind: "input_edit", value: runtime.inputBuffer };
186
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
187
+ if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) return { kind: "ignore" };
188
+ if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) return { kind: "ignore" };
189
+ if (kb.matches(data, KEYBIND_UP)) return prevNavOnUp(state, runtime);
190
+ if (kb.matches(data, KEYBIND_DOWN)) return nextNavOnDown(state, runtime);
191
+ return { kind: "ignore" };
192
+ }
193
+
194
+ function routeSubmitTab(
195
+ kb: QuestionnaireKeybindings,
196
+ data: string,
197
+ state: QuestionnaireState,
198
+ runtime: QuestionnaireRuntime,
199
+ ): QuestionnaireAction {
200
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
201
+ const tab = tabSwitchAction(data, state, runtime);
202
+ if (tab) return tab;
203
+ if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
204
+ const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
205
+ const next = wrapTab(state.submitChoiceIndex + delta, 2);
206
+ return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
207
+ }
208
+ if (isConfirm(kb, data)) {
209
+ // D1 (revised): Submit always submits; Cancel always cancels. The warning header
210
+ // is informational only — `allAnswered(state)` no longer gates submission. Partial
211
+ // answers flow through `orderedAnswers()` in the host.
212
+ return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" };
213
+ }
214
+ // Global note (#182): `n` on the Submit tab opens the notes editor scoped to the whole
215
+ // questionnaire at the pseudo-index (`questions.length` in notesByTab). Reachable only
216
+ // with the editor closed — `routeKey` dispatches notesVisible traffic to
217
+ // `routeNotesMode` before the submit-tab block runs. The branch shadows nothing above
218
+ // it (cancel, tab switch, submit-nav, confirm): a literal `n` byte matches none of them
219
+ // under default bindings, and a user who deliberately binds one of those actions to
220
+ // `n` keeps that mapping because the earlier branch still wins (Enter still submits).
221
+ if (data === NOTES_ACTIVATE_KEY) {
222
+ return { kind: "notes_enter" };
223
+ }
224
+ return { kind: "ignore" };
225
+ }
226
+
227
+ function routeMultiSelectTab(
228
+ kb: QuestionnaireKeybindings,
229
+ data: string,
230
+ state: QuestionnaireState,
231
+ runtime: QuestionnaireRuntime,
232
+ ): QuestionnaireAction {
233
+ const focusedKind = runtime.currentItem?.kind;
234
+ const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined;
235
+ // Space toggles the focused row's checkbox. Suppressed on rows whose META declares
236
+ // `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
237
+ // something." row — it is an inline input, not a checkable option).
238
+ if (data === SPACE_KEY) {
239
+ if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" };
240
+ if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
241
+ return { kind: "toggle", index: state.optionIndex };
242
+ }
243
+ if (isConfirm(kb, data)) {
244
+ // Enter on the "Type something." row is handled by the inputMode block above
245
+ // (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
246
+ // path for an inputMode-activating row.
247
+ if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
248
+ // Enter on a regular row toggles (matching Space) — committing the question is now
249
+ // gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
250
+ // (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
251
+ // without leaving the keyboard home row.
252
+ if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex };
253
+ // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
254
+ // multi-question mode, OR submit the dialog in single-question mode
255
+ // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
256
+ // question would have no way to commit at all.
257
+ return {
258
+ kind: "multi_confirm",
259
+ selected: buildMultiSelected(state, runtime),
260
+ autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
261
+ };
262
+ }
263
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
264
+ return { kind: "ignore" };
265
+ }
266
+
267
+ function routeSingleSelectTab(
268
+ kb: QuestionnaireKeybindings,
269
+ data: string,
270
+ state: QuestionnaireState,
271
+ runtime: QuestionnaireRuntime,
272
+ ): QuestionnaireAction {
273
+ if (isConfirm(kb, data)) {
274
+ const answer = buildSingleSelectAnswer(state, runtime);
275
+ if (!answer) return { kind: "ignore" };
276
+ return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
277
+ }
278
+ if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
279
+ return { kind: "ignore" };
280
+ }
281
+
153
282
  export function routeKey(data: string, state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction {
154
283
  const kb = runtime.keybindings;
155
284
 
@@ -185,60 +314,11 @@ export function routeKey(data: string, state: QuestionnaireState, runtime: Quest
185
314
  return { kind: "toggle_collapsed" };
186
315
  }
187
316
 
188
- // Collapsed-mode lockout: while collapsed, swallow every keystroke except cancel so
189
- // the user can read the now-uncovered transcript without accidentally mutating
190
- // answers or notes. The collapse toggle itself is already handled above.
191
- if (state.collapsed) {
192
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
193
- return { kind: "ignore" };
194
- }
195
-
196
- if (state.notesVisible) {
197
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "notes_exit" };
198
- if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "notes_forward", data };
199
- if (isConfirm(kb, data)) return { kind: "notes_exit" };
200
- return { kind: "notes_forward", data };
201
- }
202
-
203
- if (state.inputMode) {
204
- // Newline takes precedence over confirmation if a user configuration binds
205
- // the same physical key to both semantic actions.
206
- if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "ignore" };
207
- if (isConfirm(kb, data)) {
208
- const answer = buildSingleSelectAnswer(state, runtime);
209
- if (!answer) return { kind: "ignore" };
210
- return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
211
- }
212
- // Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear,
213
- // independent of the current cursor position.
214
- if (kb.matches(data, KEYBIND_CLEAR)) return { kind: "input_clear" };
215
- if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR)) return { kind: "input_edit", value: runtime.inputBuffer };
216
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
217
- if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) return { kind: "ignore" };
218
- if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) {
219
- return { kind: "ignore" };
220
- }
221
- if (kb.matches(data, KEYBIND_UP)) return prevNavOnUp(state, runtime);
222
- if (kb.matches(data, KEYBIND_DOWN)) return nextNavOnDown(state, runtime);
223
- return { kind: "ignore" };
224
- }
225
-
317
+ if (state.collapsed) return routeCollapsed(kb, data);
318
+ if (state.notesVisible) return routeNotesMode(kb, data);
319
+ if (state.inputMode) return routeInputMode(kb, data, state, runtime);
226
320
  if (runtime.isMulti && state.currentTab === runtime.questions.length) {
227
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
228
- const tab = tabSwitchAction(data, state, runtime);
229
- if (tab) return tab;
230
- if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
231
- const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
232
- const next = wrapTab(state.submitChoiceIndex + delta, 2);
233
- return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 };
234
- }
235
- if (isConfirm(kb, data)) {
236
- // D1 (revised): Submit always submits; Cancel always cancels. The warning header
237
- // is informational only — `allAnswered(state)` no longer gates submission. Partial
238
- // answers flow through `orderedAnswers()` in the host.
239
- return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" };
240
- }
241
- return { kind: "ignore" };
321
+ return routeSubmitTab(kb, data, state, runtime);
242
322
  }
243
323
 
244
324
  const tab = tabSwitchAction(data, state, runtime);
@@ -251,7 +331,8 @@ export function routeKey(data: string, state: QuestionnaireState, runtime: Quest
251
331
  // (single- or multi-select, preview or no-preview). The blocks above already
252
332
  // swallow `n` when it should NOT reach here — notesVisible forwards to the
253
333
  // notes Input, inputMode forwards to the inline Input, the submit-tab block
254
- // ignores it, and tabSwitchAction ignores it so by the time we reach this
334
+ // activates it via its own branch (the global note, ahead of its ignore
335
+ // fall-through), and tabSwitchAction ignores it — so by the time we reach this
255
336
  // gate, `n` is unambiguously a notes-enter request. Row intent (Next sentinel,
256
337
  // "Type something.") is irrelevant: the gate sits ABOVE the multi-select
257
338
  // toggle block and the Next sentinel never activates inputMode, so `n` is
@@ -267,46 +348,6 @@ export function routeKey(data: string, state: QuestionnaireState, runtime: Quest
267
348
  return nextNavOnDown(state, runtime);
268
349
  }
269
350
 
270
- if (q.multiSelect) {
271
- const focusedKind = runtime.currentItem?.kind;
272
- const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined;
273
- // Space toggles the focused row's checkbox. Suppressed on rows whose META declares
274
- // `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type
275
- // something." row — it is an inline input, not a checkable option).
276
- if (data === SPACE_KEY) {
277
- if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" };
278
- if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
279
- return { kind: "toggle", index: state.optionIndex };
280
- }
281
- if (isConfirm(kb, data)) {
282
- // Enter on the "Type something." row is handled by the inputMode block above
283
- // (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm
284
- // path for an inputMode-activating row.
285
- if (focusedMeta?.activatesInputMode) return { kind: "ignore" };
286
- // Enter on a regular row toggles (matching Space) — committing the question is now
287
- // gated behind explicit focus on a row whose META declares `autoSubmitsInMulti`
288
- // (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes
289
- // without leaving the keyboard home row.
290
- if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex };
291
- // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
292
- // multi-question mode, OR submit the dialog in single-question mode
293
- // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
294
- // question would have no way to commit at all.
295
- return {
296
- kind: "multi_confirm",
297
- selected: buildMultiSelected(state, runtime),
298
- autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
299
- };
300
- }
301
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
302
- return { kind: "ignore" };
303
- }
304
-
305
- if (isConfirm(kb, data)) {
306
- const answer = buildSingleSelectAnswer(state, runtime);
307
- if (!answer) return { kind: "ignore" };
308
- return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
309
- }
310
- if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" };
311
- return { kind: "ignore" };
351
+ if (q.multiSelect) return routeMultiSelectTab(kb, data, state, runtime);
352
+ return routeSingleSelectTab(kb, data, state, runtime);
312
353
  }
@@ -5,7 +5,7 @@ import type { QuestionData, QuestionnaireResult, QuestionParams } from "../tool/
5
5
  import type { WrappingSelectItem } from "../view/components/wrapping-select.js";
6
6
  import { COLLAPSED_HINT_TEMPLATE, HINT_PART_CANCEL, KEY_PLACEHOLDER } from "../view/dialog-builder.js";
7
7
  import type { QuestionnairePropsAdapter } from "../view/props-adapter.js";
8
- import { buildQuestionnaire } from "./build-questionnaire.js";
8
+ import { buildQuestionnaire, type QuestionnaireBuilt } from "./build-questionnaire.js";
9
9
  import { t } from "./i18n-bridge.js";
10
10
  import { type QuestionnaireAction, routeKey } from "./key-router.js";
11
11
  import type { QuestionnaireRuntime, QuestionnaireState } from "./state.js";
@@ -113,31 +113,38 @@ export class QuestionnaireSession {
113
113
  this.inlineInput = built.inlineInput;
114
114
  this.viewAdapter = built.adapter;
115
115
 
116
- const theme = config.theme;
117
- // Collapsed render: a single dim row at the bottom of the overlay. pi-tui sizes
118
- // the overlay to `min(lines.length, maxHeight)`, so returning one line shrinks
119
- // the bottom-anchored overlay from full-height to one row and the transcript
120
- // behind it becomes readable (#47). The overlay stays focused and in the
121
- // stack, so the collapse key still routes here to expand. `t` stays inside the
122
- // closure (live locale updates); the key display is static per session.
123
- //
124
- // With collapseKey "off" the router and raw listener never toggle `collapsed`,
125
- // but `toggleCollapsedExternal()` is a public ungated entry — fall back to the
126
- // cancel-only line rather than rendering a literal "Off to expand".
127
- const collapseKeyDisplay = formatKeySpecForDisplay(this.collapseKey);
128
- const collapsedHintLine = (): string =>
129
- this.collapseKey === COLLAPSE_KEY_OFF
130
- ? t("hint.cancel", HINT_PART_CANCEL)
131
- : t("hint.expand_line", COLLAPSED_HINT_TEMPLATE).replace(KEY_PLACEHOLDER, collapseKeyDisplay);
132
- const collapsedRender = (_width: number): string[] => [theme.fg("dim", ` ${collapsedHintLine()} `)];
116
+ this.component = this.assembleComponent(built, config.theme);
117
+ this.viewAdapter.apply(this.state);
118
+ }
133
119
 
134
- this.component = {
120
+ private assembleComponent(built: QuestionnaireBuilt, theme: Theme): QuestionnaireSessionComponent {
121
+ const collapsedRender = this.buildCollapsedRender(theme);
122
+ return {
135
123
  render: (width) => (this.state.collapsed ? collapsedRender(width) : built.render(width)),
136
124
  invalidate: built.invalidate,
137
125
  handleInput: (data) => this.dispatch(data),
138
126
  };
127
+ }
139
128
 
140
- this.viewAdapter.apply(this.state);
129
+ /**
130
+ * Collapsed render: a single dim row at the bottom of the overlay. pi-tui sizes
131
+ * the overlay to `min(lines.length, maxHeight)`, so returning one line shrinks
132
+ * the bottom-anchored overlay from full-height to one row and the transcript
133
+ * behind it becomes readable (#47). The overlay stays focused and in the
134
+ * stack, so the collapse key still routes here to expand. `t` stays inside the
135
+ * closure (live locale updates); the key display is static per session.
136
+ *
137
+ * With collapseKey "off" the router and raw listener never toggle `collapsed`,
138
+ * but `toggleCollapsedExternal()` is a public ungated entry — fall back to the
139
+ * cancel-only line rather than rendering a literal "Off to expand".
140
+ */
141
+ private buildCollapsedRender(theme: Theme): (width: number) => string[] {
142
+ const collapseKeyDisplay = formatKeySpecForDisplay(this.collapseKey);
143
+ const collapsedHintLine = (): string =>
144
+ this.collapseKey === COLLAPSE_KEY_OFF
145
+ ? t("hint.cancel", HINT_PART_CANCEL)
146
+ : t("hint.expand_line", COLLAPSED_HINT_TEMPLATE).replace(KEY_PLACEHOLDER, collapseKeyDisplay);
147
+ return (_width: number): string[] => [theme.fg("dim", ` ${collapsedHintLine()} `)];
141
148
  }
142
149
 
143
150
  dispatch(data: string): void {
@@ -174,18 +181,7 @@ export class QuestionnaireSession {
174
181
  this.inlineInput.setText("");
175
182
  return;
176
183
  case "open_input_editor":
177
- if (this.inputEditorOpen) return;
178
- this.inputEditorOpen = true;
179
- void this.editInput(effect.value).then(
180
- (value) => {
181
- this.inputEditorOpen = false;
182
- if (value !== undefined) this.commit({ kind: "input_replace", value });
183
- },
184
- () => {
185
- // The host callback reports launch errors; retain the draft and restore input handling.
186
- this.inputEditorOpen = false;
187
- },
188
- );
184
+ this.openInputEditorAsync(effect.value);
189
185
  return;
190
186
  case "set_notes_value":
191
187
  this.notesInput.setText(effect.value);
@@ -210,6 +206,22 @@ export class QuestionnaireSession {
210
206
  }
211
207
  }
212
208
 
209
+ /** Opens Pi's configured external editor; on success commits the replacement buffer, on reported failure retains the draft. */
210
+ private openInputEditorAsync(value: string): void {
211
+ if (this.inputEditorOpen) return;
212
+ this.inputEditorOpen = true;
213
+ void this.editInput(value).then(
214
+ (edited) => {
215
+ this.inputEditorOpen = false;
216
+ if (edited !== undefined) this.commit({ kind: "input_replace", value: edited });
217
+ },
218
+ () => {
219
+ // The host callback reports launch errors; retain the draft and restore input handling.
220
+ this.inputEditorOpen = false;
221
+ },
222
+ );
223
+ }
224
+
213
225
  /**
214
226
  * Per-keystroke `ignore` fast path: delegates text editing to Pi's headless
215
227
  * multiline `Editor`, including paste, undo, cursor movement, and configured
@@ -5,23 +5,32 @@ import type { SubmitPickerProps } from "../../view/components/submit-picker.js";
5
5
  import type { TabBarProps } from "../../view/components/tab-bar.js";
6
6
  import type { DialogProps } from "../../view/dialog-builder.js";
7
7
  import { displayLabel } from "../i18n-bridge.js";
8
- import type { GlobalSelector, PerTabSelector } from "./contract.js";
8
+ import type { GlobalSelector, PerTabBindingContext, PerTabSelector } from "./contract.js";
9
9
  import { selectConfirmedIndicator } from "./derivations.js";
10
10
 
11
+ function emptyMultiSelectProps(ctx: PerTabBindingContext): MultiSelectViewProps {
12
+ return {
13
+ rows: [],
14
+ other: {
15
+ active: false,
16
+ inputMode: false,
17
+ inputBuffer: ctx.inputBuffer,
18
+ inputCursorOffset: ctx.inputCursorOffset,
19
+ },
20
+ nextActive: false,
21
+ nextLabel: displayLabel("next"),
22
+ };
23
+ }
24
+
25
+ function nextLabelFor(ctx: PerTabBindingContext): string {
26
+ const isLastQuestion = ctx.i === ctx.questions.length - 1;
27
+ return isLastQuestion ? MULTI_SUBMIT_LABEL : displayLabel("next");
28
+ }
29
+
11
30
  export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (state, ctx) => {
12
31
  const question = ctx.questions[ctx.i];
13
32
  if (!question) {
14
- return {
15
- rows: [],
16
- other: {
17
- active: false,
18
- inputMode: false,
19
- inputBuffer: ctx.inputBuffer,
20
- inputCursorOffset: ctx.inputCursorOffset,
21
- },
22
- nextActive: false,
23
- nextLabel: displayLabel("next"),
24
- };
33
+ return emptyMultiSelectProps(ctx);
25
34
  }
26
35
  const focused = ctx.activeView === "options";
27
36
  const rows: { checked: boolean; active: boolean }[] = [];
@@ -33,8 +42,6 @@ export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (sta
33
42
  }
34
43
  const otherActive = focused && state.optionIndex === question.options.length;
35
44
  const nextActive = focused && state.optionIndex === question.options.length + 1;
36
- const isLastQuestion = ctx.i === ctx.questions.length - 1;
37
- const nextLabel = isLastQuestion ? MULTI_SUBMIT_LABEL : displayLabel("next");
38
45
  return {
39
46
  rows,
40
47
  other: {
@@ -44,7 +51,7 @@ export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (sta
44
51
  inputCursorOffset: ctx.inputCursorOffset,
45
52
  },
46
53
  nextActive,
47
- nextLabel,
54
+ nextLabel: nextLabelFor(ctx),
48
55
  };
49
56
  };
50
57
 
@@ -139,7 +139,17 @@ function switchTabResult(state: QuestionnaireState, nextTab: number, ctx: ApplyC
139
139
  }
140
140
 
141
141
  function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolean): ApplyResult {
142
- const result: QuestionnaireResult = { answers: orderedAnswers(state, ctx.questions), cancelled };
142
+ // Global note lift: the Submit-tab note lives at the `questions.length` pseudo-index
143
+ // in `notesByTab` — question tabs only occupy 0..questions.length-1, so this can never
144
+ // cross-contaminate a per-question note. Attached regardless of `cancelled` (the
145
+ // reducer is truth; the envelope owns decline presentation), with cancel/submit/confirm
146
+ // sharing this single lift. Conditional spread keeps note-free results byte-identical.
147
+ const globalNote = state.notesByTab.get(ctx.questions.length);
148
+ const result: QuestionnaireResult = {
149
+ answers: orderedAnswers(state, ctx.questions),
150
+ cancelled,
151
+ ...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
152
+ };
143
153
  return { state, effects: [{ kind: "done", result }] };
144
154
  }
145
155
 
@@ -9,12 +9,18 @@ export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in
9
9
  * Map a `QuestionnaireResult` (or null/cancelled) to the LLM-facing tool envelope.
10
10
  * Pure of `(result, params)`; cancelled and "no segments" both fall to `DECLINE_MESSAGE`
11
11
  * so the model sees a single canonical "didn't answer" signal regardless of why.
12
+ * "No segments" means no answers AND no global note — the `global note:` segment is
13
+ * pushed before the zero-segments check, so a note-bearing submit with zero answers
14
+ * still yields the answered envelope.
12
15
  */
13
16
  export function buildQuestionnaireResponse(result: QuestionnaireResult | null | undefined, params: QuestionParams) {
14
17
  if (!result || result.cancelled) {
18
+ // Decline text stays canonical even when a global note rides the cancelled result;
19
+ // the note survives in `details` (like partial `answers`) for replay consumers.
15
20
  return buildToolResult(DECLINE_MESSAGE, {
16
21
  answers: result?.answers ?? [],
17
22
  cancelled: true,
23
+ ...(result?.globalNote && result.globalNote.length > 0 ? { globalNote: result.globalNote } : {}),
18
24
  });
19
25
  }
20
26
  const segments: string[] = [];
@@ -22,6 +28,11 @@ export function buildQuestionnaireResponse(result: QuestionnaireResult | null |
22
28
  const a = result.answers.find((x) => x.questionIndex === i);
23
29
  if (a) segments.push(buildAnswerSegment(a));
24
30
  }
31
+ // Global note rides after the per-question segments: raw multiline echo (no
32
+ // reformatting), trailing period mirroring `buildAnswerSegment`'s shape.
33
+ if (result.globalNote && result.globalNote.length > 0) {
34
+ segments.push(`global note: ${result.globalNote}.`);
35
+ }
25
36
  if (segments.length === 0) {
26
37
  return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
27
38
  }
@@ -135,6 +135,17 @@ export type QuestionnaireError =
135
135
  export interface QuestionnaireResult {
136
136
  answers: QuestionAnswer[];
137
137
  cancelled: boolean;
138
+ /**
139
+ * Global note authored on the Submit tab: `n` opens the shared notes editor there,
140
+ * and the committed text lives at the `notesByTab[questions.length]` pseudo-index
141
+ * (a slot no question tab can occupy) until `doneFor` lifts it onto the result —
142
+ * attached on both submit and cancel, like per-question `answers[].notes`.
143
+ * Conditional-spread contract, mirroring `QuestionAnswer.notes`: the key appears
144
+ * only via conditional spread of a non-empty string — never assigned `undefined`,
145
+ * never kept for an empty/whitespace-only draft — so note-free results stay
146
+ * byte-identical (`!("globalNote" in result)` holds).
147
+ */
148
+ globalNote?: string;
138
149
  error?: QuestionnaireError;
139
150
  }
140
151
 
@@ -50,6 +50,12 @@ interface MultiSelectLayout {
50
50
  focusedRange: [number, number];
51
51
  }
52
52
 
53
+ /** Mutable row accumulator threaded through the append helpers during a layout miss. */
54
+ interface MultiSelectBuild {
55
+ lines: string[];
56
+ focusedRange: [number, number];
57
+ }
58
+
53
59
  export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
54
60
  private props: MultiSelectViewProps;
55
61
  private cachedLayout: { width: number; value: MultiSelectLayout } | undefined;
@@ -92,47 +98,55 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
92
98
  private layout(width: number): MultiSelectLayout {
93
99
  if (this.cachedLayout?.width === width) return this.cachedLayout.value;
94
100
 
95
- const lines: string[] = [];
96
- let focusedRange: [number, number] = [0, 0];
101
+ const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
97
102
  const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
98
103
  const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
104
+
105
+ this.appendOptionRows(build, width, contentWidth, numberWidth);
106
+
107
+ const otherStart = build.lines.length;
108
+ build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
109
+ if (this.props.other.active) build.focusedRange = [otherStart, build.lines.length];
110
+
111
+ this.appendNextRow(build, width);
112
+
113
+ const value = { lines: build.lines, focusedRange: build.focusedRange };
114
+ this.cachedLayout = { width, value };
115
+ return value;
116
+ }
117
+
118
+ private appendOptionRows(build: MultiSelectBuild, width: number, contentWidth: number, numberWidth: number): void {
99
119
  for (let i = 0; i < this.question.options.length; i++) {
100
120
  const opt = this.question.options[i];
101
121
  const row = this.props.rows[i];
102
122
  if (!opt || !row) continue;
103
- const start = lines.length;
123
+ const start = build.lines.length;
104
124
  const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
105
125
  // Checked and active rows share the accent hue, matching the dialog's selection rhythm.
106
126
  const box = row.checked ? this.theme.fg("accent", CHECKED) : this.theme.fg("muted", UNCHECKED);
107
127
  const label = truncateToWidth(opt.label, contentWidth, "…");
108
128
  const styledLabel = row.active ? this.theme.fg("accent", this.theme.bold(label)) : label;
109
129
  const number = String(i + 1).padStart(numberWidth, " ");
110
- lines.push(
130
+ build.lines.push(
111
131
  truncateToWidth(`${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}${styledLabel}`, width, ""),
112
132
  );
113
133
  if (opt.description) {
114
134
  for (const segment of wrapTextWithAnsi(opt.description, contentWidth)) {
115
- lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
135
+ build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
116
136
  }
117
137
  }
118
- if (row.active) focusedRange = [start, lines.length];
138
+ if (row.active) build.focusedRange = [start, build.lines.length];
119
139
  }
140
+ }
120
141
 
121
- const otherStart = lines.length;
122
- lines.push(...this.renderOtherRow(contentWidth, numberWidth));
123
- if (this.props.other.active) focusedRange = [otherStart, lines.length];
124
-
125
- const nextStart = lines.length;
142
+ private appendNextRow(build: MultiSelectBuild, width: number): void {
143
+ const nextStart = build.lines.length;
126
144
  const nextPointer = this.props.nextActive ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
127
145
  const nextLabel = this.props.nextActive
128
146
  ? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
129
147
  : this.props.nextLabel;
130
- lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
131
- if (this.props.nextActive) focusedRange = [nextStart, lines.length];
132
-
133
- const value = { lines, focusedRange };
134
- this.cachedLayout = { width, value };
135
- return value;
148
+ build.lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
149
+ if (this.props.nextActive) build.focusedRange = [nextStart, build.lines.length];
136
150
  }
137
151
 
138
152
  private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
@@ -23,6 +23,17 @@ import type { PreviewLayoutMode } from "./preview-layout-decider.js";
23
23
  */
24
24
  export const NOTES_AFFORDANCE_TEXT = "Notes: press n to add notes";
25
25
 
26
+ /** Content row budget for a layout mode: preview cap minus border + affordance overhead. */
27
+ function contentBudgetFor(mode: PreviewLayoutMode): number {
28
+ const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
29
+ return Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
30
+ }
31
+
32
+ /** Inner (padding-aware) content width for a total block width. */
33
+ function innerWidthFor(width: number): number {
34
+ return Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
35
+ }
36
+
26
37
  export interface PreviewBlockRendererConfig {
27
38
  question: QuestionData;
28
39
  theme: Theme;
@@ -66,9 +77,8 @@ export class PreviewBlockRenderer {
66
77
  * — the affordance overhead is constant, not gated by `focused`/`notesVisible`.
67
78
  */
68
79
  blockHeight(width: number, optionIndex: number, mode: PreviewLayoutMode): number {
69
- const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
70
- const contentBudget = Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
71
- const innerWidth = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
80
+ const contentBudget = contentBudgetFor(mode);
81
+ const innerWidth = innerWidthFor(width);
72
82
  const rawRows = this.cache.bodyFor(optionIndex, innerWidth).length;
73
83
  const contentRows = Math.min(rawRows, contentBudget);
74
84
  return BORDER_VERTICAL_OVERHEAD + contentRows + NOTES_AFFORDANCE_OVERHEAD;
@@ -87,9 +97,8 @@ export class PreviewBlockRenderer {
87
97
  focused: boolean,
88
98
  notesVisible: boolean,
89
99
  ): string[] {
90
- const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
91
- const contentBudget = Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
92
- const maxInnerWidth = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
100
+ const contentBudget = contentBudgetFor(mode);
101
+ const maxInnerWidth = innerWidthFor(width);
93
102
 
94
103
  const raw = this.cache.bodyFor(optionIndex, maxInnerWidth);
95
104
  const truncated = raw.length > contentBudget;
@@ -216,6 +216,20 @@ export class WrappingSelect implements Component {
216
216
  return this.renderInlineInputRow(rowPrefix, continuationPrefix, contentWidth);
217
217
  }
218
218
 
219
+ const { label, isConfirmed } = this.deriveConfirmedState(item, index);
220
+ const applySelectedStyle = isActive || isConfirmed;
221
+
222
+ return [
223
+ ...this.renderLabelBlock(label, rowPrefix, continuationPrefix, contentWidth, applySelectedStyle),
224
+ ...this.renderDescriptionBlock(item.description, continuationPrefix, contentWidth),
225
+ ];
226
+ }
227
+
228
+ /**
229
+ * Derive the confirmed-rendering facts for one row: the final label (custom draft
230
+ * or confirmed-override aware) and whether the ✔ mark applies.
231
+ */
232
+ private deriveConfirmedState(item: WrappingSelectItem, index: number): { label: string; isConfirmed: boolean } {
219
233
  // Keep an in-flight custom draft visible even while the cursor browses another row.
220
234
  // If it differs from a previously confirmed custom answer, omit the confirmation
221
235
  // mark so the pending draft is not presented as committed.
@@ -230,12 +244,7 @@ export class WrappingSelect implements Component {
230
244
  const label = isConfirmed
231
245
  ? `${this.confirmedLabelOverride ?? baseLabel}${WrappingSelect.CONFIRMED_MARK}`
232
246
  : baseLabel;
233
- const applySelectedStyle = isActive || isConfirmed;
234
-
235
- return [
236
- ...this.renderLabelBlock(label, rowPrefix, continuationPrefix, contentWidth, applySelectedStyle),
237
- ...this.renderDescriptionBlock(item.description, continuationPrefix, contentWidth),
238
- ];
247
+ return { label, isConfirmed };
239
248
  }
240
249
 
241
250
  private buildRowPrefix(index: number, isActive: boolean, numberWidth: number): string {