panex 0.9.2 → 0.9.4

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
@@ -1,10 +1,18 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
 
3
3
  // src/cli.ts
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";
@@ -131,300 +139,591 @@ var ProcessManager = class extends EventEmitter {
131
139
  }
132
140
  };
133
141
 
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
142
+ // src/hooks/useProcessManager.ts
143
+ function useProcessManager(config) {
144
+ const [, forceUpdate] = useState({});
145
+ const processManagerRef = useRef(null);
146
+ if (!processManagerRef.current) {
147
+ processManagerRef.current = new ProcessManager(config.procs);
148
+ }
149
+ const pm = processManagerRef.current;
150
+ useEffect(() => {
151
+ const update = () => forceUpdate({});
152
+ pm.on("output", update);
153
+ pm.on("started", update);
154
+ pm.on("exit", update);
155
+ pm.on("error", update);
156
+ pm.startAll();
157
+ return () => {
158
+ pm.removeAllListeners();
159
+ pm.killAll();
160
+ };
161
+ }, [pm]);
162
+ const getOutput = useCallback((name) => pm.getOutput(name), [pm]);
163
+ const getStatus = useCallback((name) => {
164
+ const proc = pm.getProcess(name);
165
+ return proc?.status ?? "stopped";
166
+ }, [pm]);
167
+ const restart = useCallback((name) => pm.restart(name), [pm]);
168
+ const restartAll = useCallback(() => pm.restartAll(), [pm]);
169
+ const kill = useCallback((name) => pm.kill(name), [pm]);
170
+ const killAll = useCallback(() => pm.killAll(), [pm]);
171
+ const write = useCallback((name, data) => pm.write(name, data), [pm]);
172
+ const resize = useCallback((name, cols, rows) => pm.resize(name, cols, rows), [pm]);
173
+ return {
174
+ processManager: pm,
175
+ processes: new Map(pm.getNames().map((n) => [n, pm.getProcess(n)])),
176
+ names: pm.getNames(),
177
+ getOutput,
178
+ getStatus,
179
+ restart,
180
+ restartAll,
181
+ kill,
182
+ killAll,
183
+ write,
184
+ resize
185
+ };
186
+ }
210
187
 
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
188
+ // src/hooks/useFocusMode.ts
189
+ import { useState as useState2, useCallback as useCallback2 } from "react";
190
+ function useFocusMode() {
191
+ const [focusMode, setFocusMode] = useState2(false);
192
+ const enterFocus = useCallback2(() => setFocusMode(true), []);
193
+ const exitFocus = useCallback2(() => setFocusMode(false), []);
194
+ const toggleFocus = useCallback2(() => setFocusMode((f) => !f), []);
195
+ return { focusMode, enterFocus, exitFocus, toggleFocus };
196
+ }
215
197
 
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
198
+ // src/hooks/useMouseWheel.ts
199
+ import { useEffect as useEffect2, useCallback as useCallback3 } from "react";
200
+ import { useStdin, useStdout } from "ink";
201
+ function useMouseWheel({ enabled = true, onWheel } = {}) {
202
+ const { stdin, setRawMode } = useStdin();
203
+ const { stdout } = useStdout();
204
+ const handleData = useCallback3((data) => {
205
+ const str = data.toString();
206
+ const sgrRegex = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
207
+ let match;
208
+ while ((match = sgrRegex.exec(str)) !== null) {
209
+ const button = parseInt(match[1] ?? "0", 10);
210
+ const x = parseInt(match[2] ?? "0", 10);
211
+ const y = parseInt(match[3] ?? "0", 10);
212
+ const isPress = match[4] === "M";
213
+ if (isPress) {
214
+ if (button === 64) {
215
+ onWheel?.({ type: "wheel-up", x, y });
216
+ } else if (button === 65) {
217
+ onWheel?.({ type: "wheel-down", x, y });
218
+ }
219
+ }
220
+ }
221
+ }, [onWheel]);
222
+ useEffect2(() => {
223
+ if (!enabled || !stdin || !stdout) return;
224
+ stdout.write("\x1B[?1000h\x1B[?1006h");
225
+ setRawMode?.(true);
226
+ stdin.on("data", handleData);
227
+ return () => {
228
+ stdin.off("data", handleData);
229
+ stdout.write("\x1B[?1000l\x1B[?1006l");
230
+ };
231
+ }, [enabled, stdin, stdout, setRawMode, handleData]);
232
+ }
233
+
234
+ // src/components/ProcessList.tsx
235
+ import { Box, Text, useStdout as useStdout2 } from "ink";
236
+ import { ScrollList } from "ink-scroll-list";
237
+ import { forwardRef, useImperativeHandle, useRef as useRef2, useEffect as useEffect3 } from "react";
238
+ import { jsx, jsxs } from "react/jsx-runtime";
239
+ var ProcessList = forwardRef(
240
+ function ProcessList2({ names, selected, getStatus, active, height }, ref) {
241
+ const borderStyle = active ? "double" : "single";
242
+ const listRef = useRef2(null);
243
+ const { stdout } = useStdout2();
244
+ useEffect3(() => {
245
+ const handleResize = () => listRef.current?.remeasure();
246
+ stdout?.on("resize", handleResize);
247
+ return () => {
248
+ stdout?.off("resize", handleResize);
249
+ };
250
+ }, [stdout]);
251
+ useImperativeHandle(ref, () => ({
252
+ scrollBy: (delta) => listRef.current?.scrollBy(delta),
253
+ scrollToTop: () => listRef.current?.scrollToTop(),
254
+ scrollToBottom: () => listRef.current?.scrollToBottom()
255
+ }));
256
+ return /* @__PURE__ */ jsx(
257
+ Box,
258
+ {
259
+ flexDirection: "column",
260
+ borderStyle,
261
+ borderColor: active ? "blue" : "gray",
262
+ width: 20,
263
+ height,
264
+ paddingX: 1,
265
+ children: /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 0, height: height ? height - 2 : void 0, children: /* @__PURE__ */ jsx(
266
+ ScrollList,
267
+ {
268
+ ref: listRef,
269
+ selectedIndex: selected,
270
+ scrollAlignment: "auto",
271
+ children: names.map((name, i) => {
272
+ const status = getStatus(name);
273
+ const statusIcon = status === "running" ? "\u25CF" : status === "error" ? "\u2717" : "\u25CB";
274
+ const statusColor = status === "running" ? "green" : status === "error" ? "red" : "gray";
275
+ const isSelected = i === selected;
276
+ return /* @__PURE__ */ jsxs(Box, { backgroundColor: isSelected ? "blue" : void 0, children: [
277
+ /* @__PURE__ */ jsxs(Text, { color: isSelected ? "black" : void 0, children: [
278
+ name,
279
+ " "
280
+ ] }),
281
+ /* @__PURE__ */ jsx(Text, { color: isSelected ? "black" : statusColor, children: statusIcon })
282
+ ] }, name);
283
+ })
284
+ }
285
+ ) })
286
+ }
287
+ );
288
+ }
289
+ );
222
290
 
