codeep 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +41 -7
  2. package/dist/acp/serverHandlers.js +1 -1
  3. package/dist/acp/session.js +22 -1
  4. package/dist/config/index.js +20 -4
  5. package/dist/config/providers.d.ts +3 -2
  6. package/dist/config/providers.js +163 -69
  7. package/dist/renderer/App.d.ts +89 -0
  8. package/dist/renderer/App.js +637 -43
  9. package/dist/renderer/Screen.d.ts +1 -0
  10. package/dist/renderer/Screen.js +8 -3
  11. package/dist/renderer/commands/helpers.d.ts +189 -0
  12. package/dist/renderer/commands/helpers.js +345 -0
  13. package/dist/renderer/commands/registry.js +2 -1
  14. package/dist/renderer/commands.js +218 -267
  15. package/dist/renderer/components/AgentTimeline.d.ts +44 -0
  16. package/dist/renderer/components/AgentTimeline.js +157 -0
  17. package/dist/renderer/components/Autocomplete.d.ts +25 -0
  18. package/dist/renderer/components/Autocomplete.js +35 -0
  19. package/dist/renderer/components/Status.d.ts +2 -0
  20. package/dist/renderer/layout.d.ts +5 -1
  21. package/dist/renderer/layout.js +12 -0
  22. package/dist/renderer/main.js +110 -30
  23. package/dist/utils/agent.js +1 -1
  24. package/dist/utils/agents.d.ts +1 -1
  25. package/dist/utils/agents.js +1 -1
  26. package/dist/utils/checkpoints.d.ts +1 -1
  27. package/dist/utils/checkpoints.js +1 -1
  28. package/dist/utils/diffPreview.d.ts +31 -0
  29. package/dist/utils/diffPreview.js +102 -0
  30. package/dist/utils/git.d.ts +28 -0
  31. package/dist/utils/git.js +111 -1
  32. package/dist/utils/mentions.d.ts +195 -0
  33. package/dist/utils/mentions.js +672 -0
  34. package/dist/utils/resourceImpact.d.ts +25 -0
  35. package/dist/utils/resourceImpact.js +54 -0
  36. package/dist/utils/tokenTracker.js +52 -37
  37. package/dist/utils/webFetch.d.ts +101 -0
  38. package/dist/utils/webFetch.js +375 -0
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +2 -1
@@ -404,3 +404,105 @@ export function getDiffStats(diffs) {
404
404
  totalFiles: diffs.length,
405
405
  };
406
406
  }
