diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00961ab..86a2296 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,8 @@ name: Release & Publish on: push: - tags: - - "v*.*.*" + branches: + - main permissions: contents: write @@ -43,21 +43,44 @@ jobs: - name: Run full checks (typecheck + tests + coverage gate) run: npm run check - - name: Verify tag and package.json version match + - name: Read package version + id: package-version shell: bash run: | - PKG_VERSION=$(node -p "require('./package.json').version") - TAG_VERSION="${GITHUB_REF_NAME#v}" + VERSION=$(node -p "require('./package.json').version") + TAG="v$VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" - if [[ "$PKG_VERSION" != "$TAG_VERSION" ]]; then - echo "Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)." - exit 1 + - name: Check whether release tag already exists + id: release-tag + shell: bash + run: | + TAG="${{ steps.package-version.outputs.tag }}" + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Release tag $TAG already exists. Skipping publish." + else + echo "exists=false" >> "$GITHUB_OUTPUT" fi + - name: Create and push release tag + if: steps.release-tag.outputs.exists != 'true' + shell: bash + run: | + TAG="${{ steps.package-version.outputs.tag }}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "chore(release): ${{ steps.package-version.outputs.version }}" + git push origin "$TAG" + - name: Publish package to npm (Trusted Publishing) + if: steps.release-tag.outputs.exists != 'true' run: npm publish --access public --provenance - name: Create GitHub Release + if: steps.release-tag.outputs.exists != 'true' uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.package-version.outputs.tag }} generate_release_notes: true 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`); }); });