startall 0.0.24 → 0.0.25

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 +644 -339
  2. package/package.json +4 -4
package/index.js CHANGED
@@ -1,11 +1,183 @@
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
+
11
+ // SQLite-backed output line storage
12
+ class OutputDatabase {
13
+ constructor(maxLines = 1000) {
14
+ this.db = new Database(':memory:');
15
+ this.maxLines = maxLines;
16
+
17
+ // Enable WAL mode for better concurrent read/write performance
18
+ this.db.exec('PRAGMA journal_mode = WAL');
19
+
20
+ // Create the output_lines table with colors bitmask for efficient filtering
21
+ this.db.exec(`
22
+ CREATE TABLE output_lines (
23
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
24
+ line_number INTEGER NOT NULL,
25
+ process TEXT NOT NULL,
26
+ process_lower TEXT NOT NULL,
27
+ text TEXT NOT NULL,
28
+ text_lower TEXT NOT NULL,
29
+ timestamp INTEGER NOT NULL,
30
+ colors INTEGER NOT NULL DEFAULT 0
31
+ )
32
+ `);
33
+ this.db.exec('CREATE INDEX idx_line_number ON output_lines(line_number)');
34
+ this.db.exec('CREATE INDEX idx_process ON output_lines(process)');
35
+ this.db.exec('CREATE INDEX idx_colors ON output_lines(colors)');
36
+
37
+ // Column list with aliases to match existing JS property names
38
+ this._columns = 'id, line_number AS lineNumber, process, process_lower AS processLower, text, text_lower AS textLower, timestamp, colors';
39
+
40
+ // Prepare reusable statements for performance
41
+ this._insertStmt = this.db.prepare(
42
+ 'INSERT INTO output_lines (line_number, process, process_lower, text, text_lower, timestamp, colors) VALUES (?, ?, ?, ?, ?, ?, ?)'
43
+ );
44
+ this._countStmt = this.db.prepare('SELECT COUNT(*) AS cnt FROM output_lines');
45
+ this._deleteOldStmt = this.db.prepare(
46
+ 'DELETE FROM output_lines WHERE id NOT IN (SELECT id FROM output_lines ORDER BY id DESC LIMIT ?)'
47
+ );
48
+ this._selectAllStmt = this.db.prepare(`SELECT ${this._columns} FROM output_lines ORDER BY id ASC`);
49
+ this._clearStmt = this.db.prepare('DELETE FROM output_lines');
50
+ // Fast path for queryVisible with no filters
51
+ this._queryVisibleNoFilterStmt = this.db.prepare(`SELECT ${this._columns} FROM (
52
+ SELECT * FROM output_lines ORDER BY id DESC LIMIT ?
53
+ ) ORDER BY id ASC`);
54
+ }
55
+
56
+ insert(lineNumber, process, text, timestamp, colorBitmask) {
57
+ this._insertStmt.run(lineNumber, process, process.toLowerCase(), text, text.toLowerCase(), timestamp, colorBitmask);
58
+
59
+ // Enforce max lines limit
60
+ const { cnt } = this._countStmt.get();
61
+ if (cnt > this.maxLines) {
62
+ this._deleteOldStmt.run(this.maxLines);
63
+ }
64
+ }
65
+
66
+ getAll() {
67
+ return this._selectAllStmt.all();
68
+ }
69
+
70
+ count() {
71
+ return this._countStmt.get().cnt;
72
+ }
73
+
74
+ clear() {
75
+ this._clearStmt.run();
76
+ }
77
+
78
+ // Build SQL conditions and params for pane filters
79
+ _buildPaneFilters(pane) {
80
+ const conditions = [];
81
+ const params = [];
82
+
83
+ if (pane.processes && pane.processes.length > 0) {
84
+ const placeholders = pane.processes.map(() => '?').join(', ');
85
+ conditions.push(`process IN (${placeholders})`);
86
+ params.push(...pane.processes);
87
+ }
88
+
89
+ if (pane.hidden && pane.hidden.length > 0) {
90
+ const placeholders = pane.hidden.map(() => '?').join(', ');
91
+ conditions.push(`process NOT IN (${placeholders})`);
92
+ params.push(...pane.hidden);
93
+ }
94
+
95
+ if (pane.filter) {
96
+ const filterLower = pane.filter.toLowerCase();
97
+ conditions.push('(process_lower LIKE ? OR text_lower LIKE ?)');
98
+ params.push(`%${filterLower}%`, `%${filterLower}%`);
99
+ }
100
+
101
+ if (pane.colorFilter) {
102
+ // Use bitmask check: (colors & bit) != 0
103
+ const colorBit = this._getColorBit(pane.colorFilter);
104
+ if (colorBit > 0) {
105
+ conditions.push('(colors & ?) != 0');
106
+ params.push(colorBit);
107
+ }
108
+ }
109
+
110
+ return { conditions, params };
111
+ }
112
+
113
+ // Get color bitmask value for a color name
114
+ _getColorBit(colorName) {
115
+ const bits = { red: 1, yellow: 2, green: 4, blue: 8, cyan: 16, magenta: 32 };
116
+ return bits[colorName] || 0;
117
+ }
118
+
119
+ // Query with pane filters applied via SQL
120
+ queryForPane(pane) {
121
+ const { conditions, params } = this._buildPaneFilters(pane);
122
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
123
+ const sql = `SELECT ${this._columns} FROM output_lines ${where} ORDER BY id ASC`;
124
+ return this.db.prepare(sql).all(...params);
125
+ }
126
+
127
+ // Query lines newer than a given line_number, with pane filters
128
+ queryNewLines(afterLineNumber, pane, limit) {
129
+ const { conditions, params } = this._buildPaneFilters(pane);
130
+ conditions.unshift('line_number > ?');
131
+ params.unshift(afterLineNumber);
132
+
133
+ const where = `WHERE ${conditions.join(' AND ')}`;
134
+ // Get newest lines up to limit, but return them in ascending order
135
+ const sql = `SELECT ${this._columns} FROM (SELECT * FROM output_lines ${where} ORDER BY id DESC LIMIT ?) ORDER BY id ASC`;
136
+ params.push(limit);
137
+
138
+ return this.db.prepare(sql).all(...params);
139
+ }
140
+
141
+ // Count lines matching pane filters (for calculating max scroll)
142
+ countForPane(pane) {
143
+ const { conditions, params } = this._buildPaneFilters(pane);
144
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
145
+ const sql = `SELECT COUNT(*) AS cnt FROM output_lines ${where}`;
146
+ return this.db.prepare(sql).get(...params).cnt;
147
+ }
148
+
149
+ // Query visible lines for virtual scrolling: get `limit` lines starting from `offset` from the end
150
+ // offset=0 means the most recent lines, offset=10 means 10 lines back from the end
151
+ queryVisible(pane, limit, offsetFromEnd) {
152
+ const { conditions, params } = this._buildPaneFilters(pane);
153
+ const totalToFetch = limit + offsetFromEnd;
154
+
155
+ let rows;
156
+ if (conditions.length === 0) {
157
+ // Fast path: no filters, use prepared statement
158
+ rows = this._queryVisibleNoFilterStmt.all(totalToFetch);
159
+ } else {
160
+ // Slow path: build query with filters
161
+ const where = `WHERE ${conditions.join(' AND ')}`;
162
+ const sql = `SELECT ${this._columns} FROM (
163
+ SELECT * FROM output_lines ${where} ORDER BY id DESC LIMIT ?
164
+ ) ORDER BY id ASC`;
165
+ params.push(totalToFetch);
166
+ rows = this.db.prepare(sql).all(...params);
167
+ }
168
+
169
+ // If offset > 0, trim the newest lines
170
+ if (offsetFromEnd > 0 && rows.length > limit) {
171
+ rows = rows.slice(0, limit);
172
+ }
173
+
174
+ return rows;
175
+ }
176
+
177
+ close() {
178
+ this.db.close();
179
+ }
180
+ }
9
181
 
