mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
* feat(app): show loading and errors in the composer command menu Rows are single lines with a kind icon, the matched part of the name, the argument hint, the description and a source tag. The detail card is gone. The menu used to stay hidden while commands loaded and then show "Error: ..." over a list whose options Enter could still pick, so a failed load plus Enter archived the agent through a hidden /exit. Loading and error are now status rows with no options; the error row names the reason and offers Retry. Loading appears after 300ms so a warm cache paints the results first, as before. An empty query used to select /exit. Provider commands now come first, so the bottom row the input selects is a provider command. useAutocomplete derives the selection during render. When results replace the loading row, the selection is valid in the same render, so the popover stays mounted at its anchor instead of remounting. Claude Code's `__`-prefixed commands (/__remote-workflow) only run in its own server-launched sessions, so the Claude provider no longer lists them. * fix(app): never default the command menu to a Paseo action An arrow-key choice was tied to the query it was made for, so returning to that query brought it back: arrow to /exit on `/`, type `com`, delete it, and Enter archived the agent. A choice now clears whenever the query changes. An empty query still selected /exit when the agent had no provider or plugin commands, because Paseo's actions were the only entries. With nothing else to list, an empty query now lists nothing and waits for a search; typing `/ex` still finds /exit. * fix(app): sort empty-query commands without toSorted Hermes has no Array.prototype.toSorted, so typing `/` threw "undefined is not a function" on iOS and Android and the app showed its error screen. Browsers and Node have it, which is why web and unit tests passed. * test(app): cover the stale command choice in Playwright and wait out the loading row on devices The renderHook test mounted React under JSDOM, which docs/testing.md forbids. The Playwright test drives the same sequence in a real browser and fails without the reset in useAutocomplete: Enter chose the stale /exit. The composer-keyboard harnesses treated a visible popover as a ready list. The popover now shows a loading row first, and a cold Claude command list takes several seconds, so both harnesses wait for the rows. * fix(app): size the command menu loading spinner to the row icons Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
188 lines
5.6 KiB
TypeScript
188 lines
5.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
getAutocompleteFallbackIndex,
|
|
orderAutocompleteOptions,
|
|
} from "@/components/ui/autocomplete-utils";
|
|
import {
|
|
applySlashCommandReplacement,
|
|
filterAndRankCommandAutocompleteEntries,
|
|
filterInlineSkillCommandEntries,
|
|
findActiveSlashCommand,
|
|
} from "./agent-command-autocomplete";
|
|
|
|
describe("filterAndRankCommandAutocompleteEntries", () => {
|
|
const entries = [
|
|
{ source: "provider" as const, command: { name: "paseo-committee" } },
|
|
{ source: "provider" as const, command: { name: "commit" } },
|
|
{ source: "provider" as const, command: { name: "paseo-advisor" } },
|
|
];
|
|
|
|
it("ranks command-name prefixes above later word-boundary partial matches", () => {
|
|
const result = filterAndRankCommandAutocompleteEntries(entries, "comm");
|
|
|
|
expect(result.map((entry) => entry.command.name)).toEqual(["commit", "paseo-committee"]);
|
|
});
|
|
|
|
it("matches client command aliases", () => {
|
|
const result = filterAndRankCommandAutocompleteEntries(
|
|
[
|
|
{ source: "client" as const, command: { name: "exit", aliases: ["quit", "q"] } },
|
|
{ source: "client" as const, command: { name: "clear", aliases: ["new"] } },
|
|
],
|
|
"q",
|
|
);
|
|
|
|
expect(result.map((entry) => entry.command.name)).toEqual(["exit"]);
|
|
});
|
|
});
|
|
|
|
describe("filterAndRankCommandAutocompleteEntries on an empty query", () => {
|
|
const rootEntries = [
|
|
{ source: "client" as const, command: { name: "exit", aliases: ["quit", "q"] } },
|
|
{ source: "client" as const, command: { name: "clear", aliases: ["new"] } },
|
|
{ source: "plugin" as const, command: { name: "handoff" } },
|
|
{ source: "provider" as const, command: { name: "compact" } },
|
|
{ source: "provider" as const, command: { name: "review" } },
|
|
];
|
|
|
|
function selectedOnEnter(query: string) {
|
|
const listed = orderAutocompleteOptions(
|
|
filterAndRankCommandAutocompleteEntries(rootEntries, query),
|
|
);
|
|
return listed[getAutocompleteFallbackIndex(listed.length)]?.command.name;
|
|
}
|
|
|
|
it("selects a provider command, never an immediate Paseo action", () => {
|
|
expect(selectedOnEnter("")).toBe("compact");
|
|
});
|
|
|
|
it("lists Paseo actions farthest from the input", () => {
|
|
const listed = orderAutocompleteOptions(
|
|
filterAndRankCommandAutocompleteEntries(rootEntries, ""),
|
|
);
|
|
expect(listed.map((entry) => entry.command.name)).toEqual([
|
|
"clear",
|
|
"exit",
|
|
"handoff",
|
|
"review",
|
|
"compact",
|
|
]);
|
|
});
|
|
|
|
it("still selects a typed Paseo action", () => {
|
|
expect(selectedOnEnter("exit")).toBe("exit");
|
|
});
|
|
|
|
it("waits for a search when Paseo actions are all there is", () => {
|
|
const clientOnly = rootEntries.filter((entry) => entry.source === "client");
|
|
|
|
expect(filterAndRankCommandAutocompleteEntries(clientOnly, "")).toEqual([]);
|
|
expect(
|
|
filterAndRankCommandAutocompleteEntries(clientOnly, "ex").map((entry) => entry.command.name),
|
|
).toEqual(["exit"]);
|
|
});
|
|
});
|
|
|
|
describe("findActiveSlashCommand", () => {
|
|
it("detects a slash command token in the middle of the prompt", () => {
|
|
const text = "use /tas before implementation";
|
|
|
|
expect(
|
|
findActiveSlashCommand({
|
|
text,
|
|
cursorIndex: "use /tas".length,
|
|
}),
|
|
).toEqual({
|
|
start: 4,
|
|
end: "use /tas".length,
|
|
query: "tas",
|
|
position: "inline",
|
|
});
|
|
});
|
|
|
|
it("classifies a slash command token at the prompt start", () => {
|
|
expect(
|
|
findActiveSlashCommand({
|
|
text: "/rew",
|
|
cursorIndex: "/rew".length,
|
|
}),
|
|
).toEqual({
|
|
start: 0,
|
|
end: "/rew".length,
|
|
query: "rew",
|
|
position: "start",
|
|
});
|
|
});
|
|
|
|
it("returns null when the cursor is outside the slash token", () => {
|
|
expect(
|
|
findActiveSlashCommand({
|
|
text: "use /taste now",
|
|
cursorIndex: "use /taste now".length,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("returns null for slash-delimited paths", () => {
|
|
expect(
|
|
findActiveSlashCommand({
|
|
text: "read /tmp/project",
|
|
cursorIndex: "read /tmp/project".length,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("applySlashCommandReplacement", () => {
|
|
it("replaces only the active slash token", () => {
|
|
const text = "use /tas before implementation";
|
|
|
|
expect(
|
|
applySlashCommandReplacement({
|
|
text,
|
|
command: { start: 4, end: "use /tas".length, query: "tas", position: "inline" },
|
|
commandName: "taste",
|
|
}),
|
|
).toBe("use /taste before implementation");
|
|
});
|
|
|
|
it("commits an inline slash token at the prompt tail", () => {
|
|
const text = "use /tas";
|
|
|
|
expect(
|
|
applySlashCommandReplacement({
|
|
text,
|
|
command: { start: 4, end: text.length, query: "tas", position: "inline" },
|
|
commandName: "taste",
|
|
}),
|
|
).toBe("use /taste ");
|
|
});
|
|
|
|
it("commits a start slash token at the prompt tail", () => {
|
|
const text = "/tas";
|
|
|
|
expect(
|
|
applySlashCommandReplacement({
|
|
text,
|
|
command: { start: 0, end: text.length, query: "tas", position: "start" },
|
|
commandName: "taste",
|
|
}),
|
|
).toBe("/taste ");
|
|
});
|
|
});
|
|
|
|
describe("filterInlineSkillCommandEntries", () => {
|
|
it("keeps provider skills and drops executable commands", () => {
|
|
const entries = [
|
|
{ source: "client" as const, command: { name: "clear", kind: "command" } },
|
|
{ source: "provider" as const, command: { name: "compact", kind: "command" } },
|
|
{ source: "provider" as const, command: { name: "taste", kind: "skill" } },
|
|
];
|
|
|
|
expect(filterInlineSkillCommandEntries(entries).map((entry) => entry.command.name)).toEqual([
|
|
"taste",
|
|
]);
|
|
});
|
|
});
|