ispbills-icli 8.5.6 → 8.5.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.5.6",
3
+ "version": "8.5.7",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/tui/app.js CHANGED
@@ -30,13 +30,25 @@ function splitArgs(s) {
30
30
  return out;
31
31
  }
32
32
 
33
- function shortenPath(p) {
33
+ function shortenPath(p, maxLen = Infinity) {
34
+ let result;
34
35
  try {
35
36
  const home = homedir();
36
37
  const rel = relative(home, p);
37
- if (!rel.startsWith('..')) return '~/' + rel;
38
- } catch {}
39
- return p;
38
+ result = rel.startsWith('..') ? p : '~/' + rel;
39
+ } catch {
40
+ result = p;
41
+ }
42
+ if (maxLen === Infinity || result.length <= maxLen) return result;
43
+ // §8: progressive shortening ~/…/sub/path
44
+ const withoutHome = result.replace(/^~\//, '');
45
+ const segs = withoutHome.split('/');
46
+ while (segs.length > 1) {
47
+ segs.shift();
48
+ const candidate = '~/\u2026/' + segs.join('/');
49
+ if (candidate.length <= maxLen) return candidate;
50
+ }
51
+ return '~/\u2026/' + segs[segs.length - 1];
40
52
  }
41
53
 
42
54
  function gitBranch(cwd = process.cwd()) {
@@ -419,9 +431,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
419
431
  focusText: false,
420
432
  allowText: false,
421
433
  options: [
422
- { label: '1. Yes', value: 'session' },
423
- { label: '2. Yes, remember', value: 'remember' },
424
- { label: '3. No, exit', value: 'exit', danger: true },
434
+ { label: '1. Yes, proceed (this session)', value: 'session' },
435
+ { label: '2. Yes, and remember this folder', value: 'remember' },
436
+ { label: '3. No, exit (Esc)', value: 'exit', danger: true },
425
437
  ],
426
438
  });
427
439
  }, [cfg, persistCfg]);
@@ -646,14 +658,49 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
646
658
  setLive(null);
647
659
  if (type === 'plan') {
648
660
  const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
661
+ // §9: choiceActionRef handles autopilot hand-off
662
+ choiceActionRef.current = (value) => {
663
+ if (value === '__autopilot__') {
664
+ apRef.current = true;
665
+ setModeState('autopilot');
666
+ runTurn('apply fix');
667
+ return;
668
+ }
669
+ if (value != null) runTurn(String(value));
670
+ };
649
671
  setChoice({
650
672
  title: 'Apply this fix?',
651
673
  note: reply.summary || '',
652
674
  sel: 0, text: '', focusText: false, allowText: false,
653
675
  options: [
654
- { label: destructive ? 'Apply fix (destructive)' : 'Apply fix', value: 'apply fix', danger: destructive },
655
- { label: 'Explain the plan first', value: 'explain this plan in more detail before applying' },
656
- { label: 'Cancel', value: null },
676
+ { label: destructive ? '1. Apply fix (destructive)' : '1. Apply fix', value: 'apply fix', danger: destructive },
677
+ { label: '2. Accept plan and build on autopilot', value: '__autopilot__' },
678
+ { label: '3. Explain the plan first', value: 'explain this plan in more detail before applying' },
679
+ { label: '4. Cancel', value: null },
680
+ ],
681
+ });
682
+ } else if (type === 'tool_approval' || (reply.toolCall && (type === 'prompt' || type === 'choice'))) {
683
+ // §13: exact tool-approval format
684
+ const toolName = reply.toolCall?.name || 'shell';
685
+ const toolArgs = reply.toolCall?.args || reply.toolCall?.command || '';
686
+ const toolDesc = toolArgs ? `${toolName}: ${toolArgs}` : (reply.note || toolName);
687
+ choiceActionRef.current = (value) => {
688
+ if (value === '__session_allow__') {
689
+ cfg.allowedTools = [...(cfg.allowedTools || []), toolName];
690
+ runTurn('yes, proceed with this tool call');
691
+ return;
692
+ }
693
+ if (value != null) runTurn(String(value));
694
+ };
695
+ setChoice({
696
+ title: '⚙ Tool request',
697
+ note: toolDesc,
698
+ sel: 0, text: '', focusText: false, allowText: false,
699
+ isToolApproval: true,
700
+ options: [
701
+ { label: '1. Yes', value: 'yes, proceed with this tool call' },
702
+ { label: '2. Yes, and approve shell for the rest of the session', value: '__session_allow__' },
703
+ { label: '3. No, and tell Copilot what to do differently (Esc)', value: null },
657
704
  ],
658
705
  });
659
706
  } else if (type === 'prompt' || type === 'choice') {
@@ -763,8 +810,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
763
810
  runTurn(prompt);
764
811
  return;
765
812
  }