407
+ // ─── Selective apply (per-hunk accept/reject) ───────────────────────────────
408
+ /**
409
+ * Apply a subset of a file diff's hunks to the original content.
410
+ *
411
+ * Hunk indices in `acceptedHunks` refer to positions in `diff.hunks`
412
+ * (0-based). Hunks not in the set are skipped — their original lines
413
+ * stay, their additions are dropped.
414
+ *
415
+ * Returns the resulting file content. The caller writes it to disk.
416
+ *
417
+ * For `type === 'create'`, the whole file is either accepted (any hunk
418
+ * accepted) or rejected (empty set) — there's no original to merge
419
+ * against. For `type === 'delete'`, accepting any hunk deletes the file.
420
+ */
421
+ export function applyHunks(diff, acceptedHunks) {
422
+ // Create: accept-all-or-nothing — there's no original content to
423
+ // selectively merge into.
424
+ if (diff.type === 'create') {
425
+ return acceptedHunks.size > 0 ? (diff.newContent ?? '') : (diff.oldContent ?? '');
426
+ }
427
+ const oldLines = (diff.oldContent ?? '').split('\n');
428
+ // No accepted hunks → original content unchanged.
429
+ if (acceptedHunks.size === 0) {
430
+ return oldLines.join('\n');
431
+ }
432
+ const acceptedHunkList = diff.hunks
433
+ .map((h, i) => ({ hunk: h, index: i }))
434
+ .filter(({ index }) => acceptedHunks.has(index));
435
+ // Per-original-line union model.
436
+ //
437
+ // The obvious implementation — walk the hunks in order, copying original
438
+ // lines between them and replaying each hunk's line list — is wrong for
439
+ // hunks that sit close together, because unified-diff context OVERLAPS:
440
+ // hunk N's trailing context is hunk N+1's leading context, so the shared
441
+ // lines get emitted twice. Worse, with a small gap a later hunk's `remove`
442
+ // can fall *inside* an earlier hunk's already-emitted context, so the
443
+ // deletion is silently lost. Both corrupt the user's file on `/apply`.
444
+ //
445
+ // Instead reduce every accepted hunk to two facts per original line — is it
446
+ // removed, and what is inserted after it — then rebuild the file once.
447
+ // Overlap becomes a set union rather than double emission, and the result
448
+ // is independent of hunk order.
449
+ const removed = new Set(); // old line numbers deleted
450
+ const insertAfter = new Map(); // old line number → inserted lines (0 = file head)
451
+ for (const { hunk } of acceptedHunkList) {
452
+ // Adds before any context in this hunk belong right before its start,
453
+ // not at the top of the file.
454
+ let anchor = Math.max(0, hunk.oldStart - 1);
455
+ for (const line of hunk.lines) {
456
+ if (line.type === 'add') {
457
+ const at = insertAfter.get(anchor);
458
+ if (at)
459
+ at.push(line.content);
460
+ else
461
+ insertAfter.set(anchor, [line.content]);
462
+ continue;
463
+ }
464
+ if (line.oldLineNum === undefined)
465
+ continue;
466
+ anchor = line.oldLineNum;
467
+ if (line.type === 'remove')
468
+ removed.add(line.oldLineNum);
469
+ }
470
+ }
471
+ const result = [];
472
+ for (const head of insertAfter.get(0) ?? [])
473
+ result.push(head);
474
+ for (let n = 1; n <= oldLines.length; n++) {
475
+ if (!removed.has(n))
476
+ result.push(oldLines[n - 1]);
477
+ const added = insertAfter.get(n);
478
+ if (added)
479
+ for (const line of added)
480
+ result.push(line);
481
+ }
482
+ return result.join('\n');
483
+ }
484
+ /**
485
+ * Apply accepted hunks across multiple file diffs and return the
486
+ * resulting content for each. The caller writes the files to disk.
487
+ *
488
+ * `accepted` maps file path → set of accepted hunk indices. Files not
489
+ * in the map are skipped entirely.
490
+ */
491
+ export function applyHunksToFiles(diffs, accepted) {
492
+ const results = [];
493
+ for (const diff of diffs) {
494
+ const acceptedSet = accepted.get(diff.path);
495
+ if (!acceptedSet)
496
+ continue;
497
+ const content = applyHunks(diff, acceptedSet);
498
+ results.push({ path: diff.path, content, type: diff.type });
499
+ }
500
+ return results;
501
+ }
502
+ /**
503
+ * Count how many hunks in a diff contain actual changes (not just
504
+ * context). Used to label hunks in the UI ("hunk 2/5").
505
+ */
506
+ export function countChangeHunks(diff) {
507
+ return diff.hunks.filter((h) => h.lines.some((l) => l.type === 'add' || l.type === 'remove')).length;
508
+ }
@@ -83,3 +83,31 @@ export declare function createBranchAndCommit(prompt: string, actions: ActionLog
83
83
  hash?: string;
84
84
  error?: string;
85
85
  };