10
182
  // Configuration
11
183
  const CONFIG_FILE = process.argv[2] || 'startall.json';
@@ -93,34 +265,65 @@ function getAllPaneIds(node, ids = []) {
93
265
  return ids;
94
266
  }
95
267
 
268
+ // Color bitmask values for efficient SQL filtering
269
+ const COLOR_BITS = {
270
+ red: 1, // 0b000001
271
+ yellow: 2, // 0b000010
272
+ green: 4, // 0b000100
273
+ blue: 8, // 0b001000
274
+ cyan: 16, // 0b010000
275
+ magenta: 32, // 0b100000
276
+ };
277
+
278
+ // ANSI color codes (normal and bright variants)
279
+ const ANSI_COLOR_CODES = {
280
+ red: [31, 91],
281
+ yellow: [33, 93],
282
+ green: [32, 92],
283
+ blue: [34, 94],
284
+ cyan: [36, 96],
285
+ magenta: [35, 95],
286
+ };
287
+
96
288
  // Get ANSI color codes for a color name (includes normal and bright variants)
97
289
  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] || [];
290
+ return ANSI_COLOR_CODES[colorName] || [];
291
+ }
292
+
293
+ // Check if text contains a specific ANSI color code
294
+ function textHasColorCode(text, code) {
295
+ // Check for direct color code: \x1b[31m
296
+ if (text.includes(`\x1b[${code}m`)) return true;
297
+ // Check for color with modifiers: \x1b[1;31m, \x1b[0;31m, etc.
298
+ if (text.includes(`;${code}m`)) return true;
299
+ // Check for color at start of sequence: \x1b[31;1m
300
+ if (text.includes(`\x1b[${code};`)) return true;
301
+ return false;
107
302
  }
108
303
 
109
304
  // Check if a line contains a specific ANSI color
110
305
  function lineHasColor(text, colorName) {
111
306
  const codes = getAnsiColorCodes(colorName);
112
- // Match ANSI escape sequences like \x1b[31m, \x1b[91m, \x1b[1;31m, etc.
113
307
  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;
308
+ if (textHasColorCode(text, code)) return true;
120
309
  }
121
310
  return false;
122
311
  }
123
312
 
313
+ // Detect all colors in text and return bitmask
314
+ function detectColorBitmask(text) {
315
+ let bitmask = 0;
316
+ for (const [colorName, codes] of Object.entries(ANSI_COLOR_CODES)) {
317
+ for (const code of codes) {
318
+ if (textHasColorCode(text, code)) {
319
+ bitmask |= COLOR_BITS[colorName];
320
+ break; // Found this color, no need to check other codes for it
321
+ }
322
+ }
323
+ }
324
+ return bitmask;
325
+ }
326
+
124
327
  // Find parent of a node
