codeep 2.14.0 → 2.16.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 (70) hide show
  1. package/README.md +47 -27
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/acp/session.js +22 -1
  5. package/dist/config/index.d.ts +10 -0
  6. package/dist/config/index.js +2 -2
  7. package/dist/config/providers.js +35 -24
  8. package/dist/renderer/App.d.ts +77 -30
  9. package/dist/renderer/App.js +429 -659
  10. package/dist/renderer/agentExecution.d.ts +1 -0
  11. package/dist/renderer/agentExecution.js +3 -2
  12. package/dist/renderer/commands/helpers.d.ts +251 -0
  13. package/dist/renderer/commands/helpers.js +450 -0
  14. package/dist/renderer/commands/registry.js +7 -1
  15. package/dist/renderer/commands.d.ts +4 -0
  16. package/dist/renderer/commands.js +363 -318
  17. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  18. package/dist/renderer/components/ActionFormatting.js +67 -0
  19. package/dist/renderer/components/Autocomplete.d.ts +58 -0
  20. package/dist/renderer/components/Autocomplete.js +75 -0
  21. package/dist/renderer/components/Intro.d.ts +9 -0
  22. package/dist/renderer/components/Intro.js +5 -15
  23. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  24. package/dist/renderer/components/MessageFormatter.js +375 -0
  25. package/dist/renderer/components/Permission.d.ts +4 -0
  26. package/dist/renderer/components/Permission.js +1 -1
  27. package/dist/renderer/components/Status.d.ts +4 -0
  28. package/dist/renderer/components/Status.js +2 -3
  29. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  30. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  31. package/dist/renderer/components/uiConstants.d.ts +8 -0
  32. package/dist/renderer/components/uiConstants.js +24 -0
  33. package/dist/renderer/inputParsing.d.ts +22 -0
  34. package/dist/renderer/inputParsing.js +28 -0
  35. package/dist/renderer/layout.d.ts +219 -0
  36. package/dist/renderer/layout.js +338 -0
  37. package/dist/renderer/main.d.ts +2 -1
  38. package/dist/renderer/main.js +79 -11
  39. package/dist/renderer/ollamaHint.d.ts +12 -0
  40. package/dist/renderer/ollamaHint.js +29 -0
  41. package/dist/utils/agentChat.js +23 -1
  42. package/dist/utils/codeepCloud.d.ts +54 -0
  43. package/dist/utils/codeepCloud.js +95 -0
  44. package/dist/utils/diffPreview.d.ts +31 -0
  45. package/dist/utils/diffPreview.js +102 -0
  46. package/dist/utils/export.d.ts +12 -0
  47. package/dist/utils/export.js +3 -3
  48. package/dist/utils/git.d.ts +28 -0
  49. package/dist/utils/git.js +111 -1
  50. package/dist/utils/hooks.d.ts +26 -0
  51. package/dist/utils/hooks.js +69 -1
  52. package/dist/utils/keychain.js +45 -29
  53. package/dist/utils/logger.d.ts +12 -0
  54. package/dist/utils/logger.js +1 -1
  55. package/dist/utils/mcpConfig.d.ts +26 -0
  56. package/dist/utils/mcpConfig.js +109 -4
  57. package/dist/utils/mentions.d.ts +195 -0
  58. package/dist/utils/mentions.js +672 -0
  59. package/dist/utils/skillBundles.d.ts +14 -0
  60. package/dist/utils/skillBundles.js +3 -3
  61. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  62. package/dist/utils/skillBundlesCloud.js +1 -1
  63. package/dist/utils/tokenTracker.js +21 -5
  64. package/dist/utils/toolParsing.d.ts +11 -0
  65. package/dist/utils/toolParsing.js +6 -0
  66. package/dist/utils/webFetch.d.ts +101 -0
  67. package/dist/utils/webFetch.js +375 -0
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +2 -2
@@ -12,31 +12,27 @@ 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, formatProfileList, formatMemoryList, formatStatsReport, extractCodeBlocks, resolveBlockIndex, extractFileChanges, formatApplyDiffLine, parsePromptArgs, formatMcpReloadReport, formatMcpResourcesList, formatMcpResourceRead, formatMcpPromptsList, formatMcpPromptResult, formatMcpServerList, parseInsightsDays, formatCloudSessionLabel, formatMeSyncReport, formatMeLearnResult, formatMeInitResult, formatSkillsShow, formatSkillsBrowseEmpty, formatSkillsPublishResult } from './commands/helpers.js';
17
+ import { resolveCommand } from './commands/registry.js';
15
18
  // ─── Helpers ──────────────────────────────────────────────────────────────────
16
19
  /**
17
20
  * Returns a hint for an Ollama model name based on parameter count.
18
21
  * Models ≥7B are suitable for agent mode; smaller ones are chat-only.
19
22
  */
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
23
  // ─── Main dispatch ────────────────────────────────────────────────────────────
