codeep 2.13.2 → 2.15.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 +35 -24
- package/dist/acp/commands.js +22 -1
- package/dist/acp/server.js +278 -251
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +2 -2
- package/dist/config/providers.js +35 -22
- package/dist/renderer/App.d.ts +0 -30
- package/dist/renderer/App.js +149 -659
- package/dist/renderer/agentExecution.d.ts +2 -1
- package/dist/renderer/agentExecution.js +10 -6
- package/dist/renderer/commands/helpers.d.ts +63 -0
- package/dist/renderer/commands/helpers.js +108 -0
- package/dist/renderer/commands/registry.js +5 -0
- package/dist/renderer/commands.d.ts +4 -0
- package/dist/renderer/commands.js +183 -64
- package/dist/renderer/components/ActionFormatting.d.ts +17 -0
- package/dist/renderer/components/ActionFormatting.js +67 -0
- package/dist/renderer/components/Autocomplete.d.ts +33 -0
- package/dist/renderer/components/Autocomplete.js +40 -0
- package/dist/renderer/components/Intro.d.ts +9 -0
- package/dist/renderer/components/Intro.js +5 -15
- package/dist/renderer/components/MessageFormatter.d.ts +96 -0
- package/dist/renderer/components/MessageFormatter.js +375 -0
- package/dist/renderer/components/Permission.d.ts +4 -0
- package/dist/renderer/components/Permission.js +1 -1
- package/dist/renderer/components/Status.d.ts +4 -0
- package/dist/renderer/components/Status.js +2 -3
- package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
- package/dist/renderer/components/WelcomeFormatter.js +79 -0
- package/dist/renderer/components/uiConstants.d.ts +8 -0
- package/dist/renderer/components/uiConstants.js +24 -0
- package/dist/renderer/inputParsing.d.ts +22 -0
- package/dist/renderer/inputParsing.js +28 -0
- package/dist/renderer/layout.d.ts +215 -0
- package/dist/renderer/layout.js +326 -0
- package/dist/renderer/main.d.ts +2 -1
- package/dist/renderer/main.js +45 -10
- package/dist/renderer/ollamaHint.d.ts +12 -0
- package/dist/renderer/ollamaHint.js +29 -0
- package/dist/utils/agentChat.js +28 -2
- package/dist/utils/codeepCloud.d.ts +54 -0
- package/dist/utils/codeepCloud.js +95 -0
- package/dist/utils/export.d.ts +12 -0
- package/dist/utils/export.js +3 -3
- package/dist/utils/hooks.d.ts +26 -0
- package/dist/utils/hooks.js +69 -1
- package/dist/utils/keychain.js +45 -29
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.js +1 -1
- package/dist/utils/mcpConfig.d.ts +26 -0
- package/dist/utils/mcpConfig.js +109 -4
- package/dist/utils/skillBundles.d.ts +14 -0
- package/dist/utils/skillBundles.js +3 -3
- package/dist/utils/skillBundlesCloud.d.ts +7 -0
- package/dist/utils/skillBundlesCloud.js +1 -1
- package/dist/utils/tokenTracker.d.ts +30 -3
- package/dist/utils/tokenTracker.js +71 -13
- package/dist/utils/toolParsing.d.ts +11 -0
- package/dist/utils/toolParsing.js +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { App } from './App';
|
|
9
9
|
import { ProjectContext } from '../utils/project';
|
|
10
|
+
export declare function getActionType(toolName: string): string;
|
|
10
11
|
export interface AppExecutionContext {
|
|
11
12
|
app: App;
|
|
12
13
|
projectPath: string;
|
|
@@ -23,7 +24,7 @@ export interface AppExecutionContext {
|
|
|
23
24
|
formatAddedFilesContext: () => string;
|
|
24
25
|
handleCommand: (command: string, args: string[]) => Promise<void>;
|
|
25
26
|
sessionDisplayName?: string;
|
|
26
|
-
setSessionDisplayName?: (name: string) => void;
|
|
27
|
+
setSessionDisplayName?: (name: string | null) => void;
|
|
27
28
|
}
|
|
28
29
|
export declare function isDangerousTool(toolName: string, parameters: Record<string, unknown>): boolean;
|
|
29
30
|
export declare function requestToolConfirmation(app: App, tool: string, parameters: Record<string, unknown>, onConfirm: () => void, onCancel: () => void): void;
|
|
@@ -10,8 +10,8 @@ import { runAgent } from '../utils/agent.js';
|
|
|
10
10
|
import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
|
|
11
11
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
12
12
|
import { getGitStatus, isGitRepository } from '../utils/git.js';
|
|
13
|
-
import { getCostBreakdown,
|
|
14
|
-
function getActionType(toolName) {
|
|
13
|
+
import { getCostBreakdown, getRecordCount } from '../utils/tokenTracker.js';
|
|
14
|
+
export function getActionType(toolName) {
|
|
15
15
|
return toolName.includes('write') ? 'write' :
|
|
16
16
|
toolName.includes('edit') ? 'edit' :
|
|
17
17
|
toolName.includes('read') ? 'read' :
|
|
@@ -27,7 +27,8 @@ export function isDangerousTool(toolName, parameters) {
|
|
|
27
27
|
const lowerName = toolName.toLowerCase();
|
|
28
28
|
if (DANGEROUS_TOOLS.some(d => lowerName.includes(d)))
|
|
29
29
|
return true;
|
|
30
|
-
const
|
|
30
|
+
const rawCommand = parameters.command;
|
|
31
|
+
const command = typeof rawCommand === 'string' ? rawCommand : '';
|
|
31
32
|
const dangerousCommands = ['rm ', 'rm -', 'rmdir', 'del ', 'delete', 'drop ', 'truncate'];
|
|
32
33
|
return dangerousCommands.some(c => command.toLowerCase().includes(c));
|
|
33
34
|
}
|
|
@@ -140,7 +141,9 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
140
141
|
ctx.setAgentRunning(true);
|
|
141
142
|
const abortController = new AbortController();
|
|
142
143
|
ctx.setAbortController(abortController);
|
|
143
|
-
|
|
144
|
+
// Marker for cloud reporting: report only this run's tokens to the dashboard
|
|
145
|
+
// without wiping the session-cumulative store the status bar and `/cost` read.
|
|
146
|
+
const tokenReportStart = getRecordCount();
|
|
144
147
|
const prefix = dryRun ? '[DRY RUN] ' : '[AGENT] ';
|
|
145
148
|
app.addMessage({ role: 'user', content: prefix + task });
|
|
146
149
|
app.setAgentRunning(true);
|
|
@@ -378,8 +381,9 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
378
381
|
messages: app.getMessages(),
|
|
379
382
|
});
|
|
380
383
|
// Report per-model so tokens are attributed to the correct model/provider
|
|
381
|
-
// even if the user switched model mid-session.
|
|
382
|
-
|
|
384
|
+
// even if the user switched model mid-session. Only this run's delta
|
|
385
|
+
// (since tokenReportStart) is reported; the cumulative store is preserved.
|
|
386
|
+
const costBreakdown = getCostBreakdown(tokenReportStart);
|
|
383
387
|
const sharedFields = {
|
|
384
388
|
sessionId,
|
|
385
389
|
sessionName: displayName,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers extracted from `renderer/commands.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher is one giant switch/case; many cases contain small but
|
|
5
|
+
* tricky bits of pure logic (arg parsing, snippet extraction, message
|
|
6
|
+
* formatting) that were previously untestable because they were inlined
|
|
7
|
+
* alongside `ctx.app.*` calls. Pulling them here gives them direct unit
|
|
8
|
+
* coverage.
|
|
9
|
+
*/
|
|
10
|
+
export interface SearchSnippet {
|
|
11
|
+
role: string;
|
|
12
|
+
messageIndex: number;
|
|
13
|
+
matchedText: string;
|
|
14
|
+
}
|
|
15
|
+
/** Snippet window: chars of context before / after the match. */
|
|
16
|
+
export declare const SEARCH_SNIPPET_BEFORE = 30;
|
|
17
|
+
export declare const SEARCH_SNIPPET_AFTER = 50;
|
|
18
|
+
/**
|
|
19
|
+
* Build search-result snippets for messages matching `term`. Mirrors the
|
|
20
|
+
* inline loop that used to live in the `/search` case. Case-insensitive.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildSearchSnippets(messages: Array<{
|
|
23
|
+
role: string;
|
|
24
|
+
content: string;
|
|
25
|
+
}>, term: string): SearchSnippet[];
|
|
26
|
+
/**
|
|
27
|
+
* Parse the `/compact <n>` argument. Returns a value of at least 2
|
|
28
|
+
* (never compacts below 2 messages); defaults to `fallback` when the arg
|
|
29
|
+
* is missing or unparseable.
|
|
30
|
+
*
|
|
31
|
+
* Note: we use `Number.isNaN` rather than `parsed || fallback` because
|
|
32
|
+
* `0` is a valid (if useless) numeric input that should clamp to 2, not
|
|
33
|
+
* silently fall through to the default.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseKeepRecent(arg: string | undefined, fallback?: number): number;
|
|
36
|
+
/**
|
|
37
|
+
* Join slash-command args into a single hyphen-separated name, as used by
|
|
38
|
+
* `/rename`. Empty args are dropped so `/rename my session ` still
|
|
39
|
+
* yields `my-session`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function joinSessionName(args: string[]): string;
|
|
42
|
+
export declare const TASK_TYPES: readonly ["task", "bug", "feature"];
|
|
43
|
+
export type TaskType = (typeof TASK_TYPES)[number];
|
|
44
|
+
/** Result of parsing `/tasks add` flags. */
|
|
45
|
+
export interface ParsedTaskAdd {
|
|
46
|
+
title: string;
|
|
47
|
+
description: string;
|
|
48
|
+
type: TaskType;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse the args following `/tasks add` into a title, description, and
|
|
52
|
+
* type. Flags (`--bug`, `--feature`, `--task`) set the type; `--desc` /
|
|
53
|
+
* `--description` captures the following words until the next flag.
|
|
54
|
+
* Non-flag words before any `--desc` form the title.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseTaskAddArgs(args: string[]): ParsedTaskAdd;
|
|
57
|
+
/** Render a list of tasks as a Markdown list, mirroring `/tasks`. */
|
|
58
|
+
export declare function formatTaskList(tasks: Array<{
|
|
59
|
+
title: string;
|
|
60
|
+
type?: string | null;
|
|
61
|
+
description?: string | null;
|
|
62
|
+
project_name?: string | null;
|
|
63
|
+
}>, scopeProjectName?: string): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers extracted from `renderer/commands.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher is one giant switch/case; many cases contain small but
|
|
5
|
+
* tricky bits of pure logic (arg parsing, snippet extraction, message
|
|
6
|
+
* formatting) that were previously untestable because they were inlined
|
|
7
|
+
* alongside `ctx.app.*` calls. Pulling them here gives them direct unit
|
|
8
|
+
* coverage.
|
|
9
|
+
*/
|
|
10
|
+
/** Snippet window: chars of context before / after the match. */
|
|
11
|
+
export const SEARCH_SNIPPET_BEFORE = 30;
|
|
12
|
+
export const SEARCH_SNIPPET_AFTER = 50;
|
|
13
|
+
/**
|
|
14
|
+
* Build search-result snippets for messages matching `term`. Mirrors the
|
|
15
|
+
* inline loop that used to live in the `/search` case. Case-insensitive.
|
|
16
|
+
*/
|
|
17
|
+
export function buildSearchSnippets(messages, term) {
|
|
18
|
+
const lowerTerm = term.toLowerCase();
|
|
19
|
+
const results = [];
|
|
20
|
+
messages.forEach((m, index) => {
|
|
21
|
+
const lowerContent = m.content.toLowerCase();
|
|
22
|
+
if (!lowerContent.includes(lowerTerm))
|
|
23
|
+
return;
|
|
24
|
+
const matchIdx = lowerContent.indexOf(lowerTerm);
|
|
25
|
+
const matchStart = Math.max(0, matchIdx - SEARCH_SNIPPET_BEFORE);
|
|
26
|
+
const matchEnd = Math.min(m.content.length, matchIdx + lowerTerm.length + SEARCH_SNIPPET_AFTER);
|
|
27
|
+
const matchedText = (matchStart > 0 ? '...' : '') +
|
|
28
|
+
m.content.slice(matchStart, matchEnd).replace(/\n/g, ' ') +
|
|
29
|
+
(matchEnd < m.content.length ? '...' : '');
|
|
30
|
+
results.push({ role: m.role, messageIndex: index, matchedText });
|
|
31
|
+
});
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
// ─── Argument parsing ─────────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* Parse the `/compact <n>` argument. Returns a value of at least 2
|
|
37
|
+
* (never compacts below 2 messages); defaults to `fallback` when the arg
|
|
38
|
+
* is missing or unparseable.
|
|
39
|
+
*
|
|
40
|
+
* Note: we use `Number.isNaN` rather than `parsed || fallback` because
|
|
41
|
+
* `0` is a valid (if useless) numeric input that should clamp to 2, not
|
|
42
|
+
* silently fall through to the default.
|
|
43
|
+
*/
|
|
44
|
+
export function parseKeepRecent(arg, fallback = 4) {
|
|
45
|
+
if (!arg)
|
|
46
|
+
return fallback;
|
|
47
|
+
const parsed = parseInt(arg, 10);
|
|
48
|
+
return Math.max(2, Number.isNaN(parsed) ? fallback : parsed);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Join slash-command args into a single hyphen-separated name, as used by
|
|
52
|
+
* `/rename`. Empty args are dropped so `/rename my session ` still
|
|
53
|
+
* yields `my-session`.
|
|
54
|
+
*/
|
|
55
|
+
export function joinSessionName(args) {
|
|
56
|
+
return args.filter((a) => a.length > 0).join('-');
|
|
57
|
+
}
|
|
58
|
+
// ─── /tasks helpers ───────────────────────────────────────────────────────────
|
|
59
|
+
export const TASK_TYPES = ['task', 'bug', 'feature'];
|
|
60
|
+
/**
|
|
61
|
+
* Parse the args following `/tasks add` into a title, description, and
|
|
62
|
+
* type. Flags (`--bug`, `--feature`, `--task`) set the type; `--desc` /
|
|
63
|
+
* `--description` captures the following words until the next flag.
|
|
64
|
+
* Non-flag words before any `--desc` form the title.
|
|
65
|
+
*/
|
|
66
|
+
export function parseTaskAddArgs(args) {
|
|
67
|
+
let type = 'task';
|
|
68
|
+
const titleWords = [];
|
|
69
|
+
const descWords = [];
|
|
70
|
+
let capturingDesc = false;
|
|
71
|
+
for (const w of args) {
|
|
72
|
+
const flag = /^--([\w-]+)$/.exec(w);
|
|
73
|
+
if (flag) {
|
|
74
|
+
const name = flag[1].toLowerCase();
|
|
75
|
+
if (name === 'desc' || name === 'description') {
|
|
76
|
+
capturingDesc = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (TASK_TYPES.includes(name))
|
|
80
|
+
type = name;
|
|
81
|
+
capturingDesc = false; // any non-desc flag ends description capture
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (capturingDesc)
|
|
85
|
+
descWords.push(w);
|
|
86
|
+
else
|
|
87
|
+
titleWords.push(w);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
title: titleWords.join(' ').trim(),
|
|
91
|
+
description: descWords.join(' ').trim(),
|
|
92
|
+
type,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Icon/badge for a task type, used in list rendering. */
|
|
96
|
+
const TYPE_ICON = { bug: '[bug]', feature: '[feature]', task: '[task]' };
|
|
97
|
+
/** Render a list of tasks as a Markdown list, mirroring `/tasks`. */
|
|
98
|
+
export function formatTaskList(tasks, scopeProjectName) {
|
|
99
|
+
const lines = [`## Tasks${scopeProjectName ? ` — ${scopeProjectName}` : ''}`, ''];
|
|
100
|
+
tasks.forEach((t, i) => {
|
|
101
|
+
const icon = TYPE_ICON[t.type ?? 'task'] ?? '[task]';
|
|
102
|
+
const proj = !scopeProjectName && t.project_name ? ` _(${t.project_name})_` : '';
|
|
103
|
+
lines.push(`${i + 1}. ${icon} ${t.title}${proj}${t.description ? `\n ${t.description}` : ''}`);
|
|
104
|
+
});
|
|
105
|
+
lines.push('', `*${tasks.length} pending task${tasks.length > 1 ? 's' : ''}. Use /tasks done <n> to mark complete.*`);
|
|
106
|
+
lines.push('*Tasks loaded into agent context — agent will see them in the next message.*');
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
@@ -87,6 +87,11 @@ export const COMMANDS = [
|
|
|
87
87
|
category: 'sessions',
|
|
88
88
|
usage: ['<query>', '<query> --resume', '<query> --summarize'],
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
name: 'cloud',
|
|
92
|
+
description: 'List and resume sessions synced from other devices',
|
|
93
|
+
category: 'sessions',
|
|
94
|
+
},
|
|
90
95
|
{ name: 'export', description: 'Export chat', category: 'sessions', usage: ['[md|json|txt]'] },
|
|
91
96
|
{ name: 'compact', description: 'AI-summarize older messages to free up context (keeps last N)', category: 'sessions', usage: ['[keepN]'] },
|
|
92
97
|
// ── checkpoints ────────────────────────────────────────────────────────────
|
|
@@ -13,4 +13,8 @@ export interface AppCommandContext extends AppExecutionContext {
|
|
|
13
13
|
setProjectContext: (ctx: ReturnType<typeof getProjectContext>) => void;
|
|
14
14
|
setHasWriteAccess: (v: boolean) => void;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Returns a hint for an Ollama model name based on parameter count.
|
|
18
|
+
* Models ≥7B are suitable for agent mode; smaller ones are chat-only.
|
|
19
|
+
*/
|
|
16
20
|
export declare function handleCommand(command: string, args: string[], ctx: AppCommandContext): Promise<void>;
|
|
@@ -12,22 +12,13 @@ import { getProviderList, getProvider, modelSupportsReasoningEffort, reasoningPa
|
|
|
12
12
|
import { setProjectContext } from '../api/index.js';
|
|
13
13
|
import { runSkill, runCommandChain } from './agentExecution.js';
|
|
14
14
|
import { loadProjectIntelligence, saveProjectIntelligence } from '../utils/projectIntelligence.js';
|
|
15
|
+
import { ollamaModelHint } from './ollamaHint.js';
|
|
16
|
+
import { buildSearchSnippets, parseKeepRecent, joinSessionName, parseTaskAddArgs, formatTaskList } from './commands/helpers.js';
|
|
15
17
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
16
18
|
/**
|
|
17
19
|
* Returns a hint for an Ollama model name based on parameter count.
|
|
18
20
|
* Models ≥7B are suitable for agent mode; smaller ones are chat-only.
|
|
19
21
|
*/
|
|
20
|
-
function ollamaModelHint(modelId) {
|
|
21
|
-
const lower = modelId.toLowerCase();
|
|
22
|
-
// Extract number before 'b' (e.g. "7b", "1.5b", "14b", "72b")
|
|
23
|
-
const match = lower.match(/(\d+(?:\.\d+)?)b/);
|
|
24
|
-
if (!match)
|
|
25
|
-
return '';
|
|
26
|
-
const params = parseFloat(match[1]);
|
|
27
|
-
if (params >= 7)
|
|
28
|
-
return '✓ agent mode';
|
|
29
|
-
return '⚠ chat only (< 7B)';
|
|
30
|
-
}
|
|
31
22
|
// ─── Main dispatch ────────────────────────────────────────────────────────────
|
|
32
23
|
export async function handleCommand(command, args, ctx) {
|
|
33
24
|
// Handle skill chaining (e.g., /commit+push)
|
|
@@ -676,9 +667,155 @@ export async function handleCommand(command, args, ctx) {
|
|
|
676
667
|
});
|
|
677
668
|
break;
|
|
678
669
|
}
|
|
670
|
+
case 'cloud': {
|
|
671
|
+
// Cross-device resume: list sessions synced from other devices/Mac app
|
|
672
|
+
// and pull the selected one into the local store, then load it.
|
|
673
|
+
//
|
|
674
|
+
// Not linked → friendly prompt to run `codeep account`.
|
|
675
|
+
// Network/empty → notify (no crash).
|
|
676
|
+
//
|
|
677
|
+
// We scope to the current project when one is open, so a user on their
|
|
678
|
+
// laptop sees the sessions they ran on the desktop for the same repo.
|
|
679
|
+
// BUT: project identity is a hash of the LOCAL absolute path, so the
|
|
680
|
+
// same repo cloned at a different path (the normal cross-device case)
|
|
681
|
+
// has a different projectId. When the scoped list comes back empty we
|
|
682
|
+
// fall back to listing everything, and a non-empty scoped list still
|
|
683
|
+
// offers a "show all" escape hatch — otherwise cross-device resume
|
|
684
|
+
// only works when both machines use identical directory layouts.
|
|
685
|
+
const { listCloudSessions, pullCloudSession, generateProjectId } = await import('../utils/codeepCloud.js');
|
|
686
|
+
const projectId = ctx.projectPath ? generateProjectId(ctx.projectPath) : undefined;
|
|
687
|
+
ctx.app.notify('Fetching cloud sessions…');
|
|
688
|
+
let summaries = await listCloudSessions(projectId);
|
|
689
|
+
if (summaries === null) {
|
|
690
|
+
ctx.app.notify('Not linked — run `codeep account` to enable cloud sync.');
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
let scopedToProject = Boolean(projectId);
|
|
694
|
+
if (summaries.length === 0 && projectId) {
|
|
695
|
+
// Nothing under this project's path-hash — try the unscoped list so
|
|
696
|
+
// sessions synced from a machine with a different path still show up.
|
|
697
|
+
const all = await listCloudSessions();
|
|
698
|
+
if (all && all.length > 0) {
|
|
699
|
+
summaries = all;
|
|
700
|
+
scopedToProject = false;
|
|
701
|
+
ctx.app.notify('No sessions matched this project — showing all cloud sessions.');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (summaries.length === 0) {
|
|
705
|
+
ctx.app.notify(projectId
|
|
706
|
+
? 'No cloud sessions for this project yet.'
|
|
707
|
+
: 'No cloud sessions yet.');
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
// Non-null binding for the picker closure — TS can't carry the null
|
|
711
|
+
// narrowing of a reassigned `let` into the callback.
|
|
712
|
+
let sessionList = summaries;
|
|
713
|
+
// Render each row as: title · date · N msg · [project?]
|
|
714
|
+
// Mirrors the /sessions list layout so the picker feels familiar.
|
|
715
|
+
const SHOW_ALL = 'Show all cloud sessions…';
|
|
716
|
+
const labels = summaries.map(s => {
|
|
717
|
+
const date = new Date(s.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
718
|
+
const title = s.sessionName || s.sessionId.slice(0, 8);
|
|
719
|
+
const projectTag = s.projectName ? ` · ${s.projectName}` : '';
|
|
720
|
+
return `${title} · ${date} · ${s.messageCount} msg${projectTag}`;
|
|
721
|
+
});
|
|
722
|
+
if (scopedToProject)
|
|
723
|
+
labels.push(SHOW_ALL);
|
|
724
|
+
// Named so the "Show all" branch can re-present the picker with the
|
|
725
|
+
// same handler (a const arrow can reference itself; the binding is
|
|
726
|
+
// initialized long before the callback can fire).
|
|
727
|
+
const onPickCloudSession = async (index) => {
|
|
728
|
+
if (scopedToProject && index === sessionList.length) {
|
|
729
|
+
// "Show all" — re-list unscoped. Re-dispatching /cloud would
|
|
730
|
+
// re-scope to the project, so fetch + present inline instead.
|
|
731
|
+
const all = await listCloudSessions();
|
|
732
|
+
if (!all || all.length === 0) {
|
|
733
|
+
ctx.app.notify('No other cloud sessions.');
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
sessionList = all;
|
|
737
|
+
scopedToProject = false;
|
|
738
|
+
const allLabels = all.map(s => {
|
|
739
|
+
const date = new Date(s.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
740
|
+
const title = s.sessionName || s.sessionId.slice(0, 8);
|
|
741
|
+
const projectTag = s.projectName ? ` · ${s.projectName}` : '';
|
|
742
|
+
return `${title} · ${date} · ${s.messageCount} msg${projectTag}`;
|
|
743
|
+
});
|
|
744
|
+
ctx.app.showList('Cloud Sessions (all)', allLabels, onPickCloudSession);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const selected = sessionList[index];
|
|
748
|
+
// The cloud id becomes a local FILENAME (saveSession joins it into
|
|
749
|
+
// .codeep/sessions/<name>.json) — whitelist it so a hostile or
|
|
750
|
+
// corrupted server response can't traverse outside the sessions dir.
|
|
751
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(selected.sessionId)) {
|
|
752
|
+
ctx.app.notify('Cloud session has an unexpected id format — refusing to save it locally.');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
ctx.app.notify(`Pulling ${selected.sessionName || selected.sessionId.slice(0, 8)}…`);
|
|
756
|
+
const full = await pullCloudSession(selected.sessionId);
|
|
757
|
+
if (!full) {
|
|
758
|
+
ctx.app.notify('Failed to pull session (network or not found).');
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
// Cloud messages are {role, content}; local Message is the same shape
|
|
762
|
+
// plus 'system'. Filter to user/assistant (the server already does,
|
|
763
|
+
// but be defensive) and coerce.
|
|
764
|
+
const history = full.messages
|
|
765
|
+
.filter(m => m.role === 'user' || m.role === 'assistant')
|
|
766
|
+
.map(m => ({ role: m.role, content: m.content }));
|
|
767
|
+
if (history.length === 0) {
|
|
768
|
+
ctx.app.notify('Cloud session has no loadable messages.');
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// Persist locally so the resumed session is first-class: it appears
|
|
772
|
+
// in /sessions, autosaves on next change, and re-syncs on next turn.
|
|
773
|
+
// We reuse the cloud sessionId as the local name so a subsequent
|
|
774
|
+
// push updates the same cloud record (ON DUPLICATE KEY UPDATE).
|
|
775
|
+
const localName = selected.sessionId;
|
|
776
|
+
// If a local copy of this session exists and is NEWER than the cloud
|
|
777
|
+
// record (continued locally since the last sync), load it instead of
|
|
778
|
+
// clobbering the newer history with the older cloud copy.
|
|
779
|
+
try {
|
|
780
|
+
const { statSync, existsSync } = await import('fs');
|
|
781
|
+
const { join } = await import('path');
|
|
782
|
+
const { getSessionsDir } = await import('../config/index.js');
|
|
783
|
+
const localPath = join(getSessionsDir(ctx.projectPath), `${localName}.json`);
|
|
784
|
+
const cloudUpdatedAt = Date.parse(selected.updatedAt);
|
|
785
|
+
if (existsSync(localPath) && Number.isFinite(cloudUpdatedAt)
|
|
786
|
+
&& statSync(localPath).mtimeMs > cloudUpdatedAt) {
|
|
787
|
+
const local = loadSession(localName, ctx.projectPath);
|
|
788
|
+
if (local && local.length > 0) {
|
|
789
|
+
ctx.app.setMessages(local);
|
|
790
|
+
ctx.setSessionId(localName);
|
|
791
|
+
config.set('currentSessionId', localName);
|
|
792
|
+
ctx.setSessionDisplayName?.(selected.sessionName ?? null);
|
|
793
|
+
ctx.app.notify('Local copy is newer than the cloud record — loaded the local session instead.');
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch { /* mtime probe is best-effort — fall through to cloud copy */ }
|
|
799
|
+
saveSession(localName, history, ctx.projectPath);
|
|
800
|
+
ctx.app.setMessages(history);
|
|
801
|
+
// Keep ALL session-identity state in step, not just the renderer's
|
|
802
|
+
// copy: autosave + agent-mode sync read config.currentSessionId, and
|
|
803
|
+
// the next syncSession reads the display name — leaving either stale
|
|
804
|
+
// writes/renames the pulled history under the PREVIOUS session.
|
|
805
|
+
ctx.setSessionId(localName);
|
|
806
|
+
config.set('currentSessionId', localName);
|
|
807
|
+
ctx.setSessionDisplayName?.(selected.sessionName ?? null);
|
|
808
|
+
ctx.app.notify(`Resumed from cloud: ${selected.sessionName || localName}`);
|
|
809
|
+
};
|
|
810
|
+
ctx.app.showList('Cloud Sessions', labels, onPickCloudSession);
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
679
813
|
case 'new': {
|
|
680
814
|
ctx.app.clearMessages();
|
|
681
815
|
ctx.setSessionId(startNewSession());
|
|
816
|
+
// Clear the derived display name so the next chat re-derives it — else
|
|
817
|
+
// the new session syncs/reports under the PREVIOUS session's name.
|
|
818
|
+
ctx.setSessionDisplayName?.(null);
|
|
682
819
|
ctx.app.notify('New session started');
|
|
683
820
|
break;
|
|
684
821
|
}
|
|
@@ -826,7 +963,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
826
963
|
ctx.app.notify('Usage: /rename <new-name>');
|
|
827
964
|
return;
|
|
828
965
|
}
|
|
829
|
-
const newName = args
|
|
966
|
+
const newName = joinSessionName(args);
|
|
830
967
|
const messages = ctx.app.getMessages();
|
|
831
968
|
if (messages.length === 0) {
|
|
832
969
|
ctx.app.notify('No messages to save. Start a conversation first.');
|
|
@@ -862,18 +999,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
862
999
|
}
|
|
863
1000
|
const searchTerm = args.join(' ').toLowerCase();
|
|
864
1001
|
const messages = ctx.app.getMessages();
|
|
865
|
-
const searchResults =
|
|
866
|
-
messages.forEach((m, index) => {
|
|
867
|
-
if (m.content.toLowerCase().includes(searchTerm)) {
|
|
868
|
-
const lowerContent = m.content.toLowerCase();
|
|
869
|
-
const matchStart = Math.max(0, lowerContent.indexOf(searchTerm) - 30);
|
|
870
|
-
const matchEnd = Math.min(m.content.length, lowerContent.indexOf(searchTerm) + searchTerm.length + 50);
|
|
871
|
-
const matchedText = (matchStart > 0 ? '...' : '') +
|
|
872
|
-
m.content.slice(matchStart, matchEnd).replace(/\n/g, ' ') +
|
|
873
|
-
(matchEnd < m.content.length ? '...' : '');
|
|
874
|
-
searchResults.push({ role: m.role, messageIndex: index, matchedText });
|
|
875
|
-
}
|
|
876
|
-
});
|
|
1002
|
+
const searchResults = buildSearchSnippets(messages, searchTerm);
|
|
877
1003
|
if (searchResults.length === 0) {
|
|
878
1004
|
ctx.app.notify(`No matches for "${searchTerm}"`);
|
|
879
1005
|
}
|
|
@@ -1071,7 +1197,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
1071
1197
|
return;
|
|
1072
1198
|
}
|
|
1073
1199
|
const index = blockNum === -1 ? codeBlocks.length - 1 : blockNum - 1;
|
|
1074
|
-
if (index < 0 || index >= codeBlocks.length) {
|
|
1200
|
+
if (Number.isNaN(index) || index < 0 || index >= codeBlocks.length) {
|
|
1075
1201
|
ctx.app.notify(`Invalid block number. Available: 1-${codeBlocks.length}`);
|
|
1076
1202
|
return;
|
|
1077
1203
|
}
|
|
@@ -1299,7 +1425,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
1299
1425
|
}
|
|
1300
1426
|
case 'compact': {
|
|
1301
1427
|
const messages = ctx.app.getMessages();
|
|
1302
|
-
const keepRecent =
|
|
1428
|
+
const keepRecent = parseKeepRecent(args[0]);
|
|
1303
1429
|
if (messages.length <= keepRecent + 2) {
|
|
1304
1430
|
ctx.app.notify(`Nothing to compact — only ${messages.length} message(s) in this session.`);
|
|
1305
1431
|
break;
|
|
@@ -1891,31 +2017,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
|
|
|
1891
2017
|
// as the description — the same field the dashboard + macOS app set, and
|
|
1892
2018
|
// which the list view and the agent task-context prompt already render.
|
|
1893
2019
|
if (subCmd === 'add') {
|
|
1894
|
-
const
|
|
1895
|
-
let type = 'task';
|
|
1896
|
-
const titleWords = [];
|
|
1897
|
-
const descWords = [];
|
|
1898
|
-
let capturingDesc = false;
|
|
1899
|
-
for (const w of args.slice(1)) {
|
|
1900
|
-
const flag = /^--([\w-]+)$/.exec(w);
|
|
1901
|
-
if (flag) {
|
|
1902
|
-
const name = flag[1].toLowerCase();
|
|
1903
|
-
if (name === 'desc' || name === 'description') {
|
|
1904
|
-
capturingDesc = true;
|
|
1905
|
-
continue;
|
|
1906
|
-
}
|
|
1907
|
-
if (TASK_TYPES.includes(name))
|
|
1908
|
-
type = name;
|
|
1909
|
-
capturingDesc = false; // any non-desc flag ends description capture
|
|
1910
|
-
continue;
|
|
1911
|
-
}
|
|
1912
|
-
if (capturingDesc)
|
|
1913
|
-
descWords.push(w);
|
|
1914
|
-
else
|
|
1915
|
-
titleWords.push(w);
|
|
1916
|
-
}
|
|
1917
|
-
const title = titleWords.join(' ').trim();
|
|
1918
|
-
const description = descWords.join(' ').trim();
|
|
2020
|
+
const { title, description, type } = parseTaskAddArgs(args.slice(1));
|
|
1919
2021
|
if (!title) {
|
|
1920
2022
|
ctx.app.notify('Usage: /tasks add <title> [--bug | --feature] [--desc <text>]');
|
|
1921
2023
|
break;
|
|
@@ -1960,18 +2062,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
|
|
|
1960
2062
|
break;
|
|
1961
2063
|
}
|
|
1962
2064
|
setTaskContext(tasks);
|
|
1963
|
-
|
|
1964
|
-
const lines = [`## Tasks${projectName ? ` — ${projectName}` : ''}`, ''];
|
|
1965
|
-
tasks.forEach((t, i) => {
|
|
1966
|
-
const icon = TYPE_ICON[t.type] ?? '[task]';
|
|
1967
|
-
// In a global listing (not scoped to one project) tag each row with its
|
|
1968
|
-
// project so a mixed list is legible — matches the macOS/web task rows.
|
|
1969
|
-
const proj = !projectName && t.project_name ? ` _(${t.project_name})_` : '';
|
|
1970
|
-
lines.push(`${i + 1}. ${icon} ${t.title}${proj}${t.description ? `\n ${t.description}` : ''}`);
|
|
1971
|
-
});
|
|
1972
|
-
lines.push('', `*${tasks.length} pending task${tasks.length > 1 ? 's' : ''}. Use /tasks done <n> to mark complete.*`);
|
|
1973
|
-
lines.push('*Tasks loaded into agent context — agent will see them in the next message.*');
|
|
1974
|
-
ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
|
|
2065
|
+
ctx.app.addMessage({ role: 'system', content: formatTaskList(tasks, projectName) });
|
|
1975
2066
|
break;
|
|
1976
2067
|
}
|
|
1977
2068
|
case 'profile': {
|
|
@@ -2248,8 +2339,36 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
|
|
|
2248
2339
|
}
|
|
2249
2340
|
return true;
|
|
2250
2341
|
};
|
|
2251
|
-
const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfig } = await import('../utils/mcpConfig.js');
|
|
2342
|
+
const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfig, loadMcpServerConfigSplit, isWorkspaceMcpTrusted, trustWorkspaceMcp, untrustWorkspaceMcp } = await import('../utils/mcpConfig.js');
|
|
2252
2343
|
const { registerSessionServers } = await import('../utils/mcpRegistry.js');
|
|
2344
|
+
if (sub === 'trust') {
|
|
2345
|
+
if (!requireProject())
|
|
2346
|
+
break;
|
|
2347
|
+
if (isWorkspaceMcpTrusted(projectPath)) {
|
|
2348
|
+
ctx.app.notify('Workspace MCP servers are already trusted here.');
|
|
2349
|
+
break;
|
|
2350
|
+
}
|
|
2351
|
+
trustWorkspaceMcp(projectPath);
|
|
2352
|
+
const { workspace } = loadMcpServerConfigSplit(projectPath);
|
|
2353
|
+
if (workspace.length === 0) {
|
|
2354
|
+
ctx.app.notify('Workspace trusted — no workspace MCP servers defined yet.');
|
|
2355
|
+
break;
|
|
2356
|
+
}
|
|
2357
|
+
ctx.app.notify(`Workspace trusted. Spawning ${workspace.length} MCP server(s)…`);
|
|
2358
|
+
const { registered, errors } = await registerSessionServers(TUI_SESSION, workspace, { workspaceRoot: projectPath });
|
|
2359
|
+
if (registered.length > 0)
|
|
2360
|
+
ctx.app.notify(`MCP: ${registered.length} tool(s) ready. Type /mcp.`);
|
|
2361
|
+
for (const e of errors)
|
|
2362
|
+
ctx.app.notifyWarn(`MCP server "${e.server}" failed: ${e.error}`);
|
|
2363
|
+
break;
|
|
2364
|
+
}
|
|
2365
|
+
if (sub === 'untrust') {
|
|
2366
|
+
if (!requireProject())
|
|
2367
|
+
break;
|
|
2368
|
+
untrustWorkspaceMcp(projectPath);
|
|
2369
|
+
ctx.app.notify('Workspace MCP trust revoked — workspace servers won\'t spawn on next start. (Running servers stop when you exit.)');
|
|
2370
|
+
break;
|
|
2371
|
+
}
|
|
2253
2372
|
if (sub === 'add') {
|
|
2254
2373
|
if (!requireProject())
|
|
2255
2374
|
break;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour per action kind. Falls back to plain white for unknown kinds so
|
|
3
|
+
* a new tool type degrades gracefully (visible, just uncoloured) instead
|
|
4
|
+
* of throwing.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getActionColor(type: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Shorten a file path for single-line display. Paths with 3+ segments
|
|
9
|
+
* keep their last two (parent dir + filename); longer paths get a leading
|
|
10
|
+
* `...`. The result is capped to `maxLen`, truncating from the left.
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatActionTarget(target: string, maxLen: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* Present-tense label for an action kind. Falls back to the raw `type`
|
|
15
|
+
* for unknown kinds.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getActionLabel(type: string): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action display helpers for the agent progress panel.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions extracted from `App.ts` so they can be unit-tested in
|
|
5
|
+
* isolation. `getActionColor` / `getActionLabel` map an action `type`
|
|
6
|
+
* (read/write/edit/delete/command/...) to its colour and present-tense
|
|
7
|
+
* label; `formatActionTarget` shortens a file path to fit a column.
|
|
8
|
+
*/
|
|
9
|
+
import { fg } from '../ansi.js';
|
|
10
|
+
/**
|
|
11
|
+
* Colour per action kind. Falls back to plain white for unknown kinds so
|
|
12
|
+
* a new tool type degrades gracefully (visible, just uncoloured) instead
|
|
13
|
+
* of throwing.
|
|
14
|
+
*/
|
|
15
|
+
export function getActionColor(type) {
|
|
16
|
+
const colors = {
|
|
17
|
+
'read': fg.blue,
|
|
18
|
+
'write': fg.green,
|
|
19
|
+
'edit': fg.yellow,
|
|
20
|
+
'delete': fg.red,
|
|
21
|
+
'command': fg.magenta,
|
|
22
|
+
'search': fg.cyan,
|
|
23
|
+
'list': fg.white,
|
|
24
|
+
'mkdir': fg.blue,
|
|
25
|
+
'fetch': fg.cyan,
|
|
26
|
+
};
|
|
27
|
+
return colors[type] || fg.white;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Shorten a file path for single-line display. Paths with 3+ segments
|
|
31
|
+
* keep their last two (parent dir + filename); longer paths get a leading
|
|
32
|
+
* `...`. The result is capped to `maxLen`, truncating from the left.
|
|
33
|
+
*/
|
|
34
|
+
export function formatActionTarget(target, maxLen) {
|
|
35
|
+
if (target.includes('/')) {
|
|
36
|
+
const parts = target.split('/');
|
|
37
|
+
const filename = parts[parts.length - 1];
|
|
38
|
+
if (parts.length > 2) {
|
|
39
|
+
const short = `.../${parts[parts.length - 2]}/${filename}`;
|
|
40
|
+
if (short.length <= maxLen)
|
|
41
|
+
return short;
|
|
42
|
+
// Still too long: keep the filename and RE-ADD the `...` marker —
|
|
43
|
+
// a bare slice from the right would cut the marker mid-way and the
|
|
44
|
+
// result would no longer signal that it was truncated.
|
|
45
|
+
return '...' + short.slice(-(maxLen - 3));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return target.length > maxLen ? '...' + target.slice(-(maxLen - 3)) : target;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Present-tense label for an action kind. Falls back to the raw `type`
|
|
52
|
+
* for unknown kinds.
|
|
53
|
+
*/
|
|
54
|
+
export function getActionLabel(type) {
|
|
55
|
+
const labels = {
|
|
56
|
+
'read': 'Reading',
|
|
57
|
+
'write': 'Creating',
|
|
58
|
+
'edit': 'Editing',
|
|
59
|
+
'delete': 'Deleting',
|
|
60
|
+
'command': 'Running',
|
|
61
|
+
'search': 'Searching',
|
|
62
|
+
'list': 'Listing',
|
|
63
|
+
'mkdir': 'Creating dir',
|
|
64
|
+
'fetch': 'Fetching',
|
|
65
|
+
};
|
|
66
|
+
return labels[type] || type;
|
|
67
|
+
}
|