Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getLocalExitSlashCommand,
getLocalNewSlashCommand,
getSlashCommandCandidate,
getSlashCommandCatalogNotice,
getSlashCommandDescription,
getSlashCommandSuggestions,
isCatalogueSlashCommand,
Expand Down Expand Up @@ -84,6 +85,28 @@ describe('createMobileSlashCommandList', () => {
expect(list).toBe(SAMPLE_COMMANDS);
});

it('keeps a cloud-agent skill row in the mobile suggestion list and invokes it like any other command', () => {
// The mobile layer only has to pass a reported skill row through; the
// wrapper guarantee that skills survive `commands.available` is pinned by
// `services/cloud-agent-next/wrapper/src/kilo-api.test.ts`.
const skill: SlashCommandInfo = {
name: 'kilo-config',
description: 'Guide for Kilo configuration',
source: 'skill',
hints: [],
};
const list = createMobileSlashCommandList('cloud-agent', [COMPACT, skill], null);

expect(getSlashCommandSuggestions('/', list)).toEqual([COMPACT, skill]);
expect(
parseChatComposerSubmission('/kilo-config', list, {
hasAttachments: false,
sessionType: 'cloud-agent',
remoteCommandState: null,
})
).toEqual({ type: 'command', command: 'kilo-config', arguments: '' });
});

it('does not strip a CLI-reported /goal from a remote catalog', () => {
const list = createMobileSlashCommandList('remote', [GOAL], remoteState({ commands: [GOAL] }));
expect(list.map(command => command.name)).toEqual(['goal', 'new']);
Expand Down Expand Up @@ -487,3 +510,24 @@ describe('isCatalogueSlashCommand', () => {
expect(isCatalogueSlashCommand(getLocalNewSlashCommand())).toBe(true);
});
});