32
24
  export async function handleCommand(command, args, ctx) {
25
+ // Resolve command aliases (e.g. `webcache` → `web-cache`) to their
26
+ // canonical name before dispatching.
27
+ const resolved = resolveCommand(command);
28
+ const canonical = resolved?.name ?? command;
33
29
  // Handle skill chaining (e.g., /commit+push)
34
- if (command.includes('+')) {
35
- const commands = command.split('+').filter(c => c.trim());
30
+ if (canonical.includes('+')) {
31
+ const commands = canonical.split('+').filter(c => c.trim());
36
32
  runCommandChain(commands, 0, ctx);
37
33
  return;
38
34
  }
39
- switch (command) {
35
+ switch (canonical) {
40
36
  case 'version': {
41
37
  const version = getCurrentVersion();
42
38
  const provider = getCurrentProvider();
@@ -317,7 +313,7 @@ export async function handleCommand(command, args, ctx) {
317
313
  ctx.app.notify('Thinking effort: auto — each model uses its own default.');
318
314
  }
319
315
  else if (!supported) {
320
- ctx.app.notify(`Thinking effort set to "${sub}", but ${model} has no graded thinking control — it will be ignored until you switch to a model that does (e.g. Opus 4.8, GPT-5.x, Gemini 3, DeepSeek V4, GLM-5.2).`);
316
+ ctx.app.notify(`Thinking effort set to "${sub}", but ${model} has no graded thinking control — it will be ignored until you switch to a model that does (e.g. Opus 5, GPT-5.x, Gemini 3, DeepSeek V4, GLM-5.2).`);
321
317
  }
322
318
  else {
323
319
  // Tell the user what THIS model will actually run (the tier may
@@ -433,21 +429,7 @@ export async function handleCommand(command, args, ctx) {
433
429
  }
434
430
  case 'insights': {
435
431
  const { formatInsights } = await import('../utils/insights.js');
436
- // Parse `--days N` (default 7). Accept both `--days 30` and `--days=30`.
437
- let days = 7;
438
- for (let i = 0; i < args.length; i++) {
439
- const a = args[i];
440
- if (a === '--days' && args[i + 1]) {
441
- const n = parseInt(args[i + 1], 10);
442
- if (Number.isFinite(n))
443
- days = n;
444
- }
445
- else if (a.startsWith('--days=')) {
446
- const n = parseInt(a.slice('--days='.length), 10);
447
- if (Number.isFinite(n))
448
- days = n;
449
- }
450
- }
432
+ const days = parseInsightsDays(args);
451
433
  ctx.app.addMessage({ role: 'system', content: formatInsights({ days }) });
452
434
  break;
453
435
  }
@@ -522,12 +504,7 @@ export async function handleCommand(command, args, ctx) {
522
504
  break;
523
505
  }
524
506
  const file = scope === 'global' ? '~/.codeep/profile.learned.md' : '.codeep/profile.learned.md';
525
- ctx.app.addMessage({
526
- role: 'system',
527
- content: res.updated
528
- ? `Updated your ${scope} learned profile (\`${file}\`):\n\n${res.facts}\n\nClear it anytime with \`/me forget\`.`
529
- : `No changes — your ${scope} learned profile already covers this:\n\n${res.facts}`,
530
- });
507
+ ctx.app.addMessage({ role: 'system', content: formatMeLearnResult(scope, file, res) });
531
508
  break;
532
509
  }
533
510
  if (sub === 'forget') {
@@ -546,14 +523,7 @@ export async function handleCommand(command, args, ctx) {
546
523
  ctx.app.notify('Syncing your profile with codeep.dev…');
547
524
  const pushed = await pushUserProfile();
548
525
  const pulled = await pullUserProfile();
549
- const lines = [];
550
- if (pushed)
551
- lines.push('✓ Profile pushed to the dashboard');
552
- if (pulled === 1)
553
- lines.push('✓ Profile pulled to this machine');
554
- if (lines.length === 0)
555
- lines.push('Nothing to sync yet — run `/me init` and fill in your profile first.');
556
- ctx.app.addMessage({ role: 'system', content: `## Profile sync\n\n${lines.join('\n')}` });
526
+ ctx.app.addMessage({ role: 'system', content: formatMeSyncReport(pushed, pulled) });
557
527
  break;
558
528
  }
559
529
  if (sub === 'init') {
@@ -567,12 +537,7 @@ export async function handleCommand(command, args, ctx) {
567
537
  ctx.app.notify('Could not create the profile file.');
568
538
  break;
569
539
  }
570
- ctx.app.addMessage({
571
- role: 'system',
572
- content: res.created
573
- ? `Created ${scope} profile: \`${res.path}\`\n\nEdit it in your editor — Codeep uses it automatically. View anytime with \`/me\`.`
574
- : `${scope === 'global' ? 'Global' : 'Project'} profile already exists: \`${res.path}\`\n\nEdit it directly, or view it with \`/me\`.`,
575
- });
540
+ ctx.app.addMessage({ role: 'system', content: formatMeInitResult(scope, res) });
576
541
  break;
577
542
  }
578
543
  // Default: show the profile view.
@@ -676,6 +641,142 @@ export async function handleCommand(command, args, ctx) {
676
641
  });
677
642
  break;
678
643
  }
644
+ case 'cloud': {
645
+ // Cross-device resume: list sessions synced from other devices/Mac app
646
+ // and pull the selected one into the local store, then load it.
647
+ //
648
+ // Not linked → friendly prompt to run `codeep account`.
649
+ // Network/empty → notify (no crash).
650
+ //
651
+ // We scope to the current project when one is open, so a user on their
652
+ // laptop sees the sessions they ran on the desktop for the same repo.
653
+ // BUT: project identity is a hash of the LOCAL absolute path, so the
654
+ // same repo cloned at a different path (the normal cross-device case)
655
+ // has a different projectId. When the scoped list comes back empty we
656
+ // fall back to listing everything, and a non-empty scoped list still
657
+ // offers a "show all" escape hatch — otherwise cross-device resume
658
+ // only works when both machines use identical directory layouts.
659
+ const { listCloudSessions, pullCloudSession, generateProjectId } = await import('../utils/codeepCloud.js');
660
+ const projectId = ctx.projectPath ? generateProjectId(ctx.projectPath) : undefined;
661
+ ctx.app.notify('Fetching cloud sessions…');
662
+ let summaries = await listCloudSessions(projectId);
663
+ if (summaries === null) {
664
+ ctx.app.notify('Not linked — run `codeep account` to enable cloud sync.');
665
+ break;
666
+ }
667
+ let scopedToProject = Boolean(projectId);
668
+ if (summaries.length === 0 && projectId) {
669
+ // Nothing under this project's path-hash — try the unscoped list so
670
+ // sessions synced from a machine with a different path still show up.
671
+ const all = await listCloudSessions();
672
+ if (all && all.length > 0) {
673
+ summaries = all;
674
+ scopedToProject = false;
675
+ ctx.app.notify('No sessions matched this project — showing all cloud sessions.');
676
+ }
677
+ }
678
+ if (summaries.length === 0) {
679
+ ctx.app.notify(projectId
680
+ ? 'No cloud sessions for this project yet.'
681
+ : 'No cloud sessions yet.');
682
+ break;
683
+ }
684
+ // Non-null binding for the picker closure — TS can't carry the null
685
+ // narrowing of a reassigned `let` into the callback.
686
+ let sessionList = summaries;
687
+ const SHOW_ALL = 'Show all cloud sessions…';
688
+ const labels = summaries.map(formatCloudSessionLabel);
689
+ if (scopedToProject)
690
+ labels.push(SHOW_ALL);
691
+ // Named so the "Show all" branch can re-present the picker with the
692
+ // same handler (a const arrow can reference itself; the binding is
693
+ // initialized long before the callback can fire).
694
+ const onPickCloudSession = async (index) => {
695
+ if (scopedToProject && index === sessionList.length) {
696
+ // "Show all" — re-list unscoped. Re-dispatching /cloud would
697
+ // re-scope to the project, so fetch + present inline instead.
698
+ const all = await listCloudSessions();
699
+ if (!all || all.length === 0) {
700
+ ctx.app.notify('No other cloud sessions.');
701
+ return;
702
+ }
703
+ sessionList = all;
704
+ scopedToProject = false;
705
+ const allLabels = all.map(s => {
706
+ const date = new Date(s.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
707
+ const title = s.sessionName || s.sessionId.slice(0, 8);
708
+ const projectTag = s.projectName ? ` · ${s.projectName}` : '';
709
+ return `${title} · ${date} · ${s.messageCount} msg${projectTag}`;
710
+ });
711
+ ctx.app.showList('Cloud Sessions (all)', allLabels, onPickCloudSession);
712
+ return;
713
+ }
714
+ const selected = sessionList[index];
715
+ // The cloud id becomes a local FILENAME (saveSession joins it into
716
+ // .codeep/sessions/<name>.json) — whitelist it so a hostile or
717
+ // corrupted server response can't traverse outside the sessions dir.
718
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(selected.sessionId)) {
719
+ ctx.app.notify('Cloud session has an unexpected id format — refusing to save it locally.');
720
+ return;
721
+ }
722
+ ctx.app.notify(`Pulling ${selected.sessionName || selected.sessionId.slice(0, 8)}…`);
723
+ const full = await pullCloudSession(selected.sessionId);
724
+ if (!full) {
725
+ ctx.app.notify('Failed to pull session (network or not found).');
726
+ return;
727
+ }
728
+ // Cloud messages are {role, content}; local Message is the same shape
729
+ // plus 'system'. Filter to user/assistant (the server already does,
730
+ // but be defensive) and coerce.
731
+ const history = full.messages
732
+ .filter(m => m.role === 'user' || m.role === 'assistant')
733
+ .map(m => ({ role: m.role, content: m.content }));
734
+ if (history.length === 0) {
735
+ ctx.app.notify('Cloud session has no loadable messages.');
736
+ return;
737
+ }
738
+ // Persist locally so the resumed session is first-class: it appears
739
+ // in /sessions, autosaves on next change, and re-syncs on next turn.
740
+ // We reuse the cloud sessionId as the local name so a subsequent
741
+ // push updates the same cloud record (ON DUPLICATE KEY UPDATE).
742
+ const localName = selected.sessionId;
743
+ // If a local copy of this session exists and is NEWER than the cloud
744
+ // record (continued locally since the last sync), load it instead of
745
+ // clobbering the newer history with the older cloud copy.
746
+ try {
747
+ const { statSync, existsSync } = await import('fs');
748
+ const { join } = await import('path');
749
+ const { getSessionsDir } = await import('../config/index.js');
750
+ const localPath = join(getSessionsDir(ctx.projectPath), `${localName}.json`);
751
+ const cloudUpdatedAt = Date.parse(selected.updatedAt);
752
+ if (existsSync(localPath) && Number.isFinite(cloudUpdatedAt)
753
+ && statSync(localPath).mtimeMs > cloudUpdatedAt) {
754
+ const local = loadSession(localName, ctx.projectPath);
755
+ if (local && local.length > 0) {
756
+ ctx.app.setMessages(local);
757
+ ctx.setSessionId(localName);
758
+ config.set('currentSessionId', localName);
759
+ ctx.setSessionDisplayName?.(selected.sessionName ?? null);
760
+ ctx.app.notify('Local copy is newer than the cloud record — loaded the local session instead.');
761
+ return;
762
+ }
763
+ }
764
+ }
765
+ catch { /* mtime probe is best-effort — fall through to cloud copy */ }
766
+ saveSession(localName, history, ctx.projectPath);
767
+ ctx.app.setMessages(history);
768
+ // Keep ALL session-identity state in step, not just the renderer's
769
+ // copy: autosave + agent-mode sync read config.currentSessionId, and
770
+ // the next syncSession reads the display name — leaving either stale
771
+ // writes/renames the pulled history under the PREVIOUS session.
772
+ ctx.setSessionId(localName);
773
+ config.set('currentSessionId', localName);
774
+ ctx.setSessionDisplayName?.(selected.sessionName ?? null);
775
+ ctx.app.notify(`Resumed from cloud: ${selected.sessionName || localName}`);
776
+ };
777
+ ctx.app.showList('Cloud Sessions', labels, onPickCloudSession);
778
+ break;
779
+ }
679
780
  case 'new': {
680
781
  ctx.app.clearMessages();
681
782
  ctx.setSessionId(startNewSession());
@@ -829,7 +930,7 @@ Format: use headers per category, only include categories where you found issues
829
930
  ctx.app.notify('Usage: /rename <new-name>');
830
931
  return;
831
932
  }
832
- const newName = args.join('-');
933
+ const newName = joinSessionName(args);
833
934
  const messages = ctx.app.getMessages();
834
935
  if (messages.length === 0) {
835
936
  ctx.app.notify('No messages to save. Start a conversation first.');
@@ -865,18 +966,7 @@ Format: use headers per category, only include categories where you found issues
865
966
  }
866
967
  const searchTerm = args.join(' ').toLowerCase();
867
968
  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
- });
969
+ const searchResults = buildSearchSnippets(messages, searchTerm);
880
970
  if (searchResults.length === 0) {
881
971
  ctx.app.notify(`No matches for "${searchTerm}"`);
882
972
  }
@@ -1063,18 +1153,13 @@ Format: use headers per category, only include categories where you found issues
1063
1153
  case 'copy': {
1064
1154
  const blockNum = args[0] ? parseInt(args[0], 10) : -1;
1065
1155
  const messages = ctx.app.getMessages();
1066
- const codeBlocks = [];
1067
- for (const msg of messages) {
1068
- for (const match of msg.content.matchAll(/```[\w]*\n([\s\S]*?)```/g)) {
1069
- codeBlocks.push(match[1]);
1070
- }
1071
- }
1156
+ const codeBlocks = messages.flatMap(m => extractCodeBlocks(m.content));
1072
1157
  if (codeBlocks.length === 0) {
1073
1158
  ctx.app.notify('No code blocks found');
1074
1159
  return;
1075
1160
  }
1076
- const index = blockNum === -1 ? codeBlocks.length - 1 : blockNum - 1;
1077
- if (Number.isNaN(index) || index < 0 || index >= codeBlocks.length) {
1161
+ const index = resolveBlockIndex(blockNum, codeBlocks.length);
1162
+ if (index === null) {
1078
1163
  ctx.app.notify(`Invalid block number. Available: 1-${codeBlocks.length}`);
1079
1164
  return;
1080
1165
  }
@@ -1112,20 +1197,7 @@ Format: use headers per category, only include categories where you found issues
1112
1197
  ctx.app.notify('No assistant response to apply');
1113
1198
  return;
1114
1199
  }
1115
- const changes = [];
1116
- const fenceFilePattern = /```\w*\s+([\w./\\-]+(?:\.\w+))\n([\s\S]*?)```/g;
1117
- let match;
1118
- while ((match = fenceFilePattern.exec(lastAssistant.content)) !== null) {
1119
- const p = match[1].trim();
1120
- if (p.includes('.') && !p.includes(' '))
1121
- changes.push({ path: p, content: match[2] });
1122
- }
1123
- if (changes.length === 0) {
1124
- const commentPattern = /```(\w+)?\s*\n(?:\/\/|#|--|\/\*)\s*(?:File|Path|file|path):\s*([^\n*]+)\n([\s\S]*?)```/g;
1125
- while ((match = commentPattern.exec(lastAssistant.content)) !== null) {
1126
- changes.push({ path: match[2].trim(), content: match[3] });
1127
- }
1128
- }
1200
+ const changes = extractFileChanges(lastAssistant.content);
1129
1201
  if (changes.length === 0) {
1130
1202
  ctx.app.notify('No file changes found in response');
1131
1203
  return;
@@ -1134,64 +1206,168 @@ Format: use headers per category, only include categories where you found issues
1134
1206
  ctx.app.notify('Write access required. Use /grant first.');
1135
1207
  return;
1136
1208
  }
1209
+ // Parse optional selective hunk spec: /apply --only file.ts:0,1 other.ts:2
1210
+ // Without --only, all hunks are applied (existing behavior).
1211
+ const selective = args.includes('--only') || args.includes('-o');
1212
+ const interactive = args.includes('--interactive') || args.includes('-i');
1213
+ const hunkSpecs = new Map();
1214
+ if (selective) {
1215
+ for (const a of args) {
1216
+ if (a === '--only' || a === '-o')
1217
+ continue;
1218
+ const m = a.match(/^(.+):([\d,]+)$/);
1219
+ if (m) {
1220
+ const [, file, idxStr] = m;
1221
+ const idxs = new Set(idxStr.split(',').map((n) => parseInt(n, 10)).filter((n) => !isNaN(n)));
1222
+ hunkSpecs.set(file, idxs);
1223
+ }
1224
+ }
1225
+ // A spec that parsed to nothing must NOT fall through to the
1226
+ // apply-everything branch below — the user asked to restrict the
1227
+ // apply, so writing every change is the opposite of the request.
1228
+ if (hunkSpecs.size === 0) {
1229
+ ctx.app.notify('Invalid --only spec. Expected `--only <file>:<hunk>[,<hunk>]` (e.g. --only src/a.ts:0,2). Nothing applied.');
1230
+ return;
1231
+ }
1232
+ }
1137
1233
  import('fs').then(async (fs) => {
1138
1234
  import('path').then(async (pathModule) => {
1235
+ const { createFileDiff, applyHunksToFiles, countChangeHunks } = await import('../utils/diffPreview.js');
1139
1236
  const diffLines = [];
1237
+ const fileDiffs = [];
1140
1238
  for (const change of changes) {
1141
1239
  const fullPath = pathModule.isAbsolute(change.path)
1142
1240
  ? change.path
1143
1241
  : pathModule.join(ctx.projectPath, change.path);
1144
- const shortPath = change.path.length > 40 ? '...' + change.path.slice(-37) : change.path;
1145
1242
  let existingContent = '';
1146
1243
  try {
1147
1244
  existingContent = await fs.promises.readFile(fullPath, 'utf-8');
1148
1245
  }
1149
1246
  catch { }
1150
- if (!existingContent) {
1151
- diffLines.push(`+ CREATE: ${shortPath}`);
1152
- diffLines.push(` (${change.content.split('\n').length} lines)`);
1247
+ const fd = createFileDiff(change.path, change.content, ctx.projectPath);
1248
+ fileDiffs.push(fd);
1249
+ const hunkCount = countChangeHunks(fd);
1250
+ diffLines.push(...formatApplyDiffLine(change, existingContent));
1251
+ if (hunkCount > 0) {
1252
+ diffLines.push(` ↳ ${hunkCount} hunk(s) — use /apply --only ${change.path}:0,1 to select`);
1153
1253
  }
1154
- else {
1155
- const oldLines = existingContent.split('\n').length;
1156
- const newLines = change.content.split('\n').length;
1157
- const lineDiff = newLines - oldLines;
1158
- diffLines.push(`~ MODIFY: ${shortPath}`);
1159
- diffLines.push(` ${oldLines} → ${newLines} lines (${lineDiff >= 0 ? '+' : ''}${lineDiff})`);
1254
+ }
1255
+ // Interactive mode: open the hunk picker (`git add -p` style).
1256
+ if (interactive) {
1257
+ const items = [];
1258
+ for (const fd of fileDiffs) {
1259
+ for (let hi = 0; hi < fd.hunks.length; hi++) {
1260
+ const hunk = fd.hunks[hi];
1261
+ // Skip pure-context hunks (no add/remove).
1262
+ if (!hunk.lines.some((l) => l.type === 'add' || l.type === 'remove'))
1263
+ continue;
1264
+ const header = `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
1265
+ const lines = hunk.lines.map((l) => {
1266
+ if (l.type === 'add')
1267
+ return `+${l.content}`;
1268
+ if (l.type === 'remove')
1269
+ return `-${l.content}`;
1270
+ return ` ${l.content}`;
1271
+ });
1272
+ items.push({ path: fd.path, hunkIndex: hi, header, lines });
1273
+ }
1160
1274
  }
1275
+ if (items.length === 0) {
1276
+ ctx.app.notify('No change hunks to review');
1277
+ return;
1278
+ }
1279
+ ctx.app.showHunkPicker({
1280
+ title: '🎯 Review hunks',
1281
+ items,
1282
+ onComplete: (accepted) => {
1283
+ if (accepted.length === 0) {
1284
+ ctx.app.notify('No hunks applied');
1285
+ return;
1286
+ }
1287
+ (async () => {
1288
+ // Group accepted hunk indices by file path.
1289
+ const byPath = new Map();
1290
+ for (const a of accepted) {
1291
+ let set = byPath.get(a.path);
1292
+ if (!set) {
1293
+ set = new Set();
1294
+ byPath.set(a.path, set);
1295
+ }
1296
+ set.add(a.hunkIndex);
1297
+ }
1298
+ const results = applyHunksToFiles(fileDiffs, byPath);
1299
+ let applied = 0;
1300
+ for (const r of results) {
1301
+ try {
1302
+ const fullPath = pathModule.isAbsolute(r.path)
1303
+ ? r.path
1304
+ : pathModule.join(ctx.projectPath, r.path);
1305
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1306
+ await fs.promises.writeFile(fullPath, r.content);
1307
+ applied++;
1308
+ }
1309
+ catch { }
1310
+ }
1311
+ ctx.app.notify(`Applied ${accepted.length} hunk(s) across ${applied} file(s)`);
1312
+ })().catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1313
+ },
1314
+ });
1315
+ return;
1161
1316
  }
1317
+ const summary = selective && hunkSpecs.size > 0
1318
+ ? `Selective apply (${hunkSpecs.size} file(s) with chosen hunks)`
1319
+ : `Found ${changes.length} file(s) to apply`;
1162
1320
  ctx.app.showConfirm({
1163
1321
  title: '📝 Apply Changes',
1164
1322
  message: [
1165
- `Found ${changes.length} file(s) to apply:`,
1323
+ summary,
1166
1324
  '',
1167
- ...diffLines.slice(0, 10),
1168
- ...(diffLines.length > 10 ? [` ...and ${diffLines.length - 10} more`] : []),
1325
+ ...diffLines.slice(0, 12),
1326
+ ...(diffLines.length > 12 ? [` ...and ${diffLines.length - 12} more`] : []),
1169
1327
  '',
1170
- 'Apply these changes?',
1328
+ selective && hunkSpecs.size > 0 ? 'Apply selected hunks?' : 'Apply these changes?',
1171
1329
  ],
1172
1330
  confirmLabel: 'Apply',
1173
1331
  cancelLabel: 'Cancel',
1174
1332
  onConfirm: () => {
1175
1333
  (async () => {
1176
1334
  let applied = 0;
1177
- for (const change of changes) {
1178
- try {
1179
- const fullPath = pathModule.isAbsolute(change.path)
1180
- ? change.path
1181
- : pathModule.join(ctx.projectPath, change.path);
1182
- await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1183
- await fs.promises.writeFile(fullPath, change.content);
1184
- applied++;
1335
+ if (selective && hunkSpecs.size > 0) {
1336
+ // Per-hunk selective apply.
1337
+ const results = applyHunksToFiles(fileDiffs, hunkSpecs);
1338
+ for (const r of results) {
1339
+ try {
1340
+ const fullPath = pathModule.isAbsolute(r.path)
1341
+ ? r.path
1342
+ : pathModule.join(ctx.projectPath, r.path);
1343
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1344
+ await fs.promises.writeFile(fullPath, r.content);
1345
+ applied++;
1346
+ }
1347
+ catch { }
1185
1348
  }
1186
- catch { }
1187
1349
  }
1188
- ctx.app.notify(`Applied ${applied}/${changes.length} file(s)`);
1189
- })();
1350
+ else {
1351
+ // All-or-nothing apply (original behavior).
1352
+ for (const change of changes) {
1353
+ try {
1354
+ const fullPath = pathModule.isAbsolute(change.path)
1355
+ ? change.path
1356
+ : pathModule.join(ctx.projectPath, change.path);
1357
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1358
+ await fs.promises.writeFile(fullPath, change.content);
1359
+ applied++;
1360
+ }
1361
+ catch { }
1362
+ }
1363
+ }
1364
+ ctx.app.notify(`Applied ${applied}/${selective ? hunkSpecs.size : changes.length} file(s)`);
1365
+ })().catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1190
1366
  },
1191
1367
  onCancel: () => ctx.app.notify('Apply cancelled'),
1192
1368
  });
1193
1369
  });
1194
- });
1370
+ }).catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1195
1371
  break;
1196
1372
  }
1197
1373
  case 'add': {
@@ -1302,7 +1478,7 @@ Format: use headers per category, only include categories where you found issues
1302
1478
  }
1303
1479
  case 'compact': {
1304
1480
  const messages = ctx.app.getMessages();
1305
- const keepRecent = args[0] ? Math.max(2, parseInt(args[0], 10) || 4) : 4;
1481
+ const keepRecent = parseKeepRecent(args[0]);
1306
1482
  if (messages.length <= keepRecent + 2) {
1307
1483
  ctx.app.notify(`Nothing to compact — only ${messages.length} message(s) in this session.`);
1308
1484
  break;
@@ -1646,10 +1822,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1646
1822
  ctx.app.notify(`Skill ${name} not found`);
1647
1823
  break;
1648
1824
  }
1649
- ctx.app.addMessage({
1650
- role: 'system',
1651
- content: `# ${bundle.name}\n_${bundle.description}_\n\n**Source:** ${bundle.source}\n\n---\n\n${bundle.body}`,
1652
- });
1825
+ ctx.app.addMessage({ role: 'system', content: formatSkillsShow(bundle) });
1653
1826
  break;
1654
1827
  }
1655
1828
  // Marketplace operations against codeep.dev.
@@ -1667,10 +1840,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1667
1840
  ctx.app.notify(`Publish failed: ${result.error}`);
1668
1841
  break;
1669
1842
  }
1670
- ctx.app.addMessage({
1671
- role: 'system',
1672
- content: `Published \`${slug}\` (${isPublic ? 'public' : 'private'}) to codeep.dev. Install elsewhere with \`/skills install ${result.skill?.owner_username ?? '<you>'}/${slug}\`.`,
1673
- });
1843
+ ctx.app.addMessage({ role: 'system', content: formatSkillsPublishResult(slug, isPublic, result.skill?.owner_username) });
1674
1844
  break;
1675
1845
  }
1676
1846
  if (sub === 'install') {
@@ -1700,7 +1870,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1700
1870
  }
1701
1871
  const skills = result.skills ?? [];
1702
1872
  if (skills.length === 0) {
1703
- ctx.app.addMessage({ role: 'system', content: query ? `_No public skills matching "${query}"._` : '_No public skills published yet._' });
1873
+ ctx.app.addMessage({ role: 'system', content: formatSkillsBrowseEmpty(query) });
1704
1874
  break;
1705
1875
  }
1706
1876
  const lines = [`# ${query ? `Skills matching "${query}"` : 'Public skills'}`, ''];
@@ -1894,31 +2064,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1894
2064
  // as the description — the same field the dashboard + macOS app set, and
1895
2065
  // which the list view and the agent task-context prompt already render.
1896
2066
  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();
2067
+ const { title, description, type } = parseTaskAddArgs(args.slice(1));
1922
2068
  if (!title) {
1923
2069
  ctx.app.notify('Usage: /tasks add <title> [--bug | --feature] [--desc <text>]');
1924
2070
  break;
@@ -1963,18 +2109,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1963
2109
  break;
1964
2110
  }
1965
2111
  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') });
2112
+ ctx.app.addMessage({ role: 'system', content: formatTaskList(tasks, projectName) });
1978
2113
  break;
1979
2114
  }
1980
2115
  case 'profile': {
@@ -1986,7 +2121,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1986
2121
  ctx.app.notify('No profiles saved. Use /profile save <name>');
1987
2122
  }
1988
2123
  else {
1989
- ctx.app.addMessage({ role: 'system', content: `## Profiles\n\n${profiles.map(p => `- ${p}`).join('\n')}\n\nUse /profile load <name> to apply.` });
2124
+ ctx.app.addMessage({ role: 'system', content: formatProfileList(profiles) });
1990
2125
  }
1991
2126
  break;
1992
2127
  }
@@ -2127,54 +2262,15 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2127
2262
  case 'stats': {
2128
2263
  const { getCostBreakdown, getSessionStats, formatTokenCount, getPricingTable, getCacheStats } = await import('../utils/tokenTracker.js');
2129
2264
  const stats = getSessionStats();
2130
- const lines = ['## Session Cost', ''];
2131
- if (stats.requestCount === 0) {
2132
- lines.push('*No API calls made yet this session.*');
2133
- lines.push('');
2134
- }
2135
- else {
2136
- lines.push(`Requests: ${stats.requestCount}`);
2137
- lines.push(`Tokens: ${formatTokenCount(stats.totalTokens)} total (${formatTokenCount(stats.totalPromptTokens)} in / ${formatTokenCount(stats.totalCompletionTokens)} out)`);
2138
- const breakdown = getCostBreakdown();
2139
- if (breakdown.length > 0) {
2140
- lines.push('');
2141
- lines.push('### By model');
2142
- for (const b of breakdown) {
2143
- const isFree = b.provider === 'ollama';
2144
- const costStr = isFree ? 'free' : b.estimatedCost > 0 ? `~$${b.estimatedCost.toFixed(4)}` : '(no pricing data)';
2145
- lines.push(`- **${b.model}** (${b.provider}): ${formatTokenCount(b.promptTokens)} in / ${formatTokenCount(b.completionTokens)} out — ${costStr}`);
2146
- }
2147
- lines.push('');
2148
- const currentProvider = config.get('provider');
2149
- if (currentProvider === 'ollama') {
2150
- lines.push(`**Total: free · ${formatTokenCount(stats.totalTokens)} tokens**`);
2151
- }
2152
- else if (stats.estimatedCost > 0) {
2153
- lines.push(`**Total: ~$${stats.estimatedCost.toFixed(4)}**`);
2154
- }
2155
- }
2156
- // Prompt caching — parity with /cost (the 2.0.2 caching section was
2157
- // only wired into formatCostReport). Shown only when caching landed.
2158
- const cache = getCacheStats();
2159
- if (cache.cacheReadTokens > 0 || cache.cacheCreationTokens > 0) {
2160
- lines.push('', '### Prompt caching');
2161
- lines.push(`Cache reads: ${formatTokenCount(cache.cacheReadTokens)} tokens (billed at 0.1× input rate)`);
2162
- if (cache.cacheCreationTokens > 0) {
2163
- lines.push(`Cache writes: ${formatTokenCount(cache.cacheCreationTokens)} tokens (billed at 1.25× input rate)`);
2164
- }
2165
- if (cache.estimatedSavingsUsd > 0) {
2166
- lines.push(`Estimated savings vs no caching: $${cache.estimatedSavingsUsd.toFixed(4)}`);
2167
- }
2168
- }
2169
- lines.push('');
2170
- }
2171
- lines.push('### Pricing (per 1M tokens)');
2172
- lines.push('| Model | Input | Output |');
2173
- lines.push('|---|---|---|');
2174
- for (const p of getPricingTable()) {
2175
- lines.push(`| ${p.model} | $${p.inputPer1M.toFixed(3)} | $${p.outputPer1M.toFixed(3)} |`);
2176
- }
2177
- ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
2265
+ const content = formatStatsReport({
2266
+ totals: stats,
2267
+ breakdown: getCostBreakdown(),
2268
+ cache: getCacheStats(),
2269
+ pricing: getPricingTable(),
2270
+ currentProvider: config.get('provider'),
2271
+ fmt: formatTokenCount,
2272
+ });
2273
+ ctx.app.addMessage({ role: 'system', content });
2178
2274
  break;
2179
2275
  }
2180
2276
  case 'memory': {
@@ -2192,8 +2288,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2192
2288
  ctx.app.notify('No memory notes. Add one with: /memory <note>');
2193
2289
  }
2194
2290
  else {
2195
- const lines = intelligence.notes.map((n, i) => ` ${i + 1}. ${n}`).join('\n');
2196
- ctx.app.addMessage({ role: 'assistant', content: `**Project memory notes:**\n${lines}` });
2291
+ ctx.app.addMessage({ role: 'assistant', content: formatMemoryList(intelligence.notes) });
2197
2292
  }
2198
2293
  break;
2199
2294
  }
@@ -2234,6 +2329,29 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2234
2329
  ctx.app.addMessage({ role: 'system', content: formatCommandList(loadCustomCommands(ctx.projectPath)) });
2235
2330
  break;
2236
2331
  }
2332
+ case 'web-cache': {
2333
+ const sub = args[0]?.toLowerCase();
2334
+ const { clearWebCache, webCacheStats } = await import('../utils/webFetch.js');
2335
+ if (sub === 'clear' || sub === 'reset' || sub === 'flush') {
2336
+ clearWebCache();
2337
+ ctx.app.notify('Web cache cleared');
2338
+ }
2339
+ else {
2340
+ const stats = webCacheStats();
2341
+ ctx.app.addMessage({
2342
+ role: 'system',
2343
+ content: [
2344
+ '🌐 Web fetch cache',
2345
+ '',
2346
+ ` Entries: ${stats.entries}/${stats.maxEntries}`,
2347
+ ` TTL: ${stats.ttlMinutes} min`,
2348
+ '',
2349
+ 'Usage: /web-cache clear',
2350
+ ].join('\n'),
2351
+ });
2352
+ }
2353
+ break;
2354
+ }
2237
2355
  case 'mcp': {
2238
2356
  // Mirrors the ACP `/mcp` handler in src/acp/commands.ts. In TUI the
2239
2357
  // session id is the constant `codeep-tui` (the same one main.ts uses
@@ -2251,8 +2369,36 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2251
2369
  }
2252
2370
  return true;
2253
2371
  };
2254
- const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfig } = await import('../utils/mcpConfig.js');
2372
+ const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfig, loadMcpServerConfigSplit, isWorkspaceMcpTrusted, trustWorkspaceMcp, untrustWorkspaceMcp } = await import('../utils/mcpConfig.js');
2255
2373
  const { registerSessionServers } = await import('../utils/mcpRegistry.js');
