ccmanager 3.12.6 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import { Effect } from 'effect';
6
6
  import { SessionManager } from '../services/sessionManager.js';
7
7
  import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
8
8
  import { useGitStatus } from '../hooks/useGitStatus.js';
9
- import { prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
9
+ import { prepareSessionItems, calculateColumnPositions, assembleSessionLabel, } from '../utils/worktreeUtils.js';
10
10
  import { projectManager } from '../services/projectManager.js';
11
11
  import { useSearchMode } from '../hooks/useSearchMode.js';
12
12
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
@@ -29,7 +29,7 @@ const createSeparatorWithText = (text, totalWidth = 35) => {
29
29
  const formatGitError = (error) => {
30
30
  return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
31
31
  };
32
- const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, version, }) => {
32
+ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, version, }) => {
33
33
  const [baseWorktrees, setBaseWorktrees] = useState([]);
34
34
  const [defaultBranch, setDefaultBranch] = useState(null);
35
35
  const [loadError, setLoadError] = useState(null);
@@ -38,6 +38,7 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
38
38
  const [items, setItems] = useState([]);
39
39
  const [recentProjects, setRecentProjects] = useState([]);
40
40
  const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
41
+ const [highlightedSession, setHighlightedSession] = useState(undefined);
41
42
  const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
42
43
  // Use the search mode hook
43
44
  const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
@@ -53,9 +54,7 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
53
54
  let cancelled = false;
54
55
  // Load worktrees and default branch using Effect composition
55
56
  // Chain getWorktreesEffect and getDefaultBranchEffect using Effect.flatMap
56
- const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect({
57
- sortByLastSession: worktreeConfig.sortByLastSession,
58
- }), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
57
+ const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect(), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
59
58
  worktrees,
60
59
  defaultBranch,
61
60
  })));
@@ -118,15 +117,12 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
118
117
  sessionManager.off('sessionDestroyed', handleSessionChange);
119
118
  sessionManager.off('sessionStateChanged', handleSessionChange);
120
119
  };
121
- }, [
122
- sessionManager,
123
- worktreeService,
124
- multiProject,
125
- worktreeConfig.sortByLastSession,
126
- ]);
120
+ }, [sessionManager, worktreeService, multiProject]);
127
121
  useEffect(() => {
128
122
  // Prepare worktree items and calculate layout
129
- const items = prepareWorktreeItems(worktrees, sessions);
123
+ const items = prepareSessionItems(worktrees, sessions, {
124
+ sortByLastSession: worktreeConfig.sortByLastSession,
125
+ });
130
126
  const columnPositions = calculateColumnPositions(items);
131
127
  // Filter worktrees based on search query
132
128
  const filteredWorktrees = filterWorktreesByQuery(items.map(item => item.worktree), searchQuery);
@@ -134,18 +130,23 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
134
130
  const filteredItems = items.filter(item => filteredWorktreeSet.has(item.worktree));
135
131
  // Build menu items with proper alignment
136
132
  const menuItems = filteredItems.map((item, index) => {
137
- const baseLabel = assembleWorktreeLabel(item, columnPositions);
133
+ const baseLabel = assembleSessionLabel(item, columnPositions);
138
134
  const aaDisabled = configReader.isAutoApprovalEnabled() &&
139
135
  sessionManager.isAutoApprovalDisabledForWorktree(item.worktree.path);
140
136
  const label = baseLabel + (aaDisabled ? ' [Auto Approval Off]' : '');
141
137
  // Only show numbers for worktrees (0-9) when not in search mode
142
138
  // Use fixed-width prefix to prevent flicker at scroll boundary
143
139
  const numberPrefix = !isSearchMode && index < 10 ? `${index} ❯ ` : ' ❯ ';
140
+ // Use session id for value if present, otherwise worktree path
141
+ const value = item.session
142
+ ? `session:${item.session.id}`
143
+ : item.worktree.path;
144
144
  return {
145
145
  type: 'worktree',
146
146
  label: numberPrefix + label,
147
- value: item.worktree.path,
147
+ value,
148
148
  worktree: item.worktree,
149
+ session: item.session,
149
150
  };
150
151
  });
151
152
  // Filter recent projects based on search query
@@ -257,14 +258,18 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
257
258
  }
258
259
  setItems(menuItems);
259
260
  // Ensure highlighted worktree path is valid for hotkey support
260
- // (e.g., on initial render or when returning from a session view)
261
261
  setHighlightedWorktreePath(prev => {
262
262
  if (prev &&
263
- menuItems.some(item => item.type === 'worktree' && item.value === prev)) {
263
+ menuItems.some(item => item.type === 'worktree' && item.worktree.path === prev)) {
264
264
  return prev;
265
265
  }
266
266
  const first = menuItems.find(item => item.type === 'worktree');
267
- return first && first.type === 'worktree' ? first.worktree.path : null;
267
+ if (first && first.type === 'worktree') {
268
+ setHighlightedSession(first.session);
269
+ return first.worktree.path;
270
+ }
271
+ setHighlightedSession(undefined);
272
+ return null;
268
273
  });
269
274
  }, [
270
275
  worktrees,
@@ -277,6 +282,7 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
277
282
  isSearchMode,
278
283
  autoApprovalToggleCounter,
279
284
  sessionManager,
285
+ worktreeConfig.sortByLastSession,
280
286
  ]);
