From 0386239f5e4165281b40db540d129d86c8af63d4 Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Sun, 19 Apr 2026 15:47:18 +0200 Subject: [PATCH] feat: support @ file autocomplete in ask editor --- README.md | 1 + docs/contract.md | 1 + src/ui/autocomplete.ts | 49 +++++++++++++++++++++++++++++++++++ src/ui/controller.ts | 39 ++++++++++++++-------------- src/ui/render-helpers.ts | 55 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 src/ui/autocomplete.ts diff --git a/README.md b/README.md index 80b5850..b118996 100644 --- a/README.md +++ b/README.md @@ -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 - 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 +- free-form answer editors support pi-style `@` file path autocomplete for quickly mentioning project files - `Esc` closes the editor and returns to navigation mode - `Space` toggles the active option on single-select questions too, but does not auto-advance diff --git a/docs/contract.md b/docs/contract.md index 8d6ee69..768acbd 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -79,6 +79,7 @@ This document defines the stable external behavior. It does not explain internal - tabbed multi-question flow - single-select, multi-select, and 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` - option notes via `N` - number-key quick selection diff --git a/src/ui/autocomplete.ts b/src/ui/autocomplete.ts new file mode 100644 index 0000000..205f35a --- /dev/null +++ b/src/ui/autocomplete.ts @@ -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; + } +} diff --git a/src/ui/controller.ts b/src/ui/controller.ts index ed986f7..ccc3d87 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -26,6 +26,7 @@ import { } from "../state/transitions.ts"; import { isEditingView } from "../state/view.ts"; import type { AskParams, AskResult, AskState } from "../types.ts"; +import { createAskAutocompleteProvider } from "./autocomplete.ts"; import type { AskInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts"; import { renderAskScreen } from "./render.ts"; @@ -38,9 +39,9 @@ type Tui = CustomCallbackArgs[0]; type Theme = CustomCallbackArgs[1]; type Keybindings = CustomCallbackArgs[2]; type Done = (result: AskResult) => void; +type AskFlowParams = AskParams & Pick; interface AskFlowController { - cachedLines: string[] | undefined; done: Done; editor: Editor; state: AskState; @@ -54,7 +55,7 @@ export function runAskFlow( params: AskParams ): Promise { return ctx.ui.custom((...args) => - createAskFlowController(args, params) + createAskFlowController(args, { ...params, cwd: ctx.cwd }) ); } @@ -65,12 +66,11 @@ function createAskFlowController( Keybindings, (result: AskResult) => void, ], - params: AskParams + params: AskFlowParams ) { const controller: AskFlowController = { - cachedLines: undefined, done, - editor: createEditor(tui, theme), + editor: createEditor(tui, theme, params.cwd), state: createInitialState(params), suppressAutoInputForSelection: false, theme, @@ -83,7 +83,7 @@ function createAskFlowController( return { render: (width: number) => renderController(controller, width), invalidate() { - controller.cachedLines = undefined; + // The editor manages its own async autocomplete redraws. }, handleInput(data: string) { handleControllerInput(controller, data); @@ -95,15 +95,12 @@ function renderController( controller: AskFlowController, width: number ): string[] { - if (!controller.cachedLines) { - controller.cachedLines = renderAskScreen({ - editor: controller.editor, - state: controller.state, - theme: controller.theme, - width, - }); - } - return controller.cachedLines; + return renderAskScreen({ + editor: controller.editor, + state: controller.state, + theme: controller.theme, + width, + }); } function handleControllerInput(controller: AskFlowController, data: string) { @@ -268,7 +265,6 @@ function submitEditorValue(state: AskState, value: string): AskState { } function refresh(controller: AskFlowController) { - controller.cachedLines = undefined; controller.tui.requestRender(); } @@ -331,8 +327,14 @@ function saveEditorAndMoveOption( return moveOption(nextState, delta); } -function createEditor(tui: Tui, theme: Theme) { - const editorTheme: EditorTheme = { +function createEditor(tui: Tui, theme: Theme, cwd: string) { + 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), selectList: { description: (text) => theme.fg("muted", text), @@ -342,7 +344,6 @@ function createEditor(tui: Tui, theme: Theme) { selectedText: (text) => theme.fg("accent", text), }, }; - return new Editor(tui, editorTheme); } function getEditorTextForCurrentView(state: AskState): string { diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index 9c2a3e2..e377a99 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -7,6 +7,11 @@ import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts"; type Theme = ExtensionContext["ui"]["theme"]; 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( lines: string[], text: string, @@ -65,8 +70,7 @@ export function renderEditorBlock(args: { placeholderColor = "muted", isEmpty = false, } = args; - const innerLines = - editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines; + const innerLines = getEditorContentLines(editorLines); if (isEmpty && placeholder) { 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( line: string, availableWidth: number,