ispbills-icli 8.1.1 → 8.3.0

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.
@@ -1,11 +1,25 @@
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';
8
8
 
9
+ // @-mention entity types. Selecting one inserts e.g. "@olt:" so the user can
10
+ // append an identifier (@olt:3); the backend treats it as a scoped reference.
11
+ const MENTIONS = [
12
+ { tag: '@device', desc: 'Any device by name or id' },
13
+ { tag: '@olt', desc: 'An OLT' },
14
+ { tag: '@router', desc: 'A router / NAS' },
15
+ { tag: '@onu', desc: 'An ONU' },
16
+ { tag: '@customer', desc: 'A customer' },
17
+ { tag: '@vlan', desc: 'A VLAN id' },
18
+ { tag: '@circuit', desc: 'A circuit / uplink' },
19
+ { tag: '@ip', desc: 'An IP address' },
20
+ { tag: '@mac', desc: 'A MAC address' },
21
+ ];
22
+
9
23
  export function Composer({ onSubmit, busy, width = 80 }) {
10
24
  const [value, setValue] = useState('');
11
25
  const [cursor, setCursor] = useState(0);
@@ -13,9 +27,29 @@ export function Composer({ onSubmit, busy, width = 80 }) {
13
27
  const [histIdx, setHistIdx] = useState(-1);
14
28
  const [sel, setSel] = useState(0);
15
29
 
16
- // The slash palette is open while typing the command token (no space yet).
17
- const matches = value.startsWith('/') && !value.includes(' ')
18
- ? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(value))
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
+ // Determine the token being typed at the cursor to drive the palette.
44
+ const upto = value.slice(0, cursor);
45
+ const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
46
+ const token = upto.slice(tokStart);
47
+ const slashMode = value.startsWith('/') && tokStart === 0;
48
+ const mentionMode = token.startsWith('@');
49
+ const matches = slashMode
50
+ ? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(token))
51
+ : mentionMode
52
+ ? MENTIONS.filter((m) => m.tag.startsWith(token.toLowerCase()))
19
53
  : [];
20
54
  const menuOpen = matches.length > 0;
21
55
 