766
- if (q === '/theme') {
767
- pushNotice('Theme: dark (default). Palette: purple accent, blue brand, green success, amber warning, red error.\nSet NO_COLOR=1 to disable colour or FORCE_COLOR=1 to force it.');
813
+ if (q === '/theme' || q.startsWith('/theme ')) {
814
+ const arg = q.slice('/theme'.length).trim();
815
+ const themes = ['default', 'dim', 'high-contrast', 'colorblind'];
816
+ if (arg && themes.includes(arg)) {
817
+ cfg.theme = arg;
818
+ persistCfg();
819
+ pushNotice(`${glyphs.check} Theme set to "${arg}". Restart iCli to fully apply.`, C.success);
820
+ return;
821
+ }
822
+ const current = cfg.theme || 'default';
823
+ choiceActionRef.current = (value) => {
824
+ if (!value) return;
825
+ cfg.theme = value;
826
+ persistCfg();
827
+ pushNotice(`${glyphs.check} Theme set to "${value}". Restart iCli to fully apply.`, C.success);
828
+ };
829
+ setChoice({
830
+ title: 'Select colour theme',
831
+ note: `Current: ${current}`,
832
+ sel: Math.max(0, themes.indexOf(current)),
833
+ text: '', focusText: false, allowText: false,
834
+ options: [
835
+ { label: 'default', value: 'default', hint: 'Auto-detected dark or light' },
836
+ { label: 'dim', value: 'dim', hint: 'Reduced brightness, lower contrast' },
837
+ { label: 'high-contrast', value: 'high-contrast', hint: 'Maximum contrast for readability' },
838
+ { label: 'colorblind', value: 'colorblind', hint: 'Adjusted for colour-vision differences' },
839
+ ],
840
+ });
768
841
  return;
769
842
  }
770
843
  if (q === '/session') {
@@ -1383,7 +1456,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1383
1456
  setChoice(null);
1384
1457
  if (act) { act(value); return; }
1385
1458
  if (value == null) {
1386
- if (/tool request|approval|shell:/i.test(`${currentChoice?.title || ''} ${currentChoice?.note || ''}`)) {
1459
+ if (currentChoice?.isToolApproval || /⚙ Tool request|tool request|approval|shell:/i.test(`${currentChoice?.title || ''} ${currentChoice?.note || ''}`)) {
1387
1460
  setChoice({
1388
1461
  title: 'Tell Copilot what to do differently:',
1389
1462
  note: '',
@@ -1616,6 +1689,20 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1616
1689
  pushNotice('Composer primed with an ISP reference. Continue typing after #.', C.muted, true);
1617
1690
  return;
1618
1691
  }
1692
+ // §5: o = open in browser, / = open search for this tab
1693
+ if (activeTab !== 'session' && input === 'o') {
1694
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
1695
+ const base = (cfg.url || '').replace(/\/$/, '');
1696
+ const url = base ? `${base}/${activeTab}` : `https://ispbills.local/${activeTab}`;
1697
+ try { spawnSync(openCmd, [url], { detached: true, stdio: 'ignore', shell: false }); } catch {}
1698
+ pushNotice(`Opening ${url} in browser…`, C.muted, true);
1699
+ return;
1700
+ }
1701
+ if (activeTab !== 'session' && input === '/') {
1702
+ openSearch();
1703
+ pushNotice(`Search ${activeTab} tab…`, C.muted, true);
1704
+ return;
1705
+ }
1619
1706
  if (key.tab && key.shift && !busy) {
1620
1707
  const cur = MODES.indexOf(modeRef.current);
1621
1708
  const next = MODES[(cur + 1) % MODES.length];
@@ -1693,17 +1780,21 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1693
1780
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
1694
1781
  case 'info': return html`<${LabeledNotice} key=${it.id} label="ℹ Info" text=${it.text} color=${C.muted} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
1695
1782
  case 'error': return html`<${LabeledNotice} key=${it.id} label="✖ Error" text=${it.text} color=${C.error} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
1783
+ case 'system': return html`<${LabeledNotice} key=${it.id} label="⚙ System" text=${it.text} color=${C.muted} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
1696
1784
  case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
1697
1785
  case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
1698
1786
  default: return null;
1699
1787
  }
1700
1788
  };
1701
1789
 
1702
- const cwd = shortenPath(process.cwd());
1703
1790
  const branch = branchRef.current;
1704
1791
  const tokenCount = approxTokens(convo.current);
1705
1792
  const tokenLabel = contextUsageLabel(tokenCount);
1706
1793
  const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
1794
+ // §8: progressive path shortening — reserve space for right section
1795
+ const footerRightEstLen = (modelLabel.length || 0) + (tokenLabel.length || 0) + 8;
1796
+ const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
1797
+ const cwd = shortenPath(process.cwd(), cwdMaxLen);
1707
1798
  const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
1708
1799
  const footerRight = hint
1709
1800
  ? hint
@@ -1731,7 +1822,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1731
1822
  <${StatusLines} lines=${live.status} busy=${busy} />
1732
1823
  ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
1733
1824
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
1734
- ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} />` : null}
1825
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1735
1826
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1736
1827
  <//>`
1737
1828
  : null}
@@ -1805,7 +1896,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1805
1896
  }}
1806
1897
  busy=${busy}
1807
1898
  blocked=${Boolean(choice || search || sessionPicker || diffState || !trusted)}
1808
- width=${cols}
1899
+ width=${Math.max(60, cols)}
1809
1900
  mode=${composerMode}
1810
1901
  activeTab=${activeTab}
1811
1902
  presetText=${composerPreset.text}
@@ -152,6 +152,8 @@ export function Banner({ cfg = {}, model, width = 80, compact = false, streamerM
152
152
  const meta = [
153
153
  ['model', streamerMode ? 'hidden' : m], ['user', cfg.user?.name], ['role', cfg.user?.role],
154
154
  ].filter(([, v]) => v);
155
+ // §3: show /login instruction when unauthenticated
156
+ const isAuth = Boolean(cfg.user?.name || cfg.user?.email || cfg.apiKey || cfg._token);
155
157
 
156
158
  if (compact) {
157
159
  return html`
@@ -182,6 +184,20 @@ export function Banner({ cfg = {}, model, width = 80, compact = false, streamerM
182
184
  <//>`)}
183
185
  <//>
184
186
  ${cfg.url ? html`<${Text} color=${C.muted}>${cfg.url}<//>` : null}
187
+
188
+ <${Box} flexDirection="column" marginTop=${1} paddingX=${2} alignSelf="flex-start">
189
+ <${Text} color=${C.muted} bold>Quick start:<//>\n
190
+ <${Text} color=${C.muted}> <${Text} color=${C.accent}>@<//> mention files or devices<//>
191
+ <${Text} color=${C.muted}> <${Text} color=${C.accent}>/<//> slash commands<//>
192
+ <${Text} color=${C.muted}> <${Text} color=${C.accent}>?<//> quick help overlay<//>
193
+ <${Text} color=${C.muted}> <${Text} color=${C.accent}>!<//> run shell command<//>
194
+ <//>
195
+ ${!isAuth
196
+ ? html`<${Box} marginTop=${1}>
197
+ <${Text} color=${C.warning}>⚠ Not authenticated — run <//>
198
+ <${Text} color=${C.slash} bold>/login<//>
199
+ <${Text} color=${C.warning}> to connect.<//><//>`
200
+ : null}
185
201
  <//>`;
186
202
  }
187
203
 
@@ -211,13 +227,36 @@ export function UserMessage({ text, cols = 80, ts }) {
211
227
  <//>`;
212
228
  }
213
229
 
214
- export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.copilotDot} iCopilot` }) {
230
+ // §12: live streaming code-fence highlighter
231
+ function StreamingText({ text = '' }) {
232
+ const lines = String(text).split('\n');
233
+ let inFence = false;
234
+ let fenceLang = '';
235
+ const rendered = [];
236
+ for (let i = 0; i < lines.length; i++) {
237
+ const line = lines[i];
238
+ const fm = line.match(/^```(\w*)/);
239
+ if (fm) {
240
+ inFence = !inFence;
241
+ fenceLang = inFence ? (fm[1] || '') : '';
242
+ rendered.push(html`<${Text} key=${i} color=${C.muted}>${line}<//>`);
243
+ } else if (inFence) {
244
+ const isShell = /^(sh|bash|zsh|fish|console|shell)$/i.test(fenceLang);
245
+ rendered.push(html`<${Text} key=${i} color=${isShell ? C.success : C.brand}>${line}<//>`);
246
+ } else {
247
+ rendered.push(html`<${Text} key=${i}>${line}<//>`);
248
+ }
249
+ }
250
+ return rendered;
251
+ }
252
+
253
+ export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.copilotDot} iCopilot`, streaming = false }) {
215
254
  return html`
