startall 0.0.18 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/index.js +389 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,7 @@ Traditional solutions fall short:
46
46
  - **Persistent layouts**: Your pane configuration is saved between sessions
47
47
  - **Process-specific views**: Show/hide specific processes in each pane
48
48
  - **Colored output**: Each process gets unique color-coded output
49
+ - **Copy mode**: Select and copy output lines to clipboard (`y`)
49
50
  - **Pause/resume**: Freeze output to review logs (`p`)
50
51
  - **Scrollable history**: 1000-line buffer with mouse wheel support
51
52
  - **Enhanced navigation**: Home/End/PageUp/PageDown keys
@@ -121,6 +122,15 @@ That's it! The TUI will:
121
122
  - `Shift+Tab` - Previous pane
122
123
  - `n` - Name current pane
123
124
 
125
+ *Copy Mode:*
126
+ - `y` - Enter copy mode (select text to copy to clipboard)
127
+ - `↑`/`↓` or `k`/`j` - Move cursor
128
+ - `Home`/`End` or `g`/`G` - Jump to first/last line
129
+ - `Page Up`/`Page Down` - Scroll by page
130
+ - `Space` - Start/clear selection from cursor
131
+ - `Enter` or `y` - Copy selected lines (or current line) to clipboard
132
+ - `Esc` or `q` - Exit copy mode
133
+
124
134
  *Filtering & View:*
125
135
  - `/` - Enter text filter mode
126
136
  - `c` - Cycle color filter (red/yellow/green/blue/cyan/magenta/none)
package/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { createCliRenderer, TextRenderable, BoxRenderable, ScrollBoxRenderable, t, fg } from '@opentui/core';
4
- import { spawn } from 'child_process';
5
- import { readFileSync, writeFileSync, existsSync } from 'fs';
4
+ import { spawn, execSync, spawnSync } from 'child_process';
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';
@@ -261,6 +261,76 @@ function deserializePaneTree(data) {
261
261
  };
262
262
  }
263
263
 
264
+ // Copy text to system clipboard
265
+ // Tries platform-specific commands first (most reliable), then OSC 52 as fallback
266
+ function copyToClipboard(text) {
267
+ let copied = false;
268
+
269
+ // Try platform-specific clipboard commands first (most reliable)
270
+ try {
271
+ const platform = process.platform;
272
+ if (platform === 'win32') {
273
+ // PowerShell Set-Clipboard handles unicode and doesn't add trailing newline like clip.exe
274
+ const result = spawnSync('powershell', ['-NoProfile', '-Command', 'Set-Clipboard -Value $input'], {
275
+ input: text,
276
+ stdio: ['pipe', 'ignore', 'ignore'],
277
+ timeout: 3000,
278
+ windowsHide: true,
279
+ });
280
+ if (result.status === 0) copied = true;
281
+ else {
282
+ // Fallback to clip.exe
283
+ const clipResult = spawnSync('clip', [], {
284
+ input: text,
285
+ stdio: ['pipe', 'ignore', 'ignore'],
286
+ timeout: 3000,
287
+ windowsHide: true,
288
+ });
289
+ if (clipResult.status === 0) copied = true;
290
+ }
291
+ } else if (platform === 'darwin') {
292
+ const result = spawnSync('pbcopy', [], {
293
+ input: text,
294
+ stdio: ['pipe', 'ignore', 'ignore'],
295
+ timeout: 3000,
296
+ });
297
+ if (result.status === 0) copied = true;
298
+ } else {
299
+ // Linux - try xclip, then xsel, then wl-copy (Wayland)
300
+ for (const cmd of [
301
+ ['xclip', ['-selection', 'clipboard']],
302
+ ['xsel', ['--clipboard', '--input']],
303
+ ['wl-copy', []],
304
+ ]) {
305
+ try {
306
+ const result = spawnSync(cmd[0], cmd[1], {
307
+ input: text,
308
+ stdio: ['pipe', 'ignore', 'ignore'],
309
+ timeout: 3000,
310
+ });
311
+ if (result.status === 0) { copied = true; break; }
312
+ } catch { /* try next */ }
313
+ }
314
+ }
315
+ } catch {
316
+ // Platform commands unavailable
317
+ }
318
+
319
+ // Fallback: OSC 52 escape sequence (works in many modern terminals)
320
+ // Write directly to fd to bypass any buffering from the TUI renderer
321
+ if (!copied) {
322
+ try {
323
+ const encoded = Buffer.from(text).toString('base64');
324
+ const osc = `\x1b]52;c;${encoded}\x1b\\`;
325
+ writeSync(1, osc);
326
+ } catch {
327
+ // Nothing more we can do
328
+ }
329
+ }
330
+
331
+ return copied;
332
+ }
333
+
264
334
  // Color palette (inspired by Tokyo Night theme)
