ccmanager 4.1.24 → 4.2.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.
@@ -32,6 +32,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
32
32
  const [error, setError] = useState(null);
33
33
  const [worktreeHookError, setWorktreeHookError] = useState(null);
34
34
  const [menuKey, setMenuKey] = useState(0); // Force menu refresh
35
+ const [menuSnapshots, setMenuSnapshots] = useState({});
35
36
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
36
37
  const [renameTarget, setRenameTarget] = useState(null);
37
38
  const [sessionActionsTarget, setSessionActionsTarget] = useState(null);
@@ -45,6 +46,25 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
45
46
  // State for streaming devcontainer up logs
46
47
  const [devcontainerLogs, setDevcontainerLogs] = useState([]);
47
48
  const [canReturnFromHookError, setCanReturnFromHookError] = useState(false);
49
+ const menuSnapshotKey = selectedProject?.path ?? process.cwd();
50
+ const handleMenuSnapshotChange = useCallback((snapshot) => {
51
+ setMenuSnapshots(previous => ({
52
+ ...previous,
53
+ [menuSnapshotKey]: snapshot,
54
+ }));
55
+ }, [menuSnapshotKey]);
56
+ const updateMenuSnapshot = useCallback((update) => {
57
+ setMenuSnapshots(previous => {
58
+ const current = previous[menuSnapshotKey];
59
+ if (!current) {
60
+ return previous;
61
+ }
62
+ return {
63
+ ...previous,
64
+ [menuSnapshotKey]: update(current),
65
+ };
66
+ });
67
+ }, [menuSnapshotKey]);
48
68
  useEffect(() => {
49
69
  if (view !== 'worktree-hook-error') {
50
70
  setCanReturnFromHookError(false);
@@ -219,6 +239,18 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
219
239
  // Helper function to handle worktree creation results
220
240
  const handleWorktreeCreationResult = (result, creationData) => {
221
241
  if (result.success) {
242
+ updateMenuSnapshot(snapshot => ({
243
+ ...snapshot,
244
+ worktrees: [
245
+ ...snapshot.worktrees.filter(worktree => worktree.path !== creationData.path),
246
+ {
247
+ path: creationData.path,
248
+ branch: creationData.branch,
249
+ isMainWorktree: false,
250
+ hasSession: false,
251
+ },
252
+ ],
253
+ }));
222
254
  if (result.warning) {
223
255
  setError(null);
224
256
  setWorktreeHookError(result.warning);
@@ -516,6 +548,11 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
516
548
  }
517
549
  }
518
550
  if (!hasError) {
551
+ const deletedPaths = new Set(worktreePaths);
552
+ updateMenuSnapshot(snapshot => ({
553
+ ...snapshot,
554
+ worktrees: snapshot.worktrees.filter(worktree => !deletedPaths.has(worktree.path)),
555
+ }));
519
556
  // Success - return to menu
520
557
  handleReturnToMenu();
521
558
  }
@@ -581,7 +618,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
581
618
  return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, onSessionAction: handleSessionActionFromDashboard, error: error, onDismissError: () => setError(null), version: version }));
582
619
  }
583
620
  if (view === 'menu') {
584
- return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
621
+ return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: menuSnapshots[menuSnapshotKey], onSnapshotChange: handleMenuSnapshotChange, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
585
622
  }
586
623
  if (view === 'session' && activeSession) {
587
624
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Session, { session: activeSession, sessionManager: sessionManager, onReturnToMenu: handleReturnToMenu }, activeSession.id) }));
@@ -169,6 +169,46 @@ describe('App component view state', () => {
169
169
  });
170
170
  });
