diff --git a/apps/desktop/e2e/desktop-preview.spec.ts b/apps/desktop/e2e/desktop-preview.spec.ts index 116cec0..9cb92c7 100644 --- a/apps/desktop/e2e/desktop-preview.spec.ts +++ b/apps/desktop/e2e/desktop-preview.spec.ts @@ -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'); +}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d217e7d..80327ea 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -327,6 +327,7 @@ function renderScreen( return ( void; + onHover: (index: number) => void; +} + +export function SlashPalette({ + commands, + activeIndex, + onPick, + onHover, +}: SlashPaletteProps): JSX.Element | null { + if (commands.length === 0) return null; + return ( +
+ {commands.map((c, i) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 053763d..fb5fef1 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -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; +} diff --git a/apps/desktop/src/lib/slash-commands.test.ts b/apps/desktop/src/lib/slash-commands.test.ts new file mode 100644 index 0000000..aa44582 --- /dev/null +++ b/apps/desktop/src/lib/slash-commands.test.ts @@ -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'); + }); +}); diff --git a/apps/desktop/src/lib/slash-commands.ts b/apps/desktop/src/lib/slash-commands.ts new file mode 100644 index 0000000..1e7ec29 --- /dev/null +++ b/apps/desktop/src/lib/slash-commands.ts @@ -0,0 +1,184 @@ +// Slash commands the desktop can actually serve. +// +// The CLI's 38 commands run against a SessionContext full of node:fs, a +// provider and a session manager — none of which exist in a WebView. Rather +// than stub them, this catalogue lists only what the renderer can do itself or +// through an existing protocol method, and `/help` says where the rest live. +// Anything needing host execution (/init, /compact, /rewind, /export) waits on +// a protocol method to carry it; it is not faked here. +// +// Pure: parsing and filtering have no React or Tauri dependency, so the whole +// surface is unit-testable. + +import type { ScreenName } from '../types/screens.js'; + +export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +export type AgentMode = + | 'default' + | 'acceptEdits' + | 'plan' + | 'auto' + | 'dontAsk' + | 'bypassPermissions'; + +export interface SlashCommand { + /** Including the leading slash. */ + name: string; + /** Argument hint shown in the palette, e.g. ``. */ + args?: string; + summary: string; +} + +export const MODELS = ['deepseek-chat', 'deepseek-reasoner'] as const; +export const MODES: AgentMode[] = [ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'dontAsk', + 'bypassPermissions', +]; +export const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; + +export const DESKTOP_COMMANDS: SlashCommand[] = [ + { name: '/help', summary: 'List the commands available here' }, + { name: '/clear', summary: 'Start a new conversation' }, + { name: '/model', args: '', summary: 'Switch model (deepseek-chat | deepseek-reasoner)' }, + { + name: '/mode', + args: '', + summary: 'Switch approval mode (default, plan, acceptEdits, …)', + }, + { name: '/effort', args: '', summary: 'Switch effort tier (low … max)' }, + { name: '/cost', summary: 'Spend and token usage this conversation' }, + { name: '/context', summary: 'How much of the context window is used' }, + { name: '/diff', summary: 'Uncommitted changes in the working tree' }, + { name: '/settings', summary: 'Open settings' }, + { name: '/permissions', summary: 'Open the permission rules' }, + { name: '/mcp', summary: 'Open the MCP server manager' }, + { name: '/plugins', summary: 'Open installed plugins' }, + { name: '/skills', summary: 'Open available skills' }, + { name: '/about', summary: 'Version, paths and configuration diagnostics' }, +]; + +export type SlashAction = + | { kind: 'help' } + | { kind: 'clear' } + | { kind: 'set-model'; value: string } + | { kind: 'set-mode'; value: AgentMode } + | { kind: 'set-effort'; value: Effort } + | { kind: 'cost' } + | { kind: 'context' } + | { kind: 'diff' } + | { kind: 'navigate'; screen: ScreenName } + | { kind: 'error'; message: string }; + +const SCREEN_COMMANDS: Record = { + '/settings': 'settings', + '/permissions': 'permissions', + '/mcp': 'mcp', + '/plugins': 'plugins', + '/skills': 'skills', + '/about': 'about', +}; + +/** Commands matching what the user has typed so far, in catalogue order. */ +export function filterCommands(input: string): SlashCommand[] { + if (!input.startsWith('/')) return []; + // A space means the user has moved past choosing a command — they are typing + // an argument, or prose that happens to start with a slash. Either way, stop + // suggesting rather than hovering a list over what they're writing. + if (/\s/.test(input)) return []; + const token = input.toLowerCase(); + return DESKTOP_COMMANDS.filter((c) => c.name.startsWith(token)); +} + +/** + * Resolve typed input to an action. Returns null when this is not a slash + * command, so the caller sends it to the model unchanged. + */ +export function parseSlash(input: string): SlashAction | null { + const trimmed = input.trim(); + if (!trimmed.startsWith('/')) return null; + + const [name, ...rest] = trimmed.split(/\s+/); + const arg = rest.join(' ').trim(); + const command = (name ?? '').toLowerCase(); + + const screen = SCREEN_COMMANDS[command]; + if (screen) return { kind: 'navigate', screen }; + + switch (command) { + case '/help': + return { kind: 'help' }; + case '/clear': + return { kind: 'clear' }; + case '/cost': + return { kind: 'cost' }; + case '/context': + return { kind: 'context' }; + case '/diff': + return { kind: 'diff' }; + case '/model': + return (MODELS as readonly string[]).includes(arg) + ? { kind: 'set-model', value: arg } + : { kind: 'error', message: `Usage: /model ${MODELS.join(' | ')}` }; + case '/mode': + return (MODES as string[]).includes(arg) + ? { kind: 'set-mode', value: arg as AgentMode } + : { kind: 'error', message: `Usage: /mode ${MODES.join(' | ')}` }; + case '/effort': + return (EFFORTS as string[]).includes(arg) + ? { kind: 'set-effort', value: arg as Effort } + : { kind: 'error', message: `Usage: /effort ${EFFORTS.join(' | ')}` }; + default: + return { + kind: 'error', + message: `Unknown command ${command}. Type /help for the list.`, + }; + } +} + +/** Body of `/help` — the catalogue plus an honest note about the rest. */ +export function helpText(): string { + const width = Math.max(...DESKTOP_COMMANDS.map((c) => `${c.name} ${c.args ?? ''}`.trim().length)); + const rows = DESKTOP_COMMANDS.map((c) => { + const left = `${c.name} ${c.args ?? ''}`.trim(); + return `${left.padEnd(width)} ${c.summary}`; + }); + return [ + 'Commands available in the desktop app:', + '', + ...rows, + '', + 'Commands that run on the host (/init, /compact, /rewind, /export, /todos …)', + 'are CLI-only for now — run `deepcode` in this folder to use them.', + ].join('\n'); +} + +/** `/diff` output — a summary line per changed file, capped. */ +export function formatWorkspaceDiff(result: { + repository: boolean; + base: 'HEAD' | 'empty' | null; + files: Array<{ path: string; status: string; additions: number; deletions: number }>; + truncated: boolean; +}): string { + if (!result.repository) return 'Not a Git repository — nothing to diff.'; + if (result.files.length === 0) return 'Working tree clean.'; + + const width = Math.max(...result.files.map((f) => f.path.length)); + const rows = result.files.map( + (f) => `${f.path.padEnd(width)} ${f.status.padEnd(9)} +${f.additions} -${f.deletions}`, + ); + const totals = result.files.reduce( + (acc, f) => ({ add: acc.add + f.additions, del: acc.del + f.deletions }), + { add: 0, del: 0 }, + ); + return [ + `${result.files.length} changed file${result.files.length === 1 ? '' : 's'} ` + + `(+${totals.add} -${totals.del})`, + '', + ...rows, + ...(result.truncated ? ['', 'Output truncated — the diff is larger than the cap.'] : []), + ].join('\n'); +} diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 5b52ec9..5280f96 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -25,10 +25,22 @@ import { type VimMode, } from '@deepcode/core/dist/keybindings/vim.js'; import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js'; +import { clearProtocolThread, getWorkspaceDiff } from '../lib/protocol-agent.js'; import { estimateCost } from '@deepcode/core/dist/providers/pricing.js'; import { Dropdown, type DropdownOption } from '../components/Dropdown.js'; import { Pill } from '../components/Pill.js'; import { PlusMenu } from '../components/PlusMenu.js'; +import { SlashPalette } from '../components/SlashPalette.js'; +import { + EFFORTS, + filterCommands, + formatWorkspaceDiff, + helpText as slashHelpText, + parseSlash, + type AgentMode, + type Effort, + type SlashCommand, +} from '../lib/slash-commands.js'; import { ToolCard } from '../components/ToolCard.js'; import { projectName } from '../lib/project.js'; import { useVoice } from '../lib/use-voice.js'; @@ -50,9 +62,12 @@ import { saveSettingsFile, } from '../lib/tauri-api.js'; import type { InspectorData, TodoItem } from '../types/inspector.js'; +import type { ScreenName } from '../types/screens.js'; interface ReplScreenProps { projectPath: string; + /** Route to a settings-family screen — `/settings`, `/mcp`, `/about`, … */ + onNavigate?: (screen: ScreenName) => void; /** Called after each turn ends so the parent can refresh the sidebar. */ onTurnComplete?: () => void; /** Called once the backend creates/adopts the canonical thread id. */ @@ -79,9 +94,8 @@ const MAX_RECENT_FILES = 8; // ─── Types ──────────────────────────────────────────────────────────── -type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; -const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; -type AgentMode = 'default' | 'acceptEdits' | 'plan' | 'auto' | 'dontAsk' | 'bypassPermissions'; +// Effort / AgentMode and their value lists live with the slash commands that +// validate them, so the palette and the dropdowns can't drift apart. const EFFORT_OPTIONS: DropdownOption[] = [ // `meta` is the per-turn output-token budget (maxTokens) the effort maps to in @@ -217,6 +231,7 @@ interface PendingQuestion { export function ReplScreen({ projectPath, + onNavigate, onTurnComplete, onSessionStarted, initialMessages, @@ -240,6 +255,9 @@ export function ReplScreen({ ], ); const [input, setInput] = useState(''); + // Slash palette: derived list + which row the arrow keys are on. + const [slashIndex, setSlashIndex] = useState(0); + const [slashDismissed, setSlashDismissed] = useState(false); const [busy, setBusy] = useState(false); const [activeTurnId, setActiveTurnId] = useState(null); const [pendingApproval, setPendingApproval] = useState(null); @@ -514,6 +532,34 @@ export function ReplScreen({ } } function handleKeyDown(e: React.KeyboardEvent): void { + // The palette owns the arrow keys, Tab and Escape while it is open — + // before vim bindings, which would otherwise swallow them. + if (slashMatches.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashIndex((i) => (i + 1) % slashMatches.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashIndex((i) => (i - 1 + slashMatches.length) % slashMatches.length); + return; + } + if ( + e.key === 'Tab' || + (e.key === 'Enter' && !e.shiftKey && input.trim() !== slashMatches[slashActive]?.name) + ) { + e.preventDefault(); + completeSlash(slashMatches[slashActive]!); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setSlashDismissed(true); + return; + } + } + // Enter submits when vim is off; in vim INSERT mode also submits. if (e.key === 'Enter' && !e.shiftKey) { if (!vimEnabled || vimStateRef.current?.mode === 'INSERT') { @@ -582,13 +628,104 @@ export function ReplScreen({ await window.deepcode.agent.answer({ requestId: req.requestId, answer }); } + // ── Slash commands ── + + function say(text: string, level?: 'error'): void { + setMessages((m) => [...m, { role: 'system', text, ...(level ? { level } : {}) }]); + } + + /** Runs a slash command locally. Returns false if the input wasn't one. */ + async function runSlash(raw: string): Promise { + const action = parseSlash(raw); + if (!action) return false; + + switch (action.kind) { + case 'help': + say(slashHelpText()); + return true; + case 'clear': + clearProtocolThread(); + setMessages([{ role: 'system', text: 'New conversation.' }]); + setUsage({ inputTokens: 0, outputTokens: 0 }); + setCostYuan(0); + setRecentFiles([]); + setTodos([]); + return true; + case 'set-model': + setModel(action.value); + say(`Model → ${action.value}`); + return true; + case 'set-mode': + setMode(action.value); + say(`Mode → ${action.value}`); + return true; + case 'set-effort': + await handleEffortChange(action.value); + say(`Effort → ${action.value}`); + return true; + case 'cost': { + const total = usage.inputTokens + usage.outputTokens; + say( + `Spend this conversation: ¥${costYuan.toFixed(4)}\n` + + `Tokens: ${usage.inputTokens.toLocaleString()} in · ` + + `${usage.outputTokens.toLocaleString()} out · ${total.toLocaleString()} total`, + ); + return true; + } + case 'context': { + const window = contextWindowFor(model); + const used = usage.inputTokens + usage.outputTokens; + const pct = window > 0 ? Math.round((used / window) * 100) : 0; + say( + `Context: ${used.toLocaleString()} / ${window.toLocaleString()} tokens (${pct}%)\n` + + `Model: ${model}`, + ); + return true; + } + case 'diff': + try { + const result = await getWorkspaceDiff(); + say(formatWorkspaceDiff(result)); + } catch (err) { + say(`Could not read the working tree: ${(err as Error).message ?? err}`, 'error'); + } + return true; + case 'navigate': + if (onNavigate) onNavigate(action.screen); + else say('This build cannot navigate from the composer.', 'error'); + return true; + case 'error': + say(action.message, 'error'); + return true; + } + } + + function completeSlash(command: SlashCommand): void { + setInput(command.args ? `${command.name} ` : command.name); + setSlashIndex(0); + // A command taking arguments stays open for typing; one that doesn't is + // ready to send, so get the palette out of the way. + if (!command.args) setSlashDismissed(true); + composerRef.current?.focus(); + } + // ── Send ── async function handleSubmit(e: React.FormEvent): Promise { e.preventDefault(); const text = input.trim(); if (!text || busy || pendingApproval || pendingQuestion) return; setInput(''); - setMessages((m) => [...m, { role: 'user', text }]); + setSlashDismissed(false); + setSlashIndex(0); + + // Slash commands run here, not in the model. Echo the command so the + // transcript shows what was asked for. + if (text.startsWith('/')) { + setMessages((m) => [...m, { role: 'user', text }]); + if (await runSlash(text)) return; + } else { + setMessages((m) => [...m, { role: 'user', text }]); + } setBusy(true); try { const r = await window.deepcode.agent.start({ @@ -624,6 +761,11 @@ export function ReplScreen({ // contradict the system prompt already sent. const controlsLocked = busy || pendingApproval !== null || pendingQuestion !== null; + // Palette rows for what's typed. Declared after controlsLocked because it + // reads it — render order is source order inside a component body. + const slashMatches = slashDismissed || controlsLocked ? [] : filterCommands(input); + const slashActive = Math.min(slashIndex, Math.max(0, slashMatches.length - 1)); + // Only the last assistant turn is "active" — its cursor blinks while the rest // stay static. Guards against a second cursor if a turn was left streaming. const activeAssistantIdx = lastAssistantIndex(messages); @@ -717,10 +859,21 @@ export function ReplScreen({
+