codeep 1.3.42 → 2.0.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 (59) hide show
  1. package/README.md +208 -0
  2. package/dist/acp/commands.js +770 -7
  3. package/dist/acp/protocol.d.ts +11 -2
  4. package/dist/acp/server.js +179 -11
  5. package/dist/acp/session.d.ts +3 -0
  6. package/dist/acp/session.js +5 -0
  7. package/dist/api/index.js +39 -6
  8. package/dist/config/index.d.ts +13 -0
  9. package/dist/config/index.js +45 -0
  10. package/dist/config/providers.js +76 -1
  11. package/dist/renderer/App.d.ts +12 -0
  12. package/dist/renderer/App.js +96 -4
  13. package/dist/renderer/agentExecution.js +5 -0
  14. package/dist/renderer/commands.js +348 -2
  15. package/dist/renderer/components/Login.d.ts +1 -0
  16. package/dist/renderer/components/Login.js +24 -9
  17. package/dist/renderer/handlers.d.ts +11 -1
  18. package/dist/renderer/handlers.js +30 -0
  19. package/dist/renderer/main.js +73 -0
  20. package/dist/utils/agent.d.ts +17 -0
  21. package/dist/utils/agent.js +91 -7
  22. package/dist/utils/agentChat.d.ts +10 -2
  23. package/dist/utils/agentChat.js +48 -9
  24. package/dist/utils/agentStream.js +6 -2
  25. package/dist/utils/checkpoints.d.ts +93 -0
  26. package/dist/utils/checkpoints.js +205 -0
  27. package/dist/utils/context.d.ts +24 -0
  28. package/dist/utils/context.js +57 -0
  29. package/dist/utils/customCommands.d.ts +62 -0
  30. package/dist/utils/customCommands.js +201 -0
  31. package/dist/utils/hooks.d.ts +97 -0
  32. package/dist/utils/hooks.js +223 -0
  33. package/dist/utils/mcpClient.d.ts +229 -0
  34. package/dist/utils/mcpClient.js +497 -0
  35. package/dist/utils/mcpConfig.d.ts +55 -0
  36. package/dist/utils/mcpConfig.js +177 -0
  37. package/dist/utils/mcpMarketplace.d.ts +49 -0
  38. package/dist/utils/mcpMarketplace.js +175 -0
  39. package/dist/utils/mcpRegistry.d.ts +129 -0
  40. package/dist/utils/mcpRegistry.js +427 -0
  41. package/dist/utils/mcpSamplingBridge.d.ts +32 -0
  42. package/dist/utils/mcpSamplingBridge.js +88 -0
  43. package/dist/utils/mcpStreamableHttp.d.ts +65 -0
  44. package/dist/utils/mcpStreamableHttp.js +207 -0
  45. package/dist/utils/openrouterPrefs.d.ts +36 -0
  46. package/dist/utils/openrouterPrefs.js +83 -0
  47. package/dist/utils/skillBundles.d.ts +84 -0
  48. package/dist/utils/skillBundles.js +257 -0
  49. package/dist/utils/skillBundlesCloud.d.ts +66 -0
  50. package/dist/utils/skillBundlesCloud.js +196 -0
  51. package/dist/utils/tokenTracker.d.ts +14 -2
  52. package/dist/utils/tokenTracker.js +59 -41
  53. package/dist/utils/toolExecution.d.ts +17 -1
  54. package/dist/utils/toolExecution.js +184 -6
  55. package/dist/utils/tools.d.ts +22 -6
  56. package/dist/utils/tools.js +83 -8
  57. package/package.json +3 -2
  58. package/bin/codeep-macos-arm64 +0 -0
  59. package/bin/codeep-macos-x64 +0 -0
