Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions src/ask-inline-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
};

Expand Down
47 changes: 44 additions & 3 deletions src/ask-tabs-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
};

Expand Down
149 changes: 149 additions & 0 deletions test/ask-ui-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading