diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index e9248f2f..f4a7353c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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. diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index e26349a8..ac44fea9 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -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+])", () => {