Skip to content
Open
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
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ This extension provides:
- **Tab-based multi-question flow** with a final submit review tab
- **Inline note editing** (no large UI pane shifts)
- **Question text auto-wrap** (avoids one-line `...` truncation)
- **Height-aware scrolling** so tall questions stay usable in small terminals
- **Optional Markdown context** for longer explanations/structure diagrams
- **Automatic `Other (type your own)` handling**

Expand Down Expand Up @@ -202,8 +203,31 @@ For `Other`, a note is required to become valid.
- `← / →`: switch question tabs
- `Enter`: select/toggle or submit (on Submit tab)
- `Tab`: start/stop inline note editing
- `Shift+↑ / Shift+↓` (or `PgUp / PgDn`): scroll when the question is taller
than the terminal
- `Esc`: cancel flow

## Tall Questions and Small Terminals

The ask UI never renders more lines than your terminal has rows. When a
question, its description and its options do not all fit, the UI shows a
window onto that content and keeps the active option visible as you move.

- A hint such as `↑ 12 more · ↓ 4 more` appears whenever content is hidden.
- `Shift+↑` / `Shift+↓` scroll without changing your selection, so you can
read back through a long description and then carry on answering.
`PgUp` / `PgDn` do the same where the terminal forwards them — many
multiplexers and terminals bind those to their own scrollback and never
pass them through, which is why Shift+arrow is the primary binding.
- Moving the selection, editing a note, or switching tabs returns the view
to the active option.
- Each tab keeps its own scroll position.

This matters beyond readability: a component taller than the viewport forces
pi's renderer to repaint the whole screen on every keystroke, which clears
the terminal scrollback and makes the prompt flicker. Bounding the height
keeps redraws incremental and leaves your scrollback intact.

## Tool Schema