223
- General
224
- ? Toggle this help
225
- q Quit panex
291
+ // src/components/OutputPanel.tsx
292
+ import { Box as Box3, Text as Text3, useStdout as useStdout3 } from "ink";
293
+ import { ScrollView } from "ink-scroll-view";
294
+ import { forwardRef as forwardRef2, useImperativeHandle as useImperativeHandle2, useRef as useRef3, useEffect as useEffect4, useState as useState3, useCallback as useCallback4 } from "react";
226
295
 
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();
296
+ // src/components/Scrollbar.tsx
297
+ import { Box as Box2, Text as Text2 } from "ink";
298
+ import { jsx as jsx2 } from "react/jsx-runtime";
299
+ function Scrollbar({
300
+ scrollOffset,
301
+ contentHeight,
302
+ viewportHeight,
303
+ height
304
+ }) {
305
+ if (contentHeight <= viewportHeight) {
306
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: height }).map((_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " " }, i)) });
244
307
  }
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
- }
308
+ const trackHeight = height;
309
+ const thumbRatio = viewportHeight / contentHeight;
310
+ const thumbHeight = Math.max(1, Math.round(trackHeight * thumbRatio));
311
+ const maxScroll = contentHeight - viewportHeight;
312
+ const scrollRatio = maxScroll > 0 ? scrollOffset / maxScroll : 0;
313
+ const thumbPosition = Math.round((trackHeight - thumbHeight) * scrollRatio);
314
+ const lines = [];
315
+ for (let i = 0; i < trackHeight; i++) {
316
+ if (i >= thumbPosition && i < thumbPosition + thumbHeight) {
317
+ lines.push("\u2588");
318
+ } else {
319
+ lines.push("\u2591");
257
320
  }
258
- screen.render();
259
321
  }
