ccmanager 4.1.8 → 4.1.10
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 +1 -1
- package/dist/components/MergeWorktree.d.ts +1 -0
- package/dist/components/MergeWorktree.js +8 -12
- package/dist/components/MergeWorktree.test.js +55 -0
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +88 -1
- package/dist/services/sessionManager.test.js +111 -0
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -566,7 +566,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
566
566
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: message, color: "cyan" }) }));
|
|
567
567
|
}
|
|
568
568
|
if (view === 'merge-worktree') {
|
|
569
|
-
return (_jsxs(Box, { flexDirection: "column", children: [error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), _jsx(MergeWorktree, { onComplete: handleReturnToMenu, onCancel: handleReturnToMenu })] }));
|
|
569
|
+
return (_jsxs(Box, { flexDirection: "column", children: [error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), _jsx(MergeWorktree, { projectPath: selectedProject?.path, onComplete: handleReturnToMenu, onCancel: handleReturnToMenu })] }));
|
|
570
570
|
}
|
|
571
571
|
if (view === 'configuration') {
|
|
572
572
|
return (_jsx(Configuration, { scope: configScope, onComplete: handleReturnToMenu }));
|
|
@@ -9,7 +9,7 @@ import Confirmation, { SimpleConfirmation } from './Confirmation.js';
|
|
|
9
9
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
10
10
|
import { GitError } from '../types/errors.js';
|
|
11
11
|
import { hasUncommittedChanges } from '../utils/gitUtils.js';
|
|
12
|
-
const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
12
|
+
const MergeWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
13
13
|
const [step, setStep] = useState('select-source');
|
|
14
14
|
const [sourceBranch, setSourceBranch] = useState('');
|
|
15
15
|
const [targetBranch, setTargetBranch] = useState('');
|
|
@@ -17,7 +17,6 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
17
17
|
const [originalBranchItems, setOriginalBranchItems] = useState([]);
|
|
18
18
|
const [operation, setOperation] = useState('merge');
|
|
19
19
|
const [mergeError, setMergeError] = useState(null);
|
|
20
|
-
const [worktreeService] = useState(() => new WorktreeService());
|
|
21
20
|
const [mergeConfig] = useState(() => configReader.getMergeConfig());
|
|
22
21
|
const [isLoading, setIsLoading] = useState(true);
|
|
23
22
|
const [loadError, setLoadError] = useState(null);
|
|
@@ -25,6 +24,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
25
24
|
let cancelled = false;
|
|
26
25
|
const loadWorktrees = async () => {
|
|
27
26
|
try {
|
|
27
|
+
const worktreeService = new WorktreeService(projectPath);
|
|
28
28
|
const loadedWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
|
|
29
29
|
if (!cancelled) {
|
|
30
30
|
// Create branch items for selection
|
|
@@ -56,7 +56,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
56
56
|
return () => {
|
|
57
57
|
cancelled = true;
|
|
58
58
|
};
|
|
59
|
-
}, [
|
|
59
|
+
}, [projectPath]);
|
|
60
60
|
useInput((input, key) => {
|
|
61
61
|
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
62
62
|
onCancel();
|
|
@@ -85,6 +85,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
85
85
|
return;
|
|
86
86
|
const performMerge = async () => {
|
|
87
87
|
try {
|
|
88
|
+
const worktreeService = new WorktreeService(projectPath);
|
|
88
89
|
await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch, operation, mergeConfig));
|
|
89
90
|
// Merge successful, check for uncommitted changes before asking about deletion
|
|
90
91
|
setStep('check-uncommitted');
|
|
@@ -101,14 +102,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
101
102
|
}
|
|
102
103
|
};
|
|
103
104
|
performMerge();
|
|
104
|
-
}, [
|
|
105
|
-
step,
|
|
106
|
-
sourceBranch,
|
|
107
|
-
targetBranch,
|
|
108
|
-
operation,
|
|
109
|
-
mergeConfig,
|
|
110
|
-
worktreeService,
|
|
111
|
-
]);
|
|
105
|
+
}, [step, sourceBranch, targetBranch, operation, mergeConfig, projectPath]);
|
|
112
106
|
// Check for uncommitted changes in source worktree when entering check-uncommitted step
|
|
113
107
|
useEffect(() => {
|
|
114
108
|
if (step !== 'check-uncommitted')
|
|
@@ -116,6 +110,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
116
110
|
const checkUncommitted = async () => {
|
|
117
111
|
try {
|
|
118
112
|
// Find the worktree path for the source branch
|
|
113
|
+
const worktreeService = new WorktreeService(projectPath);
|
|
119
114
|
const worktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
|
|
120
115
|
const sourceWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === sourceBranch);
|
|
121
116
|
if (sourceWorktree && hasUncommittedChanges(sourceWorktree.path)) {
|
|
@@ -131,7 +126,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
131
126
|
}
|
|
132
127
|
};
|
|
133
128
|
checkUncommitted();
|
|
134
|
-
}, [step, sourceBranch,
|
|
129
|
+
}, [step, sourceBranch, projectPath]);
|
|
135
130
|
if (isLoading) {
|
|
136
131
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Loading worktrees..." }) }));
|
|
137
132
|
}
|
|
@@ -197,6 +192,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
197
192
|
return (_jsx(SimpleConfirmation, { message: deleteMessage, onConfirm: async () => {
|
|
198
193
|
try {
|
|
199
194
|
// Find the worktree path for the source branch
|
|
195
|
+
const worktreeService = new WorktreeService(projectPath);
|
|
200
196
|
const worktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
|
|
201
197
|
const sourceWorktree = worktrees.find(wt => wt.branch &&
|
|
202
198
|
wt.branch.replace('refs/heads/', '') === sourceBranch);
|
|
@@ -40,6 +40,61 @@ describe('MergeWorktree - Effect Integration', () => {
|
|
|
40
40
|
beforeEach(() => {
|
|
41
41
|
vi.clearAllMocks();
|
|
42
42
|
});
|
|
43
|
+
it('should pass projectPath to WorktreeService when provided', async () => {
|
|
44
|
+
const projectPath = '/test/project';
|
|
45
|
+
const mockWorktrees = [
|
|
46
|
+
{
|
|
47
|
+
path: '/test/project/main',
|
|
48
|
+
branch: 'main',
|
|
49
|
+
isMainWorktree: true,
|
|
50
|
+
hasSession: false,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
path: '/test/project/feature',
|
|
54
|
+
branch: 'feature-1',
|
|
55
|
+
isMainWorktree: false,
|
|
56
|
+
hasSession: false,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
const mockEffect = Effect.succeed(mockWorktrees);
|
|
60
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
61
|
+
return {
|
|
62
|
+
getWorktreesEffect: vi.fn(() => mockEffect),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
const onComplete = vi.fn();
|
|
66
|
+
const onCancel = vi.fn();
|
|
67
|
+
render(_jsx(MergeWorktree, { projectPath: projectPath, onComplete: onComplete, onCancel: onCancel }));
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
69
|
+
expect(WorktreeService).toHaveBeenCalledWith(projectPath);
|
|
70
|
+
});
|
|
71
|
+
it('should use undefined when projectPath not provided', async () => {
|
|
72
|
+
const mockWorktrees = [
|
|
73
|
+
{
|
|
74
|
+
path: '/test/main',
|
|
75
|
+
branch: 'main',
|
|
76
|
+
isMainWorktree: true,
|
|
77
|
+
hasSession: false,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
path: '/test/feature',
|
|
81
|
+
branch: 'feature-1',
|
|
82
|
+
isMainWorktree: false,
|
|
83
|
+
hasSession: false,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const mockEffect = Effect.succeed(mockWorktrees);
|
|
87
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
88
|
+
return {
|
|
89
|
+
getWorktreesEffect: vi.fn(() => mockEffect),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
const onComplete = vi.fn();
|
|
93
|
+
const onCancel = vi.fn();
|
|
94
|
+
render(_jsx(MergeWorktree, { onComplete: onComplete, onCancel: onCancel }));
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
96
|
+
expect(WorktreeService).toHaveBeenCalledWith(undefined);
|
|
97
|
+
});
|
|
43
98
|
it('should load worktrees using Effect-based method', async () => {
|
|
44
99
|
// GIVEN: Mock worktrees returned by Effect
|
|
45
100
|
const mockWorktrees = [
|
|
@@ -18,6 +18,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
18
18
|
private autoApprovalDisabledWorktrees;
|
|
19
19
|
private restoringSessions;
|
|
20
20
|
private bufferedRestoreData;
|
|
21
|
+
private restoreRefreshTimers;
|
|
22
|
+
private restoreRefreshDeadlines;
|
|
21
23
|
private spawn;
|
|
22
24
|
private resolvePreset;
|
|
23
25
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -39,6 +41,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
39
41
|
private createTerminal;
|
|
40
42
|
private shouldResetRestoreScrollback;
|
|
41
43
|
private getRestoreSnapshot;
|
|
44
|
+
private scheduleRestoreRefresh;
|
|
45
|
+
private armRestoreRefreshTimer;
|
|
46
|
+
private cancelRestoreRefresh;
|
|
47
|
+
private fireRestoreRefresh;
|
|
42
48
|
private createSessionInternal;
|
|
43
49
|
/**
|
|
44
50
|
* Create session with command preset using Effect-based error handling
|
|
@@ -21,6 +21,15 @@ const { Terminal } = pkg;
|
|
|
21
21
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
22
22
|
const TERMINAL_SCROLLBACK_LINES = 5000;
|
|
23
23
|
const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
|
|
24
|
+
// Claude Code's Ink-based renderer sometimes splits a single UI redraw across
|
|
25
|
+
// multiple PTY writes with short time gaps. If we snapshot between chunks, the
|
|
26
|
+
// resulting viewport can miss rows (e.g. empty middle area while the top/bottom
|
|
27
|
+
// chrome already rendered). Re-emit the snapshot after the PTY output has been
|
|
28
|
+
// quiet for this long so late chunks are accounted for.
|
|
29
|
+
const RESTORE_REFRESH_QUIET_MS = 120;
|
|
30
|
+
// Cap on how long we wait for quiet before forcing the refresh, so continuous
|
|
31
|
+
// streaming output (e.g. a long busy turn) still produces an updated snapshot.
|
|
32
|
+
const RESTORE_REFRESH_MAX_WAIT_MS = 400;
|
|
24
33
|
export class SessionManager extends EventEmitter {
|
|
25
34
|
sessions;
|
|
26
35
|
waitingWithBottomBorder = new Map();
|
|
@@ -28,6 +37,8 @@ export class SessionManager extends EventEmitter {
|
|
|
28
37
|
autoApprovalDisabledWorktrees = new Set();
|
|
29
38
|
restoringSessions = new Set();
|
|
30
39
|
bufferedRestoreData = new Map();
|
|
40
|
+
restoreRefreshTimers = new Map();
|
|
41
|
+
restoreRefreshDeadlines = new Map();
|
|
31
42
|
async spawn(command, args, worktreePath, options = {}) {
|
|
32
43
|
const spawnOptions = {
|
|
33
44
|
name: 'xterm-256color',
|
|
@@ -205,7 +216,7 @@ export class SessionManager extends EventEmitter {
|
|
|
205
216
|
data.includes('\x1b[3J') ||
|
|
206
217
|
data.includes('\x1bc'));
|
|
207
218
|
}
|
|
208
|
-
getRestoreSnapshot(session) {
|
|
219
|
+
getRestoreSnapshot(session, options = {}) {
|
|
209
220
|
const activeBuffer = session.terminal.buffer.active;
|
|
210
221
|
if (activeBuffer.type !== 'normal') {
|
|
211
222
|
return session.serializer.serialize({
|
|
@@ -217,6 +228,22 @@ export class SessionManager extends EventEmitter {
|
|
|
217
228
|
if (bufferLength === 0) {
|
|
218
229
|
return '';
|
|
219
230
|
}
|
|
231
|
+
// While the session is busy, cursor-addressed status-box redraws can push
|
|
232
|
+
// stale frames into scrollback (e.g. Claude's spinner + token stats line).
|
|
233
|
+
// Those ghost rows render as duplicated status bars when replayed, so
|
|
234
|
+
// restore only the viewport during busy state. Refresh re-emits also
|
|
235
|
+
// bypass scrollback to avoid duplicating history into real-terminal
|
|
236
|
+
// scrollback on top of the initial emit.
|
|
237
|
+
const isBusy = session.stateMutex.getSnapshot().state === 'busy';
|
|
238
|
+
if (options.viewportOnly || isBusy) {
|
|
239
|
+
const snapshot = session.serializer.serialize({
|
|
240
|
+
scrollback: 0,
|
|
241
|
+
excludeAltBuffer: true,
|
|
242
|
+
});
|
|
243
|
+
const cursorRow = normalBuffer.cursorY + 1;
|
|
244
|
+
const cursorCol = normalBuffer.cursorX + 1;
|
|
245
|
+
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
246
|
+
}
|
|
220
247
|
const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
|
|
221
248
|
const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
|
|
222
249
|
const rangeEnd = bufferLength - 1;
|
|
@@ -231,6 +258,56 @@ export class SessionManager extends EventEmitter {
|
|
|
231
258
|
const cursorCol = normalBuffer.cursorX + 1;
|
|
232
259
|
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
233
260
|
}
|
|
261
|
+
scheduleRestoreRefresh(session) {
|
|
262
|
+
this.restoreRefreshDeadlines.set(session.id, Date.now() + RESTORE_REFRESH_MAX_WAIT_MS);
|
|
263
|
+
this.armRestoreRefreshTimer(session);
|
|
264
|
+
}
|
|
265
|
+
armRestoreRefreshTimer(session) {
|
|
266
|
+
const deadline = this.restoreRefreshDeadlines.get(session.id);
|
|
267
|
+
if (deadline === undefined) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const existing = this.restoreRefreshTimers.get(session.id);
|
|
271
|
+
if (existing !== undefined) {
|
|
272
|
+
clearTimeout(existing);
|
|
273
|
+
this.restoreRefreshTimers.delete(session.id);
|
|
274
|
+
}
|
|
275
|
+
const remaining = deadline - Date.now();
|
|
276
|
+
if (remaining <= 0) {
|
|
277
|
+
this.fireRestoreRefresh(session);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const delay = Math.min(RESTORE_REFRESH_QUIET_MS, remaining);
|
|
281
|
+
const timer = setTimeout(() => this.fireRestoreRefresh(session), delay);
|
|
282
|
+
this.restoreRefreshTimers.set(session.id, timer);
|
|
283
|
+
}
|
|
284
|
+
cancelRestoreRefresh(session) {
|
|
285
|
+
const existing = this.restoreRefreshTimers.get(session.id);
|
|
286
|
+
if (existing !== undefined) {
|
|
287
|
+
clearTimeout(existing);
|
|
288
|
+
this.restoreRefreshTimers.delete(session.id);
|
|
289
|
+
}
|
|
290
|
+
this.restoreRefreshDeadlines.delete(session.id);
|
|
291
|
+
}
|
|
292
|
+
fireRestoreRefresh(session) {
|
|
293
|
+
this.restoreRefreshTimers.delete(session.id);
|
|
294
|
+
this.restoreRefreshDeadlines.delete(session.id);
|
|
295
|
+
if (!session.isActive) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const snapshot = this.getRestoreSnapshot(session, { viewportOnly: true });
|
|
299
|
+
if (snapshot.length > 0) {
|
|
300
|
+
// \x1b[2J: clear the viewport before repainting — without this, a
|
|
301
|
+
// refresh snapshot shorter than the already-displayed content leaves
|
|
302
|
+
// a "ghost tail" of pre-refresh rows at the bottom.
|
|
303
|
+
// \x1b[?7h / \x1b[?7l: Session.tsx disables auto-wrap (DECAWM) after
|
|
304
|
+
// the initial restore, but SerializeAddon omits row separators for
|
|
305
|
+
// wrapped lines and relies on DECAWM to advance to the next row
|
|
306
|
+
// (see PR #276). Re-enable auto-wrap just for the snapshot write so
|
|
307
|
+
// wrapped viewport rows don't overlap, then restore the TUI default.
|
|
308
|
+
this.emit('sessionRestore', session, `\x1b[?7h\x1b[2J\x1b[H${snapshot}\x1b[?7l`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
234
311
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
235
312
|
const existingSessions = this.getSessionsForWorktree(worktreePath);
|
|
236
313
|
const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
|
|
@@ -334,6 +411,12 @@ export class SessionManager extends EventEmitter {
|
|
|
334
411
|
session.terminal.buffer.normal.baseY;
|
|
335
412
|
}
|
|
336
413
|
session.lastActivity = new Date();
|
|
414
|
+
// If a restore-refresh is pending, each incoming chunk resets the
|
|
415
|
+
// quiet timer so the follow-up snapshot only fires after Claude's
|
|
416
|
+
// multi-chunk redraw has settled.
|
|
417
|
+
if (this.restoreRefreshDeadlines.has(session.id)) {
|
|
418
|
+
this.armRestoreRefreshTimer(session);
|
|
419
|
+
}
|
|
337
420
|
// Only emit data events when session is active
|
|
338
421
|
if (session.isActive) {
|
|
339
422
|
if (this.restoringSessions.has(session.id)) {
|
|
@@ -493,10 +576,12 @@ export class SessionManager extends EventEmitter {
|
|
|
493
576
|
}
|
|
494
577
|
}
|
|
495
578
|
}
|
|
579
|
+
this.scheduleRestoreRefresh(session);
|
|
496
580
|
}
|
|
497
581
|
else {
|
|
498
582
|
this.restoringSessions.delete(session.id);
|
|
499
583
|
this.bufferedRestoreData.delete(session.id);
|
|
584
|
+
this.cancelRestoreRefresh(session);
|
|
500
585
|
}
|
|
501
586
|
}
|
|
502
587
|
}
|
|
@@ -571,6 +656,7 @@ export class SessionManager extends EventEmitter {
|
|
|
571
656
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
572
657
|
this.restoringSessions.delete(sessionId);
|
|
573
658
|
this.bufferedRestoreData.delete(sessionId);
|
|
659
|
+
this.cancelRestoreRefresh(session);
|
|
574
660
|
this.emit('sessionDestroyed', session);
|
|
575
661
|
}
|
|
576
662
|
}
|
|
@@ -624,6 +710,7 @@ export class SessionManager extends EventEmitter {
|
|
|
624
710
|
}
|
|
625
711
|
this.sessions.delete(sessionId);
|
|
626
712
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
713
|
+
this.cancelRestoreRefresh(session);
|
|
627
714
|
this.emit('sessionDestroyed', session);
|
|
628
715
|
},
|
|
629
716
|
catch: (error) => {
|
|
@@ -782,6 +782,7 @@ describe('SessionManager', () => {
|
|
|
782
782
|
});
|
|
783
783
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
784
784
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
785
|
+
await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
|
|
785
786
|
const normalBuffer = session.terminal.buffer.normal;
|
|
786
787
|
normalBuffer.baseY = 260;
|
|
787
788
|
normalBuffer.length = 300;
|
|
@@ -803,6 +804,32 @@ describe('SessionManager', () => {
|
|
|
803
804
|
});
|
|
804
805
|
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
|
|
805
806
|
});
|
|
807
|
+
it('should emit a viewport-only restore snapshot while session is busy', async () => {
|
|
808
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
809
|
+
id: '1',
|
|
810
|
+
name: 'Main',
|
|
811
|
+
command: 'claude',
|
|
812
|
+
});
|
|
813
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
814
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
815
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
816
|
+
normalBuffer.baseY = 260;
|
|
817
|
+
normalBuffer.length = 300;
|
|
818
|
+
normalBuffer.cursorY = 7;
|
|
819
|
+
normalBuffer.cursorX = 11;
|
|
820
|
+
session.restoreScrollbackBaseLine = 120;
|
|
821
|
+
const serializeMock = vi
|
|
822
|
+
.spyOn(session.serializer, 'serialize')
|
|
823
|
+
.mockReturnValue('\u001b[31mbusy-viewport\u001b[0m');
|
|
824
|
+
const restoreHandler = vi.fn();
|
|
825
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
826
|
+
sessionManager.setSessionActive(session.id, true);
|
|
827
|
+
expect(serializeMock).toHaveBeenCalledWith({
|
|
828
|
+
scrollback: 0,
|
|
829
|
+
excludeAltBuffer: true,
|
|
830
|
+
});
|
|
831
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mbusy-viewport\u001b[0m\u001b[8;12H');
|
|
832
|
+
});
|
|
806
833
|
it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
|
|
807
834
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
808
835
|
id: '1',
|
|
@@ -872,6 +899,90 @@ describe('SessionManager', () => {
|
|
|
872
899
|
sessionManager.setSessionActive(session.id, true);
|
|
873
900
|
expect(eventOrder).toEqual(['restore', 'data']);
|
|
874
901
|
});
|
|
902
|
+
it('should re-emit a viewport-only snapshot after the PTY quiet period', async () => {
|
|
903
|
+
vi.useFakeTimers();
|
|
904
|
+
try {
|
|
905
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
906
|
+
id: '1',
|
|
907
|
+
name: 'Main',
|
|
908
|
+
command: 'claude',
|
|
909
|
+
});
|
|
910
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
911
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
912
|
+
session.terminal.buffer.normal.length = 1;
|
|
913
|
+
const serializeMock = vi
|
|
914
|
+
.spyOn(session.serializer, 'serialize')
|
|
915
|
+
.mockReturnValue('snap');
|
|
916
|
+
const restoreHandler = vi.fn();
|
|
917
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
918
|
+
sessionManager.setSessionActive(session.id, true);
|
|
919
|
+
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
920
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
921
|
+
mockPty.emit('data', 'late-chunk');
|
|
922
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
923
|
+
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
924
|
+
await vi.advanceTimersByTimeAsync(120);
|
|
925
|
+
expect(restoreHandler).toHaveBeenCalledTimes(2);
|
|
926
|
+
expect(restoreHandler.mock.calls[1]?.[1]).toMatch(/^\u001b\[\?7h\u001b\[2J\u001b\[Hsnap.*\u001b\[\?7l$/);
|
|
927
|
+
expect(serializeMock).toHaveBeenLastCalledWith({
|
|
928
|
+
scrollback: 0,
|
|
929
|
+
excludeAltBuffer: true,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
finally {
|
|
933
|
+
vi.useRealTimers();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
it('should force a refresh at the max-wait deadline even while data keeps streaming', async () => {
|
|
937
|
+
vi.useFakeTimers();
|
|
938
|
+
try {
|
|
939
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
940
|
+
id: '1',
|
|
941
|
+
name: 'Main',
|
|
942
|
+
command: 'claude',
|
|
943
|
+
});
|
|
944
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
945
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
946
|
+
session.terminal.buffer.normal.length = 1;
|
|
947
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
948
|
+
const restoreHandler = vi.fn();
|
|
949
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
950
|
+
sessionManager.setSessionActive(session.id, true);
|
|
951
|
+
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
952
|
+
for (let elapsed = 0; elapsed < 400; elapsed += 50) {
|
|
953
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
954
|
+
mockPty.emit('data', 'chunk');
|
|
955
|
+
}
|
|
956
|
+
expect(restoreHandler).toHaveBeenCalledTimes(2);
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
vi.useRealTimers();
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
it('should cancel a scheduled refresh when the session is deactivated', async () => {
|
|
963
|
+
vi.useFakeTimers();
|
|
964
|
+
try {
|
|
965
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
966
|
+
id: '1',
|
|
967
|
+
name: 'Main',
|
|
968
|
+
command: 'claude',
|
|
969
|
+
});
|
|
970
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
971
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
972
|
+
session.terminal.buffer.normal.length = 1;
|
|
973
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
974
|
+
const restoreHandler = vi.fn();
|
|
975
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
976
|
+
sessionManager.setSessionActive(session.id, true);
|
|
977
|
+
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
978
|
+
sessionManager.setSessionActive(session.id, false);
|
|
979
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
980
|
+
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
vi.useRealTimers();
|
|
984
|
+
}
|
|
985
|
+
});
|
|
875
986
|
});
|
|
876
987
|
describe('static methods', () => {
|
|
877
988
|
describe('getSessionCounts', () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.10",
|
|
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.1.10",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.10",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.10",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.10",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.10"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|