startall 0.0.23 → 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 +690 -342
  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;
@@ -429,6 +632,15 @@ function gitPush() {
429
632
  }
430
633
  }
431
634
 
635
+ function gitPull() {
636
+ try {
637
+ const output = execSync('git pull', { stdio: 'pipe', windowsHide: true, timeout: 30000 }).toString().trim();
638
+ return { success: true, output: output || 'Pulled successfully' };
639
+ } catch (err) {
640
+ return { success: false, error: err.stderr?.toString() || err.message };
641
+ }
642
+ }
643
+
432
644
  // Detect git repo once at startup
433
645
  const IS_GIT_REPO = isGitRepo();
434
646
 
@@ -541,10 +753,10 @@ class ProcessManager {
541
753
  this.selectedIndex = 0;
542
754
  this.processes = new Map();
543
755
  this.processRefs = new Map();
544
- this.outputLines = [];
756
+ this.maxOutputLines = 1000; // Lines kept in database
757
+ this.outputDb = new OutputDatabase(this.maxOutputLines);
545
758
  this.totalLinesReceived = 0; // Track total lines ever received (never resets)
546
759
  this.filter = '';
547
- this.maxOutputLines = 1000; // Lines kept in memory
548
760
  this.maxDomLines = 150; // Lines kept in DOM (buffer for varying heights)
549
761
  this.lineRenderables = new Map(); // Reusable TextRenderables per pane
550
762
  this.maxVisibleLines = null; // Calculated dynamically based on screen height
@@ -581,11 +793,12 @@ class ProcessManager {
581
793
  this.outputBox = null; // Reference to the output container
582
794
  this.destroyed = false; // Flag to prevent operations after cleanup
583
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
584
797
  this.headerRenderable = null; // Reference to header text in running UI
585
798
  this.processListRenderable = null; // Reference to process list text in running UI
586
799
  this.renderScheduled = false; // Throttle renders for CPU efficiency
587
800
  this.lastRenderTime = 0; // Timestamp of last render
588
- this.minRenderInterval = 100; // Minimum ms between renders (~10fps cap)
801
+ this.minRenderInterval = 1; // Minimum ms between renders (~60fps cap)
589
802
 
590
803
  // Performance metrics
591
804
  this.showPerformanceMetrics = this.config.showPerformanceMetrics || false;
@@ -600,16 +813,28 @@ class ProcessManager {
600
813
  this.splitMode = false; // Whether waiting for split command after Ctrl+b
601
814
  this.showSplitMenu = false; // Whether to show the command palette
602
815
  this.splitMenuIndex = 0; // Selected item in split menu
603
- this.paneScrollPositions = new Map(); // Store scroll positions per pane ID
604
- 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)
605
818
  this.paneFilterState = new Map(); // Track filter state per pane to detect changes
606
819
  this.paneLineCount = new Map(); // Track how many lines we've rendered per pane
607
820
  this.uiJustRebuilt = false; // Flag to skip redundant render after buildRunningUI
608
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
+
609
827
  // Column view state (one pane per script, side by side)
610
828
  this.isColumnView = false; // Whether column view is active
611
829
  this.savedPaneRoot = null; // Saved pane tree to restore when toggling back
612
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
+
613
838
  // Copy mode state (select text to copy)
614
839
  this.isCopyMode = false; // Whether in copy/select mode
615
840
  this.copyModeCursor = 0; // Current cursor line index within visible lines
@@ -664,13 +889,27 @@ class ProcessManager {
664
889
  return;
665
890
  }
666
891
 
667
- // Handle Ctrl+L - clear screen buffer and redraw
892
+ // Handle Ctrl+L - clear screen and redraw (kills artifacts)
668
893
  if (key.ctrl && key.name === 'l') {
669
- if (this.phase === 'running') {
670
- this.outputLines = [];
671
- this.totalLinesReceived = 0;
672
- this.buildRunningUI();
894
+ // Debounce - cancel any pending wipe and restart
895
+ if (this.wipeTimeout) {
896
+ clearTimeout(this.wipeTimeout);
673
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);
674
913
  return;
675
914
  }
676
915
 
@@ -865,13 +1104,13 @@ class ProcessManager {
865
1104
  this.buildRunningUI();
866
1105
  } else if (keyName === 'p') {
867
1106
  // Toggle pause output scrolling globally
868
- // Save scroll positions before rebuild
869
- for (const [paneId, scrollBox] of this.paneScrollBoxes.entries()) {
870
- if (scrollBox && scrollBox.scrollY !== undefined) {
871
- 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);
872
1112
  }
873
1113
  }
874
- this.isPaused = !this.isPaused;
875
1114
  this.updateStreamPauseState();
876
1115
  this.buildRunningUI();
877
1116
  } else if (keyName === 'f') {
@@ -1094,13 +1333,16 @@ class ProcessManager {
1094
1333
  } else {
1095
1334
  this.paneRoot = createPane([]); // Empty array means show all processes
1096
1335
  }
1097
- 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;
1098
1339
 
1099
1340
  selected.forEach(scriptName => {
1100
1341
  this.startProcess(scriptName);
1101
1342
  });
1102
1343
 
1103
- this.render();
1344
+ // Build the UI immediately (don't rely on render() since hasNewLines may be false)
1345
+ this.buildRunningUI();
1104
1346
  }
1105
1347
 
1106
1348
  startProcess(scriptName) {
@@ -1147,22 +1389,25 @@ class ProcessManager {
1147
1389
  }
1148
1390
 
1149
1391
  addOutputLine(processName, text) {
1150
- // Always store the output line, even when paused
1151
- // Pre-compute lowercase for faster filtering
1152
- this.outputLines.push({
1153
- process: processName,
1154
- processLower: processName.toLowerCase(),
1155
- text,
1156
- textLower: text.toLowerCase(),
1157
- timestamp: Date.now(),
1158
- lineNumber: ++this.totalLinesReceived, // Track absolute line number
1159
- });
1392
+ // Don't write if database is closed
1393
+ if (this.destroyed) return;
1160
1394
 
1161
- // Use shift() instead of slice() to avoid creating new array
1162
- while (this.outputLines.length > this.maxOutputLines) {
1163
- this.outputLines.shift();
1164
- }
1395
+ // Detect colors in the text for efficient SQL filtering
1396
+ const colorBitmask = detectColorBitmask(text);
1165
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
+
1166
1411
  // Only render if not paused - this prevents new output from appearing
1167
1412
  // when the user is reviewing history
1168
1413
  if (!this.isPaused) {
@@ -1811,19 +2056,38 @@ class ProcessManager {
1811
2056
  // Toggle visibility of selected process in focused pane
1812
2057
  toggleProcessVisibility() {
1813
2058
  const scriptName = this.scripts[this.selectedIndex]?.name;
1814
- if (!scriptName || !this.focusedPaneId) return;
2059
+ if (!scriptName || !this.focusedPaneId) {
2060
+ return;
2061
+ }
1815
2062
 
1816
2063
  const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1817
- if (!pane) return;
2064
+ if (!pane) {
2065
+ return;
2066
+ }
1818
2067
 
1819
- // Initialize hidden array if needed
2068
+ // Initialize arrays if needed
1820
2069
  if (!pane.hidden) pane.hidden = [];
2070
+ if (!pane.processes) pane.processes = [];
1821
2071
 
1822
- // Toggle: if hidden, show it; if visible, hide it
1823
- if (pane.hidden.includes(scriptName)) {
1824
- 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
+ }
1825
2084
  } else {
1826
- 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
+ }
1827
2091
  }
1828
2092
 
1829
2093
  this.savePaneLayout();
@@ -1837,38 +2101,46 @@ class ProcessManager {
1837
2101
  debouncedSaveConfig(this.config);
1838
2102
  }
1839
2103
 
1840
- // Scroll the focused pane
2104
+ // Scroll the focused pane (virtual scrolling - adjusts offset into database)
1841
2105
  scrollFocusedPane(direction) {
1842
2106
  if (!this.focusedPaneId) return;
1843
2107
 
1844
- const scrollBox = this.paneScrollBoxes.get(this.focusedPaneId);
1845
- if (!scrollBox || !scrollBox.scrollTo) return;
2108
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
2109
+ if (!pane) return;
1846
2110
 
1847
- const currentY = scrollBox.scrollTop || 0;
1848
- const viewportHeight = scrollBox.height || 20;
1849
- 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);
1850
2115
 
1851
- let newY = currentY;
2116
+ let newOffset = currentOffset;
1852
2117
 
1853
2118
  if (direction === 'home') {
1854
- newY = 0;
2119
+ // Scroll to top (maximum offset from end)
2120
+ newOffset = maxOffset;
1855
2121
  } else if (direction === 'end') {
1856
- newY = Number.MAX_SAFE_INTEGER;
2122
+ // Scroll to bottom (offset 0 = most recent)
2123
+ newOffset = 0;
1857
2124
  } else if (direction === 'pageup') {
1858
- newY = Math.max(0, currentY - viewportHeight);
2125
+ newOffset = Math.min(maxOffset, currentOffset + visibleHeight);
1859
2126
  } else if (direction === 'pagedown') {
1860
- 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);
1861
2132
  }
1862
2133
 
1863
- scrollBox.scrollTo({ x: 0, y: newY });
1864
-
1865
- // Save the new scroll position
1866
- this.paneScrollPositions.set(this.focusedPaneId, { x: 0, y: newY });
1867
-
1868
- // Auto-pause when manually scrolling (unless going to end)
1869
- if (direction !== 'end' && !this.isPaused) {
1870
- this.isPaused = true;
1871
- 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
+
1872
2144
  this.buildRunningUI();
1873
2145
  }
1874
2146
  }
@@ -2089,51 +2361,18 @@ class ProcessManager {
2089
2361
  }
2090
2362
 
2091
2363
  // Count horizontal splits (which reduce available height per pane)
2092
- // Get output lines for a specific pane - optimized single-pass filtering
2364
+ // Get output lines for a specific pane - queries SQLite with filters
2093
2365
  getOutputLinesForPane(pane) {
2094
- // Early return if no filters active
2095
- const hasProcessFilter = pane.processes.length > 0;
2096
- const hasHiddenFilter = pane.hidden && pane.hidden.length > 0;
2097
- const hasTextFilter = !!pane.filter;
2098
- const hasColorFilter = !!pane.colorFilter;
2099
-
2100
- if (!hasProcessFilter && !hasHiddenFilter && !hasTextFilter && !hasColorFilter) {
2101
- return this.outputLines;
2102
- }
2103
-
2104
- // Build Sets for O(1) lookups
2105
- const processSet = hasProcessFilter ? new Set(pane.processes) : null;
2106
- const hiddenSet = hasHiddenFilter ? new Set(pane.hidden) : null;
2107
- const filterLower = hasTextFilter ? pane.filter.toLowerCase() : null;
2108
-
2109
- // Single pass through lines
2110
- return this.outputLines.filter(line => {
2111
- // Check process filter
2112
- if (processSet && !processSet.has(line.process)) return false;
2113
-
2114
- // Check hidden filter
2115
- if (hiddenSet && hiddenSet.has(line.process)) return false;
2116
-
2117
- // Check text filter (use cached lowercase from line if available)
2118
- if (filterLower) {
2119
- const processLower = line.processLower || line.process.toLowerCase();
2120
- const textLower = line.textLower || line.text.toLowerCase();
2121
- if (!processLower.includes(filterLower) && !textLower.includes(filterLower)) {
2122
- return false;
2123
- }
2124
- }
2125
-
2126
- // Check color filter
2127
- if (hasColorFilter && !lineHasColor(line.text, pane.colorFilter)) {
2128
- return false;
2129
- }
2130
-
2131
- return true;
2132
- });
2366
+ return this.outputDb.queryForPane(pane);
2133
2367
  }
2134
2368
 
2135
2369
  buildSettingsUI() {
2136
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
+ }
2137
2376
  if (this.selectionContainer) {
2138
2377
  this.renderer.root.remove(this.selectionContainer);
2139
2378
  this.selectionContainer.destroyRecursively();
@@ -2530,6 +2769,11 @@ class ProcessManager {
2530
2769
  // Ignore
2531
2770
  }
2532
2771
  }
2772
+
2773
+ // Close the SQLite database
2774
+ if (this.outputDb) {
2775
+ this.outputDb.close();
2776
+ }
2533
2777
  }
2534
2778
 
2535
2779
  executeCommand(scriptName) {
@@ -2665,6 +2909,22 @@ class ProcessManager {
2665
2909
  this.refreshGitStatus();
2666
2910
  this.buildRunningUI();
2667
2911
  }, 10);
2912
+ } else if (keyName === 'l') {
2913
+ // Pull
2914
+ this.gitModalPhase = 'pulling';
2915
+ this.gitModalOutput = ['Pulling...'];
2916
+ this.buildRunningUI();
2917
+ setTimeout(() => {
2918
+ const result = gitPull();
2919
+ if (result.success) {
2920
+ this.gitModalOutput = [result.output];
2921
+ } else {
2922
+ this.gitModalOutput = [`Pull failed: ${result.error}`];
2923
+ }
2924
+ this.gitModalPhase = 'result';
2925
+ this.refreshGitStatus();
2926
+ this.buildRunningUI();
2927
+ }, 10);
2668
2928
  } else if (keyName === 'r') {
2669
2929
  // Refresh status
2670
2930
  this.refreshGitStatus();
@@ -2743,6 +3003,22 @@ class ProcessManager {
2743
3003
  this.refreshGitStatus();
2744
3004
  this.buildRunningUI();
2745
3005
  }, 10);
3006
+ } else if (keyName === 'l') {
3007
+ // Allow pulling from result phase
3008
+ this.gitModalPhase = 'pulling';
3009
+ this.gitModalOutput = ['Pulling...'];
3010
+ this.buildRunningUI();
3011
+ setTimeout(() => {
3012
+ const result = gitPull();
3013
+ if (result.success) {
3014
+ this.gitModalOutput = [result.output];
3015
+ } else {
3016
+ this.gitModalOutput = [`Pull failed: ${result.error}`];
3017
+ }
3018
+ this.gitModalPhase = 'result';
3019
+ this.refreshGitStatus();
3020
+ this.buildRunningUI();
3021
+ }, 10);
2746
3022
  } else {
2747
3023
  this.gitModalPhase = 'status';
2748
3024
  this.refreshGitStatus();
@@ -2752,8 +3028,56 @@ class ProcessManager {
2752
3028
  // 'committing' and 'pushing' phases ignore input (busy)
2753
3029
  }
2754
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
+
2755
3074
  buildSelectionUI() {
2756
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
+ }
2757
3081
  if (this.selectionContainer) {
2758
3082
  this.renderer.root.remove(this.selectionContainer);
2759
3083
  this.selectionContainer.destroyRecursively();
@@ -3039,7 +3363,9 @@ class ProcessManager {
3039
3363
 
3040
3364
  getPerformanceString() {
3041
3365
  const metrics = this.getPerformanceMetrics();
3042
- 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()}`;
3043
3369
  }
3044
3370
 
3045
3371
  getProcessListContent() {
@@ -3178,109 +3504,79 @@ class ProcessManager {
3178
3504
  }
3179
3505
  }
3180
3506
 
3181
- // Update existing panes incrementally, or rebuild if needed
3182
- if (this.paneScrollBoxes.size > 0) {
3183
- // Incremental update - just append new lines to existing panes
3184
- const maxLinesPerUpdate = 200; // Limit lines added per render
3185
- // When live, limit DOM to screen height (no scroll needed)
3186
- // When paused, keep all for scrollback
3187
- const screenHeight = this.renderer.height || 50;
3188
- const maxDomLinesPerPane = this.isPaused ? this.maxOutputLines : screenHeight;
3189
-
3190
- 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()) {
3191
3516
  const pane = findPaneById(this.paneRoot, paneId);
3192
- if (!pane || !scrollBox || !scrollBox.content) {
3193
- // ScrollBox invalid, need full rebuild
3517
+ if (!pane || !outputBox) {
3194
3518
  this.buildRunningUI();
3195
3519
  return;
3196
3520
  }
3197
3521
 
3198
- // Only update focused pane every frame, others less frequently
3199
- const isFocused = paneId === this.focusedPaneId;
3200
- if (!isFocused && this.paneScrollBoxes.size > 1) {
3201
- const lastUpdate = this.paneLastUpdate?.get(paneId) || 0;
3202
- if (now - lastUpdate < 200) continue; // Update non-focused panes every 200ms
3203
- if (!this.paneLastUpdate) this.paneLastUpdate = new Map();
3204
- this.paneLastUpdate.set(paneId, now);
3205
- }
3522
+ // Only update if at the bottom (offset=0)
3523
+ const scrollOffset = this.paneScrollOffsets.get(paneId) || 0;
3524
+ if (scrollOffset !== 0) continue;
3206
3525
 
3207
- const lastRenderedLineNumber = this.paneLineCount.get(paneId) || 0;
3208
-
3209
- // Cache filter values outside loop
3210
- const hasProcessFilter = pane.processes.length > 0;
3211
- const processSet = hasProcessFilter ? new Set(pane.processes) : null;
3212
- const hasHiddenFilter = pane.hidden && pane.hidden.length > 0;
3213
- const hiddenSet = hasHiddenFilter ? new Set(pane.hidden) : null;
3214
- const filterLower = pane.filter ? pane.filter.toLowerCase() : null;
3215
- const colorFilter = pane.colorFilter;
3216
-
3217
- // Only look at lines newer than what we've rendered - avoid filtering all lines
3218
- let newLines = [];
3219
- for (let i = this.outputLines.length - 1; i >= 0; i--) {
3220
- const line = this.outputLines[i];
3221
- if (line.lineNumber <= lastRenderedLineNumber) break;
3222
- // Apply pane filters inline with cached values
3223
- if (processSet && !processSet.has(line.process)) continue;
3224
- if (hiddenSet && hiddenSet.has(line.process)) continue;
3225
- if (filterLower && !line.processLower.includes(filterLower) && !line.textLower.includes(filterLower)) continue;
3226
- if (colorFilter && !lineHasColor(line.text, colorFilter)) continue;
3227
- newLines.unshift(line);
3228
- if (newLines.length >= maxLinesPerUpdate) break;
3229
- }
3230
-
3231
- if (newLines.length > 0) {
3232
- // Get or create renderable pool for this pane
3233
- if (!this.lineRenderables.has(paneId)) {
3234
- this.lineRenderables.set(paneId, []);
3235
- }
3236
- const renderables = this.lineRenderables.get(paneId);
3237
-
3238
- // Add new lines - reuse existing renderables or create new ones
3239
- for (const line of newLines) {
3240
- const processColor = this.processColors.get(line.process) || COLORS.text;
3241
-
3242
- // Build content
3243
- const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3244
- const timestamp = this.showTimestamps ? (line.timeString || (line.timeString = new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }))) : '';
3245
-
3246
- let content;
3247
- if (this.showLineNumbers && this.showTimestamps) {
3248
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3249
- } else if (this.showLineNumbers) {
3250
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3251
- } else if (this.showTimestamps) {
3252
- content = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3253
- } else {
3254
- content = t`${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3255
- }
3256
-
3257
- // Create new renderable and add to pool
3258
- const outputLine = new TextRenderable(this.renderer, {
3259
- id: `output-${pane.id}-${line.lineNumber}`,
3260
- content: content,
3261
- bg: '#000000',
3262
- });
3263
-
3264
- scrollBox.content.add(outputLine);
3265
- renderables.push(outputLine);
3266
- }
3267
-
3268
- // Remove excess old lines - keep DOM small for performance
3269
- while (renderables.length > maxDomLinesPerPane) {
3270
- const oldLine = renderables.shift();
3271
- scrollBox.content.remove(oldLine);
3272
- oldLine.destroy();
3273
- }
3274
-
3275
- // Update to track the last absolute line number we rendered
3276
- this.paneLineCount.set(paneId, newLines[newLines.length - 1].lineNumber);
3277
-
3278
- // Auto-scroll to bottom if not paused
3279
- if (!this.isPaused && scrollBox.scrollTo) {
3280
- 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]);
3281
3554
  }
