@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.
- package/README.md +7 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
- package/node_modules/@narumitw/pi-btw/package.json +2 -2
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
- package/node_modules/pi-lens/CHANGELOG.md +90 -0
- package/node_modules/pi-lens/README.md +106 -0
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
- package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
- package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
- package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
- package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
- package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
- package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
- package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
- package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
- package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
- package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
- package/node_modules/pi-lens/dist/index.js +1224 -393
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
- package/node_modules/pi-lens/package.json +18 -5
- package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
- package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
- package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
- package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
- package/node_modules/pi-web-access/CHANGELOG.md +14 -0
- package/node_modules/pi-web-access/README.md +6 -5
- package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
- package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
- package/node_modules/pi-web-access/extract.ts +12 -2
- package/node_modules/pi-web-access/firecrawl.ts +18 -2
- package/node_modules/pi-web-access/github-extract.ts +65 -36
- package/node_modules/pi-web-access/index.ts +19 -4
- package/node_modules/pi-web-access/openai-search.ts +17 -6
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/pdf-extract.ts +18 -5
- package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
- 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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
122
|
-
lines.
|
|
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
|
|
70
|
-
const
|
|
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
|
|
91
|
-
const
|
|
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
|
-
|
|
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 {
|