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.
- package/dist/components/Session.js +26 -19
- package/dist/services/sessionManager.colorRestore.test.d.ts +1 -0
- package/dist/services/sessionManager.colorRestore.test.js +142 -0
- package/dist/services/sessionManager.d.ts +4 -1
- package/dist/services/sessionManager.integration.test.d.ts +1 -0
- package/dist/services/sessionManager.integration.test.js +178 -0
- package/dist/services/sessionManager.js +58 -105
- package/dist/services/sessionManager.test.js +137 -31
- package/dist/types/index.d.ts +4 -0
- package/dist/utils/terminalSerializer.d.ts +119 -0
- package/dist/utils/terminalSerializer.js +376 -0
- package/dist/utils/terminalSerializer.test.d.ts +1 -0
- package/dist/utils/terminalSerializer.test.js +137 -0
- package/package.json +2 -1
|
@@ -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
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
stdout.write(
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
127
|
+
});
|
|
128
|
+
// Set up interval-based state detection
|
|
129
|
+
session.stateCheckInterval = setInterval(() => {
|
|
181
130
|
const oldState = session.state;
|
|
182
|
-
const newState = this.
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
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
|
}
|