ccmanager 3.12.6 → 4.0.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.
Files changed (33) hide show
  1. package/dist/components/App.js +137 -63
  2. package/dist/components/App.test.js +16 -30
  3. package/dist/components/ConfigureCommand.js +1 -1
  4. package/dist/components/Dashboard.js +3 -3
  5. package/dist/components/Menu.d.ts +2 -2
  6. package/dist/components/Menu.js +66 -140
  7. package/dist/components/Menu.recent-projects.test.js +8 -8
  8. package/dist/components/Menu.test.js +17 -17
  9. package/dist/components/Session.js +3 -3
  10. package/dist/components/SessionActions.d.ts +9 -0
  11. package/dist/components/SessionActions.js +29 -0
  12. package/dist/components/SessionRename.d.ts +8 -0
  13. package/dist/components/SessionRename.js +18 -0
  14. package/dist/constants/statusIcons.d.ts +3 -0
  15. package/dist/constants/statusIcons.js +3 -0
  16. package/dist/services/globalSessionOrchestrator.test.js +11 -5
  17. package/dist/services/sessionManager.autoApproval.test.js +1 -4
  18. package/dist/services/sessionManager.d.ts +7 -7
  19. package/dist/services/sessionManager.effect.test.js +17 -16
  20. package/dist/services/sessionManager.js +43 -48
  21. package/dist/services/sessionManager.statePersistence.test.js +3 -6
  22. package/dist/services/sessionManager.test.js +21 -24
  23. package/dist/services/stateDetector/cursor.js +4 -1
  24. package/dist/services/stateDetector/cursor.test.js +35 -0
  25. package/dist/services/worktreeService.d.ts +1 -15
  26. package/dist/services/worktreeService.js +1 -39
  27. package/dist/services/worktreeService.sort.test.js +141 -303
  28. package/dist/types/index.d.ts +37 -6
  29. package/dist/utils/hookExecutor.test.js +8 -0
  30. package/dist/utils/worktreeUtils.d.ts +12 -6
  31. package/dist/utils/worktreeUtils.js +116 -50
  32. package/dist/utils/worktreeUtils.test.js +9 -7
  33. package/package.json +6 -6
@@ -12,6 +12,8 @@ import Configuration from './Configuration.js';
12
12
  import PresetSelector from './PresetSelector.js';
13
13
  import RemoteBranchSelector from './RemoteBranchSelector.js';
14
14
  import LoadingSpinner from './LoadingSpinner.js';
15
+ import SessionRename from './SessionRename.js';
16
+ import SessionActions from './SessionActions.js';
15
17
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
16
18
  import { WorktreeService } from '../services/worktreeService.js';
17
19
  import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
@@ -31,6 +33,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
31
33
  const [error, setError] = useState(null);
32
34
  const [menuKey, setMenuKey] = useState(0); // Force menu refresh
33
35
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
36
+ const [renameTarget, setRenameTarget] = useState(null);
37
+ const [sessionActionsTarget, setSessionActionsTarget] = useState(null);
34
38
  const [selectedProject, setSelectedProject] = useState(null); // Store selected project in multi-project mode
35
39
  const [configScope, setConfigScope] = useState('global'); // Store config scope for configuration view
36
40
  const [pendingMenuSessionLaunch, setPendingMenuSessionLaunch] = useState(null);
@@ -58,7 +62,6 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
58
62
  const sessionEffect = devcontainerConfig
59
63
  ? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt)
60
64
  : sessionManager.createSessionWithPresetEffect(worktreePath, presetId, initialPrompt);
61
- // Execute the Effect and handle both success and failure cases
62
65
  const result = await Effect.runPromise(Effect.either(sessionEffect));
63
66
  if (result._tag === 'Left') {
64
67
  const errorMessage = formatErrorMessage(result.left);
@@ -97,23 +100,34 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
97
100
  }, 10);
98
101
  }, []);