216
255
  <${Box} flexDirection="column" marginTop=${1}>
217
256
  <${Sep} cols=${cols} />
218
257
  <${SpeakerLine} label=${label} color=${C.accent} cols=${cols} ts=${ts} />
219
- <${Box} paddingX=${2}>
220
- <${Markdown} content=${text} />
258
+ <${Box} paddingX=${2} flexDirection="column">
259
+ ${streaming ? html`<${StreamingText} text=${text} />` : html`<${Markdown} content=${text} />`}
221
260
  <//>
222
261
  <//>`;
223
262
  }
@@ -296,7 +335,7 @@ export function Reasoning({ text, show = true }) {
296
335
  if (!show) {
297
336
  return html`
298
337
  <${Box} paddingX=${2} marginTop=${1}>
299
- <${Text} color=${C.muted} italic>${glyphs.reasoning} Thinking (≈${tokenEst} tokens)… Ctrl+T to expand<//><//>`;
338
+ <${Text} color=${C.muted} italic>${glyphs.copilotDot} Thinking (≈${tokenEst} tokens)… Ctrl+T to expand<//><//>`;
300
339
  }
301
340
  return html`
302
341
  <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.separator}
@@ -468,7 +507,41 @@ export function Thinking({ label = 'Working', startedAt }) {
468
507
  <//>`;
469
508
  }
