feat: support @ file autocomplete in ask editor
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-14
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user