281
287
  // Handle hotkeys
282
288
  useInput((input, _key) => {
@@ -307,7 +313,11 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
307
313
  const projectItems = items.filter(item => item.type === 'project');
308
314
  // Check if it's a worktree
309
315
  if (index < worktreeItems.length && worktreeItems[index]) {
310
- onSelectWorktree(worktreeItems[index].worktree);
316
+ onMenuAction({
317
+ type: 'selectWorktree',
318
+ worktree: worktreeItems[index].worktree,
319
+ session: worktreeItems[index].session,
320
+ });
311
321
  return;
312
322
  }
313
323
  // Check if it's a recent project (when worktrees < 10)
@@ -329,86 +339,49 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
329
339
  setAutoApprovalToggleCounter(c => c + 1);
330
340
  }
331
341
  break;
342
+ case ' ':
343
+ // Open session actions for highlighted session
344
+ if (highlightedSession && highlightedWorktreePath) {
345
+ onMenuAction({
346
+ type: 'sessionActions',
347
+ session: highlightedSession,
348
+ worktreePath: highlightedWorktreePath,
349
+ });
350
+ }
351
+ break;
332
352
  case 'n':
333
- // Trigger new worktree action
334
- onSelectWorktree({
335
- path: '',
336
- branch: '',
337
- isMainWorktree: false,
338
- hasSession: false,
339
- });
353
+ onMenuAction({ type: 'newWorktree' });
340
354
  break;
341
355
  case 'm':
342
- // Trigger merge worktree action
343
- onSelectWorktree({
344
- path: 'MERGE_WORKTREE',
345
- branch: '',
346
- isMainWorktree: false,
347
- hasSession: false,
348
- });
356
+ onMenuAction({ type: 'mergeWorktree' });
349
357
  break;
350
358
  case 'd':
351
- // Trigger delete worktree action
352
- onSelectWorktree({
353
- path: 'DELETE_WORKTREE',
354
- branch: '',
355
- isMainWorktree: false,
356
- hasSession: false,
357
- });
359
+ onMenuAction({ type: 'deleteWorktree' });
358
360
  break;
359
361
  case 'p':
360
362
  // Trigger project configuration action (only in single-project mode)
361
363
  if (!multiProject) {
362
- onSelectWorktree({
363
- path: 'CONFIGURATION_PROJECT',
364
- branch: '',
365
- isMainWorktree: false,
366
- hasSession: false,
367
- });
364
+ onMenuAction({ type: 'configuration', scope: 'project' });
368
365
  }
369
366
  break;
370
367
  case 'c':
371
- // Trigger configuration action
372
- if (multiProject) {
373
- // In multi-project mode, 'c' opens global configuration (backward compatible)
374
- onSelectWorktree({
375
- path: 'CONFIGURATION',
376
- branch: '',
377
- isMainWorktree: false,
378
- hasSession: false,
379
- });
380
- }
381
- else {
382
- // In single-project mode, 'c' opens global configuration
383
- onSelectWorktree({
384
- path: 'CONFIGURATION_GLOBAL',
385
- branch: '',
386
- isMainWorktree: false,
387
- hasSession: false,
388
- });
389
- }
368
+ onMenuAction({ type: 'configuration', scope: 'global' });
390
369
  break;
391
370
  case 'b':
392
371
  // In multi-project mode, go back to project list
393
372
  if (projectName) {
394
- onSelectWorktree({
395
- path: 'EXIT_APPLICATION',
396
- branch: '',
397
- isMainWorktree: false,
398
- hasSession: false,
399
- });
373
+ onMenuAction({ type: 'exit' });
400
374
  }
401
375
  break;
402
- case 'q':
403
376
  case 'x':
377
+ if (!projectName) {
378
+ onMenuAction({ type: 'exit' });
379
+ }
380
+ break;
381
+ case 'q':
404
382
  // Trigger exit action (only in single-project mode)
405
383
  if (!projectName) {
406
- onSelectWorktree({
407
- path: 'EXIT_APPLICATION',
408
- branch: '',
409
- isMainWorktree: false,
410
- hasSession: false,
411
- });
384
+ onMenuAction({ type: 'exit' });
412
385
  }
413
386
  break;
414
387
  }
