ispbills-icli 8.4.9 → 8.4.11
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 +1 -1
- package/src/tui/app.js +96 -18
- package/src/tui/components.js +1 -1
- package/src/tui/mouse.js +68 -0
- package/src/tui/run.js +14 -0
package/package.json
CHANGED
package/src/tui/app.js
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
// items are flushed to the terminal's native scrollback via Ink <Static> (so
|
|
4
4
|
// you can scroll back through history), and only the live/streaming area plus
|
|
5
5
|
// the input composer are re-rendered in place at the bottom.
|
|
6
|
-
|
|
6
|
+
//
|
|
7
|
+
// Mouse support (via src/tui/mouse.js + run.js):
|
|
8
|
+
// • Scroll UP → sends \x1b[3S to scroll the terminal viewport upward
|
|
9
|
+
// so the native scrollback buffer shows conversation history.
|
|
10
|
+
// • Scroll DOWN → swallowed; prevents drifting below the live/composer area.
|
|
11
|
+
// • Left click on tab bar row 1 → switches tabs by x-coordinate range.
|
|
12
|
+
import { html, useState, useEffect, useRef, useCallback, useMemo } from './dom.js';
|
|
7
13
|
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
8
14
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
15
|
+
import { mouseEvents } from './mouse.js';
|
|
9
16
|
import {
|
|
10
17
|
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
11
18
|
Plan, Connect, Notice, Thinking, Choice, ShellInput, ShellOut,
|
|
@@ -123,6 +130,62 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
123
130
|
if (input === '4') { setActiveTab(3); return; }
|
|
124
131
|
});
|
|
125
132
|
|
|
133
|
+
// ── Mouse event handling ────────────────────────────────────────────────────
|
|
134
|
+
// Tab bar x-ranges (1-indexed). Layout: paddingX=1 on the TabBar outer Box
|
|
135
|
+
// so text starts at column 2. Each tab = 2-char prefix (' ' or '❯ ') +
|
|
136
|
+
// label text, followed by marginRight=2 before the next tab.
|
|
137
|
+
const tabClickRanges = useMemo(() => {
|
|
138
|
+
let x = 2; // paddingX=1 → first tab starts at column 2
|
|
139
|
+
return TABS.map((label) => {
|
|
140
|
+
const start = x;
|
|
141
|
+
const w = 2 + label.length; // prefix (2 chars) + label
|
|
142
|
+
x += w + 2; // advance past tab + marginRight=2
|
|
143
|
+
return { start, end: start + w - 1 };
|
|
144
|
+
});
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const onMouse = ({ btn, x, y, press }) => {
|
|
149
|
+
// ── Scroll wheel UP → scroll viewport to reveal history ──────────────
|
|
150
|
+
if (btn === 64) {
|
|
151
|
+
try { process.stdout.write('\x1b[3S'); } catch {}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// ── Scroll wheel DOWN → swallowed ─────────────────────────────────────
|
|
155
|
+
if (btn === 65) return;
|
|
156
|
+
|
|
157
|
+
// ── Left click → tab bar ─────────────────────────────────────────────
|
|
158
|
+
// Ink renders INLINE at the bottom of the terminal — NOT at y=1.
|
|
159
|
+
// SGR y coordinates are 1-indexed from the terminal top.
|
|
160
|
+
//
|
|
161
|
+
// Layout rows at idle (bottom-to-top):
|
|
162
|
+
// rows = footer hint
|
|
163
|
+
// rows-1 = composer bottom border
|
|
164
|
+
// rows-2 = composer input
|
|
165
|
+
// rows-3 = composer top border
|
|
166
|
+
// rows-4 = blank (marginTop=1 before composer)
|
|
167
|
+
// rows-5 = tab separator
|
|
168
|
+
// rows-6 = tab labels ← TAB_ROW at idle
|
|
169
|
+
// rows-7 = compact header
|
|
170
|
+
//
|
|
171
|
+
// When there is live content of height h between tab separator and
|
|
172
|
+
// composer, each row shifts up by h: TAB_ROW = rows - 6 - liveH.
|
|
173
|
+
if (btn === 0 && press) {
|
|
174
|
+
const liveH = live
|
|
175
|
+
? 1 + Math.min((live.status?.length || 0) + (live.text ? 2 : 0) + (!live.text && !live.reply?.steps ? 1 : 0), 12)
|
|
176
|
+
: 0;
|
|
177
|
+
const tabRowY = rows - 6 - liveH;
|
|
178
|
+
if (Math.abs(y - tabRowY) <= 1) { // ±1 row tolerance
|
|
179
|
+
const idx = tabClickRanges.findIndex((r) => x >= r.start && x <= r.end);
|
|
180
|
+
if (idx >= 0) setActiveTab(idx);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
mouseEvents.on('event', onMouse);
|
|
186
|
+
return () => mouseEvents.off('event', onMouse);
|
|
187
|
+
}, [tabClickRanges, rows, live]);
|
|
188
|
+
|
|
126
189
|
const setAutopilotMode = useCallback((next) => {
|
|
127
190
|
apRef.current = next;
|
|
128
191
|
setAutopilot(next);
|
|
@@ -575,38 +638,52 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
575
638
|
};
|
|
576
639
|
|
|
577
640
|
const ctx = [model ? shortModel(model) : null, APP_CWD, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
578
|
-
// Footer
|
|
579
|
-
|
|
580
|
-
const footerHint = hint
|
|
581
|
-
? hint
|
|
582
|
-
: autopilot
|
|
583
|
-
? `${glyphs.bolt} autopilot ON${midDot()}/help${midDot()}Shift+Tab off${midDot()}Ctrl+C exit`
|
|
584
|
-
: `/help${midDot()}Shift+Tab autopilot${midDot()}Ctrl+C exit`;
|
|
585
|
-
const footerColor = hint ? C.yellow : autopilot ? C.yellow : void 0;
|
|
641
|
+
// Footer shows a concise key-hints bar. Autopilot badge is a right-aligned label.
|
|
642
|
+
const baseHint = hint || `/help${midDot()}Shift+Tab autopilot${midDot()}↑ scroll history${midDot()}Ctrl+C exit`;
|
|
586
643
|
const compact = cols < 60;
|
|
587
644
|
|
|
588
645
|
// The banner is the first <Static> item so it prints once at the very top and
|
|
589
646
|
// scrolls away with history; completed transcript items follow it. Ink flushes
|
|
590
647
|
// each <Static> item to the terminal's normal buffer exactly once, leaving the
|
|
591
648
|
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
649
|
+
// Scroll UP (mouse wheel) reveals this history; scroll DOWN is blocked.
|
|
592
650
|
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
|
|
593
651
|
|
|
652
|
+
// ── Main render ─────────────────────────────────────────────────────────────
|
|
653
|
+
// Layout (top to bottom):
|
|
654
|
+
// 1. Compact persistent header — "iCopilot" brand always visible
|
|
655
|
+
// 2. Tab bar — Session / Monitoring / Issues / Gists (mouse-clickable)
|
|
656
|
+
// 3. Session pane — Static history + live streaming area (scrollback via ↑)
|
|
657
|
+
// 4. Composer + footer — always pinned to the bottom
|
|
594
658
|
return html`
|
|
595
659
|
<${Box} flexDirection="column" width=${cols}>
|
|
596
|
-
|
|
660
|
+
|
|
661
|
+
${/* ── 1. Compact header: brand + active context ─────────────────── */null}
|
|
662
|
+
<${Box} paddingX=${1} paddingTop=${0}>
|
|
663
|
+
<${Text} color=${C.accent} bold>iCopilot<//>
|
|
664
|
+
<${Text} color=${C.gray}> ${dash()} <//>
|
|
665
|
+
<${Text} dimColor>${ctx}<//>
|
|
666
|
+
${latestVersion?.isNewer
|
|
667
|
+
? html`<${Box} flexGrow=${1} justifyContent="flex-end"><${Text} color=${C.yellow}> 🔔 v${latestVersion.latest} /update<//> <//>`
|
|
668
|
+
: null}
|
|
669
|
+
<//>
|
|
670
|
+
|
|
671
|
+
${/* ── 2. Tab bar (mouse-clickable + Ctrl+1–4) ──────────────────── */null}
|
|
597
672
|
<${TabBar} tabs=${TABS} active=${activeTab} onSelect=${setActiveTab} width=${cols} />
|
|
598
673
|
|
|
674
|
+
${/* ── 3a. Session pane ────────────────────────────────────────────── */null}
|
|
599
675
|
${activeTab === 0
|
|
600
676
|
? html`<${Box} flexDirection="column">
|
|
601
677
|
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
602
678
|
${(it) => it.type === 'banner'
|
|
603
|
-
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${
|
|
679
|
+
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${2}>
|
|
604
680
|
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
|
|
605
681
|
<//>`
|
|
606
682
|
: renderItem(it)}
|
|
607
683
|
<//>
|
|
684
|
+
${/* Gap between history and live area — gives breathing room */null}
|
|
608
685
|
${live
|
|
609
|
-
? html`<${Box} flexDirection="column">
|
|
686
|
+
? html`<${Box} flexDirection="column" marginTop=${1}>
|
|
610
687
|
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
611
688
|
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
612
689
|
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
@@ -623,23 +700,24 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
623
700
|
: null}
|
|
624
701
|
<//>`
|
|
625
702
|
: null}
|
|
703
|
+
|
|
704
|
+
${/* ── 3b. Other tabs ──────────────────────────────────────────────── */null}
|
|
626
705
|
${activeTab === 1 ? html`<${MonitoringView} cfg=${cfg} />` : null}
|
|
627
706
|
${activeTab === 2 ? html`<${IssuesView} issues=${issuesRef.current} />` : null}
|
|
628
707
|
${activeTab === 3 ? html`<${GistsView} cfg=${cfg} />` : null}
|
|
629
708
|
|
|
709
|
+
${/* ── 4. Composer + footer — fixed at bottom ─────────────────────── */null}
|
|
630
710
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
631
711
|
${activeTab === 0
|
|
632
712
|
? (choice
|
|
633
713
|
? html`<${Choice} ...${choice} />`
|
|
634
714
|
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} prefill=${prefillText} onPrefillConsumed=${() => setPrefillText(null)} />`)
|
|
635
|
-
: html`<${Box} paddingX=${1}><${Text} dimColor>Ctrl+1
|
|
715
|
+
: html`<${Box} paddingX=${1}><${Text} dimColor>Click a tab or Ctrl+1–4 to switch · Ctrl+1 for Session<//> <//>` }
|
|
636
716
|
<${Box} paddingX=${1} width=${cols}>
|
|
637
717
|
<${Box} flexGrow=${1}>
|
|
638
|
-
<${Text} dimColor wrap="truncate-end">${
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
? html`<${Text} color=${C.yellow} wrap="truncate-end">🔔 v${latestVersion.latest} available — /update<//>`
|
|
642
|
-
: html`<${Text} color=${footerColor} dimColor=${!footerColor} wrap="truncate-end">${footerHint}<//>` }
|
|
718
|
+
<${Text} dimColor wrap="truncate-end">${hint ? hint : baseHint}<//><//>
|
|
719
|
+
${autopilot
|
|
720
|
+
? html`<${Text} color=${C.yellow} bold wrap="truncate-end"> ${glyphs.bolt} autopilot ON<//>` : null}
|
|
643
721
|
<//>
|
|
644
722
|
<//>
|
|
645
723
|
<//>`;
|
package/src/tui/components.js
CHANGED
|
@@ -507,7 +507,7 @@ export function TabBar({ tabs = [], active = 0, onSelect, width = 80 }) {
|
|
|
507
507
|
>${on ? glyphs.prompt + ' ' : ' '}${label}<//><//>`;
|
|
508
508
|
})}
|
|
509
509
|
<${Box} flexGrow=${1}>
|
|
510
|
-
<${Text} dimColor> Ctrl+${tabs.map((_, i) => i + 1).join('/')}<//><//>
|
|
510
|
+
<${Text} dimColor> click or Ctrl+${tabs.map((_, i) => i + 1).join('/')}<//><//>
|
|
511
511
|
<//>
|
|
512
512
|
<${Text} color=${C.accent} dimColor>${sep.repeat(Math.max(8, width - 2))}<//>
|
|
513
513
|
<//>`;
|
package/src/tui/mouse.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Mouse event support for the iCli TUI.
|
|
2
|
+
// Patches process.stdin.read() to intercept SGR mouse escape sequences before
|
|
3
|
+
// Ink's input parser sees them, routing them to a shared EventEmitter instead.
|
|
4
|
+
// This prevents mouse sequences from appearing as garbage in the composer.
|
|
5
|
+
//
|
|
6
|
+
// SGR mouse format: \x1b[<{btn};{x};{y}M (button press)
|
|
7
|
+
// \x1b[<{btn};{x};{y}m (button release)
|
|
8
|
+
//
|
|
9
|
+
// Button codes of interest:
|
|
10
|
+
// 0 = left click press/release
|
|
11
|
+
// 64 = scroll wheel up
|
|
12
|
+
// 65 = scroll wheel down
|
|
13
|
+
import { EventEmitter } from 'events';
|
|
14
|
+
|
|
15
|
+
const MOUSE_SGR = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
16
|
+
|
|
17
|
+
// Shared event emitter — app.js subscribes to 'event'.
|
|
18
|
+
export const mouseEvents = new EventEmitter();
|
|
19
|
+
mouseEvents.setMaxListeners(20);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Patch stdin.read() to intercept and strip mouse sequences.
|
|
23
|
+
* Returns an unpatch function to restore the original.
|
|
24
|
+
*/
|
|
25
|
+
export function patchStdinMouse(stdin) {
|
|
26
|
+
if (!stdin || typeof stdin.read !== 'function') return () => {};
|
|
27
|
+
const origRead = stdin.read.bind(stdin);
|
|
28
|
+
|
|
29
|
+
stdin.read = function (size) {
|
|
30
|
+
const data = origRead(size);
|
|
31
|
+
if (!data) return data;
|
|
32
|
+
const str = data.toString('utf8');
|
|
33
|
+
|
|
34
|
+
// Emit each mouse event found in the chunk.
|
|
35
|
+
MOUSE_SGR.lastIndex = 0;
|
|
36
|
+
let m;
|
|
37
|
+
while ((m = MOUSE_SGR.exec(str)) !== null) {
|
|
38
|
+
mouseEvents.emit('event', {
|
|
39
|
+
btn: parseInt(m[1], 10),
|
|
40
|
+
x: parseInt(m[2], 10),
|
|
41
|
+
y: parseInt(m[3], 10),
|
|
42
|
+
press: m[4] === 'M',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Strip ALL mouse sequences so Ink never sees them.
|
|
47
|
+
const filtered = str.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, '');
|
|
48
|
+
return filtered ? Buffer.from(filtered, 'utf8') : null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return function unpatch() {
|
|
52
|
+
stdin.read = origRead;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Enable SGR mouse button + scroll-wheel tracking. */
|
|
57
|
+
export function enableMouse() {
|
|
58
|
+
try {
|
|
59
|
+
process.stdout.write('\x1b[?1000h\x1b[?1006h');
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Disable SGR mouse tracking (restore normal terminal mouse behaviour). */
|
|
64
|
+
export function disableMouse() {
|
|
65
|
+
try {
|
|
66
|
+
process.stdout.write('\x1b[?1000l\x1b[?1006l');
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
package/src/tui/run.js
CHANGED
|
@@ -3,14 +3,25 @@
|
|
|
3
3
|
// once at the top and scrolls away, and the terminal's native scrollback keeps
|
|
4
4
|
// the full conversation history. Completed transcript items are flushed to the
|
|
5
5
|
// scrollback by Ink's <Static>; only the live area + input re-render in place.
|
|
6
|
+
//
|
|
7
|
+
// Mouse support: SGR mouse tracking is enabled so that:
|
|
8
|
+
// • Left-clicks on the tab bar switch tabs.
|
|
9
|
+
// • Scroll-wheel UP scrolls the terminal's native scrollback (history).
|
|
10
|
+
// • Scroll-wheel DOWN is swallowed — no drifting below the live area.
|
|
6
11
|
import { html } from './dom.js';
|
|
7
12
|
import { render } from 'ink';
|
|
8
13
|
import { App } from './app.js';
|
|
14
|
+
import { patchStdinMouse, enableMouse, disableMouse } from './mouse.js';
|
|
9
15
|
|
|
10
16
|
export async function runTui(cfg, opts = {}) {
|
|
11
17
|
const { display = {}, onExternal, initialQuestion, version = '', resumeSession = null } = opts;
|
|
12
18
|
let externalAction;
|
|
13
19
|
|
|
20
|
+
// Patch stdin.read() to strip mouse sequences before Ink sees them,
|
|
21
|
+
// then enable SGR mouse tracking so the terminal sends us those events.
|
|
22
|
+
const unpatchMouse = patchStdinMouse(process.stdin);
|
|
23
|
+
enableMouse();
|
|
24
|
+
|
|
14
25
|
const instance = render(
|
|
15
26
|
html`<${App}
|
|
16
27
|
cfg=${cfg}
|
|
@@ -25,5 +36,8 @@ export async function runTui(cfg, opts = {}) {
|
|
|
25
36
|
|
|
26
37
|
await instance.waitUntilExit();
|
|
27
38
|
|
|
39
|
+
disableMouse();
|
|
40
|
+
unpatchMouse();
|
|
41
|
+
|
|
28
42
|
return externalAction;
|
|
29
43
|
}
|