@@ -24,6 +58,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
24
58
  const submit = useCallback((text) => {
25
59
  const q = text.trim();
26
60
  setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
61
+ undoRef.current = []; redoRef.current = []; lastMut.current = '';
27
62
  if (q) {
28
63
  setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
29
64
  onSubmit(q);
@@ -32,22 +67,40 @@ export function Composer({ onSubmit, busy, width = 80 }) {
32
67
 
33
68
  const setText = (v, c) => { setValue(v); setCursor(c ?? v.length); };
34
69
 
70
+ // Complete the active token to the highlighted palette entry.
71
+ const complete = () => {
72
+ if (!menuOpen) return;
73
+ if (slashMode) { const c = matches[sel].cmd + ' '; setText(c, c.length); return; }
74
+ const ins = matches[sel].tag + ':';
75
+ const v = value.slice(0, tokStart) + ins + value.slice(cursor);
76
+ setText(v, tokStart + ins.length);
77
+ };
78
+
35
79
  useInput((input, key) => {
36
80
  if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
37
81
 
38
82
  // ── Submit / complete ──
39
- if (key.return) {
40
- if (menuOpen) { submit(matches[sel].cmd); return; }
83
+ // Some terminals/PTYs deliver Enter as input="\r" without key.return, so
84
+ // treat a lone CR/LF as Enter too (a paste is a longer chunk, never "\r").
85
+ const enter = key.return || input === '\r' || input === '\n';
86
+ if (enter) {
87
+ // In slash mode Enter runs the command; in mention mode it completes.
88
+ if (menuOpen && slashMode) { submit(matches[sel].cmd); return; }
89
+ if (menuOpen && mentionMode) { complete(); return; }
41
90
  submit(value);
42
91
  return;
43
92
  }
44
- if (input && /[\r\n]/.test(input)) {
45
- const first = input.split(/[\r\n]/)[0];
46
- submit(value.slice(0, cursor) + first + value.slice(cursor));
93
+ if (key.tab && !key.shift) { complete(); return; }
94
+
95
+ // ── Undo / redo ──
96
+ if (key.ctrl && input === 'z') {
97
+ const prev = undoRef.current.pop();
98
+ if (prev) { redoRef.current.push({ value, cursor }); setValue(prev.value); setCursor(prev.cursor); lastMut.current = 'undo'; }
47
99
  return;
48
100
  }
49
- if (key.tab && !key.shift) {
50
- if (menuOpen) { const c = matches[sel].cmd + ' '; setText(c, c.length); }
101
+ if (key.ctrl && (input === 'y' || (input === 'z' && key.shift))) {
102
+ const nx = redoRef.current.pop();
103
+ if (nx) { undoRef.current.push({ value, cursor }); setValue(nx.value); setCursor(nx.cursor); lastMut.current = 'redo'; }
51
104
  return;
52
105
  }
53
106
 
@@ -76,8 +129,8 @@ export function Composer({ onSubmit, busy, width = 80 }) {
76
129
  // ── Cursor movement ──
77
130
  if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
78
131
  if (key.rightArrow) {
79
- // At end of a slash token, Right accepts the highlighted completion.
80
- if (menuOpen && cursor >= value.length) { const c = matches[sel].cmd + ' '; setText(c, c.length); return; }
132
+ // At end of the active token, Right accepts the highlighted completion.
133
+ if (menuOpen && cursor >= value.length) { complete(); return; }
81
134
  setCursor((c) => Math.min(value.length, c + 1));
82
135
  return;
83
136
  }
@@ -86,25 +139,30 @@ export function Composer({ onSubmit, busy, width = 80 }) {
86
139
 
87
140
  // ── Deletion ──
88
141
  if (key.backspace || key.delete) {
89
- if (cursor > 0) { setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
142
+ if (cursor > 0) { snapshot('del'); setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
90
143
  return;
91
144
  }
92
- if (key.ctrl && input === 'u') { setValue(value.slice(cursor)); setCursor(0); return; } // to start
93
- if (key.ctrl && input === 'k') { setValue(value.slice(0, cursor)); return; } // to end
145
+ if (key.ctrl && input === 'u') { snapshot('cut'); setValue(value.slice(cursor)); setCursor(0); return; } // to start
146
+ if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; } // to end
94
147
  if (key.ctrl && input === 'w') { // prev word
95
148
  let i = cursor;
96
149
  while (i > 0 && value[i - 1] === ' ') i--;
97
150
  while (i > 0 && value[i - 1] !== ' ') i--;
151
+ snapshot('cut');
98
152
  setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
99
153
  return;
100
154
  }
101
155
 
102
- // ── Printable input ──
156
+ // ── Printable input & paste ──
103
157
  if (input && !key.ctrl && !key.meta) {
104
- const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
105
- if (!printable) return;
106
- setValue((v) => v.slice(0, cursor) + printable + v.slice(cursor));
107
- setCursor((c) => c + printable.length);
158
+ // Strip bracketed-paste markers, then flatten newlines/tabs from a paste.
159
+ let text = input.replace(/\x1b\[20[01]~/g, '');
160
+ const isPaste = text.length > 1 || /[\r\n\t]/.test(text);
161
+ text = text.replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
162
+ if (!text) return;
163
+ snapshot(isPaste ? 'paste' : 'type');
164
+ setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
165
+ setCursor((c) => c + text.length);
108
166
  }
109
167
  });
110
168
 
@@ -112,13 +170,14 @@ export function Composer({ onSubmit, busy, width = 80 }) {
112
170
  const at = value[cursor] ?? ' ';
113
171
  const after = value.slice(cursor + 1);
114
172
  const empty = value.length === 0;
115
- const sug = menuOpen && matches[sel] ? matches[sel].cmd : '';
116
- const ghost = sug && sug.startsWith(value) && cursor >= value.length ? sug.slice(value.length) : '';
173
+ const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
174
+ const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
117
175
 
118
176
  return html`
119
177
  <${Box} flexDirection="column" width=${width}>
120
178
  ${menuOpen
121
179
  ? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
180
+ ${slashMode ? null : html`<${Box}><${Text} color=${C.cyan} bold>@ mention <//><${Text} color=${C.gray}>a device, customer or entity<//><//>`}
122
181
  ${(() => {
123
182
  const MAX = 8;
124
183
  let start = 0;
@@ -127,10 +186,11 @@ export function Composer({ onSubmit, busy, width = 80 }) {
127
186
  return window.map((s, wi) => {
128
187
  const i = start + wi;
129
188
  const on = i === sel;
130
- const label = (s.cmd + (s.hint ? ' ' + s.hint : '')).padEnd(21);
189
+ const key = slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag;
190
+ const label = key.padEnd(21);
131
191
  return html`<${Box} key=${'m' + i}>
132
192
  <${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
133
- <${Text} color=${on ? C.accent : C.white} bold=${on}>${label}<//>
193
+ <${Text} color=${on ? C.accent : slashMode ? C.white : C.cyan} bold=${on}>${label}<//>
134
194
  <${Text} color=${C.gray}>${s.desc}<//>
135
195
  <//>`;
136
196
  });
@@ -1,55 +1,145 @@
1
- // Minimal, dependency-free markdown → Ink renderer. Supports headings,
2
- // bold, inline code, bullet/numbered lists and fenced code blocks. Ink
1
+ // Small, dependency-free markdown → Ink renderer. Supports headings, bold,
2
+ // italic, strikethrough, inline code, links, bullet/numbered lists,
3
+ // blockquotes, horizontal rules, fenced code blocks and GFM tables. Ink
3
4
  // handles wrapping to the available width automatically.
4
- import { html, React } from './dom.js';
5
+ import { html } from './dom.js';
5
6
  import { Box, Text } from 'ink';
6
- import { C, glyphs } from './theme.js';
7
+ import { C, glyphs, asciiSafe } from './theme.js';
7
8
 
8
9
  let keyId = 0;
9
10
  const k = () => `md${keyId++}`;
10
11
 
11
- /** Parse a single line into styled Ink <Text> spans (bold, `code`). */
12
+ const stripInline = (s) =>
13
+ String(s)
14
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
15
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2')
16
+ .replace(/_([^_]+)_/g, '$1')
17
+ .replace(/~~([^~]+)~~/g, '$1')
18
+ .replace(/`([^`]+)`/g, '$1')
19
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
20
+
21
+ const cellWidth = (s) => stripInline(s).length;
22
+
23
+ /** Parse a single line into styled Ink <Text> spans. */
12
24
  function renderInline(line) {
13
25
  const spans = [];
14
- // Tokenise on **bold** and `code`.
15
- const re = /(\*\*([^*]+)\*\*|`([^`]+)`)/g;
26
+ // Order matters: links, bold, strike, italic, code.
27
+ const re = /(\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|~~([^~]+)~~|`([^`]+)`|\*([^*]+)\*|_([^_]+)_)/g;
16
28
  let last = 0;
17
29
  let m;
18
30
  while ((m = re.exec(line)) !== null) {
19
31
  if (m.index > last) spans.push(html`<${Text} key=${k()}>${line.slice(last, m.index)}<//>`);
20
- if (m[2] !== undefined) spans.push(html`<${Text} key=${k()} bold>${m[2]}<//>`);
21
- else if (m[3] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.cyan}>${m[3]}<//>`);
32
+ if (m[2] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.blue} underline>${m[2]}<//>`);
33
+ else if (m[4] !== undefined) spans.push(html`<${Text} key=${k()} bold>${m[4]}<//>`);
34
+ else if (m[5] !== undefined) spans.push(html`<${Text} key=${k()} strikethrough color=${C.gray}>${m[5]}<//>`);
35
+ else if (m[6] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.cyan}>${m[6]}<//>`);
36
+ else if (m[7] !== undefined) spans.push(html`<${Text} key=${k()} italic>${m[7]}<//>`);
37
+ else if (m[8] !== undefined) spans.push(html`<${Text} key=${k()} italic>${m[8]}<//>`);
22
38
  last = m.index + m[0].length;
23
39
  }
24
40
  if (last < line.length) spans.push(html`<${Text} key=${k()}>${line.slice(last)}<//>`);
25
41
  return spans.length ? spans : [html`<${Text} key=${k()}> <//>`];
26
42
  }
27
43
 
44
+ const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(l);
45
+ const isTableSep = (l) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(l);
46
+
47
+ const splitCells = (l) =>
48
+ l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
49
+
50
+ /** Render a parsed GFM table as an aligned, bordered box. */
51
+ function renderTable(header, rows) {
52
+ const cols = header.length;
53
+ const widths = header.map((h, i) =>
54
+ Math.max(cellWidth(h), ...rows.map((r) => cellWidth(r[i] || ''))),
55
+ );
56
+ const bar = glyphs.vbar;
57
+ const pad = (text, w) => {
58
+ const gap = Math.max(0, w - cellWidth(text));
59
+ return ' '.repeat(gap);
60
+ };
61
+ const rule = (l, mid, r) =>
62
+ l + widths.map((w) => (asciiSafe() ? '-' : '─').repeat(w + 2)).join(mid) + r;
63
+ const A = asciiSafe();
64
+ const [tl, tm, tr] = A ? ['+', '+', '+'] : ['┌', '┬', '┐'];
65
+ const [ml, mm, mr] = A ? ['+', '+', '+'] : ['├', '┼', '┤'];
66
+ const [bl, bm, br] = A ? ['+', '+', '+'] : ['└', '┴', '┘'];
67
+
68
+ const line = (cells, color, bold) => html`
69
+ <${Box} key=${k()}>
70
+ <${Text} color=${C.gray}>${bar}<//>
71
+ ${cells.map((cell, i) => html`
72
+ <${Box} key=${k()}>
73
+ <${Text}> <//>
74
+ <${Text} color=${color} bold=${bold}>${renderInline(cell || '')}<//><${Text}>${pad(cell || '', widths[i])} <//>
75
+ <${Text} color=${C.gray}>${bar}<//>
76
+ <//>`)}
77
+ <//>`;
78
+
79
+ return html`
80
+ <${Box} flexDirection="column" key=${k()} marginY=${0}>
81
+ <${Text} color=${C.gray}>${rule(tl, tm, tr)}<//>
82
+ ${line(header, C.accent, true)}
83
+ <${Text} color=${C.gray}>${rule(ml, mm, mr)}<//>
84
+ ${rows.map((r) => line(r, C.white, false))}
85
+ <${Text} color=${C.gray}>${rule(bl, bm, br)}<//>
86
+ <//>`;
87
+ }
88
+
28
89
  /** Render a markdown string as a column of Ink lines. */
29
90
  export function Markdown({ content = '' }) {
30
91
  const rows = [];
31
92
  let inCode = false;
32
93
  const lines = String(content).replace(/\s+$/, '').split('\n');
33
94
 
34
- for (const raw of lines) {
35
- const line = raw.replace(/\t/g, ' ');
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const line = lines[i].replace(/\t/g, ' ');
97
+
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; }
36
101
 
37
- if (line.trim().startsWith('```')) {
38
- inCode = !inCode;
102
+ // GFM table: header row + separator + body rows.
103
+ if (isTableRow(line) && i + 1 < lines.length && isTableSep(lines[i + 1])) {
104
+ const header = splitCells(line);
105
+ const body = [];
106
+ i += 2;
107
+ while (i < lines.length && isTableRow(lines[i])) { body.push(splitCells(lines[i])); i++; }
108
+ i--;
109
+ rows.push(renderTable(header, body));
39
110
  continue;
40
111
  }
41
- if (inCode) {
42
- rows.push(html`<${Text} key=${k()} color=${C.gray}> ${line}<//>`);
112
+
113
+ // Horizontal rule.
114
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
115
+ rows.push(html`<${Text} key=${k()} color=${C.gray}>${(asciiSafe() ? '-' : '─').repeat(40)}<//>`);
43
116
  continue;
44
117
  }
45
118
 
46
- const h = line.match(/^(#{1,3})\s+(.*)$/);
119
+ // Headings.
120
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
47
121
  if (h) {
48
- rows.push(html`<${Text} key=${k()} bold color=${C.white}>${h[2]}<//>`);
122
+ const color = h[1].length === 1 ? C.accent : h[1].length === 2 ? C.cyan : C.white;
123
+ rows.push(html`
124
+ <${Box} key=${k()} marginTop=${rows.length ? 1 : 0}>
125
+ <${Text} bold color=${color}>${stripInline(h[2])}<//>
126
+ <//>`);
127
+ continue;
128
+ }
129
+
130
+ // Blockquote.
131
+ const quote = line.match(/^\s*>\s?(.*)$/);
132
+ if (quote) {
133
+ rows.push(html`
134
+ <${Box} key=${k()}>
135
+ <${Text} color=${C.accent}>${glyphs.vbar} <//>
136
+ <${Text} color=${C.gray} italic>${renderInline(quote[1])}<//>
137
+ <//>`);
49
138
  continue;
50
139
  }
51
140
 
52
- const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
141
+ // Bullet list.
142
+ const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
53
143
  if (bullet) {
54
144
  rows.push(html`
55
145
  <${Box} key=${k()}>
@@ -59,11 +149,12 @@ export function Markdown({ content = '' }) {
59
149
  continue;
60
150
  }
61
151
 
152
+ // Numbered list.
62
153
  const num = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
63
154
  if (num) {
64
155
  rows.push(html`
65
156
  <${Box} key=${k()}>
66
- <${Text} color=${C.accent}>${num[1]}${num[2]}. <//>
157
+ <${Text} color=${C.accent} bold>${num[1]}${num[2]}. <//>
67
158
  <${Text}>${renderInline(num[3])}<//>
68
159
  <//>`);
69
160
  continue;
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
+ }