ispbills-icli 8.5.6 → 8.5.8

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 CHANGED
@@ -320,6 +320,9 @@ async function main() {
320
320
  if (has('--experimental')) {
321
321
  cfg.tui = { ...(cfg.tui || {}), experimental: true };
322
322
  }
323
+ if (has('--cloud')) {
324
+ cfg.tui = { ...(cfg.tui || {}), cloud: true };
325
+ }
323
326
  if (flag('--allow-tool')) {
324
327
  const tool = flag('--allow-tool');
325
328
  cfg.tui = { ...(cfg.tui || {}), allowedTools: [...(cfg.tui?.allowedTools || []), tool] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.5.6",
3
+ "version": "8.5.8",
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]);
@@ -430,6 +442,17 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
430
442
  if (!trusted && !choice) openTrustDialog(process.cwd());
431
443
  }, [trusted, choice, openTrustDialog]);
432
444
 
445
+ // §2 (stage 3): auth check — once the folder is trusted, warn if no token.
446
+ const authNoticeRef = useRef(false);
447
+ useEffect(() => {
448
+ if (!trusted || authNoticeRef.current) return;
449
+ const isAuth = Boolean(cfg?.user?.name || cfg?.user?.email || cfg?.apiKey || cfg?._token);
450
+ if (!isAuth) {
451
+ authNoticeRef.current = true;
452
+ pushNotice(`${glyphs.warn} Not authenticated — run /login to connect.`, C.warning, false);
453
+ }
454
+ }, [trusted, cfg, pushNotice]);
455
+
433
456
  const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
434
457
 
435
458
  const maybeProcessQueue = useCallback(() => {
@@ -646,14 +669,49 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
646
669
  setLive(null);
647
670
  if (type === 'plan') {
648
671
  const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
672
+ // §9: choiceActionRef handles autopilot hand-off
673
+ choiceActionRef.current = (value) => {
674
+ if (value === '__autopilot__') {
675
+ apRef.current = true;
676
+ setModeState('autopilot');
677
+ runTurn('apply fix');
678
+ return;
679
+ }
680
+ if (value != null) runTurn(String(value));
681
+ };
649
682
  setChoice({
650
683
  title: 'Apply this fix?',
651
684
  note: reply.summary || '',
652
685
  sel: 0, text: '', focusText: false, allowText: false,
653
686
  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 },
687
+ { label: destructive ? '1. Apply fix (destructive)' : '1. Apply fix', value: 'apply fix', danger: destructive },
688
+ { label: '2. Accept plan and build on autopilot', value: '__autopilot__' },
689
+ { label: '3. Explain the plan first', value: 'explain this plan in more detail before applying' },
690
+ { label: '4. Cancel', value: null },
691
+ ],
692
+ });
693
+ } else if (type === 'tool_approval' || (reply.toolCall && (type === 'prompt' || type === 'choice'))) {
694
+ // §13: exact tool-approval format
695
+ const toolName = reply.toolCall?.name || 'shell';
696
+ const toolArgs = reply.toolCall?.args || reply.toolCall?.command || '';
697
+ const toolDesc = toolArgs ? `${toolName}: ${toolArgs}` : (reply.note || toolName);
698
+ choiceActionRef.current = (value) => {
699
+ if (value === '__session_allow__') {
700
+ cfg.allowedTools = [...(cfg.allowedTools || []), toolName];
701
+ runTurn('yes, proceed with this tool call');
702
+ return;
703
+ }
704
+ if (value != null) runTurn(String(value));
705
+ };
706
+ setChoice({
707
+ title: '⚙ Tool request',
708
+ note: toolDesc,
709
+ sel: 0, text: '', focusText: false, allowText: false,
710
+ isToolApproval: true,
711
+ options: [
712
+ { label: '1. Yes', value: 'yes, proceed with this tool call' },
713
+ { label: '2. Yes, and approve shell for the rest of the session', value: '__session_allow__' },
714
+ { label: '3. No, and tell Copilot what to do differently (Esc)', value: null },
657
715
  ],
658
716
  });
659
717
  } else if (type === 'prompt' || type === 'choice') {
@@ -763,8 +821,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
763
821
  runTurn(prompt);
764
822
  return;
765
823
  }
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.');
824
+ if (q === '/theme' || q.startsWith('/theme ')) {
825
+ const arg = q.slice('/theme'.length).trim();
826
+ const themes = ['default', 'dim', 'high-contrast', 'colorblind'];
827
+ if (arg && themes.includes(arg)) {
828
+ cfg.theme = arg;
829
+ persistCfg();
830
+ pushNotice(`${glyphs.check} Theme set to "${arg}". Restart iCli to fully apply.`, C.success);
831
+ return;
832
+ }
833
+ const current = cfg.theme || 'default';
834
+ choiceActionRef.current = (value) => {
835
+ if (!value) return;
836
+ cfg.theme = value;
837
+ persistCfg();
838
+ pushNotice(`${glyphs.check} Theme set to "${value}". Restart iCli to fully apply.`, C.success);
839
+ };
840
+ setChoice({
841
+ title: 'Select colour theme',
842
+ note: `Current: ${current}`,
843
+ sel: Math.max(0, themes.indexOf(current)),
844
+ text: '', focusText: false, allowText: false,
845
+ options: [
846
+ { label: 'default', value: 'default', hint: 'Auto-detected dark or light' },
847
+ { label: 'dim', value: 'dim', hint: 'Reduced brightness, lower contrast' },
848
+ { label: 'high-contrast', value: 'high-contrast', hint: 'Maximum contrast for readability' },
849
+ { label: 'colorblind', value: 'colorblind', hint: 'Adjusted for colour-vision differences' },
850
+ ],
851
+ });
768
852
  return;
769
853
  }
