Skip to content
Merged
39 changes: 31 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Release & Publish

on:
push:
tags:
- "v*.*.*"
branches:
- main

permissions:
contents: write
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 14 additions & 4 deletions src/ask-inline-note.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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}`;
}

Expand All @@ -48,14 +53,17 @@ export function buildOptionLabelWithInlineNote(
isEditingNote: boolean,
maxInlineLabelLength?: number,
editingCursorIndex?: number,
includeHardwareCursorMarker = false,
): string {
const sanitizedNote = sanitizeNoteForInlineDisplay(rawNote);
if (!isEditingNote && sanitizedNote.trim().length === 0) {
return baseOptionLabel;
}

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) {
Expand All @@ -74,13 +82,15 @@ export function buildWrappedOptionLabelWithInlineNote(
maxInlineLabelLength: number,
wrapPadding = INLINE_NOTE_WRAP_PADDING,
editingCursorIndex?: number,
includeHardwareCursorMarker = false,
): string[] {
const inlineLabel = buildOptionLabelWithInlineNote(
baseOptionLabel,
rawNote,
isEditingNote,
undefined,
editingCursorIndex,
includeHardwareCursorMarker,
);
const sanitizedWrapPadding = Number.isFinite(wrapPadding) ? Math.max(0, Math.floor(wrapPadding)) : 0;
const sanitizedMaxInlineLabelLength = Number.isFinite(maxInlineLabelLength)
Expand Down
2 changes: 2 additions & 0 deletions src/ask-inline-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? ""}`)}`);
Expand Down Expand Up @@ -288,6 +289,7 @@ export async function askSingleQuestionWithInlineNote(
};

return {
focused: true,
render,
invalidate: () => {
cachedRenderedLines = undefined;
Expand Down
39 changes: 36 additions & 3 deletions src/ask-tabs-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? ""}`)}`);
Expand All @@ -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"),
);
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -585,6 +617,7 @@ export async function askQuestionsWithTabs(
};

return {
focused: true,
render,
invalidate: () => {
cachedRenderedLines = undefined;
Expand Down
18 changes: 18 additions & 0 deletions test/ask-logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "bun:test";
import { CURSOR_MARKER, visibleWidth } from "@mariozechner/pi-tui";
import {
OTHER_OPTION,
appendRecommendedTagToOptionLabels,
Expand Down Expand Up @@ -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("글")}`);
});
});
45 changes: 41 additions & 4 deletions test/ask-ui-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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) => {
Expand Down
5 changes: 3 additions & 2 deletions test/ask-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`);
});
});

Expand Down
Loading