470
509
 
471
- export function Choice({ title, note, options = [], sel = 0, allowText = false, text = '', focusText = false }) {
510
+ export function Choice({ title, note, options = [], sel = 0, allowText = false, text = '', focusText = false, isToolApproval = false }) {
511
+ // §13: tool approval uses exact spec format with Sep-style header
512
+ if (isToolApproval) {
513
+ return html`
514
+ <${Box} flexDirection="column" marginTop=${1}>
515
+ <${Sep} cols=${80} />
516
+ <${Box} paddingX=${2} marginTop=${1}>
517
+ <${Text} color=${C.accent} bold>⚙ Tool request<//>
518
+ <//>
519
+ ${note ? html`<${Box} paddingX=${2} marginTop=${1}><${Text} color=${C.muted}>${note}<//><//>` : null}
520
+ <${Box} flexDirection="column" marginTop=${1} paddingX=${2}>
521
+ ${options.map((o, i) => {
522
+ const on = !focusText && i === sel;
523
+ const label = o.label ?? o.value ?? String(o);
524
+ const tone = o.danger ? C.error : on ? C.success : C.white;
525
+ return html`
526
+ <${Box} key=${'o' + i}>
527
+ <${Text} color=${on ? C.success : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
528
+ <${Text} color=${tone} bold=${on}>${label}<//>
529
+ <//>`;
530
+ })}
531
+ ${allowText
532
+ ? html`<${Box} key="freetext" marginTop=${1}>
533
+ <${Text} color=${focusText ? C.accent : C.muted}>${focusText ? glyphs.prompt + ' ' : ' '}<//>
534
+ <${Text} color=${C.muted}>Tell Copilot what to do differently: <//>
535
+ <${Text} color=${C.white}>${text}<//>${focusText ? html`<${Text} inverse> <//>` : null}
536
+ <//>`
537
+ : null}
538
+ <//>
539
+ <${Box} marginTop=${1} paddingX=${2}>
540
+ <${Text} color=${C.muted}>${glyphs.up}${glyphs.down} choose ${midDot()}Enter confirm ${midDot()}Esc cancel<//>
541
+ <//>
542
+ <//>`;
543
+ }
544
+
472
545
  return html`
473
546
  <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1}>
474
547
  ${title ? html`<${Text} color=${C.accent} bold>${glyphs.diamond} ${title}<//>` : null}
@@ -44,6 +44,21 @@ function fileCompletions(pathToken, cwd) {
44
44
  }
45
45
  }
46
46
 
47
+ // §14: fuzzy file search for @word (no path separator)
48
+ function fuzzyFileCompletions(token, cwd) {
49
+ const query = token.slice(1).toLowerCase();
50
+ if (!query) return [];
51
+ try {
52
+ const entries = readdirSync(cwd, { withFileTypes: true });
53
+ return entries
54
+ .filter((e) => e.name.toLowerCase().includes(query))
55
+ .slice(0, 8)
56
+ .map((e) => ({ tag: '@' + e.name, desc: e.isDirectory() ? 'directory' : 'file' }));
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
47
62
  function insertAt(text, cursor, chunk) {
48
63
  return text.slice(0, cursor) + chunk + text.slice(cursor);
49
64
  }
@@ -159,14 +174,18 @@ export function Composer({
159
174
  const token = upto.slice(tokStart);
160
175
  const slashMode = value.startsWith('/') && tokStart === 0;
161
176
  const mentionMode = token.startsWith('@');
162
- // file path mention: @/ @./ @~/ or @word.ext style
163
- const fileMode = mentionMode && /^@(\/|\.\/|~\/|\.\.\/)/.test(token);
177
+ // §14: file mode = explicit path prefix OR any path containing /
178
+ const fileMode = mentionMode && (/^@(\/|\.\/|~\/|\.\.\/)/.test(token) || (token.includes('/') && token.length > 1));
179
+ // Known entity mention: @device, @olt, @onu, @customer, etc.
180
+ const knownMention = mentionMode && !fileMode && MENTIONS.some((m) => m.tag.toLowerCase().startsWith(token.toLowerCase()));
164
181
  const matches = slashMode
165
182
  ? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(token))
166
183
  : fileMode
167
184
  ? fileCompletions(token.slice(1), process.cwd())
168
- : mentionMode
169
- ? MENTIONS.filter((m) => m.tag.startsWith(token.toLowerCase()))
185
+ : knownMention
186
+ ? MENTIONS.filter((m) => m.tag.toLowerCase().startsWith(token.toLowerCase()))
187
+ : mentionMode && token.length > 1
188
+ ? fuzzyFileCompletions(token, process.cwd()) // §14: fuzzy file search
170
189
  : [];
171
190
  const menuOpen = matches.length > 0;
172
191
 
@@ -220,7 +239,10 @@ export function Composer({
220
239
  setText(c, c.length);
221
240
  return;
222
241
  }
223
- const ins = matches[sel].tag + ':';
242
+ const match = matches[sel];
243
+ // §14: file paths get trailing space; entity mentions get ':'
244
+ const isFile = fileMode || match.desc === 'file' || match.desc === 'directory';
245
+ const ins = isFile ? match.tag + ' ' : match.tag + ':';
224
246
  const v = value.slice(0, tokStart) + ins + value.slice(cursor);
225
247
  setText(v, tokStart + ins.length);
226
248
  };
@@ -501,7 +523,9 @@ export function Composer({
501
523
  ? null
502
524
  : fileMode
503
525
  ? html`<${Box}><${Text} color=${C.brand} bold>@ file<//> <${Text} color=${C.muted}>fuzzy path match<//><//>`
504
- : html`<${Box}><${Text} color=${C.brand} bold>@ mention<//> <${Text} color=${C.muted}>device · customer · entity<//><//>` }
526
+ : knownMention
527
+ ? html`<${Box}><${Text} color=${C.brand} bold>@ mention<//> <${Text} color=${C.muted}>device · customer · entity<//><//>`
528
+ : html`<${Box}><${Text} color=${C.brand} bold>@ file<//> <${Text} color=${C.muted}>fuzzy match<//><//>` }
505
529
  ${paletteRows}
506
530
  <//>`
507
531
  : null}
package/src/tui/theme.js CHANGED
@@ -47,7 +47,7 @@ const ASCII_EXTRA = {
47
47
  collapse: '-', // collapse indicator
48
48
  };
49
49
  const UNICODE_EXTRA = {
50
- copilotDot: '', // white hexagon for Copilot label
50
+ copilotDot: '', // spec §6: Copilot speaker label (◈ in accent/purple)
51
51
  busy: '⧖', // white hourglass for busy state
52
52
  gitBranch: '\uE0A0 ', // Nerd Font powerline branch glyph
53
53
  tabOpen: '[',