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.
Files changed (62) hide show
  1. package/README.md +35 -24
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +278 -251
  4. package/dist/config/index.d.ts +10 -0
  5. package/dist/config/index.js +2 -2
  6. package/dist/config/providers.js +35 -22
  7. package/dist/renderer/App.d.ts +0 -30
  8. package/dist/renderer/App.js +149 -659
  9. package/dist/renderer/agentExecution.d.ts +2 -1
  10. package/dist/renderer/agentExecution.js +10 -6
  11. package/dist/renderer/commands/helpers.d.ts +63 -0
  12. package/dist/renderer/commands/helpers.js +108 -0
  13. package/dist/renderer/commands/registry.js +5 -0
  14. package/dist/renderer/commands.d.ts +4 -0
  15. package/dist/renderer/commands.js +183 -64
  16. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  17. package/dist/renderer/components/ActionFormatting.js +67 -0
  18. package/dist/renderer/components/Autocomplete.d.ts +33 -0
  19. package/dist/renderer/components/Autocomplete.js +40 -0
  20. package/dist/renderer/components/Intro.d.ts +9 -0
  21. package/dist/renderer/components/Intro.js +5 -15
  22. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  23. package/dist/renderer/components/MessageFormatter.js +375 -0
  24. package/dist/renderer/components/Permission.d.ts +4 -0
  25. package/dist/renderer/components/Permission.js +1 -1
  26. package/dist/renderer/components/Status.d.ts +4 -0
  27. package/dist/renderer/components/Status.js +2 -3
  28. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  29. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  30. package/dist/renderer/components/uiConstants.d.ts +8 -0
  31. package/dist/renderer/components/uiConstants.js +24 -0
  32. package/dist/renderer/inputParsing.d.ts +22 -0
  33. package/dist/renderer/inputParsing.js +28 -0
  34. package/dist/renderer/layout.d.ts +215 -0
  35. package/dist/renderer/layout.js +326 -0
  36. package/dist/renderer/main.d.ts +2 -1
  37. package/dist/renderer/main.js +45 -10
  38. package/dist/renderer/ollamaHint.d.ts +12 -0
  39. package/dist/renderer/ollamaHint.js +29 -0
  40. package/dist/utils/agentChat.js +28 -2
  41. package/dist/utils/codeepCloud.d.ts +54 -0
  42. package/dist/utils/codeepCloud.js +95 -0
  43. package/dist/utils/export.d.ts +12 -0
  44. package/dist/utils/export.js +3 -3
  45. package/dist/utils/hooks.d.ts +26 -0
  46. package/dist/utils/hooks.js +69 -1
  47. package/dist/utils/keychain.js +45 -29
  48. package/dist/utils/logger.d.ts +12 -0
  49. package/dist/utils/logger.js +1 -1
  50. package/dist/utils/mcpConfig.d.ts +26 -0
  51. package/dist/utils/mcpConfig.js +109 -4
  52. package/dist/utils/skillBundles.d.ts +14 -0
  53. package/dist/utils/skillBundles.js +3 -3
  54. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  55. package/dist/utils/skillBundlesCloud.js +1 -1
  56. package/dist/utils/tokenTracker.d.ts +30 -3
  57. package/dist/utils/tokenTracker.js +71 -13
  58. package/dist/utils/toolParsing.d.ts +11 -0
  59. package/dist/utils/toolParsing.js +6 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. 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, resetTokenTracking } from '../utils/tokenTracker.js';
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 command = parameters.command || '';
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
- resetTokenTracking(); // Reset per-run so reportStats sends only this run's tokens
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
- const costBreakdown = getCostBreakdown();
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.join('-');
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 = args[0] ? Math.max(2, parseInt(args[0], 10) || 4) : 4;
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 TASK_TYPES = ['task', 'bug', 'feature'];
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
- const TYPE_ICON = { bug: '[bug]', feature: '[feature]', task: '[task]' };
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
+ }