ccmanager 4.1.25 → 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.
- package/dist/components/App.js +38 -1
- package/dist/components/App.test.js +40 -0
- package/dist/components/Menu.d.ts +8 -1
- package/dist/components/Menu.js +40 -14
- package/dist/components/Menu.test.js +70 -0
- package/dist/components/Session.js +8 -2
- package/dist/services/sessionManager.d.ts +0 -6
- package/dist/services/sessionManager.js +0 -60
- package/dist/services/sessionManager.test.js +3 -49
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -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;
|
package/dist/components/Menu.js
CHANGED
|
@@ -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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
const loadWorktreesAndBranch = Effect.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
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
|
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1
|
|
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",
|