From 161076a853e9858b3edfb89b17c32511b428d2b7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 22:15:10 +0700 Subject: [PATCH] Show loading and errors in the slash command menu, and stop defaulting to /exit (#8905) * 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 --------- Co-authored-by: Claude --- .../e2e/browser/composer-autocomplete.spec.ts | 160 +++++++- .../e2e/mobile/composer-keyboard/android.sh | 2 + .../app/e2e/mobile/composer-keyboard/ios.mjs | 24 +- packages/app/src/agent-commands/use-query.ts | 13 + .../components/ui/autocomplete-popover.tsx | 21 +- .../app/src/components/ui/autocomplete.tsx | 377 +++++++++--------- packages/app/src/composer/index.tsx | 5 +- .../app/src/hooks/use-agent-autocomplete.ts | 357 ++++++++++++----- packages/app/src/hooks/use-autocomplete.ts | 99 ++--- packages/app/src/i18n/resources.test.ts | 2 +- packages/app/src/i18n/resources/ar.ts | 6 +- packages/app/src/i18n/resources/en.ts | 6 +- packages/app/src/i18n/resources/es.ts | 6 +- packages/app/src/i18n/resources/fr.ts | 6 +- packages/app/src/i18n/resources/ja.ts | 6 +- packages/app/src/i18n/resources/ko.ts | 6 +- packages/app/src/i18n/resources/pt-BR.ts | 6 +- packages/app/src/i18n/resources/ru.ts | 6 +- packages/app/src/i18n/resources/zh-CN.ts | 6 +- .../utils/agent-command-autocomplete.test.ts | 51 +++ .../src/utils/agent-command-autocomplete.ts | 22 +- .../agent/providers/claude/agent.test.ts | 7 +- .../server/agent/providers/claude/agent.ts | 7 + 23 files changed, 809 insertions(+), 392 deletions(-) diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index f40b51304b..088159e7f0 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -105,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function installListCommandsStub(page: Page): Promise { +interface ListCommandsReply { + commands: readonly { name: string; description: string; argumentHint: string }[]; + error: string | null; +} + +const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null }; + +/** + * Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds + * the response until it settles, standing in for a slow host. + */ +async function installListCommandsStub( + page: Page, + reply: () => ListCommandsReply | Promise = () => TEST_COMMANDS_REPLY, +): Promise { await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); @@ -130,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise { }; }; }; + const payload = parsed.message?.payload; if ( parsed.type === "session" && parsed.message?.type === "list_commands_response" && - parsed.message.payload + payload ) { - parsed.message.payload.commands = TEST_COMMANDS; - parsed.message.payload.error = null; - ws.send(JSON.stringify(parsed)); + const send = (result: ListCommandsReply) => { + payload.commands = result.commands; + payload.error = result.error; + ws.send(JSON.stringify(parsed)); + }; + const result = reply(); + if (result instanceof Promise) void result.then(send); + else send(result); return; } } catch { @@ -149,6 +169,17 @@ async function installListCommandsStub(page: Page): Promise { }); } +function createHeldReply(): { + reply: () => Promise; + release: () => void; +} { + let release = () => {}; + const held = new Promise((resolve) => { + release = () => resolve(TEST_COMMANDS_REPLY); + }); + return { reply: () => held, release }; +} + async function openAppWideNewWorkspace(page: Page): Promise { await page.getByTestId("sidebar-global-new-workspace").first().click(); await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 }); @@ -545,16 +576,17 @@ test.describe("Composer autocomplete", () => { const input = composerLocator(page); await expect(input).toBeEditable({ timeout: 30_000 }); - await input.fill("/he"); + // `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows. + await input.fill("/hea"); const popover = page.getByTestId("composer-autocomplete-popover"); - await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({ timeout: 30_000, }); const beforeDelete = await visiblePopoverBox(page); await input.press("Backspace"); - await expect(input).toHaveValue("/h"); - await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({ + await expect(input).toHaveValue("/he"); + await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({ timeout: 30_000, }); const afterDelete = await visiblePopoverBox(page); @@ -733,6 +765,116 @@ test.describe("Composer autocomplete", () => { } }); + test("forgets an arrow-key choice once the search changes", async ({ page }) => { + await installListCommandsStub(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "autocomplete-stale-choice-", + title: "Autocomplete stale choice", + }); + + try { + await openAgentRoute(page, session); + await expectWorkspaceTabVisible(page, session.agentId); + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/"); + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + + // Down wraps from the default bottom row to the top: /clear, then /exit. + await input.press("ArrowDown"); + await input.press("ArrowDown"); + await input.press("h"); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible(); + await input.press("Backspace"); + await expect(input).toHaveValue("/"); + await input.press("Enter"); + + await expect(input).toHaveValue("/tdd "); + await expectWorkspaceTabVisible(page, session.agentId); + } finally { + await session.cleanup(); + } + }); + + test("shows loading inside the popover until a slow host answers", async ({ page }) => { + const host = createHeldReply(); + await installListCommandsStub(page, host.reply); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/"); + + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({ + timeout: 30_000, + }); + const loading = await visiblePopoverBox(page); + + host.release(); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0); + const results = await visiblePopoverBox(page); + + expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4); + } finally { + await agent.cleanup(); + } + }); + + test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({ + page, + }) => { + let reply: () => ListCommandsReply | Promise = () => ({ + commands: [], + error: "Timed out waiting for the agent session", + }); + await installListCommandsStub(page, () => reply()); + const session = await seedMockAgentWorkspace({ + repoPrefix: "autocomplete-failing-host-", + title: "Autocomplete failing host", + }); + + try { + await openAgentRoute(page, session); + await expectWorkspaceTabVisible(page, session.agentId); + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/"); + + const popover = page.getByTestId("composer-autocomplete-popover"); + const errorRow = popover.getByTestId("composer-autocomplete-error"); + await expect(errorRow).toContainText("Timed out waiting for the agent session", { + timeout: 30_000, + }); + + await input.press("Enter"); + await page.waitForTimeout(500); + await expectWorkspaceTabVisible(page, session.agentId); + + await input.fill("/"); + await expect(errorRow).toBeVisible({ timeout: 30_000 }); + + const host = createHeldReply(); + reply = host.reply; + await errorRow.getByRole("button", { name: "Retry" }).click(); + await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible(); + + host.release(); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + } finally { + await session.cleanup(); + } + }); + test.describe("compact sidebar layering", () => { test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index 809483ceff..b3b20ed2b7 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -509,6 +509,8 @@ check_autocomplete() { fi fi ad wait 'id="composer-autocomplete-popover"' 10000 + # Rows replace the popover's loading row once the host lists them; a cold Claude list is slow. + ad wait "text=\"${expected}\"" 30000 # The popover remeasures once keyboard motion settles. sleep 1 capture_screen "${prefix}.png" diff --git a/packages/app/e2e/mobile/composer-keyboard/ios.mjs b/packages/app/e2e/mobile/composer-keyboard/ios.mjs index 513d68623e..9deb02ab68 100644 --- a/packages/app/e2e/mobile/composer-keyboard/ios.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/ios.mjs @@ -69,17 +69,23 @@ async function waitFor(name, predicate, timeoutMs = 10_000) { } } -// Keyboard motion has settled once two snapshots agree on the composer position. +// Keyboard motion has settled once two snapshots agree on the composer position. The +// popover shows a loading row until the host lists its commands; a cold Claude list is slow. async function settledPlacement(name, keyboard) { let previousTop; - return waitFor(name, (snap) => { - const composer = nodeById(snap, "message-input-root"); - if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false; - if (keyboardShown(snap) !== keyboard) return false; - const stable = composer.rect.y === previousTop; - previousTop = composer.rect.y; - return stable; - }); + return waitFor( + name, + (snap) => { + const composer = nodeById(snap, "message-input-root"); + if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false; + if (nodeById(snap, "composer-autocomplete-loading")) return false; + if (keyboardShown(snap) !== keyboard) return false; + const stable = composer.rect.y === previousTop; + previousTop = composer.rect.y; + return stable; + }, + 30_000, + ); } function assertPlacement(name, snap, expected) { diff --git a/packages/app/src/agent-commands/use-query.ts b/packages/app/src/agent-commands/use-query.ts index 000e6e8057..61a869d092 100644 --- a/packages/app/src/agent-commands/use-query.ts +++ b/packages/app/src/agent-commands/use-query.ts @@ -1,3 +1,5 @@ +import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useFetchQuery } from "@/data/query"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -75,6 +77,7 @@ export function useAgentCommandsQuery({ }), ); + const queryClient = useQueryClient(); const query = useFetchQuery({ queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), queryFn: async () => { @@ -101,10 +104,20 @@ export function useAgentCommandsQuery({ // isLoading is true when fetching and no data yet const isLoading = query.isPending || query.isLoading; + // Reset rather than refetch: a refetch keeps the error status while it runs, and the menu + // shows loading for the retry. + const retry = useCallback(() => { + void queryClient.resetQueries({ + queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), + exact: true, + }); + }, [agentId, draftConfig, queryClient, serverId]); + return { commands: query.data ?? [], isLoading, isError: query.isError && query.data === undefined, error: query.error, + retry, }; } diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index e74c068658..c4229775bf 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -12,7 +12,11 @@ import { Portal } from "@gorhom/portal"; import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; +import { + Autocomplete, + type AutocompleteOption, + type AutocompleteStatus, +} from "@/components/ui/autocomplete"; import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, @@ -37,10 +41,7 @@ interface AutocompletePopoverProps { options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; - isLoading?: boolean; - errorMessage?: string; - loadingText?: string; - emptyText?: string; + status?: AutocompleteStatus | null; } export function AutocompletePopover({ @@ -50,10 +51,7 @@ export function AutocompletePopover({ options, selectedIndex, onSelect, - isLoading, - errorMessage, - loadingText, - emptyText, + status, }: AutocompletePopoverProps): ReactElement | null { "use no memo"; // React Compiler memoizes effect captures by reading SharedValue.value during render. @@ -154,10 +152,7 @@ export function AutocompletePopover({ options={options} selectedIndex={selectedIndex} onSelect={onSelect} - isLoading={isLoading} - errorMessage={errorMessage} - loadingText={loadingText} - emptyText={emptyText} + status={status} /> diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index a9cb10f33e..86d62ba2b1 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -9,32 +9,79 @@ import { type NativeSyntheticEvent, type PressableStateCallbackType, } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { File, Folder } from "lucide-react-native"; +import { + Archive, + CircleAlert, + File, + Folder, + Puzzle, + RotateCw, + Sparkles, + SquarePen, + SquareSlash, +} from "lucide-react-native"; +import type { MatchRange } from "@getpaseo/protocol/search/text-match"; +import { useIsCompactFormFactor } from "@/constants/layout"; import type { Theme } from "@/styles/theme"; +import { Button } from "./button"; +import { HighlightedText } from "./highlighted-text"; +import { mutedIconColorMapping } from "./icon-color"; +import { LoadingSpinner } from "./loading-spinner"; import { getScrollOffsetToRevealItem } from "./scroll-reveal"; +import { StatusBadge } from "./status-badge"; + +export type AutocompleteOptionIcon = + | "command" + | "skill" + | "plugin" + | "archive" + | "new-draft" + | "file" + | "directory"; export interface AutocompleteOption { id: string; label: string; - detail?: string; + icon: AutocompleteOptionIcon; + /** Spans of `label` the query matched. */ + labelMatches?: readonly MatchRange[]; + argumentHint?: string; description?: string; - kind?: "command" | "file" | "directory"; + /** Where the option comes from, when that is not the provider itself: `Skill`, `Paseo`, a plugin. */ + tag?: string; } +/** What the list shows in place of options. */ +export type AutocompleteStatus = + | { kind: "loading"; message: string } + | { kind: "empty"; message: string } + | { kind: "error"; title: string; reason?: string; onRetry?: () => void }; + interface AutocompleteProps { options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; - isLoading?: boolean; - errorMessage?: string; - loadingText?: string; - emptyText?: string; + status?: AutocompleteStatus | null; maxHeight?: number; } -const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu; +const ROW_ICONS = { + command: withUnistyles(SquareSlash, mutedIconColorMapping), + skill: withUnistyles(Sparkles, mutedIconColorMapping), + plugin: withUnistyles(Puzzle, mutedIconColorMapping), + archive: withUnistyles(Archive, mutedIconColorMapping), + "new-draft": withUnistyles(SquarePen, mutedIconColorMapping), + file: withUnistyles(File, mutedIconColorMapping), + directory: withUnistyles(Folder, mutedIconColorMapping), +} satisfies Record; +const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping); + +const ROW_ICON_SIZE = 14; + +const BOLT_GLYPH_PATTERN = /⚡|️/gu; function removeBoltGlyphs(value?: string): string | undefined { if (!value) { @@ -48,7 +95,7 @@ interface AutocompleteRowProps { index: number; option: AutocompleteOption; isSelected: boolean; - mutedColor: string; + showArgumentHint: boolean; onSelect: (option: AutocompleteOption) => void; onRowLayout: (index: number, event: LayoutChangeEvent) => void; } @@ -57,13 +104,15 @@ function AutocompleteRow({ index, option, isSelected, - mutedColor, + showArgumentHint, onSelect, onRowLayout, }: AutocompleteRowProps) { - const optionLabel = removeBoltGlyphs(option.label) ?? option.label; - const optionDescription = removeBoltGlyphs(option.description); - const isFileOrDir = option.kind === "directory" || option.kind === "file"; + const label = removeBoltGlyphs(option.label) ?? option.label; + const description = removeBoltGlyphs(option.description); + const argumentHint = showArgumentHint ? removeBoltGlyphs(option.argumentHint) : undefined; + const Icon = ROW_ICONS[option.icon]; + const labelMatches = label === option.label ? option.labelMatches : undefined; const handleLayout = useCallback( (event: LayoutChangeEvent) => onRowLayout(index, event), @@ -79,41 +128,77 @@ function AutocompleteRow({ ); return ( - - {isFileOrDir ? ( - <> - - {option.kind === "directory" ? ( - - ) : ( - - )} - - - - {optionLabel} - {removeBoltGlyphs(option.detail) ? ( - {removeBoltGlyphs(option.detail)} - ) : null} - - {optionDescription ? ( - - {optionDescription} - - ) : null} - - - ) : ( - - {optionLabel} - {optionDescription ? ( - - {optionDescription} + + + + + + {argumentHint ? ( + + {argumentHint} + + ) : null} + + {description ?? ""} + + {option.tag ? : null} + + ); +} + +function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) { + const { t } = useTranslation(); + + if (status.kind === "error") { + return ( + + + + + + + {status.title} + + {status.reason ? ( + + {status.reason} ) : null} - )} - + {status.onRetry ? ( + + ) : null} + + ); + } + + return ( + + {status.kind === "loading" ? ( + + + + ) : null} + + {status.message} + + ); } @@ -121,16 +206,11 @@ export function Autocomplete({ options, selectedIndex, onSelect, - isLoading = false, - errorMessage, - loadingText, - emptyText, + status, maxHeight = 220, }: AutocompleteProps) { const { t } = useTranslation(); - const { theme } = useUnistyles(); - const resolvedLoadingText = loadingText ?? t("common.states.loading"); - const resolvedEmptyText = emptyText ?? t("common.empty.noResults"); + const isCompact = useIsCompactFormFactor(); const scrollRef = useRef(null); const rowLayoutsRef = useRef>(new Map()); const viewportHeightRef = useRef(0); @@ -210,117 +290,51 @@ export function Autocomplete({ [ensureActiveItemVisible], ); - const selectedOption = options[selectedIndex]; const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]); - if (isLoading) { - return ( - - - {resolvedLoadingText} - - - ); - } + const noResultsMessage = t("common.empty.noResults"); + const shownStatus = useMemo( + () => status ?? (options.length === 0 ? { kind: "empty", message: noResultsMessage } : null), + [noResultsMessage, options.length, status], + ); - if (errorMessage) { + if (shownStatus) { return ( - - Error: {errorMessage} - - - ); - } - - if (options.length === 0) { - return ( - - - {resolvedEmptyText} - + ); } return ( - - {selectedOption?.kind === "command" && selectedOption.description ? ( - - - {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - - - {removeBoltGlyphs(selectedOption.description)} - - {selectedOption.detail ? ( - - {removeBoltGlyphs(selectedOption.detail)} - - ) : null} - - ) : null} - - - {options.map((option, index) => ( - - ))} - - + + + {options.map((option, index) => ( + + ))} + ); } const styles = StyleSheet.create((theme: Theme) => ({ - outerWrapper: { - flexShrink: 1, - minHeight: 0, - gap: theme.spacing[1], - }, - detailCard: { - flexShrink: 1, - minHeight: 0, - overflow: "hidden", - backgroundColor: theme.colors.surface1, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - ...theme.shadow.md, - }, - detailLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - detailDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, - detailHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, container: { flexShrink: 1, // Keep a complete selectable row (36), list padding (8), and borders (2). @@ -342,60 +356,63 @@ const styles = StyleSheet.create((theme: Theme) => ({ item: { flexDirection: "row", alignItems: "center", + gap: theme.spacing[2], minHeight: 36, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, - itemLeading: { - width: 18, - alignItems: "center", - justifyContent: "center", - marginRight: theme.spacing[1], - }, itemActive: { backgroundColor: theme.colors.surface2, }, - itemMain: { - flex: 1, - minWidth: 0, - }, - itemMainRow: { - flex: 1, - minWidth: 0, - flexDirection: "row", + itemIcon: { + width: 16, alignItems: "center", - gap: theme.spacing[2], - }, - itemHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], + justifyContent: "center", }, itemLabel: { + flexShrink: 0, + maxWidth: "60%", color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, - itemDetail: { + itemArgumentHint: { + flexShrink: 0, + maxWidth: "30%", color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.sm, }, itemDescription: { + flex: 1, + minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - marginTop: 2, }, - itemDescriptionInline: { + statusRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 44, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + statusText: { + flex: 1, + minWidth: 0, + gap: 2, + }, + statusTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + statusReason: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + statusMessage: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - emptyItem: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, })) as unknown as Record; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index b7dd4ee36a..60b05be23e 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1058,10 +1058,7 @@ function ComposerAutocompleteBinding({ options={autocomplete.options} selectedIndex={autocomplete.selectedIndex} onSelect={onSelect} - isLoading={autocomplete.isLoading} - errorMessage={autocomplete.errorMessage} - loadingText={autocomplete.loadingText} - emptyText={autocomplete.emptyText} + status={autocomplete.status} > {children} diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index ef144477dc..a400cf6862 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import type { AutocompleteOption } from "@/components/ui/autocomplete"; +import type { AutocompleteOption, AutocompleteStatus } from "@/components/ui/autocomplete"; +import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; import { useAutocompleteCommands, type AgentSlashCommand, @@ -13,6 +14,9 @@ import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list"; +import { resolveProviderLabel } from "@/utils/provider-definitions"; import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands"; import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands"; import { mergeSlashCommandSources } from "@/plugins/client-slash-commands/model"; @@ -70,10 +74,8 @@ interface AgentAutocompleteResult { isVisible: boolean; options: AutocompleteOption[]; selectedIndex: number; - isLoading: boolean; - errorMessage?: string; - loadingText: string; - emptyText: string; + /** Shown in place of options; set exactly when `options` is empty. */ + status: AutocompleteStatus | null; onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void; onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean; } @@ -162,39 +164,69 @@ function mapDirectorySuggestionsToEntries(payload: { })); } -function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocompleteOption { +const CLIENT_COMMAND_ICONS = { + "archive-agent": "archive", + "replace-agent-with-draft": "new-draft", +} as const satisfies Record; + +interface CommandOptionContext { + query: string; + pluginNames: ReadonlyMap; + t: TFunction; +} + +function mapCommandToOption( + entry: AvailableCommand, + context: CommandOptionContext, +): AgentAutocompleteOption { const command = entry.command; const base = { id: command.name, label: `/${command.name}`, - detail: command.argumentHint || undefined, - description: - entry.source === "client" ? t(entry.command.descriptionKey) : entry.command.description, - kind: "command" as const, + // The label carries a leading slash the query does not. + labelMatches: findHighlightRanges(context.query, command.name).map((range) => ({ + start: range.start + 1, + length: range.length, + })), + argumentHint: command.argumentHint || undefined, }; if (entry.source === "client") { return { ...base, type: "client_command", command: entry.command, + icon: CLIENT_COMMAND_ICONS[entry.command.kind], + description: context.t(entry.command.descriptionKey), + tag: "Paseo", }; } if (entry.source === "plugin") { - return { ...base, type: "plugin_command", command: entry.command }; + return { + ...base, + type: "plugin_command", + command: entry.command, + icon: "plugin", + description: entry.command.description, + tag: context.pluginNames.get(entry.command.pluginId) ?? entry.command.pluginId, + }; } + const isSkill = entry.command.kind === "skill"; return { ...base, type: "provider_command", + icon: isSkill ? "skill" : "command", + description: entry.command.description, + tag: isSkill ? context.t("agentAutocomplete.skillTag") : undefined, }; } type AutocompleteMode = "command" | "file" | null; interface BuildAutocompleteOptionsInput { - isVisible: boolean; mode: AutocompleteMode; commands: AgentSlashCommand[]; pluginCommands: readonly PluginClientSlashCommand[]; + pluginNames: ReadonlyMap; isDraftContext: boolean; commandFilterQuery: string; activeSlashCommand: SlashCommandRange | null; @@ -203,11 +235,7 @@ interface BuildAutocompleteOptionsInput { t: TFunction; } -function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { - if (!input.isVisible) { - return []; - } - +function buildAutocompleteOptions(input: BuildAutocompleteOptionsInput): AgentAutocompleteOption[] { if (input.mode === "command") { const providerCommands = input.commands.map((command) => ({ source: "provider" as const, @@ -236,8 +264,12 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { availableCommands, input.commandFilterQuery, ); - const orderedMatches = orderAutocompleteOptions(matches); - return orderedMatches.map((entry) => mapCommandToOption(entry, input.t)); + const context = { + query: input.commandFilterQuery, + pluginNames: input.pluginNames, + t: input.t, + }; + return orderAutocompleteOptions(matches).map((entry) => mapCommandToOption(entry, context)); } const activeFileMention = input.activeFileMention; @@ -247,7 +279,7 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { type: "workspace_entry" as const, id: `${entry.kind}:${entry.path}`, label: entry.path, - kind: entry.kind, + icon: entry.kind, entryPath: entry.path, mention: activeFileMention, })); @@ -295,42 +327,140 @@ function resolveCanLoadCommands(args: { return Boolean(args.agentId) || args.isDraftContext; } -function resolveAutocompleteIsLoading(args: { - mode: AutocompleteMode; - isCommandsLoading: boolean; - fileSuggestionsIsPending: boolean; - fileSuggestionsIsLoading: boolean; - optionsLength: number; -}): boolean { - if (args.mode === "command") { - return args.isCommandsLoading && args.optionsLength === 0; - } - if (args.mode === "file") { - return ( - args.fileSuggestionsIsPending || (args.fileSuggestionsIsLoading && args.optionsLength === 0) - ); - } - return false; +type CommandsState = "ready" | "loading" | "error"; + +function resolveCommandsState(input: { + canListCommands: boolean; + isLoading: boolean; + isError: boolean; +}): CommandsState { + if (!input.canListCommands) return "ready"; + if (input.isError) return "error"; + if (input.isLoading) return "loading"; + return "ready"; } -function resolveAutocompleteErrorMessage(args: { - mode: AutocompleteMode; - isCommandError: boolean; - commandError: Error | null; - fileSuggestionsError: unknown; +interface AutocompleteMenu { + options: AgentAutocompleteOption[]; + status: AutocompleteStatus | null; +} + +/** + * One state for the open menu, so options and a loading or error row cannot both claim it: + * while a status row shows there are no options for Enter or Tab to pick. + */ +function resolveCommandMenu(input: { + commandsState: CommandsState; + options: () => AgentAutocompleteOption[]; + providerLabel: string | null; + error: Error | null; + onRetry: () => void; + emptyText: string; t: TFunction; -}): string | undefined { - if (args.mode === "command") { - return args.isCommandError - ? (args.commandError?.message ?? args.t("agentAutocomplete.failedToLoad")) - : undefined; +}): AutocompleteMenu { + const { t } = input; + if (input.commandsState === "loading") { + const message = input.providerLabel + ? t("agentAutocomplete.loadingProviderCommands", { provider: input.providerLabel }) + : t("agentAutocomplete.loadingCommands"); + return { options: [], status: { kind: "loading", message } }; } - if (args.mode === "file") { - return args.fileSuggestionsError instanceof Error - ? args.fileSuggestionsError.message - : undefined; + if (input.commandsState === "error") { + const title = input.providerLabel + ? t("agentAutocomplete.providerCommandsFailed", { provider: input.providerLabel }) + : t("agentAutocomplete.commandsFailed"); + return { + options: [], + status: { kind: "error", title, reason: input.error?.message, onRetry: input.onRetry }, + }; } - return undefined; + return withEmptyStatus(input.options(), input.emptyText); +} + +function resolveFileMenu(input: { + options: () => AgentAutocompleteOption[]; + isPending: boolean; + isLoading: boolean; + error: unknown; + t: TFunction; +}): AutocompleteMenu { + const options = input.options(); + if (input.isPending || (input.isLoading && options.length === 0)) { + return { + options: [], + status: { kind: "loading", message: input.t("agentAutocomplete.searchingWorkspace") }, + }; + } + if (input.error instanceof Error) { + return { + options: [], + status: { + kind: "error", + title: input.t("agentAutocomplete.filesFailed"), + reason: input.error.message, + }, + }; + } + return withEmptyStatus(options, input.t("agentAutocomplete.noFiles")); +} + +function withEmptyStatus(options: AgentAutocompleteOption[], emptyText: string): AutocompleteMenu { + return options.length > 0 + ? { options, status: null } + : { options, status: { kind: "empty", message: emptyText } }; +} + +/** How long a command menu waits for commands before it opens on a loading row. */ +const COMMANDS_LOADING_REVEAL_MS = 300; + +/** + * Whether the open command menu has waited long enough to show a loading row. Commands that + * arrive sooner paint the list as the first frame; once the wait is over, loading (a retry) + * shows at once so the open menu never closes on itself. + */ +function useLoadingRevealed(isMenuOpen: boolean): boolean { + const [revealed, setRevealed] = useState(false); + useEffect(() => { + if (!isMenuOpen) { + setRevealed(false); + return undefined; + } + const timer = setTimeout(() => setRevealed(true), COMMANDS_LOADING_REVEAL_MS); + return () => clearTimeout(timer); + }, [isMenuOpen]); + return revealed; +} + +/** The names command rows and status rows show: the provider's label and each plugin's name. */ +function useCommandSourceNames(input: { + serverId: string; + agentId: string; + draftConfig: DraftCommandConfig | undefined; + cwd: string; + isCommandMenuOpen: boolean; + commandsState: CommandsState; + pluginCommandCount: number; +}): { providerLabel: string | null; pluginNames: ReadonlyMap } { + const agentProvider = useSessionStore( + (state) => state.sessions[input.serverId]?.agents?.get(input.agentId)?.provider, + ); + const provider = input.draftConfig?.provider ?? agentProvider; + // Only a loading or error row names the provider; only plugin rows need plugin names. + const { entries: providerEntries } = useProvidersSnapshot(input.serverId, { + cwd: input.cwd, + enabled: input.isCommandMenuOpen && input.commandsState !== "ready", + }); + const pluginList = usePluginList(input.serverId, { + enabled: input.isCommandMenuOpen && input.pluginCommandCount > 0, + }); + const pluginNames = useMemo( + () => new Map((pluginList.data ?? []).map((plugin) => [plugin.id, pluginDisplayName(plugin)])), + [pluginList.data], + ); + return { + providerLabel: provider ? resolveProviderLabel(provider, providerEntries) : null, + pluginNames, + }; } // A draft's agentId is a draft key the daemon does not know, so a draft lists @@ -345,14 +475,10 @@ function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) { }; } -function resolveAutocompleteEmptyText(args: { - mode: AutocompleteMode; +function resolveCommandsEmptyText(args: { draft: DraftCommandTarget | undefined; t: TFunction; }): string { - if (args.mode === "file") { - return args.t("agentAutocomplete.noFiles"); - } if (args.draft?.status === "needs-project") { return args.t("agentAutocomplete.chooseProjectForCommands"); } @@ -438,10 +564,25 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut enabled: mode === "command" && canLoadCommands && canListCommands, draftConfig: queryDraftConfig, }); - const { commands, isError, error } = commandsQuery; - const isCommandsLoading = canListCommands && commandsQuery.isLoading; - - const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading); + const commandsState = resolveCommandsState({ + canListCommands, + isLoading: commandsQuery.isLoading, + isError: commandsQuery.isError, + }); + const isCommandMenuOpen = mode === "command" && canShowAutocomplete; + const isLoadingRevealed = useLoadingRevealed(isCommandMenuOpen); + const isVisible = + canShowAutocomplete && + !(isCommandMenuOpen && commandsState === "loading" && !isLoadingRevealed); + const { providerLabel, pluginNames } = useCommandSourceNames({ + serverId, + agentId, + draftConfig: queryDraftConfig, + cwd: autocompleteCwd, + isCommandMenuOpen, + commandsState, + pluginCommandCount: pluginClientSlashCommands.length, + }); const fileSuggestionsQuery = useQuery({ queryKey: [ @@ -479,33 +620,67 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut placeholderData: keepPreviousData, }); - const options = useMemo( - () => - buildCommandAutocompleteOptions({ + const commandsEmptyText = resolveCommandsEmptyText({ draft, t }); + const retryCommandsQuery = commandsQuery.retry; + const retryCommands = useCallback(() => { + retryCommandsQuery(); + // Pressing Retry takes focus on web; hand it back so the user keeps typing the command. + onAutocompleteApplied?.(); + }, [onAutocompleteApplied, retryCommandsQuery]); + const { options, status } = useMemo(() => { + if (!isVisible) return { options: [], status: null }; + const buildOptions = () => + buildAutocompleteOptions({ activeFileMention, commandFilterQuery, - commands, + commands: commandsQuery.commands, pluginCommands: pluginClientSlashCommands, + pluginNames, activeSlashCommand, fileSuggestions: fileSuggestionsQuery.data ?? [], isDraftContext, - isVisible, mode, t, - }), - [ - activeFileMention, - activeSlashCommand, - commandFilterQuery, - commands, - pluginClientSlashCommands, - fileSuggestionsQuery.data, - isDraftContext, - isVisible, - mode, + }); + if (mode === "file") { + return resolveFileMenu({ + options: buildOptions, + isPending: fileSuggestionsQuery.isPending, + isLoading: fileSuggestionsQuery.isLoading, + error: fileSuggestionsQuery.error, + t, + }); + } + return resolveCommandMenu({ + commandsState, + options: buildOptions, + providerLabel, + error: commandsQuery.error, + onRetry: retryCommands, + emptyText: commandsEmptyText, t, - ], - ); + }); + }, [ + activeFileMention, + activeSlashCommand, + commandFilterQuery, + commandsEmptyText, + commandsQuery.commands, + commandsQuery.error, + commandsState, + fileSuggestionsQuery.data, + fileSuggestionsQuery.error, + fileSuggestionsQuery.isLoading, + fileSuggestionsQuery.isPending, + isDraftContext, + isVisible, + mode, + pluginClientSlashCommands, + pluginNames, + providerLabel, + retryCommands, + t, + ]); const onSelectOption = useCallback( (option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => { @@ -587,35 +762,11 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut : undefined, }); - const isLoading = resolveAutocompleteIsLoading({ - mode, - isCommandsLoading, - fileSuggestionsIsPending: fileSuggestionsQuery.isPending, - fileSuggestionsIsLoading: fileSuggestionsQuery.isLoading, - optionsLength: options.length, - }); - const errorMessage = resolveAutocompleteErrorMessage({ - mode, - isCommandError: isError, - commandError: error, - fileSuggestionsError: fileSuggestionsQuery.error, - t, - }); - - const loadingText = - mode === "file" - ? t("agentAutocomplete.searchingWorkspace") - : t("agentAutocomplete.loadingCommands"); - const emptyText = resolveAutocompleteEmptyText({ mode, draft, t }); - return { isVisible, options, selectedIndex, - isLoading, - errorMessage, - loadingText, - emptyText, + status, onSelectOption, onKeyPress, }; diff --git a/packages/app/src/hooks/use-autocomplete.ts b/packages/app/src/hooks/use-autocomplete.ts index b461c95551..6392602810 100644 --- a/packages/app/src/hooks/use-autocomplete.ts +++ b/packages/app/src/hooks/use-autocomplete.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getAutocompleteFallbackIndex, getAutocompleteNextIndex, @@ -31,38 +31,22 @@ export function useAutocomplete< TOption, TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent, >(input: UseAutocompleteInput): UseAutocompleteResult { - const [selectedIndex, setSelectedIndex] = useState(-1); - const previousQueryRef = useRef(""); + // The user's choice, tied to the query it was made for. The shown index is resolved from it + // during render, so a list never paints its first frame without a selection: the popover only + // measures once a non-empty list has one. + const [choice, setChoice] = useState<{ index: number; query: string } | null>(null); + const itemCount = input.isVisible ? input.options.length : 0; + const selectedIndex = resolveSelectedIndex({ + choice, + query: input.query, + itemCount, + optionsPosition: input.optionsPosition, + }); + // A choice belongs to one search. Until this clears it, the query check above ignores it. useEffect(() => { - if (!input.isVisible) { - previousQueryRef.current = input.query; - setSelectedIndex(-1); - return; - } - - const queryChanged = previousQueryRef.current !== input.query; - previousQueryRef.current = input.query; - - setSelectedIndex((current) => { - if (input.options.length === 0) { - return -1; - } - - const fallbackIndex = getAutocompleteFallbackIndex( - input.options.length, - input.optionsPosition, - ); - - if (queryChanged) { - return fallbackIndex; - } - if (current < 0 || current >= input.options.length) { - return fallbackIndex; - } - return current; - }); - }, [input.isVisible, input.options.length, input.query, input.optionsPosition]); + setChoice(null); + }, [input.isVisible, input.query]); const onKeyPress = useCallback( (event: TKeyPressEvent) => { @@ -70,41 +54,22 @@ export function useAutocomplete< return false; } - if (event.key === "ArrowUp") { + if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); - setSelectedIndex((current) => - getAutocompleteNextIndex({ - currentIndex: current, + setChoice({ + index: getAutocompleteNextIndex({ + currentIndex: selectedIndex, itemCount: input.options.length, - key: "ArrowUp", + key: event.key, }), - ); - return true; - } - - if (event.key === "ArrowDown") { - event.preventDefault(); - setSelectedIndex((current) => - getAutocompleteNextIndex({ - currentIndex: current, - itemCount: input.options.length, - key: "ArrowDown", - }), - ); + query: input.query, + }); return true; } if (event.key === "Tab" || event.key === "Enter") { event.preventDefault(); - const fallbackIndex = getAutocompleteFallbackIndex( - input.options.length, - input.optionsPosition, - ); - const resolvedIndex = - selectedIndex >= 0 && selectedIndex < input.options.length - ? selectedIndex - : fallbackIndex; - const selectedOption = input.options[resolvedIndex]; + const selectedOption = input.options[selectedIndex]; if (selectedOption) { input.onSelectOption(selectedOption, event); } @@ -127,3 +92,21 @@ export function useAutocomplete< onKeyPress, }; } + +function resolveSelectedIndex(input: { + choice: { index: number; query: string } | null; + query: string; + itemCount: number; + optionsPosition?: AutocompleteOptionsPosition; +}): number { + const { choice } = input; + if ( + choice && + choice.query === input.query && + choice.index >= 0 && + choice.index < input.itemCount + ) { + return choice.index; + } + return getAutocompleteFallbackIndex(input.itemCount, input.optionsPosition); +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index b73b2c4fec..af4c059fb2 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -820,7 +820,7 @@ describe("translation resources", () => { expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored"); expect(en.agentAutocomplete.searchingWorkspace).toBe("Searching workspace..."); expect(en.agentAutocomplete.noCommands).toBe("No commands found"); - expect(en.agentAutocomplete.failedToLoad).toBe("Failed to load"); + expect(en.agentAutocomplete.commandsFailed).toBe("Couldn't load commands"); expect(en.loadOlderHistory.failed).toBe("Couldn't load older history"); expect(en.agentControls.thinking.extraHigh).toBe("Extra high"); expect(en.agentControls.model.unknown).toBe("Unknown model"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 48e7f15d4f..9b3c41f663 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1642,7 +1642,11 @@ export const ar: TranslationResources = { loadingCommands: "جارٍ تحميل الأوامر...", noFiles: "لم يتم العثور على ملفات أو أدلة", noCommands: "لم يتم العثور على أي أوامر", - failedToLoad: "فشل التحميل", + loadingProviderCommands: "جارٍ تحميل أوامر {{provider}}...", + commandsFailed: "تعذر تحميل الأوامر", + providerCommandsFailed: "تعذر تحميل أوامر {{provider}}", + filesFailed: "تعذر البحث في الملفات", + skillTag: "مهارة", chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر", chooseModelForCommands: "اختر نموذجًا لعرض الأوامر", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bf1444d3ad..ecb2890c93 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1668,7 +1668,11 @@ export const en = { loadingCommands: "Loading commands...", noFiles: "No files or directories found", noCommands: "No commands found", - failedToLoad: "Failed to load", + loadingProviderCommands: "Loading {{provider}} commands...", + commandsFailed: "Couldn't load commands", + providerCommandsFailed: "Couldn't load {{provider}} commands", + filesFailed: "Couldn't search files", + skillTag: "Skill", chooseProjectForCommands: "Choose a project to see commands", chooseModelForCommands: "Select a model to see commands", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index cf2534f323..11968541d4 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1689,7 +1689,11 @@ export const es: TranslationResources = { loadingCommands: "Cargando comandos...", noFiles: "No se encontraron archivos ni directorios", noCommands: "No se encontraron comandos", - failedToLoad: "No se pudo cargar", + loadingProviderCommands: "Cargando comandos de {{provider}}...", + commandsFailed: "No se pudieron cargar los comandos", + providerCommandsFailed: "No se pudieron cargar los comandos de {{provider}}", + filesFailed: "No se pudieron buscar archivos", + skillTag: "Skill", chooseProjectForCommands: "Elige un proyecto para ver los comandos", chooseModelForCommands: "Selecciona un modelo para ver los comandos", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 73acbfb9e8..4f31acf15a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1686,7 +1686,11 @@ export const fr: TranslationResources = { loadingCommands: "Chargement des commandes…", noFiles: "Aucun fichier ou répertoire trouvé", noCommands: "Aucune commande trouvée", - failedToLoad: "Échec du chargement", + loadingProviderCommands: "Chargement des commandes {{provider}}…", + commandsFailed: "Impossible de charger les commandes", + providerCommandsFailed: "Impossible de charger les commandes {{provider}}", + filesFailed: "Impossible de rechercher les fichiers", + skillTag: "Skill", chooseProjectForCommands: "Choisissez un projet pour afficher les commandes", chooseModelForCommands: "Sélectionnez un modèle pour afficher les commandes", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a845f43303..4913934cfd 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1660,7 +1660,11 @@ export const ja: TranslationResources = { loadingCommands: "コマンドを読み込み中...", noFiles: "ファイルまたはディレクトリが見つかりません", noCommands: "コマンドが見つかりません", - failedToLoad: "読み込みに失敗しました", + loadingProviderCommands: "{{provider}} のコマンドを読み込み中...", + commandsFailed: "コマンドを読み込めませんでした", + providerCommandsFailed: "{{provider}} のコマンドを読み込めませんでした", + filesFailed: "ファイルを検索できませんでした", + skillTag: "スキル", chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください", chooseModelForCommands: "コマンドを表示するにはモデルを選択してください", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 033affa05e..76c101c1d1 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1653,7 +1653,11 @@ export const ko: TranslationResources = { loadingCommands: "명령 불러오는 중...", noFiles: "파일 또는 디렉터리를 찾을 수 없습니다", noCommands: "명령을 찾을 수 없습니다", - failedToLoad: "불러오지 못했습니다", + loadingProviderCommands: "{{provider}} 명령 불러오는 중...", + commandsFailed: "명령을 불러오지 못했습니다", + providerCommandsFailed: "{{provider}} 명령을 불러오지 못했습니다", + filesFailed: "파일을 검색하지 못했습니다", + skillTag: "스킬", chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요", chooseModelForCommands: "명령을 보려면 모델을 선택하세요", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c48f2974bc..cbd13dfd60 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1674,7 +1674,11 @@ export const ptBR: TranslationResources = { loadingCommands: "Carregando comandos...", noFiles: "Nenhum arquivo ou diretório encontrado", noCommands: "Nenhum comando encontrado", - failedToLoad: "Falha ao carregar", + loadingProviderCommands: "Carregando comandos do {{provider}}...", + commandsFailed: "Não foi possível carregar os comandos", + providerCommandsFailed: "Não foi possível carregar os comandos do {{provider}}", + filesFailed: "Não foi possível buscar arquivos", + skillTag: "Skill", chooseProjectForCommands: "Escolha um projeto para ver os comandos", chooseModelForCommands: "Selecione um modelo para ver os comandos", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 226c03cfe4..ee788167f5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1672,7 +1672,11 @@ export const ru: TranslationResources = { loadingCommands: "Загрузка команд...", noFiles: "Файлы и каталоги не найдены", noCommands: "Команды не найдены", - failedToLoad: "Не удалось загрузить", + loadingProviderCommands: "Загрузка команд {{provider}}...", + commandsFailed: "Не удалось загрузить команды", + providerCommandsFailed: "Не удалось загрузить команды {{provider}}", + filesFailed: "Не удалось найти файлы", + skillTag: "Навык", chooseProjectForCommands: "Выберите проект, чтобы увидеть команды", chooseModelForCommands: "Выберите модель, чтобы увидеть команды", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f95fdc9dfb..ea9d34fd4f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1625,7 +1625,11 @@ export const zhCN: TranslationResources = { loadingCommands: "正在加载 commands...", noFiles: "没有找到文件或目录", noCommands: "没有找到 commands", - failedToLoad: "加载失败", + loadingProviderCommands: "正在加载 {{provider}} commands...", + commandsFailed: "无法加载 commands", + providerCommandsFailed: "无法加载 {{provider}} commands", + filesFailed: "无法搜索文件", + skillTag: "Skill", chooseProjectForCommands: "选择项目以查看命令", chooseModelForCommands: "选择模型以查看命令", }, diff --git a/packages/app/src/utils/agent-command-autocomplete.test.ts b/packages/app/src/utils/agent-command-autocomplete.test.ts index 9d0067a02c..8443c0688b 100644 --- a/packages/app/src/utils/agent-command-autocomplete.test.ts +++ b/packages/app/src/utils/agent-command-autocomplete.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + getAutocompleteFallbackIndex, + orderAutocompleteOptions, +} from "@/components/ui/autocomplete-utils"; import { applySlashCommandReplacement, filterAndRankCommandAutocompleteEntries, @@ -33,6 +37,53 @@ describe("filterAndRankCommandAutocompleteEntries", () => { }); }); +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"; diff --git a/packages/app/src/utils/agent-command-autocomplete.ts b/packages/app/src/utils/agent-command-autocomplete.ts index 39ee7f94d1..ffc5810b94 100644 --- a/packages/app/src/utils/agent-command-autocomplete.ts +++ b/packages/app/src/utils/agent-command-autocomplete.ts @@ -4,7 +4,10 @@ import { scoreTextFields, } from "@getpaseo/protocol/search/text-match"; +type CommandAutocompleteSource = "provider" | "plugin" | "client"; + interface CommandAutocompleteEntry { + source: CommandAutocompleteSource; command: { name: string; aliases?: readonly string[]; @@ -12,9 +15,14 @@ interface CommandAutocompleteEntry { }; } -interface InlineSkillCommandEntry extends CommandAutocompleteEntry { - source: "provider" | "client"; -} +// The list renders reversed above the input with the first entry selected. Paseo's own actions +// run at once (`/exit` archives), so an empty query never selects one: they sort farthest from +// the input, and with nothing else to list the menu waits for a search. +const EMPTY_QUERY_SOURCE_ORDER: Record = { + provider: 0, + plugin: 1, + client: 2, +}; export type SlashCommandPosition = "start" | "inline"; @@ -54,7 +62,11 @@ export function filterAndRankCommandAutocompleteEntries entry.source === "client")) return []; + // Hermes has no `toSorted`. + return [...entries].sort( + (a, b) => EMPTY_QUERY_SOURCE_ORDER[a.source] - EMPTY_QUERY_SOURCE_ORDER[b.source], + ); } const scoredEntries: ScoredCommandAutocompleteEntry[] = []; @@ -76,7 +88,7 @@ export function filterAndRankCommandAutocompleteEntries scored.entry); } -export function filterInlineSkillCommandEntries( +export function filterInlineSkillCommandEntries( entries: readonly TEntry[], ): TEntry[] { return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill"); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index a9f709ab1a..07b46c6b7b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -2985,7 +2985,7 @@ describe("ClaudeAgentSession context window usage", () => { expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true); }); - test("classifies Claude root-only commands separately from inline skills", async () => { + test("classifies Claude root-only commands separately from inline skills and hides internal ones", async () => { const queryFactory = vi.fn(({ prompt }: { prompt: AsyncIterable }) => { void prompt; return { @@ -3018,6 +3018,11 @@ describe("ClaudeAgentSession context window usage", () => { description: "Start a new session with empty context", argumentHint: "", }, + { + name: "__remote-workflow", + description: "Run a remote workflow (server-launched sessions only)", + argumentHint: "", + }, ], rewindFiles: async () => ({ canRewind: true }), [Symbol.asyncIterator]() { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index bc5ab24af4..06b0c36e1f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -442,6 +442,12 @@ interface SlashCommandInvocation { rawInput: string; } +// Claude Code reserves `__`-prefixed commands for its own server-launched sessions +// (for example `/__remote-workflow`); a user cannot run them from a Paseo agent. +function isClaudeInternalSlashCommand(commandName: string): boolean { + return commandName.startsWith("__"); +} + function classifyClaudeSlashCommand(commandName: string): AgentSlashCommand["kind"] { // Claude exposes commands and skills as one flat SDK list, without structured source // metadata. Keep obvious root-only/session controls out of inline autocomplete and @@ -3115,6 +3121,7 @@ class ClaudeAgentSession implements AgentSession { const commands = await q.supportedCommands(); const commandMap = new Map(); for (const cmd of commands) { + if (isClaudeInternalSlashCommand(cmd.name)) continue; if (!commandMap.has(cmd.name)) { commandMap.set(cmd.name, { name: cmd.name,