startall 0.0.24 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +672 -343
  2. package/package.json +4 -4
package/index.js CHANGED
@@ -1,11 +1,184 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { createCliRenderer, TextRenderable, BoxRenderable, ScrollBoxRenderable, ASCIIFontRenderable, t, fg, bold, dim, RGBA } from '@opentui/core';
3
+ import { createCliRenderer, TextRenderable, BoxRenderable, ScrollBoxRenderable, ASCIIFontRenderable, TextareaRenderable, SyntaxStyle, parseColor, t, fg, bg, bold, dim, RGBA, StyledText, OptimizedBuffer } from '@opentui/core';
4
4
  import { spawn, execSync, spawnSync } from 'child_process';
5
5
  import { readFileSync, writeFileSync, writeSync, existsSync } from 'fs';
6
6
  import { join } from 'path';
7
7
  import kill from 'tree-kill';
8
8
  import stripAnsi from 'strip-ansi';
9
+ import { Database } from 'bun:sqlite';
10
+ import { StringDecoder } from 'string_decoder';
11
+
12
+ // SQLite-backed output line storage
13
+ class OutputDatabase {
14
+ constructor(maxLines = 1000) {
15
+ this.db = new Database(':memory:');
16
+ this.maxLines = maxLines;
17
+
18
+ // Enable WAL mode for better concurrent read/write performance
19
+ this.db.exec('PRAGMA journal_mode = WAL');
20
+
21
+ // Create the output_lines table with colors bitmask for efficient filtering
22
+ this.db.exec(`
23
+ CREATE TABLE output_lines (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ line_number INTEGER NOT NULL,
26
+ process TEXT NOT NULL,
27
+ process_lower TEXT NOT NULL,
28
+ text TEXT NOT NULL,
29
+ text_lower TEXT NOT NULL,
30
+ timestamp INTEGER NOT NULL,
31
+ colors INTEGER NOT NULL DEFAULT 0
32
+ )
33
+ `);
34
+ this.db.exec('CREATE INDEX idx_line_number ON output_lines(line_number)');
35
+ this.db.exec('CREATE INDEX idx_process ON output_lines(process)');
36
+ this.db.exec('CREATE INDEX idx_colors ON output_lines(colors)');
37
+
38
+ // Column list with aliases to match existing JS property names
39
+ this._columns = 'id, line_number AS lineNumber, process, process_lower AS processLower, text, text_lower AS textLower, timestamp, colors';
40
+
41
+ // Prepare reusable statements for performance
42
+ this._insertStmt = this.db.prepare(
43
+ 'INSERT INTO output_lines (line_number, process, process_lower, text, text_lower, timestamp, colors) VALUES (?, ?, ?, ?, ?, ?, ?)'
44
+ );
45
+ this._countStmt = this.db.prepare('SELECT COUNT(*) AS cnt FROM output_lines');
46
+ this._deleteOldStmt = this.db.prepare(
47
+ 'DELETE FROM output_lines WHERE id NOT IN (SELECT id FROM output_lines ORDER BY id DESC LIMIT ?)'
48
+ );
49
+ this._selectAllStmt = this.db.prepare(`SELECT ${this._columns} FROM output_lines ORDER BY id ASC`);
50
+ this._clearStmt = this.db.prepare('DELETE FROM output_lines');
51
+ // Fast path for queryVisible with no filters
52
+ this._queryVisibleNoFilterStmt = this.db.prepare(`SELECT ${this._columns} FROM (
53
+ SELECT * FROM output_lines ORDER BY id DESC LIMIT ?
54
+ ) ORDER BY id ASC`);
55
+ }
56
+
57
+ insert(lineNumber, process, text, timestamp, colorBitmask) {
58
+ this._insertStmt.run(lineNumber, process, process.toLowerCase(), text, text.toLowerCase(), timestamp, colorBitmask);
59
+
60
+ // Enforce max lines limit
61
+ const { cnt } = this._countStmt.get();
62
+ if (cnt > this.maxLines) {
63
+ this._deleteOldStmt.run(this.maxLines);
64
+ }
65
+ }
66
+
67
+ getAll() {
68
+ return this._selectAllStmt.all();
69
+ }
70
+
71
+ count() {
72
+ return this._countStmt.get().cnt;
73
+ }
74
+
75
+ clear() {
76
+ this._clearStmt.run();
77
+ }
78
+
79
+ // Build SQL conditions and params for pane filters
80
+ _buildPaneFilters(pane) {
81
+ const conditions = [];
82
+ const params = [];
83
+
84
+ if (pane.processes && pane.processes.length > 0) {
85
+ const placeholders = pane.processes.map(() => '?').join(', ');
86
+ conditions.push(`process IN (${placeholders})`);
87
+ params.push(...pane.processes);
88
+ }
89
+
90
+ if (pane.hidden && pane.hidden.length > 0) {
91
+ const placeholders = pane.hidden.map(() => '?').join(', ');
92
+ conditions.push(`process NOT IN (${placeholders})`);
93
+ params.push(...pane.hidden);
94
+ }
95
+
96
+ if (pane.filter) {
97
+ const filterLower = pane.filter.toLowerCase();
98
+ conditions.push('(process_lower LIKE ? OR text_lower LIKE ?)');
99
+ params.push(`%${filterLower}%`, `%${filterLower}%`);
100
+ }
101
+
102
+ if (pane.colorFilter) {
103
+ // Use bitmask check: (colors & bit) != 0
104
+ const colorBit = this._getColorBit(pane.colorFilter);
105
+ if (colorBit > 0) {
106
+ conditions.push('(colors & ?) != 0');
107
+ params.push(colorBit);
108
+ }
109
+ }
110
+
111
+ return { conditions, params };
112
+ }
113
+
114
+ // Get color bitmask value for a color name
115
+ _getColorBit(colorName) {
116
+ const bits = { red: 1, yellow: 2, green: 4, blue: 8, cyan: 16, magenta: 32 };
117
+ return bits[colorName] || 0;
118
+ }
119
+
120
+ // Query with pane filters applied via SQL
121
+ queryForPane(pane) {
122
+ const { conditions, params } = this._buildPaneFilters(pane);
123
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
124
+ const sql = `SELECT ${this._columns} FROM output_lines ${where} ORDER BY id ASC`;
125
+ return this.db.prepare(sql).all(...params);
126
+ }
127
+
128
+ // Query lines newer than a given line_number, with pane filters
129
+ queryNewLines(afterLineNumber, pane, limit) {
130
+ const { conditions, params } = this._buildPaneFilters(pane);
131
+ conditions.unshift('line_number > ?');
132
+ params.unshift(afterLineNumber);
133
+
134
+ const where = `WHERE ${conditions.join(' AND ')}`;
135
+ // Get newest lines up to limit, but return them in ascending order
136
+ const sql = `SELECT ${this._columns} FROM (SELECT * FROM output_lines ${where} ORDER BY id DESC LIMIT ?) ORDER BY id ASC`;
137
+ params.push(limit);
138
+
139
+ return this.db.prepare(sql).all(...params);
140
+ }
141
+
142
+ // Count lines matching pane filters (for calculating max scroll)
143
+ countForPane(pane) {
144
+ const { conditions, params } = this._buildPaneFilters(pane);
145
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
146
+ const sql = `SELECT COUNT(*) AS cnt FROM output_lines ${where}`;
147
+ return this.db.prepare(sql).get(...params).cnt;
148
+ }
149
+
150
+ // Query visible lines for virtual scrolling: get `limit` lines starting from `offset` from the end
151
+ // offset=0 means the most recent lines, offset=10 means 10 lines back from the end
152
+ queryVisible(pane, limit, offsetFromEnd) {
153
+ const { conditions, params } = this._buildPaneFilters(pane);
154
+ const totalToFetch = limit + offsetFromEnd;
155
+
156
+ let rows;
157
+ if (conditions.length === 0) {
158
+ // Fast path: no filters, use prepared statement
159
+ rows = this._queryVisibleNoFilterStmt.all(totalToFetch);
160
+ } else {
161
+ // Slow path: build query with filters
162
+ const where = `WHERE ${conditions.join(' AND ')}`;
163
+ const sql = `SELECT ${this._columns} FROM (
164
+ SELECT * FROM output_lines ${where} ORDER BY id DESC LIMIT ?
165
+ ) ORDER BY id ASC`;
166
+ params.push(totalToFetch);
167
+ rows = this.db.prepare(sql).all(...params);
168
+ }
169
+
170
+ // If offset > 0, trim the newest lines
171
+ if (offsetFromEnd > 0 && rows.length > limit) {
172
+ rows = rows.slice(0, limit);
173
+ }
174
+
175
+ return rows;
176
+ }
177
+
178
+ close() {
179
+ this.db.close();
180
+ }
181
+ }
9
182
 
