startall 0.0.1 → 0.0.3

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.
Files changed (3) hide show
  1. package/README.md +11 -1
  2. package/index.js +1707 -423
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -1,14 +1,222 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env bun
2
2
 
3
- import { createCliRenderer, TextRenderable, BoxRenderable, ScrollBoxRenderable, t, fg } from '@opentui/core';
4
- import { spawn } from 'child_process';
5
- import { readFileSync, writeFileSync, existsSync } from 'fs';
6
- import { join } from 'path';
7
- import kill from 'tree-kill';
3
+ import { createCliRenderer, TextRenderable, BoxRenderable, ScrollBoxRenderable, t, fg } from '@opentui/core';
4
+ import { spawn } from 'child_process';
5
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import kill from 'tree-kill';
8
+ import stripAnsi from 'strip-ansi';
8
9
 
9
10
  // Configuration
10
11
  const CONFIG_FILE = process.argv[2] || 'startall.json';
11
12
  const COUNTDOWN_SECONDS = 10;
13
+ const APP_VERSION = 'v0.0.4';
14
+
15
+ // Pane ID generator
16
+ let paneIdCounter = 0;
17
+ function generatePaneId() {
18
+ return `pane-${++paneIdCounter}`;
19
+ }
20
+
21
+ // Create a new pane node
22
+ function createPane(processes = []) {
23
+ return {
24
+ type: 'pane',
25
+ id: generatePaneId(),
26
+ processes: processes, // Array of process names shown in this pane (empty = all)
27
+ hidden: [], // Array of process names to hide from this pane
28
+ filter: '', // Text filter for this pane
29
+ isPaused: false,
30
+ scrollOffset: 0,
31
+ };
32
+ }
33
+
34
+ // Create a split node
35
+ function createSplit(direction, children) {
36
+ return {
37
+ type: 'split',
38
+ direction: direction, // 'horizontal' (top/bottom) or 'vertical' (left/right)
39
+ children: children,
40
+ sizes: children.map(() => 1), // Equal sizes by default (flex ratios)
41
+ };
42
+ }
43
+
44
+ // Find a pane by ID in the tree
45
+ function findPaneById(node, id) {
46
+ if (!node) return null;
47
+ if (node.type === 'pane') {
48
+ return node.id === id ? node : null;
49
+ }
50
+ for (const child of node.children) {
51
+ const found = findPaneById(child, id);
52
+ if (found) return found;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // Find all pane IDs in order (for navigation)
58
+ function getAllPaneIds(node, ids = []) {
59
+ if (!node) return ids;
60
+ if (node.type === 'pane') {
61
+ ids.push(node.id);
62
+ } else {
63
+ for (const child of node.children) {
64
+ getAllPaneIds(child, ids);
65
+ }
66
+ }
67
+ return ids;
68
+ }
69
+
70
+ // Find parent of a node
71
+ function findParent(root, targetId, parent = null) {
72
+ if (!root) return null;
73
+ if (root.type === 'pane') {
74
+ return root.id === targetId ? parent : null;
75
+ }
76
+ for (const child of root.children) {
77
+ if (child.type === 'pane' && child.id === targetId) {
78
+ return root;
79
+ }
80
+ const found = findParent(child, targetId, root);
81
+ if (found) return found;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ // Split a pane in a given direction
87
+ function splitPane(root, paneId, direction) {
88
+ if (!root) return root;
89
+
90
+ if (root.type === 'pane') {
91
+ if (root.id === paneId) {
92
+ // Split this pane - new pane inherits processes from current
93
+ const newPane = createPane([...root.processes]);
94
+ return createSplit(direction, [root, newPane]);
95
+ }
96
+ return root;
97
+ }
98
+
99
+ // It's a split node - recurse into children
100
+ const newChildren = root.children.map(child => splitPane(child, paneId, direction));
101
+
102
+ // Check if any child was replaced with a split of same direction - flatten it
103
+ const flattenedChildren = [];
104
+ const flattenedSizes = [];
105
+ newChildren.forEach((child, idx) => {
106
+ if (child.type === 'split' && child.direction === root.direction && child !== root.children[idx]) {
107
+ // Flatten: add the new split's children directly
108
+ flattenedChildren.push(...child.children);
109
+ const sizePerChild = root.sizes[idx] / child.children.length;
110
+ flattenedSizes.push(...child.children.map(() => sizePerChild));
111
+ } else {
112
+ flattenedChildren.push(child);
113
+ flattenedSizes.push(root.sizes[idx]);
114
+ }
115
+ });
116
+
117
+ return {
118
+ ...root,
119
+ children: flattenedChildren,
120
+ sizes: flattenedSizes,
121
+ };
122
+ }
123
+
124
+ // Close a pane (remove it from the tree)
125
+ function closePane(root, paneId) {
126
+ if (!root) return null;
127
+
128
+ if (root.type === 'pane') {
129
+ return root.id === paneId ? null : root;
130
+ }
131
+
132
+ // Find and remove the pane
133
+ const newChildren = root.children
134
+ .map(child => closePane(child, paneId))
135
+ .filter(child => child !== null);
136
+
137
+ if (newChildren.length === 0) {
138
+ return null;
139
+ }
140
+ if (newChildren.length === 1) {
141
+ // Unwrap single child
142
+ return newChildren[0];
143
+ }
144
+
145
+ // Recalculate sizes for remaining children
146
+ const originalIndices = [];
147
+ root.children.forEach((child, idx) => {
148
+ const closed = closePane(child, paneId);
149
+ if (closed !== null) {
150
+ originalIndices.push(idx);
151
+ }
152
+ });
153
+
154
+ const newSizes = originalIndices.map(idx => root.sizes[idx]);
155
+ // Normalize sizes
156
+ const total = newSizes.reduce((a, b) => a + b, 0);
157
+ const normalizedSizes = newSizes.map(s => s / total * newChildren.length);
158
+
159
+ return {
160
+ ...root,
161
+ children: newChildren,
162
+ sizes: normalizedSizes,
163
+ };
164
+ }
165
+
166
+ // Serialize pane tree for saving to config (strip runtime state)
167
+ function serializePaneTree(node) {
168
+ if (!node) return null;
169
+
170
+ if (node.type === 'pane') {
171
+ return {
172
+ type: 'pane',
173
+ processes: node.processes || [],
174
+ hidden: node.hidden || [],
175
+ };
176
+ }
177
+
178
+ return {
179
+ type: 'split',
180
+ direction: node.direction,
181
+ sizes: node.sizes,
182
+ children: node.children.map(child => serializePaneTree(child)),
183
+ };
184
+ }
185
+
186
+ // Deserialize pane tree from config (restore with fresh IDs)
187
+ function deserializePaneTree(data) {
188
+ if (!data) return null;
189
+
190
+ if (data.type === 'pane') {
191
+ const pane = createPane(data.processes || []);
192
+ pane.hidden = data.hidden || [];
193
+ return pane;
194
+ }
195
+
196
+ return {
197
+ type: 'split',
198
+ direction: data.direction,
199
+ sizes: data.sizes || data.children.map(() => 1),
200
+ children: data.children.map(child => deserializePaneTree(child)),
201
+ };
202
+ }
203
+
204
+ // Color palette (inspired by Tokyo Night theme)
205
+ const COLORS = {
206
+ border: '#3b4261',
207
+ borderFocused: '#7aa2f7',
208
+ bg: '#1a1b26',
209
+ bgLight: '#24283b',
210
+ bgHighlight: '#292e42',
211
+ text: '#c0caf5',
212
+ textDim: '#565f89',
213
+ accent: '#7aa2f7',
214
+ success: '#9ece6a',
215
+ error: '#f7768e',
216
+ warning: '#e0af68',
217
+ cyan: '#7dcfff',
218
+ magenta: '#bb9af7',
219
+ };
12
220
 
13
221
  // Match string against pattern with wildcard support
14
222
  function matchesPattern(str, pattern) {
@@ -16,6 +224,11 @@ function matchesPattern(str, pattern) {
16
224
  return regex.test(str);
17
225
  }
18
226
 
227
+ function isIncluded(name, includePatterns) {
228
+ if (!includePatterns) return true;
229
+ return includePatterns.some(pattern => matchesPattern(name, pattern));
230
+ }
231
+
19
232
  function isIgnored(name, ignorePatterns) {
20
233
  return ignorePatterns.some(pattern => matchesPattern(name, pattern));
21
234
  }
@@ -60,31 +273,51 @@ function saveConfig(config) {
60
273
  }
61
274
  }
62
275
 
63
- // Process Manager
276
+ // Process Manager
64
277
  class ProcessManager {
65
278
  constructor(renderer, scripts) {
66
279
  this.renderer = renderer;
67
280
  this.config = loadConfig();
68
- this.scripts = scripts.filter(s => !isIgnored(s.name, this.config.ignore));
69
- this.phase = 'selection'; // 'selection' | 'running'
281
+ this.allScripts = scripts; // Keep reference to all scripts (unfiltered)
282
+ this.scripts = scripts
283
+ .filter(s => isIncluded(s.name, this.config.include))
284
+ .filter(s => !isIgnored(s.name, this.config.ignore || []));
285
+ this.phase = 'selection'; // 'selection' | 'running' | 'settings'
70
286
  this.selectedScripts = new Set(this.config.defaultSelection);
71
- this.countdown = COUNTDOWN_SECONDS;
72
- this.selectedIndex = 0;
73
- this.processes = new Map();
74
- this.processRefs = new Map();
75
- this.outputLines = [];
76
- this.filter = '';
77
- this.maxOutputLines = 1000;
78
- this.maxVisibleLines = 30; // Number of recent lines to show when not paused
79
- this.isPaused = false; // Whether output scrolling is paused
80
- this.wasPaused = false; // Track previous pause state to detect changes
81
- this.isFilterMode = false; // Whether in filter input mode
82
- this.outputBox = null; // Reference to the output container
287
+ this.countdown = COUNTDOWN_SECONDS;
288
+ this.selectedIndex = 0;
289
+ this.processes = new Map();
290
+ this.processRefs = new Map();
291
+ this.outputLines = [];
292
+ this.filter = '';
293
+ this.maxOutputLines = 1000;
294
+ this.maxVisibleLines = null; // Calculated dynamically based on screen height
295
+ this.isPaused = false; // Whether output scrolling is paused
296
+ this.wasPaused = false; // Track previous pause state to detect changes
297
+ this.isFilterMode = false; // Whether in filter input mode
298
+
299
+ // Settings menu state
300
+ this.settingsSection = 'ignore'; // 'ignore' | 'include' | 'scripts'
301
+ this.settingsIndex = 0; // Current selection index within section
302
+ this.isAddingPattern = false; // Whether typing a new pattern
303
+ this.newPatternText = ''; // Text being typed for new pattern
304
+ this.settingsContainer = null; // UI reference
305
+ this.previousPhase = 'selection'; // Track where we came from
306
+ this.outputBox = null; // Reference to the output container
307
+ this.destroyed = false; // Flag to prevent operations after cleanup
83
308
  this.lastRenderedLineCount = 0; // Track how many lines we've rendered
84
- this.headerRenderable = null; // Reference to header text in running UI
85
- this.processListRenderable = null; // Reference to process list text in running UI
86
-
87
- // Assign colors to each script
309
+ this.headerRenderable = null; // Reference to header text in running UI
310
+ this.processListRenderable = null; // Reference to process list text in running UI
311
+ this.renderScheduled = false; // Throttle renders for CPU efficiency
312
+
313
+ // Split pane state
314
+ this.paneRoot = null; // Root of pane tree (initialized when running starts)
315
+ this.focusedPaneId = null; // ID of currently focused pane
316
+ this.splitMode = false; // Whether waiting for split command after Ctrl+b
317
+ this.showSplitMenu = false; // Whether to show the command palette
318
+ this.splitMenuIndex = 0; // Selected item in split menu
319
+
320
+ // Assign colors to each script
88
321
  this.processColors = new Map();
89
322
  const colors = ['#7aa2f7', '#bb9af7', '#9ece6a', '#f7768e', '#e0af68', '#73daca'];
90
323
  scripts.forEach((script, index) => {
@@ -123,42 +356,61 @@ class ProcessManager {
123
356
  // We'll add onMouseDown to individual script lines in buildSelectionUI
124
357
  }
125
358
 
126
- handleInput(keyName, keyEvent) {
127
- if (this.phase === 'selection') {
128
- if (keyName === 'enter' || keyName === 'return') {
129
- clearInterval(this.countdownInterval);
130
- this.startProcesses();
131
- } else if (keyName === 'up') {
132
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
133
- } else if (keyName === 'down') {
134
- this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
135
- } else if (keyName === 'space') {
136
- const scriptName = this.scripts[this.selectedIndex]?.name;
137
- if (scriptName) {
138
- if (this.selectedScripts.has(scriptName)) {
139
- this.selectedScripts.delete(scriptName);
140
- } else {
141
- this.selectedScripts.add(scriptName);
142
- }
143
- }
144
- }
145
- } else if (this.phase === 'running') {
146
- // If in filter mode, handle filter input
147
- if (this.isFilterMode) {
148
- if (keyName === 'escape') {
149
- this.isFilterMode = false;
150
- this.filter = '';
151
- this.buildRunningUI(); // Rebuild to clear filter
359
+ handleInput(keyName, keyEvent) {
360
+ if (this.phase === 'selection') {
361
+ if (keyName === 'enter' || keyName === 'return') {
362
+ clearInterval(this.countdownInterval);
363
+ this.startProcesses();
364
+ } else if (keyName === 'up') {
365
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
366
+ } else if (keyName === 'down') {
367
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
368
+ } else if (keyName === 'space') {
369
+ const scriptName = this.scripts[this.selectedIndex]?.name;
370
+ if (scriptName) {
371
+ if (this.selectedScripts.has(scriptName)) {
372
+ this.selectedScripts.delete(scriptName);
373
+ } else {
374
+ this.selectedScripts.add(scriptName);
375
+ }
376
+ // Reset countdown when selection changes
377
+ this.countdown = COUNTDOWN_SECONDS;
378
+ }
379
+ } else if (keyName === 'c') {
380
+ // Open settings menu
381
+ clearInterval(this.countdownInterval);
382
+ this.previousPhase = 'selection';
383
+ this.phase = 'settings';
384
+ this.settingsSection = 'ignore';
385
+ this.settingsIndex = 0;
386
+ this.buildSettingsUI();
387
+ return;
388
+ }
389
+ } else if (this.phase === 'settings') {
390
+ this.handleSettingsInput(keyName, keyEvent);
391
+ return;
392
+ } else if (this.phase === 'running') {
393
+ // Handle split menu
394
+ if (this.showSplitMenu) {
395
+ this.handleSplitMenuInput(keyName, keyEvent);
396
+ return;
397
+ }
398
+
399
+ // If in filter mode, handle filter input
400
+ if (this.isFilterMode) {
401
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
402
+ if (keyName === 'escape') {
403
+ this.isFilterMode = false;
404
+ if (pane) pane.filter = '';
405
+ this.buildRunningUI(); // Rebuild to clear filter
152
406
  } else if (keyName === 'enter' || keyName === 'return') {
153
407
  this.isFilterMode = false;
154
- this.isPaused = true; // Pause when filter is applied
155
- this.updateStreamPauseState();
156
408
  this.buildRunningUI(); // Rebuild with filter
157
409
  } else if (keyName === 'backspace') {
158
- this.filter = this.filter.slice(0, -1);
410
+ if (pane) pane.filter = (pane.filter || '').slice(0, -1);
159
411
  this.buildRunningUI(); // Update UI to show filter change
160
412
  } else if (keyName && keyName.length === 1 && !keyEvent.ctrl && !keyEvent.meta) {
161
- this.filter += keyName;
413
+ if (pane) pane.filter = (pane.filter || '') + keyName;
162
414
  this.buildRunningUI(); // Update UI to show filter change
163
415
  }
164
416
  } else {
@@ -166,60 +418,99 @@ class ProcessManager {
166
418
  if (keyName === 'q') {
167
419
  this.cleanup();
168
420
  this.renderer.destroy();
421
+ } else if (keyName === '\\') {
422
+ // Open split/pane menu (VSCode-friendly alternative to Ctrl+b)
423
+ this.showSplitMenu = true;
424
+ this.splitMenuIndex = 0;
425
+ this.buildRunningUI();
426
+ } else if (keyName === '|') {
427
+ // Quick vertical split
428
+ this.splitCurrentPane('vertical');
429
+ this.buildRunningUI();
430
+ } else if (keyName === '_') {
431
+ // Quick horizontal split
432
+ this.splitCurrentPane('horizontal');
433
+ this.buildRunningUI();
434
+ } else if (keyName === 'x' && getAllPaneIds(this.paneRoot).length > 1) {
435
+ // Close current pane (only if more than one)
436
+ this.closeCurrentPane();
437
+ this.buildRunningUI();
169
438
  } else if (keyName === 'space') {
170
- // Toggle pause output scrolling
439
+ // Toggle visibility of selected process in focused pane
440
+ this.toggleProcessVisibility();
441
+ this.buildRunningUI();
442
+ } else if (keyName === 'p') {
443
+ // Toggle pause output scrolling globally
171
444
  this.isPaused = !this.isPaused;
172
445
  this.updateStreamPauseState();
446
+ this.buildRunningUI();
173
447
  } else if (keyName === 'f') {
174
- // Filter to currently selected process
448
+ // Filter focused pane to currently selected process
175
449
  const scriptName = this.scripts[this.selectedIndex]?.name;
176
- if (scriptName) {
177
- this.filter = scriptName;
178
- this.isPaused = true; // Auto-pause when filtering
179
- this.updateStreamPauseState();
450
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
451
+ if (scriptName && pane) {
452
+ pane.filter = scriptName;
180
453
  this.buildRunningUI(); // Rebuild to apply filter
181
454
  }
182
455
  } else if (keyName === '/') {
183
- // Enter filter mode
456
+ // Enter filter mode for focused pane
184
457
  this.isFilterMode = true;
185
- this.filter = '';
458
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
459
+ if (pane) pane.filter = '';
186
460
  } else if (keyName === 'escape') {
187
- // Clear filter and unpause
188
- this.filter = '';
461
+ // Clear filter on focused pane
462
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
463
+ if (pane) pane.filter = '';
189
464
  this.isPaused = false;
190
465
  this.updateStreamPauseState();
191
466
  this.buildRunningUI(); // Rebuild to clear filter
192
467
  } else if (keyName === 'up' || keyName === 'k') {
193
- // Navigate processes up
194
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
195
- this.buildRunningUI(); // Rebuild to show selection change
196
- } else if (keyName === 'down' || keyName === 'j') {
197
- // Navigate processes down
198
- this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
199
- this.buildRunningUI(); // Rebuild to show selection change
200
- } else if (keyName === 'left' || keyName === 'h') {
201
- // Navigate processes left
202
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
203
- this.buildRunningUI(); // Rebuild to show selection change
204
- } else if (keyName === 'right' || keyName === 'l') {
205
- // Navigate processes right
206
- this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
207
- this.buildRunningUI(); // Rebuild to show selection change
208
- } else if (keyName === 'r') {
209
- const scriptName = this.scripts[this.selectedIndex]?.name;
210
- if (scriptName) {
211
- this.restartProcess(scriptName);
212
- }
213
- } else if (keyName === 's') {
214
- // Stop/start selected process
215
- const scriptName = this.scripts[this.selectedIndex]?.name;
216
- if (scriptName) {
217
- this.toggleProcess(scriptName);
218
- }
219
- }
220
- }
221
- }
222
- }
468
+ // Navigate processes up
469
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
470
+ this.buildRunningUI(); // Rebuild to show selection change
471
+ } else if (keyName === 'down' || keyName === 'j') {
472
+ // Navigate processes down
473
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
474
+ this.buildRunningUI(); // Rebuild to show selection change
475
+ } else if (keyName === 'left' || keyName === 'h') {
476
+ // Navigate processes left
477
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
478
+ this.buildRunningUI(); // Rebuild to show selection change
479
+ } else if (keyName === 'right' || keyName === 'l') {
480
+ // Navigate processes right
481
+ this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
482
+ this.buildRunningUI(); // Rebuild to show selection change
483
+ } else if (keyName === 'r') {
484
+ const scriptName = this.scripts[this.selectedIndex]?.name;
485
+ if (scriptName) {
486
+ this.restartProcess(scriptName);
487
+ }
488
+ } else if (keyName === 's') {
489
+ // Stop/start selected process
490
+ const scriptName = this.scripts[this.selectedIndex]?.name;
491
+ if (scriptName) {
492
+ this.toggleProcess(scriptName);
493
+ }
494
+ } else if (keyName === 'c') {
495
+ // Open settings
496
+ this.previousPhase = 'running';
497
+ this.phase = 'settings';
498
+ this.settingsSection = 'ignore';
499
+ this.settingsIndex = 0;
500
+ this.buildSettingsUI();
501
+ return;
502
+ } else if (keyName === 'tab') {
503
+ // Navigate to next pane
504
+ this.navigateToNextPane(1);
505
+ this.buildRunningUI();
506
+ } else if (keyEvent.shift && keyName === 'tab') {
507
+ // Navigate to previous pane
508
+ this.navigateToNextPane(-1);
509
+ this.buildRunningUI();
510
+ }
511
+ }
512
+ }
513
+ }
223
514
 
224
515
  handleMouse(mouse) {
225
516
  if (this.phase === 'selection') {
@@ -228,20 +519,22 @@ class ProcessManager {
228
519
  // Check if click is on a script line
229
520
  const clickedIndex = this.scriptLinePositions.findIndex(pos => pos === mouse.y);
230
521
 
231
- if (clickedIndex !== -1) {
232
- const scriptName = this.scripts[clickedIndex]?.name;
233
- if (scriptName) {
234
- // Toggle selection
235
- if (this.selectedScripts.has(scriptName)) {
236
- this.selectedScripts.delete(scriptName);
237
- } else {
238
- this.selectedScripts.add(scriptName);
239
- }
240
- // Update focused index
241
- this.selectedIndex = clickedIndex;
242
- this.render();
243
- }
244
- }
522
+ if (clickedIndex !== -1) {
523
+ const scriptName = this.scripts[clickedIndex]?.name;
524
+ if (scriptName) {
525
+ // Toggle selection
526
+ if (this.selectedScripts.has(scriptName)) {
527
+ this.selectedScripts.delete(scriptName);
528
+ } else {
529
+ this.selectedScripts.add(scriptName);
530
+ }
531
+ // Reset countdown when selection changes
532
+ this.countdown = COUNTDOWN_SECONDS;
533
+ // Update focused index
534
+ this.selectedIndex = clickedIndex;
535
+ this.render();
536
+ }
537
+ }
245
538
  } else if (mouse.type === 'wheeldown') {
246
539
  this.selectedIndex = Math.min(this.scripts.length - 1, this.selectedIndex + 1);
247
540
  this.render();
@@ -280,25 +573,33 @@ class ProcessManager {
280
573
  }, 1000);
281
574
  }
282
575
 
283
- startProcesses() {
284
- const selected = Array.from(this.selectedScripts);
285
-
286
- if (selected.length === 0) {
287
- console.log('No scripts selected.');
288
- process.exit(0);
289
- }
290
-
576
+ startProcesses() {
577
+ const selected = Array.from(this.selectedScripts);
578
+
579
+ if (selected.length === 0) {
580
+ console.log('No scripts selected.');
581
+ process.exit(0);
582
+ }
583
+
291
584
  this.config.defaultSelection = selected;
292
- saveConfig(this.config);
293
- this.phase = 'running';
294
- this.selectedIndex = 0;
295
-
296
- selected.forEach(scriptName => {
297
- this.startProcess(scriptName);
298
- });
299
-
300
- this.render();
301
- }
585
+ saveConfig(this.config);
586
+ this.phase = 'running';
587
+ this.selectedIndex = 0;
588
+
589
+ // Load pane layout from config or create default
590
+ if (this.config.paneLayout) {
591
+ this.paneRoot = deserializePaneTree(this.config.paneLayout);
592
+ } else {
593
+ this.paneRoot = createPane([]); // Empty array means show all processes
594
+ }
595
+ this.focusedPaneId = this.paneRoot.id;
596
+
597
+ selected.forEach(scriptName => {
598
+ this.startProcess(scriptName);
599
+ });
600
+
601
+ this.render();
602
+ }
302
603
 
303
604
  startProcess(scriptName) {
304
605
  const script = this.scripts.find(s => s.name === scriptName);
@@ -313,57 +614,64 @@ class ProcessManager {
313
614
  shell: true,
314
615
  });
315
616
 
316
- proc.stdout.on('data', (data) => {
317
- const text = data.toString();
318
- const lines = text.split('\n');
319
- lines.forEach(line => {
320
- if (line.trim()) {
321
- this.addOutputLine(scriptName, line);
322
- }
323
- });
324
- this.render();
325
- });
326
-
327
- proc.stderr.on('data', (data) => {
328
- const text = data.toString();
329
- const lines = text.split('\n');
330
- lines.forEach(line => {
331
- if (line.trim()) {
332
- this.addOutputLine(scriptName, line);
333
- }
334
- });
335
- this.render();
336
- });
337
-
338
- proc.on('exit', (code) => {
339
- const status = code === 0 ? 'exited' : 'crashed';
340
- this.processes.set(scriptName, { status, exitCode: code });
341
- this.addOutputLine(scriptName, `Process exited with code ${code}`);
342
- this.render();
343
- });
617
+ proc.stdout.on('data', (data) => {
618
+ const text = data.toString();
619
+ const lines = text.split('\n');
620
+ lines.forEach(line => {
621
+ if (line.trim()) {
622
+ this.addOutputLine(scriptName, line);
623
+ }
624
+ });
625
+ });
626
+
627
+ proc.stderr.on('data', (data) => {
628
+ const text = data.toString();
629
+ const lines = text.split('\n');
630
+ lines.forEach(line => {
631
+ if (line.trim()) {
632
+ this.addOutputLine(scriptName, line);
633
+ }
634
+ });
635
+ });
636
+
637
+ proc.on('exit', (code) => {
638
+ const status = code === 0 ? 'exited' : 'crashed';
639
+ this.processes.set(scriptName, { status, exitCode: code });
640
+ this.addOutputLine(scriptName, `Process exited with code ${code}`);
641
+ });
344
642
 
345
643
  this.processRefs.set(scriptName, proc);
346
644
  this.processes.set(scriptName, { status: 'running', pid: proc.pid });
347
645
  }
348
646
 
349
- addOutputLine(processName, text) {
350
- // Always store the output line, even when paused
351
- this.outputLines.push({
352
- process: processName,
353
- text,
354
- timestamp: Date.now(),
355
- });
356
-
357
- if (this.outputLines.length > this.maxOutputLines) {
358
- this.outputLines = this.outputLines.slice(-this.maxOutputLines);
359
- }
360
-
361
- // Only render if not paused - this prevents new output from appearing
362
- // when the user is reviewing history
363
- if (!this.isPaused) {
364
- this.render();
365
- }
366
- }
647
+ addOutputLine(processName, text) {
648
+ // Always store the output line, even when paused
649
+ this.outputLines.push({
650
+ process: processName,
651
+ text,
652
+ timestamp: Date.now(),
653
+ });
654
+
655
+ if (this.outputLines.length > this.maxOutputLines) {
656
+ this.outputLines = this.outputLines.slice(-this.maxOutputLines);
657
+ }
658
+
659
+ // Only render if not paused - this prevents new output from appearing
660
+ // when the user is reviewing history
661
+ if (!this.isPaused) {
662
+ this.scheduleRender();
663
+ }
664
+ }
665
+
666
+ scheduleRender() {
667
+ // Throttle renders to ~60fps to reduce CPU usage
668
+ if (this.renderScheduled) return;
669
+ this.renderScheduled = true;
670
+ setTimeout(() => {
671
+ this.renderScheduled = false;
672
+ this.render();
673
+ }, 16);
674
+ }
367
675
 
368
676
  stopProcess(scriptName) {
369
677
  const proc = this.processRefs.get(scriptName);
@@ -398,6 +706,694 @@ class ProcessManager {
398
706
  }
399
707
  }
400
708
 
709
+ handleSettingsInput(keyName, keyEvent) {
710
+ // Handle text input mode for adding patterns
711
+ if (this.isAddingPattern) {
712
+ if (keyName === 'escape') {
713
+ this.isAddingPattern = false;
714
+ this.newPatternText = '';
715
+ this.buildSettingsUI();
716
+ } else if (keyName === 'enter' || keyName === 'return') {
717
+ if (this.newPatternText.trim()) {
718
+ // Add the pattern to the appropriate list
719
+ if (this.settingsSection === 'ignore') {
720
+ if (!this.config.ignore) this.config.ignore = [];
721
+ this.config.ignore.push(this.newPatternText.trim());
722
+ } else if (this.settingsSection === 'include') {
723
+ if (!this.config.include) this.config.include = [];
724
+ this.config.include.push(this.newPatternText.trim());
725
+ }
726
+ saveConfig(this.config);
727
+ this.applyFilters();
728
+ }
729
+ this.isAddingPattern = false;
730
+ this.newPatternText = '';
731
+ this.buildSettingsUI();
732
+ } else if (keyName === 'backspace') {
733
+ this.newPatternText = this.newPatternText.slice(0, -1);
734
+ this.buildSettingsUI();
735
+ } else if (keyName && keyName.length === 1 && !keyEvent.ctrl && !keyEvent.meta) {
736
+ this.newPatternText += keyName;
737
+ this.buildSettingsUI();
738
+ }
739
+ return;
740
+ }
741
+
742
+ // Normal settings navigation
743
+ if (keyName === 'escape' || keyName === 'q') {
744
+ // Return to previous phase
745
+ if (this.previousPhase === 'running') {
746
+ this.phase = 'running';
747
+ this.buildRunningUI();
748
+ } else {
749
+ this.phase = 'selection';
750
+ this.buildSelectionUI();
751
+ this.countdown = COUNTDOWN_SECONDS;
752
+ this.startCountdown();
753
+ }
754
+ } else if (keyName === 'tab' || keyName === 'right') {
755
+ // Switch section
756
+ const sections = ['ignore', 'include', 'scripts'];
757
+ const idx = sections.indexOf(this.settingsSection);
758
+ this.settingsSection = sections[(idx + 1) % sections.length];
759
+ this.settingsIndex = 0;
760
+ this.buildSettingsUI();
761
+ } else if (keyEvent.shift && keyName === 'tab') {
762
+ // Switch section backwards
763
+ const sections = ['ignore', 'include', 'scripts'];
764
+ const idx = sections.indexOf(this.settingsSection);
765
+ this.settingsSection = sections[(idx - 1 + sections.length) % sections.length];
766
+ this.settingsIndex = 0;
767
+ this.buildSettingsUI();
768
+ } else if (keyName === 'left') {
769
+ // Switch section backwards
770
+ const sections = ['ignore', 'include', 'scripts'];
771
+ const idx = sections.indexOf(this.settingsSection);
772
+ this.settingsSection = sections[(idx - 1 + sections.length) % sections.length];
773
+ this.settingsIndex = 0;
774
+ this.buildSettingsUI();
775
+ } else if (keyName === 'up') {
776
+ this.settingsIndex = Math.max(0, this.settingsIndex - 1);
777
+ this.buildSettingsUI();
778
+ } else if (keyName === 'down') {
779
+ const maxIndex = this.getSettingsMaxIndex();
780
+ this.settingsIndex = Math.min(maxIndex, this.settingsIndex + 1);
781
+ this.buildSettingsUI();
782
+ } else if (keyName === 'a') {
783
+ // Add new pattern (only for ignore/include sections)
784
+ if (this.settingsSection === 'ignore' || this.settingsSection === 'include') {
785
+ this.isAddingPattern = true;
786
+ this.newPatternText = '';
787
+ this.buildSettingsUI();
788
+ }
789
+ } else if (keyName === 'd' || keyName === 'backspace') {
790
+ // Delete selected pattern or toggle script ignore
791
+ this.deleteSelectedItem();
792
+ this.buildSettingsUI();
793
+ } else if (keyName === 'space' || keyName === 'enter' || keyName === 'return') {
794
+ // Toggle for scripts section
795
+ if (this.settingsSection === 'scripts') {
796
+ this.toggleScriptIgnore();
797
+ this.buildSettingsUI();
798
+ }
799
+ }
800
+ }
801
+
802
+ getSettingsMaxIndex() {
803
+ if (this.settingsSection === 'ignore') {
804
+ return Math.max(0, (this.config.ignore?.length || 0) - 1);
805
+ } else if (this.settingsSection === 'include') {
806
+ return Math.max(0, (this.config.include?.length || 0) - 1);
807
+ } else if (this.settingsSection === 'scripts') {
808
+ return Math.max(0, this.allScripts.length - 1);
809
+ }
810
+ return 0;
811
+ }
812
+
813
+ deleteSelectedItem() {
814
+ if (this.settingsSection === 'ignore' && this.config.ignore?.length > 0) {
815
+ this.config.ignore.splice(this.settingsIndex, 1);
816
+ if (this.config.ignore.length === 0) delete this.config.ignore;
817
+ saveConfig(this.config);
818
+ this.applyFilters();
819
+ this.settingsIndex = Math.max(0, Math.min(this.settingsIndex, (this.config.ignore?.length || 1) - 1));
820
+ } else if (this.settingsSection === 'include' && this.config.include?.length > 0) {
821
+ this.config.include.splice(this.settingsIndex, 1);
822
+ if (this.config.include.length === 0) delete this.config.include;
823
+ saveConfig(this.config);
824
+ this.applyFilters();
825
+ this.settingsIndex = Math.max(0, Math.min(this.settingsIndex, (this.config.include?.length || 1) - 1));
826
+ }
827
+ }
828
+
829
+ toggleScriptIgnore() {
830
+ const script = this.allScripts[this.settingsIndex];
831
+ if (!script) return;
832
+
833
+ if (!this.config.ignore) this.config.ignore = [];
834
+
835
+ const exactPattern = script.name;
836
+ const idx = this.config.ignore.indexOf(exactPattern);
837
+
838
+ if (idx >= 0) {
839
+ // Remove from ignore list
840
+ this.config.ignore.splice(idx, 1);
841
+ if (this.config.ignore.length === 0) delete this.config.ignore;
842
+ } else {
843
+ // Add to ignore list
844
+ this.config.ignore.push(exactPattern);
845
+ }
846
+
847
+ saveConfig(this.config);
848
+ this.applyFilters();
849
+ }
850
+
851
+ applyFilters() {
852
+ // Re-filter scripts based on current config
853
+ this.scripts = this.allScripts
854
+ .filter(s => isIncluded(s.name, this.config.include))
855
+ .filter(s => !isIgnored(s.name, this.config.ignore || []));
856
+
857
+ // Clean up selected scripts that are no longer visible
858
+ const visibleNames = new Set(this.scripts.map(s => s.name));
859
+ this.selectedScripts = new Set([...this.selectedScripts].filter(name => visibleNames.has(name)));
860
+
861
+ // Update default selection in config
862
+ this.config.defaultSelection = Array.from(this.selectedScripts);
863
+ saveConfig(this.config);
864
+ }
865
+
866
+ // Handle split mode commands (after Ctrl+b prefix)
867
+ handleSplitModeInput(keyName, keyEvent) {
868
+ this.splitMode = false; // Exit split mode after any key
869
+
870
+ if (keyName === 'escape') {
871
+ // Just cancel split mode
872
+ this.buildRunningUI();
873
+ return;
874
+ }
875
+
876
+ // % or | = vertical split (left/right)
877
+ if (keyName === '5' && keyEvent.shift) { // % key
878
+ this.splitCurrentPane('vertical');
879
+ } else if (keyName === '\\' && keyEvent.shift) { // | key
880
+ this.splitCurrentPane('vertical');
881
+ }
882
+ // " or - = horizontal split (top/bottom)
883
+ else if (keyName === "'" && keyEvent.shift) { // " key
884
+ this.splitCurrentPane('horizontal');
885
+ } else if (keyName === '-') {
886
+ this.splitCurrentPane('horizontal');
887
+ }
888
+ // Arrow keys = navigate between panes
889
+ else if (keyName === 'up' || keyName === 'down' || keyName === 'left' || keyName === 'right') {
890
+ this.navigatePaneByDirection(keyName);
891
+ }
892
+ // x = close current pane
893
+ else if (keyName === 'x') {
894
+ this.closeCurrentPane();
895
+ }
896
+ // m = move selected process to current pane
897
+ else if (keyName === 'm') {
898
+ this.moveProcessToCurrentPane();
899
+ }
900
+ // o = cycle through panes
901
+ else if (keyName === 'o') {
902
+ this.navigateToNextPane(1);
903
+ }
904
+
905
+ this.buildRunningUI();
906
+ }
907
+
908
+ // Handle command palette input
909
+ handleSplitMenuInput(keyName, keyEvent) {
910
+ const menuItems = this.getSplitMenuItems();
911
+
912
+ if (keyName === 'escape' || keyName === 'q') {
913
+ this.showSplitMenu = false;
914
+ this.buildRunningUI();
915
+ return;
916
+ }
917
+
918
+ if (keyName === 'up' || keyName === 'k') {
919
+ this.splitMenuIndex = Math.max(0, this.splitMenuIndex - 1);
920
+ this.buildRunningUI();
921
+ } else if (keyName === 'down' || keyName === 'j') {
922
+ this.splitMenuIndex = Math.min(menuItems.length - 1, this.splitMenuIndex + 1);
923
+ this.buildRunningUI();
924
+ } else if (keyName === 'enter' || keyName === 'return') {
925
+ const selectedItem = menuItems[this.splitMenuIndex];
926
+ if (selectedItem) {
927
+ selectedItem.action();
928
+ }
929
+ this.showSplitMenu = false;
930
+ this.buildRunningUI();
931
+ }
932
+ }
933
+
934
+ getSplitMenuItems() {
935
+ const allPanes = getAllPaneIds(this.paneRoot);
936
+ const items = [
937
+ { label: 'Split Vertical (left/right)', shortcut: '|', action: () => this.splitCurrentPane('vertical') },
938
+ { label: 'Split Horizontal (top/bottom)', shortcut: '_', action: () => this.splitCurrentPane('horizontal') },
939
+ ];
940
+
941
+ if (allPanes.length > 1) {
942
+ items.push({ label: 'Close Pane', shortcut: 'x', action: () => this.closeCurrentPane() });
943
+ items.push({ label: 'Next Pane', shortcut: 'Tab', action: () => this.navigateToNextPane(1) });
944
+ items.push({ label: 'Previous Pane', shortcut: 'Shift+Tab', action: () => this.navigateToNextPane(-1) });
945
+ }
946
+
947
+ return items;
948
+ }
949
+
950
+ // Split the currently focused pane
951
+ splitCurrentPane(direction) {
952
+ if (!this.focusedPaneId) return;
953
+
954
+ this.paneRoot = splitPane(this.paneRoot, this.focusedPaneId, direction);
955
+
956
+ // Focus the new pane (second child of the split)
957
+ const allPanes = getAllPaneIds(this.paneRoot);
958
+ const currentIdx = allPanes.indexOf(this.focusedPaneId);
959
+ if (currentIdx >= 0 && currentIdx + 1 < allPanes.length) {
960
+ this.focusedPaneId = allPanes[currentIdx + 1];
961
+ }
962
+
963
+ this.savePaneLayout();
964
+ }
965
+
966
+ // Close the currently focused pane
967
+ closeCurrentPane() {
968
+ if (!this.focusedPaneId) return;
969
+
970
+ const allPanes = getAllPaneIds(this.paneRoot);
971
+ if (allPanes.length <= 1) {
972
+ // Don't close the last pane
973
+ return;
974
+ }
975
+
976
+ // Find the next pane to focus
977
+ const currentIdx = allPanes.indexOf(this.focusedPaneId);
978
+ const nextIdx = currentIdx > 0 ? currentIdx - 1 : 1;
979
+ const nextPaneId = allPanes[nextIdx];
980
+
981
+ this.paneRoot = closePane(this.paneRoot, this.focusedPaneId);
982
+ this.focusedPaneId = nextPaneId;
983
+
984
+ this.savePaneLayout();
985
+ }
986
+
987
+ // Navigate to next/previous pane
988
+ navigateToNextPane(direction) {
989
+ const allPanes = getAllPaneIds(this.paneRoot);
990
+ if (allPanes.length <= 1) return;
991
+
992
+ const currentIdx = allPanes.indexOf(this.focusedPaneId);
993
+ let nextIdx = (currentIdx + direction + allPanes.length) % allPanes.length;
994
+ this.focusedPaneId = allPanes[nextIdx];
995
+ }
996
+
997
+ // Navigate pane by direction (up/down/left/right)
998
+ navigatePaneByDirection(direction) {
999
+ // For now, just cycle through panes
1000
+ // A more sophisticated implementation would use pane positions
1001
+ if (direction === 'right' || direction === 'down') {
1002
+ this.navigateToNextPane(1);
1003
+ } else {
1004
+ this.navigateToNextPane(-1);
1005
+ }
1006
+ }
1007
+
1008
+ // Move the currently selected process to the focused pane
1009
+ moveProcessToCurrentPane() {
1010
+ const scriptName = this.scripts[this.selectedIndex]?.name;
1011
+ if (!scriptName || !this.focusedPaneId) return;
1012
+
1013
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1014
+ if (!pane) return;
1015
+
1016
+ // If pane shows all processes (empty array), make it show only this one
1017
+ if (pane.processes.length === 0) {
1018
+ pane.processes = [scriptName];
1019
+ } else if (!pane.processes.includes(scriptName)) {
1020
+ pane.processes.push(scriptName);
1021
+ }
1022
+
1023
+ // Remove from other panes
1024
+ const allPanes = getAllPaneIds(this.paneRoot);
1025
+ for (const paneId of allPanes) {
1026
+ if (paneId !== this.focusedPaneId) {
1027
+ const otherPane = findPaneById(this.paneRoot, paneId);
1028
+ if (otherPane && otherPane.processes.length > 0) {
1029
+ otherPane.processes = otherPane.processes.filter(p => p !== scriptName);
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // Hide the selected process from the focused pane
1036
+ hideProcessFromCurrentPane() {
1037
+ const scriptName = this.scripts[this.selectedIndex]?.name;
1038
+ if (!scriptName || !this.focusedPaneId) return;
1039
+
1040
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1041
+ if (!pane) return;
1042
+
1043
+ // Initialize hidden array if needed
1044
+ if (!pane.hidden) pane.hidden = [];
1045
+
1046
+ // Add to hidden list if not already there
1047
+ if (!pane.hidden.includes(scriptName)) {
1048
+ pane.hidden.push(scriptName);
1049
+ }
1050
+
1051
+ // Also remove from processes list if it was explicitly added
1052
+ if (pane.processes.length > 0) {
1053
+ pane.processes = pane.processes.filter(p => p !== scriptName);
1054
+ }
1055
+ }
1056
+
1057
+ // Unhide/show the selected process in the focused pane
1058
+ unhideProcessInCurrentPane() {
1059
+ const scriptName = this.scripts[this.selectedIndex]?.name;
1060
+ if (!scriptName || !this.focusedPaneId) return;
1061
+
1062
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1063
+ if (!pane) return;
1064
+
1065
+ // Remove from hidden list
1066
+ if (pane.hidden) {
1067
+ pane.hidden = pane.hidden.filter(p => p !== scriptName);
1068
+ }
1069
+ }
1070
+
1071
+ // Toggle visibility of selected process in focused pane
1072
+ toggleProcessVisibility() {
1073
+ const scriptName = this.scripts[this.selectedIndex]?.name;
1074
+ if (!scriptName || !this.focusedPaneId) return;
1075
+
1076
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1077
+ if (!pane) return;
1078
+
1079
+ // Initialize hidden array if needed
1080
+ if (!pane.hidden) pane.hidden = [];
1081
+
1082
+ // Toggle: if hidden, show it; if visible, hide it
1083
+ if (pane.hidden.includes(scriptName)) {
1084
+ pane.hidden = pane.hidden.filter(p => p !== scriptName);
1085
+ } else {
1086
+ pane.hidden.push(scriptName);
1087
+ }
1088
+
1089
+ this.savePaneLayout();
1090
+ }
1091
+
1092
+ // Save the current pane layout to config
1093
+ savePaneLayout() {
1094
+ this.config.paneLayout = serializePaneTree(this.paneRoot);
1095
+ saveConfig(this.config);
1096
+ }
1097
+
1098
+ // Check if a process is visible in the focused pane
1099
+ isProcessVisibleInPane(scriptName, pane) {
1100
+ if (!pane) return true;
1101
+
1102
+ // If pane has specific processes, check if this one is included
1103
+ if (pane.processes.length > 0 && !pane.processes.includes(scriptName)) {
1104
+ return false;
1105
+ }
1106
+
1107
+ // Check if hidden
1108
+ if (pane.hidden && pane.hidden.includes(scriptName)) {
1109
+ return false;
1110
+ }
1111
+
1112
+ return true;
1113
+ }
1114
+
1115
+ // Count horizontal splits (which reduce available height per pane)
1116
+ // Get output lines for a specific pane
1117
+ getOutputLinesForPane(pane) {
1118
+ let lines = this.outputLines;
1119
+
1120
+ // Filter by processes assigned to this pane
1121
+ if (pane.processes.length > 0) {
1122
+ lines = lines.filter(line => pane.processes.includes(line.process));
1123
+ }
1124
+
1125
+ // Exclude hidden processes
1126
+ if (pane.hidden && pane.hidden.length > 0) {
1127
+ lines = lines.filter(line => !pane.hidden.includes(line.process));
1128
+ }
1129
+
1130
+ // Apply pane-specific text filter (from / or f command)
1131
+ if (pane.filter) {
1132
+ lines = lines.filter(line =>
1133
+ line.process.toLowerCase().includes(pane.filter.toLowerCase()) ||
1134
+ line.text.toLowerCase().includes(pane.filter.toLowerCase())
1135
+ );
1136
+ }
1137
+
1138
+ return lines;
1139
+ }
1140
+
1141
+ buildSettingsUI() {
1142
+ // Remove old containers - use destroyRecursively to clean up all children
1143
+ if (this.selectionContainer) {
1144
+ this.renderer.root.remove(this.selectionContainer);
1145
+ this.selectionContainer.destroyRecursively();
1146
+ this.selectionContainer = null;
1147
+ this.scriptLines = null;
1148
+ this.headerText = null;
1149
+ }
1150
+ if (this.settingsContainer) {
1151
+ this.renderer.root.remove(this.settingsContainer);
1152
+ this.settingsContainer.destroyRecursively();
1153
+ this.settingsContainer = null;
1154
+ }
1155
+ if (this.runningContainer) {
1156
+ this.renderer.root.remove(this.runningContainer);
1157
+ this.runningContainer.destroyRecursively();
1158
+ this.runningContainer = null;
1159
+ this.outputBox = null;
1160
+ }
1161
+
1162
+ // Create main container - full screen with dark background
1163
+ this.settingsContainer = new BoxRenderable(this.renderer, {
1164
+ id: 'settings-container',
1165
+ flexDirection: 'column',
1166
+ width: '100%',
1167
+ height: '100%',
1168
+ backgroundColor: COLORS.bg,
1169
+ padding: 1,
1170
+ });
1171
+
1172
+ // Header bar with title
1173
+ const headerBar = new BoxRenderable(this.renderer, {
1174
+ id: 'header-bar',
1175
+ flexDirection: 'row',
1176
+ justifyContent: 'space-between',
1177
+ width: '100%',
1178
+ border: ['bottom'],
1179
+ borderStyle: 'single',
1180
+ borderColor: COLORS.border,
1181
+ paddingBottom: 1,
1182
+ marginBottom: 1,
1183
+ });
1184
+
1185
+ const titleText = new TextRenderable(this.renderer, {
1186
+ id: 'title',
1187
+ content: t`${fg(COLORS.accent)('# Settings')}`,
1188
+ });
1189
+ headerBar.add(titleText);
1190
+
1191
+ const versionText = new TextRenderable(this.renderer, {
1192
+ id: 'version',
1193
+ content: t`${fg(COLORS.textDim)(APP_VERSION)}`,
1194
+ });
1195
+ headerBar.add(versionText);
1196
+
1197
+ this.settingsContainer.add(headerBar);
1198
+
1199
+ // Input prompt if adding pattern
1200
+ if (this.isAddingPattern) {
1201
+ const inputBar = new BoxRenderable(this.renderer, {
1202
+ id: 'input-bar',
1203
+ border: ['left'],
1204
+ borderStyle: 'single',
1205
+ borderColor: COLORS.accent,
1206
+ paddingLeft: 1,
1207
+ marginBottom: 1,
1208
+ });
1209
+ const inputText = new TextRenderable(this.renderer, {
1210
+ id: 'input-text',
1211
+ content: t`${fg(COLORS.textDim)('Add ' + this.settingsSection + ' pattern:')} ${fg(COLORS.text)(this.newPatternText)}${fg(COLORS.accent)('_')}`,
1212
+ });
1213
+ inputBar.add(inputText);
1214
+ this.settingsContainer.add(inputBar);
1215
+ }
1216
+
1217
+ // Section tabs
1218
+ const tabsContainer = new BoxRenderable(this.renderer, {
1219
+ id: 'tabs-container',
1220
+ flexDirection: 'row',
1221
+ gap: 2,
1222
+ marginBottom: 1,
1223
+ });
1224
+
1225
+ const sections = [
1226
+ { id: 'ignore', label: 'IGNORE' },
1227
+ { id: 'include', label: 'INCLUDE' },
1228
+ { id: 'scripts', label: 'SCRIPTS' },
1229
+ ];
1230
+
1231
+ sections.forEach(({ id, label }) => {
1232
+ const isActive = this.settingsSection === id;
1233
+ const tab = new TextRenderable(this.renderer, {
1234
+ id: `tab-${id}`,
1235
+ content: isActive
1236
+ ? t`${fg(COLORS.accent)('[' + label + ']')}`
1237
+ : t`${fg(COLORS.textDim)(' ' + label + ' ')}`,
1238
+ });
1239
+ tabsContainer.add(tab);
1240
+ });
1241
+
1242
+ this.settingsContainer.add(tabsContainer);
1243
+
1244
+ // Content panel with border
1245
+ const contentPanel = new BoxRenderable(this.renderer, {
1246
+ id: 'content-panel',
1247
+ flexDirection: 'column',
1248
+ border: true,
1249
+ borderStyle: 'rounded',
1250
+ borderColor: COLORS.border,
1251
+ title: ` ${this.settingsSection.charAt(0).toUpperCase() + this.settingsSection.slice(1)} `,
1252
+ titleAlignment: 'left',
1253
+ flexGrow: 1,
1254
+ padding: 1,
1255
+ });
1256
+
1257
+ // Section content
1258
+ if (this.settingsSection === 'ignore') {
1259
+ this.buildIgnoreSectionContent(contentPanel);
1260
+ } else if (this.settingsSection === 'include') {
1261
+ this.buildIncludeSectionContent(contentPanel);
1262
+ } else if (this.settingsSection === 'scripts') {
1263
+ this.buildScriptsSectionContent(contentPanel);
1264
+ }
1265
+
1266
+ this.settingsContainer.add(contentPanel);
1267
+
1268
+ // Footer bar with keyboard shortcuts
1269
+ const footerBar = new BoxRenderable(this.renderer, {
1270
+ id: 'footer-bar',
1271
+ flexDirection: 'row',
1272
+ width: '100%',
1273
+ border: ['top'],
1274
+ borderStyle: 'single',
1275
+ borderColor: COLORS.border,
1276
+ paddingTop: 1,
1277
+ marginTop: 1,
1278
+ gap: 2,
1279
+ });
1280
+
1281
+ const shortcuts = this.isAddingPattern
1282
+ ? [
1283
+ { key: 'enter', desc: 'save' },
1284
+ { key: 'esc', desc: 'cancel' },
1285
+ ]
1286
+ : [
1287
+ { key: 'tab', desc: 'section' },
1288
+ { key: 'a', desc: 'add' },
1289
+ { key: 'd', desc: 'delete' },
1290
+ { key: 'space', desc: 'toggle' },
1291
+ { key: 'esc', desc: 'back' },
1292
+ ];
1293
+
1294
+ shortcuts.forEach(({ key, desc }) => {
1295
+ const shortcut = new TextRenderable(this.renderer, {
1296
+ id: `shortcut-${key}`,
1297
+ content: t`${fg(COLORS.textDim)(key)} ${fg(COLORS.text)(desc)}`,
1298
+ });
1299
+ footerBar.add(shortcut);
1300
+ });
1301
+
1302
+ this.settingsContainer.add(footerBar);
1303
+
1304
+ this.renderer.root.add(this.settingsContainer);
1305
+ }
1306
+
1307
+ buildIgnoreSectionContent(container) {
1308
+ const desc = new TextRenderable(this.renderer, {
1309
+ id: 'ignore-desc',
1310
+ content: t`${fg(COLORS.textDim)('Patterns to exclude from script list. Use * as wildcard.')}`,
1311
+ });
1312
+ container.add(desc);
1313
+
1314
+ container.add(new TextRenderable(this.renderer, { id: 'spacer', content: '' }));
1315
+
1316
+ const patterns = this.config.ignore || [];
1317
+
1318
+ if (patterns.length === 0) {
1319
+ const empty = new TextRenderable(this.renderer, {
1320
+ id: 'ignore-empty',
1321
+ content: t`${fg(COLORS.textDim)('No ignore patterns defined. Press A to add.')}`,
1322
+ });
1323
+ container.add(empty);
1324
+ } else {
1325
+ patterns.forEach((pattern, idx) => {
1326
+ const isFocused = idx === this.settingsIndex;
1327
+ const indicator = isFocused ? '>' : ' ';
1328
+
1329
+ const line = new TextRenderable(this.renderer, {
1330
+ id: `ignore-pattern-${idx}`,
1331
+ content: t`${fg(isFocused ? COLORS.accent : COLORS.textDim)(indicator)} ${fg(COLORS.error)(pattern)}`,
1332
+ });
1333
+ container.add(line);
1334
+ });
1335
+ }
1336
+ }
1337
+
1338
+ buildIncludeSectionContent(container) {
1339
+ const desc = new TextRenderable(this.renderer, {
1340
+ id: 'include-desc',
1341
+ content: t`${fg(COLORS.textDim)('Only show scripts matching these patterns. Use * as wildcard.')}`,
1342
+ });
1343
+ container.add(desc);
1344
+
1345
+ container.add(new TextRenderable(this.renderer, { id: 'spacer', content: '' }));
1346
+
1347
+ const patterns = this.config.include || [];
1348
+
1349
+ if (patterns.length === 0) {
1350
+ const empty = new TextRenderable(this.renderer, {
1351
+ id: 'include-empty',
1352
+ content: t`${fg(COLORS.textDim)('No include patterns (all scripts shown). Press A to add.')}`,
1353
+ });
1354
+ container.add(empty);
1355
+ } else {
1356
+ patterns.forEach((pattern, idx) => {
1357
+ const isFocused = idx === this.settingsIndex;
1358
+ const indicator = isFocused ? '>' : ' ';
1359
+
1360
+ const line = new TextRenderable(this.renderer, {
1361
+ id: `include-pattern-${idx}`,
1362
+ content: t`${fg(isFocused ? COLORS.accent : COLORS.textDim)(indicator)} ${fg(COLORS.success)(pattern)}`,
1363
+ });
1364
+ container.add(line);
1365
+ });
1366
+ }
1367
+ }
1368
+
1369
+ buildScriptsSectionContent(container) {
1370
+ const desc = new TextRenderable(this.renderer, {
1371
+ id: 'scripts-desc',
1372
+ content: t`${fg(COLORS.textDim)('Toggle individual scripts. Ignored scripts are hidden from selection.')}`,
1373
+ });
1374
+ container.add(desc);
1375
+
1376
+ container.add(new TextRenderable(this.renderer, { id: 'spacer', content: '' }));
1377
+
1378
+ const ignorePatterns = this.config.ignore || [];
1379
+
1380
+ this.allScripts.forEach((script, idx) => {
1381
+ const isIgnored = ignorePatterns.includes(script.name);
1382
+ const isFocused = idx === this.settingsIndex;
1383
+ const indicator = isFocused ? '>' : ' ';
1384
+ const checkbox = isIgnored ? '[x]' : '[ ]';
1385
+ const checkColor = isIgnored ? COLORS.error : COLORS.success;
1386
+ const processColor = this.processColors.get(script.name) || COLORS.text;
1387
+ const nameColor = isIgnored ? COLORS.textDim : processColor;
1388
+
1389
+ const line = new TextRenderable(this.renderer, {
1390
+ id: `script-toggle-${idx}`,
1391
+ content: t`${fg(isFocused ? COLORS.accent : COLORS.textDim)(indicator)} ${fg(checkColor)(checkbox)} ${fg(nameColor)(script.displayName)}${isIgnored ? t` ${fg(COLORS.textDim)('(ignored)')}` : ''}`,
1392
+ });
1393
+ container.add(line);
1394
+ });
1395
+ }
1396
+
401
1397
  updateStreamPauseState() {
402
1398
  // Pause or resume all process stdout/stderr streams
403
1399
  for (const proc of this.processRefs.values()) {
@@ -413,79 +1409,158 @@ class ProcessManager {
413
1409
  }
414
1410
  }
415
1411
 
416
- cleanup() {
417
- for (const [scriptName, proc] of this.processRefs.entries()) {
418
- try {
419
- if (proc.pid) {
420
- kill(proc.pid, 'SIGKILL');
421
- }
422
- } catch (err) {
423
- // Ignore
424
- }
425
- }
426
- }
1412
+ cleanup() {
1413
+ this.destroyed = true;
1414
+
1415
+ // Stop the countdown interval
1416
+ if (this.countdownInterval) {
1417
+ clearInterval(this.countdownInterval);
1418
+ this.countdownInterval = null;
1419
+ }
1420
+
1421
+ for (const [scriptName, proc] of this.processRefs.entries()) {
1422
+ try {
1423
+ if (proc.pid) {
1424
+ kill(proc.pid, 'SIGKILL');
1425
+ }
1426
+ } catch (err) {
1427
+ // Ignore
1428
+ }
1429
+ }
1430
+ }
427
1431
 
428
- buildSelectionUI() {
429
- // Remove old container if it exists
430
- if (this.selectionContainer) {
431
- this.renderer.root.remove(this.selectionContainer);
432
- this.selectionContainer.destroy();
433
- }
434
-
435
- // Create container
436
- this.selectionContainer = new BoxRenderable(this.renderer, {
437
- id: 'selection-container',
438
- flexDirection: 'column',
439
- padding: 1,
440
- });
441
-
442
- // Create header
443
- this.headerText = new TextRenderable(this.renderer, {
444
- id: 'header',
445
- content: this.getHeaderText(),
446
- fg: '#00FFFF',
447
- });
448
- this.selectionContainer.add(this.headerText);
449
-
450
- // Empty line
451
- this.selectionContainer.add(new TextRenderable(this.renderer, {
452
- id: 'spacer',
453
- content: '',
454
- }));
455
-
456
- // Create script lines with colors
457
- // Starting Y position is: padding (1) + header (1) + spacer (1) = 3
458
- // But we need to account for 0-based indexing, so it's actually row 3 (0-indexed: 2)
459
- let currentY = 4; // padding + header + empty line + 1 for 1-based terminal coords
460
- this.scriptLinePositions = [];
461
-
462
- this.scriptLines = this.scripts.map((script, index) => {
463
- const isSelected = this.selectedScripts.has(script.name);
464
- const isFocused = index === this.selectedIndex;
465
- const prefix = isFocused ? '▶' : ' ';
466
- const checkbox = isSelected ? '✓' : ' ';
467
- const processColor = this.processColors.get(script.name) || '#FFFFFF';
468
- const prefixColor = isFocused ? '#00FFFF' : '#FFFFFF';
469
-
470
- // Build styled content
471
- const content = t`${fg(prefixColor)(prefix)} [${checkbox}] ${fg(processColor)(script.displayName)}`;
472
-
473
- const line = new TextRenderable(this.renderer, {
474
- id: `script-${index}`,
475
- content: content,
476
- });
477
- this.selectionContainer.add(line);
478
- this.scriptLinePositions.push(currentY);
479
- currentY++;
480
- return line;
481
- });
482
-
483
- this.renderer.root.add(this.selectionContainer);
484
- }
1432
+ buildSelectionUI() {
1433
+ // Remove old containers if they exist - use destroyRecursively to clean up all children
1434
+ if (this.selectionContainer) {
1435
+ this.renderer.root.remove(this.selectionContainer);
1436
+ this.selectionContainer.destroyRecursively();
1437
+ this.selectionContainer = null;
1438
+ this.scriptLines = null;
1439
+ this.headerText = null;
1440
+ }
1441
+ if (this.settingsContainer) {
1442
+ this.renderer.root.remove(this.settingsContainer);
1443
+ this.settingsContainer.destroyRecursively();
1444
+ this.settingsContainer = null;
1445
+ }
1446
+ if (this.runningContainer) {
1447
+ this.renderer.root.remove(this.runningContainer);
1448
+ this.runningContainer.destroyRecursively();
1449
+ this.runningContainer = null;
1450
+ this.outputBox = null;
1451
+ }
1452
+
1453
+ // Create main container - full screen with dark background
1454
+ this.selectionContainer = new BoxRenderable(this.renderer, {
1455
+ id: 'selection-container',
1456
+ flexDirection: 'column',
1457
+ width: '100%',
1458
+ height: '100%',
1459
+ backgroundColor: COLORS.bg,
1460
+ });
1461
+
1462
+ // Scripts panel - compact with background for focused item
1463
+ const scriptsPanel = new BoxRenderable(this.renderer, {
1464
+ id: 'scripts-panel',
1465
+ flexDirection: 'column',
1466
+ flexGrow: 1,
1467
+ paddingLeft: 1,
1468
+ paddingTop: 1,
1469
+ });
1470
+
1471
+ // Track Y positions for mouse clicks
1472
+ let currentY = 1; // start of scripts
1473
+ this.scriptLinePositions = [];
1474
+
1475
+ this.scriptLines = this.scripts.map((script, index) => {
1476
+ const isSelected = this.selectedScripts.has(script.name);
1477
+ const isFocused = index === this.selectedIndex;
1478
+ const checkIcon = isSelected ? '●' : '○';
1479
+ const checkColor = isSelected ? COLORS.success : COLORS.textDim;
1480
+ const processColor = this.processColors.get(script.name) || COLORS.text;
1481
+ const nameColor = isFocused ? COLORS.text : processColor;
1482
+ const bgColor = isFocused ? COLORS.bgHighlight : null;
1483
+
1484
+ // Build styled content - all in one template, no nesting
1485
+ const content = t`${fg(checkColor)(checkIcon)} ${fg(nameColor)(script.displayName)}`;
1486
+
1487
+ const lineContainer = new BoxRenderable(this.renderer, {
1488
+ id: `script-box-${index}`,
1489
+ backgroundColor: bgColor,
1490
+ paddingLeft: 1,
1491
+ width: '100%',
1492
+ });
1493
+
1494
+ const line = new TextRenderable(this.renderer, {
1495
+ id: `script-${index}`,
1496
+ content: content,
1497
+ });
1498
+ lineContainer.add(line);
1499
+ scriptsPanel.add(lineContainer);
1500
+ this.scriptLinePositions.push(currentY);
1501
+ currentY++;
1502
+ return lineContainer;
1503
+ });
1504
+
1505
+ this.selectionContainer.add(scriptsPanel);
1506
+
1507
+ // Footer bar with title, countdown, and shortcuts
1508
+ const footerBar = new BoxRenderable(this.renderer, {
1509
+ id: 'footer-bar',
1510
+ flexDirection: 'row',
1511
+ justifyContent: 'space-between',
1512
+ width: '100%',
1513
+ backgroundColor: COLORS.bgLight,
1514
+ paddingLeft: 1,
1515
+ paddingRight: 1,
1516
+ });
1517
+
1518
+ // Left side: title and countdown
1519
+ const leftSide = new BoxRenderable(this.renderer, {
1520
+ id: 'footer-left',
1521
+ flexDirection: 'row',
1522
+ gap: 2,
1523
+ });
1524
+
1525
+ const titleText = new TextRenderable(this.renderer, {
1526
+ id: 'title',
1527
+ content: t`${fg(COLORS.accent)('startall')} ${fg(COLORS.warning)(this.countdown + 's')}`,
1528
+ });
1529
+ leftSide.add(titleText);
1530
+ this.headerText = titleText; // Save reference for countdown updates
1531
+
1532
+ footerBar.add(leftSide);
1533
+
1534
+ // Right side: shortcuts
1535
+ const rightSide = new BoxRenderable(this.renderer, {
1536
+ id: 'footer-right',
1537
+ flexDirection: 'row',
1538
+ gap: 2,
1539
+ });
1540
+
1541
+ const shortcuts = [
1542
+ { key: 'spc', desc: 'sel', color: COLORS.success },
1543
+ { key: 'ret', desc: 'go', color: COLORS.accent },
1544
+ { key: 'c', desc: 'cfg', color: COLORS.magenta },
1545
+ ];
1546
+
1547
+ shortcuts.forEach(({ key, desc, color }) => {
1548
+ const shortcut = new TextRenderable(this.renderer, {
1549
+ id: `shortcut-${key}`,
1550
+ content: t`${fg(color)(key)}${fg(COLORS.textDim)(':' + desc)}`,
1551
+ });
1552
+ rightSide.add(shortcut);
1553
+ });
1554
+
1555
+ footerBar.add(rightSide);
1556
+ this.selectionContainer.add(footerBar);
1557
+
1558
+ this.renderer.root.add(this.selectionContainer);
1559
+ }
485
1560
 
486
- getHeaderText() {
487
- return `Starting in ${this.countdown}s... [Click or Space to toggle, Enter to start, Ctrl+C to quit]`;
488
- }
1561
+ getHeaderText() {
1562
+ return `Starting in ${this.countdown}s...`;
1563
+ }
489
1564
 
490
1565
  getScriptLineText(script, index) {
491
1566
  const isSelected = this.selectedScripts.has(script.name);
@@ -503,21 +1578,26 @@ class ProcessManager {
503
1578
  return index === this.selectedIndex ? '#00FFFF' : '#FFFFFF';
504
1579
  }
505
1580
 
506
- updateSelectionUI() {
507
- // Rebuild the entire UI to update colors and selection state
508
- // This is simpler and more reliable with OpenTUI Core
509
- this.buildSelectionUI();
510
- }
1581
+ updateSelectionUI() {
1582
+ // Rebuild UI each time - simpler and more reliable with the new structure
1583
+ this.buildSelectionUI();
1584
+ }
511
1585
 
512
- render() {
513
- if (this.phase === 'selection') {
514
- // For selection phase, just update the text content
515
- this.updateSelectionUI();
516
- } else if (this.phase === 'running') {
517
- // For running phase, only update output, don't rebuild entire UI
518
- this.updateRunningUI();
519
- }
520
- }
1586
+ render() {
1587
+ // Don't render if destroyed
1588
+ if (this.destroyed) return;
1589
+
1590
+ if (this.phase === 'selection') {
1591
+ // For selection phase, just update the text content
1592
+ this.updateSelectionUI();
1593
+ } else if (this.phase === 'settings') {
1594
+ // Settings UI is rebuilt on each input
1595
+ // No-op here as buildSettingsUI handles everything
1596
+ } else if (this.phase === 'running') {
1597
+ // For running phase, only update output, don't rebuild entire UI
1598
+ this.updateRunningUI();
1599
+ }
1600
+ }
521
1601
 
522
1602
  getProcessListContent() {
523
1603
  // Build process list content dynamically for any number of processes
@@ -642,165 +1722,366 @@ class ProcessManager {
642
1722
  this.buildRunningUI();
643
1723
  }
644
1724
 
645
- buildRunningUI() {
646
- // Remove old containers if they exist
647
- if (this.selectionContainer) {
648
- this.renderer.root.remove(this.selectionContainer);
649
- this.selectionContainer.destroy();
650
- this.selectionContainer = null;
651
- }
652
- if (this.runningContainer) {
653
- this.renderer.root.remove(this.runningContainer);
654
- this.runningContainer.destroy();
655
- }
656
-
657
- // Create main container - full screen
658
- const mainContainer = new BoxRenderable(this.renderer, {
659
- id: 'running-container',
660
- flexDirection: 'column',
661
- width: '100%',
662
- height: '100%',
663
- padding: 1,
664
- });
665
-
666
- // Header with status
667
- const selectedScript = this.scripts[this.selectedIndex];
668
- const selectedName = selectedScript ? selectedScript.displayName : '';
669
- const pauseIndicator = this.isPaused ? ' [PAUSED]' : '';
670
- const filterIndicator = this.isFilterMode ? ` [FILTER: ${this.filter}_]` : (this.filter ? ` [FILTER: ${this.filter}]` : '');
671
- const headerText = `[←→: Navigate | Space: Pause | S: Stop | R: Restart | F: Filter Selected | /: Filter Text | Q: Quit] ${selectedName}${pauseIndicator}${filterIndicator}`;
672
- this.headerRenderable = new TextRenderable(this.renderer, {
673
- id: 'running-header',
674
- content: headerText,
675
- fg: '#00FFFF',
676
- });
677
- mainContainer.add(this.headerRenderable);
678
-
679
- // Empty line
680
- mainContainer.add(new TextRenderable(this.renderer, {
681
- id: 'spacer1',
682
- content: '',
683
- }));
684
-
685
- // Track positions for mouse clicks
686
- let currentY = 4; // padding + header + spacer + 1 for 1-based coords
687
- this.scriptLinePositions = [];
688
-
689
- // Process list - compact horizontal layout with all processes
690
- // Create a container to hold all process items in a row
691
- const processListContainer = new BoxRenderable(this.renderer, {
692
- id: 'process-list-container',
693
- flexDirection: 'row',
694
- gap: 2,
695
- });
696
-
697
- // Add each process as a separate text element
698
- this.scripts.forEach((script, index) => {
699
- const proc = this.processes.get(script.name);
700
- const status = proc?.status || 'stopped';
701
- const icon = status === 'running' ? '●' : status === 'crashed' ? '✖' : '○';
702
- const statusColor = status === 'running' ? '#00FF00' : status === 'crashed' ? '#FF0000' : '#666666';
703
- const processColor = this.processColors.get(script.name) || '#FFFFFF';
704
- const prefix = this.selectedIndex === index ? '▶' : '';
705
-
706
- const processItem = new TextRenderable(this.renderer, {
707
- id: `process-item-${index}`,
708
- content: t`${prefix}${fg(processColor)(script.displayName)} ${fg(statusColor)(icon)}`,
709
- });
710
- processListContainer.add(processItem);
711
- });
712
-
713
- this.processListRenderable = processListContainer;
714
- mainContainer.add(this.processListRenderable);
715
- currentY++;
716
-
717
- // Empty line separator
718
- mainContainer.add(new TextRenderable(this.renderer, {
719
- id: 'spacer2',
720
- content: '',
721
- }));
722
-
723
- // Output section header
724
- const outputHeader = new TextRenderable(this.renderer, {
725
- id: 'output-header',
726
- content: 'Output',
727
- fg: '#00FFFF',
728
- });
729
- mainContainer.add(outputHeader);
730
-
731
- // Calculate available height for output
732
- // Header (1) + spacer (1) + process-list (1) + spacer (1) + output header (1) = 5 lines used
733
- const usedLines = 5;
734
- const availableHeight = Math.max(10, this.renderer.height - usedLines - 2); // -2 for padding
735
-
736
- // Create output container
737
- // Use ScrollBoxRenderable when paused (to allow scrolling), BoxRenderable when not paused
738
- if (this.outputBox) {
739
- this.outputBox.destroy();
740
- }
741
-
742
- if (this.isPaused) {
743
- // When paused, use ScrollBoxRenderable to allow scrolling through all history
744
- this.outputBox = new ScrollBoxRenderable(this.renderer, {
745
- id: 'output-box',
746
- flexGrow: 1,
747
- showScrollbar: true, // Show scrollbar when paused
748
- });
749
- } else {
750
- // When not paused, use regular BoxRenderable (no scrollbar needed)
751
- this.outputBox = new BoxRenderable(this.renderer, {
752
- id: 'output-box',
753
- flexDirection: 'column',
754
- flexGrow: 1,
755
- overflow: 'hidden',
756
- });
757
- }
758
-
759
- // Add output lines to scrollbox in reverse order (newest first)
760
- const filteredLines = this.filter
761
- ? this.outputLines.filter(line =>
762
- line.process.toLowerCase().includes(this.filter.toLowerCase()) ||
763
- line.text.toLowerCase().includes(this.filter.toLowerCase())
764
- )
765
- : this.outputLines;
766
-
767
- // Decide which lines to show
768
- let linesToShow;
769
- if (this.isPaused) {
770
- // When paused, show all lines (scrollable)
771
- linesToShow = filteredLines;
772
- } else {
773
- // When not paused, only show most recent N lines
774
- linesToShow = filteredLines.slice(-this.maxVisibleLines);
775
- }
776
-
777
- // Add lines in reverse order (newest first)
778
- for (let i = linesToShow.length - 1; i >= 0; i--) {
779
- const line = linesToShow[i];
780
- const processColor = this.processColors.get(line.process) || '#FFFFFF';
781
-
782
- // Truncate long lines to prevent wrapping (terminal width - prefix length - padding)
783
- const maxWidth = Math.max(40, this.renderer.width - line.process.length - 10);
784
- const truncatedText = line.text.length > maxWidth
785
- ? line.text.substring(0, maxWidth - 3) + '...'
786
- : line.text;
787
-
788
- const outputLine = new TextRenderable(this.renderer, {
789
- id: `output-${i}`,
790
- content: t`${fg(processColor)(`[${line.process}]`)} ${truncatedText}`,
791
- });
792
- this.outputBox.add(outputLine);
793
- }
794
-
795
- this.lastRenderedLineCount = filteredLines.length;
796
- this.wasPaused = this.isPaused;
797
-
798
- mainContainer.add(this.outputBox);
799
-
800
- this.renderer.root.add(mainContainer);
801
- this.runningContainer = mainContainer;
802
- }
803
- }
1725
+ // Build a single pane's output area
1726
+ buildPaneOutput(pane, container, height) {
1727
+ const isFocused = pane.id === this.focusedPaneId;
1728
+ const lines = this.getOutputLinesForPane(pane);
1729
+
1730
+ // Calculate visible lines - use global pause state
1731
+ const outputHeight = Math.max(3, height - 2);
1732
+ let linesToShow = this.isPaused ? lines : lines.slice(-outputHeight);
1733
+
1734
+ // Add lines in reverse order (newest first)
1735
+ for (let i = linesToShow.length - 1; i >= 0; i--) {
1736
+ const line = linesToShow[i];
1737
+ const processColor = this.processColors.get(line.process) || COLORS.text;
1738
+
1739
+ const maxWidth = Math.max(20, this.renderer.width / 2 - line.process.length - 10);
1740
+ const visibleLength = stripAnsi(line.text).length;
1741
+ let truncatedText = line.text;
1742
+ if (visibleLength > maxWidth) {
1743
+ let visible = 0;
1744
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
1745
+ let lastIndex = 0;
1746
+ let result = '';
1747
+ let match;
1748
+ const text = line.text;
1749
+ while ((match = ansiRegex.exec(text)) !== null) {
1750
+ const before = text.slice(lastIndex, match.index);
1751
+ for (const char of before) {
1752
+ if (visible >= maxWidth - 3) break;
1753
+ result += char;
1754
+ visible++;
1755
+ }
1756
+ if (visible >= maxWidth - 3) break;
1757
+ result += match[0];
1758
+ lastIndex = ansiRegex.lastIndex;
1759
+ }
1760
+ if (visible < maxWidth - 3) {
1761
+ const remaining = text.slice(lastIndex);
1762
+ for (const char of remaining) {
1763
+ if (visible >= maxWidth - 3) break;
1764
+ result += char;
1765
+ visible++;
1766
+ }
1767
+ }
1768
+ truncatedText = result + '\x1b[0m...';
1769
+ }
1770
+
1771
+ const outputLine = new TextRenderable(this.renderer, {
1772
+ id: `output-${pane.id}-${i}`,
1773
+ content: t`${fg(processColor)(`[${line.process}]`)} ${truncatedText}`,
1774
+ });
1775
+ container.add(outputLine);
1776
+ }
1777
+ }
1778
+
1779
+ // Build a pane panel with title bar
1780
+ buildPanePanel(pane, flexGrow = 1, availableHeight = null) {
1781
+ const isFocused = pane.id === this.focusedPaneId;
1782
+ const borderColor = isFocused ? COLORS.borderFocused : COLORS.border;
1783
+
1784
+ // Title shows assigned processes or "All", plus filter and hidden count
1785
+ const processLabel = pane.processes.length > 0
1786
+ ? pane.processes.join(', ')
1787
+ : 'All';
1788
+ const focusLabel = isFocused ? '*' : '';
1789
+ const hiddenCount = pane.hidden?.length || 0;
1790
+ const hiddenLabel = hiddenCount > 0 ? ` -${hiddenCount}` : '';
1791
+ const filterLabel = pane.filter ? ` /${pane.filter}` : '';
1792
+ const filterInputLabel = (isFocused && this.isFilterMode) ? `/${pane.filter || ''}_` : '';
1793
+ const title = ` ${focusLabel}${processLabel}${hiddenLabel}${filterInputLabel || filterLabel} `;
1794
+
1795
+ const paneContainer = new BoxRenderable(this.renderer, {
1796
+ id: `pane-${pane.id}`,
1797
+ flexDirection: 'column',
1798
+ flexGrow: flexGrow,
1799
+ border: true,
1800
+ borderStyle: 'rounded',
1801
+ borderColor: borderColor,
1802
+ title: title,
1803
+ titleAlignment: 'left',
1804
+ padding: 0,
1805
+ overflow: 'hidden',
1806
+ });
1807
+
1808
+ // Output content - always use BoxRenderable for consistent sizing
1809
+ const outputBox = new BoxRenderable(this.renderer, {
1810
+ id: `pane-output-${pane.id}`,
1811
+ flexDirection: 'column',
1812
+ flexGrow: 1,
1813
+ overflow: 'hidden',
1814
+ paddingLeft: 1,
1815
+ });
1816
+
1817
+ // Use passed height or calculate default
1818
+ const height = availableHeight ? Math.max(5, availableHeight - 2) : Math.max(5, this.renderer.height - 6);
1819
+ this.buildPaneOutput(pane, outputBox, height);
1820
+
1821
+ paneContainer.add(outputBox);
1822
+ return paneContainer;
1823
+ }
1824
+
1825
+ // Recursively build the pane layout, passing available height down
1826
+ buildPaneLayout(node, flexGrow = 1, availableHeight = null) {
1827
+ if (!node) return null;
1828
+
1829
+ // Default available height (screen minus header/footer)
1830
+ if (availableHeight === null) {
1831
+ availableHeight = this.renderer.height - 2;
1832
+ }
1833
+
1834
+ if (node.type === 'pane') {
1835
+ return this.buildPanePanel(node, flexGrow, availableHeight);
1836
+ }
1837
+
1838
+ // It's a split node
1839
+ const container = new BoxRenderable(this.renderer, {
1840
+ id: `split-${node.direction}`,
1841
+ flexDirection: node.direction === 'vertical' ? 'row' : 'column',
1842
+ flexGrow: flexGrow,
1843
+ gap: 0,
1844
+ });
1845
+
1846
+ // Calculate child heights - only horizontal splits divide height
1847
+ const childCount = node.children.length;
1848
+ const childHeight = node.direction === 'horizontal'
1849
+ ? Math.floor(availableHeight / childCount)
1850
+ : availableHeight; // vertical splits don't reduce height
1851
+
1852
+ node.children.forEach((child, idx) => {
1853
+ const childElement = this.buildPaneLayout(child, node.sizes[idx], childHeight);
1854
+ if (childElement) {
1855
+ container.add(childElement);
1856
+ }
1857
+ });
1858
+
1859
+ return container;
1860
+ }
1861
+
1862
+ // Build command palette overlay
1863
+ buildSplitMenuOverlay(parent) {
1864
+ const menuItems = this.getSplitMenuItems();
1865
+
1866
+ // Create centered overlay
1867
+ const overlay = new BoxRenderable(this.renderer, {
1868
+ id: 'split-menu-overlay',
1869
+ position: 'absolute',
1870
+ top: '30%',
1871
+ left: '30%',
1872
+ width: '40%',
1873
+ backgroundColor: COLORS.bgLight,
1874
+ border: true,
1875
+ borderStyle: 'rounded',
1876
+ borderColor: COLORS.accent,
1877
+ title: ' Command Palette ',
1878
+ padding: 1,
1879
+ flexDirection: 'column',
1880
+ });
1881
+
1882
+ menuItems.forEach((item, idx) => {
1883
+ const isFocused = idx === this.splitMenuIndex;
1884
+ const indicator = isFocused ? '>' : ' ';
1885
+ const bgColor = isFocused ? COLORS.bgHighlight : null;
1886
+
1887
+ const itemContainer = new BoxRenderable(this.renderer, {
1888
+ id: `menu-item-${idx}`,
1889
+ backgroundColor: bgColor,
1890
+ paddingLeft: 1,
1891
+ });
1892
+
1893
+ const itemText = new TextRenderable(this.renderer, {
1894
+ id: `menu-text-${idx}`,
1895
+ content: t`${fg(isFocused ? COLORS.accent : COLORS.textDim)(indicator)} ${fg(COLORS.text)(item.label)} ${fg(COLORS.textDim)(`(${item.shortcut})`)}`,
1896
+ });
1897
+
1898
+ itemContainer.add(itemText);
1899
+ overlay.add(itemContainer);
1900
+ });
1901
+
1902
+ // Footer hint
1903
+ const hint = new TextRenderable(this.renderer, {
1904
+ id: 'menu-hint',
1905
+ content: t`${fg(COLORS.textDim)('Enter to select, Esc to close')}`,
1906
+ });
1907
+ overlay.add(hint);
1908
+
1909
+ parent.add(overlay);
1910
+ }
1911
+
1912
+ buildRunningUI() {
1913
+ // Remove old containers if they exist - use destroyRecursively to clean up all children
1914
+ if (this.selectionContainer) {
1915
+ this.renderer.root.remove(this.selectionContainer);
1916
+ this.selectionContainer.destroyRecursively();
1917
+ this.selectionContainer = null;
1918
+ this.scriptLines = null;
1919
+ this.headerText = null;
1920
+ }
1921
+ if (this.settingsContainer) {
1922
+ this.renderer.root.remove(this.settingsContainer);
1923
+ this.settingsContainer.destroyRecursively();
1924
+ this.settingsContainer = null;
1925
+ }
1926
+ if (this.runningContainer) {
1927
+ this.renderer.root.remove(this.runningContainer);
1928
+ this.runningContainer.destroyRecursively();
1929
+ this.runningContainer = null;
1930
+ }
1931
+ // Clear outputBox reference since it was destroyed with runningContainer
1932
+ this.outputBox = null;
1933
+
1934
+ // Create main container - full screen with dark background
1935
+ const mainContainer = new BoxRenderable(this.renderer, {
1936
+ id: 'running-container',
1937
+ flexDirection: 'column',
1938
+ width: '100%',
1939
+ height: '100%',
1940
+ backgroundColor: COLORS.bg,
1941
+ });
1942
+
1943
+ // Process tabs at top
1944
+ const processBar = new BoxRenderable(this.renderer, {
1945
+ id: 'process-bar',
1946
+ flexDirection: 'row',
1947
+ width: '100%',
1948
+ backgroundColor: COLORS.bgLight,
1949
+ paddingLeft: 1,
1950
+ });
1951
+
1952
+ // Pane count indicator
1953
+ const allPanes = getAllPaneIds(this.paneRoot);
1954
+ if (allPanes.length > 1) {
1955
+ const paneIndicator = new TextRenderable(this.renderer, {
1956
+ id: 'pane-indicator',
1957
+ content: t`${fg(COLORS.cyan)(`[${allPanes.length} panes]`)} `,
1958
+ });
1959
+ processBar.add(paneIndicator);
1960
+ }
1961
+
1962
+ // Add each process with checkbox showing visibility in focused pane
1963
+ const focusedPane = findPaneById(this.paneRoot, this.focusedPaneId);
1964
+
1965
+ this.scripts.forEach((script, index) => {
1966
+ const proc = this.processes.get(script.name);
1967
+ const status = proc?.status || 'stopped';
1968
+ const statusIcon = status === 'running' ? '●' : status === 'crashed' ? '!' : '○';
1969
+ const statusColor = status === 'running' ? COLORS.success : status === 'crashed' ? COLORS.error : COLORS.textDim;
1970
+ const processColor = this.processColors.get(script.name) || COLORS.text;
1971
+ const isSelected = this.selectedIndex === index;
1972
+ const isVisible = this.isProcessVisibleInPane(script.name, focusedPane);
1973
+ const checkbox = isVisible ? '[x]' : '[ ]';
1974
+ const nameColor = isSelected ? COLORS.accent : (isVisible ? processColor : COLORS.textDim);
1975
+ const indicator = isSelected ? '>' : ' ';
1976
+
1977
+ const processItem = new TextRenderable(this.renderer, {
1978
+ id: `process-item-${index}`,
1979
+ content: t`${fg(isSelected ? COLORS.accent : COLORS.textDim)(indicator)}${fg(isVisible ? COLORS.text : COLORS.textDim)(checkbox)} ${fg(nameColor)(script.displayName)} ${fg(statusColor)(statusIcon)}`,
1980
+ });
1981
+ processBar.add(processItem);
1982
+ });
1983
+
1984
+ this.processListRenderable = processBar;
1985
+ mainContainer.add(processBar);
1986
+
1987
+ // Build pane layout
1988
+ const paneArea = new BoxRenderable(this.renderer, {
1989
+ id: 'pane-area',
1990
+ flexDirection: 'column',
1991
+ flexGrow: 1,
1992
+ backgroundColor: COLORS.bg,
1993
+ });
1994
+
1995
+ const paneLayout = this.buildPaneLayout(this.paneRoot);
1996
+ if (paneLayout) {
1997
+ paneArea.add(paneLayout);
1998
+ }
1999
+
2000
+ mainContainer.add(paneArea);
2001
+
2002
+ // Footer bar - compact style matching selection UI
2003
+ const footerBar = new BoxRenderable(this.renderer, {
2004
+ id: 'footer-bar',
2005
+ flexDirection: 'row',
2006
+ width: '100%',
2007
+ backgroundColor: COLORS.bgLight,
2008
+ paddingLeft: 1,
2009
+ paddingRight: 1,
2010
+ justifyContent: 'space-between',
2011
+ });
2012
+
2013
+ // Left side: status indicator and filter
2014
+ const leftSide = new BoxRenderable(this.renderer, {
2015
+ id: 'footer-left',
2016
+ flexDirection: 'row',
2017
+ gap: 2,
2018
+ });
2019
+
2020
+ // Status (LIVE/PAUSED)
2021
+ const statusText = this.isPaused ? 'PAUSED' : 'LIVE';
2022
+ const statusColor = this.isPaused ? COLORS.warning : COLORS.success;
2023
+ const statusIndicator = new TextRenderable(this.renderer, {
2024
+ id: 'status-indicator',
2025
+ content: t`${fg(statusColor)(statusText)}`,
2026
+ });
2027
+ leftSide.add(statusIndicator);
2028
+
2029
+ // Filter indicator if active
2030
+ if (this.filter || this.isFilterMode) {
2031
+ const filterText = this.isFilterMode ? `/${this.filter}_` : `/${this.filter}`;
2032
+ const filterIndicator = new TextRenderable(this.renderer, {
2033
+ id: 'filter-indicator',
2034
+ content: t`${fg(COLORS.cyan)(filterText)}`,
2035
+ });
2036
+ leftSide.add(filterIndicator);
2037
+ }
2038
+
2039
+ footerBar.add(leftSide);
2040
+
2041
+ // Right side: shortcuts and title
2042
+ const rightSide = new BoxRenderable(this.renderer, {
2043
+ id: 'footer-right',
2044
+ flexDirection: 'row',
2045
+ gap: 2,
2046
+ });
2047
+
2048
+ const shortcuts = [
2049
+ { key: '\\', desc: 'panes', color: COLORS.cyan },
2050
+ { key: 'spc', desc: 'toggle', color: COLORS.success },
2051
+ { key: 'p', desc: 'pause', color: COLORS.warning },
2052
+ { key: '/', desc: 'filter', color: COLORS.cyan },
2053
+ { key: 's', desc: 'stop', color: COLORS.error },
2054
+ { key: 'r', desc: 'restart', color: COLORS.success },
2055
+ { key: 'q', desc: 'quit', color: COLORS.error },
2056
+ ];
2057
+
2058
+ shortcuts.forEach(({ key, desc, color }) => {
2059
+ const shortcut = new TextRenderable(this.renderer, {
2060
+ id: `shortcut-${key}`,
2061
+ content: t`${fg(color)(key)}${fg(COLORS.textDim)(':' + desc)}`,
2062
+ });
2063
+ rightSide.add(shortcut);
2064
+ });
2065
+
2066
+ // Title and version on far right
2067
+ const titleText = new TextRenderable(this.renderer, {
2068
+ id: 'footer-title',
2069
+ content: t`${fg(COLORS.accent)('running')} ${fg(COLORS.textDim)(APP_VERSION)}`,
2070
+ });
2071
+ rightSide.add(titleText);
2072
+
2073
+ footerBar.add(rightSide);
2074
+ mainContainer.add(footerBar);
2075
+
2076
+ // Add command palette overlay if active
2077
+ if (this.showSplitMenu) {
2078
+ this.buildSplitMenuOverlay(mainContainer);
2079
+ }
2080
+
2081
+ this.renderer.root.add(mainContainer);
2082
+ this.runningContainer = mainContainer;
2083
+ }
2084
+ }
804
2085
 
805
2086
  // Main
806
2087
  async function main() {
@@ -822,12 +2103,15 @@ async function main() {
822
2103
  const renderer = await createCliRenderer();
823
2104
  const manager = new ProcessManager(renderer, scripts);
824
2105
 
825
- // Handle cleanup on exit
826
- process.on('SIGINT', () => {
827
- manager.cleanup();
828
- renderer.destroy();
829
- });
830
- }
2106
+ // Handle cleanup on exit
2107
+ const handleExit = () => {
2108
+ manager.cleanup();
2109
+ renderer.destroy();
2110
+ };
2111
+
2112
+ process.on('SIGINT', handleExit);
2113
+ process.on('SIGTERM', handleExit);
2114
+ }
831
2115
 
832
2116
  main().catch(err => {
833
2117
  console.error('Error:', err);