Merge PR #5501: fix(tui): constrain automatic file completion triggers (@roboomp)

This commit is contained in:
can1357
2026-07-14 23:11:08 +02:00
5 changed files with 93 additions and 38 deletions
+1
View File
@@ -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
+12 -9
View File
@@ -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;
}
+6 -29
View File
@@ -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";
+32
View File
@@ -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();