260
- function saveScrollPosition() {
261
- const name = processNames[selectedIndex];
262
- if (name) {
263
- scrollPositions.set(name, outputBox.getScrollPerc());
264
- }
322
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: lines.map((char, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: char === "\u2591", children: char }, i)) });
323
+ }
324
+
325
+ // src/components/OutputPanel.tsx
326
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
327
+ var OutputPanel = forwardRef2(
328
+ function OutputPanel2({ name, output, active, height, autoScroll = true, onAutoScrollChange }, ref) {
329
+ const borderStyle = active ? "double" : "single";
330
+ const lines = output.split("\n");
331
+ const scrollRef = useRef3(null);
332
+ const { stdout } = useStdout3();
333
+ const [scrollOffset, setScrollOffset] = useState3(0);
334
+ const [contentHeight, setContentHeight] = useState3(0);
335
+ const [viewportHeight, setViewportHeight] = useState3(0);
336
+ useEffect4(() => {
337
+ const handleResize = () => scrollRef.current?.remeasure();
338
+ stdout?.on("resize", handleResize);
339
+ return () => {
340
+ stdout?.off("resize", handleResize);
341
+ };
342
+ }, [stdout]);
343
+ const isAtBottom = useCallback4(() => {
344
+ if (!scrollRef.current) return true;
345
+ const offset = scrollRef.current.getScrollOffset();
346
+ const bottom = scrollRef.current.getBottomOffset();
347
+ return offset >= bottom - 1;
348
+ }, []);
349
+ const handleContentHeightChange = useCallback4((newHeight) => {
350
+ setContentHeight(newHeight);
351
+ if (autoScroll && scrollRef.current) {
352
+ setTimeout(() => {
353
+ scrollRef.current?.scrollToBottom();
354
+ }, 0);
355
+ }
356
+ }, [autoScroll]);
357
+ const handleScroll = useCallback4((offset) => {
358
+ setScrollOffset(offset);
359
+ if (scrollRef.current) {
360
+ const bottom = scrollRef.current.getBottomOffset();
361
+ const atBottom = offset >= bottom - 1;
362
+ if (!atBottom && autoScroll) {
363
+ onAutoScrollChange?.(false);
364
+ } else if (atBottom && !autoScroll) {
365
+ onAutoScrollChange?.(true);
366
+ }
367
+ }
368
+ }, [autoScroll, onAutoScrollChange]);
369
+ useImperativeHandle2(ref, () => ({
370
+ scrollBy: (delta) => scrollRef.current?.scrollBy(delta),
371
+ scrollToTop: () => scrollRef.current?.scrollToTop(),
372
+ scrollToBottom: () => scrollRef.current?.scrollToBottom(),
373
+ getScrollOffset: () => scrollRef.current?.getScrollOffset() ?? 0,
374
+ getContentHeight: () => scrollRef.current?.getContentHeight() ?? 0,
375
+ getViewportHeight: () => scrollRef.current?.getViewportHeight() ?? 0,
376
+ isAtBottom
377
+ }));
378
+ const scrollbarHeight = height ? height - 4 : 20;
379
+ const hasScroll = contentHeight > viewportHeight;
380
+ const pinIndicator = !autoScroll && hasScroll ? " \u2357" : "";
381
+ return /* @__PURE__ */ jsx3(
382
+ Box3,
383
+ {
384
+ flexDirection: "column",
385
+ borderStyle,
386
+ borderColor: active ? "green" : "gray",
387
+ flexGrow: 1,
388
+ height,
389
+ paddingLeft: 1,
390
+ children: /* @__PURE__ */ jsxs2(Box3, { flexDirection: "row", marginTop: 0, height: height ? height - 2 : void 0, children: [
391
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", flexGrow: 1, children: /* @__PURE__ */ jsx3(
392
+ ScrollView,
393
+ {
394
+ ref: scrollRef,
395
+ onScroll: handleScroll,
396
+ onContentHeightChange: handleContentHeightChange,
397
+ onViewportSizeChange: (layout) => setViewportHeight(layout.height),
398
+ children: lines.map((line, i) => /* @__PURE__ */ jsx3(Text3, { wrap: "truncate", children: line }, i))
399
+ }
400
+ ) }),
401
+ hasScroll && /* @__PURE__ */ jsx3(
402
+ Scrollbar,
403
+ {
404
+ scrollOffset,
405
+ contentHeight,
406
+ viewportHeight,
407
+ height: scrollbarHeight
408
+ }
409
+ )
410
+ ] })
411
+ }
412
+ );
413
+ }
414
+ );
415
+
416
+ // src/components/StatusBar.tsx
417
+ import { Box as Box4, Text as Text4 } from "ink";
418
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
419
+ function StatusBar({ focusMode, processName, showShiftTabHint = true }) {
420
+ if (focusMode && processName) {
421
+ const shiftTabHint = showShiftTabHint ? "Shift-Tab/" : "";
422
+ return /* @__PURE__ */ jsx4(Box4, { backgroundColor: "green", width: "100%", children: /* @__PURE__ */ jsxs3(Text4, { bold: true, color: "black", backgroundColor: "green", children: [
423
+ " ",
424
+ "FOCUS: ",
425
+ processName,
426
+ " - Type to interact, [",
427
+ shiftTabHint,
428
+ "Esc] to exit focus mode",
429
+ " "
430
+ ] }) });
265
431
  }
266
- processManager.on("output", (name) => {
267
- if (name === processNames[selectedIndex]) {
268
- updateOutput(true);
432
+ return /* @__PURE__ */ jsx4(Box4, { backgroundColor: "blue", width: "100%", children: /* @__PURE__ */ jsxs3(Text4, { bold: true, color: "black", backgroundColor: "blue", children: [
433
+ " ",
434
+ "[\u2191\u2193/jk] select [Tab/Enter] focus [r] restart [A] restart All [x] kill [q] quit [?] help",
435
+ " "
436
+ ] }) });
437
+ }
438
+
439
+ // src/components/HelpPopup.tsx
440
+ import { Box as Box5, Text as Text5 } from "ink";
441
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
442
+ function HelpPopup({ visible }) {
443
+ if (!visible) return null;
444
+ return /* @__PURE__ */ jsxs4(
445
+ Box5,
446
+ {
447
+ flexDirection: "column",
448
+ borderStyle: "single",
449
+ borderColor: "yellow",
450
+ padding: 1,
451
+ position: "absolute",
452
+ marginLeft: 10,
453
+ marginTop: 5,
454
+ children: [
455
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "yellow", children: " Help " }),
456
+ /* @__PURE__ */ jsxs4(Text5, { children: [
457
+ "\n",
458
+ "Keyboard Shortcuts"
459
+ ] }),
460
+ /* @__PURE__ */ jsx5(Text5, { children: "\u2500".repeat(18) }),
461
+ /* @__PURE__ */ jsxs4(Text5, { children: [
462
+ "\n",
463
+ "Navigation"
464
+ ] }),
465
+ /* @__PURE__ */ jsx5(Text5, { children: " \u2191/\u2193 or j/k Navigate process list" }),
466
+ /* @__PURE__ */ jsx5(Text5, { children: " g/G Scroll to top/bottom of output" }),
467
+ /* @__PURE__ */ jsx5(Text5, { children: " PgUp/PgDn Scroll output" }),
468
+ /* @__PURE__ */ jsxs4(Text5, { children: [
469
+ "\n",
470
+ "Process Control"
471
+ ] }),
472
+ /* @__PURE__ */ jsx5(Text5, { children: " Tab/Enter Focus process (interactive mode)" }),
473
+ /* @__PURE__ */ jsx5(Text5, { children: " Esc Exit focus mode" }),
474
+ /* @__PURE__ */ jsx5(Text5, { children: " r Restart selected process" }),
475
+ /* @__PURE__ */ jsx5(Text5, { children: " A Restart all processes" }),
476
+ /* @__PURE__ */ jsx5(Text5, { children: " x Kill selected process" }),
477
+ /* @__PURE__ */ jsxs4(Text5, { children: [
478
+ "\n",
479
+ "General"
480
+ ] }),
481
+ /* @__PURE__ */ jsx5(Text5, { children: " ? Toggle this help" }),
482
+ /* @__PURE__ */ jsx5(Text5, { children: " q Quit panex" }),
483
+ /* @__PURE__ */ jsxs4(Text5, { children: [
484
+ "\n",
485
+ "Press any key to close this help..."
486
+ ] })
487
+ ]
269
488
  }
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();
489
+ );
490
+ }
491
+
492
+ // src/components/App.tsx
493
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
494
+ function App({ config }) {
495
+ const { exit } = useApp();
496
+ const { stdout } = useStdout4();
497
+ const { setRawMode } = useStdin2();
498
+ const [selected, setSelected] = useState4(0);
499
+ const [showHelp, setShowHelp] = useState4(false);
500
+ const { focusMode, enterFocus, exitFocus } = useFocusMode();
501
+ const outputRef = useRef4(null);
502
+ const processListRef = useRef4(null);
503
+ const [autoScroll, setAutoScroll] = useState4({});
504
+ const {
505
+ names,
506
+ getOutput,
507
+ getStatus,
508
+ restart,
509
+ restartAll,
510
+ kill,
511
+ killAll,
512
+ write,
513
+ resize
514
+ } = useProcessManager(config);
515
+ const isShiftTabDisabled = (name) => {
516
+ const setting = config.settings?.noShiftTab;
517
+ if (setting === true) return true;
518
+ if (Array.isArray(setting)) return setting.includes(name);
519
+ return false;
520
+ };
521
+ const maxPanelHeight = stdout ? stdout.rows - 1 : void 0;
522
+ useEffect5(() => {
523
+ const name = names[selected];
524
+ if (name && stdout) {
525
+ const cols = Math.floor(stdout.columns * 0.8) - 2;
526
+ const rows = stdout.rows - 3;
527
+ resize(name, cols, rows);
281
528
  }
282
- });
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();
295
- return;
529
+ }, [stdout?.columns, stdout?.rows, selected, names, resize]);
530
+ useEffect5(() => {
531
+ setAutoScroll((prev) => {
532
+ const next = { ...prev };
533
+ let changed = false;
534
+ for (const name of names) {
535
+ if (next[name] === void 0) {
536
+ next[name] = true;
537
+ changed = true;
538
+ }
539
+ }
540
+ return changed ? next : prev;
541
+ });
542
+ }, [names]);
543
+ const selectedName = names[selected] ?? "";
544
+ const output = selectedName ? getOutput(selectedName) : "";
545
+ const currentAutoScroll = selectedName ? autoScroll[selectedName] ?? true : true;
546
+ const handleAutoScrollChange = useCallback5((enabled) => {
547
+ if (selectedName) {
548
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: enabled }));
296
549
  }
