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.
- package/README.md +53 -16
- package/dist/acp/commands.js +11 -55
- package/dist/acp/protocol.d.ts +34 -0
- package/dist/acp/server.d.ts +6 -1
- package/dist/acp/server.js +97 -2
- package/dist/api/index.js +9 -0
- package/dist/commands/core/index.d.ts +19 -0
- package/dist/commands/core/index.js +28 -0
- package/dist/commands/core/keysync.d.ts +2 -0
- package/dist/commands/core/keysync.js +34 -0
- package/dist/commands/core/telemetry.d.ts +2 -0
- package/dist/commands/core/telemetry.js +34 -0
- package/dist/config/index.js +2 -2
- package/dist/config/providers.js +7 -4
- package/dist/renderer/App.d.ts +9 -48
- package/dist/renderer/App.js +113 -338
- package/dist/renderer/Screen.d.ts +13 -0
- package/dist/renderer/Screen.js +22 -0
- package/dist/renderer/commands/registry.js +3 -3
- package/dist/renderer/commands.js +19 -51
- package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
- package/dist/renderer/components/CommandAutocomplete.js +103 -0
- package/dist/renderer/components/HunkPicker.d.ts +48 -0
- package/dist/renderer/components/HunkPicker.js +140 -0
- package/dist/renderer/components/MentionPicker.d.ts +60 -0
- package/dist/renderer/components/MentionPicker.js +111 -0
- package/dist/renderer/components/PasteDialog.d.ts +43 -0
- package/dist/renderer/components/PasteDialog.js +70 -0
- package/dist/renderer/layout.js +1 -0
- package/dist/renderer/main.js +15 -39
- package/dist/utils/agent.js +121 -26
- package/dist/utils/agentChat.d.ts +11 -4
- package/dist/utils/agentChat.js +53 -25
- package/dist/utils/codeepCloud.d.ts +3 -0
- package/dist/utils/codeepCloud.js +62 -7
- package/dist/utils/personalities.d.ts +63 -5
- package/dist/utils/personalities.js +583 -31
- package/dist/utils/shell.d.ts +11 -1
- package/dist/utils/shell.js +169 -82
- package/dist/utils/ssrfGuard.d.ts +18 -0
- package/dist/utils/ssrfGuard.js +83 -0
- package/dist/utils/taskPlanner.d.ts +7 -1
- package/dist/utils/taskPlanner.js +16 -7
- package/dist/utils/tokenTracker.js +5 -3
- package/dist/utils/toolExecution.d.ts +1 -0
- package/dist/utils/toolExecution.js +48 -88
- package/dist/utils/tools.d.ts +3 -3
- package/dist/utils/tools.js +18 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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)
|
package/dist/renderer/Screen.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
279
|
-
ctx.app.notify(
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
308
|
-
ctx.app.notify(
|
|
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:
|
|
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;
|