ispbills-icli 8.2.0 → 8.3.1
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/README.md +25 -0
- package/package.json +1 -1
- package/src/clipboard.js +42 -0
- package/src/commands.js +22 -0
- package/src/gist.js +90 -0
- package/src/gitrepo.js +78 -0
- package/src/memory.js +72 -0
- package/src/tui/app.js +152 -8
- package/src/tui/composer.js +87 -16
- package/src/userdir.js +18 -0
package/README.md
CHANGED
|
@@ -146,10 +146,29 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
|
|
|
146
146
|
|---|---|
|
|
147
147
|
| `/mac [addr]` | Locate a MAC address on the network |
|
|
148
148
|
| `/arp [device]` · `/route [device]` | ARP / routing tables |
|
|
149
|
+
| `/overlaps [ip/vlan/bgp/route]` | Find overlapping IP subnets, VLAN ids, interface assignments, BGP prefixes or routes |
|
|
150
|
+
| `/flapping [device]` | List flapping interfaces (frequent up/down transitions) |
|
|
149
151
|
| `/dhcp` | DHCP leases and pool usage |
|
|
150
152
|
| `/ping [host]` · `/trace [host]` | Ping / traceroute a host |
|
|
151
153
|
| `/customer [name/IP]` | Look up a customer |
|
|
152
154
|
|
|
155
|
+
**Backups & version control**
|
|
156
|
+
|
|
157
|
+
| Slash | Description |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `/backup [device]` | Back up device config (all devices if omitted). Each answer is archived to your per-user git repo |
|
|
160
|
+
| `/git [args]` | Run git in that backup repo — e.g. `/git status`, `/git log`, `/git commit -m "note"`, `/git remote add origin <url>`, `/git push` |
|
|
161
|
+
| `/gist` | Save the last answer or the whole transcript to a **secret** GitHub gist (uses the `gh` CLI, or `/gist token <TOKEN>`) |
|
|
162
|
+
|
|
163
|
+
**Memory**
|
|
164
|
+
|
|
165
|
+
| Slash | Description |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `/remember <note>` | Save a standing note the AI keeps in mind on every turn |
|
|
168
|
+
| `/memory` | List your saved memory notes |
|
|
169
|
+
| `/forget <n/all>` | Delete a note by number, matching text, or `all` |
|
|
170
|
+
| `/copy` | Copy the last AI answer to the system clipboard |
|
|
171
|
+
|
|
153
172
|
**Session**
|
|
154
173
|
|
|
155
174
|
| Slash | Description |
|
|
@@ -168,6 +187,8 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
|
|
|
168
187
|
| **/** then **↑/↓** | Open the slash menu and move the selection; **Tab** / **→** completes, **Enter** runs |
|
|
169
188
|
| **@** | Mention a device/entity (e.g. `@olt:`, `@onu:`); **Tab** / **→** completes the tag |
|
|
170
189
|
| **↑/↓** (empty input) | Navigate input history |
|
|
190
|
+
| **Ctrl+Z** / **Ctrl+Y** | Undo / redo the input line |
|
|
191
|
+
| **Paste** | Multi-line pastes (incl. mouse right-click) keep their line breaks and are never auto-submitted — press **Enter** to send |
|
|
171
192
|
| **Shift+Tab** | Toggle autopilot mode |
|
|
172
193
|
| **Ctrl+T** | Toggle reasoning display |
|
|
173
194
|
| **Ctrl+L** | Clear the transcript |
|
|
@@ -201,6 +222,10 @@ iCli connects to the same AI engine as the in-browser terminal:
|
|
|
201
222
|
- **Rich rendering** — Markdown tables, colored lists, headings, and code blocks; `@`-mentions are highlighted; long lists render as aligned tables
|
|
202
223
|
- **Interactive prompts** — when the AI needs a decision, it shows a selectable choice list (↑/↓, **Enter**) with an optional free-text answer (**Tab**)
|
|
203
224
|
- **Plans & connect cards** — proposed fixes stream in live as a reviewable plan (`apply fix` to confirm, or use the Apply/Explain/Cancel prompt); device connections show as a connect card
|
|
225
|
+
- **Bot memory** — `/remember` saves standing operator notes that are injected into every answer (`/memory`, `/forget` to manage)
|
|
226
|
+
- **Backups in git** — `/backup` archives config to a per-user git repo; run `/git status|log|commit|push` from the prompt
|
|
227
|
+
- **Share as a gist** — `/gist` publishes the last answer or transcript to a secret GitHub gist
|
|
228
|
+
- **Copy & paste** — `/copy` puts the last answer on the clipboard; multi-line pastes (mouse right-click or Cmd/Ctrl+V) keep their line breaks, and **Ctrl+Z / Ctrl+Y** undo/redo work in the input
|
|
204
229
|
- **Full-screen terminal UI** — a full-height alternate-screen TUI (built on [Ink](https://github.com/vadimdemedes/ink)) with the banner pinned to the top, a bottom-pinned bordered input, a selectable slash-command menu, input history, and a bottom-anchored transcript — the same layout as GitHub Copilot CLI / Gemini CLI. Piped/non-interactive use falls back to plain streaming output
|
|
205
230
|
- **Terminal-aware rendering** — the layout reflows to your terminal width and resizes live; on classic Windows consoles (`cmd.exe` / legacy PowerShell) it auto-switches to ASCII-safe glyphs and borders so nothing shows as broken boxes
|
|
206
231
|
|
package/package.json
CHANGED
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Cross-platform "copy to system clipboard" helper. Best-effort: resolves to
|
|
2
|
+
// false (rather than throwing) when no clipboard tool is available so the TUI
|
|
3
|
+
// can show a friendly notice.
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
|
|
6
|
+
function candidates() {
|
|
7
|
+
if (process.platform === 'darwin') return [['pbcopy', []]];
|
|
8
|
+
if (process.platform === 'win32') return [['clip', []]];
|
|
9
|
+
// Linux / BSD: prefer Wayland, then X11 tools.
|
|
10
|
+
return [
|
|
11
|
+
['wl-copy', []],
|
|
12
|
+
['xclip', ['-selection', 'clipboard']],
|
|
13
|
+
['xsel', ['--clipboard', '--input']],
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tryCopy(cmd, args, text) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let child;
|
|
20
|
+
try {
|
|
21
|
+
child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
22
|
+
} catch {
|
|
23
|
+
resolve(false);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
child.on('error', () => resolve(false));
|
|
27
|
+
child.on('close', (code) => resolve(code === 0));
|
|
28
|
+
try {
|
|
29
|
+
child.stdin.end(text);
|
|
30
|
+
} catch {
|
|
31
|
+
resolve(false);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Copy `text` to the system clipboard. Returns the tool name on success, else null. */
|
|
37
|
+
export async function copyToClipboard(text) {
|
|
38
|
+
for (const [cmd, args] of candidates()) {
|
|
39
|
+
if (await tryCopy(cmd, args, text)) return cmd;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
package/src/commands.js
CHANGED
|
@@ -34,11 +34,23 @@ export const SLASH_COMMANDS = [
|
|
|
34
34
|
{ cmd: '/mac', hint: '[addr]', desc: 'Locate a MAC address on the network', group: 'Lookup' },
|
|
35
35
|
{ cmd: '/arp', hint: '[device]', desc: 'ARP table for a device', group: 'Lookup' },
|
|
36
36
|
{ cmd: '/route', hint: '[device]', desc: 'Routing table for a device', group: 'Lookup' },
|
|
37
|
+
{ cmd: '/overlaps', hint: '[ip/vlan/bgp/route]', desc: 'Find overlapping IP, VLAN, interface, BGP or routes', group: 'Lookup' },
|
|
38
|
+
{ cmd: '/flapping', hint: '[device]', desc: 'List flapping interfaces (frequent up/down)', group: 'Lookup' },
|
|
37
39
|
{ cmd: '/dhcp', hint: '', desc: 'DHCP leases and pool usage', group: 'Lookup' },
|
|
38
40
|
{ cmd: '/ping', hint: '[host]', desc: 'Ping a host through the network', group: 'Lookup' },
|
|
39
41
|
{ cmd: '/trace', hint: '[host]', desc: 'Traceroute the path to a host', group: 'Lookup' },
|
|
40
42
|
{ cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer', group: 'Lookup' },
|
|
41
43
|
|
|
44
|
+
// ── Backups & version control ──
|
|
45
|
+
{ cmd: '/git', hint: '[args]', desc: 'Run git in your backup repo (status/commit/push/log)', group: 'Backups', local: true },
|
|
46
|
+
{ cmd: '/gist', hint: '', desc: 'Save the last answer or transcript to a GitHub gist', group: 'Backups', local: true },
|
|
47
|
+
|
|
48
|
+
// ── Memory ──
|
|
49
|
+
{ cmd: '/remember', hint: '<note>', desc: 'Save a standing note the AI keeps in mind', group: 'Memory', local: true },
|
|
50
|
+
{ cmd: '/memory', hint: '', desc: 'List saved memory notes', group: 'Memory', local: true },
|
|
51
|
+
{ cmd: '/forget', hint: '[n/all]', desc: 'Delete a memory note (by number or all)', group: 'Memory', local: true },
|
|
52
|
+
{ cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
|
|
53
|
+
|
|
42
54
|
// ── Session & app ──
|
|
43
55
|
{ cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
|
|
44
56
|
{ cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
|
|
@@ -106,6 +118,16 @@ export function slashToQuestion(line) {
|
|
|
106
118
|
case '/mac': return on(`locate MAC address ${arg} — which device and port is it on?`, 'how do I locate a MAC address? ask me for the address');
|
|
107
119
|
case '/arp': return on(`show the ARP table for ${arg}`, 'show ARP tables and flag any duplicate or suspicious entries');
|
|
108
120
|
case '/route': return on(`show the routing table for ${arg}`, 'show routing tables across the network');
|
|
121
|
+
case '/overlaps': {
|
|
122
|
+
const t = arg.toLowerCase();
|
|
123
|
+
if (/vlan/.test(t)) return 'scan all devices for duplicate or overlapping VLAN ids and list each conflict with the devices/interfaces involved';
|
|
124
|
+
if (/bgp|prefix|as\b|peer/.test(t)) return 'check BGP for overlapping or conflicting prefixes and duplicate AS announcements across routers and list each conflict';
|
|
125
|
+
if (/route|routing/.test(t)) return 'find overlapping or conflicting routes across the routing tables and list each pair with the devices involved';
|
|
126
|
+
if (/int|port/.test(t)) return 'find interfaces/ports with overlapping or duplicate address or VLAN assignments and list each conflict';
|
|
127
|
+
if (/ip|subnet|network|cidr/.test(t)) return 'find overlapping or conflicting IP subnets and duplicate IP addresses across the network and list each conflict with the devices involved';
|
|
128
|
+
return 'scan the whole network for any overlapping or conflicting IP subnets, duplicate IPs, duplicate/overlapping VLAN ids, interface assignment conflicts, and overlapping BGP prefixes or routes — list each conflict grouped by type with the devices involved';
|
|
129
|
+
}
|
|
130
|
+
case '/flapping': return on(`check ${arg} for flapping interfaces — list ports with frequent up/down transitions, their flap counts and last change time`, 'scan all devices for flapping interfaces (frequent up/down transitions) and list them with flap counts and the last state-change time');
|
|
109
131
|
case '/dhcp': return 'show DHCP leases and pool usage, and flag pools that are nearly exhausted';
|
|
110
132
|
case '/ping': return on(`ping ${arg} through the network and report latency and loss`, 'which hosts should I ping? list reachable devices');
|
|
111
133
|
case '/trace': return on(`traceroute the path to ${arg} and show each hop`, 'what host should I traceroute?');
|
package/src/gist.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Create GitHub gists from the terminal. Prefers the `gh` CLI (uses its stored
|
|
2
|
+
// auth); falls back to the REST API with a token from the icli config
|
|
3
|
+
// (cfg.github_token) or the GITHUB_TOKEN / GH_TOKEN environment variables.
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
function sh(cmd, args, { input } = {}) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let child;
|
|
12
|
+
try {
|
|
13
|
+
child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
14
|
+
} catch (e) {
|
|
15
|
+
resolve({ code: 127, stdout: '', stderr: String(e.message || e) });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let stdout = '', stderr = '';
|
|
19
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
20
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
21
|
+
child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e.message || e) }));
|
|
22
|
+
child.on('close', (code) => resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
23
|
+
if (input != null) { try { child.stdin.end(input); } catch {} }
|
|
24
|
+
else child.stdin.end();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function hasGh() {
|
|
29
|
+
const res = await sh('gh', ['--version']);
|
|
30
|
+
return res.code === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function token(cfg) {
|
|
34
|
+
return cfg?.github_token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True if any GitHub auth is available (gh CLI or a token). */
|
|
38
|
+
export async function canGist(cfg) {
|
|
39
|
+
if (token(cfg)) return true;
|
|
40
|
+
return hasGh();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function gistViaGh({ filename, content, description, isPublic }) {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), 'icli-gist-'));
|
|
45
|
+
const path = join(dir, filename);
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(path, content);
|
|
48
|
+
const args = ['gist', 'create', path, '-d', description];
|
|
49
|
+
if (isPublic) args.push('--public');
|
|
50
|
+
const res = await sh('gh', args);
|
|
51
|
+
if (res.code !== 0) throw new Error(res.stderr || 'gh gist create failed');
|
|
52
|
+
const url = (res.stdout.match(/https?:\/\/\S+/) || [res.stdout])[0];
|
|
53
|
+
return { url };
|
|
54
|
+
} finally {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function gistViaApi({ filename, content, description, isPublic }, tok) {
|
|
60
|
+
const res = await fetch('https://api.github.com/gists', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: 'Bearer ' + tok,
|
|
64
|
+
Accept: 'application/vnd.github+json',
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
'User-Agent': 'ispbills-icli',
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ description, public: !!isPublic, files: { [filename]: { content } } }),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const txt = await res.text().catch(() => '');
|
|
72
|
+
throw new Error(`GitHub API ${res.status}: ${txt.slice(0, 160)}`);
|
|
73
|
+
}
|
|
74
|
+
const data = await res.json();
|
|
75
|
+
return { url: data.html_url };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a gist. `opts` = { filename, content, description, isPublic }.
|
|
80
|
+
* Returns { url }. Throws with a helpful message when no auth is available.
|
|
81
|
+
*/
|
|
82
|
+
export async function createGist(cfg, opts) {
|
|
83
|
+
const o = { isPublic: false, filename: 'icli.md', description: 'iCli export', ...opts };
|
|
84
|
+
const tok = token(cfg);
|
|
85
|
+
if (tok) return gistViaApi(o, tok);
|
|
86
|
+
if (await hasGh()) return gistViaGh(o);
|
|
87
|
+
throw new Error(
|
|
88
|
+
'No GitHub auth. Run `gh auth login`, or save a token with `/gist token <TOKEN>` (needs the "gist" scope).',
|
|
89
|
+
);
|
|
90
|
+
}
|
package/src/gitrepo.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Per-user local git repository for archiving device config backups and giving
|
|
2
|
+
// the operator plain /git commands (status, log, commit, push, diff, …). The
|
|
3
|
+
// repo lives under ~/.config/ispbills-icli/backups/<user>/ and is git-init'd on
|
|
4
|
+
// first use with the operator's IspBills identity.
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { existsSync, writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { userDir } from './userdir.js';
|
|
9
|
+
|
|
10
|
+
// Sub-commands the operator may run through /git. Read-mostly plus the handful
|
|
11
|
+
// needed to record and publish backups; anything else is refused.
|
|
12
|
+
const ALLOWED = new Set([
|
|
13
|
+
'status', 'log', 'diff', 'show', 'add', 'commit', 'push', 'pull',
|
|
14
|
+
'fetch', 'remote', 'branch', 'checkout', 'init', 'config', 'ls-files', 'rev-parse',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Absolute path to the user's backup git repo (created if missing). */
|
|
18
|
+
export function repoDir(cfg) {
|
|
19
|
+
return userDir(cfg, 'backups');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function run(cfg, args, { input } = {}) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let child;
|
|
25
|
+
try {
|
|
26
|
+
child = spawn('git', args, { cwd: repoDir(cfg), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
27
|
+
} catch (e) {
|
|
28
|
+
resolve({ code: 127, stdout: '', stderr: String(e.message || e) });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let stdout = '', stderr = '';
|
|
32
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
33
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
34
|
+
child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e.message || e) }));
|
|
35
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
36
|
+
if (input != null) { try { child.stdin.end(input); } catch {} }
|
|
37
|
+
else child.stdin.end();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Initialise the repo on first use and set the local identity. */
|
|
42
|
+
export async function ensureRepo(cfg) {
|
|
43
|
+
const dir = repoDir(cfg);
|
|
44
|
+
if (!existsSync(join(dir, '.git'))) {
|
|
45
|
+
await run(cfg, ['init', '-q']);
|
|
46
|
+
await run(cfg, ['config', 'user.name', cfg?.user?.name || 'IspBills operator']);
|
|
47
|
+
await run(cfg, ['config', 'user.email', cfg?.user?.email || 'operator@ispbills.local']);
|
|
48
|
+
await run(cfg, ['config', 'commit.gpgsign', 'false']);
|
|
49
|
+
writeFileSync(join(dir, 'README.md'), '# IspBills device backups\n\nManaged by iCli (`/backup`, `/git`).\n');
|
|
50
|
+
await run(cfg, ['add', 'README.md']);
|
|
51
|
+
await run(cfg, ['commit', '-q', '-m', 'Initialise backup repository']);
|
|
52
|
+
}
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Run a whitelisted git sub-command; returns { ok, code, stdout, stderr }. */
|
|
57
|
+
export async function git(cfg, args) {
|
|
58
|
+
const sub = (args[0] || '').toLowerCase();
|
|
59
|
+
if (!sub) return { ok: false, code: 2, stdout: '', stderr: 'Usage: /git <status|log|commit|push|diff|…>' };
|
|
60
|
+
if (!ALLOWED.has(sub)) {
|
|
61
|
+
return { ok: false, code: 2, stdout: '', stderr: `git ${sub} is not allowed from iCli. Allowed: ${[...ALLOWED].join(', ')}` };
|
|
62
|
+
}
|
|
63
|
+
await ensureRepo(cfg);
|
|
64
|
+
const res = await run(cfg, args);
|
|
65
|
+
return { ok: res.code === 0, ...res };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Save backup content to a file and commit it. Returns { file, committed, stderr }. */
|
|
69
|
+
export async function commitBackup(cfg, label, content) {
|
|
70
|
+
await ensureRepo(cfg);
|
|
71
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
72
|
+
const safe = String(label || 'fleet').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').slice(0, 40) || 'fleet';
|
|
73
|
+
const name = `${stamp}__${safe}.txt`;
|
|
74
|
+
writeFileSync(join(repoDir(cfg), name), String(content));
|
|
75
|
+
await run(cfg, ['add', name]);
|
|
76
|
+
const res = await run(cfg, ['commit', '-q', '-m', `Backup ${safe} @ ${stamp}`]);
|
|
77
|
+
return { file: name, committed: res.code === 0, stderr: res.stderr };
|
|
78
|
+
}
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Per-user persistent "bot memory": short operator notes that are injected
|
|
2
|
+
// into the AI context on every turn so the assistant remembers preferences,
|
|
3
|
+
// naming conventions, standing instructions, etc. Managed with /remember,
|
|
4
|
+
// /forget and /memory.
|
|
5
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { userDir } from './userdir.js';
|
|
8
|
+
|
|
9
|
+
const MAX_NOTES = 50;
|
|
10
|
+
|
|
11
|
+
function file(cfg) {
|
|
12
|
+
return join(userDir(cfg, 'memory'), 'notes.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Load the user's notes as an array of { at, text }. */
|
|
16
|
+
export function loadNotes(cfg) {
|
|
17
|
+
try {
|
|
18
|
+
const data = JSON.parse(readFileSync(file(cfg), 'utf8'));
|
|
19
|
+
return Array.isArray(data?.notes) ? data.notes : [];
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function save(cfg, notes) {
|
|
26
|
+
writeFileSync(file(cfg), JSON.stringify({ notes }, null, 2), { mode: 0o600 });
|
|
27
|
+
return notes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Append a note. Returns the updated list. */
|
|
31
|
+
export function addNote(cfg, text) {
|
|
32
|
+
const t = String(text || '').trim();
|
|
33
|
+
if (!t) return loadNotes(cfg);
|
|
34
|
+
const notes = loadNotes(cfg);
|
|
35
|
+
notes.push({ at: new Date().toISOString(), text: t });
|
|
36
|
+
return save(cfg, notes.slice(-MAX_NOTES));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Remove a note. `which` may be a 1-based index, the string 'all', or a
|
|
41
|
+
* substring to match. Returns { notes, removed }.
|
|
42
|
+
*/
|
|
43
|
+
export function forgetNote(cfg, which) {
|
|
44
|
+
const notes = loadNotes(cfg);
|
|
45
|
+
if (String(which).toLowerCase() === 'all') {
|
|
46
|
+
save(cfg, []);
|
|
47
|
+
return { notes: [], removed: notes.length };
|
|
48
|
+
}
|
|
49
|
+
const idx = Number(which);
|
|
50
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= notes.length) {
|
|
51
|
+
const [gone] = notes.splice(idx - 1, 1);
|
|
52
|
+
save(cfg, notes);
|
|
53
|
+
return { notes, removed: gone ? 1 : 0 };
|
|
54
|
+
}
|
|
55
|
+
const needle = String(which || '').toLowerCase();
|
|
56
|
+
const before = notes.length;
|
|
57
|
+
const kept = needle ? notes.filter((n) => !n.text.toLowerCase().includes(needle)) : notes;
|
|
58
|
+
save(cfg, kept);
|
|
59
|
+
return { notes: kept, removed: before - kept.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build the preamble message injected ahead of the conversation, or null. */
|
|
63
|
+
export function memoryPreamble(cfg) {
|
|
64
|
+
const notes = loadNotes(cfg);
|
|
65
|
+
if (!notes.length) return null;
|
|
66
|
+
const body = notes.map((n) => `- ${n.text}`).join('\n');
|
|
67
|
+
return {
|
|
68
|
+
role: 'user',
|
|
69
|
+
content:
|
|
70
|
+
`[operator memory — standing notes to keep in mind for every answer; do not reply to this message directly]\n${body}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/tui/app.js
CHANGED
|
@@ -14,6 +14,19 @@ import { streamChat } from '../client.js';
|
|
|
14
14
|
import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
15
15
|
import { saveConfig } from '../config.js';
|
|
16
16
|
import { shortModel } from '../ui.js';
|
|
17
|
+
import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
18
|
+
import { copyToClipboard } from '../clipboard.js';
|
|
19
|
+
import { createGist } from '../gist.js';
|
|
20
|
+
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
21
|
+
|
|
22
|
+
// Split a command argument string into argv, honouring "quoted" segments.
|
|
23
|
+
function splitArgs(s) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
26
|
+
let m;
|
|
27
|
+
while ((m = re.exec(s))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
17
30
|
|
|
18
31
|
function useTerminalSize() {
|
|
19
32
|
const { stdout } = useStdout();
|
|
@@ -84,6 +97,16 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
84
97
|
const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
|
|
85
98
|
const apRef = useRef(false); // live autopilot flag (read inside runTurn)
|
|
86
99
|
const modelRef = useRef(cfg._model); // preferred model to request
|
|
100
|
+
const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
|
|
101
|
+
const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
|
|
102
|
+
|
|
103
|
+
// Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
|
|
104
|
+
// Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
|
|
105
|
+
// them with newlines preserved instead of submitting on the first newline.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
108
|
+
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
109
|
+
}, []);
|
|
87
110
|
|
|
88
111
|
const setAutopilotMode = useCallback((next) => {
|
|
89
112
|
apRef.current = next;
|
|
@@ -93,7 +116,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
93
116
|
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
94
117
|
const log = (line) => plain.current.push(line);
|
|
95
118
|
|
|
96
|
-
const runTurn = useCallback(async (question,
|
|
119
|
+
const runTurn = useCallback(async (question, opts = {}) => {
|
|
120
|
+
const { depth = 0, archive = null, label = '' } = opts;
|
|
97
121
|
push({ type: 'user', text: question });
|
|
98
122
|
log(`\n${glyphs.prompt} ${question}\n`);
|
|
99
123
|
convo.current.push({ role: 'user', content: question });
|
|
@@ -114,7 +138,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
114
138
|
if (modelRef.current) context.model = modelRef.current;
|
|
115
139
|
|
|
116
140
|
try {
|
|
117
|
-
const
|
|
141
|
+
const preamble = memoryPreamble(cfg);
|
|
142
|
+
const outbound = preamble ? [preamble, ...convo.current] : [...convo.current];
|
|
143
|
+
const { reply, text, meta } = await streamChat(cfg, outbound, {
|
|
118
144
|
signal: abort.signal,
|
|
119
145
|
context,
|
|
120
146
|
onEvent: (ev) => {
|
|
@@ -132,12 +158,25 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
132
158
|
else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
|
|
133
159
|
else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
|
|
134
160
|
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
161
|
+
lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
|
|
162
|
+
|
|
163
|
+
// Archive a /backup answer to the per-user git repo.
|
|
164
|
+
if (archive === 'backup' && text) {
|
|
165
|
+
try {
|
|
166
|
+
const r = await commitBackup(cfg, label, text);
|
|
167
|
+
push({ type: 'notice',
|
|
168
|
+
text: `${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`,
|
|
169
|
+
color: C.green });
|
|
170
|
+
} catch (e) {
|
|
171
|
+
push({ type: 'notice', text: `${glyphs.warn} Could not archive backup: ${e.message}`, color: C.yellow });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
135
174
|
|
|
136
175
|
// Autopilot: auto-confirm a proposed plan without waiting for the user.
|
|
137
176
|
if (type === 'plan' && apRef.current && depth < 3) {
|
|
138
177
|
push({ type: 'notice', text: `${glyphs.bolt} autopilot ${dash()} applying fix…`, color: C.yellow });
|
|
139
178
|
setLive(null); setBusy(false); abortRef.current = null;
|
|
140
|
-
return runTurn('apply fix', depth + 1);
|
|
179
|
+
return runTurn('apply fix', { depth: depth + 1 });
|
|
141
180
|
}
|
|
142
181
|
|
|
143
182
|
// Otherwise surface an interactive prompt for the user to choose/answer.
|
|
@@ -178,6 +217,29 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
178
217
|
|
|
179
218
|
const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
|
|
180
219
|
|
|
220
|
+
// Create a gist from the last answer or the whole transcript (invoked by the
|
|
221
|
+
// /gist Choice below).
|
|
222
|
+
const doGist = useCallback(async (kind) => {
|
|
223
|
+
let content, filename, description;
|
|
224
|
+
if (kind === 'transcript') {
|
|
225
|
+
content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
|
|
226
|
+
filename = 'icli-transcript.md';
|
|
227
|
+
description = 'iCli conversation transcript';
|
|
228
|
+
} else {
|
|
229
|
+
content = lastAnswerRef.current || '(no answer)';
|
|
230
|
+
filename = 'icli-answer.md';
|
|
231
|
+
description = 'iCli answer';
|
|
232
|
+
}
|
|
233
|
+
push({ type: 'notice', text: `${glyphs.arrow} Creating secret gist…`, color: C.gray });
|
|
234
|
+
try {
|
|
235
|
+
const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
|
|
236
|
+
const tool = await copyToClipboard(url);
|
|
237
|
+
push({ type: 'notice', text: `${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, color: C.green });
|
|
238
|
+
} catch (e) {
|
|
239
|
+
push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
|
|
240
|
+
}
|
|
241
|
+
}, [cfg, push]);
|
|
242
|
+
|
|
181
243
|
const onSubmit = useCallback((raw) => {
|
|
182
244
|
const q = raw.trim();
|
|
183
245
|
if (!q) return;
|
|
@@ -224,7 +286,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
224
286
|
const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
|
|
225
287
|
return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
|
|
226
288
|
}).join('\n');
|
|
227
|
-
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+L clear · Ctrl+C twice to exit' });
|
|
289
|
+
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+Z/Ctrl+Y undo/redo · Ctrl+L clear · Ctrl+C twice to exit' });
|
|
228
290
|
return;
|
|
229
291
|
}
|
|
230
292
|
if (q === '/history') {
|
|
@@ -235,18 +297,100 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
235
297
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
236
298
|
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
237
299
|
|
|
300
|
+
// ── Memory ──
|
|
301
|
+
if (q === '/remember' || q.startsWith('/remember ')) {
|
|
302
|
+
const note = q.slice('/remember'.length).trim();
|
|
303
|
+
if (!note) { push({ type: 'notice', text: 'Usage: /remember <note>' }); return; }
|
|
304
|
+
const notes = addNote(cfg, note);
|
|
305
|
+
push({ type: 'notice', text: `${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, color: C.green });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (q === '/memory') {
|
|
309
|
+
const notes = loadNotes(cfg);
|
|
310
|
+
push({ type: 'notice', text: notes.length
|
|
311
|
+
? 'Memory notes:\n' + notes.map((n, i) => ` ${i + 1}. ${n.text}`).join('\n') + '\n\n /forget <n|all> to remove.'
|
|
312
|
+
: 'No memory notes yet. Add one with /remember <note>.' });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (q === '/forget' || q.startsWith('/forget ')) {
|
|
316
|
+
const which = q.slice('/forget'.length).trim();
|
|
317
|
+
if (!which) { push({ type: 'notice', text: 'Usage: /forget <number | all | text>' }); return; }
|
|
318
|
+
const { notes, removed } = forgetNote(cfg, which);
|
|
319
|
+
push({ type: 'notice',
|
|
320
|
+
text: removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.',
|
|
321
|
+
color: removed ? C.green : C.gray });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Clipboard ──
|
|
326
|
+
if (q === '/copy') {
|
|
327
|
+
const ans = lastAnswerRef.current;
|
|
328
|
+
if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
|
|
329
|
+
copyToClipboard(ans).then((tool) => push({ type: 'notice',
|
|
330
|
+
text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
|
|
331
|
+
: `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
|
|
332
|
+
color: tool ? C.green : C.red }));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── GitHub gist ──
|
|
337
|
+
if (q === '/gist' || q.startsWith('/gist ')) {
|
|
338
|
+
const rest = q.slice('/gist'.length).trim();
|
|
339
|
+
if (rest.startsWith('token ')) {
|
|
340
|
+
cfg.github_token = rest.slice('token '.length).trim();
|
|
341
|
+
try { saveConfig(cfg); } catch {}
|
|
342
|
+
push({ type: 'notice', text: `${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, color: C.green });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
346
|
+
push({ type: 'notice', text: 'Nothing to save yet — ask something first.', color: C.gray });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
choiceActionRef.current = (v) => doGist(v);
|
|
350
|
+
setChoice({
|
|
351
|
+
title: 'Create a secret gist from…',
|
|
352
|
+
note: '', sel: 0, text: '', focusText: false, allowText: false,
|
|
353
|
+
options: [
|
|
354
|
+
{ label: 'The last AI answer', value: 'answer' },
|
|
355
|
+
{ label: 'The entire conversation transcript', value: 'transcript' },
|
|
356
|
+
{ label: 'Cancel', value: null },
|
|
357
|
+
],
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Local git repo ──
|
|
363
|
+
if (q === '/git' || q.startsWith('/git ')) {
|
|
364
|
+
const args = q.slice('/git'.length).trim();
|
|
365
|
+
if (!args) { push({ type: 'notice', text: 'Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>' }); return; }
|
|
366
|
+
gitCmd(cfg, splitArgs(args)).then((r) => {
|
|
367
|
+
const body = (r.stdout || '').trim() || (r.stderr || '').trim() || (r.ok ? '(done)' : '(no output)');
|
|
368
|
+
push({ type: 'notice', text: `$ git ${args}\n${body.slice(0, 3500)}`, color: r.ok ? undefined : C.red });
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
238
373
|
let question = q;
|
|
374
|
+
const opts = {};
|
|
239
375
|
if (q.startsWith('/')) {
|
|
240
376
|
const mapped = slashToQuestion(q);
|
|
241
|
-
if (mapped)
|
|
242
|
-
|
|
377
|
+
if (mapped) {
|
|
378
|
+
question = mapped;
|
|
379
|
+
if (q === '/backup' || q.startsWith('/backup ')) {
|
|
380
|
+
opts.archive = 'backup';
|
|
381
|
+
opts.label = q.slice('/backup'.length).trim() || 'fleet';
|
|
382
|
+
}
|
|
383
|
+
} else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
|
|
243
384
|
}
|
|
244
|
-
runTurn(question);
|
|
245
|
-
}, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg]);
|
|
385
|
+
runTurn(question, opts);
|
|
386
|
+
}, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg, doGist]);
|
|
246
387
|
|
|
247
388
|
// Interactive prompt/choice handling (owns the keyboard while open).
|
|
248
389
|
const pickChoice = useCallback((value) => {
|
|
390
|
+
const act = choiceActionRef.current;
|
|
391
|
+
choiceActionRef.current = null;
|
|
249
392
|
setChoice(null);
|
|
393
|
+
if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.gray }); return; }
|
|
250
394
|
if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.gray }); return; }
|
|
251
395
|
runTurn(String(value));
|
|
252
396
|
}, [push, runTurn]);
|
package/src/tui/composer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Bottom-pinned input composer with cursor editing, input history, readline
|
|
2
2
|
// key bindings and a Copilot-style slash-command palette that appears ABOVE
|
|
3
3
|
// the input and is navigable with the arrow keys.
|
|
4
|
-
import { html, useState, useEffect, useCallback } from './dom.js';
|
|
4
|
+
import { html, useState, useEffect, useCallback, useRef } from './dom.js';
|
|
5
5
|
import { Box, Text, useInput } from 'ink';
|
|
6
6
|
import { C, glyphs, borderStyle } from './theme.js';
|
|
7
7
|
import { SLASH_COMMANDS } from '../commands.js';
|
|
@@ -27,6 +27,39 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
27
27
|
const [histIdx, setHistIdx] = useState(-1);
|
|
28
28
|
const [sel, setSel] = useState(0);
|
|
29
29
|
|
|
30
|
+
// Undo/redo history: snapshots of { value, cursor }. Consecutive typing is
|
|
31
|
+
// coalesced into a single undo step via `lastMut`.
|
|
32
|
+
const undoRef = useRef([]);
|
|
33
|
+
const redoRef = useRef([]);
|
|
34
|
+
const lastMut = useRef('');
|
|
35
|
+
const snapshot = (kind) => {
|
|
36
|
+
if (kind === 'type' && lastMut.current === 'type') return; // coalesce a typing run
|
|
37
|
+
undoRef.current.push({ value, cursor });
|
|
38
|
+
if (undoRef.current.length > 200) undoRef.current.shift();
|
|
39
|
+
redoRef.current = [];
|
|
40
|
+
lastMut.current = kind;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Bracketed-paste assembly. A right-click / Cmd+V paste is wrapped by the
|
|
44
|
+
// terminal in \x1b[200~ … \x1b[201~ and can arrive split across several
|
|
45
|
+
// reads, so we buffer until the end marker before inserting.
|
|
46
|
+
const pasteRef = useRef({ active: false, buf: '' });
|
|
47
|
+
|
|
48
|
+
// Insert pasted text at the cursor, PRESERVING newlines so multi-line pastes
|
|
49
|
+
// (configs, logs) keep their structure. Tabs → 2 spaces; other control chars
|
|
50
|
+
// and the bracketed-paste markers are dropped.
|
|
51
|
+
const insertPaste = (raw) => {
|
|
52
|
+
const text = raw
|
|
53
|
+
.replace(/\x1b?\[20[01]~/g, '')
|
|
54
|
+
.replace(/\r\n?/g, '\n')
|
|
55
|
+
.replace(/\t/g, ' ')
|
|
56
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
57
|
+
if (!text) return;
|
|
58
|
+
snapshot('paste');
|
|
59
|
+
setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
|
|
60
|
+
setCursor((c) => c + text.length);
|
|
61
|
+
};
|
|
62
|
+
|
|
30
63
|
// Determine the token being typed at the cursor to drive the palette.
|
|
31
64
|
const upto = value.slice(0, cursor);
|
|
32
65
|
const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
|
|
@@ -45,6 +78,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
45
78
|
const submit = useCallback((text) => {
|
|
46
79
|
const q = text.trim();
|
|
47
80
|
setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
|
|
81
|
+
undoRef.current = []; redoRef.current = []; lastMut.current = '';
|
|
48
82
|
if (q) {
|
|
49
83
|
setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
|
|
50
84
|
onSubmit(q);
|
|
@@ -65,20 +99,49 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
65
99
|
useInput((input, key) => {
|
|
66
100
|
if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
|
|
67
101
|
|
|
102
|
+
// ── Bracketed paste (mouse right-click / Cmd+V), possibly split reads ──
|
|
103
|
+
// Ink strips the leading ESC, so the terminal's \x1b[200~ … \x1b[201~
|
|
104
|
+
// wrappers arrive as the marker text "[200~" / "[201~" (sometimes as their
|
|
105
|
+
// own useInput calls). Buffer everything between them so embedded newlines
|
|
106
|
+
// are never treated as Enter, then insert the whole block at once.
|
|
107
|
+
const START = '[200~';
|
|
108
|
+
const END = '[201~';
|
|
109
|
+
if (pasteRef.current.active || input.includes(START)) {
|
|
110
|
+
pasteRef.current.active = true;
|
|
111
|
+
pasteRef.current.buf += input;
|
|
112
|
+
const e = pasteRef.current.buf.indexOf(END);
|
|
113
|
+
if (e === -1) return; // still receiving the paste
|
|
114
|
+
const s = pasteRef.current.buf.indexOf(START);
|
|
115
|
+
const raw = pasteRef.current.buf.slice(s + START.length, e);
|
|
116
|
+
pasteRef.current = { active: false, buf: '' };
|
|
117
|
+
insertPaste(raw);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
68
121
|
// ── Submit / complete ──
|
|
69
|
-
|
|
122
|
+
// Some terminals/PTYs deliver Enter as input="\r" without key.return, so
|
|
123
|
+
// treat a lone CR/LF as Enter too (a paste is a longer chunk, never "\r").
|
|
124
|
+
const enter = key.return || input === '\r' || input === '\n';
|
|
125
|
+
if (enter) {
|
|
70
126
|
// In slash mode Enter runs the command; in mention mode it completes.
|
|
71
127
|
if (menuOpen && slashMode) { submit(matches[sel].cmd); return; }
|
|
72
128
|
if (menuOpen && mentionMode) { complete(); return; }
|
|
73
129
|
submit(value);
|
|
74
130
|
return;
|
|
75
131
|
}
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
132
|
+
if (key.tab && !key.shift) { complete(); return; }
|
|
133
|
+
|
|
134
|
+
// ── Undo / redo ──
|
|
135
|
+
if (key.ctrl && input === 'z') {
|
|
136
|
+
const prev = undoRef.current.pop();
|
|
137
|
+
if (prev) { redoRef.current.push({ value, cursor }); setValue(prev.value); setCursor(prev.cursor); lastMut.current = 'undo'; }
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (key.ctrl && (input === 'y' || (input === 'z' && key.shift))) {
|
|
141
|
+
const nx = redoRef.current.pop();
|
|
142
|
+
if (nx) { undoRef.current.push({ value, cursor }); setValue(nx.value); setCursor(nx.cursor); lastMut.current = 'redo'; }
|
|
79
143
|
return;
|
|
80
144
|
}
|
|
81
|
-
if (key.tab && !key.shift) { complete(); return; }
|
|
82
145
|
|
|
83
146
|
// ── Menu / history navigation ──
|
|
84
147
|
if (key.upArrow) {
|
|
@@ -115,31 +178,39 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
115
178
|
|
|
116
179
|
// ── Deletion ──
|
|
117
180
|
if (key.backspace || key.delete) {
|
|
118
|
-
if (cursor > 0) { setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
|
|
181
|
+
if (cursor > 0) { snapshot('del'); setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
|
|
119
182
|
return;
|
|
120
183
|
}
|
|
121
|
-
if (key.ctrl && input === 'u') { setValue(value.slice(cursor)); setCursor(0); return; } // to start
|
|
122
|
-
if (key.ctrl && input === 'k') { setValue(value.slice(0, cursor)); return; } // to end
|
|
184
|
+
if (key.ctrl && input === 'u') { snapshot('cut'); setValue(value.slice(cursor)); setCursor(0); return; } // to start
|
|
185
|
+
if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; } // to end
|
|
123
186
|
if (key.ctrl && input === 'w') { // prev word
|
|
124
187
|
let i = cursor;
|
|
125
188
|
while (i > 0 && value[i - 1] === ' ') i--;
|
|
126
189
|
while (i > 0 && value[i - 1] !== ' ') i--;
|
|
190
|
+
snapshot('cut');
|
|
127
191
|
setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
|
|
128
192
|
return;
|
|
129
193
|
}
|
|
130
194
|
|
|
131
|
-
// ── Printable input ──
|
|
195
|
+
// ── Printable input & paste ──
|
|
132
196
|
if (input && !key.ctrl && !key.meta) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
197
|
+
// A multi-char chunk or one containing newlines/tabs is a non-bracketed
|
|
198
|
+
// paste (terminal without bracketed-paste support): keep its newlines.
|
|
199
|
+
if (input.length > 1 || /[\r\n\t]/.test(input)) { insertPaste(input); return; }
|
|
200
|
+
const text = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
201
|
+
if (!text) return;
|
|
202
|
+
snapshot('type');
|
|
203
|
+
setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
|
|
204
|
+
setCursor((c) => c + text.length);
|
|
137
205
|
}
|
|
138
206
|
});
|
|
139
207
|
|
|
140
208
|
const before = value.slice(0, cursor);
|
|
141
|
-
const
|
|
142
|
-
|
|
209
|
+
const rawAt = value[cursor];
|
|
210
|
+
// When the cursor sits on a newline, show an inverse space and keep the line
|
|
211
|
+
// break in `after` so the buffer still renders across multiple lines.
|
|
212
|
+
const at = (rawAt === undefined || rawAt === '\n') ? ' ' : rawAt;
|
|
213
|
+
const after = rawAt === '\n' ? '\n' + value.slice(cursor + 1) : value.slice(cursor + 1);
|
|
143
214
|
const empty = value.length === 0;
|
|
144
215
|
const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
|
|
145
216
|
const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
|
package/src/userdir.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mkdirSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const BASE = join(homedir(), '.config', 'ispbills-icli');
|
|
6
|
+
|
|
7
|
+
/** A filesystem-safe key identifying the logged-in IspBills user. */
|
|
8
|
+
export function userKey(cfg) {
|
|
9
|
+
const raw = cfg?.user?.email || cfg?.user?.name || (cfg?.user?.id != null ? 'id-' + cfg.user.id : '') || 'default';
|
|
10
|
+
return String(raw).toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'default';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Ensure and return a per-user subdirectory under the config dir. */
|
|
14
|
+
export function userDir(cfg, ...parts) {
|
|
15
|
+
const dir = join(BASE, ...parts, userKey(cfg));
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
return dir;
|
|
18
|
+
}
|