297
- 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();
550
+ }, [selectedName]);
551
+ const handleWheel = useCallback5((event) => {
552
+ const delta = event.type === "wheel-up" ? -3 : 3;
553
+ if (outputRef.current) {
554
+ outputRef.current.scrollBy(delta);
555
+ if (event.type === "wheel-up" && selectedName) {
556
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: false }));
557
+ }
301
558
  }
559
+ }, [selectedName]);
560
+ useMouseWheel({
561
+ enabled: !showHelp,
562
+ // Disable when help is shown
563
+ onWheel: handleWheel
302
564
  });
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();
565
+ useInput((input, key) => {
566
+ if (showHelp) {
567
+ setShowHelp(false);
568
+ return;
314
569
  }
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();
570
+ if (input === "q" || key.ctrl && input === "c") {
571
+ killAll();
572
+ setRawMode(false);
573
+ const rows = stdout?.rows ?? 999;
574
+ stdout?.write(`\x1B[${rows};1H\x1B[J\x1B[?1000l\x1B[?1006l\x1B[?25h\x1B[0m
575
+ `);
576
+ exit();
577
+ process.exit(0);
323
578
  }
324
- });
325
- screen.key(["enter"], () => {
326
- if (!helpBox.hidden) {
327
- helpBox.hide();
328
- screen.render();
579
+ if (input === "?") {
580
+ setShowHelp(true);
329
581
  return;
330
582
  }
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");
583
+ if (focusMode) {
584
+ const name = names[selected];
585
+ if (!name) return;
586
+ if (key.escape) {
587
+ exitFocus();
588
+ return;
589
+ }
590
+ if (key.shift && key.tab && !isShiftTabDisabled(name)) {
591
+ exitFocus();
592
+ return;
593
+ }
594
+ if (key.return) {
595
+ write(name, "\r");
596
+ return;
597
+ }
598
+ if (key.upArrow) {
599
+ write(name, "\x1B[A");
600
+ return;
601
+ }
602
+ if (key.downArrow) {
603
+ write(name, "\x1B[B");
604
+ return;
605
+ }
606
+ if (key.leftArrow) {
607
+ write(name, "\x1B[D");
608
+ return;
340
609
  }
610
+ if (key.rightArrow) {
611
+ write(name, "\x1B[C");
612
+ return;
613
+ }
614
+ if (input && !key.ctrl && !key.meta) {
615
+ write(name, input);
616
+ }
617
+ return;
341
618
  }
342
- });
343
- screen.key(["r"], () => {
344
- if (focusMode || !helpBox.hidden) return;
345
- const name = processNames[selectedIndex];
346
- if (name) {
347
- processManager.restart(name);
619
+ if (key.upArrow || input === "k") {
620
+ setSelected((s) => Math.max(s - 1, 0));
621
+ return;
348
622
  }
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);
623
+ if (key.downArrow || input === "j") {
624
+ setSelected((s) => Math.min(s + 1, names.length - 1));
625
+ return;
359
626
  }
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();
627
+ if (key.return || key.tab) {
628
+ enterFocus();
629
+ return;
378
630
  }
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 ");
631
+ if (input === "r") {
632
+ const name = names[selected];
633
+ if (name) restart(name);
634
+ return;
388
635
  }
389
- if (clickedIndex !== selectedIndex) {
390
- saveScrollPosition();
391
- selectedIndex = clickedIndex;
392
- updateProcessList();
393
- updateOutput();
636
+ if (input === "A") {
637
+ restartAll();
638
+ return;
394
639
  }
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;
640
+ if (input === "x") {
641
+ const name = names[selected];
642
+ if (name) kill(name);
643
+ return;
644
+ }
645
+ if (input === "g") {
646
+ outputRef.current?.scrollToTop();
647
+ if (selectedName) {
648
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: false }));
401
649
  }
402
- const name = processNames[selectedIndex];
403
- if (name) {
404
- processManager.write(name, ch);
650
+ return;
651
+ }
652
+ if (input === "G") {
653
+ outputRef.current?.scrollToBottom();
654
+ if (selectedName) {
655
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: true }));
405
656
  }