86
+ /**
87
+ * Result of resolving a `@git <ref>` mention.
88
+ */
89
+ export interface GitContentResult {
90
+ success: boolean;
91
+ /** Raw output from git (diff text, file content, or commit metadata). */
92
+ content: string;
93
+ /** A short label for the [Attached files]-style block header. */
94
+ label: string;
95
+ error?: string;
96
+ }
97
+ /** Max bytes we'll inline from a single `@git` mention. */
98
+ export declare const MAX_GIT_BYTES: number;
99
+ export declare function isSafeGitRef(token: string): boolean;
100
+ /**
101
+ * Resolve a `@git <ref>` mention to inline content. The `ref` can be:
102
+ *
103
+ * - `diff` — unstaged changes (`git diff`)
104
+ * - `diff --staged` — staged changes (`git diff --cached`)
105
+ * - `diff a..b` — diff between two refs (`git diff a..b`)
106
+ * - `HEAD` — the latest commit's full diff vs its parent
107
+ * - `<sha>` — a specific commit's patch (`git show <sha>`)
108
+ * - `<ref>:<path>` — a file at a ref (`git show main:src/x.ts`)
109
+ * - `<ref>` — any other git ref → `git show`
110
+ *
111
+ * Sync (spawn-based) so it slots into the mention-expansion pipeline.
112
+ */
113
+ export declare function getGitContent(ref: string, cwd?: string): GitContentResult;
package/dist/utils/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync, spawnSync } from 'child_process';
1
+ import { execSync, execFileSync, spawnSync } from 'child_process';
2
2
  import { existsSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  /**
@@ -397,3 +397,113 @@ export function createBranchAndCommit(prompt, actions, cwd = process.cwd()) {
397
397
  hash: commitResult.hash,
398
398
  };
399
399
  }
400
+ /** Max bytes we'll inline from a single `@git` mention. */
401
+ export const MAX_GIT_BYTES = 64 * 1024;
402
+ /**
403
+ * Characters a git ref/pathspec may contain for `@git` mentions. Deliberately
404
+ * conservative: word chars plus the punctuation real refs use
405
+ * (`main..feature`, `HEAD~3`, `v1.2.0^{}`, `main:src/x.ts`, `origin/main`).
406
+ * A leading `-` is rejected separately so a ref can never be read as a flag.
407
+ */
408
+ const SAFE_GIT_REF = /^[A-Za-z0-9._/:~^@{}-]+$/;
409
+ export function isSafeGitRef(token) {
410
+ return token.length > 0 && !token.startsWith('-') && SAFE_GIT_REF.test(token);
411
+ }
412
+ /**
413
+ * The only flags `@git diff …` may pass through. An allowlist rather than a
414
+ * deny-list because several git flags write files or run commands
415
+ * (`--output=`, `--ext-diff`), which would turn a mention into a side effect.
416
+ */
417
+ const GIT_DIFF_FLAG_ALLOWLIST = new Set([
418
+ '--staged', '--cached', '--stat', '--numstat', '--shortstat',
419
+ '--name-only', '--name-status', '--patch', '-p', '--no-color',
420
+ ]);
421
+ /**
422
+ * Resolve a `@git <ref>` mention to inline content. The `ref` can be:
423
+ *
424
+ * - `diff` — unstaged changes (`git diff`)
425
+ * - `diff --staged` — staged changes (`git diff --cached`)
426
+ * - `diff a..b` — diff between two refs (`git diff a..b`)
427
+ * - `HEAD` — the latest commit's full diff vs its parent
428
+ * - `<sha>` — a specific commit's patch (`git show <sha>`)
429
+ * - `<ref>:<path>` — a file at a ref (`git show main:src/x.ts`)
430
+ * - `<ref>` — any other git ref → `git show`
431
+ *
432
+ * Sync (spawn-based) so it slots into the mention-expansion pipeline.
433
+ */
434
+ export function getGitContent(ref, cwd = process.cwd()) {
435
+ if (!isGitRepository(cwd)) {
436
+ return { success: false, content: '', label: ref, error: 'not a git repository' };
437
+ }
438
+ const trimmed = ref.trim();
439
+ if (!trimmed) {
440
+ return { success: false, content: '', label: ref, error: 'empty git ref' };
441
+ }
442
+ // Pick the git subcommand based on the ref shape. NOTE: the ref comes from
443
+ // free-form prompt text (`@git <ref>`), which may be pasted from an issue,
444
+ // a log, or model output — so it is UNTRUSTED. We therefore (a) build an
445
+ // argv array and spawn git directly with `execFileSync` (no `/bin/sh`, so
446
+ // `;`, `|`, backticks and friends are inert), and (b) validate every token,
447
+ // because argv alone doesn't stop *argument* injection — a ref that starts
448
+ // with `-` would still be read by git as a flag (e.g. `--output=…` writes a
449
+ // file). Anything unrecognized is rejected rather than guessed at.
450
+ let args;
451
+ let label;
452
+ if (trimmed === 'diff') {
453
+ args = ['diff'];
454
+ label = 'diff (unstaged)';
455
+ }
456
+ else if (trimmed === 'diff --staged' || trimmed === 'diff --cached' || trimmed === 'staged') {
457
+ args = ['diff', '--cached'];
458
+ label = 'diff (staged)';
459
+ }
460
+ else if (trimmed.startsWith('diff ')) {
461
+ // e.g. `diff main..feature` or `diff HEAD~3` or `diff --stat`
462
+ const rest = trimmed.slice('diff '.length).trim();
463
+ const tokens = rest.split(/\s+/).filter(Boolean);
464
+ const bad = tokens.find(t => !(isSafeGitRef(t) || GIT_DIFF_FLAG_ALLOWLIST.has(t)));
465
+ if (bad) {
466
+ return { success: false, content: '', label: ref, error: `unsupported git argument: ${bad}` };
467
+ }
468
+ args = ['diff', ...tokens];
469
+ label = `diff (${rest})`;
470
+ }
471
+ else if (trimmed === 'HEAD' || trimmed === '@') {
472
+ // Show the latest commit's patch.
473
+ args = ['show', 'HEAD'];
474
+ label = 'HEAD';
475
+ }
476
+ else {
477
+ // Any other ref → `git show`. Works for SHAs, tags, branches, and
478
+ // `<ref>:<path>` (file-at-ref) forms.
479
+ if (!isSafeGitRef(trimmed)) {
480
+ return { success: false, content: '', label: ref, error: `unsupported git ref: ${trimmed}` };
481
+ }
482
+ args = ['show', trimmed];
483
+ label = trimmed;
484
+ }
485
+ try {
486
+ const out = execFileSync('git', args, {
487
+ cwd,
488
+ encoding: 'utf-8',
489
+ maxBuffer: 4 * 1024 * 1024,
490
+ });
491
+ const content = (out ?? '').trimEnd();
492
+ if (!content) {
493
+ return { success: false, content: '', label, error: 'empty result (no changes / unknown ref)' };
494
+ }
495
+ // Truncate to the cap so a massive diff can't blow the context.
496
+ const capped = content.length > MAX_GIT_BYTES
497
+ ? content.slice(0, MAX_GIT_BYTES) + `\n\n… (truncated at ${MAX_GIT_BYTES / 1024}KB)`
498
+ : content;
499
+ return { success: true, content: capped, label };
500
+ }
501
+ catch (error) {
502
+ const msg = error instanceof Error ? error.message : String(error);
503
+ // git show exits non-zero on unknown refs; surface a friendly reason.
504
+ const reason = /unknown revision|bad revision|ambiguous argument/i.test(msg)
505
+ ? `unknown git ref: ${trimmed}`
506
+ : msg;
507
+ return { success: false, content: '', label, error: reason };
508
+ }
509
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `@-mention` context expansion for the CLI chat input.
3
+ *
4
+ * When the user types `@path/to/file.ts` inline in their prompt, we
5
+ * detect those mentions, read the file contents, and inject them as an
6
+ * "[Attached files]" block prepended to the prompt — same format as the
7
+ * explicit `/add` command, so the agent sees a single, consistent shape.
8
+ *
9
+ * Supported mention forms (case-sensitive `@`):
10
+ * @src/index.ts → relative-to-project-root file
11
+ * @./local.ts → relative-to-cwd file
12
+ * @/abs/path.ts → absolute path
13
+ * @"path with space.ts" → quoted (spaces/special chars allowed)
14
+ * @'path with space.ts' → single-quoted variant
15
+ *
16
+ * A `@` immediately followed by whitespace, another `@`, or a non-path
17
+ * character (e.g. an email like `user@host`, or a GitHub `@handle`) is
18
+ * left untouched.
19
+ *
20
+ * Mentions are resolved against the project root (or cwd when no
21
+ * project is open). Files larger than `MAX_MENTION_BYTES` are skipped
22
+ * with a warning rather than silently truncated — the user should
23
+ * explicitly `/add` very large files if they really want them.
24
+ */
25
+ /** Max file size we'll auto-inline from a mention (100 KB). */
26
+ export declare const MAX_MENTION_BYTES: number;
27
+ /** Result of expanding `@-mentions` in a prompt. */
28
+ export interface MentionExpansionResult {
29
+ /** The prompt with file contents prepended (or the original if no mentions). */
30
+ enrichedPrompt: string;
31
+ /**
32
+ * The prompt with each mention's `@` sigil removed but WITHOUT the attached
33
+ * block — i.e. `enrichedPrompt` minus its header. Callers that merge several
34
+ * expanders into one block need this: parsing the block back out of
35
+ * `enrichedPrompt` with a regex silently left the file bodies behind and
36
+ * attached every mentioned file twice.
37
+ */
38
+ strippedPrompt: string;
39
+ /** Successfully loaded files: `[fullPath, relativePath, content][]`. */
40
+ loaded: Array<{
41
+ fullPath: string;
42
+ relativePath: string;
43
+ content: string;
44
+ }>;
45
+ /** Mentions that couldn't be resolved, with a human-readable reason. */
46
+ failures: Array<{
47
+ mention: string;
48
+ reason: string;
49
+ }>;
50
+ }
51
+ /**
52
+ * The raw text of a mention match (without the leading `@`).
53
+ * Used internally by the tokenizer.
54
+ */
55
+ interface MentionToken {
56
+ /** Full match including `@`, for replacement. */
57
+ raw: string;
58
+ /** The path portion (without quotes if it was quoted). */
59
+ path: string;
60
+ /** Start index in the source string. */
61
+ start: number;
62
+ /** End index (exclusive). */
63
+ end: number;
64
+ }
65
+ /**
66
+ * The single source of truth for "may a mention start after this character?".
67
+ * `MENTION_RE`'s lookbehind above and the editor's `detectMentionQuery` picker
68
+ * MUST agree — when they diverged, the picker happily completed mentions
69
+ * (e.g. after `]`) that the expander then ignored, so the file silently never
70
+ * got attached. Import this rather than re-spelling the class.
71
+ */
72
+ export declare const MENTION_BOUNDARY: RegExp;
73
+ /**
74
+ * Extract all `@-mention` tokens from `text`. Returns them in document
75
+ * order. Pure (no FS) — testable without touching the disk.
76
+ */
77
+ export declare function extractMentions(text: string): MentionToken[];
78
+ export interface MentionExpansionOptions {
79
+ /**
80
+ * The root directory mentions are resolved against when they're
81
+ * relative (not starting with `/` or `.`). Usually the project root
82
+ * or `process.cwd()`.
83
+ */
84
+ root: string;
85
+ }
86
+ /**
87
+ * Expand all `@-mentions` in `prompt`: load each referenced file,
88
+ * prepend the contents as an `[Attached files]` block, and strip the
89
+ * `@path` tokens from the visible prompt (replacing them with a bare
90
+ * path so the agent still sees what was referenced).
91
+ *
92
+ * Failures (missing file, too large, not a file) are collected and
93
+ * returned rather than thrown — the caller decides how to surface them.
94
+ */
95
+ export declare function expandMentions(prompt: string, opts: MentionExpansionOptions): MentionExpansionResult;
96
+ /**
97
+ * Max total bytes of file content we'll inline from a single `@folder`
98
+ * mention (200 KB). Prevents a huge directory from blowing the context
99
+ * window — the user can raise this via explicit `/add` if they really
100
+ * want everything.
101
+ */
102
+ export declare const MAX_FOLDER_BYTES: number;
103
+ /** One `@folder <path>` mention match. */
104
+ interface FolderToken {
105
+ /** Full match including `@folder `, for display in failures. */
106
+ raw: string;
107
+ /** The path portion (after `@folder `). */
108
+ path: string;
109
+ /** Start index of the match (pointing at the `@`). */
110
+ start: number;
111
+ /** End index (exclusive). */
112
+ end: number;
113
+ }
114
+ /**
115
+ * Extract all `@folder`/`@dir` mentions from `text`. Pure (no FS).
116
+ * Returns them in document order.
117
+ */
118
+ export declare function extractFolderMentions(text: string): FolderToken[];
119
+ /**
120
+ * Expand all `@folder`/`@dir` mentions in `prompt`: recursively read
121
+ * every source file under each directory, and return them in the same
122
+ * shape as `expandMentions` (so the caller can merge the results).
123
+ *
124
+ * Skips the same ignored directories (`node_modules`, `.git`, …) and
125
+ * binary/generated extensions as the autocomplete scanner. Caps total
126
+ * content per mention at `MAX_FOLDER_BYTES` so a single huge tree
127
+ * can't blow the context window.
128
+ *
129
+ * Sync (filesystem reads only) — call before or after `expandMentions`.
130
+ */
131
+ export declare function expandFolderMentions(prompt: string, opts: MentionExpansionOptions): MentionExpansionResult;
132
+ /**
133
+ * Expand both `@folder` and `@file` mentions in one pass, merging the
134
+ * loaded files into a single `[Attached files]` block (instead of two
135
+ * separate blocks when called back-to-back).
136
+ *
137
+ * `@web` mentions are async and handled separately in `webFetch.ts`.
138
+ */
139
+ export declare function expandFileAndFolderMentions(prompt: string, opts: MentionExpansionOptions): MentionExpansionResult;
140
+ /** Format the `[Attached files]` block prepended to the enriched prompt. */
141
+ export declare function formatFileBlock(files: Array<{
142
+ relativePath: string;
143
+ content: string;
144
+ }>): string;
145
+ export interface MentionSuggestion {
146
+ /** Display label for the picker (e.g. `src/index.ts`). */
147
+ label: string;
148
+ /** The path to insert after `@` when picked. */
149
+ insertPath: string;
150
+ /** A short hint — the file's directory or type. */
151
+ detail: string;
152
+ }
153
+ export interface SuggestOptions {
154
+ /** Root directory to scan. */
155
+ root: string;
156
+ /** Filter prefix typed so far (e.g. `src/ind` from `@src/ind`). */
157
+ query?: string;
158
+ /** Max suggestions to return. */
159
+ limit?: number;
160
+ /** Extra directories to skip (merged with the defaults). */
161
+ extraIgnoreDirs?: string[];
162
+ }
163
+ /**
164
+ * Build (or reuse from cache) the flat list of suggestible files under
165
+ * `root`, then filter by `query`. The scan walks up to `maxScan` files,
166
+ * skipping ignored directories and binary/generated extensions.
167
+ */
168
+ export declare function suggestMentions(opts: SuggestOptions): MentionSuggestion[];
169
+ /** Clear the suggestion cache. Call between tests so fixtures don't leak. */
170
+ export declare function clearSuggestionCache(): void;
171
+ /** True if `fullPath`'s basename looks like it holds secrets. */
172
+ export declare function isSensitiveFile(fullPath: string): boolean;
173
+ /** One `@git <ref>` mention match. */
174
+ interface GitToken {
175
+ raw: string;
176
+ ref: string;
177
+ start: number;
178
+ end: number;
179
+ }
180
+ /**
181
+ * Extract all `@git <ref>` mentions from `text`. Pure (no FS / no git).
182
+ */
183
+ export declare function extractGitMentions(text: string): GitToken[];
184
+ /**
185
+ * Expand all `@git <ref>` mentions in `prompt`: resolve each ref to
186
+ * git content (diff, file-at-ref, or commit patch) and inject it as
187
+ * a `[Git ref]` block. Sync (git is run via `execSync`).
188
+ *
189
+ * The block is appended *after* any `[Attached files]` block from
190
+ * `@folder`/`@file` expansion, so the final prompt reads:
191
+ *
192
+ * [Attached files] … [Git ref] … <user text>
193
+ */
194
+ export declare function expandGitMentions(prompt: string, opts: MentionExpansionOptions): Promise<MentionExpansionResult>;
195
+ export {};