diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 94fda27..b2fd976 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -206,6 +206,8 @@ async function main(): Promise { continueSession: args.continue, forkSession: args.forkSession, bare: args.bare, + noColor: args.noColor, + hideThinking: args.noThinking, noPlugins: args.noPlugins, settingsPath: args.settingsFile, }); diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 7af77ef..4978183 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -45,6 +45,10 @@ export interface ParsedArgs { verbose: boolean; /** `--json` — machine-readable output for subcommands (plugins/skills list). */ json: boolean; + /** `--no-color` — never emit ANSI escapes (NO_COLOR / a non-TTY do this too). */ + noColor: boolean; + /** `--no-thinking` — don't stream the model's reasoning in the REPL. */ + noThinking: boolean; // Settings overrides settingsFile?: string; @@ -120,6 +124,8 @@ export function parseArgs(argv: string[]): ParsedArgs { json: false, noPlugins: false, strict: false, + noColor: false, + noThinking: false, unknownFlags: [], unimplementedFlags: [], positional: [], @@ -242,6 +248,12 @@ export function parseArgs(argv: string[]): ParsedArgs { case a === '--verbose': out.verbose = true; break; + case a === '--no-color': + out.noColor = true; + break; + case a === '--no-thinking': + out.noThinking = true; + break; case a === '--settings': out.settingsFile = next(); break; @@ -345,6 +357,10 @@ OVERRIDES --settings Override settings.json discovery (highest-precedence layer) --no-plugins Disable all plugins for this run +TERMINAL OUTPUT + --no-color No ANSI colour (NO_COLOR / a pipe do this too) + --no-thinking Hide the model's reasoning stream + DIAGNOSTICS -h, --help Show this -v, --version Show version diff --git a/apps/cli/src/render.test.ts b/apps/cli/src/render.test.ts new file mode 100644 index 0000000..c8e6817 --- /dev/null +++ b/apps/cli/src/render.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { + ThinkingStream, + colorEnabled, + makePalette, + renderApprovalPreview, + renderDiff, + renderToolCall, + renderToolResult, + toolTarget, +} from './render.js'; + +const plain = makePalette(false); + +describe('colorEnabled', () => { + it('follows the TTY when nothing overrides it', () => { + expect(colorEnabled({}, true)).toBe(true); + expect(colorEnabled({}, false)).toBe(false); + }); + + it('NO_COLOR wins over everything, including FORCE_COLOR', () => { + expect(colorEnabled({ NO_COLOR: '1', FORCE_COLOR: '1' }, true)).toBe(false); + expect(colorEnabled({ NO_COLOR: 'anything' }, true)).toBe(false); + }); + + it('ignores an empty NO_COLOR, per the spec', () => { + expect(colorEnabled({ NO_COLOR: '' }, true)).toBe(true); + }); + + it('FORCE_COLOR turns colour on for a pipe, and 0 turns it off', () => { + expect(colorEnabled({ FORCE_COLOR: '1' }, false)).toBe(true); + expect(colorEnabled({ FORCE_COLOR: '0' }, true)).toBe(false); + }); + + it('treats a dumb terminal as no colour', () => { + expect(colorEnabled({ TERM: 'dumb' }, true)).toBe(false); + }); +}); + +describe('makePalette', () => { + it('emits no escape bytes when disabled', () => { + expect(plain.red('x')).toBe('x'); + }); + + it('wraps and resets when enabled', () => { + expect(makePalette(true).red('x')).toBe('\u001b[31mx\u001b[0m'); + }); +}); + +describe('renderDiff', () => { + it('marks additions and deletions with line numbers', () => { + const out = renderDiff('a\nb\nc', 'a\nB\nc', plain); + expect(out).toContain('- b'); + expect(out).toContain('+ B'); + expect(out).toContain('a'); + }); + + it('collapses unchanged stretches instead of printing the whole file', () => { + const before = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n'); + const after = before.replace('line 100', 'line 100 CHANGED'); + const out = renderDiff(before, after, plain); + expect(out).toContain('line 100 CHANGED'); + expect(out).toContain('⋯'); + // 1 del + 1 add + 4 context + a gap marker — nowhere near 200 rows. + expect(out.trim().split('\n').length).toBeLessThan(12); + expect(out).not.toContain('line 5'); + }); + + it('caps a huge rewrite and says how much it withheld', () => { + const before = Array.from({ length: 300 }, (_, i) => `old ${i}`).join('\n'); + const after = Array.from({ length: 300 }, (_, i) => `new ${i}`).join('\n'); + const out = renderDiff(before, after, plain, { maxLines: 10 }); + expect(out.trim().split('\n').length).toBeLessThanOrEqual(11); + expect(out).toMatch(/⋯ \d+ more changed lines/); + }); + + it('says so when the two revisions are identical', () => { + expect(renderDiff('same\ntext', 'same\ntext', plain)).toContain('(no change)'); + }); + + it('renders a brand-new file as pure additions', () => { + const out = renderDiff('', 'hello\nworld', plain); + expect(out).toContain('+ hello'); + expect(out).toContain('+ world'); + expect(out).not.toContain('- '); + }); +}); + +describe('renderToolCall', () => { + it('leads with the file path for file tools', () => { + expect(renderToolCall('Edit', { file_path: 'src/a.ts' }, plain)).toContain('src/a.ts'); + }); + + it('shows only the first line of a multi-line command', () => { + expect(toolTarget({ command: 'echo one\necho two' })).toBe('echo one'); + }); + + it('falls back to compact JSON for tools with no obvious target', () => { + expect(toolTarget({ some: 'thing' })).toBe('{"some":"thing"}'); + }); +}); + +describe('renderToolResult', () => { + it('keeps head and tail and elides the middle', () => { + const content = Array.from({ length: 100 }, (_, i) => `row ${i}`).join('\n'); + const out = renderToolResult(content, false, plain, { head: 3, tail: 2 }); + expect(out).toContain('row 0'); + expect(out).toContain('row 2'); + expect(out).toContain('row 99'); + expect(out).toContain('95 lines elided'); + expect(out).not.toContain('row 50'); + }); + + it('shows short output untouched', () => { + const out = renderToolResult('one\ntwo', false, plain); + expect(out).toContain('one'); + expect(out).toContain('two'); + expect(out).not.toContain('elided'); + }); + + it('clips a single pathological line instead of flooding the terminal', () => { + const out = renderToolResult('x'.repeat(5000), false, plain, { maxLineWidth: 100 }); + expect(out.length).toBeLessThan(300); + }); + + it('marks errors distinctly', () => { + expect(renderToolResult('boom', true, plain)).toContain('✕'); + expect(renderToolResult('fine', false, plain)).toContain('✓'); + }); + + it('does not print a bare tick for empty output', () => { + expect(renderToolResult(' ', false, plain)).toContain('(no output)'); + }); +}); + +describe('renderApprovalPreview', () => { + it('diffs an Edit from its own arguments', () => { + const out = renderApprovalPreview( + 'Edit', + { file_path: 'a.ts', old_string: 'const a = 1', new_string: 'const a = 2' }, + plain, + ); + expect(out).toContain('- const a = 1'); + expect(out).toContain('+ const a = 2'); + }); + + it('diffs a Write against the file already on disk', () => { + const out = renderApprovalPreview('Write', { content: 'new\nbody' }, plain, 'old\nbody'); + expect(out).toContain('- old'); + expect(out).toContain('+ new'); + }); + + it('shows a Bash command in full, every line of it', () => { + const out = renderApprovalPreview('Bash', { command: 'rm -rf build\nmake all' }, plain); + expect(out).toContain('rm -rf build'); + expect(out).toContain('make all'); + }); + + it('includes the description a Bash call carries', () => { + const out = renderApprovalPreview('Bash', { command: 'ls', description: 'list files' }, plain); + expect(out).toContain('list files'); + }); + + it('falls back to the arguments for an unknown tool', () => { + expect(renderApprovalPreview('Whatever', { a: 1 }, plain)).toContain('"a": 1'); + }); +}); + +describe('ThinkingStream', () => { + it('opens a labelled gutter and prefixes each line', () => { + const s = new ThinkingStream(plain); + const out = s.delta('first\nsecond'); + expect(out).toContain('┆ thinking'); + const body = out.split('\n').filter((l) => l.startsWith(' ┆ ') && !l.includes('thinking')); + expect(body).toEqual([' ┆ first', ' ┆ second']); + }); + + it('only labels the block once across deltas', () => { + const s = new ThinkingStream(plain); + const combined = s.delta('a') + s.delta('b'); + expect(combined.match(/┆ thinking/g)?.length).toBe(1); + }); + + it('closes cleanly and is idempotent', () => { + const s = new ThinkingStream(plain); + s.delta('mid-line'); + expect(s.isOpen).toBe(true); + expect(s.close()).toBe('\n\n'); + expect(s.isOpen).toBe(false); + expect(s.close()).toBe(''); + }); + + it('closing before anything streamed emits nothing', () => { + expect(new ThinkingStream(plain).close()).toBe(''); + }); +}); diff --git a/apps/cli/src/render.ts b/apps/cli/src/render.ts new file mode 100644 index 0000000..2e95bee --- /dev/null +++ b/apps/cli/src/render.ts @@ -0,0 +1,324 @@ +// Terminal rendering for the REPL. +// +// Everything here is a pure string -> string function so it can be tested +// without a TTY. The REPL owns the writing; this module owns what the bytes +// look like. Colour is resolved once at startup and threaded through as a +// palette, so a non-TTY / NO_COLOR run produces byte-identical text minus the +// escape codes. + +import { computeLineDiff, type DiffLine } from '@deepcode/core'; + +// ── colour ────────────────────────────────────────────────────────────────── + +export type Paint = (s: string) => string; + +export interface Palette { + readonly enabled: boolean; + dim: Paint; + bold: Paint; + red: Paint; + green: Paint; + yellow: Paint; + cyan: Paint; +} + +const wrap = + (open: string): Paint => + (s) => + `\u001b[${open}m${s}\u001b[0m`; +const plain: Paint = (s) => s; + +export function makePalette(enabled: boolean): Palette { + if (!enabled) { + return { + enabled, + dim: plain, + bold: plain, + red: plain, + green: plain, + yellow: plain, + cyan: plain, + }; + } + return { + enabled, + dim: wrap('2'), + bold: wrap('1'), + red: wrap('31'), + green: wrap('32'), + yellow: wrap('33'), + cyan: wrap('36'), + }; +} + +/** + * Standard precedence: NO_COLOR beats everything (any value, per no-color.org), + * then FORCE_COLOR, then TERM=dumb, then whether we're actually on a terminal. + */ +export function colorEnabled(env: NodeJS.ProcessEnv, isTTY: boolean): boolean { + if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false; + if (env.FORCE_COLOR !== undefined) return env.FORCE_COLOR !== '0' && env.FORCE_COLOR !== 'false'; + if (env.TERM === 'dumb') return false; + return isTTY; +} + +// ── diffs ─────────────────────────────────────────────────────────────────── + +export interface DiffRenderOptions { + /** Unchanged lines kept either side of a change. */ + context?: number; + /** Hard cap on emitted diff rows; the rest is summarised in a footer. */ + maxLines?: number; + /** Left margin applied to every line. */ + indent?: string; +} + +/** + * A unified diff, hunked and capped. Long stretches of unchanged code collapse + * to a `⋯` marker so a two-line edit in a 900-line file prints as two lines. + */ +export function renderDiff( + oldText: string, + newText: string, + palette: Palette, + options: DiffRenderOptions = {}, +): string { + const context = options.context ?? 2; + const maxLines = options.maxLines ?? 40; + const indent = options.indent ?? ' '; + + const lines = computeLineDiff(oldText, newText); + const keep = keptIndices(lines, context); + if (keep.size === 0) return `${indent}${palette.dim('(no change)')}\n`; + + const out: string[] = []; + let emitted = 0; + let elided = 0; + let gap = false; + + for (let i = 0; i < lines.length; i++) { + if (!keep.has(i)) { + gap = true; + continue; + } + if (emitted >= maxLines) { + if (lines[i]!.kind !== 'ctx') elided++; + continue; + } + // Also marks a leading gap, so a diff that starts at line 400 doesn't look + // like it starts at the top of the file. + if (gap) out.push(`${indent}${palette.dim(' ⋯')}`); + gap = false; + out.push(indent + paintDiffLine(lines[i]!, palette)); + emitted++; + } + + if (elided > 0) { + out.push( + `${indent}${palette.dim(` ⋯ ${elided} more changed line${elided === 1 ? '' : 's'}`)}`, + ); + } + return out.join('\n') + '\n'; +} + +function paintDiffLine(line: DiffLine, palette: Palette): string { + const no = String(line.newNo ?? line.oldNo ?? '').padStart(4, ' '); + if (line.kind === 'add') return palette.green(`${no} + ${line.text}`); + if (line.kind === 'del') return palette.red(`${no} - ${line.text}`); + return palette.dim(`${no} ${line.text}`); +} + +/** Indices worth printing: every change, plus `context` rows around each. */ +function keptIndices(lines: DiffLine[], context: number): Set { + const keep = new Set(); + for (let i = 0; i < lines.length; i++) { + if (lines[i]!.kind === 'ctx') continue; + for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) { + keep.add(j); + } + } + return keep; +} + +// ── tool calls ────────────────────────────────────────────────────────────── + +/** The one-line target shown next to a tool name. */ +export function toolTarget(input: Record): string { + for (const key of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) { + const v = input[key]; + if (typeof v === 'string') return v.split('\n')[0]!; + } + return JSON.stringify(input).slice(0, 80); +} + +export function renderToolCall( + name: string, + input: Record, + palette: Palette, +): string { + return `\n ${palette.cyan('●')} ${palette.bold(name)} ${palette.dim(toolTarget(input))}\n`; +} + +export interface ResultRenderOptions { + /** Lines kept from the top of the output. */ + head?: number; + /** Lines kept from the bottom. */ + tail?: number; + /** Any single line longer than this is cut (minified JSON, base64, ...). */ + maxLineWidth?: number; +} + +/** + * Tool output, elided in the middle rather than cut off at a byte count — the + * tail of a failing command is usually the part that matters. + */ +export function renderToolResult( + content: string, + isError: boolean, + palette: Palette, + options: ResultRenderOptions = {}, +): string { + const head = options.head ?? 12; + const tail = options.tail ?? 4; + const maxLineWidth = options.maxLineWidth ?? 400; + + const mark = isError ? palette.red('✕') : palette.green('✓'); + // Failing output stays at full brightness so it is readable; success output + // recedes. Exactly one paint per line — nesting produces unreadable byte soup + // (`ESC[31m ESC[2m … ESC[0m ESC[0m`) and doubles the output size. + const paint: Paint = isError ? (l) => l : palette.dim; + const all = content.replace(/\n+$/, '').split('\n'); + if (content.trim() === '') return ` ${mark} ${palette.dim('(no output)')}\n`; + + const clip = (s: string): string => + s.length > maxLineWidth ? s.slice(0, maxLineWidth) + ' …' : s; + + const shown: string[] = + all.length <= head + tail + 1 + ? all.map(clip) + : [ + ...all.slice(0, head).map(clip), + `… ${all.length - head - tail} lines elided …`, + ...all.slice(-tail).map(clip), + ]; + + return ( + shown.map((l, i) => (i === 0 ? ` ${mark} ${paint(l)}` : ` ${paint(l)}`)).join('\n') + + '\n' + ); +} + +// ── approval previews ─────────────────────────────────────────────────────── + +/** + * What the user is actually being asked to approve. Returning the change itself + * — rather than just a tool name — is the difference between an informed yes + * and a reflexive one. + * + * `existing` is the current file content, when the caller could read it; it + * turns a Write into a real before/after instead of a wall of additions. + */ +export function renderApprovalPreview( + toolName: string, + input: Record, + palette: Palette, + existing?: string, +): string { + const str = (key: string): string | undefined => + typeof input[key] === 'string' ? (input[key] as string) : undefined; + + if (toolName === 'Edit') { + const oldStr = str('old_string'); + const newStr = str('new_string'); + if (oldStr !== undefined && newStr !== undefined) { + return renderDiff(oldStr, newStr, palette); + } + } + + if (toolName === 'Write') { + const content = str('content'); + if (content !== undefined) return renderDiff(existing ?? '', content, palette); + } + + if (toolName === 'Bash') { + const command = str('command'); + if (command !== undefined) { + const body = command + .split('\n') + .map((l) => ` ${palette.yellow(l)}`) + .join('\n'); + const description = str('description'); + return description ? ` ${palette.dim(description)}\n${body}\n` : `${body}\n`; + } + } + + const json = JSON.stringify(input, null, 2) ?? ''; + const lines = json.split('\n'); + const shown = + lines.length > 20 ? [...lines.slice(0, 20), `… ${lines.length - 20} more lines`] : lines; + return shown.map((l) => ` ${palette.dim(l)}`).join('\n') + '\n'; +} + +// ── reasoning ─────────────────────────────────────────────────────────────── + +/** + * Streams the model's reasoning as a dim, gutter-marked side channel so it is + * visibly not the answer. Stateful because deltas arrive mid-line: it tracks + * whether a gutter marker is still owed for the current line. + * + * DeepSeek's reasoner is the point of this product; dropping its reasoning on + * the floor (which is what the REPL did) hid the most useful thing it emits. + */ +export class ThinkingStream { + private open = false; + private atLineStart = true; + + constructor( + private readonly palette: Palette, + private readonly gutter = ' ┆ ', + ) {} + + /** + * Text to write for a reasoning delta. + * + * Painted per line segment, not per character: deltas arrive a few tokens at + * a time, and wrapping every character in its own escape pair turns a + * paragraph of reasoning into kilobytes of terminal soup. + */ + delta(text: string): string { + let out = ''; + if (!this.open) { + out += `\n ${this.palette.dim('┆ thinking')}\n`; + this.open = true; + this.atLineStart = true; + } + const segments = text.split('\n'); + for (let i = 0; i < segments.length; i++) { + if (i > 0) { + out += '\n'; + this.atLineStart = true; + } + const segment = segments[i]!; + if (segment === '') continue; + if (this.atLineStart) { + out += this.palette.dim(this.gutter); + this.atLineStart = false; + } + out += this.palette.dim(segment); + } + return out; + } + + /** Close the reasoning block before non-reasoning output. */ + close(): string { + if (!this.open) return ''; + this.open = false; + const trailingNewline = this.atLineStart ? '' : '\n'; + this.atLineStart = true; + return `${trailingNewline}\n`; + } + + get isOpen(): boolean { + return this.open; + } +} diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 4e2c5bd..2083434 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -58,6 +58,15 @@ import { captureVoice } from './voice-capture.js'; import { resolveEffort } from './parse-args.js'; import { TrustStore } from './trust.js'; import { resolveBuiltinSkillsDir } from './builtin-skills.js'; +import { + ThinkingStream, + colorEnabled, + makePalette, + renderApprovalPreview, + renderToolCall, + renderToolResult, + type Palette, +} from './render.js'; export interface ReplOpts { input: Readable; @@ -99,6 +108,10 @@ export interface ReplOpts { noPlugins?: boolean; /** `--settings ` → a settings file that wins over discovered layers. */ settingsPath?: string; + /** `--no-color` → force plain output even on a TTY (NO_COLOR is honoured too). */ + noColor?: boolean; + /** `--no-thinking` → don't stream the model's reasoning. */ + hideThinking?: boolean; } const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). Be concise and accurate. When you modify files, briefly explain what you changed and why.`; @@ -524,6 +537,12 @@ export async function startRepl(opts: ReplOpts): Promise { }); ctx.tasks = tasks; + // Colour resolves once: a --no-color flag, NO_COLOR/FORCE_COLOR, or whether + // stdout is actually a terminal. Piped output stays plain. + const palette = makePalette( + !opts.noColor && colorEnabled(process.env, (output as { isTTY?: boolean }).isTTY === true), + ); + if (!opts.bare) { output.write( `\n ▎ DeepCode · ${ctx.model} · mode: ${ctx.mode} · effort: ${ctx.effort}\n`, @@ -678,6 +697,10 @@ export async function startRepl(opts: ReplOpts): Promise { } } + // One reasoning stream per turn: it owns the "am I mid-line inside the + // thinking gutter" state, which must not leak across turns. + const thinking = opts.hideThinking ? null : new ThinkingStream(palette); + // Otherwise: send to agent (with mode/permission/hooks gating from M3b) const result = await runtime.run({ systemPrompt, @@ -696,8 +719,23 @@ export async function startRepl(opts: ReplOpts): Promise { // Session-scoped manager: the agent's TaskCreate calls land here too, so // background tasks persist across turns and show up in /tasks. taskManager: tasks, - approval: async (toolName, _input, verdict) => { - output.write(`\n ⏸ Approve ${toolName}? Reason: ${verdict.reason}\n`); + approval: async (toolName, input, verdict) => { + output.write( + `\n ${palette.yellow('⏸')} Approve ${palette.bold(toolName)}? ${palette.dim(verdict.reason)}\n`, + ); + // Show what is about to happen. A Write gets diffed against the file on + // disk when it already exists, so an overwrite doesn't look like a + // creation. + let existing: string | undefined; + if (toolName === 'Write' && typeof input.file_path === 'string') { + try { + const { readFile } = await import('node:fs/promises'); + existing = await readFile(input.file_path, 'utf8'); + } catch { + /* new file — no baseline */ + } + } + output.write(renderApprovalPreview(toolName, input, palette, existing)); 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 @@ -731,8 +769,9 @@ export async function startRepl(opts: ReplOpts): Promise { } return `Other: ${reply}`; }, - onEvent: (e: AgentEvent) => formatEvent(output, e), + onEvent: (e: AgentEvent) => formatEvent(output, e, palette, thinking), }); + if (thinking) output.write(thinking.close()); history = result.history; ctx.usage.inputTokens += result.usage.inputTokens; ctx.usage.outputTokens += result.usage.outputTokens; @@ -770,25 +809,48 @@ export async function startRepl(opts: ReplOpts): Promise { return 0; } -function formatEvent(out: Writable, e: AgentEvent): void { +/** + * Renders one agent event. Reasoning is a dim side channel that has to be + * closed before anything else prints, so this needs the per-turn + * `ThinkingStream` rather than being a pure function of the event. + */ +function formatEvent( + out: Writable, + e: AgentEvent, + palette: Palette, + thinking: ThinkingStream | null, +): void { + const closeThinking = (): void => { + if (thinking) out.write(thinking.close()); + }; + switch (e.type) { case 'text_delta': + closeThinking(); out.write(e.text); return; case 'thinking_delta': + if (thinking) out.write(thinking.delta(e.text)); return; case 'tool_use': - out.write(`\n ● ${e.name} ${formatToolInput(e.input)}\n`); + closeThinking(); + out.write(renderToolCall(e.name, e.input, palette)); + // Show the change itself, not just "Edit src/a.ts" — in acceptEdits mode + // there is no approval prompt, so this is the only place the user sees it. + if (e.name === 'Edit' || e.name === 'Write') { + out.write(renderApprovalPreview(e.name, e.input, palette)); + } return; case 'tool_result': - if (e.result.isError) out.write(` ✕ ${truncate(e.result.content, 200)}\n`); - else out.write(` ✓ ${truncate(e.result.content, 200)}\n`); + closeThinking(); + out.write(renderToolResult(e.result.content, e.result.isError === true, palette)); return; case 'usage': case 'model_step_complete': return; case 'error': - out.write(`\n ✕ ${e.error}\n`); + closeThinking(); + out.write(`\n ${palette.red('✕')} ${e.error}\n`); return; case 'turn_complete': return; @@ -806,18 +868,6 @@ function assistantText(history: StoredMessage[]): string { .trim(); } -function formatToolInput(input: Record): string { - for (const key of ['file_path', 'command', 'pattern', 'path']) { - const v = input[key]; - if (typeof v === 'string') return v; - } - return JSON.stringify(input).slice(0, 80); -} - -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + '…' : s; -} - /** * Multi-phase /init flow — scans the project, asks the LLM to draft an * AGENTS.md, shows the draft, and asks the user to approve. Returns the diff --git a/apps/desktop/src/lib/diff.ts b/apps/desktop/src/lib/diff.ts index 08cb366..6e7897c 100644 --- a/apps/desktop/src/lib/diff.ts +++ b/apps/desktop/src/lib/diff.ts @@ -1,75 +1,3 @@ -// Pure line differ for the file panel's Diff view. Produces a full (un-hunked) -// unified diff as DiffLine[] — the FilePanel renders inline or split from the -// same rows. Kept dependency-free + side-effect-free so it's trivially testable. -// -// Algorithm: classic longest-common-subsequence over lines, then a backtrack -// that emits ctx / del / add rows with running old/new line numbers. Lines are -// split on '\n' to match how SourceView numbers the file, so a diff row's -// oldNo/newNo line up with the Source view. - -import type { DiffLine } from '../types/file-panel.js'; - -// LCS builds an (n+1)×(m+1) table. Cap the cell count so a pathologically large -// pair can't blow up memory; beyond it we fall back to a naive replace-all diff -// (every old line deleted, every new line added). Real source files are far -// under this (a 4000×4000 diff = 16M cells ≈ 64MB Int32Array, transient). -const MAX_CELLS = 16_000_000; - -export function computeLineDiff(oldText: string, newText: string): DiffLine[] { - // An empty string is zero lines, not one empty line — otherwise diffing an - // empty baseline (e.g. a brand-new file's pre-snapshot) emits a phantom - // "delete empty line" row before the real additions. - const a = oldText === '' ? [] : oldText.split('\n'); - const b = newText === '' ? [] : newText.split('\n'); - const n = a.length; - const m = b.length; - if (n === 0 && m === 0) return []; - if ((n + 1) * (m + 1) > MAX_CELLS) return naiveDiff(a, b); - - // dp[i*w + j] = LCS length of a[i..] and b[j..]; filled bottom-up. - const w = m + 1; - const dp = new Int32Array((n + 1) * w); - for (let i = n - 1; i >= 0; i--) { - for (let j = m - 1; j >= 0; j--) { - dp[i * w + j] = - a[i] === b[j] - ? dp[(i + 1) * w + (j + 1)] + 1 - : Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]); - } - } - - const out: DiffLine[] = []; - let i = 0; - let j = 0; - let oldNo = 1; - let newNo = 1; - while (i < n && j < m) { - if (a[i] === b[j]) { - out.push({ kind: 'ctx', oldNo: oldNo++, newNo: newNo++, text: a[i] }); - i++; - j++; - } else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) { - out.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: a[i] }); - i++; - } else { - out.push({ kind: 'add', oldNo: null, newNo: newNo++, text: b[j] }); - j++; - } - } - while (i < n) out.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: a[i++] }); - while (j < m) out.push({ kind: 'add', oldNo: null, newNo: newNo++, text: b[j++] }); - return out; -} - -/** Whole-file replacement diff — fallback for oversized inputs. */ -function naiveDiff(a: string[], b: string[]): DiffLine[] { - const out: DiffLine[] = []; - a.forEach((text, i) => out.push({ kind: 'del', oldNo: i + 1, newNo: null, text })); - b.forEach((text, i) => out.push({ kind: 'add', oldNo: null, newNo: i + 1, text })); - return out; -} - -/** True when a diff has at least one add/del (i.e. the two revisions differ). */ -export function hasChanges(lines: DiffLine[]): boolean { - return lines.some((l) => l.kind !== 'ctx'); -} +// The line differ now lives in core so the CLI renders the same diffs the file +// panel does. Re-exported here so the panel's imports stay put. +export { computeLineDiff, hasChanges } from '@deepcode/core/dist/util/diff.js'; diff --git a/packages/core/package.json b/packages/core/package.json index d15e6b3..6a5f56c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,6 +36,10 @@ "types": "./dist/keybindings/vim.d.ts", "import": "./dist/keybindings/vim.js" }, + "./dist/util/diff.js": { + "types": "./dist/util/diff.d.ts", + "import": "./dist/util/diff.js" + }, "./credentials": { "types": "./dist/credentials/index.d.ts", "import": "./dist/credentials/index.js" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55e1e8f..1519ed6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -394,6 +394,7 @@ export { // Scrubbed environment for spawning `git` against an explicit cwd (strips // inherited GIT_* so a leaked GIT_DIR can't redirect the call). export { gitSpawnEnv } from './util/git-env.js'; +export { computeLineDiff, hasChanges, type DiffLine } from './util/diff.js'; // launchd LaunchAgent installer (M8 — macOS scheduled tasks daemon) export { diff --git a/apps/desktop/src/lib/diff.test.ts b/packages/core/src/util/diff.test.ts similarity index 95% rename from apps/desktop/src/lib/diff.test.ts rename to packages/core/src/util/diff.test.ts index 2631b7c..fdb6e38 100644 --- a/apps/desktop/src/lib/diff.test.ts +++ b/packages/core/src/util/diff.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { computeLineDiff, hasChanges } from './diff.js'; -import type { DiffLine } from '../types/file-panel.js'; +import { computeLineDiff, hasChanges, type DiffLine } from './diff.js'; /** Compact a diff to "" rows for readable assertions. */ function sigs(lines: DiffLine[]): string[] { diff --git a/packages/core/src/util/diff.ts b/packages/core/src/util/diff.ts new file mode 100644 index 0000000..e02033c --- /dev/null +++ b/packages/core/src/util/diff.ts @@ -0,0 +1,83 @@ +// Pure line differ shared by every surface that shows a change: the desktop +// file panel's Diff view, and the CLI's inline edit rendering. Produces a full +// (un-hunked) unified diff as DiffLine[]; callers decide how to display it. +// Dependency-free + side-effect-free so it's trivially testable. +// +// Algorithm: classic longest-common-subsequence over lines, then a backtrack +// that emits ctx / del / add rows with running old/new line numbers. Lines are +// split on '\n' so a diff row's oldNo/newNo line up with the file as numbered +// by an editor. + +export interface DiffLine { + kind: 'add' | 'del' | 'ctx'; + /** 1-based line number in the OLD revision (null for added lines). */ + oldNo: number | null; + /** 1-based line number in the NEW revision (null for deleted lines). */ + newNo: number | null; + text: string; +} + +// LCS builds an (n+1)×(m+1) table. Cap the cell count so a pathologically large +// pair can't blow up memory; beyond it we fall back to a naive replace-all diff +// (every old line deleted, every new line added). Real source files are far +// under this (a 4000×4000 diff = 16M cells ≈ 64MB Int32Array, transient). +const MAX_CELLS = 16_000_000; + +export function computeLineDiff(oldText: string, newText: string): DiffLine[] { + // An empty string is zero lines, not one empty line — otherwise diffing an + // empty baseline (e.g. a brand-new file's pre-snapshot) emits a phantom + // "delete empty line" row before the real additions. + const a = oldText === '' ? [] : oldText.split('\n'); + const b = newText === '' ? [] : newText.split('\n'); + const n = a.length; + const m = b.length; + if (n === 0 && m === 0) return []; + if ((n + 1) * (m + 1) > MAX_CELLS) return naiveDiff(a, b); + + // dp[i*w + j] = LCS length of a[i..] and b[j..]; filled bottom-up. + const w = m + 1; + const dp = new Int32Array((n + 1) * w); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i * w + j] = + a[i] === b[j] + ? dp[(i + 1) * w + (j + 1)] + 1 + : Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]); + } + } + + const out: DiffLine[] = []; + let i = 0; + let j = 0; + let oldNo = 1; + let newNo = 1; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ kind: 'ctx', oldNo: oldNo++, newNo: newNo++, text: a[i] }); + i++; + j++; + } else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) { + out.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: a[i] }); + i++; + } else { + out.push({ kind: 'add', oldNo: null, newNo: newNo++, text: b[j] }); + j++; + } + } + while (i < n) out.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: a[i++] }); + while (j < m) out.push({ kind: 'add', oldNo: null, newNo: newNo++, text: b[j++] }); + return out; +} + +/** Whole-file replacement diff — fallback for oversized inputs. */ +function naiveDiff(a: string[], b: string[]): DiffLine[] { + const out: DiffLine[] = []; + a.forEach((text, i) => out.push({ kind: 'del', oldNo: i + 1, newNo: null, text })); + b.forEach((text, i) => out.push({ kind: 'add', oldNo: null, newNo: i + 1, text })); + return out; +} + +/** True when a diff has at least one add/del (i.e. the two revisions differ). */ +export function hasChanges(lines: DiffLine[]): boolean { + return lines.some((l) => l.kind !== 'ctx'); +}