657
+ return;
406
658
  }
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);
659
+ if (key.pageUp) {
660
+ const pageSize = outputRef.current?.getViewportHeight() ?? 10;
661
+ outputRef.current?.scrollBy(-pageSize);
662
+ if (selectedName) {
663
+ setAutoScroll((prev) => ({ ...prev, [selectedName]: false }));
664
+ }
665
+ return;
666
+ }
667
+ if (key.pageDown) {
668
+ const pageSize = outputRef.current?.getViewportHeight() ?? 10;
669
+ outputRef.current?.scrollBy(pageSize);
670
+ return;
414
671
  }
415
672
  });
416
- updateProcessList();
417
- updateOutput();
418
- processList.focus();
419
- await processManager.startAll();
420
- updateProcessList();
421
- updateOutput();
422
- screen.render();
673
+ const showShiftTabHint = selectedName ? !isShiftTabDisabled(selectedName) : true;
674
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", height: maxPanelHeight, children: [
675
+ /* @__PURE__ */ jsxs5(Box6, { flexDirection: "row", flexGrow: 1, height: maxPanelHeight ? maxPanelHeight - 1 : void 0, children: [
676
+ /* @__PURE__ */ jsx6(
677
+ ProcessList,
678
+ {
679
+ ref: processListRef,
680
+ names,
681
+ selected,
682
+ getStatus,
683
+ active: !focusMode,
684
+ height: maxPanelHeight ? maxPanelHeight - 1 : void 0
685
+ }
686
+ ),
687
+ /* @__PURE__ */ jsx6(
688
+ OutputPanel,
689
+ {
690
+ ref: outputRef,
691
+ name: selectedName,
692
+ output,
693
+ active: focusMode,
694
+ height: maxPanelHeight ? maxPanelHeight - 1 : void 0,
695
+ autoScroll: currentAutoScroll,
696
+ onAutoScrollChange: handleAutoScrollChange
697
+ }
698
+ )
699
+ ] }),
700
+ /* @__PURE__ */ jsx6(
701
+ StatusBar,
702
+ {
703
+ focusMode,
704
+ processName: selectedName,
705
+ showShiftTabHint
706
+ }
707
+ ),
708
+ /* @__PURE__ */ jsx6(HelpPopup, { visible: showHelp })
709
+ ] });
710
+ }
711
+
712
+ // src/tui.ts
713
+ async function createTUI(config) {
714
+ const { waitUntilExit } = render(createElement(App, { config }));
715
+ await waitUntilExit();
423
716
  }
