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
122 changes: 122 additions & 0 deletions apps/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
});
140 changes: 140 additions & 0 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import type {
DeepCodeSettings,
McpClientHandle,
Provider,
SessionManager,
SessionMeta,
StoredMessage,
} from '@deepcode/core';
import { redact, type Credentials } from '@deepcode/core';

Expand Down Expand Up @@ -41,6 +43,12 @@ export interface SessionContext {
* approve → write). Returns the path written, or null if user cancelled.
*/
initFlow?: () => Promise<string | null>;
/** 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 {
Expand Down Expand Up @@ -412,6 +420,137 @@ export const PluginsCommand: SlashCommand = {
},
};

export const RewindCommand: SlashCommand = {
name: '/rewind',
description:
'List file snapshots and roll back (5 ops): /rewind [<seq> 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 <seq> <action>');
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,
Expand All @@ -431,6 +570,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
PluginsCommand,
KeybindingsCommand,
VimCommand,
RewindCommand,
];

// ──────────────────────────────────────────────────────────────────────────
Expand Down
38 changes: 37 additions & 1 deletion apps/cli/src/parse-args.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
25 changes: 25 additions & 0 deletions apps/cli/src/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> = [
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,
Expand Down
Loading
Loading