diff --git a/apps/cli/src/commands.test.ts b/apps/cli/src/commands.test.ts index 537734a..0dd2113 100644 --- a/apps/cli/src/commands.test.ts +++ b/apps/cli/src/commands.test.ts @@ -212,4 +212,126 @@ describe('built-in command behavior', () => { const out = await reg.match('/todos')!.cmd.run([], ctx); expect(out.join('\n')).toMatch(/No active todos/); }); + + describe('/rewind', () => { + it('reports empty when no snapshots exist', async () => { + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create('/foo'); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match('/rewind')!.cmd.run([], ctx); + expect(out.join('\n')).toMatch(/No snapshots in this session yet/); + }); + + it('lists snapshots and explains action menu', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + // Create a real file + capture + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'v1'); + await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match('/rewind')!.cmd.run([], ctx); + const joined = out.join('\n'); + expect(joined).toMatch(/Snapshots \(1\)/); + expect(joined).toMatch(/pre-Edit/); + expect(joined).toMatch(/code/); + expect(joined).toMatch(/conversation/); + expect(joined).toMatch(/summarize-from/); + expect(joined).toMatch(/summarize-up-to/); + }); + + it('restores file content with `code` action', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'original'); + const snap = await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + // Modify the file after snapshot + await fs.writeFile(file, 'changed'); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match(`/rewind ${snap!.seq} code`)!.cmd.run( + [String(snap!.seq), 'code'], + ctx, + ); + expect(out.join('\n')).toMatch(/Restored/); + const after = await fs.readFile(file, 'utf8'); + expect(after).toBe('original'); + }); + + it('trims conversation with `conversation` action by capture timestamp', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'v1'); + // history: 1 message BEFORE snapshot, 1 AFTER + const before = { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'first' }], + timestamp: new Date(Date.now() - 60_000).toISOString(), + }; + // capture + const snap = await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + const after = { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'second' }], + timestamp: new Date(Date.now() + 60_000).toISOString(), + }; + const ctx = makeContext({ + sessions: sm, + sessionId: meta.id, + history: [before, after], + }); + const out = await reg.match(`/rewind ${snap!.seq} conversation`)!.cmd.run( + [String(snap!.seq), 'conversation'], + ctx, + ); + expect(out.join('\n')).toMatch(/kept 1 of 2 messages/); + expect(ctx.newHistory).toEqual([before]); + }); + + it('rejects bad seq numbers', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'v1'); + // Capture at least one snapshot so we move past the early-exit. + await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match('/rewind 999 code')!.cmd.run(['999', 'code'], ctx); + expect(out.join('\n')).toMatch(/No snapshot with seq #999/); + }); + }); }); diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index bdc2e20..d8ad0c6 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -4,8 +4,10 @@ import type { DeepCodeSettings, McpClientHandle, + Provider, SessionManager, SessionMeta, + StoredMessage, } from '@deepcode/core'; import { redact, type Credentials } from '@deepcode/core'; @@ -41,6 +43,12 @@ export interface SessionContext { * approve → write). Returns the path written, or null if user cancelled. */ initFlow?: () => Promise; + /** Current conversation history — REPL refreshes this before each command call. */ + history?: StoredMessage[]; + /** Provider for commands that need to call the LLM (e.g. /rewind summarize). */ + provider?: Provider; + /** Set by /rewind to request history replacement. REPL applies after run. */ + newHistory?: StoredMessage[]; } export interface SlashCommand { @@ -412,6 +420,137 @@ export const PluginsCommand: SlashCommand = { }, }; +export const RewindCommand: SlashCommand = { + name: '/rewind', + description: + 'List file snapshots and roll back (5 ops): /rewind [ code|conversation|both|summarize-from|summarize-up-to]', + async run(args, ctx) { + const { listSnapshots, restoreSnapshot, compact } = await import('@deepcode/core'); + const sessionsRoot = ctx.sessions.root; + const snaps = await listSnapshots({ sessionsRoot, sessionId: ctx.sessionId }); + + if (snaps.length === 0) { + return [ + 'No snapshots in this session yet.', + 'Snapshots are captured automatically before Edit / Write tool calls.', + ]; + } + + // No args → list snapshots in reverse chrono so the latest is at top. + if (args.length === 0) { + const lines = [`Snapshots (${snaps.length}):`, '']; + const top = [...snaps].reverse().slice(0, 20); + for (const s of top) { + const when = s.capturedAt.slice(11, 19); // HH:MM:SS + const file = trimMiddle(s.filePath, 50); + lines.push(` #${String(s.seq).padStart(3)} ${when} ${s.reason.padEnd(10)} ${file}`); + } + if (snaps.length > 20) lines.push(` ... and ${snaps.length - 20} older`); + lines.push(''); + lines.push('Rewind: /rewind '); + lines.push('Actions:'); + lines.push(' code — restore the file from this snapshot'); + lines.push(' conversation — trim history to before this snapshot'); + lines.push(' both — code + conversation'); + lines.push(' summarize-from — keep history up to here; summarize the rest'); + lines.push(' summarize-up-to — summarize history up to here; keep the rest'); + return lines; + } + + const seqArg = Number.parseInt(args[0] ?? '', 10); + if (!Number.isFinite(seqArg)) { + return [`Bad seq "${args[0]}". Run /rewind to list snapshots.`]; + } + const target = snaps.find((s) => s.seq === seqArg); + if (!target) { + return [`No snapshot with seq #${seqArg}. Valid: ${snaps.map((s) => s.seq).join(', ')}`]; + } + + const action = (args[1] ?? 'code').toLowerCase(); + const cutoffMs = Date.parse(target.capturedAt); + const currentHistory = ctx.history ?? []; + + switch (action) { + case 'code': { + await restoreSnapshot(target); + return [`✓ Restored ${target.filePath} from snapshot #${target.seq}`]; + } + case 'conversation': { + const kept = trimHistoryBefore(currentHistory, cutoffMs); + ctx.newHistory = kept; + return [ + `✓ Rewound conversation to snapshot #${target.seq} (kept ${kept.length} of ${currentHistory.length} messages).`, + ]; + } + case 'both': { + await restoreSnapshot(target); + const kept = trimHistoryBefore(currentHistory, cutoffMs); + ctx.newHistory = kept; + return [ + `✓ Restored ${target.filePath} from snapshot #${target.seq}`, + `✓ Rewound conversation (kept ${kept.length} of ${currentHistory.length} messages).`, + ]; + } + case 'summarize-from': { + if (!ctx.provider) return ['(/rewind summarize-from requires a provider — none configured.)']; + const kept = trimHistoryBefore(currentHistory, cutoffMs); + const tail = currentHistory.slice(kept.length); + if (tail.length === 0) { + return [`Nothing after snapshot #${target.seq} to summarize.`]; + } + const result = await compact(tail, { provider: ctx.provider }); + // New history: head (verbatim) + the compacted tail + ctx.newHistory = [...kept, ...result.history]; + return [ + `✓ Summarized ${tail.length} messages after snapshot #${target.seq} → ${result.history.length} kept.`, + ]; + } + case 'summarize-up-to': { + if (!ctx.provider) return ['(/rewind summarize-up-to requires a provider — none configured.)']; + const head = trimHistoryBefore(currentHistory, cutoffMs); + const tail = currentHistory.slice(head.length); + if (head.length === 0) { + return [`Nothing before snapshot #${target.seq} to summarize.`]; + } + const result = await compact(head, { provider: ctx.provider }); + // New history: compacted head + tail (verbatim) + ctx.newHistory = [...result.history, ...tail]; + return [ + `✓ Summarized ${head.length} messages up to snapshot #${target.seq} → ${result.history.length} kept.`, + ]; + } + default: + return [ + `Unknown action "${action}".`, + 'Valid: code | conversation | both | summarize-from | summarize-up-to', + ]; + } + }, +}; + +/** Keep messages with timestamp < cutoffMs. Falls back to a simple length-based + * heuristic if messages don't carry timestamps. */ +function trimHistoryBefore(history: StoredMessage[], cutoffMs: number): StoredMessage[] { + const out: StoredMessage[] = []; + for (const msg of history) { + const ts = msg.timestamp ? Date.parse(msg.timestamp) : NaN; + if (Number.isFinite(ts) && ts < cutoffMs) { + out.push(msg); + } else if (!Number.isFinite(ts)) { + // No timestamp: include — better to over-keep than to drop a turn the + // user didn't intend to lose. The conversation can be re-trimmed. + out.push(msg); + } + } + return out; +} + +function trimMiddle(s: string, maxLen: number): string { + if (s.length <= maxLen) return s; + const keep = Math.floor((maxLen - 1) / 2); + return s.slice(0, keep) + '…' + s.slice(s.length - keep); +} + export const BUILTIN_COMMANDS: SlashCommand[] = [ HelpCommand, ClearCommand, @@ -431,6 +570,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ PluginsCommand, KeybindingsCommand, VimCommand, + RewindCommand, ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/apps/cli/src/parse-args.test.ts b/apps/cli/src/parse-args.test.ts index eeb2ce1..ee63c9c 100644 --- a/apps/cli/src/parse-args.test.ts +++ b/apps/cli/src/parse-args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseArgs } from './parse-args.js'; +import { parseArgs, resolveEffort } from './parse-args.js'; describe('parseArgs', () => { it('parses empty argv', () => { @@ -129,3 +129,39 @@ describe('parseArgs', () => { expect(p.unknownFlags).toEqual([]); }); }); + +describe('resolveEffort (precedence)', () => { + it('cli flag wins over env and settings', () => { + expect( + resolveEffort({ cliFlag: 'high', envVar: 'low', settingsLevel: 'max' }), + ).toBe('high'); + }); + + it('env var wins when no cli flag', () => { + expect(resolveEffort({ envVar: 'xhigh', settingsLevel: 'low' })).toBe( + 'xhigh', + ); + }); + + it('settings wins when no cli flag and no env', () => { + expect(resolveEffort({ settingsLevel: 'low' })).toBe('low'); + }); + + it('defaults to medium when nothing is set', () => { + expect(resolveEffort({})).toBe('medium'); + }); + + it('ignores invalid env var', () => { + expect(resolveEffort({ envVar: 'ultra', settingsLevel: 'low' })).toBe( + 'low', + ); + }); + + it('trims whitespace in env var', () => { + expect(resolveEffort({ envVar: ' high ' })).toBe('high'); + }); + + it('ignores empty env var', () => { + expect(resolveEffort({ envVar: '', settingsLevel: 'max' })).toBe('max'); + }); +}); diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 9035bf2..a926ce2 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -68,6 +68,31 @@ const VALID_MODES: Mode[] = [ ]; const VALID_EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; +/** + * Resolve the effective effort level from all precedence layers. + * Order (high → low): cli flag → DEEPCODE_EFFORT_LEVEL env → settings → default. + * Spec: docs/DEVELOPMENT_PLAN.md §3.13c. + * + * Returns `'medium'` if nothing produces a valid value. + */ +export function resolveEffort(args: { + cliFlag?: string; + envVar?: string; + settingsLevel?: string; +}): Effort { + const candidates: Array = [ + args.cliFlag, + args.envVar?.trim(), + args.settingsLevel, + ]; + for (const c of candidates) { + if (c && (VALID_EFFORTS as string[]).includes(c)) { + return c as Effort; + } + } + return 'medium'; +} + export function parseArgs(argv: string[]): ParsedArgs { const out: ParsedArgs = { showHelp: false, diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 16b2ce7..68887db 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -12,6 +12,7 @@ import { ToolRegistry, WebFetchTool, WriteTool, + appendAllowMatcher, applyStyle, buildSkillsDescriptionBlock, closeAllMcpServers, @@ -24,6 +25,7 @@ import { makeSkillTool, resolveCredentials, runAgent, + settingsPaths, wirePlugins, type DeepCodeSettings, type Effort, @@ -36,6 +38,7 @@ import { import { createInterface } from 'node:readline/promises'; import type { Readable, Writable } from 'node:stream'; import { CommandRegistry, type SessionContext } from './commands.js'; +import { resolveEffort } from './parse-args.js'; export interface ReplOpts { input: Readable; @@ -87,7 +90,14 @@ export async function startRepl(opts: ReplOpts): Promise { const model = opts.model ?? settings.model ?? 'deepseek-chat'; const mode = (opts.mode ?? settings.permissions?.defaultMode ?? 'default') as Mode; - const effort = opts.effort ?? settings.effortLevel ?? 'medium'; + // Precedence: --effort flag → DEEPCODE_EFFORT_LEVEL env → settings.effortLevel → default. + // Spec: docs/DEVELOPMENT_PLAN.md §3.13c. (/effort runtime switch and skill + // frontmatter override happen later in the loop, not at construction time.) + const effort = resolveEffort({ + cliFlag: opts.effort, + envVar: process.env.DEEPCODE_EFFORT_LEVEL, + settingsLevel: settings.effortLevel, + }); const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; const sessions = new SessionManager(); @@ -226,6 +236,9 @@ export async function startRepl(opts: ReplOpts): Promise { ...(pluginsWire?.spawnFailures.map((n) => `${n}: failed to start`) ?? []), ], initFlow: () => runInitFlow({ cwd, output, rl, provider, model, maxTokens, temperature }), + // M7: /rewind needs access to history + provider. + provider, + history, }; output.write(`\n ▎ DeepCode · ${ctx.model} · mode: ${ctx.mode} · effort: ${ctx.effort}\n`); @@ -262,6 +275,8 @@ export async function startRepl(opts: ReplOpts): Promise { // Slash command? const match = commands.match(userInput); if (match) { + // Refresh ctx.history snapshot before running — /rewind reads it. + ctx.history = history; const lines = await Promise.resolve(match.cmd.run(match.args, ctx)); for (const line of lines) output.write(line + '\n'); output.write('\n'); @@ -269,6 +284,10 @@ export async function startRepl(opts: ReplOpts): Promise { history = []; ctx.clearHistory = false; } + if (ctx.newHistory) { + history = ctx.newHistory; + ctx.newHistory = undefined; + } if (ctx.exitRequested) break; continue; } @@ -294,7 +313,21 @@ export async function startRepl(opts: ReplOpts): Promise { sandboxConfig: settings.sandbox, approval: async (toolName, _input, verdict) => { output.write(`\n ⏸ Approve ${toolName}? Reason: ${verdict.reason}\n`); - const answer = (await rl.question(' [y]es / [n]o: ')).trim().toLowerCase(); + const answer = ( + await rl.question(' [y]es / [n]o / [a]lways: ') + ).trim().toLowerCase(); + if (answer === 'a' || answer === 'always') { + // Persist a bare-tool matcher to project-local settings so the next + // run of this tool from this project skips the prompt. + try { + const { localPath } = settingsPaths({ cwd: ctx.cwd }); + await appendAllowMatcher(localPath, toolName); + output.write(` ✓ Added "${toolName}" to ${localPath} permissions.allow\n`); + } catch (err) { + output.write(` ⚠ Could not persist always-allow: ${(err as Error).message}\n`); + } + return 'always'; + } return answer === 'y' || answer === 'yes'; }, askUser: async (req) => { diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index db2d7f7..f26b976 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -47,6 +47,73 @@ pub fn get_settings_path() -> Option { settings::user_settings_path() } +/// Read ~/.deepcode/keybindings.json — returns {} if absent. +#[tauri::command] +pub fn load_keybindings() -> Result { + let Some(home) = dirs::home_dir() else { + return Ok(serde_json::json!({})); + }; + let path = home.join(".deepcode").join("keybindings.json"); + match std::fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw).map_err(|e| e.to_string()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::json!({})), + Err(e) => Err(format!("read {}: {}", path.display(), e)), + } +} + +/// Write ~/.deepcode/keybindings.json (creates ~/.deepcode/ if needed). +#[tauri::command] +pub fn save_keybindings(value: serde_json::Value) -> Result<(), String> { + let Some(home) = dirs::home_dir() else { + return Err("no home directory".into()); + }; + let dir = home.join(".deepcode"); + std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?; + let path = dir.join("keybindings.json"); + let raw = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?; + std::fs::write(&path, raw).map_err(|e| format!("write {}: {}", path.display(), e)) +} + +/// Append a matcher string to permissions.allow[] in ~/.deepcode/settings.json, +/// creating the file (and the permissions object) if needed. Idempotent. +/// +/// Called from the renderer when the user clicks "Always allow" on an +/// inline permission prompt. We deliberately target USER-level settings +/// (not project-local) because the renderer doesn't have a stable cwd +/// concept — the user can later tighten the rule by editing the file. +#[tauri::command] +pub fn append_allow_matcher(matcher: String) -> Result<(), String> { + let trimmed = matcher.trim(); + if trimmed.is_empty() { + return Ok(()); + } + let mut value = settings::read_user()?; + // Ensure `permissions.allow` is an array. + if !value.is_object() { + value = serde_json::json!({}); + } + let obj = value.as_object_mut().unwrap(); + let perms = obj + .entry("permissions".to_string()) + .or_insert_with(|| serde_json::json!({})); + if !perms.is_object() { + *perms = serde_json::json!({}); + } + let perms_obj = perms.as_object_mut().unwrap(); + let allow = perms_obj + .entry("allow".to_string()) + .or_insert_with(|| serde_json::json!([])); + if !allow.is_array() { + *allow = serde_json::json!([]); + } + let arr = allow.as_array_mut().unwrap(); + let exists = arr.iter().any(|v| v.as_str() == Some(trimmed)); + if !exists { + arr.push(serde_json::Value::String(trimmed.to_string())); + } + settings::write_user(&value) +} + /// List session files under ~/.deepcode/sessions/. Returns just metadata. #[derive(Serialize)] pub struct SessionMeta { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c8f0e62..778ca3a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -15,8 +15,9 @@ mod settings; mod tools; use commands::{ - cli_path, get_app_info, get_settings_path, list_sessions, load_settings_file, open_url, - read_credentials, save_credentials, save_settings_file, + append_allow_matcher, cli_path, get_app_info, get_settings_path, list_sessions, + load_keybindings, load_settings_file, open_url, read_credentials, save_credentials, + save_keybindings, save_settings_file, }; use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; use tauri::Manager; @@ -37,6 +38,9 @@ pub fn run() { load_settings_file, save_settings_file, get_settings_path, + append_allow_matcher, + load_keybindings, + save_keybindings, list_sessions, cli_path, open_url, diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts index 3c740b6..e46d0c6 100644 --- a/apps/desktop/src/lib/mac-agent.ts +++ b/apps/desktop/src/lib/mac-agent.ts @@ -12,9 +12,13 @@ // avoid pulling BUILTIN_TOOLS / SessionManager / etc. at module-load time. // The renderer can't link against node:fs / node:child_process. import { runAgent } from '@deepcode/core/dist/agent.js'; -import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; +import { + DeepSeekProvider, + EFFORT_PARAMS, +} from '@deepcode/core/dist/providers/deepseek.js'; import type { AgentEvent, + Effort, Mode, ToolHandler, } from '@deepcode/core/dist/types.js'; @@ -76,10 +80,19 @@ export interface StartTurnArgs { userMessage: string; model?: string; mode?: Mode; + /** Effort tier — controls maxTokens + temperature. Default 'medium'. */ + effort?: Effort; onEvent: (e: AgentEvent) => void; onDone: (reason: 'end_turn' | 'max_turns' | 'aborted' | 'error') => void; - /** Called when the agent needs user approval for a tool call. Resolves to allow/deny. */ - onApproval?: (toolName: string, reason: string) => Promise; + /** Called when the agent needs user approval for a tool call. Resolves to: + * 'allow' — permit this one call + * 'deny' — reject + * 'always' — permit + persist a permissions.allow matcher + */ + onApproval?: ( + toolName: string, + reason: string, + ) => Promise<'allow' | 'deny' | 'always'>; } export interface StartTurnResult { @@ -101,6 +114,7 @@ export async function startAgentTurn(args: StartTurnArgs): Promise { try { + const effortParams = EFFORT_PARAMS[args.effort ?? 'medium']; const result = await runAgent({ provider: prov, tools, @@ -108,6 +122,8 @@ export async function startAgentTurn(args: StartTurnArgs): Promise { const reason = verdict.reason ?? `Approve ${toolName}?`; - return await args.onApproval!(toolName, reason); + const decision = await args.onApproval!(toolName, reason); + if (decision === 'always') return 'always'; + return decision === 'allow'; } : undefined, onEvent: args.onEvent, diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index 3537c26..4cc6e2b 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -64,6 +64,32 @@ export async function getSettingsPath(): Promise { return (await invoke('get_settings_path')) as string | null; } +/** Append a permissions matcher to ~/.deepcode/settings.json. Idempotent. */ +export async function appendAllowMatcher(matcher: string): Promise { + await invoke('append_allow_matcher', { matcher }); +} + +export interface KeybindingsConfigOnDisk { + enabled?: boolean; + vim?: boolean; + bindings?: Array<{ + key: string; + action: string; + when?: 'NORMAL' | 'INSERT' | 'VISUAL'; + description?: string; + }>; +} + +/** Read ~/.deepcode/keybindings.json. Returns {} if absent. */ +export async function loadKeybindings(): Promise { + return (await invoke('load_keybindings')) as KeybindingsConfigOnDisk; +} + +/** Write ~/.deepcode/keybindings.json. */ +export async function saveKeybindings(value: KeybindingsConfigOnDisk): Promise { + await invoke('save_keybindings', { value }); +} + export async function listSessions(): Promise { return (await invoke('list_sessions')) as SessionMeta[]; } diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index 95b153a..7b6713d 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -6,6 +6,7 @@ import type { AgentEvent, Mode } from '@deepcode/core/dist/types.js'; import type { DeepCodeAPI } from '../types/global.js'; import { abortAgentTurn, startAgentTurn } from './mac-agent.js'; import { + appendAllowMatcher, getAppInfo, listSessions, loadSettingsFile, @@ -29,6 +30,19 @@ function emitEvent(e: unknown): void { } } +// Approval round-trips: mac-agent calls onApproval with a promise; we emit +// a `permission_request` event carrying a unique requestId and stash the +// resolver here. The UI calls api.agent.approve({ requestId, decision }) +// which pops the resolver and resolves the original promise. +const pendingApprovals = new Map< + string, + (decision: 'allow' | 'deny' | 'always') => void +>(); + +function nextRequestId(): string { + return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; +} + export function installTauriShim(): void { const api: DeepCodeAPI = { async version() { @@ -88,7 +102,7 @@ export function installTauriShim(): void { }, }, agent: { - async start({ userMessage, model, mode }) { + async start({ userMessage, model, mode, effort }) { // Pre-allocate turn ID so onEvent callbacks can reference it // without waiting for the promise to resolve. let pendingTurnId = `pending-${Date.now()}`; @@ -96,10 +110,33 @@ export function installTauriShim(): void { userMessage, model, mode: mode as Mode | undefined, + effort: effort as + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' + | undefined, onEvent: (e: AgentEvent) => emitEvent({ kind: 'event', turnId: pendingTurnId, ...e }), onDone: (reason) => emitEvent({ kind: 'turn_done', turnId: pendingTurnId, stopReason: reason }), + onApproval: (toolName, reason) => { + // Mint a request ID, emit it as a synthetic event, and return + // a promise the UI resolves via agent.approve(). + const requestId = nextRequestId(); + return new Promise<'allow' | 'deny' | 'always'>((resolve) => { + pendingApprovals.set(requestId, resolve); + emitEvent({ + kind: 'event', + turnId: pendingTurnId, + type: 'permission_request', + requestId, + toolName, + reason, + }); + }); + }, }); pendingTurnId = result.turnId; return result; @@ -107,10 +144,16 @@ export function installTauriShim(): void { async abort({ turnId }) { return abortAgentTurn(turnId); }, - async approve() { - // Approval prompts are handled inline via the onApproval callback - // passed to startAgentTurn — not via this method. Kept for API - // shape compatibility. + async approve({ requestId, decision }) { + // Persistence note: when `decision === 'always'`, the caller is + // expected to also have called `appendAllowMatcher(toolName)` so + // the rule survives the next session. We don't do it here because + // the shim no longer has access to the toolName by the time the + // user decides. See ReplScreen.tsx where this is wired. + const resolver = pendingApprovals.get(requestId); + if (!resolver) return; // no-op if already resolved (e.g. stale click) + pendingApprovals.delete(requestId); + resolver(decision); }, async answer() { // AskUserQuestion answers: same — for v1 Mac MVP we don't wire diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 0a4d4ea..a76bc08 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -3,6 +3,28 @@ // Milestone: M6 (real agent integration) import { useEffect, useRef, useState } from 'react'; +import { + DEFAULT_KEYBINDINGS, + VimState, + type KeyBinding, + type VimMode, +} from '@deepcode/core/dist/keybindings/vim.js'; +import { + appendAllowMatcher, + loadKeybindings, + loadSettingsFile, + saveSettingsFile, +} from '../lib/tauri-api.js'; + +type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +const EFFORT_LABELS: Record = { + low: 'Standard', + medium: 'Standard+', + high: 'High', + xhigh: 'Extra High', + max: 'Max', +}; +const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; interface Message { role: 'user' | 'assistant' | 'system' | 'tool'; @@ -21,6 +43,16 @@ interface AgentStreamEvt { result?: { content: string; isError?: boolean }; error?: string; stopReason?: string; + // permission_request fields + requestId?: string; + toolName?: string; + reason?: string; +} + +interface PendingApproval { + requestId: string; + toolName: string; + reason: string; } export function ReplScreen(): JSX.Element { @@ -35,7 +67,57 @@ export function ReplScreen(): JSX.Element { const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); const [activeTurnId, setActiveTurnId] = useState(null); + const [pendingApproval, setPendingApproval] = useState(null); + const [effort, setEffort] = useState('medium'); + // Vim mode (off by default until keybindings.json#vim is true). + const [vimEnabled, setVimEnabled] = useState(false); + const [vimMode, setVimMode] = useState('INSERT'); + const vimStateRef = useRef(null); + const bindingsRef = useRef(DEFAULT_KEYBINDINGS); const listRef = useRef(null); + const composerRef = useRef(null); + + // Load Vim config + custom bindings on mount. + useEffect(() => { + void (async () => { + try { + const kb = await loadKeybindings(); + bindingsRef.current = [...DEFAULT_KEYBINDINGS, ...(kb.bindings ?? [])]; + if (kb.vim) { + setVimEnabled(true); + vimStateRef.current = new VimState(); + setVimMode(vimStateRef.current.mode); + } + } catch { + /* keep defaults */ + } + })(); + }, []); + + // Load saved effort on mount. + useEffect(() => { + void (async () => { + try { + const s = (await loadSettingsFile()) as { effortLevel?: string }; + if (s.effortLevel && EFFORTS.includes(s.effortLevel as Effort)) { + setEffort(s.effortLevel as Effort); + } + } catch { + /* fall back to default */ + } + })(); + }, []); + + async function handleEffortChange(next: Effort): Promise { + setEffort(next); + // Persist to ~/.deepcode/settings.json so the choice survives restart. + try { + const current = (await loadSettingsFile()) as Record; + await saveSettingsFile({ ...current, effortLevel: next }); + } catch (err) { + console.warn('Failed to persist effort:', err); + } + } // Subscribe to agent events for the lifetime of this view useEffect(() => { @@ -96,12 +178,142 @@ export function ReplScreen(): JSX.Element { { role: 'system', text: `✕ Error: ${e.error ?? 'unknown'}` }, ]); break; + case 'permission_request': + if (e.requestId && e.toolName) { + setPendingApproval({ + requestId: e.requestId, + toolName: e.toolName, + reason: e.reason ?? `Approve ${e.toolName}?`, + }); + } + break; // text 'usage', 'thinking_delta', 'turn_complete' silently dropped } }); return () => off(); }, []); + /** Translate a DOM KeyboardEvent into a normalized chord string for VimState. */ + function chordFromEvent(e: React.KeyboardEvent): string { + const parts: string[] = []; + if (e.ctrlKey) parts.push('ctrl'); + if (e.shiftKey) parts.push('shift'); + if (e.altKey) parts.push('alt'); + if (e.metaKey) parts.push('meta'); + // Normalize the key half: + // 'Escape' → 'esc', single letters → lowercased, leave others as-is + let key = e.key; + if (key === 'Escape') key = 'esc'; + else if (key.length === 1) key = key.toLowerCase(); + parts.push(key); + return parts.join('+'); + } + + /** Apply a host-side action returned by VimState.feed (cursor ops, etc.). */ + function applyAction(action: string): void { + const ta = composerRef.current; + if (!ta) return; + switch (action) { + case 'cursor-line-start': + ta.setSelectionRange(0, 0); + break; + case 'cursor-line-end': + ta.setSelectionRange(ta.value.length, ta.value.length); + break; + case 'cursor-buffer-start': + ta.setSelectionRange(0, 0); + break; + case 'cursor-buffer-end': + ta.setSelectionRange(ta.value.length, ta.value.length); + break; + case 'kill-line': + case 'kill-to-end': { + const cur = ta.selectionStart; + vimStateRef.current!.yanked = ta.value.slice(cur); + setInput(ta.value.slice(0, cur)); + break; + } + case 'kill-to-start': { + const cur = ta.selectionStart; + vimStateRef.current!.yanked = ta.value.slice(0, cur); + setInput(ta.value.slice(cur)); + break; + } + case 'yank-line': + if (vimStateRef.current) vimStateRef.current.yanked = ta.value; + break; + case 'paste-after': { + const cur = ta.selectionStart; + const y = vimStateRef.current?.yanked ?? ''; + setInput(ta.value.slice(0, cur) + y + ta.value.slice(cur)); + break; + } + // vim-*-mode actions are handled inside VimState — no host work. + } + } + + function handleComposerKeyDown(e: React.KeyboardEvent): void { + // Submit on plain Enter (allow Shift+Enter for newline) + if (e.key === 'Enter' && !e.shiftKey && !vimEnabled) { + e.preventDefault(); + void handleSubmit(e as unknown as React.FormEvent); + return; + } + if (!vimEnabled || !vimStateRef.current) return; + const chord = chordFromEvent(e); + const before = vimStateRef.current.mode; + const action = vimStateRef.current.feed(chord, bindingsRef.current); + const after = vimStateRef.current.mode; + if (action) { + // We consumed the key: block default insertion + apply effect. + e.preventDefault(); + applyAction(action); + } else if (before === 'NORMAL') { + // NORMAL mode: swallow uncaught keys so they don't insert text. The + // only ALLOWED untranslated keys are arrow keys + backspace, which + // let the user navigate even mid-binding pending. + if ( + e.key !== 'ArrowLeft' && + e.key !== 'ArrowRight' && + e.key !== 'ArrowUp' && + e.key !== 'ArrowDown' && + e.key !== 'Backspace' + ) { + e.preventDefault(); + } + } + if (after !== before) setVimMode(after); + } + + async function handleApproval( + decision: 'allow' | 'deny' | 'always', + ): Promise { + if (!pendingApproval) return; + const req = pendingApproval; + setPendingApproval(null); + if (decision === 'always') { + try { + await appendAllowMatcher(req.toolName); + setMessages((m) => [ + ...m, + { + role: 'system', + text: `✓ Added "${req.toolName}" to settings.permissions.allow`, + }, + ]); + } catch (err) { + setMessages((m) => [ + ...m, + { + role: 'system', + text: `⚠ Could not persist always-allow: ${(err as Error).message}`, + }, + ]); + } + } + await window.deepcode.agent.approve({ requestId: req.requestId, decision }); + } + useEffect(() => { listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' }); }, [messages]); @@ -117,6 +329,7 @@ export function ReplScreen(): JSX.Element { const r = await window.deepcode.agent.start({ sessionId: 'default', userMessage: text, + effort, }); setActiveTurnId(r.turnId); } catch (err) { @@ -158,15 +371,95 @@ export function ReplScreen(): JSX.Element { ))} + {pendingApproval && ( +
+
+ ⏸ Approval needed + {' — '} + {pendingApproval.toolName} +
+
+ {pendingApproval.reason} +
+
+ + + +
+
+ )}
+
+ + + + controls max tokens + temperature for each turn + + {vimEnabled && ( + + -- {vimMode} -- + + )} +
- setInput(e.target.value)} - placeholder={busy ? 'Agent is responding…' : 'Ask DeepCode…'} - disabled={busy} - className="flex-1 rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent disabled:opacity-50" + onKeyDown={handleComposerKeyDown} + placeholder={ + pendingApproval + ? 'Approve or reject the tool call above to continue…' + : busy + ? 'Agent is responding…' + : vimEnabled + ? `[${vimMode}] Ask DeepCode… (Enter submits, Shift+Enter newline)` + : 'Ask DeepCode… (Enter submits, Shift+Enter for newline)' + } + disabled={busy || pendingApproval !== null} + rows={Math.min(6, Math.max(1, input.split('\n').length))} + className="flex-1 resize-none rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent disabled:opacity-50" /> {busy ? (