panex 0.9.3 → 0.9.5

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/cli.js CHANGED
@@ -4,17 +4,96 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/tui.ts
7
- import blessed from "blessed";
7
+ import { render } from "ink";
8
+ import { createElement } from "react";
9
+
10
+ // src/components/App.tsx
11
+ import { useState as useState4, useEffect as useEffect5, useRef as useRef4, useCallback as useCallback5 } from "react";
12
+ import { Box as Box6, useApp, useInput, useStdin as useStdin2, useStdout as useStdout4 } from "ink";
13
+
14
+ // src/hooks/useProcessManager.ts
15
+ import { useState, useEffect, useCallback, useRef } from "react";
8
16
 
9
17
  // src/process-manager.ts
10
18
  import { EventEmitter } from "events";
19
+
20
+ // src/terminal-buffer.ts
21
+ import { Terminal } from "@xterm/headless";
22
+ var TerminalBuffer = class {
23
+ terminal;
24
+ rows;
25
+ cols;
26
+ constructor(cols = 200, rows = 500) {
27
+ this.rows = rows;
28
+ this.cols = cols;
29
+ this.terminal = new Terminal({
30
+ cols,
31
+ rows,
32
+ scrollback: 1e4,
33
+ allowProposedApi: true
34
+ });
35
+ }
36
+ /**
37
+ * Write data to the terminal buffer.
38
+ * The terminal will interpret all ANSI escape sequences.
39
+ */
40
+ write(data) {
41
+ this.terminal.write(data);
42
+ }
43
+ /**
44
+ * Get the current terminal buffer content as an array of lines.
45
+ * Only returns lines that have content (not all 500 rows).
46
+ */
47
+ getLines() {
48
+ const buffer = this.terminal.buffer.active;
49
+ const lines = [];
50
+ const contentLength = buffer.baseY + buffer.cursorY + 1;
51
+ for (let i = 0; i < contentLength; i++) {
52
+ const line = buffer.getLine(i);
53
+ if (line) {
54
+ lines.push(line.translateToString(true));
55
+ }
56
+ }
57
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
58
+ lines.pop();
59
+ }
60
+ return lines;
61
+ }
62
+ /**
63
+ * Get the terminal content as a single string with newlines.
64
+ */
65
+ toString() {
66
+ return this.getLines().join("\n");
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
11
91
  var ProcessManager = class extends EventEmitter {
12
92
  constructor(procs) {
13
93
  super();
14
94
  this.procs = procs;
15
95
  }
16
96
  processes = /* @__PURE__ */ new Map();
17
- maxOutputLines = 1e4;
18
97
  async startAll() {
19
98
  for (const [name, config] of Object.entries(this.procs)) {
20
99
  await this.start(name, config);
@@ -34,7 +113,7 @@ var ProcessManager = class extends EventEmitter {
34
113
  config,
35
114
  pty: null,
36
115
  status: "running",
37
- output: [],
116
+ terminalBuffer: new TerminalBuffer(120, 30),
38
117
  exitCode: null
39
118
  };
40
119
  this.processes.set(name, managed);
@@ -47,10 +126,7 @@ var ProcessManager = class extends EventEmitter {
47
126
  rows: 30,
48
127
  data: (_terminal, data) => {
49
128
  const str = new TextDecoder().decode(data);
50
- managed.output.push(str);
51
- if (managed.output.length > this.maxOutputLines) {
52
- managed.output = managed.output.slice(-this.maxOutputLines);
53
- }
129
+ managed.terminalBuffer.write(str);
54
130
  this.emit("output", name, str);
55
131
  }
56
132
  }
@@ -72,7 +148,7 @@ var ProcessManager = class extends EventEmitter {
72
148
  this.emit("started", name);
73
149
  } catch (error) {
74
150
  managed.status = "error";
75
- managed.output = [`Error starting process: ${error}`];
151
+ managed.terminalBuffer.write(`Error starting process: ${error}`);
76
152
  managed.exitCode = -1;
77
153
  this.emit("error", name, error);
78
154
  }
@@ -83,7 +159,7 @@ var ProcessManager = class extends EventEmitter {
83
159
  if (proc.pty) {
84
160
  proc.pty.kill();
85
161
  }
86
- proc.output = [];
162
+ proc.terminalBuffer.clear();
87
163
  this.start(name, proc.config);
88
164
  }
89
165
  }
@@ -127,299 +203,614 @@ var ProcessManager = class extends EventEmitter {
127
203
  return Array.from(this.processes.keys());
128
204
  }
129
205
  getOutput(name) {
130
- return this.processes.get(name)?.output.join("") ?? "";
206
+ return this.processes.get(name)?.terminalBuffer.toString() ?? "";
131
207
  }
132
208
  };
133
209
 
134
- // src/tui.ts
135
- async function createTUI(config) {
136
- const processManager = new ProcessManager(config.procs);
137
- const screen = blessed.screen({
138
- smartCSR: true,
139
- title: "panex",
140
- fullUnicode: true
141
- });
142
- const processList = blessed.list({
143
- parent: screen,
144
- label: " PROCESSES ",
145
- top: 0,
146
- left: 0,
147
- width: "20%",
148
- height: "100%-1",
149
- border: { type: "line" },
150
- style: {
151
- border: { fg: "blue" },
152
- selected: { bg: "blue", fg: "white" },
153
- item: { fg: "white" }
154
- },
155
- mouse: config.settings?.mouse ?? true,
156
- scrollbar: {
157
- ch: "\u2502",
158
- style: { bg: "blue" }
159
- }
160
- });
161
- const outputBox = blessed.box({
162
- parent: screen,
163
- label: " OUTPUT ",
164
- top: 0,
165
- left: "20%",
166
- width: "80%",
167
- height: "100%-1",
168
- border: { type: "line" },
169
- style: {
170
- border: { fg: "green" }
171
- },
172
- scrollable: true,
173
- alwaysScroll: true,
174
- scrollbar: {
175
- ch: "\u2502",
176
- style: { bg: "green" }
177
- },
178
- mouse: config.settings?.mouse ?? true,
179
- keys: true,
180
- vi: true
181
- });
182
- const statusBar = blessed.box({
183
- parent: screen,
184
- bottom: 0,
185
- left: 0,
186
- width: "100%",
187
- height: 1,
188
- style: {
189
- bg: "blue",
190
- fg: "white"
191
- },
192
- content: " [\u2191\u2193/jk] select [Enter] focus [r] restart [A] restart All [x] kill [q] quit [?] help "
193
- });
194
- const helpBox = blessed.box({
195
- parent: screen,
196
- top: "center",
197
- left: "center",
198
- width: "60%",
199
- height: "60%",
200
- label: " Help ",
201
- border: { type: "line" },
202
- style: {
203
- border: { fg: "yellow" },
204
- bg: "black"
205
- },
206
- hidden: true,
207
- content: `
208
- Keyboard Shortcuts
209
- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
210
+ // src/hooks/useProcessManager.ts
211
+ function useProcessManager(config) {
212
+ const [, forceUpdate] = useState({});
213
+ const processManagerRef = useRef(null);
214
+ if (!processManagerRef.current) {
215
+ processManagerRef.current = new ProcessManager(config.procs);
216
+ }
217
+ const pm = processManagerRef.current;
218
+ useEffect(() => {
219
+ const update = () => forceUpdate({});
220
+ pm.on("output", update);
221
+ pm.on("started", update);
222
+ pm.on("exit", update);
223
+ pm.on("error", update);
224
+ pm.startAll();
225
+ return () => {
226
+ pm.removeAllListeners();
227
+ pm.killAll();
228
+ };
229
+ }, [pm]);
230
+ const getOutput = useCallback((name) => pm.getOutput(name), [pm]);
231
+ const getStatus = useCallback((name) => {
232
+ const proc = pm.getProcess(name);
233
+ return proc?.status ?? "stopped";
234
+ }, [pm]);
235
+ const restart = useCallback((name) => pm.restart(name), [pm]);
236
+ const restartAll = useCallback(() => pm.restartAll(), [pm]);
237
+ const kill = useCallback((name) => pm.kill(name), [pm]);
238
+ const killAll = useCallback(() => pm.killAll(), [pm]);
239
+ const write = useCallback((name, data) => pm.write(name, data), [pm]);
240
+ const resize = useCallback((name, cols, rows) => pm.resize(name, cols, rows), [pm]);
241
+ return {
242
+ processManager: pm,
243
+ processes: new Map(pm.getNames().map((n) => [n, pm.getProcess(n)])),
244
+ names: pm.getNames(),
245
+ getOutput,
246
+ getStatus,
247
+ restart,
248
+ restartAll,
249
+ kill,
250
+ killAll,
251
+ write,
252
+ resize
253
+ };
254
+ }
210
255
 
211
- Navigation
212
- \u2191/\u2193 or j/k Navigate process list
213
- g/G Scroll to top/bottom of output
214
- PgUp/PgDn Scroll output
256
+ // src/hooks/useFocusMode.ts
257
+ import { useState as useState2, useCallback as useCallback2 } from "react";
258
+ function useFocusMode() {
259
+ const [focusMode, setFocusMode] = useState2(false);
260
+ const enterFocus = useCallback2(() => setFocusMode(true), []);
261
+ const exitFocus = useCallback2(() => setFocusMode(false), []);
262
+ const toggleFocus = useCallback2(() => setFocusMode((f) => !f), []);
263
+ return { focusMode, enterFocus, exitFocus, toggleFocus };
264
+ }
215
265
 
216
- Process Control
217
- Enter Focus process (interactive mode)
218
- Esc Exit focus mode
219
- r Restart selected process
220
- A Restart all processes
221
- x Kill selected process
266
+ // src/hooks/useMouseWheel.ts
267
+ import { useEffect as useEffect2, useCallback as useCallback3 } from "react";
268
+ import { useStdin, useStdout } from "ink";
269
+ function useMouseWheel({ enabled = true, onWheel, onClick } = {}) {
270
+ const { stdin, setRawMode } = useStdin();
271
+ const { stdout } = useStdout();
272
+ const handleData = useCallback3((data) => {
273
+ const str = data.toString();
274
+ const sgrRegex = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
275
+ let match;
276
+ while ((match = sgrRegex.exec(str)) !== null) {
277
+ const button = parseInt(match[1] ?? "0", 10);
278
+ const x = parseInt(match[2] ?? "0", 10);
279
+ const y = parseInt(match[3] ?? "0", 10);
280
+ const isPress = match[4] === "M";
281
+ if (isPress) {
282
+ if (button === 0) {
283
+ onClick?.({ type: "click", x, y });
284
+ } else if (button === 64) {
285
+ onWheel?.({ type: "wheel-up", x, y });
286
+ } else if (button === 65) {
287
+ onWheel?.({ type: "wheel-down", x, y });
288
+ }
289
+ }
290
+ }
291
+ }, [onWheel, onClick]);
292
+ useEffect2(() => {
293
+ if (!enabled || !stdin || !stdout) return;
294
+ stdout.write("\x1B[?1000h\x1B[?1006h");
295
+ setRawMode?.(true);
296
+ stdin.on("data", handleData);
297
+ return () => {
298
+ stdin.off("data", handleData);
299
+ stdout.write("\x1B[?1000l\x1B[?1006l");
300
+ };
301
+ }, [enabled, stdin, stdout, setRawMode, handleData]);
302
+ }
222
303
 
223
- General
224
- ? Toggle this help
225
- q Quit panex
304
+ // src/components/ProcessList.tsx
305
+ import { Box, Text, useStdout as useStdout2 } from "ink";
306
+ import { ScrollList } from "ink-scroll-list";
307
+ import { forwardRef, useImperativeHandle, useRef as useRef2, useEffect as useEffect3 } from "react";
308
+ import { jsx, jsxs } from "react/jsx-runtime";
309
+ var PROCESS_LIST_WIDTH = 20;
310
+ var ProcessList = forwardRef(
311
+ function ProcessList2({ names, selected, getStatus, active, height }, ref) {
312
+ const borderStyle = active ? "double" : "single";
313
+ const listRef = useRef2(null);
314
+ const { stdout } = useStdout2();
315
+ useEffect3(() => {
316
+ const handleResize = () => listRef.current?.remeasure();
317
+ stdout?.on("resize", handleResize);
318
+ return () => {
319
+ stdout?.off("resize", handleResize);
320
+ };
321
+ }, [stdout]);
322
+ useImperativeHandle(ref, () => ({
323
+ scrollBy: (delta) => listRef.current?.scrollBy(delta),
324
+ scrollToTop: () => listRef.current?.scrollToTop(),
325
+ scrollToBottom: () => listRef.current?.scrollToBottom()
326
+ }));
327
+ return /* @__PURE__ */ jsx(
328
+ Box,
329
+ {
330
+ flexDirection: "column",
331
+ borderStyle,
332
+ borderColor: active ? "blue" : "gray",
333
+ width: PROCESS_LIST_WIDTH,
334
+ height,
335
+ paddingX: 1,
336
+ children: /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 0, height: height ? height - 2 : void 0, children: /* @__PURE__ */ jsx(
337
+ ScrollList,
338
+ {
339
+ ref: listRef,
340
+ selectedIndex: selected,
341
+ scrollAlignment: "auto",
342
+ children: names.map((name, i) => {
343
+ const status = getStatus(name);
344
+ const statusIcon = status === "running" ? "\u25CF" : status === "error" ? "\u2717" : "\u25CB";
345
+ const statusColor = status === "running" ? "green" : status === "error" ? "red" : "gray";
346
+ const isSelected = i === selected;
347
+ return /* @__PURE__ */ jsxs(Box, { backgroundColor: isSelected ? "blue" : void 0, children: [
348
+ /* @__PURE__ */ jsxs(Text, { color: isSelected ? "black" : void 0, children: [
349
+ name,
350
+ " "
351
+ ] }),
352
+ /* @__PURE__ */ jsx(Text, { color: statusColor, children: statusIcon })
353
+ ] }, name);
354
+ })
355
+ }
356
+ ) })
357
+ }
358
+ );
359
+ }
360
+ );
226
361
 
227
- Press any key to close this help...
228
- `
229
- });
230
- let selectedIndex = 0;
231
- let focusMode = false;
232
- const processNames = Object.keys(config.procs);
233
- const scrollPositions = /* @__PURE__ */ new Map();
234
- function updateProcessList() {
235
- const items = processNames.map((name, i) => {
236
- const proc = processManager.getProcess(name);
237
- const status = proc?.status === "running" ? "\u25CF" : proc?.status === "error" ? "\u2717" : "\u25CB";
238
- const prefix = i === selectedIndex ? "\u25B6" : " ";
239
- return `${prefix} ${name} ${status}`;
240
- });
241
- processList.setItems(items);
242
- processList.select(selectedIndex);
243
- screen.render();
362
+ // src/components/OutputPanel.tsx
363
+ import { Box as Box3, Text as Text3, useStdout as useStdout3 } from "ink";
364
+ import { ScrollView } from "ink-scroll-view";
365
+ import { forwardRef as forwardRef2, useImperativeHandle as useImperativeHandle2, useRef as useRef3, useEffect as useEffect4, useState as useState3, useCallback as useCallback4 } from "react";
366
+
367
+ // src/components/Scrollbar.tsx
368
+ import { Box as Box2, Text as Text2 } from "ink";
369
+ import { jsx as jsx2 } from "react/jsx-runtime";
370
+ function Scrollbar({
371
+ scrollOffset,
372
+ contentHeight,
373
+ viewportHeight,
374
+ height
375
+ }) {
376
+ if (contentHeight <= viewportHeight) {
377
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: height }).map((_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " " }, i)) });
244
378
  }
