@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user