feat: support @ file autocomplete in ask editor

This commit is contained in:
Erik Kotsiuba
2026-04-19 16:32:27 +02:00
parent c40990eaf6
commit 0386239f5e
5 changed files with 124 additions and 21 deletions
+1
View File
@@ -161,6 +161,7 @@ Behavior details:
- on multi-select questions, submitted `values` and `labels` include both selected options and the free-form answer when both exist - on multi-select questions, submitted `values` and `labels` include both selected options and the free-form answer when both exist
- when the free-form option is selected, it becomes an inline input row with the selected-tab background style spanning the full width - when the free-form option is selected, it becomes an inline input row with the selected-tab background style spanning the full width
- while editing a note or free-form answer, arrow keys and `Tab` stay inside the editor so the typing cursor can move naturally - while editing a note or free-form answer, arrow keys and `Tab` stay inside the editor so the typing cursor can move naturally
- free-form answer editors support pi-style `@` file path autocomplete for quickly mentioning project files
- `Esc` closes the editor and returns to navigation mode - `Esc` closes the editor and returns to navigation mode
- `Space` toggles the active option on single-select questions too, but does not auto-advance - `Space` toggles the active option on single-select questions too, but does not auto-advance
+1
View File
@@ -79,6 +79,7 @@ This document defines the stable external behavior. It does not explain internal
- tabbed multi-question flow - tabbed multi-question flow
- single-select, multi-select, and preview questions - single-select, multi-select, and preview questions
- inline free-form answers for non-preview questions - inline free-form answers for non-preview questions
- pi-style `@` file path autocomplete inside free-form answer and note editors
- question notes via `Ctrl+N` - question notes via `Ctrl+N`
- option notes via `N` - option notes via `N`
- number-key quick selection - number-key quick selection
+49
View File
@@ -0,0 +1,49 @@
import { accessSync, constants as fsConstants } from "node:fs";
import { delimiter, join } from "node:path";
import { CombinedAutocompleteProvider } from "@mariozechner/pi-tui";
const FD_BINARY_NAMES =
process.platform === "win32"
? ["fd.exe", "fdfind.exe", "fd", "fdfind"]
: ["fd", "fdfind"];
/**
* pi resolves fd internally for its main editor, but that resolver is not part of
* the public extension API. Custom editors therefore need to supply the fd path
* themselves when reusing CombinedAutocompleteProvider for `@` file mentions.
*/
export function createAskAutocompleteProvider(cwd: string) {
return new CombinedAutocompleteProvider(
[],
cwd,
findAutocompleteBinary(FD_BINARY_NAMES)
);
}
function findAutocompleteBinary(binaryNames: readonly string[]): string | null {
const pathValue = process.env.PATH;
if (!pathValue) {
return null;
}
const directories = pathValue.split(delimiter).filter(Boolean);
for (const binaryName of binaryNames) {
const executablePath = directories
.map((directory) => join(directory, binaryName))
.find(isExecutableFile);
if (executablePath) {
return executablePath;
}
}
return null;
}
function isExecutableFile(path: string): boolean {
try {
accessSync(path, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
+20 -19
View File
@@ -26,6 +26,7 @@ import {
} from "../state/transitions.ts"; } from "../state/transitions.ts";
import { isEditingView } from "../state/view.ts"; import { isEditingView } from "../state/view.ts";
import type { AskParams, AskResult, AskState } from "../types.ts"; import type { AskParams, AskResult, AskState } from "../types.ts";
import { createAskAutocompleteProvider } from "./autocomplete.ts";
import type { AskInputCommand } from "./input.ts"; import type { AskInputCommand } from "./input.ts";
import { getInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts";
import { renderAskScreen } from "./render.ts"; import { renderAskScreen } from "./render.ts";
@@ -38,9 +39,9 @@ type Tui = CustomCallbackArgs[0];
type Theme = CustomCallbackArgs[1]; type Theme = CustomCallbackArgs[1];
type Keybindings = CustomCallbackArgs[2]; type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void; type Done = (result: AskResult) => void;
type AskFlowParams = AskParams & Pick<ExtensionContext, "cwd">;
interface AskFlowController { interface AskFlowController {
cachedLines: string[] | undefined;
done: Done; done: Done;
editor: Editor; editor: Editor;
state: AskState; state: AskState;
@@ -54,7 +55,7 @@ export function runAskFlow(
params: AskParams params: AskParams
): Promise<AskResult> { ): Promise<AskResult> {
return ctx.ui.custom<AskResult>((...args) => return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, params) createAskFlowController(args, { ...params, cwd: ctx.cwd })
); );
} }
@@ -65,12 +66,11 @@ function createAskFlowController(
Keybindings, Keybindings,
(result: AskResult) => void, (result: AskResult) => void,
], ],
params: AskParams params: AskFlowParams
) { ) {
const controller: AskFlowController = { const controller: AskFlowController = {
cachedLines: undefined,
done, done,
editor: createEditor(tui, theme), editor: createEditor(tui, theme, params.cwd),
state: createInitialState(params), state: createInitialState(params),
suppressAutoInputForSelection: false, suppressAutoInputForSelection: false,
theme, theme,
@@ -83,7 +83,7 @@ function createAskFlowController(
return { return {
render: (width: number) => renderController(controller, width), render: (width: number) => renderController(controller, width),
invalidate() { invalidate() {
controller.cachedLines = undefined; // The editor manages its own async autocomplete redraws.
}, },
handleInput(data: string) { handleInput(data: string) {
handleControllerInput(controller, data); handleControllerInput(controller, data);
@@ -95,15 +95,12 @@ function renderController(
controller: AskFlowController, controller: AskFlowController,
width: number width: number
): string[] { ): string[] {
if (!controller.cachedLines) { return renderAskScreen({
controller.cachedLines = renderAskScreen({ editor: controller.editor,
editor: controller.editor, state: controller.state,
state: controller.state, theme: controller.theme,
theme: controller.theme, width,
width, });
});
}
return controller.cachedLines;
} }
function handleControllerInput(controller: AskFlowController, data: string) { function handleControllerInput(controller: AskFlowController, data: string) {
@@ -268,7 +265,6 @@ function submitEditorValue(state: AskState, value: string): AskState {
} }
function refresh(controller: AskFlowController) { function refresh(controller: AskFlowController) {
controller.cachedLines = undefined;
controller.tui.requestRender(); controller.tui.requestRender();
} }
@@ -331,8 +327,14 @@ function saveEditorAndMoveOption(
return moveOption(nextState, delta); return moveOption(nextState, delta);
} }
function createEditor(tui: Tui, theme: Theme) { function createEditor(tui: Tui, theme: Theme, cwd: string) {
const editorTheme: EditorTheme = { const editor = new Editor(tui, createEditorTheme(theme));
editor.setAutocompleteProvider(createAskAutocompleteProvider(cwd));
return editor;
}
function createEditorTheme(theme: Theme): EditorTheme {
return {
borderColor: (text) => theme.fg("accent", text), borderColor: (text) => theme.fg("accent", text),
selectList: { selectList: {
description: (text) => theme.fg("muted", text), description: (text) => theme.fg("muted", text),
@@ -342,7 +344,6 @@ function createEditor(tui: Tui, theme: Theme) {
selectedText: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text),
}, },
}; };
return new Editor(tui, editorTheme);
} }
function getEditorTextForCurrentView(state: AskState): string { function getEditorTextForCurrentView(state: AskState): string {
+53 -2
View File
@@ -7,6 +7,11 @@ import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
type Theme = ExtensionContext["ui"]["theme"]; type Theme = ExtensionContext["ui"]["theme"];
type ThemeColor = "accent" | "muted" | "text" | "dim" | "success" | "warning"; type ThemeColor = "accent" | "muted" | "text" | "dim" | "success" | "warning";
const EDITOR_BORDER_PATTERN = /^[┌┐└┘─]+$/;
const EDITOR_SCROLL_BORDER_PATTERN = /^─── [↑↓] \d+ more ─*$/;
const ANSI_CONTROL_SEQUENCE = "\u001b[";
const ANSI_TERMINATOR = "m";
export function pushWrappedText( export function pushWrappedText(
lines: string[], lines: string[],
text: string, text: string,
@@ -65,8 +70,7 @@ export function renderEditorBlock(args: {
placeholderColor = "muted", placeholderColor = "muted",
isEmpty = false, isEmpty = false,
} = args; } = args;
const innerLines = const innerLines = getEditorContentLines(editorLines);
editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines;
if (isEmpty && placeholder) { if (isEmpty && placeholder) {
lines.push( lines.push(
@@ -93,6 +97,53 @@ export function renderEditorBlock(args: {
} }
} }
function getEditorContentLines(editorLines: string[]): string[] {
if (editorLines.length <= 2) {
return editorLines;
}
const contentLines = editorLines.slice(1);
const trailingBorderIndex = contentLines.findIndex(isEditorBorderLine);
if (trailingBorderIndex === -1) {
return contentLines;
}
return contentLines.filter((_, index) => index !== trailingBorderIndex);
}
function isEditorBorderLine(line: string): boolean {
const plainText = stripAnsiColorCodes(line).trim();
if (plainText.length === 0) {
return false;
}
return (
EDITOR_BORDER_PATTERN.test(plainText) ||
EDITOR_SCROLL_BORDER_PATTERN.test(plainText)
);
}
function stripAnsiColorCodes(text: string): string {
let result = text;
while (true) {
const start = result.indexOf(ANSI_CONTROL_SEQUENCE);
if (start === -1) {
return result;
}
const end = result.indexOf(
ANSI_TERMINATOR,
start + ANSI_CONTROL_SEQUENCE.length
);
if (end === -1) {
return result;
}
result =
result.slice(0, start) + result.slice(end + ANSI_TERMINATOR.length);
}
}
function renderEditorLine( function renderEditorLine(
line: string, line: string,
availableWidth: number, availableWidth: number,