171
171
  describe('App component loading state machine', () => {
172
+ it('updates the cached worktree snapshot after creating and deleting worktrees', async () => {
173
+ const { unmount } = render(_jsx(App, { version: "test" }));
174
+ await waitForCondition(() => Boolean(menuProps));
175
+ menuProps.onSnapshotChange?.({
176
+ worktrees: [
177
+ {
178
+ path: '/tmp/existing',
179
+ branch: 'existing',
180
+ isMainWorktree: true,
181
+ hasSession: false,
182
+ },
183
+ ],
184
+ defaultBranch: 'main',
185
+ });
186
+ await flush();
187
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
188
+ await waitForCondition(() => Boolean(newWorktreeProps));
189
+ await Promise.resolve(newWorktreeProps.onComplete({
190
+ creationMode: 'manual',
191
+ path: '/tmp/created',
192
+ branch: 'created',
193
+ baseBranch: 'main',
194
+ copySessionData: false,
195
+ copyClaudeDirectory: false,
196
+ }));
197
+ await waitForCondition(() => Boolean(menuProps?.initialSnapshot?.worktrees.some(worktree => worktree.path === '/tmp/created')));
198
+ await Promise.resolve(menuProps.onMenuAction({ type: 'deleteWorktree' }));
199
+ await waitForCondition(() => Boolean(deleteWorktreeProps));
200
+ await Promise.resolve(deleteWorktreeProps.onComplete(['/tmp/created'], true));
201
+ await waitForCondition(() => !menuProps?.initialSnapshot?.worktrees.some(worktree => worktree.path === '/tmp/created'));
202
+ expect(menuProps?.initialSnapshot?.worktrees).toEqual([
203
+ {
204
+ path: '/tmp/existing',
205
+ branch: 'existing',
206
+ isMainWorktree: true,
207
+ hasSession: false,
208
+ },
209
+ ]);
210
+ unmount();
211
+ });
172
212
  it('displays copying message while creating a worktree with session data', async () => {
173
213
  let resolveWorktree;
174
214
  createWorktreeEffectMock.mockImplementation(() => Effect.tryPromise({
@@ -1,10 +1,12 @@
1
1
  import React from 'react';
2
- import { GitProject, MenuAction } from '../types/index.js';
2
+ import { Worktree, 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
+ initialSnapshot?: MenuSnapshot;
9
+ onSnapshotChange?: (snapshot: MenuSnapshot) => void;
8
10
  onMenuAction: (action: MenuAction) => void;
9
11
  onSelectRecentProject?: (project: GitProject) => void;
10
12
  error?: string | null;
@@ -13,5 +15,10 @@ interface MenuProps {
13
15
  multiProject?: boolean;
14
16
  version: string;
15
17
  }
18
+ export interface MenuSnapshot {
19
+ worktrees: Worktree[];
20
+ defaultBranch: string | null;
21
+ loadError?: string | null;
22
+ }
16
23
  declare const Menu: React.FC<MenuProps>;
17
24
  export default Menu;
@@ -1,5 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import SelectInput from 'ink-select-input';
5
5
  import { Effect } from 'effect';
@@ -29,10 +29,15 @@ 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, onMenuAction, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, version, }) => {
33
- const [baseWorktrees, setBaseWorktrees] = useState([]);
34
- const [defaultBranch, setDefaultBranch] = useState(null);
35
- const [loadError, setLoadError] = useState(null);
32
+ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChange, onMenuAction, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, version, }) => {
33
+ const [baseWorktrees, setBaseWorktrees] = useState(() => initialSnapshot?.worktrees ?? []);
34
+ const [defaultBranch, setDefaultBranch] = useState(() => initialSnapshot?.defaultBranch ?? null);
35
+ const [loadError, setLoadError] = useState(() => initialSnapshot?.loadError ?? null);
36
+ const snapshotRef = useRef({
37
+ worktrees: initialSnapshot?.worktrees ?? [],
38
+ defaultBranch: initialSnapshot?.defaultBranch ?? null,
39
+ loadError: initialSnapshot?.loadError ?? null,
40
+ });
36
41
  const worktrees = useGitStatus(baseWorktrees, defaultBranch);
37
42
  const [sessions, setSessions] = useState([]);
38
43
  const [items, setItems] = useState([]);
@@ -53,12 +58,12 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
53
58
  const worktreeConfig = configReader.getWorktreeConfig();
54
59
  useEffect(() => {
55
60
  let cancelled = false;
56
- // Load worktrees and default branch using Effect composition
57
- // Chain getWorktreesEffect and getDefaultBranchEffect using Effect.flatMap
58
- const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect(), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
59
- worktrees,
60
- defaultBranch,
61
- })));
61
+ // These operations are independent. Run them concurrently so the initial
62
+ // menu load waits only for the slower command.
63
+ const loadWorktreesAndBranch = Effect.all({
64
+ worktrees: worktreeService.getWorktreesEffect(),
65
+ defaultBranch: worktreeService.getDefaultBranchEffect(),
66
+ }, { concurrency: 'unbounded' });
62
67
  Effect.runPromise(Effect.match(loadWorktreesAndBranch, {
63
68
  onFailure: (error) => ({
64
69
  success: false,
@@ -83,17 +88,38 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
83
88
  setBaseWorktrees(result.worktrees);
84
89
  setDefaultBranch(result.defaultBranch);
85
90
  setLoadError(null);
91
+ const snapshot = {
92
+ worktrees: result.worktrees,
93
+ defaultBranch: result.defaultBranch,
94
+ loadError: null,
95
+ };
96
+ snapshotRef.current = snapshot;
97
+ onSnapshotChange?.(snapshot);
86
98
  }
87
99
  else {
88
100
  // Handle GitError with pattern matching
89
- setLoadError(formatGitError(result.error));
101
+ const loadError = formatGitError(result.error);
102
+ setLoadError(loadError);
103
+ const snapshot = {
104
+ ...snapshotRef.current,
105
+ loadError,
106
+ };
107
+ snapshotRef.current = snapshot;
108
+ onSnapshotChange?.(snapshot);
90
109
  }
91
110
  }
92
111
  })
93
112
  .catch((err) => {
94
113
  // This catch should not normally be reached with Effect.match
95
114
  if (!cancelled) {
96
- setLoadError(String(err));
115
+ const loadError = String(err);
116
+ setLoadError(loadError);
117
+ const snapshot = {
118
+ ...snapshotRef.current,
119
+ loadError,
120
+ };
121
+ snapshotRef.current = snapshot;
122
+ onSnapshotChange?.(snapshot);
97
123
  }
98
124
  });
99
125
  // Load recent projects if in multi-project mode
@@ -118,7 +144,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
118
144
  sessionManager.off('sessionDestroyed', handleSessionChange);
119
145
  sessionManager.off('sessionStateChanged', handleSessionChange);
120
146
  };
121
- }, [sessionManager, worktreeService, multiProject]);
147
+ }, [sessionManager, worktreeService, multiProject, onSnapshotChange]);
122
148
  useEffect(() => {
123
149
  // Prepare worktree items and calculate layout
124
150
  const items = prepareSessionItems(worktrees, sessions, {
@@ -183,6 +183,76 @@ describe('Menu component Effect-based error handling', () => {
183
183
  expect(getWorktreesSpy).toHaveBeenCalled();
184
184
  expect(getDefaultBranchSpy).toHaveBeenCalled();
185
185
  });
186
+ it('should render a cached snapshot before its background refresh completes', async () => {
187
+ const { Effect } = await import('effect');
188
+ const cachedWorktree = {
189
+ path: '/test/cached',
190
+ branch: 'cached-branch',
191
+ isMainWorktree: true,
192
+ hasSession: false,
193
+ };
194
+ const refreshedWorktree = {
195
+ path: '/test/refreshed',
196
+ branch: 'refreshed-branch',
197
+ isMainWorktree: true,
198
+ hasSession: false,
199
+ };
200
+ let resolveWorktrees;
201
+ const worktreesPromise = new Promise(resolve => {
202
+ resolveWorktrees = resolve;
203
+ });
204
+ const onSnapshotChange = vi.fn();
205
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.promise(() => worktreesPromise));
206
+ const getDefaultBranchSpy = vi
207
+ .spyOn(worktreeService, 'getDefaultBranchEffect')
208
+ .mockReturnValue(Effect.succeed('main'));
209
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
210
+ worktrees: [cachedWorktree],
211
+ defaultBranch: 'main',
212
+ }, onSnapshotChange: onSnapshotChange, onMenuAction: vi.fn(), version: "test" }));
213
+ // The cached rows are assembled in Menu's render effect, without waiting
214
+ // for the deferred Git refresh.
215
+ await new Promise(resolve => setTimeout(resolve, 0));
216
+ expect(lastFrame()).toContain('cached-branch');
217
+ expect(getDefaultBranchSpy).toHaveBeenCalled();
218
+ resolveWorktrees([refreshedWorktree]);
219
+ await new Promise(resolve => setTimeout(resolve, 20));
220
+ expect(lastFrame()).toContain('refreshed-branch');
221
+ expect(onSnapshotChange).toHaveBeenCalledWith({
222
+ worktrees: [refreshedWorktree],
223
+ defaultBranch: 'main',
224
+ loadError: null,
225
+ });
226
+ });
227
+ it('should retain cached worktrees when a background refresh fails', async () => {
228
+ const { Effect } = await import('effect');
229
+ const { GitError } = await import('../types/errors.js');
230
+ const cachedWorktree = {
231
+ path: '/test/cached',
232
+ branch: 'cached-branch',
233
+ isMainWorktree: true,
234
+ hasSession: false,
235
+ };
236
+ const onSnapshotChange = vi.fn();
237
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.fail(new GitError({
238
+ command: 'git worktree list --porcelain',
239
+ exitCode: 1,
240
+ stderr: 'temporary failure',
241
+ })));
242
+ vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
243
+ const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
244
+ worktrees: [cachedWorktree],
245
+ defaultBranch: 'main',
246
+ }, onSnapshotChange: onSnapshotChange, onMenuAction: vi.fn(), version: "test" }));
247
+ await new Promise(resolve => setTimeout(resolve, 20));
248
+ expect(lastFrame()).toContain('cached-branch');
249
+ expect(lastFrame()).toContain('temporary failure');
250
+ expect(onSnapshotChange).toHaveBeenCalledWith({
251
+ worktrees: [cachedWorktree],
252
+ defaultBranch: 'main',
253
+ loadError: expect.stringContaining('temporary failure'),
254
+ });
255
+ });
186
256
  });