10
183
  // Configuration
11
184
  const CONFIG_FILE = process.argv[2] || 'startall.json';
@@ -93,34 +266,65 @@ function getAllPaneIds(node, ids = []) {
93
266
  return ids;
94
267
  }
95
268
 
269
+ // Color bitmask values for efficient SQL filtering
270
+ const COLOR_BITS = {
271
+ red: 1, // 0b000001
272
+ yellow: 2, // 0b000010
273
+ green: 4, // 0b000100
274
+ blue: 8, // 0b001000
275
+ cyan: 16, // 0b010000
276
+ magenta: 32, // 0b100000
277
+ };
278
+
279
+ // ANSI color codes (normal and bright variants)
280
+ const ANSI_COLOR_CODES = {
281
+ red: [31, 91],
282
+ yellow: [33, 93],
283
+ green: [32, 92],
284
+ blue: [34, 94],
285
+ cyan: [36, 96],
286
+ magenta: [35, 95],
287
+ };
288
+
96
289
  // Get ANSI color codes for a color name (includes normal and bright variants)
97
290
  function getAnsiColorCodes(colorName) {
98
- const colorMap = {
99
- red: [31, 91], // normal red, bright red
100
- yellow: [33, 93], // normal yellow, bright yellow (warnings)
101
- green: [32, 92], // normal green, bright green
102
- blue: [34, 94], // normal blue, bright blue
103
- cyan: [36, 96], // normal cyan, bright cyan
104
- magenta: [35, 95], // normal magenta, bright magenta
105
- };
106
- return colorMap[colorName] || [];
291
+ return ANSI_COLOR_CODES[colorName] || [];
292
+ }
293
+
294
+ // Check if text contains a specific ANSI color code
295
+ function textHasColorCode(text, code) {
296
+ // Check for direct color code: \x1b[31m
297
+ if (text.includes(`\x1b[${code}m`)) return true;
298
+ // Check for color with modifiers: \x1b[1;31m, \x1b[0;31m, etc.
299
+ if (text.includes(`;${code}m`)) return true;
300
+ // Check for color at start of sequence: \x1b[31;1m
301
+ if (text.includes(`\x1b[${code};`)) return true;
302
+ return false;
107
303
  }
108
304
 
109
305
  // Check if a line contains a specific ANSI color
110
306
  function lineHasColor(text, colorName) {
111
307
  const codes = getAnsiColorCodes(colorName);
112
- // Match ANSI escape sequences like \x1b[31m, \x1b[91m, \x1b[1;31m, etc.
113
308
  for (const code of codes) {
114
- // Check for direct color code: \x1b[31m
115
- if (text.includes(`\x1b[${code}m`)) return true;
116
- // Check for color with modifiers: \x1b[1;31m, \x1b[0;31m, etc.
117
- if (text.includes(`;${code}m`)) return true;
118
- // Check for color at start of sequence: \x1b[31;1m
119
- if (text.includes(`\x1b[${code};`)) return true;
309
+ if (textHasColorCode(text, code)) return true;
120
310
  }
121
311
  return false;
122
312
  }
123
313
 
314
+ // Detect all colors in text and return bitmask
315
+ function detectColorBitmask(text) {
316
+ let bitmask = 0;
317
+ for (const [colorName, codes] of Object.entries(ANSI_COLOR_CODES)) {
318
+ for (const code of codes) {
319
+ if (textHasColorCode(text, code)) {
320
+ bitmask |= COLOR_BITS[colorName];
321
+ break; // Found this color, no need to check other codes for it
322
+ }
323
+ }
324
+ }
325
+ return bitmask;
326
+ }
327
+
124
328
  // Find parent of a node
