ccmanager 0.2.1 → 1.1.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,23 @@
1
- # CCManager - Claude Code Session Manager
1
+ # CCManager - AI Code Assistant Session Manager
2
+
3
+ CCManager is a TUI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI) across Git worktrees.
4
+
5
+
6
+
7
+ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
2
8
 
3
- CCManager is a TUI application for managing multiple Claude Code sessions across Git worktrees.
4
9
 
5
- https://github.com/user-attachments/assets/a6d80e73-dc06-4ef8-849d-e3857f6c7024
6
10
 
7
11
  ## Features
8
12
 
9
- - Run multiple Claude Code sessions in parallel across different Git worktrees
13
+ - Run multiple AI assistant sessions in parallel across different Git worktrees
14
+ - Support for multiple AI coding assistants (Claude Code, Gemini CLI)
10
15
  - Switch between sessions seamlessly
11
16
  - Visual status indicators for session states (busy, waiting, idle)
12
17
  - Create, merge, and delete worktrees from within the app
13
18
  - Configurable keyboard shortcuts
14
- - Command configuration with automatic fallback support
19
+ - Command presets with automatic fallback support
20
+ - Configurable state detection strategies for different CLI tools
15
21
  - Status change hooks for automation and notifications
16
22
 
17
23
  ## Why CCManager over Claude Squad?
@@ -50,13 +56,6 @@ $ npm start
50
56
  $ npx ccmanager
51
57
  ```
52
58
 
53
- ## Environment Variables
54
-
55
- ### CCMANAGER_CLAUDE_ARGS
56
-
57
- ⚠️ **Deprecated in v0.1.9**: `CCMANAGER_CLAUDE_ARGS` is no longer supported. Please use the [Command Configuration](#command-configuration) feature instead.
58
-
59
-
60
59
  ## Keyboard Shortcuts
61
60
 
62
61
  ### Default Shortcuts
@@ -69,7 +68,7 @@ $ npx ccmanager
69
68
  You can customize keyboard shortcuts in two ways:
70
69
 
71
70
  1. **Through the UI**: Select "Configuration" → "Configure Shortcuts" from the main menu
72
- 2. **Configuration file**: Edit `~/.config/ccmanager/config.json` (or legacy `~/.config/ccmanager/shortcuts.json`)
71
+ 2. **Configuration file**: Edit `~/.config/ccmanager/config.json`
73
72
 
74
73
  Example configuration:
75
74
  ```json
@@ -85,17 +84,6 @@ Example configuration:
85
84
  }
86
85
  }
87
86
  }
88
-
89
- // shortcuts.json (legacy format, still supported)
90
- {
91
- "returnToMenu": {
92
- "ctrl": true,
93
- "key": "r"
94
- },
95
- "cancel": {
96
- "key": "escape"
97
- }
98
- }
99
87
  ```
100
88
 
101
89
  Note: Shortcuts from `shortcuts.json` will be automatically migrated to `config.json` on first use.
@@ -108,6 +96,26 @@ Note: Shortcuts from `shortcuts.json` will be automatically migrated to `config.
108
96
  - Ctrl+D
