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 +26 -4
- package/dist/components/ConfigureCommand.js +153 -68
- package/dist/services/sessionManager.d.ts +1 -4
- package/dist/services/sessionManager.js +9 -30
- 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 +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
# CCManager -
|
|
1
|
+
# CCManager - AI Code Assistant Session Manager
|
|
2
2
|
|
|
3
|
-
CCManager is a TUI application for managing multiple Claude Code
|
|
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
|
|
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
|
|
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
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
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 (
|
|
36
|
-
case
|
|
36
|
+
switch (item.value) {
|
|
37
|
+
case 'name':
|
|
37
38
|
setEditField('name');
|
|
38
39
|
setInputValue(preset.name);
|
|
39
40
|
break;
|
|
40
|
-
case
|
|
41
|
+
case 'command':
|
|
41
42
|
setEditField('command');
|
|
42
43
|
setInputValue(preset.command);
|
|
43
44
|
break;
|
|
44
|
-
case
|
|
45
|
+
case 'args':
|
|
45
46
|
setEditField('args');
|
|
46
47
|
setInputValue(preset.args?.join(' ') || '');
|
|
47
48
|
break;
|
|
48
|
-
case
|
|
49
|
+
case 'fallbackArgs':
|
|
49
50
|
setEditField('fallbackArgs');
|
|
50
51
|
setInputValue(preset.fallbackArgs?.join(' ') || '');
|
|
51
52
|
break;
|
|
52
|
-
case
|
|
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
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
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 (
|
|
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(
|
|
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 ||
|
|
199
|
-
|
|
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
|
|
306
|
-
{
|
|
307
|
-
|
|
308
|
-
|
|
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:
|
|
311
|
-
value:
|
|
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: '
|
|
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(
|
|
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(
|
|
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(
|
|
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,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
|
|
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
|
+
});
|
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>;
|
|
@@ -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[];
|