265
335
  const COLORS = {
266
336
  border: '#3b4261',
@@ -276,6 +346,11 @@ const COLORS = {
276
346
  warning: '#e0af68',
277
347
  cyan: '#7dcfff',
278
348
  magenta: '#bb9af7',
349
+ // Copy mode colors (high contrast for visibility)
350
+ copyCursorBg: '#3d59a1', // Bright blue bg for cursor line
351
+ copySelectBg: '#2a3a6e', // Medium blue bg for selected range
352
+ copyCursorText: '#ffffff', // White text on cursor line
353
+ copySelectText: '#c0caf5', // Light text on selected lines
279
354
  };
280
355
 
281
356
  // Match string against pattern with wildcard support
@@ -428,6 +503,15 @@ class ProcessManager {
428
503
  this.paneScrollBoxes = new Map(); // Store ScrollBox references per pane ID
429
504
  this.paneFilterState = new Map(); // Track filter state per pane to detect changes
430
505
  this.paneLineCount = new Map(); // Track how many lines we've rendered per pane
506
+ this.uiJustRebuilt = false; // Flag to skip redundant render after buildRunningUI
507
+
508
+ // Copy mode state (select text to copy)
509
+ this.isCopyMode = false; // Whether in copy/select mode
510
+ this.copyModeCursor = 0; // Current cursor line index within visible lines
511
+ this.copyModeAnchor = null; // Selection anchor (null = no selection started, number = anchor line index)
512
+ this.copyModeWasPaused = false; // Whether output was already paused before entering copy mode
513
+ this.copyFeedbackMessage = ''; // Temporary feedback message after copying
514
+ this.copyFeedbackTimer = null; // Timer to clear feedback message
431
515
 
432
516
  // Assign colors to each script
433
517
  this.processColors = new Map();
@@ -613,6 +697,10 @@ class ProcessManager {
613
697
  if (pane) pane.filter = (pane.filter || '') + keyName;
614
698
  this.buildRunningUI(); // Update UI to show filter change
615
699
  }
700
+ }
701
+ // If in copy mode, handle copy mode input
702
+ else if (this.isCopyMode) {
703
+ this.handleCopyModeInput(keyName, keyEvent);
616
704
  } else {
617
705
  // Normal mode - handle commands
618
706
  if (keyName === 'q') {
@@ -766,6 +854,9 @@ class ProcessManager {
766
854
  this.showRunCommandModal = true;
767
855
  this.runCommandModalIndex = 0;
768
856
  this.buildRunningUI();
857
+ } else if (keyName === 'y') {
858
+ // Enter copy mode (select text to copy)
859
+ this.enterCopyMode();
769
860
  } else if (keyName && keyName.length === 1 && !keyEvent.ctrl && !keyEvent.meta && !keyEvent.shift) {
770
861
  // Check if this key is a custom shortcut
771
862
  const shortcuts = this.config.shortcuts || {};
@@ -1578,6 +1669,204 @@ class ProcessManager {
1578
1669
  }
1579
1670
  }
1580
1671
 
1672
+ // Enter copy mode for the focused pane
1673
+ enterCopyMode() {
1674
+ if (!this.focusedPaneId) return;
1675
+
1676
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1677
+ if (!pane) return;
1678
+
1679
+ const lines = this.getOutputLinesForPane(pane);
1680
+ if (lines.length === 0) return;
1681
+
1682
+ this.isCopyMode = true;
1683
+ this.copyModeAnchor = null;
1684
+ this.copyModeCursor = lines.length - 1; // Start at the last line
1685
+
1686
+ // Auto-pause output so lines don't shift while selecting
1687
+ this.copyModeWasPaused = this.isPaused;
1688
+ if (!this.isPaused) {
1689
+ this.isPaused = true;
1690
+ this.updateStreamPauseState();
1691
+ }
1692
+
1693
+ // Ensure cursor is visible — save scroll position to show the last line
1694
+ // Use MAX_SAFE_INTEGER so buildRunningUI scrolls to bottom where cursor starts
1695
+ this.paneScrollPositions.set(this.focusedPaneId, { x: 0, y: Number.MAX_SAFE_INTEGER });
1696
+
1697
+ this.buildRunningUI();
1698
+ }
1699
+
1700
+ // Exit copy mode
1701
+ exitCopyMode() {
1702
+ this.isCopyMode = false;
1703
+ this.copyModeCursor = 0;
1704
+ this.copyModeAnchor = null;
1705
+
1706
+ // Restore pause state
1707
+ if (!this.copyModeWasPaused) {
1708
+ this.isPaused = false;
1709
+ this.updateStreamPauseState();
1710
+ }
1711
+
1712
+ this.buildRunningUI();
1713
+ }
1714
+
1715
+ // Handle keyboard input in copy mode
1716
+ handleCopyModeInput(keyName, keyEvent) {
1717
+ if (!this.focusedPaneId) {
1718
+ this.exitCopyMode();
1719
+ return;
1720
+ }
1721
+
1722
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1723
+ if (!pane) {
1724
+ this.exitCopyMode();
1725
+ return;
1726
+ }
1727
+
1728
+ const lines = this.getOutputLinesForPane(pane);
1729
+ const lineCount = lines.length;
1730
+ if (lineCount === 0) {
1731
+ this.exitCopyMode();
1732
+ return;
1733
+ }
1734
+
1735
+ if (keyName === 'escape' || keyName === 'q') {
1736
+ this.exitCopyMode();
1737
+ } else if (keyName === 'up' || keyName === 'k') {
1738
+ this.copyModeCursor = Math.max(0, this.copyModeCursor - 1);
1739
+ this.scrollCopyModeCursorIntoView();
1740
+ this.buildRunningUI();
1741
+ } else if (keyName === 'down' || keyName === 'j') {
1742
+ this.copyModeCursor = Math.min(lineCount - 1, this.copyModeCursor + 1);
1743
+ this.scrollCopyModeCursorIntoView();
1744
+ this.buildRunningUI();
1745
+ } else if (keyName === 'home' || (keyName === 'g' && !keyEvent.shift)) {
1746
+ this.copyModeCursor = 0;
1747
+ this.scrollCopyModeCursorIntoView();
1748
+ this.buildRunningUI();
1749
+ } else if (keyName === 'end' || keyName === 'G') {
1750
+ this.copyModeCursor = lineCount - 1;
1751
+ this.scrollCopyModeCursorIntoView();
1752
+ this.buildRunningUI();
1753
+ } else if (keyName === 'pageup') {
1754
+ const scrollBox = this.paneScrollBoxes.get(this.focusedPaneId);
1755
+ const pageSize = (scrollBox?.height || 20);
1756
+ this.copyModeCursor = Math.max(0, this.copyModeCursor - pageSize);
1757
+ this.scrollCopyModeCursorIntoView();
1758
+ this.buildRunningUI();
1759
+ } else if (keyName === 'pagedown') {
1760
+ const scrollBox = this.paneScrollBoxes.get(this.focusedPaneId);
1761
+ const pageSize = (scrollBox?.height || 20);
1762
+ this.copyModeCursor = Math.min(lineCount - 1, this.copyModeCursor + pageSize);
1763
+ this.scrollCopyModeCursorIntoView();
1764
+ this.buildRunningUI();
1765
+ } else if (keyName === 'space') {
1766
+ // Toggle selection anchor
1767
+ if (this.copyModeAnchor === null) {
1768
+ // Start selection from current cursor position
1769
+ this.copyModeAnchor = this.copyModeCursor;
1770
+ } else {
1771
+ // Clear selection
1772
+ this.copyModeAnchor = null;
1773
+ }
1774
+ this.buildRunningUI();
1775
+ } else if (keyName === 'enter' || keyName === 'return' || keyName === 'y') {
1776
+ // Copy selected lines (or current line if no selection)
1777
+ this.copySelectedLines();
1778
+ }
1779
+ }
1780
+
1781
+ // Compute scroll position to keep cursor visible and save to paneScrollPositions.
1782
+ // buildRunningUI() will restore this position after rebuilding the scrollbox.
1783
+ scrollCopyModeCursorIntoView() {
1784
+ const cursorY = this.copyModeCursor;
1785
+
1786
+ // Get current scroll position and viewport size from either live scrollBox or saved state
1787
+ const scrollBox = this.paneScrollBoxes.get(this.focusedPaneId);
1788
+ const savedPos = this.paneScrollPositions.get(this.focusedPaneId);
1789
+ const viewportHeight = scrollBox?.height || 20;
1790
+ const currentScrollY = scrollBox?.scrollTop ?? savedPos?.y ?? 0;
1791
+
1792
+ let newY = currentScrollY;
1793
+
1794
+ // If cursor is above the viewport, scroll up to show it
1795
+ if (cursorY < currentScrollY) {
1796
+ newY = cursorY;
1797
+ }
1798
+ // If cursor is below the viewport, scroll down to show it
1799
+ else if (cursorY >= currentScrollY + viewportHeight) {
1800
+ newY = cursorY - viewportHeight + 1;
1801
+ }
1802
+
1803
+ // Save computed position — buildRunningUI will restore it since we're paused
1804
+ this.paneScrollPositions.set(this.focusedPaneId, { x: 0, y: newY });
1805
+ }
1806
+
1807
+ // Copy the selected lines to clipboard
1808
+ copySelectedLines() {
1809
+ const pane = findPaneById(this.paneRoot, this.focusedPaneId);
1810
+ if (!pane) {
1811
+ this.exitCopyMode();
1812
+ return;
1813
+ }
1814
+
1815
+ const lines = this.getOutputLinesForPane(pane);
1816
+ if (lines.length === 0) {
1817
+ this.exitCopyMode();
1818
+ return;
1819
+ }
1820
+
1821
+ // Determine range to copy
1822
+ let startIdx, endIdx;
1823
+ if (this.copyModeAnchor !== null) {
1824
+ startIdx = Math.min(this.copyModeAnchor, this.copyModeCursor);
1825
+ endIdx = Math.max(this.copyModeAnchor, this.copyModeCursor);
1826
+ } else {
1827
+ // No selection - copy just the current line
1828
+ startIdx = this.copyModeCursor;
1829
+ endIdx = this.copyModeCursor;
1830
+ }
1831
+
1832
+ // Clamp to valid range
1833
+ startIdx = Math.max(0, startIdx);
1834
+ endIdx = Math.min(lines.length - 1, endIdx);
1835
+
1836
+ const lineCount = endIdx - startIdx + 1;
1837
+
1838
+ // Build text to copy (strip ANSI codes for clean clipboard text)
1839
+ const textToCopy = lines
1840
+ .slice(startIdx, endIdx + 1)
1841
+ .map(line => stripAnsi(line.text.trim()))
1842
+ .join('\n');
1843
+
1844
+ let success = false;
1845
+ if (textToCopy) {
1846
+ success = copyToClipboard(textToCopy);
1847
+ }
1848
+
1849
+ this.exitCopyMode();
1850
+
1851
+ // Show feedback message
1852
+ this.showCopyFeedback(success ? `Copied ${lineCount} line${lineCount > 1 ? 's' : ''}!` : `Copied ${lineCount} line${lineCount > 1 ? 's' : ''} (clipboard may need OSC 52)`);
1853
+ }
1854
+
1855
+ // Show a temporary feedback message in the footer
1856
+ showCopyFeedback(message) {
1857
+ if (this.copyFeedbackTimer) {
1858
+ clearTimeout(this.copyFeedbackTimer);
1859
+ }
1860
+ this.copyFeedbackMessage = message;
1861
+ this.buildRunningUI();
1862
+
1863
+ this.copyFeedbackTimer = setTimeout(() => {
1864
+ this.copyFeedbackMessage = '';
1865
+ this.copyFeedbackTimer = null;
1866
+ this.buildRunningUI();
1867
+ }, 2000);
1868
+ }
1869
+
1581
1870
  // Check if a process is visible in the focused pane
1582
1871
  isProcessVisibleInPane(scriptName, pane) {
1583
1872
  if (!pane) return true;
@@ -2291,6 +2580,11 @@ class ProcessManager {
2291
2580
  // Settings UI is rebuilt on each input
2292
2581
  // No-op here as buildSettingsUI handles everything
2293
2582
  } else if (this.phase === 'running') {
2583
+ // Skip redundant render if buildRunningUI() was already called this tick
2584
+ if (this.uiJustRebuilt) {
2585
+ this.uiJustRebuilt = false;
2586
+ return;
2587
+ }
2294
2588
  // For running phase, only update output, don't rebuild entire UI
2295
2589
  this.updateRunningUI();
2296
2590
  }
@@ -2644,6 +2938,17 @@ class ProcessManager {
2644
2938
  const renderables = [];
2645
2939
  this.lineRenderables.set(pane.id, renderables);
2646
2940
 
2941
+ // Determine copy mode selection range for this pane
2942
+ const inCopyMode = this.isCopyMode && isFocused;
2943
+ let copySelStart = -1;
2944
+ let copySelEnd = -1;
2945
+ if (inCopyMode) {
2946
+ if (this.copyModeAnchor !== null) {
2947
+ copySelStart = Math.min(this.copyModeAnchor, this.copyModeCursor);
2948
+ copySelEnd = Math.max(this.copyModeAnchor, this.copyModeCursor);
2949
+ }
2950
+ }
2951
+
2647
2952
  // Add lines (oldest first, so newest is at bottom)
2648
2953
  for (let i = 0; i < linesToShow.length; i++) {
2649
2954
  const line = linesToShow[i];
@@ -2653,21 +2958,60 @@ class ProcessManager {
2653
2958
  const lineNumber = this.showLineNumbers ? String(line.lineNumber).padStart(4, ' ') : '';
2654
2959
  const timestamp = this.showTimestamps ? new Date(line.timestamp).toLocaleTimeString('en-US', { hour12: false }) : '';
2655
2960
 
2961
+ // Determine copy mode highlighting for this line
2962
+ const isCursorLine = inCopyMode && i === this.copyModeCursor;
2963
+ const isSelectedLine = inCopyMode && i >= copySelStart && i <= copySelEnd;
2964
+
2656
2965
  let content;
2657
- if (this.showLineNumbers && this.showTimestamps) {
2658
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
2659
- } else if (this.showLineNumbers) {
2660
- content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
2661
- } else if (this.showTimestamps) {
2662
- content = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
2966
+ if (inCopyMode) {
2967
+ // In copy mode: cursor line gets bright marker + white text on blue bg
2968
+ // Selected lines get lighter text on darker blue bg
2969
+ // Unselected lines are dimmed to make selection stand out
2970
+ const marker = isCursorLine ? fg(COLORS.copyCursorText)('\u25b6 ') : ' ';
2971
+
2972
+ let textColor, procColor, dimColor;
2973
+ if (isCursorLine) {
2974
+ textColor = COLORS.copyCursorText;
2975
+ procColor = COLORS.copyCursorText;
2976
+ dimColor = COLORS.copySelectText;
2977
+ } else if (isSelectedLine) {
2978
+ textColor = COLORS.copySelectText;
2979
+ procColor = processColor;
2980
+ dimColor = COLORS.textDim;
2981
+ } else {
2982
+ textColor = COLORS.textDim;
2983
+ procColor = COLORS.textDim;
2984
+ dimColor = COLORS.textDim;
2985
+ }
2986
+
2987
+ if (this.showLineNumbers && this.showTimestamps) {
2988
+ content = t`${marker}${fg(dimColor)(lineNumber)} ${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
2989
+ } else if (this.showLineNumbers) {
2990
+ content = t`${marker}${fg(dimColor)(lineNumber)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
2991
+ } else if (this.showTimestamps) {
2992
+ content = t`${marker}${fg(dimColor)(`[${timestamp}]`)} ${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
2993
+ } else {
2994
+ content = t`${marker}${fg(procColor)(`[${line.process}]`)} ${fg(textColor)(line.text)}`;
2995
+ }
2663
2996
  } else {
2664
- content = t`${fg(processColor)(`[${line.process}]`)} ${line.text}`;
2997
+ if (this.showLineNumbers && this.showTimestamps) {
2998
+ content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
2999
+ } else if (this.showLineNumbers) {
3000
+ content = t`${fg(COLORS.textDim)(lineNumber)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3001
+ } else if (this.showTimestamps) {
3002
+ content = t`${fg(COLORS.textDim)(`[${timestamp}]`)} ${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3003
+ } else {
3004
+ content = t`${fg(processColor)(`[${line.process}]`)} ${line.text}`;
3005
+ }
2665
3006
  }
2666
3007
 
3008
+ // High-contrast background: bright blue for cursor, medium blue for selected, black for rest
3009
+ const bgColor = isCursorLine ? COLORS.copyCursorBg : (isSelectedLine ? COLORS.copySelectBg : '#000000');
3010
+
2667
3011
  const outputLine = new TextRenderable(this.renderer, {
2668
3012
  id: `output-${pane.id}-${line.lineNumber}`,
2669
3013
  content: content,
2670
- bg: '#000000', // Black background for pane content
3014
+ bg: bgColor,
2671
3015
  });
2672
3016
 
2673
3017
  container.add(outputLine);
@@ -2761,6 +3105,12 @@ class ProcessManager {
2761
3105
 
2762
3106
  this.buildPaneOutput(pane, outputBox.content, height);
2763
3107
 
3108
+ // Update paneLineCount so updateRunningUI() won't re-add these lines
3109
+ const paneLines = this.getOutputLinesForPane(pane);
3110
+ if (paneLines.length > 0) {
3111
+ this.paneLineCount.set(pane.id, paneLines[paneLines.length - 1].lineNumber);
3112
+ }
3113
+
2764
3114
  // Restore or set scroll position immediately
2765
3115
  if (outputBox && outputBox.scrollTo) {
2766
3116
  if (this.isPaused && this.paneScrollPositions.has(pane.id)) {
@@ -3181,9 +3531,9 @@ class ProcessManager {
3181
3531
  gap: 2,
3182
3532
  });
3183
3533
 
3184
- // Status (LIVE/PAUSED)
3185
- const statusText = this.isPaused ? 'PAUSED' : 'LIVE';
3186
- const statusColor = this.isPaused ? COLORS.warning : COLORS.success;
3534
+ // Status (LIVE/PAUSED/COPY)
3535
+ const statusText = this.isCopyMode ? 'COPY' : (this.isPaused ? 'PAUSED' : 'LIVE');
3536
+ const statusColor = this.isCopyMode ? COLORS.accent : (this.isPaused ? COLORS.warning : COLORS.success);
3187
3537
  const statusIndicator = new TextRenderable(this.renderer, {
3188
3538
  id: 'status-indicator',
3189
3539
  content: t`${fg(statusColor)(statusText)}`,
@@ -3220,6 +3570,30 @@ class ProcessManager {
3220
3570
  leftSide.add(inputIndicator);
3221
3571
  }
3222
3572
 
3573
+ // Copy mode indicator if active
3574
+ if (this.isCopyMode) {
3575
+ const selCount = this.copyModeAnchor !== null
3576
+ ? Math.abs(this.copyModeCursor - this.copyModeAnchor) + 1
3577
+ : 0;
3578
+ const copyText = selCount > 0
3579
+ ? `COPY [${selCount} line${selCount > 1 ? 's' : ''}] Space:clear y/Enter:copy`
3580
+ : 'COPY Space:start selection y/Enter:copy line Esc:exit';
3581
+ const copyIndicator = new TextRenderable(this.renderer, {
3582
+ id: 'copy-mode-indicator',
3583
+ content: t`${fg(COLORS.accent)(copyText)}`,
3584
+ });
3585
+ leftSide.add(copyIndicator);
3586
+ }
3587
+
3588
+ // Copy feedback message (shown briefly after copying)
3589
+ if (this.copyFeedbackMessage) {
3590
+ const feedbackIndicator = new TextRenderable(this.renderer, {
3591
+ id: 'copy-feedback',
3592
+ content: t`${fg(COLORS.success)(this.copyFeedbackMessage)}`,
3593
+ });
3594
+ leftSide.add(feedbackIndicator);
3595
+ }
3596
+
3223
3597
  // Color filter indicator if active on focused pane
3224
3598
  if (focusedPane?.colorFilter) {
3225
3599
  const colorMap = {
@@ -3264,6 +3638,7 @@ class ProcessManager {
3264
3638
  { key: '1-9', desc: 'toggle', color: COLORS.success },
3265
3639
  { key: 'i', desc: 'input', color: COLORS.success },
3266
3640
  { key: 'n', desc: 'name', color: COLORS.accent },
3641
+ { key: 'y', desc: 'copy', color: COLORS.accent },
3267
3642
  { key: 'p', desc: 'pause', color: COLORS.warning },
3268
3643
  { key: '/', desc: 'filter', color: COLORS.cyan },
3269
3644
  { key: 'c', desc: 'color', color: COLORS.magenta },
@@ -3321,6 +3696,7 @@ class ProcessManager {
3321
3696
 
3322
3697
  this.renderer.root.add(mainContainer);
3323
3698
  this.runningContainer = mainContainer;
3699
+ this.uiJustRebuilt = true; // Prevent redundant render in the same tick
3324
3700
  }
3325
3701
  }
3326
3702
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "startall",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {