ikie-cli 0.1.33 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/repl.js CHANGED
@@ -10,6 +10,8 @@ import { join as pathJoin } from 'path';
10
10
  import { deleteSession, listSessions, loadSession, normalizeSessionName, saveSession } from './session.js';
11
11
  import { buildUserContent, formatBytes, loadClipboardImageAttachment, loadImageAttachment, hasClipboardImage } from './attachments.js';
12
12
  import { runOnboarding } from './onboarding.js';
13
+ import { getMcpManager, parseClaudeMcpAddCommand } from './mcp-manager.js';
14
+ import { generateTree, parseTreeArgs } from './tree.js';
13
15
  async function fetchModelsFromServer(config) {
14
16
  const baseUrl = config.baseURL || IKIE_API_BASE;
15
17
  const apiUrl = baseUrl.includes('/api/v1')
@@ -72,6 +74,11 @@ const SLASH_CMDS = [
72
74
  ],
73
75
  },
74
76
  { name: 'context', desc: 'Show project context' },
77
+ {
78
+ name: 'tree',
79
+ desc: 'Show file tree',
80
+ args: '[path] [-a] [-s] [-d depth]',
81
+ },
75
82
  {
76
83
  name: 'models',
77
84
  desc: 'List available models',
@@ -109,10 +116,51 @@ const SLASH_CMDS = [
109
116
  { name: 'agent', desc: 'Switch to agent mode (full)' },
110
117
  { name: 'compact', desc: 'Summarize conversation to free up context window' },
111
118
  { name: 'onboarding', desc: 'Run the first-time onboarding tutorial' },
119
+ {
120
+ name: 'mcp',
121
+ desc: 'MCP servers',
122
+ args: '[add|remove|reconnect|list]',
123
+ subs: [
124
+ { name: 'add', desc: 'Add a server from a claude mcp add line' },
125
+ { name: 'remove', desc: 'Remove a server by name' },
126
+ { name: 'reconnect', desc: 'Reconnect a server by name' },
127
+ { name: 'list', desc: 'List configured servers' },
128
+ ],
129
+ },
112
130
  { name: 'login', desc: 'Sign in to ikie account' },
113
131
  { name: 'logout', desc: 'Sign out of ikie account' },
114
132
  { name: 'exit', desc: 'Exit Ikie' },
115
133
  ];
134
+ // ── Interactive picker state (arrow-key selectors like /theme) ──
135
+ let pickerActive = false;
136
+ let pickerItems = [];
137
+ let pickerSel = 0;
138
+ let pickerResolve = null;
139
+ let drawPickerFn = null;
140
+ let pickerTitle = '';
141
+ let pickerActiveValue = null;
142
+ // ── Rotating tips shown below the prompt ─────────────────────────
143
+ const TIPS = [
144
+ 'Use /theme to change the color theme',
145
+ 'Type /model to switch the AI model',
146
+ 'Use /help to see all commands',
147
+ 'Press Esc to cancel a running task',
148
+ 'Use /session to manage chat sessions',
149
+ 'Type /clear to reset the conversation',
150
+ 'Use /settings to adjust your preferences',
151
+ 'Type /usage to check your API usage',
152
+ 'Use /context to see project context',
153
+ 'Use /image to attach images',
154
+ 'Type /tokens for token estimates',
155
+ 'Use /mode to switch between plan and agent mode',
156
+ 'Type /compact to summarize the conversation',
157
+ 'Use /memory to save notes',
158
+ 'Type /exit to quit ikie',
159
+ 'Use /skills to manage skills',
160
+ 'Type /tree to show the file tree',
161
+ 'Use /rpm to set the requests-per-minute limit',
162
+ ];
163
+ let tipIndex = Date.now(); // start at a pseudo-random position
116
164
  /**
117
165
  * Compute live slash-menu items for the current input line.
118
166
  * Returns rich items (name/desc/insert) plus the typed fragment to highlight.
@@ -137,6 +185,13 @@ function computeSlashMenu(line) {
137
185
  // Command-name stage: "/<frag>".
138
186
  const items = SLASH_CMDS
139
187
  .filter(s => s.name.startsWith(cmd))
188
+ .sort((a, b) => {
189
+ if (a.name === cmd)
190
+ return -1;
191
+ if (b.name === cmd)
192
+ return 1;
193
+ return 0;
194
+ })
140
195
  .map(s => ({
141
196
  name: s.name,
142
197
  desc: s.desc,
@@ -160,6 +215,7 @@ ${c.primary.bold('Ikie Commands')}
160
215
  ${c.warning('/session load')} Load saved chat
161
216
  ${c.warning('/session new')} Start a fresh chat (auto-saved)
162
217
  ${c.warning('/context')} Show current project context
218
+ ${c.warning('/tree [path]')} Show file tree structure
163
219
  ${c.warning('/models')} List available models
164
220
  ${c.warning('/model <name>')} Switch AI model
165
221
  ${c.warning('/settings')} View all settings
@@ -181,6 +237,10 @@ ${c.primary.bold('Ikie Commands')}
181
237
  ${c.warning('/skills show <name>')} Show a skill's instructions
182
238
  ${c.warning('/skills install <src>')} Install a skill (git URL or path)
183
239
  ${c.warning('/skills remove <name>')} Remove an installed skill
240
+ ${c.warning('/mcp')} List MCP servers and tools
241
+ ${c.warning('/mcp add <line>')} Add a server from a claude mcp add line
242
+ ${c.warning('/mcp remove <name>')} Remove a server by name
243
+ ${c.warning('/mcp reconnect <name>')} Reconnect a server by name
184
244
  ${c.warning('/plan')} Plan mode — research & propose, no changes
185
245
  ${c.warning('/agent')} Agent mode — full execution (default)
186
246
  ${c.warning('/mode')} Show or set mode ${c.muted('(Shift+Tab toggles)')}
@@ -356,6 +416,15 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
356
416
  console.log(`\n${c.primary.bold('Project Context')}\n`);
357
417
  console.log(renderMarkdown(projectContext));
358
418
  return true;
419
+ case 'tree': {
420
+ const { path, options } = parseTreeArgs(args);
421
+ console.log();
422
+ console.log(generateTree(path, options));
423
+ console.log();
424
+ console.log(c.muted(' Usage: /tree [path] [-a all] [-s size] [-d depth]'));
425
+ console.log(c.muted(' Example: /tree src -a -d 4'));
426
+ return true;
427
+ }
359
428
  case 'models': {
360
429
  const sub = args[0]?.toLowerCase();
361
430
  if (sub === 'refresh' || !sub || sub === 'list') {
@@ -370,7 +439,8 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
370
439
  }
371
440
  else {
372
441
  config.model = model;
373
- saveConfig({ model });
442
+ config.modelPinned = true;
443
+ saveConfig({ model, modelPinned: true });
374
444
  console.log(successLine(`Default model set to: ${model}`));
375
445
  console.log(infoLine('Persisted to settings — will be used for all new sessions.'));
376
446
  }
@@ -477,6 +547,71 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
477
547
  await runOnboarding(config);
478
548
  return true;
479
549
  }
550
+ case 'mcp': {
551
+ const sub = (args[0] ?? 'list').toLowerCase();
552
+ const rest = args.slice(1).join(' ').trim();
553
+ const manager = getMcpManager();
554
+ if (sub === 'add') {
555
+ if (!rest) {
556
+ console.log(errorLine('Usage: /mcp add <claude mcp add line>'));
557
+ return true;
558
+ }
559
+ const parsed = parseClaudeMcpAddCommand(rest);
560
+ if ('error' in parsed) {
561
+ console.log(errorLine(parsed.error));
562
+ return true;
563
+ }
564
+ try {
565
+ await manager.addServer(parsed.name, parsed.entry, 'project');
566
+ console.log(successLine(`Added MCP server "${parsed.name}" and connected.`));
567
+ }
568
+ catch (err) {
569
+ console.log(errorLine(err instanceof Error ? err.message : String(err)));
570
+ }
571
+ return true;
572
+ }
573
+ if (sub === 'remove' || sub === 'rm') {
574
+ if (!rest) {
575
+ console.log(errorLine('Usage: /mcp remove <name>'));
576
+ return true;
577
+ }
578
+ const removed = manager.removeServer(rest, 'project');
579
+ console.log(removed ? successLine(`Removed MCP server "${rest}".`) : errorLine(`No MCP server named "${rest}" in project scope.`));
580
+ return true;
581
+ }
582
+ if (sub === 'reconnect') {
583
+ if (!rest) {
584
+ console.log(errorLine('Usage: /mcp reconnect <name>'));
585
+ return true;
586
+ }
587
+ try {
588
+ await manager.reconnectServer(rest);
589
+ console.log(successLine(`Reconnected MCP server "${rest}".`));
590
+ }
591
+ catch (err) {
592
+ console.log(errorLine(err instanceof Error ? err.message : String(err)));
593
+ }
594
+ return true;
595
+ }
596
+ const servers = manager.listServers();
597
+ if (!servers.length) {
598
+ console.log(infoLine('No MCP servers configured.'));
599
+ console.log(`\n ${c.muted('Add one with')} ${c.warning('/mcp add <claude mcp add line>')}`);
600
+ return true;
601
+ }
602
+ console.log(`\n${c.primary.bold('MCP Servers')}\n`);
603
+ for (const s of servers) {
604
+ const status = s.connected ? c.success('●') : s.enabled ? c.warning('○') : c.error('○');
605
+ console.log(` ${status} ${c.white.bold(s.name)} ${s.connected ? c.success('connected') : s.enabled ? c.muted('not connected') : c.error('disabled')}`);
606
+ if (s.error)
607
+ console.log(` ${c.error(s.error)}`);
608
+ if (s.tools.length) {
609
+ console.log(` ${c.muted('tools:')} ${s.tools.map(t => t.name).join(', ')}`);
610
+ }
611
+ }
612
+ console.log();
613
+ return true;
614
+ }
480
615
  case 'logout': {
481
616
  if (!isLoggedIn(config)) {
482
617
  console.log(infoLine('Not signed in.'));
@@ -515,7 +650,8 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
515
650
  return true;
516
651
  }
517
652
  config.model = value;
518
- saveConfig({ model: value });
653
+ config.modelPinned = true;
654
+ saveConfig({ model: value, modelPinned: true });
519
655
  console.log(successLine(`Default model changed to: ${value}`));
520
656
  return true;
521
657
  }
@@ -598,6 +734,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
598
734
  }
599
735
  if (sub === 'reset') {
600
736
  config.model = DEFAULT_MODEL;
737
+ config.modelPinned = false;
601
738
  config.maxTokens = 32768;
602
739
  config.temperature = 0.6;
603
740
  config.topP = 0.95;
@@ -606,6 +743,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
606
743
  config.requestsPerMinute = 10;
607
744
  config.theme = 'nebula';
608
745
  saveConfig({
746
+ modelPinned: false,
609
747
  maxTokens: 32768,
610
748
  temperature: 0.6,
611
749
  topP: 0.95,
@@ -848,43 +986,52 @@ function formatTaskTimeline(agent, elapsed, state) {
848
986
  const tail = state === 'cancelled' ? `cancelled after ${elapsed}` : `${state} in ${elapsed}`;
849
987
  return `${modelLabel} · ${toolLabel}${filesLabel} · ${tail}`;
850
988
  }
989
+ function printTaskBreakdown(agent, elapsedMs, tokensUsed) {
990
+ const stats = agent.getLastTurnStats();
991
+ const totalSec = (elapsedMs / 1000).toFixed(1);
992
+ console.log(`\n${c.primary('┌')} ${c.white.bold('Task Summary')}`);
993
+ console.log(`${c.primary('│')} ${c.muted('Total time:')} ${c.accent(totalSec + 's')}`);
994
+ console.log(`${c.primary('│')} ${c.muted('AI calls:')} ${c.secondary(stats.modelCalls.toString())} ${c.dim('(~' + tokensUsed.toLocaleString() + ' tokens)')}`);
995
+ console.log(`${c.primary('│')} ${c.muted('Tools executed:')} ${c.secondary(stats.toolCalls.toString())}`);
996
+ if (stats.filesChanged > 0) {
997
+ console.log(`${c.primary('│')} ${c.muted('Files changed:')} ${c.warning(stats.filesChanged.toString())}`);
998
+ }
999
+ // Rough cost estimate (assuming ~$0.003 per 1K tokens for Claude Sonnet)
1000
+ const estimatedCost = (tokensUsed / 1000) * 0.003;
1001
+ if (estimatedCost > 0.001) {
1002
+ console.log(`${c.primary('│')} ${c.muted('Est. cost:')} ${c.dim('~$' + estimatedCost.toFixed(4))}`);
1003
+ }
1004
+ console.log(`${c.primary('└')}${'─'.repeat(40)}\n`);
1005
+ }
851
1006
  function selectModelInteractively(rl, config) {
852
1007
  return new Promise((resolve) => {
853
- const models = [];
854
- const prompt = () => {
855
- rl.question(c.muted('Enter number (or 0 to cancel): '), (answer) => {
856
- const num = parseInt(answer, 10);
857
- if (isNaN(num) || num < 0 || num > models.length) {
858
- prompt();
859
- return;
860
- }
861
- if (num === 0) {
862
- console.log(infoLine('Model selection cancelled.'));
863
- resolve();
864
- return;
865
- }
866
- const chosen = models[num - 1];
867
- config.model = chosen.name;
868
- saveConfig({ model: chosen.name });
869
- console.log(successLine(`Switched to ${chosen.name} — ${chosen.display_name}`));
870
- resolve();
871
- });
872
- };
873
- console.log(`\n ${c.accent.bold('Select a Model')}`);
874
1008
  fetchModelsFromServer(config).then((fetched) => {
875
- models.push(...fetched);
876
- if (models.length === 0) {
1009
+ if (fetched.length === 0) {
877
1010
  console.log(` ${c.error('No models available.')}`);
878
1011
  resolve();
879
1012
  return;
880
1013
  }
881
- models.forEach((m, i) => {
882
- const num = c.primary((i + 1).toString().padStart(2));
883
- const def = m.is_default ? ` ${c.success('[DEFAULT]')}` : '';
884
- console.log(` ${num}) ${c.bold(m.name.padEnd(24))} ${c.dim(m.display_name)} ${c.muted(`(${(m.context_window / 1024).toFixed(0)}K)`)}${def}`);
885
- });
886
- console.log(` ${c.muted(' 0)')} Cancel`);
887
- prompt();
1014
+ pickerTitle = 'Select a Model';
1015
+ pickerActiveValue = config.model;
1016
+ pickerItems = fetched.map(m => ({
1017
+ name: m.name,
1018
+ desc: `${m.display_name} (${(m.context_window / 1024).toFixed(0)}K)${m.is_default ? ' [DEFAULT]' : ''}`,
1019
+ }));
1020
+ pickerSel = Math.max(0, fetched.findIndex(m => m.name === config.model));
1021
+ pickerActive = true;
1022
+ pickerResolve = (chosen) => {
1023
+ if (chosen) {
1024
+ config.model = chosen;
1025
+ config.modelPinned = true;
1026
+ saveConfig({ model: chosen, modelPinned: true });
1027
+ console.log(successLine(`Switched to ${chosen}`));
1028
+ }
1029
+ else {
1030
+ console.log(infoLine('Model selection cancelled.'));
1031
+ }
1032
+ resolve();
1033
+ };
1034
+ drawPickerFn?.();
888
1035
  }).catch(() => {
889
1036
  console.log(` ${c.error('Failed to load models.')}`);
890
1037
  resolve();
@@ -894,37 +1041,24 @@ function selectModelInteractively(rl, config) {
894
1041
  function selectThemeInteractively(rl, config) {
895
1042
  return new Promise((resolve) => {
896
1043
  const themesList = Object.keys(THEMES);
897
- const currentIndex = themesList.indexOf(config.theme);
898
- let lastIndex = currentIndex === -1 ? 0 : currentIndex;
899
- const prompt = () => {
900
- rl.question(c.muted('Enter number (or 0 to cancel): '), (answer) => {
901
- const num = parseInt(answer, 10);
902
- if (isNaN(num) || num < 0 || num > themesList.length) {
903
- prompt();
904
- return;
905
- }
906
- if (num === 0) {
907
- console.log(infoLine('Theme selection cancelled.'));
908
- resolve();
909
- return;
910
- }
911
- const chosen = themesList[num - 1];
1044
+ pickerTitle = 'Select a Theme';
1045
+ pickerActiveValue = config.theme;
1046
+ pickerItems = themesList.map(name => ({ name, desc: THEMES[name].description }));
1047
+ pickerSel = Math.max(0, themesList.indexOf(config.theme));
1048
+ pickerActive = true;
1049
+ pickerResolve = (chosen) => {
1050
+ if (chosen) {
912
1051
  setTheme(chosen);
913
1052
  config.theme = chosen;
914
1053
  drawBanner(config.model);
915
1054
  console.log(successLine(`Theme switched to "${chosen}".`));
916
- resolve();
917
- });
1055
+ }
1056
+ else {
1057
+ console.log(infoLine('Theme selection cancelled.'));
1058
+ }
1059
+ resolve();
918
1060
  };
919
- console.log(`\n ${c.accent.bold('Select a Theme')}`);
920
- themesList.forEach((name, i) => {
921
- const theme = THEMES[name];
922
- const num = c.primary((i + 1).toString().padStart(2));
923
- const active = i === currentIndex ? ` ${c.success('[ACTIVE]')}` : '';
924
- console.log(` ${num}) ${c.bold(name.padEnd(12))} ${c.muted('-')} ${c.dim(theme.description)}${active}`);
925
- });
926
- console.log(` ${c.muted(' 0)')} Cancel`);
927
- prompt();
1061
+ drawPickerFn?.();
928
1062
  });
929
1063
  }
930
1064
  // Notify the user when a long task finishes (> 10s). Terminal bell always;
@@ -964,14 +1098,34 @@ export async function startREPL(agent, config, projectContext, oneShot) {
964
1098
  // A plain launch arg is a one-shot prompt: run it once and exit.
965
1099
  const initialCommand = oneShot && oneShot.trim().startsWith('/') ? oneShot.trim() : undefined;
966
1100
  if (oneShot && !initialCommand) {
1101
+ const abortController = new AbortController();
1102
+ // Set up Esc/Ctrl-C handler for one-shot mode too.
1103
+ if (process.stdin.isTTY) {
1104
+ const wasRaw = process.stdin.isRaw ?? false;
1105
+ if (!wasRaw)
1106
+ process.stdin.setRawMode(true);
1107
+ process.stdin.resume();
1108
+ const cancelHandler = (data) => {
1109
+ const b = data[0];
1110
+ if ((b === 0x1b && data.length === 1) || b === 0x03) {
1111
+ if (!abortController.signal.aborted) {
1112
+ abortController.abort();
1113
+ process.stdout.write(`\n${c.warning(' Cancelled')}\n`);
1114
+ }
1115
+ if (b === 0x03)
1116
+ process.exit(0);
1117
+ }
1118
+ };
1119
+ process.stdin.on('data', cancelHandler);
1120
+ }
967
1121
  try {
968
- await agent.send(oneShot, { autoApprove: config.autoApprove });
1122
+ await agent.send(oneShot, { autoApprove: config.autoApprove, signal: abortController.signal });
1123
+ process.exit(0);
969
1124
  }
970
1125
  catch (err) {
971
1126
  console.error(errorLine(`Agent error: ${err}`));
972
1127
  process.exit(1);
973
1128
  }
974
- return;
975
1129
  }
976
1130
  drawBanner(config.model);
977
1131
  const rl = readline.createInterface({
@@ -1009,6 +1163,8 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1009
1163
  let menuFragment = '';
1010
1164
  let menuSel = 0;
1011
1165
  let menuRows = 0; // menu lines currently drawn below input
1166
+ // ── Interactive picker state (arrow-key selectors) ──────────────────────────
1167
+ let pickerRows = 0;
1012
1168
  const inputCol = () => stripAnsi(currentPrompt).length + (rl.cursor ?? 0);
1013
1169
  const eraseMenu = () => {
1014
1170
  if (menuRows === 0)
@@ -1039,6 +1195,40 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1039
1195
  process.stdout.write(out);
1040
1196
  menuRows = rows.length;
1041
1197
  };
1198
+ const erasePicker = () => {
1199
+ if (pickerRows === 0)
1200
+ return;
1201
+ let out = '\r';
1202
+ for (let i = 0; i < pickerRows; i++)
1203
+ out += '\n\x1b[2K';
1204
+ out += `\x1b[${pickerRows}A\r`;
1205
+ process.stdout.write(out);
1206
+ pickerRows = 0;
1207
+ };
1208
+ const drawPicker = () => {
1209
+ erasePicker();
1210
+ if (!pickerActive || pickerItems.length === 0)
1211
+ return;
1212
+ const rows = [];
1213
+ rows.push(` ${c.accent.bold(pickerTitle)} ${c.muted('(↑↓ · Enter · Esc)')}`);
1214
+ rows.push('');
1215
+ pickerItems.forEach((item, i) => {
1216
+ const sel = i === pickerSel;
1217
+ const active = item.name === pickerActiveValue;
1218
+ const bullet = sel ? c.primary('●') : c.muted('○');
1219
+ const name = sel ? c.white.bold(item.name) : c.white(item.name);
1220
+ const tag = active ? ` ${c.success('[ACTIVE]')}` : '';
1221
+ const desc = item.desc ? ` ${c.dim(item.desc)}` : '';
1222
+ rows.push(` ${bullet} ${name}${tag}${desc}`);
1223
+ });
1224
+ let out = '\r';
1225
+ for (const r of rows)
1226
+ out += `\n${r}`;
1227
+ out += `\x1b[${rows.length}A\r`;
1228
+ process.stdout.write(out);
1229
+ pickerRows = rows.length;
1230
+ };
1231
+ drawPickerFn = drawPicker;
1042
1232
  const closeMenu = () => {
1043
1233
  if (!menuOpen && menuRows === 0)
1044
1234
  return;
@@ -1129,18 +1319,23 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1129
1319
  if (text === '\r' || text === '\n') {
1130
1320
  const item = menuItems[menuSel];
1131
1321
  if (item) {
1132
- // Fill the highlighted command. If it takes no args/subs (no trailing
1133
- // space) submit it immediately; otherwise keep editing for the arg.
1134
- const submit = !item.insert.endsWith(' ');
1322
+ const line = rl.line ?? '';
1135
1323
  eraseMenu();
1136
1324
  menuOpen = false;
1137
- rl.write(null, { ctrl: true, name: 'e' });
1138
- rl.write(null, { ctrl: true, name: 'u' });
1139
- rl.write(item.insert);
1140
- if (submit)
1141
- forwardToReadline(Buffer.from('\r'));
1142
- else
1143
- updateMenu();
1325
+ // Line already exactly matches the selected command — submit directly.
1326
+ if (line === `/${item.name}`) {
1327
+ forwardToReadline(data);
1328
+ }
1329
+ else {
1330
+ const submit = !item.insert.endsWith(' ');
1331
+ rl.write(null, { ctrl: true, name: 'e' });
1332
+ rl.write(null, { ctrl: true, name: 'u' });
1333
+ rl.write(item.insert);
1334
+ if (submit)
1335
+ forwardToReadline(Buffer.from('\r'));
1336
+ else
1337
+ updateMenu();
1338
+ }
1144
1339
  }
1145
1340
  else {
1146
1341
  closeMenu();
@@ -1149,6 +1344,33 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1149
1344
  return;
1150
1345
  }
1151
1346
  }
1347
+ // ── Interactive picker (arrow-key selection like /theme) ──
1348
+ if (pickerActive) {
1349
+ if (text === '\x1b[A') {
1350
+ pickerSel = (pickerSel - 1 + pickerItems.length) % pickerItems.length;
1351
+ drawPicker();
1352
+ return;
1353
+ }
1354
+ if (text === '\x1b[B') {
1355
+ pickerSel = (pickerSel + 1) % pickerItems.length;
1356
+ drawPicker();
1357
+ return;
1358
+ }
1359
+ if (text === '\r' || text === '\n') {
1360
+ const picked = pickerItems[pickerSel].name;
1361
+ pickerActive = false;
1362
+ erasePicker();
1363
+ pickerResolve?.(picked);
1364
+ return;
1365
+ }
1366
+ if (text === '\x1b') {
1367
+ pickerActive = false;
1368
+ erasePicker();
1369
+ pickerResolve?.(null);
1370
+ return;
1371
+ }
1372
+ return; // eat all other input while picker is active
1373
+ }
1152
1374
  // Shift+Tab (CSI Z) → cycle agent⇄plan mode live, updating ONLY the mode
1153
1375
  // word in the header line directly above the prompt. No new line, no
1154
1376
  // reprinted prompt — the cursor and whatever the user has typed stay put.
@@ -1248,6 +1470,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1248
1470
  if (multilineBuffer) {
1249
1471
  currentPrompt = CONTINUE_PROMPT;
1250
1472
  rl.setPrompt(CONTINUE_PROMPT);
1473
+ rl.prompt();
1251
1474
  }
1252
1475
  else {
1253
1476
  printPromptHeader(agent.getMode());
@@ -1263,8 +1486,11 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1263
1486
  }
1264
1487
  currentPrompt = prompt;
1265
1488
  rl.setPrompt(prompt);
1489
+ rl.prompt();
1490
+ // Rotating tip below the prompt
1491
+ const tip = TIPS[tipIndex++ % TIPS.length];
1492
+ process.stdout.write(`\x1b[s\n${c.dim(' ' + tip)}\x1b[u`);
1266
1493
  }
1267
- rl.prompt();
1268
1494
  };
1269
1495
  rl.on('close', () => {
1270
1496
  setBracketedPaste(false);
package/dist/skills.d.ts CHANGED
@@ -22,6 +22,16 @@ export interface Skill {
22
22
  source: 'project' | 'user';
23
23
  /** The roots this skill was discovered under, e.g. ".ikie" or ".claude". */
24
24
  origin: string;
25
+ /** Tools the skill pre-approves for the session. */
26
+ allowedTools?: string[];
27
+ /** Tools the skill explicitly forbids. */
28
+ disallowedTools?: string[];
29
+ /** If true, do not show the skill in the catalog or offer it to the model. */
30
+ disableModelInvocation?: boolean;
31
+ /** If true, the user can invoke the skill directly (e.g. via /skill-name). */
32
+ userInvocable?: boolean;
33
+ /** Extra guidance on when the skill should be used. */
34
+ whenToUse?: string;
25
35
  }
26
36
  /**
27
37
  * Discover all available skills across project and user roots.
@@ -37,8 +47,14 @@ export declare function listSkillFiles(skill: Skill): string[];
37
47
  * Render the skill catalog for the system prompt: each skill's name +
38
48
  * description, so the model knows what exists and when to reach for one.
39
49
  * Returns '' when no skills are installed (so the section is omitted).
50
+ * Skips skills marked disable-model-invocation.
40
51
  */
41
52
  export declare function formatSkillsForPrompt(skills: Skill[]): string;
53
+ /**
54
+ * Map Claude-style allowed-tools tokens to ikie tool names.
55
+ * Strips arguments, lowercases, and maps known tokens.
56
+ */
57
+ export declare function mapAllowedTools(tokens: string[] | undefined): string[];
42
58
  /**
43
59
  * Build the full text returned to the model when it loads a skill via
44
60
  * `use_skill`: the instructions plus a manifest of bundled files and the