245
- function updateOutput(autoScroll = false) {
246
- const name = processNames[selectedIndex];
247
- if (name) {
248
- outputBox.setLabel(` OUTPUT: ${name} `);
249
- const output = processManager.getOutput(name);
250
- outputBox.setContent(output);
251
- if (autoScroll) {
252
- outputBox.setScrollPerc(100);
253
- } else {
254
- const savedPos = scrollPositions.get(name) ?? 100;
255
- outputBox.setScrollPerc(savedPos);
256
- }
379
+ const trackHeight = height;
380
+ const thumbRatio = viewportHeight / contentHeight;
381
+ const thumbHeight = Math.max(1, Math.round(trackHeight * thumbRatio));
382
+ const maxScroll = contentHeight - viewportHeight;
383
+ const scrollRatio = maxScroll > 0 ? scrollOffset / maxScroll : 0;
384
+ const thumbPosition = Math.round((trackHeight - thumbHeight) * scrollRatio);
385
+ const lines = [];
386
+ for (let i = 0; i < trackHeight; i++) {
387
+ if (i >= thumbPosition && i < thumbPosition + thumbHeight) {
388
+ lines.push("\u2588");
389
+ } else {
390
+ lines.push("\u2591");
257
391
  }
258
- screen.render();
259
392
  }
260
- function saveScrollPosition() {
261
- const name = processNames[selectedIndex];
262
- if (name) {
263
- scrollPositions.set(name, outputBox.getScrollPerc());
264
- }
393
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: lines.map((char, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: char === "\u2591", children: char }, i)) });
394
+ }
395
+
396
+ // src/components/OutputPanel.tsx
397
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
398
+ var OutputPanel = forwardRef2(
399
+ function OutputPanel2({ name, output, active, height, autoScroll = true, onAutoScrollChange }, ref) {
400
+ const borderStyle = active ? "double" : "single";
401
+ const lines = output.split("\n");
402
+ const scrollRef = useRef3(null);
403
+ const { stdout } = useStdout3();
404
+ const [scrollOffset, setScrollOffset] = useState3(0);
405
+ const [contentHeight, setContentHeight] = useState3(0);
406
+ const [viewportHeight, setViewportHeight] = useState3(0);
407
+ useEffect4(() => {
408
+ const handleResize = () => scrollRef.current?.remeasure();
409
+ stdout?.on("resize", handleResize);
410
+ return () => {
411
+ stdout?.off("resize", handleResize);
412
+ };
413
+ }, [stdout]);
414
+ const isAtBottom = useCallback4(() => {
415
+ if (!scrollRef.current) return true;
416
+ const offset = scrollRef.current.getScrollOffset();
417
+ const bottom = scrollRef.current.getBottomOffset();
418
+ return offset >= bottom - 1;
419
+ }, []);
420
+ const handleContentHeightChange = useCallback4((newHeight) => {
421
+ setContentHeight(newHeight);
422
+ if (autoScroll && scrollRef.current) {
423
+ setTimeout(() => {
424
+ scrollRef.current?.scrollToBottom();
425
+ }, 0);
426
+ }
427
+ }, [autoScroll]);
428
+ const handleScroll = useCallback4((offset) => {
429
+ setScrollOffset(offset);
430
+ if (scrollRef.current) {
431
+ const bottom = scrollRef.current.getBottomOffset();
432
+ const atBottom = offset >= bottom - 1;
433
+ if (!atBottom && autoScroll) {
434
+ onAutoScrollChange?.(false);
435
+ } else if (atBottom && !autoScroll) {
436
+ onAutoScrollChange?.(true);
437
+ }
438
+ }
439
+ }, [autoScroll, onAutoScrollChange]);
440
+ useImperativeHandle2(ref, () => ({
441
+ scrollBy: (delta) => scrollRef.current?.scrollBy(delta),
442
+ scrollToTop: () => scrollRef.current?.scrollToTop(),
443
+ scrollToBottom: () => scrollRef.current?.scrollToBottom(),
444
+ getScrollOffset: () => scrollRef.current?.getScrollOffset() ?? 0,
445
+ getContentHeight: () => scrollRef.current?.getContentHeight() ?? 0,
446
+ getViewportHeight: () => scrollRef.current?.getViewportHeight() ?? 0,
447
+ isAtBottom
448
+ }));
449
+ const scrollbarHeight = height ? height - 4 : 20;
450
+ const hasScroll = contentHeight > viewportHeight;
451
+ const pinIndicator = !autoScroll && hasScroll ? " \u2357" : "";
452
+ return /* @__PURE__ */ jsx3(
453
+ Box3,
454
+ {
455
+ flexDirection: "column",
456
+ borderStyle,
457
+ borderColor: active ? "green" : "gray",
458
+ flexGrow: 1,
459
+ height,
460
+ paddingLeft: 1,
461
+ children: /* @__PURE__ */ jsxs2(Box3, { flexDirection: "row", marginTop: 0, height: height ? height - 2 : void 0, children: [
462
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", flexGrow: 1, children: /* @__PURE__ */ jsx3(
463
+ ScrollView,
464
+ {
465
+ ref: scrollRef,
466
+ onScroll: handleScroll,
467
+ onContentHeightChange: handleContentHeightChange,
468
+ onViewportSizeChange: (layout) => setViewportHeight(layout.height),
469
+ children: lines.map((line, i) => /* @__PURE__ */ jsx3(Text3, { wrap: "truncate", children: line }, i))
470
+ }
471
+ ) }),
472
+ hasScroll && /* @__PURE__ */ jsx3(
473
+ Scrollbar,
474
+ {
475
+ scrollOffset,
476
+ contentHeight,
477
+ viewportHeight,
478
+ height: scrollbarHeight
479
+ }
480
+ )
481
+ ] })
482
+ }
483
+ );
484
+ }
485
+ );
486
+
487
+ // src/components/StatusBar.tsx
488
+ import { Box as Box4, Text as Text4 } from "ink";
489
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
490
+ function StatusBar({ focusMode, processName, showShiftTabHint = true }) {
491
+ if (focusMode && processName) {
492
+ const shiftTabHint = showShiftTabHint ? "Shift-Tab/" : "";
493
+ return /* @__PURE__ */ jsx4(Box4, { backgroundColor: "green", width: "100%", children: /* @__PURE__ */ jsxs3(Text4, { bold: true, color: "black", backgroundColor: "green", children: [
494
+ " ",
495
+ processName,
496
+ " | [",
497
+ shiftTabHint,
498
+ "Esc] to exit focus mode",
499
+ " "
500
+ ] }) });
265
501
  }
266
- processManager.on("output", (name) => {
267
- if (name === processNames[selectedIndex]) {
268
- updateOutput(true);
502
+ return /* @__PURE__ */ jsx4(Box4, { backgroundColor: "blue", width: "100%", children: /* @__PURE__ */ jsxs3(Text4, { bold: true, color: "black", backgroundColor: "blue", children: [
503
+ " ",
504
+ "[\u2191\u2193/jk] select [Tab/Enter] focus [r] restart [A] restart All [x] kill [q] quit [?] help",
505
+ " "
506
+ ] }) });
507
+ }
508
+
509
+ // src/components/HelpPopup.tsx
510
+ import { Box as Box5, Text as Text5 } from "ink";
511
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
512
+ function HelpPopup({ visible }) {
513
+ if (!visible) return null;
514
+ return /* @__PURE__ */ jsxs4(
515
+ Box5,
516
+ {
517
+ flexDirection: "column",
518
+ borderStyle: "single",
519
+ borderColor: "yellow",
520
+ padding: 1,
521
+ position: "absolute",
522
+ marginLeft: 10,
523
+ marginTop: 5,
524
+ children: [
525
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "yellow", children: " Help " }),
526
+ /* @__PURE__ */ jsxs4(Text5, { children: [
527
+ "\n",
528
+ "Keyboard Shortcuts"
529
+ ] }),
530
+ /* @__PURE__ */ jsx5(Text5, { children: "\u2500".repeat(18) }),
531
+ /* @__PURE__ */ jsxs4(Text5, { children: [
532
+ "\n",
533
+ "Navigation"
534
+ ] }),
535
+ /* @__PURE__ */ jsx5(Text5, { children: " \u2191/\u2193 or j/k Navigate process list" }),
536
+ /* @__PURE__ */ jsx5(Text5, { children: " g/G Scroll to top/bottom of output" }),
537
+ /* @__PURE__ */ jsx5(Text5, { children: " PgUp/PgDn Scroll output" }),
538
+ /* @__PURE__ */ jsxs4(Text5, { children: [
539
+ "\n",
540
+ "Process Control"
541
+ ] }),
542
+ /* @__PURE__ */ jsx5(Text5, { children: " Tab/Enter Focus process (interactive mode)" }),
543
+ /* @__PURE__ */ jsx5(Text5, { children: " Esc Exit focus mode" }),
544
+ /* @__PURE__ */ jsx5(Text5, { children: " r Restart selected process" }),
545
+ /* @__PURE__ */ jsx5(Text5, { children: " A Restart all processes" }),
546
+ /* @__PURE__ */ jsx5(Text5, { children: " x Kill selected process" }),
547
+ /* @__PURE__ */ jsxs4(Text5, { children: [
548
+ "\n",
549
+ "General"
550
+ ] }),
551
+ /* @__PURE__ */ jsx5(Text5, { children: " ? Toggle this help" }),
552
+ /* @__PURE__ */ jsx5(Text5, { children: " q Quit panex" }),
553
+ /* @__PURE__ */ jsxs4(Text5, { children: [
554
+ "\n",
555
+ "Press any key to close this help..."
556
+ ] })
557
+ ]
269
558
  }
270
- });
271
- processManager.on("started", () => {
272
- updateProcessList();
273
- });
274
- processManager.on("exit", () => {
275
- updateProcessList();
276
- });
277
- processManager.on("error", (name) => {
278
- updateProcessList();
279
- if (name === processNames[selectedIndex]) {
280
- updateOutput();
559
+ );
560
+ }
561
+
562
+ // src/components/App.tsx
563
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
564
+ function App({ config }) {
565
+ const { exit } = useApp();
566
+ const { stdout } = useStdout4();
567
+ const { setRawMode } = useStdin2();
568
+ const [selected, setSelected] = useState4(0);
569
+ const [showHelp, setShowHelp] = useState4(false);
570
+ const { focusMode, enterFocus, exitFocus } = useFocusMode();
571
+ const outputRef = useRef4(null);
572
+ const processListRef = useRef4(null);
573
+ const [autoScroll, setAutoScroll] = useState4({});
574
+ const {
575
+ names,
576
+ getOutput,
577
+ getStatus,
578
+ restart,
579
+ restartAll,
580
+ kill,
581
+ killAll,
582
+ write,
583
+ resize
584
+ } = useProcessManager(config);
585
+ const isShiftTabDisabled = (name) => {
586
+ const setting = config.settings?.noShiftTab;
587
+ if (setting === true) return true;
588
+ if (Array.isArray(setting)) return setting.includes(name);
589
+ return false;
590
+ };
591
+ const maxPanelHeight = stdout ? stdout.rows - 1 : void 0;
592
+ useEffect5(() => {
593
+ const name = names[selected];
594
+ if (name && stdout) {
595
+ const cols = Math.floor(stdout.columns * 0.8) - 2;
596
+ const rows = stdout.rows - 3;
597
+ resize(name, cols, rows);
281
598
  }
599
+ }, [stdout?.columns, stdout?.rows, selected, names, resize]);
600
+ useEffect5(() => {
601
+ setAutoScroll((prev) => {
602
+ const next = { ...prev };
603
+ let changed = false;
604
+ for (const name of names) {
605
+ if (next[name] === void 0) {
606
+ next[name] = true;
607
+ changed = true;
608
+ }
609
+ }
610
+ return changed ? next : prev;
611
+ });
612
+ }, [names]);
613
+ const selectedName = names[selected] ?? "";
614
+ const output = selectedName ? getOutput(selectedName) : "";
615
+ const currentAutoScroll = selectedName ? autoScroll[selectedName] ?? true : true;
616
+ const handleAutoScrollChange = useCallback5((enabled) => {
617
+ if (selectedName) {
618
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: enabled }));
619
+ }
620
+ }, [selectedName]);
621
+ const handleWheel = useCallback5((event) => {
622
+ const delta = event.type === "wheel-up" ? -3 : 3;
623
+ if (outputRef.current) {
624
+ outputRef.current.scrollBy(delta);
625
+ if (event.type === "wheel-up" && selectedName) {
626
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: false }));
627
+ }
628
+ }
629
+ }, [selectedName]);
630
+ const handleClick = useCallback5((event) => {
631
+ if (event.x <= PROCESS_LIST_WIDTH) {
632
+ if (focusMode) {
633
+ exitFocus();
634
+ }
635
+ const clickedIndex = event.y - 2;
636
+ if (clickedIndex >= 0 && clickedIndex < names.length) {
637
+ setSelected(clickedIndex);
638
+ }
639
+ } else {
640
+ if (!focusMode) {
641
+ enterFocus();
642
+ }
643
+ }
644
+ }, [names.length, focusMode, enterFocus, exitFocus]);
645
+ useMouseWheel({
646
+ enabled: !showHelp,
647
+ // Disable when help is shown
648
+ onWheel: handleWheel,
649
+ onClick: handleClick
282
650
  });