99
102
  const startSessionForWorktree = useCallback(async (worktree, options) => {
100
- let session = sessionManager.getSession(worktree.path);
101
- if (!session) {
102
- if (!options?.presetId && configReader.getSelectPresetOnStart()) {
103
- setSelectedWorktree(worktree);
104
- navigateWithClear('preset-selector');
105
- return;
106
- }
107
- setView(options?.presetId ? 'creating-session-preset' : 'creating-session');
108
- const result = await createSessionWithEffect(worktree.path, options?.presetId, options?.initialPrompt);
109
- if (!result.success) {
110
- setError(result.errorMessage);
111
- navigateWithClear('menu');
103
+ // If a specific session is provided, navigate to it directly
104
+ if (options?.session) {
105
+ navigateToSession(options.session);
106
+ return;
107
+ }
108
+ // Check if there are running sessions for this worktree.
109
+ // Navigate to the first one found (matches old getSession(path) behavior).
110
+ // Skip when forceNew is set (S key always create new session).
111
+ if (!options?.forceNew) {
112
+ const wtSessions = sessionManager.getSessionsForWorktree(worktree.path);
113
+ if (wtSessions.length > 0 && wtSessions[0]) {
114
+ navigateToSession(wtSessions[0]);
112
115
  return;
113
116
  }
114
- session = result.session;
115
117
  }
116
- navigateToSession(session);
118
+ if (!options?.presetId && configReader.getSelectPresetOnStart()) {
119
+ setSelectedWorktree(worktree);
120
+ navigateWithClear('preset-selector');
121
+ return;
122
+ }
123
+ setView(options?.presetId ? 'creating-session-preset' : 'creating-session');
124
+ const result = await createSessionWithEffect(worktree.path, options?.presetId, options?.initialPrompt);
125
+ if (!result.success) {
126
+ setError(result.errorMessage);
127
+ navigateWithClear('menu');
128
+ return;
129
+ }
130
+ navigateToSession(result.session);
117
131
  }, [
118
132
  sessionManager,
119
133
  navigateWithClear,
@@ -228,54 +242,62 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
228
242
  setView('new-worktree');
229
243
  }
230
244
  };
231
- const handleSelectWorktree = async (worktree) => {
232
- // Check if this is the new worktree option
233
- if (worktree.path === '') {
234
- navigateWithClear('new-worktree');
235
- return;
236
- }
237
- // Check if this is the delete worktree option
238
- if (worktree.path === 'DELETE_WORKTREE') {
239
- navigateWithClear('delete-worktree');
240
- return;
241
- }
242
- // Check if this is the merge worktree option
243
- if (worktree.path === 'MERGE_WORKTREE') {
244
- navigateWithClear('merge-worktree');
245
- return;
246
- }
247
- // Check if this is the configuration option
248
- if (worktree.path === 'CONFIGURATION') {
249
- setConfigScope('global');
250
- navigateWithClear('configuration');
251
- return;
252
- }
253
- // Check if this is the project configuration option
254
- if (worktree.path === 'CONFIGURATION_PROJECT') {
255
- setConfigScope('project');
256
- navigateWithClear('configuration');
257
- return;
258
- }
259
- // Check if this is the global configuration option
260
- if (worktree.path === 'CONFIGURATION_GLOBAL') {
261
- setConfigScope('global');
262
- navigateWithClear('configuration');
263
- return;
264
- }
265
- // Check if this is the exit application option
266
- if (worktree.path === 'EXIT_APPLICATION') {
267
- // In multi-project mode with a selected project, go back to project list
268
- if (multiProject && selectedProject) {
269
- handleBackToProjectList();
270
- }
271
- else {
272
- // Only destroy all sessions when actually exiting the app
273
- globalSessionOrchestrator.destroyAllSessions();
274
- exit();
275
- }
276
- return;
245
+ const handleMenuAction = async (action) => {
246
+ switch (action.type) {
247
+ case 'newWorktree':
248
+ navigateWithClear('new-worktree');
249
+ return;
250
+ case 'newSession':
251
+ await startSessionForWorktree({
252
+ path: action.worktreePath,
253
+ branch: '',
254
+ isMainWorktree: false,
255
+ hasSession: true,
256
+ }, { forceNew: true });
257
+ return;
258
+ case 'renameSession':
259
+ setRenameTarget({
260
+ id: action.session.id,
261
+ name: action.session.sessionName,
262
+ });
263
+ navigateWithClear('rename-session');
264
+ return;
265
+ case 'killSession':
266
+ sessionManager.destroySession(action.sessionId);
267
+ setMenuKey(prev => prev + 1);
268
+ return;
269
+ case 'sessionActions':
270
+ setSessionActionsTarget({
271
+ session: action.session,
272
+ worktreePath: action.worktreePath,
273
+ });
274
+ navigateWithClear('session-actions');
275
+ return;
276
+ case 'deleteWorktree':
277
+ navigateWithClear('delete-worktree');
278
+ return;
279
+ case 'mergeWorktree':
280
+ navigateWithClear('merge-worktree');
281
+ return;
282
+ case 'configuration':
283
+ setConfigScope(action.scope);
284
+ navigateWithClear('configuration');
285
+ return;
286
+ case 'exit':
287
+ if (multiProject && selectedProject) {
288
+ handleBackToProjectList();
289
+ }
290
+ else {
291
+ globalSessionOrchestrator.destroyAllSessions();
292
+ exit();
293
+ }
294
+ return;
295
+ case 'selectWorktree':
296
+ await startSessionForWorktree(action.worktree, {
297
+ session: action.session,
298
+ });
299
+ return;
277
300
  }
278
- await startSessionForWorktree(worktree);
279
301
  };
280
302
  const handlePresetSelected = async (presetId) => {
281
303
  if (!selectedWorktree)
@@ -430,6 +452,11 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
430
452
  // Delete the worktrees sequentially using Effect
431
453
  let hasError = false;
432
454
  for (const path of worktreePaths) {
455
+ // Destroy any running sessions for this worktree
456
+ const wtSessions = sessionManager.getSessionsForWorktree(path);
457
+ for (const s of wtSessions) {
458
+ sessionManager.destroySession(s.id);
459
+ }
433
460
  const result = await Effect.runPromise(Effect.either(worktreeService.deleteWorktreeEffect(path, { deleteBranch })));
434
461
  if (result._tag === 'Left') {
435
462
  // Handle error using pattern matching on _tag
@@ -495,7 +522,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
495
522
  return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, error: error, onDismissError: () => setError(null), version: version }));
496
523
  }
497
524
  if (view === 'menu') {
498
- return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: handleSelectWorktree, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
525
+ return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
499
526
  }
500
527
  if (view === 'session' && activeSession) {
501
528
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Session, { session: activeSession, sessionManager: sessionManager, onReturnToMenu: handleReturnToMenu }, activeSession.id) }));
@@ -530,6 +557,53 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
530
557
  if (view === 'configuration') {
531
558
  return (_jsx(Configuration, { scope: configScope, onComplete: handleReturnToMenu }));
532
559
  }
