import { Key, matchesKey } from "@earendil-works/pi-tui"; import type { QuestionAnswer } from "../tool/types.js"; import { ROW_INTENT_META } from "./row-intent.js"; import type { QuestionnaireRuntime, QuestionnaireState } from "./state.js"; const KEYBIND_UP = "tui.select.up"; const KEYBIND_DOWN = "tui.select.down"; const KEYBIND_CONFIRM = "tui.select.confirm"; const KEYBIND_CANCEL = "tui.select.cancel"; const KEYBIND_NEW_LINE = "tui.input.newLine"; const KEYBIND_EDITOR_UP = "tui.editor.cursorUp"; const KEYBIND_EDITOR_DOWN = "tui.editor.cursorDown"; const KEYBIND_CLEAR = "tui.editor.deleteToLineStart"; const KEYBIND_EXTERNAL_EDITOR = "app.editor.external"; const NOTES_ACTIVATE_KEY = "n"; const SPACE_KEY = " "; const MAX_DIRECT_OPTION_KEY = 9; export type QuestionnaireAction = | { kind: "nav"; nextIndex: number; inputValue: string } | { kind: "input_clear" } | { kind: "input_edit"; value: string } | { kind: "input_replace"; value: string } | { kind: "tab_switch"; nextTab: number } | { kind: "confirm"; answer: QuestionAnswer; autoAdvanceTab?: number } | { kind: "toggle"; index: number } | { kind: "multi_confirm"; selected: string[]; autoAdvanceTab?: number } | { kind: "cancel" } | { kind: "notes_enter" } | { kind: "notes_exit" } | { kind: "submit" } | { kind: "submit_nav"; nextIndex: 0 | 1 } | { kind: "notes_forward"; data: string } /** Flip `state.collapsed`. Always available, regardless of inner mode (see top intercept in `routeKey`). */ | { kind: "toggle_collapsed" } | { kind: "ignore" }; export interface QuestionnaireKeybindings { matches(data: string, name: string): boolean; } export function wrapTab(index: number, total: number): number { if (total <= 0) return 0; return ((index % total) + total) % total; } export function allAnswered(state: QuestionnaireState, runtime: QuestionnaireRuntime): boolean { if (runtime.questions.length === 0) return false; for (let i = 0; i < runtime.questions.length; i++) { if (!state.answers.has(i)) return false; } return true; } function totalTabs(runtime: QuestionnaireRuntime): number { return runtime.isMulti ? runtime.questions.length + 1 : 1; } function computeAutoAdvanceTab(state: QuestionnaireState, runtime: QuestionnaireRuntime): number | undefined { if (!runtime.isMulti) return undefined; if (state.currentTab < runtime.questions.length - 1) return state.currentTab + 1; return runtime.questions.length; } function buildSingleSelectAnswer(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionAnswer | null { const q = runtime.questions[state.currentTab]; if (!q) return null; const item = runtime.currentItem; if (state.inputMode) { const label = runtime.inputBuffer; return { questionIndex: state.currentTab, question: q.question, kind: "custom", answer: label.length > 0 ? label : null, }; } if (!item) return null; if (item.kind === "other") { return null; } if (item.kind === "next") { return null; } return { questionIndex: state.currentTab, question: q.question, kind: "option", answer: item.label, }; } function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] { const q = runtime.questions[state.currentTab]; if (!q) return []; const out: string[] = []; for (let i = 0; i < q.options.length; i++) { if (state.multiSelectChecked.has(i)) { const label = q.options[i]?.label; if (typeof label === "string") out.push(label); } } return out; } function directRowIndex(data: string, rowCount: number): number | undefined { const upper = Math.min(rowCount, MAX_DIRECT_OPTION_KEY); for (let index = 0; index < upper; index++) { const key = String(index + 1) as Parameters[1]; if (matchesKey(data, key)) return index; } return undefined; } function tabSwitchAction( data: string, state: QuestionnaireState, runtime: QuestionnaireRuntime, ): QuestionnaireAction | null { if (!runtime.isMulti) return null; const total = totalTabs(runtime); if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) }; } if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { return { kind: "tab_switch", nextTab: wrapTab(state.currentTab - 1, total) }; } return null; } // DOWN at the last item wraps to the first (cycle through [option0, …, optionLast]). function nextNavOnDown(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction { return { kind: "nav", nextIndex: wrapTab(state.optionIndex + 1, Math.max(1, runtime.items.length)), inputValue: runtime.inputBuffer, }; } // UP at the first item wraps to the last (symmetric with nextNavOnDown). function prevNavOnUp(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction { return { kind: "nav", nextIndex: wrapTab(state.optionIndex - 1, Math.max(1, runtime.items.length)), inputValue: runtime.inputBuffer, }; } export function routeKey(data: string, state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction { const kb = runtime.keybindings; // Collapse/expand is intercepted at the top so it works from every inner state // (notes, inputMode, submit tab, multi-select) without reaching a branch that // would otherwise consume the keystroke. The non-overlay custom component remains // focused while collapsed, so the same routing path handles expand as well. // // The default `ctrl+]` is free in every mainstream macOS terminal (Terminal.app, // iTerm2, Warp), every multiplexer (tmux, zellij, screen — none use it as a prefix), // and the legacy telnet/ssh escape role doesn't apply because our overlay runs // in-process. It is, however, awkward on keyboard layouts where `]` is on the // shifted layer (Latin American `es-AR`/`es-MX` require `Ctrl+Shift+}` for `Ctrl+]`) // — use the `collapseKey` config field to override. // Treat a missing/non-string key as disabled. This can occur at runtime when // a long-lived Pi process retains an older outer module while a package update // replaces the lazily imported QuestionnaireSession graph on disk. Passing // undefined into matchesKey reaches parseKeyId().toLowerCase() and crashes the // entire host process, so keep the runtime boundary defensive even though the // TypeScript contract requires a string. if ( typeof runtime.collapseKey === "string" && runtime.collapseKey !== "off" && matchesKey(data, runtime.collapseKey as Parameters[1]) ) { return { kind: "toggle_collapsed" }; } // Collapsed-mode lockout: while collapsed, swallow every keystroke except cancel so // the user can read the reflowed transcript without accidentally mutating answers // or notes. The collapse toggle itself is already handled above. if (state.collapsed) { if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" }; return { kind: "ignore" }; } if (state.notesVisible) { if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "notes_exit" }; if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "notes_forward", data }; if (kb.matches(data, KEYBIND_CONFIRM)) return { kind: "notes_exit" }; return { kind: "notes_forward", data }; } if (state.inputMode) { // Newline takes precedence over confirmation if a user configuration binds // the same physical key to both semantic actions. if (kb.matches(data, KEYBIND_NEW_LINE)) return { kind: "ignore" }; if (kb.matches(data, KEYBIND_CONFIRM)) { const answer = buildSingleSelectAnswer(state, runtime); if (!answer) return { kind: "ignore" }; return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) }; } // Treat Pi's Ctrl+U line-kill binding as an explicit whole-draft clear, // independent of the current cursor position. if (kb.matches(data, KEYBIND_CLEAR)) return { kind: "input_clear" }; if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR)) return { kind: "input_edit", value: runtime.inputBuffer }; if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" }; if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp) return { kind: "ignore" }; if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown) { return { kind: "ignore" }; } if (kb.matches(data, KEYBIND_UP)) return prevNavOnUp(state, runtime); if (kb.matches(data, KEYBIND_DOWN)) return nextNavOnDown(state, runtime); return { kind: "ignore" }; } if (runtime.isMulti && state.currentTab === runtime.questions.length) { if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" }; const tab = tabSwitchAction(data, state, runtime); if (tab) return tab; if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) { const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1; const next = wrapTab(state.submitChoiceIndex + delta, 2); return { kind: "submit_nav", nextIndex: (next === 1 ? 1 : 0) as 0 | 1 }; } if (kb.matches(data, KEYBIND_CONFIRM)) { // D1 (revised): Submit always submits; Cancel always cancels. The warning header // is informational only — `allAnswered(state)` no longer gates submission. Partial // answers flow through `orderedAnswers()` in the host. return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" }; } return { kind: "ignore" }; } const tab = tabSwitchAction(data, state, runtime); if (tab) return tab; const q = runtime.questions[state.currentTab]; if (!q) return { kind: "ignore" }; // Number keys address authored options plus the immediately following "Type something." // row. Once that row gains focus, inputMode returns above before this block, so every // subsequent digit is inserted as text. The final Next row in multi-select questions // remains navigation-only and cannot be submitted accidentally by number. const directIndex = directRowIndex(data, q.options.length + 1); if (directIndex !== undefined) { if (directIndex === q.options.length) { const directItem = runtime.items[directIndex]; return directItem?.kind === "other" ? { kind: "nav", nextIndex: directIndex, inputValue: runtime.inputBuffer } : { kind: "ignore" }; } if (q.multiSelect) return { kind: "toggle", index: directIndex }; const option = q.options[directIndex]; if (!option) return { kind: "ignore" }; return { kind: "confirm", answer: { questionIndex: state.currentTab, question: q.question, kind: "option", answer: option.label, }, autoAdvanceTab: computeAutoAdvanceTab(state, runtime), }; } // Universal `n` activation (FR-1): the notes editor opens on every question tab // (single- or multi-select, preview or no-preview). The blocks above already // swallow `n` when it should NOT reach here — notesVisible forwards to the // notes Input, inputMode forwards to the inline Input, the submit-tab block // ignores it, and tabSwitchAction ignores it — so by the time we reach this // gate, `n` is unambiguously a notes-enter request. Row intent (Next sentinel, // "Type something.") is irrelevant: the gate sits ABOVE the multi-select // toggle block and the Next sentinel never activates inputMode, so `n` is // neither swallowed earlier nor blocked by `blocksMultiToggle`. if (data === NOTES_ACTIVATE_KEY) { return { kind: "notes_enter" }; } if (kb.matches(data, KEYBIND_UP)) { return prevNavOnUp(state, runtime); } if (kb.matches(data, KEYBIND_DOWN)) { return nextNavOnDown(state, runtime); } if (q.multiSelect) { const focusedKind = runtime.currentItem?.kind; const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined; // Space toggles the focused row's checkbox. Suppressed on rows whose META declares // `blocksMultiToggle` (the Next sentinel) or `activatesInputMode` (the "Type // something." row — it is an inline input, not a checkable option). if (data === SPACE_KEY) { if (focusedMeta?.blocksMultiToggle) return { kind: "ignore" }; if (focusedMeta?.activatesInputMode) return { kind: "ignore" }; return { kind: "toggle", index: state.optionIndex }; } if (kb.matches(data, KEYBIND_CONFIRM)) { // Enter on the "Type something." row is handled by the inputMode block above // (→ confirm kind:"custom"). Defensive: never enter the toggle/multi_confirm // path for an inputMode-activating row. if (focusedMeta?.activatesInputMode) return { kind: "ignore" }; // Enter on a regular row toggles (matching Space) — committing the question is now // gated behind explicit focus on a row whose META declares `autoSubmitsInMulti` // (the Next sentinel), so Enter on options is a no-cost way to flip checkboxes // without leaving the keyboard home row. if (!focusedMeta?.autoSubmitsInMulti) return { kind: "toggle", index: state.optionIndex }; // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in // multi-question mode, OR submit the dialog in single-question mode // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select // question would have no way to commit at all. return { kind: "multi_confirm", selected: buildMultiSelected(state, runtime), autoAdvanceTab: computeAutoAdvanceTab(state, runtime), }; } if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" }; return { kind: "ignore" }; } if (kb.matches(data, KEYBIND_CONFIRM)) { const answer = buildSingleSelectAnswer(state, runtime); if (!answer) return { kind: "ignore" }; return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) }; } if (kb.matches(data, KEYBIND_CANCEL)) return { kind: "cancel" }; return { kind: "ignore" }; }