Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,52 @@ describe('SuggestionsDisplay', () => {
expect(output.split('\n').length).toBeLessThanOrEqual(2);
});

it('keeps the full MCP resource reference on one line and truncates its description (reverse mode)', () => {
// Two resources that share a long `server:scheme://` prefix and differ only
// in the tail — the discriminating part. The reference (label) must stay
// intact on a single line so the two rows are distinguishable; the
// description yields the width and truncates instead.
const a = 'asys-mcp-http:asight://skills/ppu_bubble_analysis';
const b = 'asys-mcp-http:asight://skills/ppu_operator_performance';
const { lastFrame } = render(
<SuggestionsDisplay
suggestions={[
{
label: a,
value: a,
description:
'Analyze PPU bubble (idle time) from a loaded trace report.',
},
{
label: b,
value: b,
description:
'Analyze PPU operator performance from a loaded trace report.',
},
]}
activeIndex={0}
isLoading={false}
width={80}
scrollOffset={0}
userInput="asys-mcp-http:asight"
mode="reverse"
/>,
);

const output = lastFrame() ?? '';
// Each full reference appears verbatim (the tail is NOT truncated away), so
// the two rows can be told apart.
expect(output).toContain(a);
expect(output).toContain(b);
// The description is what gets cut — its tail must be gone, ellipsized.
expect(output).toContain('…');
expect(output).not.toContain('loaded trace report.');
// One visible row per suggestion: the label is not wrapped onto extra lines.
expect(
output.split('\n').filter((l) => l.includes('asys-mcp-http:')),
).toHaveLength(2);
});

