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.
Files changed (61) hide show
  1. package/README.md +35 -24
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/config/index.d.ts +10 -0
  5. package/dist/config/index.js +2 -2
  6. package/dist/config/providers.js +15 -10
  7. package/dist/renderer/App.d.ts +0 -30
  8. package/dist/renderer/App.js +149 -659
  9. package/dist/renderer/agentExecution.d.ts +1 -0
  10. package/dist/renderer/agentExecution.js +3 -2
  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 +179 -63
  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 +23 -1
  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.js +12 -2
  57. package/dist/utils/toolParsing.d.ts +11 -0
  58. package/dist/utils/toolParsing.js +6 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. 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;
@@ -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 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
  }
@@ -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.join('-');
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 = args[0] ? Math.max(2, parseInt(args[0], 10) || 4) : 4;
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 TASK_TYPES = ['task', 'bug', 'feature'];
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
- const TYPE_ICON = { bug: '[bug]', feature: '[feature]', task: '[task]' };
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;