@@ -418,7 +391,6 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
418
391
  // Do nothing for separators and headers
419
392
  }
420
393
  else if (item.type === 'project') {
421
- // Handle recent project selection
422
394
  if (onSelectRecentProject) {
423
395
  const project = {
424
396
  path: item.recentProject.path,
@@ -430,79 +402,32 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
430
402
  }
431
403
  }
432
404
  else if (item.value === 'new-worktree') {
433
- // Handle in parent component
434
- onSelectWorktree({
435
- path: '',
436
- branch: '',
437
- isMainWorktree: false,
438
- hasSession: false,
439
- });
405
+ onMenuAction({ type: 'newWorktree' });
440
406
  }
441
407
  else if (item.value === 'merge-worktree') {
442
- // Handle in parent component - use special marker
443
- onSelectWorktree({
444
- path: 'MERGE_WORKTREE',
445
- branch: '',
446
- isMainWorktree: false,
447
- hasSession: false,
448
- });
408
+ onMenuAction({ type: 'mergeWorktree' });
449
409
  }
450
410
  else if (item.value === 'delete-worktree') {
451
- // Handle in parent component - use special marker
452
- onSelectWorktree({
453
- path: 'DELETE_WORKTREE',
454
- branch: '',
455
- isMainWorktree: false,
456
- hasSession: false,
457
- });
411
+ onMenuAction({ type: 'deleteWorktree' });
458
412
  }
459
413
  else if (item.value === 'configuration') {
460
- // Handle in parent component - use special marker (backward compatible for multi-project mode)
461
- onSelectWorktree({
462
- path: 'CONFIGURATION',
463
- branch: '',
464
- isMainWorktree: false,
465
- hasSession: false,
466
- });
414
+ onMenuAction({ type: 'configuration', scope: 'global' });
467
415
  }
468
416
  else if (item.value === 'configuration-project') {
469
- // Handle in parent component - use special marker for project config
470
- onSelectWorktree({
471
- path: 'CONFIGURATION_PROJECT',
472
- branch: '',
473
- isMainWorktree: false,
474
- hasSession: false,
475
- });
417
+ onMenuAction({ type: 'configuration', scope: 'project' });
476
418
  }
477
419
  else if (item.value === 'configuration-global') {
478
- // Handle in parent component - use special marker for global config
479
- onSelectWorktree({
480
- path: 'CONFIGURATION_GLOBAL',
481
- branch: '',
482
- isMainWorktree: false,
483
- hasSession: false,
484
- });
420
+ onMenuAction({ type: 'configuration', scope: 'global' });
485
421
  }
486
- else if (item.value === 'exit') {
487
- // Handle in parent component - use special marker
488
- onSelectWorktree({
489
- path: 'EXIT_APPLICATION',
490
- branch: '',
491
- isMainWorktree: false,
492
- hasSession: false,
493
- });
494
- }
495
- else if (item.value === 'back-to-projects') {
496
- // Handle in parent component - use special marker
497
- onSelectWorktree({
498
- path: 'EXIT_APPLICATION',
499
- branch: '',
500
- isMainWorktree: false,
501
- hasSession: false,
502
- });
422
+ else if (item.value === 'exit' || item.value === 'back-to-projects') {
423
+ onMenuAction({ type: 'exit' });
503
424
  }
504
425
  else if (item.type === 'worktree') {
505
- onSelectWorktree(item.worktree);
426
+ onMenuAction({
427
+ type: 'selectWorktree',
428
+ worktree: item.worktree,
429
+ session: item.session,
430
+ });
506
431
  }
507
432
  };
508
433
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), onHighlight: item => {
@@ -514,11 +439,12 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
514
439
  const menuItem = item;
515
440
  if (menuItem.type === 'worktree') {
516
441
  setHighlightedWorktreePath(menuItem.worktree.path);
442
+ setHighlightedSession(menuItem.session);
517
443
  }
518
444
  }, isFocused: !error, initialIndex: selectedIndex, limit: limit }) }), (error || loadError) && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", error || loadError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE, configReader.isAutoApprovalEnabled() && (_jsxs(_Fragment, { children: [' | ', _jsx(Text, { color: "green", children: "Auto Approval Enabled" })] }))] }), _jsx(Text, { dimColor: true, children: isSearchMode