```ts
Expand Down
151 changes: 137 additions & 14 deletions src/ask-inline-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ import {
import { getLinearCursorIndexFromEditor } from "./ask-inline-editor-cursor";
import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
import { appendWrappedTextLines } from "./ask-text-wrap";
import {
buildScrollIndicator,
clampScrollOffset,
MIN_VIEWPORT_HEIGHT,
maxScrollOffset,
resolveViewportHeight,
sliceViewport,
type ViewportAnchor,
} from "./ask-viewport";

interface SingleQuestionInput {
question: string;
Expand Down Expand Up @@ -62,6 +71,11 @@ export async function askSingleQuestionWithInlineNote(
let isNoteEditorOpen = false;
let cachedRenderedLines: string[] | undefined;
let cachedRenderedWidth: number | undefined;
let cachedRenderedHeight: number | undefined;
let scrollOffset = 0;
let hasUserScrolled = false;
let lastViewportHeight: number | undefined;
let lastTotalBodyLines = 0;
const noteByOptionIndex = new Map<number, string>();

const editorTheme: EditorTheme = {
Expand Down Expand Up @@ -101,9 +115,32 @@ export async function askSingleQuestionWithInlineNote(
const requestUiRerender = () => {
cachedRenderedLines = undefined;
cachedRenderedWidth = undefined;
cachedRenderedHeight = undefined;
tui.requestRender();
};

/** Follow the cursor again after any selection-changing key. */
const resetScrollFollow = () => {
hasUserScrolled = false;
};

/** One screenful, minus a line of overlap so context is not lost. */
const scrollStep = (): number => Math.max(1, (lastViewportHeight ?? MIN_VIEWPORT_HEIGHT) - 1);

/** Scroll the viewport by whole lines without moving the option cursor. */
const scrollViewportBy = (lineDelta: number): boolean => {
if (lastViewportHeight == null || lastTotalBodyLines <= lastViewportHeight) return false;

const maxOffset = maxScrollOffset(lastTotalBodyLines, lastViewportHeight);
const nextOffset = clampScrollOffset(scrollOffset + lineDelta, lastTotalBodyLines, lastViewportHeight);
if (nextOffset === scrollOffset) return false;

scrollOffset = nextOffset;
hasUserScrolled = nextOffset < maxOffset || lineDelta < 0;
requestUiRerender();
return true;
};

const getRawNoteForOption = (optionIndex: number): string => noteByOptionIndex.get(optionIndex) ?? "";
const getTrimmedNoteForOption = (optionIndex: number): string => getRawNoteForOption(optionIndex).trim();

Expand All @@ -114,6 +151,7 @@ export async function askSingleQuestionWithInlineNote(
const openNoteEditorForCurrentOption = () => {
if (isNoteEditorOpen) return;
isNoteEditorOpen = true;
resetScrollFollow();
loadCurrentNoteIntoEditor();
};

Expand All @@ -131,6 +169,7 @@ export async function askSingleQuestionWithInlineNote(

noteEditor.onChange = (value) => {
saveCurrentNoteFromEditor(value);
resetScrollFollow();
requestUiRerender();
};

Expand All @@ -147,29 +186,37 @@ export async function askSingleQuestionWithInlineNote(
submitCurrentSelection(selectedOptionLabel, trimmedNote);
};

const render = (width: number): string[] => {
if (cachedRenderedLines && cachedRenderedWidth === width) return cachedRenderedLines;

const renderedLines: string[] = [];
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));

addLine(theme.fg("accent", "─".repeat(width)));
appendWrappedTextLines(renderedLines, questionInput.question, width, {
/**
* Build the scrollable body: question, description and options.
* Kept free of viewport arithmetic so slicing stays deterministic.
*/
const buildBodyLines = (
width: number,
): { lines: string[]; anchor: ViewportAnchor; priority: ViewportAnchor } => {
const bodyLines: string[] = [];
const addBodyLine = (line: string) => bodyLines.push(truncateToWidth(line, width));

appendWrappedTextLines(bodyLines, questionInput.question, width, {
indent: 1,
formatLine: (line) => theme.fg("text", line),
});
if (questionDescriptionMarkdown) {
renderedLines.push("");
bodyLines.push("");
const descriptionLines = questionDescriptionMarkdown.render(Math.max(1, width - 1));
for (const descriptionLine of descriptionLines) {
addLine(` ${descriptionLine}`);
addBodyLine(` ${descriptionLine}`);
}
}
renderedLines.push("");
bodyLines.push("");

const activeEditingCursorIndex = isNoteEditorOpen
? getLinearCursorIndexFromEditor(noteEditor)
: undefined;
let anchorStart = 0;
let anchorEnd = 0;
// The options block is what the reader has to choose from, so it
// takes precedence over the question and description above it.
const optionsStart = bodyLines.length;
for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) {
const optionLabel = selectableOptionLabels[optionIndex];
const isCursorOption = optionIndex === cursorOptionIndex;
Expand All @@ -190,13 +237,69 @@ export async function askSingleQuestionWithInlineNote(
isEditingThisOption,
);
const continuationPrefix = " ".repeat(prefixWidth);
addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
if (isCursorOption) {
anchorStart = bodyLines.length;
}
addBodyLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
addBodyLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
}
if (isCursorOption) {
anchorEnd = bodyLines.length - 1;
}
}
const optionsEnd = Math.max(optionsStart, bodyLines.length - 1);

renderedLines.push("");
bodyLines.push("");

return {
lines: bodyLines,
anchor: { start: anchorStart, end: anchorEnd },
priority: { start: optionsStart, end: optionsEnd },
};
};

const render = (width: number): string[] => {
const terminalRows = tui.terminal?.rows;
if (cachedRenderedLines && cachedRenderedWidth === width && cachedRenderedHeight === terminalRows) {
return cachedRenderedLines;
}

const renderedLines: string[] = [];
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));

const { lines: bodyLines, anchor, priority } = buildBodyLines(width);

// Chrome is the top rule, the hint line and the bottom rule. An
// overflowing body adds one more line for the scroll indicator.
const baseChromeRows = 3;
const fitsWithoutIndicator = resolveViewportHeight(terminalRows, baseChromeRows);
const viewportHeight =
fitsWithoutIndicator != null && bodyLines.length > fitsWithoutIndicator
? resolveViewportHeight(terminalRows, baseChromeRows + 1)
: fitsWithoutIndicator;

const slice = sliceViewport({
lines: bodyLines,
viewportHeight,
anchor,
priority,
scrollOffset,
preferScrollOffset: hasUserScrolled,
});
scrollOffset = slice.scrollOffset;
lastViewportHeight = viewportHeight;
lastTotalBodyLines = slice.totalLines;

addLine(theme.fg("accent", "─".repeat(width)));
for (const bodyLine of slice.lines) {
renderedLines.push(bodyLine);
}

const scrollIndicator = buildScrollIndicator(slice);
if (scrollIndicator) {
addLine(theme.fg("dim", ` ${scrollIndicator} • Shift+↑/↓ scroll`));
}

if (isNoteEditorOpen) {
addLine(theme.fg("dim", " Typing note inline • Enter submit • Tab/Esc stop editing"));
Expand All @@ -209,6 +312,7 @@ export async function askSingleQuestionWithInlineNote(
addLine(theme.fg("accent", "─".repeat(width)));
cachedRenderedLines = renderedLines;
cachedRenderedWidth = width;
cachedRenderedHeight = terminalRows;
return renderedLines;
};

Expand All @@ -218,6 +322,22 @@ export async function askSingleQuestionWithInlineNote(
return;
}

// Scrolling never changes the selection, so it stays available
// while the inline note editor is open.
//
// Shift+arrow is the primary binding: terminal multiplexers and
// terminal emulators commonly bind PgUp/PgDn to their own
// scrollback and never forward them. PgUp/PgDn stays as a
// secondary binding for setups that do forward it.
if (matchesKey(data, Key.shift("up")) || matchesKey(data, Key.pageUp)) {
scrollViewportBy(-scrollStep());
return;
}
if (matchesKey(data, Key.shift("down")) || matchesKey(data, Key.pageDown)) {
scrollViewportBy(scrollStep());
return;
}

if (isNoteEditorOpen) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
isNoteEditorOpen = false;
Expand All @@ -239,6 +359,7 @@ export async function askSingleQuestionWithInlineNote(

if (matchesKey(data, Key.up)) {
cursorOptionIndex = Math.max(0, cursorOptionIndex - 1);
resetScrollFollow();
if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) {
openNoteEditorForCurrentOption();
}
Expand All @@ -247,6 +368,7 @@ export async function askSingleQuestionWithInlineNote(
}
if (matchesKey(data, Key.down)) {
cursorOptionIndex = Math.min(selectableOptionLabels.length - 1, cursorOptionIndex + 1);
resetScrollFollow();
if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) {
openNoteEditorForCurrentOption();
}
Expand Down Expand Up @@ -294,6 +416,7 @@ export async function askSingleQuestionWithInlineNote(
invalidate: () => {
cachedRenderedLines = undefined;
cachedRenderedWidth = undefined;
cachedRenderedHeight = undefined;
},
handleInput,
};
Expand Down
Loading