ispbills-icli 8.4.9 → 8.4.10

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.4.9",
3
+ "version": "8.4.10",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
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
- import { html, useState, useEffect, useRef, useCallback } from './dom.js';
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,43 @@ 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 occupies row 1 (1-indexed y). Each tab is: 2-char prefix + label
135
+ // + marginRight=2. paddingX=1 on the outer Box shifts all tabs one column
136
+ // right, so column 2 is where the first tab starts.
137
+ const tabClickRanges = useMemo(() => {
138
+ let x = 2; // paddingX=1 → first content starts at column 2 (1-indexed)
139
+ return TABS.map((label) => {
140
+ const start = x;
141
+ const w = 2 + label.length; // ' ' or '❯ ' prefix + label chars
142
+ x += w + 2; // +2 for marginRight
143
+ return { start, end: start + w - 1 };
144
+ });
145
+ }, []);
146
+
147
+ useEffect(() => {
148
+ const TAB_ROW = 1; // y=1 in SGR coords (first row)
149
+
150
+ const onMouse = ({ btn, x, y, press }) => {
151
+ // ── Scroll wheel UP → scroll the terminal viewport to reveal history ──
152
+ if (btn === 64) {
153
+ try { process.stdout.write('\x1b[3S'); } catch {}
154
+ return;
155
+ }
156
+ // ── Scroll wheel DOWN → swallowed (no drifting below live area) ────────
157
+ if (btn === 65) return;
158
+
159
+ // ── Left click (btn=0, press) on tab bar row ──────────────────────────
160
+ if (btn === 0 && press && y === TAB_ROW) {
161
+ const idx = tabClickRanges.findIndex((r) => x >= r.start && x <= r.end);
162
+ if (idx >= 0) setActiveTab(idx);
163
+ }
164
+ };
165
+
166
+ mouseEvents.on('event', onMouse);
167
+ return () => mouseEvents.off('event', onMouse);
168
+ }, [tabClickRanges]);
169
+
126
170
  const setAutopilotMode = useCallback((next) => {
127
171
  apRef.current = next;
128
172
  setAutopilot(next);
@@ -575,38 +619,52 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
575
619
  };
576
620
 
577
621
  const ctx = [model ? shortModel(model) : null, APP_CWD, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
578
- // Footer: left = session context (dimmed), right = hint or key-hints.
579
- // Autopilot mode turns the right side yellow.
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;
622
+ // Footer shows a concise key-hints bar. Autopilot badge is a right-aligned label.
623
+ const baseHint = hint || `/help${midDot()}Shift+Tab autopilot${midDot()}↑ scroll history${midDot()}Ctrl+C exit`;
586
624
  const compact = cols < 60;
587
625
 
588
626
  // The banner is the first <Static> item so it prints once at the very top and
589
627
  // scrolls away with history; completed transcript items follow it. Ink flushes
590
628
  // each <Static> item to the terminal's normal buffer exactly once, leaving the
591
629
  // native scrollback intact so earlier conversation can be scrolled back to.
630
+ // Scroll UP (mouse wheel) reveals this history; scroll DOWN is blocked.
592
631
  const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
593
632
 
633
+ // ── Main render ─────────────────────────────────────────────────────────────
634
+ // Layout (top to bottom):
635
+ // 1. Compact persistent header — "iCopilot" brand always visible
636
+ // 2. Tab bar — Session / Monitoring / Issues / Gists (mouse-clickable)
637
+ // 3. Session pane — Static history + live streaming area (scrollback via ↑)
638
+ // 4. Composer + footer — always pinned to the bottom
594
639
  return html`
595
640
  <${Box} flexDirection="column" width=${cols}>
596
- ${/* Tab bar — always pinned above content */null}
641
+
642
+ ${/* ── 1. Compact header: brand + active context ─────────────────── */null}
643
+ <${Box} paddingX=${1} paddingTop=${0}>
644
+ <${Text} color=${C.accent} bold>iCopilot<//>
645
+ <${Text} color=${C.gray}> ${dash()} <//>
646
+ <${Text} dimColor>${ctx}<//>
647
+ ${latestVersion?.isNewer
648
+ ? html`<${Box} flexGrow=${1} justifyContent="flex-end"><${Text} color=${C.yellow}> 🔔 v${latestVersion.latest} /update<//> <//>`
649
+ : null}
650
+ <//>
651
+
652
+ ${/* ── 2. Tab bar (mouse-clickable + Ctrl+1–4) ──────────────────── */null}
597
653
  <${TabBar} tabs=${TABS} active=${activeTab} onSelect=${setActiveTab} width=${cols} />
598
654
 
655
+ ${/* ── 3a. Session pane ────────────────────────────────────────────── */null}
599
656
  ${activeTab === 0
600
657
  ? html`<${Box} flexDirection="column">
601
658
  <${Static} key=${clearEpoch} items=${staticItems}>
602
659
  ${(it) => it.type === 'banner'
603
- ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
660
+ ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${2}>
604
661
  <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
605
662
  <//>`
606
663
  : renderItem(it)}
607
664
  <//>
665
+ ${/* Gap between history and live area — gives breathing room */null}
608
666
  ${live
609
- ? html`<${Box} flexDirection="column">
667
+ ? html`<${Box} flexDirection="column" marginTop=${1}>
610
668
  <${StatusLines} lines=${live.status} busy=${busy} />
611
669
  ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
612
670
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
@@ -623,23 +681,24 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
623
681
  : null}
624
682
  <//>`
625
683
  : null}
684
+
685
+ ${/* ── 3b. Other tabs ──────────────────────────────────────────────── */null}
626
686
  ${activeTab === 1 ? html`<${MonitoringView} cfg=${cfg} />` : null}
627
687
  ${activeTab === 2 ? html`<${IssuesView} issues=${issuesRef.current} />` : null}
628
688
  ${activeTab === 3 ? html`<${GistsView} cfg=${cfg} />` : null}
629
689
 
690
+ ${/* ── 4. Composer + footer — fixed at bottom ─────────────────────── */null}
630
691
  <${Box} flexDirection="column" marginTop=${1}>
631
692
  ${activeTab === 0
632
693
  ? (choice
633
694
  ? html`<${Choice} ...${choice} />`
634
695
  : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} prefill=${prefillText} onPrefillConsumed=${() => setPrefillText(null)} />`)
635
- : html`<${Box} paddingX=${1}><${Text} dimColor>Ctrl+1 for Session · Ctrl+2/3/4 for other tabs<//> <//>` }
696
+ : html`<${Box} paddingX=${1}><${Text} dimColor>Click a tab or Ctrl+1–4 to switch · Ctrl+1 for Session<//> <//>` }
636
697
  <${Box} paddingX=${1} width=${cols}>
637
698
  <${Box} flexGrow=${1}>
638
- <${Text} dimColor wrap="truncate-end">${ctx}<//>
639
- <//>
640
- ${latestVersion?.isNewer
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}<//>` }
699
+ <${Text} dimColor wrap="truncate-end">${hint ? hint : baseHint}<//><//>
700
+ ${autopilot
701
+ ? html`<${Text} color=${C.yellow} bold wrap="truncate-end"> ${glyphs.bolt} autopilot ON<//>` : null}
643
702
  <//>
644
703
  <//>
645
704
  <//>`;
@@ -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
  <//>`;
@@ -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
  }