519
445
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
520
446
  : searchQuery
521
- ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
522
- : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
447
+ ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
448
+ : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
523
449
  };
524
450
  export default Menu;
@@ -108,7 +108,7 @@ describe('Menu - Recent Projects', () => {
108
108
  vi.mocked(projectManager.getRecentProjects).mockReturnValue([
109
109
  { path: '/project1', name: 'Project 1', lastAccessed: 1000 },
110
110
  ]);
111
- const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), multiProject: false, version: "test" }));
111
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), multiProject: false, version: "test" }));
112
112
  const output = lastFrame();
113
113
  expect(output).not.toContain('─ Recent ─');
114
114
  expect(output).not.toContain('Project 1');
@@ -129,9 +129,9 @@ describe('Menu - Recent Projects', () => {
129
129
  teamMembers: 0,
130
130
  });
131
131
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
132
- const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
132
+ const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
133
133
  // Force a rerender to ensure all effects have run
134
- rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
134
+ rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
135
135
  await vi.waitFor(() => {
136
136
  const output = lastFrame();
137
137
  expect(output).toContain('─ Recent ─');
@@ -141,7 +141,7 @@ describe('Menu - Recent Projects', () => {
141
141
  });
142
142
  it('should not show recent projects section when no recent projects', () => {
143
143
  vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
144
- const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
144
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
145
145
  const output = lastFrame();
146
146
  expect(output).not.toContain('─ Recent ─');
147
147
  });
@@ -163,7 +163,7 @@ describe('Menu - Recent Projects', () => {
163
163
  teamMembers: 0,
164
164
  });
165
165
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
166
- const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
166
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
167
167
  await vi.waitFor(() => {
168
168
  const output = lastFrame();
169
169
  expect(output).toContain('─ Recent ─');
@@ -183,9 +183,9 @@ describe('Menu - Recent Projects', () => {
183
183
  ...mockWorktreeService,
184
184
  getGitRootPath: vi.fn().mockReturnValue('/current/project'),
185
185
  };
186
- const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
186
+ const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
187
187
  // Force a rerender to ensure all effects have run
188
- rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
188
+ rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
189
189
  await vi.waitFor(() => {
190
190
  const output = lastFrame();
191
191
  expect(output).toContain('─ Recent ─');
@@ -204,7 +204,7 @@ describe('Menu - Recent Projects', () => {
204
204
  ]);
205
205
  // Mock getGitRootPath to return the current project path
206
206
  vi.mocked(mockWorktreeService.getGitRootPath).mockReturnValue('/current/project');
207
- const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
207
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
208
208
  const output = lastFrame();
209
209
  expect(output).not.toContain('─ Recent ─');
210
210
  expect(output).not.toContain('Current Project');
@@ -78,7 +78,7 @@ describe('Menu component Effect-based error handling', () => {
78
78
  it('should handle GitError from getWorktreesEffect and display error message', async () => {
79
79
  const { Effect } = await import('effect');
80
80
  const { GitError } = await import('../types/errors.js');
81
- const onSelectWorktree = vi.fn();
81
+ const onMenuAction = vi.fn();
82
82
  const onDismissError = vi.fn();
83
83
  // Mock getWorktreesEffect to return a failing Effect
84
84
  const gitError = new GitError({
@@ -88,7 +88,7 @@ describe('Menu component Effect-based error handling', () => {
88
88
  stdout: '',
89
89
  });
90
90
  vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.fail(gitError));
91
- const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onDismissError: onDismissError, version: "test" }));
91
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onDismissError: onDismissError, version: "test" }));
92
92
  // Wait for Effect to execute
93
93
  await new Promise(resolve => setTimeout(resolve, 100));
94
94
  const output = lastFrame();
@@ -99,7 +99,7 @@ describe('Menu component Effect-based error handling', () => {
99
99
  });
100
100
  it('should successfully load worktrees using getWorktreesEffect', async () => {
101
101
  const { Effect } = await import('effect');
102
- const onSelectWorktree = vi.fn();
102
+ const onMenuAction = vi.fn();
103
103
  const mockWorktrees = [
104
104
  {
105
105
  path: '/test/main',
@@ -118,7 +118,7 @@ describe('Menu component Effect-based error handling', () => {
118
118
  vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed(mockWorktrees));
119
119
  // Mock getDefaultBranchEffect to return successful Effect
120
120
  vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
121
- const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, version: "test" }));
121
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }));
122
122
  // Wait for Effect to execute
123
123
  await new Promise(resolve => setTimeout(resolve, 100));
124
124
  const output = lastFrame();
@@ -129,7 +129,7 @@ describe('Menu component Effect-based error handling', () => {
129
129
  it('should handle GitError from getDefaultBranchEffect and display error message', async () => {
130
130
  const { Effect } = await import('effect');
131
131
  const { GitError } = await import('../types/errors.js');
132
- const onSelectWorktree = vi.fn();
132
+ const onMenuAction = vi.fn();
133
133
  const onDismissError = vi.fn();
134
134
  const mockWorktrees = [
135
135
  {
@@ -149,7 +149,7 @@ describe('Menu component Effect-based error handling', () => {
149
149
  stdout: '',
150
150
  });
151
151
  vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.fail(gitError));
152
- const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onDismissError: onDismissError, version: "test" }));
152
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onDismissError: onDismissError, version: "test" }));
153
153
  // Wait for Effect to execute
154
154
  await new Promise(resolve => setTimeout(resolve, 100));
155
155
  const output = lastFrame();
@@ -160,7 +160,7 @@ describe('Menu component Effect-based error handling', () => {
160
160
  });
161
161
  it('should use Effect composition to load worktrees and default branch together', async () => {
162
162
  const { Effect } = await import('effect');
163
- const onSelectWorktree = vi.fn();
163
+ const onMenuAction = vi.fn();
164
164
  const mockWorktrees = [
165
165
  {
166
166
  path: '/test/main',
@@ -176,7 +176,7 @@ describe('Menu component Effect-based error handling', () => {
176
176
  const getDefaultBranchSpy = vi
177
177
  .spyOn(worktreeService, 'getDefaultBranchEffect')
178
178
  .mockReturnValue(Effect.succeed('main'));
179
- render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, version: "test" }));
179
+ render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }));
180
180
  // Wait for Effect to execute
181
181
  await new Promise(resolve => setTimeout(resolve, 100));
182
182
  // Verify both Effect-based methods were called (Effect composition)
@@ -203,9 +203,9 @@ describe('Menu component rendering', () => {
203
203
  vi.restoreAllMocks();
204
204
  });
205
205
  it('should not render duplicate title when re-rendered with new key', async () => {
206
- const onSelectWorktree = vi.fn();
206
+ const onMenuAction = vi.fn();
207
207
  // First render
208
- const { unmount, lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, version: "test" }, 1));
208
+ const { unmount, lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }, 1));
209
209
  // Wait for async operations
210
210
  await new Promise(resolve => setTimeout(resolve, 100));
211
211
  const firstRenderOutput = lastFrame();
@@ -215,7 +215,7 @@ describe('Menu component rendering', () => {
215
215
  expect(titleCount).toBe(1);
216
216
  // Unmount and re-render with new key
217
217
  unmount();
218
- const { lastFrame: lastFrame2 } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, version: "test" }, 2));
218
+ const { lastFrame: lastFrame2 } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }, 2));
219
219
  await new Promise(resolve => setTimeout(resolve, 100));
