codeep 2.18.1 → 2.20.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 (51) hide show
  1. package/README.md +53 -16
  2. package/dist/acp/commands.js +11 -55
  3. package/dist/acp/protocol.d.ts +34 -0
  4. package/dist/acp/server.d.ts +6 -1
  5. package/dist/acp/server.js +97 -2
  6. package/dist/api/index.js +9 -0
  7. package/dist/commands/core/index.d.ts +19 -0
  8. package/dist/commands/core/index.js +28 -0
  9. package/dist/commands/core/keysync.d.ts +2 -0
  10. package/dist/commands/core/keysync.js +34 -0
  11. package/dist/commands/core/telemetry.d.ts +2 -0
  12. package/dist/commands/core/telemetry.js +34 -0
  13. package/dist/config/index.js +2 -2
  14. package/dist/config/providers.js +7 -4
  15. package/dist/renderer/App.d.ts +9 -48
  16. package/dist/renderer/App.js +113 -338
  17. package/dist/renderer/Screen.d.ts +13 -0
  18. package/dist/renderer/Screen.js +22 -0
  19. package/dist/renderer/commands/registry.js +3 -3
  20. package/dist/renderer/commands.js +19 -51
  21. package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
  22. package/dist/renderer/components/CommandAutocomplete.js +103 -0
  23. package/dist/renderer/components/HunkPicker.d.ts +48 -0
  24. package/dist/renderer/components/HunkPicker.js +140 -0
  25. package/dist/renderer/components/MentionPicker.d.ts +60 -0
  26. package/dist/renderer/components/MentionPicker.js +111 -0
  27. package/dist/renderer/components/PasteDialog.d.ts +43 -0
  28. package/dist/renderer/components/PasteDialog.js +70 -0
  29. package/dist/renderer/layout.js +1 -0
  30. package/dist/renderer/main.js +15 -39
  31. package/dist/utils/agent.js +121 -26
  32. package/dist/utils/agentChat.d.ts +11 -4
  33. package/dist/utils/agentChat.js +53 -25
  34. package/dist/utils/codeepCloud.d.ts +3 -0
  35. package/dist/utils/codeepCloud.js +62 -7
  36. package/dist/utils/personalities.d.ts +63 -5
  37. package/dist/utils/personalities.js +583 -31
  38. package/dist/utils/shell.d.ts +11 -1
  39. package/dist/utils/shell.js +169 -82
  40. package/dist/utils/ssrfGuard.d.ts +18 -0
  41. package/dist/utils/ssrfGuard.js +83 -0
  42. package/dist/utils/taskPlanner.d.ts +7 -1
  43. package/dist/utils/taskPlanner.js +16 -7
  44. package/dist/utils/tokenTracker.js +5 -3
  45. package/dist/utils/toolExecution.d.ts +1 -0
  46. package/dist/utils/toolExecution.js +48 -88
  47. package/dist/utils/tools.d.ts +3 -3
  48. package/dist/utils/tools.js +18 -13
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +1 -1
@@ -73,6 +73,19 @@ export declare class Screen {
73
73
  /**
74
74
  * Full render (no diff, redraw everything)
75
75
  */
76
+ /**
77
+ * Force the next `render()` to repaint every cell.
78
+ *
79
+ * The differential renderer skips a cell whose buffer value already matches
80
+ * the shadow copy — correct only while nothing else writes to the terminal.
81
+ * The inline overlays (session picker, confirm prompt) draw below the managed
82
+ * area and scroll it, after which the shadow no longer describes what is on
83
+ * screen and stale glyphs survive: most visibly a leftover character in
84
+ * column 0 of the header, which the header never overwrites because it starts
85
+ * at x = 1. Resizing already recovered by rebuilding both buffers; this is the
86
+ * same recovery without making the user resize the window.
87
+ */
88
+ invalidate(): void;
76
89
  fullRender(): void;
77
90
  /**
78
91
  * Initialize screen (hide cursor, clear)
@@ -286,6 +286,28 @@ export class Screen {
286
286
  /**
287
287
  * Full render (no diff, redraw everything)
288
288
  */
289
+ /**
290
+ * Force the next `render()` to repaint every cell.
291
+ *
292
+ * The differential renderer skips a cell whose buffer value already matches
293
+ * the shadow copy — correct only while nothing else writes to the terminal.
294
+ * The inline overlays (session picker, confirm prompt) draw below the managed
295
+ * area and scroll it, after which the shadow no longer describes what is on
296
+ * screen and stale glyphs survive: most visibly a leftover character in
297
+ * column 0 of the header, which the header never overwrites because it starts
298
+ * at x = 1. Resizing already recovered by rebuilding both buffers; this is the
299
+ * same recovery without making the user resize the window.
300
+ */
301
+ invalidate() {
302
+ // Fill the shadow with a sentinel no real cell can hold, NOT with spaces.
303
+ // createEmptyBuffer() fills with ' ', which still compares EQUAL to a blank
304
+ // buffer cell — so every blank stayed skipped and column 0 of the header
305
+ // (the wordmark starts at x = 1) was never emitted at all. Whatever the
306
+ // terminal happened to show there survived indefinitely.
307
+ const impossible = '\u0000';
308
+ this.rendered = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => ({ char: impossible, style: impossible })));
309
+ process.stdout.write('\x1b[2J');
310
+ }
289
311
  fullRender() {
290
312
  let output = cursor.hide + cursor.home;
291
313
  let lastStyle = '';
@@ -197,7 +197,7 @@ export const COMMANDS = [
197
197
  category: 'settings',
198
198
  usage: ['save <name>', 'list', 'apply <name>', 'delete <name>'],
199
199
  },
200
- { name: 'personality', description: 'List or switch agent tone (concise / verbose / security / senior-reviewer / …)', category: 'settings', usage: ['<name>', 'off'] },
200
+ { name: 'personality', description: 'List or switch a personality or structured custom bot', category: 'settings', usage: ['<name>', 'off'] },
201
201
  {
202
202
  name: 'me',
203
203
  description: 'Your user profile (reply language, style, stack) — adapts the agent to you',
@@ -441,8 +441,8 @@ export const HELP_LAYOUT = [
441
441
  { key: '/profile save <name>', description: 'Save current provider+model as profile' },
442
442
  { key: '/profile list', description: 'List saved profiles' },
443
443
  { key: '/openrouter', description: 'OpenRouter routing prefs (prefer/ignore providers, fallbacks, privacy)' },
444
- { key: '/personality', description: 'List or switch agent tone (concise / verbose / security / senior-reviewer / …)' },
445
- { key: '/personality <name>', description: 'Activate a personality. /personality off to clear.' },
444
+ { key: '/personality', description: 'List personalities and custom bots with model, tools, and scope' },
445
+ { key: '/personality <name>', description: 'Activate a personality or custom bot. /personality off to clear.' },
446
446
  { key: '/me', description: 'Your user profile (reply language, style, stack) — adapts the agent to you' },
447
447
  { key: '/me init [project]', description: 'Scaffold a profile template (global, or for this project). /me off to disable' },
448
448
  { key: '/me learn [on|off]', description: 'Learn durable prefs from this session now; on/off toggles auto-learn. /me forget clears it' },
@@ -5,7 +5,7 @@
5
5
  * decoupled from global state. Import-heavy commands use dynamic imports
6
6
  * to keep startup time low.
7
7
  */
8
- import { config, getCurrentProvider, getModelsForCurrentProvider, PROTOCOLS, LANGUAGES, setProvider, setApiKey, clearApiKey, getApiKey, isTelemetryEnabled, telemetryForcedOffByEnv, isKeySyncEnabled, keySyncForcedOffByEnv, saveSession, startNewSession, loadSession, listSessionsWithInfo, deleteSession, renameSession, setProjectPermission, saveProfile, loadProfile, applyProfile, listProfiles, deleteProfile, initializeAsProject, isManuallyInitializedProject, } from '../config/index.js';
8
+ import { config, getCurrentProvider, getModelsForCurrentProvider, PROTOCOLS, LANGUAGES, setProvider, setApiKey, clearApiKey, getApiKey, saveSession, startNewSession, loadSession, listSessionsWithInfo, deleteSession, renameSession, setProjectPermission, saveProfile, loadProfile, applyProfile, listProfiles, deleteProfile, initializeAsProject, isManuallyInitializedProject, } from '../config/index.js';
9
9
  import { getProjectContext } from '../utils/project.js';
10
10
  import { getCurrentVersion } from '../utils/update.js';
11
11
  import { getProviderList, getProvider, modelSupportsReasoningEffort, reasoningParamsFor, availableReasoningTiers, resolveReasoningTier, REASONING_TIERS } from '../config/providers.js';
@@ -15,6 +15,8 @@ import { loadProjectIntelligence, saveProjectIntelligence } from '../utils/proje
15
15
  import { ollamaModelHint } from './ollamaHint.js';
16
16
  import { buildSearchSnippets, parseKeepRecent, joinSessionName, parseTaskAddArgs, formatTaskList, formatProfileList, formatMemoryList, formatStatsReport, extractCodeBlocks, resolveBlockIndex, extractFileChanges, formatApplyDiffLine, parsePromptArgs, formatMcpReloadReport, formatMcpResourcesList, formatMcpResourceRead, formatMcpPromptsList, formatMcpPromptResult, formatMcpServerList, parseInsightsDays, formatCloudSessionLabel, formatMeSyncReport, formatMeLearnResult, formatMeInitResult, formatSkillsShow, formatSkillsBrowseEmpty, formatSkillsPublishResult } from './commands/helpers.js';
17
17
  import { resolveCommand } from './commands/registry.js';
18
+ import { telemetryCommand } from '../commands/core/telemetry.js';
19
+ import { keysyncCommand } from '../commands/core/keysync.js';
18
20
  // ─── Helpers ──────────────────────────────────────────────────────────────────
19
21
  /**
20
22
  * Returns a hint for an Ollama model name based on parameter count.
@@ -262,61 +264,27 @@ export async function handleCommand(command, args, ctx) {
262
264
  break;
263
265
  }
264
266
  case 'telemetry': {
265
- const sub = args[0]?.toLowerCase();
266
- const envOff = telemetryForcedOffByEnv();
267
- if (sub === 'on' || sub === 'off') {
268
- if (envOff) {
269
- ctx.app.notify('Telemetry is forced OFF by CODEEP_NO_TELEMETRY / DO_NOT_TRACK — unset that env var to change it.');
270
- break;
271
- }
272
- config.set('telemetry', sub === 'on');
273
- ctx.app.notify(sub === 'on'
274
- ? 'Telemetry on — usage stats, transcripts, progress & notes sync to codeep.dev.'
275
- : 'Telemetry off — no automatic cloud uploads.');
276
- break;
267
+ // Core semantics in commands/core/telemetry.ts — this surface renders
268
+ // status as a banner system message, everything else as a notification.
269
+ const result = telemetryCommand(args);
270
+ if (result.kind === 'info') {
271
+ ctx.app.addMessage({ role: 'system', content: `## Telemetry\n\n${result.message}` });
277
272
  }
278
- if (sub && sub !== 'status') {
279
- ctx.app.notify('Usage: /telemetry · /telemetry on · /telemetry off');
280
- break;
273
+ else {
274
+ ctx.app.notify(result.message);
281
275
  }
282
- const flag = config.get('telemetry') !== false;
283
- const tLines = ['## Telemetry', ''];
284
- tLines.push(`**State** ${isTelemetryEnabled() ? 'on' : 'off'}`);
285
- tLines.push(`**Flag** telemetry = ${flag}`);
286
- if (envOff)
287
- tLines.push('**Env** forced off by CODEEP_NO_TELEMETRY / DO_NOT_TRACK (overrides the flag)');
288
- tLines.push('');
289
- tLines.push('Toggle with `/telemetry on` or `/telemetry off`. Controls automatic uploads of usage stats, session transcripts, progress, and memory notes.');
290
- ctx.app.addMessage({ role: 'system', content: tLines.join('\n') });
291
276
  break;
292
277
  }
293
278
  case 'keysync': {
294
- const sub = args[0]?.toLowerCase();
295
- const envOff = keySyncForcedOffByEnv();
296
- if (sub === 'on' || sub === 'off') {
297
- if (envOff) {
298
- ctx.app.notify('Cloud key sync is forced OFF by CODEEP_NO_KEY_SYNC — unset that env var to change it.');
299
- break;
300
- }
301
- config.set('syncKeysToCloud', sub === 'on');
302
- ctx.app.notify(sub === 'on'
303
- ? 'Cloud key sync on — `codeep account push/sync` will now upload/download API keys. Note: synced keys are stored server-readable on codeep.dev.'
304
- : 'Cloud key sync off — API keys stay in your OS keychain only. (Run `codeep account purge-keys` to also wipe any keys already on the server.)');
305
- break;
279
+ // Core semantics in commands/core/keysync.ts — this surface renders
280
+ // status as a banner system message, everything else as a notification.
281
+ const result = keysyncCommand(args);
282
+ if (result.kind === 'info') {
283
+ ctx.app.addMessage({ role: 'system', content: `## Cloud key sync\n\n${result.message}` });
306
284
  }
307
- if (sub && sub !== 'status') {
308
- ctx.app.notify('Usage: /keysync · /keysync on · /keysync off');
309
- break;
285
+ else {
286
+ ctx.app.notify(result.message);
310
287
  }
311
- const flag = config.get('syncKeysToCloud') === true;
312
- const kLines = ['## Cloud key sync', ''];
313
- kLines.push(`**State** ${isKeySyncEnabled() ? 'on' : 'off'}`);
314
- kLines.push(`**Flag** syncKeysToCloud = ${flag}`);
315
- if (envOff)
316
- kLines.push('**Env** forced off by CODEEP_NO_KEY_SYNC (overrides the flag)');
317
- kLines.push('');
318
- kLines.push('OFF by default. API keys live only in your OS keychain unless you turn this on. When on, `codeep account push`/`sync` upload/download keys, which are stored **server-readable** on codeep.dev. Toggle with `/keysync on` or `/keysync off`; wipe server copies with `codeep account purge-keys`.');
319
- ctx.app.addMessage({ role: 'system', content: kLines.join('\n') });
320
288
  break;
321
289
  }
322
290
  case 'effort':
@@ -454,7 +422,7 @@ export async function handleCommand(command, args, ctx) {
454
422
  break;
455
423
  }
456
424
  case 'personality': {
457
- const { formatPersonalityList, findPersonality } = await import('../utils/personalities.js');
425
+ const { formatPersonalityList, findPersonality, formatPersonalityActivation } = await import('../utils/personalities.js');
458
426
  const sub = args[0]?.toLowerCase();
459
427
  if (!sub) {
460
428
  ctx.app.addMessage({ role: 'system', content: formatPersonalityList(ctx.projectPath) });
@@ -473,7 +441,7 @@ export async function handleCommand(command, args, ctx) {
473
441
  config.set('activePersonality', personality.name);
474
442
  ctx.app.addMessage({
475
443
  role: 'system',
476
- content: `Active personality: **${personality.displayName}** (\`${personality.name}\`, ${personality.scope})\n\n_${personality.description}_\n\nClear with \`/personality off\`.`,
444
+ content: formatPersonalityActivation(personality),
477
445
  });
478
446
  break;
479
447
  }
@@ -0,0 +1,46 @@
1
+ import type { Screen } from '../Screen';
2
+ export interface CommandAutocompleteState {
3
+ open: boolean;
4
+ index: number;
5
+ items: string[];
6
+ }
7
+ export declare function createCommandAutocompleteState(): CommandAutocompleteState;
8
+ /** Set the picker to a fresh filter result (from filterCommands). */
9
+ export declare function setCommandAutocomplete(state: CommandAutocompleteState, items: string[], index: number): CommandAutocompleteState;
10
+ /** Close the picker and clear its items. */
11
+ export declare function closeCommandAutocomplete(state: CommandAutocompleteState): CommandAutocompleteState;
12
+ /** Minimal key shape App's KeyEvent satisfies. */
13
+ export interface CommandAutocompleteKeyEvent {
14
+ key: string;
15
+ }
16
+ /** What App should do after a key press. */
17
+ export type CommandAutocompleteAction = {
18
+ type: 'none';
19
+ } | {
20
+ type: 'close';
21
+ } | {
22
+ type: 'navigate';
23
+ delta: -1 | 1;
24
+ } | {
25
+ type: 'select';
26
+ command: string;
27
+ };
28
+ /**
29
+ * Handle one key event while the `/command` picker is open. Returns the
30
+ * next state plus an action; `select` carries the chosen command name so
31
+ * App can rewrite the editor buffer (`/<command> `).
32
+ */
33
+ export declare function handleCommandAutocompleteKey(state: CommandAutocompleteState, event: CommandAutocompleteKeyEvent): {
34
+ state: CommandAutocompleteState;
35
+ action: CommandAutocompleteAction;
36
+ };
37
+ /**
38
+ * Compute the editor buffer after selecting a command. Pure: returns the
39
+ * next value for App to set.
40
+ */
41
+ export declare function commandToBuffer(command: string): string;
42
+ /**
43
+ * Paint the `/command` picker below the status bar. `descriptions` maps
44
+ * command name → one-line description (App passes COMMAND_DESCRIPTIONS).
45
+ */
46
+ export declare function renderCommandAutocomplete(screen: Screen, state: CommandAutocompleteState, startY: number, descriptions: Record<string, string>): void;
@@ -0,0 +1,103 @@
1
+ // components/CommandAutocomplete.ts
2
+ // `/command` autocomplete picker.
3
+ //
4
+ // Extracted from App.ts (P2 refactor) following the component convention
5
+ // (HunkPicker/PasteDialog/MentionPicker). Appears when the input starts
6
+ // with `/` and offers matching slash commands; ↑/↓ navigate, Tab or Enter
7
+ // select (App rewrites the editor buffer with `/<command> `), Esc closes.
8
+ // Descriptions come from the command registry via COMMAND_DESCRIPTIONS —
9
+ // passed into render so this module doesn't import the registry itself.
10
+ import { fg, style } from '../ansi.js';
11
+ import { PRIMARY_COLOR } from './uiConstants.js';
12
+ export function createCommandAutocompleteState() {
13
+ return { open: false, index: 0, items: [] };
14
+ }
15
+ /** Set the picker to a fresh filter result (from filterCommands). */
16
+ export function setCommandAutocomplete(state, items, index) {
17
+ return { open: items.length > 0, items, index };
18
+ }
19
+ /** Close the picker and clear its items. */
20
+ export function closeCommandAutocomplete(state) {
21
+ return { ...state, open: false, items: [] };
22
+ }
23
+ /**
24
+ * Handle one key event while the `/command` picker is open. Returns the
25
+ * next state plus an action; `select` carries the chosen command name so
26
+ * App can rewrite the editor buffer (`/<command> `).
27
+ */
28
+ export function handleCommandAutocompleteKey(state, event) {
29
+ switch (event.key) {
30
+ case 'up':
31
+ return {
32
+ state: { ...state, index: Math.max(0, state.index - 1) },
33
+ action: { type: 'navigate', delta: -1 },
34
+ };
35
+ case 'down':
36
+ return {
37
+ state: {
38
+ ...state,
39
+ index: Math.min(state.items.length - 1, state.index + 1),
40
+ },
41
+ action: { type: 'navigate', delta: 1 },
42
+ };
43
+ case 'tab':
44
+ case 'enter':
45
+ if (state.items.length > 0) {
46
+ const command = state.items[state.index];
47
+ return {
48
+ state: closeCommandAutocomplete(state),
49
+ action: { type: 'select', command },
50
+ };
51
+ }
52
+ return { state, action: { type: 'none' } };
53
+ default:
54
+ return { state, action: { type: 'none' } };
55
+ }
56
+ }
57
+ /**
58
+ * Compute the editor buffer after selecting a command. Pure: returns the
59
+ * next value for App to set.
60
+ */
61
+ export function commandToBuffer(command) {
62
+ return '/' + command + ' ';
63
+ }
64
+ /**
65
+ * Paint the `/command` picker below the status bar. `descriptions` maps
66
+ * command name → one-line description (App passes COMMAND_DESCRIPTIONS).
67
+ */
68
+ export function renderCommandAutocomplete(screen, state, startY, descriptions) {
69
+ const items = state.items;
70
+ const maxVisible = Math.min(items.length, 8);
71
+ let y = startY;
72
+ // Separator line
73
+ screen.horizontalLine(y++, '─', PRIMARY_COLOR);
74
+ // Title
75
+ screen.writeLine(y++, 'Commands', PRIMARY_COLOR + style.bold);
76
+ // Items with descriptions
77
+ const visibleStart = Math.max(0, state.index - maxVisible + 1);
78
+ const visibleItems = items.slice(visibleStart, visibleStart + maxVisible);
79
+ for (let i = 0; i < visibleItems.length; i++) {
80
+ const item = visibleItems[i];
81
+ const actualIndex = visibleStart + i;
82
+ const isSelected = actualIndex === state.index;
83
+ const desc = descriptions[item] || '';
84
+ const prefix = isSelected ? '► ' : ' ';
85
+ const cmdText = ('/' + item).padEnd(18);
86
+ if (isSelected) {
87
+ screen.write(0, y, prefix, PRIMARY_COLOR);
88
+ screen.write(prefix.length, y, cmdText, PRIMARY_COLOR + style.bold);
89
+ screen.write(prefix.length + cmdText.length, y, desc, fg.white);
90
+ }
91
+ else {
92
+ screen.write(0, y, prefix, '');
93
+ screen.write(prefix.length, y, cmdText, fg.green);
94
+ screen.write(prefix.length + cmdText.length, y, desc, fg.gray);
95
+ }
96
+ y++;
97
+ }
98
+ // Footer
99
+ const scrollInfo = items.length > maxVisible
100
+ ? ` (${visibleStart + 1}-${visibleStart + visibleItems.length}/${items.length})`
101
+ : '';
102
+ screen.writeLine(y, `↑↓ navigate • Tab/Enter select • Esc cancel${scrollInfo}`, fg.gray);
103
+ }
@@ -0,0 +1,48 @@
1
+ import type { Screen } from '../Screen';
2
+ /** One hunk in the interactive `/apply --interactive` picker. */
3
+ export interface HunkPickerItem {
4
+ /** File path this hunk belongs to. */
5
+ path: string;
6
+ /** 0-based hunk index within the file diff. */
7
+ hunkIndex: number;
8
+ /** Human-readable hunk header, e.g. `@@ -12,3 +12,5 @@`. */
9
+ header: string;
10
+ /** Pre-formatted diff lines to display. */
11
+ lines: string[];
12
+ }
13
+ export interface HunkPickerOptions {
14
+ title: string;
15
+ items: HunkPickerItem[];
16
+ onComplete: (accepted: Array<{
17
+ path: string;
18
+ hunkIndex: number;
19
+ }>) => void;
20
+ }
21
+ export interface HunkPickerState {
22
+ open: boolean;
23
+ options: HunkPickerOptions | null;
24
+ index: number;
25
+ accepted: Array<{
26
+ path: string;
27
+ hunkIndex: number;
28
+ }>;
29
+ }
30
+ export declare function createHunkPickerState(): HunkPickerState;
31
+ /** Minimal key shape App's KeyEvent satisfies. */
32
+ export interface HunkPickerKeyEvent {
33
+ key: string;
34
+ }
35
+ /**
36
+ * Handle one key event for the picker. Returns the (possibly updated) state
37
+ * and whether the picker consumed the event (always true while open — the
38
+ * picker is modal). `onComplete` from the options fires exactly once, from
39
+ * `finish()`; after that the state is reset to closed.
40
+ */
41
+ export declare function handleHunkPickerKey(state: HunkPickerState, event: HunkPickerKeyEvent): HunkPickerState;
42
+ /** Panel height the layout needs to reserve for the picker. */
43
+ export declare function hunkPickerPanelHeight(): number;
44
+ /**
45
+ * Paint the picker into the screen. Mirrors the rendering previously inlined
46
+ * in App.renderInlineHunkPicker.
47
+ */
48
+ export declare function renderHunkPicker(screen: Screen, state: HunkPickerState, startY: number, width: number): void;
@@ -0,0 +1,140 @@
1
+ // components/HunkPicker.ts
2
+ // Interactive hunk-by-hunk diff picker for `/apply --interactive`.
3
+ //
4
+ // Extracted from App.ts (P2 refactor) following the established component
5
+ // convention (Settings/Export/Search): a pure-ish state object + key handler
6
+ // + render function, so App.ts only owns a HunkPickerState field and the
7
+ // wiring. The picker walks the user through items one at a time; for each
8
+ // they accept (`y`/Enter/→) or skip (`n`/←). `a` accepts all remaining,
9
+ // `q`/Esc quits. `onComplete` fires exactly once with the accepted set.
10
+ import { fg, style } from '../ansi.js';
11
+ import { PRIMARY_COLOR } from './uiConstants.js';
12
+ export function createHunkPickerState() {
13
+ return { open: false, options: null, index: 0, accepted: [] };
14
+ }
15
+ /**
16
+ * Handle one key event for the picker. Returns the (possibly updated) state
17
+ * and whether the picker consumed the event (always true while open — the
18
+ * picker is modal). `onComplete` from the options fires exactly once, from
19
+ * `finish()`; after that the state is reset to closed.
20
+ */
21
+ export function handleHunkPickerKey(state, event) {
22
+ const opts = state.options;
23
+ if (!opts || !state.open) {
24
+ return { ...state, open: false };
25
+ }
26
+ const finish = () => {
27
+ const accepted = state.accepted;
28
+ const cb = opts.onComplete;
29
+ // Reset first so a re-entrant show() from the callback can't race the
30
+ // stale state; then fire the callback once.
31
+ const next = createHunkPickerState();
32
+ cb(accepted);
33
+ return next;
34
+ };
35
+ const advance = () => {
36
+ if (state.index >= opts.items.length - 1) {
37
+ return finish();
38
+ }
39
+ return { ...state, index: state.index + 1 };
40
+ };
41
+ const acceptCurrent = () => {
42
+ const item = opts.items[state.index];
43
+ if (item) {
44
+ return advanceWith(state, { path: item.path, hunkIndex: item.hunkIndex });
45
+ }
46
+ return advance();
47
+ };
48
+ const advanceWith = (s, entry) => {
49
+ const next = { ...s, accepted: [...s.accepted, entry] };
50
+ if (next.index >= opts.items.length - 1) {
51
+ return finishFrom(next);
52
+ }
53
+ return { ...next, index: next.index + 1 };
54
+ };
55
+ const finishFrom = (s) => {
56
+ const cb = opts.onComplete;
57
+ const next = createHunkPickerState();
58
+ cb(s.accepted);
59
+ return next;
60
+ };
61
+ switch (event.key) {
62
+ case 'y':
63
+ case 'enter':
64
+ case 'right':
65
+ return acceptCurrent();
66
+ case 'n':
67
+ case 'left':
68
+ return advance();
69
+ case 'a': {
70
+ // Accept current + all remaining.
71
+ const accepted = [...state.accepted];
72
+ for (let i = state.index; i < opts.items.length; i++) {
73
+ const item = opts.items[i];
74
+ accepted.push({ path: item.path, hunkIndex: item.hunkIndex });
75
+ }
76
+ return finishFrom({ ...state, accepted });
77
+ }
78
+ case 'q':
79
+ case 'escape':
80
+ return finish();
81
+ case 'up':
82
+ return state.index > 0 ? { ...state, index: state.index - 1 } : state;
83
+ case 'down':
84
+ return state.index < opts.items.length - 1
85
+ ? { ...state, index: state.index + 1 }
86
+ : state;
87
+ default:
88
+ return state;
89
+ }
90
+ }
91
+ /** Panel height the layout needs to reserve for the picker. */
92
+ export function hunkPickerPanelHeight() {
93
+ // Title + progress + path + header + up to 12 diff lines + more marker + legend.
94
+ return 18;
95
+ }
96
+ /**
97
+ * Paint the picker into the screen. Mirrors the rendering previously inlined
98
+ * in App.renderInlineHunkPicker.
99
+ */
100
+ export function renderHunkPicker(screen, state, startY, width) {
101
+ const opts = state.options;
102
+ if (!opts)
103
+ return;
104
+ const item = opts.items[state.index];
105
+ let y = startY;
106
+ screen.horizontalLine(y++, '─', PRIMARY_COLOR);
107
+ // Title + progress
108
+ const progress = opts.items.length > 0 ? ` (${state.index + 1}/${opts.items.length})` : '';
109
+ screen.writeLine(y++, `${opts.title}${progress}`, PRIMARY_COLOR + style.bold);
110
+ if (!item) {
111
+ screen.writeLine(y++, 'No hunks to review.', fg.gray);
112
+ screen.writeLine(y, 'Press any key to close.', fg.gray);
113
+ return;
114
+ }
115
+ // File path + hunk header
116
+ screen.writeLine(y++, `File: ${item.path}`, fg.cyan);
117
+ screen.writeLine(y++, `Hunk: ${item.header}`, fg.gray);
118
+ // Diff lines (capped to available vertical space; show up to 12)
119
+ const maxDiffLines = 12;
120
+ const lines = item.lines.slice(0, maxDiffLines);
121
+ for (const line of lines) {
122
+ const prefix = line.charAt(0);
123
+ let color = fg.white;
124
+ if (prefix === '+')
125
+ color = fg.green;
126
+ else if (prefix === '-')
127
+ color = fg.red;
128
+ else if (prefix === '@')
129
+ color = fg.cyan;
130
+ // Truncate long lines to terminal width.
131
+ const truncated = line.length > width - 2 ? line.slice(0, width - 5) + '...' : line;
132
+ screen.writeLine(y++, ` ${truncated}`, color);
133
+ }
134
+ if (item.lines.length > maxDiffLines) {
135
+ screen.writeLine(y++, ` … (${item.lines.length - maxDiffLines} more lines)`, fg.gray);
136
+ }
137
+ y++;
138
+ // Key legend
139
+ screen.writeLine(y, 'y/Enter accept • n skip • a accept all • q/Esc quit • ↑/↓ navigate', fg.gray);
140
+ }
@@ -0,0 +1,60 @@
1
+ import type { Screen } from '../Screen';
2
+ import type { MentionSuggestion } from '../../utils/mentions';
3
+ export interface MentionPickerState {
4
+ open: boolean;
5
+ index: number;
6
+ items: MentionSuggestion[];
7
+ /** Index of the `@` in the editor value at the time the query started. */
8
+ atStart: number;
9
+ /** Project root used to resolve suggestions (cached per update). */
10
+ root: string;
11
+ }
12
+ export declare function createMentionPickerState(): MentionPickerState;
13
+ /** Set the picker to a fresh query result. */
14
+ export declare function openMentionPicker(state: MentionPickerState, items: MentionSuggestion[], atStart: number, root: string): MentionPickerState;
15
+ /** Close the picker and clear its items. */
16
+ export declare function closeMentionPicker(state: MentionPickerState): MentionPickerState;
17
+ /** Minimal key shape App's KeyEvent satisfies. */
18
+ export interface MentionPickerKeyEvent {
19
+ key: string;
20
+ }
21
+ /** What App should do after a key press. */
22
+ export type MentionPickerAction = {
23
+ type: 'none';
24
+ } | {
25
+ type: 'close';
26
+ } | {
27
+ type: 'navigate';
28
+ delta: -1 | 1;
29
+ } | {
30
+ type: 'select';
31
+ suggestion: MentionSuggestion;
32
+ atStart: number;
33
+ };
34
+ /**
35
+ * Handle one key event while the mention picker is open. Returns the next
36
+ * state plus an action; `select` carries the chosen suggestion and the
37
+ * `@` index so App can rewrite the editor buffer.
38
+ */
39
+ export declare function handleMentionPickerKey(state: MentionPickerState, event: MentionPickerKeyEvent): {
40
+ state: MentionPickerState;
41
+ action: MentionPickerAction;
42
+ };
43
+ /**
44
+ * Compute the editor buffer after applying a mention selection. Pure:
45
+ * returns the next value + cursor position for App to set.
46
+ *
47
+ * `atStart` is the index OF the `@`, so the `before` slice EXCLUDES it —
48
+ * the sigil is re-added or the completed path is no longer a mention and
49
+ * the file never gets attached.
50
+ */
51
+ export declare function applyMentionToBuffer(value: string, cursor: number, atStart: number, suggestion: MentionSuggestion): {
52
+ value: string;
53
+ cursor: number;
54
+ };
55
+ /**
56
+ * Paint the mention picker below the status bar. Mirrors the layout of the
57
+ * `/` Autocomplete (separator → title → items → footer) but shows file
58
+ * paths with their parent directory as the description, and an `@` prefix.
59
+ */
60
+ export declare function renderMentionPicker(screen: Screen, state: MentionPickerState, startY: number): void;