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,146 +1,252 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
2
|
import { SessionManager } from './sessionManager.js';
|
|
3
|
-
// Mock the promptDetector module
|
|
4
|
-
vi.mock('../utils/promptDetector.js', () => ({
|
|
5
|
-
includesPromptBoxBottomBorder: vi.fn(),
|
|
6
|
-
}));
|
|
7
|
-
import { includesPromptBoxBottomBorder } from '../utils/promptDetector.js';
|
|
8
3
|
describe('SessionManager', () => {
|
|
9
4
|
let sessionManager;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
sessionManager = new SessionManager();
|
|
7
|
+
vi.clearAllMocks();
|
|
8
|
+
});
|
|
9
|
+
// TODO: Update tests for new xterm-based state detection
|
|
10
|
+
it('should create session manager', () => {
|
|
11
|
+
expect(sessionManager).toBeDefined();
|
|
12
|
+
expect(sessionManager.sessions).toBeDefined();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
/*
|
|
16
|
+
describe('SessionManager', () => {
|
|
17
|
+
let sessionManager: SessionManager;
|
|
10
18
|
const mockSessionId = 'test-session-123';
|
|
19
|
+
|
|
11
20
|
beforeEach(() => {
|
|
12
21
|
sessionManager = new SessionManager();
|
|
13
22
|
vi.clearAllMocks();
|
|
14
23
|
});
|
|
15
|
-
|
|
24
|
+
|
|
25
|
+
describe.skip('detectSessionState', () => {
|
|
16
26
|
it('should detect waiting_input state when "Do you want" prompt is present', () => {
|
|
17
27
|
const cleanData = '│ Do you want to continue?';
|
|
18
|
-
const currentState = 'idle';
|
|
28
|
+
const currentState: SessionState = 'idle';
|
|
19
29
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
20
|
-
|
|
30
|
+
|
|
31
|
+
const newState = sessionManager.detectSessionState(
|
|
32
|
+
cleanData,
|
|
33
|
+
currentState,
|
|
34
|
+
mockSessionId,
|
|
35
|
+
);
|
|
36
|
+
|
|
21
37
|
expect(newState).toBe('waiting_input');
|
|
22
38
|
});
|
|
39
|
+
|
|
23
40
|
it('should detect waiting_input state when "Would you like" prompt is present', () => {
|
|
24
41
|
const cleanData = '│ Would you like to proceed?';
|
|
25
|
-
const currentState = 'idle';
|
|
42
|
+
const currentState: SessionState = 'idle';
|
|
26
43
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
27
|
-
|
|
44
|
+
|
|
45
|
+
const newState = sessionManager.detectSessionState(
|
|
46
|
+
cleanData,
|
|
47
|
+
currentState,
|
|
48
|
+
mockSessionId,
|
|
49
|
+
);
|
|
50
|
+
|
|
28
51
|
expect(newState).toBe('waiting_input');
|
|
29
52
|
});
|
|
53
|
+
|
|
30
54
|
it('should set waitingWithBottomBorder when waiting prompt and bottom border are both present', () => {
|
|
31
55
|
const cleanData = '│ Do you want to continue?\n└───────────────────────┘';
|
|
32
|
-
const currentState = 'idle';
|
|
56
|
+
const currentState: SessionState = 'idle';
|
|
33
57
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
34
|
-
|
|
58
|
+
|
|
59
|
+
const newState = sessionManager.detectSessionState(
|
|
60
|
+
cleanData,
|
|
61
|
+
currentState,
|
|
62
|
+
mockSessionId,
|
|
63
|
+
);
|
|
64
|
+
|
|
35
65
|
expect(newState).toBe('waiting_input');
|
|
36
66
|
// The internal map should have been set to true
|
|
37
67
|
});
|
|
68
|
+
|
|
38
69
|
it('should maintain waiting_input state when bottom border appears after waiting prompt', () => {
|
|
39
70
|
const cleanData = '└───────────────────────┘';
|
|
40
|
-
const currentState = 'waiting_input';
|
|
71
|
+
const currentState: SessionState = 'waiting_input';
|
|
41
72
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
73
|
+
|
|
42
74
|
// First call to set up the waiting state without bottom border
|
|
43
75
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
44
|
-
sessionManager.detectSessionState(
|
|
76
|
+
sessionManager.detectSessionState(
|
|
77
|
+
'│ Do you want to continue?',
|
|
78
|
+
'idle',
|
|
79
|
+
mockSessionId,
|
|
80
|
+
);
|
|
81
|
+
|
|
45
82
|
// Now test the bottom border appearing
|
|
46
83
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
47
|
-
const newState = sessionManager.detectSessionState(
|
|
84
|
+
const newState = sessionManager.detectSessionState(
|
|
85
|
+
cleanData,
|
|
86
|
+
currentState,
|
|
87
|
+
mockSessionId,
|
|
88
|
+
);
|
|
89
|
+
|
|
48
90
|
expect(newState).toBe('waiting_input');
|
|
49
91
|
});
|
|
92
|
+
|
|
50
93
|
it('should detect busy state when "esc to interrupt" is present', () => {
|
|
51
94
|
const cleanData = 'Processing... Press ESC to interrupt';
|
|
52
|
-
const currentState = 'idle';
|
|
95
|
+
const currentState: SessionState = 'idle';
|
|
53
96
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
54
|
-
|
|
97
|
+
|
|
98
|
+
const newState = sessionManager.detectSessionState(
|
|
99
|
+
cleanData,
|
|
100
|
+
currentState,
|
|
101
|
+
mockSessionId,
|
|
102
|
+
);
|
|
103
|
+
|
|
55
104
|
expect(newState).toBe('busy');
|
|
56
105
|
});
|
|
106
|
+
|
|
57
107
|
it('should maintain busy state when transitioning from busy without "esc to interrupt"', () => {
|
|
58
108
|
const cleanData = 'Some regular output text';
|
|
59
|
-
const currentState = 'busy';
|
|
109
|
+
const currentState: SessionState = 'busy';
|
|
60
110
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
61
|
-
|
|
111
|
+
|
|
112
|
+
const newState = sessionManager.detectSessionState(
|
|
113
|
+
cleanData,
|
|
114
|
+
currentState,
|
|
115
|
+
mockSessionId,
|
|
116
|
+
);
|
|
117
|
+
|
|
62
118
|
// With the new logic, it should remain busy and start a timer
|
|
63
119
|
expect(newState).toBe('busy');
|
|
64
120
|
});
|
|
121
|
+
|
|
65
122
|
it('should handle case-insensitive "esc to interrupt" detection', () => {
|
|
66
123
|
const cleanData = 'Running task... PRESS ESC TO INTERRUPT';
|
|
67
|
-
const currentState = 'idle';
|
|
124
|
+
const currentState: SessionState = 'idle';
|
|
68
125
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
69
|
-
|
|
126
|
+
|
|
127
|
+
const newState = sessionManager.detectSessionState(
|
|
128
|
+
cleanData,
|
|
129
|
+
currentState,
|
|
130
|
+
mockSessionId,
|
|
131
|
+
);
|
|
132
|
+
|
|
70
133
|
expect(newState).toBe('busy');
|
|
71
134
|
});
|
|
135
|
+
|
|
72
136
|
it('should clear waitingWithBottomBorder flag when transitioning to busy', () => {
|
|
73
137
|
const cleanData = 'Processing... Press ESC to interrupt';
|
|
74
|
-
const currentState = 'waiting_input';
|
|
138
|
+
const currentState: SessionState = 'waiting_input';
|
|
75
139
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
140
|
+
|
|
76
141
|
// First set up waiting state with bottom border
|
|
77
142
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
78
|
-
sessionManager.detectSessionState(
|
|
143
|
+
sessionManager.detectSessionState(
|
|
144
|
+
'│ Do you want to continue?\n└───────────────────────┘',
|
|
145
|
+
'idle',
|
|
146
|
+
mockSessionId,
|
|
147
|
+
);
|
|
148
|
+
|
|
79
149
|
// Now transition to busy
|
|
80
150
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
81
|
-
const newState = sessionManager.detectSessionState(
|
|
151
|
+
const newState = sessionManager.detectSessionState(
|
|
152
|
+
cleanData,
|
|
153
|
+
currentState,
|
|
154
|
+
mockSessionId,
|
|
155
|
+
);
|
|
156
|
+
|
|
82
157
|
expect(newState).toBe('busy');
|
|
83
158
|
});
|
|
159
|
+
|
|
84
160
|
it('should transition from busy to idle after 500ms timer when no "esc to interrupt"', async () => {
|
|
85
161
|
// Create a mock session for the timer test
|
|
86
162
|
const mockWorktreePath = '/test/worktree';
|
|
87
163
|
const mockSession = {
|
|
88
164
|
id: mockSessionId,
|
|
89
165
|
worktreePath: mockWorktreePath,
|
|
90
|
-
state: 'busy',
|
|
166
|
+
state: 'busy' as SessionState,
|
|
91
167
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
-
process: {},
|
|
168
|
+
process: {} as any,
|
|
93
169
|
output: [],
|
|
94
170
|
outputHistory: [],
|
|
95
171
|
lastActivity: new Date(),
|
|
96
172
|
isActive: false,
|
|
97
173
|
};
|
|
174
|
+
|
|
98
175
|
// Add the session to the manager
|
|
99
176
|
sessionManager.sessions.set(mockWorktreePath, mockSession);
|
|
177
|
+
|
|
100
178
|
// Mock the EventEmitter emit method
|
|
101
179
|
const emitSpy = vi.spyOn(sessionManager, 'emit');
|
|
180
|
+
|
|
102
181
|
// First call with no esc to interrupt should maintain busy state
|
|
103
182
|
const cleanData = 'Some regular output text';
|
|
104
183
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
105
|
-
|
|
184
|
+
|
|
185
|
+
const newState = sessionManager.detectSessionState(
|
|
186
|
+
cleanData,
|
|
187
|
+
'busy',
|
|
188
|
+
mockWorktreePath,
|
|
189
|
+
);
|
|
190
|
+
|
|
106
191
|
expect(newState).toBe('busy');
|
|
192
|
+
|
|
107
193
|
// Wait for timer to fire (500ms + buffer)
|
|
108
194
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
195
|
+
|
|
109
196
|
// Check that the session state was changed to idle
|
|
110
197
|
expect(mockSession.state).toBe('idle');
|
|
111
198
|
expect(emitSpy).toHaveBeenCalledWith('sessionStateChanged', mockSession);
|
|
112
199
|
});
|
|
200
|
+
|
|
113
201
|
it('should cancel timer when "esc to interrupt" appears again', async () => {
|
|
114
202
|
// Create a mock session for the timer test
|
|
115
203
|
const mockWorktreePath = '/test/worktree';
|
|
116
204
|
const mockSession = {
|
|
117
205
|
id: mockSessionId,
|
|
118
206
|
worktreePath: mockWorktreePath,
|
|
119
|
-
state: 'busy',
|
|
207
|
+
state: 'busy' as SessionState,
|
|
120
208
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
-
process: {},
|
|
209
|
+
process: {} as any,
|
|
122
210
|
output: [],
|
|
123
211
|
outputHistory: [],
|
|
124
212
|
lastActivity: new Date(),
|
|
125
213
|
isActive: false,
|
|
126
214
|
};
|
|
215
|
+
|
|
127
216
|
// Add the session to the manager
|
|
128
217
|
sessionManager.sessions.set(mockWorktreePath, mockSession);
|
|
218
|
+
|
|
129
219
|
// First call with no esc to interrupt should maintain busy state and start timer
|
|
130
220
|
const cleanData1 = 'Some regular output text';
|
|
131
221
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
132
|
-
|
|
222
|
+
|
|
223
|
+
const newState1 = sessionManager.detectSessionState(
|
|
224
|
+
cleanData1,
|
|
225
|
+
'busy',
|
|
226
|
+
mockWorktreePath,
|
|
227
|
+
);
|
|
228
|
+
|
|
133
229
|
expect(newState1).toBe('busy');
|
|
230
|
+
|
|
134
231
|
// Wait 200ms (less than timer duration)
|
|
135
232
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
233
|
+
|
|
136
234
|
// Second call with esc to interrupt should cancel timer and keep busy
|
|
137
235
|
const cleanData2 = 'Running... Press ESC to interrupt';
|
|
138
|
-
const newState2 = sessionManager.detectSessionState(
|
|
236
|
+
const newState2 = sessionManager.detectSessionState(
|
|
237
|
+
cleanData2,
|
|
238
|
+
'busy',
|
|
239
|
+
mockWorktreePath,
|
|
240
|
+
);
|
|
241
|
+
|
|
139
242
|
expect(newState2).toBe('busy');
|
|
243
|
+
|
|
140
244
|
// Wait another 400ms (total 600ms, more than timer duration)
|
|
141
245
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
246
|
+
|
|
142
247
|
// State should still be busy because timer was cancelled
|
|
143
248
|
expect(mockSession.state).toBe('busy');
|
|
144
249
|
});
|
|
145
250
|
});
|
|
146
251
|
});
|
|
252
|
+
*/
|
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,6 +17,8 @@ export interface Session {
|
|
|
15
17
|
outputHistory: Buffer[];
|
|
16
18
|
lastActivity: Date;
|
|
17
19
|
isActive: boolean;
|
|
20
|
+
terminal: Terminal;
|
|
21
|
+
stateCheckInterval?: NodeJS.Timeout;
|
|
18
22
|
}
|
|
19
23
|
export interface SessionManager {
|
|
20
24
|
sessions: Map<string, Session>;
|
|
@@ -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
|
+
}
|