220
220
  const secondRenderOutput = lastFrame2();
221
221
  const titleCount2 = (secondRenderOutput?.match(/CCManager - Claude Code Worktree Manager/g) ||
@@ -223,8 +223,8 @@ describe('Menu component rendering', () => {
223
223
  expect(titleCount2).toBe(1);
224
224
  });
225
225
  it('should render title and description only once', async () => {
226
- const onSelectWorktree = vi.fn();
227
- const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, version: "test" }));
226
+ const onMenuAction = vi.fn();
227
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }));
228
228
  await new Promise(resolve => setTimeout(resolve, 100));
229
229
  const output = lastFrame();
230
230
  // Check title appears only once
@@ -236,7 +236,7 @@ describe('Menu component rendering', () => {
236
236
  });
237
237
  it('should display number shortcuts for recent projects when worktrees < 10', async () => {
238
238
  const { Effect } = await import('effect');
239
- const onSelectWorktree = vi.fn();
239
+ const onMenuAction = vi.fn();
240
240
  const onSelectRecentProject = vi.fn();
241
241
  // Setup: 3 worktrees
242
242
  const mockWorktrees = [
@@ -280,7 +280,7 @@ describe('Menu component rendering', () => {
280
280
  teamMembers: 0,
281
281
  });
282
282
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
283
- const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true, version: "test" }));
283
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onSelectRecentProject: onSelectRecentProject, multiProject: true, version: "test" }));
284
284
  await new Promise(resolve => setTimeout(resolve, 100));
