fix(tui): replaced stale slash autocomplete prefixes

Slash command completions now replace the live token at the cursor instead of trusting the prefix captured when the autocomplete list rendered. This prevents fast typing before Tab acceptance from leaving partial command text before the completed command.\n\nFixes #1745
This commit is contained in:
roboomp
2026-06-03 02:29:08 +00:00
parent b92aa30b24
commit 36f86aa0bd
4 changed files with 98 additions and 27 deletions
+1
View File
@@ -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)).
+42 -26
View File
@@ -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
+30
View File
@@ -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;
@@ -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[],