424
717
 
425
718
  // src/cli.ts
719
+ if (typeof Bun === "undefined") {
720
+ console.error("Error: panex requires Bun runtime.");
721
+ console.error("Please run with bunx instead of npx:");
722
+ console.error(' bunx panex "cmd1" "cmd2"');
723
+ process.exit(1);
724
+ }
426
725
  var program = new Command();
427
- 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) => {
726
+ 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) => {
428
727
  const rawNames = options.names?.split(",") ?? commands.map((_, i) => `proc${i + 1}`);
429
728
  const usedNames = /* @__PURE__ */ new Map();
430
729
  const names = rawNames.map((name, i) => {
@@ -433,10 +732,19 @@ program.name("panex").description("Terminal UI for running multiple processes in
433
732
  usedNames.set(baseName, count + 1);
434
733
  return count === 0 ? baseName : `${baseName}-${count + 1}`;
435
734
  });
735
+ let noShiftTab;
736
+ if (options.shiftTab === false) {
737
+ noShiftTab = true;
738
+ } else if (typeof options.shiftTab === "string") {
739
+ noShiftTab = options.shiftTab.split(",");
740
+ }
436
741
  const config = {
437
742
  procs: Object.fromEntries(
438
743
  commands.map((cmd, i) => [names[i], { shell: cmd }])
439
- )
744
+ ),
745
+ settings: {
746
+ noShiftTab
747
+ }
440
748
  };
441
749
  await createTUI(config);
442
750
  });