ccmanager 4.1.17 → 4.1.19

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.
@@ -572,6 +572,16 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
572
572
  setActiveSession(session);
573
573
  navigateWithClear('session');
574
574
  };
575
+ const handleSessionActionFromDashboard = (session, project) => {
576
+ const projectSessionManager = globalSessionOrchestrator.getManagerForProject(project.path);
577
+ setSessionManager(projectSessionManager);
578
+ setWorktreeService(new WorktreeService(project.path));
579
+ setSessionActionsTarget({
580
+ session,
581
+ worktreePath: session.worktreePath,
582
+ });
583
+ navigateWithClear('session-actions');
584
+ };
575
585
  const handleBackToProjectList = () => {
576
586
  // Sessions persist in their project-specific managers
577
587
  setSelectedProject(null);
@@ -587,7 +597,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
587
597
  if (!projectsDir) {
588
598
  return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: ["Error: ", MULTI_PROJECT_ERRORS.NO_PROJECTS_DIR] }) }));
589
599
  }
590
- return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, error: error, onDismissError: () => setError(null), version: version }));
600
+ return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, onSessionAction: handleSessionActionFromDashboard, error: error, onDismissError: () => setError(null), version: version }));
591
601
  }
592
602
  if (view === 'menu') {
593
603
  return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
@@ -4,6 +4,7 @@ interface DashboardProps {
4
4
  projectsDir: string;
5
5
  onSelectSession: (session: ISession, project: GitProject) => void;
6
6
  onSelectProject: (project: GitProject) => void;
7
+ onSessionAction?: (session: ISession, project: GitProject) => void;
7
8
  error: string | null;
8
9
  onDismissError: () => void;
9
10
  version: string;
@@ -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, assembleSessionLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
15
+ import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, displaySuffix, } 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;
@@ -63,7 +63,7 @@ function resolveProjectDisplayNames(projects) {
63
63
  }
64
64
  return displayNames;
65
65
  }
66
- const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDismissError, version, }) => {
66
+ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAction, error, onDismissError, version, }) => {
67
67
  const [projects, setProjects] = useState([]);
68
68
  const [recentProjects, setRecentProjects] = useState([]);
69
69
  const [loading, setLoading] = useState(true);
@@ -73,6 +73,8 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
73
73
  const [sessionEntries, setSessionEntries] = useState([]);
74
74
  const [baseSessionWorktrees, setBaseSessionWorktrees] = useState([]);
75
75
  const [sessionRefreshKey, setSessionRefreshKey] = useState(0);
76
+ const [highlightedSession, setHighlightedSession] = useState(null);
77
+ const [highlightedProject, setHighlightedProject] = useState(null);
76
78
  const displayError = error || loadError;
77
79
  const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
78
80
  isDisabled: !!displayError,
@@ -220,7 +222,10 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
220
222
  : wt.path.split('/').pop() || 'detached';
221
223
  const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
222
224
  const isMain = wt.isMainWorktree ? ' (main)' : '';
223
- const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${status}`;
225
+ const worktreeSessionCount = sessionEntries.filter(e => e.worktree.path === entry.worktree.path &&
226
+ e.projectPath === entry.projectPath).length;
227
+ const sessionSuffix = displaySuffix(entry.session, worktreeSessionCount > 1);
228
+ const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}${status}`;
224
229
  let fileChanges = '';
225
230
  let aheadBehind = '';
226
231
  let parentBranch = '';
@@ -407,6 +412,11 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
407
412
  return;
408
413
  }
