From f4ad447ab4d5ecc9435c045a98f890b8edfa8fdd Mon Sep 17 00:00:00 2001 From: devkade Date: Fri, 1 May 2026 15:42:31 +0900 Subject: [PATCH] feat: auto-open other note editor --- src/ask-inline-ui.ts | 37 ++++++-- src/ask-tabs-ui.ts | 47 +++++++++- test/ask-ui-interaction.test.ts | 149 ++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 8 deletions(-) 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) => {