ccmanager 0.1.0 → 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 +19 -18
- package/dist/services/sessionManager.colorRestore.test.d.ts +1 -0
- package/dist/services/sessionManager.colorRestore.test.js +142 -0
- package/dist/services/sessionManager.integration.test.d.ts +1 -0
- package/dist/services/sessionManager.integration.test.js +178 -0
- package/dist/services/sessionManager.js +7 -16
- package/dist/types/index.d.ts +3 -1
- 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 +1 -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
|
}
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -101,7 +101,7 @@ export class SessionManager extends EventEmitter {
|
|
|
101
101
|
process: ptyProcess,
|
|
102
102
|
state: 'busy', // Session starts as busy when created
|
|
103
103
|
output: [],
|
|
104
|
-
outputHistory: [],
|
|
104
|
+
outputHistory: [], // Kept for backward compatibility but no longer used
|
|
105
105
|
lastActivity: new Date(),
|
|
106
106
|
isActive: false,
|
|
107
107
|
terminal,
|
|
@@ -115,20 +115,10 @@ export class SessionManager extends EventEmitter {
|
|
|
115
115
|
setupBackgroundHandler(session) {
|
|
116
116
|
// This handler always runs for all data
|
|
117
117
|
session.process.onData((data) => {
|
|
118
|
-
// Write data to virtual terminal
|
|
118
|
+
// Write data to virtual terminal - this maintains the proper rendered state
|
|
119
119
|
session.terminal.write(data);
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
session.outputHistory.push(buffer);
|
|
123
|
-
// Limit memory usage - keep max 10MB of output history
|
|
124
|
-
const MAX_HISTORY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
125
|
-
let totalSize = session.outputHistory.reduce((sum, buf) => sum + buf.length, 0);
|
|
126
|
-
while (totalSize > MAX_HISTORY_SIZE && session.outputHistory.length > 0) {
|
|
127
|
-
const removed = session.outputHistory.shift();
|
|
128
|
-
if (removed) {
|
|
129
|
-
totalSize -= removed.length;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
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
|
|
132
122
|
session.lastActivity = new Date();
|
|
133
123
|
// Only emit data events when session is active
|
|
134
124
|
if (session.isActive) {
|
|
@@ -163,8 +153,9 @@ export class SessionManager extends EventEmitter {
|
|
|
163
153
|
const session = this.sessions.get(worktreePath);
|
|
164
154
|
if (session) {
|
|
165
155
|
session.isActive = active;
|
|
166
|
-
// If becoming active, emit a restore event
|
|
167
|
-
|
|
156
|
+
// If becoming active, emit a restore event
|
|
157
|
+
// The Session component will use the virtual terminal buffer instead of outputHistory
|
|
158
|
+
if (active) {
|
|
168
159
|
this.emit('sessionRestore', session);
|
|
169
160
|
}
|
|
170
161
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { IPty } from 'node-pty';
|
|
2
|
+
import type pkg from '@xterm/headless';
|
|
3
|
+
export type Terminal = InstanceType<typeof pkg.Terminal>;
|
|
2
4
|
export type SessionState = 'idle' | 'busy' | 'waiting_input';
|
|
3
5
|
export interface Worktree {
|
|
4
6
|
path: string;
|
|
@@ -15,7 +17,7 @@ export interface Session {
|
|
|
15
17
|
outputHistory: Buffer[];
|
|
16
18
|
lastActivity: Date;
|
|
17
19
|
isActive: boolean;
|
|
18
|
-
terminal:
|
|
20
|
+
terminal: Terminal;
|
|
19
21
|
stateCheckInterval?: NodeJS.Timeout;
|
|
20
22
|
}
|
|
21
23
|
export interface SessionManager {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Terminal } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* TerminalSerializer: Converts terminal screen content to text while preserving colors and formatting.
|
|
4
|
+
*
|
|
5
|
+
* Imagine taking a screenshot of your terminal, but instead of an image, you get text that
|
|
6
|
+
* can recreate the exact same appearance with all colors and styles when displayed again.
|
|
7
|
+
*/
|
|
8
|
+
export declare class TerminalSerializer {
|
|
9
|
+
/**
|
|
10
|
+
* ESC: The "magic" prefix that tells the terminal "the next characters are instructions, not text"
|
|
11
|
+
* '\x1b[' is like saying "Hey terminal, listen up for special commands!"
|
|
12
|
+
*/
|
|
13
|
+
private static readonly ESC;
|
|
14
|
+
/**
|
|
15
|
+
* RESET: The command that tells terminal "go back to normal text" (no colors, no bold, etc.)
|
|
16
|
+
* Like clicking "clear formatting" in a word processor
|
|
17
|
+
*/
|
|
18
|
+
private static readonly RESET;
|
|
19
|
+
/**
|
|
20
|
+
* Determines which color system is being used for a given color value.
|
|
21
|
+
*
|
|
22
|
+
* Terminal supports different color systems:
|
|
23
|
+
* - Mode 0 (DEFAULT): Basic terminal colors (like white text on black background)
|
|
24
|
+
* - Mode 1 (P16): 16 basic colors (8 normal + 8 bright versions)
|
|
25
|
+
* - Mode 2 (P256): 256 colors (used for more variety)
|
|
26
|
+
* - Mode 3 (RGB): True color with Red, Green, Blue values (16.7 million colors)
|
|
27
|
+
*
|
|
28
|
+
* @param colorValue - The packed color information from the terminal
|
|
29
|
+
* @returns 0, 1, 2, or 3 indicating which color system to use
|
|
30
|
+
*/
|
|
31
|
+
private static getColorMode;
|
|
32
|
+
/**
|
|
33
|
+
* Extracts the actual color value from the packed color information.
|
|
34
|
+
*
|
|
35
|
+
* The extraction method depends on the color mode:
|
|
36
|
+
* - For RGB mode: Extracts all three color components (R, G, B)
|
|
37
|
+
* - For palette modes: Extracts just the color index number
|
|
38
|
+
*
|
|
39
|
+
* Think of it like unpacking a suitcase - different items are packed differently
|
|
40
|
+
*
|
|
41
|
+
* @param colorValue - The packed color information from the terminal
|
|
42
|
+
* @returns The actual color value (either RGB values or palette index)
|
|
43
|
+
*/
|
|
44
|
+
private static extractColor;
|
|
45
|
+
/**
|
|
46
|
+
* Converts a color value into the special text codes that terminals understand.
|
|
47
|
+
*
|
|
48
|
+
* Terminals use "ANSI escape sequences" - special character combinations that control
|
|
49
|
+
* how text appears. It's like HTML tags, but for terminals.
|
|
50
|
+
*
|
|
51
|
+
* Examples of what this function produces:
|
|
52
|
+
* - Red text: "\x1b[31m" (tells terminal "make the following text red")
|
|
53
|
+
* - Blue background: "\x1b[44m" (tells terminal "make the background blue")
|
|
54
|
+
* - RGB color: "\x1b[38;2;255;128;0m" (orange text using RGB values)
|
|
55
|
+
*
|
|
56
|
+
* @param colorValue - The color to convert (packed number with mode and color data)
|
|
57
|
+
* @param isBackground - true for background color, false for text color
|
|
58
|
+
* @returns ANSI escape sequence string that terminals can interpret
|
|
59
|
+
*/
|
|
60
|
+
private static colorToAnsi;
|
|
61
|
+
/**
|
|
62
|
+
* Converts a single line of terminal content into text with color/style codes.
|
|
63
|
+
*
|
|
64
|
+
* This function processes each character in a line and:
|
|
65
|
+
* 1. Extracts the character itself
|
|
66
|
+
* 2. Checks its color (text and background)
|
|
67
|
+
* 3. Checks its style (bold, italic, underline, etc.)
|
|
68
|
+
* 4. Adds the necessary codes to recreate that appearance
|
|
69
|
+
*
|
|
70
|
+
* It's like going through a line character by character and noting:
|
|
71
|
+
* "This letter is red, this one is bold, this one has blue background..."
|
|
72
|
+
*
|
|
73
|
+
* @param line - One line from the terminal screen
|
|
74
|
+
* @param cols - Number of columns (width) of the terminal
|
|
75
|
+
* @param trimRight - Whether to remove trailing spaces (like right-trim in text editors)
|
|
76
|
+
* @returns The line as text with embedded color/style codes
|
|
77
|
+
*/
|
|
78
|
+
private static serializeLine;
|
|
79
|
+
/**
|
|
80
|
+
* Converts the entire terminal screen (or part of it) into text with colors preserved.
|
|
81
|
+
*
|
|
82
|
+
* This is the main function that processes multiple lines of terminal content.
|
|
83
|
+
* It's like taking a "text screenshot" of your terminal that can be replayed later
|
|
84
|
+
* with all the colors and formatting intact.
|
|
85
|
+
*
|
|
86
|
+
* @param terminal - The terminal object containing the screen buffer
|
|
87
|
+
* @param options - Configuration options:
|
|
88
|
+
* - startLine: First line to include (default: 0, the top)
|
|
89
|
+
* - endLine: Last line to include (default: bottom of screen)
|
|
90
|
+
* - trimRight: Remove trailing spaces from each line (default: true)
|
|
91
|
+
* - includeEmptyLines: Keep blank lines in output (default: true)
|
|
92
|
+
* @returns Multi-line string with embedded ANSI codes for colors/styles
|
|
93
|
+
*/
|
|
94
|
+
static serialize(terminal: Terminal, options?: {
|
|
95
|
+
startLine?: number;
|
|
96
|
+
endLine?: number;
|
|
97
|
+
trimRight?: boolean;
|
|
98
|
+
includeEmptyLines?: boolean;
|
|
99
|
+
}): string;
|
|
100
|
+
/**
|
|
101
|
+
* Convenience function to get just the last few lines from the terminal.
|
|
102
|
+
*
|
|
103
|
+
* Useful when you only need recent output, like:
|
|
104
|
+
* - Getting the last error message
|
|
105
|
+
* - Showing recent command output
|
|
106
|
+
* - Displaying the current prompt
|
|
107
|
+
*
|
|
108
|
+
* Example: getLastLines(terminal, 10) gets the last 10 lines
|
|
109
|
+
*
|
|
110
|
+
* @param terminal - The terminal object containing the screen buffer
|
|
111
|
+
* @param lineCount - How many lines from the bottom to include
|
|
112
|
+
* @param options - Same options as serialize() for controlling output format
|
|
113
|
+
* @returns The requested lines as text with color/style codes
|
|
114
|
+
*/
|
|
115
|
+
static getLastLines(terminal: Terminal, lineCount: number, options?: {
|
|
116
|
+
trimRight?: boolean;
|
|
117
|
+
includeEmptyLines?: boolean;
|
|
118
|
+
}): string;
|
|
119
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants for decoding color information stored in terminal cells.
|
|
3
|
+
* Terminal colors are packed into a single number where different bits represent different information.
|
|
4
|
+
* Think of it like a compressed file - multiple pieces of data stored in one number.
|
|
5
|
+
*
|
|
6
|
+
* These constants are based on xterm.js internal implementation:
|
|
7
|
+
* Source: https://github.com/xtermjs/xterm.js/blob/master/src/common/buffer/Constants.ts
|
|
8
|
+
*
|
|
9
|
+
* The color storage format uses bit packing where:
|
|
10
|
+
* - Bits 1-8: Blue component (also used for palette color indices)
|
|
11
|
+
* - Bits 9-16: Green component (RGB mode only)
|
|
12
|
+
* - Bits 17-24: Red component (RGB mode only)
|
|
13
|
+
* - Bits 25-26: Color mode indicator
|
|
14
|
+
*
|
|
15
|
+
* Reference implementation:
|
|
16
|
+
* https://github.com/xtermjs/xterm.js/blob/master/src/common/buffer/AttributeData.ts
|
|
17
|
+
*/
|
|
18
|
+
const Attributes = {
|
|
19
|
+
/**
|
|
20
|
+
* BLUE_MASK: Used to extract blue color value (bits 1-8)
|
|
21
|
+
* In 256-color and 16-color modes, the entire color number is stored here
|
|
22
|
+
* Example: 0x0000FF masks out everything except the last 8 bits
|
|
23
|
+
*/
|
|
24
|
+
BLUE_MASK: 0xff,
|
|
25
|
+
BLUE_SHIFT: 0,
|
|
26
|
+
/**
|
|
27
|
+
* GREEN_MASK: Used to extract green color value (bits 9-16)
|
|
28
|
+
* Only used in RGB (true color) mode
|
|
29
|
+
* Example: 0x00FF00 masks out everything except bits 9-16
|
|
30
|
+
*/
|
|
31
|
+
GREEN_MASK: 0xff00,
|
|
32
|
+
GREEN_SHIFT: 8,
|
|
33
|
+
/**
|
|
34
|
+
* RED_MASK: Used to extract red color value (bits 17-24)
|
|
35
|
+
* Only used in RGB (true color) mode
|
|
36
|
+
* Example: 0xFF0000 masks out everything except bits 17-24
|
|
37
|
+
*/
|
|
38
|
+
RED_MASK: 0xff0000,
|
|
39
|
+
RED_SHIFT: 16,
|
|
40
|
+
/**
|
|
41
|
+
* CM_MASK: Used to determine which color system is being used (bits 25-26)
|
|
42
|
+
* Like a label that says "this is 16-color" or "this is RGB"
|
|
43
|
+
*
|
|
44
|
+
* Color modes from xterm.js:
|
|
45
|
+
* https://github.com/xtermjs/xterm.js/blob/master/src/common/Types.d.ts#L33
|
|
46
|
+
*/
|
|
47
|
+
CM_MASK: 0x3000000,
|
|
48
|
+
CM_DEFAULT: 0, // Default terminal colors (usually white text on black background)
|
|
49
|
+
CM_P16: 0x1000000, // 16-color palette (basic colors like red, blue, green)
|
|
50
|
+
CM_P256: 0x2000000, // 256-color palette (more shades and colors)
|
|
51
|
+
CM_RGB: 0x3000000, // RGB/true color (millions of colors, like in photos)
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* TerminalSerializer: Converts terminal screen content to text while preserving colors and formatting.
|
|
55
|
+
*
|
|
56
|
+
* Imagine taking a screenshot of your terminal, but instead of an image, you get text that
|
|
57
|
+
* can recreate the exact same appearance with all colors and styles when displayed again.
|
|
58
|
+
*/
|
|
59
|
+
export class TerminalSerializer {
|
|
60
|
+
/**
|
|
61
|
+
* Determines which color system is being used for a given color value.
|
|
62
|
+
*
|
|
63
|
+
* Terminal supports different color systems:
|
|
64
|
+
* - Mode 0 (DEFAULT): Basic terminal colors (like white text on black background)
|
|
65
|
+
* - Mode 1 (P16): 16 basic colors (8 normal + 8 bright versions)
|
|
66
|
+
* - Mode 2 (P256): 256 colors (used for more variety)
|
|
67
|
+
* - Mode 3 (RGB): True color with Red, Green, Blue values (16.7 million colors)
|
|
68
|
+
*
|
|
69
|
+
* @param colorValue - The packed color information from the terminal
|
|
70
|
+
* @returns 0, 1, 2, or 3 indicating which color system to use
|
|
71
|
+
*/
|
|
72
|
+
static getColorMode(colorValue) {
|
|
73
|
+
const mode = colorValue & Attributes.CM_MASK;
|
|
74
|
+
if (mode === Attributes.CM_P16)
|
|
75
|
+
return 1; // P16
|
|
76
|
+
if (mode === Attributes.CM_P256)
|
|
77
|
+
return 2; // P256
|
|
78
|
+
if (mode === Attributes.CM_RGB)
|
|
79
|
+
return 3; // RGB
|
|
80
|
+
return 0; // DEFAULT
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Extracts the actual color value from the packed color information.
|
|
84
|
+
*
|
|
85
|
+
* The extraction method depends on the color mode:
|
|
86
|
+
* - For RGB mode: Extracts all three color components (R, G, B)
|
|
87
|
+
* - For palette modes: Extracts just the color index number
|
|
88
|
+
*
|
|
89
|
+
* Think of it like unpacking a suitcase - different items are packed differently
|
|
90
|
+
*
|
|
91
|
+
* @param colorValue - The packed color information from the terminal
|
|
92
|
+
* @returns The actual color value (either RGB values or palette index)
|
|
93
|
+
*/
|
|
94
|
+
static extractColor(colorValue) {
|
|
95
|
+
const mode = colorValue & Attributes.CM_MASK;
|
|
96
|
+
if (mode === Attributes.CM_RGB) {
|
|
97
|
+
// For RGB, extract R, G, B components (all 24 bits of color data)
|
|
98
|
+
return colorValue & 0xffffff;
|
|
99
|
+
}
|
|
100
|
+
// For palette colors, it's stored in the blue channel (just 8 bits)
|
|
101
|
+
return colorValue & Attributes.BLUE_MASK;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Converts a color value into the special text codes that terminals understand.
|
|
105
|
+
*
|
|
106
|
+
* Terminals use "ANSI escape sequences" - special character combinations that control
|
|
107
|
+
* how text appears. It's like HTML tags, but for terminals.
|
|
108
|
+
*
|
|
109
|
+
* Examples of what this function produces:
|
|
110
|
+
* - Red text: "\x1b[31m" (tells terminal "make the following text red")
|
|
111
|
+
* - Blue background: "\x1b[44m" (tells terminal "make the background blue")
|
|
112
|
+
* - RGB color: "\x1b[38;2;255;128;0m" (orange text using RGB values)
|
|
113
|
+
*
|
|
114
|
+
* @param colorValue - The color to convert (packed number with mode and color data)
|
|
115
|
+
* @param isBackground - true for background color, false for text color
|
|
116
|
+
* @returns ANSI escape sequence string that terminals can interpret
|
|
117
|
+
*/
|
|
118
|
+
static colorToAnsi(colorValue, isBackground) {
|
|
119
|
+
// -1 is a special value meaning "use the terminal's default color"
|
|
120
|
+
if (colorValue === -1) {
|
|
121
|
+
return isBackground ? `${this.ESC}49m` : `${this.ESC}39m`;
|
|
122
|
+
}
|
|
123
|
+
// Different prefixes for text color (38) vs background color (48)
|
|
124
|
+
const prefix = isBackground ? '48' : '38';
|
|
125
|
+
const mode = this.getColorMode(colorValue);
|
|
126
|
+
const color = this.extractColor(colorValue);
|
|
127
|
+
switch (mode) {
|
|
128
|
+
case 0: // DEFAULT
|
|
129
|
+
// Reset to terminal's default colors
|
|
130
|
+
return isBackground ? `${this.ESC}49m` : `${this.ESC}39m`;
|
|
131
|
+
case 1: // P16 - Basic 16 colors
|
|
132
|
+
// Colors 0-7 are normal intensity (black, red, green, yellow, blue, magenta, cyan, white)
|
|
133
|
+
// Colors 8-15 are bright/bold versions of the same colors
|
|
134
|
+
if (color < 8) {
|
|
135
|
+
// Standard colors: 30-37 for text, 40-47 for background
|
|
136
|
+
return `${this.ESC}${(isBackground ? 40 : 30) + color}m`;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// Bright colors: 90-97 for text, 100-107 for background
|
|
140
|
+
return `${this.ESC}${(isBackground ? 100 : 90) + (color - 8)}m`;
|
|
141
|
+
}
|
|
142
|
+
case 2: // P256 - Extended 256 color palette
|
|
143
|
+
// Format: ESC[38;5;{color}m for foreground, ESC[48;5;{color}m for background
|
|
144
|
+
// The ;5; tells terminal "the next number is a color from the 256-color palette"
|
|
145
|
+
return `${this.ESC}${prefix};5;${color}m`;
|
|
146
|
+
case 3: {
|
|
147
|
+
// RGB - True color (24-bit, millions of colors)
|
|
148
|
+
// Extract individual Red, Green, Blue components from the packed color
|
|
149
|
+
const r = (color >> 16) & 0xff; // Red: bits 17-24
|
|
150
|
+
const g = (color >> 8) & 0xff; // Green: bits 9-16
|
|
151
|
+
const b = color & 0xff; // Blue: bits 1-8
|
|
152
|
+
// Format: ESC[38;2;{r};{g};{b}m for foreground
|
|
153
|
+
// The ;2; tells terminal "the next three numbers are RGB values"
|
|
154
|
+
return `${this.ESC}${prefix};2;${r};${g};${b}m`;
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Converts a single line of terminal content into text with color/style codes.
|
|
162
|
+
*
|
|
163
|
+
* This function processes each character in a line and:
|
|
164
|
+
* 1. Extracts the character itself
|
|
165
|
+
* 2. Checks its color (text and background)
|
|
166
|
+
* 3. Checks its style (bold, italic, underline, etc.)
|
|
167
|
+
* 4. Adds the necessary codes to recreate that appearance
|
|
168
|
+
*
|
|
169
|
+
* It's like going through a line character by character and noting:
|
|
170
|
+
* "This letter is red, this one is bold, this one has blue background..."
|
|
171
|
+
*
|
|
172
|
+
* @param line - One line from the terminal screen
|
|
173
|
+
* @param cols - Number of columns (width) of the terminal
|
|
174
|
+
* @param trimRight - Whether to remove trailing spaces (like right-trim in text editors)
|
|
175
|
+
* @returns The line as text with embedded color/style codes
|
|
176
|
+
*/
|
|
177
|
+
static serializeLine(line, cols, trimRight = true) {
|
|
178
|
+
if (!line)
|
|
179
|
+
return '';
|
|
180
|
+
let result = '';
|
|
181
|
+
// Keep track of the current colors/styles to avoid repeating the same codes
|
|
182
|
+
let lastFgColor = null;
|
|
183
|
+
let lastBgColor = null;
|
|
184
|
+
let lastStyles = {
|
|
185
|
+
bold: false,
|
|
186
|
+
italic: false,
|
|
187
|
+
underline: false,
|
|
188
|
+
strikethrough: false,
|
|
189
|
+
};
|
|
190
|
+
// Find the last character that isn't a space (for trimming)
|
|
191
|
+
// Start from the right and work backwards to find content
|
|
192
|
+
let lastNonEmptyIndex = -1;
|
|
193
|
+
if (trimRight) {
|
|
194
|
+
for (let x = cols - 1; x >= 0; x--) {
|
|
195
|
+
const cell = line.getCell(x);
|
|
196
|
+
if (cell) {
|
|
197
|
+
const chars = cell.getChars();
|
|
198
|
+
if (chars && chars.trim() !== '') {
|
|
199
|
+
lastNonEmptyIndex = x;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// If the line is completely empty and we're trimming, return empty string
|
|
206
|
+
if (trimRight && lastNonEmptyIndex === -1) {
|
|
207
|
+
return '';
|
|
208
|
+
}
|
|
209
|
+
const endCol = trimRight ? lastNonEmptyIndex + 1 : cols;
|
|
210
|
+
// Process each character position in the line
|
|
211
|
+
for (let x = 0; x < endCol; x++) {
|
|
212
|
+
const cell = line.getCell(x);
|
|
213
|
+
if (!cell) {
|
|
214
|
+
// No cell data at this position, just add a space
|
|
215
|
+
result += ' ';
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// Get the actual character at this position
|
|
219
|
+
const chars = cell.getChars();
|
|
220
|
+
const cellChar = chars || ' ';
|
|
221
|
+
// Build up any necessary escape sequences for this character
|
|
222
|
+
let escapeSequence = '';
|
|
223
|
+
// STEP 1: Check text color
|
|
224
|
+
// Get both the color value and the color mode (which type of color it is)
|
|
225
|
+
const fgColor = cell.getFgColor();
|
|
226
|
+
const fgColorMode = cell.getFgColorMode();
|
|
227
|
+
// Combine them into a single value that includes both color and mode information
|
|
228
|
+
const fgColorValue = fgColorMode === 0 ? fgColor : fgColorMode | fgColor;
|
|
229
|
+
// Only add color code if it's different from the previous character
|
|
230
|
+
if (fgColorValue !== lastFgColor) {
|
|
231
|
+
escapeSequence += this.colorToAnsi(fgColorValue, false);
|
|
232
|
+
lastFgColor = fgColorValue;
|
|
233
|
+
}
|
|
234
|
+
// STEP 2: Check background color (same process as text color)
|
|
235
|
+
const bgColor = cell.getBgColor();
|
|
236
|
+
const bgColorMode = cell.getBgColorMode();
|
|
237
|
+
const bgColorValue = bgColorMode === 0 ? bgColor : bgColorMode | bgColor;
|
|
238
|
+
if (bgColorValue !== lastBgColor) {
|
|
239
|
+
escapeSequence += this.colorToAnsi(bgColorValue, true);
|
|
240
|
+
lastBgColor = bgColorValue;
|
|
241
|
+
}
|
|
242
|
+
// STEP 3: Check text styles (bold, italic, etc.)
|
|
243
|
+
const currentStyles = {
|
|
244
|
+
bold: !!cell.isBold(), // Is this character bold?
|
|
245
|
+
italic: !!cell.isItalic(), // Is it italicized?
|
|
246
|
+
underline: !!cell.isUnderline(), // Is it underlined?
|
|
247
|
+
strikethrough: !!cell.isStrikethrough(), // Is it crossed out?
|
|
248
|
+
};
|
|
249
|
+
// If any style changed from the previous character, update them
|
|
250
|
+
if (currentStyles.bold !== lastStyles.bold ||
|
|
251
|
+
currentStyles.italic !== lastStyles.italic ||
|
|
252
|
+
currentStyles.underline !== lastStyles.underline ||
|
|
253
|
+
currentStyles.strikethrough !== lastStyles.strikethrough) {
|
|
254
|
+
// First, turn off all styles with reset codes
|
|
255
|
+
// 22m = not bold, 23m = not italic, 24m = not underline, 29m = not strikethrough
|
|
256
|
+
escapeSequence += `${this.ESC}22m${this.ESC}23m${this.ESC}24m${this.ESC}29m`;
|
|
257
|
+
// Then turn on only the styles we need
|
|
258
|
+
// 1m = bold, 3m = italic, 4m = underline, 9m = strikethrough
|
|
259
|
+
if (currentStyles.bold)
|
|
260
|
+
escapeSequence += `${this.ESC}1m`;
|
|
261
|
+
if (currentStyles.italic)
|
|
262
|
+
escapeSequence += `${this.ESC}3m`;
|
|
263
|
+
if (currentStyles.underline)
|
|
264
|
+
escapeSequence += `${this.ESC}4m`;
|
|
265
|
+
if (currentStyles.strikethrough)
|
|
266
|
+
escapeSequence += `${this.ESC}9m`;
|
|
267
|
+
lastStyles = { ...currentStyles };
|
|
268
|
+
}
|
|
269
|
+
// Add the escape sequences (if any) followed by the actual character
|
|
270
|
+
result += escapeSequence + cellChar;
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Converts the entire terminal screen (or part of it) into text with colors preserved.
|
|
276
|
+
*
|
|
277
|
+
* This is the main function that processes multiple lines of terminal content.
|
|
278
|
+
* It's like taking a "text screenshot" of your terminal that can be replayed later
|
|
279
|
+
* with all the colors and formatting intact.
|
|
280
|
+
*
|
|
281
|
+
* @param terminal - The terminal object containing the screen buffer
|
|
282
|
+
* @param options - Configuration options:
|
|
283
|
+
* - startLine: First line to include (default: 0, the top)
|
|
284
|
+
* - endLine: Last line to include (default: bottom of screen)
|
|
285
|
+
* - trimRight: Remove trailing spaces from each line (default: true)
|
|
286
|
+
* - includeEmptyLines: Keep blank lines in output (default: true)
|
|
287
|
+
* @returns Multi-line string with embedded ANSI codes for colors/styles
|
|
288
|
+
*/
|
|
289
|
+
static serialize(terminal, options = {}) {
|
|
290
|
+
// Get the current screen content and dimensions
|
|
291
|
+
const buffer = terminal.buffer.active;
|
|
292
|
+
const cols = terminal.cols;
|
|
293
|
+
// Apply default options
|
|
294
|
+
const { startLine = 0, endLine = buffer.length, trimRight = true, includeEmptyLines = true, } = options;
|
|
295
|
+
const lines = [];
|
|
296
|
+
// Process each line in the specified range
|
|
297
|
+
for (let y = startLine; y < Math.min(endLine, buffer.length); y++) {
|
|
298
|
+
const line = buffer.getLine(y);
|
|
299
|
+
if (line) {
|
|
300
|
+
// Convert this line to text with color codes
|
|
301
|
+
const serializedLine = this.serializeLine(line, cols, trimRight);
|
|
302
|
+
// Skip empty lines if user doesn't want them
|
|
303
|
+
if (!includeEmptyLines && serializedLine.trim() === '') {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
lines.push(serializedLine);
|
|
307
|
+
}
|
|
308
|
+
else if (includeEmptyLines) {
|
|
309
|
+
// Line doesn't exist but we want to preserve empty lines
|
|
310
|
+
lines.push('');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Remove trailing empty lines if trimming is enabled
|
|
314
|
+
// This is like removing blank lines at the end of a document
|
|
315
|
+
if (trimRight && lines.length > 0) {
|
|
316
|
+
let lastNonEmptyIndex = lines.length - 1;
|
|
317
|
+
while (lastNonEmptyIndex >= 0) {
|
|
318
|
+
const line = lines[lastNonEmptyIndex];
|
|
319
|
+
if (line && line.trim() !== '') {
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
lastNonEmptyIndex--;
|
|
323
|
+
}
|
|
324
|
+
// Join all lines and add a reset code at the end to clear any formatting
|
|
325
|
+
return lines.slice(0, lastNonEmptyIndex + 1).join('\n') + this.RESET;
|
|
326
|
+
}
|
|
327
|
+
// Join all lines and add a reset code at the end
|
|
328
|
+
return lines.join('\n') + this.RESET;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Convenience function to get just the last few lines from the terminal.
|
|
332
|
+
*
|
|
333
|
+
* Useful when you only need recent output, like:
|
|
334
|
+
* - Getting the last error message
|
|
335
|
+
* - Showing recent command output
|
|
336
|
+
* - Displaying the current prompt
|
|
337
|
+
*
|
|
338
|
+
* Example: getLastLines(terminal, 10) gets the last 10 lines
|
|
339
|
+
*
|
|
340
|
+
* @param terminal - The terminal object containing the screen buffer
|
|
341
|
+
* @param lineCount - How many lines from the bottom to include
|
|
342
|
+
* @param options - Same options as serialize() for controlling output format
|
|
343
|
+
* @returns The requested lines as text with color/style codes
|
|
344
|
+
*/
|
|
345
|
+
static getLastLines(terminal, lineCount, options = {}) {
|
|
346
|
+
const buffer = terminal.buffer.active;
|
|
347
|
+
// Calculate where to start (can't go below 0)
|
|
348
|
+
const startLine = Math.max(0, buffer.length - lineCount);
|
|
349
|
+
// Use the main serialize function but with a specific range
|
|
350
|
+
return this.serialize(terminal, {
|
|
351
|
+
startLine,
|
|
352
|
+
endLine: buffer.length,
|
|
353
|
+
...options,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* ESC: The "magic" prefix that tells the terminal "the next characters are instructions, not text"
|
|
359
|
+
* '\x1b[' is like saying "Hey terminal, listen up for special commands!"
|
|
360
|
+
*/
|
|
361
|
+
Object.defineProperty(TerminalSerializer, "ESC", {
|
|
362
|
+
enumerable: true,
|
|
363
|
+
configurable: true,
|
|
364
|
+
writable: true,
|
|
365
|
+
value: '\x1b['
|
|
366
|
+
});
|
|
367
|
+
/**
|
|
368
|
+
* RESET: The command that tells terminal "go back to normal text" (no colors, no bold, etc.)
|
|
369
|
+
* Like clicking "clear formatting" in a word processor
|
|
370
|
+
*/
|
|
371
|
+
Object.defineProperty(TerminalSerializer, "RESET", {
|
|
372
|
+
enumerable: true,
|
|
373
|
+
configurable: true,
|
|
374
|
+
writable: true,
|
|
375
|
+
value: '\x1b[0m'
|
|
376
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import pkg from '@xterm/headless';
|
|
3
|
+
import { TerminalSerializer } from './terminalSerializer.js';
|
|
4
|
+
const { Terminal } = pkg;
|
|
5
|
+
describe('TerminalSerializer', () => {
|
|
6
|
+
let terminal;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
terminal = new Terminal({
|
|
9
|
+
cols: 80,
|
|
10
|
+
rows: 24,
|
|
11
|
+
allowProposedApi: true,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
// Helper to write to terminal and wait for processing
|
|
15
|
+
const writeAsync = (data) => {
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
terminal.write(data, () => resolve());
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
it('should serialize plain text without escape sequences', async () => {
|
|
21
|
+
await writeAsync('Hello, World!');
|
|
22
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
23
|
+
expect(serialized).toContain('Hello, World!');
|
|
24
|
+
});
|
|
25
|
+
it('should preserve foreground colors', async () => {
|
|
26
|
+
// Write red text
|
|
27
|
+
await writeAsync('\x1b[31mRed Text\x1b[0m');
|
|
28
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
29
|
+
expect(serialized).toContain('\x1b[31m');
|
|
30
|
+
expect(serialized).toContain('Red Text');
|
|
31
|
+
});
|
|
32
|
+
it('should preserve background colors', async () => {
|
|
33
|
+
// Write text with blue background
|
|
34
|
+
await writeAsync('\x1b[44mBlue Background\x1b[0m');
|
|
35
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
36
|
+
expect(serialized).toContain('\x1b[44m');
|
|
37
|
+
expect(serialized).toContain('Blue Background');
|
|
38
|
+
});
|
|
39
|
+
it('should preserve 256 colors', async () => {
|
|
40
|
+
// Write text with extended color (color 196 = bright red)
|
|
41
|
+
await writeAsync('\x1b[38;5;196mExtended Color\x1b[0m');
|
|
42
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
43
|
+
expect(serialized).toContain('\x1b[38;5;196m');
|
|
44
|
+
expect(serialized).toContain('Extended Color');
|
|
45
|
+
});
|
|
46
|
+
it('should preserve RGB colors', async () => {
|
|
47
|
+
// Write text with true color RGB
|
|
48
|
+
await writeAsync('\x1b[38;2;255;128;0mOrange RGB\x1b[0m');
|
|
49
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
50
|
+
expect(serialized).toContain('\x1b[38;2;255;128;0m');
|
|
51
|
+
expect(serialized).toContain('Orange RGB');
|
|
52
|
+
});
|
|
53
|
+
it('should preserve text styles', async () => {
|
|
54
|
+
// Write bold text
|
|
55
|
+
await writeAsync('\x1b[1mBold Text\x1b[0m');
|
|
56
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
57
|
+
expect(serialized).toContain('\x1b[1m');
|
|
58
|
+
expect(serialized).toContain('Bold Text');
|
|
59
|
+
// Clear and write italic text
|
|
60
|
+
terminal.reset();
|
|
61
|
+
await writeAsync('\x1b[3mItalic Text\x1b[0m');
|
|
62
|
+
const serializedItalic = TerminalSerializer.serialize(terminal);
|
|
63
|
+
expect(serializedItalic).toContain('\x1b[3m');
|
|
64
|
+
expect(serializedItalic).toContain('Italic Text');
|
|
65
|
+
});
|
|
66
|
+
it('should handle multiple lines correctly', async () => {
|
|
67
|
+
await writeAsync('Line 1\r\n');
|
|
68
|
+
await writeAsync('\x1b[32mLine 2 (green)\x1b[0m\r\n');
|
|
69
|
+
await writeAsync('Line 3');
|
|
70
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
71
|
+
const lines = serialized.split('\n');
|
|
72
|
+
expect(lines.length).toBeGreaterThanOrEqual(3);
|
|
73
|
+
expect(lines[0]).toContain('Line 1');
|
|
74
|
+
expect(lines[1]).toContain('\x1b[32m');
|
|
75
|
+
expect(lines[1]).toContain('Line 2 (green)');
|
|
76
|
+
expect(lines[2]).toContain('Line 3');
|
|
77
|
+
});
|
|
78
|
+
it('should trim trailing empty lines when trimRight is true', async () => {
|
|
79
|
+
await writeAsync('Content\r\n\r\n\r\n\r\n');
|
|
80
|
+
const serialized = TerminalSerializer.serialize(terminal, {
|
|
81
|
+
trimRight: true,
|
|
82
|
+
});
|
|
83
|
+
const lines = serialized.split('\n');
|
|
84
|
+
// Should only have the content line, not the trailing empty lines
|
|
85
|
+
expect(lines[0]).toContain('Content');
|
|
86
|
+
expect(lines.length).toBe(1); // Plus reset code
|
|
87
|
+
});
|
|
88
|
+
it('should preserve empty lines when includeEmptyLines is true', async () => {
|
|
89
|
+
await writeAsync('Line 1\r\n\r\nLine 3');
|
|
90
|
+
const serialized = TerminalSerializer.serialize(terminal, {
|
|
91
|
+
includeEmptyLines: true,
|
|
92
|
+
});
|
|
93
|
+
const lines = serialized.split('\n');
|
|
94
|
+
expect(lines.length).toBe(3); // 3 lines including the empty one
|
|
95
|
+
expect(lines[1]).toBe(''); // Empty line
|
|
96
|
+
});
|
|
97
|
+
it('should handle getLastLines correctly', async () => {
|
|
98
|
+
// Create a small terminal so we can see the scrolling
|
|
99
|
+
const smallTerminal = new Terminal({
|
|
100
|
+
cols: 80,
|
|
101
|
+
rows: 10,
|
|
102
|
+
allowProposedApi: true,
|
|
103
|
+
});
|
|
104
|
+
// Helper for this specific terminal
|
|
105
|
+
const writeToSmall = (data) => {
|
|
106
|
+
return new Promise(resolve => {
|
|
107
|
+
smallTerminal.write(data, () => resolve());
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
// Write enough lines to fill and scroll the buffer
|
|
111
|
+
for (let i = 1; i <= 15; i++) {
|
|
112
|
+
await writeToSmall(`Line ${i}\r\n`);
|
|
113
|
+
}
|
|
114
|
+
const lastLines = TerminalSerializer.getLastLines(smallTerminal, 3);
|
|
115
|
+
// Should contain the last few lines
|
|
116
|
+
expect(lastLines).toContain('Line 14');
|
|
117
|
+
expect(lastLines).toContain('Line 15');
|
|
118
|
+
// Should not contain much older lines
|
|
119
|
+
expect(lastLines).not.toContain('Line 10');
|
|
120
|
+
});
|
|
121
|
+
it('should handle complex mixed formatting', async () => {
|
|
122
|
+
// Complex formatting with multiple attributes
|
|
123
|
+
await writeAsync('\x1b[1;31;44mBold Red on Blue\x1b[0m Normal \x1b[3;38;5;226mItalic Yellow\x1b[0m');
|
|
124
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
125
|
+
// Should contain various escape sequences
|
|
126
|
+
expect(serialized).toContain('Bold Red on Blue');
|
|
127
|
+
expect(serialized).toContain('Normal');
|
|
128
|
+
expect(serialized).toContain('Italic Yellow');
|
|
129
|
+
// Should have some escape sequences (exact sequences may vary based on implementation)
|
|
130
|
+
expect(serialized).toMatch(/\x1b\[\d+(;\d+)*m/);
|
|
131
|
+
});
|
|
132
|
+
it('should add reset at the end', async () => {
|
|
133
|
+
await writeAsync('Some text');
|
|
134
|
+
const serialized = TerminalSerializer.serialize(terminal);
|
|
135
|
+
expect(serialized).toMatch(/\x1b\[0m$/);
|
|
136
|
+
});
|
|
137
|
+
});
|