ccmanager 4.1.15 → 4.1.18
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/PresetSelector.js +3 -10
- package/dist/components/PresetSelector.test.js +4 -15
- package/dist/components/Session.js +6 -2
- package/dist/components/Session.test.js +52 -0
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +52 -14
- package/dist/services/sessionManager.test.js +96 -4
- package/dist/services/worktreeService.js +20 -8
- package/dist/services/worktreeService.test.js +69 -4
- package/package.json +6 -6
|
@@ -37,18 +37,11 @@ const PresetSelector = ({ onSelect, onCancel, }) => {
|
|
|
37
37
|
};
|
|
38
38
|
// Find initial index based on default preset
|
|
39
39
|
const initialIndex = selectItems.findIndex(item => item.value === defaultPresetId);
|
|
40
|
-
|
|
40
|
+
// ink-select-input v6+ handles number keys 1-9 natively, so only handle ESC here
|
|
41
|
+
// to avoid double-firing onSelect (which would create two sessions for one worktree).
|
|
42
|
+
useInput((_input, key) => {
|
|
41
43
|
if (key.escape) {
|
|
42
44
|
onCancel();
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
// Number keys 1-9: immediate launch
|
|
46
|
-
if (/^[1-9]$/.test(input)) {
|
|
47
|
-
const idx = parseInt(input) - 1;
|
|
48
|
-
if (idx < presets.length && presets[idx]) {
|
|
49
|
-
onSelect(presets[idx].id);
|
|
50
|
-
}
|
|
51
|
-
return;
|
|
52
45
|
}
|
|
53
46
|
});
|
|
54
47
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Select Command Preset" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Choose a preset to start the session with" }) }), _jsx(SelectInput, { items: selectItems, onSelect: handleSelectItem, initialIndex: initialIndex >= 0 ? initialIndex : 0 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 Navigate 1-9 Quick Select Enter Select ESC Cancel" }) })] }));
|
|
@@ -86,26 +86,15 @@ describe('PresetSelector component', () => {
|
|
|
86
86
|
expect(output).toContain('(default)');
|
|
87
87
|
expect(output).toContain('← Cancel');
|
|
88
88
|
});
|
|
89
|
-
|
|
89
|
+
// Number key selection is handled by ink-select-input v6+ natively.
|
|
90
|
+
// PresetSelector's useInput only handles ESC to avoid double-firing onSelect
|
|
91
|
+
// (which would create two sessions for one worktree).
|
|
92
|
+
it('pressing number keys via useInput does NOT trigger onSelect', () => {
|
|
90
93
|
render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
|
|
91
94
|
expect(capturedHandlers.inputHandler).not.toBeNull();
|
|
92
95
|
capturedHandlers.inputHandler('1', makeKey());
|
|
93
|
-
expect(onSelect).toHaveBeenCalledWith('preset-1');
|
|
94
|
-
expect(onCancel).not.toHaveBeenCalled();
|
|
95
|
-
});
|
|
96
|
-
it('pressing 2 calls onSelect with second preset id immediately', () => {
|
|
97
|
-
render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
|
|
98
96
|
capturedHandlers.inputHandler('2', makeKey());
|
|
99
|
-
expect(onSelect).toHaveBeenCalledWith('preset-2');
|
|
100
|
-
});
|
|
101
|
-
it('pressing 3 calls onSelect with third preset id immediately', () => {
|
|
102
|
-
render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
|
|
103
97
|
capturedHandlers.inputHandler('3', makeKey());
|
|
104
|
-
expect(onSelect).toHaveBeenCalledWith('preset-3');
|
|
105
|
-
});
|
|
106
|
-
it('pressing a number beyond preset count does nothing', () => {
|
|
107
|
-
render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
|
|
108
|
-
capturedHandlers.inputHandler('9', makeKey());
|
|
109
98
|
expect(onSelect).not.toHaveBeenCalled();
|
|
110
99
|
expect(onCancel).not.toHaveBeenCalled();
|
|
111
100
|
});
|
|
@@ -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 &&
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
991
|
+
range: {
|
|
992
|
+
start: 120,
|
|
993
|
+
end: 299,
|
|
994
|
+
},
|
|
903
995
|
excludeAltBuffer: true,
|
|
904
996
|
});
|
|
905
|
-
expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
997
|
+
expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({ scrollback: 0 }));
|
|
906
998
|
expect(restoreHandler).toHaveBeenCalledWith(session, '[31mviewport[0m[8;12H');
|
|
907
999
|
}
|
|
908
1000
|
finally {
|
|
@@ -775,10 +775,14 @@ export class WorktreeService {
|
|
|
775
775
|
copySessionData,
|
|
776
776
|
copyClaudeDirectory,
|
|
777
777
|
});
|
|
778
|
-
// Check if branch exists
|
|
779
|
-
|
|
778
|
+
// Check if a LOCAL branch exists (refs/heads/ only).
|
|
779
|
+
// Using `git rev-parse --verify` without qualifying the ref would also
|
|
780
|
+
// match remote-tracking refs (e.g. refs/remotes/origin/<branch>), which
|
|
781
|
+
// causes `git worktree add` to create the worktree in detached-HEAD
|
|
782
|
+
// state instead of on a proper local branch.
|
|
783
|
+
const localBranchExists = yield* Effect.catchAll(Effect.try({
|
|
780
784
|
try: () => {
|
|
781
|
-
execSync(`git
|
|
785
|
+
execSync(`git show-ref --verify --quiet refs/heads/${branch}`, {
|
|
782
786
|
cwd: self.rootPath,
|
|
783
787
|
encoding: 'utf8',
|
|
784
788
|
});
|
|
@@ -798,15 +802,23 @@ export class WorktreeService {
|
|
|
798
802
|
worktreeHooksConfig.pre_creation?.command) {
|
|
799
803
|
yield* executeWorktreePreCreationHook(worktreeHooksConfig.pre_creation.command, resolvedPath, branch, absoluteGitRoot, baseBranch);
|
|
800
804
|
}
|
|
801
|
-
// Create the worktree command
|
|
805
|
+
// Create the worktree command.
|
|
806
|
+
// Three cases:
|
|
807
|
+
// 1. Local branch exists → attach worktree directly
|
|
808
|
+
// 2. No local branch, but remote-tracking branch exists → create
|
|
809
|
+
// local branch from the remote ref (avoids detached HEAD)
|
|
810
|
+
// 3. Branch is brand-new → create from baseBranch
|
|
802
811
|
let command;
|
|
803
|
-
if (
|
|
812
|
+
if (localBranchExists) {
|
|
804
813
|
command = `git worktree add "${resolvedPath}" "${branch}"`;
|
|
805
814
|
}
|
|
806
815
|
else {
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
816
|
+
const resolvedRef = self.resolveBranchReference(branch);
|
|
817
|
+
const isRemoteBranch = resolvedRef !== branch;
|
|
818
|
+
const startPoint = isRemoteBranch
|
|
819
|
+
? resolvedRef
|
|
820
|
+
: self.resolveBranchReference(baseBranch);
|
|
821
|
+
command = `git worktree add -b "${branch}" "${resolvedPath}" "${startPoint}"`;
|
|
810
822
|
}
|
|
811
823
|
// Execute the worktree creation command
|
|
812
824
|
yield* Effect.try({
|
|
@@ -631,9 +631,15 @@ branch refs/heads/feature
|
|
|
631
631
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
632
632
|
return '/fake/path/.git\n';
|
|
633
633
|
}
|
|
634
|
-
if (cmd.includes('
|
|
634
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
635
635
|
throw new Error('Branch not found');
|
|
636
636
|
}
|
|
637
|
+
if (cmd === 'git remote') {
|
|
638
|
+
return 'origin\n';
|
|
639
|
+
}
|
|
640
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
641
|
+
throw new Error('Remote branch not found');
|
|
642
|
+
}
|
|
637
643
|
if (cmd.includes('git worktree add')) {
|
|
638
644
|
return '';
|
|
639
645
|
}
|
|
@@ -648,15 +654,62 @@ branch refs/heads/feature
|
|
|
648
654
|
isMainWorktree: false,
|
|
649
655
|
});
|
|
650
656
|
});
|
|
657
|
+
it('should create local branch from remote ref when only remote branch exists', async () => {
|
|
658
|
+
const executedCommands = [];
|
|
659
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
660
|
+
if (typeof cmd === 'string') {
|
|
661
|
+
executedCommands.push(cmd);
|
|
662
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
663
|
+
return '/fake/path/.git\n';
|
|
664
|
+
}
|
|
665
|
+
// No local branch
|
|
666
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
667
|
+
throw new Error('Branch not found');
|
|
668
|
+
}
|
|
669
|
+
if (cmd === 'git remote') {
|
|
670
|
+
return 'origin\n';
|
|
671
|
+
}
|
|
672
|
+
// Remote branch exists
|
|
673
|
+
if (cmd ===
|
|
674
|
+
'git show-ref --verify --quiet refs/remotes/origin/feature/remote-only') {
|
|
675
|
+
return '';
|
|
676
|
+
}
|
|
677
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
678
|
+
throw new Error('Remote branch not found');
|
|
679
|
+
}
|
|
680
|
+
if (cmd.includes('git worktree add')) {
|
|
681
|
+
return '';
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return '';
|
|
685
|
+
});
|
|
686
|
+
const effect = service.createWorktreeEffect('/path/to/worktree', 'feature/remote-only', 'main');
|
|
687
|
+
const result = await Effect.runPromise(effect);
|
|
688
|
+
expect(result.worktree).toMatchObject({
|
|
689
|
+
path: '/path/to/worktree',
|
|
690
|
+
branch: 'feature/remote-only',
|
|
691
|
+
isMainWorktree: false,
|
|
692
|
+
});
|
|
693
|
+
// Should use -b with the remote ref as start point, not baseBranch
|
|
694
|
+
const worktreeAddCmd = executedCommands.find(c => c.includes('git worktree add'));
|
|
695
|
+
expect(worktreeAddCmd).toContain('-b "feature/remote-only"');
|
|
696
|
+
expect(worktreeAddCmd).toContain('"origin/feature/remote-only"');
|
|
697
|
+
});
|
|
651
698
|
it('should return Effect that fails with GitError on git command failure', async () => {
|
|
652
699
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
653
700
|
if (typeof cmd === 'string') {
|
|
654
701
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
655
702
|
return '/fake/path/.git\n';
|
|
656
703
|
}
|
|
657
|
-
if (cmd.includes('
|
|
704
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
658
705
|
throw new Error('Branch not found');
|
|
659
706
|
}
|
|
707
|
+
if (cmd === 'git remote') {
|
|
708
|
+
return 'origin\n';
|
|
709
|
+
}
|
|
710
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
711
|
+
throw new Error('Remote branch not found');
|
|
712
|
+
}
|
|
660
713
|
if (cmd.includes('git worktree add')) {
|
|
661
714
|
const error = new Error('fatal: invalid reference: main');
|
|
662
715
|
error.status = 128;
|
|
@@ -696,9 +749,15 @@ branch refs/heads/feature
|
|
|
696
749
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
697
750
|
return '/fake/path/.git\n';
|
|
698
751
|
}
|
|
699
|
-
if (cmd.includes('
|
|
752
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
700
753
|
throw new Error('Branch not found');
|
|
701
754
|
}
|
|
755
|
+
if (cmd === 'git remote') {
|
|
756
|
+
return 'origin\n';
|
|
757
|
+
}
|
|
758
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
759
|
+
throw new Error('Remote branch not found');
|
|
760
|
+
}
|
|
702
761
|
if (cmd.includes('git worktree add')) {
|
|
703
762
|
throw new Error('git worktree add should not be called');
|
|
704
763
|
}
|
|
@@ -732,9 +791,15 @@ branch refs/heads/feature
|
|
|
732
791
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
733
792
|
return '/fake/path/.git\n';
|
|
734
793
|
}
|
|
735
|
-
if (cmd.includes('
|
|
794
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
736
795
|
throw new Error('Branch not found');
|
|
737
796
|
}
|
|
797
|
+
if (cmd === 'git remote') {
|
|
798
|
+
return 'origin\n';
|
|
799
|
+
}
|
|
800
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
801
|
+
throw new Error('Remote branch not found');
|
|
802
|
+
}
|
|
738
803
|
if (cmd.includes('git worktree add')) {
|
|
739
804
|
return '';
|
|
740
805
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.18",
|
|
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.18",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.18",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.18",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.18",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.18"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|