panex 0.9.4 → 0.9.6

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/dist/index.d.ts CHANGED
@@ -18,6 +18,50 @@ interface PanexConfig {
18
18
 
19
19
  declare function createTUI(config: PanexConfig): Promise<void>;
20
20
 
21
+ /**
22
+ * A terminal buffer that properly interprets ANSI escape sequences including:
23
+ * - Cursor movement (\x1b[A up, \x1b[B down, \x1b[C right, \x1b[D left)
24
+ * - Cursor positioning (\x1b[H, \x1b[row;colH)
25
+ * - Line clearing (\x1b[K erase to end, \x1b[2K erase line)
26
+ * - Screen clearing (\x1b[2J clear screen)
27
+ * - Carriage return (\r) for in-place updates like progress bars
28
+ * - ANSI colors and formatting (preserved in output via SerializeAddon)
29
+ */
30
+ declare class TerminalBuffer {
31
+ private terminal;
32
+ private serializeAddon;
33
+ private rows;
34
+ private cols;
35
+ constructor(cols?: number, rows?: number);
36
+ /**
37
+ * Write data to the terminal buffer.
38
+ * The terminal will interpret all ANSI escape sequences.
39
+ */
40
+ write(data: string): void;
41
+ /**
42
+ * Get the current terminal buffer content as an array of lines.
43
+ * Only returns lines that have content (not all 500 rows).
44
+ */
45
+ getLines(): string[];
46
+ /**
47
+ * Get the terminal content as a single string with newlines.
48
+ * Preserves ANSI color codes and formatting.
49
+ */
50
+ toString(): string;
51
+ /**
52
+ * Clear the terminal buffer.
53
+ */
54
+ clear(): void;
55
+ /**
56
+ * Resize the terminal.
57
+ */
58
+ resize(cols: number, rows: number): void;
59
+ /**
60
+ * Dispose of the terminal to free resources.
61
+ */
62
+ dispose(): void;
63
+ }
64
+
21
65
  interface PtyHandle {
22
66
  write(data: string): void;
23
67
  resize(cols: number, rows: number): void;
@@ -28,13 +72,12 @@ interface ManagedProcess {
28
72
  config: ProcessConfig;
29
73
  pty: PtyHandle | null;
30
74
  status: 'running' | 'stopped' | 'error';
31
- output: string[];
75
+ terminalBuffer: TerminalBuffer;
32
76
  exitCode: number | null;
33
77
  }
34
78
  declare class ProcessManager extends EventEmitter {
35
79
  private procs;
36
80
  private processes;
37
- private maxOutputLines;
38
81
  constructor(procs: Record<string, ProcessConfig>);
39
82
  startAll(): Promise<void>;
40
83
  start(name: string, config: ProcessConfig): Promise<void>;
package/dist/index.js CHANGED
@@ -11,13 +11,89 @@ import { useState, useEffect, useCallback, useRef } from "react";
11
11
 
12
12
  // src/process-manager.ts
13
13
  import { EventEmitter } from "events";
14
+
15
+ // src/terminal-buffer.ts
16
+ import { Terminal } from "@xterm/headless";
17
+ import { SerializeAddon } from "@xterm/addon-serialize";
18
+ var TerminalBuffer = class {
19
+ terminal;
20
+ serializeAddon;
21
+ rows;
22
+ cols;
23
+ constructor(cols = 200, rows = 500) {
24
+ this.rows = rows;
25
+ this.cols = cols;
26
+ this.terminal = new Terminal({
27
+ cols,
28
+ rows,
29
+ scrollback: 1e4,
30
+ allowProposedApi: true
31
+ });
32
+ this.serializeAddon = new SerializeAddon();
33
+ this.terminal.loadAddon(this.serializeAddon);
34
+ }
35
+ /**
36
+ * Write data to the terminal buffer.
37
+ * The terminal will interpret all ANSI escape sequences.
38
+ */
39
+ write(data) {
40
+ this.terminal.write(data);
41
+ }
42
+ /**
43
+ * Get the current terminal buffer content as an array of lines.
44
+ * Only returns lines that have content (not all 500 rows).
45
+ */
46
+ getLines() {
47
+ const buffer = this.terminal.buffer.active;
48
+ const lines = [];
49
+ const contentLength = buffer.baseY + buffer.cursorY + 1;
50
+ for (let i = 0; i < contentLength; i++) {
51
+ const line = buffer.getLine(i);
52
+ if (line) {
53
+ lines.push(line.translateToString(true));
54
+ }
55
+ }
56
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
57
+ lines.pop();
58
+ }
59
+ return lines;
60
+ }
61
+ /**
62
+ * Get the terminal content as a single string with newlines.
63
+ * Preserves ANSI color codes and formatting.
64
+ */
65
+ toString() {
66
+ return this.serializeAddon.serialize();
67
+ }
68
+ /**
69
+ * Clear the terminal buffer.
70
+ */
71
+ clear() {
72
+ this.terminal.reset();
73
+ }
74
+ /**
75
+ * Resize the terminal.
76
+ */
77
+ resize(cols, rows) {
78
+ this.cols = cols;
79
+ this.rows = rows;
80
+ this.terminal.resize(cols, rows);
81
+ }
82
+ /**
83
+ * Dispose of the terminal to free resources.
84
+ */
85
+ dispose() {
86
+ this.terminal.dispose();
87
+ }
88
+ };
89
+
90
+ // src/process-manager.ts
14
91
  var ProcessManager = class extends EventEmitter {
15
92
  constructor(procs) {
16
93
  super();
17
94
  this.procs = procs;
18
95
  }
19
96
  processes = /* @__PURE__ */ new Map();
20
- maxOutputLines = 1e4;
21
97
  async startAll() {
22
98
  for (const [name, config] of Object.entries(this.procs)) {
23
99
  await this.start(name, config);
@@ -37,7 +113,7 @@ var ProcessManager = class extends EventEmitter {
37
113
  config,
38
114
  pty: null,
39
115
  status: "running",
40
- output: [],
116
+ terminalBuffer: new TerminalBuffer(120, 30),
41
117
  exitCode: null
42
118
  };
43
119
  this.processes.set(name, managed);
@@ -50,10 +126,7 @@ var ProcessManager = class extends EventEmitter {
50
126
  rows: 30,
51
127
  data: (_terminal, data) => {
52
128
  const str = new TextDecoder().decode(data);
53
- managed.output.push(str);
54
- if (managed.output.length > this.maxOutputLines) {
55
- managed.output = managed.output.slice(-this.maxOutputLines);
56
- }
129
+ managed.terminalBuffer.write(str);
57
130
  this.emit("output", name, str);
58
131
  }
59
132
  }
@@ -75,7 +148,7 @@ var ProcessManager = class extends EventEmitter {
75
148
  this.emit("started", name);
76
149
  } catch (error) {
77
150
  managed.status = "error";
78
- managed.output = [`Error starting process: ${error}`];
151
+ managed.terminalBuffer.write(`Error starting process: ${error}`);
79
152
  managed.exitCode = -1;
80
153
  this.emit("error", name, error);
81
154
  }
@@ -86,7 +159,7 @@ var ProcessManager = class extends EventEmitter {
86
159
  if (proc.pty) {
87
160
  proc.pty.kill();
88
161
  }
89
- proc.output = [];
162
+ proc.terminalBuffer.clear();
90
163
  this.start(name, proc.config);
91
164
  }
92
165
  }
@@ -130,7 +203,7 @@ var ProcessManager = class extends EventEmitter {
130
203
  return Array.from(this.processes.keys());
131
204
  }
132
205
  getOutput(name) {
133
- return this.processes.get(name)?.output.join("") ?? "";
206
+ return this.processes.get(name)?.terminalBuffer.toString() ?? "";
134
207
  }
135
208
  };
136
209
 
@@ -193,7 +266,7 @@ function useFocusMode() {
193
266
  // src/hooks/useMouseWheel.ts
194
267
  import { useEffect as useEffect2, useCallback as useCallback3 } from "react";
195
268
  import { useStdin, useStdout } from "ink";
196
- function useMouseWheel({ enabled = true, onWheel } = {}) {
269
+ function useMouseWheel({ enabled = true, onWheel, onClick } = {}) {
197
270
  const { stdin, setRawMode } = useStdin();
198
271
  const { stdout } = useStdout();
199
272
  const handleData = useCallback3((data) => {
@@ -206,14 +279,16 @@ function useMouseWheel({ enabled = true, onWheel } = {}) {
206
279
  const y = parseInt(match[3] ?? "0", 10);
207
280
  const isPress = match[4] === "M";
208
281
  if (isPress) {
209
- if (button === 64) {
282
+ if (button === 0) {
283
+ onClick?.({ type: "click", x, y });
284
+ } else if (button === 64) {
210
285
  onWheel?.({ type: "wheel-up", x, y });
211
286
  } else if (button === 65) {
212
287
  onWheel?.({ type: "wheel-down", x, y });
213
288
  }
214
289
  }
215
290
  }
216
- }, [onWheel]);
291
+ }, [onWheel, onClick]);
217
292
  useEffect2(() => {
218
293
  if (!enabled || !stdin || !stdout) return;
219
294
  stdout.write("\x1B[?1000h\x1B[?1006h");
@@ -231,6 +306,7 @@ import { Box, Text, useStdout as useStdout2 } from "ink";
231
306
  import { ScrollList } from "ink-scroll-list";
232
307
  import { forwardRef, useImperativeHandle, useRef as useRef2, useEffect as useEffect3 } from "react";
233
308
  import { jsx, jsxs } from "react/jsx-runtime";
309
+ var PROCESS_LIST_WIDTH = 20;
234
310
  var ProcessList = forwardRef(
235
311
  function ProcessList2({ names, selected, getStatus, active, height }, ref) {
236
312
  const borderStyle = active ? "double" : "single";
@@ -254,7 +330,7 @@ var ProcessList = forwardRef(
254
330
  flexDirection: "column",
255
331
  borderStyle,
256
332
  borderColor: active ? "blue" : "gray",
257
- width: 20,
333
+ width: PROCESS_LIST_WIDTH,
258
334
  height,
259
335
  paddingX: 1,
260
336
  children: /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 0, height: height ? height - 2 : void 0, children: /* @__PURE__ */ jsx(
@@ -273,7 +349,7 @@ var ProcessList = forwardRef(
273
349
  name,
274
350
  " "
275
351
  ] }),
276
- /* @__PURE__ */ jsx(Text, { color: isSelected ? "black" : statusColor, children: statusIcon })
352
+ /* @__PURE__ */ jsx(Text, { color: statusColor, children: statusIcon })
277
353
  ] }, name);
278
354
  })
279
355
  }
@@ -328,6 +404,13 @@ var OutputPanel = forwardRef2(
328
404
  const [scrollOffset, setScrollOffset] = useState3(0);
329
405
  const [contentHeight, setContentHeight] = useState3(0);
330
406
  const [viewportHeight, setViewportHeight] = useState3(0);
407
+ const [prevName, setPrevName] = useState3(name);
408
+ if (name !== prevName) {
409
+ setPrevName(name);
410
+ setScrollOffset(0);
411
+ setContentHeight(0);
412
+ setViewportHeight(0);
413
+ }
331
414
  useEffect4(() => {
332
415
  const handleResize = () => scrollRef.current?.remeasure();
333
416
  stdout?.on("resize", handleResize);
@@ -391,7 +474,8 @@ var OutputPanel = forwardRef2(
391
474
  onContentHeightChange: handleContentHeightChange,
392
475
  onViewportSizeChange: (layout) => setViewportHeight(layout.height),
393
476
  children: lines.map((line, i) => /* @__PURE__ */ jsx3(Text3, { wrap: "truncate", children: line }, i))
394
- }
477
+ },
478
+ name
395
479
  ) }),
396
480
  hasScroll && /* @__PURE__ */ jsx3(
397
481
  Scrollbar,
@@ -416,9 +500,8 @@ function StatusBar({ focusMode, processName, showShiftTabHint = true }) {
416
500
  const shiftTabHint = showShiftTabHint ? "Shift-Tab/" : "";
417
501
  return /* @__PURE__ */ jsx4(Box4, { backgroundColor: "green", width: "100%", children: /* @__PURE__ */ jsxs3(Text4, { bold: true, color: "black", backgroundColor: "green", children: [
418
502
  " ",
419
- "FOCUS: ",
420
503
  processName,
421
- " - Type to interact, [",
504
+ " | [",
422
505
  shiftTabHint,
423
506
  "Esc] to exit focus mode",
424
507
  " "
@@ -496,6 +579,8 @@ function App({ config }) {
496
579
  const outputRef = useRef4(null);
497
580
  const processListRef = useRef4(null);
498
581
  const [autoScroll, setAutoScroll] = useState4({});
582
+ const scrollPositionsRef = useRef4({});
583
+ const pendingRestoreRef = useRef4(null);
499
584
  const {
500
585
  names,
501
586
  getOutput,
@@ -538,6 +623,42 @@ function App({ config }) {
538
623
  const selectedName = names[selected] ?? "";
539
624
  const output = selectedName ? getOutput(selectedName) : "";
540
625
  const currentAutoScroll = selectedName ? autoScroll[selectedName] ?? true : true;
626
+ const saveCurrentScrollPosition = useCallback5(() => {
627
+ if (selectedName && outputRef.current) {
628
+ scrollPositionsRef.current[selectedName] = outputRef.current.getScrollOffset();
629
+ }
630
+ }, [selectedName]);
631
+ const handleSetSelected = useCallback5((newSelected) => {
632
+ saveCurrentScrollPosition();
633
+ setSelected((prev) => {
634
+ const next = typeof newSelected === "function" ? newSelected(prev) : newSelected;
635
+ if (next !== prev) {
636
+ pendingRestoreRef.current = names[next] ?? null;
637
+ }
638
+ return next;
639
+ });
640
+ }, [saveCurrentScrollPosition, names]);
641
+ useEffect5(() => {
642
+ const restoreName = pendingRestoreRef.current;
643
+ if (restoreName && restoreName === selectedName) {
644
+ pendingRestoreRef.current = null;
645
+ const savedOffset = scrollPositionsRef.current[restoreName];
646
+ if (savedOffset !== void 0 && savedOffset > 0 && !autoScroll[restoreName]) {
647
+ setImmediate(() => {
648
+ if (outputRef.current) {
649
+ const contentHeight = outputRef.current.getContentHeight();
650
+ const viewportHeight = outputRef.current.getViewportHeight();
651
+ if (contentHeight > viewportHeight) {
652
+ const currentOffset = outputRef.current.getScrollOffset();
653
+ if (currentOffset !== savedOffset) {
654
+ outputRef.current.scrollBy(savedOffset - currentOffset);
655
+ }
656
+ }
657
+ }
658
+ });
659
+ }
660
+ }
661
+ }, [selectedName, autoScroll]);
541
662
  const handleAutoScrollChange = useCallback5((enabled) => {
542
663
  if (selectedName) {
543
664
  setAutoScroll((prev) => ({ ...prev, [selectedName]: enabled }));
@@ -552,17 +673,33 @@ function App({ config }) {
552
673
  }
553
674
  }
554
675
  }, [selectedName]);
676
+ const handleClick = useCallback5((event) => {
677
+ if (event.x <= PROCESS_LIST_WIDTH) {
678
+ if (focusMode) {
679
+ exitFocus();
680
+ }
681
+ const clickedIndex = event.y - 2;
682
+ if (clickedIndex >= 0 && clickedIndex < names.length) {
683
+ handleSetSelected(clickedIndex);
684
+ }
685
+ } else {
686
+ if (!focusMode) {
687
+ enterFocus();
688
+ }
689
+ }
690
+ }, [names.length, focusMode, enterFocus, exitFocus, handleSetSelected]);
555
691
  useMouseWheel({
556
692
  enabled: !showHelp,
557
693
  // Disable when help is shown
558
- onWheel: handleWheel
694
+ onWheel: handleWheel,
695
+ onClick: handleClick
559
696
  });
560
697
  useInput((input, key) => {
561
698
  if (showHelp) {
562
699
  setShowHelp(false);
563
700
  return;
564
701
  }
565
- if (input === "q" || key.ctrl && input === "c") {
702
+ if (key.ctrl && input === "c") {
566
703
  killAll();
567
704
  setRawMode(false);
568
705
  const rows = stdout?.rows ?? 999;
@@ -571,10 +708,6 @@ function App({ config }) {
571
708
  exit();
572
709
  process.exit(0);
573
710
  }
574
- if (input === "?") {
575
- setShowHelp(true);
576
- return;
577
- }
578
711
  if (focusMode) {
579
712
  const name = names[selected];
580
713
  if (!name) return;
@@ -607,16 +740,32 @@ function App({ config }) {
607
740
  return;
608
741
  }
609
742
  if (input && !key.ctrl && !key.meta) {
610
- write(name, input);
743
+ const filtered = input.replace(/\x1b?\[<\d+;\d+;\d+[Mm]/g, "");
744
+ if (filtered) {
745
+ write(name, filtered);
746
+ }
611
747
  }
612
748
  return;
613
749
  }
750
+ if (input === "q") {
751
+ killAll();
752
+ setRawMode(false);
753
+ const rows = stdout?.rows ?? 999;
754
+ stdout?.write(`\x1B[${rows};1H\x1B[J\x1B[?1000l\x1B[?1006l\x1B[?25h\x1B[0m
755
+ `);
756
+ exit();
757
+ process.exit(0);
758
+ }
759
+ if (input === "?") {
760
+ setShowHelp(true);
761
+ return;
762
+ }
614
763
  if (key.upArrow || input === "k") {
615
- setSelected((s) => Math.max(s - 1, 0));
764
+ handleSetSelected((s) => Math.max(s - 1, 0));
616
765
  return;
617
766
  }
618
767
  if (key.downArrow || input === "j") {
619
- setSelected((s) => Math.min(s + 1, names.length - 1));
768
+ handleSetSelected((s) => Math.min(s + 1, names.length - 1));
620
769
  return;
621
770
  }
622
771
  if (key.return || key.tab) {