@@ -96,6 +96,30 @@ export async function handleCommand(command, args, ctx) {
96
96
  const providerId = config.get('provider');
97
97
  const { isDynamicModelsProvider, isNoApiKeyProvider: _noKey } = await import('../config/providers.js');
98
98
  if (isDynamicModelsProvider(providerId)) {
99
+ if (providerId === 'openrouter') {
100
+ ctx.app.notify('Fetching OpenRouter catalog…');
101
+ const { fetchOpenRouterModels } = await import('../config/index.js');
102
+ const apiKey = (await import('../config/index.js')).getApiKey('openrouter');
103
+ const models = await fetchOpenRouterModels(apiKey || undefined);
104
+ if (!models || models.length === 0) {
105
+ ctx.app.notify('Could not fetch OpenRouter models (network? key?). Using built-in shortlist.');
106
+ const fallback = getModelsForCurrentProvider();
107
+ const currentModel = config.get('model');
108
+ const modelItems = Object.keys(fallback).map(id => ({ key: id, label: fallback[id], description: '' }));
109
+ ctx.app.showSelect('Select OpenRouter Model', modelItems, currentModel, (item) => {
110
+ config.set('model', item.key);
111
+ ctx.app.notify(`Model: ${item.key}`);
112
+ });
113
+ break;
114
+ }
115
+ const modelItems = models.map(m => ({ key: m.id, label: m.id, description: m.description }));
116
+ const currentModel = config.get('model');
117
+ ctx.app.showSelect(`Select OpenRouter Model (${models.length})`, modelItems, currentModel, (item) => {
118
+ config.set('model', item.key);
119
+ ctx.app.notify(`Model: ${item.key}`);
120
+ });
121
+ break;
122
+ }
99
123
  const { fetchOllamaModels } = await import('../config/index.js');
100
124
  ctx.app.notify('Fetching models from Ollama...');
101
125
  const ollamaModels = await fetchOllamaModels();
@@ -491,7 +515,7 @@ Format: use headers per category, only include categories where you found issues
491
515
  }
492
516
  case 'login': {
493
517
  const providers = getProviderList();
494
- ctx.app.showLogin(providers.map(p => ({ id: p.id, name: p.name, subscribeUrl: p.subscribeUrl, noApiKey: p.noApiKey })), async (result) => {
518
+ ctx.app.showLogin(providers.map(p => ({ id: p.id, name: p.name, description: p.description, subscribeUrl: p.subscribeUrl, noApiKey: p.noApiKey })), async (result) => {
495
519
  if (result) {
496
520
  setProvider(result.providerId);
497
521
  await setApiKey(result.apiKey);
@@ -790,6 +814,168 @@ Format: use headers per category, only include categories where you found issues
790
814
  }).catch(() => ctx.app.notify('No changes tracked'));
791
815
  break;
792
816
  }
817
+ case 'cost': {
818
+ const { formatCostReport } = await import('../utils/tokenTracker.js');
819
+ ctx.app.addMessage({ role: 'system', content: formatCostReport() });
820
+ break;
821
+ }
822
+ case 'compact': {
823
+ const messages = ctx.app.getMessages();
824
+ const keepRecent = args[0] ? Math.max(2, parseInt(args[0], 10) || 4) : 4;
825
+ if (messages.length <= keepRecent + 2) {
826
+ ctx.app.notify(`Nothing to compact — only ${messages.length} message(s) in this session.`);
827
+ break;
828
+ }
829
+ ctx.app.notify(`Compacting ${messages.length - keepRecent} older message(s)…`);
830
+ const { compactHistory } = await import('../utils/context.js');
831
+ try {
832
+ const result = await compactHistory(messages, { keepRecent, projectContext: ctx.projectContext });
833
+ if (result.replaced === 0) {
834
+ ctx.app.notify('Nothing to compact');
835
+ break;
836
+ }
837
+ ctx.app.setMessages(result.compacted);
838
+ // Persist so the compacted history survives a restart.
839
+ saveSession(ctx.sessionId, result.compacted, ctx.projectPath);
840
+ ctx.app.addMessage({
841
+ role: 'system',
842
+ content: `# Conversation Compacted\n\nReplaced ${result.replaced} earlier message${result.replaced === 1 ? '' : 's'} with a summary. Kept the last ${keepRecent} message${keepRecent === 1 ? '' : 's'} verbatim.\n\n**Summary:**\n\n${result.summary}`,
843
+ });
844
+ }
845
+ catch (err) {
846
+ ctx.app.notify(`Compaction failed: ${err.message}`);
847
+ }
848
+ break;
849
+ }
850
+ case 'checkpoint': {
851
+ const sub = args[0]?.toLowerCase();
852
+ const { createCheckpoint, deleteCheckpoint, listCheckpoints, formatCheckpointList } = await import('../utils/checkpoints.js');
853
+ const { getCurrentSessionActions } = await import('../utils/agent.js');
854
+ if (sub === 'delete') {
855
+ const id = args[1];
856
+ if (!id) {
857
+ ctx.app.notify('Usage: /checkpoint delete <id>');
858
+ break;
859
+ }
860
+ ctx.app.notify(deleteCheckpoint(ctx.projectPath, id) ? `Deleted checkpoint ${id}` : `Checkpoint not found: ${id}`);
861
+ break;
862
+ }
863
+ if (sub === 'list') {
864
+ ctx.app.addMessage({ role: 'system', content: formatCheckpointList(listCheckpoints(ctx.projectPath)) });
865
+ break;
866
+ }
867
+ const name = args.join(' ').trim() || undefined;
868
+ const provider = getCurrentProvider();
869
+ const filesTouched = Array.from(new Set(getCurrentSessionActions()
870
+ .filter(a => a.target && (a.type === 'write' || a.type === 'edit' || a.type === 'delete' || a.type === 'mkdir'))
871
+ .map(a => a.target)));
872
+ const cp = createCheckpoint({
873
+ workspaceRoot: ctx.projectPath,
874
+ sessionId: ctx.sessionId,
875
+ provider: provider.id,
876
+ model: config.get('model'),
877
+ messages: ctx.app.getMessages(),
878
+ filesTouched,
879
+ name,
880
+ });
881
+ ctx.app.addMessage({
882
+ role: 'system',
883
+ content: `# Checkpoint created\n\n\`${cp.id}\`${cp.name ? ` — **${cp.name}**` : ''}\n\nCaptured ${cp.messages.length} message${cp.messages.length === 1 ? '' : 's'}, ${cp.filesTouched.length} file${cp.filesTouched.length === 1 ? '' : 's'} touched${cp.gitHead ? `, git \`${cp.gitHead}\`` : ''}.\n\nUse \`/rewind ${cp.id}\` to restore.`,
884
+ });
885
+ break;
886
+ }
887
+ case 'checkpoints': {
888
+ const { listCheckpoints, formatCheckpointList } = await import('../utils/checkpoints.js');
889
+ ctx.app.addMessage({ role: 'system', content: formatCheckpointList(listCheckpoints(ctx.projectPath)) });
890
+ break;
891
+ }
892
+ case 'openrouter': {
893
+ const { readOpenRouterPreferences, writeOpenRouterPreferences, formatOpenRouterPreferences } = await import('../utils/openrouterPrefs.js');
894
+ const sub = args[0]?.toLowerCase();
895
+ const current = readOpenRouterPreferences();
896
+ if (!sub || sub === 'show') {
897
+ ctx.app.addMessage({ role: 'system', content: formatOpenRouterPreferences(current) });
898
+ break;
899
+ }
900
+ if (sub === 'clear') {
901
+ writeOpenRouterPreferences(null);
902
+ ctx.app.notify('OpenRouter preferences cleared');
903
+ break;
904
+ }
905
+ if (sub === 'prefer') {
906
+ const list = args.slice(1).join(' ').split(',').map(s => s.trim()).filter(Boolean);
907
+ if (list.length === 0) {
908
+ ctx.app.notify('Usage: /openrouter prefer <p1>[,<p2>...]');
909
+ break;
910
+ }
911
+ writeOpenRouterPreferences({ ...current, order: list });
912
+ ctx.app.notify(`OpenRouter preference: ${list.join(' → ')}`);
913
+ break;
914
+ }
915
+ if (sub === 'ignore') {
916
+ const list = args.slice(1).join(' ').split(',').map(s => s.trim()).filter(Boolean);
917
+ if (list.length === 0) {
918
+ ctx.app.notify('Usage: /openrouter ignore <p1>[,<p2>...]');
919
+ break;
920
+ }
921
+ writeOpenRouterPreferences({ ...current, ignore: list });
922
+ ctx.app.notify(`OpenRouter ignoring: ${list.join(', ')}`);
923
+ break;
924
+ }
925
+ if (sub === 'fallbacks') {
926
+ const val = args[1]?.toLowerCase();
927
+ if (val !== 'on' && val !== 'off') {
928
+ ctx.app.notify('Usage: /openrouter fallbacks on|off');
929
+ break;
930
+ }
931
+ writeOpenRouterPreferences({ ...current, allow_fallbacks: val === 'on' });
932
+ ctx.app.notify(`Fallbacks ${val}`);
933
+ break;
934
+ }
935
+ if (sub === 'privacy') {
936
+ const val = args[1]?.toLowerCase();
937
+ if (val !== 'strict' && val !== 'allow') {
938
+ ctx.app.notify('Usage: /openrouter privacy strict|allow');
939
+ break;
940
+ }
941
+ writeOpenRouterPreferences({ ...current, data_collection: val === 'strict' ? 'deny' : 'allow' });
942
+ ctx.app.notify(`Privacy: ${val}`);
943
+ break;
944
+ }
945
+ ctx.app.notify(`Unknown subcommand: ${sub}`);
946
+ break;
947
+ }
948
+ case 'hooks': {
949
+ const { listInstalledHooks, formatHookList } = await import('../utils/hooks.js');
950
+ ctx.app.addMessage({ role: 'system', content: formatHookList(listInstalledHooks(ctx.projectPath)) });
951
+ break;
952
+ }
953
+ case 'rewind': {
954
+ const id = args[0];
955
+ if (!id) {
956
+ ctx.app.notify('Usage: /rewind <id> — run /checkpoints to see ids');
957
+ break;
958
+ }
959
+ const { loadCheckpoint, buildRewindGitHint } = await import('../utils/checkpoints.js');
960
+ const cp = loadCheckpoint(ctx.projectPath, id);
961
+ if (!cp) {
962
+ ctx.app.notify(`Checkpoint not found: ${id}`);
963
+ break;
964
+ }
965
+ const replacedCount = ctx.app.getMessages().length;
966
+ ctx.app.setMessages(cp.messages);
967
+ saveSession(ctx.sessionId, cp.messages, ctx.projectPath);
968
+ // Switch provider/model back to checkpoint state if different.
969
+ if (cp.provider && cp.provider !== getCurrentProvider().id)
970
+ setProvider(cp.provider);
971
+ if (cp.model && cp.model !== config.get('model'))
972
+ config.set('model', cp.model);
973
+ ctx.app.addMessage({
974
+ role: 'system',
975
+ content: `# Rewound to ${cp.name ? `**${cp.name}**` : `\`${cp.id}\``}\n\nRestored ${cp.messages.length} message${cp.messages.length === 1 ? '' : 's'} (was ${replacedCount}). Provider: \`${cp.provider}\` · Model: \`${cp.model}\`\n\n${buildRewindGitHint(cp)}`,
976
+ });
977
+ break;
978
+ }
793
979
  case 'context-save': {
794
980
  const messages = ctx.app.getMessages();
795
981
  if (saveSession(`context-${ctx.sessionId}`, messages, ctx.projectPath)) {
@@ -911,6 +1097,148 @@ Format: use headers per category, only include categories where you found issues
911
1097
  break;
912
1098
  }
913
1099
  case 'skills': {
1100
+ const sub = args[0]?.toLowerCase();
1101
+ // Structured skill bundles (separate from the JSON skills in skills.ts).
1102
+ if (sub === 'bundles' || sub === 'list-bundles') {
1103
+ const { loadSkillBundles, formatBundleList } = await import('../utils/skillBundles.js');
1104
+ ctx.app.addMessage({ role: 'system', content: formatBundleList(loadSkillBundles(ctx.projectPath)) });
1105
+ break;
1106
+ }
1107
+ if (sub === 'create-bundle') {
1108
+ const name = (args[1] ?? '').toLowerCase();
1109
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
1110
+ ctx.app.notify('Usage: /skills create-bundle <name> — lowercase letters/digits/hyphens');
1111
+ break;
1112
+ }
1113
+ const { mkdirSync, writeFileSync, existsSync } = await import('fs');
1114
+ const { join } = await import('path');
1115
+ const dir = join(ctx.projectPath, '.codeep', 'skills', name);
1116
+ if (existsSync(dir)) {
1117
+ ctx.app.notify(`Skill ${name} already exists`);
1118
+ break;
1119
+ }
1120
+ mkdirSync(dir, { recursive: true });
1121
+ writeFileSync(join(dir, 'SKILL.md'), `---
1122
+ name: ${name}
1123
+ description: One-sentence summary shown in the agent's catalog.
1124
+ triggers:
1125
+ - keyword
1126
+ - phrase
1127
+ ---
1128
+
1129
+ # ${name}
1130
+
1131
+ Describe what this skill does. The agent reads this body verbatim when it invokes the skill.
1132
+
1133
+ ## Steps
1134
+
1135
+ 1. First step
1136
+ 2. Second step
1137
+ 3. …
1138
+ `);
1139
+ ctx.app.addMessage({
1140
+ role: 'system',
1141
+ content: `Created skill bundle at \`.codeep/skills/${name}/SKILL.md\`. Edit it, then \`/skills bundles\` to confirm.`,
1142
+ });
1143
+ break;
1144
+ }
1145
+ if (sub === 'show' || sub === 'detail') {
1146
+ const name = args[1];
1147
+ if (!name) {
1148
+ ctx.app.notify('Usage: /skills show <name>');
1149
+ break;
1150
+ }
1151
+ const { findSkillBundle } = await import('../utils/skillBundles.js');
1152
+ const bundle = findSkillBundle(name, ctx.projectPath);
1153
+ if (!bundle) {
1154
+ ctx.app.notify(`Skill ${name} not found`);
1155
+ break;
1156
+ }
1157
+ ctx.app.addMessage({
1158
+ role: 'system',
1159
+ content: `# ${bundle.name}\n_${bundle.description}_\n\n**Source:** ${bundle.source}\n\n---\n\n${bundle.body}`,
1160
+ });
1161
+ break;
1162
+ }
1163
+ // Marketplace operations against codeep.dev.
1164
+ if (sub === 'publish') {
1165
+ const slug = args[1];
1166
+ if (!slug) {
1167
+ ctx.app.notify('Usage: /skills publish <slug> [--public]');
1168
+ break;
1169
+ }
1170
+ const isPublic = args.includes('--public');
1171
+ ctx.app.notify(`Publishing ${slug} to codeep.dev…`);
1172
+ const { publishBundle } = await import('../utils/skillBundlesCloud.js');
1173
+ const result = await publishBundle(ctx.projectPath, slug, { isPublic });
1174
+ if (!result.ok) {
1175
+ ctx.app.notify(`Publish failed: ${result.error}`);
1176
+ break;
1177
+ }
1178
+ ctx.app.addMessage({
1179
+ role: 'system',
1180
+ content: `Published \`${slug}\` (${isPublic ? 'public' : 'private'}) to codeep.dev. Install elsewhere with \`/skills install ${result.skill?.owner_username ?? '<you>'}/${slug}\`.`,
1181
+ });
1182
+ break;
1183
+ }
1184
+ if (sub === 'install') {
1185
+ const target = args[1];
1186
+ if (!target) {
1187
+ ctx.app.notify('Usage: /skills install <owner>/<slug>');
1188
+ break;
1189
+ }
1190
+ const { installBundle } = await import('../utils/skillBundlesCloud.js');
1191
+ ctx.app.notify(`Fetching ${target}…`);
1192
+ const result = await installBundle(ctx.projectPath, target);
1193
+ if (!result.ok) {
1194
+ ctx.app.notify(`Install failed: ${result.error}`);
1195
+ break;
1196
+ }
1197
+ ctx.app.notify(`Installed ${result.name} to .codeep/skills/${result.name}/`);
1198
+ break;
1199
+ }
1200
+ if (sub === 'browse') {
1201
+ const query = args.slice(1).join(' ').trim();
1202
+ const { browseSkills } = await import('../utils/skillBundlesCloud.js');
1203
+ ctx.app.notify('Fetching marketplace…');
1204
+ const result = await browseSkills({ query });
1205
+ if (!result.ok) {
1206
+ ctx.app.notify(`Browse failed: ${result.error}`);
1207
+ break;
1208
+ }
1209
+ const skills = result.skills ?? [];
1210
+ if (skills.length === 0) {
1211
+ ctx.app.addMessage({ role: 'system', content: query ? `_No public skills matching "${query}"._` : '_No public skills published yet._' });
1212
+ break;
1213
+ }
1214
+ const lines = [`# ${query ? `Skills matching "${query}"` : 'Public skills'}`, ''];
1215
+ for (const s of skills.slice(0, 30)) {
1216
+ const owner = s.owner_username ?? s.github_id;
1217
+ const ver = s.version ? ` v${s.version}` : '';
1218
+ lines.push(`- **${s.name}** \`${owner}/${s.slug}\`${ver} — ${s.description} _(${s.install_count} installs)_`);
1219
+ }
1220
+ if (skills.length > 30)
1221
+ lines.push('', `_(showing first 30 of ${skills.length})_`);
1222
+ lines.push('', 'Install with `/skills install <owner>/<slug>`.');
1223
+ ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
1224
+ break;
1225
+ }
1226
+ if (sub === 'unpublish') {
1227
+ const target = args[1];
1228
+ if (!target) {
1229
+ ctx.app.notify('Usage: /skills unpublish <owner>/<slug>');
1230
+ break;
1231
+ }
1232
+ const { unpublishBundle } = await import('../utils/skillBundlesCloud.js');
1233
+ const result = await unpublishBundle(target);
1234
+ if (!result.ok) {
1235
+ ctx.app.notify(`Unpublish failed: ${result.error}`);
1236
+ break;
1237
+ }
1238
+ ctx.app.notify(`Unpublished ${target} from codeep.dev`);
1239
+ break;
1240
+ }
1241
+ // Default: built-in JSON skills (legacy behaviour).
914
1242
  import('../utils/skills.js').then(({ getAllSkills, searchSkills, formatSkillsList, getSkillStats }) => {
915
1243
  const query = args.join(' ').toLowerCase();
916
1244
  if (query === 'stats') {
@@ -1352,10 +1680,28 @@ Format: use headers per category, only include categories where you found issues
1352
1680
  ctx.app.notify(`Memory saved (${intelligence.notes.length} total): "${note}"`);
1353
1681
  break;
1354
1682
  }
1355
- default:
1683
+ case 'commands': {
1684
+ const { loadCustomCommands, formatCommandList } = await import('../utils/customCommands.js');
1685
+ ctx.app.addMessage({ role: 'system', content: formatCommandList(loadCustomCommands(ctx.projectPath)) });
1686
+ break;
1687
+ }
1688
+ default: {
1689
+ // 1. Try custom user command (project + global Markdown templates).
1690
+ const { findCustomCommand, expandCommand } = await import('../utils/customCommands.js');
1691
+ const custom = findCustomCommand(command, ctx.projectPath);
1692
+ if (custom) {
1693
+ const expandedPrompt = expandCommand(custom, args);
1694
+ ctx.app.notify(`Running custom command /${command} (${custom.scope})`);
1695
+ ctx.app.addMessage({ role: 'user', content: expandedPrompt });
1696
+ const { runAgentTask } = await import('./agentExecution.js');
1697
+ runAgentTask(expandedPrompt, false, ctx, () => null, () => { });
1698
+ break;
1699
+ }
1700
+ // 2. Fall through to skill registry.
1356
1701
  runSkill(command, args, ctx).then(handled => {
1357
1702
  if (!handled)
1358
1703
  ctx.app.notify(`Unknown command: /${command}`);
1359
1704
  });
1705
+ }
1360
1706
  }
1361
1707
  }
@@ -44,4 +44,5 @@ export declare class LoginScreen {
44
44
  export declare function renderProviderSelect(screen: Screen, providers: Array<{
45
45
  id: string;
46
46
  name: string;
47
+ description?: string;
47
48
  }>, selectedIndex: number): void;
@@ -190,15 +190,17 @@ export function renderProviderSelect(screen, providers, selectedIndex) {
190
190
  const { width, height } = screen.getSize();
191
191
  screen.clear();
192
192
  // Title
193
- const title = '═══ Codeep Setup ═══';
193
+ const title = '═══ Welcome to Codeep ═══';
194
194
  const titleX = Math.floor((width - title.length) / 2);
195
195
  screen.write(titleX, 1, title, PRIMARY_COLOR + style.bold);
196
196
  // Subtitle
197
- const subtitle = 'Select your AI provider';
197
+ const subtitle = 'Pick an AI provider — you can switch later with /provider';
198
198
  const subtitleX = Math.floor((width - subtitle.length) / 2);
199
199
  screen.write(subtitleX, 3, subtitle, fg.white);
200
- // Box
201
- const boxWidth = Math.min(40, width - 4);
200
+ // Box — wider so we can show name + description on a single row.
201
+ const longestName = providers.reduce((m, p) => Math.max(m, p.name.length), 0);
202
+ const longestDesc = providers.reduce((m, p) => Math.max(m, (p.description ?? '').length), 0);
203
+ const boxWidth = Math.min(width - 4, Math.max(60, 6 + longestName + 3 + longestDesc));
202
204
  const boxHeight = providers.length + 4;
203
205
  const { x: boxX, y: boxY } = centerBox(width, height, boxWidth, boxHeight);
204
206
  const boxLines = createBox({
@@ -212,19 +214,32 @@ export function renderProviderSelect(screen, providers, selectedIndex) {
212
214
  for (const line of boxLines) {
213
215
  screen.writeLine(line.y, line.text, line.style);
214
216
  }
215
- // Provider list
217
+ // Provider list — name in white/bold-on-selected, dim description beside it.
218
+ // Description is clipped to the room remaining inside the box so it never
219
+ // overwrites the right border on narrow terminals (80-col laptop splits).
216
220
  const contentX = boxX + 3;
217
- let contentY = boxY + 2;
221
+ const contentY = boxY + 2;
222
+ const nameColWidth = longestName + 2;
223
+ const descStartX = contentX + 2 + nameColWidth;
224
+ const boxInnerRight = boxX + boxWidth - 2;
225
+ const descBudget = Math.max(0, boxInnerRight - descStartX);
218
226
  for (let i = 0; i < providers.length; i++) {
219
227
  const provider = providers[i];
220
228
  const isSelected = i === selectedIndex;
221
229
  const prefix = isSelected ? '► ' : ' ';
222
- const itemStyle = isSelected ? PRIMARY_BRIGHT + style.bold : fg.white;
223
- screen.write(contentX, contentY + i, prefix + provider.name, itemStyle);
230
+ const nameStyle = isSelected ? PRIMARY_BRIGHT + style.bold : fg.white;
231
+ const descStyle = isSelected ? fg.white : fg.gray;
232
+ screen.write(contentX, contentY + i, prefix + provider.name.padEnd(nameColWidth), nameStyle);
233
+ if (provider.description && descBudget > 0) {
234
+ const desc = provider.description.length > descBudget
235
+ ? provider.description.slice(0, Math.max(1, descBudget - 1)) + '…'
236
+ : provider.description;
237
+ screen.write(descStartX, contentY + i, desc, descStyle);
238
+ }
224
239
  }
225
240
  // Footer
226
241
  const footerY = height - 2;
227
- screen.write(2, footerY, '↑↓ Navigate | Enter Select', fg.gray);
242
+ screen.write(2, footerY, '↑↓ Navigate · Enter Select · Esc skip (provider chosen later)', fg.gray);
228
243
  screen.showCursor(false);
229
244
  screen.fullRender();
230
245
  }
@@ -26,7 +26,17 @@ export interface MenuHandlerContext {
26
26
  close(callback: ((item: SelectItem) => void) | null, selected: SelectItem | null): void;
27
27
  render(): void;
28
28
  }
29
- export declare function handleMenuKey(event: KeyEvent, ctx: MenuHandlerContext): void;
29
+ /**
30
+ * Optional context for type-to-filter support. Callers that supply these
31
+ * get inline filtering: letters/digits append to `filter`, Backspace
32
+ * deletes, first Esc clears a non-empty filter, second Esc closes.
33
+ * Callers that omit them behave exactly as before (back-compat).
34
+ */
35
+ export interface MenuFilterContext {
36
+ filter: string;
37
+ setFilter(v: string): void;
38
+ }
39
+ export declare function handleMenuKey(event: KeyEvent, ctx: MenuHandlerContext & Partial<MenuFilterContext>): void;
30
40
  declare const PERMISSION_OPTIONS: readonly ["read", "write", "none"];
31
41
  type PermissionLevel = typeof PERMISSION_OPTIONS[number];
32
42
  export interface PermissionHandlerContext {
@@ -42,7 +42,14 @@ export function handleInlineHelpKey(event, ctx) {
42
42
  }
43
43
  }
44
44
  export function handleMenuKey(event, ctx) {
45
+ const supportsFilter = typeof ctx.setFilter === 'function';
45
46
  if (event.key === 'escape') {
47
+ // Two-stage Esc when filter is on: first clears the filter, second closes.
48
+ if (supportsFilter && ctx.filter) {
49
+ ctx.setFilter('');
50
+ ctx.render();
51
+ return;
52
+ }
46
53
  ctx.close(null, null);
47
54
  ctx.render();
48
55
  return;
@@ -71,6 +78,29 @@ export function handleMenuKey(event, ctx) {
71
78
  const selected = ctx.items[ctx.index];
72
79
  ctx.close(null, selected);
73
80
  ctx.render();
81
+ return;
82
+ }
83
+ if (!supportsFilter)
84
+ return;
85
+ // Backspace removes the last filter char.
86
+ if (event.key === 'backspace') {
87
+ if (ctx.filter) {
88
+ ctx.setFilter(ctx.filter.slice(0, -1));
89
+ ctx.render();
90
+ }
91
+ return;
92
+ }
93
+ // Single printable char (letters, digits, punctuation). Modifier-free,
94
+ // non-paste. We deliberately exclude bare-space at empty filter so a
95
+ // stray space doesn't kick the user into an unintended filtered view.
96
+ if (!event.ctrl && !event.alt && !event.isPaste && event.key.length === 1) {
97
+ const ch = event.key;
98
+ if (ch === ' ' && !ctx.filter)
99
+ return;
100
+ if (/^[\S ]$/.test(ch)) {
101
+ ctx.setFilter((ctx.filter ?? '') + ch);
102
+ ctx.render();
103
+ }
74
104
  }
75
105
  }
76
106
  // ─── Permission ──────────────────────────────────────────────────────────────
@@ -540,10 +540,83 @@ Commands (in chat):
540
540
  welcomeLines.push(githubId
541
541
  ? ` Account codeep.dev linked`
542
542
  : ` Account not linked · run: codeep account`);
543
+ // Warn before first use if this workspace defines project-scoped custom
544
+ // slash commands. They run as user prompts — a hostile or unfamiliar repo
545
+ // could ship `.codeep/commands/refactor.md` whose body silently sends
546
+ // something the user didn't intend. The banner is informed-consent;
547
+ // `/commands` shows the full bodies.
548
+ if (projectPath) {
549
+ try {
550
+ const { loadCustomCommands } = await import('../utils/customCommands.js');
551
+ const projectCustom = loadCustomCommands(projectPath).filter(c => c.scope === 'project');
552
+ if (projectCustom.length > 0) {
553
+ const list = projectCustom.slice(0, 6).map(c => `/${c.name}`).join(', ');
554
+ const more = projectCustom.length > 6 ? ` (+${projectCustom.length - 6} more)` : '';
555
+ welcomeLines.push('');
556
+ welcomeLines.push(` ⚠ This workspace defines ${projectCustom.length} custom slash command${projectCustom.length === 1 ? '' : 's'}: ${list}${more}`);
557
+ welcomeLines.push(' Type /commands to review before invoking');
558
+ }
559
+ }
560
+ catch {
561
+ // Loading must never block the welcome banner.
562
+ }
563
+ // Same flag for lifecycle hooks — they're arbitrary shell that fires on
564
+ // tool calls. A surprise post_edit / pre_tool_call from a freshly cloned
565
+ // repo is exactly the kind of thing a user should be told up front.
566
+ try {
567
+ const { summarizeHooks } = await import('../utils/hooks.js');
568
+ const summary = summarizeHooks(projectPath);
569
+ if (summary) {
570
+ welcomeLines.push('');
571
+ welcomeLines.push(` ⚠ ${summary} — shell hooks run automatically. Type /hooks to inspect.`);
572
+ }
573
+ }
574
+ catch {
575
+ // Don't block on hook discovery failure.
576
+ }
577
+ // Skill bundles — less dangerous than hooks but worth surfacing
578
+ // because the agent will invoke them autonomously.
579
+ try {
580
+ const { summarizeBundles } = await import('../utils/skillBundles.js');
581
+ const summary = summarizeBundles(projectPath);
582
+ if (summary) {
583
+ welcomeLines.push('');
584
+ welcomeLines.push(` ℹ This workspace ships ${summary}. Type /skills bundles to inspect.`);
585
+ }
586
+ }
587
+ catch {
588
+ // Don't block on skill discovery failure.
589
+ }
590
+ }
543
591
  welcomeLines.push('');
544
592
  welcomeLines.push(' /help · Ctrl+L clear · Esc cancel');
545
593
  app.addMessage({ role: 'welcome', content: welcomeLines.join('\n') });
546
594
  app.start();
595
+ // Spawn MCP servers in the background. They register against the fixed
596
+ // session id `codeep-tui` that runAgentTask passes into runAgent's
597
+ // `mcpSessionId` — so the agent picks up `.codeep/mcp_servers.json`
598
+ // entries (project + global) the same way an ACP client would.
599
+ if (projectPath) {
600
+ (async () => {
601
+ try {
602
+ const { loadMcpServerConfig } = await import('../utils/mcpConfig.js');
603
+ const { registerSessionServers } = await import('../utils/mcpRegistry.js');
604
+ const servers = loadMcpServerConfig(projectPath);
605
+ if (servers.length === 0)
606
+ return;
607
+ const { registered, errors } = await registerSessionServers('codeep-tui', servers, { workspaceRoot: projectPath });
608
+ if (registered.length > 0) {
609
+ app.notify(`MCP: ${registered.length} tool(s) from ${servers.length} server(s) ready. Type /mcp.`);
610
+ }
611
+ for (const e of errors) {
612
+ app.notifyWarn(`MCP server "${e.server}" failed: ${e.error}`);
613
+ }
614
+ }
615
+ catch {
616
+ // Loading MCP must never block the TUI.
617
+ }
618
+ })();
619
+ }
547
620
  // Check for updates in background — show notify if new version available
548
621
  checkForUpdates().then(info => {
549
622
  if (info.hasUpdate) {
@@ -30,6 +30,23 @@ export interface AgentOptions {
30
30
  stderr: string;
31
31
  exitCode: number;
32
32
  }>;
33
+ /**
34
+ * Optional filesystem callbacks. When the ACP client advertises `fs`
35
+ * capability, the server populates these so read_file/write_file/edit_file
36
+ * tools route through the client (preserving dirty buffers and undo
37
+ * history) instead of touching disk directly. Falls back to disk if not
38
+ * provided or if a delegated call throws.
39
+ */
40
+ fs?: import('./toolExecution').FsCallbacks;
41
+ /**
42
+ * Optional ACP session id used to route MCP-prefixed tool calls
43
+ * (`<server>__<tool>`) to the per-session `mcpRegistry`. Not set in TUI
44
+ * mode (no MCP support there yet); set by `runAgentSession` in ACP mode.
45
+ * When set, the agent loop also fetches the session's MCP tool list and
46
+ * passes it into the provider's tool catalog so the model can invoke
47
+ * those tools natively.
48
+ */
49
+ mcpSessionId?: string;
33
50
  abortSignal?: AbortSignal;
34
51
  dryRun?: boolean;
35
52
  autoVerify?: 'off' | 'build' | 'typecheck' | 'test' | 'all' | boolean;