diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7460da996..1c7727ba7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 574b71533..fe9d0c776 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -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 = { 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", diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 6aa0802fd..f08bde2a6 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -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")}`, diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 336141080..5f9fa2bfb 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -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)); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 2f4719d60..abe86f85c 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index bd1748c99..3ef1afa84 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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, ); diff --git a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts new file mode 100644 index 000000000..2db64edf6 --- /dev/null +++ b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts @@ -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); +} diff --git a/packages/coding-agent/test/prompt-action-autocomplete.test.ts b/packages/coding-agent/test/prompt-action-autocomplete.test.ts new file mode 100644 index 000000000..78d3f8d58 --- /dev/null +++ b/packages/coding-agent/test/prompt-action-autocomplete.test.ts @@ -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(); + }); +}); diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 8f0f2fae0..cf37f10ef 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -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 */ diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index db1d7775c..1212d9532 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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(); + } } } diff --git a/packages/tui/test/editor-autocomplete-actions.test.ts b/packages/tui/test/editor-autocomplete-actions.test.ts new file mode 100644 index 000000000..5cd737f5b --- /dev/null +++ b/packages/tui/test/editor-autocomplete-actions.test.ts @@ -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); + }); +});