ccmanager 0.1.8 → 0.1.10
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 +27 -11
- package/dist/components/App.js +8 -2
- package/dist/components/Configuration.js +11 -0
- package/dist/components/ConfigureCommand.d.ts +6 -0
- package/dist/components/ConfigureCommand.js +182 -0
- package/dist/components/DeleteWorktree.js +6 -3
- package/dist/components/Menu.js +3 -1
- package/dist/components/MergeWorktree.js +2 -2
- package/dist/services/configurationManager.d.ts +3 -1
- package/dist/services/configurationManager.js +14 -0
- package/dist/services/sessionManager.d.ts +5 -2
- package/dist/services/sessionManager.js +63 -37
- package/dist/services/sessionManager.test.js +241 -233
- package/dist/services/worktreeService.js +37 -26
- package/dist/types/index.d.ts +10 -2
- package/package.json +1 -1
- package/dist/app.d.ts +0 -6
- package/dist/app.js +0 -57
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ https://github.com/user-attachments/assets/a6d80e73-dc06-4ef8-849d-e3857f6c7024
|
|
|
11
11
|
- Visual status indicators for session states (busy, waiting, idle)
|
|
12
12
|
- Create, merge, and delete worktrees from within the app
|
|
13
13
|
- Configurable keyboard shortcuts
|
|
14
|
+
- Command configuration with automatic fallback support
|
|
14
15
|
- Status change hooks for automation and notifications
|
|
15
16
|
|
|
16
17
|
## Why CCManager over Claude Squad?
|
|
@@ -53,18 +54,8 @@ $ npx ccmanager
|
|
|
53
54
|
|
|
54
55
|
### CCMANAGER_CLAUDE_ARGS
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
⚠️ **Deprecated in v0.1.9**: `CCMANAGER_CLAUDE_ARGS` is no longer supported. Please use the [Command Configuration](#command-configuration) feature instead.
|
|
57
58
|
|
|
58
|
-
```bash
|
|
59
|
-
# Start Claude Code with specific arguments for all sessions
|
|
60
|
-
export CCMANAGER_CLAUDE_ARGS="--resume"
|
|
61
|
-
npx ccmanager
|
|
62
|
-
|
|
63
|
-
# Or set it inline
|
|
64
|
-
CCMANAGER_CLAUDE_ARGS="--resume" npx ccmanager
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
The arguments are applied to all Claude Code sessions started by CCManager.
|
|
68
59
|
|
|
69
60
|
## Keyboard Shortcuts
|
|
70
61
|
|
|
@@ -117,6 +108,31 @@ Note: Shortcuts from `shortcuts.json` will be automatically migrated to `config.
|
|
|
117
108
|
- Ctrl+D
|
|
118
109
|
- Ctrl+[ (equivalent to Escape)
|
|
119
110
|
|
|
111
|
+
|
|
112
|
+
## Command Configuration
|
|
113
|
+
|
|
114
|
+

|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
CCManager supports configuring the command and arguments used to run Claude Code sessions, with automatic fallback options for reliability.
|
|
118
|
+
|
|
119
|
+
### Features
|
|
120
|
+
|
|
121
|
+
- Configure the main command (default: `claude`)
|
|
122
|
+
- Set primary arguments (e.g., `--resume`)
|
|
123
|
+
- Define fallback arguments if the primary configuration fails
|
|
124
|
+
- Automatic retry with no arguments as final fallback
|
|
125
|
+
|
|
126
|
+
### Quick Start
|
|
127
|
+
|
|
128
|
+
1. Navigate to **Configuration** → **Configure Command**
|
|
129
|
+
2. Set your desired arguments (e.g., `--resume` for resuming sessions)
|
|
130
|
+
3. Optionally set fallback arguments
|
|
131
|
+
4. Save changes
|
|
132
|
+
|
|
133
|
+
For detailed configuration options and examples, see [docs/command-config.md](docs/command-config.md).
|
|
134
|
+
|
|
135
|
+
|
|
120
136
|
## Status Change Hooks
|
|
121
137
|
|
|
122
138
|
CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
|
package/dist/components/App.js
CHANGED
|
@@ -46,7 +46,7 @@ const App = () => {
|
|
|
46
46
|
sessionManager.destroy();
|
|
47
47
|
};
|
|
48
48
|
}, [sessionManager]);
|
|
49
|
-
const handleSelectWorktree = (worktree) => {
|
|
49
|
+
const handleSelectWorktree = async (worktree) => {
|
|
50
50
|
// Check if this is the new worktree option
|
|
51
51
|
if (worktree.path === '') {
|
|
52
52
|
setView('new-worktree');
|
|
@@ -76,7 +76,13 @@ const App = () => {
|
|
|
76
76
|
// Get or create session for this worktree
|
|
77
77
|
let session = sessionManager.getSession(worktree.path);
|
|
78
78
|
if (!session) {
|
|
79
|
-
|
|
79
|
+
try {
|
|
80
|
+
session = await sessionManager.createSession(worktree.path);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
setError(`Failed to create session: ${error}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
80
86
|
}
|
|
81
87
|
setActiveSession(session);
|
|
82
88
|
setView('session');
|
|
@@ -4,6 +4,7 @@ import SelectInput from 'ink-select-input';
|
|
|
4
4
|
import ConfigureShortcuts from './ConfigureShortcuts.js';
|
|
5
5
|
import ConfigureHooks from './ConfigureHooks.js';
|
|
6
6
|
import ConfigureWorktree from './ConfigureWorktree.js';
|
|
7
|
+
import ConfigureCommand from './ConfigureCommand.js';
|
|
7
8
|
const Configuration = ({ onComplete }) => {
|
|
8
9
|
const [view, setView] = useState('menu');
|
|
9
10
|
const menuItems = [
|
|
@@ -19,6 +20,10 @@ const Configuration = ({ onComplete }) => {
|
|
|
19
20
|
label: '📁 Configure Worktree Settings',
|
|
20
21
|
value: 'worktree',
|
|
21
22
|
},
|
|
23
|
+
{
|
|
24
|
+
label: '🚀 Configure Command',
|
|
25
|
+
value: 'command',
|
|
26
|
+
},
|
|
22
27
|
{
|
|
23
28
|
label: '← Back to Main Menu',
|
|
24
29
|
value: 'back',
|
|
@@ -37,6 +42,9 @@ const Configuration = ({ onComplete }) => {
|
|
|
37
42
|
else if (item.value === 'worktree') {
|
|
38
43
|
setView('worktree');
|
|
39
44
|
}
|
|
45
|
+
else if (item.value === 'command') {
|
|
46
|
+
setView('command');
|
|
47
|
+
}
|
|
40
48
|
};
|
|
41
49
|
const handleSubMenuComplete = () => {
|
|
42
50
|
setView('menu');
|
|
@@ -50,6 +58,9 @@ const Configuration = ({ onComplete }) => {
|
|
|
50
58
|
if (view === 'worktree') {
|
|
51
59
|
return React.createElement(ConfigureWorktree, { onComplete: handleSubMenuComplete });
|
|
52
60
|
}
|
|
61
|
+
if (view === 'command') {
|
|
62
|
+
return React.createElement(ConfigureCommand, { onComplete: handleSubMenuComplete });
|
|
63
|
+
}
|
|
53
64
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
54
65
|
React.createElement(Box, { marginBottom: 1 },
|
|
55
66
|
React.createElement(Text, { bold: true, color: "green" }, "Configuration")),
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { configurationManager } from '../services/configurationManager.js';
|
|
5
|
+
import { shortcutManager } from '../services/shortcutManager.js';
|
|
6
|
+
const ConfigureCommand = ({ onComplete }) => {
|
|
7
|
+
// Load current configuration once
|
|
8
|
+
const currentConfig = configurationManager.getCommandConfig();
|
|
9
|
+
const [originalConfig] = useState(currentConfig);
|
|
10
|
+
const [config, setConfig] = useState(currentConfig);
|
|
11
|
+
const [editMode, setEditMode] = useState('menu');
|
|
12
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
13
|
+
const [inputValue, setInputValue] = useState('');
|
|
14
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
15
|
+
const menuItems = [
|
|
16
|
+
{
|
|
17
|
+
label: 'Command',
|
|
18
|
+
value: config.command,
|
|
19
|
+
key: 'command',
|
|
20
|
+
isButton: false,
|
|
21
|
+
disabled: false,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: 'Arguments',
|
|
25
|
+
value: config.args?.join(' ') || '(none)',
|
|
26
|
+
key: 'args',
|
|
27
|
+
isButton: false,
|
|
28
|
+
disabled: false,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: 'Fallback Arguments',
|
|
32
|
+
value: config.fallbackArgs?.join(' ') || '(none)',
|
|
33
|
+
key: 'fallbackArgs',
|
|
34
|
+
isButton: false,
|
|
35
|
+
disabled: false,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: hasChanges ? '💾 Save Changes' : '💾 Save Changes (no changes)',
|
|
39
|
+
value: '',
|
|
40
|
+
key: 'save',
|
|
41
|
+
isButton: true,
|
|
42
|
+
disabled: !hasChanges,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: '❌ Exit Without Saving',
|
|
46
|
+
value: '',
|
|
47
|
+
key: 'exit',
|
|
48
|
+
isButton: true,
|
|
49
|
+
disabled: false,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
const handleMenuNavigation = (key) => {
|
|
53
|
+
if (key.upArrow) {
|
|
54
|
+
setSelectedIndex(prev => (prev > 0 ? prev - 1 : menuItems.length - 1));
|
|
55
|
+
}
|
|
56
|
+
else if (key.downArrow) {
|
|
57
|
+
setSelectedIndex(prev => (prev < menuItems.length - 1 ? prev + 1 : 0));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const getInitialInputValue = (key) => {
|
|
61
|
+
switch (key) {
|
|
62
|
+
case 'command':
|
|
63
|
+
return config.command;
|
|
64
|
+
case 'args':
|
|
65
|
+
return config.args?.join(' ') || '';
|
|
66
|
+
case 'fallbackArgs':
|
|
67
|
+
return config.fallbackArgs?.join(' ') || '';
|
|
68
|
+
default:
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const handleMenuItemSelect = () => {
|
|
73
|
+
const selectedItem = menuItems[selectedIndex];
|
|
74
|
+
if (!selectedItem || selectedItem.disabled)
|
|
75
|
+
return;
|
|
76
|
+
switch (selectedItem.key) {
|
|
77
|
+
case 'save':
|
|
78
|
+
configurationManager.setCommandConfig(config);
|
|
79
|
+
onComplete();
|
|
80
|
+
break;
|
|
81
|
+
case 'exit':
|
|
82
|
+
onComplete();
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
if (!selectedItem.isButton) {
|
|
86
|
+
setEditMode(selectedItem.key);
|
|
87
|
+
setInputValue(getInitialInputValue(selectedItem.key));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
useInput((input, key) => {
|
|
92
|
+
// Handle cancel shortcut in any mode
|
|
93
|
+
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
94
|
+
if (editMode === 'menu') {
|
|
95
|
+
onComplete(); // Exit without saving
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
setEditMode('menu');
|
|
99
|
+
setInputValue('');
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Handle menu mode inputs
|
|
104
|
+
if (editMode === 'menu') {
|
|
105
|
+
handleMenuNavigation(key);
|
|
106
|
+
if (key.return) {
|
|
107
|
+
handleMenuItemSelect();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const handleInputSubmit = (value) => {
|
|
112
|
+
let updatedConfig = { ...config };
|
|
113
|
+
if (editMode === 'command') {
|
|
114
|
+
updatedConfig.command = value || 'claude';
|
|
115
|
+
}
|
|
116
|
+
else if (editMode === 'args') {
|
|
117
|
+
// Parse arguments, handling empty string as no arguments
|
|
118
|
+
const args = value.trim() ? value.trim().split(/\s+/) : undefined;
|
|
119
|
+
updatedConfig.args = args;
|
|
120
|
+
}
|
|
121
|
+
else if (editMode === 'fallbackArgs') {
|
|
122
|
+
// Parse fallback arguments, handling empty string as no arguments
|
|
123
|
+
const fallbackArgs = value.trim() ? value.trim().split(/\s+/) : undefined;
|
|
124
|
+
updatedConfig.fallbackArgs = fallbackArgs;
|
|
125
|
+
}
|
|
126
|
+
// Update state only (don't save to file yet)
|
|
127
|
+
setConfig(updatedConfig);
|
|
128
|
+
// Check if there are changes
|
|
129
|
+
const hasChanges = JSON.stringify(updatedConfig) !== JSON.stringify(originalConfig);
|
|
130
|
+
setHasChanges(hasChanges);
|
|
131
|
+
// Return to menu
|
|
132
|
+
setEditMode('menu');
|
|
133
|
+
setInputValue('');
|
|
134
|
+
};
|
|
135
|
+
if (editMode !== 'menu') {
|
|
136
|
+
const titles = {
|
|
137
|
+
command: 'Enter command (e.g., claude):',
|
|
138
|
+
args: 'Enter command arguments (space-separated):',
|
|
139
|
+
fallbackArgs: 'Enter fallback arguments (space-separated):',
|
|
140
|
+
};
|
|
141
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
142
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
143
|
+
React.createElement(Text, { bold: true, color: "green" }, "Configure Command")),
|
|
144
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
145
|
+
React.createElement(Text, null, titles[editMode])),
|
|
146
|
+
React.createElement(Box, null,
|
|
147
|
+
React.createElement(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: editMode === 'args' || editMode === 'fallbackArgs'
|
|
148
|
+
? 'e.g., --resume or leave empty'
|
|
149
|
+
: '' })),
|
|
150
|
+
React.createElement(Box, { marginTop: 1 },
|
|
151
|
+
React.createElement(Text, { dimColor: true },
|
|
152
|
+
"Press Enter to save, ",
|
|
153
|
+
shortcutManager.getShortcutDisplay('cancel'),
|
|
154
|
+
' ',
|
|
155
|
+
"to cancel"))));
|
|
156
|
+
}
|
|
157
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
158
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
159
|
+
React.createElement(Text, { bold: true, color: "green" }, "Configure Command")),
|
|
160
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
161
|
+
React.createElement(Text, { dimColor: true }, "Configure the command and arguments for running code sessions")),
|
|
162
|
+
hasChanges && (React.createElement(Box, { marginBottom: 1 },
|
|
163
|
+
React.createElement(Text, { color: "yellow" }, "\u26A0\uFE0F You have unsaved changes"))),
|
|
164
|
+
React.createElement(Box, { flexDirection: "column" }, menuItems.map((item, index) => {
|
|
165
|
+
const isSelected = selectedIndex === index;
|
|
166
|
+
const isDisabled = item.disabled || false;
|
|
167
|
+
const color = isDisabled ? 'gray' : isSelected ? 'cyan' : undefined;
|
|
168
|
+
return (React.createElement(Box, { key: item.key, marginTop: item.isButton && index > 0 ? 1 : 0 },
|
|
169
|
+
React.createElement(Text, { color: color },
|
|
170
|
+
isSelected ? '> ' : ' ',
|
|
171
|
+
item.isButton ? (React.createElement(Text, { bold: isSelected && !isDisabled, dimColor: isDisabled }, item.label)) : (`${item.label}: ${item.value}`))));
|
|
172
|
+
})),
|
|
173
|
+
React.createElement(Box, { marginTop: 1 },
|
|
174
|
+
React.createElement(Text, { dimColor: true },
|
|
175
|
+
"Press \u2191\u2193 to navigate, Enter to edit,",
|
|
176
|
+
' ',
|
|
177
|
+
shortcutManager.getShortcutDisplay('cancel'),
|
|
178
|
+
" to go back")),
|
|
179
|
+
React.createElement(Box, { marginTop: 1 },
|
|
180
|
+
React.createElement(Text, { dimColor: true }, "Note: If command fails with main args, fallback args will be tried"))));
|
|
181
|
+
};
|
|
182
|
+
export default ConfigureCommand;
|
|
@@ -75,8 +75,9 @@ const DeleteWorktree = ({ onComplete, onCancel, }) => {
|
|
|
75
75
|
React.createElement(Text, null, "You are about to delete the following worktrees:"),
|
|
76
76
|
selectedWorktrees.map(wt => (React.createElement(Text, { key: wt.path, color: "red" },
|
|
77
77
|
"\u2022 ",
|
|
78
|
-
wt.branch.replace('refs/heads/', ''),
|
|
79
|
-
|
|
78
|
+
wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached',
|
|
79
|
+
' ',
|
|
80
|
+
"(",
|
|
80
81
|
wt.path,
|
|
81
82
|
")")))),
|
|
82
83
|
React.createElement(Text, { bold: true }, "This will also delete their branches. Are you sure?")));
|
|
@@ -90,7 +91,9 @@ const DeleteWorktree = ({ onComplete, onCancel, }) => {
|
|
|
90
91
|
worktrees.map((worktree, index) => {
|
|
91
92
|
const isSelected = selectedIndices.has(index);
|
|
92
93
|
const isFocused = index === focusedIndex;
|
|
93
|
-
const branchName = worktree.branch
|
|
94
|
+
const branchName = worktree.branch
|
|
95
|
+
? worktree.branch.replace('refs/heads/', '')
|
|
96
|
+
: 'detached';
|
|
94
97
|
return (React.createElement(Box, { key: worktree.path },
|
|
95
98
|
React.createElement(Text, { color: isFocused ? 'green' : undefined, inverse: isFocused, dimColor: !isFocused && !isSelected },
|
|
96
99
|
isSelected ? '[✓]' : '[ ]',
|
package/dist/components/Menu.js
CHANGED
|
@@ -41,7 +41,9 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
41
41
|
if (session) {
|
|
42
42
|
status = ` [${getStatusDisplay(session.state)}]`;
|
|
43
43
|
}
|
|
44
|
-
const branchName = wt.branch
|
|
44
|
+
const branchName = wt.branch
|
|
45
|
+
? wt.branch.replace('refs/heads/', '')
|
|
46
|
+
: 'detached';
|
|
45
47
|
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
46
48
|
return {
|
|
47
49
|
label: `${branchName}${isMain}${status}`,
|
|
@@ -16,9 +16,9 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
16
16
|
const loadedWorktrees = worktreeService.getWorktrees();
|
|
17
17
|
// Create branch items for selection
|
|
18
18
|
const items = loadedWorktrees.map(wt => ({
|
|
19
|
-
label: wt.branch.replace('refs/heads/', '') +
|
|
19
|
+
label: (wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached') +
|
|
20
20
|
(wt.isMainWorktree ? ' (main)' : ''),
|
|
21
|
-
value: wt.branch.replace('refs/heads/', ''),
|
|
21
|
+
value: wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached',
|
|
22
22
|
}));
|
|
23
23
|
setBranchItems(items);
|
|
24
24
|
}, []);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigurationData, StatusHookConfig, ShortcutConfig, WorktreeConfig } from '../types/index.js';
|
|
1
|
+
import { ConfigurationData, StatusHookConfig, ShortcutConfig, WorktreeConfig, CommandConfig } from '../types/index.js';
|
|
2
2
|
export declare class ConfigurationManager {
|
|
3
3
|
private configPath;
|
|
4
4
|
private legacyShortcutsPath;
|
|
@@ -15,5 +15,7 @@ export declare class ConfigurationManager {
|
|
|
15
15
|
setConfiguration(config: ConfigurationData): void;
|
|
16
16
|
getWorktreeConfig(): WorktreeConfig;
|
|
17
17
|
setWorktreeConfig(worktreeConfig: WorktreeConfig): void;
|
|
18
|
+
getCommandConfig(): CommandConfig;
|
|
19
|
+
setCommandConfig(commandConfig: CommandConfig): void;
|
|
18
20
|
}
|
|
19
21
|
export declare const configurationManager: ConfigurationManager;
|
|
@@ -68,6 +68,11 @@ export class ConfigurationManager {
|
|
|
68
68
|
autoDirectory: false,
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
+
if (!this.config.command) {
|
|
72
|
+
this.config.command = {
|
|
73
|
+
command: 'claude',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
71
76
|
}
|
|
72
77
|
migrateLegacyShortcuts() {
|
|
73
78
|
if (existsSync(this.legacyShortcutsPath)) {
|
|
@@ -125,5 +130,14 @@ export class ConfigurationManager {
|
|
|
125
130
|
this.config.worktree = worktreeConfig;
|
|
126
131
|
this.saveConfig();
|
|
127
132
|
}
|
|
133
|
+
getCommandConfig() {
|
|
134
|
+
return (this.config.command || {
|
|
135
|
+
command: 'claude',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
setCommandConfig(commandConfig) {
|
|
139
|
+
this.config.command = commandConfig;
|
|
140
|
+
this.saveConfig();
|
|
141
|
+
}
|
|
128
142
|
}
|
|
129
143
|
export const configurationManager = new ConfigurationManager();
|
|
@@ -6,11 +6,14 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
6
6
|
sessions: Map<string, Session>;
|
|
7
7
|
private waitingWithBottomBorder;
|
|
8
8
|
private busyTimers;
|
|
9
|
-
private
|
|
9
|
+
private spawn;
|
|
10
10
|
detectTerminalState(terminal: InstanceType<typeof Terminal>): SessionState;
|
|
11
11
|
constructor();
|
|
12
|
-
createSession(worktreePath: string): Session
|
|
12
|
+
createSession(worktreePath: string): Promise<Session>;
|
|
13
|
+
private setupDataHandler;
|
|
14
|
+
private setupExitHandler;
|
|
13
15
|
private setupBackgroundHandler;
|
|
16
|
+
private cleanupSession;
|
|
14
17
|
getSession(worktreePath: string): Session | undefined;
|
|
15
18
|
setSessionActive(worktreePath: string, active: boolean): void;
|
|
16
19
|
destroySession(worktreePath: string): void;
|
|
@@ -6,19 +6,15 @@ import { configurationManager } from './configurationManager.js';
|
|
|
6
6
|
import { WorktreeService } from './worktreeService.js';
|
|
7
7
|
const { Terminal } = pkg;
|
|
8
8
|
export class SessionManager extends EventEmitter {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
.
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '') // Control characters except newline (\x0A)
|
|
19
|
-
.replace(/\r/g, '') // Carriage returns
|
|
20
|
-
.replace(/^[0-9;]+m/gm, '') // Orphaned color codes at line start
|
|
21
|
-
.replace(/[0-9]+;[0-9]+;[0-9;]+m/g, ''); // Orphaned 24-bit color codes
|
|
9
|
+
async spawn(command, args, worktreePath) {
|
|
10
|
+
const spawnOptions = {
|
|
11
|
+
name: 'xterm-color',
|
|
12
|
+
cols: process.stdout.columns || 80,
|
|
13
|
+
rows: process.stdout.rows || 24,
|
|
14
|
+
cwd: worktreePath,
|
|
15
|
+
env: process.env,
|
|
16
|
+
};
|
|
17
|
+
return spawn(command, args, spawnOptions);
|
|
22
18
|
}
|
|
23
19
|
detectTerminalState(terminal) {
|
|
24
20
|
// Get the last 30 lines from the terminal buffer
|
|
@@ -72,7 +68,7 @@ export class SessionManager extends EventEmitter {
|
|
|
72
68
|
});
|
|
73
69
|
this.sessions = new Map();
|
|
74
70
|
}
|
|
75
|
-
createSession(worktreePath) {
|
|
71
|
+
async createSession(worktreePath) {
|
|
76
72
|
// Check if session already exists
|
|
77
73
|
const existing = this.sessions.get(worktreePath);
|
|
78
74
|
if (existing) {
|
|
@@ -81,17 +77,12 @@ export class SessionManager extends EventEmitter {
|
|
|
81
77
|
const id = `session-${Date.now()}-${Math.random()
|
|
82
78
|
.toString(36)
|
|
83
79
|
.substr(2, 9)}`;
|
|
84
|
-
//
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
cols: process.stdout.columns || 80,
|
|
91
|
-
rows: process.stdout.rows || 24,
|
|
92
|
-
cwd: worktreePath,
|
|
93
|
-
env: process.env,
|
|
94
|
-
});
|
|
80
|
+
// Get command configuration
|
|
81
|
+
const commandConfig = configurationManager.getCommandConfig();
|
|
82
|
+
const command = commandConfig.command || 'claude';
|
|
83
|
+
const args = commandConfig.args || [];
|
|
84
|
+
// Spawn the process with fallback support
|
|
85
|
+
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
95
86
|
// Create virtual terminal for state detection
|
|
96
87
|
const terminal = new Terminal({
|
|
97
88
|
cols: process.stdout.columns || 80,
|
|
@@ -108,6 +99,8 @@ export class SessionManager extends EventEmitter {
|
|
|
108
99
|
lastActivity: new Date(),
|
|
109
100
|
isActive: false,
|
|
110
101
|
terminal,
|
|
102
|
+
isPrimaryCommand: true,
|
|
103
|
+
commandConfig,
|
|
111
104
|
};
|
|
112
105
|
// Set up persistent background data handler for state detection
|
|
113
106
|
this.setupBackgroundHandler(session);
|
|
@@ -115,7 +108,7 @@ export class SessionManager extends EventEmitter {
|
|
|
115
108
|
this.emit('sessionCreated', session);
|
|
116
109
|
return session;
|
|
117
110
|
}
|
|
118
|
-
|
|
111
|
+
setupDataHandler(session) {
|
|
119
112
|
// This handler always runs for all data
|
|
120
113
|
session.process.onData((data) => {
|
|
121
114
|
// Write data to virtual terminal
|
|
@@ -138,6 +131,37 @@ export class SessionManager extends EventEmitter {
|
|
|
138
131
|
this.emit('sessionData', session, data);
|
|
139
132
|
}
|
|
140
133
|
});
|
|
134
|
+
}
|
|
135
|
+
setupExitHandler(session) {
|
|
136
|
+
session.process.onExit(async (e) => {
|
|
137
|
+
// Check if we should attempt fallback
|
|
138
|
+
if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
|
|
139
|
+
try {
|
|
140
|
+
// Spawn fallback process
|
|
141
|
+
const fallbackProcess = await this.spawn(session.commandConfig?.command || 'claude', session.commandConfig?.fallbackArgs || [], session.worktreePath);
|
|
142
|
+
// Replace the process
|
|
143
|
+
session.process = fallbackProcess;
|
|
144
|
+
session.isPrimaryCommand = false;
|
|
145
|
+
// Setup handlers for the new process (data and exit only)
|
|
146
|
+
this.setupDataHandler(session);
|
|
147
|
+
this.setupExitHandler(session);
|
|
148
|
+
// Emit event to notify process replacement
|
|
149
|
+
this.emit('sessionProcessReplaced', session);
|
|
150
|
+
}
|
|
151
|
+
catch (_error) {
|
|
152
|
+
// Fallback failed, proceed with cleanup
|
|
153
|
+
this.cleanupSession(session);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// No fallback needed or possible, cleanup
|
|
158
|
+
this.cleanupSession(session);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
setupBackgroundHandler(session) {
|
|
163
|
+
// Setup data handler
|
|
164
|
+
this.setupDataHandler(session);
|
|
141
165
|
// Set up interval-based state detection
|
|
142
166
|
session.stateCheckInterval = setInterval(() => {
|
|
143
167
|
const oldState = session.state;
|
|
@@ -148,17 +172,19 @@ export class SessionManager extends EventEmitter {
|
|
|
148
172
|
this.emit('sessionStateChanged', session);
|
|
149
173
|
}
|
|
150
174
|
}, 100); // Check every 100ms
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
session.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
// Setup exit handler
|
|
176
|
+
this.setupExitHandler(session);
|
|
177
|
+
}
|
|
178
|
+
cleanupSession(session) {
|
|
179
|
+
// Clear the state check interval
|
|
180
|
+
if (session.stateCheckInterval) {
|
|
181
|
+
clearInterval(session.stateCheckInterval);
|
|
182
|
+
}
|
|
183
|
+
// Update state to idle before destroying
|
|
184
|
+
session.state = 'idle';
|
|
185
|
+
this.emit('sessionStateChanged', session);
|
|
186
|
+
this.destroySession(session.worktreePath);
|
|
187
|
+
this.emit('sessionExit', session);
|
|
162
188
|
}
|
|
163
189
|
getSession(worktreePath) {
|
|
164
190
|
return this.sessions.get(worktreePath);
|
|
@@ -1,252 +1,260 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { SessionManager } from './sessionManager.js';
|
|
3
|
+
import { configurationManager } from './configurationManager.js';
|
|
4
|
+
import { spawn } from 'node-pty';
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
// Mock node-pty
|
|
7
|
+
vi.mock('node-pty');
|
|
8
|
+
// Mock configuration manager
|
|
9
|
+
vi.mock('./configurationManager.js', () => ({
|
|
10
|
+
configurationManager: {
|
|
11
|
+
getCommandConfig: vi.fn(),
|
|
12
|
+
getStatusHooks: vi.fn(() => ({})),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
// Mock Terminal
|
|
16
|
+
vi.mock('@xterm/headless', () => ({
|
|
17
|
+
default: {
|
|
18
|
+
Terminal: vi.fn().mockImplementation(() => ({
|
|
19
|
+
buffer: {
|
|
20
|
+
active: {
|
|
21
|
+
length: 0,
|
|
22
|
+
getLine: vi.fn(),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
write: vi.fn(),
|
|
26
|
+
})),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
// Create a mock IPty class
|
|
30
|
+
class MockPty extends EventEmitter {
|
|
31
|
+
constructor() {
|
|
32
|
+
super(...arguments);
|
|
33
|
+
Object.defineProperty(this, "kill", {
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
value: vi.fn()
|
|
38
|
+
});
|
|
39
|
+
Object.defineProperty(this, "resize", {
|
|
40
|
+
enumerable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
writable: true,
|
|
43
|
+
value: vi.fn()
|
|
44
|
+
});
|
|
45
|
+
Object.defineProperty(this, "write", {
|
|
46
|
+
enumerable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
writable: true,
|
|
49
|
+
value: vi.fn()
|
|
50
|
+
});
|
|
51
|
+
Object.defineProperty(this, "onData", {
|
|
52
|
+
enumerable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
value: vi.fn((callback) => {
|
|
56
|
+
this.on('data', callback);
|
|
57
|
+
})
|
|
58
|
+
});
|
|
59
|
+
Object.defineProperty(this, "onExit", {
|
|
60
|
+
enumerable: true,
|
|
61
|
+
configurable: true,
|
|
62
|
+
writable: true,
|
|
63
|
+
value: vi.fn((callback) => {
|
|
64
|
+
this.on('exit', callback);
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
3
69
|
describe('SessionManager', () => {
|
|
4
70
|
let sessionManager;
|
|
71
|
+
let mockPty;
|
|
5
72
|
beforeEach(() => {
|
|
6
|
-
sessionManager = new SessionManager();
|
|
7
73
|
vi.clearAllMocks();
|
|
8
|
-
});
|
|
9
|
-
// TODO: Update tests for new xterm-based state detection
|
|
10
|
-
it('should create session manager', () => {
|
|
11
|
-
expect(sessionManager).toBeDefined();
|
|
12
|
-
expect(sessionManager.sessions).toBeDefined();
|
|
13
|
-
});
|
|
14
|
-
});
|
|
15
|
-
/*
|
|
16
|
-
describe('SessionManager', () => {
|
|
17
|
-
let sessionManager: SessionManager;
|
|
18
|
-
const mockSessionId = 'test-session-123';
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
74
|
sessionManager = new SessionManager();
|
|
22
|
-
|
|
75
|
+
mockPty = new MockPty();
|
|
23
76
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
sessionManager.destroy();
|
|
79
|
+
});
|
|
80
|
+
describe('createSession with command configuration', () => {
|
|
81
|
+
it('should create session with default command when no args configured', async () => {
|
|
82
|
+
// Setup mock configuration
|
|
83
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
84
|
+
command: 'claude',
|
|
85
|
+
});
|
|
86
|
+
// Setup spawn mock
|
|
87
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
88
|
+
// Create session
|
|
89
|
+
await sessionManager.createSession('/test/worktree');
|
|
90
|
+
// Verify spawn was called with correct arguments
|
|
91
|
+
expect(spawn).toHaveBeenCalledWith('claude', [], {
|
|
92
|
+
name: 'xterm-color',
|
|
93
|
+
cols: expect.any(Number),
|
|
94
|
+
rows: expect.any(Number),
|
|
95
|
+
cwd: '/test/worktree',
|
|
96
|
+
env: process.env,
|
|
97
|
+
});
|
|
98
|
+
// Session creation verified by spawn being called
|
|
38
99
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
100
|
+
it('should create session with configured arguments', async () => {
|
|
101
|
+
// Setup mock configuration with args
|
|
102
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
103
|
+
command: 'claude',
|
|
104
|
+
args: ['--resume', '--model', 'opus'],
|
|
105
|
+
});
|
|
106
|
+
// Setup spawn mock
|
|
107
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
108
|
+
// Create session
|
|
109
|
+
await sessionManager.createSession('/test/worktree');
|
|
110
|
+
// Verify spawn was called with configured arguments
|
|
111
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--model', 'opus'], expect.objectContaining({
|
|
112
|
+
cwd: '/test/worktree',
|
|
113
|
+
}));
|
|
114
|
+
// Session creation verified by spawn being called
|
|
52
115
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
116
|
+
it('should use fallback args when main command exits with code 1', async () => {
|
|
117
|
+
// Setup mock configuration with fallback
|
|
118
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
119
|
+
command: 'claude',
|
|
120
|
+
args: ['--invalid-flag'],
|
|
121
|
+
fallbackArgs: ['--resume'],
|
|
122
|
+
});
|
|
123
|
+
// First spawn attempt - will exit with code 1
|
|
124
|
+
const firstMockPty = new MockPty();
|
|
125
|
+
// Second spawn attempt - succeeds
|
|
126
|
+
const secondMockPty = new MockPty();
|
|
127
|
+
vi.mocked(spawn)
|
|
128
|
+
.mockReturnValueOnce(firstMockPty)
|
|
129
|
+
.mockReturnValueOnce(secondMockPty);
|
|
130
|
+
// Create session
|
|
131
|
+
const session = await sessionManager.createSession('/test/worktree');
|
|
132
|
+
// Verify initial spawn
|
|
133
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
135
|
+
// Simulate exit with code 1 on first attempt
|
|
136
|
+
firstMockPty.emit('exit', { exitCode: 1 });
|
|
137
|
+
// Wait for fallback to occur
|
|
138
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
139
|
+
// Verify fallback spawn was called
|
|
140
|
+
expect(spawn).toHaveBeenCalledTimes(2);
|
|
141
|
+
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
142
|
+
// Verify session process was replaced
|
|
143
|
+
expect(session.process).toBe(secondMockPty);
|
|
144
|
+
expect(session.isPrimaryCommand).toBe(false);
|
|
67
145
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
vi.mocked(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
// Now test the bottom border appearing
|
|
83
|
-
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
|
|
84
|
-
const newState = sessionManager.detectSessionState(
|
|
85
|
-
cleanData,
|
|
86
|
-
currentState,
|
|
87
|
-
mockSessionId,
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
expect(newState).toBe('waiting_input');
|
|
146
|
+
it('should throw error when spawn fails and no fallback configured', async () => {
|
|
147
|
+
// Setup mock configuration without fallback
|
|
148
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
149
|
+
command: 'claude',
|
|
150
|
+
args: ['--invalid-flag'],
|
|
151
|
+
});
|
|
152
|
+
// Mock spawn to throw error
|
|
153
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
154
|
+
throw new Error('spawn failed');
|
|
155
|
+
});
|
|
156
|
+
// Expect createSession to throw
|
|
157
|
+
await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('spawn failed');
|
|
91
158
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
159
|
+
it('should handle custom command configuration', async () => {
|
|
160
|
+
// Setup mock configuration with custom command
|
|
161
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
162
|
+
command: 'my-custom-claude',
|
|
163
|
+
args: ['--config', '/path/to/config'],
|
|
164
|
+
});
|
|
165
|
+
// Setup spawn mock
|
|
166
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
167
|
+
// Create session
|
|
168
|
+
await sessionManager.createSession('/test/worktree');
|
|
169
|
+
// Verify spawn was called with custom command
|
|
170
|
+
expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
|
|
171
|
+
cwd: '/test/worktree',
|
|
172
|
+
}));
|
|
173
|
+
// Session creation verified by spawn being called
|
|
105
174
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
175
|
+
it('should not use fallback if main command succeeds', async () => {
|
|
176
|
+
// Setup mock configuration with fallback
|
|
177
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
178
|
+
command: 'claude',
|
|
179
|
+
args: ['--resume'],
|
|
180
|
+
fallbackArgs: ['--other-flag'],
|
|
181
|
+
});
|
|
182
|
+
// Setup spawn mock - process doesn't exit early
|
|
183
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
184
|
+
// Create session
|
|
185
|
+
await sessionManager.createSession('/test/worktree');
|
|
186
|
+
// Wait a bit to ensure no early exit
|
|
187
|
+
await new Promise(resolve => setTimeout(resolve, 600));
|
|
188
|
+
// Verify only one spawn attempt
|
|
189
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(spawn).toHaveBeenCalledWith('claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
191
|
+
// Session creation verified by spawn being called
|
|
120
192
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
193
|
+
it('should return existing session if already created', async () => {
|
|
194
|
+
// Setup mock configuration
|
|
195
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
196
|
+
command: 'claude',
|
|
197
|
+
});
|
|
198
|
+
// Setup spawn mock
|
|
199
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
200
|
+
// Create session twice
|
|
201
|
+
const session1 = await sessionManager.createSession('/test/worktree');
|
|
202
|
+
const session2 = await sessionManager.createSession('/test/worktree');
|
|
203
|
+
// Should return the same session
|
|
204
|
+
expect(session1).toBe(session2);
|
|
205
|
+
// Spawn should only be called once
|
|
206
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
134
207
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
// Now transition to busy
|
|
150
|
-
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
151
|
-
const newState = sessionManager.detectSessionState(
|
|
152
|
-
cleanData,
|
|
153
|
-
currentState,
|
|
154
|
-
mockSessionId,
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
expect(newState).toBe('busy');
|
|
208
|
+
it('should throw error when spawn fails with fallback args', async () => {
|
|
209
|
+
// Setup mock configuration with fallback
|
|
210
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
211
|
+
command: 'nonexistent-command',
|
|
212
|
+
args: ['--flag1'],
|
|
213
|
+
fallbackArgs: ['--flag2'],
|
|
214
|
+
});
|
|
215
|
+
// Mock spawn to always throw error
|
|
216
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
217
|
+
throw new Error('Command not found');
|
|
218
|
+
});
|
|
219
|
+
// Expect createSession to throw the original error
|
|
220
|
+
await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('Command not found');
|
|
158
221
|
});
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
// Add the session to the manager
|
|
176
|
-
sessionManager.sessions.set(mockWorktreePath, mockSession);
|
|
177
|
-
|
|
178
|
-
// Mock the EventEmitter emit method
|
|
179
|
-
const emitSpy = vi.spyOn(sessionManager, 'emit');
|
|
180
|
-
|
|
181
|
-
// First call with no esc to interrupt should maintain busy state
|
|
182
|
-
const cleanData = 'Some regular output text';
|
|
183
|
-
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
184
|
-
|
|
185
|
-
const newState = sessionManager.detectSessionState(
|
|
186
|
-
cleanData,
|
|
187
|
-
'busy',
|
|
188
|
-
mockWorktreePath,
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
expect(newState).toBe('busy');
|
|
192
|
-
|
|
193
|
-
// Wait for timer to fire (500ms + buffer)
|
|
194
|
-
await new Promise(resolve => setTimeout(resolve, 600));
|
|
195
|
-
|
|
196
|
-
// Check that the session state was changed to idle
|
|
197
|
-
expect(mockSession.state).toBe('idle');
|
|
198
|
-
expect(emitSpy).toHaveBeenCalledWith('sessionStateChanged', mockSession);
|
|
222
|
+
});
|
|
223
|
+
describe('session lifecycle', () => {
|
|
224
|
+
it('should destroy session and clean up resources', async () => {
|
|
225
|
+
// Setup
|
|
226
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
227
|
+
command: 'claude',
|
|
228
|
+
});
|
|
229
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
230
|
+
// Create and destroy session
|
|
231
|
+
await sessionManager.createSession('/test/worktree');
|
|
232
|
+
sessionManager.destroySession('/test/worktree');
|
|
233
|
+
// Verify cleanup
|
|
234
|
+
expect(mockPty.kill).toHaveBeenCalled();
|
|
235
|
+
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
199
236
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
222
|
-
|
|
223
|
-
const newState1 = sessionManager.detectSessionState(
|
|
224
|
-
cleanData1,
|
|
225
|
-
'busy',
|
|
226
|
-
mockWorktreePath,
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
expect(newState1).toBe('busy');
|
|
230
|
-
|
|
231
|
-
// Wait 200ms (less than timer duration)
|
|
232
|
-
await new Promise(resolve => setTimeout(resolve, 200));
|
|
233
|
-
|
|
234
|
-
// Second call with esc to interrupt should cancel timer and keep busy
|
|
235
|
-
const cleanData2 = 'Running... Press ESC to interrupt';
|
|
236
|
-
const newState2 = sessionManager.detectSessionState(
|
|
237
|
-
cleanData2,
|
|
238
|
-
'busy',
|
|
239
|
-
mockWorktreePath,
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
expect(newState2).toBe('busy');
|
|
243
|
-
|
|
244
|
-
// Wait another 400ms (total 600ms, more than timer duration)
|
|
245
|
-
await new Promise(resolve => setTimeout(resolve, 400));
|
|
246
|
-
|
|
247
|
-
// State should still be busy because timer was cancelled
|
|
248
|
-
expect(mockSession.state).toBe('busy');
|
|
237
|
+
it('should handle session exit event', async () => {
|
|
238
|
+
// Setup
|
|
239
|
+
vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
|
|
240
|
+
command: 'claude',
|
|
241
|
+
});
|
|
242
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
243
|
+
// Track session exit event
|
|
244
|
+
let exitedSession = null;
|
|
245
|
+
sessionManager.on('sessionExit', (session) => {
|
|
246
|
+
exitedSession = session;
|
|
247
|
+
});
|
|
248
|
+
// Create session
|
|
249
|
+
const createdSession = await sessionManager.createSession('/test/worktree');
|
|
250
|
+
// Simulate process exit after successful creation
|
|
251
|
+
setTimeout(() => {
|
|
252
|
+
mockPty.emit('exit', { exitCode: 0 });
|
|
253
|
+
}, 600); // After early exit timeout
|
|
254
|
+
// Wait for exit event
|
|
255
|
+
await new Promise(resolve => setTimeout(resolve, 700));
|
|
256
|
+
expect(exitedSession).toBe(createdSession);
|
|
257
|
+
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
249
258
|
});
|
|
250
259
|
});
|
|
251
260
|
});
|
|
252
|
-
*/
|
|
@@ -42,32 +42,41 @@ export class WorktreeService {
|
|
|
42
42
|
});
|
|
43
43
|
const worktrees = [];
|
|
44
44
|
const lines = output.trim().split('\n');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
worktrees.push(currentWorktree);
|
|
50
|
-
}
|
|
51
|
-
currentWorktree = {
|
|
52
|
-
path: line.substring(9),
|
|
53
|
-
isMainWorktree: false,
|
|
54
|
-
hasSession: false,
|
|
55
|
-
};
|
|
45
|
+
const parseWorktree = (lines, startIndex) => {
|
|
46
|
+
const worktreeLine = lines[startIndex];
|
|
47
|
+
if (!worktreeLine?.startsWith('worktree ')) {
|
|
48
|
+
return [null, startIndex];
|
|
56
49
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
const worktree = {
|
|
51
|
+
path: worktreeLine.substring(9),
|
|
52
|
+
isMainWorktree: false,
|
|
53
|
+
hasSession: false,
|
|
54
|
+
};
|
|
55
|
+
let i = startIndex + 1;
|
|
56
|
+
while (i < lines.length &&
|
|
57
|
+
lines[i] &&
|
|
58
|
+
!lines[i].startsWith('worktree ')) {
|
|
59
|
+
const line = lines[i];
|
|
60
|
+
if (line && line.startsWith('branch ')) {
|
|
61
|
+
const branch = line.substring(7);
|
|
62
|
+
worktree.branch = branch.startsWith('refs/heads/')
|
|
63
|
+
? branch.substring(11)
|
|
64
|
+
: branch;
|
|
62
65
|
}
|
|
63
|
-
|
|
66
|
+
else if (line === 'bare') {
|
|
67
|
+
worktree.isMainWorktree = true;
|
|
68
|
+
}
|
|
69
|
+
i++;
|
|
64
70
|
}
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
return [worktree, i];
|
|
72
|
+
};
|
|
73
|
+
let index = 0;
|
|
74
|
+
while (index < lines.length) {
|
|
75
|
+
const [worktree, nextIndex] = parseWorktree(lines, index);
|
|
76
|
+
if (worktree) {
|
|
77
|
+
worktrees.push(worktree);
|
|
67
78
|
}
|
|
68
|
-
|
|
69
|
-
if (currentWorktree.path) {
|
|
70
|
-
worktrees.push(currentWorktree);
|
|
79
|
+
index = nextIndex > index ? nextIndex : index + 1;
|
|
71
80
|
}
|
|
72
81
|
// Mark the first worktree as main if none are marked
|
|
73
82
|
if (worktrees.length > 0 && !worktrees.some(w => w.isMainWorktree)) {
|
|
@@ -224,7 +233,9 @@ export class WorktreeService {
|
|
|
224
233
|
encoding: 'utf8',
|
|
225
234
|
});
|
|
226
235
|
// Delete the branch if it exists
|
|
227
|
-
const branchName = worktree.branch
|
|
236
|
+
const branchName = worktree.branch
|
|
237
|
+
? worktree.branch.replace('refs/heads/', '')
|
|
238
|
+
: 'detached';
|
|
228
239
|
try {
|
|
229
240
|
execSync(`git branch -D "${branchName}"`, {
|
|
230
241
|
cwd: this.rootPath,
|
|
@@ -248,7 +259,7 @@ export class WorktreeService {
|
|
|
248
259
|
try {
|
|
249
260
|
// Get worktrees to find the target worktree path
|
|
250
261
|
const worktrees = this.getWorktrees();
|
|
251
|
-
const targetWorktree = worktrees.find(wt => wt.branch.replace('refs/heads/', '') === targetBranch);
|
|
262
|
+
const targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === targetBranch);
|
|
252
263
|
if (!targetWorktree) {
|
|
253
264
|
return {
|
|
254
265
|
success: false,
|
|
@@ -258,7 +269,7 @@ export class WorktreeService {
|
|
|
258
269
|
// Perform the merge or rebase in the target worktree
|
|
259
270
|
if (useRebase) {
|
|
260
271
|
// For rebase, we need to checkout source branch and rebase it onto target
|
|
261
|
-
const sourceWorktree = worktrees.find(wt => wt.branch.replace('refs/heads/', '') === sourceBranch);
|
|
272
|
+
const sourceWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === sourceBranch);
|
|
262
273
|
if (!sourceWorktree) {
|
|
263
274
|
return {
|
|
264
275
|
success: false,
|
|
@@ -300,7 +311,7 @@ export class WorktreeService {
|
|
|
300
311
|
try {
|
|
301
312
|
// Get worktrees to find the worktree by branch
|
|
302
313
|
const worktrees = this.getWorktrees();
|
|
303
|
-
const worktree = worktrees.find(wt => wt.branch.replace('refs/heads/', '') === branch);
|
|
314
|
+
const worktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === branch);
|
|
304
315
|
if (!worktree) {
|
|
305
316
|
return {
|
|
306
317
|
success: false,
|
package/dist/types/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type Terminal = InstanceType<typeof pkg.Terminal>;
|
|
|
4
4
|
export type SessionState = 'idle' | 'busy' | 'waiting_input';
|
|
5
5
|
export interface Worktree {
|
|
6
6
|
path: string;
|
|
7
|
-
branch
|
|
7
|
+
branch?: string;
|
|
8
8
|
isMainWorktree: boolean;
|
|
9
9
|
hasSession: boolean;
|
|
10
10
|
}
|
|
@@ -19,10 +19,12 @@ export interface Session {
|
|
|
19
19
|
isActive: boolean;
|
|
20
20
|
terminal: Terminal;
|
|
21
21
|
stateCheckInterval?: NodeJS.Timeout;
|
|
22
|
+
isPrimaryCommand?: boolean;
|
|
23
|
+
commandConfig?: CommandConfig;
|
|
22
24
|
}
|
|
23
25
|
export interface SessionManager {
|
|
24
26
|
sessions: Map<string, Session>;
|
|
25
|
-
createSession(worktreePath: string): Session
|
|
27
|
+
createSession(worktreePath: string): Promise<Session>;
|
|
26
28
|
getSession(worktreePath: string): Session | undefined;
|
|
27
29
|
destroySession(worktreePath: string): void;
|
|
28
30
|
getAllSessions(): Session[];
|
|
@@ -51,8 +53,14 @@ export interface WorktreeConfig {
|
|
|
51
53
|
autoDirectory: boolean;
|
|
52
54
|
autoDirectoryPattern?: string;
|
|
53
55
|
}
|
|
56
|
+
export interface CommandConfig {
|
|
57
|
+
command: string;
|
|
58
|
+
args?: string[];
|
|
59
|
+
fallbackArgs?: string[];
|
|
60
|
+
}
|
|
54
61
|
export interface ConfigurationData {
|
|
55
62
|
shortcuts?: ShortcutConfig;
|
|
56
63
|
statusHooks?: StatusHookConfig;
|
|
57
64
|
worktree?: WorktreeConfig;
|
|
65
|
+
command?: CommandConfig;
|
|
58
66
|
}
|
package/package.json
CHANGED
package/dist/app.d.ts
DELETED
package/dist/app.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
3
|
-
import { spawn } from 'node-pty';
|
|
4
|
-
const App = ({ onReturnToMenu }) => {
|
|
5
|
-
const { exit } = useApp();
|
|
6
|
-
const { stdout } = useStdout();
|
|
7
|
-
const [pty, setPty] = useState(null);
|
|
8
|
-
const [showMenu, setShowMenu] = useState(false);
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
const ptyProcess = spawn('claude', [], {
|
|
11
|
-
name: 'xterm-color',
|
|
12
|
-
cols: process.stdout.columns || 80,
|
|
13
|
-
rows: process.stdout.rows || 24,
|
|
14
|
-
cwd: process.cwd(),
|
|
15
|
-
env: process.env,
|
|
16
|
-
});
|
|
17
|
-
ptyProcess.onData((data) => {
|
|
18
|
-
if (stdout) {
|
|
19
|
-
stdout.write(data);
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
ptyProcess.onExit(() => {
|
|
23
|
-
exit();
|
|
24
|
-
});
|
|
25
|
-
setPty(ptyProcess);
|
|
26
|
-
if (stdout) {
|
|
27
|
-
stdout.on('resize', () => {
|
|
28
|
-
ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
return () => {
|
|
32
|
-
ptyProcess.kill();
|
|
33
|
-
};
|
|
34
|
-
}, [exit, stdout]);
|
|
35
|
-
useInput((char, key) => {
|
|
36
|
-
if (!pty)
|
|
37
|
-
return;
|
|
38
|
-
if (key.ctrl && char === 'e') {
|
|
39
|
-
if (onReturnToMenu) {
|
|
40
|
-
onReturnToMenu();
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
setShowMenu(true);
|
|
44
|
-
}
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
// if (char) {
|
|
48
|
-
// pty.write(char);
|
|
49
|
-
// }
|
|
50
|
-
});
|
|
51
|
-
if (showMenu) {
|
|
52
|
-
return (React.createElement(Box, { flexDirection: "column" },
|
|
53
|
-
React.createElement(Text, { color: "green" }, "Press Ctrl+E to return to menu")));
|
|
54
|
-
}
|
|
55
|
-
return null;
|
|
56
|
-
};
|
|
57
|
-
export default App;
|