feat: add hash prompt actions

Fixes #283
This commit is contained in:
can1357
2026-03-09 14:19:10 +01:00
parent 111d5f9cc6
commit aa18e3f26d
11 changed files with 488 additions and 54 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `#`-triggered prompt action suggestions in the editor, with keybinding hints for line navigation and prompt copy actions
## [13.9.10] - 2026-03-08
### Added
@@ -31,6 +31,8 @@ export type AppAction =
| "followUp"
| "dequeue"
| "pasteImage"
| "copyLine"
| "copyPrompt"
| "newSession"
| "tree"
| "fork"
@@ -70,6 +72,8 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
followUp: "ctrl+enter",
dequeue: "alt+up",
pasteImage: "ctrl+v",
copyLine: "alt+shift+l",
copyPrompt: "alt+shift+c",
newSession: [],
tree: [],
fork: [],
@@ -104,6 +108,8 @@ const APP_ACTIONS: AppAction[] = [
"followUp",
"dequeue",
"pasteImage",
"copyLine",
"copyPrompt",
"newSession",
"tree",
"fork",
@@ -122,6 +122,7 @@ export class WelcomeComponent implements Component {
const rightLines = [
` ${theme.bold(theme.fg("accent", "Tips"))}`,
` ${theme.fg("dim", "?")}${theme.fg("muted", " for keyboard shortcuts")}`,
` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
@@ -435,49 +435,54 @@ export class CommandController {
const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
const planModeKey = this.ctx.keybindings.getDisplayString("togglePlanMode") || "Alt+Shift+P";
const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || "Alt+H";
const copyLineKey = this.ctx.keybindings.getDisplayString("copyLine") || "Alt+Shift+L";
const copyPromptKey = this.ctx.keybindings.getDisplayString("copyPrompt") || "Alt+Shift+C";
const hotkeys = `
**Navigation**
| Key | Action |
|-----|--------|
| \`Arrow keys\` | Move cursor / browse history (Up when empty) |
| \`Option+Left/Right\` | Move by word |
| \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
| \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
**Editing**
| Key | Action |
|-----|--------|
| \`Enter\` | Send message |
| \`Shift+Enter\` / \`Alt+Enter\` | New line |
| \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
| \`Ctrl+U\` | Delete to start of line |
| \`Ctrl+K\` | Delete to end of line |
**Other**
| Key | Action |
|-----|--------|
| \`Tab\` | Path completion / accept autocomplete |
| \`Escape\` | Cancel autocomplete / abort streaming |
| \`Ctrl+C\` | Clear editor (first) / exit (second) |
| \`Ctrl+D\` | Exit (when editor is empty) |
| \`Ctrl+Z\` | Suspend to background |
| \`Shift+Tab\` | Cycle thinking level |
| \`Ctrl+P\` | Cycle role models (slow/default/smol) |
| \`Shift+Ctrl+P\` | Cycle role models (temporary) |
| \`Alt+P\` | Select model (temporary) |
| \`Ctrl+L\` | Select model (set roles) |
| \`${planModeKey}\` | Toggle plan mode |
| \`Ctrl+R\` | Search prompt history |
| \`${expandToolsKey}\` | Toggle tool output expansion |
| \`Ctrl+T\` | Toggle todo list expansion |
| \`Ctrl+G\` | Edit message in external editor |
| \`${sttKey}\` | Toggle speech-to-text recording |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
| \`!!\` | Run bash command (excluded from context) |
| \`$\` | Run Python in shared kernel |
| \`$$\` | Run Python (excluded from context) |
`;
**Navigation**
| Key | Action |
|-----|--------|
| \`Arrow keys\` | Move cursor / browse history (Up when empty) |
| \`Option+Left/Right\` | Move by word |
| \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
| \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
**Editing**
| Key | Action |
|-----|--------|
| \`Enter\` | Send message |
| \`Shift+Enter\` / \`Alt+Enter\` | New line |
| \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
| \`Ctrl+U\` | Delete to start of line |
| \`Ctrl+K\` | Delete to end of line |
| \`${copyLineKey}\` | Copy current line |
| \`${copyPromptKey}\` | Copy whole prompt |
**Other**
| Key | Action |
|-----|--------|
| \`Tab\` | Path completion / accept autocomplete |
| \`Escape\` | Cancel autocomplete / abort streaming |
| \`Ctrl+C\` | Clear editor (first) / exit (second) |
| \`Ctrl+D\` | Exit (when editor is empty) |
| \`Ctrl+Z\` | Suspend to background |
| \`Shift+Tab\` | Cycle thinking level |
| \`Ctrl+P\` | Cycle role models (slow/default/smol) |
| \`Shift+Ctrl+P\` | Cycle role models (temporary) |
| \`Alt+P\` | Select model (temporary) |
| \`Ctrl+L\` | Select model (set roles) |
| \`${planModeKey}\` | Toggle plan mode |
| \`Ctrl+R\` | Search prompt history |
| \`${expandToolsKey}\` | Toggle tool output expansion |
| \`Ctrl+T\` | Toggle todo list expansion |
| \`Ctrl+G\` | Edit message in external editor |
| \`${sttKey}\` | Toggle speech-to-text recording |
| \`#\` | Open prompt actions |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
| \`!!\` | Run bash command (excluded from context) |
| \`$\` | Run Python in shared kernel |
| \`$$\` | Run Python (excluded from context) |
`;
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
@@ -1,8 +1,10 @@
import * as fs from "node:fs/promises";
import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@oh-my-pi/pi-natives";
import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
import { $env } from "@oh-my-pi/pi-utils";
import { settings } from "../../config/settings";
import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
import { theme } from "../../modes/theme/theme";
import type { InteractiveModeContext } from "../../modes/types";
import type { AgentSessionEvent } from "../../session/agent-session";
@@ -87,7 +89,8 @@ export class InputController {
this.ctx.editor.onCtrlG = () => void this.openExternalEditor();
this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
this.ctx.editor.onCtrlV = () => this.handleImagePaste();
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
const copyPromptKeys = this.ctx.keybindings.getKeys("copyPrompt");
this.ctx.editor.onCopyPrompt = copyPromptKeys.includes("alt+shift+c") ? () => this.handleCopyPrompt() : undefined;
// Wire up extension shortcuts
this.registerExtensionShortcuts();
@@ -129,6 +132,13 @@ export class InputController {
for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
}
for (const key of this.ctx.keybindings.getKeys("copyLine")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
}
for (const key of copyPromptKeys) {
if (key === "alt+shift+c") continue;
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyPrompt());
}
this.ctx.editor.onChange = (text: string) => {
const wasBashMode = this.ctx.isBashMode;
@@ -503,6 +513,36 @@ export class InputController {
}
}
createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider {
return createPromptActionAutocompleteProvider({
commands,
basePath,
keybindings: this.ctx.keybindings,
copyCurrentLine: () => this.handleCopyCurrentLine(),
copyPrompt: () => this.handleCopyPrompt(),
moveCursorToLineStart: () => this.ctx.editor.moveToLineStart(),
moveCursorToLineEnd: () => this.ctx.editor.moveToLineEnd(),
});
}
/** Copy the current editor line to the system clipboard. */
handleCopyCurrentLine(): void {
const { line } = this.ctx.editor.getCursor();
const text = this.ctx.editor.getLines()[line] || "";
if (!text) {
this.ctx.showStatus("Nothing to copy");
return;
}
try {
copyToClipboard(text);
const sanitized = sanitizeText(text);
const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
this.ctx.showStatus(`Copied line: ${preview}`);
} catch {
this.ctx.showWarning("Failed to copy to clipboard");
}
}
/** Copy current prompt text to system clipboard. */
handleCopyPrompt(): void {
const text = this.ctx.editor.getText();
@@ -6,15 +6,7 @@ import * as path from "node:path";
import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui";
import {
CombinedAutocompleteProvider,
Container,
Markdown,
ProcessTerminal,
Spacer,
Text,
TUI,
} from "@oh-my-pi/pi-tui";
import { Container, Markdown, ProcessTerminal, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { KeybindingsManager } from "../config/keybindings";
@@ -389,7 +381,7 @@ export class InteractiveMode implements InteractiveModeContext {
name: cmd.name,
description: cmd.description,
}));
const autocompleteProvider = new CombinedAutocompleteProvider(
const autocompleteProvider = this.#inputController.createAutocompleteProvider(
[...this.#pendingSlashCommands, ...fileSlashCommands],
basePath,
);
@@ -0,0 +1,201 @@
import {
CombinedAutocompleteProvider,
getEditorKeybindings,
type AutocompleteItem,
type AutocompleteProvider,
type SlashCommand,
} from "@oh-my-pi/pi-tui";
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
interface PromptActionDefinition {
id: string;
label: string;
description: string;
keywords: string[];
execute: () => void;
}
interface PromptActionAutocompleteItem extends AutocompleteItem {
actionId: string;
execute: () => void;
}
interface PromptActionAutocompleteOptions {
commands: SlashCommand[];
basePath: string;
keybindings: KeybindingsManager;
copyCurrentLine: () => void;
copyPrompt: () => void;
moveCursorToLineStart: () => void;
moveCursorToLineEnd: () => void;
}
function fuzzyMatch(query: string, target: string): boolean {
if (query.length === 0) return true;
if (query.length > target.length) return false;
let queryIndex = 0;
for (let targetIndex = 0; targetIndex < target.length && queryIndex < query.length; targetIndex += 1) {
if (query[queryIndex] === target[targetIndex]) {
queryIndex += 1;
}
}
return queryIndex === query.length;
}
function fuzzyScore(query: string, target: string): number {
if (query.length === 0) return 1;
if (target === query) return 100;
if (target.startsWith(query)) return 80;
if (target.includes(query)) return 60;
let queryIndex = 0;
let gaps = 0;
let lastMatchIndex = -1;
for (let targetIndex = 0; targetIndex < target.length && queryIndex < query.length; targetIndex += 1) {
if (query[queryIndex] === target[targetIndex]) {
if (lastMatchIndex >= 0 && targetIndex - lastMatchIndex > 1) {
gaps += 1;
}
lastMatchIndex = targetIndex;
queryIndex += 1;
}
}
if (queryIndex !== query.length) return 0;
return Math.max(1, 40 - gaps * 5);
}
function isPromptActionItem(item: AutocompleteItem): item is PromptActionAutocompleteItem {
return (
"actionId" in item && "execute" in item && typeof (item as PromptActionAutocompleteItem).execute === "function"
);
}
function getPromptActionPrefix(textBeforeCursor: string): string | null {
const hashIndex = textBeforeCursor.lastIndexOf("#");
if (hashIndex === -1) return null;
const query = textBeforeCursor.slice(hashIndex + 1);
if (/[\s]/.test(query)) {
return null;
}
return textBeforeCursor.slice(hashIndex);
}
export class PromptActionAutocompleteProvider implements AutocompleteProvider {
#baseProvider: CombinedAutocompleteProvider;
#actions: PromptActionDefinition[];
constructor(commands: SlashCommand[], basePath: string, actions: PromptActionDefinition[]) {
this.#baseProvider = new CombinedAutocompleteProvider(commands, basePath);
this.#actions = actions;
}
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
const promptActionPrefix = getPromptActionPrefix(textBeforeCursor);
if (promptActionPrefix) {
const query = promptActionPrefix.slice(1).toLowerCase();
const items = this.#actions
.map(action => {
const searchable = [action.label, action.description, ...action.keywords].join(" ").toLowerCase();
if (!fuzzyMatch(query, searchable)) return null;
return {
value: action.label,
label: action.label,
description: action.description,
actionId: action.id,
execute: action.execute,
score: fuzzyScore(query, searchable),
} satisfies PromptActionAutocompleteItem & { score: number };
})
.filter(item => item !== null)
.sort((a, b) => b.score - a.score)
.map(({ score: _score, ...item }) => item);
if (items.length > 0) {
return { items, prefix: promptActionPrefix };
}
}
return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): {
lines: string[];
cursorLine: number;
cursorCol: number;
onApplied?: () => void;
} {
if (prefix.startsWith("#") && isPromptActionItem(item)) {
const currentLine = lines[cursorLine] || "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const newLines = [...lines];
newLines[cursorLine] = beforePrefix + afterCursor;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length,
onApplied: item.execute,
};
}
return this.#baseProvider.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
}
getInlineHint(lines: string[], cursorLine: number, cursorCol: number): string | null {
return this.#baseProvider.getInlineHint?.(lines, cursorLine, cursorCol) ?? null;
}
}
export function createPromptActionAutocompleteProvider(
options: PromptActionAutocompleteOptions,
): PromptActionAutocompleteProvider {
const editorKeybindings = getEditorKeybindings();
const actions: PromptActionDefinition[] = [
{
id: "copy-line",
label: "Copy current line",
description: formatKeyHints(options.keybindings.getKeys("copyLine")),
keywords: ["copy", "line", "clipboard", "current"],
execute: options.copyCurrentLine,
},
{
id: "copy-prompt",
label: "Copy whole prompt",
description: formatKeyHints(options.keybindings.getKeys("copyPrompt")),
keywords: ["copy", "prompt", "clipboard", "message"],
execute: options.copyPrompt,
},
{
id: "cursor-line-start",
label: "Move cursor to beginning of line",
description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")),
keywords: ["move", "cursor", "line", "start", "beginning", "home"],
execute: options.moveCursorToLineStart,
},
{
id: "cursor-line-end",
label: "Move cursor to end of line",
description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")),
keywords: ["move", "cursor", "line", "end"],
execute: options.moveCursorToLineEnd,
},
];
return new PromptActionAutocompleteProvider(options.commands, options.basePath, actions);
}
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { EditorKeybindingsManager, setEditorKeybindings } from "../../tui/src/keybindings";
import { KeybindingsManager } from "../src/config/keybindings";
import { createPromptActionAutocompleteProvider } from "../src/modes/prompt-action-autocomplete";
describe("prompt action autocomplete", () => {
beforeEach(() => {
setEditorKeybindings(
new EditorKeybindingsManager({
cursorLineStart: ["home", "f6"],
cursorLineEnd: "f7",
}),
);
});
afterEach(() => {
setEditorKeybindings(new EditorKeybindingsManager());
});
it("shows prompt actions with configured shortcut hints", async () => {
const provider = createPromptActionAutocompleteProvider({
commands: [],
basePath: "/tmp",
keybindings: KeybindingsManager.inMemory({
copyLine: "ctrl+shift+l",
copyPrompt: ["alt+shift+c", "ctrl+shift+c"],
}),
copyCurrentLine: () => {},
copyPrompt: () => {},
moveCursorToLineStart: () => {},
moveCursorToLineEnd: () => {},
});
const suggestions = await provider.getSuggestions(["#"], 0, 1);
expect(suggestions).not.toBeNull();
expect(suggestions?.prefix).toBe("#");
expect(suggestions?.items.map(item => item.label)).toEqual([
"Copy current line",
"Copy whole prompt",
"Move cursor to beginning of line",
"Move cursor to end of line",
]);
expect(suggestions?.items.find(item => item.label === "Copy current line")?.description).toBe("Ctrl+Shift+L");
expect(suggestions?.items.find(item => item.label === "Copy whole prompt")?.description).toBe(
"Alt+Shift+C/Ctrl+Shift+C",
);
expect(suggestions?.items.find(item => item.label === "Move cursor to beginning of line")?.description).toBe(
"Home/F6",
);
expect(suggestions?.items.find(item => item.label === "Move cursor to end of line")?.description).toBe("F7");
});
it("executes selected prompt actions and removes the trigger text", async () => {
let promptCopies = 0;
const provider = createPromptActionAutocompleteProvider({
commands: [],
basePath: "/tmp",
keybindings: KeybindingsManager.inMemory(),
copyCurrentLine: () => {},
copyPrompt: () => {
promptCopies += 1;
},
moveCursorToLineStart: () => {},
moveCursorToLineEnd: () => {},
});
const suggestions = await provider.getSuggestions(["hello #cop"], 0, 10);
const item = suggestions?.items.find(entry => entry.label === "Copy whole prompt");
expect(item).toBeDefined();
if (!item || !suggestions) {
throw new Error("expected copy whole prompt suggestion");
}
const result = provider.applyCompletion(["hello #cop"], 0, 10, item, suggestions.prefix);
expect(result.lines).toEqual(["hello "]);
expect(result.cursorLine).toBe(0);
expect(result.cursorCol).toBe(6);
result.onApplied?.();
expect(promptCopies).toBe(1);
});
it("falls back to normal typing for literal hashtags with no matching action", async () => {
const provider = createPromptActionAutocompleteProvider({
commands: [],
basePath: "/tmp",
keybindings: KeybindingsManager.inMemory(),
copyCurrentLine: () => {},
copyPrompt: () => {},
moveCursorToLineStart: () => {},
moveCursorToLineEnd: () => {},
});
const suggestions = await provider.getSuggestions(["release #v1"], 0, 11);
expect(suggestions).toBeNull();
});
});
+1
View File
@@ -188,6 +188,7 @@ export interface AutocompleteProvider {
lines: string[];
cursorLine: number;
cursorCol: number;
onApplied?: () => void;
};
/** Get inline hint text to show as dim ghost text after the cursor */
+32 -1
View File
@@ -789,6 +789,8 @@ export class Editor implements Component, Focusable {
if (this.onChange) {
this.onChange(this.getText());
}
result.onApplied?.();
}
return;
}
@@ -818,6 +820,7 @@ export class Editor implements Component, Focusable {
this.#state.lines = result.lines;
this.#state.cursorLine = result.cursorLine;
this.#setCursorCol(result.cursorCol);
result.onApplied?.();
}
this.#cancelAutocomplete();
}
@@ -844,6 +847,8 @@ export class Editor implements Component, Focusable {
if (this.onChange) {
this.onChange(this.getText());
}
result.onApplied?.();
}
return;
}
@@ -1132,6 +1137,14 @@ export class Editor implements Component, Focusable {
return { line: this.#state.cursorLine, col: this.#state.cursorCol };
}
moveToLineStart(): void {
this.#moveToLineStart();
}
moveToLineEnd(): void {
this.#moveToLineEnd();
}
setText(text: string): void {
this.#historyIndex = -1; // Exit history browsing mode
this.#resetKillSequence();
@@ -1200,7 +1213,11 @@ export class Editor implements Component, Focusable {
this.#tryTriggerAutocomplete();
}
}
// Also auto-trigger when typing letters/path chars in a slash command context
// Auto-trigger for "#" prompt actions anywhere in the current token
else if (char === "#") {
this.#tryTriggerAutocomplete();
}
// Also auto-trigger when typing letters/path chars in a completable context
else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
@@ -1212,6 +1229,10 @@ export class Editor implements Component, Focusable {
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.#tryTriggerAutocomplete();
}
// Check if we're in a # prompt action context
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
this.#tryTriggerAutocomplete();
}
}
} else {
this.#debouncedUpdateAutocomplete();
@@ -1385,6 +1406,10 @@ export class Editor implements Component, Focusable {
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.#tryTriggerAutocomplete();
}
// # prompt action context
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
this.#tryTriggerAutocomplete();
}
}
}
@@ -1521,6 +1546,8 @@ export class Editor implements Component, Focusable {
this.#tryTriggerAutocomplete();
} else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.#tryTriggerAutocomplete();
} else if (textBeforeCursor.match(/#[^\s#]*$/)) {
this.#tryTriggerAutocomplete();
}
}
}
@@ -1812,6 +1839,10 @@ export class Editor implements Component, Focusable {
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.#tryTriggerAutocomplete();
}
// # prompt action context
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
this.#tryTriggerAutocomplete();
}
}
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "bun:test";
import type { AutocompleteItem, AutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete";
import { Editor } from "@oh-my-pi/pi-tui/components/editor";
import { defaultEditorTheme } from "./test-themes";
class HashActionProvider implements AutocompleteProvider {
async getSuggestions(
lines: string[],
_cursorLine: number,
cursorCol: number,
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
const prefix = (lines[0] || "").slice(0, cursorCol);
if (prefix !== "#") {
return null;
}
return {
prefix,
items: [{ value: "action", label: "Do action" }],
};
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
_item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number; onApplied?: () => void } {
const line = lines[cursorLine] || "";
return {
lines: [line.slice(0, cursorCol - prefix.length) + line.slice(cursorCol)],
cursorLine,
cursorCol: cursorCol - prefix.length,
onApplied: () => {
this.calls += 1;
},
};
}
calls = 0;
}
describe("Editor hash autocomplete actions", () => {
it("auto-triggers # suggestions and runs autocomplete callbacks on selection", async () => {
const provider = new HashActionProvider();
const editor = new Editor(defaultEditorTheme);
editor.setAutocompleteProvider(provider);
editor.handleInput("#");
await Bun.sleep(0);
editor.handleInput("\r");
expect(editor.getText()).toBe("");
expect(provider.calls).toBe(1);
});
});