3282
3555
  }
3556
+ styledText = new StyledText(chunks);
3557
+ this.paneContentCache.set(paneId, { firstLineId, lineCount: lines.length, chunks });
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();
3283
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
+ }
3284
3580
  } else {
3285
3581
  // First time or no panes exist - do full rebuild
3286
3582
  this.buildRunningUI();
@@ -3295,9 +3591,8 @@ class ProcessManager {
3295
3591
  }
3296
3592
 
3297
3593
  // Remove old process bar items
3298
- while (this.processBarContainer.children && this.processBarContainer.children.length > 0) {
3299
- const child = this.processBarContainer.children[0];
3300
- this.processBarContainer.remove(child);
3594
+ for (const child of this.processBarContainer.getChildren()) {
3595
+ this.processBarContainer.remove(child.id);
3301
3596
  child.destroy();
3302
3597
  }
3303
3598
 
@@ -3314,7 +3609,7 @@ class ProcessManager {
3314
3609
  const isVisible = this.isProcessVisibleInPane(script.name, focusedPane);
3315
3610
  const nameColor = isSelected ? COLORS.accent : (isVisible ? processColor : COLORS.textDim);
3316
3611
  const numberColor = isVisible ? processColor : COLORS.textDim;
3317
- const indicator = isSelected ? '>' : ' ';
3612
+ const indicator = isSelected ? '' : ' ';
3318
3613
  const bracketColor = isVisible ? processColor : COLORS.textDim;
3319
3614
 
3320
3615
  const numberLabel = index < 9 ? `${index + 1}` : ' ';
@@ -3334,7 +3629,83 @@ class ProcessManager {
3334
3629
  });
3335
3630
  }
3336
3631
 
3337
- // 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
3338
3709
  buildPaneOutput(pane, container, height) {
3339
3710
  const isFocused = pane.id === this.focusedPaneId;
3340
3711
  const lines = this.getOutputLinesForPane(pane);
@@ -3344,90 +3715,34 @@ class ProcessManager {
3344
3715
  const maxLines = this.isPaused ? lines.length : Math.min(lines.length, height || 50);
3345
3716
  const linesToShow = lines.slice(-maxLines);
3346
3717
 
3347
- // Initialize renderable pool for this pane
3348
- const renderables = [];
3349
- this.lineRenderables.set(pane.id, renderables);
3350
-
3351
3718
  // Determine copy mode selection range for this pane
3352
3719
  const inCopyMode = this.isCopyMode && isFocused;
3353
3720
  let copySelStart = -1;
3354
3721
  let copySelEnd = -1;
3355
- if (inCopyMode) {
3356
- if (this.copyModeAnchor !== null) {
3357
- copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
3358
- copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
3359
- }
3722
+ if (inCopyMode && this.copyModeAnchor !== null) {
3723
+ copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
3724
+ copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
3360
3725
  }
3361
3726
 
3362
- // 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 = [];
3363
3729
  for (let i = 0; i < linesToShow.length; i++) {
3364
- const line = linesToShow[i];
3365
- const processColor = this.processColors.get(line.process) || COLORS.text;
3366
-
3367
- // Build content with proper template literal
3368
- const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
3369
- const timestamp = this.showTimestamps ? new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }) : '';
3370
-
3371
- // Determine copy mode highlighting for this line
3372
- const isCursorLine = inCopyMode && i === this.copyModeCursor;
3373
- const isSelectedLine = inCopyMode && i >= copySelStart && i <= copySelEnd;
3374
-
3375
- let content;
3376
- if (inCopyMode) {
3377
- // In copy mode: cursor line gets bright marker + white text on blue bg
3378
- // Selected lines get lighter text on darker blue bg
3379
- // Unselected lines are dimmed to make selection stand out
3380
- const marker = isCursorLine ? fg(COLORS.copyCursorText)('\u25b6 ') : ' ';
3381
-
3382
- let textColor, procColor, dimColor;
3383
- if (isCursorLine) {
3384
- textColor = COLORS.copyCursorText;
3385
- procColor = COLORS.copyCursorText;
3386
- dimColor = COLORS.copySelectText;
3387
- } else if (isSelectedLine) {
3388
- textColor = COLORS.copySelectText;
3389
- procColor = processColor;
3390
- dimColor = COLORS.textDim;
3391
- } else {
3392
- textColor = COLORS.textDim;
3393
- procColor = COLORS.textDim;
3394
- dimColor = COLORS.textDim;
3395
- }
3396
-
3397
- if (this.showLineNumbers && this.showTimestamps) {
3398
- content = t`${marker}${fg(dimColor)(lineNumber)} ${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3399
- } else if (this.showLineNumbers) {
3400
- content = t`${marker}${fg(dimColor)(lineNumber)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3401
- } else if (this.showTimestamps) {
3402
- content = t`${marker}${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3403
- } else {
3404
- content = t`${marker}${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
3405
- }
3406
- } else {
3407
- if (this.showLineNumbers && this.showTimestamps) {
3408
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3409
- } else if (this.showLineNumbers) {
3410
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3411
- } else if (this.showTimestamps) {
3412
- content = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3413
- } else {
3414
- content = t`${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3415
- }
3416
- }
3417
-
3418
- // High-contrast background: bright blue for cursor, medium blue for selected, black for rest
3419
- const bgColor = isCursorLine ? COLORS.copyCursorBg : (isSelectedLine ? COLORS.copySelectBg : '#000000');
3420
-
3421
- const outputLine = new TextRenderable(this.renderer, {
3422
- id: `output-${pane.id}-${line.lineNumber}`,
3423
- content: content,
3424
- bg: bgColor,
3425
- });
3426
-
3427
- container.add(outputLine);
3428
- renderables.push(outputLine);
3730
+ const lineContent = this.formatOutputLine(linesToShow[i], i, inCopyMode, copySelStart, copySelEnd);
3731
+ chunks.push(...lineContent.chunks);
3429
3732
  }
3430
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
+
3431
3746
  // Track last rendered line number
3432
3747
  if (linesToShow.length > 0) {
3433
3748
  this.paneLineCount.set(pane.id, linesToShow[linesToShow.length - 1].lineNumber);
@@ -3447,7 +3762,7 @@ class ProcessManager {
3447
3762
  }
3448
3763
  }
3449
3764
 
3450
- // Build a pane panel with title bar
3765
+ // Build a pane panel with title bar - uses virtual scrolling (no ScrollBox)
3451
3766
  buildPanePanel(pane, flexGrow = 1, availableHeight = null) {
3452
3767
  const isFocused = pane.id === this.focusedPaneId;
3453
3768
  const borderColor = isFocused ? COLORS.borderFocused : COLORS.border;
@@ -3463,14 +3778,20 @@ class ProcessManager {
3463
3778
  const filterLabel = pane.filter ? ` /${pane.filter}` : '';
3464
3779
  const namingInputLabel = (isFocused && this.isNamingMode) ? `Name: ${this.namingModeText}_` : '';
3465
3780
  const filterInputLabel = (isFocused && this.isFilterMode) ? `/${pane.filter || ''}_` : '';
3466
- 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} `;
3467
3788
 
3468
3789
  const paneContainer = new BoxRenderable(this.renderer, {
3469
3790
  id: `pane-${pane.id}`,
3470
3791
  flexDirection: 'column',
3471
3792
  flexGrow: flexGrow,
3472
- flexShrink: 0, // Prevent shrinking - maintain 50/50 split
3473
- flexBasis: 0, // Use flexGrow ratio for sizing, not content size
3793
+ flexShrink: 0,
3794
+ flexBasis: 0,
3474
3795
  border: true,
3475
3796
  borderStyle: 'rounded',
3476
3797
  borderColor: borderColor,
@@ -3478,63 +3799,81 @@ class ProcessManager {
3478
3799
  titleAlignment: 'left',
3479
3800
  padding: 0,
3480
3801
  overflow: 'hidden',
3481
- backgroundColor: '#000000', // Black background for pane container
3802
+ backgroundColor: '#000000',
3482
3803
  });
3483
3804
 
3484
- // Use passed height or calculate default for line count calculation
3485
- 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);
3486
3807
 
3487
- 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, {
3488
3813
  id: `pane-output-${pane.id}`,
3489
- height: height,
3490
- scrollX: false,
3491
- scrollY: true,
3492
- focusable: true,
3493
- style: {
3494
- rootOptions: {
3495
- flexGrow: 1,
3496
- flexShrink: 1,
3497
- flexBasis: 0,
3498
- paddingLeft: 1,
3499
- backgroundColor: '#000000',
3500
- },
3501
- contentOptions: {
3502
- backgroundColor: '#000000',
3503
- width: '100%',
3504
- },
3505
- },
3814
+ flexDirection: 'column',
3815
+ flexGrow: 1,
3816
+ paddingLeft: 1,
3817
+ backgroundColor: '#000000',
3818
+ overflow: 'hidden',
3819
+ shouldFill: true,
3506
3820
  });
3507
3821
 
3508
- // Show scrollbar when paused, hide when not paused
3509
- if (outputBox.verticalScrollBar) {
3510
- 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);
3511
3834
  }
3512
3835
 
3513
- // Store ScrollBox reference for this pane
3514
- 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;
3515
3843
 
3516
- 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
+ }
3517
3852
 
3518
- // Update paneLineCount so updateRunningUI() won't re-add these lines
3519
- const paneLines = this.getOutputLinesForPane(pane);
3520
- if (paneLines.length > 0) {
3521
- 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);
3522
3858
  }
3523
3859
 
3524
- // Restore or set scroll position immediately
3525
- if (outputBox && outputBox.scrollTo) {
3526
- if (this.isPaused && this.paneScrollPositions.has(pane.id)) {
3527
- // Restore saved scroll position when paused
3528
- const savedPos = this.paneScrollPositions.get(pane.id);
3529
- outputBox.scrollTo(savedPos);
3530
- } else if (!this.isPaused) {
3531
- // Auto-scroll to bottom when not paused
3532
- outputBox.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER });
3533
- }
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' });
3534
3863
  }
3535
3864
 
3536
- paneContainer.add(outputBox);
3537
- 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]);
3538
3877
  }
