ccmanager 0.2.0 → 1.0.0
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/README.md +26 -4
- package/dist/components/App.js +35 -1
- package/dist/components/ConfigureCommand.js +455 -124
- package/dist/components/PresetSelector.d.ts +7 -0
- package/dist/components/PresetSelector.js +52 -0
- package/dist/services/configurationManager.d.ts +11 -1
- package/dist/services/configurationManager.js +111 -3
- package/dist/services/configurationManager.selectPresetOnStart.test.d.ts +1 -0
- package/dist/services/configurationManager.selectPresetOnStart.test.js +103 -0
- package/dist/services/configurationManager.test.d.ts +1 -0
- package/dist/services/configurationManager.test.js +313 -0
- package/dist/services/sessionManager.d.ts +2 -4
- package/dist/services/sessionManager.js +78 -30
- package/dist/services/sessionManager.test.js +103 -0
- package/dist/services/stateDetector.d.ts +16 -0
- package/dist/services/stateDetector.js +67 -0
- package/dist/services/stateDetector.test.d.ts +1 -0
- package/dist/services/stateDetector.test.js +242 -0
- package/dist/types/index.d.ts +16 -0
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import pkg from '@xterm/headless';
|
|
|
4
4
|
import { exec } from 'child_process';
|
|
5
5
|
import { configurationManager } from './configurationManager.js';
|
|
6
6
|
import { WorktreeService } from './worktreeService.js';
|
|
7
|
+
import { createStateDetector } from './stateDetector.js';
|
|
7
8
|
const { Terminal } = pkg;
|
|
8
9
|
export class SessionManager extends EventEmitter {
|
|
9
10
|
async spawn(command, args, worktreePath) {
|
|
@@ -16,35 +17,11 @@ export class SessionManager extends EventEmitter {
|
|
|
16
17
|
};
|
|
17
18
|
return spawn(command, args, spawnOptions);
|
|
18
19
|
}
|
|
19
|
-
detectTerminalState(
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
for (let i = buffer.length - 1; i >= 0 && lines.length < 30; i--) {
|
|
25
|
-
const line = buffer.getLine(i);
|
|
26
|
-
if (line) {
|
|
27
|
-
const text = line.translateToString(true);
|
|
28
|
-
// Skip empty lines at the bottom
|
|
29
|
-
if (lines.length > 0 || text.trim() !== '') {
|
|
30
|
-
lines.unshift(text);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
// Join lines and check for patterns
|
|
35
|
-
const content = lines.join('\n');
|
|
36
|
-
const lowerContent = content.toLowerCase();
|
|
37
|
-
// Check for waiting prompts with box character
|
|
38
|
-
if (content.includes('│ Do you want') ||
|
|
39
|
-
content.includes('│ Would you like')) {
|
|
40
|
-
return 'waiting_input';
|
|
41
|
-
}
|
|
42
|
-
// Check for busy state
|
|
43
|
-
if (lowerContent.includes('esc to interrupt')) {
|
|
44
|
-
return 'busy';
|
|
45
|
-
}
|
|
46
|
-
// Otherwise idle
|
|
47
|
-
return 'idle';
|
|
20
|
+
detectTerminalState(session) {
|
|
21
|
+
// Create a detector based on the session's detection strategy
|
|
22
|
+
const strategy = session.detectionStrategy || 'claude';
|
|
23
|
+
const detector = createStateDetector(strategy);
|
|
24
|
+
return detector.detectState(session.terminal);
|
|
48
25
|
}
|
|
49
26
|
constructor() {
|
|
50
27
|
super();
|
|
@@ -101,6 +78,77 @@ export class SessionManager extends EventEmitter {
|
|
|
101
78
|
terminal,
|
|
102
79
|
isPrimaryCommand: true,
|
|
103
80
|
commandConfig,
|
|
81
|
+
detectionStrategy: 'claude', // Default to claude for legacy method
|
|
82
|
+
};
|
|
83
|
+
// Set up persistent background data handler for state detection
|
|
84
|
+
this.setupBackgroundHandler(session);
|
|
85
|
+
this.sessions.set(worktreePath, session);
|
|
86
|
+
this.emit('sessionCreated', session);
|
|
87
|
+
return session;
|
|
88
|
+
}
|
|
89
|
+
async createSessionWithPreset(worktreePath, presetId) {
|
|
90
|
+
// Check if session already exists
|
|
91
|
+
const existing = this.sessions.get(worktreePath);
|
|
92
|
+
if (existing) {
|
|
93
|
+
return existing;
|
|
94
|
+
}
|
|
95
|
+
const id = `session-${Date.now()}-${Math.random()
|
|
96
|
+
.toString(36)
|
|
97
|
+
.substr(2, 9)}`;
|
|
98
|
+
// Get preset configuration
|
|
99
|
+
let preset = presetId ? configurationManager.getPresetById(presetId) : null;
|
|
100
|
+
if (!preset) {
|
|
101
|
+
preset = configurationManager.getDefaultPreset();
|
|
102
|
+
}
|
|
103
|
+
const command = preset.command;
|
|
104
|
+
const args = preset.args || [];
|
|
105
|
+
const commandConfig = {
|
|
106
|
+
command: preset.command,
|
|
107
|
+
args: preset.args,
|
|
108
|
+
fallbackArgs: preset.fallbackArgs,
|
|
109
|
+
};
|
|
110
|
+
// Try to spawn the process
|
|
111
|
+
let ptyProcess;
|
|
112
|
+
let isPrimaryCommand = true;
|
|
113
|
+
try {
|
|
114
|
+
ptyProcess = await this.spawn(command, args, worktreePath);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// If primary command fails and we have fallback args, try them
|
|
118
|
+
if (preset.fallbackArgs) {
|
|
119
|
+
try {
|
|
120
|
+
ptyProcess = await this.spawn(command, preset.fallbackArgs, worktreePath);
|
|
121
|
+
isPrimaryCommand = false;
|
|
122
|
+
}
|
|
123
|
+
catch (_fallbackError) {
|
|
124
|
+
// Both attempts failed, throw the original error
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// No fallback args, throw the error
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Create virtual terminal for state detection
|
|
134
|
+
const terminal = new Terminal({
|
|
135
|
+
cols: process.stdout.columns || 80,
|
|
136
|
+
rows: process.stdout.rows || 24,
|
|
137
|
+
allowProposedApi: true,
|
|
138
|
+
});
|
|
139
|
+
const session = {
|
|
140
|
+
id,
|
|
141
|
+
worktreePath,
|
|
142
|
+
process: ptyProcess,
|
|
143
|
+
state: 'busy', // Session starts as busy when created
|
|
144
|
+
output: [],
|
|
145
|
+
outputHistory: [],
|
|
146
|
+
lastActivity: new Date(),
|
|
147
|
+
isActive: false,
|
|
148
|
+
terminal,
|
|
149
|
+
isPrimaryCommand,
|
|
150
|
+
commandConfig,
|
|
151
|
+
detectionStrategy: preset.detectionStrategy || 'claude',
|
|
104
152
|
};
|
|
105
153
|
// Set up persistent background data handler for state detection
|
|
106
154
|
this.setupBackgroundHandler(session);
|
|
@@ -165,7 +213,7 @@ export class SessionManager extends EventEmitter {
|
|
|
165
213
|
// Set up interval-based state detection
|
|
166
214
|
session.stateCheckInterval = setInterval(() => {
|
|
167
215
|
const oldState = session.state;
|
|
168
|
-
const newState = this.detectTerminalState(session
|
|
216
|
+
const newState = this.detectTerminalState(session);
|
|
169
217
|
if (newState !== oldState) {
|
|
170
218
|
session.state = newState;
|
|
171
219
|
this.executeStatusHook(oldState, newState, session);
|
|
@@ -10,6 +10,8 @@ vi.mock('./configurationManager.js', () => ({
|
|
|
10
10
|
configurationManager: {
|
|
11
11
|
getCommandConfig: vi.fn(),
|
|
12
12
|
getStatusHooks: vi.fn(() => ({})),
|
|
13
|
+
getDefaultPreset: vi.fn(),
|
|
14
|
+
getPresetById: vi.fn(),
|
|
13
15
|
},
|
|
14
16
|
}));
|
|
15
17
|
// Mock Terminal
|
|
@@ -257,4 +259,105 @@ describe('SessionManager', () => {
|
|
|
257
259
|
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
258
260
|
});
|
|
259
261
|
});
|
|
262
|
+
describe('createSession with presets', () => {
|
|
263
|
+
it('should use default preset when no preset ID specified', async () => {
|
|
264
|
+
// Setup mock preset
|
|
265
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
266
|
+
id: '1',
|
|
267
|
+
name: 'Main',
|
|
268
|
+
command: 'claude',
|
|
269
|
+
args: ['--preset-arg'],
|
|
270
|
+
});
|
|
271
|
+
// Setup spawn mock
|
|
272
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
273
|
+
// Create session with preset
|
|
274
|
+
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
275
|
+
// Verify spawn was called with preset config
|
|
276
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
|
|
277
|
+
name: 'xterm-color',
|
|
278
|
+
cols: expect.any(Number),
|
|
279
|
+
rows: expect.any(Number),
|
|
280
|
+
cwd: '/test/worktree',
|
|
281
|
+
env: process.env,
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
it('should use specific preset when ID provided', async () => {
|
|
285
|
+
// Setup mock preset
|
|
286
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue({
|
|
287
|
+
id: '2',
|
|
288
|
+
name: 'Development',
|
|
289
|
+
command: 'claude',
|
|
290
|
+
args: ['--resume', '--dev'],
|
|
291
|
+
fallbackArgs: ['--no-mcp'],
|
|
292
|
+
});
|
|
293
|
+
// Setup spawn mock
|
|
294
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
295
|
+
// Create session with specific preset
|
|
296
|
+
await sessionManager.createSessionWithPreset('/test/worktree', '2');
|
|
297
|
+
// Verify getPresetById was called with correct ID
|
|
298
|
+
expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
|
|
299
|
+
// Verify spawn was called with preset config
|
|
300
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--dev'], {
|
|
301
|
+
name: 'xterm-color',
|
|
302
|
+
cols: expect.any(Number),
|
|
303
|
+
rows: expect.any(Number),
|
|
304
|
+
cwd: '/test/worktree',
|
|
305
|
+
env: process.env,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
it('should fall back to default preset if specified preset not found', async () => {
|
|
309
|
+
// Setup mocks
|
|
310
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
|
|
311
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
312
|
+
id: '1',
|
|
313
|
+
name: 'Main',
|
|
314
|
+
command: 'claude',
|
|
315
|
+
});
|
|
316
|
+
// Setup spawn mock
|
|
317
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
318
|
+
// Create session with non-existent preset
|
|
319
|
+
await sessionManager.createSessionWithPreset('/test/worktree', 'invalid');
|
|
320
|
+
// Verify fallback to default preset
|
|
321
|
+
expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
|
|
322
|
+
expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
|
|
323
|
+
});
|
|
324
|
+
it('should try fallback args with preset if main command fails', async () => {
|
|
325
|
+
// Setup mock preset with fallback
|
|
326
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
327
|
+
id: '1',
|
|
328
|
+
name: 'Main',
|
|
329
|
+
command: 'claude',
|
|
330
|
+
args: ['--bad-flag'],
|
|
331
|
+
fallbackArgs: ['--good-flag'],
|
|
332
|
+
});
|
|
333
|
+
// Mock spawn to fail first, succeed second
|
|
334
|
+
let callCount = 0;
|
|
335
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
336
|
+
callCount++;
|
|
337
|
+
if (callCount === 1) {
|
|
338
|
+
throw new Error('Command failed');
|
|
339
|
+
}
|
|
340
|
+
return mockPty;
|
|
341
|
+
});
|
|
342
|
+
// Create session
|
|
343
|
+
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
344
|
+
// Verify both attempts were made
|
|
345
|
+
expect(spawn).toHaveBeenCalledTimes(2);
|
|
346
|
+
expect(spawn).toHaveBeenNthCalledWith(1, 'claude', ['--bad-flag'], expect.any(Object));
|
|
347
|
+
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--good-flag'], expect.any(Object));
|
|
348
|
+
});
|
|
349
|
+
it('should maintain backward compatibility with createSession', async () => {
|
|
350
|
+
// Setup legacy config
|
|
351
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
352
|
+
command: 'claude',
|
|
353
|
+
args: ['--legacy'],
|
|
354
|
+
});
|
|
355
|
+
// Setup spawn mock
|
|
356
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
357
|
+
// Create session using legacy method
|
|
358
|
+
await sessionManager.createSession('/test/worktree');
|
|
359
|
+
// Verify legacy method still works
|
|
360
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--legacy'], expect.any(Object));
|
|
361
|
+
});
|
|
362
|
+
});
|
|
260
363
|
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SessionState, Terminal, StateDetectionStrategy } from '../types/index.js';
|
|
2
|
+
export interface StateDetector {
|
|
3
|
+
detectState(terminal: Terminal): SessionState;
|
|
4
|
+
}
|
|
5
|
+
export declare function createStateDetector(strategy?: StateDetectionStrategy): StateDetector;
|
|
6
|
+
export declare abstract class BaseStateDetector implements StateDetector {
|
|
7
|
+
abstract detectState(terminal: Terminal): SessionState;
|
|
8
|
+
protected getTerminalLines(terminal: Terminal, maxLines?: number): string[];
|
|
9
|
+
protected getTerminalContent(terminal: Terminal, maxLines?: number): string;
|
|
10
|
+
}
|
|
11
|
+
export declare class ClaudeStateDetector extends BaseStateDetector {
|
|
12
|
+
detectState(terminal: Terminal): SessionState;
|
|
13
|
+
}
|
|
14
|
+
export declare class GeminiStateDetector extends BaseStateDetector {
|
|
15
|
+
detectState(terminal: Terminal): SessionState;
|
|
16
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function createStateDetector(strategy = 'claude') {
|
|
2
|
+
switch (strategy) {
|
|
3
|
+
case 'claude':
|
|
4
|
+
return new ClaudeStateDetector();
|
|
5
|
+
case 'gemini':
|
|
6
|
+
return new GeminiStateDetector();
|
|
7
|
+
default:
|
|
8
|
+
return new ClaudeStateDetector();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class BaseStateDetector {
|
|
12
|
+
getTerminalLines(terminal, maxLines = 30) {
|
|
13
|
+
const buffer = terminal.buffer.active;
|
|
14
|
+
const lines = [];
|
|
15
|
+
// Start from the bottom and work our way up
|
|
16
|
+
for (let i = buffer.length - 1; i >= 0 && lines.length < maxLines; i--) {
|
|
17
|
+
const line = buffer.getLine(i);
|
|
18
|
+
if (line) {
|
|
19
|
+
const text = line.translateToString(true);
|
|
20
|
+
// Skip empty lines at the bottom
|
|
21
|
+
if (lines.length > 0 || text.trim() !== '') {
|
|
22
|
+
lines.unshift(text);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
getTerminalContent(terminal, maxLines = 30) {
|
|
29
|
+
return this.getTerminalLines(terminal, maxLines).join('\n');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class ClaudeStateDetector extends BaseStateDetector {
|
|
33
|
+
detectState(terminal) {
|
|
34
|
+
const content = this.getTerminalContent(terminal);
|
|
35
|
+
const lowerContent = content.toLowerCase();
|
|
36
|
+
// Check for waiting prompts with box character
|
|
37
|
+
if (content.includes('│ Do you want') ||
|
|
38
|
+
content.includes('│ Would you like')) {
|
|
39
|
+
return 'waiting_input';
|
|
40
|
+
}
|
|
41
|
+
// Check for busy state
|
|
42
|
+
if (lowerContent.includes('esc to interrupt')) {
|
|
43
|
+
return 'busy';
|
|
44
|
+
}
|
|
45
|
+
// Otherwise idle
|
|
46
|
+
return 'idle';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
|
|
50
|
+
export class GeminiStateDetector extends BaseStateDetector {
|
|
51
|
+
detectState(terminal) {
|
|
52
|
+
const content = this.getTerminalContent(terminal);
|
|
53
|
+
const lowerContent = content.toLowerCase();
|
|
54
|
+
// Check for waiting prompts with box character
|
|
55
|
+
if (content.includes('│ Apply this change?') ||
|
|
56
|
+
content.includes('│ Allow execution?') ||
|
|
57
|
+
content.includes('│ Do you want to proceed?')) {
|
|
58
|
+
return 'waiting_input';
|
|
59
|
+
}
|
|
60
|
+
// Check for busy state
|
|
61
|
+
if (lowerContent.includes('esc to cancel')) {
|
|
62
|
+
return 'busy';
|
|
63
|
+
}
|
|
64
|
+
// Otherwise idle
|
|
65
|
+
return 'idle';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ClaudeStateDetector, GeminiStateDetector } from './stateDetector.js';
|
|
3
|
+
describe('ClaudeStateDetector', () => {
|
|
4
|
+
let detector;
|
|
5
|
+
let terminal;
|
|
6
|
+
const createMockTerminal = (lines) => {
|
|
7
|
+
const buffer = {
|
|
8
|
+
length: lines.length,
|
|
9
|
+
getLine: (index) => {
|
|
10
|
+
if (index >= 0 && index < lines.length) {
|
|
11
|
+
return {
|
|
12
|
+
translateToString: () => lines[index],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
buffer: {
|
|
20
|
+
active: buffer,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
detector = new ClaudeStateDetector();
|
|
26
|
+
});
|
|
27
|
+
describe('detectState', () => {
|
|
28
|
+
it('should detect waiting_input when "Do you want" prompt is present', () => {
|
|
29
|
+
// Arrange
|
|
30
|
+
terminal = createMockTerminal([
|
|
31
|
+
'Some previous output',
|
|
32
|
+
'│ Do you want to continue? (y/n)',
|
|
33
|
+
'│ > ',
|
|
34
|
+
]);
|
|
35
|
+
// Act
|
|
36
|
+
const state = detector.detectState(terminal);
|
|
37
|
+
// Assert
|
|
38
|
+
expect(state).toBe('waiting_input');
|
|
39
|
+
});
|
|
40
|
+
it('should detect waiting_input when "Would you like" prompt is present', () => {
|
|
41
|
+
// Arrange
|
|
42
|
+
terminal = createMockTerminal([
|
|
43
|
+
'Some output',
|
|
44
|
+
'│ Would you like to save changes?',
|
|
45
|
+
'│ > ',
|
|
46
|
+
]);
|
|
47
|
+
// Act
|
|
48
|
+
const state = detector.detectState(terminal);
|
|
49
|
+
// Assert
|
|
50
|
+
expect(state).toBe('waiting_input');
|
|
51
|
+
});
|
|
52
|
+
it('should detect busy when "ESC to interrupt" is present', () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
terminal = createMockTerminal([
|
|
55
|
+
'Processing...',
|
|
56
|
+
'Press ESC to interrupt',
|
|
57
|
+
]);
|
|
58
|
+
// Act
|
|
59
|
+
const state = detector.detectState(terminal);
|
|
60
|
+
// Assert
|
|
61
|
+
expect(state).toBe('busy');
|
|
62
|
+
});
|
|
63
|
+
it('should detect busy when "esc to interrupt" is present (case insensitive)', () => {
|
|
64
|
+
// Arrange
|
|
65
|
+
terminal = createMockTerminal([
|
|
66
|
+
'Running command...',
|
|
67
|
+
'press esc to interrupt the process',
|
|
68
|
+
]);
|
|
69
|
+
// Act
|
|
70
|
+
const state = detector.detectState(terminal);
|
|
71
|
+
// Assert
|
|
72
|
+
expect(state).toBe('busy');
|
|
73
|
+
});
|
|
74
|
+
it('should detect idle when no specific patterns are found', () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
terminal = createMockTerminal([
|
|
77
|
+
'Command completed successfully',
|
|
78
|
+
'Ready for next command',
|
|
79
|
+
'> ',
|
|
80
|
+
]);
|
|
81
|
+
// Act
|
|
82
|
+
const state = detector.detectState(terminal);
|
|
83
|
+
// Assert
|
|
84
|
+
expect(state).toBe('idle');
|
|
85
|
+
});
|
|
86
|
+
it('should handle empty terminal', () => {
|
|
87
|
+
// Arrange
|
|
88
|
+
terminal = createMockTerminal([]);
|
|
89
|
+
// Act
|
|
90
|
+
const state = detector.detectState(terminal);
|
|
91
|
+
// Assert
|
|
92
|
+
expect(state).toBe('idle');
|
|
93
|
+
});
|
|
94
|
+
it('should only consider last 30 lines', () => {
|
|
95
|
+
// Arrange
|
|
96
|
+
const lines = [];
|
|
97
|
+
// Add more than 30 lines
|
|
98
|
+
for (let i = 0; i < 40; i++) {
|
|
99
|
+
lines.push(`Line ${i}`);
|
|
100
|
+
}
|
|
101
|
+
// The "Do you want" should be outside the 30 line window
|
|
102
|
+
lines.push('│ Do you want to continue?');
|
|
103
|
+
// Add 30 more lines to push it out
|
|
104
|
+
for (let i = 0; i < 30; i++) {
|
|
105
|
+
lines.push(`Recent line ${i}`);
|
|
106
|
+
}
|
|
107
|
+
terminal = createMockTerminal(lines);
|
|
108
|
+
// Act
|
|
109
|
+
const state = detector.detectState(terminal);
|
|
110
|
+
// Assert
|
|
111
|
+
expect(state).toBe('idle'); // Should not detect the old prompt
|
|
112
|
+
});
|
|
113
|
+
it('should prioritize waiting_input over busy state', () => {
|
|
114
|
+
// Arrange
|
|
115
|
+
terminal = createMockTerminal([
|
|
116
|
+
'Press ESC to interrupt',
|
|
117
|
+
'│ Do you want to continue?',
|
|
118
|
+
'│ > ',
|
|
119
|
+
]);
|
|
120
|
+
// Act
|
|
121
|
+
const state = detector.detectState(terminal);
|
|
122
|
+
// Assert
|
|
123
|
+
expect(state).toBe('waiting_input'); // waiting_input should take precedence
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('GeminiStateDetector', () => {
|
|
128
|
+
let detector;
|
|
129
|
+
let terminal;
|
|
130
|
+
const createMockTerminal = (lines) => {
|
|
131
|
+
const buffer = {
|
|
132
|
+
length: lines.length,
|
|
133
|
+
getLine: (index) => {
|
|
134
|
+
if (index >= 0 && index < lines.length) {
|
|
135
|
+
return {
|
|
136
|
+
translateToString: () => lines[index],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
buffer: {
|
|
144
|
+
active: buffer,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
detector = new GeminiStateDetector();
|
|
150
|
+
});
|
|
151
|
+
describe('detectState', () => {
|
|
152
|
+
it('should detect waiting_input when "Apply this change?" prompt is present', () => {
|
|
153
|
+
// Arrange
|
|
154
|
+
terminal = createMockTerminal([
|
|
155
|
+
'Some output from Gemini',
|
|
156
|
+
'│ Apply this change?',
|
|
157
|
+
'│ > ',
|
|
158
|
+
]);
|
|
159
|
+
// Act
|
|
160
|
+
const state = detector.detectState(terminal);
|
|
161
|
+
// Assert
|
|
162
|
+
expect(state).toBe('waiting_input');
|
|
163
|
+
});
|
|
164
|
+
it('should detect waiting_input when "Allow execution?" prompt is present', () => {
|
|
165
|
+
// Arrange
|
|
166
|
+
terminal = createMockTerminal([
|
|
167
|
+
'Command found: npm install',
|
|
168
|
+
'│ Allow execution?',
|
|
169
|
+
'│ > ',
|
|
170
|
+
]);
|
|
171
|
+
// Act
|
|
172
|
+
const state = detector.detectState(terminal);
|
|
173
|
+
// Assert
|
|
174
|
+
expect(state).toBe('waiting_input');
|
|
175
|
+
});
|
|
176
|
+
it('should detect waiting_input when "Do you want to proceed?" prompt is present', () => {
|
|
177
|
+
// Arrange
|
|
178
|
+
terminal = createMockTerminal([
|
|
179
|
+
'Changes detected',
|
|
180
|
+
'│ Do you want to proceed?',
|
|
181
|
+
'│ > ',
|
|
182
|
+
]);
|
|
183
|
+
// Act
|
|
184
|
+
const state = detector.detectState(terminal);
|
|
185
|
+
// Assert
|
|
186
|
+
expect(state).toBe('waiting_input');
|
|
187
|
+
});
|
|
188
|
+
it('should detect busy when "esc to cancel" is present', () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
terminal = createMockTerminal([
|
|
191
|
+
'Processing your request...',
|
|
192
|
+
'Press ESC to cancel',
|
|
193
|
+
]);
|
|
194
|
+
// Act
|
|
195
|
+
const state = detector.detectState(terminal);
|
|
196
|
+
// Assert
|
|
197
|
+
expect(state).toBe('busy');
|
|
198
|
+
});
|
|
199
|
+
it('should detect busy when "ESC to cancel" is present (case insensitive)', () => {
|
|
200
|
+
// Arrange
|
|
201
|
+
terminal = createMockTerminal([
|
|
202
|
+
'Running command...',
|
|
203
|
+
'Press Esc to cancel the operation',
|
|
204
|
+
]);
|
|
205
|
+
// Act
|
|
206
|
+
const state = detector.detectState(terminal);
|
|
207
|
+
// Assert
|
|
208
|
+
expect(state).toBe('busy');
|
|
209
|
+
});
|
|
210
|
+
it('should detect idle when no specific patterns are found', () => {
|
|
211
|
+
// Arrange
|
|
212
|
+
terminal = createMockTerminal([
|
|
213
|
+
'Welcome to Gemini CLI',
|
|
214
|
+
'Type your message below',
|
|
215
|
+
]);
|
|
216
|
+
// Act
|
|
217
|
+
const state = detector.detectState(terminal);
|
|
218
|
+
// Assert
|
|
219
|
+
expect(state).toBe('idle');
|
|
220
|
+
});
|
|
221
|
+
it('should handle empty terminal', () => {
|
|
222
|
+
// Arrange
|
|
223
|
+
terminal = createMockTerminal([]);
|
|
224
|
+
// Act
|
|
225
|
+
const state = detector.detectState(terminal);
|
|
226
|
+
// Assert
|
|
227
|
+
expect(state).toBe('idle');
|
|
228
|
+
});
|
|
229
|
+
it('should prioritize waiting_input over busy state', () => {
|
|
230
|
+
// Arrange
|
|
231
|
+
terminal = createMockTerminal([
|
|
232
|
+
'Press ESC to cancel',
|
|
233
|
+
'│ Apply this change?',
|
|
234
|
+
'│ > ',
|
|
235
|
+
]);
|
|
236
|
+
// Act
|
|
237
|
+
const state = detector.detectState(terminal);
|
|
238
|
+
// Assert
|
|
239
|
+
expect(state).toBe('waiting_input'); // waiting_input should take precedence
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type pkg from '@xterm/headless';
|
|
|
3
3
|
import { GitStatus } from '../utils/gitStatus.js';
|
|
4
4
|
export type Terminal = InstanceType<typeof pkg.Terminal>;
|
|
5
5
|
export type SessionState = 'idle' | 'busy' | 'waiting_input';
|
|
6
|
+
export type StateDetectionStrategy = 'claude' | 'gemini';
|
|
6
7
|
export interface Worktree {
|
|
7
8
|
path: string;
|
|
8
9
|
branch?: string;
|
|
@@ -24,6 +25,7 @@ export interface Session {
|
|
|
24
25
|
stateCheckInterval?: NodeJS.Timeout;
|
|
25
26
|
isPrimaryCommand?: boolean;
|
|
26
27
|
commandConfig?: CommandConfig;
|
|
28
|
+
detectionStrategy?: StateDetectionStrategy;
|
|
27
29
|
}
|
|
28
30
|
export interface SessionManager {
|
|
29
31
|
sessions: Map<string, Session>;
|
|
@@ -61,9 +63,23 @@ export interface CommandConfig {
|
|
|
61
63
|
args?: string[];
|
|
62
64
|
fallbackArgs?: string[];
|
|
63
65
|
}
|
|
66
|
+
export interface CommandPreset {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
command: string;
|
|
70
|
+
args?: string[];
|
|
71
|
+
fallbackArgs?: string[];
|
|
72
|
+
detectionStrategy?: StateDetectionStrategy;
|
|
73
|
+
}
|
|
74
|
+
export interface CommandPresetsConfig {
|
|
75
|
+
presets: CommandPreset[];
|
|
76
|
+
defaultPresetId: string;
|
|
77
|
+
selectPresetOnStart?: boolean;
|
|
78
|
+
}
|
|
64
79
|
export interface ConfigurationData {
|
|
65
80
|
shortcuts?: ShortcutConfig;
|
|
66
81
|
statusHooks?: StatusHookConfig;
|
|
67
82
|
worktree?: WorktreeConfig;
|
|
68
83
|
command?: CommandConfig;
|
|
84
|
+
commandPresets?: CommandPresetsConfig;
|
|
69
85
|
}
|