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
39 changes: 39 additions & 0 deletions apps/desktop/e2e/desktop-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,42 @@ test('shows the shared trust-aware configuration diagnostics in About', async ({
await expect(main.getByText('permissions', { exact: true })).toBeVisible();
await expect(main.getByText('1', { exact: true })).toBeVisible();
});

test('runs slash commands in the composer instead of sending them to the model', async ({
page,
}) => {
const composer = page.getByPlaceholder(composerPlaceholder);
const main = page.getByRole('main');

// Typing a slash opens the palette; typing further narrows it.
await composer.fill('/');
const palette = page.getByRole('listbox', { name: 'Slash commands' });
await expect(palette).toBeVisible();
await composer.fill('/mo');
await expect(palette.getByRole('option')).toHaveCount(2);

// Arrow keys move the selection; Enter completes rather than submitting
// while the highlighted row is not yet fully typed.
await composer.press('ArrowDown');
await composer.press('Enter');
await expect(composer).toHaveValue('/mode ');

// A complete command runs locally: the header pill flips, and no turn starts.
await composer.fill('/mode plan');
await composer.press('Enter');
await expect(main.getByText('Mode → plan', { exact: true })).toBeVisible();
await expect(page.getByText('plan mode', { exact: true })).toBeVisible();
await expect(composer).toBeEnabled();

// Unknown commands are named, not forwarded to the model.
await composer.fill('/nope');
await composer.press('Enter');
await expect(main.getByText(/Unknown command \/nope/)).toBeVisible();

// Escape dismisses the palette without clearing what was typed.
await composer.fill('/he');
await expect(palette).toBeVisible();
await composer.press('Escape');
await expect(palette).toBeHidden();
await expect(composer).toHaveValue('/he');
});
1 change: 1 addition & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ function renderScreen(
return (
<ReplScreen
projectPath={projectPath}
onNavigate={setScreen}
onTurnComplete={onTurnComplete}
onSessionStarted={onSessionStarted}
initialMessages={initialMessages}
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src/components/SlashPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Command palette that opens above the composer when the input starts with '/'.
// Presentational: the parent owns the selected index and the keyboard handling,
// because the arrow keys have to be intercepted in the textarea itself.

import type { JSX } from 'react';
import type { SlashCommand } from '../lib/slash-commands.js';

interface SlashPaletteProps {
commands: SlashCommand[];
activeIndex: number;
onPick: (command: SlashCommand) => void;
onHover: (index: number) => void;
}

export function SlashPalette({
commands,
activeIndex,
onPick,
onHover,
}: SlashPaletteProps): JSX.Element | null {
if (commands.length === 0) return null;
return (
<div className="slash-palette" role="listbox" aria-label="Slash commands">
{commands.map((c, i) => (
<button
type="button"
key={c.name}
role="option"
aria-selected={i === activeIndex}
className={'slash-row' + (i === activeIndex ? ' active' : '')}
// Pick on mousedown: the composer must not lose focus first, or the
// blur handler closes the palette before the click lands.
onMouseDown={(e) => {
e.preventDefault();
onPick(c);
}}
onMouseEnter={() => onHover(i)}
>
<span className="slash-name">
{c.name}
{c.args ? <span className="slash-args"> {c.args}</span> : null}
</span>
<span className="slash-summary">{c.summary}</span>
</button>
))}
</div>
);
}
54 changes: 54 additions & 0 deletions apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1694,3 +1694,57 @@ select {
font-size: 11px;
color: var(--text-3);
}

/* ── Slash palette ───────────────────────────────────────────────────────
Opens upward from inside the composer box, so it never covers the message
the user is replying to. Positioned against .box (which is the flex column
holding the textarea + toolbar). */
.composer .box {
position: relative;
}
.slash-palette {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 12px 32px rgb(0 0 0 / 28%);
padding: 6px;
z-index: 20;
}
.slash-row {
display: flex;
align-items: baseline;
gap: 10px;
width: 100%;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-1);
text-align: left;
cursor: pointer;
font: inherit;
}
.slash-row.active {
background: var(--brand-tint);
}
.slash-name {
font-family: var(--mono, ui-monospace, monospace);
font-size: 13px;
white-space: nowrap;
}
.slash-args {
color: var(--text-2);
}
.slash-summary {
color: var(--text-2);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
149 changes: 149 additions & 0 deletions apps/desktop/src/lib/slash-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import {
DESKTOP_COMMANDS,
filterCommands,
formatWorkspaceDiff,
helpText,
parseSlash,
} from './slash-commands.js';

describe('filterCommands', () => {
it('suggests nothing for ordinary prose', () => {
expect(filterCommands('what does this do?')).toEqual([]);
});

it('lists everything for a bare slash', () => {
expect(filterCommands('/')).toHaveLength(DESKTOP_COMMANDS.length);
});

it('narrows by prefix', () => {
expect(filterCommands('/mo').map((c) => c.name)).toEqual(['/model', '/mode']);
});

it('is case-insensitive', () => {
expect(filterCommands('/MO').map((c) => c.name)).toEqual(['/model', '/mode']);
});

it('stops suggesting once the user is typing an argument', () => {
expect(filterCommands('/effort ')).toEqual([]);
expect(filterCommands('/effort hi')).toEqual([]);
});

it('stops at the first space, even mid-word', () => {
expect(filterCommands('/mo del')).toEqual([]);
expect(filterCommands('/ something')).toEqual([]);
});
});

describe('parseSlash', () => {
it('leaves prose alone so it reaches the model', () => {
expect(parseSlash('fix the bug')).toBeNull();
expect(parseSlash(' path/to/file')).toBeNull();
});

it('resolves the no-argument commands', () => {
expect(parseSlash('/help')).toEqual({ kind: 'help' });
expect(parseSlash('/clear')).toEqual({ kind: 'clear' });
expect(parseSlash('/cost')).toEqual({ kind: 'cost' });
expect(parseSlash('/context')).toEqual({ kind: 'context' });
expect(parseSlash('/diff')).toEqual({ kind: 'diff' });
});

it('routes the screen commands', () => {
expect(parseSlash('/mcp')).toEqual({ kind: 'navigate', screen: 'mcp' });
expect(parseSlash('/about')).toEqual({ kind: 'navigate', screen: 'about' });
});

it('accepts valid arguments', () => {
expect(parseSlash('/model deepseek-reasoner')).toEqual({
kind: 'set-model',
value: 'deepseek-reasoner',
});
expect(parseSlash('/mode plan')).toEqual({ kind: 'set-mode', value: 'plan' });
expect(parseSlash('/effort max')).toEqual({ kind: 'set-effort', value: 'max' });
});

it('rejects an invalid argument with the usage line, not silently', () => {
const r = parseSlash('/effort ludicrous');
expect(r?.kind).toBe('error');
expect(r && 'message' in r && r.message).toContain('low');
});

it('rejects a missing argument', () => {
expect(parseSlash('/model')?.kind).toBe('error');
});

it('names an unknown command instead of sending it to the model', () => {
const r = parseSlash('/nope');
expect(r?.kind).toBe('error');
expect(r && 'message' in r && r.message).toContain('/nope');
});

it('tolerates surrounding whitespace and case', () => {
expect(parseSlash(' /MODE plan ')).toEqual({ kind: 'set-mode', value: 'plan' });
});
});

describe('helpText', () => {
it('lists every catalogued command', () => {
const text = helpText();
for (const c of DESKTOP_COMMANDS) expect(text).toContain(c.name);
});

it('says where the host-only commands live rather than pretending they exist', () => {
expect(helpText()).toContain('CLI-only');
});
});

describe('formatWorkspaceDiff', () => {
const file = (path: string, additions: number, deletions: number) => ({
path,
status: 'modified',
additions,
deletions,
});

it('reports a clean tree', () => {
expect(
formatWorkspaceDiff({ repository: true, base: 'HEAD', files: [], truncated: false }),
).toBe('Working tree clean.');
});

it('says when there is no repository at all', () => {
expect(
formatWorkspaceDiff({ repository: false, base: null, files: [], truncated: false }),
).toContain('Not a Git repository');
});

it('totals the changes across files', () => {
const out = formatWorkspaceDiff({
repository: true,
base: 'HEAD',
files: [file('src/a.ts', 3, 1), file('src/b.ts', 10, 0)],
truncated: false,
});
expect(out).toContain('2 changed files (+13 -1)');
expect(out).toContain('src/a.ts');
expect(out).toContain('src/b.ts');
});

it('uses the singular for one file', () => {
const out = formatWorkspaceDiff({
repository: true,
base: 'HEAD',
files: [file('src/a.ts', 1, 1)],
truncated: false,
});
expect(out).toContain('1 changed file (');
});

it('discloses truncation', () => {
const out = formatWorkspaceDiff({
repository: true,
base: 'HEAD',
files: [file('src/a.ts', 1, 1)],
truncated: true,
});
expect(out).toContain('truncated');
});
});
Loading
Loading