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 +59 -25
- package/dist/components/ConfigureCommand.js +162 -68
- package/dist/services/sessionManager.d.ts +1 -4
- package/dist/services/sessionManager.js +9 -30
- package/dist/services/stateDetector.d.ts +19 -0
- package/dist/services/stateDetector.js +87 -0
- package/dist/services/stateDetector.test.d.ts +1 -0
- package/dist/services/stateDetector.test.js +333 -0
- package/dist/types/index.d.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
# CCManager -
|
|
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
|
|
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
|
|
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`
|
|
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/
|
|
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
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
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 (
|
|
36
|
-
case
|
|
43
|
+
switch (item.value) {
|
|
44
|
+
case 'name':
|
|
37
45
|
setEditField('name');
|
|
38
46
|
setInputValue(preset.name);
|
|
39
47
|
break;
|
|
40
|
-
case
|
|
48
|
+
case 'command':
|
|
41
49
|
setEditField('command');
|
|
42
50
|
setInputValue(preset.command);
|
|
43
51
|
break;
|
|
44
|
-
case
|
|
52
|
+
case 'args':
|
|
45
53
|
setEditField('args');
|
|
46
54
|
setInputValue(preset.args?.join(' ') || '');
|
|
47
55
|
break;
|
|
48
|
-
case
|
|
56
|
+
case 'fallbackArgs':
|
|
49
57
|
setEditField('fallbackArgs');
|
|
50
58
|
setInputValue(preset.fallbackArgs?.join(' ') || '');
|
|
51
59
|
break;
|
|
52
|
-
case
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
|
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(
|
|
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 (
|
|
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(
|
|
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 ||
|
|
199
|
-
|
|
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
|
|
306
|
-
{
|
|
307
|
-
|
|
308
|
-
|
|
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:
|
|
311
|
-
value:
|
|
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: '
|
|
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(
|
|
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(
|
|
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,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
|
+
});
|
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' | '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[];
|