describe('getSlashCommandCatalogNotice', () => {
it('says nothing when the wrapper sent the whole catalog', () => {
expect(getSlashCommandCatalogNotice(null)).toBeNull();
expect(getSlashCommandCatalogNotice(undefined)).toBeNull();
expect(getSlashCommandCatalogNotice({ dropped: 0, overLimit: false })).toBeNull();
});

it('says that commands are hidden when rows were dropped', () => {
expect(getSlashCommandCatalogNotice({ dropped: 7, overLimit: false })).toBe(
en.agentChat.slashCommands.catalogFull
);
});

it('says that every skill is listed when an over-limit catalog dropped nothing', () => {
const notice = getSlashCommandCatalogNotice({ dropped: 0, overLimit: true });
expect(notice).toBe(en.agentChat.slashCommands.catalogOverLimit);
// The catalog is complete in this case, so the dropped-rows copy would lie.
expect(notice).not.toBe(en.agentChat.slashCommands.catalogFull);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { type ActiveSessionType, type SlashCommandInfo } from '@kilocode/cloud-agent-sdk';
import {
type ActiveSessionType,
type SlashCommandCatalogStatus,
type SlashCommandInfo,
} from '@kilocode/cloud-agent-sdk';
import { type RemoteCommandState } from '@kilocode/cloud-agent-sdk/remote-command-catalog';

import { i18n } from '@/i18n';
Expand Down Expand Up @@ -255,6 +259,29 @@ export function createMobileSlashCommandList(
];
}

/**
* The notice the open slash menu shows for the catalog the wrapper sent, or
* `null` when there is nothing to say.
*
* The wrapper bounds the catalog to the shared 256-command / 512 KiB limits and
* never truncates a skill row, so two different things need saying: rows were
* dropped (`dropped`), or the rows kept are still over a bound because the
* skill rows alone are over it (`dropped === 0` with `overLimit`). An
* over-limit catalog that dropped nothing is complete, so it must not claim
* that commands are hidden.
*/
export function getSlashCommandCatalogNotice(
status: SlashCommandCatalogStatus | null | undefined
): string | null {
if (!status) {
return null;
}
if (status.dropped > 0) {
return i18n.t('agentChat.slashCommands.catalogFull');
}
return status.overLimit ? i18n.t('agentChat.slashCommands.catalogOverLimit') : null;
}

/**
* Returns the input when it can still match a command name, `null` otherwise.
* Keeping non-candidates collapsed to `null` lets the composer skip
Expand Down
26 changes: 25 additions & 1 deletion apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/
import * as Haptics from 'expo-haptics';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { type SlashCommandInfo, type StandaloneSuggestion } from '@kilocode/cloud-agent-sdk';
import {
type SlashCommandCatalogStatus,
type SlashCommandInfo,
type StandaloneSuggestion,
} from '@kilocode/cloud-agent-sdk';
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import { type RemoteCommandState } from '@kilocode/cloud-agent-sdk/remote-command-catalog';
import {
Expand Down Expand Up @@ -48,6 +52,7 @@ import { usePreventRemove } from '@/lib/navigation/prevent-remove';
import {
createMobileSlashCommandList,
getSlashCommandCandidate,
getSlashCommandCatalogNotice,
getSlashCommandSuggestions,
isGoalCommandDraft,
parseChatComposerSubmission,
Expand Down Expand Up @@ -194,6 +199,12 @@ type ChatComposerProps = {
activeSessionType?: 'cloud-agent' | 'remote' | 'read-only' | null;
/** Wrapper commands; remote presentation adds /new and capability-gated /exit after stripping aliases. */
commands?: SlashCommandInfo[];
/**
* Bound status the wrapper reported for `commands`. Present when the wrapper
* bounded the catalog, so the open slash menu says that rows are missing or
* that the catalog is over its size limit instead of looking complete.
*/
commandCatalogStatus?: SlashCommandCatalogStatus | null;
/** Remote command state — empty for non-remote sessions. */
commandState?: RemoteCommandState | null;
/** Share-gate delivery id; composer takes the payload and clears the route param. */
Expand Down Expand Up @@ -249,6 +260,7 @@ export function ChatComposer({
attachmentsEnabled = true,
activeSessionType = null,
commands = [],
commandCatalogStatus = null,
commandState = null,
shareId,
autoSend,
Expand Down Expand Up @@ -760,6 +772,10 @@ export function ChatComposer({
);
const slashCommandSuggestions =
slashCommandInput === null ? [] : getSlashCommandSuggestions(slashCommandInput, commandList);
// The bound notice belongs beside the open slash menu: that is where the
// reader expects to see every command, and a dropped row is otherwise
// invisible.
const slashCommandCatalogNotice = getSlashCommandCatalogNotice(commandCatalogStatus);

// The strip must show share-prefilled files before the session resolves.
const showAttachments = attachmentsEnabled || upload.attachments.length > 0;
Expand Down Expand Up @@ -1272,6 +1288,14 @@ export function ChatComposer({
/>
) : null}

{slashCommandInput !== null && slashCommandCatalogNotice !== null && !isSending ? (
<AccessibleStatus
tone="status"
message={slashCommandCatalogNotice}
className="mb-2 px-4 text-xs"
/>
) : null}

{slashCommandSuggestions.length > 0 && !isSending ? (
<Animated.View
entering={selectReducedMotionEntrance(reducedMotion, FadeIn.duration(150))}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export function SessionDetailContent({
const remoteModelOverride = useAtomValue(manager.atoms.remoteModelOverride);
const cloudAgentModelOverride = useAtomValue(manager.atoms.cloudAgentModelOverride);
const availableCommands = useAtomValue(manager.atoms.availableCommands);
const availableCommandsCatalogStatus = useAtomValue(manager.atoms.availableCommandsCatalogStatus);
const sessionInfo = useAtomValue(manager.atoms.sessionInfo);
const sessionGoal = selectVisibleGoal(sessionInfo, isReadOnly);
const remoteCommandState = useAtomValue(manager.atoms.remoteCommandState);
Expand Down Expand Up @@ -2423,6 +2424,7 @@ export function SessionDetailContent({
attachmentsEnabled={supportsAttachments}
activeSessionType={activeSessionType}
commands={availableCommands}
commandCatalogStatus={availableCommandsCatalogStatus}
commandState={remoteCommandState}
shareId={shareId}
autoSend={autoSend}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ function makeManager() {
remoteModelOverride: { value: null },
cloudAgentModelOverride: { value: null },
availableCommands: { value: [] },
availableCommandsCatalogStatus: { value: null },
sessionInfo: { value: null },
remoteCommandState: { value: null },
contextUsage: { value: null },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ function descriptionLines(root: TestRenderer.ReactTestInstance): string[] {
.map(node => node.props.children as string);
}

/** The skill badge of every row: the one Text that carries `uppercase`. */
function skillBadges(root: TestRenderer.ReactTestInstance): string[] {
return findHost(root, 'Text')
.filter(
node => typeof node.props.className === 'string' && node.props.className.includes('uppercase')
)
.map(node => node.props.children as string);
}

/**
* Sync-commit mount so the pre-resolution render is observable: the dynamic
* import cannot resolve inside a synchronous `act`, which is exactly the
Expand Down Expand Up @@ -171,6 +180,26 @@ beforeEach(() => {
setConfig({ enabled: false, model: MODEL });
});

describe('SlashCommandSuggestions skill rows', () => {
it('marks a skill row and leaves plain and MCP commands unmarked', () => {
const renderer = renderSuggestionsSync([REVIEW, MCP_TOOL, SKILL_TOOL]);

expect(skillBadges(renderer.root)).toEqual(['agentChat.slashCommands.skillBadge']);
act(() => {
renderer.unmount();
});
});

it('does not mark a local reserved command as a skill', () => {
const renderer = renderSuggestionsSync([LOCAL_NEW]);

expect(skillBadges(renderer.root)).toEqual([]);
act(() => {
renderer.unmount();
});
});
});

describe('SlashCommandSuggestions translation', () => {
it('translates runtime (MCP and skill) descriptions in place when enabled', async () => {
requestMock.mockImplementation(
Expand Down
18 changes: 16 additions & 2 deletions apps/mobile/src/components/agents/slash-command-suggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,19 @@ function SlashCommandSuggestionRow({
isRuntimeDescription,
SLASH_COMMAND_ITEM_PREFIX + command.name
);
// A skill row is marked so the user can tell it apart from a plain command.
const isSkill = command.source === 'skill';

return (
<Pressable
onPress={() => {
onSelect(command);
}}
accessibilityRole="button"
accessibilityLabel={t('agentChat.slashCommands.useCommand', { command: command.name })}
accessibilityLabel={t(
isSkill ? 'agentChat.slashCommands.useSkillCommand' : 'agentChat.slashCommands.useCommand',
{ command: command.name }
)}
accessibilityHint={sourceDescription ? description : undefined}
hitSlop={4}
className={cn(
Expand All @@ -80,7 +85,16 @@ function SlashCommandSuggestionRow({
)}
>
<View className="flex-1">
<Text className="text-sm font-semibold text-foreground">/{command.name}</Text>
<View className="flex-row items-center gap-2">
<Text className="text-sm font-semibold text-foreground">/{command.name}</Text>
{isSkill ? (
<View className="rounded-full bg-muted px-2 py-0.5">
<Text className="text-[10px] font-semibold uppercase tracking-[0.5px] text-muted-foreground">
{t('agentChat.slashCommands.skillBadge')}
</Text>
</View>
) : null}
</View>
{sourceDescription ? (
<Text className="mt-0.5 text-xs text-muted-foreground" numberOfLines={1}>
{description}
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/i18n/catalog-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ const PLURAL_SUFFIX = /_(?:zero|one|two|few|many|other)$/;
* cannot ship. Delete an entry the translation slice has landed in every
* catalog.
*
* Empty: the profile-editor validation copy the review-fix slice added (the
* duplicate-key refusal and the MCP bound messages) has landed in every one of
* the 87 catalogs, so the missing-key assertion is strict again.
* Empty: the `notifications.category.*Unavailable` reasons, the profile-editor
* validation copy (the duplicate-key refusal and the MCP bound messages), and
* the `/` suggestion list's skill-row keys
* (`agentChat.slashCommands.skillBadge` and
* `agentChat.slashCommands.useSkillCommand`, added by
* `slash-command-suggestions.tsx`) have all landed in every catalog, so the
* missing-key assertion is strict again.
*/
const PENDING_TRANSLATION_KEYS = new Set<string>();

Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/az.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/be.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/bg.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/bn.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/bs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/ca.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading