ccmanager 4.1.12 → 4.1.14
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 +2 -2
- package/dist/components/Session.js +21 -10
- package/dist/components/Session.test.js +1 -1
- package/dist/services/sessionManager.d.ts +17 -0
- package/dist/services/sessionManager.js +206 -15
- package/dist/services/sessionManager.test.js +211 -0
- package/dist/services/stateDetector/base.d.ts +1 -0
- package/dist/services/stateDetector/base.js +3 -0
- package/dist/services/stateDetector/claude.d.ts +1 -0
- package/dist/services/stateDetector/claude.js +25 -0
- package/dist/services/stateDetector/claude.test.js +56 -0
- package/dist/services/stateDetector/types.d.ts +1 -0
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -59,12 +59,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
59
59
|
};
|
|
60
60
|
}, [view]);
|
|
61
61
|
useInput(() => {
|
|
62
|
-
if (
|
|
62
|
+
if (!canReturnFromHookError) {
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
setWorktreeHookError(null);
|
|
66
66
|
handleReturnToMenu();
|
|
67
|
-
});
|
|
67
|
+
}, { isActive: view === 'worktree-hook-error' });
|
|
68
68
|
// Helper function to format error messages based on error type using _tag discrimination
|
|
69
69
|
const formatErrorMessage = (error) => {
|
|
70
70
|
switch (error._tag) {
|
|
@@ -64,19 +64,33 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
64
64
|
// Clear screen when entering session
|
|
65
65
|
stdout.write('\x1B[2J\x1B[H');
|
|
66
66
|
// Restore the current terminal state from the headless xterm snapshot.
|
|
67
|
-
// The xterm serialize addon relies on auto-wrap (DECAWM) being enabled
|
|
68
|
-
// render wrapped lines. It omits row separators for wrapped rows
|
|
69
|
-
// characters to naturally overflow to the next line, so
|
|
70
|
-
//
|
|
67
|
+
// The xterm serialize addon relies on auto-wrap (DECAWM) being enabled
|
|
68
|
+
// to render wrapped lines. It omits row separators for wrapped rows
|
|
69
|
+
// and expects characters to naturally overflow to the next line, so
|
|
70
|
+
// re-enable DECAWM around the snapshot write and restore the live-TUI
|
|
71
|
+
// default afterward. This matters for both the synchronous initial
|
|
72
|
+
// restore and the deferred restore that may fire after Session.tsx
|
|
73
|
+
// has already disabled DECAWM for live TUI redraws.
|
|
71
74
|
const handleSessionRestore = (restoredSession, restoreSnapshot) => {
|
|
72
75
|
if (restoredSession.id === session.id) {
|
|
73
76
|
if (restoreSnapshot.length > 0) {
|
|
74
|
-
stdout.write(restoreSnapshot);
|
|
77
|
+
stdout.write(`\x1b[?7h${restoreSnapshot}\x1b[?7l`);
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
};
|
|
78
81
|
// Listen for restore event first
|
|
79
82
|
sessionManager.on('sessionRestore', handleSessionRestore);
|
|
83
|
+
// Repaint the user's terminal viewport from the post-resize headless
|
|
84
|
+
// snapshot. Without this, Ink-based TUIs (e.g. Claude Code) re-emit
|
|
85
|
+
// their full static history on SIGWINCH, which the user's terminal
|
|
86
|
+
// appends below the (already-clipped) viewport, producing duplicated
|
|
87
|
+
// rows equal to the resize delta.
|
|
88
|
+
const handleSessionResize = (resizedSession, redrawPayload) => {
|
|
89
|
+
if (resizedSession.id === session.id && redrawPayload.length > 0) {
|
|
90
|
+
stdout.write(redrawPayload);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
sessionManager.on('sessionResize', handleSessionResize);
|
|
80
94
|
// Listen for session data events
|
|
81
95
|
const handleSessionData = (activeSession, data) => {
|
|
82
96
|
// Only handle data for our session
|
|
@@ -120,11 +134,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
120
134
|
const handleResize = () => {
|
|
121
135
|
const cols = process.stdout.columns || 80;
|
|
122
136
|
const rows = process.stdout.rows || 24;
|
|
123
|
-
session.
|
|
124
|
-
// Also resize the virtual terminal
|
|
125
|
-
if (session.terminal) {
|
|
126
|
-
session.terminal.resize(cols, rows);
|
|
127
|
-
}
|
|
137
|
+
sessionManager.performResize(session.id, cols, rows);
|
|
128
138
|
};
|
|
129
139
|
stdout.on('resize', handleResize);
|
|
130
140
|
return () => {
|
|
@@ -138,6 +148,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
138
148
|
sessionManager.setSessionActive(session.id, false);
|
|
139
149
|
// Remove event listeners
|
|
140
150
|
sessionManager.off('sessionRestore', handleSessionRestore);
|
|
151
|
+
sessionManager.off('sessionResize', handleSessionResize);
|
|
141
152
|
sessionManager.off('sessionData', handleSessionData);
|
|
142
153
|
sessionManager.off('sessionExit', handleSessionExit);
|
|
143
154
|
stdout.off('resize', handleResize);
|
|
@@ -96,7 +96,7 @@ describe('Session', () => {
|
|
|
96
96
|
expect(terminalResize).toHaveBeenCalledWith(120, 40);
|
|
97
97
|
expect(processResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
|
|
98
98
|
expect(terminalResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
|
|
99
|
-
expect(testState.stdout?.write).toHaveBeenNthCalledWith(2, '\nrestored');
|
|
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
102
|
});
|
|
@@ -18,6 +18,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
18
18
|
private autoApprovalDisabledWorktrees;
|
|
19
19
|
private restoringSessions;
|
|
20
20
|
private bufferedRestoreData;
|
|
21
|
+
private resizingSessions;
|
|
22
|
+
private resizeSuppressTimers;
|
|
23
|
+
private restoreDeferTimers;
|
|
24
|
+
private restoreDeferDeadlines;
|
|
21
25
|
private spawn;
|
|
22
26
|
private resolvePreset;
|
|
23
27
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -39,6 +43,15 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
39
43
|
private createTerminal;
|
|
40
44
|
private shouldResetRestoreScrollback;
|
|
41
45
|
private getRestoreSnapshot;
|
|
46
|
+
private getViewportRedrawSnapshot;
|
|
47
|
+
/**
|
|
48
|
+
* Resize a session's PTY and headless terminal, then repaint the user's
|
|
49
|
+
* terminal viewport from the post-resize headless state. Live PTY → stdout
|
|
50
|
+
* forwarding is suppressed for a short window so the child's SIGWINCH
|
|
51
|
+
* redraw (which on Ink-based TUIs re-emits the full static history) cannot
|
|
52
|
+
* append duplicates below the repainted viewport.
|
|
53
|
+
*/
|
|
54
|
+
performResize(sessionId: string, cols: number, rows: number): void;
|
|
42
55
|
private createSessionInternal;
|
|
43
56
|
/**
|
|
44
57
|
* Create session with command preset using Effect-based error handling
|
|
@@ -72,6 +85,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
72
85
|
getSessionById(id: string): Session | undefined;
|
|
73
86
|
getSessionsForWorktree(worktreePath: string): Session[];
|
|
74
87
|
setSessionActive(sessionId: string, active: boolean): void;
|
|
88
|
+
private emitRestoreSnapshot;
|
|
89
|
+
private armRestoreDeferTimer;
|
|
90
|
+
private fireRestoreDefer;
|
|
91
|
+
private cancelRestoreDefer;
|
|
75
92
|
cancelAutoApproval(sessionId: string, reason?: string): void;
|
|
76
93
|
toggleAutoApprovalForWorktree(worktreePath: string): boolean;
|
|
77
94
|
isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
|
|
@@ -21,6 +21,18 @@ 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
|
+
// How long to suppress PTY → stdout forwarding after a viewport resize so the
|
|
25
|
+
// child process's SIGWINCH-triggered re-emission of static content does not
|
|
26
|
+
// duplicate already-displayed rows in the user's terminal.
|
|
27
|
+
const RESIZE_SUPPRESS_MS = 250;
|
|
28
|
+
// While a busy TUI is mid-frame across multiple PTY chunks, a synchronous
|
|
29
|
+
// viewport-only restore can capture an incomplete frame and paint a half-
|
|
30
|
+
// drawn screen. Defer the restore emit until PTY output has been quiet for
|
|
31
|
+
// this long (extended on each chunk) so the snapshot captures a coherent
|
|
32
|
+
// post-frame state. Capped by RESTORE_DEFER_MAX_MS so a continuously
|
|
33
|
+
// streaming session still restores within a small bounded delay.
|
|
34
|
+
const RESTORE_DEFER_QUIET_MS = 80;
|
|
35
|
+
const RESTORE_DEFER_MAX_MS = 250;
|
|
24
36
|
export class SessionManager extends EventEmitter {
|
|
25
37
|
sessions;
|
|
26
38
|
waitingWithBottomBorder = new Map();
|
|
@@ -28,6 +40,10 @@ export class SessionManager extends EventEmitter {
|
|
|
28
40
|
autoApprovalDisabledWorktrees = new Set();
|
|
29
41
|
restoringSessions = new Set();
|
|
30
42
|
bufferedRestoreData = new Map();
|
|
43
|
+
resizingSessions = new Set();
|
|
44
|
+
resizeSuppressTimers = new Map();
|
|
45
|
+
restoreDeferTimers = new Map();
|
|
46
|
+
restoreDeferDeadlines = new Map();
|
|
31
47
|
async spawn(command, args, worktreePath, options = {}) {
|
|
32
48
|
const spawnOptions = {
|
|
33
49
|
name: 'xterm-256color',
|
|
@@ -183,6 +199,15 @@ export class SessionManager extends EventEmitter {
|
|
|
183
199
|
...additionalUpdates,
|
|
184
200
|
}));
|
|
185
201
|
if (oldState !== newState) {
|
|
202
|
+
// While busy, cursor-addressed footer redraws (spinner, token stats,
|
|
203
|
+
// "accept edits on …" line) can push ghost frames into scrollback as
|
|
204
|
+
// chat content scrolls. Once the busy turn ends, advance the restore
|
|
205
|
+
// baseline so the next session restore replays only post-busy
|
|
206
|
+
// scrollback and skips the ghost-bearing range.
|
|
207
|
+
if (oldState === 'busy' && newState !== 'busy') {
|
|
208
|
+
session.restoreScrollbackBaseLine =
|
|
209
|
+
session.terminal.buffer.normal.baseY;
|
|
210
|
+
}
|
|
186
211
|
void Effect.runPromise(executeStatusHook(oldState, newState, session));
|
|
187
212
|
this.emit('sessionStateChanged', session);
|
|
188
213
|
}
|
|
@@ -217,6 +242,21 @@ export class SessionManager extends EventEmitter {
|
|
|
217
242
|
if (bufferLength === 0) {
|
|
218
243
|
return '';
|
|
219
244
|
}
|
|
245
|
+
const cursorRow = normalBuffer.cursorY + 1;
|
|
246
|
+
const cursorCol = normalBuffer.cursorX + 1;
|
|
247
|
+
// When the live viewport shows a transient footer (spinner activity,
|
|
248
|
+
// token stats, persistent shift+tab footer, etc.), the renderer keeps
|
|
249
|
+
// redrawing it in place and earlier copies have likely been pushed into
|
|
250
|
+
// scrollback by chat output scrolling beneath it. Replaying that
|
|
251
|
+
// scrollback would paint duplicated footer rows, so emit only the
|
|
252
|
+
// viewport in this case.
|
|
253
|
+
if (session.stateDetector.hasTransientRenderFooter(session.terminal)) {
|
|
254
|
+
const viewportSnapshot = session.serializer.serialize({
|
|
255
|
+
scrollback: 0,
|
|
256
|
+
excludeAltBuffer: true,
|
|
257
|
+
});
|
|
258
|
+
return `${viewportSnapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
259
|
+
}
|
|
220
260
|
const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
|
|
221
261
|
const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
|
|
222
262
|
const rangeEnd = bufferLength - 1;
|
|
@@ -227,10 +267,76 @@ export class SessionManager extends EventEmitter {
|
|
|
227
267
|
},
|
|
228
268
|
excludeAltBuffer: true,
|
|
229
269
|
});
|
|
270
|
+
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
271
|
+
}
|
|
272
|
+
getViewportRedrawSnapshot(session) {
|
|
273
|
+
const snapshot = session.serializer.serialize({ scrollback: 0 });
|
|
274
|
+
if (snapshot.length === 0) {
|
|
275
|
+
return '';
|
|
276
|
+
}
|
|
277
|
+
const activeBuffer = session.terminal.buffer.active;
|
|
278
|
+
if (activeBuffer.type !== 'normal') {
|
|
279
|
+
// Serialized alternate-buffer output already carries its own cursor
|
|
280
|
+
// positioning, so do not append a cursor home sequence here.
|
|
281
|
+
return snapshot;
|
|
282
|
+
}
|
|
283
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
230
284
|
const cursorRow = normalBuffer.cursorY + 1;
|
|
231
285
|
const cursorCol = normalBuffer.cursorX + 1;
|
|
232
286
|
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
233
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Resize a session's PTY and headless terminal, then repaint the user's
|
|
290
|
+
* terminal viewport from the post-resize headless state. Live PTY → stdout
|
|
291
|
+
* forwarding is suppressed for a short window so the child's SIGWINCH
|
|
292
|
+
* redraw (which on Ink-based TUIs re-emits the full static history) cannot
|
|
293
|
+
* append duplicates below the repainted viewport.
|
|
294
|
+
*/
|
|
295
|
+
performResize(sessionId, cols, rows) {
|
|
296
|
+
const session = this.sessions.get(sessionId);
|
|
297
|
+
if (!session) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
session.process.resize(cols, rows);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
/* empty */
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
session.terminal.resize(cols, rows);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
/* empty */
|
|
311
|
+
}
|
|
312
|
+
this.resizingSessions.add(sessionId);
|
|
313
|
+
const existing = this.resizeSuppressTimers.get(sessionId);
|
|
314
|
+
if (existing !== undefined) {
|
|
315
|
+
clearTimeout(existing);
|
|
316
|
+
}
|
|
317
|
+
const timer = setTimeout(() => {
|
|
318
|
+
this.resizeSuppressTimers.delete(sessionId);
|
|
319
|
+
this.resizingSessions.delete(sessionId);
|
|
320
|
+
}, RESIZE_SUPPRESS_MS);
|
|
321
|
+
this.resizeSuppressTimers.set(sessionId, timer);
|
|
322
|
+
if (!session.isActive) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const snapshot = this.getViewportRedrawSnapshot(session);
|
|
326
|
+
if (snapshot.length === 0) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// \x1b[?7h ... \x1b[?7l: SerializeAddon's wrapped-row encoding relies
|
|
330
|
+
// on auto-wrap (DECAWM) being enabled to advance the cursor across
|
|
331
|
+
// row boundaries (see PR #276). Session.tsx keeps DECAWM disabled
|
|
332
|
+
// for live TUI redraws, so re-enable it just for the snapshot write
|
|
333
|
+
// and restore the live default afterward.
|
|
334
|
+
// \x1b[2J\x1b[H: clear the post-resize viewport so the snapshot paints
|
|
335
|
+
// onto a known canvas; rows that the child won't cover (e.g. trailing
|
|
336
|
+
// blanks after a height shrink) stay clean rather than retaining
|
|
337
|
+
// pre-resize content.
|
|
338
|
+
this.emit('sessionResize', session, `\x1b[?7h\x1b[2J\x1b[H${snapshot}\x1b[?7l`);
|
|
339
|
+
}
|
|
234
340
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
235
341
|
const existingSessions = this.getSessionsForWorktree(worktreePath);
|
|
236
342
|
const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
|
|
@@ -334,6 +440,14 @@ export class SessionManager extends EventEmitter {
|
|
|
334
440
|
session.terminal.buffer.normal.baseY;
|
|
335
441
|
}
|
|
336
442
|
session.lastActivity = new Date();
|
|
443
|
+
// Deferred restore is waiting for the TUI to finish a frame. Reset
|
|
444
|
+
// the quiet timer with each chunk; the snapshot fired at the end of
|
|
445
|
+
// the quiet window will already include this data through the
|
|
446
|
+
// headless write above, so do not buffer or forward.
|
|
447
|
+
if (this.restoreDeferDeadlines.has(session.id)) {
|
|
448
|
+
this.armRestoreDeferTimer(session);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
337
451
|
// Only emit data events when session is active
|
|
338
452
|
if (session.isActive) {
|
|
339
453
|
if (this.restoringSessions.has(session.id)) {
|
|
@@ -342,6 +456,15 @@ export class SessionManager extends EventEmitter {
|
|
|
342
456
|
this.bufferedRestoreData.set(session.id, bufferedData);
|
|
343
457
|
return;
|
|
344
458
|
}
|
|
459
|
+
// During a viewport resize, the child process re-emits its full
|
|
460
|
+
// static content to adapt to new line-wrapping. The headless
|
|
461
|
+
// terminal already absorbed the data above, so the post-resize
|
|
462
|
+
// snapshot we wrote on resize is in sync. Drop the live forward
|
|
463
|
+
// here to keep the user's terminal from acquiring duplicated
|
|
464
|
+
// rows below the snapshot.
|
|
465
|
+
if (this.resizingSessions.has(session.id)) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
345
468
|
this.emit('sessionData', session, data);
|
|
346
469
|
}
|
|
347
470
|
});
|
|
@@ -477,29 +600,83 @@ export class SessionManager extends EventEmitter {
|
|
|
477
600
|
if (active) {
|
|
478
601
|
session.lastAccessedAt = Date.now();
|
|
479
602
|
this.restoringSessions.add(session.id);
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const bufferedData = this.bufferedRestoreData.get(session.id);
|
|
489
|
-
if (bufferedData && bufferedData.length > 0) {
|
|
490
|
-
this.bufferedRestoreData.delete(session.id);
|
|
491
|
-
for (const chunk of bufferedData) {
|
|
492
|
-
this.emit('sessionData', session, chunk);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
603
|
+
// While the TUI shows a busy footer it may be mid-frame across
|
|
604
|
+
// multiple PTY chunks; capturing the snapshot synchronously can
|
|
605
|
+
// surface a half-drawn viewport. Defer until PTY output is
|
|
606
|
+
// quiet so the snapshot reflects a coherent post-frame state.
|
|
607
|
+
if (session.stateDetector.hasTransientRenderFooter(session.terminal)) {
|
|
608
|
+
this.restoreDeferDeadlines.set(session.id, Date.now() + RESTORE_DEFER_MAX_MS);
|
|
609
|
+
this.armRestoreDeferTimer(session);
|
|
610
|
+
return;
|
|
495
611
|
}
|
|
612
|
+
this.emitRestoreSnapshot(session);
|
|
496
613
|
}
|
|
497
614
|
else {
|
|
498
615
|
this.restoringSessions.delete(session.id);
|
|
499
616
|
this.bufferedRestoreData.delete(session.id);
|
|
617
|
+
this.resizingSessions.delete(session.id);
|
|
618
|
+
const resizeTimer = this.resizeSuppressTimers.get(session.id);
|
|
619
|
+
if (resizeTimer !== undefined) {
|
|
620
|
+
clearTimeout(resizeTimer);
|
|
621
|
+
this.resizeSuppressTimers.delete(session.id);
|
|
622
|
+
}
|
|
623
|
+
this.cancelRestoreDefer(session.id);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
emitRestoreSnapshot(session) {
|
|
628
|
+
try {
|
|
629
|
+
const restoreSnapshot = this.getRestoreSnapshot(session);
|
|
630
|
+
if (restoreSnapshot.length > 0) {
|
|
631
|
+
this.emit('sessionRestore', session, restoreSnapshot);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
this.restoringSessions.delete(session.id);
|
|
636
|
+
const bufferedData = this.bufferedRestoreData.get(session.id);
|
|
637
|
+
if (bufferedData && bufferedData.length > 0) {
|
|
638
|
+
this.bufferedRestoreData.delete(session.id);
|
|
639
|
+
for (const chunk of bufferedData) {
|
|
640
|
+
this.emit('sessionData', session, chunk);
|
|
641
|
+
}
|
|
500
642
|
}
|
|
501
643
|
}
|
|
502
644
|
}
|
|
645
|
+
armRestoreDeferTimer(session) {
|
|
646
|
+
const deadline = this.restoreDeferDeadlines.get(session.id);
|
|
647
|
+
if (deadline === undefined) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const existing = this.restoreDeferTimers.get(session.id);
|
|
651
|
+
if (existing !== undefined) {
|
|
652
|
+
clearTimeout(existing);
|
|
653
|
+
}
|
|
654
|
+
const remaining = deadline - Date.now();
|
|
655
|
+
if (remaining <= 0) {
|
|
656
|
+
this.fireRestoreDefer(session);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const delay = Math.min(RESTORE_DEFER_QUIET_MS, remaining);
|
|
660
|
+
const timer = setTimeout(() => this.fireRestoreDefer(session), delay);
|
|
661
|
+
this.restoreDeferTimers.set(session.id, timer);
|
|
662
|
+
}
|
|
663
|
+
fireRestoreDefer(session) {
|
|
664
|
+
this.restoreDeferTimers.delete(session.id);
|
|
665
|
+
this.restoreDeferDeadlines.delete(session.id);
|
|
666
|
+
if (!session.isActive) {
|
|
667
|
+
this.restoringSessions.delete(session.id);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.emitRestoreSnapshot(session);
|
|
671
|
+
}
|
|
672
|
+
cancelRestoreDefer(sessionId) {
|
|
673
|
+
const timer = this.restoreDeferTimers.get(sessionId);
|
|
674
|
+
if (timer !== undefined) {
|
|
675
|
+
clearTimeout(timer);
|
|
676
|
+
this.restoreDeferTimers.delete(sessionId);
|
|
677
|
+
}
|
|
678
|
+
this.restoreDeferDeadlines.delete(sessionId);
|
|
679
|
+
}
|
|
503
680
|
cancelAutoApproval(sessionId, reason = 'User input received') {
|
|
504
681
|
const session = this.sessions.get(sessionId);
|
|
505
682
|
if (!session) {
|
|
@@ -571,6 +748,13 @@ export class SessionManager extends EventEmitter {
|
|
|
571
748
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
572
749
|
this.restoringSessions.delete(sessionId);
|
|
573
750
|
this.bufferedRestoreData.delete(sessionId);
|
|
751
|
+
this.resizingSessions.delete(sessionId);
|
|
752
|
+
const resizeTimer = this.resizeSuppressTimers.get(sessionId);
|
|
753
|
+
if (resizeTimer !== undefined) {
|
|
754
|
+
clearTimeout(resizeTimer);
|
|
755
|
+
this.resizeSuppressTimers.delete(sessionId);
|
|
756
|
+
}
|
|
757
|
+
this.cancelRestoreDefer(sessionId);
|
|
574
758
|
this.emit('sessionDestroyed', session);
|
|
575
759
|
}
|
|
576
760
|
}
|
|
@@ -624,6 +808,13 @@ export class SessionManager extends EventEmitter {
|
|
|
624
808
|
}
|
|
625
809
|
this.sessions.delete(sessionId);
|
|
626
810
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
811
|
+
this.resizingSessions.delete(sessionId);
|
|
812
|
+
const resizeTimer = this.resizeSuppressTimers.get(sessionId);
|
|
813
|
+
if (resizeTimer !== undefined) {
|
|
814
|
+
clearTimeout(resizeTimer);
|
|
815
|
+
this.resizeSuppressTimers.delete(sessionId);
|
|
816
|
+
}
|
|
817
|
+
this.cancelRestoreDefer(sessionId);
|
|
627
818
|
this.emit('sessionDestroyed', session);
|
|
628
819
|
},
|
|
629
820
|
catch: (error) => {
|
|
@@ -872,6 +872,217 @@ describe('SessionManager', () => {
|
|
|
872
872
|
sessionManager.setSessionActive(session.id, true);
|
|
873
873
|
expect(eventOrder).toEqual(['restore', 'data']);
|
|
874
874
|
});
|
|
875
|
+
it('should defer the viewport-only restore until PTY output is quiet when a transient footer is visible', async () => {
|
|
876
|
+
vi.useFakeTimers();
|
|
877
|
+
try {
|
|
878
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
879
|
+
id: '1',
|
|
880
|
+
name: 'Main',
|
|
881
|
+
command: 'claude',
|
|
882
|
+
});
|
|
883
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
884
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
885
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
886
|
+
normalBuffer.baseY = 260;
|
|
887
|
+
normalBuffer.length = 300;
|
|
888
|
+
normalBuffer.cursorY = 7;
|
|
889
|
+
normalBuffer.cursorX = 11;
|
|
890
|
+
session.restoreScrollbackBaseLine = 120;
|
|
891
|
+
vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
|
|
892
|
+
const serializeMock = vi
|
|
893
|
+
.spyOn(session.serializer, 'serialize')
|
|
894
|
+
.mockReturnValue('[31mviewport[0m');
|
|
895
|
+
const restoreHandler = vi.fn();
|
|
896
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
897
|
+
sessionManager.setSessionActive(session.id, true);
|
|
898
|
+
expect(restoreHandler).not.toHaveBeenCalled();
|
|
899
|
+
expect(serializeMock).not.toHaveBeenCalled();
|
|
900
|
+
vi.advanceTimersByTime(80);
|
|
901
|
+
expect(serializeMock).toHaveBeenCalledWith({
|
|
902
|
+
scrollback: 0,
|
|
903
|
+
excludeAltBuffer: true,
|
|
904
|
+
});
|
|
905
|
+
expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({ range: expect.anything() }));
|
|
906
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '[31mviewport[0m[8;12H');
|
|
907
|
+
}
|
|
908
|
+
finally {
|
|
909
|
+
vi.useRealTimers();
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
it('should extend the restore quiet timer while PTY output keeps streaming, capped by the deadline', async () => {
|
|
913
|
+
vi.useFakeTimers();
|
|
914
|
+
try {
|
|
915
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
916
|
+
id: '1',
|
|
917
|
+
name: 'Main',
|
|
918
|
+
command: 'claude',
|
|
919
|
+
});
|
|
920
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
921
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
922
|
+
session.terminal.buffer.normal.length =
|
|
923
|
+
1;
|
|
924
|
+
vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
|
|
925
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
926
|
+
const restoreHandler = vi.fn();
|
|
927
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
928
|
+
sessionManager.setSessionActive(session.id, true);
|
|
929
|
+
expect(restoreHandler).not.toHaveBeenCalled();
|
|
930
|
+
// Each chunk arrives within the quiet window and resets the timer
|
|
931
|
+
// without firing the snapshot. Three 50 ms ticks keep the cap
|
|
932
|
+
// (250 ms) safely ahead.
|
|
933
|
+
for (let i = 0; i < 3; i++) {
|
|
934
|
+
vi.advanceTimersByTime(50);
|
|
935
|
+
mockPty.emit('data', 'tick');
|
|
936
|
+
}
|
|
937
|
+
expect(restoreHandler).not.toHaveBeenCalled();
|
|
938
|
+
// Advance past the cap so the snapshot fires even though chunks
|
|
939
|
+
// kept arriving inside the quiet window.
|
|
940
|
+
vi.advanceTimersByTime(150);
|
|
941
|
+
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
942
|
+
}
|
|
943
|
+
finally {
|
|
944
|
+
vi.useRealTimers();
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
it('should cancel a pending deferred restore when the session is deactivated', async () => {
|
|
948
|
+
vi.useFakeTimers();
|
|
949
|
+
try {
|
|
950
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
951
|
+
id: '1',
|
|
952
|
+
name: 'Main',
|
|
953
|
+
command: 'claude',
|
|
954
|
+
});
|
|
955
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
956
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
957
|
+
vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
|
|
958
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
959
|
+
const restoreHandler = vi.fn();
|
|
960
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
961
|
+
sessionManager.setSessionActive(session.id, true);
|
|
962
|
+
sessionManager.setSessionActive(session.id, false);
|
|
963
|
+
vi.advanceTimersByTime(500);
|
|
964
|
+
expect(restoreHandler).not.toHaveBeenCalled();
|
|
965
|
+
}
|
|
966
|
+
finally {
|
|
967
|
+
vi.useRealTimers();
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
it('should advance the restore baseline when transitioning out of busy', async () => {
|
|
971
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
972
|
+
id: '1',
|
|
973
|
+
name: 'Main',
|
|
974
|
+
command: 'claude',
|
|
975
|
+
});
|
|
976
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
977
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
978
|
+
session.terminal.buffer.normal.baseY = 42;
|
|
979
|
+
session.restoreScrollbackBaseLine = 5;
|
|
980
|
+
await session.stateMutex.update(data => ({ ...data, state: 'busy' }));
|
|
981
|
+
const updateState = sessionManager.updateSessionState.bind(sessionManager);
|
|
982
|
+
await updateState(session, 'idle');
|
|
983
|
+
expect(session.restoreScrollbackBaseLine).toBe(42);
|
|
984
|
+
});
|
|
985
|
+
it('should not advance the restore baseline on transitions that do not leave busy', async () => {
|
|
986
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
987
|
+
id: '1',
|
|
988
|
+
name: 'Main',
|
|
989
|
+
command: 'claude',
|
|
990
|
+
});
|
|
991
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
992
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
993
|
+
session.terminal.buffer.normal.baseY = 42;
|
|
994
|
+
session.restoreScrollbackBaseLine = 5;
|
|
995
|
+
await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
|
|
996
|
+
const updateState = sessionManager.updateSessionState.bind(sessionManager);
|
|
997
|
+
await updateState(session, 'busy');
|
|
998
|
+
expect(session.restoreScrollbackBaseLine).toBe(5);
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
describe('performResize', () => {
|
|
1002
|
+
beforeEach(() => {
|
|
1003
|
+
vi.useFakeTimers();
|
|
1004
|
+
});
|
|
1005
|
+
afterEach(() => {
|
|
1006
|
+
vi.useRealTimers();
|
|
1007
|
+
});
|
|
1008
|
+
it('emits a wrapped viewport snapshot to repaint after resize', async () => {
|
|
1009
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
1010
|
+
id: '1',
|
|
1011
|
+
name: 'Main',
|
|
1012
|
+
command: 'claude',
|
|
1013
|
+
});
|
|
1014
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
1015
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
1016
|
+
session.isActive = true;
|
|
1017
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
1018
|
+
normalBuffer.cursorY = 3;
|
|
1019
|
+
normalBuffer.cursorX = 4;
|
|
1020
|
+
const serializeMock = vi
|
|
1021
|
+
.spyOn(session.serializer, 'serialize')
|
|
1022
|
+
.mockReturnValue('VIEWPORT');
|
|
1023
|
+
const resizeHandler = vi.fn();
|
|
1024
|
+
sessionManager.on('sessionResize', resizeHandler);
|
|
1025
|
+
sessionManager.performResize(session.id, 100, 24);
|
|
1026
|
+
expect(session.process.resize).toHaveBeenCalledWith(100, 24);
|
|
1027
|
+
expect(session.terminal.resize).toHaveBeenCalledWith(100, 24);
|
|
1028
|
+
expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
|
|
1029
|
+
expect(resizeHandler).toHaveBeenCalledWith(session, '[?7h[2J[HVIEWPORT[4;5H[?7l');
|
|
1030
|
+
});
|
|
1031
|
+
it('suppresses live PTY → stdout forwarding for the post-resize quiet window', async () => {
|
|
1032
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
1033
|
+
id: '1',
|
|
1034
|
+
name: 'Main',
|
|
1035
|
+
command: 'claude',
|
|
1036
|
+
});
|
|
1037
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
1038
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
1039
|
+
session.isActive = true;
|
|
1040
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('VIEWPORT');
|
|
1041
|
+
const dataHandler = vi.fn();
|
|
1042
|
+
sessionManager.on('sessionData', dataHandler);
|
|
1043
|
+
sessionManager.performResize(session.id, 100, 24);
|
|
1044
|
+
mockPty.emit('data', 'static-replay');
|
|
1045
|
+
expect(dataHandler).not.toHaveBeenCalled();
|
|
1046
|
+
vi.advanceTimersByTime(260);
|
|
1047
|
+
mockPty.emit('data', 'live-after-window');
|
|
1048
|
+
expect(dataHandler).toHaveBeenCalledTimes(1);
|
|
1049
|
+
expect(dataHandler).toHaveBeenCalledWith(session, 'live-after-window');
|
|
1050
|
+
});
|
|
1051
|
+
it('skips emitting a resize repaint for inactive sessions', async () => {
|
|
1052
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
1053
|
+
id: '1',
|
|
1054
|
+
name: 'Main',
|
|
1055
|
+
command: 'claude',
|
|
1056
|
+
});
|
|
1057
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
1058
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
1059
|
+
session.isActive = false;
|
|
1060
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('VIEWPORT');
|
|
1061
|
+
const resizeHandler = vi.fn();
|
|
1062
|
+
sessionManager.on('sessionResize', resizeHandler);
|
|
1063
|
+
sessionManager.performResize(session.id, 100, 24);
|
|
1064
|
+
expect(resizeHandler).not.toHaveBeenCalled();
|
|
1065
|
+
expect(session.process.resize).toHaveBeenCalledWith(100, 24);
|
|
1066
|
+
});
|
|
1067
|
+
it('clears the resize suppression when the session is deactivated', async () => {
|
|
1068
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
1069
|
+
id: '1',
|
|
1070
|
+
name: 'Main',
|
|
1071
|
+
command: 'claude',
|
|
1072
|
+
});
|
|
1073
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
1074
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
1075
|
+
session.isActive = true;
|
|
1076
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('VIEWPORT');
|
|
1077
|
+
const dataHandler = vi.fn();
|
|
1078
|
+
sessionManager.on('sessionData', dataHandler);
|
|
1079
|
+
sessionManager.performResize(session.id, 100, 24);
|
|
1080
|
+
sessionManager.setSessionActive(session.id, false);
|
|
1081
|
+
session.isActive = true;
|
|
1082
|
+
mockPty.emit('data', 'live-after-deactivate');
|
|
1083
|
+
expect(dataHandler).toHaveBeenCalledTimes(1);
|
|
1084
|
+
expect(dataHandler).toHaveBeenCalledWith(session, 'live-after-deactivate');
|
|
1085
|
+
});
|
|
875
1086
|
});
|
|
876
1087
|
describe('static methods', () => {
|
|
877
1088
|
describe('getSessionCounts', () => {
|
|
@@ -6,4 +6,5 @@ export declare abstract class BaseStateDetector implements StateDetector {
|
|
|
6
6
|
protected getTerminalContent(terminal: Terminal, maxLines: number): string;
|
|
7
7
|
abstract detectBackgroundTask(terminal: Terminal): number;
|
|
8
8
|
abstract detectTeamMembers(terminal: Terminal): number;
|
|
9
|
+
hasTransientRenderFooter(_terminal: Terminal): boolean;
|
|
9
10
|
}
|
|
@@ -33,5 +33,6 @@ export declare class ClaudeStateDetector extends BaseStateDetector {
|
|
|
33
33
|
private getRecentContentAbovePromptBox;
|
|
34
34
|
detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
35
35
|
detectBackgroundTask(terminal: Terminal): number;
|
|
36
|
+
hasTransientRenderFooter(terminal: Terminal): boolean;
|
|
36
37
|
detectTeamMembers(terminal: Terminal): number;
|
|
37
38
|
}
|
|
@@ -145,6 +145,31 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
145
145
|
// No background task detected
|
|
146
146
|
return 0;
|
|
147
147
|
}
|
|
148
|
+
hasTransientRenderFooter(terminal) {
|
|
149
|
+
// Only flag busy-turn footer markers. The persistent "(shift+tab to
|
|
150
|
+
// cycle)" footer is always visible during an active Claude session
|
|
151
|
+
// even when idle, but it does not by itself indicate that recent
|
|
152
|
+
// scrolling pushed ghost frames into scrollback — only an in-flight
|
|
153
|
+
// busy turn does. busy → non-busy transitions advance the restore
|
|
154
|
+
// baseline separately so already-scrolled ghosts from a just-ended
|
|
155
|
+
// turn are not replayed either.
|
|
156
|
+
const viewport = this.getTerminalContent(terminal, terminal.rows);
|
|
157
|
+
if (viewport.length === 0) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (SPINNER_ACTIVITY_PATTERN.test(viewport)) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (TOKEN_STATS_LINE_PATTERN.test(viewport)) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
const lower = viewport.toLowerCase();
|
|
167
|
+
if (lower.includes('esc to interrupt') ||
|
|
168
|
+
lower.includes('ctrl+c to interrupt')) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
148
173
|
detectTeamMembers(terminal) {
|
|
149
174
|
const lines = this.getTerminalLines(terminal, 3);
|
|
150
175
|
// Look for the team member line containing "shift+↑ to expand"
|
|
@@ -778,6 +778,62 @@ describe('ClaudeStateDetector', () => {
|
|
|
778
778
|
expect(count).toBe(3);
|
|
779
779
|
});
|
|
780
780
|
});
|
|
781
|
+
describe('hasTransientRenderFooter', () => {
|
|
782
|
+
it('returns true when viewport contains spinner activity label', () => {
|
|
783
|
+
terminal = createMockTerminal([
|
|
784
|
+
'✶ Befuddling… (1m 1s · ↓ 283 tokens)',
|
|
785
|
+
'──────────────────────────────',
|
|
786
|
+
'❯',
|
|
787
|
+
'──────────────────────────────',
|
|
788
|
+
]);
|
|
789
|
+
expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
|
|
790
|
+
});
|
|
791
|
+
it('returns true when viewport contains a token stats line', () => {
|
|
792
|
+
terminal = createMockTerminal([
|
|
793
|
+
'(9m 21s · ↓ 13.7k tokens)',
|
|
794
|
+
'──────────────────────────────',
|
|
795
|
+
'❯',
|
|
796
|
+
'──────────────────────────────',
|
|
797
|
+
]);
|
|
798
|
+
expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
|
|
799
|
+
});
|
|
800
|
+
it('returns false for a steady idle viewport that only shows the persistent shift+tab footer', () => {
|
|
801
|
+
// The "(shift+tab to cycle)" line is rendered even when nothing
|
|
802
|
+
// is scrolling, so on its own it does not imply scrollback ghosts.
|
|
803
|
+
terminal = createMockTerminal([
|
|
804
|
+
'Some idle conversation',
|
|
805
|
+
'──────────────────────────────',
|
|
806
|
+
'❯',
|
|
807
|
+
'──────────────────────────────',
|
|
808
|
+
'⏵⏵ accept edits on (shift+tab to cycle)',
|
|
809
|
+
]);
|
|
810
|
+
expect(detector.hasTransientRenderFooter(terminal)).toBe(false);
|
|
811
|
+
});
|
|
812
|
+
it('returns true when viewport contains "esc to interrupt"', () => {
|
|
813
|
+
terminal = createMockTerminal([
|
|
814
|
+
'Working...',
|
|
815
|
+
'Press esc to interrupt',
|
|
816
|
+
'❯',
|
|
817
|
+
]);
|
|
818
|
+
expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
|
|
819
|
+
});
|
|
820
|
+
it('returns true when viewport contains "ctrl+c to interrupt"', () => {
|
|
821
|
+
terminal = createMockTerminal(['Searching… (ctrl+c to interrupt)', '❯']);
|
|
822
|
+
expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
|
|
823
|
+
});
|
|
824
|
+
it('returns false on a quiet idle viewport without footer markers', () => {
|
|
825
|
+
terminal = createMockTerminal([
|
|
826
|
+
'Some output',
|
|
827
|
+
'Command completed successfully',
|
|
828
|
+
'> ',
|
|
829
|
+
]);
|
|
830
|
+
expect(detector.hasTransientRenderFooter(terminal)).toBe(false);
|
|
831
|
+
});
|
|
832
|
+
it('returns false for an empty terminal', () => {
|
|
833
|
+
terminal = createMockTerminal([]);
|
|
834
|
+
expect(detector.hasTransientRenderFooter(terminal)).toBe(false);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
781
837
|
describe('detectTeamMembers', () => {
|
|
782
838
|
it('should return 2 when two @name members are present with shift+↑ to expand', () => {
|
|
783
839
|
terminal = createMockTerminal([
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.14",
|
|
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.14",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.14",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.14",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.14",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.14"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|