Merge PR #5501: fix(tui): constrain automatic file completion triggers (@roboomp)
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
- Fixed an issue where streamed diff code fences retained unhighlighted rows in native scrollback when long transient blocks left the viewport before finalization ([#5126](https://github.com/can1357/oh-my-pi/issues/5126)).
|
||||
- Fixed native Windows Terminal sessions failing to detect mid-run light/dark theme changes when Mode 2031 appearance notifications are unavailable ([#5091](https://github.com/can1357/oh-my-pi/issues/5091)).
|
||||
- Hid empty HTML comment separators in Markdown-rendered TUI output instead of displaying them literally ([#4911](https://github.com/can1357/oh-my-pi/issues/4911)).
|
||||
- Fixed automatic file completion treating punctuation, trailing spaces, and ambiguous slash-command text as paths, immediately dismissing slash autocomplete on Backspace and requiring selection before applying a sole forced file match ([#5376](https://github.com/can1357/oh-my-pi/issues/5376)).
|
||||
|
||||
## [16.5.0] - 2026-07-13
|
||||
|
||||
|
||||
@@ -456,6 +456,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
prefix: isMidPromptSkillLookup ? commandText : textBeforeCursor,
|
||||
};
|
||||
}
|
||||
if (!isMidPromptSkillLookup && slashStart === leadingSlashStart && !commandText.slice(1).includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A slash token with no matching command may still be an absolute
|
||||
// path (`/tmp/fo` at prompt start, `see /tmp` mid-prompt); fall
|
||||
// through to file-path completion.
|
||||
@@ -679,15 +683,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
|
||||
// Only return empty string if the text looks like it's starting a path context
|
||||
if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
// Return empty string only after a space (not for completely empty text)
|
||||
// Empty text should not trigger file suggestions - that's for forced Tab completion
|
||||
if (pathPrefix === "" && text.endsWith(" ")) {
|
||||
// Automatic updates complete only unambiguous path syntax. Bare relative
|
||||
// tokens remain available through explicit Tab completion.
|
||||
if (
|
||||
pathPrefix.startsWith("/") ||
|
||||
pathPrefix.startsWith("./") ||
|
||||
pathPrefix.startsWith("../") ||
|
||||
pathPrefix.startsWith("~/")
|
||||
) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
|
||||
@@ -2077,15 +2077,13 @@ export class Editor implements Component, Focusable {
|
||||
this.#resetKillSequence();
|
||||
this.#recordUndoState();
|
||||
|
||||
let removedMidPromptSlashTrigger = false;
|
||||
let removedSlashTrigger = false;
|
||||
|
||||
if (this.#state.cursorCol > 0) {
|
||||
const line = this.#state.lines[this.#state.cursorLine] || "";
|
||||
const textBeforeCursor = line.slice(0, this.#state.cursorCol);
|
||||
const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
|
||||
removedMidPromptSlashTrigger =
|
||||
trailingSlashStart === this.#state.cursorCol - 1 &&
|
||||
(!this.#hasOnlyWhitespaceBeforeCursorLine() || textBeforeCursor.slice(0, trailingSlashStart).trim() !== "");
|
||||
removedSlashTrigger = trailingSlashStart === this.#state.cursorCol - 1;
|
||||
// An atomic placeholder token (image/paste marker) deletes as a unit, so a single
|
||||
// backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
|
||||
const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
|
||||
@@ -2125,7 +2123,7 @@ export class Editor implements Component, Focusable {
|
||||
|
||||
// Update or re-trigger autocomplete after backspace
|
||||
if (this.#autocompleteState) {
|
||||
if (removedMidPromptSlashTrigger) {
|
||||
if (removedSlashTrigger) {
|
||||
this.#cancelAutocomplete();
|
||||
this.onAutocompleteUpdate?.();
|
||||
} else {
|
||||
@@ -3046,17 +3044,17 @@ export class Editor implements Component, Focusable {
|
||||
} else if (this.#isInMidPromptSkillSlashContext()) {
|
||||
await this.#handleSlashCommandCompletion();
|
||||
if (!this.#autocompleteState) {
|
||||
await this.#forceFileAutocomplete(true);
|
||||
await this.#forceFileAutocomplete();
|
||||
}
|
||||
} else {
|
||||
await this.#forceFileAutocomplete(true);
|
||||
await this.#forceFileAutocomplete();
|
||||
}
|
||||
}
|
||||
async #handleSlashCommandCompletion(): Promise<void> {
|
||||
await this.#tryTriggerAutocomplete();
|
||||
}
|
||||
|
||||
async #forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
|
||||
async #forceFileAutocomplete(): Promise<void> {
|
||||
if (!this.#autocompleteProvider) return;
|
||||
|
||||
// File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
|
||||
@@ -3076,27 +3074,6 @@ export class Editor implements Component, Focusable {
|
||||
if (requestId !== this.#autocompleteRequestId) return;
|
||||
|
||||
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
||||
// If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
|
||||
if (explicitTab && suggestions.items.length === 1) {
|
||||
const item = suggestions.items[0]!;
|
||||
const result = this.#autocompleteProvider.applyCompletion(
|
||||
this.#state.lines,
|
||||
this.#state.cursorLine,
|
||||
this.#state.cursorCol,
|
||||
item,
|
||||
suggestions.prefix,
|
||||
);
|
||||
|
||||
this.#state.lines = result.lines;
|
||||
this.#state.cursorLine = result.cursorLine;
|
||||
this.#setCursorCol(result.cursorCol);
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#autocompletePrefix = suggestions.prefix;
|
||||
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
||||
this.#autocompleteState = "force";
|
||||
|
||||
@@ -265,6 +265,38 @@ describe("CombinedAutocompleteProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("natural file completion triggers", () => {
|
||||
it("uses only explicit path contexts during automatic updates", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-natural-trigger-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(baseDir, ".secret"), "secret\n");
|
||||
fs.mkdirSync(path.join(baseDir, "src"));
|
||||
fs.writeFileSync(path.join(baseDir, "src", "index.ts"), "export {};\n");
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[{ name: "model", description: "Switch model" }],
|
||||
baseDir,
|
||||
);
|
||||
|
||||
for (const line of [".", "Sentence .", "Sentence ", "use src/in", "/tmp"]) {
|
||||
expect(await provider.getSuggestions([line], 0, line.length)).toBeNull();
|
||||
}
|
||||
|
||||
const explicitPath = "./src/in";
|
||||
expect(
|
||||
(await provider.getSuggestions([explicitPath], 0, explicitPath.length))?.items.map(item => item.value),
|
||||
).toContain("./src/index.ts");
|
||||
const forcedPath = "use src/in";
|
||||
expect(
|
||||
(await provider.getForceFileSuggestions([forcedPath], 0, forcedPath.length))?.items.map(
|
||||
item => item.value,
|
||||
),
|
||||
).toContain("src/index.ts");
|
||||
} finally {
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("absolute path completion", () => {
|
||||
let baseDir: string;
|
||||
|
||||
|
||||
@@ -150,6 +150,35 @@ describe("Editor slash autocomplete acceptance", () => {
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("shows a sole forced file suggestion before applying it", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "editor-single-file-tab-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(baseDir, "alpha.ts"), "export {};\n");
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const getForceFileSuggestions = provider.getForceFileSuggestions;
|
||||
const requestHandled = Promise.withResolvers<void>();
|
||||
provider.getForceFileSuggestions = async (lines, cursorLine, cursorCol) => {
|
||||
const suggestions = await getForceFileSuggestions.call(provider, lines, cursorLine, cursorCol);
|
||||
requestHandled.resolve();
|
||||
return suggestions;
|
||||
};
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
editor.setAutocompleteProvider(provider);
|
||||
editor.setText("alp");
|
||||
|
||||
editor.handleInput("\t");
|
||||
await requestHandled.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(editor.getText()).toBe("alp");
|
||||
expect(editor.isShowingAutocomplete()).toBe(true);
|
||||
|
||||
editor.handleInput("\t");
|
||||
expect(editor.getText()).toBe("alpha.ts");
|
||||
} finally {
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
class SyncSlashProvider implements AutocompleteProvider {
|
||||
async getSuggestions(
|
||||
@@ -259,6 +288,19 @@ describe("Editor Enter handler sync slash completion", () => {
|
||||
expect(editor.isShowingAutocomplete()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides leading slash autocomplete immediately when Backspace removes the slash", async () => {
|
||||
const editor = createSkillEditor();
|
||||
|
||||
editor.handleInput("/");
|
||||
await Promise.resolve();
|
||||
expect(editor.isShowingAutocomplete()).toBe(true);
|
||||
|
||||
editor.handleInput("\x7f");
|
||||
|
||||
expect(editor.getText()).toBe("");
|
||||
expect(editor.isShowingAutocomplete()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes mid-prompt skill autocomplete on its own once the token stops being skill-shaped", async () => {
|
||||
const editor = createSkillEditor();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user