ccmanager 3.10.0 → 3.11.1
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/dist/components/App.js +154 -45
- package/dist/components/App.test.js +92 -3
- package/dist/components/Menu.recent-projects.test.js +19 -19
- package/dist/components/NewWorktree.d.ts +20 -1
- package/dist/components/NewWorktree.js +103 -56
- package/dist/components/NewWorktree.test.js +17 -4
- package/dist/components/TextInputWrapper.d.ts +0 -3
- package/dist/components/TextInputWrapper.js +120 -11
- package/dist/services/sessionManager.d.ts +3 -2
- package/dist/services/sessionManager.js +37 -40
- package/dist/services/sessionManager.test.js +26 -0
- package/dist/services/worktreeNameGenerator.d.ts +8 -0
- package/dist/services/worktreeNameGenerator.js +192 -0
- package/dist/services/worktreeNameGenerator.test.d.ts +1 -0
- package/dist/services/worktreeNameGenerator.test.js +35 -0
- package/dist/utils/presetPrompt.d.ts +11 -0
- package/dist/utils/presetPrompt.js +73 -0
- package/dist/utils/presetPrompt.test.d.ts +1 -0
- package/dist/utils/presetPrompt.test.js +155 -0
- package/package.json +6 -6
|
@@ -9,18 +9,15 @@ import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
|
|
|
9
9
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
10
10
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
11
11
|
import { Effect } from 'effect';
|
|
12
|
+
import { describePromptInjection, getPromptInjectionMethod, } from '../utils/presetPrompt.js';
|
|
12
13
|
const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
13
14
|
const worktreeConfig = configReader.getWorktreeConfig();
|
|
15
|
+
const presetsConfig = configReader.getCommandPresets();
|
|
14
16
|
const isAutoDirectory = worktreeConfig.autoDirectory;
|
|
15
17
|
const isAutoUseDefaultBranch = worktreeConfig.autoUseDefaultBranch ?? false;
|
|
16
18
|
const limit = 10;
|
|
17
|
-
// Determine initial step based on config options
|
|
18
|
-
// If autoUseDefaultBranch is enabled, we start at a temporary 'loading' state
|
|
19
|
-
// and will transition to 'branch-strategy' after branches are loaded
|
|
20
19
|
const getInitialStep = () => {
|
|
21
20
|
if (isAutoDirectory) {
|
|
22
|
-
// With autoDirectory, skip path input
|
|
23
|
-
// If autoUseDefaultBranch is also enabled, we'll skip base-branch after loading
|
|
24
21
|
return 'base-branch';
|
|
25
22
|
}
|
|
26
23
|
return 'path';
|
|
@@ -31,17 +28,16 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
31
28
|
const [baseBranch, setBaseBranch] = useState('');
|
|
32
29
|
const [copyClaudeDirectory, setCopyClaudeDirectory] = useState(true);
|
|
33
30
|
const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
|
|
34
|
-
|
|
31
|
+
const [selectedPresetId, setSelectedPresetId] = useState(presetsConfig.defaultPresetId);
|
|
32
|
+
const [initialPrompt, setInitialPrompt] = useState('');
|
|
35
33
|
const [isLoadingBranches, setIsLoadingBranches] = useState(true);
|
|
36
34
|
const [branchLoadError, setBranchLoadError] = useState(null);
|
|
37
35
|
const [branches, setBranches] = useState([]);
|
|
38
36
|
const [defaultBranch, setDefaultBranch] = useState('main');
|
|
39
|
-
// Initialize worktree service and load branches using Effect
|
|
40
37
|
useEffect(() => {
|
|
41
38
|
let cancelled = false;
|
|
42
39
|
const service = new WorktreeService(projectPath);
|
|
43
40
|
const loadBranches = async () => {
|
|
44
|
-
// Use Effect.all to load branches and defaultBranch in parallel
|
|
45
41
|
const workflow = Effect.all([service.getAllBranchesEffect(), service.getDefaultBranchEffect()], { concurrency: 2 });
|
|
46
42
|
const result = await Effect.runPromise(Effect.match(workflow, {
|
|
47
43
|
onFailure: (error) => ({
|
|
@@ -63,13 +59,9 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
63
59
|
setBranches(result.branches);
|
|
64
60
|
setDefaultBranch(result.defaultBranch);
|
|
65
61
|
setIsLoadingBranches(false);
|
|
66
|
-
// If autoUseDefaultBranch is enabled, auto-set the base branch
|
|
67
|
-
// and skip to branch-strategy step
|
|
68
62
|
if (isAutoUseDefaultBranch && result.defaultBranch) {
|
|
69
63
|
setBaseBranch(result.defaultBranch);
|
|
70
|
-
|
|
71
|
-
// (if we're at base-branch step, which happens with autoDirectory)
|
|
72
|
-
setStep(currentStep => currentStep === 'base-branch' ? 'branch-strategy' : currentStep);
|
|
64
|
+
setStep(currentStep => currentStep === 'base-branch' ? 'creation-mode' : currentStep);
|
|
73
65
|
}
|
|
74
66
|
}
|
|
75
67
|
}
|
|
@@ -84,98 +76,142 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
84
76
|
cancelled = true;
|
|
85
77
|
};
|
|
86
78
|
}, [projectPath, isAutoUseDefaultBranch]);
|
|
87
|
-
// Create branch items with default branch first (memoized)
|
|
88
79
|
const allBranchItems = useMemo(() => [
|
|
89
80
|
{ label: `${defaultBranch} (default)`, value: defaultBranch },
|
|
90
81
|
...branches
|
|
91
82
|
.filter(br => br !== defaultBranch)
|
|
92
83
|
.map(br => ({ label: br, value: br })),
|
|
93
84
|
], [branches, defaultBranch]);
|
|
94
|
-
// Use search mode for base branch selection
|
|
95
85
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(allBranchItems.length, {
|
|
96
86
|
isDisabled: step !== 'base-branch',
|
|
97
87
|
});
|
|
98
|
-
// Filter branch items based on search query
|
|
99
88
|
const branchItems = useMemo(() => {
|
|
100
89
|
if (!searchQuery)
|
|
101
90
|
return allBranchItems;
|
|
102
91
|
return allBranchItems.filter(item => item.value.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
103
92
|
}, [allBranchItems, searchQuery]);
|
|
93
|
+
const presetItems = useMemo(() => presetsConfig.presets.map(preset => ({
|
|
94
|
+
label: `${preset.name}${preset.id === presetsConfig.defaultPresetId ? ' (default)' : ''}\n Command: ${preset.command}${preset.args?.length ? ` ${preset.args.join(' ')}` : ''}`,
|
|
95
|
+
value: preset.id,
|
|
96
|
+
})), [presetsConfig.defaultPresetId, presetsConfig.presets]);
|
|
97
|
+
const selectedPreset = useMemo(() => presetsConfig.presets.find(preset => preset.id === selectedPresetId) ||
|
|
98
|
+
presetsConfig.presets[0], [selectedPresetId, presetsConfig.presets]);
|
|
104
99
|
useInput((input, key) => {
|
|
105
100
|
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
106
101
|
onCancel();
|
|
107
102
|
}
|
|
108
|
-
// Handle arrow key navigation in search mode for base branch selection
|
|
109
103
|
if (step === 'base-branch' && isSearchMode) {
|
|
110
|
-
// Don't handle any keys here - let useSearchMode handle them
|
|
111
|
-
// The hook will handle arrow keys for navigation and Enter to exit search mode
|
|
112
104
|
return;
|
|
113
105
|
}
|
|
114
106
|
});
|
|
115
107
|
const handlePathSubmit = (value) => {
|
|
116
|
-
if (value.trim())
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
setStep('branch-strategy');
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
setStep('base-branch');
|
|
126
|
-
}
|
|
108
|
+
if (!value.trim())
|
|
109
|
+
return;
|
|
110
|
+
setPath(value.trim());
|
|
111
|
+
if (isAutoUseDefaultBranch && defaultBranch) {
|
|
112
|
+
setBaseBranch(defaultBranch);
|
|
113
|
+
setStep('creation-mode');
|
|
127
114
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (value.trim()) {
|
|
131
|
-
setBranch(value.trim());
|
|
132
|
-
setStep('copy-settings');
|
|
115
|
+
else {
|
|
116
|
+
setStep('base-branch');
|
|
133
117
|
}
|
|
134
118
|
};
|
|
135
119
|
const handleBaseBranchSelect = (item) => {
|
|
136
120
|
setBaseBranch(item.value);
|
|
137
|
-
setStep('
|
|
121
|
+
setStep('creation-mode');
|
|
122
|
+
};
|
|
123
|
+
const handleCreationModeSelect = (item) => {
|
|
124
|
+
if (item.value === 'manual') {
|
|
125
|
+
setStep('branch-strategy');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
setStep('auto-preset');
|
|
138
129
|
};
|
|
139
130
|
const handleBranchStrategySelect = (item) => {
|
|
140
131
|
const useExisting = item.value === 'existing';
|
|
141
132
|
if (useExisting) {
|
|
142
|
-
// Use the base branch as the branch name for existing branch
|
|
143
133
|
setBranch(baseBranch);
|
|
144
134
|
setStep('copy-settings');
|
|
145
135
|
}
|
|
146
136
|
else {
|
|
147
|
-
// Need to input new branch name
|
|
148
137
|
setStep('branch');
|
|
149
138
|
}
|
|
150
139
|
};
|
|
140
|
+
const handleBranchSubmit = (value) => {
|
|
141
|
+
if (!value.trim())
|
|
142
|
+
return;
|
|
143
|
+
setBranch(value.trim());
|
|
144
|
+
setStep('copy-settings');
|
|
145
|
+
};
|
|
146
|
+
const handlePresetSelect = (item) => {
|
|
147
|
+
setSelectedPresetId(item.value);
|
|
148
|
+
setStep('auto-prompt');
|
|
149
|
+
};
|
|
150
|
+
const handlePromptSubmit = (value) => {
|
|
151
|
+
if (!value.trim())
|
|
152
|
+
return;
|
|
153
|
+
setInitialPrompt(value.trim());
|
|
154
|
+
setStep('copy-settings');
|
|
155
|
+
};
|
|
151
156
|
const handleCopySettingsSelect = (item) => {
|
|
152
157
|
setCopyClaudeDirectory(item.value);
|
|
153
158
|
setStep('copy-session');
|
|
154
159
|
};
|
|
160
|
+
const getResolvedPath = () => {
|
|
161
|
+
if (!isAutoDirectory) {
|
|
162
|
+
return path;
|
|
163
|
+
}
|
|
164
|
+
const branchForPath = step === 'copy-session' && branch ? branch : 'generated-from-prompt';
|
|
165
|
+
return generateWorktreeDirectory(projectPath || process.cwd(), branchForPath, worktreeConfig.autoDirectoryPattern);
|
|
166
|
+
};
|
|
155
167
|
const handleCopySessionSelect = (item) => {
|
|
156
168
|
const shouldCopy = item.value === 'yes';
|
|
169
|
+
const resolvedPath = getResolvedPath();
|
|
157
170
|
setCopySessionData(shouldCopy);
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
const autoPath = generateWorktreeDirectory(projectPath || process.cwd(), branch, worktreeConfig.autoDirectoryPattern);
|
|
161
|
-
onComplete(autoPath, branch, baseBranch, shouldCopy, copyClaudeDirectory);
|
|
171
|
+
if (step !== 'copy-session') {
|
|
172
|
+
return;
|
|
162
173
|
}
|
|
163
|
-
|
|
164
|
-
onComplete(
|
|
174
|
+
if (initialPrompt && selectedPresetId) {
|
|
175
|
+
onComplete({
|
|
176
|
+
creationMode: 'prompt',
|
|
177
|
+
path: isAutoDirectory ? projectPath || process.cwd() : resolvedPath,
|
|
178
|
+
projectPath: projectPath || process.cwd(),
|
|
179
|
+
autoDirectoryPattern: isAutoDirectory
|
|
180
|
+
? worktreeConfig.autoDirectoryPattern
|
|
181
|
+
: undefined,
|
|
182
|
+
baseBranch,
|
|
183
|
+
presetId: selectedPresetId,
|
|
184
|
+
initialPrompt,
|
|
185
|
+
copySessionData: shouldCopy,
|
|
186
|
+
copyClaudeDirectory,
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
165
189
|
}
|
|
190
|
+
onComplete({
|
|
191
|
+
creationMode: 'manual',
|
|
192
|
+
path: resolvedPath,
|
|
193
|
+
branch,
|
|
194
|
+
baseBranch,
|
|
195
|
+
copySessionData: shouldCopy,
|
|
196
|
+
copyClaudeDirectory,
|
|
197
|
+
});
|
|
166
198
|
};
|
|
167
|
-
// Calculate generated path for preview (memoized to avoid expensive recalculations)
|
|
168
199
|
const generatedPath = useMemo(() => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
200
|
+
if (!isAutoDirectory) {
|
|
201
|
+
return '';
|
|
202
|
+
}
|
|
203
|
+
const branchForPath = branch || (initialPrompt ? 'generated-from-prompt' : '');
|
|
204
|
+
if (!branchForPath) {
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
return generateWorktreeDirectory(projectPath || process.cwd(), branchForPath, worktreeConfig.autoDirectoryPattern);
|
|
172
208
|
}, [
|
|
173
209
|
isAutoDirectory,
|
|
174
210
|
branch,
|
|
211
|
+
initialPrompt,
|
|
175
212
|
worktreeConfig.autoDirectoryPattern,
|
|
176
213
|
projectPath,
|
|
177
214
|
]);
|
|
178
|
-
// Format errors using TaggedError discrimination
|
|
179
215
|
const formatError = (error) => {
|
|
180
216
|
switch (error._tag) {
|
|
181
217
|
case 'GitError':
|
|
@@ -190,17 +226,28 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
190
226
|
return `Validation failed for ${error.field}: ${error.constraint}`;
|
|
191
227
|
}
|
|
192
228
|
};
|
|
193
|
-
// Show loading indicator while branches load
|
|
194
229
|
if (isLoadingBranches) {
|
|
195
230
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), _jsx(Box, { children: _jsx(Text, { children: "Loading branches..." }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) })] }));
|
|
196
231
|
}
|
|
197
|
-
// Show error message if branch loading failed
|
|
198
232
|
if (branchLoadError) {
|
|
199
233
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: "Error loading branches:" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: branchLoadError }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to go back"] }) })] }));
|
|
200
234
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
235
|
+
const promptHandlingText = selectedPreset
|
|
236
|
+
? describePromptInjection(selectedPreset)
|
|
237
|
+
: '';
|
|
238
|
+
const promptMethod = selectedPreset
|
|
239
|
+
? getPromptInjectionMethod(selectedPreset)
|
|
240
|
+
: 'stdin';
|
|
241
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter branches..." })] })), isSearchMode && branchItems.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "No branches match your search" }) })) : isSearchMode ? (_jsx(Box, { flexDirection: "column", children: branchItems.slice(0, limit).map((item, index) => (_jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, children: [index === selectedIndex ? '❯ ' : ' ', item.label] }, item.value))) })) : (_jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode })), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
|
|
242
|
+
{
|
|
243
|
+
label: '1. Choose the branch name yourself',
|
|
244
|
+
value: 'manual',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
label: '2. Enter a prompt first and let Claude decide the branch name',
|
|
248
|
+
value: 'prompt',
|
|
249
|
+
},
|
|
250
|
+
], onSelect: handleCreationModeSelect, initialIndex: 0 })] })), step === 'branch-strategy' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Choose branch creation strategy:" }) }), _jsx(SelectInput, { items: [
|
|
204
251
|
{
|
|
205
252
|
label: 'Create new branch from base branch',
|
|
206
253
|
value: 'new',
|
|
@@ -209,13 +256,13 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
209
256
|
label: 'Use existing base branch',
|
|
210
257
|
value: 'existing',
|
|
211
258
|
},
|
|
212
|
-
], onSelect: handleBranchStrategySelect, initialIndex: 0 })] })), step === 'branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enter new branch name (will be created from", ' ', _jsx(Text, { color: "cyan", children: baseBranch }), "):"] }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: branch, onChange: setBranch, onSubmit: handleBranchSubmit, placeholder: "e.g., feature/new-feature" })] }), isAutoDirectory && generatedPath && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Worktree will be created at:", ' ', _jsx(Text, { color: "green", children: generatedPath })] }) }))] })), step === 'copy-settings' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Copy .claude directory from base branch (", _jsx(Text, { color: "cyan", children: baseBranch }), ")?"] }) }), _jsx(SelectInput, { items: [
|
|
259
|
+
], onSelect: handleBranchStrategySelect, initialIndex: 0 })] })), step === 'branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enter new branch name (will be created from", ' ', _jsx(Text, { color: "cyan", children: baseBranch }), "):"] }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: branch, onChange: setBranch, onSubmit: handleBranchSubmit, placeholder: "e.g., feature/new-feature" })] }), isAutoDirectory && generatedPath && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Worktree will be created at:", ' ', _jsx(Text, { color: "green", children: generatedPath })] }) }))] })), step === 'auto-preset' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select the preset to use for the first session:" }) }), _jsx(SelectInput, { items: presetItems, onSelect: handlePresetSelect, initialIndex: Math.max(0, presetItems.findIndex(item => item.value === selectedPresetId)) })] })), step === 'auto-prompt' && selectedPreset && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Preset: ", _jsx(Text, { color: "cyan", children: selectedPreset.name })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter the prompt for the new session:" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: promptHandlingText }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Examples: Claude/Codex use the final argument, OpenCode uses `--prompt`, and other commands may receive the prompt over stdin." }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "Automatic branch naming requires the `claude` command in your PATH." }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: initialPrompt, onChange: setInitialPrompt, onSubmit: handlePromptSubmit, placeholder: "Describe what you want the agent to do" })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Prompt delivery mode for this preset:", ' ', _jsx(Text, { color: "green", children: promptMethod })] }) })] })), step === 'copy-settings' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Copy .claude directory from base branch (", _jsx(Text, { color: "cyan", children: baseBranch }), ")?"] }) }), initialPrompt ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "The branch name will be generated automatically right before the worktree is created." }) })) : null, _jsx(SelectInput, { items: [
|
|
213
260
|
{
|
|
214
261
|
label: 'Yes - Copy .claude directory from base branch',
|
|
215
262
|
value: true,
|
|
216
263
|
},
|
|
217
264
|
{ label: 'No - Start without .claude directory', value: false },
|
|
218
|
-
], onSelect: handleCopySettingsSelect, initialIndex: 0 })] })), step === 'copy-session' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Copy Claude Code session data to the new worktree?" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This will copy conversation history and context from the current worktree" }) }), _jsx(SelectInput, { items: [
|
|
265
|
+
], onSelect: handleCopySettingsSelect, initialIndex: 0 })] })), step === 'copy-session' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Copy Claude Code session data to the new worktree?" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This will copy conversation history and context from the current worktree." }) }), isAutoDirectory && generatedPath ? (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Worktree path preview:", ' ', _jsx(Text, { color: "green", children: generatedPath })] }) })) : null, _jsx(SelectInput, { items: [
|
|
219
266
|
{ label: '✅ Yes, copy session data', value: 'yes' },
|
|
220
267
|
{ label: '❌ No, start fresh', value: 'no' },
|
|
221
268
|
], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) })] }));
|
|
@@ -50,6 +50,18 @@ vi.mock('../services/config/configReader.js', () => ({
|
|
|
50
50
|
autoDirectoryPattern: '../{project}-{branch}',
|
|
51
51
|
copySessionData: true,
|
|
52
52
|
}),
|
|
53
|
+
getCommandPresets: () => ({
|
|
54
|
+
presets: [
|
|
55
|
+
{
|
|
56
|
+
id: 'claude',
|
|
57
|
+
name: 'Claude',
|
|
58
|
+
command: 'claude',
|
|
59
|
+
args: ['--resume'],
|
|
60
|
+
detectionStrategy: 'claude',
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
defaultPresetId: 'claude',
|
|
64
|
+
}),
|
|
53
65
|
},
|
|
54
66
|
}));
|
|
55
67
|
vi.mock('../hooks/useSearchMode.js', () => ({
|
|
@@ -259,7 +271,7 @@ describe('NewWorktree component Effect integration', () => {
|
|
|
259
271
|
// Verify Effect was executed (Effect.match pattern)
|
|
260
272
|
expect(effectExecuted).toBe(true);
|
|
261
273
|
});
|
|
262
|
-
it('should skip base branch selection when autoUseDefaultBranch is enabled with autoDirectory', async () => {
|
|
274
|
+
it('should skip base branch selection and show creation mode when autoUseDefaultBranch is enabled with autoDirectory', async () => {
|
|
263
275
|
const { Effect } = await import('effect');
|
|
264
276
|
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
265
277
|
const { configReader } = await import('../services/config/configReader.js');
|
|
@@ -285,12 +297,13 @@ describe('NewWorktree component Effect integration', () => {
|
|
|
285
297
|
// Wait for Effect to execute and state to update
|
|
286
298
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
287
299
|
const output = lastFrame();
|
|
288
|
-
// Should skip base-branch step and show
|
|
289
|
-
// which displays "Base branch:" and "Choose branch creation strategy"
|
|
300
|
+
// Should skip base-branch step and show the manual/prompt creation mode choice
|
|
290
301
|
expect(output).toContain('Create New Worktree');
|
|
291
302
|
expect(output).toContain('Base branch:');
|
|
292
303
|
expect(output).toContain('main');
|
|
293
|
-
expect(output).toContain('
|
|
304
|
+
expect(output).toContain('How do you want to create the new worktree?');
|
|
305
|
+
expect(output).toContain('Choose the branch name yourself');
|
|
306
|
+
expect(output).toContain('Enter a prompt first');
|
|
294
307
|
});
|
|
295
308
|
it('should show base branch selection when autoUseDefaultBranch is disabled', async () => {
|
|
296
309
|
const { Effect } = await import('effect');
|
|
@@ -5,9 +5,6 @@ interface TextInputWrapperProps {
|
|
|
5
5
|
onSubmit?: (value: string) => void;
|
|
6
6
|
placeholder?: string;
|
|
7
7
|
focus?: boolean;
|
|
8
|
-
mask?: string;
|
|
9
|
-
showCursor?: boolean;
|
|
10
|
-
highlightPastedText?: boolean;
|
|
11
8
|
}
|
|
12
9
|
declare const TextInputWrapper: React.FC<TextInputWrapperProps>;
|
|
13
10
|
export default TextInputWrapper;
|
|
@@ -1,15 +1,124 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
2
|
+
import { useReducer, useEffect, useRef, useMemo, } from 'react';
|
|
3
|
+
import { Text, useInput } from 'ink';
|
|
4
|
+
import chalk from 'chalk';
|
|
3
5
|
import stripAnsi from 'strip-ansi';
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
const reducer = (state, action) => {
|
|
7
|
+
switch (action.type) {
|
|
8
|
+
case 'move-cursor-left': {
|
|
9
|
+
return {
|
|
10
|
+
...state,
|
|
11
|
+
cursorOffset: Math.max(0, state.cursorOffset - 1),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
case 'move-cursor-right': {
|
|
15
|
+
return {
|
|
16
|
+
...state,
|
|
17
|
+
cursorOffset: Math.min(state.value.length, state.cursorOffset + 1),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
case 'insert': {
|
|
21
|
+
return {
|
|
22
|
+
value: state.value.slice(0, state.cursorOffset) +
|
|
23
|
+
action.text +
|
|
24
|
+
state.value.slice(state.cursorOffset),
|
|
25
|
+
cursorOffset: state.cursorOffset + action.text.length,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
case 'delete': {
|
|
29
|
+
if (state.cursorOffset === 0)
|
|
30
|
+
return state;
|
|
31
|
+
const newOffset = state.cursorOffset - 1;
|
|
32
|
+
return {
|
|
33
|
+
value: state.value.slice(0, newOffset) + state.value.slice(newOffset + 1),
|
|
34
|
+
cursorOffset: newOffset,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
case 'set': {
|
|
38
|
+
return {
|
|
39
|
+
value: action.value,
|
|
40
|
+
cursorOffset: action.value.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function cleanInput(input) {
|
|
46
|
+
let cleaned = stripAnsi(input);
|
|
47
|
+
cleaned = cleaned.replace(/\[200~/g, '').replace(/\[201~/g, '');
|
|
48
|
+
return cleaned;
|
|
49
|
+
}
|
|
50
|
+
const cursor = chalk.inverse(' ');
|
|
51
|
+
const TextInputWrapper = ({ value, onChange, onSubmit, placeholder = '', focus = true, }) => {
|
|
52
|
+
const [state, dispatch] = useReducer(reducer, {
|
|
53
|
+
value,
|
|
54
|
+
cursorOffset: value.length,
|
|
55
|
+
});
|
|
56
|
+
const lastReportedValue = useRef(value);
|
|
57
|
+
// Sync external value changes into internal state
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (value !== lastReportedValue.current) {
|
|
60
|
+
lastReportedValue.current = value;
|
|
61
|
+
dispatch({ type: 'set', value });
|
|
62
|
+
}
|
|
63
|
+
}, [value]);
|
|
64
|
+
// Report internal state changes to parent
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (state.value !== lastReportedValue.current) {
|
|
67
|
+
lastReportedValue.current = state.value;
|
|
68
|
+
onChange(state.value);
|
|
69
|
+
}
|
|
70
|
+
}, [state.value, onChange]);
|
|
71
|
+
useInput((input, key) => {
|
|
72
|
+
if (key.upArrow ||
|
|
73
|
+
key.downArrow ||
|
|
74
|
+
(key.ctrl && input === 'c') ||
|
|
75
|
+
key.tab ||
|
|
76
|
+
(key.shift && key.tab)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key.return) {
|
|
80
|
+
onSubmit?.(state.value);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (key.leftArrow) {
|
|
84
|
+
dispatch({ type: 'move-cursor-left' });
|
|
85
|
+
}
|
|
86
|
+
else if (key.rightArrow) {
|
|
87
|
+
dispatch({ type: 'move-cursor-right' });
|
|
88
|
+
}
|
|
89
|
+
else if (key.backspace || key.delete) {
|
|
90
|
+
dispatch({ type: 'delete' });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const cleaned = cleanInput(input);
|
|
94
|
+
if (cleaned) {
|
|
95
|
+
dispatch({ type: 'insert', text: cleaned });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, { isActive: focus });
|
|
99
|
+
const renderedPlaceholder = useMemo(() => {
|
|
100
|
+
if (!focus) {
|
|
101
|
+
return placeholder ? chalk.dim(placeholder) : '';
|
|
102
|
+
}
|
|
103
|
+
return placeholder.length > 0
|
|
104
|
+
? chalk.inverse(placeholder[0]) + chalk.dim(placeholder.slice(1))
|
|
105
|
+
: cursor;
|
|
106
|
+
}, [focus, placeholder]);
|
|
107
|
+
const renderedValue = useMemo(() => {
|
|
108
|
+
if (!focus) {
|
|
109
|
+
return state.value;
|
|
110
|
+
}
|
|
111
|
+
let result = state.value.length > 0 ? '' : cursor;
|
|
112
|
+
let index = 0;
|
|
113
|
+
for (const char of state.value) {
|
|
114
|
+
result += index === state.cursorOffset ? chalk.inverse(char) : char;
|
|
115
|
+
index++;
|
|
116
|
+
}
|
|
117
|
+
if (state.value.length > 0 && state.cursorOffset === state.value.length) {
|
|
118
|
+
result += cursor;
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}, [focus, state.value, state.cursorOffset]);
|
|
122
|
+
return (_jsx(Text, { children: state.value.length > 0 ? renderedValue : renderedPlaceholder }));
|
|
14
123
|
};
|
|
15
124
|
export default TextInputWrapper;
|
|
@@ -17,6 +17,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
17
17
|
private busyTimers;
|
|
18
18
|
private autoApprovalDisabledWorktrees;
|
|
19
19
|
private spawn;
|
|
20
|
+
private resolvePreset;
|
|
20
21
|
detectTerminalState(session: Session): SessionState;
|
|
21
22
|
detectBackgroundTask(session: Session): number;
|
|
22
23
|
detectTeamMembers(session: Session): number;
|
|
@@ -54,7 +55,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
54
55
|
* );
|
|
55
56
|
* ```
|
|
56
57
|
*/
|
|
57
|
-
createSessionWithPresetEffect(worktreePath: string, presetId?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
58
|
+
createSessionWithPresetEffect(worktreePath: string, presetId?: string, initialPrompt?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
58
59
|
private setupDataHandler;
|
|
59
60
|
/**
|
|
60
61
|
* Sets up exit handler for the session process.
|
|
@@ -95,7 +96,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
95
96
|
* Create session with devcontainer integration using Effect-based error handling
|
|
96
97
|
* @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
|
|
97
98
|
*/
|
|
98
|
-
createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
99
|
+
createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string, initialPrompt?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
99
100
|
destroy(): void;
|
|
100
101
|
static getSessionCounts(sessions: Session[]): SessionCounts;
|
|
101
102
|
static formatSessionCounts(counts: SessionCounts): string;
|
|
@@ -16,6 +16,7 @@ import { Mutex, createInitialSessionStateData } from '../utils/mutex.js';
|
|
|
16
16
|
import { getBackgroundTaskTag, getTeamMemberTag, } from '../constants/statusIcons.js';
|
|
17
17
|
import { getTerminalScreenContent } from '../utils/screenCapture.js';
|
|
18
18
|
import { injectTeammateMode } from '../utils/commandArgs.js';
|
|
19
|
+
import { preparePresetLaunch } from '../utils/presetPrompt.js';
|
|
19
20
|
const { Terminal } = pkg;
|
|
20
21
|
const execAsync = promisify(exec);
|
|
21
22
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
@@ -34,6 +35,24 @@ export class SessionManager extends EventEmitter {
|
|
|
34
35
|
};
|
|
35
36
|
return spawn(command, args, spawnOptions);
|
|
36
37
|
}
|
|
38
|
+
resolvePreset(presetId) {
|
|
39
|
+
let preset = presetId
|
|
40
|
+
? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
|
|
41
|
+
: null;
|
|
42
|
+
if (!preset) {
|
|
43
|
+
preset = configReader.getDefaultPreset();
|
|
44
|
+
}
|
|
45
|
+
if (!preset) {
|
|
46
|
+
throw new ConfigError({
|
|
47
|
+
configPath: 'configuration',
|
|
48
|
+
reason: 'validation',
|
|
49
|
+
details: presetId
|
|
50
|
+
? `Preset with ID '${presetId}' not found and no default preset available`
|
|
51
|
+
: 'No default preset available',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return preset;
|
|
55
|
+
}
|
|
37
56
|
detectTerminalState(session) {
|
|
38
57
|
const stateData = session.stateMutex.getSnapshot();
|
|
39
58
|
const detectedState = session.stateDetector.detectState(session.terminal, stateData.state);
|
|
@@ -227,7 +246,7 @@ export class SessionManager extends EventEmitter {
|
|
|
227
246
|
* );
|
|
228
247
|
* ```
|
|
229
248
|
*/
|
|
230
|
-
createSessionWithPresetEffect(worktreePath, presetId) {
|
|
249
|
+
createSessionWithPresetEffect(worktreePath, presetId, initialPrompt) {
|
|
231
250
|
return Effect.tryPromise({
|
|
232
251
|
try: async () => {
|
|
233
252
|
// Check if session already exists
|
|
@@ -235,31 +254,20 @@ export class SessionManager extends EventEmitter {
|
|
|
235
254
|
if (existing) {
|
|
236
255
|
return existing;
|
|
237
256
|
}
|
|
238
|
-
|
|
239
|
-
let preset = presetId
|
|
240
|
-
? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
|
|
241
|
-
: null;
|
|
242
|
-
if (!preset) {
|
|
243
|
-
preset = configReader.getDefaultPreset();
|
|
244
|
-
}
|
|
245
|
-
// Validate preset exists
|
|
246
|
-
if (!preset) {
|
|
247
|
-
throw new ConfigError({
|
|
248
|
-
configPath: 'configuration',
|
|
249
|
-
reason: 'validation',
|
|
250
|
-
details: presetId
|
|
251
|
-
? `Preset with ID '${presetId}' not found and no default preset available`
|
|
252
|
-
: 'No default preset available',
|
|
253
|
-
});
|
|
254
|
-
}
|
|
257
|
+
const preset = this.resolvePreset(presetId);
|
|
255
258
|
const command = preset.command;
|
|
256
|
-
const
|
|
259
|
+
const launch = preparePresetLaunch(preset, initialPrompt);
|
|
260
|
+
const args = launch.args;
|
|
257
261
|
// Spawn the process - fallback will be handled by setupExitHandler
|
|
258
262
|
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
259
|
-
|
|
263
|
+
const session = await this.createSessionInternal(worktreePath, ptyProcess, {
|
|
260
264
|
isPrimaryCommand: true,
|
|
261
265
|
detectionStrategy: preset.detectionStrategy,
|
|
262
266
|
});
|
|
267
|
+
if (launch.stdinPayload) {
|
|
268
|
+
session.process.write(launch.stdinPayload);
|
|
269
|
+
}
|
|
270
|
+
return session;
|
|
263
271
|
},
|
|
264
272
|
catch: (error) => {
|
|
265
273
|
// If it's already a ConfigError, return it
|
|
@@ -617,7 +625,7 @@ export class SessionManager extends EventEmitter {
|
|
|
617
625
|
* Create session with devcontainer integration using Effect-based error handling
|
|
618
626
|
* @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
|
|
619
627
|
*/
|
|
620
|
-
createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId) {
|
|
628
|
+
createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt) {
|
|
621
629
|
return Effect.tryPromise({
|
|
622
630
|
try: async () => {
|
|
623
631
|
// Check if session already exists
|
|
@@ -635,37 +643,26 @@ export class SessionManager extends EventEmitter {
|
|
|
635
643
|
message: `Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`,
|
|
636
644
|
});
|
|
637
645
|
}
|
|
638
|
-
|
|
639
|
-
let preset = presetId
|
|
640
|
-
? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
|
|
641
|
-
: null;
|
|
642
|
-
if (!preset) {
|
|
643
|
-
preset = configReader.getDefaultPreset();
|
|
644
|
-
}
|
|
645
|
-
// Validate preset exists
|
|
646
|
-
if (!preset) {
|
|
647
|
-
throw new ConfigError({
|
|
648
|
-
configPath: 'configuration',
|
|
649
|
-
reason: 'validation',
|
|
650
|
-
details: presetId
|
|
651
|
-
? `Preset with ID '${presetId}' not found and no default preset available`
|
|
652
|
-
: 'No default preset available',
|
|
653
|
-
});
|
|
654
|
-
}
|
|
646
|
+
const preset = this.resolvePreset(presetId);
|
|
655
647
|
// Parse the exec command to extract arguments
|
|
656
648
|
const execParts = devcontainerConfig.execCommand.split(/\s+/);
|
|
657
649
|
const devcontainerCmd = execParts[0] || 'devcontainer';
|
|
658
650
|
const execArgs = execParts.slice(1);
|
|
659
651
|
// Build the full command: devcontainer exec [args] -- [preset command] [preset args]
|
|
660
|
-
const
|
|
652
|
+
const launch = preparePresetLaunch(preset, initialPrompt);
|
|
653
|
+
const presetArgs = launch.args;
|
|
661
654
|
const fullArgs = [...execArgs, '--', preset.command, ...presetArgs];
|
|
662
655
|
// Spawn the process within devcontainer
|
|
663
656
|
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
664
|
-
|
|
657
|
+
const session = await this.createSessionInternal(worktreePath, ptyProcess, {
|
|
665
658
|
isPrimaryCommand: true,
|
|
666
659
|
detectionStrategy: preset.detectionStrategy,
|
|
667
660
|
devcontainerConfig,
|
|
668
661
|
});
|
|
662
|
+
if (launch.stdinPayload) {
|
|
663
|
+
session.process.write(launch.stdinPayload);
|
|
664
|
+
}
|
|
665
|
+
return session;
|
|
669
666
|
},
|
|
670
667
|
catch: (error) => {
|
|
671
668
|
// If it's already a ConfigError or ProcessError, return it
|