ccmanager 0.2.1 → 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 CHANGED
@@ -1,17 +1,19 @@
1
- # CCManager - Claude Code Session Manager
1
+ # CCManager - AI Code Assistant Session Manager
2
2
 
3
- CCManager is a TUI application for managing multiple Claude Code sessions across Git worktrees.
3
+ CCManager is a TUI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI) across Git worktrees.
4
4
 
5
5
  https://github.com/user-attachments/assets/a6d80e73-dc06-4ef8-849d-e3857f6c7024
6
6
 
7
7
  ## Features
8
8
 
9
- - Run multiple Claude Code sessions in parallel across different Git worktrees
9
+ - Run multiple AI assistant sessions in parallel across different Git worktrees
10
+ - Support for multiple AI coding assistants (Claude Code, Gemini CLI)
10
11
  - Switch between sessions seamlessly
11
12
  - Visual status indicators for session states (busy, waiting, idle)
12
13
  - Create, merge, and delete worktrees from within the app
13
14
  - Configurable keyboard shortcuts
14
- - Command configuration with automatic fallback support
15
+ - Command presets with automatic fallback support
16
+ - Configurable state detection strategies for different CLI tools
15
17
  - Status change hooks for automation and notifications
16
18
 
17
19
  ## Why CCManager over Claude Squad?