409
414
  switch (keyPressed) {
415
+ case ' ':
416
+ if (highlightedSession && highlightedProject && onSessionAction) {
417
+ onSessionAction(highlightedSession, highlightedProject);
418
+ }
419
+ break;
410
420
  case 'r':
411
421
  refreshAll();
412
422
  break;
@@ -447,10 +457,20 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
447
457
  if (!item)
448
458
  return;
449
459
  handleSelect(item);
460
+ }, onHighlight: raw => {
461
+ const item = items.find(i => i.value === raw?.value);
462
+ if (item?.type === 'session') {
463
+ setHighlightedSession(item.session);
464
+ setHighlightedProject(item.project);
465
+ }
466
+ else {
467
+ setHighlightedSession(null);
468
+ setHighlightedProject(null);
469
+ }
450
470
  }, isFocused: !displayError, limit: limit, initialIndex: selectedIndex }) })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _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] }), _jsx(Text, { dimColor: true, children: isSearchMode
451
471
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
452
472
  : searchQuery
453
- ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
454
- : 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit' })] })] }));
473
+ ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Space-Session actions (session rows only) R-Refresh Q-Quit`
474
+ : 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Space-Session actions (session rows only) R-Refresh Q-Quit' })] })] }));
455
475
  };
456
476
  export default Dashboard;
@@ -1,26 +1,50 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from 'ink-testing-library';
3
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ const capturedHandlers = { inputHandlers: [], onHighlight: null };
5
+ const makeKey = (overrides = {}) => ({
6
+ upArrow: false,
7
+ downArrow: false,
8
+ leftArrow: false,
9
+ rightArrow: false,
10
+ pageDown: false,
11
+ pageUp: false,
12
+ home: false,
13
+ end: false,
14
+ return: false,
15
+ escape: false,
16
+ ctrl: false,
17
+ shift: false,
18
+ tab: false,
19
+ backspace: false,
20
+ delete: false,
21
+ meta: false,
22
+ ...overrides,
23
+ });
4
24
  // Mock bunTerminal to avoid native module loading issues
5
25
  vi.mock('../services/bunTerminal.js', () => ({
6
26
  spawn: vi.fn(function () {
7
27
  return null;
8
28
  }),
9
29
  }));
10
- // Import the actual component code but skip the useInput hook
30
+ // Import the actual component code but capture useInput handlers
11
31
  vi.mock('ink', async () => {
12
32
  const actual = await vi.importActual('ink');
13
33
  return {
14
34
  ...actual,
15
- useInput: vi.fn(),
35
+ useInput: vi.fn((handler) => {
36
+ capturedHandlers.inputHandlers.push(handler);
37
+ }),
16
38
  };
17
39
  });
18
- // Mock SelectInput to render items as simple text
40
+ // Mock SelectInput to render items as simple text and capture onHighlight
19
41
  vi.mock('ink-select-input', async () => {
20
42
  const React = await vi.importActual('react');
21
43
  const { Text, Box } = await vi.importActual('ink');
22
44
  return {
23
- default: ({ items }) => {
45
+ default: ({ items, onHighlight, }) => {
46
+ if (onHighlight)
47
+ capturedHandlers.onHighlight = onHighlight;
24
48
  return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
25
49
  },
26
50
  };
@@ -120,6 +144,8 @@ describe('Dashboard', () => {
120
144
  ];
121
145
  beforeEach(() => {
122
146
  vi.clearAllMocks();
147
+ capturedHandlers.inputHandlers = [];
148
+ capturedHandlers.onHighlight = null;
123
149
  vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
124
150
  vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([]);
125
151
  vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([]);
@@ -308,6 +334,7 @@ describe('Dashboard', () => {
308
334
  const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
309
335
  expect(lastFrame()).toContain('Controls:');
310
336
  expect(lastFrame()).toContain('0-9 Quick Select');
337
+ expect(lastFrame()).toContain('Space-Session actions');
311
338
  expect(lastFrame()).toContain('R-Refresh');
312
339
  expect(lastFrame()).toContain('Q-Quit');
313
340
  });
@@ -345,4 +372,261 @@ describe('Dashboard', () => {
345
372
  // shared-lib should appear first (index 0) because it's recent
346
373
  expect(frame).toContain('0 ❯ shared-lib');
347
374
  });
375
+ it('should show session name suffix in label when session has a name', async () => {
376
+ const mockSession = {
377
+ id: 'session-named',
378
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
379
+ sessionNumber: 1,
380
+ sessionName: 'my-feature',
381
+ lastActivity: new Date(),
382
+ isActive: true,
383
+ stateMutex: {
384
+ getSnapshot: () => ({
385
+ state: 'busy',
386
+ backgroundTaskCount: 0,
387
+ teamMemberCount: 0,
388
+ }),
389
+ },
390
+ };
391
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
392
+ '/projects/my-app',
393
+ ]);
394
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
395
+ mockSession,
396
+ ]);
397
+ vi.mocked(WorktreeService).mockImplementation(function () {
398
+ return {
399
+ getWorktreesEffect: () => Effect.succeed([
400
+ {
401
+ path: '/projects/my-app/worktrees/feature-auth',
402
+ branch: 'feature/auth',
403
+ isMainWorktree: false,
404
+ hasSession: true,
405
+ },
406
+ ]),
407
+ getGitRootPath: () => '/projects/my-app',
408
+ };
409
+ });
410
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
411
+ await new Promise(resolve => setTimeout(resolve, 200));
412
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
413
+ await vi.waitFor(() => {
414
+ return lastFrame()?.includes('Active Sessions') ?? false;
415
+ }, { timeout: 3000 });
416
+ expect(lastFrame()).toContain('my-app :: feature/auth: my-feature');
417
+ });
418
+ it('should show session number suffix when multiple unnamed sessions on same worktree', async () => {
419
+ const mockSessions = [
420
+ {
421
+ id: 'session-1',
422
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
423
+ sessionNumber: 1,
424
+ lastActivity: new Date(),
425
+ isActive: true,
426
+ stateMutex: {
427
+ getSnapshot: () => ({
428
+ state: 'busy',
429
+ backgroundTaskCount: 0,
430
+ teamMemberCount: 0,
431
+ }),
432
+ },
433
+ },
434
+ {
435
+ id: 'session-2',
436
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
437
+ sessionNumber: 2,
438
+ lastActivity: new Date(),
439
+ isActive: true,
440
+ stateMutex: {
441
+ getSnapshot: () => ({
442
+ state: 'idle',
443
+ backgroundTaskCount: 0,
444
+ teamMemberCount: 0,
445
+ }),
446
+ },
447
+ },
448
+ ];
449
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
450
+ '/projects/my-app',
451
+ ]);
452
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue(mockSessions);
453
+ vi.mocked(WorktreeService).mockImplementation(function () {
454
+ return {
455
+ getWorktreesEffect: () => Effect.succeed([
456
+ {
457
+ path: '/projects/my-app/worktrees/feature-auth',
458
+ branch: 'feature/auth',
459
+ isMainWorktree: false,
460
+ hasSession: true,
461
+ },
462
+ ]),
463
+ getGitRootPath: () => '/projects/my-app',
464
+ };
465
+ });
466
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
467
+ await new Promise(resolve => setTimeout(resolve, 200));
468
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
469
+ await vi.waitFor(() => {
470
+ return lastFrame()?.includes('Active Sessions') ?? false;
471
+ }, { timeout: 3000 });
472
+ const frame = lastFrame();
473
+ expect(frame).toContain('#1');
474
+ expect(frame).toContain('#2');
475
+ });
476
+ it('should show no session suffix for single unnamed session', async () => {
477
+ const mockSession = {
478
+ id: 'session-single',
479
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
480
+ sessionNumber: 1,
481
+ lastActivity: new Date(),
482
+ isActive: true,
483
+ stateMutex: {
484
+ getSnapshot: () => ({
485
+ state: 'busy',
486
+ backgroundTaskCount: 0,
487
+ teamMemberCount: 0,
488
+ }),
489
+ },
490
+ };
491
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
492
+ '/projects/my-app',
493
+ ]);
494
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
495
+ mockSession,
496
+ ]);
497
+ vi.mocked(WorktreeService).mockImplementation(function () {
498
+ return {
499
+ getWorktreesEffect: () => Effect.succeed([
500
+ {
501
+ path: '/projects/my-app/worktrees/feature-auth',
502
+ branch: 'feature/auth',
503
+ isMainWorktree: false,
504
+ hasSession: true,
505
+ },
506
+ ]),
507
+ getGitRootPath: () => '/projects/my-app',
508
+ };
509
+ });
510
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
511
+ await new Promise(resolve => setTimeout(resolve, 200));
512
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
513
+ await vi.waitFor(() => {
514
+ return lastFrame()?.includes('Active Sessions') ?? false;
515
+ }, { timeout: 3000 });
516
+ const frame = lastFrame();
517
+ expect(frame).toContain('my-app :: feature/auth');
518
+ expect(frame).not.toContain('feature/auth:');
519
+ expect(frame).not.toContain('feature/auth #');
520
+ });
521
+ it('should call onSessionAction when space is pressed on highlighted session', async () => {
522
+ const mockOnSessionAction = vi.fn();
523
+ const mockSession = {
524
+ id: 'session-action',
525
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
526
+ sessionNumber: 1,
527
+ lastActivity: new Date(),
528
+ isActive: true,
529
+ stateMutex: {
530
+ getSnapshot: () => ({
531
+ state: 'busy',
532
+ backgroundTaskCount: 0,
533
+ teamMemberCount: 0,
534
+ }),
535
+ },
536
+ };
537
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
538
+ '/projects/my-app',
539
+ ]);
540
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
541
+ mockSession,
542
+ ]);
543
+ vi.mocked(WorktreeService).mockImplementation(function () {
544
+ return {
545
+ getWorktreesEffect: () => Effect.succeed([
546
+ {
547
+ path: '/projects/my-app/worktrees/feature-auth',
548
+ branch: 'feature/auth',
549
+ isMainWorktree: false,
550
+ hasSession: true,
551
+ },
552
+ ]),
553
+ getGitRootPath: () => '/projects/my-app',
554
+ };
555
+ });
556
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
557
+ await new Promise(resolve => setTimeout(resolve, 200));
558
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
559
+ await vi.waitFor(() => {
560
+ return lastFrame()?.includes('Active Sessions') ?? false;
561
+ }, { timeout: 3000 });
562
+ // Enable the useInput handler (Dashboard guards on process.stdin.setRawMode)
563
+ const origSetRawMode = process.stdin.setRawMode;
564
+ process.stdin.setRawMode = vi.fn();
565
+ // Simulate highlighting a session item
566
+ expect(capturedHandlers.onHighlight).not.toBeNull();
567
+ capturedHandlers.onHighlight({
568
+ value: 'session-session-action',
569
+ label: 'my-app :: feature/auth',
570
+ });
571
+ // Force re-render to flush state update — new useInput handler captures updated closure
572
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
573
+ await new Promise(resolve => setTimeout(resolve, 50));
574
+ // Use the latest Dashboard handler (last in array — has updated highlightedSession state)
575
+ const latestHandler = capturedHandlers.inputHandlers[capturedHandlers.inputHandlers.length - 1];
576
+ expect(latestHandler).toBeDefined();
577
+ latestHandler(' ', makeKey());
578
+ expect(mockOnSessionAction).toHaveBeenCalledWith(expect.objectContaining({ id: 'session-action' }), expect.objectContaining({ path: '/projects/my-app' }));
579
+ process.stdin.setRawMode = origSetRawMode;
580
+ });
581
+ it('should not call onSessionAction when space is pressed with no highlighted session', async () => {
582
+ const mockOnSessionAction = vi.fn();
583
+ const mockSession = {
584
+ id: 'session-noop',
585
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
586
+ sessionNumber: 1,
587
+ lastActivity: new Date(),
588
+ isActive: true,
589
+ stateMutex: {
590
+ getSnapshot: () => ({
591
+ state: 'busy',
592
+ backgroundTaskCount: 0,
593
+ teamMemberCount: 0,
594
+ }),
595
+ },
596
+ };
597
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
598
+ '/projects/my-app',
599
+ ]);
600
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
601
+ mockSession,
602
+ ]);
603
+ vi.mocked(WorktreeService).mockImplementation(function () {
604
+ return {
605
+ getWorktreesEffect: () => Effect.succeed([
606
+ {
607
+ path: '/projects/my-app/worktrees/feature-auth',
608
+ branch: 'feature/auth',
609
+ isMainWorktree: false,
610
+ hasSession: true,
611
+ },
612
+ ]),
613
+ getGitRootPath: () => '/projects/my-app',
614
+ };
615
+ });
616
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
617
+ await new Promise(resolve => setTimeout(resolve, 200));
618
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
619
+ await vi.waitFor(() => {
620
+ return lastFrame()?.includes('Active Sessions') ?? false;
621
+ }, { timeout: 3000 });
622
+ // Enable the useInput handler (Dashboard guards on process.stdin.setRawMode)
623
+ const origSetRawMode = process.stdin.setRawMode;
624
+ process.stdin.setRawMode = vi.fn();
625
+ // Do NOT call onHighlight — highlightedSession stays null
626
+ const dashboardHandler = capturedHandlers.inputHandlers.find(handler => handler !== capturedHandlers.inputHandlers[0]);
627
+ expect(dashboardHandler).toBeDefined();
628
+ dashboardHandler(' ', makeKey());
629
+ expect(mockOnSessionAction).not.toHaveBeenCalled();
630
+ process.stdin.setRawMode = origSetRawMode;
631
+ });
348
632
  });
@@ -45,6 +45,8 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
45
45
  return;
46
46
  // Check for return to menu shortcut
47
47
  if (shortcutManager.matchesRawInput('returnToMenu', data)) {
48
+ isExitingRef.current = true;
49
+ sessionManager.setSessionActive(session.id, false);
48
50
  // Disable any extended input modes that might have been enabled by the PTY
49
51
  if (stdout) {
50
52
  resetTerminalInputModes();
@@ -72,7 +74,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
72
74
  // restore and the deferred restore that may fire after Session.tsx
73
75
  // has already disabled DECAWM for live TUI redraws.
74
76
  const handleSessionRestore = (restoredSession, restoreSnapshot) => {
75
- if (restoredSession.id === session.id) {
77
+ if (restoredSession.id === session.id && !isExitingRef.current) {
76
78
  if (restoreSnapshot.length > 0) {
77
79
  stdout.write(`\x1b[?7h${restoreSnapshot}\x1b[?7l`);
78
80
  }
@@ -86,7 +88,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
86
88
  // appends below the (already-clipped) viewport, producing duplicated
87
89
  // rows equal to the resize delta.
88
90
  const handleSessionResize = (resizedSession, redrawPayload) => {
89
- if (resizedSession.id === session.id && redrawPayload.length > 0) {
91
+ if (resizedSession.id === session.id &&
92
+ redrawPayload.length > 0 &&
93
+ !isExitingRef.current) {
90
94
  stdout.write(redrawPayload);
91
95
  }
92
96
  };
@@ -99,4 +99,56 @@ describe('Session', () => {
99
99
  expect(testState.stdout?.write).toHaveBeenNthCalledWith(2, '\x1b[?7h\nrestored\x1b[?7l');
100
100
  expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\x1b[?7l');
101
101
  });
102
+ it('detaches synchronously when returning to menu so late session output cannot repaint', async () => {
103
+ const listeners = new Map();
104
+ const session = {
105
+ id: 'session-1',
106
+ process: {
107
+ write: vi.fn(),
108
+ resize: vi.fn(),
109
+ },
110
+ terminal: {
111
+ resize: vi.fn(),
112
+ },
113
+ stateMutex: {
114
+ getSnapshot: () => ({ state: 'busy' }),
115
+ },
116
+ };
117
+ const onReturnToMenu = vi.fn();
118
+ const setSessionActive = vi.fn();
119
+ const sessionManager = {
120
+ on: vi.fn((event, handler) => {
121
+ const handlers = listeners.get(event) ?? new Set();
122
+ handlers.add(handler);
123
+ listeners.set(event, handlers);
124
+ return sessionManager;
125
+ }),
126
+ off: vi.fn((event, handler) => {
127
+ listeners.get(event)?.delete(handler);
128
+ return sessionManager;
129
+ }),
130
+ setSessionActive,
131
+ cancelAutoApproval: vi.fn(),
132
+ performResize: vi.fn(),
133
+ };
134
+ const { unmount } = render(_jsx(Session, { session: session, sessionManager: sessionManager, onReturnToMenu: onReturnToMenu }));
135
+ await new Promise(resolve => setTimeout(resolve, 0));
136
+ testState.stdout?.write.mockClear();
137
+ process.stdin.emit('data', '\u0005');
138
+ for (const handler of listeners.get('sessionData') ?? []) {
139
+ handler(session, 'late-data');
140
+ }
141
+ for (const handler of listeners.get('sessionRestore') ?? []) {
142
+ handler(session, 'late-restore');
143
+ }
144
+ for (const handler of listeners.get('sessionResize') ?? []) {
145
+ handler(session, 'late-resize');
146
+ }
147
+ expect(setSessionActive).toHaveBeenCalledWith(session.id, false);
148
+ expect(onReturnToMenu).toHaveBeenCalledTimes(1);
149
+ expect(testState.stdout?.write).not.toHaveBeenCalledWith('late-data');
150
+ expect(testState.stdout?.write).not.toHaveBeenCalledWith('\x1b[?7hlate-restore\x1b[?7l');
151
+ expect(testState.stdout?.write).not.toHaveBeenCalledWith('late-resize');
152
+ unmount();
153
+ });
102
154
  });
@@ -22,6 +22,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
22
22
  private resizeSuppressTimers;
23
23
  private restoreDeferTimers;
24
24
  private restoreDeferDeadlines;
25
+ private cursorRedrawSessions;
26
+ private cursorRedrawTimers;
25
27
  private spawn;
26
28
  private resolvePreset;
27
29
  detectTerminalState(session: Session): SessionState;
@@ -42,6 +44,9 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
42
44
  constructor();
43
45
  private createTerminal;
44
46
  private shouldResetRestoreScrollback;
47
+ private hasCursorAddressedRedraw;
48
+ private markCursorRedrawActive;
49
+ private handleScrollbackGrowthDuringRedraw;
45
50
  private getRestoreSnapshot;
46
51
  private getViewportRedrawSnapshot;
47
52
  /**
@@ -89,6 +94,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
89
94
  private armRestoreDeferTimer;
90
95
  private fireRestoreDefer;
91
96
  private cancelRestoreDefer;
97
+ private clearCursorRedrawTracking;
92
98
  cancelAutoApproval(sessionId: string, reason?: string): void;
93
99
  toggleAutoApprovalForWorktree(worktreePath: string): boolean;
94
100
  isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
@@ -20,7 +20,7 @@ import { preparePresetLaunch } from '../utils/presetPrompt.js';
20
20
  const { Terminal } = pkg;
21
21
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
22
  const TERMINAL_SCROLLBACK_LINES = 5000;
23
- const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
23
+ const TERMINAL_RESTORE_SCROLLBACK_LINES = TERMINAL_SCROLLBACK_LINES;
24
24
  // How long to suppress PTY → stdout forwarding after a viewport resize so the
25
25
  // child process's SIGWINCH-triggered re-emission of static content does not
26
26
  // duplicate already-displayed rows in the user's terminal.
@@ -33,6 +33,11 @@ const RESIZE_SUPPRESS_MS = 250;
33
33
  // streaming session still restores within a small bounded delay.
34
34
  const RESTORE_DEFER_QUIET_MS = 80;
35
35
  const RESTORE_DEFER_MAX_MS = 250;
36
+ // Cursor-addressed redraws (progress bars, spinners, TUIs) can leave transient
37
+ // rows in scrollback if the viewport scrolls before those rows are overwritten.
38
+ // Keep a short generic redraw window so restore can skip that ghost-bearing
39
+ // range without depending on any specific CLI output text.
40
+ const REDRAW_SCROLLBACK_QUIET_MS = 500;
36
41
  export class SessionManager extends EventEmitter {
37
42
  sessions;
38
43
  waitingWithBottomBorder = new Map();
@@ -44,6 +49,8 @@ export class SessionManager extends EventEmitter {
44
49
  resizeSuppressTimers = new Map();
45
50
  restoreDeferTimers = new Map();
46
51
  restoreDeferDeadlines = new Map();
52
+ cursorRedrawSessions = new Set();
53
+ cursorRedrawTimers = new Map();
47
54
  async spawn(command, args, worktreePath, options = {}) {
48
55
  const spawnOptions = {
49
56
  name: 'xterm-256color',
@@ -231,6 +238,35 @@ export class SessionManager extends EventEmitter {
231
238
  data.includes('\x1b[3J') ||
232
239
  data.includes('\x1bc'));
233
240
  }
241
+ hasCursorAddressedRedraw(data) {
242
+ return (
243
+ // CSI cursor movement/positioning, erase, and scroll controls.
244
+ /\x1b\[[0-?]*[ -/]*[ABCDEFGHJKSTX`abcdefg]/.test(data) ||
245
+ // DEC save/restore cursor.
246
+ /\x1b[78]/.test(data) ||
247
+ // Synchronized output mode usually brackets full-frame redraws.
248
+ /\x1b\[\?2026[hl]/.test(data));
249
+ }
250
+ markCursorRedrawActive(session) {
251
+ this.cursorRedrawSessions.add(session.id);
252
+ const existing = this.cursorRedrawTimers.get(session.id);
253
+ if (existing !== undefined) {
254
+ clearTimeout(existing);
255
+ }
256
+ const timer = setTimeout(() => {
257
+ this.cursorRedrawTimers.delete(session.id);
258
+ this.cursorRedrawSessions.delete(session.id);
259
+ }, REDRAW_SCROLLBACK_QUIET_MS);
260
+ this.cursorRedrawTimers.set(session.id, timer);
261
+ }
262
+ handleScrollbackGrowthDuringRedraw(session, beforeBaseY) {
263
+ const afterBaseY = session.terminal.buffer.normal.baseY;
264
+ if (afterBaseY <= beforeBaseY ||
265
+ !this.cursorRedrawSessions.has(session.id)) {
266
+ return;
267
+ }
268
+ session.restoreScrollbackBaseLine = Math.max(session.restoreScrollbackBaseLine, afterBaseY);
269
+ }
234
270
  getRestoreSnapshot(session) {
235
271
  const activeBuffer = session.terminal.buffer.active;
236
272
  if (activeBuffer.type !== 'normal') {
@@ -245,19 +281,6 @@ export class SessionManager extends EventEmitter {
245
281
  }
246
282
  const cursorRow = normalBuffer.cursorY + 1;
247
283
  const cursorCol = normalBuffer.cursorX + 1;
248
- // When the live viewport shows a transient footer (spinner activity,
249
- // token stats, persistent shift+tab footer, etc.), the renderer keeps
250
- // redrawing it in place and earlier copies have likely been pushed into
251
- // scrollback by chat output scrolling beneath it. Replaying that
252
- // scrollback would paint duplicated footer rows, so emit only the
253
- // viewport in this case.
254
- if (session.stateDetector.hasTransientRenderFooter(session.terminal)) {
255
- const viewportSnapshot = session.serializer.serialize({
256
- scrollback: 0,
257
- excludeAltBuffer: true,
258
- });
259
- return `${viewportSnapshot}\x1b[${cursorRow};${cursorCol}H`;
260
- }
261
284
  const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
262
285
  const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
263
286
  const rangeEnd = bufferLength - 1;
@@ -434,8 +457,13 @@ export class SessionManager extends EventEmitter {
434
457
  setupDataHandler(session) {
435
458
  // This handler always runs for all data
436
459
  session.process.onData((data) => {
460
+ const beforeBaseY = session.terminal.buffer.normal.baseY;
461
+ if (this.hasCursorAddressedRedraw(data)) {
462
+ this.markCursorRedrawActive(session);
463
+ }
437
464
  // Write data to virtual terminal
438
465
  session.terminal.write(data);
466
+ this.handleScrollbackGrowthDuringRedraw(session, beforeBaseY);
439
467
  if (this.shouldResetRestoreScrollback(data)) {
440
468
  session.restoreScrollbackBaseLine =
441
469
  session.terminal.buffer.normal.baseY;
@@ -678,6 +706,14 @@ export class SessionManager extends EventEmitter {
678
706
  }
679
707
  this.restoreDeferDeadlines.delete(sessionId);
680
708
  }
709
+ clearCursorRedrawTracking(sessionId) {
710
+ this.cursorRedrawSessions.delete(sessionId);
711
+ const timer = this.cursorRedrawTimers.get(sessionId);
712
+ if (timer !== undefined) {
713
+ clearTimeout(timer);
714
+ this.cursorRedrawTimers.delete(sessionId);
715
+ }
716
+ }
681
717
  cancelAutoApproval(sessionId, reason = 'User input received') {
682
718
  const session = this.sessions.get(sessionId);
683
719
  if (!session) {
@@ -755,6 +791,7 @@ export class SessionManager extends EventEmitter {
755
791
  clearTimeout(resizeTimer);
756
792
  this.resizeSuppressTimers.delete(sessionId);
757
793
  }
794
+ this.clearCursorRedrawTracking(sessionId);
758
795
  this.cancelRestoreDefer(sessionId);
759
796
  this.emit('sessionDestroyed', session);
760
797
  }
@@ -815,6 +852,7 @@ export class SessionManager extends EventEmitter {
815
852
  clearTimeout(resizeTimer);
816
853
  this.resizeSuppressTimers.delete(sessionId);
817
854
  }
855
+ this.clearCursorRedrawTracking(sessionId);
818
856
  this.cancelRestoreDefer(sessionId);
819
857
  this.emit('sessionDestroyed', session);
820
858
  },
@@ -774,7 +774,7 @@ describe('SessionManager', () => {
774
774
  });
775
775
  });
776
776
  describe('session restore snapshots', () => {
777
- it('should emit a bounded normal-buffer restore snapshot and restore the cursor position', async () => {
777
+ it('should emit a normal-buffer restore snapshot from the restore baseline and restore the cursor position', async () => {
778
778
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
779
779
  id: '1',
780
780
  name: 'Main',
@@ -803,6 +803,32 @@ describe('SessionManager', () => {
803
803
  });
804
804
  expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
805
805
  });
806
+ it('should restore all retained normal-buffer scrollback when no baseline excludes it', async () => {
807
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
808
+ id: '1',
809
+ name: 'Main',
810
+ command: 'claude',
811
+ });
812
+ vi.mocked(spawn).mockReturnValue(mockPty);
813
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
814
+ const normalBuffer = session.terminal.buffer.normal;
815
+ normalBuffer.baseY = 260;
816
+ normalBuffer.length = 300;
817
+ normalBuffer.cursorY = 7;
818
+ normalBuffer.cursorX = 11;
819
+ session.restoreScrollbackBaseLine = 0;
820
+ const serializeMock = vi
821
+ .spyOn(session.serializer, 'serialize')
822
+ .mockReturnValue('\u001b[31mrestored\u001b[0m');
823
+ sessionManager.setSessionActive(session.id, true);
824
+ expect(serializeMock).toHaveBeenCalledWith({
825
+ range: {
826
+ start: 0,
827
+ end: 299,
828
+ },
829
+ excludeAltBuffer: true,
830
+ });
831
+ });
806
832
  it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
807
833
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
808
834
  id: '1',
@@ -847,6 +873,69 @@ describe('SessionManager', () => {
847
873
  mockPty.emit('data', '\x1b[2J\x1b[Hfresh');
848
874
  expect(session.restoreScrollbackBaseLine).toBe(17);
849
875
  });
876
+ it('should keep full scrollback for normal output that scrolls', async () => {
877
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
878
+ id: '1',
879
+ name: 'Main',
880
+ command: 'claude',
881
+ });
882
+ vi.mocked(spawn).mockReturnValue(mockPty);
883
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
884
+ const normalBuffer = session.terminal.buffer.normal;
885
+ normalBuffer.baseY = 10;
886
+ vi.mocked(session.terminal.write).mockImplementation(() => {
887
+ normalBuffer.baseY = 12;
888
+ });
889
+ mockPty.emit('data', 'ordinary output\n');
890
+ expect(session.restoreScrollbackBaseLine).toBe(0);
891
+ });
892
+ it('should skip scrollback that grows during cursor-addressed redraws', async () => {
893
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
894
+ id: '1',
895
+ name: 'Main',
896
+ command: 'claude',
897
+ });
898
+ vi.mocked(spawn).mockReturnValue(mockPty);
899
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
900
+ const normalBuffer = session.terminal.buffer.normal;
901
+ normalBuffer.baseY = 10;
902
+ vi.mocked(session.terminal.write).mockImplementation(() => {
903
+ normalBuffer.baseY = 12;
904
+ });
905
+ mockPty.emit('data', '\x1b[Hredrawn status\n');
906
+ expect(session.restoreScrollbackBaseLine).toBe(12);
907
+ });
908
+ it('should keep cursor-addressed redraw tracking active for a short quiet window', async () => {
909
+ vi.useFakeTimers();
910
+ try {
911
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
912
+ id: '1',
913
+ name: 'Main',
914
+ command: 'claude',
915
+ });
916
+ vi.mocked(spawn).mockReturnValue(mockPty);
917
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
918
+ const normalBuffer = session.terminal.buffer.normal;
919
+ normalBuffer.baseY = 10;
920
+ vi.mocked(session.terminal.write).mockImplementation(() => {
921
+ if (normalBuffer.baseY === 10) {
922
+ return;
923
+ }
924
+ normalBuffer.baseY++;
925
+ });
926
+ mockPty.emit('data', '\x1b[Hredraw frame');
927
+ normalBuffer.baseY = 11;
928
+ mockPty.emit('data', 'plain continuation that scrolls');
929
+ expect(session.restoreScrollbackBaseLine).toBe(12);
930
+ vi.advanceTimersByTime(501);
931
+ normalBuffer.baseY = 20;
932
+ mockPty.emit('data', 'ordinary output after quiet window');
933
+ expect(session.restoreScrollbackBaseLine).toBe(12);
934
+ }
935
+ finally {
936
+ vi.useRealTimers();
937
+ }
938
+ });
850
939
  it('should flush live session data after the restore snapshot completes', async () => {
851
940
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
852
941
  id: '1',
@@ -872,7 +961,7 @@ describe('SessionManager', () => {
872
961
  sessionManager.setSessionActive(session.id, true);
873
962
  expect(eventOrder).toEqual(['restore', 'data']);
874
963
  });
875
- it('should defer the viewport-only restore until PTY output is quiet when a transient footer is visible', async () => {
964
+ it('should defer the scrollback restore until PTY output is quiet when a transient footer is visible', async () => {
876
965
  vi.useFakeTimers();
877
966
  try {
878
967
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -899,10 +988,13 @@ describe('SessionManager', () => {
899
988
  expect(serializeMock).not.toHaveBeenCalled();
900
989
  vi.advanceTimersByTime(80);
901
990
  expect(serializeMock).toHaveBeenCalledWith({
902
- scrollback: 0,
991
+ range: {
992
+ start: 120,
993
+ end: 299,
994
+ },
903
995
  excludeAltBuffer: true,
904
996
  });
905
- expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({ range: expect.anything() }));
997
+ expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({ scrollback: 0 }));
906
998
  expect(restoreHandler).toHaveBeenCalledWith(session, 'viewport');
907
999
  }
908
1000
  finally {
@@ -29,6 +29,7 @@ export declare function extractBranchParts(branchName: string): {
29
29
  prefix?: string;
30
30
  name: string;
31
31
  };
32
+ export declare function displaySuffix(session: Session, multipleForWorktree: boolean): string;
32
33
  /**
33
34
  * Prepares session items for display.
34
35
  * Supports multiple sessions per worktree.
@@ -122,7 +122,7 @@ function indexSessionsByWorktree(sessions) {
122
122
  }
123
123
  return { byWorktreePath, maxAccessAt };
124
124
  }
125
- function displaySuffix(session, multipleForWorktree) {
125
+ export function displaySuffix(session, multipleForWorktree) {
126
126
  if (multipleForWorktree) {
127
127
  return session.sessionName
128
128
  ? `: ${session.sessionName}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.17",
3
+ "version": "4.1.19",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.17",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.17",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.17",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.17",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.17"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.19",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.19",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.19",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.19",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.19"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",