it('collapses newlines in multi-line descriptions so a row stays one line', () => {
const description = [
'First line of the skill description.',
Expand Down
34 changes: 29 additions & 5 deletions packages/cli/src/ui/components/SuggestionsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ interface SuggestionsDisplayProps {
export const MAX_SUGGESTIONS_TO_SHOW = 8;
export { MAX_WIDTH };

/**
* In @-mention mode a wide resource-reference column must still leave the row's
* description at least this many columns, so an unusually long reference can't
* shrink the description away entirely.
*/
const MIN_DESCRIPTION_WIDTH = 12;

/**
* Collapse all runs of whitespace (including newlines from multi-line
* SKILL.md/command descriptions) into single spaces so a description renders
Expand Down Expand Up @@ -101,8 +108,25 @@ export function SuggestionsDisplay({
const maxLabelLength = Math.max(
...suggestions.map((s) => getFullLabel(s).length),
);
const commandColumnWidth =
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
// Width of the left label column. In slash mode every row shares one
// half-width command column. In @-mention (reverse) mode only rows WITH a
// description (MCP resources/servers) share a column — sized to the longest
// such reference so the references stay intact and their descriptions line
// up, capped so the description keeps a minimum readable width — while plain
// file rows (no description) keep the full row width. The reference takes
// priority over its description, which truncates.
const describedLabelLengths = suggestions
.filter((s) => s.description)
.map((s) => getFullLabel(s).length);
const labelColumnWidth =
mode === 'slash'
? Math.min(maxLabelLength, Math.floor(width * 0.5))
: describedLabelLengths.length > 0
? Math.min(
Math.max(...describedLabelLengths),
Math.max(width - MIN_DESCRIPTION_WIDTH - 2, 1),
)
: 0;

return (
<Box flexDirection="column" width={width}>
Expand All @@ -117,7 +141,7 @@ export function SuggestionsDisplay({
const isLong = displayLabel.length >= MAX_WIDTH;
const expansionIndicatorWidth = isActive && isLong ? 3 : 0;
const descriptionColumnWidth = Math.max(
width - commandColumnWidth - 2 - expansionIndicatorWidth,
width - labelColumnWidth - 2 - expansionIndicatorWidth,
1,
);
const labelElement = (
Expand All @@ -133,8 +157,8 @@ export function SuggestionsDisplay({
return (
<Box key={`${suggestion.value}-${originalIndex}`} flexDirection="row">
<Box
{...(mode === 'slash'
? { width: commandColumnWidth, flexShrink: 0 as const }
{...(mode === 'slash' || suggestion.description
? { width: labelColumnWidth, flexShrink: 0 as const }
: { flexShrink: 1 as const })}
>
<Box>
Expand Down
162 changes: 162 additions & 0 deletions packages/cli/src/ui/hooks/useAtCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,4 +959,166 @@ describe('useAtCompletion', () => {
expect(values).toContain('my-notes.txt');
});
});

describe('Global MCP resource completion', () => {
it('matches resources globally for a bare @<partial> with no server prefix', async () => {
testRootDir = await createTmpDir({ 'unrelated.txt': '' });
const resourceConfig = {
...mockConfig,
getMcpServers: () => ({ 'asys-mcp-http': {} }),
getResourceRegistry: () => ({
getResourcesByServer: () => [],
getAllResources: () => [
{
uri: 'asight://skills/ppu_bubble',
name: 'bubble',
serverName: 'asys-mcp-http',
},
{
uri: 'asight://skills/ppu_op',
name: 'op',
serverName: 'asys-mcp-http',
},
],
}),
} as unknown as Config;

const { result } = renderHook(() =>
useTestHarnessForAtCompletion(
true,
'asight',
resourceConfig,
testRootDir,
),
);

await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
// 'asight' is not a configured server name and carries no ':' — yet both
// resources match by URI prefix and are injected as @server:uri.
const values = result.current.suggestions.map((s) => s.value);
expect(values).toContain('asys-mcp-http:asight://skills/ppu_bubble');
expect(values).toContain('asys-mcp-http:asight://skills/ppu_op');
});

it('matches a resource globally by its friendly name/title', async () => {
testRootDir = await createTmpDir({ 'file.txt': '' });
const resourceConfig = {
...mockConfig,
getMcpServers: () => ({ demo: {} }),
getResourceRegistry: () => ({
getResourcesByServer: () => [],
getAllResources: () => [
{
uri: 'file:///x/spec.md',
name: 'spec',
title: 'Project Spec',
serverName: 'demo',
},
],
}),
} as unknown as Config;

const { result } = renderHook(() =>
useTestHarnessForAtCompletion(
true,
'Project',
resourceConfig,
testRootDir,
),
);

await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(result.current.suggestions.map((s) => s.value)).toContain(
'demo:file:///x/spec.md',
);
});

it('prepends globally-matched resources before file results', async () => {
// A file AND a resource both match 'doc'.
testRootDir = await createTmpDir({ 'doc.txt': '' });
const resourceConfig = {
...mockConfig,
getMcpServers: () => ({ demo: {} }),
getResourceRegistry: () => ({
getResourcesByServer: () => [],
getAllResources: () => [
{ uri: 'doc://readme', name: 'r', serverName: 'demo' },
],
}),
} as unknown as Config;

const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, 'doc', resourceConfig, testRootDir),
);

await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(1);
});
const values = result.current.suggestions.map((s) => s.value);
const resIdx = values.indexOf('demo:doc://readme');
const fileIdx = values.indexOf('doc.txt');
expect(resIdx).toBeGreaterThanOrEqual(0);
expect(fileIdx).toBeGreaterThanOrEqual(0);
// Resources come first so a file flood can't bury them.
expect(resIdx).toBeLessThan(fileIdx);
});

it('does not surface global resources for the empty @ trigger (files only)', async () => {
testRootDir = await createTmpDir({ 'file.txt': '' });
const resourceConfig = {
...mockConfig,
getMcpServers: () => ({ demo: {} }),
getResourceRegistry: () => ({
getResourcesByServer: () => [],
getAllResources: () => [
{ uri: 'res://x', name: 'x', serverName: 'demo' },
],
}),
} as unknown as Config;

const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', resourceConfig, testRootDir),
);

await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const values = result.current.suggestions.map((s) => s.value);
expect(values).toContain('file.txt');
expect(values).not.toContain('demo:res://x');
});

it('does not surface global resources in an untrusted folder', async () => {
testRootDir = await createTmpDir({ 'file.txt': '' });
const resourceConfig = {
...mockConfig,
isTrustedFolder: () => false,
getMcpServers: () => ({ demo: {} }),
getResourceRegistry: () => ({
getResourcesByServer: () => [],
getAllResources: () => [
{ uri: 'asight://secret', name: 's', serverName: 'demo' },
],
}),
} as unknown as Config;

const { result } = renderHook(() =>
useTestHarnessForAtCompletion(
true,
'asight',
resourceConfig,
testRootDir,
),
);

await new Promise((r) => setTimeout(r, 300));
expect(result.current.suggestions.map((s) => s.value)).not.toContain(
'demo:asight://secret',
);
});
});
});
Loading
Loading