ccmanager 4.1.11 → 4.1.13
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/Session.js +21 -10
- package/dist/components/Session.test.js +1 -1
- package/dist/services/sessionManager.d.ts +17 -6
- package/dist/services/sessionManager.js +190 -86
- package/dist/services/sessionManager.test.js +151 -51
- 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/codex.js +2 -1
- package/dist/services/stateDetector/codex.test.js +20 -0
- package/dist/services/stateDetector/types.d.ts +1 -0
- package/package.json +6 -6
|
@@ -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,8 +18,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
18
18
|
private autoApprovalDisabledWorktrees;
|
|
19
19
|
private restoringSessions;
|
|
20
20
|
private bufferedRestoreData;
|
|
21
|
-
private
|
|
22
|
-
private
|
|
21
|
+
private resizingSessions;
|
|
22
|
+
private resizeSuppressTimers;
|
|
23
|
+
private restoreDeferTimers;
|
|
24
|
+
private restoreDeferDeadlines;
|
|
23
25
|
private spawn;
|
|
24
26
|
private resolvePreset;
|
|
25
27
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -41,10 +43,15 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
41
43
|
private createTerminal;
|
|
42
44
|
private shouldResetRestoreScrollback;
|
|
43
45
|
private getRestoreSnapshot;
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
48
55
|
private createSessionInternal;
|
|
49
56
|
/**
|
|
50
57
|
* Create session with command preset using Effect-based error handling
|
|
@@ -78,6 +85,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
78
85
|
getSessionById(id: string): Session | undefined;
|
|
79
86
|
getSessionsForWorktree(worktreePath: string): Session[];
|
|
80
87
|
setSessionActive(sessionId: string, active: boolean): void;
|
|
88
|
+
private emitRestoreSnapshot;
|
|
89
|
+
private armRestoreDeferTimer;
|
|
90
|
+
private fireRestoreDefer;
|
|
91
|
+
private cancelRestoreDefer;
|
|
81
92
|
cancelAutoApproval(sessionId: string, reason?: string): void;
|
|
82
93
|
toggleAutoApprovalForWorktree(worktreePath: string): boolean;
|
|
83
94
|
isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
|
|
@@ -21,15 +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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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;
|
|
33
36
|
export class SessionManager extends EventEmitter {
|
|
34
37
|
sessions;
|
|
35
38
|
waitingWithBottomBorder = new Map();
|
|
@@ -37,8 +40,10 @@ export class SessionManager extends EventEmitter {
|
|
|
37
40
|
autoApprovalDisabledWorktrees = new Set();
|
|
38
41
|
restoringSessions = new Set();
|
|
39
42
|
bufferedRestoreData = new Map();
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
resizingSessions = new Set();
|
|
44
|
+
resizeSuppressTimers = new Map();
|
|
45
|
+
restoreDeferTimers = new Map();
|
|
46
|
+
restoreDeferDeadlines = new Map();
|
|
42
47
|
async spawn(command, args, worktreePath, options = {}) {
|
|
43
48
|
const spawnOptions = {
|
|
44
49
|
name: 'xterm-256color',
|
|
@@ -194,6 +199,15 @@ export class SessionManager extends EventEmitter {
|
|
|
194
199
|
...additionalUpdates,
|
|
195
200
|
}));
|
|
196
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
|
+
}
|
|
197
211
|
void Effect.runPromise(executeStatusHook(oldState, newState, session));
|
|
198
212
|
this.emit('sessionStateChanged', session);
|
|
199
213
|
}
|
|
@@ -216,7 +230,7 @@ export class SessionManager extends EventEmitter {
|
|
|
216
230
|
data.includes('\x1b[3J') ||
|
|
217
231
|
data.includes('\x1bc'));
|
|
218
232
|
}
|
|
219
|
-
getRestoreSnapshot(session
|
|
233
|
+
getRestoreSnapshot(session) {
|
|
220
234
|
const activeBuffer = session.terminal.buffer.active;
|
|
221
235
|
if (activeBuffer.type !== 'normal') {
|
|
222
236
|
return session.serializer.serialize({
|
|
@@ -228,21 +242,20 @@ export class SessionManager extends EventEmitter {
|
|
|
228
242
|
if (bufferLength === 0) {
|
|
229
243
|
return '';
|
|
230
244
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// scrollback
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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({
|
|
240
255
|
scrollback: 0,
|
|
241
256
|
excludeAltBuffer: true,
|
|
242
257
|
});
|
|
243
|
-
|
|
244
|
-
const cursorCol = normalBuffer.cursorX + 1;
|
|
245
|
-
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
258
|
+
return `${viewportSnapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
246
259
|
}
|
|
247
260
|
const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
|
|
248
261
|
const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
|
|
@@ -254,59 +267,75 @@ export class SessionManager extends EventEmitter {
|
|
|
254
267
|
},
|
|
255
268
|
excludeAltBuffer: true,
|
|
256
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;
|
|
257
284
|
const cursorRow = normalBuffer.cursorY + 1;
|
|
258
285
|
const cursorCol = normalBuffer.cursorX + 1;
|
|
259
286
|
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
260
287
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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) {
|
|
268
298
|
return;
|
|
269
299
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
clearTimeout(existing);
|
|
273
|
-
this.restoreRefreshTimers.delete(session.id);
|
|
300
|
+
try {
|
|
301
|
+
session.process.resize(cols, rows);
|
|
274
302
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
this.fireRestoreRefresh(session);
|
|
278
|
-
return;
|
|
303
|
+
catch {
|
|
304
|
+
/* empty */
|
|
279
305
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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);
|
|
286
314
|
if (existing !== undefined) {
|
|
287
315
|
clearTimeout(existing);
|
|
288
|
-
this.restoreRefreshTimers.delete(session.id);
|
|
289
316
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
this.
|
|
317
|
+
const timer = setTimeout(() => {
|
|
318
|
+
this.resizeSuppressTimers.delete(sessionId);
|
|
319
|
+
this.resizingSessions.delete(sessionId);
|
|
320
|
+
}, RESIZE_SUPPRESS_MS);
|
|
321
|
+
this.resizeSuppressTimers.set(sessionId, timer);
|
|
295
322
|
if (!session.isActive) {
|
|
296
323
|
return;
|
|
297
324
|
}
|
|
298
|
-
const snapshot = this.
|
|
299
|
-
if (snapshot.length
|
|
300
|
-
|
|
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`);
|
|
325
|
+
const snapshot = this.getViewportRedrawSnapshot(session);
|
|
326
|
+
if (snapshot.length === 0) {
|
|
327
|
+
return;
|
|
309
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`);
|
|
310
339
|
}
|
|
311
340
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
312
341
|
const existingSessions = this.getSessionsForWorktree(worktreePath);
|
|
@@ -411,11 +440,13 @@ export class SessionManager extends EventEmitter {
|
|
|
411
440
|
session.terminal.buffer.normal.baseY;
|
|
412
441
|
}
|
|
413
442
|
session.lastActivity = new Date();
|
|
414
|
-
//
|
|
415
|
-
// quiet timer
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
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;
|
|
419
450
|
}
|
|
420
451
|
// Only emit data events when session is active
|
|
421
452
|
if (session.isActive) {
|
|
@@ -425,6 +456,15 @@ export class SessionManager extends EventEmitter {
|
|
|
425
456
|
this.bufferedRestoreData.set(session.id, bufferedData);
|
|
426
457
|
return;
|
|
427
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
|
+
}
|
|
428
468
|
this.emit('sessionData', session, data);
|
|
429
469
|
}
|
|
430
470
|
});
|
|
@@ -560,31 +600,83 @@ export class SessionManager extends EventEmitter {
|
|
|
560
600
|
if (active) {
|
|
561
601
|
session.lastAccessedAt = Date.now();
|
|
562
602
|
this.restoringSessions.add(session.id);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const bufferedData = this.bufferedRestoreData.get(session.id);
|
|
572
|
-
if (bufferedData && bufferedData.length > 0) {
|
|
573
|
-
this.bufferedRestoreData.delete(session.id);
|
|
574
|
-
for (const chunk of bufferedData) {
|
|
575
|
-
this.emit('sessionData', session, chunk);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
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;
|
|
578
611
|
}
|
|
579
|
-
this.
|
|
612
|
+
this.emitRestoreSnapshot(session);
|
|
580
613
|
}
|
|
581
614
|
else {
|
|
582
615
|
this.restoringSessions.delete(session.id);
|
|
583
616
|
this.bufferedRestoreData.delete(session.id);
|
|
584
|
-
this.
|
|
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
|
+
}
|
|
585
642
|
}
|
|
586
643
|
}
|
|
587
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
|
+
}
|
|
588
680
|
cancelAutoApproval(sessionId, reason = 'User input received') {
|
|
589
681
|
const session = this.sessions.get(sessionId);
|
|
590
682
|
if (!session) {
|
|
@@ -656,7 +748,13 @@ export class SessionManager extends EventEmitter {
|
|
|
656
748
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
657
749
|
this.restoringSessions.delete(sessionId);
|
|
658
750
|
this.bufferedRestoreData.delete(sessionId);
|
|
659
|
-
this.
|
|
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);
|
|
660
758
|
this.emit('sessionDestroyed', session);
|
|
661
759
|
}
|
|
662
760
|
}
|
|
@@ -710,7 +808,13 @@ export class SessionManager extends EventEmitter {
|
|
|
710
808
|
}
|
|
711
809
|
this.sessions.delete(sessionId);
|
|
712
810
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
713
|
-
this.
|
|
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);
|
|
714
818
|
this.emit('sessionDestroyed', session);
|
|
715
819
|
},
|
|
716
820
|
catch: (error) => {
|
|
@@ -782,7 +782,6 @@ 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' }));
|
|
786
785
|
const normalBuffer = session.terminal.buffer.normal;
|
|
787
786
|
normalBuffer.baseY = 260;
|
|
788
787
|
normalBuffer.length = 300;
|
|
@@ -804,32 +803,6 @@ describe('SessionManager', () => {
|
|
|
804
803
|
});
|
|
805
804
|
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
|
|
806
805
|
});
|
|
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
|
-
});
|
|
833
806
|
it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
|
|
834
807
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
835
808
|
id: '1',
|
|
@@ -899,7 +872,7 @@ describe('SessionManager', () => {
|
|
|
899
872
|
sessionManager.setSessionActive(session.id, true);
|
|
900
873
|
expect(eventOrder).toEqual(['restore', 'data']);
|
|
901
874
|
});
|
|
902
|
-
it('should
|
|
875
|
+
it('should defer the viewport-only restore until PTY output is quiet when a transient footer is visible', async () => {
|
|
903
876
|
vi.useFakeTimers();
|
|
904
877
|
try {
|
|
905
878
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -909,31 +882,34 @@ describe('SessionManager', () => {
|
|
|
909
882
|
});
|
|
910
883
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
911
884
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
912
|
-
session.terminal.buffer.normal
|
|
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);
|
|
913
892
|
const serializeMock = vi
|
|
914
893
|
.spyOn(session.serializer, 'serialize')
|
|
915
|
-
.mockReturnValue('
|
|
894
|
+
.mockReturnValue('[31mviewport[0m');
|
|
916
895
|
const restoreHandler = vi.fn();
|
|
917
896
|
sessionManager.on('sessionRestore', restoreHandler);
|
|
918
897
|
sessionManager.setSessionActive(session.id, true);
|
|
919
|
-
expect(restoreHandler).
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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({
|
|
898
|
+
expect(restoreHandler).not.toHaveBeenCalled();
|
|
899
|
+
expect(serializeMock).not.toHaveBeenCalled();
|
|
900
|
+
vi.advanceTimersByTime(80);
|
|
901
|
+
expect(serializeMock).toHaveBeenCalledWith({
|
|
928
902
|
scrollback: 0,
|
|
929
903
|
excludeAltBuffer: true,
|
|
930
904
|
});
|
|
905
|
+
expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({ range: expect.anything() }));
|
|
906
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '[31mviewport[0m[8;12H');
|
|
931
907
|
}
|
|
932
908
|
finally {
|
|
933
909
|
vi.useRealTimers();
|
|
934
910
|
}
|
|
935
911
|
});
|
|
936
|
-
it('should
|
|
912
|
+
it('should extend the restore quiet timer while PTY output keeps streaming, capped by the deadline', async () => {
|
|
937
913
|
vi.useFakeTimers();
|
|
938
914
|
try {
|
|
939
915
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -943,23 +919,32 @@ describe('SessionManager', () => {
|
|
|
943
919
|
});
|
|
944
920
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
945
921
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
946
|
-
session.terminal.buffer.normal.length =
|
|
922
|
+
session.terminal.buffer.normal.length =
|
|
923
|
+
1;
|
|
924
|
+
vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
|
|
947
925
|
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
948
926
|
const restoreHandler = vi.fn();
|
|
949
927
|
sessionManager.on('sessionRestore', restoreHandler);
|
|
950
928
|
sessionManager.setSessionActive(session.id, true);
|
|
951
|
-
expect(restoreHandler).
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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');
|
|
955
936
|
}
|
|
956
|
-
expect(restoreHandler).
|
|
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);
|
|
957
942
|
}
|
|
958
943
|
finally {
|
|
959
944
|
vi.useRealTimers();
|
|
960
945
|
}
|
|
961
946
|
});
|
|
962
|
-
it('should cancel a
|
|
947
|
+
it('should cancel a pending deferred restore when the session is deactivated', async () => {
|
|
963
948
|
vi.useFakeTimers();
|
|
964
949
|
try {
|
|
965
950
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -969,20 +954,135 @@ describe('SessionManager', () => {
|
|
|
969
954
|
});
|
|
970
955
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
971
956
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
972
|
-
session.
|
|
957
|
+
vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
|
|
973
958
|
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
974
959
|
const restoreHandler = vi.fn();
|
|
975
960
|
sessionManager.on('sessionRestore', restoreHandler);
|
|
976
961
|
sessionManager.setSessionActive(session.id, true);
|
|
977
|
-
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
978
962
|
sessionManager.setSessionActive(session.id, false);
|
|
979
|
-
|
|
980
|
-
expect(restoreHandler).
|
|
963
|
+
vi.advanceTimersByTime(500);
|
|
964
|
+
expect(restoreHandler).not.toHaveBeenCalled();
|
|
981
965
|
}
|
|
982
966
|
finally {
|
|
983
967
|
vi.useRealTimers();
|
|
984
968
|
}
|
|
985
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
|
+
});
|
|
986
1086
|
});
|
|
987
1087
|
describe('static methods', () => {
|
|
988
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([
|
|
@@ -15,7 +15,8 @@ export class CodexStateDetector extends BaseStateDetector {
|
|
|
15
15
|
// Check for waiting prompts
|
|
16
16
|
if (lowerContent.includes('allow command?') ||
|
|
17
17
|
lowerContent.includes('[y/n]') ||
|
|
18
|
-
lowerContent.includes('yes (y)')
|
|
18
|
+
lowerContent.includes('yes (y)') ||
|
|
19
|
+
lowerContent.includes('enter to submit')) {
|
|
19
20
|
return 'waiting_input';
|
|
20
21
|
}
|
|
21
22
|
if (/(do you want|would you like)[\s\S]*?\n+[\s\S]*?\byes\b/.test(lowerContent)) {
|
|
@@ -171,4 +171,24 @@ describe('CodexStateDetector', () => {
|
|
|
171
171
|
// Assert
|
|
172
172
|
expect(state).toBe('waiting_input');
|
|
173
173
|
});
|
|
174
|
+
it('should detect waiting_input for MCP tool permission prompt with "enter to submit"', () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
terminal = createMockTerminal([
|
|
177
|
+
'Field 1/1',
|
|
178
|
+
'Allow the chrome-devtools MCP server to run tool "new_page"?',
|
|
179
|
+
'',
|
|
180
|
+
'timeout: 10000',
|
|
181
|
+
'url: http://localhost:4000/scenarios',
|
|
182
|
+
'',
|
|
183
|
+
'› 1. Allow Run the tool and continue.',
|
|
184
|
+
'2. Allow for this session Run the tool and remember this choice for this session.',
|
|
185
|
+
'3. Always allow Run the tool and remember this choice for future tool calls.',
|
|
186
|
+
'4. Cancel Cancel this tool call',
|
|
187
|
+
'enter to submit | esc to cancel',
|
|
188
|
+
]);
|
|
189
|
+
// Act
|
|
190
|
+
const state = detector.detectState(terminal, 'idle');
|
|
191
|
+
// Assert
|
|
192
|
+
expect(state).toBe('waiting_input');
|
|
193
|
+
});
|
|
174
194
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.13",
|
|
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.13",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.13",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.13",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.13",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.13"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|