3539
3878
 
3540
3879
  // Recursively build the pane layout, passing available height down
@@ -3835,6 +4174,7 @@ class ProcessManager {
3835
4174
  let titleIcon = '';
3836
4175
  if (this.gitModalPhase === 'committing') titleIcon = '...';
3837
4176
  else if (this.gitModalPhase === 'pushing') titleIcon = '...';
4177
+ else if (this.gitModalPhase === 'pulling') titleIcon = '...';
3838
4178
  else titleIcon = '';
3839
4179
  const title = ` Git: ${branch} ${titleIcon}`;
3840
4180
 
@@ -3924,9 +4264,9 @@ class ProcessManager {
3924
4264
  content: t`${fg(COLORS.textDim)('All changes will be staged and committed.')}`,
3925
4265
  });
3926
4266
  overlay.add(commitHint);
3927
- } else if (this.gitModalPhase === 'committing' || this.gitModalPhase === 'pushing') {
4267
+ } else if (this.gitModalPhase === 'committing' || this.gitModalPhase === 'pushing' || this.gitModalPhase === 'pulling') {
3928
4268
  // Show busy indicator
3929
- const busyText = this.gitModalPhase === 'committing' ? 'Committing...' : 'Pushing...';
4269
+ const busyText = this.gitModalPhase === 'committing' ? 'Committing...' : this.gitModalPhase === 'pushing' ? 'Pushing...' : 'Pulling...';
3930
4270
  const busyLine = new TextRenderable(this.renderer, {
3931
4271
  id: 'git-busy',
3932
4272
  content: t`${fg(COLORS.warning)(busyText)}`,
@@ -4080,7 +4420,7 @@ class ProcessManager {
4080
4420
  });
4081
4421
  hintBar.add(hint);
4082
4422
  });
