ccmanager 4.1.6 → 4.1.7

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.
@@ -65,9 +65,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
65
65
  stdout.write('\x1B[2J\x1B[H');
66
66
  // Restore the current terminal state from the headless xterm snapshot.
67
67
  // The xterm serialize addon relies on auto-wrap (DECAWM) being enabled to
68
- // render wrapped lines it omits row separators for wrapped rows, expecting
69
- // characters to naturally overflow to the next line. We therefore keep
70
- // auto-wrap enabled while writing the snapshot and only disable it afterward.
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.
71
71
  const handleSessionRestore = (restoredSession, restoreSnapshot) => {
72
72
  if (restoredSession.id === session.id) {
73
73
  if (restoreSnapshot.length > 0) {
@@ -109,15 +109,12 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
109
109
  /* empty */
110
110
  }
111
111
  // Mark session as active after resizing so the restore snapshot matches
112
- // the current terminal dimensions. setSessionActive synchronously emits
113
- // the 'sessionRestore' event, so the snapshot is written to stdout before
114
- // we proceed.
112
+ // the current terminal dimensions. setSessionActive synchronously emits the
113
+ // restore event, so the snapshot is written to stdout before we proceed.
115
114
  sessionManager.setSessionActive(session.id, true);
116
115
  // Prevent line wrapping from drifting redraws in TUIs that rely on
117
- // cursor-up clears. This MUST come after the restore snapshot write
118
- // because the xterm serialize addon relies on auto-wrap (DECAWM) being
119
- // enabled — it omits row separators for wrapped rows, expecting characters
120
- // to naturally overflow to the next line.
116
+ // cursor-up clears. This must happen after the restore snapshot write,
117
+ // otherwise wrapped restore content can overlap on the same row.
121
118
  stdout.write('\x1b[?7l');
122
119
  // Handle terminal resize
123
120
  const handleResize = () => {
@@ -69,13 +69,25 @@ vi.mock('@xterm/addon-serialize', () => ({
69
69
  vi.mock('@xterm/headless', () => ({
70
70
  default: {
71
71
  Terminal: vi.fn().mockImplementation(function () {
72
+ const normalBuffer = {
73
+ type: 'normal',
74
+ baseY: 0,
75
+ cursorY: 0,
76
+ cursorX: 0,
77
+ length: 0,
78
+ getLine: vi.fn(),
79
+ };
72
80
  return {
73
81
  rows: 24,
74
82
  cols: 80,
75
83
  buffer: {
76
- active: {
77
- type: 'normal',
84
+ active: normalBuffer,
85
+ normal: normalBuffer,
86
+ alternate: {
87
+ type: 'alternate',
78
88
  baseY: 0,
89
+ cursorY: 0,
90
+ cursorX: 0,
79
91
  length: 0,
80
92
  getLine: vi.fn(),
81
93
  },
@@ -16,6 +16,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
16
16
  private waitingWithBottomBorder;
17
17
  private busyTimers;
18
18
  private autoApprovalDisabledWorktrees;
19
+ private restoringSessions;
20
+ private bufferedRestoreData;
19
21
  private spawn;
20
22
  private resolvePreset;
21
23
  detectTerminalState(session: Session): SessionState;
@@ -35,6 +37,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
35
37
  private updateSessionState;
36
38
  constructor();
37
39
  private createTerminal;
40
+ private shouldResetRestoreScrollback;
38
41
  private getRestoreSnapshot;
39
42
  private createSessionInternal;
40
43
  /**
@@ -46,13 +46,25 @@ vi.mock('@xterm/addon-serialize', () => ({
46
46
  vi.mock('@xterm/headless', () => ({
47
47
  default: {
48
48
  Terminal: vi.fn().mockImplementation(function () {
49
+ const normalBuffer = {
50
+ type: 'normal',
51
+ baseY: 0,
52
+ cursorY: 0,
53
+ cursorX: 0,
54
+ length: 0,
55
+ getLine: vi.fn(),
56
+ };
49
57
  return {
50
58
  rows: 24,
51
59
  cols: 80,
52
60
  buffer: {
53
- active: {
54
- type: 'normal',
61
+ active: normalBuffer,
62
+ normal: normalBuffer,
63
+ alternate: {
64
+ type: 'alternate',
55
65
  baseY: 0,
66
+ cursorY: 0,
67
+ cursorX: 0,
56
68
  length: 0,
57
69
  getLine: vi.fn(),
58
70
  },
@@ -20,11 +20,14 @@ import { preparePresetLaunch } from '../utils/presetPrompt.js';
20
20
  const { Terminal } = pkg;
21
21
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
22
  const TERMINAL_SCROLLBACK_LINES = 5000;
23
+ const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
23
24
  export class SessionManager extends EventEmitter {
24
25
  sessions;
25
26
  waitingWithBottomBorder = new Map();
26
27
  busyTimers = new Map();
27
28
  autoApprovalDisabledWorktrees = new Set();
29
+ restoringSessions = new Set();
30
+ bufferedRestoreData = new Map();
28
31
  async spawn(command, args, worktreePath, options = {}) {
29
32
  const spawnOptions = {
30
33
  name: 'xterm-256color',
@@ -197,10 +200,36 @@ export class SessionManager extends EventEmitter {
197
200
  logLevel: 'off',
198
201
  });
199
202
  }
203
+ shouldResetRestoreScrollback(data) {
204
+ return (data.includes('\x1b[2J') ||
205
+ data.includes('\x1b[3J') ||
206
+ data.includes('\x1bc'));
207
+ }
200
208
  getRestoreSnapshot(session) {
201
- return session.serializer.serialize({
202
- scrollback: 0,
209
+ const activeBuffer = session.terminal.buffer.active;
210
+ if (activeBuffer.type !== 'normal') {
211
+ return session.serializer.serialize({
212
+ scrollback: 0,
213
+ });
214
+ }
215
+ const normalBuffer = session.terminal.buffer.normal;
216
+ const bufferLength = normalBuffer.length;
217
+ if (bufferLength === 0) {
218
+ return '';
219
+ }
220
+ const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
221
+ const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
222
+ const rangeEnd = bufferLength - 1;
223
+ const snapshot = session.serializer.serialize({
224
+ range: {
225
+ start: rangeStart,
226
+ end: rangeEnd,
227
+ },
228
+ excludeAltBuffer: true,
203
229
  });
230
+ const cursorRow = normalBuffer.cursorY + 1;
231
+ const cursorCol = normalBuffer.cursorX + 1;
232
+ return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
204
233
  }
205
234
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
206
235
  const existingSessions = this.getSessionsForWorktree(worktreePath);
@@ -224,6 +253,7 @@ export class SessionManager extends EventEmitter {
224
253
  isActive: false,
225
254
  terminal,
226
255
  serializer,
256
+ restoreScrollbackBaseLine: 0,
227
257
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
228
258
  isPrimaryCommand: options.isPrimaryCommand ?? true,
229
259
  presetName: options.presetName,
@@ -299,9 +329,19 @@ export class SessionManager extends EventEmitter {
299
329
  session.process.onData((data) => {
300
330
  // Write data to virtual terminal
301
331
  session.terminal.write(data);
332
+ if (this.shouldResetRestoreScrollback(data)) {
333
+ session.restoreScrollbackBaseLine =
334
+ session.terminal.buffer.normal.baseY;
335
+ }
302
336
  session.lastActivity = new Date();
303
337
  // Only emit data events when session is active
304
338
  if (session.isActive) {
339
+ if (this.restoringSessions.has(session.id)) {
340
+ const bufferedData = this.bufferedRestoreData.get(session.id) ?? [];
341
+ bufferedData.push(data);
342
+ this.bufferedRestoreData.set(session.id, bufferedData);
343
+ return;
344
+ }
305
345
  this.emit('sessionData', session, data);
306
346
  }
307
347
  });
@@ -436,10 +476,27 @@ export class SessionManager extends EventEmitter {
436
476
  session.isActive = active;
437
477
  if (active) {
438
478
  session.lastAccessedAt = Date.now();
439
- const restoreSnapshot = this.getRestoreSnapshot(session);
440
- if (restoreSnapshot.length > 0) {
441
- this.emit('sessionRestore', session, restoreSnapshot);
479
+ 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
+ }
442
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
+ }
495
+ }
496
+ }
497
+ else {
498
+ this.restoringSessions.delete(session.id);
499
+ this.bufferedRestoreData.delete(session.id);
443
500
  }
444
501
  }
445
502
  }
@@ -512,6 +569,8 @@ export class SessionManager extends EventEmitter {
512
569
  }
513
570
  this.sessions.delete(sessionId);
514
571
  this.waitingWithBottomBorder.delete(sessionId);
572
+ this.restoringSessions.delete(sessionId);
573
+ this.bufferedRestoreData.delete(sessionId);
515
574
  this.emit('sessionDestroyed', session);
516
575
  }
517
576
  }
@@ -50,13 +50,27 @@ vi.mock('@xterm/addon-serialize', () => ({
50
50
  vi.mock('@xterm/headless', () => ({
51
51
  default: {
52
52
  Terminal: vi.fn(function () {
53
+ const normalBuffer = {
54
+ type: 'normal',
55
+ baseY: 0,
56
+ cursorY: 0,
57
+ cursorX: 0,
58
+ length: 0,
59
+ getLine: vi.fn(function () {
60
+ return null;
61
+ }),
62
+ };
53
63
  return {
54
64
  rows: 24,
55
65
  cols: 80,
56
66
  buffer: {
57
- active: {
58
- type: 'normal',
67
+ active: normalBuffer,
68
+ normal: normalBuffer,
69
+ alternate: {
70
+ type: 'alternate',
59
71
  baseY: 0,
72
+ cursorY: 0,
73
+ cursorX: 0,
60
74
  length: 0,
61
75
  getLine: vi.fn(function () {
62
76
  return null;
@@ -760,7 +774,7 @@ describe('SessionManager', () => {
760
774
  });
761
775
  });
762
776
  describe('session restore snapshots', () => {
763
- it('should emit serialized terminal output when activating a session', async () => {
777
+ it('should emit a bounded normal-buffer restore snapshot and restore the cursor position', async () => {
764
778
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
765
779
  id: '1',
766
780
  name: 'Main',
@@ -768,14 +782,44 @@ describe('SessionManager', () => {
768
782
  });
769
783
  vi.mocked(spawn).mockReturnValue(mockPty);
770
784
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
785
+ const normalBuffer = session.terminal.buffer.normal;
786
+ normalBuffer.baseY = 260;
787
+ normalBuffer.length = 300;
788
+ normalBuffer.cursorY = 7;
789
+ normalBuffer.cursorX = 11;
790
+ session.restoreScrollbackBaseLine = 120;
771
791
  const serializeMock = vi
772
792
  .spyOn(session.serializer, 'serialize')
773
793
  .mockReturnValue('\u001b[31mrestored\u001b[0m');
774
794
  const restoreHandler = vi.fn();
775
795
  sessionManager.on('sessionRestore', restoreHandler);
776
796
  sessionManager.setSessionActive(session.id, true);
797
+ expect(serializeMock).toHaveBeenCalledWith({
798
+ range: {
799
+ start: 120,
800
+ end: 299,
801
+ },
802
+ excludeAltBuffer: true,
803
+ });
804
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
805
+ });
806
+ it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
807
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
808
+ id: '1',
809
+ name: 'Main',
810
+ command: 'claude',
811
+ });
812
+ vi.mocked(spawn).mockReturnValue(mockPty);
813
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
814
+ session.terminal.buffer.active = session.terminal.buffer.alternate;
815
+ const serializeMock = vi
816
+ .spyOn(session.serializer, 'serialize')
817
+ .mockReturnValue('\u001b[31malt\u001b[0m');
818
+ const restoreHandler = vi.fn();
819
+ sessionManager.on('sessionRestore', restoreHandler);
820
+ sessionManager.setSessionActive(session.id, true);
777
821
  expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
778
- expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m');
822
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31malt\u001b[0m');
779
823
  });
780
824
  it('should skip restore event when serialized output is empty', async () => {
781
825
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -791,6 +835,43 @@ describe('SessionManager', () => {
791
835
  sessionManager.setSessionActive(session.id, true);
792
836
  expect(restoreHandler).not.toHaveBeenCalled();
793
837
  });
838
+ it('should reset restore scrollback baseline after a clear-screen sequence', async () => {
839
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
840
+ id: '1',
841
+ name: 'Main',
842
+ command: 'claude',
843
+ });
844
+ vi.mocked(spawn).mockReturnValue(mockPty);
845
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
846
+ session.terminal.buffer.normal.baseY = 17;
847
+ mockPty.emit('data', '\x1b[2J\x1b[Hfresh');
848
+ expect(session.restoreScrollbackBaseLine).toBe(17);
849
+ });
850
+ it('should flush live session data after the restore snapshot completes', async () => {
851
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
852
+ id: '1',
853
+ name: 'Main',
854
+ command: 'claude',
855
+ });
856
+ vi.mocked(spawn).mockReturnValue(mockPty);
857
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
858
+ session.terminal.buffer.normal.length = 1;
859
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('restored');
860
+ const eventOrder = [];
861
+ sessionManager.on('sessionRestore', restoredSession => {
862
+ if (restoredSession.id === session.id) {
863
+ eventOrder.push('restore');
864
+ mockPty.emit('data', 'live-output');
865
+ }
866
+ });
867
+ sessionManager.on('sessionData', activeSession => {
868
+ if (activeSession.id === session.id) {
869
+ eventOrder.push('data');
870
+ }
871
+ });
872
+ sessionManager.setSessionActive(session.id, true);
873
+ expect(eventOrder).toEqual(['restore', 'data']);
874
+ });
794
875
  });
795
876
  describe('static methods', () => {
796
877
  describe('getSessionCounts', () => {
@@ -30,6 +30,7 @@ export interface Session {
30
30
  isActive: boolean;
31
31
  terminal: Terminal;
32
32
  serializer: SerializeAddon;
33
+ restoreScrollbackBaseLine: number;
33
34
  stateCheckInterval: NodeJS.Timeout | undefined;
34
35
  isPrimaryCommand: boolean;
35
36
  presetName: string | undefined;
@@ -383,6 +383,7 @@ describe('hookExecutor Integration Tests', () => {
383
383
  process: {},
384
384
  terminal: {},
385
385
  serializer: {},
386
+ restoreScrollbackBaseLine: 0,
386
387
  output: [],
387
388
  stateCheckInterval: undefined,
388
389
  isPrimaryCommand: true,
@@ -442,6 +443,7 @@ describe('hookExecutor Integration Tests', () => {
442
443
  process: {},
443
444
  terminal: {},
444
445
  serializer: {},
446
+ restoreScrollbackBaseLine: 0,
445
447
  output: [],
446
448
  stateCheckInterval: undefined,
447
449
  isPrimaryCommand: true,
@@ -499,6 +501,7 @@ describe('hookExecutor Integration Tests', () => {
499
501
  process: {},
500
502
  terminal: {},
501
503
  serializer: {},
504
+ restoreScrollbackBaseLine: 0,
502
505
  output: [],
503
506
  stateCheckInterval: undefined,
504
507
  isPrimaryCommand: true,
@@ -558,6 +561,7 @@ describe('hookExecutor Integration Tests', () => {
558
561
  process: {},
559
562
  terminal: {},
560
563
  serializer: {},
564
+ restoreScrollbackBaseLine: 0,
561
565
  output: [],
562
566
  stateCheckInterval: undefined,
563
567
  isPrimaryCommand: true,
@@ -133,6 +133,7 @@ describe('prepareSessionItems', () => {
133
133
  isActive: true,
134
134
  terminal: {},
135
135
  serializer: {},
136
+ restoreScrollbackBaseLine: 0,
136
137
  stateCheckInterval: undefined,
137
138
  isPrimaryCommand: true,
138
139
  presetName: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.6",
3
+ "version": "4.1.7",
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.6",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.6",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.6",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.6",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.6"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.7",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.7",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.7",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.7",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.7"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",