2374
+ if (sub === 'trust') {
2375
+ if (!requireProject())
2376
+ break;
2377
+ if (isWorkspaceMcpTrusted(projectPath)) {
2378
+ ctx.app.notify('Workspace MCP servers are already trusted here.');
2379
+ break;
2380
+ }
2381
+ trustWorkspaceMcp(projectPath);
2382
+ const { workspace } = loadMcpServerConfigSplit(projectPath);
2383
+ if (workspace.length === 0) {
2384
+ ctx.app.notify('Workspace trusted — no workspace MCP servers defined yet.');
2385
+ break;
2386
+ }
2387
+ ctx.app.notify(`Workspace trusted. Spawning ${workspace.length} MCP server(s)…`);
2388
+ const { registered, errors } = await registerSessionServers(TUI_SESSION, workspace, { workspaceRoot: projectPath });
2389
+ if (registered.length > 0)
2390
+ ctx.app.notify(`MCP: ${registered.length} tool(s) ready. Type /mcp.`);
2391
+ for (const e of errors)
2392
+ ctx.app.notifyWarn(`MCP server "${e.server}" failed: ${e.error}`);
2393
+ break;
2394
+ }
2395
+ if (sub === 'untrust') {
2396
+ if (!requireProject())
2397
+ break;
2398
+ untrustWorkspaceMcp(projectPath);
2399
+ ctx.app.notify('Workspace MCP trust revoked — workspace servers won\'t spawn on next start. (Running servers stop when you exit.)');
2400
+ break;
2401
+ }
2256
2402
  if (sub === 'add') {
2257
2403
  if (!requireProject())
2258
2404
  break;
@@ -2364,35 +2510,14 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2364
2510
  ctx.app.notify('Reloading MCP server config…');
2365
2511
  const merged = loadMcpServerConfig(projectPath);
2366
2512
  const { registered, errors } = await registerSessionServers(TUI_SESSION, merged, { workspaceRoot: projectPath });
2367
- const lines = [`## MCP reloaded`, '', `**${registered.length}** tool${registered.length === 1 ? '' : 's'} from **${merged.length}** server${merged.length === 1 ? '' : 's'}.`];
2368
- if (errors.length > 0) {
2369
- lines.push('', '### Failed servers');
2370
- for (const e of errors)
2371
- lines.push(`- **${e.server}** — \`${e.error}\``);
2372
- }
2373
- ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
2513
+ ctx.app.addMessage({ role: 'system', content: formatMcpReloadReport(registered.length, merged.length, errors) });
2374
2514
  break;
2375
2515
  }