187
257
  describe('Menu component rendering', () => {
188
258
  let sessionManager;
@@ -63,8 +63,14 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
63
63
  session.process.write(data);
64
64
  };
65
65
  stdin.on('data', handleStdinData);
66
- // Clear screen when entering session
67
- stdout.write('\x1B[2J\x1B[H');
66
+ // Clear the viewport and the host terminal's scrollback (\x1b[3J) when
67
+ // entering a session. The restore snapshot then rebuilds the whole
68
+ // canvas — scrollback included — from this session's headless buffer,
69
+ // so content left behind by the menu or other sessions can never
70
+ // appear when the user scrolls up. \x1b[2J runs first because some
71
+ // emulators (e.g. xterm.js-based ones) push the erased viewport into
72
+ // scrollback, which \x1b[3J must then discard.
73
+ stdout.write('\x1B[2J\x1B[3J\x1B[H');
68
74
  // Restore the current terminal state from the headless xterm snapshot.
69
75
  // The xterm serialize addon relies on auto-wrap (DECAWM) being enabled
70
76
  // to render wrapped lines. It omits row separators for wrapped rows
@@ -1,4 +1,6 @@
1
1
  import { Worktree } from '../types/index.js';
2
+ /** Clear the module-level git-status cache. Intended for tests. */
3
+ export declare function clearGitStatusCache(): void;
2
4
  /**
3
5
  * Custom hook for polling git status and commit dates of worktrees with Effect-based execution
4
6
  *
@@ -1,6 +1,58 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { Effect, Exit, Cause, Option } from 'effect';
3
3
  import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
4
+ /**
5
+ * Module-level cache of the last fetched git status, keyed by worktree path.
6
+ *
7
+ * The menu is fully unmounted and remounted on every return to it (App renders
8
+ * `<Menu key={menuKey} />`, and the key changes on navigation), which would
9
+ * otherwise wipe this hook's state and force every worktree to flash
10
+ * "[fetching...]" and refetch from scratch each time. Seeding state from this
11
+ * cache shows each worktree's last known status instantly; background polling
12
+ * still refreshes it. Entries are intentionally not pruned so switching between
13
+ * projects keeps each project's cached status.
14
+ */
15
+ const statusCache = new Map();
16
+ /** Clear the module-level git-status cache. Intended for tests. */
17
+ export function clearGitStatusCache() {
18
+ statusCache.clear();
19
+ }
20
+ /**
21
+ * Merge any cached status onto the given worktrees so a remount can show the
22
+ * last known status immediately instead of "[fetching...]". Returns the same
23
+ * array reference when nothing was cached, so React can skip a re-render.
24
+ */
25
+ function hydrateFromCache(worktrees) {
26
+ let changed = false;
27
+ const next = worktrees.map(wt => {
28
+ const cached = statusCache.get(wt.path);
29
+ if (!cached) {
30
+ return wt;
31
+ }
32
+ changed = true;
33
+ return {
34
+ ...wt,
35
+ gitStatus: cached.gitStatus,
36
+ gitStatusError: cached.gitStatusError,
37
+ lastCommitDate: cached.lastCommitDate,
38
+ };
39
+ });
40
+ return changed ? next : worktrees;
41
+ }
42
+ /** Record a worktree's latest status update into the module-level cache. */
43
+ function cacheStatusUpdate(path, update) {
44
+ const next = { ...statusCache.get(path) };
45
+ if ('gitStatus' in update) {
46
+ next.gitStatus = update.gitStatus;
47
+ }
48
+ if ('gitStatusError' in update) {
49
+ next.gitStatusError = update.gitStatusError;
50
+ }
51
+ if ('lastCommitDate' in update) {
52
+ next.lastCommitDate = update.lastCommitDate;
53
+ }
54
+ statusCache.set(path, next);
55
+ }
4
56
  /**
5
57
  * Custom hook for polling git status and commit dates of worktrees with Effect-based execution
6
58
  *
@@ -20,7 +72,7 @@ import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStat
20
72
  * @returns Array of worktrees with updated gitStatus, gitStatusError, and lastCommitDate fields
21
73
  */
22
74
  export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
23
- const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
75
+ const [worktreesWithStatus, setWorktreesWithStatus] = useState(() => hydrateFromCache(worktrees));
24
76
  useEffect(() => {
25
77
  if (!defaultBranch) {
26
78
  return;
@@ -66,7 +118,7 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
66
118
  }, updateInterval);
67
119
  }
68
120
  };
