Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 2 additions & 18 deletions packages/tui/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from "child_process";
import { readdirSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { fuzzyFilter } from "./fuzzy.ts";
import { getSlashCommandSuggestions } from "./slash-command-autocomplete.ts";

const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);

Expand Down Expand Up @@ -307,23 +307,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {

if (spaceIndex === -1) {
const prefix = textBeforeCursor.slice(1);
const commandItems = this.commands.map((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
const desc = cmd.description ?? "";
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
return {
name,
label: name,
description: fullDesc || undefined,
};
});

const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
value: item.name,
label: item.label,
...(item.description && { description: item.description }),
}));
const filtered = getSlashCommandSuggestions(this.commands, prefix);

if (filtered.length === 0) return null;

Expand Down
54 changes: 54 additions & 0 deletions packages/tui/src/slash-command-autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { AutocompleteItem, SlashCommand } from "./autocomplete.ts";
import { fuzzyFilter } from "./fuzzy.ts";

type CommandItem = {
readonly name: string;
readonly label: string;
readonly description?: string;
};

type RankedCommandItem = AutocompleteItem & {
readonly index: number;
};

function compareSlashCommandSuggestion(prefix: string, left: RankedCommandItem, right: RankedCommandItem): number {
const leftExact = left.value === prefix;
const rightExact = right.value === prefix;
if (leftExact !== rightExact) return leftExact ? -1 : 1;

const leftPrefix = left.value.startsWith(prefix);
const rightPrefix = right.value.startsWith(prefix);
if (leftPrefix !== rightPrefix) return leftPrefix ? -1 : 1;
if (leftPrefix && rightPrefix && left.value.length !== right.value.length) {
return right.value.length - left.value.length;
}

return left.index - right.index;
}

export function getSlashCommandSuggestions(
commands: readonly (SlashCommand | AutocompleteItem)[],
prefix: string,
): AutocompleteItem[] {
const commandItems: CommandItem[] = commands.map((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
const desc = cmd.description ?? "";
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
return {
name,
label: name,
description: fullDesc || undefined,
};
});

return fuzzyFilter(commandItems, prefix, (item) => item.name)
.map((item, index) => ({
value: item.name,
label: item.label,
...(item.description && { description: item.description }),
index,
}))
.sort((left, right) => compareSlashCommandSuggestion(prefix, left, right))
.map(({ index: _index, ...item }) => item);
}
48 changes: 48 additions & 0 deletions packages/tui/test/autocomplete-slash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { CombinedAutocompleteProvider } from "../src/autocomplete.ts";

const getSuggestions = (provider: CombinedAutocompleteProvider, line: string) =>
provider.getSuggestions([line], 0, line.length, { signal: new AbortController().signal });

describe("CombinedAutocompleteProvider slash command suggestions", () => {
it("ranks longer prefix matches before shorter commands when the typed slash command is ambiguous", async () => {
// Given
const provider = new CombinedAutocompleteProvider(
[
{ name: "session", description: "Show session info and stats" },
{ name: "sessions", description: "Peek at previous session transcripts in a HUD" },
],
"/tmp",
);

// When
const result = await getSuggestions(provider, "/sessio");

// Then
assert.deepStrictEqual(
result?.items.map((item) => item.value),
["sessions", "session"],
);
});

it("keeps exact slash command matches before longer commands", async () => {
// Given
const provider = new CombinedAutocompleteProvider(
[
{ name: "session", description: "Show session info and stats" },
{ name: "sessions", description: "Peek at previous session transcripts in a HUD" },
],
"/tmp",
);

// When
const result = await getSuggestions(provider, "/session");

// Then
assert.deepStrictEqual(
result?.items.map((item) => item.value),
["session", "sessions"],
);
});
});
Loading