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 +3 -0
- package/package.json +1 -1
- package/src/tui/app.js +119 -17
- package/src/tui/components.js +81 -6
- package/src/tui/composer.js +32 -8
- package/src/tui/run.js +17 -2
- package/src/tui/theme.js +1 -1
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
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
|
-
|
|
38
|
-
} catch {
|
|
39
|
-
|
|
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: '
|
|
656
|
-
{ label: '
|
|
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
|
-
|
|
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 (
|
|
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="
|
|
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}
|
package/src/tui/components.js
CHANGED
|
@@ -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="
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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}
|
package/src/tui/composer.js
CHANGED
|
@@ -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
|
|
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
|
-
:
|
|
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
|
|
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
|
-
:
|
|
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: '
|
|
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: '[',
|