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,