770
854
  if (q === '/session') {
@@ -1383,7 +1467,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1383
1467
  setChoice(null);
1384
1468
  if (act) { act(value); return; }
1385
1469
  if (value == null) {
1386
- if (/tool request|approval|shell:/i.test(`${currentChoice?.title || ''} ${currentChoice?.note || ''}`)) {
1470
+ if (currentChoice?.isToolApproval || /⚙ Tool request|tool request|approval|shell:/i.test(`${currentChoice?.title || ''} ${currentChoice?.note || ''}`)) {
1387
1471
  setChoice({
1388
1472
  title: 'Tell Copilot what to do differently:',
1389
1473
  note: '',
@@ -1616,6 +1700,20 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1616
1700
  pushNotice('Composer primed with an ISP reference. Continue typing after #.', C.muted, true);
1617
1701
  return;
1618
1702
  }
1703
+ // §5: o = open in browser, / = open search for this tab
1704
+ if (activeTab !== 'session' && input === 'o') {
1705
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
1706
+ const base = (cfg.url || '').replace(/\/$/, '');
1707
+ const url = base ? `${base}/${activeTab}` : `https://ispbills.local/${activeTab}`;
1708
+ try { spawnSync(openCmd, [url], { detached: true, stdio: 'ignore', shell: false }); } catch {}
1709
+ pushNotice(`Opening ${url} in browser…`, C.muted, true);
1710
+ return;
1711
+ }
1712
+ if (activeTab !== 'session' && input === '/') {
1713
+ openSearch();
1714
+ pushNotice(`Search ${activeTab} tab…`, C.muted, true);
1715
+ return;
1716
+ }
1619
1717
  if (key.tab && key.shift && !busy) {
1620
1718
  const cur = MODES.indexOf(modeRef.current);
1621
1719
  const next = MODES[(cur + 1) % MODES.length];
@@ -1692,18 +1790,22 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1692
1790
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
1693
1791
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
1694
1792
  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
- 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} />`;
1793
+ 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} />`;
1794
+ 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
1795
  case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
1697
1796
  case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
1698
1797
  default: return null;
1699
1798
  }
1700
1799
  };
1701
1800
 
1702
- const cwd = shortenPath(process.cwd());
1703
1801
  const branch = branchRef.current;
1704
1802
  const tokenCount = approxTokens(convo.current);
1705
1803
  const tokenLabel = contextUsageLabel(tokenCount);
1706
1804
  const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
1805
+ // §8: progressive path shortening — reserve space for right section
1806
+ const footerRightEstLen = (modelLabel.length || 0) + (tokenLabel.length || 0) + 8;
1807
+ const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
1808
+ const cwd = shortenPath(process.cwd(), cwdMaxLen);
1707
1809
  const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
1708
1810
  const footerRight = hint
1709
1811
  ? hint
@@ -1731,7 +1833,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1731
1833
  <${StatusLines} lines=${live.status} busy=${busy} />
1732
1834
  ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
1733
1835
  ${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}
1836
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1735
1837
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1736
1838
  <//>`
1737
1839
  : null}
@@ -1805,7 +1907,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1805
1907
  }}