125
328
  function findParent(root, targetId, parent = null) {
126
329
  if (!root) return null;
@@ -550,10 +753,10 @@ class ProcessManager {
550
753
  this.selectedIndex = 0;
551
754
  this.processes = new Map();
552
755
  this.processRefs = new Map();
553
- this.outputLines = [];
756
+ this.maxOutputLines = 1000; // Lines kept in database
757
+ this.outputDb = new OutputDatabase(this.maxOutputLines);
554
758
  this.totalLinesReceived = 0; // Track total lines ever received (never resets)
555
759
  this.filter = '';
556
- this.maxOutputLines = 1000; // Lines kept in memory
557
760
  this.maxDomLines = 150; // Lines kept in DOM (buffer for varying heights)
558
761
  this.lineRenderables = new Map(); // Reusable TextRenderables per pane
559
762
  this.maxVisibleLines = null; // Calculated dynamically based on screen height
@@ -590,11 +793,12 @@ class ProcessManager {
590
793
  this.outputBox = null; // Reference to the output container
591
794
  this.destroyed = false; // Flag to prevent operations after cleanup
592
795
  this.lastRenderedLineCount = 0; // Track how many lines we've rendered
796
+ this.hasNewLines = false; // Flag set when new lines are added, cleared after render
593
797
  this.headerRenderable = null; // Reference to header text in running UI
594
798
  this.processListRenderable = null; // Reference to process list text in running UI
595
799
  this.renderScheduled = false; // Throttle renders for CPU efficiency
596
800
  this.lastRenderTime = 0; // Timestamp of last render
597
- this.minRenderInterval = 100; // Minimum ms between renders (~10fps cap)
801
+ this.minRenderInterval = 1; // Minimum ms between renders (~60fps cap)
598
802
 
599
803
  // Performance metrics
600
804
  this.showPerformanceMetrics = this.config.showPerformanceMetrics || false;
@@ -609,16 +813,28 @@ class ProcessManager {
609
813
  this.splitMode = false; // Whether waiting for split command after Ctrl+b
610
814
  this.showSplitMenu = false; // Whether to show the command palette
611
815
  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
816
+ this.paneScrollPositions = new Map(); // Store scroll positions per pane ID (legacy)
817
+ this.paneScrollBoxes = new Map(); // Store ScrollBox references per pane ID (legacy)
614
818
  this.paneFilterState = new Map(); // Track filter state per pane to detect changes
615
819
  this.paneLineCount = new Map(); // Track how many lines we've rendered per pane
616
820
  this.uiJustRebuilt = false; // Flag to skip redundant render after buildRunningUI
617
821
 
822
+ // Virtual scrolling state
823
+ this.paneScrollOffsets = new Map(); // Scroll offset from end per pane (0 = at bottom)
824
+ this.paneVisibleHeight = new Map(); // Visible height per pane for scroll calculations
825
+ this.paneOutputBoxes = new Map(); // Store output BoxRenderable references per pane
826
+
618
827
  // Column view state (one pane per script, side by side)
619
828
  this.isColumnView = false; // Whether column view is active
620
829
  this.savedPaneRoot = null; // Saved pane tree to restore when toggling back
621
830
 
831
+ // Line format cache (keyed by line ID + settings hash)
832
+ this.lineFormatCache = new Map();
833
+ this.lineFormatCacheKey = ''; // Cache key based on display settings
834
+
835
+ // Pane content cache (keyed by pane ID -> {lastLineId, styledText})
836
+ this.paneContentCache = new Map();
837
+
622
838
  // Copy mode state (select text to copy)
623
839
  this.isCopyMode = false; // Whether in copy/select mode
624
840
  this.copyModeCursor = 0; // Current cursor line index within visible lines
@@ -673,13 +889,27 @@ class ProcessManager {
673
889
  return;
674
890
  }
675
891
 
676
- // Handle Ctrl+L - clear screen buffer and redraw
892
+ // Handle Ctrl+L - clear screen and redraw (kills artifacts)
677
893
  if (key.ctrl && key.name === 'l') {
678
- if (this.phase === 'running') {
679
- this.outputLines = [];
680
- this.totalLinesReceived = 0;
681
- this.buildRunningUI();
894
+ // Debounce - cancel any pending wipe and restart
895
+ if (this.wipeTimeout) {
896
+ clearTimeout(this.wipeTimeout);
682
897
  }
898
+
899
+ // Save current phase and show wipe screen
900
+ const savedPhase = this.phase;
901
+ this.buildWipeScreen();
902
+
903
+ this.wipeTimeout = setTimeout(() => {
904
+ this.wipeTimeout = null;
905
+ if (savedPhase === 'running') {
906
+ this.buildRunningUI();
907
+ } else if (savedPhase === 'selection') {
908
+ this.buildSelectionUI();
909
+ } else if (savedPhase === 'settings') {
910
+ this.buildSettingsUI();
911
+ }
912
+ }, 50);
683
913
  return;
684
914
  }
685
915
 
@@ -874,13 +1104,13 @@ class ProcessManager {
874
1104
  this.buildRunningUI();
875
1105
  } else if (keyName === 'p') {
876
1106
  // 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 });
1107
+ this.isPaused = !this.isPaused;
1108
+ // Reset scroll offset to bottom when unpausing
1109
+ if (!this.isPaused) {
1110
+ for (const paneId of getAllPaneIds(this.paneRoot)) {
1111
+ this.paneScrollOffsets.set(paneId, 0);
881
1112
  }
882
1113
  }
883
- this.isPaused = !this.isPaused;
884
1114
  this.updateStreamPauseState();
885
1115
  this.buildRunningUI();
886
1116
  } else if (keyName === 'f') {
@@ -1103,13 +1333,16 @@ class ProcessManager {
1103
1333
  } else {
1104
1334
  this.paneRoot = createPane([]); // Empty array means show all processes
1105
1335
  }
1106
- this.focusedPaneId = this.paneRoot.id;
1336
+ // Focus the first actual pane (paneRoot might be a split node)
1337
+ const allPanes = getAllPaneIds(this.paneRoot);
1338
+ this.focusedPaneId = allPanes.length > 0 ? allPanes[0] : this.paneRoot.id;
1107
1339
 
1108
1340
  selected.forEach(scriptName => {
1109
1341
  this.startProcess(scriptName);
1110
1342
  });
1111
1343
 
1112
- this.render();
1344
+ // Build the UI immediately (don't rely on render() since hasNewLines may be false)
1345
+ this.buildRunningUI();
1113
1346
  }
1114
1347
 
1115
1348
  startProcess(scriptName) {
@@ -1156,22 +1389,25 @@ class ProcessManager {
1156
1389
  }
1157
1390
 
1158
1391
  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
- });
1392
+ // Don't write if database is closed
1393
+ if (this.destroyed) return;
1169
1394
 
1170
- // Use shift() instead of slice() to avoid creating new array
1171
- while (this.outputLines.length > this.maxOutputLines) {
1172
- this.outputLines.shift();
1173
- }
1395
+ // Detect colors in the text for efficient SQL filtering
1396
+ const colorBitmask = detectColorBitmask(text);
1174
1397
 
1398
+ // Always store the output line, even when paused
1399
+ // Insert into SQLite database
1400
+ this.outputDb.insert(
1401
+ ++this.totalLinesReceived,
1402
+ processName,
1403
+ text,
1404
+ Date.now(),
1405
+ colorBitmask
1406
+ );
1407
+
1408
+ // Mark that new lines are available
1409
+ this.hasNewLines = true;
1410
+
1175
1411
  // Only render if not paused - this prevents new output from appearing
1176
1412
  // when the user is reviewing history
1177
1413
  if (!this.isPaused) {
@@ -1820,19 +2056,38 @@ class ProcessManager {
1820
2056
  // Toggle visibility of selected process in focused pane
1821
2057
  toggleProcessVisibility() {
1822
2058
  const scriptName = this.scripts[this.selectedIndex]?.name;
1823
- if (!scriptName || !this.focusedPaneId) return;
2059
+ if (!scriptName || !this.focusedPaneId) {
2060
+ return;
2061
+ }
1824
2062
 
1825
2063
  const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1826
- if (!pane) return;
2064
+ if (!pane) {
2065
+ return;
2066
+ }
1827
2067
 
1828
- // Initialize hidden array if needed
2068
+ // Initialize arrays if needed
1829
2069
  if (!pane.hidden) pane.hidden = [];
2070
+ if (!pane.processes) pane.processes = [];
1830
2071
 
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);
2072
+ // Check current visibility
2073
+ const isCurrentlyVisible = this.isProcessVisibleInPane(scriptName, pane);
2074
+
2075
+ if (isCurrentlyVisible) {
2076
+ // Hide it - add to hidden list
2077
+ if (!pane.hidden.includes(scriptName)) {
2078
+ pane.hidden.push(scriptName);
2079
+ }
2080
+ // Also remove from processes if it's there
2081
+ if (pane.processes.includes(scriptName)) {
2082
+ pane.processes = pane.processes.filter(p => p !== scriptName);
2083
+ }
1834
2084
  } else {
1835
- pane.hidden.push(scriptName);
2085
+ // Show it - remove from hidden list
2086
+ pane.hidden = pane.hidden.filter(p => p !== scriptName);
2087
+ // If pane has a process filter, add this process to it
2088
+ if (pane.processes.length > 0 && !pane.processes.includes(scriptName)) {
2089
+ pane.processes.push(scriptName);
2090
+ }
1836
2091
  }
1837
2092
 
1838
2093
  this.savePaneLayout();
@@ -1846,38 +2101,46 @@ class ProcessManager {
1846
2101
  debouncedSaveConfig(this.config);
1847
2102
  }
1848
2103
 
1849
- // Scroll the focused pane
2104
+ // Scroll the focused pane (virtual scrolling - adjusts offset into database)
1850
2105
  scrollFocusedPane(direction) {
1851
2106
  if (!this.focusedPaneId) return;
1852
2107
 
1853
- const scrollBox = this.paneScrollBoxes.get(this.focusedPaneId);
1854
- if (!scrollBox || !scrollBox.scrollTo) return;
2108
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
2109
+ if (!pane) return;
1855
2110
 
1856
- const currentY = scrollBox.scrollTop || 0;
1857
- const viewportHeight = scrollBox.height || 20;
1858
- const contentHeight = scrollBox.contentHeight || 0;
2111
+ const totalLines = this.outputDb.countForPane(pane);
2112
+ const visibleHeight = this.paneVisibleHeight.get(this.focusedPaneId) || 20;
2113
+ const currentOffset = this.paneScrollOffsets.get(this.focusedPaneId) || 0;
2114
+ const maxOffset = Math.max(0, totalLines - visibleHeight);
1859
2115
 
1860
- let newY = currentY;
2116
+ let newOffset = currentOffset;
1861
2117
 
1862
2118
  if (direction === 'home') {
1863
- newY = 0;
2119
+ // Scroll to top (maximum offset from end)
2120
+ newOffset = maxOffset;
1864
2121
  } else if (direction === 'end') {
1865
- newY = Number.MAX_SAFE_INTEGER;
2122
+ // Scroll to bottom (offset 0 = most recent)
2123
+ newOffset = 0;
1866
2124
  } else if (direction === 'pageup') {
1867
- newY = Math.max(0, currentY - viewportHeight);
2125
+ newOffset = Math.min(maxOffset, currentOffset + visibleHeight);
1868
2126
  } else if (direction === 'pagedown') {
1869
- newY = Math.min(contentHeight - viewportHeight, currentY + viewportHeight);
2127
+ newOffset = Math.max(0, currentOffset - visibleHeight);
2128
+ } else if (direction === 'up') {
2129
+ newOffset = Math.min(maxOffset, currentOffset + 1);
2130
+ } else if (direction === 'down') {
2131
+ newOffset = Math.max(0, currentOffset - 1);
1870
2132
  }
1871
2133
 
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();
2134
+ // Only update if changed
2135
+ if (newOffset !== currentOffset) {
2136
+ this.paneScrollOffsets.set(this.focusedPaneId, newOffset);
2137
+
2138
+ // Auto-pause when manually scrolling (unless going to end)
2139
+ if (direction !== 'end' && !this.isPaused) {
2140
+ this.isPaused = true;
2141
+ this.updateStreamPauseState();
2142
+ }
2143
+
1881
2144
  this.buildRunningUI();
1882
2145
  }
1883
2146
  }
@@ -2098,51 +2361,18 @@ class ProcessManager {
2098
2361
  }
2099
2362
 
2100
2363
  // Count horizontal splits (which reduce available height per pane)
2101
- // Get output lines for a specific pane - optimized single-pass filtering
2364
+ // Get output lines for a specific pane - queries SQLite with filters
2102
2365
  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
- });
2366
+ return this.outputDb.queryForPane(pane);
2142
2367
  }
2143
2368
 
2144
2369
  buildSettingsUI() {
2145
2370
  // Remove old containers - use destroyRecursively to clean up all children
2371
+ if (this.wipeContainer) {
2372
+ this.renderer.root.remove(this.wipeContainer);
2373
+ this.wipeContainer.destroyRecursively();
2374
+ this.wipeContainer = null;
2375
+ }
2146
2376
  if (this.selectionContainer) {
2147
2377
  this.renderer.root.remove(this.selectionContainer);
2148
2378
  this.selectionContainer.destroyRecursively();
@@ -2539,6 +2769,11 @@ class ProcessManager {
2539
2769
  // Ignore
2540
2770
  }
2541
2771
  }
2772
+
2773
+ // Close the SQLite database
2774
+ if (this.outputDb) {
2775
+ this.outputDb.close();
2776
+ }
2542
2777
  }
2543
2778
 
2544
2779
  executeCommand(scriptName) {
@@ -2793,8 +3028,56 @@ class ProcessManager {
2793
3028
  // 'committing' and 'pushing' phases ignore input (busy)
2794
3029
  }
2795
3030
 
3031
+ // Full screen wipe to clear artifacts (Ctrl+L)
3032
+ buildWipeScreen() {
3033
+ // Remove old containers
3034
+ if (this.selectionContainer) {
3035
+ this.renderer.root.remove(this.selectionContainer);
3036
+ this.selectionContainer.destroyRecursively();
3037
+ this.selectionContainer = null;
3038
+ }
3039
+ if (this.settingsContainer) {
3040
+ this.renderer.root.remove(this.settingsContainer);
3041
+ this.settingsContainer.destroyRecursively();
3042
+ this.settingsContainer = null;
3043
+ }
3044
+ if (this.runningContainer) {
3045
+ this.renderer.root.remove(this.runningContainer);
3046
+ this.runningContainer.destroyRecursively();
3047
+ this.runningContainer = null;
3048
+ this.outputBox = null;
3049
+ this.paneScrollBoxes.clear();
3050
+ this.paneOutputBoxes.clear();
3051
+ this.lineRenderables.clear();
3052
+ }
3053
+
3054
+ // Create full-screen wipe with different background color
3055
+ const wipeContainer = new BoxRenderable(this.renderer, {
3056
+ id: 'wipe-container',
3057
+ width: '100%',
3058
+ height: '100%',
3059
+ backgroundColor: COLORS.bg,
3060
+ justifyContent: 'center',
3061
+ alignItems: 'center',
3062
+ });
3063
+
3064
+ const wipeText = new TextRenderable(this.renderer, {
3065
+ id: 'wipe-text',
3066
+ content: t`${fg(COLORS.textDim)('Clearing...')}`,
3067
+ });
3068
+ wipeContainer.add(wipeText);
3069
+
3070
+ this.renderer.root.add(wipeContainer);
3071
+ this.wipeContainer = wipeContainer;
3072
+ }
3073
+
2796
3074
  buildSelectionUI() {
2797
3075
  // Remove old containers if they exist - use destroyRecursively to clean up all children
3076
+ if (this.wipeContainer) {
3077
+ this.renderer.root.remove(this.wipeContainer);
3078
+ this.wipeContainer.destroyRecursively();
3079
+ this.wipeContainer = null;
3080
+ }
2798
3081
  if (this.selectionContainer) {
2799
3082
  this.renderer.root.remove(this.selectionContainer);
2800
3083
  this.selectionContainer.destroyRecursively();
@@ -3080,7 +3363,9 @@ class ProcessManager {
3080
3363
 
3081
3364
  getPerformanceString() {
3082
3365
  const metrics = this.getPerformanceMetrics();
3083
- return `${metrics.fps}fps ${metrics.avgRenderTime}ms mem:${this.outputLines.length}`;
3366
+ const rendererStats = this.renderer.getStats ? this.renderer.getStats() : null;
3367
+ const rendererFps = rendererStats ? rendererStats.fps : '?';
3368
+ return `${metrics.fps}fps ${metrics.avgRenderTime}ms r:${rendererFps}fps db:${this.outputDb.count()}`;
3084
3369
  }
3085
3370
 
3086
3371
  getProcessListContent() {
@@ -3219,109 +3504,79 @@ class ProcessManager {
3219
3504
  }
3220
3505
  }
3221
3506
 
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()) {
3507
+ // Skip update if paused or no new lines
3508
+ if (this.isPaused || !this.hasNewLines) return;
3509
+
3510
+ // Clear the flag
3511
+ this.hasNewLines = false;
3512
+
3513
+ // Virtual scrolling update - only update visible lines from database
3514
+ if (this.paneOutputBoxes.size > 0) {
3515
+ for (const [paneId, outputBox] of this.paneOutputBoxes.entries()) {
3232
3516
  const pane = findPaneById(this.paneRoot, paneId);
3233
- if (!pane || !scrollBox || !scrollBox.content) {
3234
- // ScrollBox invalid, need full rebuild
3517
+ if (!pane || !outputBox) {
3235
3518
  this.buildRunningUI();
3236
3519
  return;
3237
3520
  }
3238
3521
 
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
- }
3522
+ // Only update if at the bottom (offset=0)
3523
+ const scrollOffset = this.paneScrollOffsets.get(paneId) || 0;
3524
+ if (scrollOffset !== 0) continue;
3247
3525
 
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 });
3526
+ const visibleHeight = this.paneVisibleHeight.get(paneId) || 20;
3527
+ const lines = this.outputDb.queryVisible(pane, visibleHeight, 0);
3528
+
3529
+ // Skip if no lines or last line hasn't changed
3530
+ if (lines.length === 0) continue;
3531
+ const lastLineNum = lines[lines.length - 1].lineNumber;
3532
+ const prevLastLine = this.paneLineCount.get(paneId) || 0;
3533
+ if (lastLineNum === prevLastLine) continue;
3534
+
3535
+ // Check pane content cache - if first line ID matches, we can append
3536
+ const firstLineId = lines[0].id;
3537
+ const cache = this.paneContentCache.get(paneId);
3538
+
3539
+ let styledText;
3540
+ if (cache && cache.firstLineId === firstLineId && cache.lineCount === lines.length - 1) {
3541
+ // Only 1 new line at the end - append to cached chunks
3542
+ const newLine = lines[lines.length - 1];
3543
+ const newLineContent = this.formatOutputLine(newLine, lines.length - 1, false, -1, -1);
3544
+ const chunks = cache.chunks.concat(newLineContent.chunks);
3545
+ styledText = new StyledText(chunks);
3546
+ this.paneContentCache.set(paneId, { firstLineId, lineCount: lines.length, chunks });
3547
+ } else {
3548
+ // Full rebuild
3549
+ const chunks = [];
3550
+ for (let i = 0; i < lines.length; i++) {
3551
+ const lineContent = this.formatOutputLine(lines[i], i, false, -1, -1);
3552
+ for (let j = 0; j < lineContent.chunks.length; j++) {
3553
+ chunks.push(lineContent.chunks[j]);
3322
3554
  }
3323
3555
  }
3556
+ styledText = new StyledText(chunks);
3557
+ this.paneContentCache.set(paneId, { firstLineId, lineCount: lines.length, chunks });
3324
3558
  }
3559
+
3560
+ // Destroy old renderable and create new one to avoid artifacts
3561
+ const renderables = this.lineRenderables.get(paneId);
3562
+ if (renderables && renderables.length > 0) {
3563
+ outputBox.remove(renderables[0]);
3564
+ renderables[0].destroy();
3565
+ }
3566
+
3567
+ // Create new TextRenderable
3568
+ const outputText = new TextRenderable(this.renderer, {
3569
+ id: `output-${paneId}`,
3570
+ content: styledText,
3571
+ bg: '#000000',
3572
+ width: '100%',
3573
+ });
3574
+ outputBox.add(outputText);
3575
+ this.lineRenderables.set(paneId, [outputText]);
3576
+
3577
+ // Track last rendered line
3578
+ this.paneLineCount.set(paneId, lastLineNum);
3579
+ }
3325
3580
  } else {
3326
3581
  // First time or no panes exist - do full rebuild
3327
3582
  this.buildRunningUI();
@@ -3336,9 +3591,8 @@ class ProcessManager {
3336
3591
  }
3337
3592
 
3338
3593
  // 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);
3594
+ for (const child of this.processBarContainer.getChildren()) {
3595
+ this.processBarContainer.remove(child.id);
3342
3596
  child.destroy();
3343
3597
  }
3344
3598
 
@@ -3355,7 +3609,7 @@ class ProcessManager {
3355
3609
  const isVisible = this.isProcessVisibleInPane(script.name, focusedPane);
3356
3610
  const nameColor = isSelected ? COLORS.accent : (isVisible ? processColor : COLORS.textDim);
3357
3611
  const numberColor = isVisible ? processColor : COLORS.textDim;
3358
- const indicator = isSelected ? '>' : ' ';
3612
+ const indicator = isSelected ? '' : ' ';
3359
3613
  const bracketColor = isVisible ? processColor : COLORS.textDim;
3360
3614
 
3361
3615
  const numberLabel = index < 9 ? `${index + 1}` : ' ';
@@ -3375,7 +3629,83 @@ class ProcessManager {
3375
3629
  });
3376
3630
  }
3377
3631
 
3378
- // Build a single pane's output area
3632
+ // Get cache key for current display settings
3633
+ getFormatCacheKey() {
3634
+ return `${this.showLineNumbers}-${this.showTimestamps}-${this.renderer.width}`;
3635
+ }
3636
+
3637
+ // Format a single line for display (returns styled text chunks)
3638
+ formatOutputLine(line, index, inCopyMode, copySelStart, copySelEnd) {
3639
+ // Copy mode can't use cache (dynamic highlighting)
3640
+ if (inCopyMode) {
3641
+ return this.formatOutputLineCopyMode(line, index, copySelStart, copySelEnd);
3642
+ }
3643
+
3644
+ // Can't cache if we're padding to width (width can vary)
3645
+ // Check cache only if no padding needed
3646
+ const cacheKey = this.getFormatCacheKey();
3647
+ if (cacheKey !== this.lineFormatCacheKey) {
3648
+ // Settings changed, clear cache
3649
+ this.lineFormatCache.clear();
3650
+ this.lineFormatCacheKey = cacheKey;
3651
+ }
3652
+
3653
+ // Format line
3654
+ const processColor = this.processColors.get(line.process) || COLORS.text;
3655
+ const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3656
+ const timestamp = this.showTimestamps ? (line.timeString || (line.timeString = new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }))) : '';
3657
+
3658
+ let result;
3659
+ if (this.showLineNumbers && this.showTimestamps) {
3660
+ result = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3661
+ } else if (this.showLineNumbers) {
3662
+ result = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3663
+ } else if (this.showTimestamps) {
3664
+ result = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3665
+ } else {
3666
+ result = t`${fg(processColor)(`[${line.process}]`)} ${line.text}\n`;
3667
+ }
3668
+
3669
+ return result;
3670
+ }
3671
+
3672
+ // Format line in copy mode (no caching - dynamic highlighting)
3673
+ formatOutputLineCopyMode(line, index, copySelStart, copySelEnd) {
3674
+ const processColor = this.processColors.get(line.process) || COLORS.text;
3675
+ const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3676
+ const timestamp = this.showTimestamps ? (line.timeString || (line.timeString = new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }))) : '';
3677
+
3678
+ const isCursorLine = index === this.copyModeCursor;
3679
+ const isSelectedLine = index >= copySelStart && index <= copySelEnd;
3680
+
3681
+ const marker = isCursorLine ? fg(COLORS.copyCursorText)('\u25b6 ') : ' ';
3682
+ let textColor, procColor, dimColor;
3683
+ if (isCursorLine) {
3684
+ textColor = COLORS.copyCursorText;
3685
+ procColor = COLORS.copyCursorText;
3686
+ dimColor = COLORS.copySelectText;
3687
+ } else if (isSelectedLine) {
3688
+ textColor = COLORS.copySelectText;
3689
+ procColor = processColor;
3690
+ dimColor = COLORS.textDim;
3691
+ } else {
3692
+ textColor = COLORS.textDim;
3693
+ procColor = COLORS.textDim;
3694
+ dimColor = COLORS.textDim;
3695
+ }
3696
+
3697
+ if (this.showLineNumbers && this.showTimestamps) {
3698
+ return t`${marker}${fg(dimColor)(lineNumber)} ${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3699
+ } else if (this.showLineNumbers) {
3700
+ return t`${marker}${fg(dimColor)(lineNumber)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3701
+ } else if (this.showTimestamps) {
3702
+ return t`${marker}${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3703
+ } else {
3704
+ return t`${marker}${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}\n`;
3705
+ }
3706
+ }
3707
+
3708
+ // Build a single pane's output area - uses single TextRenderable for efficiency
3379
3709
  buildPaneOutput(pane, container, height) {
3380
3710
  const isFocused = pane.id === this.focusedPaneId;
3381
3711
  const lines = this.getOutputLinesForPane(pane);
@@ -3385,90 +3715,34 @@ class ProcessManager {
3385
3715
  const maxLines = this.isPaused ? lines.length : Math.min(lines.length, height || 50);
3386
3716
  const linesToShow = lines.slice(-maxLines);
3387
3717
 
3388
- // Initialize renderable pool for this pane
3389
- const renderables = [];
3390
- this.lineRenderables.set(pane.id, renderables);
3391
-
3392
3718
  // Determine copy mode selection range for this pane
3393
3719
  const inCopyMode = this.isCopyMode && isFocused;
3394
3720
  let copySelStart = -1;
3395
3721
  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
- }
3722
+ if (inCopyMode && this.copyModeAnchor !== null) {
3723
+ copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
3724
+ copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
3401
3725
  }
3402
3726
 
3403
- // Add lines (oldest first, so newest is at bottom)
3727
+ // Build all lines as a single styled text (much more efficient than one renderable per line)
3728
+ const chunks = [];
3404
3729
  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);
3730
+ const lineContent = this.formatOutputLine(linesToShow[i], i, inCopyMode, copySelStart, copySelEnd);
3731
+ chunks.push(...lineContent.chunks);
3470
3732
  }
3471
3733
 
3734
+ // Create single TextRenderable with all content
3735
+ const outputText = new TextRenderable(this.renderer, {
3736
+ id: `output-${pane.id}`,
3737
+ content: new StyledText(chunks),
3738
+ bg: '#000000',
3739
+ });
3740
+
3741
+ container.add(outputText);
3742
+
3743
+ // Store reference for incremental updates
3744
+ this.lineRenderables.set(pane.id, [outputText]);
3745
+
3472
3746
  // Track last rendered line number
3473
3747
  if (linesToShow.length > 0) {
3474
3748
  this.paneLineCount.set(pane.id, linesToShow[linesToShow.length - 1].lineNumber);
@@ -3488,7 +3762,7 @@ class ProcessManager {
3488
3762
  }
3489
3763
  }
3490
3764
 
3491
- // Build a pane panel with title bar
3765
+ // Build a pane panel with title bar - uses virtual scrolling (no ScrollBox)
3492
3766
  buildPanePanel(pane, flexGrow = 1, availableHeight = null) {
3493
3767
  const isFocused = pane.id === this.focusedPaneId;
3494
3768
  const borderColor = isFocused ? COLORS.borderFocused : COLORS.border;
@@ -3504,14 +3778,20 @@ class ProcessManager {
3504
3778
  const filterLabel = pane.filter ? ` /${pane.filter}` : '';
3505
3779
  const namingInputLabel = (isFocused && this.isNamingMode) ? `Name: ${this.namingModeText}_` : '';
3506
3780
  const filterInputLabel = (isFocused && this.isFilterMode) ? `/${pane.filter || ''}_` : '';
3507
- const title = ` ${focusLabel}${namingInputLabel || processLabel}${hiddenLabel}${filterInputLabel || filterLabel} `;
3781
+
3782
+ // Show scroll position indicator when paused
3783
+ const scrollOffset = this.paneScrollOffsets.get(pane.id) || 0;
3784
+ const totalLines = this.outputDb.countForPane(pane);
3785
+ const scrollIndicator = (this.isPaused && scrollOffset > 0) ? ` [${scrollOffset}↑]` : '';
3786
+
3787
+ const title = ` ${focusLabel}${namingInputLabel || processLabel}${hiddenLabel}${filterInputLabel || filterLabel}${scrollIndicator} `;
3508
3788
 
3509
3789
  const paneContainer = new BoxRenderable(this.renderer, {
3510
3790
  id: `pane-${pane.id}`,
3511
3791
  flexDirection: 'column',
3512
3792
  flexGrow: flexGrow,
3513
- flexShrink: 0, // Prevent shrinking - maintain 50/50 split
3514
- flexBasis: 0, // Use flexGrow ratio for sizing, not content size
3793
+ flexShrink: 0,
3794
+ flexBasis: 0,
3515
3795
  border: true,
3516
3796
  borderStyle: 'rounded',
3517
3797
  borderColor: borderColor,
@@ -3519,63 +3799,81 @@ class ProcessManager {
3519
3799
  titleAlignment: 'left',
3520
3800
  padding: 0,
3521
3801
  overflow: 'hidden',
3522
- backgroundColor: '#000000', // Black background for pane container
3802
+ backgroundColor: '#000000',
3523
3803
  });
3524
3804
 
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);
3805
+ // Calculate visible height (minus border)
3806
+ const visibleHeight = availableHeight ? Math.max(5, availableHeight - 2) : Math.max(5, this.renderer.height - 6);
3527
3807
 
3528
- const outputBox = new ScrollBoxRenderable(this.renderer, {
3808
+ // Store visible height for scroll calculations
3809
+ this.paneVisibleHeight.set(pane.id, visibleHeight);
3810
+
3811
+ // Create output container (no ScrollBox - we handle scrolling virtually)
3812
+ const outputBox = new BoxRenderable(this.renderer, {
3529
3813
  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
- },
3814
+ flexDirection: 'column',
3815
+ flexGrow: 1,
3816
+ paddingLeft: 1,
3817
+ backgroundColor: '#000000',
3818
+ overflow: 'hidden',
3819
+ shouldFill: true,
3547
3820
  });
3548
3821
 
3549
- // Show scrollbar when paused, hide when not paused
3550
- if (outputBox.verticalScrollBar) {
3551
- outputBox.verticalScrollBar.width = this.isPaused ? 1 : 0;
3822
+ // Query only the visible lines from database
3823
+ const lines = this.outputDb.queryVisible(pane, visibleHeight, scrollOffset);
3824
+
3825
+ // Build content for visible lines
3826
+ this.buildPaneOutputVirtual(pane, outputBox, lines, visibleHeight);
3827
+
3828
+ // Store reference to output box for updates
3829
+ this.paneOutputBoxes.set(pane.id, outputBox);
3830
+
3831
+ // Track the last line number for incremental updates
3832
+ if (lines.length > 0) {
3833
+ this.paneLineCount.set(pane.id, lines[lines.length - 1].lineNumber);
3552
3834
  }
3553
3835
 
3554
- // Store ScrollBox reference for this pane
3555
- this.paneScrollBoxes.set(pane.id, outputBox);
3836
+ paneContainer.add(outputBox);
3837
+ return paneContainer;
3838
+ }
3839
+
3840
+ // Build pane output with virtual scrolling - only renders visible lines
3841
+ buildPaneOutputVirtual(pane, container, lines, visibleHeight) {
3842
+ const isFocused = pane.id === this.focusedPaneId;
3556
3843
 
3557
- this.buildPaneOutput(pane, outputBox.content, height);
3844
+ // Determine copy mode selection range
3845
+ const inCopyMode = this.isCopyMode && isFocused;
3846
+ let copySelStart = -1;
3847
+ let copySelEnd = -1;
3848
+ if (inCopyMode && this.copyModeAnchor !== null) {
3849
+ copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
3850
+ copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
3851
+ }
3558
3852
 
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);
3853
+ // Build all visible lines as single styled text
3854
+ const chunks = [];
3855
+ for (let i = 0; i < lines.length; i++) {
3856
+ const lineContent = this.formatOutputLine(lines[i], i, inCopyMode, copySelStart, copySelEnd);
3857
+ chunks.push(...lineContent.chunks);
3563
3858
  }
3564
3859
 
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
- }
3860
+ // Fill remaining visible area with blank lines to clear artifacts
3861
+ for (let i = lines.length; i < visibleHeight; i++) {
3862
+ chunks.push({ text: '\n' });
3575
3863
  }
3576
3864
 
3577
- paneContainer.add(outputBox);
3578
- return paneContainer;
3865
+ // Create single TextRenderable with all visible content
3866
+ const outputText = new TextRenderable(this.renderer, {
3867
+ id: `output-${pane.id}`,
3868
+ content: new StyledText(chunks),
3869
+ bg: '#000000',
3870
+ width: '100%',
3871
+ });
3872
+
3873
+ container.add(outputText);
3874
+
3875
+ // Store reference for updates
3876
+ this.lineRenderables.set(pane.id, [outputText]);
3579
3877
  }
3580
3878
 
3581
3879
  // Recursively build the pane layout, passing available height down
@@ -4162,6 +4460,11 @@ class ProcessManager {
4162
4460
  }
4163
4461
 
4164
4462
  // Remove old containers if they exist - use destroyRecursively to clean up all children
4463
+ if (this.wipeContainer) {
4464
+ this.renderer.root.remove(this.wipeContainer);
4465
+ this.wipeContainer.destroyRecursively();
4466
+ this.wipeContainer = null;
4467
+ }
4165
4468
  if (this.selectionContainer) {
4166
4469
  this.renderer.root.remove(this.selectionContainer);
4167
4470
  this.selectionContainer.destroyRecursively();
@@ -4221,7 +4524,7 @@ class ProcessManager {
4221
4524
  const isVisible = this.isProcessVisibleInPane(script.name, focusedPane);
4222
4525
  const nameColor = isSelected ? COLORS.accent : (isVisible ? processColor : COLORS.textDim);
4223
4526
  const numberColor = isVisible ? processColor : COLORS.textDim;
4224
- const indicator = isSelected ? '>' : ' ';
4527
+ const indicator = isSelected ? '' : ' ';
4225
4528
  const bracketColor = isVisible ? processColor : COLORS.textDim;
4226
4529
 
4227
4530
  // Show number for first 9 processes
@@ -4532,7 +4835,9 @@ async function main() {
4532
4835
  process.exit(1);
4533
4836
  }
4534
4837
 
4535
- const renderer = await createCliRenderer();
4838
+ const renderer = await createCliRenderer({
4839
+ backgroundColor: '#000000',
4840
+ });
4536
4841
  renderer.start(); // Start the automatic render loop
4537
4842
  const manager = new ProcessManager(renderer, scripts);
4538
4843
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "startall",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
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",