2376
2516
  if (sub === 'resources') {
2377
2517
  const { getSessionResources, awaitSessionReady } = await import('../utils/mcpRegistry.js');
2378
2518
  await awaitSessionReady(TUI_SESSION);
2379
2519
  const groups = await getSessionResources(TUI_SESSION);
2380
- if (groups.length === 0) {
2381
- ctx.app.addMessage({ role: 'system', content: '_No MCP server in this session exposes resources._' });
2382
- break;
2383
- }
2384
- const lines = ['## MCP resources', ''];
2385
- for (const g of groups) {
2386
- lines.push(`**${g.serverName}** — ${g.resources.length} resource${g.resources.length === 1 ? '' : 's'}`);
2387
- for (const r of g.resources) {
2388
- const label = r.name ? `${r.name} — ` : '';
2389
- const mime = r.mimeType ? ` (${r.mimeType})` : '';
2390
- lines.push(`- ${label}\`${r.uri}\`${mime}${r.description ? ` — ${r.description}` : ''}`);
2391
- }
2392
- lines.push('');
2393
- }
2394
- lines.push('Read one with `/mcp read <uri>`.');
2395
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2520
+ ctx.app.addMessage({ role: 'system', content: formatMcpResourcesList(groups) });
2396
2521
  break;
2397
2522
  }
