ikie-cli 0.1.32 → 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({
@@ -981,6 +1135,16 @@ export async function startREPL(agent, config, projectContext, oneShot) {
981
1135
  historySize: MAX_HISTORY,
982
1136
  prompt: PROMPT,
983
1137
  });
1138
+ // Enable bracketed-paste mode so the terminal wraps pasted text in
1139
+ // \x1b[200~ … \x1b[201~ markers. This lets a multi-chunk paste (a long
1140
+ // paragraph the TTY delivers across several reads) be coalesced into ONE
1141
+ // paste instead of being split into several. Disabled again on exit.
1142
+ const setBracketedPaste = (on) => {
1143
+ if (process.stdout.isTTY)
1144
+ process.stdout.write(on ? '\x1b[?2004h' : '\x1b[?2004l');
1145
+ };
1146
+ setBracketedPaste(true);
1147
+ process.on('exit', () => setBracketedPaste(false));
984
1148
  let multilineBuffer = '';
985
1149
  let busy = false;
986
1150
  let ctrlCCount = 0;
@@ -999,6 +1163,8 @@ export async function startREPL(agent, config, projectContext, oneShot) {
999
1163
  let menuFragment = '';
1000
1164
  let menuSel = 0;
1001
1165
  let menuRows = 0; // menu lines currently drawn below input
1166
+ // ── Interactive picker state (arrow-key selectors) ──────────────────────────
1167
+ let pickerRows = 0;
1002
1168
  const inputCol = () => stripAnsi(currentPrompt).length + (rl.cursor ?? 0);
1003
1169
  const eraseMenu = () => {
1004
1170
  if (menuRows === 0)
@@ -1029,6 +1195,40 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1029
1195
  process.stdout.write(out);
1030
1196
  menuRows = rows.length;
1031
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;
1032
1232
  const closeMenu = () => {
1033
1233
  if (!menuOpen && menuRows === 0)
1034
1234
  return;
@@ -1083,6 +1283,13 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1083
1283
  const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1084
1284
  if (!normalized)
1085
1285
  return;
1286
+ // Short, single-line pastes (a word, a path, a URL) belong inline as if
1287
+ // typed — only collapse multi-line or long pastes into a [Pasted #N] block.
1288
+ if (!normalized.includes('\n') && normalized.length <= 200) {
1289
+ forwardToReadline(Buffer.from(normalized, 'utf8'));
1290
+ updateMenu();
1291
+ return;
1292
+ }
1086
1293
  const token = `[Pasted #${++pasteSeq}: ${pasteSummary(normalized)}]`;
1087
1294
  pastedBlocks.set(token, normalized);
1088
1295
  rl.write(token);
@@ -1112,18 +1319,23 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1112
1319
  if (text === '\r' || text === '\n') {
1113
1320
  const item = menuItems[menuSel];
1114
1321
  if (item) {
1115
- // Fill the highlighted command. If it takes no args/subs (no trailing
1116
- // space) submit it immediately; otherwise keep editing for the arg.
1117
- const submit = !item.insert.endsWith(' ');
1322
+ const line = rl.line ?? '';
1118
1323
  eraseMenu();
1119
1324
  menuOpen = false;
1120
- rl.write(null, { ctrl: true, name: 'e' });
1121
- rl.write(null, { ctrl: true, name: 'u' });
1122
- rl.write(item.insert);
1123
- if (submit)
1124
- forwardToReadline(Buffer.from('\r'));
1125
- else
1126
- 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
+ }
1127
1339
  }
1128
1340
  else {
1129
1341
  closeMenu();
@@ -1132,6 +1344,33 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1132
1344
  return;
1133
1345
  }
1134
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
+ }
1135
1374
  // Shift+Tab (CSI Z) → cycle agent⇄plan mode live, updating ONLY the mode
1136
1375
  // word in the header line directly above the prompt. No new line, no
1137
1376
  // reprinted prompt — the cursor and whatever the user has typed stay put.
@@ -1231,6 +1470,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1231
1470
  if (multilineBuffer) {
1232
1471
  currentPrompt = CONTINUE_PROMPT;
1233
1472
  rl.setPrompt(CONTINUE_PROMPT);
1473
+ rl.prompt();
1234
1474
  }
1235
1475
  else {
1236
1476
  printPromptHeader(agent.getMode());
@@ -1246,10 +1486,14 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1246
1486
  }
1247
1487
  currentPrompt = prompt;
1248
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`);
1249
1493
  }
1250
- rl.prompt();
1251
1494
  };
1252
1495
  rl.on('close', () => {
1496
+ setBracketedPaste(false);
1253
1497
  printGoodbye(sessionState, agent, config);
1254
1498
  process.exit(0);
1255
1499
  });
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