@@ -108,6 +110,26 @@ Note: Shortcuts from `shortcuts.json` will be automatically migrated to `config.
108
110
  - Ctrl+D
109
111
  - Ctrl+[ (equivalent to Escape)
110
112
 
113
+ ## Supported AI Assistants
114
+
115
+ CCManager now supports multiple AI coding assistants with tailored state detection:
116
+
117
+ ### Claude Code (Default)
118
+ - Command: `claude`
119
+ - State detection: Built-in patterns for Claude's prompts and status messages
120
+
121
+ ### Gemini CLI
122
+ - Command: `gemini`
123
+ - State detection: Custom patterns for Gemini's confirmation prompts
124
+ - Installation: [google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)
125
+
126
+ Each assistant has its own state detection strategy to properly track:
127
+ - **Idle**: Ready for new input
128
+ - **Busy**: Processing a request
129
+ - **Waiting**: Awaiting user confirmation
130
+
131
+ See [Gemini Support Documentation](docs/gemini-support.md) for detailed configuration instructions.
132
+
111
133
 
112
134
  ## Command Configuration
113
135
 
@@ -4,6 +4,10 @@ import TextInput from 'ink-text-input';
4
4
  import SelectInput from 'ink-select-input';
5
5
  import { configurationManager } from '../services/configurationManager.js';
6
6
  import { shortcutManager } from '../services/shortcutManager.js';
7
+ const formatDetectionStrategy = (strategy) => {
8
+ const value = strategy || 'claude';
9
+ return value === 'gemini' ? 'Gemini' : 'Claude';
10
+ };
7
11
  const ConfigureCommand = ({ onComplete }) => {
8
12
  const presetsConfig = configurationManager.getCommandPresets();
9
13
  const [presets, setPresets] = useState(presetsConfig.presets);
@@ -14,52 +18,52 @@ const ConfigureCommand = ({ onComplete }) => {
14
18
  const [selectedIndex, setSelectedIndex] = useState(0);
15
19
  const [editField, setEditField] = useState(null);
16
20
  const [inputValue, setInputValue] = useState('');
21
+ const [isSelectingStrategy, setIsSelectingStrategy] = useState(false);
22
+ const [isSelectingStrategyInAdd, setIsSelectingStrategyInAdd] = useState(false);
17
23
  const [newPreset, setNewPreset] = useState({});
18
24
  const [addStep, setAddStep] = useState('name');
19
25
  const [errorMessage, setErrorMessage] = useState(null);
20
26
  // Remove handleListNavigation as SelectInput handles navigation internally
21
27
  // Remove handleListSelection as we now use handleSelectItem
22
- const handleEditNavigation = (key) => {
23
- const menuItems = 7; // name, command, args, fallbackArgs, set default, delete, back
24
- if (key.upArrow) {
25
- setSelectedIndex(prev => (prev > 0 ? prev - 1 : menuItems - 1));
26
- }
27
- else if (key.downArrow) {
28
- setSelectedIndex(prev => (prev < menuItems - 1 ? prev + 1 : 0));
28
+ const handleEditMenuSelect = (item) => {
29
+ // Ignore separator selections
30
+ if (item.value.startsWith('separator')) {
31
+ return;
29
32
  }
30
- };
31
- const handleEditSelection = () => {
32
33
  const preset = presets.find(p => p.id === selectedPresetId);
33
34
  if (!preset)
34
35
  return;
35
- switch (selectedIndex) {
36
- case 0: // Name
36
+ switch (item.value) {
37
+ case 'name':
37
38
  setEditField('name');
38
39
  setInputValue(preset.name);
39
40
  break;
40
- case 1: // Command
41
+ case 'command':
41
42
  setEditField('command');
42
43
  setInputValue(preset.command);
43
44
  break;
44
- case 2: // Args
45
+ case 'args':
45
46
  setEditField('args');
46
47
  setInputValue(preset.args?.join(' ') || '');
47
48
  break;
48
- case 3: // Fallback Args
49
+ case 'fallbackArgs':
49
50
  setEditField('fallbackArgs');
50
51
  setInputValue(preset.fallbackArgs?.join(' ') || '');
51
52
  break;
52
- case 4: // Set as Default
53
+ case 'detectionStrategy':
54
+ setIsSelectingStrategy(true);
55
+ break;
56
+ case 'setDefault':
53
57
  setDefaultPresetId(preset.id);
54
58
  configurationManager.setDefaultPreset(preset.id);
55
59
  break;
56
- case 5: // Delete
60
+ case 'delete':
57
61
  if (presets.length > 1) {
58
62
  setViewMode('delete-confirm');
59
63
  setSelectedIndex(0);
60
64
  }
61
65
  break;
62
- case 6: // Back
66
+ case 'back':
63
67
  setViewMode('list');
64
68
  setSelectedIndex(presets.findIndex(p => p.id === selectedPresetId));
65
69
  break;
@@ -129,23 +133,45 @@ const ConfigureCommand = ({ onComplete }) => {
129
133
  const fallbackArgs = value.trim()
130
134
  ? value.trim().split(/\s+/)
131
135
  : undefined;
132
- const id = Date.now().toString();
133
- const completePreset = {
134
- id,
135
- name: newPreset.name || 'New Preset',
136
- command: newPreset.command || 'claude',
137
- args: newPreset.args,
138
- fallbackArgs,
139
- };
140
- const updatedPresets = [...presets, completePreset];
141
- setPresets(updatedPresets);
142
- configurationManager.addPreset(completePreset);
143
- setViewMode('list');
144
- setSelectedIndex(updatedPresets.length - 1);
136
+ setNewPreset({ ...newPreset, fallbackArgs });
137
+ setAddStep('detectionStrategy');
138
+ setIsSelectingStrategyInAdd(true);
145
139
  break;
146
140
  }
147
141
  }
148
142
  };
143
+ const handleStrategySelect = (item) => {
144
+ const preset = presets.find(p => p.id === selectedPresetId);
145
+ if (!preset)
146
+ return;
147
+ const updatedPreset = { ...preset };
148
+ updatedPreset.detectionStrategy = item.value;
149
+ const updatedPresets = presets.map(p => p.id === preset.id ? updatedPreset : p);
150
+ setPresets(updatedPresets);
151
+ configurationManager.addPreset(updatedPreset);
152
+ setIsSelectingStrategy(false);
153
+ };
154
+ const handleAddStrategySelect = (item) => {
155
+ const id = Date.now().toString();
156
+ const completePreset = {
157
+ id,
158
+ name: newPreset.name || 'New Preset',
159
+ command: newPreset.command || 'claude',
160
+ args: newPreset.args,
161
+ fallbackArgs: newPreset.fallbackArgs,
162
+ detectionStrategy: item.value,
163
+ };
164
+ const updatedPresets = [...presets, completePreset];
165
+ setPresets(updatedPresets);
166
+ configurationManager.addPreset(completePreset);
167
+ setViewMode('list');
168
+ setSelectedIndex(updatedPresets.length - 1);
169
+ setNewPreset({});
170
+ setAddStep('name');
171
+ setInputValue('');
172
+ setIsSelectingStrategyInAdd(false);
173
+ setErrorMessage(null);
174
+ };
149
175
  const handleDeleteConfirm = () => {
150
176
  if (selectedIndex === 0) {
151
177
  // Yes, delete
@@ -166,12 +192,21 @@ const ConfigureCommand = ({ onComplete }) => {
166
192
  else {
167
193
  // Cancel
168
194
  setViewMode('edit');
169
- setSelectedIndex(5); // Back to delete option
195
+ setSelectedIndex(6); // Back to delete option (index updated for new field)
170
196
  }
171
197
  };
172
198
  useInput((input, key) => {
173
199
  if (shortcutManager.matchesShortcut('cancel', input, key)) {
174
- if (editField) {
200
+ if (isSelectingStrategy) {
201
+ setIsSelectingStrategy(false);
202
+ }
203
+ else if (isSelectingStrategyInAdd) {
204
+ setIsSelectingStrategyInAdd(false);
205
+ setViewMode('list');
206
+ setAddStep('name');
207
+ setNewPreset({});
208
+ }
209
+ else if (editField) {
175
210
  setEditField(null);
176
211
  setInputValue('');
177
212
  setErrorMessage(null);
@@ -188,27 +223,26 @@ const ConfigureCommand = ({ onComplete }) => {
188
223
  }
189
224
  else if (viewMode === 'delete-confirm') {
190
225
  setViewMode('edit');
191
- setSelectedIndex(5);
226
+ setSelectedIndex(6); // Updated index for delete option
192
227
  }
193
228
  else {
194
229
  onComplete();
195
230
  }
196
231
  return;
197
232
  }
198
- if (editField || (viewMode === 'add' && inputValue !== undefined)) {
199
- // In input mode, let TextInput handle it
233
+ if (editField ||
234
+ (viewMode === 'add' &&
235
+ inputValue !== undefined &&
236
+ !isSelectingStrategyInAdd) ||
237
+ isSelectingStrategy ||
238
+ isSelectingStrategyInAdd) {
239
+ // In input mode, let TextInput or SelectInput handle it
200
240
  return;
201
241
  }
202
- if (viewMode === 'list') {
242
+ if (viewMode === 'list' || viewMode === 'edit') {
203
243
  // SelectInput handles navigation and selection
204
244
  return;
205
245
  }
206
- else if (viewMode === 'edit') {
207
- handleEditNavigation(key);
208
- if (key.return) {
209
- handleEditSelection();
210
- }
211
- }
212
246
  else if (viewMode === 'delete-confirm') {
213
247
  if (key.upArrow || key.downArrow) {
214
248
  setSelectedIndex(prev => (prev === 0 ? 1 : 0));
@@ -218,6 +252,30 @@ const ConfigureCommand = ({ onComplete }) => {
218
252
  }
219
253
  }
220
254
  });
255
+ // Render strategy selection
256
+ if (isSelectingStrategy) {
257
+ const preset = presets.find(p => p.id === selectedPresetId);
258
+ if (!preset)
259
+ return null;
260
+ const strategyItems = [
261
+ { label: 'Claude', value: 'claude' },
262
+ { label: 'Gemini', value: 'gemini' },
263
+ ];
264
+ const currentStrategy = preset.detectionStrategy || 'claude';
265
+ const initialIndex = strategyItems.findIndex(item => item.value === currentStrategy);
266
+ return (React.createElement(Box, { flexDirection: "column" },
267
+ React.createElement(Box, { marginBottom: 1 },
268
+ React.createElement(Text, { bold: true, color: "green" }, "Select Detection Strategy")),
269
+ React.createElement(Box, { marginBottom: 1 },
270
+ React.createElement(Text, null, "Choose the state detection strategy for this preset:")),
271
+ React.createElement(SelectInput, { items: strategyItems, onSelect: handleStrategySelect, initialIndex: initialIndex }),
272
+ React.createElement(Box, { marginTop: 1 },
273
+ React.createElement(Text, { dimColor: true },
274
+ "Press Enter to select,",
275
+ ' ',
276
+ shortcutManager.getShortcutDisplay('cancel'),
277
+ " to cancel"))));
278
+ }
221
279
  // Render input field
222
280
  if (editField) {
223
281
  const titles = {
@@ -246,6 +304,24 @@ const ConfigureCommand = ({ onComplete }) => {
246
304
  }
247
305
  // Render add preset form
248
306
  if (viewMode === 'add') {
307
+ if (isSelectingStrategyInAdd) {
308
+ const strategyItems = [
309
+ { label: 'Claude', value: 'claude' },
310
+ { label: 'Gemini', value: 'gemini' },
311
+ ];
312
+ return (React.createElement(Box, { flexDirection: "column" },
313
+ React.createElement(Box, { marginBottom: 1 },
314
+ React.createElement(Text, { bold: true, color: "green" }, "Add New Preset - Detection Strategy")),
315
+ React.createElement(Box, { marginBottom: 1 },
316
+ React.createElement(Text, null, "Choose the state detection strategy for this preset:")),
317
+ React.createElement(SelectInput, { items: strategyItems, onSelect: handleAddStrategySelect, initialIndex: 0 }),
318
+ React.createElement(Box, { marginTop: 1 },
319
+ React.createElement(Text, { dimColor: true },
320
+ "Press Enter to select,",
321
+ ' ',
322
+ shortcutManager.getShortcutDisplay('cancel'),
323
+ " to cancel"))));
324
+ }
249
325
  const titles = {
250
326
  name: 'Enter preset name:',
251
327
  command: 'Enter command (e.g., claude):',
@@ -302,30 +378,49 @@ const ConfigureCommand = ({ onComplete }) => {
302
378
  return null;
303
379
  const isDefault = preset.id === defaultPresetId;
304
380
  const canDelete = presets.length > 1;
305
- const menuItems = [
306
- { label: 'Name', value: preset.name },
307
- { label: 'Command', value: preset.command },
308
- { label: 'Arguments', value: preset.args?.join(' ') || '(none)' },
381
+ const editMenuItems = [
382
+ {
383
+ label: `Name: ${preset.name}`,
384
+ value: 'name',
385
+ },
386
+ {
387
+ label: `Command: ${preset.command}`,
388
+ value: 'command',
389
+ },
390
+ {
391
+ label: `Arguments: ${preset.args?.join(' ') || '(none)'}`,
392
+ value: 'args',
393
+ },
394
+ {
395
+ label: `Fallback Arguments: ${preset.fallbackArgs?.join(' ') || '(none)'}`,
396
+ value: 'fallbackArgs',
397
+ },
309
398
  {
310
- label: 'Fallback Arguments',
311
- value: preset.fallbackArgs?.join(' ') || '(none)',
399
+ label: `Detection Strategy: ${formatDetectionStrategy(preset.detectionStrategy)}`,
400
+ value: 'detectionStrategy',
312
401
  },
402
+ { label: '─────────────────────────', value: 'separator1' },
313
403
  {
314
- label: isDefault ? 'Already Default' : 'Set as Default',
315
- value: '',
316
- isButton: true,
317
- disabled: isDefault,
404
+ label: isDefault ? 'Already Default' : 'Set as Default',
405
+ value: 'setDefault',
318
406
  },
319
407
  {
320
408
  label: canDelete
321
409
  ? 'Delete Preset'
322
410
  : 'Delete Preset (cannot delete last preset)',
323
- value: '',
324
- isButton: true,
325
- disabled: !canDelete,
411
+ value: 'delete',
326
412
  },
327
- { label: 'Back to List', value: '', isButton: true, disabled: false },
413
+ { label: '─────────────────────────', value: 'separator2' },
414
+ { label: '← Back to List', value: 'back' },
328
415
  ];
416
+ // Filter out disabled items for SelectInput
417
+ const selectableItems = editMenuItems.filter(item => {
418
+ if (item.value === 'setDefault' && isDefault)
419
+ return false;
420
+ if (item.value === 'delete' && !canDelete)
421
+ return false;
422
+ return true;
423
+ });
329
424
  return (React.createElement(Box, { flexDirection: "column" },
330
425
  React.createElement(Box, { marginBottom: 1 },
331
426
  React.createElement(Text, { bold: true, color: "green" },
@@ -333,18 +428,7 @@ const ConfigureCommand = ({ onComplete }) => {
333
428
  preset.name)),
334
429
  isDefault && (React.createElement(Box, { marginBottom: 1 },
335
430
  React.createElement(Text, { color: "yellow" }, "\u2B50 This is the default preset"))),
336
- React.createElement(Box, { flexDirection: "column" }, menuItems.map((item, index) => {
337
- const isSelected = selectedIndex === index;
338
- const color = item.disabled
339
- ? 'gray'
340
- : isSelected
341
- ? 'cyan'
342
- : undefined;
343
- return (React.createElement(Box, { key: index, marginTop: item.isButton && index > 0 ? 1 : 0 },
344
- React.createElement(Text, { color: color },
345
- isSelected ? '> ' : ' ',
346
- item.isButton ? (React.createElement(Text, { bold: isSelected && !item.disabled, dimColor: item.disabled }, item.label)) : (`${item.label}: ${item.value}`))));
347
- })),
431
+ React.createElement(SelectInput, { items: selectableItems, onSelect: handleEditMenuSelect }),
348
432
  React.createElement(Box, { marginTop: 1 },
349
433
  React.createElement(Text, { dimColor: true },
350
434
  "Press \u2191\u2193 to navigate, Enter to edit/select,",
@@ -366,6 +450,7 @@ const ConfigureCommand = ({ onComplete }) => {
366
450
  label += `\n Args: ${args}`;
367
451
  if (fallback)
368
452
  label += `\n Fallback: ${fallback}`;
453
+ label += `\n Detection: ${formatDetectionStrategy(preset.detectionStrategy)}`;
369
454
  return {
370
455
  label,
371
456
  value: preset.id,
@@ -1,13 +1,11 @@
1
1
  import { Session, SessionManager as ISessionManager, SessionState } from '../types/index.js';
2
2
  import { EventEmitter } from 'events';
3
- import pkg from '@xterm/headless';
4
- declare const Terminal: typeof pkg.Terminal;
5
3
  export declare class SessionManager extends EventEmitter implements ISessionManager {
6
4
  sessions: Map<string, Session>;
7
5
  private waitingWithBottomBorder;
8
6
  private busyTimers;
9
7
  private spawn;
10
- detectTerminalState(terminal: InstanceType<typeof Terminal>): SessionState;
8
+ detectTerminalState(session: Session): SessionState;
11
9
  constructor();
12
10
  createSession(worktreePath: string): Promise<Session>;
13
11
  createSessionWithPreset(worktreePath: string, presetId?: string): Promise<Session>;
@@ -22,4 +20,3 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
22
20
  private executeStatusHook;
23
21
  destroy(): void;
24
22
  }
25
- export {};
@@ -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,7 @@ export class SessionManager extends EventEmitter {
101
78
  terminal,
102
79
  isPrimaryCommand: true,
103
80
  commandConfig,
81
+ detectionStrategy: 'claude', // Default to claude for legacy method
104
82
  };
105
83
  // Set up persistent background data handler for state detection
106
84
  this.setupBackgroundHandler(session);
@@ -170,6 +148,7 @@ export class SessionManager extends EventEmitter {
170
148
  terminal,
171
149
  isPrimaryCommand,
172
150
  commandConfig,
151
+ detectionStrategy: preset.detectionStrategy || 'claude',
173
152
  };
174
153
  // Set up persistent background data handler for state detection
175
154
  this.setupBackgroundHandler(session);
@@ -234,7 +213,7 @@ export class SessionManager extends EventEmitter {
234
213
  // Set up interval-based state detection
235
214
  session.stateCheckInterval = setInterval(() => {
236
215
  const oldState = session.state;
237
- const newState = this.detectTerminalState(session.terminal);
216
+ const newState = this.detectTerminalState(session);
238
217
  if (newState !== oldState) {
239
218
  session.state = newState;
240
219
  this.executeStatusHook(oldState, newState, session);
@@ -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>;
@@ -67,6 +69,7 @@ export interface CommandPreset {
67
69
  command: string;
68
70
  args?: string[];
69
71
  fallbackArgs?: string[];
72
+ detectionStrategy?: StateDetectionStrategy;
70
73
  }
71
74
  export interface CommandPresetsConfig {
72
75
  presets: CommandPreset[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "0.2.1",
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",