125
329
  function findParent(root, targetId, parent = null) {
126
330
  if (!root) return null;
@@ -550,10 +754,10 @@ class ProcessManager {
550
754
  this.selectedIndex = 0;
551
755
  this.processes = new Map();
552
756
  this.processRefs = new Map();
553
- this.outputLines = [];
757
+ this.maxOutputLines = 1000; // Lines kept in database
758
+ this.outputDb = new OutputDatabase(this.maxOutputLines);
554
759
  this.totalLinesReceived = 0; // Track total lines ever received (never resets)
555
760
  this.filter = '';
556
- this.maxOutputLines = 1000; // Lines kept in memory
557
761
  this.maxDomLines = 150; // Lines kept in DOM (buffer for varying heights)
558
762
  this.lineRenderables = new Map(); // Reusable TextRenderables per pane
559
763
  this.maxVisibleLines = null; // Calculated dynamically based on screen height
@@ -590,11 +794,12 @@ class ProcessManager {
590
794
  this.outputBox = null; // Reference to the output container
591
795
  this.destroyed = false; // Flag to prevent operations after cleanup
592
796
  this.lastRenderedLineCount = 0; // Track how many lines we've rendered
797
+ this.hasNewLines = false; // Flag set when new lines are added, cleared after render
593
798
  this.headerRenderable = null; // Reference to header text in running UI
594
799
  this.processListRenderable = null; // Reference to process list text in running UI
595
800
  this.renderScheduled = false; // Throttle renders for CPU efficiency
596
801
  this.lastRenderTime = 0; // Timestamp of last render
597
- this.minRenderInterval = 100; // Minimum ms between renders (~10fps cap)
802
+ this.minRenderInterval = 1; // Minimum ms between renders (~60fps cap)
598
803
 
599
804
  // Performance metrics
600
805
  this.showPerformanceMetrics = this.config.showPerformanceMetrics || false;
@@ -609,16 +814,28 @@ class ProcessManager {
609
814
  this.splitMode = false; // Whether waiting for split command after Ctrl+b
610
815
  this.showSplitMenu = false; // Whether to show the command palette
611
816
  this.splitMenuIndex = 0; // Selected item in split menu
612
- this.paneScrollPositions = new Map(); // Store scroll positions per pane ID
613
- this.paneScrollBoxes = new Map(); // Store ScrollBox references per pane ID
817
+ this.paneScrollPositions = new Map(); // Store scroll positions per pane ID (legacy)
818
+ this.paneScrollBoxes = new Map(); // Store ScrollBox references per pane ID (legacy)
614
819
  this.paneFilterState = new Map(); // Track filter state per pane to detect changes
615
820
  this.paneLineCount = new Map(); // Track how many lines we've rendered per pane
616
821
  this.uiJustRebuilt = false; // Flag to skip redundant render after buildRunningUI
617
822
 
823
+ // Virtual scrolling state
824
+ this.paneScrollOffsets = new Map(); // Scroll offset from end per pane (0 = at bottom)
825
+ this.paneVisibleHeight = new Map(); // Visible height per pane for scroll calculations
826
+ this.paneOutputBoxes = new Map(); // Store output BoxRenderable references per pane
827
+
618
828
  // Column view state (one pane per script, side by side)
619
829
  this.isColumnView = false; // Whether column view is active
620
830
  this.savedPaneRoot = null; // Saved pane tree to restore when toggling back
621
831
 
832
+ // Line format cache (keyed by line ID + settings hash)
833
+ this.lineFormatCache = new Map();
834
+ this.lineFormatCacheKey = ''; // Cache key based on display settings
835
+
836
+ // Pane content cache (keyed by pane ID -> {lastLineId, styledText})
837
+ this.paneContentCache = new Map();
838
+
622
839
  // Copy mode state (select text to copy)
623
840
  this.isCopyMode = false; // Whether in copy/select mode
624
841
  this.copyModeCursor = 0; // Current cursor line index within visible lines
@@ -673,13 +890,27 @@ class ProcessManager {
673
890
  return;
674
891
  }
675
892
 
676
- // Handle Ctrl+L - clear screen buffer and redraw
893
+ // Handle Ctrl+L - clear screen and redraw (kills artifacts)
677
894
  if (key.ctrl && key.name === 'l') {
678
- if (this.phase === 'running') {
679
- this.outputLines = [];
680
- this.totalLinesReceived = 0;
681
- this.buildRunningUI();
895
+ // Debounce - cancel any pending wipe and restart
896
+ if (this.wipeTimeout) {
897
+ clearTimeout(this.wipeTimeout);
682
898
  }
899
+
900
+ // Save current phase and show wipe screen
901
+ const savedPhase = this.phase;
902
+ this.buildWipeScreen();
903
+
904
+ this.wipeTimeout = setTimeout(() => {
905
+ this.wipeTimeout = null;
906
+ if (savedPhase === 'running') {
907
+ this.buildRunningUI();
908
+ } else if (savedPhase === 'selection') {
909
+ this.buildSelectionUI();
910
+ } else if (savedPhase === 'settings') {
911
+ this.buildSettingsUI();
912
+ }
913
+ }, 50);
683
914
  return;
684
915
  }
685
916
 
@@ -874,13 +1105,13 @@ class ProcessManager {
874
1105
  this.buildRunningUI();
875
1106
  } else if (keyName === 'p') {
876
1107
  // Toggle pause output scrolling globally
877
- // Save scroll positions before rebuild
878
- for (const [paneId, scrollBox] of this.paneScrollBoxes.entries()) {
879
- if (scrollBox && scrollBox.scrollY !== undefined) {
880
- this.paneScrollPositions.set(paneId, { x: 0, y: scrollBox.scrollY });
1108
+ this.isPaused = !this.isPaused;
1109
+ // Reset scroll offset to bottom when unpausing
1110
+ if (!this.isPaused) {
1111
+ for (const paneId of getAllPaneIds(this.paneRoot)) {
1112
+ this.paneScrollOffsets.set(paneId, 0);
881
1113
  }
882
1114
  }
883
- this.isPaused = !this.isPaused;
884
1115
  this.updateStreamPauseState();
885
1116
  this.buildRunningUI();
886
1117
  } else if (keyName === 'f') {
@@ -1103,13 +1334,16 @@ class ProcessManager {
1103
1334
  } else {
1104
1335
  this.paneRoot = createPane([]); // Empty array means show all processes
1105
1336
  }
1106
- this.focusedPaneId = this.paneRoot.id;
1337
+ // Focus the first actual pane (paneRoot might be a split node)
1338
+ const allPanes = getAllPaneIds(this.paneRoot);
1339
+ this.focusedPaneId = allPanes.length > 0 ? allPanes[0] : this.paneRoot.id;
1107
1340
 
1108
1341
  selected.forEach(scriptName => {
1109
1342
  this.startProcess(scriptName);
1110
1343
  });
1111
1344
 
1112
- this.render();
1345
+ // Build the UI immediately (don't rely on render() since hasNewLines may be false)
1346
+ this.buildRunningUI();
1113
1347
  }
1114
1348
 
1115
1349
  startProcess(scriptName) {
@@ -1125,9 +1359,19 @@ class ProcessManager {
1125
1359
  shell: true,
1126
1360
  });
1127
1361
 
1362
+ // Use StringDecoder to properly handle multi-byte UTF-8 characters
1363
+ // that may be split across data chunks
1364
+ const stdoutDecoder = new StringDecoder('utf8');
1365
+ const stderrDecoder = new StringDecoder('utf8');
1366
+ let stdoutBuffer = '';
1367
+ let stderrBuffer = '';
1368
+
1128
1369
  proc.stdout.on('data', (data) => {
1129
- const text = data.toString();
1130
- const lines = text.split('\n');
1370
+ // Decode properly handles partial multi-byte characters
1371
+ stdoutBuffer += stdoutDecoder.write(data);
1372
+ const lines = stdoutBuffer.split('\n');
1373
+ // Keep the last incomplete line in the buffer
1374
+ stdoutBuffer = lines.pop();
1131
1375
  lines.forEach(line => {
1132
1376
  if (line.trim()) {
1133
1377
  this.addOutputLine(scriptName, line);
@@ -1136,8 +1380,11 @@ class ProcessManager {
1136
1380
  });
1137
1381
 
1138
1382
  proc.stderr.on('data', (data) => {
1139
- const text = data.toString();
1140
- const lines = text.split('\n');
1383
+ // Decode properly handles partial multi-byte characters
1384
+ stderrBuffer += stderrDecoder.write(data);
1385
+ const lines = stderrBuffer.split('\n');
1386
+ // Keep the last incomplete line in the buffer
1387
+ stderrBuffer = lines.pop();
1141
1388
  lines.forEach(line => {
1142
1389
  if (line.trim()) {
1143
1390
  this.addOutputLine(scriptName, line);
@@ -1146,6 +1393,16 @@ class ProcessManager {
1146
1393
  });
1147
1394
 
1148
1395
  proc.on('exit', (code) => {
1396
+ // Flush any remaining buffered content from the decoders
1397
+ const remainingStdout = stdoutBuffer + stdoutDecoder.end();
1398
+ const remainingStderr = stderrBuffer + stderrDecoder.end();
1399
+ if (remainingStdout.trim()) {
1400
+ this.addOutputLine(scriptName, remainingStdout);
1401
+ }
1402
+ if (remainingStderr.trim()) {
1403
+ this.addOutputLine(scriptName, remainingStderr);
1404
+ }
1405
+
1149
1406
  const status = code === 0 ? 'exited' : 'crashed';
1150
1407
  this.processes.set(scriptName, { status, exitCode: code });
1151
1408
  this.addOutputLine(scriptName, `Process exited with code ${code}`);
@@ -1156,22 +1413,25 @@ class ProcessManager {
1156
1413
  }
1157
1414
 
1158
1415
  addOutputLine(processName, text) {
1159
- // Always store the output line, even when paused
1160
- // Pre-compute lowercase for faster filtering
1161
- this.outputLines.push({
1162
- process: processName,
1163
- processLower: processName.toLowerCase(),
1164
- text,
1165
- textLower: text.toLowerCase(),
1166
- timestamp: Date.now(),
1167
- lineNumber: ++this.totalLinesReceived, // Track absolute line number
1168
- });
1416
+ // Don't write if database is closed
1417
+ if (this.destroyed) return;
1169
1418
 
1170
- // Use shift() instead of slice() to avoid creating new array
1171
- while (this.outputLines.length > this.maxOutputLines) {
1172
- this.outputLines.shift();
1173
- }
1419
+ // Detect colors in the text for efficient SQL filtering
1420
+ const colorBitmask = detectColorBitmask(text);
1174
1421
 
1422
+ // Always store the output line, even when paused
1423
+ // Insert into SQLite database
1424
+ this.outputDb.insert(
1425
+ ++this.totalLinesReceived,
1426
+ processName,
1427
+ text,
1428
+ Date.now(),
1429
+ colorBitmask
1430
+ );
1431
+
1432
+ // Mark that new lines are available
1433
+ this.hasNewLines = true;
1434
+
1175
1435
  // Only render if not paused - this prevents new output from appearing
1176
1436
  // when the user is reviewing history
1177
1437
  if (!this.isPaused) {
@@ -1820,19 +2080,38 @@ class ProcessManager {
1820
2080
  // Toggle visibility of selected process in focused pane
1821
2081
  toggleProcessVisibility() {
1822
2082
  const scriptName = this.scripts[this.selectedIndex]?.name;
1823
- if (!scriptName || !this.focusedPaneId) return;
2083
+ if (!scriptName || !this.focusedPaneId) {
2084
+ return;
2085
+ }
1824
2086
 
1825
2087
  const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1826
- if (!pane) return;
2088
+ if (!pane) {
2089
+ return;
2090
+ }
1827
2091
 
1828
- // Initialize hidden array if needed
2092
+ // Initialize arrays if needed
1829
2093
  if (!pane.hidden) pane.hidden = [];
2094
+ if (!pane.processes) pane.processes = [];
1830
2095
 
1831
- // Toggle: if hidden, show it; if visible, hide it
1832
- if (pane.hidden.includes(scriptName)) {
1833
- pane.hidden = pane.hidden.filter(p => p !== scriptName);
2096
+ // Check current visibility
2097
+ const isCurrentlyVisible = this.isProcessVisibleInPane(scriptName, pane);
2098
+
2099
+ if (isCurrentlyVisible) {
2100
+ // Hide it - add to hidden list
2101
+ if (!pane.hidden.includes(scriptName)) {
2102
+ pane.hidden.push(scriptName);
2103
+ }
2104
+ // Also remove from processes if it's there
2105
+ if (pane.processes.includes(scriptName)) {
2106
+ pane.processes = pane.processes.filter(p => p !== scriptName);
2107
+ }
1834
2108
  } else {
1835
- pane.hidden.push(scriptName);
2109
+ // Show it - remove from hidden list
2110
+ pane.hidden = pane.hidden.filter(p => p !== scriptName);
2111
+ // If pane has a process filter, add this process to it
2112
+ if (pane.processes.length > 0 && !pane.processes.includes(scriptName)) {
2113
+ pane.processes.push(scriptName);
2114
+ }
1836
2115
  }
1837
2116
 
1838
2117
  this.savePaneLayout();
@@ -1846,38 +2125,46 @@ class ProcessManager {
1846
2125
  debouncedSaveConfig(this.config);
1847
2126
  }
1848
2127
 
1849
- // Scroll the focused pane
2128
+ // Scroll the focused pane (virtual scrolling - adjusts offset into database)
1850
2129
  scrollFocusedPane(direction) {
1851
2130
  if (!this.focusedPaneId) return;
1852
2131
 
1853
- const scrollBox = this.paneScrollBoxes.get(this.focusedPaneId);
1854
- if (!scrollBox || !scrollBox.scrollTo) return;
2132
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
2133
+ if (!pane) return;
1855
2134
 
1856
- const currentY = scrollBox.scrollTop || 0;
1857
- const viewportHeight = scrollBox.height || 20;
1858
- const contentHeight = scrollBox.contentHeight || 0;
2135
+ const totalLines = this.outputDb.countForPane(pane);
2136
+ const visibleHeight = this.paneVisibleHeight.get(this.focusedPaneId) || 20;
2137
+ const currentOffset = this.paneScrollOffsets.get(this.focusedPaneId) || 0;
2138
+ const maxOffset = Math.max(0, totalLines - visibleHeight);
1859
2139
 
1860
- let newY = currentY;
2140
+ let newOffset = currentOffset;
1861
2141
 
1862
2142
  if (direction === 'home') {
1863
- newY = 0;
2143
+ // Scroll to top (maximum offset from end)
2144
+ newOffset = maxOffset;
1864
2145
  } else if (direction === 'end') {
1865
- newY = Number.MAX_SAFE_INTEGER;
2146
+ // Scroll to bottom (offset 0 = most recent)
2147
+ newOffset = 0;
1866
2148
  } else if (direction === 'pageup') {
1867
- newY = Math.max(0, currentY - viewportHeight);
2149
+ newOffset = Math.min(maxOffset, currentOffset + visibleHeight);
1868
2150
  } else if (direction === 'pagedown') {
1869
- newY = Math.min(contentHeight - viewportHeight, currentY + viewportHeight);
2151
+ newOffset = Math.max(0, currentOffset - visibleHeight);
2152
+ } else if (direction === 'up') {
2153
+ newOffset = Math.min(maxOffset, currentOffset + 1);
2154
+ } else if (direction === 'down') {
2155
+ newOffset = Math.max(0, currentOffset - 1);
1870
2156
  }
1871
2157
 
1872
- scrollBox.scrollTo({ x: 0, y: newY });
1873
-
1874
- // Save the new scroll position
1875
- this.paneScrollPositions.set(this.focusedPaneId, { x: 0, y: newY });
1876
-
1877
- // Auto-pause when manually scrolling (unless going to end)
1878
- if (direction !== 'end' && !this.isPaused) {
1879
- this.isPaused = true;
1880
- this.updateStreamPauseState();
2158
+ // Only update if changed
2159
+ if (newOffset !== currentOffset) {
2160
+ this.paneScrollOffsets.set(this.focusedPaneId, newOffset);
2161
+
2162
+ // Auto-pause when manually scrolling (unless going to end)
2163
+ if (direction !== 'end' && !this.isPaused) {
2164
+ this.isPaused = true;
2165
+ this.updateStreamPauseState();
2166
+ }
2167
+
1881
2168
  this.buildRunningUI();
1882
2169
  }
1883
2170
  }
@@ -2098,51 +2385,18 @@ class ProcessManager {
2098
2385
  }
2099
2386
 
2100
2387
  // Count horizontal splits (which reduce available height per pane)
2101
- // Get output lines for a specific pane - optimized single-pass filtering
2388
+ // Get output lines for a specific pane - queries SQLite with filters
2102
2389
  getOutputLinesForPane(pane) {
2103
- // Early return if no filters active
2104
- const hasProcessFilter = pane.processes.length > 0;
2105
- const hasHiddenFilter = pane.hidden && pane.hidden.length > 0;
2106
- const hasTextFilter = !!pane.filter;
2107
- const hasColorFilter = !!pane.colorFilter;
2108
-
2109
- if (!hasProcessFilter && !hasHiddenFilter && !hasTextFilter && !hasColorFilter) {
2110
- return this.outputLines;
2111
- }
2112
-
2113
- // Build Sets for O(1) lookups
2114
- const processSet = hasProcessFilter ? new Set(pane.processes) : null;
2115
- const hiddenSet = hasHiddenFilter ? new Set(pane.hidden) : null;
2116
- const filterLower = hasTextFilter ? pane.filter.toLowerCase() : null;
2117
-
2118
- // Single pass through lines
2119
- return this.outputLines.filter(line => {
2120
- // Check process filter
2121
- if (processSet && !processSet.has(line.process)) return false;
2122
-
2123
- // Check hidden filter
2124
- if (hiddenSet && hiddenSet.has(line.process)) return false;
2125
-
2126
- // Check text filter (use cached lowercase from line if available)
2127
- if (filterLower) {
2128
- const processLower = line.processLower || line.process.toLowerCase();
2129
- const textLower = line.textLower || line.text.toLowerCase();
2130
- if (!processLower.includes(filterLower) && !textLower.includes(filterLower)) {
2131
- return false;
2132
- }
2133
- }
2134
-
2135
- // Check color filter
2136
- if (hasColorFilter && !lineHasColor(line.text, pane.colorFilter)) {
2137
- return false;
2138
- }
2139
-
2140
- return true;
2141
- });
2390
+ return this.outputDb.queryForPane(pane);
2142
2391
  }
2143
2392
 
2144
2393
  buildSettingsUI() {
2145
2394
  // Remove old containers - use destroyRecursively to clean up all children
2395
+ if (this.wipeContainer) {
2396
+ this.renderer.root.remove(this.wipeContainer);
2397
+ this.wipeContainer.destroyRecursively();
2398
+ this.wipeContainer = null;
2399
+ }
2146
2400
  if (this.selectionContainer) {
2147
2401
  this.renderer.root.remove(this.selectionContainer);
2148
2402
  this.selectionContainer.destroyRecursively();
@@ -2539,6 +2793,11 @@ class ProcessManager {
2539
2793
  // Ignore
2540
2794
  }
2541
2795
  }
2796
+
2797
+ // Close the SQLite database
2798
+ if (this.outputDb) {
2799
+ this.outputDb.close();
2800
+ }
2542
2801
  }
2543
2802
 
2544
2803
  executeCommand(scriptName) {
@@ -2793,8 +3052,56 @@ class ProcessManager {
2793
3052
  // 'committing' and 'pushing' phases ignore input (busy)
2794
3053
  }
2795
3054
 
3055
+ // Full screen wipe to clear artifacts (Ctrl+L)
3056
+ buildWipeScreen() {
3057
+ // Remove old containers
3058
+ if (this.selectionContainer) {
3059
+ this.renderer.root.remove(this.selectionContainer);
3060
+ this.selectionContainer.destroyRecursively();
3061
+ this.selectionContainer = null;
3062
+ }
3063
+ if (this.settingsContainer) {
3064
+ this.renderer.root.remove(this.settingsContainer);
3065
+ this.settingsContainer.destroyRecursively();
3066
+ this.settingsContainer = null;
3067
+ }
3068
+ if (this.runningContainer) {
3069
+ this.renderer.root.remove(this.runningContainer);
3070
+ this.runningContainer.destroyRecursively();
3071
+ this.runningContainer = null;
3072
+ this.outputBox = null;
3073
+ this.paneScrollBoxes.clear();
3074
+ this.paneOutputBoxes.clear();
3075
+ this.lineRenderables.clear();
3076
+ }
3077
+
3078
+ // Create full-screen wipe with different background color
3079
+ const wipeContainer = new BoxRenderable(this.renderer, {
3080
+ id: 'wipe-container',
3081
+ width: '100%',
3082
+ height: '100%',
3083
+ backgroundColor: COLORS.bg,
3084
+ justifyContent: 'center',
3085
+ alignItems: 'center',
3086
+ });
3087
+
3088
+ const wipeText = new TextRenderable(this.renderer, {
3089
+ id: 'wipe-text',
3090
+ content: t`${fg(COLORS.textDim)('Clearing...')}`,
3091
+ });
3092
+ wipeContainer.add(wipeText);
3093
+
3094
+ this.renderer.root.add(wipeContainer);
3095
+ this.wipeContainer = wipeContainer;
3096
+ }
3097
+
2796
3098
  buildSelectionUI() {
2797
3099
  // Remove old containers if they exist - use destroyRecursively to clean up all children
3100
+ if (this.wipeContainer) {
3101
+ this.renderer.root.remove(this.wipeContainer);
3102
+ this.wipeContainer.destroyRecursively();
3103
+ this.wipeContainer = null;
3104
+ }
2798
3105
  if (this.selectionContainer) {
2799
3106
  this.renderer.root.remove(this.selectionContainer);
2800
3107
  this.selectionContainer.destroyRecursively();
@@ -3080,7 +3387,9 @@ class ProcessManager {
3080
3387
 
3081
3388
  getPerformanceString() {
3082
3389
  const metrics = this.getPerformanceMetrics();
3083
- return `${metrics.fps}fps ${metrics.avgRenderTime}ms mem:${this.outputLines.length}`;
3390
+ const rendererStats = this.renderer.getStats ? this.renderer.getStats() : null;
3391
+ const rendererFps = rendererStats ? rendererStats.fps : '?';
3392
+ return `${metrics.fps}fps ${metrics.avgRenderTime}ms r:${rendererFps}fps db:${this.outputDb.count()}`;
3084
3393
  }
3085
3394
 
3086
3395
  getProcessListContent() {
@@ -3219,109 +3528,79 @@ class ProcessManager {
3219
3528
  }
3220
3529
  }
3221
3530
 
3222
- // Update existing panes incrementally, or rebuild if needed
3223
- if (this.paneScrollBoxes.size > 0) {
3224
- // Incremental update - just append new lines to existing panes
3225
- const maxLinesPerUpdate = 200; // Limit lines added per render
3226
- // When live, limit DOM to screen height (no scroll needed)
3227
- // When paused, keep all for scrollback
3228
- const screenHeight = this.renderer.height || 50;
3229
- const maxDomLinesPerPane = this.isPaused ? this.maxOutputLines : screenHeight;
3230
-
3231
- for (const [paneId, scrollBox] of this.paneScrollBoxes.entries()) {
3531
+ // Skip update if paused or no new lines
3532
+ if (this.isPaused || !this.hasNewLines) return;
3533
+
3534
+ // Clear the flag
3535
+ this.hasNewLines = false;
3536
+
3537
+ // Virtual scrolling update - only update visible lines from database
3538
+ if (this.paneOutputBoxes.size > 0) {
3539
+ for (const [paneId, outputBox] of this.paneOutputBoxes.entries()) {
3232
3540
  const pane = findPaneById(this.paneRoot, paneId);
3233
- if (!pane || !scrollBox || !scrollBox.content) {
3234
- // ScrollBox invalid, need full rebuild
3541
+ if (!pane || !outputBox) {
3235
3542
  this.buildRunningUI();
3236
3543
  return;
3237
3544
  }
3238
3545
 
3239
- // Only update focused pane every frame, others less frequently
3240
- const isFocused = paneId === this.focusedPaneId;
3241
- if (!isFocused && this.paneScrollBoxes.size > 1) {
3242
- const lastUpdate = this.paneLastUpdate?.get(paneId) || 0;
3243
- if (now - lastUpdate < 200) continue; // Update non-focused panes every 200ms
3244
- if (!this.paneLastUpdate) this.paneLastUpdate = new Map();
3245
- this.paneLastUpdate.set(paneId, now);
3246
- }
3546
+ // Only update if at the bottom (offset=0)
3547
+ const scrollOffset = this.paneScrollOffsets.get(paneId) || 0;
3548
+ if (scrollOffset !== 0) continue;
3247
3549
 
3248
- const lastRenderedLineNumber = this.paneLineCount.get(paneId) || 0;
3249
-
3250
- // Cache filter values outside loop
3251
- const hasProcessFilter = pane.processes.length > 0;
3252
- const processSet = hasProcessFilter ? new Set(pane.processes) : null;
3253
- const hasHiddenFilter = pane.hidden && pane.hidden.length > 0;
3254
- const hiddenSet = hasHiddenFilter ? new Set(pane.hidden) : null;
3255
- const filterLower = pane.filter ? pane.filter.toLowerCase() : null;
3256
- const colorFilter = pane.colorFilter;
3257
-
3258
- // Only look at lines newer than what we've rendered - avoid filtering all lines
3259
- let newLines = [];
3260
- for (let i = this.outputLines.length - 1; i >= 0; i--) {
3261
- const line = this.outputLines[i];
3262
- if (line.lineNumber <= lastRenderedLineNumber) break;
3263
- // Apply pane filters inline with cached values
3264
- if (processSet && !processSet.has(line.process)) continue;
3265
- if (hiddenSet && hiddenSet.has(line.process)) continue;
3266
- if (filterLower && !line.processLower.includes(filterLower) && !line.textLower.includes(filterLower)) continue;
3267
- if (colorFilter && !lineHasColor(line.text, colorFilter)) continue;
3268
- newLines.unshift(line);
3269
- if (newLines.length >= maxLinesPerUpdate) break;
3270
- }
3271
-
3272
- if (newLines.length > 0) {
3273
- // Get or create renderable pool for this pane
3274
- if (!this.lineRenderables.has(paneId)) {
3275
- this.lineRenderables.set(paneId, []);
3276
- }
3277
- const renderables = this.lineRenderables.get(paneId);
3278
-
3279
- // Add new lines - reuse existing renderables or create new ones
3280
- for (const line of newLines) {
3281
- const processColor = this.processColors.get(line.process) || COLORS.text;
3282
-
3283
- // Build content
3284
- const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3285
- const timestamp = this.showTimestamps ? (line.timeString || (line.timeString = new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }))) : '';
3286
-
3287
- let content;
3288
- if (this.showLineNumbers && this.showTimestamps) {
3289
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3290
- } else if (this.showLineNumbers) {
3291
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3292
- } else if (this.showTimestamps) {
3293
- content = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3294
- } else {
3295
- content = t`${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3296
- }
3297
-
3298
- // Create new renderable and add to pool
3299
- const outputLine = new TextRenderable(this.renderer, {
3300
- id: `output-${pane.id}-${line.lineNumber}`,
3301
- content: content,
3302
- bg: '#000000',
3303
- });
3304
-
3305
- scrollBox.content.add(outputLine);
3306
- renderables.push(outputLine);
3307
- }
3308
-
3309
- // Remove excess old lines - keep DOM small for performance
3310
- while (renderables.length > maxDomLinesPerPane) {
3311
- const oldLine = renderables.shift();
3312
- scrollBox.content.remove(oldLine);
3313
- oldLine.destroy();
3314
- }
3315
-
3316
- // Update to track the last absolute line number we rendered
3317
- this.paneLineCount.set(paneId, newLines[newLines.length - 1].lineNumber);
3318
-
3319
- // Auto-scroll to bottom if not paused
3320
- if (!this.isPaused && scrollBox.scrollTo) {
3321
- scrollBox.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER });
3550
+ const visibleHeight = this.paneVisibleHeight.get(paneId) || 20;
3551
+ const lines = this.outputDb.queryVisible(pane, visibleHeight, 0);
3552
+
3553
+ // Skip if no lines or last line hasn't changed
3554
+ if (lines.length === 0) continue;
3555
+ const lastLineNum = lines[lines.length - 1].lineNumber;
3556
+ const prevLastLine = this.paneLineCount.get(paneId) || 0;
3557
+ if (lastLineNum === prevLastLine) continue;
3558
+
3559
+ // Check pane content cache - if first line ID matches, we can append
3560
+ const firstLineId = lines[0].id;
3561
+ const cache = this.paneContentCache.get(paneId);
3562
+
3563
+ let styledText;
3564
+ if (cache && cache.firstLineId === firstLineId && cache.lineCount === lines.length - 1) {
3565
+ // Only 1 new line at the end - append to cached chunks
3566
+ const newLine = lines[lines.length - 1];
3567
+ const newLineContent = this.formatOutputLine(newLine, lines.length - 1, false, -1, -1);
3568
+ const chunks = cache.chunks.concat(newLineContent.chunks);
3569
+ styledText = new StyledText(chunks);
3570
+ this.paneContentCache.set(paneId, { firstLineId, lineCount: lines.length, chunks });
3571
+ } else {
3572
+ // Full rebuild
3573
+ const chunks = [];
3574
+ for (let i = 0; i < lines.length; i++) {
3575
+ const lineContent = this.formatOutputLine(lines[i], i, false, -1, -1);
3576
+ for (let j = 0; j < lineContent.chunks.length; j++) {
3577
+ chunks.push(lineContent.chunks[j]);
3322
3578
  }
3323
3579
  }
3580
+ styledText = new StyledText(chunks);
3581
+ this.paneContentCache.set(paneId, { firstLineId, lineCount: lines.length, chunks });
3324
3582
  }
3583
+
3584
+ // Destroy old renderable and create new one to avoid artifacts
3585
+ const renderables = this.lineRenderables.get(paneId);
3586
+ if (renderables && renderables.length > 0) {
3587
+ outputBox.remove(renderables[0]);
3588
+ renderables[0].destroy();
3589
+ }
3590
+
3591
+ // Create new TextRenderable
3592
+ const outputText = new TextRenderable(this.renderer, {
3593
+ id: `output-${paneId}`,
3594
+ content: styledText,
3595
+ bg: '#000000',
3596
+ width: '100%',
3597
+ });
3598
+ outputBox.add(outputText);
3599
+ this.lineRenderables.set(paneId, [outputText]);
3600
+
3601
+ // Track last rendered line
3602
+ this.paneLineCount.set(paneId, lastLineNum);
3603
+ }
3325
3604
  } else {
3326
3605
  // First time or no panes exist - do full rebuild
3327
3606
  this.buildRunningUI();
@@ -3336,9 +3615,8 @@ class ProcessManager {
3336
3615
  }
3337
3616
 
3338
3617
  // Remove old process bar items
3339
- while (this.processBarContainer.children && this.processBarContainer.children.length > 0) {
3340
- const child = this.processBarContainer.children[0];
3341
- this.processBarContainer.remove(child);
3618
+ for (const child of this.processBarContainer.getChildren()) {
3619
+ this.processBarContainer.remove(child.id);
3342
3620
  child.destroy();
3343
3621
  }
3344
3622
 
@@ -3355,7 +3633,7 @@ class ProcessManager {
3355
3633
  const isVisible = this.isProcessVisibleInPane(script.name, focusedPane);
3356
3634
  const nameColor = isSelected ? COLORS.accent : (isVisible ? processColor : COLORS.textDim);
3357
3635
  const numberColor = isVisible ? processColor : COLORS.textDim;
3358
- const indicator = isSelected ? '>' : ' ';
3636
+ const indicator = isSelected ? '' : ' ';
3359
3637
  const bracketColor = isVisible ? processColor : COLORS.textDim;
3360
3638
 
3361
3639
  const numberLabel = index < 9 ? `${index + 1}` : ' ';
@@ -3375,7 +3653,83 @@ class ProcessManager {
3375
3653
  });
3376
3654
  }
3377
3655
 
3378
- // Build a single pane's output area
3656
+ // Get cache key for current display settings
3657
+ getFormatCacheKey() {
3658
+ return `${this.showLineNumbers}-${this.showTimestamps}-${this.renderer.width}`;
3659
+ }
3660
+
3661
+ // Format a single line for display (returns styled text chunks)
3662
+ formatOutputLine(line, index, inCopyMode, copySelStart, copySelEnd) {
3663
+ // Copy mode can't use cache (dynamic highlighting)
3664
+ if (inCopyMode) {
3665
+ return this.formatOutputLineCopyMode(line, index, copySelStart, copySelEnd);
3666
+ }
3667
+
3668
+ // Can't cache if we're padding to width (width can vary)
3669
+ // Check cache only if no padding needed
3670
+ const cacheKey = this.getFormatCacheKey();
3671
+ if (cacheKey !== this.lineFormatCacheKey) {
3672
+ // Settings changed, clear cache
3673
+ this.lineFormatCache.clear();
3674
+ this.lineFormatCacheKey = cacheKey;
3675
+ }
3676
+
3677
+ // Format line
3678
+ const processColor = this.processColors.get(line.process) || COLORS.text;
3679
+ const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3680
+ const timestamp = this.showTimestamps ? (line.timeString || (line.timeString = new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }))) : '';
3681
+
3682
+ let result;
3683
+ if (this.showLineNumbers && this.showTimestamps) {
3684
+ result = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3685
+ } else if (this.showLineNumbers) {
3686
+ result = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3687
+ } else if (this.showTimestamps) {
3688
+ result = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3689
+ } else {
3690
+ result = t`${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3691
+ }
3692
+
3693
+ return result;
3694
+ }
3695
+
3696
+ // Format line in copy mode (no caching - dynamic highlighting)
3697
+ formatOutputLineCopyMode(line, index, copySelStart, copySelEnd) {
3698
+ const processColor = this.processColors.get(line.process) || COLORS.text;
3699
+ const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3700
+ const timestamp = this.showTimestamps ? (line.timeString || (line.timeString = new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }))) : '';
3701
+
3702
+ const isCursorLine = index === this.copyModeCursor;
3703
+ const isSelectedLine = index >= copySelStart && index <= copySelEnd;
3704
+
3705
+ const marker = isCursorLine ? fg(COLORS.copyCursorText)('\u25b6 ') : ' ';
3706
+ let textColor, procColor, dimColor;
3707
+ if (isCursorLine) {
3708
+ textColor = COLORS.copyCursorText;
3709
+ procColor = COLORS.copyCursorText;
3710
+ dimColor = COLORS.copySelectText;
3711
+ } else if (isSelectedLine) {
3712
+ textColor = COLORS.copySelectText;
3713
+ procColor = processColor;
3714
+ dimColor = COLORS.textDim;
3715
+ } else {
3716
+ textColor = COLORS.textDim;
3717
+ procColor = COLORS.textDim;
3718
+ dimColor = COLORS.textDim;
3719
+ }
3720
+
3721
+ if (this.showLineNumbers && this.showTimestamps) {
3722
+ return t`${marker}${fg(dimColor)(lineNumber)} ${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3723
+ } else if (this.showLineNumbers) {
3724
+ return t`${marker}${fg(dimColor)(lineNumber)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3725
+ } else if (this.showTimestamps) {
3726
+ return t`${marker}${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3727
+ } else {
3728
+ return t`${marker}${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3729
+ }
3730
+ }
3731
+
3732
+ // Build a single pane's output area - uses single TextRenderable for efficiency
3379
3733
  buildPaneOutput(pane, container, height) {
3380
3734
  const isFocused = pane.id === this.focusedPaneId;
3381
3735
  const lines = this.getOutputLinesForPane(pane);
@@ -3385,90 +3739,34 @@ class ProcessManager {
3385
3739
  const maxLines = this.isPaused ? lines.length : Math.min(lines.length, height || 50);
3386
3740
  const linesToShow = lines.slice(-maxLines);
3387
3741
 
3388
- // Initialize renderable pool for this pane
3389
- const renderables = [];
3390
- this.lineRenderables.set(pane.id, renderables);
3391
-
3392
3742
  // Determine copy mode selection range for this pane
3393
3743
  const inCopyMode = this.isCopyMode && isFocused;
3394
3744
  let copySelStart = -1;
3395
3745
  let copySelEnd = -1;
3396
- if (inCopyMode) {
3397
- if (this.copyModeAnchor !== null) {
3398
- copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
3399
- copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
3400
- }
3746
+ if (inCopyMode && this.copyModeAnchor !== null) {
3747
+ copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
3748
+ copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
3401
3749
  }
3402
3750
 
3403
- // Add lines (oldest first, so newest is at bottom)
3751
+ // Build all lines as a single styled text (much more efficient than one renderable per line)
3752
+ const chunks = [];
3404
3753
  for (let i = 0; i < linesToShow.length; i++) {
3405
- const line = linesToShow[i];
3406
- const processColor = this.processColors.get(line.process) || COLORS.text;
3407
-
3408
- // Build content with proper template literal
3409
- const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3410
- const timestamp = this.showTimestamps ? new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }) : '';
3411
-
3412
- // Determine copy mode highlighting for this line
3413
- const isCursorLine = inCopyMode && i === this.copyModeCursor;
3414
- const isSelectedLine = inCopyMode && i >= copySelStart && i <= copySelEnd;
3415
-
3416
- let content;
3417
- if (inCopyMode) {
3418
- // In copy mode: cursor line gets bright marker + white text on blue bg
3419
- // Selected lines get lighter text on darker blue bg
3420
- // Unselected lines are dimmed to make selection stand out
3421
- const marker = isCursorLine ? fg(COLORS.copyCursorText)('\u25b6 ') : ' ';
3422
-
3423
- let textColor, procColor, dimColor;
3424
- if (isCursorLine) {
3425
- textColor = COLORS.copyCursorText;
3426
- procColor = COLORS.copyCursorText;
3427
- dimColor = COLORS.copySelectText;
3428
- } else if (isSelectedLine) {
3429
- textColor = COLORS.copySelectText;
3430
- procColor = processColor;
3431
- dimColor = COLORS.textDim;
3432
- } else {
3433
- textColor = COLORS.textDim;
3434
- procColor = COLORS.textDim;
3435
- dimColor = COLORS.textDim;
3436
- }
3437
-
3438
- if (this.showLineNumbers && this.showTimestamps) {
3439
- content = t`${marker}${fg(dimColor)(lineNumber)} ${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3440
- } else if (this.showLineNumbers) {
3441
- content = t`${marker}${fg(dimColor)(lineNumber)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3442
- } else if (this.showTimestamps) {
3443
- content = t`${marker}${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3444
- } else {
3445
- content = t`${marker}${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3446
- }
3447
- } else {
3448
- if (this.showLineNumbers && this.showTimestamps) {
3449
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3450
- } else if (this.showLineNumbers) {
3451
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3452
- } else if (this.showTimestamps) {
3453
- content = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3454
- } else {
3455
- content = t`${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3456
- }
3457
- }
3458
-
3459
- // High-contrast background: bright blue for cursor, medium blue for selected, black for rest
3460
- const bgColor = isCursorLine ? COLORS.copyCursorBg : (isSelectedLine ? COLORS.copySelectBg : '#000000');
3461
-
3462
- const outputLine = new TextRenderable(this.renderer, {
3463
- id: `output-${pane.id}-${line.lineNumber}`,
3464
- content: content,
3465
- bg: bgColor,
3466
- });
3467
-
3468
- container.add(outputLine);
3469
- renderables.push(outputLine);
3754
+ const lineContent = this.formatOutputLine(linesToShow[i], i, inCopyMode, copySelStart, copySelEnd);
3755
+ chunks.push(...lineContent.chunks);
3470
3756
  }
3471
3757
 
3758
+ // Create single TextRenderable with all content
3759
+ const outputText = new TextRenderable(this.renderer, {
3760
+ id: `output-${pane.id}`,
3761
+ content: new StyledText(chunks),
3762
+ bg: '#000000',
3763
+ });
3764
+
3765
+ container.add(outputText);
3766
+
3767
+ // Store reference for incremental updates
3768
+ this.lineRenderables.set(pane.id, [outputText]);
3769
+
3472
3770
  // Track last rendered line number
3473
3771
  if (linesToShow.length > 0) {
3474
3772
  this.paneLineCount.set(pane.id, linesToShow[linesToShow.length - 1].lineNumber);
@@ -3488,7 +3786,7 @@ class ProcessManager {
3488
3786
  }
3489
3787
  }
3490
3788
 
3491
- // Build a pane panel with title bar
3789
+ // Build a pane panel with title bar - uses virtual scrolling (no ScrollBox)
3492
3790
  buildPanePanel(pane, flexGrow = 1, availableHeight = null) {
3493
3791
  const isFocused = pane.id === this.focusedPaneId;
3494
3792
  const borderColor = isFocused ? COLORS.borderFocused : COLORS.border;
@@ -3504,14 +3802,20 @@ class ProcessManager {
3504
3802
  const filterLabel = pane.filter ? ` /${pane.filter}` : '';
3505
3803
  const namingInputLabel = (isFocused && this.isNamingMode) ? `Name: ${this.namingModeText}_` : '';
3506
3804
  const filterInputLabel = (isFocused && this.isFilterMode) ? `/${pane.filter || ''}_` : '';
3507
- const title = ` ${focusLabel}${namingInputLabel || processLabel}${hiddenLabel}${filterInputLabel || filterLabel} `;
3805
+
3806
+ // Show scroll position indicator when paused
3807
+ const scrollOffset = this.paneScrollOffsets.get(pane.id) || 0;
3808
+ const totalLines = this.outputDb.countForPane(pane);
3809
+ const scrollIndicator = (this.isPaused && scrollOffset > 0) ? ` [${scrollOffset}↑]` : '';
3810
+
3811
+ const title = ` ${focusLabel}${namingInputLabel || processLabel}${hiddenLabel}${filterInputLabel || filterLabel}${scrollIndicator} `;
3508
3812
 
3509
3813
  const paneContainer = new BoxRenderable(this.renderer, {
3510
3814
  id: `pane-${pane.id}`,
3511
3815
  flexDirection: 'column',
3512
3816
  flexGrow: flexGrow,
3513
- flexShrink: 0, // Prevent shrinking - maintain 50/50 split
3514
- flexBasis: 0, // Use flexGrow ratio for sizing, not content size
3817
+ flexShrink: 0,
3818
+ flexBasis: 0,
3515
3819
  border: true,
3516
3820
  borderStyle: 'rounded',
3517
3821
  borderColor: borderColor,
@@ -3519,63 +3823,81 @@ class ProcessManager {
3519
3823
  titleAlignment: 'left',
3520
3824
  padding: 0,
3521
3825
  overflow: 'hidden',
3522
- backgroundColor: '#000000', // Black background for pane container
3826
+ backgroundColor: '#000000',
3523
3827
  });
3524
3828
 
3525
- // Use passed height or calculate default for line count calculation
3526
- const height = availableHeight ? Math.max(5, availableHeight - 2) : Math.max(5, this.renderer.height - 6);
3829
+ // Calculate visible height (minus border)
3830
+ const visibleHeight = availableHeight ? Math.max(5, availableHeight - 2) : Math.max(5, this.renderer.height - 6);
3527
3831
 
3528
- const outputBox = new ScrollBoxRenderable(this.renderer, {
3832
+ // Store visible height for scroll calculations
3833
+ this.paneVisibleHeight.set(pane.id, visibleHeight);
3834
+
3835
+ // Create output container (no ScrollBox - we handle scrolling virtually)
3836
+ const outputBox = new BoxRenderable(this.renderer, {
3529
3837
  id: `pane-output-${pane.id}`,
3530
- height: height,
3531
- scrollX: false,
3532
- scrollY: true,
3533
- focusable: true,
3534
- style: {
3535
- rootOptions: {
3536
- flexGrow: 1,
3537
- flexShrink: 1,
3538
- flexBasis: 0,
3539
- paddingLeft: 1,
3540
- backgroundColor: '#000000',
3541
- },
3542
- contentOptions: {
3543
- backgroundColor: '#000000',
3544
- width: '100%',
3545
- },
3546
- },
3838
+ flexDirection: 'column',
3839
+ flexGrow: 1,
3840
+ paddingLeft: 1,
3841
+ backgroundColor: '#000000',
3842
+ overflow: 'hidden',
3843
+ shouldFill: true,
3547
3844
  });
3548
3845
 
3549
- // Show scrollbar when paused, hide when not paused
3550
- if (outputBox.verticalScrollBar) {
3551
- outputBox.verticalScrollBar.width = this.isPaused ? 1 : 0;
3846
+ // Query only the visible lines from database
3847
+ const lines = this.outputDb.queryVisible(pane, visibleHeight, scrollOffset);
3848
+
3849
+ // Build content for visible lines
3850
+ this.buildPaneOutputVirtual(pane, outputBox, lines, visibleHeight);
3851
+
3852
+ // Store reference to output box for updates
3853
+ this.paneOutputBoxes.set(pane.id, outputBox);
3854
+
3855
+ // Track the last line number for incremental updates
3856
+ if (lines.length > 0) {
3857
+ this.paneLineCount.set(pane.id, lines[lines.length - 1].lineNumber);
3552
3858
  }
3553
3859
 
3554
- // Store ScrollBox reference for this pane
3555
- this.paneScrollBoxes.set(pane.id, outputBox);
3860
+ paneContainer.add(outputBox);
3861
+ return paneContainer;
3862
+ }
3863
+
3864
+ // Build pane output with virtual scrolling - only renders visible lines
3865
+ buildPaneOutputVirtual(pane, container, lines, visibleHeight) {
3866
+ const isFocused = pane.id === this.focusedPaneId;
3556
3867
 
3557
- this.buildPaneOutput(pane, outputBox.content, height);
3868
+ // Determine copy mode selection range
3869
+ const inCopyMode = this.isCopyMode && isFocused;
3870
+ let copySelStart = -1;
3871
+ let copySelEnd = -1;
3872
+ if (inCopyMode && this.copyModeAnchor !== null) {
3873
+ copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
3874
+ copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
3875
+ }
3558
3876
 
3559
- // Update paneLineCount so updateRunningUI() won't re-add these lines
3560
- const paneLines = this.getOutputLinesForPane(pane);
3561
- if (paneLines.length > 0) {
3562
- this.paneLineCount.set(pane.id, paneLines[paneLines.length - 1].lineNumber);
3877
+ // Build all visible lines as single styled text
3878
+ const chunks = [];
3879
+ for (let i = 0; i < lines.length; i++) {
3880
+ const lineContent = this.formatOutputLine(lines[i], i, inCopyMode, copySelStart, copySelEnd);
3881
+ chunks.push(...lineContent.chunks);
3563
3882
  }
3564
3883
 
3565
- // Restore or set scroll position immediately
3566
- if (outputBox && outputBox.scrollTo) {
3567
- if (this.isPaused && this.paneScrollPositions.has(pane.id)) {
3568
- // Restore saved scroll position when paused
3569
- const savedPos = this.paneScrollPositions.get(pane.id);
3570
- outputBox.scrollTo(savedPos);
3571
- } else if (!this.isPaused) {
3572
- // Auto-scroll to bottom when not paused
3573
- outputBox.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER });
3574
- }
3884
+ // Fill remaining visible area with blank lines to clear artifacts
3885
+ for (let i = lines.length; i < visibleHeight; i++) {
3886
+ chunks.push({ text: '\n' });
3575
3887
  }
3576
3888
 
3577
- paneContainer.add(outputBox);
3578
- return paneContainer;
3889
+ // Create single TextRenderable with all visible content
3890
+ const outputText = new TextRenderable(this.renderer, {
3891
+ id: `output-${pane.id}`,
3892
+ content: new StyledText(chunks),
3893
+ bg: '#000000',
3894
+ width: '100%',
3895
+ });
3896
+
3897
+ container.add(outputText);
3898
+
3899
+ // Store reference for updates
3900
+ this.lineRenderables.set(pane.id, [outputText]);
3579
3901
  }
3580
3902
 
3581
3903
  // Recursively build the pane layout, passing available height down
@@ -4162,6 +4484,11 @@ class ProcessManager {
4162
4484
  }
4163
4485
 
4164
4486
  // Remove old containers if they exist - use destroyRecursively to clean up all children
4487
+ if (this.wipeContainer) {
4488
+ this.renderer.root.remove(this.wipeContainer);
4489
+ this.wipeContainer.destroyRecursively();
4490
+ this.wipeContainer = null;
4491
+ }
4165
4492
  if (this.selectionContainer) {
4166
4493
  this.renderer.root.remove(this.selectionContainer);
4167
4494
  this.selectionContainer.destroyRecursively();
@@ -4221,7 +4548,7 @@ class ProcessManager {
4221
4548
  const isVisible = this.isProcessVisibleInPane(script.name, focusedPane);
4222
4549
  const nameColor = isSelected ? COLORS.accent : (isVisible ? processColor : COLORS.textDim);
4223
4550
  const numberColor = isVisible ? processColor : COLORS.textDim;
4224
- const indicator = isSelected ? '>' : ' ';
4551
+ const indicator = isSelected ? '' : ' ';
4225
4552
  const bracketColor = isVisible ? processColor : COLORS.textDim;
4226
4553
 
4227
4554
  // Show number for first 9 processes
@@ -4532,7 +4859,9 @@ async function main() {
4532
4859
  process.exit(1);
4533
4860
  }
4534
4861
 
4535
- const renderer = await createCliRenderer();
4862
+ const renderer = await createCliRenderer({
4863
+ backgroundColor: '#000000',
4864
+ });
4536
4865
  renderer.start(); // Start the automatic render loop
4537
4866
  const manager = new ProcessManager(renderer, scripts);
4538
4867
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "startall",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "test": "echo \"Error: no test specified\" && exit 1",
11
11
  "start": "bun index.js",
12
12
  "demo:frontend": "node -e \"setInterval(() => console.log('Server running...'), 1000)\"",
13
- "demo:backend": "node -e \"setInterval(() => console.log('API ready... API ready... API ready... API ready... API ready...'), 600)\"",
13
+ "demo:backend": "node -e \"setInterval(() => console.log('API ready... API ready... API ready... API ready... API ready...'), 100)\"",
14
14
  "demo:worker": "node -e \"setInterval(() => console.log('Processing jobs...'), 500)\"",
15
15
  "demo:worker2": "node -e \"setInterval(() => console.log('Processing jobs...'), 100)\"",
16
16
  "demo:stress": "node -e \"let i=0; setInterval(() => { for(let j=0;j<10;j++) console.log('Line ' + (++i)); }, 16)\"",
@@ -28,8 +28,8 @@
28
28
  },
29
29
  "type": "module",
30
30
  "dependencies": {
31
- "@opentui/core": "^0.1.74",
32
- "@opentui/react": "^0.1.74",
31
+ "@opentui/core": "^0.1.79",
32
+ "@opentui/react": "^0.1.79",
33
33
  "chalk": "^5.6.2",
34
34
  "react": "^19.2.3",
35
35
  "strip-ansi": "^7.1.2",