69
- setWorktreesWithStatus(worktrees);
121
+ setWorktreesWithStatus(hydrateFromCache(worktrees));
70
122
  runCycle().catch(() => {
71
123
  // Ignore errors - the fetch failed or was aborted
72
124
  });
@@ -97,6 +149,7 @@ function applyStatusResults(results, setWorktreesWithStatus) {
97
149
  const update = buildStatusUpdate(result.statusExit, result.dateExit);
98
150
  if (update) {
99
151
  updatesByPath.set(result.path, update);
152
+ cacheStatusUpdate(result.path, update);
100
153
  }
101
154
  }
102
155
  if (updatesByPath.size === 0) {
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  import { render, cleanup } from 'ink-testing-library';
4
4
  import { Text } from 'ink';
5
5
  import { Effect, Exit } from 'effect';
6
- import { useGitStatus } from './useGitStatus.js';
6
+ import { useGitStatus, clearGitStatusCache } from './useGitStatus.js';
7
7
  import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
8
8
  import { GitError } from '../types/errors.js';
9
9
  // Mock the gitStatus module
@@ -29,6 +29,9 @@ describe('useGitStatus', () => {
29
29
  });
30
30
  beforeEach(() => {
31
31
  vi.useFakeTimers();
32
+ // The status cache is module-level and persists across tests; clear it so
33
+ // each test starts from a cold cache.
34
+ clearGitStatusCache();
32
35
  mockGetGitStatus.mockClear();
33
36
  mockGetLastCommitDate.mockClear();
34
37
  // Default: return a date for all worktrees
@@ -66,6 +69,27 @@ describe('useGitStatus', () => {
66
69
  expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
67
70
  expect(hookResult[1]?.gitStatus).toEqual(gitStatus2);
68
71
  });
72
+ it('should hydrate from cache on remount so status shows immediately', async () => {
73
+ const worktrees = [createWorktree('/path1')];
74
+ const gitStatus1 = createGitStatus(7, 2);
75
+ mockGetGitStatus.mockReturnValue(Effect.succeed(gitStatus1));
76
+ let hookResult = [];
77
+ const TestComponent = () => {
78
+ hookResult = useGitStatus(worktrees, 'main', 100);
79
+ return React.createElement(Text, null, 'test');
80
+ };
81
+ // First mount: fetch populates the module-level cache.
82
+ const first = render(React.createElement(TestComponent));
83
+ await vi.waitFor(() => {
84
+ expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
85
+ });
86
+ first.unmount();
87
+ // Remount: cached status must be present on the very first render, before
88
+ // any new fetch resolves, so the menu does not flash "[fetching...]".
89
+ hookResult = [];
90
+ render(React.createElement(TestComponent));
91
+ expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
92
+ });
69
93
  it('should handle empty worktree array', () => {
70
94
  let hookResult = [];
71
95
  const TestComponent = () => {
@@ -22,8 +22,6 @@ 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;
27
25
  private spawn;
28
26
  private resolvePreset;
29
27
  detectTerminalState(session: Session): SessionState;
@@ -44,9 +42,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
44
42
  constructor();
45
43
  private createTerminal;
46
44
  private shouldResetRestoreScrollback;
47
- private hasCursorAddressedRedraw;
48
- private markCursorRedrawActive;
49
- private handleScrollbackGrowthDuringRedraw;
50
45
  private getRestoreSnapshot;
51
46
  private getViewportRedrawSnapshot;
52
47
  /**
@@ -94,7 +89,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
94
89
  private armRestoreDeferTimer;
95
90
  private fireRestoreDefer;
96
91
  private cancelRestoreDefer;
97
- private clearCursorRedrawTracking;
98
92
  cancelAutoApproval(sessionId: string, reason?: string): void;
99
93
  toggleAutoApprovalForWorktree(worktreePath: string): boolean;
100
94
  isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
@@ -33,11 +33,6 @@ 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;
41
36
  export class SessionManager extends EventEmitter {
42
37
  sessions;
43
38
  waitingWithBottomBorder = new Map();
@@ -49,8 +44,6 @@ export class SessionManager extends EventEmitter {
49
44
  resizeSuppressTimers = new Map();
50
45
  restoreDeferTimers = new Map();
51
46
  restoreDeferDeadlines = new Map();
52
- cursorRedrawSessions = new Set();
53
- cursorRedrawTimers = new Map();
54
47
  async spawn(command, args, worktreePath, options = {}) {
55
48
  const spawnOptions = {
56
49
  name: 'xterm-256color',
@@ -207,15 +200,6 @@ export class SessionManager extends EventEmitter {
207
200
  ...additionalUpdates,
208
201
  }));
209
202
  if (oldState !== newState) {
210
- // While busy, cursor-addressed footer redraws (spinner, token stats,
211
- // "accept edits on …" line) can push ghost frames into scrollback as
212
- // chat content scrolls. Once the busy turn ends, advance the restore
213
- // baseline so the next session restore replays only post-busy
214
- // scrollback and skips the ghost-bearing range.
215
- if (oldState === 'busy' && newState !== 'busy') {
216
- session.restoreScrollbackBaseLine =
217
- session.terminal.buffer.normal.baseY;
218
- }
219
203
  void Effect.runPromise(executeStatusHook(oldState, newState, session));
220
204
  this.emit('sessionStateChanged', session);
221
205
  }
@@ -238,35 +222,6 @@ export class SessionManager extends EventEmitter {
238
222
  data.includes('\x1b[3J') ||
239
223
  data.includes('\x1bc'));
240
224
  }
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
- }
270
225
  getRestoreSnapshot(session) {
271
226
  const activeBuffer = session.terminal.buffer.active;
272
227
  if (activeBuffer.type !== 'normal') {
@@ -457,13 +412,8 @@ export class SessionManager extends EventEmitter {
457
412
  setupDataHandler(session) {
458
413
  // This handler always runs for all data
459
414
  session.process.onData((data) => {
460
- const beforeBaseY = session.terminal.buffer.normal.baseY;
461
- if (this.hasCursorAddressedRedraw(data)) {
462
- this.markCursorRedrawActive(session);
463
- }
464
415
  // Write data to virtual terminal
465
416
  session.terminal.write(data);
466
- this.handleScrollbackGrowthDuringRedraw(session, beforeBaseY);
467
417
  if (this.shouldResetRestoreScrollback(data)) {
468
418
  session.restoreScrollbackBaseLine =
469
419
  session.terminal.buffer.normal.baseY;
@@ -706,14 +656,6 @@ export class SessionManager extends EventEmitter {
706
656
  }
707
657
  this.restoreDeferDeadlines.delete(sessionId);
708
658
  }
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
- }
717
659
  cancelAutoApproval(sessionId, reason = 'User input received') {
718
660
  const session = this.sessions.get(sessionId);
719
661
  if (!session) {
@@ -791,7 +733,6 @@ export class SessionManager extends EventEmitter {
791
733
  clearTimeout(resizeTimer);
792
734
  this.resizeSuppressTimers.delete(sessionId);
793
735
  }
794
- this.clearCursorRedrawTracking(sessionId);
795
736
  this.cancelRestoreDefer(sessionId);
796
737
  this.emit('sessionDestroyed', session);
797
738
  }
@@ -852,7 +793,6 @@ export class SessionManager extends EventEmitter {
852
793
  clearTimeout(resizeTimer);
853
794
  this.resizeSuppressTimers.delete(sessionId);
854
795
  }
855
- this.clearCursorRedrawTracking(sessionId);
856
796
  this.cancelRestoreDefer(sessionId);
857
797
  this.emit('sessionDestroyed', session);
858
798
  },
@@ -889,7 +889,7 @@ describe('SessionManager', () => {
889
889
  mockPty.emit('data', 'ordinary output\n');
890
890
  expect(session.restoreScrollbackBaseLine).toBe(0);
891
891
  });
892
- it('should skip scrollback that grows during cursor-addressed redraws', async () => {
892
+ it('should keep scrollback that grows during cursor-addressed redraws', async () => {
893
893
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
894
894
  id: '1',
895
895
  name: 'Main',
@@ -903,38 +903,7 @@ describe('SessionManager', () => {
903
903
  normalBuffer.baseY = 12;
904
904
  });
905
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
- }
906
+ expect(session.restoreScrollbackBaseLine).toBe(0);
938
907
  });
939
908
  it('should flush live session data after the restore snapshot completes', async () => {
940
909
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -1059,7 +1028,7 @@ describe('SessionManager', () => {
1059
1028
  vi.useRealTimers();
1060
1029
  }
1061
1030
  });
1062
- it('should advance the restore baseline when transitioning out of busy', async () => {
1031
+ it('should retain the restore baseline when transitioning out of busy', async () => {
1063
1032
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
1064
1033
  id: '1',
1065
1034
  name: 'Main',
@@ -1072,21 +1041,6 @@ describe('SessionManager', () => {
1072
1041
  await session.stateMutex.update(data => ({ ...data, state: 'busy' }));
1073
1042
  const updateState = sessionManager.updateSessionState.bind(sessionManager);
1074
1043
  await updateState(session, 'idle');
1075
- expect(session.restoreScrollbackBaseLine).toBe(42);
1076
- });
1077
- it('should not advance the restore baseline on transitions that do not leave busy', async () => {
1078
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
1079
- id: '1',
1080
- name: 'Main',
1081
- command: 'claude',
1082
- });
1083
- vi.mocked(spawn).mockReturnValue(mockPty);
1084
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
1085
- session.terminal.buffer.normal.baseY = 42;
1086
- session.restoreScrollbackBaseLine = 5;
1087
- await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
1088
- const updateState = sessionManager.updateSessionState.bind(sessionManager);
1089
- await updateState(session, 'busy');
1090
1044
  expect(session.restoreScrollbackBaseLine).toBe(5);
1091
1045
  });
1092
1046
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.24",
3
+ "version": "4.2.1",
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.24",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.24",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.24",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.24",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.24"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.2.1",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.2.1",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.2.1",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.2.1",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.2.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",