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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-11-06 22:15:10 +07:00
committed by GitHub
co-authored by Claude
parent 14845d3ff3
commit 161076a853
23 changed files with 809 additions and 392 deletions
@@ -105,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function installListCommandsStub(page: Page): Promise<void> {
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<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -130,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise<void> {
};
};
};
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<void> {
});
}
function createHeldReply(): {
reply: () => Promise<ListCommandsReply>;
release: () => void;
} {
let release = () => {};
const held = new Promise<ListCommandsReply>((resolve) => {
release = () => resolve(TEST_COMMANDS_REPLY);
});
return { reply: () => held, release };
}
async function openAppWideNewWorkspace(page: Page): Promise<void> {
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<ListCommandsReply> = () => ({
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 });
@@ -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"
@@ -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) {
@@ -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,
};
}
@@ -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}
/>
</Animated.View>
</KeyboardTranslateView>
+197 -180
View File
@@ -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<AutocompleteOptionIcon, unknown>;
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 (
<Pressable onLayout={handleLayout} onPress={handlePress} style={pressableStyle}>
{isFileOrDir ? (
<>
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={mutedColor} />
) : (
<File size={14} color={mutedColor} />
)}
</View>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
<Pressable
onLayout={handleLayout}
onPress={handlePress}
style={pressableStyle}
// Name and description only; the hint and tag repeat what the name implies.
accessibilityLabel={description ? `${label}, ${description}` : label}
>
<View style={styles.itemIcon}>
<Icon size={ROW_ICON_SIZE} />
</View>
<HighlightedText
text={label}
ranges={labelMatches}
style={styles.itemLabel}
numberOfLines={1}
/>
{argumentHint ? (
<Text style={styles.itemArgumentHint} numberOfLines={1}>
{argumentHint}
</Text>
) : null}
<Text style={styles.itemDescription} numberOfLines={1}>
{description ?? ""}
</Text>
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
</Pressable>
);
}
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
const { t } = useTranslation();
if (status.kind === "error") {
return (
<View style={styles.statusRow} testID="composer-autocomplete-error">
<View style={styles.itemIcon}>
<ErrorIcon size={ROW_ICON_SIZE} />
</View>
<View style={styles.statusText}>
<Text style={styles.statusTitle} numberOfLines={2}>
{status.title}
</Text>
{status.reason ? (
<Text style={styles.statusReason} numberOfLines={2}>
{status.reason}
</Text>
) : null}
</View>
)}
</Pressable>
{status.onRetry ? (
<Button variant="secondary" size="sm" leftIcon={RotateCw} onPress={status.onRetry}>
{t("common.actions.retry")}
</Button>
) : null}
</View>
);
}
return (
<View
style={styles.statusRow}
testID={status.kind === "loading" ? "composer-autocomplete-loading" : undefined}
>
{status.kind === "loading" ? (
<View style={styles.itemIcon}>
<ThemedLoadingSpinner size={ROW_ICON_SIZE} />
</View>
) : null}
<Text style={styles.statusMessage} numberOfLines={2}>
{status.message}
</Text>
</View>
);
}
@@ -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<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(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 (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{resolvedLoadingText}</Text>
</View>
</View>
);
}
const noResultsMessage = t("common.empty.noResults");
const shownStatus = useMemo<AutocompleteStatus | null>(
() => status ?? (options.length === 0 ? { kind: "empty", message: noResultsMessage } : null),
[noResultsMessage, options.length, status],
);
if (errorMessage) {
if (shownStatus) {
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
</View>
);
}
if (options.length === 0) {
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{resolvedEmptyText}</Text>
</View>
<AutocompleteStatusRow status={shownStatus} />
</View>
);
}
return (
<View style={styles.outerWrapper}>
{selectedOption?.kind === "command" && selectedOption.description ? (
<View style={styles.detailCard}>
<Text style={styles.detailLabel} numberOfLines={1}>
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
</Text>
<Text style={styles.detailDescription} numberOfLines={2} ellipsizeMode="tail">
{removeBoltGlyphs(selectedOption.description)}
</Text>
{selectedOption.detail ? (
<Text style={styles.detailHint} numberOfLines={1}>
{removeBoltGlyphs(selectedOption.detail)}
</Text>
) : null}
</View>
) : null}
<View style={containerStyle}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
mutedColor={theme.colors.foregroundMuted}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
</View>
<View style={containerStyle}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
showArgumentHint={!isCompact}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
</View>
);
}
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<string, object>;
+1 -4
View File
@@ -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}
</ComposerAutocomplete>
+254 -103
View File
@@ -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<ClientSlashCommand["kind"], AutocompleteOption["icon"]>;
interface CommandOptionContext {
query: string;
pluginNames: ReadonlyMap<string, string>;
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<string, string>;
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<string, string> } {
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<AgentAutocompleteOption[]>(
() =>
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<AutocompleteMenu>(() => {
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,
};
+41 -58
View File
@@ -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<TOption, TKeyPressEvent>): UseAutocompleteResult<TKeyPressEvent> {
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);
}
+1 -1
View File
@@ -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");
+5 -1
View File
@@ -1642,7 +1642,11 @@ export const ar: TranslationResources = {
loadingCommands: "جارٍ تحميل الأوامر...",
noFiles: "لم يتم العثور على ملفات أو أدلة",
noCommands: "لم يتم العثور على أي أوامر",
failedToLoad: "فشل التحميل",
loadingProviderCommands: "جارٍ تحميل أوامر {{provider}}...",
commandsFailed: "تعذر تحميل الأوامر",
providerCommandsFailed: "تعذر تحميل أوامر {{provider}}",
filesFailed: "تعذر البحث في الملفات",
skillTag: "مهارة",
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
},
+5 -1
View File
@@ -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",
},
+5 -1
View File
@@ -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",
},
+5 -1
View File
@@ -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",
},
+5 -1
View File
@@ -1660,7 +1660,11 @@ export const ja: TranslationResources = {
loadingCommands: "コマンドを読み込み中...",
noFiles: "ファイルまたはディレクトリが見つかりません",
noCommands: "コマンドが見つかりません",
failedToLoad: "読み込みに失敗しました",
loadingProviderCommands: "{{provider}} のコマンドを読み込み中...",
commandsFailed: "コマンドを読み込めませんでした",
providerCommandsFailed: "{{provider}} のコマンドを読み込めませんでした",
filesFailed: "ファイルを検索できませんでした",
skillTag: "スキル",
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
},
+5 -1
View File
@@ -1653,7 +1653,11 @@ export const ko: TranslationResources = {
loadingCommands: "명령 불러오는 중...",
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
noCommands: "명령을 찾을 수 없습니다",
failedToLoad: "불러오지 못했습니다",
loadingProviderCommands: "{{provider}} 명령 불러오는 중...",
commandsFailed: "명령을 불러오지 못했습니다",
providerCommandsFailed: "{{provider}} 명령을 불러오지 못했습니다",
filesFailed: "파일을 검색하지 못했습니다",
skillTag: "스킬",
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
},
+5 -1
View File
@@ -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",
},
+5 -1
View File
@@ -1672,7 +1672,11 @@ export const ru: TranslationResources = {
loadingCommands: "Загрузка команд...",
noFiles: "Файлы и каталоги не найдены",
noCommands: "Команды не найдены",
failedToLoad: "Не удалось загрузить",
loadingProviderCommands: "Загрузка команд {{provider}}...",
commandsFailed: "Не удалось загрузить команды",
providerCommandsFailed: "Не удалось загрузить команды {{provider}}",
filesFailed: "Не удалось найти файлы",
skillTag: "Навык",
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
},
+5 -1
View File
@@ -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: "选择模型以查看命令",
},
@@ -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";
@@ -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<CommandAutocompleteSource, number> = {
provider: 0,
plugin: 1,
client: 2,
};
export type SlashCommandPosition = "start" | "inline";
@@ -54,7 +62,11 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
): TEntry[] {
const normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length === 0) {
return [...entries];
if (entries.every((entry) => 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<TEntry>[] = [];
@@ -76,7 +88,7 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
return scoredEntries.map((scored) => scored.entry);
}
export function filterInlineSkillCommandEntries<TEntry extends InlineSkillCommandEntry>(
export function filterInlineSkillCommandEntries<TEntry extends CommandAutocompleteEntry>(
entries: readonly TEntry[],
): TEntry[] {
return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill");
@@ -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<unknown> }) => {
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]() {
@@ -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<string, AgentSlashCommand>();
for (const cmd of commands) {
if (isClaudeInternalSlashCommand(cmd.name)) continue;
if (!commandMap.has(cmd.name)) {
commandMap.set(cmd.name, {
name: cmd.name,