startall 0.0.2 → 0.0.8

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