560
+ if (view === 'rename-session' && renameTarget) {
561
+ return (_jsx(SessionRename, { currentName: renameTarget.name, onRename: name => {
562
+ const session = sessionManager.getSessionById(renameTarget.id);
563
+ if (session) {
564
+ session.sessionName = name;
565
+ }
566
+ setRenameTarget(null);
567
+ handleReturnToMenu();
568
+ }, onCancel: () => {
569
+ setRenameTarget(null);
570
+ handleReturnToMenu();
571
+ } }));
572
+ }
573
+ if (view === 'session-actions' && sessionActionsTarget) {
574
+ const { session: targetSession, worktreePath } = sessionActionsTarget;
575
+ const label = targetSession.sessionName
576
+ ? `${worktreePath} : ${targetSession.sessionName}`
577
+ : `${worktreePath} #${targetSession.sessionNumber}`;
578
+ const handleSessionAction = async (action) => {
579
+ setSessionActionsTarget(null);
580
+ switch (action) {
581
+ case 'newSession':
582
+ await startSessionForWorktree({
583
+ path: worktreePath,
584
+ branch: '',
585
+ isMainWorktree: false,
586
+ hasSession: true,
587
+ }, { forceNew: true });
588
+ return;
589
+ case 'rename':
590
+ setRenameTarget({
591
+ id: targetSession.id,
592
+ name: targetSession.sessionName,
593
+ });
594
+ navigateWithClear('rename-session');
595
+ return;
596
+ case 'kill':
597
+ sessionManager.destroySession(targetSession.id);
598
+ handleReturnToMenu();
599
+ return;
600
+ }
601
+ };
602
+ return (_jsx(SessionActions, { sessionLabel: label, onSelect: handleSessionAction, onCancel: () => {
603
+ setSessionActionsTarget(null);
604
+ handleReturnToMenu();
605
+ } }));
606
+ }
533
607
  if (view === 'preset-selector') {
534
608
  return (_jsx(PresetSelector, { onSelect: handlePresetSelected, onCancel: handlePresetSelectorCancel }));
535
609
  }
