diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5f4c7a46d..397d8cf1e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed slash command autocomplete acceptance replacing only a stale rendered prefix, which could leave fast-typed characters before `/skills:` completions and corrupt the submitted command ([#1745](https://github.com/can1357/oh-my-pi/issues/1745)). - Fixed Ghostty/kitty/Alacritty-style ED3-risk terminals freezing the prompt after a deferred shrink; focused keyboard input now uses the same explicit user-input viewport opt-in as autocomplete and can repaint immediately instead of waiting for a resize. - Fixed emoji-presentation symbols (a default-text symbol followed by variation-selector-16 `U+FE0F`, e.g. `⚠️`, `ℹ️`, `❤️`, keycaps) measuring as 1 cell instead of 2 in the native width engine on macOS. The native scanner now keeps `UnicodeWidthStr` as the source of truth for multi-codepoint graphemes and applies only the local macOS Hangul Compatibility Jamo character-width delta, preserving VS16/keycap sequence widths without reintroducing jamo cursor drift. - Deferred eager live scrollback rebuilds on macOS Terminal.app and iTerm2 so assistant/tool streaming no longer emits ED3 (`CSI 3 J`) while their native viewport position is unobservable, preserving readers scrolled into terminal history ([#1300](https://github.com/can1357/oh-my-pi/issues/1300)). diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 341d88e0f..c775a268b 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -371,27 +371,39 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { const currentLine = lines[cursorLine] || ""; - const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const textBeforeCursor = currentLine.slice(0, cursorCol); const afterCursor = currentLine.slice(cursorCol); - // Check if we're completing a slash command (prefix starts with "/" but NOT a file path) - // Slash commands are at the start of the line and don't contain path separators after the first / - const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/"); - if (isSlashCommand) { - // This is a command name completion - const newLine = `${beforePrefix}/${item.value} ${afterCursor}`; - const newLines = [...lines]; - newLines[cursorLine] = newLine; + const slashStart = textBeforeCursor.indexOf("/"); + const hasOnlyWhitespaceBeforeSlash = slashStart >= 0 && textBeforeCursor.slice(0, slashStart).trim() === ""; - return { - lines: newLines, - cursorLine, - cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space - }; + // Slash command suggestions can be accepted before the debounced refresh + // catches up to newly typed characters. Replace the live command token, + // not only the prefix captured when the suggestion list was rendered. + if (prefix.startsWith("/") && hasOnlyWhitespaceBeforeSlash) { + const slashPrefix = textBeforeCursor.slice(slashStart); + if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) { + const beforeSlash = currentLine.slice(0, slashStart); + const newLine = `${beforeSlash}/${item.value} ${afterCursor}`; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + return { + lines: newLines, + cursorLine, + cursorCol: beforeSlash.length + item.value.length + 2, // +2 for "/" and space + }; + } } + let beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + // Check if we're completing a file attachment (prefix starts with "@") if (prefix.startsWith("@")) { + const liveAtPrefix = this.#extractAtPrefix(textBeforeCursor); + if (liveAtPrefix) { + beforePrefix = currentLine.slice(0, cursorCol - liveAtPrefix.length); + } // This is a file attachment completion const newLine = `${beforePrefix + item.value} ${afterCursor}`; const newLines = [...lines]; @@ -404,19 +416,23 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { }; } - // Check if we're in a slash command context (beforePrefix contains "/command ") - const textBeforeCursor = currentLine.slice(0, cursorCol); - if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) { - // This is likely a command argument completion - const newLine = beforePrefix + item.value + afterCursor; - const newLines = [...lines]; - newLines[cursorLine] = newLine; + // Check if the live cursor is in a slash command argument. + if (hasOnlyWhitespaceBeforeSlash) { + const slashText = textBeforeCursor.slice(slashStart); + const spaceIndex = slashText.indexOf(" "); + if (spaceIndex !== -1) { + // This is likely a command argument completion + const beforeArgument = currentLine.slice(0, slashStart + spaceIndex + 1); + const newLine = beforeArgument + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; - return { - lines: newLines, - cursorLine, - cursorCol: beforePrefix.length + item.value.length, - }; + return { + lines: newLines, + cursorLine, + cursorCol: beforeArgument.length + item.value.length, + }; + } } // For file paths, complete the path diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index ea12c22d5..61855e302 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -61,6 +61,36 @@ describe("CombinedAutocompleteProvider", () => { }); }); + describe("applyCompletion", () => { + it("replaces the live slash command prefix when rendered suggestions are stale", () => { + const provider = new CombinedAutocompleteProvider([], "/tmp"); + const result = provider.applyCompletion( + ["/ski"], + 0, + 4, + { value: "skills:fix-bug", label: "/skills:fix-bug" }, + "/s", + ); + + expect(result.lines[0]).toBe("/skills:fix-bug "); + expect(result.cursorCol).toBe("/skills:fix-bug ".length); + }); + + it("replaces the live slash command argument when rendered suggestions are stale", () => { + const provider = new CombinedAutocompleteProvider([], "/tmp"); + const result = provider.applyCompletion( + ["/model clau"], + 0, + 11, + { value: "claude-sonnet", label: "claude-sonnet" }, + "cl", + ); + + expect(result.lines[0]).toBe("/model claude-sonnet"); + expect(result.cursorCol).toBe("/model claude-sonnet".length); + }); + }); + describe("hidden paths", () => { let baseDir: string; diff --git a/packages/tui/test/editor-autocomplete-actions.test.ts b/packages/tui/test/editor-autocomplete-actions.test.ts index 4d8bd2988..c4e9c3fc5 100644 --- a/packages/tui/test/editor-autocomplete-actions.test.ts +++ b/packages/tui/test/editor-autocomplete-actions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import type { AutocompleteItem, AutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; +import { + type AutocompleteItem, + type AutocompleteProvider, + CombinedAutocompleteProvider, +} from "@oh-my-pi/pi-tui/autocomplete"; import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { defaultEditorTheme } from "./test-themes"; @@ -55,6 +59,26 @@ describe("Editor hash autocomplete actions", () => { expect(provider.calls).toBe(1); }); }); + +describe("Editor slash autocomplete acceptance", () => { + it("replaces characters typed after the rendered prefix before accepting with Tab", async () => { + const editor = new Editor(defaultEditorTheme); + editor.setAutocompleteProvider( + new CombinedAutocompleteProvider([{ name: "skills:fix-bug", description: "Fix a bug" }], "/tmp"), + ); + + editor.handleInput("/"); + await Bun.sleep(0); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput("s"); + editor.handleInput("k"); + editor.handleInput("i"); + editor.handleInput("\t"); + + expect(editor.getText()).toBe("/skills:fix-bug "); + }); +}); class SyncSlashProvider implements AutocompleteProvider { async getSuggestions( _lines: string[],