1806
1908
  busy=${busy}
1807
1909
  blocked=${Boolean(choice || search || sessionPicker || diffState || !trusted)}
1808
- width=${cols}
1910
+ width=${Math.max(60, cols)}
1809
1911
  mode=${composerMode}
1810
1912
  activeTab=${activeTab}
1811
1913
  presetText=${composerPreset.text}
@@ -121,7 +121,7 @@ export function TabBar({ active = 'session', cols = 80, mode = 'ask' }) {
121
121
  ${i > 0 ? html`<${Text} color="cyan" backgroundColor="blue"> <//>` : null}
122
122
  ${on
123
123
  ? html`<${Text} color="white" bold backgroundColor="blue">[${t.label}]<//>`
124
- : html`<${Text} color="cyan" backgroundColor="blue">${t.label}<//>`}
124
+ : html`<${Text} color="white" dimColor backgroundColor="blue">${t.label}<//>`}
125
125
  <//>`;
126
126
  })}
127
127
  <//>
@@ -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,38 @@ 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 }) {
254
+ // §11: response content box capped at 100 columns; separators stay full-width (§6).
255
+ const bodyWidth = Math.min(cols, 100);
215
256
  return html`
216
257
  <${Box} flexDirection="column" marginTop=${1}>
217
258
  <${Sep} cols=${cols} />
218
259
  <${SpeakerLine} label=${label} color=${C.accent} cols=${cols} ts=${ts} />
219
- <${Box} paddingX=${2}>
220
- <${Markdown} content=${text} />
260
+ <${Box} paddingX=${2} flexDirection="column" width=${bodyWidth}>
261
+ ${streaming ? html`<${StreamingText} text=${text} />` : html`<${Markdown} content=${text} />`}
221
262
  <//>
222
263
  <//>`;
223
264
  }
@@ -296,7 +337,7 @@ export function Reasoning({ text, show = true }) {
296
337
  if (!show) {
297
338
  return html`
298
339
  <${Box} paddingX=${2} marginTop=${1}>
299
- <${Text} color=${C.muted} italic>${glyphs.reasoning} Thinking (≈${tokenEst} tokens)… Ctrl+T to expand<//><//>`;
340
+ <${Text} color=${C.muted} italic>${glyphs.copilotDot} Thinking (≈${tokenEst} tokens)… Ctrl+T to expand<//><//>`;
300
341
  }
301
342
  return html`
302
343
  <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.separator}
@@ -468,7 +509,41 @@ export function Thinking({ label = 'Working', startedAt }) {
468
509
  <//>`;
469
510
  }
470
511
 
