diff --git a/package-lock.json b/package-lock.json index 6b8fb74..dac0f28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-ask-tool-extension", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-ask-tool-extension", - "version": "0.2.5", + "version": "0.2.6", "devDependencies": { "@mariozechner/pi-coding-agent": "^0.52.12", "@mariozechner/pi-tui": "^0.52.12", diff --git a/package.json b/package.json index 9fdca95..ac347c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-ask-tool-extension", - "version": "0.2.5", + "version": "0.2.6", "description": "Ask tool extension for pi with tabbed questioning and inline note editing", "repository": { "type": "git", diff --git a/src/ask-inline-note.ts b/src/ask-inline-note.ts index f4df9b8..7d4d24a 100644 --- a/src/ask-inline-note.ts +++ b/src/ask-inline-note.ts @@ -1,4 +1,4 @@ -import { wrapTextWithAnsi } from "@mariozechner/pi-tui"; +import { CURSOR_MARKER, wrapTextWithAnsi } from "@mariozechner/pi-tui"; const INLINE_NOTE_SEPARATOR = " — note: "; const INLINE_EDIT_CURSOR_INVERT_ON = "\u001b[7m"; @@ -17,14 +17,19 @@ function clampCursorIndex(index: number, rawTextLength: number): number { return Math.floor(index); } -function buildEditingInlineNote(rawNote: string, editingCursorIndex?: number): string { +function buildEditingInlineNote( + rawNote: string, + editingCursorIndex?: number, + includeHardwareCursorMarker = false, +): string { const cursorIndex = clampCursorIndex(editingCursorIndex ?? rawNote.length, rawNote.length); const beforeCursor = sanitizeNoteForInlineDisplay(rawNote.slice(0, cursorIndex)); const rawCharAtCursor = rawNote.slice(cursorIndex, cursorIndex + 1); const charAtCursor = sanitizeNoteForInlineDisplay(rawCharAtCursor) || " "; const afterCursorStartIndex = rawCharAtCursor.length > 0 ? cursorIndex + 1 : cursorIndex; const afterCursor = sanitizeNoteForInlineDisplay(rawNote.slice(afterCursorStartIndex)); - const cursorCell = `${INLINE_EDIT_CURSOR_INVERT_ON}${charAtCursor}${INLINE_EDIT_CURSOR_INVERT_OFF}`; + const cursorMarker = includeHardwareCursorMarker ? CURSOR_MARKER : ""; + const cursorCell = `${cursorMarker}${INLINE_EDIT_CURSOR_INVERT_ON}${charAtCursor}${INLINE_EDIT_CURSOR_INVERT_OFF}`; return `${beforeCursor}${cursorCell}${afterCursor}`; } @@ -48,6 +53,7 @@ export function buildOptionLabelWithInlineNote( isEditingNote: boolean, maxInlineLabelLength?: number, editingCursorIndex?: number, + includeHardwareCursorMarker = false, ): string { const sanitizedNote = sanitizeNoteForInlineDisplay(rawNote); if (!isEditingNote && sanitizedNote.trim().length === 0) { @@ -55,7 +61,9 @@ export function buildOptionLabelWithInlineNote( } const labelPrefix = `${baseOptionLabel}${INLINE_NOTE_SEPARATOR}`; - const inlineNote = isEditingNote ? buildEditingInlineNote(rawNote, editingCursorIndex) : sanitizedNote.trim(); + const inlineNote = isEditingNote + ? buildEditingInlineNote(rawNote, editingCursorIndex, includeHardwareCursorMarker) + : sanitizedNote.trim(); const inlineLabel = `${labelPrefix}${inlineNote}`; if (maxInlineLabelLength == null) { @@ -74,6 +82,7 @@ export function buildWrappedOptionLabelWithInlineNote( maxInlineLabelLength: number, wrapPadding = INLINE_NOTE_WRAP_PADDING, editingCursorIndex?: number, + includeHardwareCursorMarker = false, ): string[] { const inlineLabel = buildOptionLabelWithInlineNote( baseOptionLabel, @@ -81,6 +90,7 @@ export function buildWrappedOptionLabelWithInlineNote( isEditingNote, undefined, editingCursorIndex, + includeHardwareCursorMarker, ); const sanitizedWrapPadding = Number.isFinite(wrapPadding) ? Math.max(0, Math.floor(wrapPadding)) : 0; const sanitizedMaxInlineLabelLength = Number.isFinite(maxInlineLabelLength) diff --git a/src/ask-inline-ui.ts b/src/ask-inline-ui.ts index d4a70d5..c82a550 100644 --- a/src/ask-inline-ui.ts +++ b/src/ask-inline-ui.ts @@ -187,6 +187,7 @@ export async function askSingleQuestionWithInlineNote( Math.max(1, width - prefixWidth), INLINE_NOTE_WRAP_PADDING, isEditingThisOption ? activeEditingCursorIndex : undefined, + isEditingThisOption, ); const continuationPrefix = " ".repeat(prefixWidth); addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`); @@ -288,6 +289,7 @@ export async function askSingleQuestionWithInlineNote( }; return { + focused: true, render, invalidate: () => { cachedRenderedLines = undefined; diff --git a/src/ask-tabs-ui.ts b/src/ask-tabs-ui.ts index fb7f8e8..00868ae 100644 --- a/src/ask-tabs-ui.ts +++ b/src/ask-tabs-ui.ts @@ -388,6 +388,7 @@ export async function askQuestionsWithTabs( Math.max(1, width - prefixWidth), INLINE_NOTE_WRAP_PADDING, isEditingThisOption ? activeEditingCursorIndex : undefined, + isEditingThisOption, ); const continuationPrefix = " ".repeat(prefixWidth); addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`); @@ -404,12 +405,12 @@ export async function askQuestionsWithTabs( addLine( theme.fg( "dim", - " ↑↓ move • Enter toggle/select • Tab add note • ←/→ switch tabs • Esc cancel", + " ↑↓ move • Space toggle/select • Enter next • Tab add note • ←/→ switch tabs • Esc cancel", ), ); } else { addLine( - theme.fg("dim", " ↑↓ move • Enter select • Tab add note • ←/→ switch tabs • Esc cancel"), + theme.fg("dim", " ↑↓ move • Space/Enter select • Tab add note • ←/→ switch tabs • Esc cancel"), ); } } @@ -533,7 +534,7 @@ export async function askQuestionsWithTabs( return; } - if (matchesKey(data, Key.enter)) { + if (matchesKey(data, Key.space)) { const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex]; if (preparedQuestion.multi) { @@ -566,6 +567,37 @@ export async function askQuestionsWithTabs( return; } + requestUiRerender(); + return; + } + + if (matchesKey(data, Key.enter)) { + const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex]; + + if (preparedQuestion.multi) { + if ( + cursorOptionIndex === preparedQuestion.otherOptionIndex && + selectedOptionIndexesByQuestion[questionIndex].includes(cursorOptionIndex) && + getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0 + ) { + openNoteEditorForActiveOption(); + return; + } + + advanceToNextTabOrSubmit(); + requestUiRerender(); + return; + } + + selectedOptionIndexesByQuestion[questionIndex] = [cursorOptionIndex]; + if ( + cursorOptionIndex === preparedQuestion.otherOptionIndex && + getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0 + ) { + openNoteEditorForActiveOption(); + return; + } + advanceToNextTabOrSubmit(); requestUiRerender(); return; @@ -585,6 +617,7 @@ export async function askQuestionsWithTabs( }; return { + focused: true, render, invalidate: () => { cachedRenderedLines = undefined; diff --git a/test/ask-logic.test.ts b/test/ask-logic.test.ts index 2675121..048bd7d 100644 --- a/test/ask-logic.test.ts +++ b/test/ask-logic.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { CURSOR_MARKER, visibleWidth } from "@mariozechner/pi-tui"; import { OTHER_OPTION, appendRecommendedTagToOptionLabels, @@ -181,4 +182,21 @@ describe("buildWrappedOptionLabelWithInlineNote", () => { expect(wrapped.join(" ")).toContain(`0123${renderCursorCell("4")}56789`); }); + + it("can include a zero-width hardware cursor marker for IME composition", () => { + const wrapped = buildWrappedOptionLabelWithInlineNote( + "Session", + "한글", + true, + 16, + INLINE_NOTE_WRAP_PADDING, + 1, + true, + ); + const rendered = wrapped.join(" "); + + expect(rendered).toContain(CURSOR_MARKER); + expect(visibleWidth(CURSOR_MARKER)).toBe(0); + expect(rendered).toContain(`한${CURSOR_MARKER}${renderCursorCell("글")}`); + }); }); diff --git a/test/ask-ui-interaction.test.ts b/test/ask-ui-interaction.test.ts index 77c0d4e..69f2f81 100644 --- a/test/ask-ui-interaction.test.ts +++ b/test/ask-ui-interaction.test.ts @@ -314,11 +314,11 @@ describe("askQuestionsWithTabs interactive branches", () => { const component = await factory(tui, theme, {}, done); component.render(26); - component.handleInput("\r"); - component.handleInput("\r"); + component.handleInput(" "); + component.handleInput(" "); component.handleInput("\u001b[B"); component.handleInput("\u001b[B"); - component.handleInput("\r"); + component.handleInput(" "); component.handleInput("\r"); const emptyOtherStillEditing = component.render(26).join("\n"); expect(emptyOtherStillEditing).toContain("Typing note inline"); @@ -328,7 +328,7 @@ describe("askQuestionsWithTabs interactive branches", () => { component.handleInput(ch); } component.handleInput("\r"); - component.handleInput("\u001b[C"); + component.handleInput("\r"); const submitScreen = component.render(26).join("\n"); expect(submitScreen).toContain("Review answers"); component.handleInput("\r"); @@ -352,6 +352,43 @@ describe("askQuestionsWithTabs interactive branches", () => { }); }); + it("advances multi-select questions with Enter without toggling the cursor option", 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(40); + component.handleInput("\r"); + const submitScreen = component.render(40).join("\n"); + expect(submitScreen).toContain("Review answers"); + expect(submitScreen).toContain("Complete required answers"); + expect(submitScreen).toContain("(not answered)"); + component.handleInput("\u001b"); + return result; + }, + } as unknown as ExtensionUIContext; + + const result = await askQuestionsWithTabs(ui, [ + { + id: "auth_methods", + question: "Select all methods", + options: [{ label: "JWT" }, { label: "Session" }], + multi: true, + }, + ]); + + expect(result).toEqual({ + cancelled: true, + selections: [{ selectedOptions: [] }], + }); + }); + it("covers single-select Other note path and submit tab enter", async () => { const ui = { custom: async (factory: any) => { diff --git a/test/ask-ui.test.ts b/test/ask-ui.test.ts index 4ae4bc8..00bfd8a 100644 --- a/test/ask-ui.test.ts +++ b/test/ask-ui.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent"; +import { CURSOR_MARKER } from "@mariozechner/pi-tui"; import { OTHER_OPTION, type AskQuestion } from "../src/ask-logic"; import { askSingleQuestionWithInlineNote } from "../src/ask-inline-ui"; import { askQuestionsWithTabs, formatSelectionForSubmitReview } from "../src/ask-tabs-ui"; @@ -102,8 +103,8 @@ describe("askSingleQuestionWithInlineNote", () => { options: [{ label: "Session" }], }); - expect(caretAtEndLine).toContain(`Session — note: split${renderCursorCell()}`); - expect(caretMovedLine).toContain(`Session — note: spl${renderCursorCell("i")}t`); + expect(caretAtEndLine).toContain(`Session — note: split${CURSOR_MARKER}${renderCursorCell()}`); + expect(caretMovedLine).toContain(`Session — note: spl${CURSOR_MARKER}${renderCursorCell("i")}t`); }); });