@@ -16,8 +16,11 @@ const mockSession = {
16
16
  class MockSessionManager {
17
17
  on = vi.fn((_, __) => this);
18
18
  off = vi.fn((_, __) => this);
19
- getSession = vi.fn((_) => null);
19
+ getSessionById = vi.fn((_) => null);
20
+ getSessionsForWorktree = vi.fn((_) => []);
20
21
  getAllSessions = vi.fn(() => []);
22
+ destroySession = vi.fn((_) => { });
23
+ cancelAutoApproval = vi.fn((_, __) => { });
21
24
  createSessionWithPresetEffect = vi.fn((_, __) => Effect.succeed(mockSession));
22
25
  createSessionWithDevcontainerEffect = vi.fn((_, __) => Effect.succeed(mockSession));
23
26
  }
@@ -179,12 +182,7 @@ describe('App component loading state machine', () => {
179
182
  const { lastFrame, unmount } = render(_jsx(App, { version: "test" }));
180
183
  await waitForCondition(() => Boolean(menuProps));
181
184
  const menu = menuProps;
182
- const selectPromise = Promise.resolve(menu.onSelectWorktree({
183
- path: '',
184
- branch: '',
185
- isMainWorktree: false,
186
- hasSession: false,
187
- }));
185
+ const selectPromise = Promise.resolve(menu.onMenuAction({ type: 'newWorktree' }));
188
186
  await waitForCondition(() => Boolean(newWorktreeProps));
189
187
  const newWorktree = newWorktreeProps;
190
188
  const createPromise = Promise.resolve(newWorktree.onComplete({
@@ -210,12 +208,7 @@ describe('App component loading state machine', () => {
210
208
  expect(sessionManagers).toHaveLength(1);
211
209
  const sessionManager = sessionManagers[0];
212
210
  const menu = menuProps;
213
- await Promise.resolve(menu.onSelectWorktree({
214
- path: '',
215
- branch: '',
216
- isMainWorktree: false,
217
- hasSession: false,
218
- }));
211
+ await Promise.resolve(menu.onMenuAction({ type: 'newWorktree' }));
219
212
  await waitForCondition(() => Boolean(newWorktreeProps));
220
213
  await Promise.resolve(newWorktreeProps.onComplete({
221
214
  creationMode: 'prompt',
@@ -245,12 +238,7 @@ describe('App component loading state machine', () => {
245
238
  const { unmount } = render(_jsx(App, { version: "test" }));
246
239
  await waitForCondition(() => Boolean(menuProps));
247
240
  const sessionManager = sessionManagers[0];
248
- await Promise.resolve(menuProps.onSelectWorktree({
249
- path: '',
250
- branch: '',
251
- isMainWorktree: false,
252
- hasSession: false,
253
- }));
241
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
254
242
  await waitForCondition(() => Boolean(newWorktreeProps));
255
243
  await Promise.resolve(newWorktreeProps.onComplete({
256
244
  creationMode: 'prompt',
@@ -278,12 +266,7 @@ describe('App component loading state machine', () => {
278
266
  const { lastFrame, unmount } = render(_jsx(App, { version: "test" }));
279
267
  await waitForCondition(() => Boolean(menuProps));
280
268
  const menu = menuProps;
281
- const selectPromise = Promise.resolve(menu.onSelectWorktree({
282
- path: 'DELETE_WORKTREE',
283
- branch: '',
284
- isMainWorktree: false,
285
- hasSession: false,
286
- }));
269
+ const selectPromise = Promise.resolve(menu.onMenuAction({ type: 'deleteWorktree' }));
287
270
  await waitForCondition(() => Boolean(deleteWorktreeProps));
288
271
  const deleteWorktree = deleteWorktreeProps;
289
272
  const deletePromise = Promise.resolve(deleteWorktree.onComplete(['/tmp/test'], true));
@@ -313,11 +296,14 @@ describe('App component loading state machine', () => {
313
296
  catch: (error) => error,
314
297
  }));
315
298
  const menu = menuProps;
316
- const selectPromise = Promise.resolve(menu.onSelectWorktree({
317
- path: '/project/worktree',
318
- branch: 'feature',
319
- isMainWorktree: false,
320
- hasSession: false,
299
+ const selectPromise = Promise.resolve(menu.onMenuAction({
300
+ type: 'selectWorktree',
301
+ worktree: {
302
+ path: '/project/worktree',
303
+ branch: 'feature',
304
+ isMainWorktree: false,
305
+ hasSession: false,
306
+ },
321
307
  }));
322
308
  await flush();
323
309
  expect(lastFrame()).toContain('Starting devcontainer (this may take a moment)...');
@@ -32,7 +32,7 @@ const DEFAULT_COMMANDS = {
32
32
  claude: 'claude',
33
33
  gemini: 'gemini',
34
34
  codex: 'codex',
35
- cursor: 'cursor',
35
+ cursor: 'agent',
36
36
  'github-copilot': 'copilot',
37
37
  cline: 'cline',
38
38
  opencode: 'opencode',
@@ -12,7 +12,7 @@ import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../c
12
12
  import { useSearchMode } from '../hooks/useSearchMode.js';
13
13
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
14
14
  import { useGitStatus } from '../hooks/useGitStatus.js';
15
- import { truncateString, calculateColumnPositions, assembleWorktreeLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
15
+ import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
16
16
  import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
17
17
  import SearchableList from './SearchableList.js';
18
18
  const MAX_BRANCH_NAME_LENGTH = 70;
@@ -208,7 +208,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
208
208
  let currentIndex = 0;
209
209
  // --- Active Sessions section ---
210
210
  if (sessionEntries.length > 0) {
211
- // Build WorktreeItems for column alignment
211
+ // Build SessionItems for column alignment
212
212
  const sessionWorkItems = sessionEntries.map(entry => {
213
213
  // Use enriched worktree if available (has git status)
214
214
  const wt = enrichedWorktrees.find(w => w.path === entry.worktree.path) ||
@@ -278,7 +278,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
278
278
  filteredEntries.forEach(entry => {
279
279
  const itemIndex = sessionEntries.indexOf(entry);
280
280
  const workItem = sessionWorkItems[itemIndex];
281
- const label = assembleWorktreeLabel(workItem, columns);
281
+ const label = assembleSessionLabel(workItem, columns);
282
282
  const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
283
283
  const project = {
284
284
  path: entry.projectPath,
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
- import { Worktree, GitProject } from '../types/index.js';
2
+ import { GitProject, MenuAction } from '../types/index.js';
3
3
  import { WorktreeService } from '../services/worktreeService.js';
4
4
  import { SessionManager } from '../services/sessionManager.js';
5
5
  interface MenuProps {
6
6
  sessionManager: SessionManager;
7
7
  worktreeService: WorktreeService;
8
- onSelectWorktree: (worktree: Worktree) => void;
8
+ onMenuAction: (action: MenuAction) => void;
9
9
  onSelectRecentProject?: (project: GitProject) => void;
10
10
  error?: string | null;
11
11
  onDismissError?: () => void;