109
97
  - Ctrl+[ (equivalent to Escape)
110
98
 
99
+ ## Supported AI Assistants
100
+
101
+ CCManager now supports multiple AI coding assistants with tailored state detection:
102
+
103
+ ### Claude Code (Default)
104
+ - Command: `claude`
105
+ - State detection: Built-in patterns for Claude's prompts and status messages
106
+
107
+ ### Gemini CLI
108
+ - Command: `gemini`
109
+ - State detection: Custom patterns for Gemini's confirmation prompts
110
+ - Installation: [google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)
111
+
112
+ Each assistant has its own state detection strategy to properly track:
113
+ - **Idle**: Ready for new input
114
+ - **Busy**: Processing a request
115
+ - **Waiting**: Awaiting user confirmation
116
+
117
+ See [Gemini Support Documentation](docs/gemini-support.md) for detailed configuration instructions.
118
+
111
119
 
112
120
  ## Command Configuration
113
121
 
@@ -145,7 +153,33 @@ Status hooks allow you to:
145
153
  - Trigger automations based on session activity
146
154
  - Integrate with notification systems like [noti](https://github.com/variadico/noti)
147
155
 
148
- For detailed setup instructions, see [docs/state-hooks.md](docs/state-hooks.md).
156
+ For detailed setup instructions, see [docs/state-hooks.md](docs/status-hooks.md).
157
+
158
+ ## Automatic Worktree Directory Generation
159
+
160
+ CCManager can automatically generate worktree directory paths based on branch names, streamlining the worktree creation process.
161
+
162
+ - **Auto-generate paths**: No need to manually specify directories
163
+ - **Customizable patterns**: Use placeholders like `{branch}` in your pattern
164
+ - **Smart sanitization**: Branch names are automatically made filesystem-safe
165
+
166
+ For detailed configuration and examples, see [docs/worktree-auto-directory.md](docs/worktree-auto-directory.md).
167
+
168
+ ## Git Worktree Configuration
169
+
170
+ CCManager can display enhanced git status information for each worktree when Git's worktree configuration extension is enabled.
171
+
172
+ ```bash
173
+ # Enable enhanced status tracking
174
+ git config extensions.worktreeConfig true
175
+ ```
176
+
177
+ With this enabled, you'll see:
178
+ - **File changes**: `+10 -5` (additions/deletions)
179
+ - **Commit tracking**: `↑3 ↓1` (ahead/behind parent branch)
180
+ - **Parent branch context**: Shows which branch the worktree was created from
181
+
182
+ For complete setup instructions and troubleshooting, see [docs/git-worktree-config.md](docs/git-worktree-config.md).
149
183
 
150
184
  ## Development
151
185
 
@@ -4,6 +4,17 @@ 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
+ switch (value) {
10
+ case 'gemini':
11
+ return 'Gemini';
12
+ case 'codex':
13
+ return 'Codex';
14
+ default:
15
+ return 'Claude';
16
+ }
17
+ };
7
18
  const ConfigureCommand = ({ onComplete }) => {
8
19
  const presetsConfig = configurationManager.getCommandPresets();
9
20
  const [presets, setPresets] = useState(presetsConfig.presets);
@@ -14,52 +25,52 @@ const ConfigureCommand = ({ onComplete }) => {
14
25
  const [selectedIndex, setSelectedIndex] = useState(0);
15
26
  const [editField, setEditField] = useState(null);
16
27
  const [inputValue, setInputValue] = useState('');
28
+ const [isSelectingStrategy, setIsSelectingStrategy] = useState(false);
29
+ const [isSelectingStrategyInAdd, setIsSelectingStrategyInAdd] = useState(false);
17
30
  const [newPreset, setNewPreset] = useState({});
18
31
  const [addStep, setAddStep] = useState('name');
19
32
  const [errorMessage, setErrorMessage] = useState(null);
20
33
  // Remove handleListNavigation as SelectInput handles navigation internally
21
34
  // 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));
35
+ const handleEditMenuSelect = (item) => {
36
+ // Ignore separator selections
37
+ if (item.value.startsWith('separator')) {
38
+ return;
29
39
  }
30
- };
31
- const handleEditSelection = () => {
32
40
  const preset = presets.find(p => p.id === selectedPresetId);
33
41
  if (!preset)
34
42
  return;
35
- switch (selectedIndex) {
36
- case 0: // Name
43
+ switch (item.value) {
44
+ case 'name':
37
45
  setEditField('name');
38
46
  setInputValue(preset.name);
39
47
  break;
40
- case 1: // Command
48
+ case 'command':
41
49
  setEditField('command');
42
50
  setInputValue(preset.command);
43
51
  break;
44
- case 2: // Args
52
+ case 'args':
45
53
  setEditField('args');
46
54
  setInputValue(preset.args?.join(' ') || '');
47
55
  break;
48
- case 3: // Fallback Args
56
+ case 'fallbackArgs':
49
57
  setEditField('fallbackArgs');
50
58
  setInputValue(preset.fallbackArgs?.join(' ') || '');
51
59
  break;
52
- case 4: // Set as Default
60
+ case 'detectionStrategy':
61
+ setIsSelectingStrategy(true);
62
+ break;
63
+ case 'setDefault':
53
64
  setDefaultPresetId(preset.id);
54
65
  configurationManager.setDefaultPreset(preset.id);
55
66
  break;
56
- case 5: // Delete
67
+ case 'delete':
57
68
  if (presets.length > 1) {
58
69
  setViewMode('delete-confirm');
59
70
  setSelectedIndex(0);
60
71
  }
61
72
  break;
62
- case 6: // Back
73
+ case 'back':
63
74
  setViewMode('list');
64
75
  setSelectedIndex(presets.findIndex(p => p.id === selectedPresetId));
65
76
  break;
@@ -129,23 +140,45 @@ const ConfigureCommand = ({ onComplete }) => {
129
140
  const fallbackArgs = value.trim()
130
141
  ? value.trim().split(/\s+/)
131
142
  : 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);
143
+ setNewPreset({ ...newPreset, fallbackArgs });
144
+ setAddStep('detectionStrategy');
145
+ setIsSelectingStrategyInAdd(true);
145
146
  break;
146
147
  }
147
148
  }
148
149
  };
150
+ const handleStrategySelect = (item) => {
151
+ const preset = presets.find(p => p.id === selectedPresetId);
152
+ if (!preset)
153
+ return;
154
+ const updatedPreset = { ...preset };
155
+ updatedPreset.detectionStrategy = item.value;
156
+ const updatedPresets = presets.map(p => p.id === preset.id ? updatedPreset : p);
157
+ setPresets(updatedPresets);
158
+ configurationManager.addPreset(updatedPreset);
159
+ setIsSelectingStrategy(false);
160
+ };
161
+ const handleAddStrategySelect = (item) => {
162
+ const id = Date.now().toString();
163
+ const completePreset = {
164
+ id,
165
+ name: newPreset.name || 'New Preset',
166
+ command: newPreset.command || 'claude',
167
+ args: newPreset.args,
168
+ fallbackArgs: newPreset.fallbackArgs,
169
+ detectionStrategy: item.value,
170
+ };
171
+ const updatedPresets = [...presets, completePreset];
172
+ setPresets(updatedPresets);
173
+ configurationManager.addPreset(completePreset);
174
+ setViewMode('list');
175
+ setSelectedIndex(updatedPresets.length - 1);
176
+ setNewPreset({});
177
+ setAddStep('name');
178
+ setInputValue('');
179
+ setIsSelectingStrategyInAdd(false);
180
+ setErrorMessage(null);
181
+ };
149
182
  const handleDeleteConfirm = () => {
150
183
  if (selectedIndex === 0) {
151
184
  // Yes, delete
@@ -166,12 +199,21 @@ const ConfigureCommand = ({ onComplete }) => {
166
199
  else {
167
200
  // Cancel
168
201
  setViewMode('edit');
169
- setSelectedIndex(5); // Back to delete option
202
+ setSelectedIndex(6); // Back to delete option (index updated for new field)
170
203
  }
171
204
  };
172
205
  useInput((input, key) => {
173
206
  if (shortcutManager.matchesShortcut('cancel', input, key)) {
174
- if (editField) {
207
+ if (isSelectingStrategy) {
208
+ setIsSelectingStrategy(false);
209
+ }
210
+ else if (isSelectingStrategyInAdd) {
211
+ setIsSelectingStrategyInAdd(false);
212
+ setViewMode('list');
213
+ setAddStep('name');
214
+ setNewPreset({});
215
+ }
216
+ else if (editField) {
175
217
  setEditField(null);
176
218
  setInputValue('');
177
219
  setErrorMessage(null);
@@ -188,27 +230,26 @@ const ConfigureCommand = ({ onComplete }) => {
188
230
  }
189
231
  else if (viewMode === 'delete-confirm') {
190
232
  setViewMode('edit');
191
- setSelectedIndex(5);
233
+ setSelectedIndex(6); // Updated index for delete option
192
234
  }
193
235
  else {
194
236
  onComplete();
195
237
  }
196
238
  return;
197
239
  }
198
- if (editField || (viewMode === 'add' && inputValue !== undefined)) {
199
- // In input mode, let TextInput handle it
240
+ if (editField ||
241
+ (viewMode === 'add' &&
242
+ inputValue !== undefined &&
243
+ !isSelectingStrategyInAdd) ||
244
+ isSelectingStrategy ||
245
+ isSelectingStrategyInAdd) {
246
+ // In input mode, let TextInput or SelectInput handle it
200
247
  return;
201
248
  }
202
- if (viewMode === 'list') {
249
+ if (viewMode === 'list' || viewMode === 'edit') {
203
250
  // SelectInput handles navigation and selection
204
251
  return;
205
252
  }
206
- else if (viewMode === 'edit') {
207
- handleEditNavigation(key);
208
- if (key.return) {
209
- handleEditSelection();
210
- }
211
- }
212
253
  else if (viewMode === 'delete-confirm') {
213
254
  if (key.upArrow || key.downArrow) {
214
255
  setSelectedIndex(prev => (prev === 0 ? 1 : 0));
@@ -218,6 +259,31 @@ const ConfigureCommand = ({ onComplete }) => {
218
259
  }
219
260
  }
220
261
  });
262
+ // Render strategy selection
263
+ if (isSelectingStrategy) {
264
+ const preset = presets.find(p => p.id === selectedPresetId);
265
+ if (!preset)
266
+ return null;
267
+ const strategyItems = [
268
+ { label: 'Claude', value: 'claude' },
269
+ { label: 'Gemini', value: 'gemini' },
270
+ { label: 'Codex', value: 'codex' },
271
+ ];
272
+ const currentStrategy = preset.detectionStrategy || 'claude';
273
+ const initialIndex = strategyItems.findIndex(item => item.value === currentStrategy);
274
+ return (React.createElement(Box, { flexDirection: "column" },
275
+ React.createElement(Box, { marginBottom: 1 },
276
+ React.createElement(Text, { bold: true, color: "green" }, "Select Detection Strategy")),
277
+ React.createElement(Box, { marginBottom: 1 },
278
+ React.createElement(Text, null, "Choose the state detection strategy for this preset:")),
279
+ React.createElement(SelectInput, { items: strategyItems, onSelect: handleStrategySelect, initialIndex: initialIndex }),
280
+ React.createElement(Box, { marginTop: 1 },
281
+ React.createElement(Text, { dimColor: true },
282
+ "Press Enter to select,",
283
+ ' ',
284
+ shortcutManager.getShortcutDisplay('cancel'),
285
+ " to cancel"))));
286
+ }
221
287
  // Render input field
222
288
  if (editField) {
223
289
  const titles = {
@@ -246,6 +312,25 @@ const ConfigureCommand = ({ onComplete }) => {
246
312
  }
247
313
  // Render add preset form
248
314
  if (viewMode === 'add') {
315
+ if (isSelectingStrategyInAdd) {
316
+ const strategyItems = [
317
+ { label: 'Claude', value: 'claude' },
318
+ { label: 'Gemini', value: 'gemini' },
319
+ { label: 'Codex', value: 'codex' },
320
+ ];
321
+ return (React.createElement(Box, { flexDirection: "column" },
322
+ React.createElement(Box, { marginBottom: 1 },
323
+ React.createElement(Text, { bold: true, color: "green" }, "Add New Preset - Detection Strategy")),
324
+ React.createElement(Box, { marginBottom: 1 },
325
+ React.createElement(Text, null, "Choose the state detection strategy for this preset:")),
326
+ React.createElement(SelectInput, { items: strategyItems, onSelect: handleAddStrategySelect, initialIndex: 0 }),
327
+ React.createElement(Box, { marginTop: 1 },
328
+ React.createElement(Text, { dimColor: true },
329
+ "Press Enter to select,",
330
+ ' ',
331
+ shortcutManager.getShortcutDisplay('cancel'),
332
+ " to cancel"))));
333
+ }
249
334
  const titles = {
250
335
  name: 'Enter preset name:',
251
336
  command: 'Enter command (e.g., claude):',
@@ -302,30 +387,49 @@ const ConfigureCommand = ({ onComplete }) => {
302
387
  return null;
303
388
  const isDefault = preset.id === defaultPresetId;
304
389
  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)' },
390
+ const editMenuItems = [
391
+ {
392
+ label: `Name: ${preset.name}`,
393
+ value: 'name',
394
+ },
395
+ {
396
+ label: `Command: ${preset.command}`,
397
+ value: 'command',
398
+ },
399
+ {
400
+ label: `Arguments: ${preset.args?.join(' ') || '(none)'}`,
401
+ value: 'args',
402
+ },
403
+ {
404
+ label: `Fallback Arguments: ${preset.fallbackArgs?.join(' ') || '(none)'}`,
405
+ value: 'fallbackArgs',
406
+ },
309
407
  {
310
- label: 'Fallback Arguments',
311
- value: preset.fallbackArgs?.join(' ') || '(none)',
408
+ label: `Detection Strategy: ${formatDetectionStrategy(preset.detectionStrategy)}`,
409
+ value: 'detectionStrategy',
312
410
  },
411
+ { label: '─────────────────────────', value: 'separator1' },
313
412
  {
314
- label: isDefault ? 'Already Default' : 'Set as Default',
315
- value: '',
316
- isButton: true,
317
- disabled: isDefault,
413
+ label: isDefault ? 'Already Default' : 'Set as Default',
414
+ value: 'setDefault',
318
415
  },
319
416
  {
320
417
  label: canDelete
321
418
  ? 'Delete Preset'
322
419
  : 'Delete Preset (cannot delete last preset)',
323
- value: '',
324
- isButton: true,
325
- disabled: !canDelete,
420
+ value: 'delete',
326
421
  },
327
- { label: 'Back to List', value: '', isButton: true, disabled: false },
422
+ { label: '─────────────────────────', value: 'separator2' },
423
+ { label: '← Back to List', value: 'back' },
328
424
  ];
425
+ // Filter out disabled items for SelectInput
426
+ const selectableItems = editMenuItems.filter(item => {
427
+ if (item.value === 'setDefault' && isDefault)
428
+ return false;
429
+ if (item.value === 'delete' && !canDelete)
430
+ return false;
431
+ return true;
432
+ });
329
433
  return (React.createElement(Box, { flexDirection: "column" },
330
434
  React.createElement(Box, { marginBottom: 1 },
331
435
  React.createElement(Text, { bold: true, color: "green" },
@@ -333,18 +437,7 @@ const ConfigureCommand = ({ onComplete }) => {
333
437
  preset.name)),
334
438
  isDefault && (React.createElement(Box, { marginBottom: 1 },
335
439
  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
- })),
440
+ React.createElement(SelectInput, { items: selectableItems, onSelect: handleEditMenuSelect }),
348
441
  React.createElement(Box, { marginTop: 1 },
349
442
  React.createElement(Text, { dimColor: true },
350
443
  "Press \u2191\u2193 to navigate, Enter to edit/select,",
@@ -366,6 +459,7 @@ const ConfigureCommand = ({ onComplete }) => {
366
459
  label += `\n Args: ${args}`;
367
460
  if (fallback)
368
461
  label += `\n Fallback: ${fallback}`;
462
+ label += `\n Detection: ${formatDetectionStrategy(preset.detectionStrategy)}`;
369
463
  return {
370
464
  label,
371
465
  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,19 @@
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
+ }
17
+ export declare class CodexStateDetector extends BaseStateDetector {
18
+ detectState(terminal: Terminal): SessionState;
19
+ }
@@ -0,0 +1,87 @@
1
+ export function createStateDetector(strategy = 'claude') {
2
+ switch (strategy) {
3
+ case 'claude':
4
+ return new ClaudeStateDetector();
5
+ case 'gemini':
6
+ return new GeminiStateDetector();
7
+ case 'codex':
8
+ return new CodexStateDetector();
9
+ default:
10
+ return new ClaudeStateDetector();
11
+ }
12
+ }
13
+ export class BaseStateDetector {
14
+ getTerminalLines(terminal, maxLines = 30) {
15
+ const buffer = terminal.buffer.active;
16
+ const lines = [];
17
+ // Start from the bottom and work our way up
18
+ for (let i = buffer.length - 1; i >= 0 && lines.length < maxLines; i--) {
19
+ const line = buffer.getLine(i);
20
+ if (line) {
21
+ const text = line.translateToString(true);
22
+ // Skip empty lines at the bottom
23
+ if (lines.length > 0 || text.trim() !== '') {
24
+ lines.unshift(text);
25
+ }
26
+ }
27
+ }
28
+ return lines;
29
+ }
30
+ getTerminalContent(terminal, maxLines = 30) {
31
+ return this.getTerminalLines(terminal, maxLines).join('\n');
32
+ }
33
+ }
34
+ export class ClaudeStateDetector extends BaseStateDetector {
35
+ detectState(terminal) {
36
+ const content = this.getTerminalContent(terminal);
37
+ const lowerContent = content.toLowerCase();
38
+ // Check for waiting prompts with box character
39
+ if (content.includes('│ Do you want') ||
40
+ content.includes('│ Would you like')) {
41
+ return 'waiting_input';
42
+ }
43
+ // Check for busy state
44
+ if (lowerContent.includes('esc to interrupt')) {
45
+ return 'busy';
46
+ }
47
+ // Otherwise idle
48
+ return 'idle';
49
+ }
50
+ }
51
+ // https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
52
+ export class GeminiStateDetector extends BaseStateDetector {
53
+ detectState(terminal) {
54
+ const content = this.getTerminalContent(terminal);
55
+ const lowerContent = content.toLowerCase();
56
+ // Check for waiting prompts with box character
57
+ if (content.includes('│ Apply this change?') ||
58
+ content.includes('│ Allow execution?') ||
59
+ content.includes('│ Do you want to proceed?')) {
60
+ return 'waiting_input';
61
+ }
62
+ // Check for busy state
63
+ if (lowerContent.includes('esc to cancel')) {
64
+ return 'busy';
65
+ }
66
+ // Otherwise idle
67
+ return 'idle';
68
+ }
69
+ }
70
+ export class CodexStateDetector extends BaseStateDetector {
71
+ detectState(terminal) {
72
+ const content = this.getTerminalContent(terminal);
73
+ const lowerContent = content.toLowerCase();
74
+ // Check for waiting prompts
75
+ if (content.includes('│Allow') ||
76
+ content.includes('[y/N]') ||
77
+ content.includes('Press any key')) {
78
+ return 'waiting_input';
79
+ }
80
+ // Check for busy state
81
+ if (lowerContent.includes('press esc')) {
82
+ return 'busy';
83
+ }
84
+ // Otherwise idle
85
+ return 'idle';
86
+ }
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,333 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { ClaudeStateDetector, GeminiStateDetector, CodexStateDetector, } 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
+ });
243
+ describe('CodexStateDetector', () => {
244
+ let detector;
245
+ let terminal;
246
+ const createMockTerminal = (lines) => {
247
+ const buffer = {
248
+ length: lines.length,
249
+ active: {
250
+ length: lines.length,
251
+ getLine: (index) => {
252
+ if (index >= 0 && index < lines.length) {
253
+ return {
254
+ translateToString: () => lines[index],
255
+ };
256
+ }
257
+ return null;
258
+ },
259
+ },
260
+ };
261
+ return { buffer };
262
+ };
263
+ beforeEach(() => {
264
+ detector = new CodexStateDetector();
265
+ });
266
+ it('should detect waiting_input state for │Allow pattern', () => {
267
+ // Arrange
268
+ terminal = createMockTerminal(['Some output', '│Allow execution?', '│ > ']);
269
+ // Act
270
+ const state = detector.detectState(terminal);
271
+ // Assert
272
+ expect(state).toBe('waiting_input');
273
+ });
274
+ it('should detect waiting_input state for [y/N] pattern', () => {
275
+ // Arrange
276
+ terminal = createMockTerminal(['Some output', 'Continue? [y/N]', '> ']);
277
+ // Act
278
+ const state = detector.detectState(terminal);
279
+ // Assert
280
+ expect(state).toBe('waiting_input');
281
+ });
282
+ it('should detect waiting_input state for Press any key pattern', () => {
283
+ // Arrange
284
+ terminal = createMockTerminal([
285
+ 'Some output',
286
+ 'Press any key to continue...',
287
+ ]);
288
+ // Act
289
+ const state = detector.detectState(terminal);
290
+ // Assert
291
+ expect(state).toBe('waiting_input');
292
+ });
293
+ it('should detect busy state for press esc pattern', () => {
294
+ // Arrange
295
+ terminal = createMockTerminal([
296
+ 'Processing...',
297
+ 'press esc to cancel',
298
+ 'Working...',
299
+ ]);
300
+ // Act
301
+ const state = detector.detectState(terminal);
302
+ // Assert
303
+ expect(state).toBe('busy');
304
+ });
305
+ it('should detect busy state for PRESS ESC (uppercase)', () => {
306
+ // Arrange
307
+ terminal = createMockTerminal([
308
+ 'Processing...',
309
+ 'PRESS ESC to stop',
310
+ 'Working...',
311
+ ]);
312
+ // Act
313
+ const state = detector.detectState(terminal);
314
+ // Assert
315
+ expect(state).toBe('busy');
316
+ });
317
+ it('should detect idle state when no patterns match', () => {
318
+ // Arrange
319
+ terminal = createMockTerminal(['Normal output', 'Some message', 'Ready']);
320
+ // Act
321
+ const state = detector.detectState(terminal);
322
+ // Assert
323
+ expect(state).toBe('idle');
324
+ });
325
+ it('should prioritize waiting_input over busy', () => {
326
+ // Arrange
327
+ terminal = createMockTerminal(['press esc to cancel', '[y/N]']);
328
+ // Act
329
+ const state = detector.detectState(terminal);
330
+ // Assert
331
+ expect(state).toBe('waiting_input');
332
+ });
333
+ });
@@ -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' | 'codex';
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.1.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",