285
285
  const output = lastFrame();
286
286
  // Check that worktrees have numbers 0-2
@@ -294,7 +294,7 @@ describe('Menu component rendering', () => {
294
294
  });
295
295
  it('should not display number shortcuts for recent projects when worktrees >= 10', async () => {
296
296
  const { Effect } = await import('effect');
297
- const onSelectWorktree = vi.fn();
297
+ const onMenuAction = vi.fn();
298
298
  const onSelectRecentProject = vi.fn();
299
299
  // Setup: 10 worktrees
300
300
  const mockWorktrees = Array.from({ length: 10 }, (_, i) => ({
@@ -323,7 +323,7 @@ describe('Menu component rendering', () => {
323
323
  teamMembers: 0,
324
324
  });
325
325
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
326
- const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true, version: "test" }));
326
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onSelectRecentProject: onSelectRecentProject, multiProject: true, version: "test" }));
327
327
  await new Promise(resolve => setTimeout(resolve, 100));
328
328
  const output = lastFrame();
329
329
  // Check that recent projects don't have numbers (just ❯ prefix)
@@ -76,7 +76,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
76
76
  // Listen for restore event first
77
77
  sessionManager.on('sessionRestore', handleSessionRestore);
78
78
  // Mark session as active (this will trigger the restore event)