4083
- } else if (this.gitModalPhase === 'committing' || this.gitModalPhase === 'pushing') {
4423
+ } else if (this.gitModalPhase === 'committing' || this.gitModalPhase === 'pushing' || this.gitModalPhase === 'pulling') {
4084
4424
  const hint = new TextRenderable(this.renderer, {
4085
4425
  id: 'git-hint-busy',
4086
4426
  content: t`${fg(COLORS.warning)('Please wait...')}`,
@@ -4091,6 +4431,7 @@ class ProcessManager {
4091
4431
  { key: 'c', desc: 'commit', color: COLORS.success },
4092
4432
  { key: 'a', desc: 'stage all', color: COLORS.warning },
4093
4433
  { key: 'p', desc: 'push', color: COLORS.cyan },
4434
+ { key: 'l', desc: 'pull', color: COLORS.cyan },
4094
4435
  { key: 'r', desc: 'refresh', color: COLORS.magenta },
4095
4436
  { key: 'esc', desc: 'close', color: COLORS.error },
4096
4437
  ];
@@ -4119,6 +4460,11 @@ class ProcessManager {
4119
4460
  }
4120
4461
 
4121
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
+ }
4122
4468
  if (this.selectionContainer) {
4123
4469
  this.renderer.root.remove(this.selectionContainer);
4124
4470
  this.selectionContainer.destroyRecursively();
@@ -4178,7 +4524,7 @@ class ProcessManager {
4178
4524
  const isVisible = this.isProcessVisibleInPane(script.name, focusedPane);
4179
4525
  const nameColor = isSelected ? COLORS.accent : (isVisible ? processColor : COLORS.textDim);
4180
4526
  const numberColor = isVisible ? processColor : COLORS.textDim;
4181
- const indicator = isSelected ? '>' : ' ';
4527
+ const indicator = isSelected ? '' : ' ';
4182
4528
  const bracketColor = isVisible ? processColor : COLORS.textDim;
4183
4529
 
4184
4530
  // Show number for first 9 processes
@@ -4489,7 +4835,9 @@ async function main() {
4489
4835
  process.exit(1);
4490
4836
  }
4491
4837
 
4492
- const renderer = await createCliRenderer();
4838
+ const renderer = await createCliRenderer({
4839
+ backgroundColor: '#000000',
4840
+ });
4493
4841
  renderer.start(); // Start the automatic render loop
4494
4842
  const manager = new ProcessManager(renderer, scripts);
4495
4843