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 +4 -3
- package/dist/repl.js +150 -14
- package/dist/theme.d.ts +18 -3
- package/dist/theme.js +164 -33
- package/package.json +1 -1
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
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 [
|
|
108
|
-
const
|
|
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(
|
|
112
|
-
if (match?.subs)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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(
|
|
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(
|
|
390
|
+
out.push(`${cont}${c.dim(clampLine(shown[i]))}`);
|
|
390
391
|
}
|
|
391
392
|
if (hidden > 0) {
|
|
392
|
-
out.push(
|
|
393
|
+
out.push(`${cont}${c.muted(`… +${hidden} lines`)}`);
|
|
393
394
|
}
|
|
394
395
|
return out.join('\n');
|
|
395
396
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
}
|