79
- sessionManager.setSessionActive(session.worktreePath, true);
79
+ sessionManager.setSessionActive(session.id, true);
80
80
  // Immediately resize the PTY and terminal to current dimensions
81
81
  // This fixes rendering issues when terminal width changed while in menu
82
82
  // https://github.com/kbwo/ccmanager/issues/2
@@ -142,7 +142,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
142
142
  return;
143
143
  }
144
144
  if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
145
- sessionManager.cancelAutoApproval(session.worktreePath, 'User input received during auto-approval');
145
+ sessionManager.cancelAutoApproval(session.id, 'User input received during auto-approval');
146
146
  }
147
147
  // Pass all other input directly to the PTY
148
148
  session.process.write(data);
@@ -156,7 +156,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
156
156
  resetTerminalInputModes();
157
157
  }
158
158
  // Mark session as inactive
159
- sessionManager.setSessionActive(session.worktreePath, false);
159
+ sessionManager.setSessionActive(session.id, false);
160
160
  // Remove event listeners
161
161
  sessionManager.off('sessionRestore', handleSessionRestore);
162
162
  sessionManager.off('sessionData', handleSessionData);
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ export type SessionActionType = 'newSession' | 'rename' | 'kill';
3
+ interface SessionActionsProps {
4
+ sessionLabel: string;
5
+ onSelect: (action: SessionActionType) => void;
6
+ onCancel: () => void;
7
+ }
8
+ declare const SessionActions: React.FC<SessionActionsProps>;
9
+ export default SessionActions;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import SelectInput from 'ink-select-input';
4
+ const items = [
5
+ { label: 'S New session in same directory', value: 'newSession' },
6
+ { label: 'R Rename this session', value: 'rename' },
7
+ { label: 'X Close session', value: 'kill' },
8
+ ];
9
+ const SessionActions = ({ sessionLabel, onSelect, onCancel, }) => {
10
+ useInput((input, key) => {
11
+ if (key.escape) {
12
+ onCancel();
13
+ return;
14
+ }
15
+ switch (input.toLowerCase()) {
16
+ case 's':
17
+ onSelect('newSession');
18
+ break;
19
+ case 'r':
20
+ onSelect('rename');
21
+ break;
22
+ case 'x':
23
+ onSelect('kill');
24
+ break;
25
+ }
26
+ });
27
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Session Actions" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: sessionLabel }) }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "S/R/X or arrow keys + Enter | Escape to cancel" }) })] }));
28
+ };
29
+ export default SessionActions;
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface SessionRenameProps {
3
+ currentName?: string;
4
+ onRename: (name?: string) => void;
5
+ onCancel: () => void;
6
+ }
7
+ declare const SessionRename: React.FC<SessionRenameProps>;
8
+ export default SessionRename;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ const SessionRename = ({ currentName, onRename, onCancel, }) => {
6
+ const [name, setName] = useState(currentName || '');
7
+ useInput((_input, key) => {
8
+ if (key.escape) {
9
+ onCancel();
10
+ }
11
+ });
12
+ const handleSubmit = () => {
13
+ const trimmed = name.trim();
14
+ onRename(trimmed || undefined);
15
+ };
16
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Rename Session" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: "Name: " }), _jsx(TextInput, { value: name, onChange: setName, onSubmit: handleSubmit, placeholder: "Enter session name (empty to clear)" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter to confirm, Escape to cancel" }) })] }));
17
+ };
18
+ export default SessionRename;