ispbills-icli 8.4.11 → 8.4.13
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/bin/icli.js +4 -24
- package/package.json +1 -1
- package/src/banner.js +1 -3
- package/src/clipboard.js +1 -35
- package/src/commands.js +0 -3
- package/src/tui/app.js +27 -249
- package/src/tui/components.js +89 -587
- package/src/tui/composer.js +4 -39
- package/src/tui/markdown.js +3 -19
- package/src/tui/run.js +1 -17
- package/src/tui/theme.js +11 -29
- package/src/resume.js +0 -30
- package/src/shell.js +0 -18
- package/src/tui/mouse.js +0 -68
- package/src/version-check.js +0 -43
package/src/tui/composer.js
CHANGED
|
@@ -20,7 +20,7 @@ const MENTIONS = [
|
|
|
20
20
|
{ tag: '@mac', desc: 'A MAC address' },
|
|
21
21
|
];
|
|
22
22
|
|
|
23
|
-
export function Composer({ onSubmit, busy, width = 80
|
|
23
|
+
export function Composer({ onSubmit, busy, width = 80 }) {
|
|
24
24
|
const [value, setValue] = useState('');
|
|
25
25
|
const [cursor, setCursor] = useState(0);
|
|
26
26
|
const [history, setHistory] = useState([]);
|
|
@@ -40,18 +40,6 @@ export function Composer({ onSubmit, busy, width = 80, prefill = null, onPrefill
|
|
|
40
40
|
lastMut.current = kind;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
// When app.js injects a /paste prefill, load it into the input box once.
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (!prefill) return;
|
|
46
|
-
setValue(prefill);
|
|
47
|
-
setCursor(prefill.length);
|
|
48
|
-
undoRef.current = []; redoRef.current = []; lastMut.current = '';
|
|
49
|
-
onPrefillConsumed?.();
|
|
50
|
-
}, [prefill]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
51
|
-
|
|
52
|
-
// Ctrl+S stash: save the current prompt to restore later (GitHub Copilot CLI parity).
|
|
53
|
-
const stashRef = useRef('');
|
|
54
|
-
|
|
55
43
|
// Bracketed-paste assembly. A right-click / Cmd+V paste is wrapped by the
|
|
56
44
|
// terminal in \x1b[200~ … \x1b[201~ and can arrive split across several
|
|
57
45
|
// reads, so we buffer until the end marker before inserting.
|
|
@@ -155,18 +143,6 @@ export function Composer({ onSubmit, busy, width = 80, prefill = null, onPrefill
|
|
|
155
143
|
return;
|
|
156
144
|
}
|
|
157
145
|
|
|
158
|
-
// ── Ctrl+S: stash / pop prompt (GitHub Copilot CLI parity) ──
|
|
159
|
-
if (key.ctrl && input === 's') {
|
|
160
|
-
if (value) {
|
|
161
|
-
stashRef.current = value;
|
|
162
|
-
setValue(''); setCursor(0);
|
|
163
|
-
} else if (stashRef.current) {
|
|
164
|
-
const v = stashRef.current; stashRef.current = '';
|
|
165
|
-
setText(v, v.length);
|
|
166
|
-
}
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
146
|
// ── Menu / history navigation ──
|
|
171
147
|
if (key.upArrow) {
|
|
172
148
|
if (menuOpen) { setSel((i) => Math.max(0, i - 1)); return; }
|
|
@@ -239,15 +215,6 @@ export function Composer({ onSubmit, busy, width = 80, prefill = null, onPrefill
|
|
|
239
215
|
const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
|
|
240
216
|
const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
|
|
241
217
|
|
|
242
|
-
// ── Shell mode: prefix '!' triggers yellow $ prompt + orange border ──
|
|
243
|
-
const shellMode = value.startsWith('!') && !menuOpen;
|
|
244
|
-
const borderColor = busy ? C.gray : shellMode ? C.orange : C.accent;
|
|
245
|
-
const promptGlyph = shellMode ? '$ ' : glyphs.prompt + ' ';
|
|
246
|
-
const promptColor = busy ? C.gray : shellMode ? C.orange : C.accent;
|
|
247
|
-
|
|
248
|
-
// Number of lines in the buffer (for the line-count badge).
|
|
249
|
-
const lineCount = value.split('\n').length;
|
|
250
|
-
|
|
251
218
|
return html`
|
|
252
219
|
<${Box} flexDirection="column" width=${width}>
|
|
253
220
|
${menuOpen
|
|
@@ -273,18 +240,16 @@ export function Composer({ onSubmit, busy, width = 80, prefill = null, onPrefill
|
|
|
273
240
|
<//>`
|
|
274
241
|
: null}
|
|
275
242
|
|
|
276
|
-
<${Box} width=${width} borderStyle=${borderStyle()} borderColor=${
|
|
277
|
-
<${Text} color=${
|
|
243
|
+
<${Box} width=${width} borderStyle=${borderStyle()} borderColor=${busy ? C.gray : C.accent} paddingX=${1}>
|
|
244
|
+
<${Text} color=${busy ? C.gray : C.accent}>${glyphs.prompt} <//>
|
|
278
245
|
${empty
|
|
279
246
|
? html`<${Box} flexGrow=${1}>
|
|
280
247
|
<${Text} color=${C.gray} inverse> <//>
|
|
281
|
-
<${Text} color=${C.gray}>${busy ? 'Working…' : '
|
|
248
|
+
<${Text} color=${C.gray}>${busy ? 'Working…' : 'Ask about your network, or type / for commands'}<//>
|
|
282
249
|
<//>`
|
|
283
250
|
: html`<${Box} flexGrow=${1}>
|
|
284
251
|
<${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.gray}>${ghost}<//>
|
|
285
252
|
<//>`}
|
|
286
|
-
${lineCount > 1
|
|
287
|
-
? html`<${Text} dimColor> ${lineCount}L<//>` : null}
|
|
288
253
|
<//>
|
|
289
254
|
<//>`;
|
|
290
255
|
}
|
package/src/tui/markdown.js
CHANGED
|
@@ -90,30 +90,14 @@ function renderTable(header, rows) {
|
|
|
90
90
|
export function Markdown({ content = '' }) {
|
|
91
91
|
const rows = [];
|
|
92
92
|
let inCode = false;
|
|
93
|
-
let codeLines = [];
|
|
94
93
|
const lines = String(content).replace(/\s+$/, '').split('\n');
|
|
95
94
|
|
|
96
95
|
for (let i = 0; i < lines.length; i++) {
|
|
97
96
|
const line = lines[i].replace(/\t/g, ' ');
|
|
98
97
|
|
|
99
|
-
// Fenced code block
|
|
100
|
-
if (line.trim().startsWith('```')) {
|
|
101
|
-
|
|
102
|
-
inCode = true;
|
|
103
|
-
codeLines = [];
|
|
104
|
-
} else {
|
|
105
|
-
inCode = false;
|
|
106
|
-
const captured = codeLines;
|
|
107
|
-
rows.push(html`
|
|
108
|
-
<${Box} key=${k()} flexDirection="column" marginY=${1}
|
|
109
|
-
borderStyle=${asciiSafe() ? 'single' : 'single'} borderColor=${C.gray}
|
|
110
|
-
borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
|
|
111
|
-
${captured.map((cl, ci) => html`<${Text} key=${k()} color=${C.green}>${cl}<//>` )}
|
|
112
|
-
<//>`);
|
|
113
|
-
}
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
if (inCode) { codeLines.push(line); continue; }
|
|
98
|
+
// Fenced code block.
|
|
99
|
+
if (line.trim().startsWith('```')) { inCode = !inCode; continue; }
|
|
100
|
+
if (inCode) { rows.push(html`<${Text} key=${k()} color=${C.green}> ${line}<//>`); continue; }
|
|
117
101
|
|
|
118
102
|
// GFM table: header row + separator + body rows.
|
|
119
103
|
if (isTableRow(line) && i + 1 < lines.length && isTableSep(lines[i + 1])) {
|
package/src/tui/run.js
CHANGED
|
@@ -3,32 +3,19 @@
|
|
|
3
3
|
// once at the top and scrolls away, and the terminal's native scrollback keeps
|
|
4
4
|
// the full conversation history. Completed transcript items are flushed to the
|
|
5
5
|
// scrollback by Ink's <Static>; only the live area + input re-render in place.
|
|
6
|
-
//
|
|
7
|
-
// Mouse support: SGR mouse tracking is enabled so that:
|
|
8
|
-
// • Left-clicks on the tab bar switch tabs.
|
|
9
|
-
// • Scroll-wheel UP scrolls the terminal's native scrollback (history).
|
|
10
|
-
// • Scroll-wheel DOWN is swallowed — no drifting below the live area.
|
|
11
6
|
import { html } from './dom.js';
|
|
12
7
|
import { render } from 'ink';
|
|
13
8
|
import { App } from './app.js';
|
|
14
|
-
import { patchStdinMouse, enableMouse, disableMouse } from './mouse.js';
|
|
15
9
|
|
|
16
10
|
export async function runTui(cfg, opts = {}) {
|
|
17
|
-
const { display = {}, onExternal, initialQuestion
|
|
11
|
+
const { display = {}, onExternal, initialQuestion } = opts;
|
|
18
12
|
let externalAction;
|
|
19
13
|
|
|
20
|
-
// Patch stdin.read() to strip mouse sequences before Ink sees them,
|
|
21
|
-
// then enable SGR mouse tracking so the terminal sends us those events.
|
|
22
|
-
const unpatchMouse = patchStdinMouse(process.stdin);
|
|
23
|
-
enableMouse();
|
|
24
|
-
|
|
25
14
|
const instance = render(
|
|
26
15
|
html`<${App}
|
|
27
16
|
cfg=${cfg}
|
|
28
17
|
display=${display}
|
|
29
18
|
initialQuestion=${initialQuestion}
|
|
30
|
-
version=${version}
|
|
31
|
-
resumeSession=${resumeSession}
|
|
32
19
|
onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
|
|
33
20
|
/>`,
|
|
34
21
|
{ exitOnCtrlC: false },
|
|
@@ -36,8 +23,5 @@ export async function runTui(cfg, opts = {}) {
|
|
|
36
23
|
|
|
37
24
|
await instance.waitUntilExit();
|
|
38
25
|
|
|
39
|
-
disableMouse();
|
|
40
|
-
unpatchMouse();
|
|
41
|
-
|
|
42
26
|
return externalAction;
|
|
43
27
|
}
|
package/src/tui/theme.js
CHANGED
|
@@ -5,36 +5,18 @@ import { glyphs, asciiSafe } from '../ui.js';
|
|
|
5
5
|
|
|
6
6
|
export { glyphs, asciiSafe };
|
|
7
7
|
|
|
8
|
-
// Soft-blue brand accent (approx. xterm-256 colour 111)
|
|
8
|
+
// Soft-blue brand accent (approx. xterm-256 colour 111).
|
|
9
9
|
export const ACCENT = '#87afff';
|
|
10
|
-
|
|
11
|
-
// Semantic color palette. Where possible mirror Copilot CLI conventions:
|
|
12
|
-
// - accent/cyan for interactive / in-flight elements
|
|
13
|
-
// - green for success / completed
|
|
14
|
-
// - red for errors / destructive
|
|
15
|
-
// - yellow for warnings / caution / autopilot
|
|
16
|
-
// - magenta for reasoning/thinking
|
|
17
|
-
// - gray / dimColor for secondary / completed-dim text
|
|
18
10
|
export const C = {
|
|
19
|
-
accent:
|
|
20
|
-
gray:
|
|
21
|
-
white:
|
|
22
|
-
cyan:
|
|
23
|
-
green:
|
|
24
|
-
yellow:
|
|
25
|
-
red:
|
|
11
|
+
accent: ACCENT,
|
|
12
|
+
gray: 'gray',
|
|
13
|
+
white: 'white',
|
|
14
|
+
cyan: 'cyan',
|
|
15
|
+
green: 'green',
|
|
16
|
+
yellow: 'yellow',
|
|
17
|
+
red: 'red',
|
|
26
18
|
magenta: 'magenta',
|
|
27
|
-
blue:
|
|
28
|
-
orange: '#ffb86c', // shell mode / caution
|
|
29
|
-
purple: '#d097ff', // sub-agent / agent role
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
// Copilot-style task status glyphs (Unicode only; fall back handled per use-site).
|
|
33
|
-
// ● filled = step completed ◐ half = step in-progress ○ empty = pending
|
|
34
|
-
export const SG = {
|
|
35
|
-
filled: '●', // U+25CF completed
|
|
36
|
-
half: '◐', // U+25D0 in-progress (used alongside spinner)
|
|
37
|
-
empty: '○', // U+25CB pending
|
|
19
|
+
blue: 'blue',
|
|
38
20
|
};
|
|
39
21
|
|
|
40
22
|
// ink-spinner uses cli-spinners; 'line' is pure ASCII (|/-\), 'dots' is braille.
|
|
@@ -48,9 +30,9 @@ export function borderStyle() {
|
|
|
48
30
|
return asciiSafe() ? 'single' : 'round';
|
|
49
31
|
}
|
|
50
32
|
|
|
51
|
-
// Middle-dot separator
|
|
33
|
+
// Middle-dot separator, ASCII-safe on classic Windows consoles.
|
|
52
34
|
export function midDot() {
|
|
53
|
-
return asciiSafe() ? ' - ' : '
|
|
35
|
+
return asciiSafe() ? ' - ' : ' · ';
|
|
54
36
|
}
|
|
55
37
|
|
|
56
38
|
// Em-dash, ASCII-safe on classic Windows consoles.
|
package/src/resume.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Temporary session persistence for the /update hot-reload flow.
|
|
3
|
-
*
|
|
4
|
-
* Before updating, icli serialises the live conversation to a JSON file in
|
|
5
|
-
* /tmp so the new binary can resume right where the user left off.
|
|
6
|
-
*/
|
|
7
|
-
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
|
|
8
|
-
import { tmpdir } from 'os';
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
|
|
11
|
-
const PREFIX = 'icli-resume-';
|
|
12
|
-
|
|
13
|
-
/** Write a resume file and return its path. */
|
|
14
|
-
export function saveResume(convo, plain, items = []) {
|
|
15
|
-
const path = join(tmpdir(), `${PREFIX}${Date.now()}.json`);
|
|
16
|
-
writeFileSync(path, JSON.stringify({ convo, plain, items, savedAt: new Date().toISOString() }), 'utf8');
|
|
17
|
-
return path;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Load and delete a resume file. Returns { convo, plain, items } or null. */
|
|
21
|
-
export function loadResume(path) {
|
|
22
|
-
if (!path || !existsSync(path)) return null;
|
|
23
|
-
try {
|
|
24
|
-
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
-
try { unlinkSync(path); } catch { /* best-effort delete */ }
|
|
26
|
-
return { convo: data.convo || [], plain: data.plain || [], items: data.items || [] };
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
package/src/shell.js
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { exec } from 'child_process';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Run a shell command and capture combined stdout/stderr.
|
|
5
|
-
* Returns { ok, stdout, stderr, exitCode }.
|
|
6
|
-
*/
|
|
7
|
-
export function runShell(cmd, timeout = 30_000) {
|
|
8
|
-
return new Promise((resolve) => {
|
|
9
|
-
exec(cmd, { timeout, maxBuffer: 512 * 1024, shell: true }, (err, stdout, stderr) => {
|
|
10
|
-
resolve({
|
|
11
|
-
ok: !err || err.code === 0,
|
|
12
|
-
stdout: (stdout || '').trim(),
|
|
13
|
-
stderr: (stderr || '').trim(),
|
|
14
|
-
exitCode: err?.code ?? 0,
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
}
|
package/src/tui/mouse.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Mouse event support for the iCli TUI.
|
|
2
|
-
// Patches process.stdin.read() to intercept SGR mouse escape sequences before
|
|
3
|
-
// Ink's input parser sees them, routing them to a shared EventEmitter instead.
|
|
4
|
-
// This prevents mouse sequences from appearing as garbage in the composer.
|
|
5
|
-
//
|
|
6
|
-
// SGR mouse format: \x1b[<{btn};{x};{y}M (button press)
|
|
7
|
-
// \x1b[<{btn};{x};{y}m (button release)
|
|
8
|
-
//
|
|
9
|
-
// Button codes of interest:
|
|
10
|
-
// 0 = left click press/release
|
|
11
|
-
// 64 = scroll wheel up
|
|
12
|
-
// 65 = scroll wheel down
|
|
13
|
-
import { EventEmitter } from 'events';
|
|
14
|
-
|
|
15
|
-
const MOUSE_SGR = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
16
|
-
|
|
17
|
-
// Shared event emitter — app.js subscribes to 'event'.
|
|
18
|
-
export const mouseEvents = new EventEmitter();
|
|
19
|
-
mouseEvents.setMaxListeners(20);
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Patch stdin.read() to intercept and strip mouse sequences.
|
|
23
|
-
* Returns an unpatch function to restore the original.
|
|
24
|
-
*/
|
|
25
|
-
export function patchStdinMouse(stdin) {
|
|
26
|
-
if (!stdin || typeof stdin.read !== 'function') return () => {};
|
|
27
|
-
const origRead = stdin.read.bind(stdin);
|
|
28
|
-
|
|
29
|
-
stdin.read = function (size) {
|
|
30
|
-
const data = origRead(size);
|
|
31
|
-
if (!data) return data;
|
|
32
|
-
const str = data.toString('utf8');
|
|
33
|
-
|
|
34
|
-
// Emit each mouse event found in the chunk.
|
|
35
|
-
MOUSE_SGR.lastIndex = 0;
|
|
36
|
-
let m;
|
|
37
|
-
while ((m = MOUSE_SGR.exec(str)) !== null) {
|
|
38
|
-
mouseEvents.emit('event', {
|
|
39
|
-
btn: parseInt(m[1], 10),
|
|
40
|
-
x: parseInt(m[2], 10),
|
|
41
|
-
y: parseInt(m[3], 10),
|
|
42
|
-
press: m[4] === 'M',
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Strip ALL mouse sequences so Ink never sees them.
|
|
47
|
-
const filtered = str.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, '');
|
|
48
|
-
return filtered ? Buffer.from(filtered, 'utf8') : null;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
return function unpatch() {
|
|
52
|
-
stdin.read = origRead;
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Enable SGR mouse button + scroll-wheel tracking. */
|
|
57
|
-
export function enableMouse() {
|
|
58
|
-
try {
|
|
59
|
-
process.stdout.write('\x1b[?1000h\x1b[?1006h');
|
|
60
|
-
} catch {}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Disable SGR mouse tracking (restore normal terminal mouse behaviour). */
|
|
64
|
-
export function disableMouse() {
|
|
65
|
-
try {
|
|
66
|
-
process.stdout.write('\x1b[?1000l\x1b[?1006l');
|
|
67
|
-
} catch {}
|
|
68
|
-
}
|
package/src/version-check.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Background version check against npm registry.
|
|
3
|
-
* Caches the result in the session so we only hit npm once per run.
|
|
4
|
-
* Resolves to { latest, isNewer } or null on any error (network, etc.).
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const PKG_NAME = 'ispbills-icli';
|
|
8
|
-
const REGISTRY = `https://registry.npmjs.org/${PKG_NAME}/latest`;
|
|
9
|
-
|
|
10
|
-
/** Parse a semver string into [major, minor, patch] ints. */
|
|
11
|
-
function parse(v) {
|
|
12
|
-
const parts = String(v || '').replace(/[^0-9.]/g, '').split('.').map(Number);
|
|
13
|
-
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Returns true when `remote` is strictly newer than `current`. */
|
|
17
|
-
function isNewer(current, remote) {
|
|
18
|
-
const [cMaj, cMin, cPat] = parse(current);
|
|
19
|
-
const [rMaj, rMin, rPat] = parse(remote);
|
|
20
|
-
if (rMaj !== cMaj) return rMaj > cMaj;
|
|
21
|
-
if (rMin !== cMin) return rMin > cMin;
|
|
22
|
-
return rPat > cPat;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Check npm for a newer version of ispbills-icli.
|
|
27
|
-
* Resolves to { latest: string, isNewer: boolean } or null.
|
|
28
|
-
*/
|
|
29
|
-
export async function checkLatestVersion(currentVersion) {
|
|
30
|
-
try {
|
|
31
|
-
const ctrl = new AbortController();
|
|
32
|
-
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
33
|
-
const res = await fetch(REGISTRY, { signal: ctrl.signal });
|
|
34
|
-
clearTimeout(timer);
|
|
35
|
-
if (!res.ok) return null;
|
|
36
|
-
const data = await res.json();
|
|
37
|
-
const latest = data.version;
|
|
38
|
-
if (!latest) return null;
|
|
39
|
-
return { latest, isNewer: isNewer(currentVersion, latest) };
|
|
40
|
-
} catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|