From 750bef4320e55c7517b70e4fb5ff3625797c4cac Mon Sep 17 00:00:00 2001 From: t Date: Mon, 3 Aug 2026 08:47:45 +0800 Subject: [PATCH] feat(desktop): slash commands in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop had none. The PlusMenu's "Slash command" item inserted a literal "/" and said "palette lands in v0.2" — we are at 0.2.0. Anything typed with a leading slash went to the model as prose. Finding C in docs/THREE_WAY_REVIEW.md. Typing "/" now opens a filtered palette above the composer: ↑↓ to move, Tab or Enter to complete, Esc to dismiss, click to pick. A complete command runs locally instead of starting a turn. The catalogue is deliberately short. 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, so this ships only what the renderer can genuinely serve: /help /clear /model /mode /effort /cost /context, /diff over the existing workspace/diff protocol method, and the six screens the app already has (/settings /permissions /mcp /plugins /skills /about). /help says plainly that host-side commands are CLI-only rather than listing them as if they work. Effort and AgentMode move next to the commands that validate them, so the palette and the composer dropdowns cannot drift apart. Multi-line system output (/help, /diff, /cost) now renders as a left-aligned monospace block. The existing renderer centred system messages at 11.5px with no whitespace preservation, which turned the help table into a paragraph — caught by looking at it, not by the tests. Co-Authored-By: Claude Opus 5 --- apps/desktop/e2e/desktop-preview.spec.ts | 39 ++++ apps/desktop/src/App.tsx | 1 + apps/desktop/src/components/SlashPalette.tsx | 48 +++++ apps/desktop/src/index.css | 54 ++++++ apps/desktop/src/lib/slash-commands.test.ts | 149 +++++++++++++++ apps/desktop/src/lib/slash-commands.ts | 184 +++++++++++++++++++ apps/desktop/src/screens/Repl.tsx | 178 +++++++++++++++++- 7 files changed, 644 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/components/SlashPalette.tsx create mode 100644 apps/desktop/src/lib/slash-commands.test.ts create mode 100644 apps/desktop/src/lib/slash-commands.ts 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({
+