471
- export function Choice({ title, note, options = [], sel = 0, allowText = false, text = '', focusText = false }) {
512
+ export function Choice({ title, note, options = [], sel = 0, allowText = false, text = '', focusText = false, isToolApproval = false }) {
513
+ // §13: tool approval uses exact spec format with Sep-style header
514
+ if (isToolApproval) {
515
+ return html`
516
+ <${Box} flexDirection="column" marginTop=${1}>
517
+ <${Sep} cols=${80} />
518
+ <${Box} paddingX=${2} marginTop=${1}>
519
+ <${Text} color=${C.accent} bold>⚙ Tool request<//>
520
+ <//>
521
+ ${note ? html`<${Box} paddingX=${2} marginTop=${1}><${Text} color=${C.muted}>${note}<//><//>` : null}
522
+ <${Box} flexDirection="column" marginTop=${1} paddingX=${2}>
523
+ ${options.map((o, i) => {
524
+ const on = !focusText && i === sel;
525
+ const label = o.label ?? o.value ?? String(o);
526
+ const tone = o.danger ? C.error : on ? C.success : C.white;
527
+ return html`
528
+ <${Box} key=${'o' + i}>
529
+ <${Text} color=${on ? C.success : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
530
+ <${Text} color=${tone} bold=${on}>${label}<//>
531
+ <//>`;
532
+ })}
533
+ ${allowText
534
+ ? html`<${Box} key="freetext" marginTop=${1}>
535
+ <${Text} color=${focusText ? C.accent : C.muted}>${focusText ? glyphs.prompt + ' ' : ' '}<//>
536
+ <${Text} color=${C.muted}>Tell Copilot what to do differently: <//>
537
+ <${Text} color=${C.white}>${text}<//>${focusText ? html`<${Text} inverse> <//>` : null}
538
+ <//>`
539
+ : null}
540
+ <//>
541
+ <${Box} marginTop=${1} paddingX=${2}>
542
+ <${Text} color=${C.muted}>${glyphs.up}${glyphs.down} choose ${midDot()}Enter confirm ${midDot()}Esc cancel<//>
543
+ <//>
544
+ <//>`;
545
+ }
546
+
472
547
  return html`
473
548
  <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1}>
474
549
  ${title ? html`<${Text} color=${C.accent} bold>${glyphs.diamond} ${title}<//>` : null}
@@ -3,7 +3,7 @@
3
3
  // the input and is navigable with the arrow keys.
4
4
  import { html, useState, useEffect, useCallback, useRef } from './dom.js';
5
5
  import { Box, Text, useInput } from 'ink';
6
- import { C, glyphs } from './theme.js';
6
+ import { C, glyphs, asciiSafe } from './theme.js';
7
7
  import { SLASH_COMMANDS } from '../commands.js';
8
8
  import { readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
9
9
  import { join, basename, dirname, resolve } from 'path';
@@ -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,11 +523,13 @@ 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}
508
532
 
509
- <${Text} color=${C.separator}>${'─'.repeat(Math.max(1, width))}<//>
533
+ <${Text} color=${C.separator}>${(asciiSafe() ? '-' : '─').repeat(Math.max(1, width))}<//>
510
534
  <//>`;
511
535
  }
package/src/tui/run.js CHANGED
@@ -14,7 +14,7 @@ const LOGO = [
14
14
  ];
15
15
  const COLORS = ['35', '95', '94', '96', '97'];
16
16
 
17
- async function animateSplash() {
17
+ async function animateSplash(cfg = {}) {
18
18
  if (!process.stdout.isTTY) return;
19
19
  for (let frame = 0; frame < COLORS.length; frame++) {
20
20
  const color = COLORS[frame];
@@ -23,6 +23,21 @@ async function animateSplash() {
23
23
  await new Promise((resolve) => setTimeout(resolve, 110));
24
24
  if (frame !== COLORS.length - 1) process.stdout.write(`\x1b[${LOGO.length}A`);
25
25
  }
26
+ // §3: welcome text, quick-start tips and a visible /login instruction.
27
+ const DIM = '\x1b[2m';
28
+ const ACCENT = '\x1b[38;5;141m';
29
+ const WARN = '\x1b[38;5;179m';
30
+ const R = '\x1b[0m';
31
+ process.stdout.write(`\n ${ACCENT}iCopilot${R} ${DIM}— IspBills network engineer in your terminal${R}\n\n`);
32
+ process.stdout.write(` ${DIM}Quick start:${R}\n`);
33
+ process.stdout.write(` ${ACCENT}@${R}${DIM} mention files or devices${R}\n`);
34
+ process.stdout.write(` ${ACCENT}/${R}${DIM} slash commands${R}\n`);
35
+ process.stdout.write(` ${ACCENT}?${R}${DIM} quick help overlay${R}\n`);
36
+ process.stdout.write(` ${ACCENT}!${R}${DIM} run a shell command${R}\n`);
37
+ const isAuth = Boolean(cfg?.user?.name || cfg?.user?.email || cfg?.apiKey || cfg?._token);
38
+ if (!isAuth) {
39
+ process.stdout.write(`\n ${WARN}⚠ Not authenticated — run ${R}${ACCENT}/login${R}${WARN} to connect.${R}\n`);
40
+ }
26
41
  process.stdout.write('\n');
27
42
  }
28
43
 
@@ -33,7 +48,7 @@ export async function runTui(cfg, opts = {}) {
33
48
  const showAnimation = banner || !seen;
34
49
 
35
50
  if (showAnimation) {
36
- await animateSplash();
51
+ await animateSplash(cfg);
37
52
  cfg.tui = { ...(cfg.tui || {}), bannerSeen: true };
38
53
  try { saveConfig(cfg); } catch {}
39
54
  }
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: '[',