diff --git a/docs/okr/2026-03-30-inline-note-char-level-visual-navigation.md b/docs/okr/2026-03-30-inline-note-char-level-visual-navigation.md new file mode 100644 index 0000000..2ea4009 --- /dev/null +++ b/docs/okr/2026-03-30-inline-note-char-level-visual-navigation.md @@ -0,0 +1,175 @@ +# OKR — Char-Level Visual Navigation for Wrapped Inline Note Editing + +Date: 2026-03-30 +Related track: B2 (follow-up to wrapped inline note visual cursor work) +Related context: +- issue #1 comment `#issuecomment-4150421927` +- existing intermediate fix: word-wrap-based visual movement (`B1`) + +## Objective + +Make wrapped inline note editing in `pi-ask-tool` follow the **actual visible rendered lines** so that `↑` and `↓` move the caret one on-screen line at a time, even in the presence of repeated spaces, reflow-sensitive content, and inline renderer-specific wrapping. + +## Why This Exists + +The current intermediate solution improves vertical movement by using `pi-tui` editor internals for visual-line movement. However, those internals are based on the editor's own `wordWrapLine` segmentation, which is not guaranteed to match the exact visual rows produced by `pi-ask-tool`'s inline option renderer. + +That means the current state can still diverge from the user's actual visual expectation in cases like: +- repeated spaces +- wrap-sensitive phrase boundaries +- inline prefix/marker/separator width effects +- renderer-specific wrapping differences + +This OKR defines the next step: movement based on **the rendered rows the user actually sees**, not only on editor-internal wrapped segments. + +## Desired User Behavior + +When an inline note is visually wrapped across multiple on-screen rows: +- `↑` moves the caret to the visible row above +- `↓` moves the caret to the visible row below +- movement preserves the same visible column when possible +- if the target row is shorter, the caret clamps to the nearest valid visible position +- behavior remains stable even when the text contains repeated spaces +- behavior matches what the user sees, not merely what the editor's internal word-wrap model predicts + +## Key Results + +1. **Rendered-row semantics** + - Inline note cursor movement uses the same row segmentation as the displayed inline note rendering. + +2. **Char/cell-accurate vertical movement** + - `↑` / `↓` preserve visible column position as closely as possible on the target rendered row. + +3. **Repeated-space robustness** + - Cases with multiple spaces do not cause obviously incorrect vertical jumps or misleading reflow behavior. + +4. **Renderer parity** + - The mapping used for cursor movement matches the wrapping used by inline note display. + +5. **Cross-screen consistency** + - The same behavior works in: + - `ask-inline-ui.ts` + - `ask-tabs-ui.ts` + +6. **Regression safety** + - Add focused tests for repeated spaces, short/long row transitions, and rendered-row movement. + +7. **Quality bar maintained** + - `npm run typecheck` passes + - targeted UI tests pass + - existing note editing behavior remains intact + +## Acceptance Criteria + +### Functional behavior +- `↑` / `↓` move by one **visible rendered row** at a time. +- Movement follows the actual wrapped rows shown in the inline note UI. +- `←` / `→` remain logical left/right movement. +- `Tab`, `Esc`, `Enter`, submit, cancel, and `Other` validation rules remain unchanged. + +### Stability behavior +- Multiple spaces do not produce obviously incorrect row targeting. +- Movement from long row → short row clamps safely. +- Movement from short row → long row restores as much visible column as possible. +- Width changes produce a new correct rendered-row map before subsequent `↑` / `↓` movement. + +### Scope behavior +- Single-question inline ask flow supports rendered-row movement. +- Tabbed ask flow supports rendered-row movement. +- No UX regression for inline note editing when no wrapping occurs. + +## Key Scenarios + +### Scenario 1 — Wrapped note with normal spacing +- A note wraps into three visible rows. +- `↑` / `↓` move between those rows predictably. + +### Scenario 2 — Repeated spaces +- A note contains multiple spaces between words. +- The visible wrapping may shift in non-trivial ways. +- `↑` / `↓` still follow the actual rendered rows. + +### Scenario 3 — Long row to shorter row +- Caret starts near the end of a long rendered row. +- Moving to a shorter row clamps to the nearest valid visible position. + +### Scenario 4 — Short row back to longer row +- After clamping onto a shorter row, moving back should preserve as much intended visible column as possible. + +### Scenario 5 — Same behavior in tabbed flow +- A wrapped inline note in tabbed ask uses the same movement semantics as the single-question flow. + +### Scenario 6 — Resize-aware follow-up movement +- After width changes and rewrap, the next `↑` / `↓` action uses the new rendered-row map. + +## Proposed Implementation Direction + +## Core principle +Do not rely solely on `pi-tui`'s internal editor visual-line map for vertical navigation. +Instead, compute cursor movement from the **same rendered-row model used by the inline note UI**. + +### Minimum architectural pieces +1. **Rendered-row map generator** + - Produces the same wrapped rows used by inline note rendering + - Tracks source index ranges for each rendered row + +2. **Cursor position mapper** + - Converts current source cursor index → rendered row + visible column + +3. **Rendered-row movement function** + - Moves to row - 1 / row + 1 + - Preserves visible column where possible + - Clamps safely when needed + +4. **Source-index resolver** + - Converts target rendered row + visible column back to source cursor index + +### Suggested file shape +A focused helper module is preferred, for example: +- `src/ask-inline-note-map.ts` +- or equivalent naming + +This helper should be the source of truth for: +- rendered inline note rows +- cursor-to-row mapping +- row-based cursor movement + +### Important constraint +The wrapping logic used for cursor movement must match the wrapping logic used for rendering. +If these diverge, the UI will appear inconsistent even if the code "works." + +## Non-goals + +- Replacing the `Editor` implementation +- Replacing `pi-tui` +- Broad terminal layout framework work +- Solving unrelated tmux resize redraw issues in this same task +- Redesigning the inline note UX into a separate modal/editor + +## Risks + +- Source index ↔ rendered row/column mapping can become subtle around repeated spaces. +- ANSI/cursor rendering details must not leak into logical mapping assumptions. +- Width changes can invalidate preferred-column memory if not handled carefully. +- Single-question and tabbed flows could drift if the mapping helper is not shared. + +## Definition of Done + +This OKR is complete when: +- `↑` / `↓` in wrapped inline note editing follow actual visible rendered rows +- repeated-space cases behave consistently with on-screen expectations +- single-question and tabbed flows share the same movement semantics +- existing editing behavior remains intact +- tests cover the important rendered-row edge cases +- `npm run typecheck` and targeted tests pass + +## Suggested Work Breakdown + +1. Define a rendered-row map structure for inline notes. +2. Extract or unify wrapping behavior so rendering and movement share the same model. +3. Implement cursor-index → rendered-row/column mapping. +4. Implement rendered-row up/down movement. +5. Integrate into `ask-inline-ui.ts`. +6. Integrate into `ask-tabs-ui.ts`. +7. Add repeated-space and clamp behavior tests. +8. Verify behavior in real `pi` + tmux interaction. diff --git a/docs/okr/2026-03-30-inline-note-visual-cursor-navigation.md b/docs/okr/2026-03-30-inline-note-visual-cursor-navigation.md new file mode 100644 index 0000000..362ae8a --- /dev/null +++ b/docs/okr/2026-03-30-inline-note-visual-cursor-navigation.md @@ -0,0 +1,154 @@ +# Plan — Visual Up/Down Navigation for Wrapped Inline Note Editing + +Date: 2026-03-30 +Related source: issue #1 comment `#issuecomment-4150421927` +Scope: wrapped inline note editing behavior in `ask-inline-ui.ts` and `ask-tabs-ui.ts` + +## Summary + +Wrapped inline note editing currently presents note text across multiple visual lines, but `↑` and `↓` behavior appears to follow the editor's logical/raw-text cursor model rather than the user's visual-line expectation. This creates a mismatch: users see multiple wrapped lines, but vertical cursor movement does not behave like moving one visible line up or down. + +This work should be tracked separately from overflow/viewport scrolling because it changes **cursor movement semantics**, not just screen visibility. + +## Objective + +Make `↑` / `↓` during wrapped inline note editing move the caret by **one visible wrapped line** at a time, preserving user expectations for vertical navigation. + +## Problem Statement + +Today, inline note editing uses the underlying editor for text state and cursor state, while the displayed caret is rendered inside a wrapped presentation layer. The displayed text is visually multi-line, but vertical movement does not appear to respect visual lines. In practice this means: + +- `↑` / `↓` can feel like moving through raw text positions instead of visible rows +- behavior becomes more confusing with multiple spaces and wrapping/reflow +- caret display may be accurate, but movement semantics still feel wrong + +## Desired Behavior + +When a note is visually wrapped into multiple on-screen lines: + +- `↑` moves the caret to the line above +- `↓` moves the caret to the line below +- horizontal position should be preserved as much as possible +- if the target line is shorter, the target position clamps to the nearest valid column +- `←` / `→` keep their existing logical left/right behavior +- `Enter`, `Tab`, `Esc`, and submit/cancel behavior remain unchanged + +## Acceptance Criteria + +1. **Visual-line vertical navigation** + - In wrapped note editing, `↑` and `↓` move relative to visible lines rather than raw string positions. + +2. **Preferred column preservation** + - Repeated `↑` / `↓` attempts to preserve the same visual column when moving between wrapped lines. + +3. **Line-length clamping** + - When moving to a shorter line, the caret lands at the nearest valid position on that line. + +4. **Whitespace robustness** + - Multiple spaces and awkward wrap boundaries do not produce obviously broken vertical movement. + +5. **Parity across both UIs** + - Behavior is consistent in: + - `ask-inline-ui.ts` + - `ask-tabs-ui.ts` + +6. **No regression in existing controls** + - `←` / `→`, typing, submit, cancel, and `Other` note requirements continue to work. + +7. **Verification** + - Automated tests cover the main navigation scenarios. + - `npm run check` passes after the change. + +## Non-goals + +- Replacing the text editor implementation entirely +- Adding mouse support +- Solving viewport overflow/scrolling in this same task +- Implementing a full terminal text-layout engine + +## Key Scenarios + +### Scenario 1 — Two visual lines +- A note wraps into exactly two visible lines. +- `↓` from the first line moves to the second line. +- `↑` from the second line returns to the first line. + +### Scenario 2 — Three visual lines with preferred column +- A note wraps into three lines. +- Moving from line 1 → line 2 → line 3 preserves column where possible. +- Moving back upward restores the same approximate column. + +### Scenario 3 — Shorter target line +- The caret is near the end of a long line. +- `↓` moves to a shorter wrapped line. +- Caret clamps to the valid end position of the shorter line. + +### Scenario 4 — Multiple spaces in content +- The note contains repeated spaces. +- Wrapping changes the visual layout. +- `↑` / `↓` still behave according to visible lines instead of feeling random. + +### Scenario 5 — Same behavior in tabbed flow +- Edit a note in multi-question tab UI. +- Wrapped vertical navigation behaves the same as in the single-question flow. + +### Scenario 6 — Width-sensitive rewrap +- Terminal width changes cause rewrap. +- Subsequent `↑` / `↓` still produce consistent visual movement under the new layout. + +## Implementation Direction + +### Core approach +Intercept `↑` / `↓` while inline note editing is active and translate them into a **visual-line-aware caret move** before updating the underlying editor cursor. + +### Likely components needed +1. **Current note text** +2. **Current linear cursor index** from the editor +3. **Wrap mapping** for the current render width: + - visual line index + - start/end source indices per visual line + - visual column positions +4. **Preferred column state** for repeated vertical movement +5. **Cursor relocation function**: + - find current visual line + - target line = current ± 1 + - compute target source index from preferred column + - clamp if needed + - update editor caret position + +### Architectural note +This likely deserves a small dedicated helper module, for example: +- `ask-inline-visual-cursor.ts` +- or a similarly named utility for wrapped cursor navigation + +That helper should stay separate from rendering-only helpers so the distinction remains clear: +- rendering helper = how wrapped text is shown +- visual cursor helper = how caret movement maps across wrapped lines + +## Risks + +- The underlying `Editor` may not expose enough direct cursor-control APIs, which could require adapter workarounds. +- Multiple spaces and wrapped presentation may complicate source-index ↔ visual-column mapping. +- Width changes may invalidate preferred-column assumptions unless handled carefully. +- Shared behavior across both inline and tabbed UIs could drift if not centralized. + +## Definition of Done + +This work is complete when: + +- `↑` / `↓` in wrapped inline note editing behave like visual one-line-up / one-line-down movement +- behavior is consistent across single-question and tabbed flows +- awkward spacing/wrapping cases are covered by tests +- existing note-editing behavior remains intact +- `npm run check` passes + +## Suggested Work Breakdown + +1. Inspect `Editor` cursor-control capabilities. +2. Define the wrap-to-source-index mapping model. +3. Build a small helper for visual vertical cursor movement. +4. Integrate it into `ask-inline-ui.ts` while editing is active. +5. Integrate the same logic into `ask-tabs-ui.ts`. +6. Add focused tests for wrapped vertical navigation. +7. Run full verification. +8. Document the behavior if needed in README or issue notes. diff --git a/docs/okr/2026-03-30-overflow-scroll-okrs.md b/docs/okr/2026-03-30-overflow-scroll-okrs.md new file mode 100644 index 0000000..020ce97 --- /dev/null +++ b/docs/okr/2026-03-30-overflow-scroll-okrs.md @@ -0,0 +1,186 @@ +# OKR — Overflow/Scroll Stability for Ask UI + +Date: 2026-03-30 +Related issue: #5 — Unable to scroll upwards when prompt/context/options height exceeds terminal height +Scope: `pi-ask-tool` ask UI overflow behavior in `ask-inline-ui.ts` and `ask-tabs-ui.ts` + +## Objective + +Make `pi-ask-tool`'s ask UI reliably navigable in small terminals and long-context flows so users can read, move, and answer without the interaction breaking when rendered content exceeds terminal height. + +## Problem Statement + +The current ask UI supports width-based wrapping for long questions, descriptions, and inline notes, but it does not appear to manage vertical overflow with a height-aware viewport. When total rendered content exceeds terminal height, users can lose access to earlier lines or fail to navigate naturally through the full prompt/context/options area. + +## Key Results + +1. **Small terminal support** + - Single-question UI and tabbed UI both remain usable at terminal heights of **12, 16, and 20 rows**. + +2. **Overflow navigability** + - When total rendered lines for `question + description + options` exceed **2x terminal height**, the user can still reach: + - the first visible content region, + - the last option, + - and the submit area (when applicable). + +3. **Viewport correctness** + - After `↑↓` movement, `←→` tab switches, and inline note edit entry/exit, the **active option / active review line stays visible 100% of the time**. + +4. **Behavior parity across screens** + - Overflow handling is consistent across: + - single-question flow, + - multi-question tabs, + - submit review tab, + - and `Other` note-editing states. + +5. **Regression protection** + - Add at least **5 overflow-focused test scenarios** covering movement, submission, cancellation, and note editing. + +6. **Quality bar maintained** + - `npm run check` continues to pass after the change. + - UI coverage for touched modules remains at or above the current practical quality bar. + +7. **Manual validation outcome** + - Zero reproducible cases remain for: + - “can’t scroll back up”, + - “content gets cut off in a way that blocks answering”, + - or “current selection/caret disappears off-screen”. + +## Quantified Acceptance Criteria + +### Terminal height targets +- Minimum verified height: **12 rows** +- Stability target range: **12 / 16 / 20 rows** + +### Long-content stress cases +- Description length: **20+ rendered lines** +- Option count: **8+ options** +- Wrapped inline-note cases: **24+ total rendered lines** +- Multi-question review: enough questions to force submit-tab overflow + +### Viewport rules +- The active cursor target must always remain within the visible viewport. +- Entering note edit mode must keep the edited option/caret region visible. +- Leaving note edit mode must preserve a sensible viewport around the active option. +- Switching tabs must preserve usability; at minimum, the active target must not render off-screen. + +### Input guarantees under overflow +- `Enter` keeps its current meaning. +- `Esc` keeps its current meaning. +- `Ctrl-C` keeps its current meaning. +- `Other` still requires note input before valid submission. + +## Core Scenarios + +### Scenario 1 — Single question with long description +- A single question includes a long Markdown/plain-text description that exceeds terminal height. +- The user can navigate downward to options. +- The user can navigate upward again to re-read the earlier context. +- The user can still submit a valid answer. + +### Scenario 2 — Single question with long wrapped inline notes +- Several options include long inline notes. +- Wrapped note rendering increases total vertical height. +- The currently edited option and visible caret region remain on-screen while editing. + +### Scenario 3 — Multi-question tabs with uneven heights +- Question 1 is short. +- Question 2 has a long description. +- Question 3 has many options and long notes. +- Switching tabs remains usable and the active target stays visible. + +### Scenario 4 — Submit review tab overflow +- Enough questions exist to make the review/submit tab exceed terminal height. +- The user can inspect incomplete answers. +- The user can still submit once all validations pass. + +### Scenario 5 — `Other` option with required note +- The user selects `Other` in a long-overflow screen. +- Inline note editing opens and remains visually usable. +- Empty note state still blocks valid submission. + +### Scenario 6 — Small terminal with recommended option +- The recommended option is not near the top. +- Initial selection/cursor visibility is still correct in a short terminal. + +### Scenario 7 — Cancel behavior under deep overflow +- After moving deep into a long screen, `Esc` and `Ctrl-C` still cancel correctly. +- Cancellation behavior remains unchanged by viewport logic. + +## Deliverables + +- Update `src/ask-inline-ui.ts` + - add height-aware viewport behavior + - track scroll offset + - keep active option/editor region visible + +- Update `src/ask-tabs-ui.ts` + - add height-aware viewport behavior for question tabs + - add overflow handling for submit review tab + - ensure visibility rules during tab switching and editing + +- Add tests + - overflow render scenarios + - movement/visibility scenarios + - submit-tab overflow scenario + - `Other` note-entry scenario + - cancel behavior under overflow + +- Update `README.md` + - document that long content remains navigable vertically + - document any new navigation behavior if keys or interaction rules change + +## Implementation Notes + +### Preferred design shape +- Separate **line generation** from **viewport slicing**. +- Keep wrapping logic width-based and deterministic. +- Introduce a simple vertical viewport model: + - `scrollOffset` + - `viewportHeight` + - `activeLineIndex` +- Recompute/adjust offset whenever: + - cursor moves, + - editing starts/stops, + - tab changes, + - terminal size changes, + - submit screen is entered. + +### Reuse goal +- If practical, extract a small helper for vertical viewport calculations so single-question UI and tabbed UI do not diverge. + +## Non-goals + +- Replacing the existing TUI framework +- Mouse support +- A full virtual scrolling/layout engine +- Tool schema/API changes unrelated to overflow behavior +- Cosmetic redesign beyond what is needed for readable overflow handling + +## Risks + +- Inline note editor visibility could drift from the rendered caret position. +- Submit-tab overflow may require slightly different viewport logic from question tabs. +- Width wrapping + height scrolling interactions can make UI tests brittle if not structured carefully. +- Terminal resize behavior may introduce hidden state bugs if offset logic is not centralized. + +## Definition of Done + +This OKR is considered complete when all of the following are true: + +- Issue #5 can no longer be reproduced locally using long-content scenarios. +- Overflow-focused automated tests have been added. +- `npm run check` passes. +- README reflects the resulting behavior where relevant. +- Active selection/caret visibility is preserved across the supported overflow scenarios. + +## Suggested Work Breakdown + +1. Reproduce overflow in the smallest deterministic local scenario. +2. Define viewport invariants and offset-adjustment rules. +3. Patch single-question UI. +4. Patch tabbed-question UI. +5. Patch submit review overflow handling. +6. Add overflow-focused tests. +7. Run full verification (`npm run check`). +8. Update docs and close the issue with verified behavior notes. diff --git a/src/ask-inline-ui.ts b/src/ask-inline-ui.ts index 3bcf6bd..d4a70d5 100644 --- a/src/ask-inline-ui.ts +++ b/src/ask-inline-ui.ts @@ -111,6 +111,12 @@ export async function askSingleQuestionWithInlineNote( noteEditor.setText(getRawNoteForOption(cursorOptionIndex)); }; + const openNoteEditorForCurrentOption = () => { + if (isNoteEditorOpen) return; + isNoteEditorOpen = true; + loadCurrentNoteIntoEditor(); + }; + const saveCurrentNoteFromEditor = (value: string) => { noteByOptionIndex.set(cursorOptionIndex, value); }; @@ -217,25 +223,38 @@ export async function askSingleQuestionWithInlineNote( requestUiRerender(); return; } - noteEditor.handleInput(data); - requestUiRerender(); - return; + + if ( + (matchesKey(data, Key.up) || matchesKey(data, Key.down)) && + getTrimmedNoteForOption(cursorOptionIndex).length === 0 + ) { + isNoteEditorOpen = false; + } else { + noteEditor.handleInput(data); + requestUiRerender(); + return; + } } if (matchesKey(data, Key.up)) { cursorOptionIndex = Math.max(0, cursorOptionIndex - 1); + if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) { + openNoteEditorForCurrentOption(); + } requestUiRerender(); return; } if (matchesKey(data, Key.down)) { cursorOptionIndex = Math.min(selectableOptionLabels.length - 1, cursorOptionIndex + 1); + if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) { + openNoteEditorForCurrentOption(); + } requestUiRerender(); return; } if (matchesKey(data, Key.tab)) { - isNoteEditorOpen = true; - loadCurrentNoteIntoEditor(); + openNoteEditorForCurrentOption(); requestUiRerender(); return; } @@ -257,6 +276,14 @@ export async function askSingleQuestionWithInlineNote( if (matchesKey(data, Key.escape)) { done({ cancelled: true }); + return; + } + + if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) { + openNoteEditorForCurrentOption(); + noteEditor.handleInput(data); + requestUiRerender(); + return; } }; diff --git a/src/ask-tabs-ui.ts b/src/ask-tabs-ui.ts index c0d2336..fb7f8e8 100644 --- a/src/ask-tabs-ui.ts +++ b/src/ask-tabs-ui.ts @@ -449,19 +449,44 @@ export async function askQuestionsWithTabs( requestUiRerender(); return; } - noteEditor.handleInput(data); - requestUiRerender(); - return; + + const questionIndex = getActiveQuestionIndex(); + const cursorOptionIndex = questionIndex == null ? 0 : cursorOptionIndexByQuestion[questionIndex]; + const noteIsEmpty = questionIndex == null || getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0; + if ( + noteIsEmpty && + (matchesKey(data, Key.up) || matchesKey(data, Key.down) || matchesKey(data, Key.left) || matchesKey(data, Key.right)) + ) { + isNoteEditorOpen = false; + } else { + noteEditor.handleInput(data); + requestUiRerender(); + return; + } } if (matchesKey(data, Key.left)) { activeTabIndex = (activeTabIndex - 1 + preparedQuestions.length + 1) % (preparedQuestions.length + 1); + if (getActiveQuestionIndex() != null) { + const questionIndex = getActiveQuestionIndex() as number; + if (preparedQuestions[questionIndex].options[cursorOptionIndexByQuestion[questionIndex]] === OTHER_OPTION) { + openNoteEditorForActiveOption(); + return; + } + } requestUiRerender(); return; } if (matchesKey(data, Key.right)) { activeTabIndex = (activeTabIndex + 1) % (preparedQuestions.length + 1); + if (getActiveQuestionIndex() != null) { + const questionIndex = getActiveQuestionIndex() as number; + if (preparedQuestions[questionIndex].options[cursorOptionIndexByQuestion[questionIndex]] === OTHER_OPTION) { + openNoteEditorForActiveOption(); + return; + } + } requestUiRerender(); return; } @@ -482,6 +507,10 @@ export async function askQuestionsWithTabs( if (matchesKey(data, Key.up)) { cursorOptionIndexByQuestion[questionIndex] = Math.max(0, cursorOptionIndexByQuestion[questionIndex] - 1); + if (preparedQuestion.options[cursorOptionIndexByQuestion[questionIndex]] === OTHER_OPTION) { + openNoteEditorForActiveOption(); + return; + } requestUiRerender(); return; } @@ -491,6 +520,10 @@ export async function askQuestionsWithTabs( preparedQuestion.options.length - 1, cursorOptionIndexByQuestion[questionIndex] + 1, ); + if (preparedQuestion.options[cursorOptionIndexByQuestion[questionIndex]] === OTHER_OPTION) { + openNoteEditorForActiveOption(); + return; + } requestUiRerender(); return; } @@ -540,6 +573,14 @@ export async function askQuestionsWithTabs( if (matchesKey(data, Key.escape)) { done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption)); + return; + } + + if (preparedQuestion.options[cursorOptionIndexByQuestion[questionIndex]] === OTHER_OPTION) { + openNoteEditorForActiveOption(); + noteEditor.handleInput(data); + requestUiRerender(); + return; } }; diff --git a/test/ask-ui-interaction.test.ts b/test/ask-ui-interaction.test.ts index cff364d..77c0d4e 100644 --- a/test/ask-ui-interaction.test.ts +++ b/test/ask-ui-interaction.test.ts @@ -30,6 +30,72 @@ code block `; describe("askSingleQuestionWithInlineNote interactive branches", () => { + it("opens Other note editor immediately on navigation and accepts direct typing", async () => { + const ui = { + custom: async (factory: any) => { + const tui = { requestRender() {} }; + const theme = createFakeTheme(); + let result: any; + const done = (value: any) => { + result = value; + }; + + const component = await factory(tui, theme, {}, done); + component.render(80); + component.handleInput("\u001b[B"); + const otherState = component.render(80).join("\n"); + expect(otherState).toContain("Other (type your own) — note:"); + expect(otherState).toContain("Typing note inline"); + for (const ch of "custom-flow") { + component.handleInput(ch); + } + const withTypedNote = component.render(80).join("\n"); + expect(withTypedNote).toContain("Other (type your own) — note: custom-flow"); + component.handleInput("\r"); + return result; + }, + } as unknown as ExtensionUIContext; + + const result = await askSingleQuestionWithInlineNote(ui, { + question: "Choose one very long answer so wrapped rendering is exercised in tests.", + description: RICH_MARKDOWN, + options: [{ label: "Default strategy with extra-long option label" }], + }); + + expect(result).toEqual({ selectedOptions: [], customInput: "custom-flow" }); + }); + + it("returns to navigation when pressing arrows on an empty Other note", async () => { + const ui = { + custom: async (factory: any) => { + const tui = { requestRender() {} }; + const theme = createFakeTheme(); + let result: any; + const done = (value: any) => { + result = value; + }; + + const component = await factory(tui, theme, {}, done); + component.render(80); + component.handleInput("\u001b[B"); + const openState = component.render(80).join("\n"); + expect(openState).toContain("Typing note inline"); + component.handleInput("\u001b[A"); + const movedState = component.render(80).join("\n"); + expect(movedState).not.toContain("Typing note inline"); + expect(movedState).toContain("→ ● Default strategy with extra-long option label"); + done({ cancelled: true }); + return result; + }, + } as unknown as ExtensionUIContext; + + await askSingleQuestionWithInlineNote(ui, { + question: "Choose one very long answer so wrapped rendering is exercised in tests.", + description: RICH_MARKDOWN, + options: [{ label: "Default strategy with extra-long option label" }], + }); + }); + it("requires note for Other before allowing submit", async () => { const ui = { custom: async (factory: any) => { @@ -153,6 +219,89 @@ describe("askSingleQuestionWithInlineNote interactive branches", () => { }); describe("askQuestionsWithTabs interactive branches", () => { + it("opens Other note editor immediately in tab flow and accepts direct typing", async () => { + const ui = { + custom: async (factory: any) => { + const tui = { requestRender() {} }; + const theme = createFakeTheme(); + let result: any; + const done = (value: any) => { + result = value; + }; + + const component = await factory(tui, theme, {}, done); + component.render(80); + component.handleInput("\u001b[B"); + component.handleInput("\u001b[B"); + const otherState = component.render(80).join("\n"); + expect(otherState).toContain("Other (type your own) — note:"); + expect(otherState).toContain("Typing note inline"); + for (const ch of "org-sso") { + component.handleInput(ch); + } + const withTypedNote = component.render(80).join("\n"); + expect(withTypedNote).toContain("Other (type your own) — note: org-sso"); + component.handleInput("\r"); + const submitScreen = component.render(80).join("\n"); + expect(submitScreen).toContain("Review answers"); + component.handleInput("\r"); + return result; + }, + } as unknown as ExtensionUIContext; + + const result = await askQuestionsWithTabs(ui, [ + { + id: "primary_choice", + question: "Pick one option", + options: [{ label: "Option A" }, { label: "Option B" }], + }, + ]); + + expect(result).toEqual({ + cancelled: false, + selections: [{ selectedOptions: [], customInput: "org-sso" }], + }); + }); + + it("returns to tab navigation when pressing arrows on an empty Other note", async () => { + const ui = { + custom: async (factory: any) => { + const tui = { requestRender() {} }; + const theme = createFakeTheme(); + let result: any; + const done = (value: any) => { + result = value; + }; + + const component = await factory(tui, theme, {}, done); + component.render(80); + component.handleInput("\u001b[B"); + component.handleInput("\u001b[B"); + const openState = component.render(80).join("\n"); + expect(openState).toContain("Typing note inline"); + component.handleInput("\u001b[C"); + const movedState = component.render(80).join("\n"); + expect(movedState).not.toContain("Typing note inline"); + expect(movedState).toContain("Second question"); + done({ cancelled: true, selectedOptionIndexesByQuestion: [[], []], noteByQuestionByOption: [["", "", ""], ["", "", ""]] }); + return result; + }, + } as unknown as ExtensionUIContext; + + await askQuestionsWithTabs(ui, [ + { + id: "primary_choice", + question: "Pick one option", + options: [{ label: "Option A" }, { label: "Option B" }], + }, + { + id: "second_question", + question: "Second question", + options: [{ label: "X" }, { label: "Y" }], + }, + ]); + }); + it("covers multi-select toggling, Other note flow, and submit", async () => { const ui = { custom: async (factory: any) => {