ccmanager 0.0.6 → 0.1.1

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.
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useStdout } from 'ink';
3
3
  import { shortcutManager } from '../services/shortcutManager.js';
4
+ import { TerminalSerializer } from '../utils/terminalSerializer.js';
4
5
  const Session = ({ session, sessionManager, onReturnToMenu, }) => {
5
6
  const { stdout } = useStdout();
6
7
  const [isExiting, setIsExiting] = useState(false);
@@ -12,24 +13,24 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
12
13
  // Handle session restoration
13
14
  const handleSessionRestore = (restoredSession) => {
14
15
  if (restoredSession.id === session.id) {
15
- // Replay all buffered output, but skip the initial clear if present
16
- for (let i = 0; i < restoredSession.outputHistory.length; i++) {
17
- const buffer = restoredSession.outputHistory[i];
18
- if (!buffer)
19
- continue;
20
- const str = buffer.toString('utf8');
21
- // Skip clear screen sequences at the beginning
22
- if (i === 0 && (str.includes('\x1B[2J') || str.includes('\x1B[H'))) {
23
- // Skip this buffer or remove the clear sequence
24
- const cleaned = str
25
- .replace(/\x1B\[2J/g, '')
26
- .replace(/\x1B\[H/g, '');
27
- if (cleaned.length > 0) {
28
- stdout.write(Buffer.from(cleaned, 'utf8'));
29
- }
30
- }
31
- else {
32
- stdout.write(buffer);
16
+ // Instead of replaying all history, use the virtual terminal's current buffer
17
+ // This avoids duplicate content issues
18
+ const terminal = restoredSession.terminal;
19
+ if (terminal) {
20
+ // Use the TerminalSerializer to preserve ANSI escape sequences (colors, styles)
21
+ const serializedOutput = TerminalSerializer.serialize(terminal, {
22
+ trimRight: true,
23
+ includeEmptyLines: true,
24
+ });
25
+ // Write the serialized terminal state with preserved formatting
26
+ if (serializedOutput) {
27
+ stdout.write(serializedOutput);
28
+ // Position cursor at the correct location
29
+ const buffer = terminal.buffer.active;
30
+ const cursorY = buffer.cursorY;
31
+ const cursorX = buffer.cursorX;
32
+ // Move cursor to the saved position
33
+ stdout.write(`\x1B[${cursorY + 1};${cursorX + 1}H`);
33
34
  }
34
35
  }
35
36
  }
@@ -55,7 +56,13 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
55
56
  sessionManager.on('sessionExit', handleSessionExit);
56
57
  // Handle terminal resize
57
58
  const handleResize = () => {
58
- session.process.resize(process.stdout.columns || 80, process.stdout.rows || 24);
59
+ const cols = process.stdout.columns || 80;
60
+ const rows = process.stdout.rows || 24;
61
+ session.process.resize(cols, rows);
62
+ // Also resize the virtual terminal
63
+ if (session.terminal) {
64
+ session.terminal.resize(cols, rows);
65
+ }
59
66
  };
60
67
  stdout.on('resize', handleResize);
61
68
  // Set up raw input handling
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { SessionManager } from './sessionManager.js';
3
+ import { spawn } from 'node-pty';
4
+ // Create mock pty process
5
+ const createMockPtyProcess = () => {
6
+ const handlers = {
7
+ data: [],
8
+ exit: [],
9
+ };
10
+ return {
11
+ write: vi.fn(),
12
+ resize: vi.fn(),
13
+ onData: vi.fn((handler) => {
14
+ handlers.data.push(handler);
15
+ }),
16
+ onExit: vi.fn((handler) => {
17
+ handlers.exit.push(handler);
18
+ }),
19
+ kill: vi.fn(),
20
+ _emit: (event, ...args) => {
21
+ if (event === 'data' && handlers.data.length > 0) {
22
+ handlers.data.forEach(h => h(args[0]));
23
+ }
24
+ else if (event === 'exit' && handlers.exit.length > 0) {
25
+ handlers.exit.forEach(h => h(args[0]));
26
+ }
27
+ },
28
+ };
29
+ };
30
+ // Mock node-pty
31
+ vi.mock('node-pty', () => ({
32
+ spawn: vi.fn(),
33
+ }));
34
+ // Don't mock @xterm/headless - let it use the real implementation
35
+ // since we need actual terminal functionality for color testing
36
+ describe('SessionManager - Color Restoration', () => {
37
+ let sessionManager;
38
+ const mockWorktreePath = '/test/worktree';
39
+ beforeEach(() => {
40
+ sessionManager = new SessionManager();
41
+ vi.clearAllMocks();
42
+ });
43
+ it('should preserve ANSI colors when switching between sessions', async () => {
44
+ // Create a mock PTY process
45
+ const mockProcess = createMockPtyProcess();
46
+ vi.mocked(spawn).mockReturnValue(mockProcess);
47
+ sessionManager.createSession(mockWorktreePath);
48
+ const session = sessionManager.sessions.get(mockWorktreePath);
49
+ expect(session).toBeDefined();
50
+ // Simulate colorful output from Claude Code
51
+ const colorfulData = [
52
+ '\x1b[32m✓\x1b[0m File created successfully\n',
53
+ '\x1b[1;34mRunning tests...\x1b[0m\n',
54
+ '\x1b[38;5;196mError:\x1b[0m Test failed\n',
55
+ '\x1b[38;2;255;165;0mWarning:\x1b[0m Deprecated API\n',
56
+ ];
57
+ // Activate session first
58
+ sessionManager.setSessionActive(mockWorktreePath, true);
59
+ // Send colored data to the terminal
60
+ for (const data of colorfulData) {
61
+ mockProcess._emit('data', data);
62
+ // Wait for terminal to process the data
63
+ await new Promise(resolve => setTimeout(resolve, 10));
64
+ }
65
+ // Deactivate session
66
+ sessionManager.setSessionActive(mockWorktreePath, false);
67
+ // Set up listener to capture restore event
68
+ let restoredContent = null;
69
+ sessionManager.on('sessionRestore', restoredSession => {
70
+ // In real usage, the Session component would use TerminalSerializer here
71
+ // For this test, we'll verify the terminal buffer contains the data
72
+ const terminal = restoredSession.terminal;
73
+ if (terminal) {
74
+ // Access the terminal buffer to verify colors are preserved
75
+ const buffer = terminal.buffer.active;
76
+ restoredContent = '';
77
+ // Simple check: verify buffer has content
78
+ for (let i = 0; i < buffer.length; i++) {
79
+ const line = buffer.getLine(i);
80
+ if (line) {
81
+ // Check if line has colored cells
82
+ for (let x = 0; x < terminal.cols; x++) {
83
+ const cell = line.getCell(x);
84
+ if (cell && cell.getChars()) {
85
+ const fgColorMode = cell.getFgColorMode();
86
+ const bgColorMode = cell.getBgColorMode();
87
+ // If any cell has non-default color, we know colors are preserved
88
+ if (fgColorMode !== 0 || bgColorMode !== 0) {
89
+ restoredContent = 'has-colors';
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ });
98
+ // Reactivate session (simulating switching back)
99
+ sessionManager.setSessionActive(mockWorktreePath, true);
100
+ // Verify that colors were preserved in the terminal buffer
101
+ expect(restoredContent).toBe('has-colors');
102
+ });
103
+ it('should handle complex color sequences during restoration', async () => {
104
+ // Create a mock PTY process
105
+ const mockProcess = createMockPtyProcess();
106
+ vi.mocked(spawn).mockReturnValue(mockProcess);
107
+ sessionManager.createSession(mockWorktreePath);
108
+ const session = sessionManager.sessions.get(mockWorktreePath);
109
+ // Activate session
110
+ sessionManager.setSessionActive(mockWorktreePath, true);
111
+ // Send a complex sequence with cursor movements and color changes
112
+ const complexSequence = [
113
+ 'Line 1: Normal text\n',
114
+ '\x1b[32mLine 2: Green text\x1b[0m\n',
115
+ '\x1b[1A\x1b[K\x1b[31mLine 2: Now red text\x1b[0m\n', // Move up, clear line, write red
116
+ '\x1b[1;33mLine 3: Bold yellow\x1b[0m\n',
117
+ '\x1b[48;5;17m\x1b[38;5;231mWhite on dark blue background\x1b[0m\n',
118
+ ];
119
+ for (const data of complexSequence) {
120
+ mockProcess._emit('data', data);
121
+ await new Promise(resolve => setTimeout(resolve, 10));
122
+ }
123
+ // Check terminal has processed the sequences correctly
124
+ const terminal = session.terminal;
125
+ expect(terminal).toBeDefined();
126
+ // Verify buffer contains content (actual color verification would require
127
+ // checking individual cells, which is done in terminalSerializer.test.ts)
128
+ const buffer = terminal.buffer.active;
129
+ let hasContent = false;
130
+ for (let i = 0; i < buffer.length; i++) {
131
+ const line = buffer.getLine(i);
132
+ if (line) {
133
+ const text = line.translateToString(true);
134
+ if (text.trim()) {
135
+ hasContent = true;
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ expect(hasContent).toBe(true);
141
+ });
142
+ });
@@ -1,11 +1,13 @@
1
1
  import { Session, SessionManager as ISessionManager, SessionState } from '../types/index.js';
2
2
  import { EventEmitter } from 'events';
3
+ import pkg from '@xterm/headless';
4
+ declare const Terminal: typeof pkg.Terminal;
3
5
  export declare class SessionManager extends EventEmitter implements ISessionManager {
4
6
  sessions: Map<string, Session>;
5
7
  private waitingWithBottomBorder;
6
8
  private busyTimers;
7
9
  private stripAnsi;
8
- detectSessionState(cleanData: string, currentState: SessionState, sessionId: string): SessionState;
10
+ detectTerminalState(terminal: InstanceType<typeof Terminal>): SessionState;
9
11
  constructor();
10
12
  createSession(worktreePath: string): Session;
11
13
  private setupBackgroundHandler;
@@ -15,3 +17,4 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
15
17
  getAllSessions(): Session[];
16
18
  destroy(): void;
17
19
  }
20
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { SessionManager } from './sessionManager.js';
3
+ import { spawn } from 'node-pty';
4
+ // Create mock pty process
5
+ const createMockPtyProcess = () => {
6
+ const handlers = {
7
+ data: [],
8
+ exit: [],
9
+ };
10
+ return {
11
+ write: vi.fn(),
12
+ resize: vi.fn(),
13
+ onData: vi.fn((handler) => {
14
+ handlers.data.push(handler);
15
+ }),
16
+ onExit: vi.fn((handler) => {
17
+ handlers.exit.push(handler);
18
+ }),
19
+ kill: vi.fn(),
20
+ _emit: (event, ...args) => {
21
+ if (event === 'data' && handlers.data.length > 0) {
22
+ handlers.data.forEach(h => h(args[0]));
23
+ }
24
+ else if (event === 'exit' && handlers.exit.length > 0) {
25
+ handlers.exit.forEach(h => h(args[0]));
26
+ }
27
+ },
28
+ };
29
+ };
30
+ // Mock node-pty
31
+ vi.mock('node-pty', () => ({
32
+ spawn: vi.fn(),
33
+ }));
34
+ // Mock @xterm/headless
35
+ vi.mock('@xterm/headless', () => ({
36
+ default: {
37
+ Terminal: vi.fn().mockImplementation(() => ({
38
+ buffer: {
39
+ active: {
40
+ length: 10,
41
+ cursorY: 0,
42
+ cursorX: 0,
43
+ getLine: vi.fn(),
44
+ },
45
+ },
46
+ write: vi.fn(),
47
+ resize: vi.fn(),
48
+ clear: vi.fn(),
49
+ onData: vi.fn(),
50
+ })),
51
+ },
52
+ }));
53
+ describe('SessionManager - Partial TUI Update Integration', () => {
54
+ let sessionManager;
55
+ const mockWorktreePath = '/test/worktree';
56
+ beforeEach(() => {
57
+ sessionManager = new SessionManager();
58
+ vi.clearAllMocks();
59
+ });
60
+ it('should not accumulate duplicate content in output history', () => {
61
+ // Create a mock PTY process
62
+ const mockProcess = createMockPtyProcess();
63
+ vi.mocked(spawn).mockReturnValue(mockProcess);
64
+ // Create a session
65
+ sessionManager.createSession(mockWorktreePath);
66
+ const session = sessionManager.sessions.get(mockWorktreePath);
67
+ expect(session).toBeDefined();
68
+ // Simulate multiple partial updates from Claude Code
69
+ const updates = [
70
+ '+ Exploring... (10s ・ 53 tokens ・ esc to interrupt)\r',
71
+ '\x1B[1A\x1B[K+ Exploring... (10s ・ 57 tokens ・ esc to interrupt)\r',
72
+ '\x1B[1A\x1B[K+ Exploring... (10s ・ 76 tokens ・ esc to interrupt)\r',
73
+ '\x1B[1A\x1B[K+ Exploring... (10s ・ 89 tokens ・ esc to interrupt)\r',
74
+ '\x1B[1A\x1B[K+ Exploring... (10s ・ 102 tokens ・ esc to interrupt)\r',
75
+ ];
76
+ // Process each update
77
+ updates.forEach(update => {
78
+ // Simulate PTY data event
79
+ mockProcess._emit('data', update);
80
+ });
81
+ // Check that the virtual terminal received all updates
82
+ expect(session.terminal.write).toHaveBeenCalledTimes(5);
83
+ // The outputHistory should be empty since we removed that functionality
84
+ expect(session.outputHistory).toEqual([]);
85
+ });
86
+ it('should use virtual terminal buffer for session restoration', () => {
87
+ // Create a mock PTY process
88
+ const mockProcess = createMockPtyProcess();
89
+ vi.mocked(spawn).mockReturnValue(mockProcess);
90
+ sessionManager.createSession(mockWorktreePath);
91
+ const session = sessionManager.sessions.get(mockWorktreePath);
92
+ // Mock the terminal buffer to contain the final state
93
+ // Type cast is acceptable for test mocks
94
+ const mockTerminal = session.terminal;
95
+ mockTerminal.buffer.active.getLine = vi.fn((index) => {
96
+ const lines = [
97
+ 'Welcome to Claude Code',
98
+ '+ Exploring... (10s ・ 218 tokens ・ esc to interrupt)',
99
+ '',
100
+ 'Task completed successfully',
101
+ '> ',
102
+ ];
103
+ if (index < lines.length) {
104
+ return {
105
+ translateToString: () => lines[index],
106
+ };
107
+ }
108
+ return null;
109
+ });
110
+ mockTerminal.buffer.active.length = 5;
111
+ mockTerminal.buffer.active.cursorY = 4;
112
+ mockTerminal.buffer.active.cursorX = 2;
113
+ // Emit restore event
114
+ sessionManager.emit('sessionRestore', session);
115
+ // The terminal buffer should be used for restoration, not output history
116
+ // This prevents duplicate content issues
117
+ expect(session.outputHistory).toEqual([]);
118
+ });
119
+ it('should handle ANSI escape sequences correctly in virtual terminal', () => {
120
+ // Create a mock PTY process
121
+ const mockProcess = createMockPtyProcess();
122
+ vi.mocked(spawn).mockReturnValue(mockProcess);
123
+ sessionManager.createSession(mockWorktreePath);
124
+ const session = sessionManager.sessions.get(mockWorktreePath);
125
+ // Simulate data with ANSI escape sequences
126
+ const dataWithEscapes = [
127
+ 'Line 1\n',
128
+ 'Line 2\n',
129
+ '\x1B[1A\x1B[KReplaced Line 2\n', // Move up one line, clear line, write new text
130
+ '\x1B[2J\x1B[H', // Clear screen and move to home
131
+ 'Fresh start\n',
132
+ ];
133
+ dataWithEscapes.forEach(data => {
134
+ mockProcess._emit('data', data);
135
+ });
136
+ // Virtual terminal should handle all the escape sequences
137
+ expect(session.terminal.write).toHaveBeenCalledTimes(5);
138
+ // No raw output should be stored
139
+ expect(session.outputHistory).toEqual([]);
140
+ });
141
+ it('should emit sessionData events for active sessions only', () => {
142
+ // Create a mock PTY process
143
+ const mockProcess = createMockPtyProcess();
144
+ vi.mocked(spawn).mockReturnValue(mockProcess);
145
+ const dataHandler = vi.fn();
146
+ sessionManager.on('sessionData', dataHandler);
147
+ sessionManager.createSession(mockWorktreePath);
148
+ const session = sessionManager.sessions.get(mockWorktreePath);
149
+ // Session is not active by default
150
+ mockProcess._emit('data', 'Test data 1');
151
+ // Should not emit data when inactive
152
+ expect(dataHandler).not.toHaveBeenCalled();
153
+ // Activate session
154
+ sessionManager.setSessionActive(mockWorktreePath, true);
155
+ // Now data should be emitted
156
+ mockProcess._emit('data', 'Test data 2');
157
+ expect(dataHandler).toHaveBeenCalledWith(session, 'Test data 2');
158
+ });
159
+ it('should restore session without replaying output history', () => {
160
+ // Create a mock PTY process
161
+ const mockProcess = createMockPtyProcess();
162
+ vi.mocked(spawn).mockReturnValue(mockProcess);
163
+ const restoreHandler = vi.fn();
164
+ sessionManager.on('sessionRestore', restoreHandler);
165
+ sessionManager.createSession(mockWorktreePath);
166
+ const session = sessionManager.sessions.get(mockWorktreePath);
167
+ // Add some data to the session
168
+ mockProcess._emit('data', 'Old output that should not be replayed\n');
169
+ mockProcess._emit('data', 'More old output\n');
170
+ // Deactivate then reactivate session
171
+ sessionManager.setSessionActive(mockWorktreePath, false);
172
+ sessionManager.setSessionActive(mockWorktreePath, true);
173
+ // Should emit restore event
174
+ expect(restoreHandler).toHaveBeenCalledWith(session);
175
+ // But should not have any output history to replay
176
+ expect(session.outputHistory).toEqual([]);
177
+ });
178
+ });
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node-pty';
2
2
  import { EventEmitter } from 'events';
3
- import { includesPromptBoxBottomBorder } from '../utils/promptDetector.js';
3
+ import pkg from '@xterm/headless';
4
+ const { Terminal } = pkg;
4
5
  export class SessionManager extends EventEmitter {
5
6
  stripAnsi(str) {
6
7
  // Remove all ANSI escape sequences including cursor movement, color codes, etc.
@@ -16,76 +17,35 @@ export class SessionManager extends EventEmitter {
16
17
  .replace(/^[0-9;]+m/gm, '') // Orphaned color codes at line start
17
18
  .replace(/[0-9]+;[0-9]+;[0-9;]+m/g, ''); // Orphaned 24-bit color codes
18
19
  }
19
- detectSessionState(cleanData, currentState, sessionId) {
20
- const hasBottomBorder = includesPromptBoxBottomBorder(cleanData);
21
- const hasWaitingPrompt = cleanData.includes('│ Do you want') ||
22
- cleanData.includes('│ Would you like');
23
- const wasWaitingWithBottomBorder = this.waitingWithBottomBorder.get(sessionId) || false;
24
- const hasEscToInterrupt = cleanData
25
- .toLowerCase()
26
- .includes('esc to interrupt');
27
- let newState = currentState;
28
- // Check if current state is waiting and this is just a prompt box bottom border
29
- if (hasWaitingPrompt) {
30
- newState = 'waiting_input';
31
- // Check if this same data also contains the bottom border
32
- if (hasBottomBorder) {
33
- this.waitingWithBottomBorder.set(sessionId, true);
34
- }
35
- else {
36
- this.waitingWithBottomBorder.set(sessionId, false);
37
- }
38
- // Clear any pending busy timer
39
- const existingTimer = this.busyTimers.get(sessionId);
40
- if (existingTimer) {
41
- clearTimeout(existingTimer);
42
- this.busyTimers.delete(sessionId);
43
- }
44
- }
45
- else if (currentState === 'waiting_input' &&
46
- hasBottomBorder &&
47
- !hasWaitingPrompt &&
48
- !wasWaitingWithBottomBorder) {
49
- // Keep the waiting state and mark that we've seen the bottom border
50
- newState = 'waiting_input';
51
- this.waitingWithBottomBorder.set(sessionId, true);
52
- // Clear any pending busy timer
53
- const existingTimer = this.busyTimers.get(sessionId);
54
- if (existingTimer) {
55
- clearTimeout(existingTimer);
56
- this.busyTimers.delete(sessionId);
20
+ detectTerminalState(terminal) {
21
+ // Get the last 30 lines from the terminal buffer
22
+ const buffer = terminal.buffer.active;
23
+ const lines = [];
24
+ // Start from the bottom and work our way up
25
+ for (let i = buffer.length - 1; i >= 0 && lines.length < 30; i--) {
26
+ const line = buffer.getLine(i);
27
+ if (line) {
28
+ const text = line.translateToString(true);
29
+ // Skip empty lines at the bottom
30
+ if (lines.length > 0 || text.trim() !== '') {
31
+ lines.unshift(text);
32
+ }
57
33
  }
58
34
  }
59
- else if (hasEscToInterrupt) {
60
- // If "esc to interrupt" is present, set state to busy
61
- newState = 'busy';
62
- this.waitingWithBottomBorder.set(sessionId, false);
63
- // Clear any pending timer since we're confirming busy state
64
- const existingTimer = this.busyTimers.get(sessionId);
65
- if (existingTimer) {
66
- clearTimeout(existingTimer);
67
- this.busyTimers.delete(sessionId);
68
- }
35
+ // Join lines and check for patterns
36
+ const content = lines.join('\n');
37
+ const lowerContent = content.toLowerCase();
38
+ // Check for waiting prompts with box character
39
+ if (content.includes('│ Do you want') ||
40
+ content.includes('│ Would you like')) {
41
+ return 'waiting_input';
69
42
  }
70
- else if (currentState === 'busy' && !hasEscToInterrupt) {
71
- // If we were busy but no "esc to interrupt" in current data,
72
- // start a timer to switch to idle after 500ms
73
- if (!this.busyTimers.has(sessionId)) {
74
- const timer = setTimeout(() => {
75
- // sessionId is actually the worktreePath
76
- const session = this.sessions.get(sessionId);
77
- if (session && session.state === 'busy') {
78
- session.state = 'idle';
79
- this.emit('sessionStateChanged', session);
80
- }
81
- this.busyTimers.delete(sessionId);
82
- }, 500);
83
- this.busyTimers.set(sessionId, timer);
84
- }
85
- // Keep current busy state for now
86
- newState = 'busy';
43
+ // Check for busy state
44
+ if (lowerContent.includes('esc to interrupt')) {
45
+ return 'busy';
87
46
  }
88
- return newState;
47
+ // Otherwise idle
48
+ return 'idle';
89
49
  }
90
50
  constructor() {
91
51
  super();
@@ -129,15 +89,22 @@ export class SessionManager extends EventEmitter {
129
89
  cwd: worktreePath,
130
90
  env: process.env,
131
91
  });
92
+ // Create virtual terminal for state detection
93
+ const terminal = new Terminal({
94
+ cols: process.stdout.columns || 80,
95
+ rows: process.stdout.rows || 24,
96
+ allowProposedApi: true,
97
+ });
132
98
  const session = {
133
99
  id,
134
100
  worktreePath,
135
101
  process: ptyProcess,
136
102
  state: 'busy', // Session starts as busy when created
137
103
  output: [],
138
- outputHistory: [],
104
+ outputHistory: [], // Kept for backward compatibility but no longer used
139
105
  lastActivity: new Date(),
140
106
  isActive: false,
107
+ terminal,
141
108
  };
142
109
  // Set up persistent background data handler for state detection
143
110
  this.setupBackgroundHandler(session);
@@ -148,49 +115,30 @@ export class SessionManager extends EventEmitter {
148
115
  setupBackgroundHandler(session) {
149
116
  // This handler always runs for all data
150
117
  session.process.onData((data) => {
151
- // Store in output history as Buffer
152
- const buffer = Buffer.from(data, 'utf8');
153
- session.outputHistory.push(buffer);
154
- // Limit memory usage - keep max 10MB of output history
155
- const MAX_HISTORY_SIZE = 10 * 1024 * 1024; // 10MB
156
- let totalSize = session.outputHistory.reduce((sum, buf) => sum + buf.length, 0);
157
- while (totalSize > MAX_HISTORY_SIZE && session.outputHistory.length > 0) {
158
- const removed = session.outputHistory.shift();
159
- if (removed) {
160
- totalSize -= removed.length;
161
- }
162
- }
163
- // Also store for state detection
164
- session.output.push(data);
165
- // Keep only last 100 chunks for state detection
166
- if (session.output.length > 100) {
167
- session.output.shift();
168
- }
118
+ // Write data to virtual terminal - this maintains the proper rendered state
119
+ session.terminal.write(data);
120
+ // We no longer need to maintain outputHistory since we use the virtual terminal buffer
121
+ // This prevents duplicate content issues and reduces memory usage
169
122
  session.lastActivity = new Date();
170
- // Strip ANSI codes for pattern matching
171
- const cleanData = this.stripAnsi(data);
172
- // Skip state monitoring if cleanData is empty
173
- if (!cleanData.trim()) {
174
- // Only emit data events when session is active
175
- if (session.isActive) {
176
- this.emit('sessionData', session, data);
177
- }
178
- return;
123
+ // Only emit data events when session is active
124
+ if (session.isActive) {
125
+ this.emit('sessionData', session, data);
179
126
  }
180
- // Detect state based on the new data
127
+ });
128
+ // Set up interval-based state detection
129
+ session.stateCheckInterval = setInterval(() => {
181
130
  const oldState = session.state;
182
- const newState = this.detectSessionState(cleanData, oldState, session.worktreePath);
183
- // Update state if changed
131
+ const newState = this.detectTerminalState(session.terminal);
184
132
  if (newState !== oldState) {
185
133
  session.state = newState;
186
134
  this.emit('sessionStateChanged', session);
187
135
  }
188
- // Only emit data events when session is active
189
- if (session.isActive) {
190
- this.emit('sessionData', session, data);
191
- }
192
- });
136
+ }, 100); // Check every 100ms
193
137
  session.process.onExit(() => {
138
+ // Clear the state check interval
139
+ if (session.stateCheckInterval) {
140
+ clearInterval(session.stateCheckInterval);
141
+ }
194
142
  // Update state to idle before destroying
195
143
  session.state = 'idle';
196
144
  this.emit('sessionStateChanged', session);
@@ -205,8 +153,9 @@ export class SessionManager extends EventEmitter {
205
153
  const session = this.sessions.get(worktreePath);
206
154
  if (session) {
207
155
  session.isActive = active;
208
- // If becoming active, emit a restore event with the output history
209
- if (active && session.outputHistory.length > 0) {
156
+ // If becoming active, emit a restore event
157
+ // The Session component will use the virtual terminal buffer instead of outputHistory
158
+ if (active) {
210
159
  this.emit('sessionRestore', session);
211
160
  }
212
161
  }
@@ -214,6 +163,10 @@ export class SessionManager extends EventEmitter {
214
163
  destroySession(worktreePath) {
215
164
  const session = this.sessions.get(worktreePath);
216
165
  if (session) {
166
+ // Clear the state check interval
167
+ if (session.stateCheckInterval) {
168
+ clearInterval(session.stateCheckInterval);
169
+ }
217
170
  try {
218
171
  session.process.kill();
219
172
  }