283
- screen.key(["q", "C-c"], () => {
284
- processManager.killAll();
285
- process.exit(0);
286
- });
287
- screen.key(["?"], () => {
288
- helpBox.toggle();
289
- screen.render();
290
- });
291
- screen.key(["escape"], () => {
292
- if (!helpBox.hidden) {
293
- helpBox.hide();
294
- screen.render();
651
+ useInput((input, key) => {
652
+ if (showHelp) {
653
+ setShowHelp(false);
295
654
  return;
296
655
  }
656
+ if (key.ctrl && input === "c") {
657
+ killAll();
658
+ setRawMode(false);
659
+ const rows = stdout?.rows ?? 999;
660
+ stdout?.write(`\x1B[${rows};1H\x1B[J\x1B[?1000l\x1B[?1006l\x1B[?25h\x1B[0m
661
+ `);
662
+ exit();
663
+ process.exit(0);
664
+ }
297
665
  if (focusMode) {
298
- focusMode = false;
299
- statusBar.setContent(" [\u2191\u2193/jk] select [Enter] focus [r] restart [A] restart All [x] kill [q] quit [?] help ");
300
- screen.render();
666
+ const name = names[selected];
667
+ if (!name) return;
668
+ if (key.escape) {
669
+ exitFocus();
670
+ return;
671
+ }
672
+ if (key.shift && key.tab && !isShiftTabDisabled(name)) {
673
+ exitFocus();
674
+ return;
675
+ }
676
+ if (key.return) {
677
+ write(name, "\r");
678
+ return;
679
+ }
680
+ if (key.upArrow) {
681
+ write(name, "\x1B[A");
682
+ return;
683
+ }
684
+ if (key.downArrow) {
685
+ write(name, "\x1B[B");
686
+ return;
687
+ }
688
+ if (key.leftArrow) {
689
+ write(name, "\x1B[D");
690
+ return;
691
+ }
692
+ if (key.rightArrow) {
693
+ write(name, "\x1B[C");
694
+ return;
695
+ }
696
+ if (input && !key.ctrl && !key.meta) {
697
+ const filtered = input.replace(/\x1b?\[<\d+;\d+;\d+[Mm]/g, "");
698
+ if (filtered) {
699
+ write(name, filtered);
700
+ }
701
+ }
702
+ return;
301
703
  }
302
- });
303
- helpBox.key(["escape", "q", "?", "enter", "space"], () => {
304
- helpBox.hide();
305
- screen.render();
306
- });
307
- screen.key(["up", "k"], () => {
308
- if (focusMode || !helpBox.hidden) return;
309
- if (selectedIndex > 0) {
310
- saveScrollPosition();
311
- selectedIndex--;
312
- updateProcessList();
313
- updateOutput();
704
+ if (input === "q") {
705
+ killAll();
706
+ setRawMode(false);
707
+ const rows = stdout?.rows ?? 999;
708
+ stdout?.write(`\x1B[${rows};1H\x1B[J\x1B[?1000l\x1B[?1006l\x1B[?25h\x1B[0m
709
+ `);
710
+ exit();
711
+ process.exit(0);
314
712
  }
315
- });
316
- screen.key(["down", "j"], () => {
317
- if (focusMode || !helpBox.hidden) return;
318
- if (selectedIndex < processNames.length - 1) {
319
- saveScrollPosition();
320
- selectedIndex++;
321
- updateProcessList();
322
- updateOutput();
713
+ if (input === "?") {
714
+ setShowHelp(true);
715
+ return;
323
716
  }
324
- });
325
- screen.key(["enter"], () => {
326
- if (!helpBox.hidden) {
327
- helpBox.hide();
328
- screen.render();
717
+ if (key.upArrow || input === "k") {
718
+ setSelected((s) => Math.max(s - 1, 0));
329
719
  return;
330
720
  }
331
- if (!focusMode) {
332
- focusMode = true;
333
- const name = processNames[selectedIndex];
334
- statusBar.setContent(` FOCUS: ${name} - Type to interact, [Esc] to exit focus mode `);
335
- screen.render();
336
- } else {
337
- const name = processNames[selectedIndex];
338
- if (name) {
339
- processManager.write(name, "\r");
340
- }
721
+ if (key.downArrow || input === "j") {
722
+ setSelected((s) => Math.min(s + 1, names.length - 1));
723
+ return;
341
724
  }
342
- });
343
- screen.key(["r"], () => {
344
- if (focusMode || !helpBox.hidden) return;
345
- const name = processNames[selectedIndex];
346
- if (name) {
347
- processManager.restart(name);
725
+ if (key.return || key.tab) {
726
+ enterFocus();
727
+ return;
348
728
  }
349
- });
350
- screen.key(["S-a"], () => {
351
- if (focusMode || !helpBox.hidden) return;
352
- processManager.restartAll();
353
- });
354
- screen.key(["x"], () => {
355
- if (focusMode || !helpBox.hidden) return;
356
- const name = processNames[selectedIndex];
357
- if (name) {
358
- processManager.kill(name);
729
+ if (input === "r") {
730
+ const name = names[selected];
731
+ if (name) restart(name);
732
+ return;
359
733
  }
360
- });
361
- screen.key(["g"], () => {
362
- if (focusMode || !helpBox.hidden) return;
363
- outputBox.setScrollPerc(0);
364
- screen.render();
365
- });
366
- screen.key(["S-g"], () => {
367
- if (focusMode || !helpBox.hidden) return;
368
- outputBox.setScrollPerc(100);
369
- screen.render();
370
- });
371
- outputBox.on("click", () => {
372
- if (!helpBox.hidden) return;
373
- if (!focusMode) {
374
- focusMode = true;
375
- const name = processNames[selectedIndex];
376
- statusBar.setContent(` FOCUS: ${name} - Type to interact, [Esc] to exit focus mode `);
377
- screen.render();
734
+ if (input === "A") {
735
+ restartAll();
736
+ return;
378
737
  }
379
- });
380
- processList.on("element click", (_el, data) => {
381
- if (!helpBox.hidden) return;
382
- const absTop = processList.atop ?? 0;
383
- const clickedIndex = data.y - absTop - 1;
384
- if (clickedIndex < 0 || clickedIndex >= processNames.length) return;
385
- if (focusMode) {
386
- focusMode = false;
387
- statusBar.setContent(" [\u2191\u2193/jk] select [Enter] focus [r] restart [A] restart All [x] kill [q] quit [?] help ");
738
+ if (input === "x") {
739
+ const name = names[selected];
740
+ if (name) kill(name);
741
+ return;
388
742
  }
389
- if (clickedIndex !== selectedIndex) {
390
- saveScrollPosition();
391
- selectedIndex = clickedIndex;
392
- updateProcessList();
393
- updateOutput();
743
+ if (input === "g") {
744
+ outputRef.current?.scrollToTop();
745
+ if (selectedName) {
746
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: false }));
747
+ }
748
+ return;
394
749
  }
395
- screen.render();
396
- });
397
- screen.on("keypress", (ch, key) => {
398
- if (focusMode && ch) {
399
- if (key.name === "escape" || key.name === "return" || key.name === "enter") {
400
- return;
750
+ if (input === "G") {
751
+ outputRef.current?.scrollToBottom();
752
+ if (selectedName) {
753
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: true }));
401
754
  }
402
- const name = processNames[selectedIndex];
403
- if (name) {
404
- processManager.write(name, ch);
755
+ return;
756
+ }
757
+ if (key.pageUp) {
758
+ const pageSize = outputRef.current?.getViewportHeight() ?? 10;
759
+ outputRef.current?.scrollBy(-pageSize);
760
+ if (selectedName) {
761
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: false }));
405
762
  }
763
+ return;
406
764
  }
407
- });
408
- screen.on("resize", () => {
409
- const name = processNames[selectedIndex];
410
- if (name) {
411
- const cols = Math.floor(screen.width * 0.8) - 2;
412
- const rows = screen.height - 3;
413
- processManager.resize(name, cols, rows);
765
+ if (key.pageDown) {
766
+ const pageSize = outputRef.current?.getViewportHeight() ?? 10;
767
+ outputRef.current?.scrollBy(pageSize);
768
+ return;
414
769
  }
415
770
  });
416
- updateProcessList();
417
- updateOutput();
418
- processList.focus();
419
- await processManager.startAll();
420
- updateProcessList();
421
- updateOutput();
422
- screen.render();
771
+ const showShiftTabHint = selectedName ? !isShiftTabDisabled(selectedName) : true;
772
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", height: maxPanelHeight, children: [
773
+ /* @__PURE__ */ jsxs5(Box6, { flexDirection: "row", flexGrow: 1, height: maxPanelHeight ? maxPanelHeight - 1 : void 0, children: [
774
+ /* @__PURE__ */ jsx6(
775
+ ProcessList,
776
+ {
777
+ ref: processListRef,
778
+ names,
779
+ selected,
780
+ getStatus,
781
+ active: !focusMode,
782
+ height: maxPanelHeight ? maxPanelHeight - 1 : void 0
783
+ }
784
+ ),
785
+ /* @__PURE__ */ jsx6(
786
+ OutputPanel,
787
+ {
788
+ ref: outputRef,
789
+ name: selectedName,
790
+ output,
791
+ active: focusMode,
792
+ height: maxPanelHeight ? maxPanelHeight - 1 : void 0,
793
+ autoScroll: currentAutoScroll,
794
+ onAutoScrollChange: handleAutoScrollChange
795
+ }
796
+ )
797
+ ] }),
798
+ /* @__PURE__ */ jsx6(
799
+ StatusBar,
800
+ {
801
+ focusMode,
802
+ processName: selectedName,
803
+ showShiftTabHint
804
+ }
805
+ ),
806
+ /* @__PURE__ */ jsx6(HelpPopup, { visible: showHelp })
807
+ ] });
808
+ }
809
+
810
+ // src/tui.ts
811
+ async function createTUI(config) {
812
+ const { waitUntilExit } = render(createElement(App, { config }));
813
+ await waitUntilExit();
423
814
  }
424
815
 
425
816
  // src/cli.ts
@@ -430,7 +821,7 @@ if (typeof Bun === "undefined") {
430
821
  process.exit(1);
431
822
  }
432
823
  var program = new Command();
433
- program.name("panex").description("Terminal UI for running multiple processes in parallel").version("0.1.0").argument("<commands...>", "Commands to run in parallel").option("-n, --names <names>", "Comma-separated names for each process").action(async (commands, options) => {
824
+ program.name("panex").description("Terminal UI for running multiple processes in parallel").version("0.1.0").argument("<commands...>", "Commands to run in parallel").option("-n, --names <names>", "Comma-separated names for each process").option("--no-shift-tab [names]", "Disable Shift-Tab to exit focus (all or comma-separated process names)").action(async (commands, options) => {
434
825
  const rawNames = options.names?.split(",") ?? commands.map((_, i) => `proc${i + 1}`);
435
826
  const usedNames = /* @__PURE__ */ new Map();
436
827
  const names = rawNames.map((name, i) => {
@@ -439,10 +830,19 @@ program.name("panex").description("Terminal UI for running multiple processes in
439
830
  usedNames.set(baseName, count + 1);
440
831
  return count === 0 ? baseName : `${baseName}-${count + 1}`;
441
832
  });
833
+ let noShiftTab;
834
+ if (options.shiftTab === false) {
835
+ noShiftTab = true;
836
+ } else if (typeof options.shiftTab === "string") {
837
+ noShiftTab = options.shiftTab.split(",");
838
+ }
442
839
  const config = {
443
840
  procs: Object.fromEntries(
444
841
  commands.map((cmd, i) => [names[i], { shell: cmd }])
445
- )
842
+ ),
843
+ settings: {
844
+ noShiftTab
845
+ }
446
846
  };
447
847
  await createTUI(config);
448
848
  });