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.
@@ -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(terminal) {
20
- // Get the last 30 lines from the terminal buffer
21
- const buffer = terminal.buffer.active;
22
- const lines = [];
23
- // Start from the bottom and work our way up
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.terminal);
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
+ });
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",