ikie-cli 0.1.13 → 0.1.15

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/agent.js CHANGED
@@ -530,12 +530,13 @@ export class Agent {
530
530
  if (name === 'edit_file' && !result.startsWith('Error')) {
531
531
  const oldStr = String(input.old_string ?? '');
532
532
  const newStr = String(input.new_string ?? '');
533
- block = toolDiffBlock(oldStr, newStr, ms);
533
+ const path = typeof input.path === 'string' ? input.path : undefined;
534
+ block = toolDiffBlock(oldStr, newStr, ms, { path, indent: this.indent });
534
535
  }
535
536
  else {
536
- block = toolOutputBlock(result, ms);
537
+ block = toolOutputBlock(result, ms, this.indent);
537
538
  }
538
- process.stdout.write(`${this.indent}${block}\n`);
539
+ process.stdout.write(`${block}\n`);
539
540
  return result;
540
541
  }
541
542
  catch (err) {
package/dist/repl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as readline from 'node:readline';
2
2
  import { execSync, exec } from 'child_process';
3
3
  import { restoreStdinListeners } from './agent.js';
4
- import { c, PROMPT, CONTINUE_PROMPT, PROMPT_ARROW, printPromptHeader, modeTag, drawBanner, infoLine, successLine, errorLine, THEMES, setTheme, stripAnsi, contextRing, } from './theme.js';
4
+ import { c, PROMPT, CONTINUE_PROMPT, PROMPT_ARROW, printPromptHeader, modeTag, drawBanner, infoLine, successLine, errorLine, THEMES, setTheme, stripAnsi, contextRing, renderSlashMenu, } from './theme.js';
5
5
  import { renderMarkdown } from './renderer.js';
6
6
  import { loadAllMemory } from './memory.js';
7
7
  import { HOME_DIR, saveConfig, DEFAULT_MODEL, IKIE_HOST, IKIE_API_BASE, isLoggedIn, getApiKey } from './config.js';
@@ -102,22 +102,36 @@ const SLASH_CMDS = [
102
102
  { name: 'logout', desc: 'Sign out of ikie account' },
103
103
  { name: 'exit', desc: 'Exit Ikie' },
104
104
  ];
105
- function slashCompleter(line) {
105
+ /**
106
+ * Compute live slash-menu items for the current input line.
107
+ * Returns rich items (name/desc/insert) plus the typed fragment to highlight.
108
+ */
109
+ function computeSlashMenu(line) {
106
110
  if (!line.startsWith('/'))
107
- return [[], line];
108
- const parts = line.slice(1).split(/\s+/);
111
+ return { items: [], fragment: '' };
112
+ const afterSlash = line.slice(1);
113
+ const parts = afterSlash.split(/\s+/);
109
114
  const cmd = parts[0]?.toLowerCase() ?? '';
115
+ // Sub-command stage: "/cmd <frag>" where cmd has subs.
110
116
  if (parts.length >= 2) {
111
- const match = SLASH_CMDS.find(c => c.name === cmd);
112
- if (match?.subs) {
113
- const sub = parts[1].toLowerCase();
114
- const hits = match.subs.filter(s => s.name.startsWith(sub)).map(s => `/${cmd} ${s.name}`);
115
- return [hits, line];
116
- }
117
- return [[], line];
117
+ const match = SLASH_CMDS.find(s => s.name === cmd);
118
+ if (!match?.subs)
119
+ return { items: [], fragment: '' };
120
+ const frag = parts[1].toLowerCase();
121
+ const items = match.subs
122
+ .filter(s => s.name.startsWith(frag))
123
+ .map(s => ({ name: `${cmd} ${s.name}`, desc: s.desc, insert: `/${cmd} ${s.name} ` }));
124
+ return { items, fragment: frag };
118
125
  }
119
- const hits = SLASH_CMDS.filter(c => c.name.startsWith(cmd)).map(c => '/' + c.name);
120
- return [hits, line];
126
+ // Command-name stage: "/<frag>".
127
+ const items = SLASH_CMDS
128
+ .filter(s => s.name.startsWith(cmd))
129
+ .map(s => ({
130
+ name: s.name,
131
+ desc: s.desc,
132
+ insert: `/${s.name}${s.subs || s.args ? ' ' : ''}`,
133
+ }));
134
+ return { items, fragment: cmd };
121
135
  }
122
136
  function printHelp() {
123
137
  console.log(`
@@ -847,7 +861,6 @@ export async function startREPL(agent, config, projectContext, oneShot) {
847
861
  terminal: true,
848
862
  historySize: MAX_HISTORY,
849
863
  prompt: PROMPT,
850
- completer: slashCompleter,
851
864
  });
852
865
  let multilineBuffer = '';
853
866
  let busy = false;
@@ -860,6 +873,77 @@ export async function startREPL(agent, config, projectContext, oneShot) {
860
873
  let pasteMode = false;
861
874
  let pasteBuffer = '';
862
875
  const pastedBlocks = new Map();
876
+ // ── Live slash-command menu state ──────────────────────────────────────────
877
+ let currentPrompt = PROMPT; // the prompt string readline is showing
878
+ let menuOpen = false;
879
+ let menuItems = [];
880
+ let menuFragment = '';
881
+ let menuSel = 0;
882
+ let menuRows = 0; // menu lines currently drawn below input
883
+ const inputCol = () => stripAnsi(currentPrompt).length + (rl.cursor ?? 0);
884
+ const eraseMenu = () => {
885
+ if (menuRows === 0)
886
+ return;
887
+ let out = '\r';
888
+ for (let i = 0; i < menuRows; i++)
889
+ out += '\n\x1b[2K';
890
+ out += `\x1b[${menuRows}A\r`;
891
+ const col = inputCol();
892
+ if (col > 0)
893
+ out += `\x1b[${col}C`;
894
+ process.stdout.write(out);
895
+ menuRows = 0;
896
+ };
897
+ const drawMenu = () => {
898
+ eraseMenu();
899
+ if (!menuOpen || menuItems.length === 0)
900
+ return;
901
+ const cols = Math.max(40, process.stdout.columns ?? 80);
902
+ const rows = renderSlashMenu(menuItems, menuSel, menuFragment, cols);
903
+ let out = '\r';
904
+ for (const r of rows)
905
+ out += `\n\x1b[2K${r}`;
906
+ out += `\x1b[${rows.length}A\r`;
907
+ const col = inputCol();
908
+ if (col > 0)
909
+ out += `\x1b[${col}C`;
910
+ process.stdout.write(out);
911
+ menuRows = rows.length;
912
+ };
913
+ const closeMenu = () => {
914
+ if (!menuOpen && menuRows === 0)
915
+ return;
916
+ menuOpen = false;
917
+ eraseMenu();
918
+ };
919
+ const updateMenu = () => {
920
+ const line = rl.line ?? '';
921
+ const { items, fragment } = computeSlashMenu(line);
922
+ // Hide when the sole match is exactly what's typed (nothing left to suggest).
923
+ const exhausted = items.length === 1 && line === `/${items[0].name}`;
924
+ if (items.length && line.startsWith('/') && !exhausted) {
925
+ menuItems = items;
926
+ menuFragment = fragment;
927
+ menuSel = 0;
928
+ menuOpen = true;
929
+ drawMenu();
930
+ }
931
+ else {
932
+ closeMenu();
933
+ }
934
+ };
935
+ const acceptSelection = () => {
936
+ if (!menuOpen || !menuItems[menuSel])
937
+ return;
938
+ const insert = menuItems[menuSel].insert;
939
+ eraseMenu();
940
+ menuOpen = false;
941
+ // Replace the line via public readline API (cursor→end, kill line, write).
942
+ rl.write(null, { ctrl: true, name: 'e' });
943
+ rl.write(null, { ctrl: true, name: 'u' });
944
+ rl.write(insert);
945
+ updateMenu(); // a trailing space may surface a sub-command menu
946
+ };
863
947
  const readlineDataListeners = process.stdin.rawListeners('data').slice();
864
948
  for (const fn of readlineDataListeners) {
865
949
  process.stdin.removeListener('data', fn);
@@ -886,8 +970,52 @@ export async function startREPL(agent, config, projectContext, oneShot) {
886
970
  };
887
971
  const routeInputData = (data) => {
888
972
  const text = data.toString('utf8');
973
+ // ── Live slash-menu navigation (only while the menu is open) ──
974
+ if (menuOpen) {
975
+ if (text === '\x1b[A') {
976
+ menuSel = (menuSel - 1 + menuItems.length) % menuItems.length;
977
+ drawMenu();
978
+ return;
979
+ }
980
+ if (text === '\x1b[B') {
981
+ menuSel = (menuSel + 1) % menuItems.length;
982
+ drawMenu();
983
+ return;
984
+ }
985
+ if (text === '\t') {
986
+ acceptSelection();
987
+ return;
988
+ }
989
+ if (text === '\x1b') {
990
+ closeMenu();
991
+ return;
992
+ }
993
+ if (text === '\r' || text === '\n') {
994
+ const item = menuItems[menuSel];
995
+ if (item) {
996
+ // Fill the highlighted command. If it takes no args/subs (no trailing
997
+ // space) submit it immediately; otherwise keep editing for the arg.
998
+ const submit = !item.insert.endsWith(' ');
999
+ eraseMenu();
1000
+ menuOpen = false;
1001
+ rl.write(null, { ctrl: true, name: 'e' });
1002
+ rl.write(null, { ctrl: true, name: 'u' });
1003
+ rl.write(item.insert);
1004
+ if (submit)
1005
+ forwardToReadline(Buffer.from('\r'));
1006
+ else
1007
+ updateMenu();
1008
+ }
1009
+ else {
1010
+ closeMenu();
1011
+ forwardToReadline(data);
1012
+ }
1013
+ return;
1014
+ }
1015
+ }
889
1016
  // Shift+Tab (CSI Z) → cycle agent⇄plan mode live at the prompt.
890
1017
  if (text === '\x1b[Z') {
1018
+ closeMenu();
891
1019
  const next = agent.getMode() === 'agent' ? 'plan' : 'agent';
892
1020
  agent.setMode(next);
893
1021
  const saved = rl.line || '';
@@ -900,6 +1028,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
900
1028
  }
901
1029
  // Check for Ctrl+V (0x16) - try to paste image from clipboard
902
1030
  if (data.length === 1 && data[0] === 0x16) {
1031
+ closeMenu();
903
1032
  // Check if clipboard has an image
904
1033
  if (hasClipboardImage()) {
905
1034
  try {
@@ -923,6 +1052,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
923
1052
  return;
924
1053
  }
925
1054
  if (!pasteMode && !text.includes('\x1b[200~') && /[\r\n]/.test(text.trim()) && text.length > 3) {
1055
+ closeMenu();
926
1056
  const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
927
1057
  rl.write(normalized);
928
1058
  return;
@@ -946,11 +1076,13 @@ export async function startREPL(agent, config, projectContext, oneShot) {
946
1076
  const start = rest.indexOf('\x1b[200~');
947
1077
  if (start === -1) {
948
1078
  forwardToReadline(Buffer.from(rest, 'utf8'));
1079
+ updateMenu();
949
1080
  return;
950
1081
  }
951
1082
  if (start > 0) {
952
1083
  forwardToReadline(Buffer.from(rest.slice(0, start), 'utf8'));
953
1084
  }
1085
+ closeMenu();
954
1086
  pasteMode = true;
955
1087
  rest = rest.slice(start + '\x1b[200~'.length);
956
1088
  }
@@ -976,7 +1108,9 @@ export async function startREPL(agent, config, projectContext, oneShot) {
976
1108
  return `${expanded}\n\n${attachments.join('\n\n')}`;
977
1109
  };
978
1110
  const showPrompt = () => {
1111
+ closeMenu();
979
1112
  if (multilineBuffer) {
1113
+ currentPrompt = CONTINUE_PROMPT;
980
1114
  rl.setPrompt(CONTINUE_PROMPT);
981
1115
  }
982
1116
  else {
@@ -991,6 +1125,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
991
1125
  const labels = imageState.pending.map(image => `[Image #${image.id}]`).join(' ');
992
1126
  process.stdout.write(`${c.muted(' attached')} ${c.secondary(labels)}\n`);
993
1127
  }
1128
+ currentPrompt = prompt;
994
1129
  rl.setPrompt(prompt);
995
1130
  }
996
1131
  rl.prompt();
@@ -1013,6 +1148,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1013
1148
  showPrompt();
1014
1149
  });
1015
1150
  const handleLine = async (raw) => {
1151
+ closeMenu();
1016
1152
  const line = raw.trim();
1017
1153
  if (!line) {
1018
1154
  showPrompt();
package/dist/theme.d.ts CHANGED
@@ -62,13 +62,28 @@ export declare class InlineSpinner {
62
62
  }
63
63
  export declare function toolLine(name: string, args: string): string;
64
64
  /** Multi-line output block shown after a tool runs. */
65
- export declare function toolOutputBlock(result: string, ms: number): string;
66
- /** Colored diff block for edit_file / write_file. */
67
- export declare function toolDiffBlock(oldStr: string, newStr: string, ms: number): string;
65
+ export declare function toolOutputBlock(result: string, ms: number, indent?: string): string;
66
+ interface DiffOpts {
67
+ path?: string;
68
+ indent?: string;
69
+ }
70
+ /**
71
+ * Render an edit as a polished unified diff: line-number gutter, a few lines of
72
+ * surrounding context pulled from the file, and full-width muted red/green rows.
73
+ */
74
+ export declare function toolDiffBlock(oldStr: string, newStr: string, ms: number, opts?: DiffOpts): string;
68
75
  export declare function toolSuccessLine(ms: number, preview?: string): string;
69
76
  export declare function toolErrorLine(msg: string): string;
77
+ export interface SlashMenuItem {
78
+ name: string;
79
+ desc: string;
80
+ insert: string;
81
+ }
82
+ /** Render the live slash-command dropdown as an array of fully-styled rows. */
83
+ export declare function renderSlashMenu(items: SlashMenuItem[], sel: number, fragment: string, cols: number): string[];
70
84
  export declare function successLine(msg: string): string;
71
85
  export declare function errorLine(msg: string): string;
72
86
  export declare function warnLine(msg: string): string;
73
87
  export declare function infoLine(msg: string): string;
74
88
  export declare function permissionPrompt(toolName: string, preview: string): string;
89
+ export {};
package/dist/theme.js CHANGED
@@ -375,55 +375,159 @@ export function toolLine(name, args) {
375
375
  return `${tint('●')} ${c.white.bold(verb + badge)}${c.dim('(')}${c.muted(args)}${c.dim(')')}`;
376
376
  }
377
377
  /** Multi-line output block shown after a tool runs. */
378
- export function toolOutputBlock(result, ms) {
378
+ export function toolOutputBlock(result, ms, indent = ' ') {
379
379
  const time = c.muted(formatDuration(ms));
380
380
  const lines = result.split('\n').filter(l => l.trim() !== '');
381
381
  if (!lines.length)
382
- return ` ${c.muted('⎿')} ${time}`;
382
+ return `${indent}${c.muted('⎿')} ${time}`;
383
383
  const MAX = 5;
384
384
  const shown = lines.slice(0, MAX);
385
385
  const hidden = lines.length - MAX;
386
+ const cont = indent + ' ';
386
387
  const out = [];
387
- out.push(` ${c.muted('⎿')} ${time} ${c.dim(clampLine(shown[0]))}`);
388
+ out.push(`${indent}${c.muted('⎿')} ${time} ${c.dim(clampLine(shown[0]))}`);
388
389
  for (let i = 1; i < shown.length; i++) {
389
- out.push(` ${c.dim(clampLine(shown[i]))}`);
390
+ out.push(`${cont}${c.dim(clampLine(shown[i]))}`);
390
391
  }
391
392
  if (hidden > 0) {
392
- out.push(` ${c.muted(`… +${hidden} lines`)}`);
393
+ out.push(`${cont}${c.muted(`… +${hidden} lines`)}`);
393
394
  }
394
395
  return out.join('\n');
395
396
  }
396
- /** Colored diff block for edit_file / write_file. */
397
- export function toolDiffBlock(oldStr, newStr, ms) {
398
- const time = c.muted(formatDuration(ms));
399
- const removed = oldStr.split('\n');
400
- const added = newStr.split('\n');
401
- const nRemoved = removed.filter(l => l.trim()).length;
402
- const nAdded = added.filter(l => l.trim()).length;
403
- const parts = [];
404
- if (nAdded)
405
- parts.push(`Added ${nAdded} line${nAdded === 1 ? '' : 's'}`);
406
- if (nRemoved)
407
- parts.push(`removed ${nRemoved} line${nRemoved === 1 ? '' : 's'}`);
408
- const summary = parts.join(', ') || 'no changes';
397
+ // ── Diff rendering ────────────────────────────────────────────────────────────
398
+ // Diff palette (256-color, muted — chalk degrades gracefully on low-color TTYs).
399
+ const diffColors = {
400
+ delBg: chalk.bgAnsi256(52), // deep red
401
+ insBg: chalk.bgAnsi256(22), // deep green
402
+ delGutter: chalk.ansi256(210),
403
+ insGutter: chalk.ansi256(151),
404
+ onBg: chalk.ansi256(255),
405
+ ctxGutter: chalk.ansi256(240),
406
+ ctxCode: chalk.ansi256(248),
407
+ };
408
+ /** Classic LCS line diff interleaves equal/removed/added like a real unified diff. */
409
+ function lineDiff(a, b) {
410
+ const n = a.length, m = b.length;
411
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
412
+ for (let i = n - 1; i >= 0; i--) {
413
+ for (let j = m - 1; j >= 0; j--) {
414
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
415
+ }
416
+ }
409
417
  const out = [];
410
- out.push(` ${c.muted('⎿')} ${time} ${c.dim(summary)}`);
411
- const MAX = 10;
412
- let shown = 0;
413
- for (const line of removed) {
414
- if (shown >= MAX)
415
- break;
416
- out.push(chalk.bgRed.white(` - ${clampLine(line, 116)}`));
417
- shown++;
418
+ let i = 0, j = 0;
419
+ while (i < n && j < m) {
420
+ if (a[i] === b[j]) {
421
+ out.push({ t: 'eq', s: a[i] });
422
+ i++;
423
+ j++;
424
+ }
425
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
426
+ out.push({ t: 'del', s: a[i] });
427
+ i++;
428
+ }
429
+ else {
430
+ out.push({ t: 'ins', s: b[j] });
431
+ j++;
432
+ }
433
+ }
434
+ while (i < n)
435
+ out.push({ t: 'del', s: a[i++] });
436
+ while (j < m)
437
+ out.push({ t: 'ins', s: b[j++] });
438
+ return out;
439
+ }
440
+ /**
441
+ * Render an edit as a polished unified diff: line-number gutter, a few lines of
442
+ * surrounding context pulled from the file, and full-width muted red/green rows.
443
+ */
444
+ export function toolDiffBlock(oldStr, newStr, ms, opts = {}) {
445
+ const indent = opts.indent ?? ' ';
446
+ const cols = Math.max(40, (process.stdout.columns ?? 100));
447
+ const time = c.muted(formatDuration(ms));
448
+ const oldLines = oldStr.split('\n');
449
+ const newLines = newStr.split('\n');
450
+ const ops = lineDiff(oldLines, newLines);
451
+ const nDel = ops.filter(o => o.t === 'del').length;
452
+ const nIns = ops.filter(o => o.t === 'ins').length;
453
+ // Anchor line numbers + context lines by locating the new text in the file.
454
+ let anchor = 1;
455
+ let fileLines = null;
456
+ const CTX = 3;
457
+ if (opts.path) {
458
+ try {
459
+ const content = readFileSync(opts.path, 'utf-8');
460
+ const idx = content.indexOf(newStr);
461
+ if (idx >= 0) {
462
+ anchor = (content.slice(0, idx).match(/\n/g)?.length ?? 0) + 1;
463
+ fileLines = content.split('\n');
464
+ }
465
+ }
466
+ catch { /* no context — fall back to anchorless diff */ }
467
+ }
468
+ const summaryParts = [];
469
+ if (nIns)
470
+ summaryParts.push(`Added ${nIns} line${nIns === 1 ? '' : 's'}`);
471
+ if (nDel)
472
+ summaryParts.push(`removed ${nDel} line${nDel === 1 ? '' : 's'}`);
473
+ const summary = summaryParts.join(', ') || 'no changes';
474
+ // Width of the line-number gutter.
475
+ const maxNum = anchor + Math.max(oldLines.length, newLines.length) + CTX;
476
+ const gw = String(maxNum).length;
477
+ const rowsRaw = [];
478
+ // Leading context (lines just before the edit).
479
+ if (fileLines) {
480
+ for (let k = Math.max(1, anchor - CTX); k < anchor; k++) {
481
+ rowsRaw.push({ kind: 'eq', num: k, text: fileLines[k - 1] ?? '' });
482
+ }
418
483
  }
419
- for (const line of added) {
420
- if (shown >= MAX)
421
- break;
422
- out.push(chalk.bgGreen.black(` + ${clampLine(line, 116)}`));
423
- shown++;
484
+ // The diff body — track separate old/new line counters off the same anchor.
485
+ let oldNum = anchor, newNum = anchor;
486
+ for (const op of ops) {
487
+ if (op.t === 'eq') {
488
+ rowsRaw.push({ kind: 'eq', num: newNum, text: op.s });
489
+ oldNum++;
490
+ newNum++;
491
+ }
492
+ else if (op.t === 'del') {
493
+ rowsRaw.push({ kind: 'del', num: oldNum, text: op.s });
494
+ oldNum++;
495
+ }
496
+ else {
497
+ rowsRaw.push({ kind: 'ins', num: newNum, text: op.s });
498
+ newNum++;
499
+ }
424
500
  }
425
- if (shown >= MAX && (removed.length + added.length > MAX)) {
426
- out.push(` ${c.muted(`… +${removed.length + added.length - MAX} lines`)}`);
501
+ // Trailing context (lines just after the edit).
502
+ if (fileLines) {
503
+ const afterStart = newNum;
504
+ for (let k = afterStart; k < afterStart + CTX && k <= fileLines.length; k++) {
505
+ rowsRaw.push({ kind: 'eq', num: k, text: fileLines[k - 1] ?? '' });
506
+ }
507
+ }
508
+ const out = [];
509
+ out.push(`${indent}${c.muted('⎿')} ${time} ${c.dim(summary)}`);
510
+ const MAX = 16;
511
+ const codeWidth = cols - indent.length - gw - 4; // gutter + " x " marker + space
512
+ const render = rowsRaw.slice(0, MAX);
513
+ for (const r of render) {
514
+ const numStr = String(r.num).padStart(gw);
515
+ const code = r.text.length > codeWidth ? r.text.slice(0, codeWidth - 1) + '…' : r.text;
516
+ if (r.kind === 'eq') {
517
+ out.push(`${indent}${diffColors.ctxGutter(numStr)} ${diffColors.ctxCode(code)}`);
518
+ }
519
+ else {
520
+ const marker = r.kind === 'del' ? '-' : '+';
521
+ const bg = r.kind === 'del' ? diffColors.delBg : diffColors.insBg;
522
+ const gutter = r.kind === 'del' ? diffColors.delGutter : diffColors.insGutter;
523
+ const plainLen = numStr.length + 3 + code.length; // num + " x " + code
524
+ const pad = Math.max(0, cols - indent.length - plainLen);
525
+ const inner = `${gutter(numStr)} ${gutter(marker)} ${diffColors.onBg(code)}${' '.repeat(pad)}`;
526
+ out.push(`${indent}${bg(inner)}`);
527
+ }
528
+ }
529
+ if (rowsRaw.length > MAX) {
530
+ out.push(`${indent}${c.muted(`… +${rowsRaw.length - MAX} lines`)}`);
427
531
  }
428
532
  return out.join('\n');
429
533
  }
@@ -441,6 +545,33 @@ function formatDuration(ms) {
441
545
  const s = ms / 1000;
442
546
  return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
443
547
  }
548
+ /** Render the live slash-command dropdown as an array of fully-styled rows. */
549
+ export function renderSlashMenu(items, sel, fragment, cols) {
550
+ if (!items.length)
551
+ return [];
552
+ const nameW = Math.min(22, Math.max(...items.map(i => i.name.length)));
553
+ const frag = fragment.toLowerCase();
554
+ return items.map((item, i) => {
555
+ const selected = i === sel;
556
+ const bar = selected ? c.primary('▌') : ' ';
557
+ // Highlight the matched prefix within the command name.
558
+ let name;
559
+ if (frag && item.name.toLowerCase().startsWith(frag)) {
560
+ const hit = item.name.slice(0, frag.length);
561
+ const rest = item.name.slice(frag.length);
562
+ name = `${c.accent.bold(hit)}${selected ? c.white.bold(rest) : c.white(rest)}`;
563
+ }
564
+ else {
565
+ name = selected ? c.white.bold(item.name) : c.white(item.name);
566
+ }
567
+ const namePadded = name + ' '.repeat(Math.max(0, nameW - item.name.length));
568
+ // Description, clamped to the remaining width.
569
+ const used = 2 /* bar+space */ + 1 + nameW + 2; // leading "/" + gap
570
+ const descMax = Math.max(8, cols - used - 2);
571
+ const desc = item.desc.length > descMax ? item.desc.slice(0, descMax - 1) + '…' : item.desc;
572
+ return `${bar} ${c.primary('/')}${namePadded} ${c.dim(desc)}`;
573
+ });
574
+ }
444
575
  export function successLine(msg) {
445
576
  return ` ${c.success('ok')} ${c.muted(msg)}`;
446
577
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikie-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Agentic coding CLI — your terminal AI pair programmer",
5
5
  "type": "module",
6
6
  "bin": {