codeep 2.14.0 → 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 +13 -2
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +2 -2
- package/dist/config/providers.js +15 -10
- package/dist/renderer/App.d.ts +0 -30
- package/dist/renderer/App.js +149 -659
- package/dist/renderer/agentExecution.d.ts +1 -0
- package/dist/renderer/agentExecution.js +3 -2
- 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 +179 -63
- 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 +23 -1
- 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.js +12 -2
- 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
|
@@ -11,7 +11,7 @@ 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
13
|
import { getCostBreakdown, getRecordCount } from '../utils/tokenTracker.js';
|
|
14
|
-
function getActionType(toolName) {
|
|
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
|
}
|
|
@@ -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,6 +667,149 @@ 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());
|
|
@@ -829,7 +963,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
829
963
|
ctx.app.notify('Usage: /rename <new-name>');
|
|
830
964
|
return;
|
|
831
965
|
}
|
|
832
|
-
const newName = args
|
|
966
|
+
const newName = joinSessionName(args);
|
|
833
967
|
const messages = ctx.app.getMessages();
|
|
834
968
|
if (messages.length === 0) {
|
|
835
969
|
ctx.app.notify('No messages to save. Start a conversation first.');
|
|
@@ -865,18 +999,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
865
999
|
}
|
|
866
1000
|
const searchTerm = args.join(' ').toLowerCase();
|
|
867
1001
|
const messages = ctx.app.getMessages();
|
|
868
|
-
const searchResults =
|
|
869
|
-
messages.forEach((m, index) => {
|
|
870
|
-
if (m.content.toLowerCase().includes(searchTerm)) {
|
|
871
|
-
const lowerContent = m.content.toLowerCase();
|
|
872
|
-
const matchStart = Math.max(0, lowerContent.indexOf(searchTerm) - 30);
|
|
873
|
-
const matchEnd = Math.min(m.content.length, lowerContent.indexOf(searchTerm) + searchTerm.length + 50);
|
|
874
|
-
const matchedText = (matchStart > 0 ? '...' : '') +
|
|
875
|
-
m.content.slice(matchStart, matchEnd).replace(/\n/g, ' ') +
|
|
876
|
-
(matchEnd < m.content.length ? '...' : '');
|
|
877
|
-
searchResults.push({ role: m.role, messageIndex: index, matchedText });
|
|
878
|
-
}
|
|
879
|
-
});
|
|
1002
|
+
const searchResults = buildSearchSnippets(messages, searchTerm);
|
|
880
1003
|
if (searchResults.length === 0) {
|
|
881
1004
|
ctx.app.notify(`No matches for "${searchTerm}"`);
|
|
882
1005
|
}
|
|
@@ -1302,7 +1425,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
1302
1425
|
}
|
|
1303
1426
|
case 'compact': {
|
|
1304
1427
|
const messages = ctx.app.getMessages();
|
|
1305
|
-
const keepRecent =
|
|
1428
|
+
const keepRecent = parseKeepRecent(args[0]);
|
|
1306
1429
|
if (messages.length <= keepRecent + 2) {
|
|
1307
1430
|
ctx.app.notify(`Nothing to compact — only ${messages.length} message(s) in this session.`);
|
|
1308
1431
|
break;
|
|
@@ -1894,31 +2017,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
|
|
|
1894
2017
|
// as the description — the same field the dashboard + macOS app set, and
|
|
1895
2018
|
// which the list view and the agent task-context prompt already render.
|
|
1896
2019
|
if (subCmd === 'add') {
|
|
1897
|
-
const
|
|
1898
|
-
let type = 'task';
|
|
1899
|
-
const titleWords = [];
|
|
1900
|
-
const descWords = [];
|
|
1901
|
-
let capturingDesc = false;
|
|
1902
|
-
for (const w of args.slice(1)) {
|
|
1903
|
-
const flag = /^--([\w-]+)$/.exec(w);
|
|
1904
|
-
if (flag) {
|
|
1905
|
-
const name = flag[1].toLowerCase();
|
|
1906
|
-
if (name === 'desc' || name === 'description') {
|
|
1907
|
-
capturingDesc = true;
|
|
1908
|
-
continue;
|
|
1909
|
-
}
|
|
1910
|
-
if (TASK_TYPES.includes(name))
|
|
1911
|
-
type = name;
|
|
1912
|
-
capturingDesc = false; // any non-desc flag ends description capture
|
|
1913
|
-
continue;
|
|
1914
|
-
}
|
|
1915
|
-
if (capturingDesc)
|
|
1916
|
-
descWords.push(w);
|
|
1917
|
-
else
|
|
1918
|
-
titleWords.push(w);
|
|
1919
|
-
}
|
|
1920
|
-
const title = titleWords.join(' ').trim();
|
|
1921
|
-
const description = descWords.join(' ').trim();
|
|
2020
|
+
const { title, description, type } = parseTaskAddArgs(args.slice(1));
|
|
1922
2021
|
if (!title) {
|
|
1923
2022
|
ctx.app.notify('Usage: /tasks add <title> [--bug | --feature] [--desc <text>]');
|
|
1924
2023
|
break;
|
|
@@ -1963,18 +2062,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
|
|
|
1963
2062
|
break;
|
|
1964
2063
|
}
|
|
1965
2064
|
setTaskContext(tasks);
|
|
1966
|
-
|
|
1967
|
-
const lines = [`## Tasks${projectName ? ` — ${projectName}` : ''}`, ''];
|
|
1968
|
-
tasks.forEach((t, i) => {
|
|
1969
|
-
const icon = TYPE_ICON[t.type] ?? '[task]';
|
|
1970
|
-
// In a global listing (not scoped to one project) tag each row with its
|
|
1971
|
-
// project so a mixed list is legible — matches the macOS/web task rows.
|
|
1972
|
-
const proj = !projectName && t.project_name ? ` _(${t.project_name})_` : '';
|
|
1973
|
-
lines.push(`${i + 1}. ${icon} ${t.title}${proj}${t.description ? `\n ${t.description}` : ''}`);
|
|
1974
|
-
});
|
|
1975
|
-
lines.push('', `*${tasks.length} pending task${tasks.length > 1 ? 's' : ''}. Use /tasks done <n> to mark complete.*`);
|
|
1976
|
-
lines.push('*Tasks loaded into agent context — agent will see them in the next message.*');
|
|
1977
|
-
ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
|
|
2065
|
+
ctx.app.addMessage({ role: 'system', content: formatTaskList(tasks, projectName) });
|
|
1978
2066
|
break;
|
|
1979
2067
|
}
|
|
1980
2068
|
case 'profile': {
|
|
@@ -2251,8 +2339,36 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
|
|
|
2251
2339
|
}
|
|
2252
2340
|
return true;
|
|
2253
2341
|
};
|
|
2254
|
-
const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfig } = await import('../utils/mcpConfig.js');
|
|
2342
|
+
const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfig, loadMcpServerConfigSplit, isWorkspaceMcpTrusted, trustWorkspaceMcp, untrustWorkspaceMcp } = await import('../utils/mcpConfig.js');
|
|
2255
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
|
+
}
|
|
2256
2372
|
if (sub === 'add') {
|
|
2257
2373
|
if (!requireProject())
|
|
2258
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete logic for the `/`-command picker.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `App.ts` so the filter rule (prefix match, 8-item cap,
|
|
5
|
+
* only triggered for command-shaped input) can be unit-tested without
|
|
6
|
+
* the editor / render machinery.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Result of filtering a command list for the autocomplete dropdown.
|
|
10
|
+
* `null` means "autocomplete shouldn't be open" (the input isn't a
|
|
11
|
+
* command prefix, or there are no matches).
|
|
12
|
+
*/
|
|
13
|
+
export interface AutocompleteResult {
|
|
14
|
+
items: string[];
|
|
15
|
+
/** Always 0 on a fresh filter — the caller may move it via arrow keys. */
|
|
16
|
+
index: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Filter `commands` to those that start with the typed prefix and the
|
|
20
|
+
* dropdown should appear.
|
|
21
|
+
*
|
|
22
|
+
* Rules:
|
|
23
|
+
* - Input must start with `/` (a slash command).
|
|
24
|
+
* - Input must not contain a space (the user is still typing the
|
|
25
|
+
* command name, not an argument).
|
|
26
|
+
* - Match case-insensitively on the text after the `/`.
|
|
27
|
+
* - Cap at 8 results so the dropdown never grows past the panel.
|
|
28
|
+
*
|
|
29
|
+
* @param value The raw editor value (e.g. `/h`, `/lo`, `/hel world`).
|
|
30
|
+
* @param commands The full command-name list (from `COMMAND_DESCRIPTIONS`).
|
|
31
|
+
* @returns Match list, or `null` when the dropdown should be hidden.
|
|
32
|
+
*/
|
|
33
|
+
export declare function filterCommands(value: string, commands: string[]): AutocompleteResult | null;
|