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.
@@ -59,12 +59,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
59
59
  };
60
60
  }, [view]);
61
61
  useInput(() => {
62
- if (view !== 'worktree-hook-error' || !canReturnFromHookError) {
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 to
68
- // render wrapped lines. It omits row separators for wrapped rows and expects
69
- // characters to naturally overflow to the next line, so auto-wrap must stay
70
- // enabled while writing the snapshot and only be disabled afterward.
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.process.resize(cols, rows);
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
- try {
481
- const restoreSnapshot = this.getRestoreSnapshot(session);
482
- if (restoreSnapshot.length > 0) {
483
- this.emit('sessionRestore', session, restoreSnapshot);
484
- }
485
- }
486
- finally {
487
- this.restoringSessions.delete(session.id);
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('viewport');
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, 'viewport');
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, '[?7hVIEWPORT[?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
  }
@@ -7,4 +7,7 @@ export class BaseStateDetector {
7
7
  getTerminalContent(terminal, maxLines) {
8
8
  return getTerminalScreenContent(terminal, maxLines);
9
9
  }
10
+ hasTransientRenderFooter(_terminal) {
11
+ return false;
12
+ }
10
13
  }
@@ -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([
@@ -3,4 +3,5 @@ export interface StateDetector {
3
3
  detectState(terminal: Terminal, currentState: SessionState): SessionState;
4
4
  detectBackgroundTask(terminal: Terminal): number;
5
5
  detectTeamMembers(terminal: Terminal): number;
6
+ hasTransientRenderFooter(terminal: Terminal): boolean;
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.12",
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.12",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.12",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.12",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.12",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.12"
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",