2398
2523
  if (sub === 'read') {
@@ -2404,23 +2529,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2404
2529
  const { readSessionResource } = await import('../utils/mcpRegistry.js');
2405
2530
  try {
2406
2531
  const contents = await readSessionResource(TUI_SESSION, uri);
2407
- if (contents.length === 0) {
2408
- ctx.app.addMessage({ role: 'system', content: `_No content returned for \`${uri}\`._` });
2409
- break;
2410
- }
2411
- const lines = [`## Resource: \`${uri}\``, ''];
2412
- for (const c of contents) {
2413
- if (c.text !== undefined) {
2414
- const fence = c.mimeType?.includes('json') ? 'json' : c.mimeType?.includes('markdown') ? 'markdown' : '';
2415
- lines.push('```' + fence);
2416
- lines.push(c.text);
2417
- lines.push('```');
2418
- }
2419
- else if (c.blob) {
2420
- lines.push(`_(${c.mimeType ?? 'binary'} blob, ${c.blob.length} base64 chars — not rendered)_`);
2421
- }
2422
- }
2423
- ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
2532
+ ctx.app.addMessage({ role: 'system', content: formatMcpResourceRead(uri, contents) });
2424
2533
  }
2425
2534
  catch (err) {
2426
2535
  ctx.app.addMessage({ role: 'system', content: `Failed to read \`${uri}\`: ${err.message}` });
@@ -2431,23 +2540,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2431
2540
  const { getSessionPrompts, awaitSessionReady } = await import('../utils/mcpRegistry.js');
2432
2541
  await awaitSessionReady(TUI_SESSION);
2433
2542
  const groups = await getSessionPrompts(TUI_SESSION);
2434
- if (groups.length === 0) {
2435
- ctx.app.addMessage({ role: 'system', content: '_No MCP server in this session exposes prompt templates._' });
2436
- break;
2437
- }
2438
- const lines = ['## MCP prompt templates', ''];
2439
- for (const g of groups) {
2440
- lines.push(`**${g.serverName}** — ${g.prompts.length} prompt${g.prompts.length === 1 ? '' : 's'}`);
2441
- for (const p of g.prompts) {
2442
- const argList = p.arguments?.length
2443
- ? ` (${p.arguments.map(a => a.required ? a.name : `[${a.name}]`).join(', ')})`
2444
- : '';
2445
- lines.push(`- \`${p.name}\`${argList}${p.description ? ` — ${p.description}` : ''}`);
2446
- }
2447
- lines.push('');
2448
- }
2449
- lines.push('Materialise one with `/mcp prompt <server> <name> [key=value...]`.');
2450
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2543
+ ctx.app.addMessage({ role: 'system', content: formatMcpPromptsList(groups) });
2451
2544
  break;
2452
2545
  }
2453
2546
  if (sub === 'prompt') {
@@ -2457,25 +2550,11 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2457
2550
  ctx.app.addMessage({ role: 'system', content: 'Usage: `/mcp prompt <server> <name> [key=value ...]`' });
2458
2551
  break;
2459
2552
  }
2460
- const promptArgs = {};
2461
- for (const tok of args.slice(3)) {
2462
- const eq = tok.indexOf('=');
2463
- if (eq > 0)
2464
- promptArgs[tok.slice(0, eq)] = tok.slice(eq + 1);
2465
- }
2553
+ const promptArgs = parsePromptArgs(args.slice(3));
2466
2554
  const { getSessionPrompt } = await import('../utils/mcpRegistry.js');
2467
2555
  try {
2468
2556
  const { description, messages } = await getSessionPrompt(TUI_SESSION, serverName, name, promptArgs);
2469
- const lines = [`## Prompt \`${serverName}/${name}\``];
2470
- if (description)
2471
- lines.push(`_${description}_`);
2472
- lines.push('');
2473
- for (const m of messages) {
2474
- const text = typeof m.content?.text === 'string' ? m.content.text : JSON.stringify(m.content);
2475
- lines.push(`**${m.role}:** ${text}`);
2476
- lines.push('');
2477
- }
2478
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2557
+ ctx.app.addMessage({ role: 'system', content: formatMcpPromptResult(serverName, name, description, messages) });
2479
2558
  }
2480
2559
  catch (err) {
2481
2560
  ctx.app.addMessage({ role: 'system', content: `Failed to materialise prompt: ${err.message}` });
@@ -2487,41 +2566,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2487
2566
  await awaitSessionReady(TUI_SESSION);
2488
2567
  const tools = await getSessionTools(TUI_SESSION);
2489
2568
  const mcpErrors = getSessionRegistrationErrors(TUI_SESSION);
2490
- if (tools.length === 0 && mcpErrors.length === 0) {
2491
- ctx.app.addMessage({
2492
- role: 'system',
2493
- content: [
2494
- '_No MCP servers connected to this session._',
2495
- '',
2496
- 'Add one with `/mcp add <name> <command> [args...]` — it persists to `.codeep/mcp_servers.json`.',
2497
- 'Or browse the marketplace with `/mcp browse` and install with `/mcp install <id>`.',
2498
- ].join('\n'),
2499
- });
2500
- break;
2501
- }
2502
- const lines = ['## MCP servers', ''];
2503
- if (tools.length > 0) {
2504
- const byServer = new Map();
2505
- for (const t of tools) {
2506
- if (!byServer.has(t.serverName))
2507
- byServer.set(t.serverName, []);
2508
- byServer.get(t.serverName).push(t);
2509
- }
2510
- for (const [serverName, serverTools] of byServer) {
2511
- lines.push(`**${serverName}** — ${serverTools.length} tool${serverTools.length === 1 ? '' : 's'}`);
2512
- for (const t of serverTools) {
2513
- const desc = t.description ? ` — ${t.description}` : '';
2514
- lines.push(`- \`${t.agentName}\`${desc}`);
2515
- }
2516
- lines.push('');
2517
- }
2518
- }
2519
- if (mcpErrors.length > 0) {
2520
- lines.push('### Failed servers');
2521
- for (const e of mcpErrors)
2522
- lines.push(`- **${e.server}** — \`${e.error}\``);
2523
- }
2524
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2569
+ ctx.app.addMessage({ role: 'system', content: formatMcpServerList(tools, mcpErrors) });
2525
2570
  break;
2526
2571
  }
2527
2572
  default: {