fix(tui): chain slash arg autocomplete after Tab completion

fixes #1437
This commit is contained in:
Benjamin Rapaport
2026-02-09 13:56:01 -05:00
committed by Mario Zechner
parent 52bf3b515d
commit 6937d2129e
2 changed files with 86 additions and 4 deletions

View File

@@ -479,6 +479,8 @@ export class Editor implements Component, Focusable {
if (kb.matches(data, "tab")) {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection();
this.pushUndoSnapshot();
this.lastAction = null;
const result = this.autocompleteProvider.applyCompletion(
@@ -493,6 +495,10 @@ export class Editor implements Component, Focusable {
this.setCursorCol(result.cursorCol);
this.cancelAutocomplete();
if (this.onChange) this.onChange(this.getText());
if (shouldChainSlashArgumentAutocomplete && this.isBareCompletedSlashCommandAtCursor()) {
this.tryTriggerAutocomplete();
}
}
return;
}
@@ -1827,6 +1833,26 @@ export class Editor implements Component, Focusable {
return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
}
private shouldChainSlashArgumentAutocompleteOnTabSelection(): boolean {
if (this.autocompleteState !== "regular") {
return false;
}
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
return this.isInSlashCommandContext(textBeforeCursor) && !textBeforeCursor.trimStart().includes(" ");
}
private isBareCompletedSlashCommandAtCursor(): boolean {
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol !== currentLine.length) {
return false;
}
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol).trimStart();
return /^\/\S+ $/.test(textBeforeCursor);
}
// Autocomplete methods
/**
* Find the best autocomplete item index for the given prefix.

View File

@@ -1,7 +1,7 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { stripVTControlCharacters } from "node:util";
import type { AutocompleteProvider } from "../src/autocomplete.js";
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.js";
import { Editor, wordWrapLine } from "../src/components/editor.js";
import { TUI } from "../src/tui.js";
import { visibleWidth } from "../src/utils.js";
@@ -2064,9 +2064,9 @@ describe("Editor component", () => {
if (argtestMatch) {
const argumentText = argtestMatch[1]!;
const allArguments = [
{ value: "two", label: "two" }, // First item
{ value: "three", label: "three" }, // Second item
{ value: "twelve", label: "twelve" }, // Third item
{ value: "two", label: "two" },
{ value: "three", label: "three" },
{ value: "twelve", label: "twelve" },
];
// Return all items that start with the typed prefix
const filtered = allArguments.filter((arg) => arg.value.startsWith(argumentText));
@@ -2254,6 +2254,62 @@ describe("Editor component", () => {
// The exact typed value should be retained
assert.strictEqual(editor.getText(), "/model gpt-4o-mini");
});
it("chains into argument completions after tab-completing slash command names", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([
{
name: "model",
description: "Switch model",
getArgumentCompletions: (prefix: string) => {
const items = [
{ value: "claude-opus", label: "claude-opus" },
{ value: "claude-sonnet", label: "claude-sonnet" },
];
return items.filter((item) => item.value.startsWith(prefix));
},
},
{ name: "help", description: "Show help" },
]);
editor.setAutocompleteProvider(provider);
editor.handleInput("/");
editor.handleInput("m");
editor.handleInput("o");
editor.handleInput("d");
assert.strictEqual(editor.isShowingAutocomplete(), true);
editor.handleInput("\t");
assert.strictEqual(editor.getText(), "/model ");
assert.strictEqual(editor.isShowingAutocomplete(), true);
editor.handleInput("\t");
assert.strictEqual(editor.getText(), "/model claude-opus");
assert.strictEqual(editor.isShowingAutocomplete(), false);
});
it("does not show argument completions when command has no argument completer", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const provider = new CombinedAutocompleteProvider([
{ name: "help", description: "Show help" },
{
name: "model",
description: "Switch model",
getArgumentCompletions: () => [{ value: "claude-opus", label: "claude-opus" }],
},
]);
editor.setAutocompleteProvider(provider);
editor.handleInput("/");
editor.handleInput("h");
editor.handleInput("e");
assert.strictEqual(editor.isShowingAutocomplete(), true);
editor.handleInput("\t");
assert.strictEqual(editor.getText(), "/help ");
assert.strictEqual(editor.isShowingAutocomplete(), false);
});
});
describe("Character jump (Ctrl+])", () => {