ccmanager 4.0.3 → 4.1.0

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.
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import type { Key } from 'ink';
2
3
  export interface ConfirmationOption {
3
4
  label: string;
4
5
  value: string;
@@ -14,9 +15,7 @@ interface ConfirmationProps {
14
15
  hint?: React.ReactNode;
15
16
  onCancel?: () => void;
16
17
  onEscape?: () => void;
17
- onCustomInput?: (input: string, key: {
18
- [key: string]: boolean;
19
- }) => boolean;
18
+ onCustomInput?: (input: string, key: Key) => boolean;
20
19
  }
21
20
  /**
22
21
  * Reusable confirmation component with SelectInput UI pattern
@@ -60,6 +60,10 @@ const makeKey = (overrides = {}) => ({
60
60
  backspace: false,
61
61
  delete: false,
62
62
  meta: false,
63
+ super: false,
64
+ hyper: false,
65
+ capsLock: false,
66
+ numLock: false,
63
67
  ...overrides,
64
68
  });
65
69
  describe('PresetSelector component', () => {
@@ -65,23 +65,11 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
65
65
  stdout.write('\x1b[?7l');
66
66
  // Clear screen when entering session
67
67
  stdout.write('\x1B[2J\x1B[H');
68
- // Handle session restoration by writing all buffered output at once.
69
- // This is faster than chunk-by-chunk replay and preserves scrollback.
70
- const handleSessionRestore = (restoredSession) => {
68
+ // Restore the current terminal state from the headless xterm snapshot.
69
+ const handleSessionRestore = (restoredSession, restoreSnapshot) => {
71
70
  if (restoredSession.id === session.id) {
72
- if (restoredSession.outputHistory.length === 0)
73
- return;
74
- // Concatenate all history buffers and write at once for better performance
75
- const allHistory = Buffer.concat(restoredSession.outputHistory);
76
- const historyStr = allHistory.toString('utf8');
77
- // Normalize the output
78
- const normalized = normalizeLineEndings(historyStr);
79
- // Remove leading clear screen sequences to avoid double-clear
80
- const cleaned = normalized
81
- .replace(/^\x1B\[2J/g, '')
82
- .replace(/^\x1B\[H/g, '');
83
- if (cleaned.length > 0) {
84
- stdout.write(cleaned);
71
+ if (restoreSnapshot.length > 0) {
72
+ stdout.write(restoreSnapshot);
85
73
  }
86
74
  }
87
75
  };
@@ -102,8 +90,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
102
90
  };
103
91
  sessionManager.on('sessionData', handleSessionData);
104
92
  sessionManager.on('sessionExit', handleSessionExit);
105
- // Mark session as active (this will trigger the restore event)
106
- sessionManager.setSessionActive(session.id, true);
107
93
  // Immediately resize the PTY and terminal to current dimensions
108
94
  // This fixes rendering issues when terminal width changed while in menu
109
95
  // https://github.com/kbwo/ccmanager/issues/2
@@ -120,6 +106,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
120
106
  catch {
121
107
  /* empty */
122
108
  }
109
+ // Mark session as active after resizing so the restore snapshot matches
110
+ // the current terminal dimensions.
111
+ sessionManager.setSessionActive(session.id, true);
123
112
  // Handle terminal resize
124
113
  const handleResize = () => {
125
114
  const cols = process.stdout.columns || 80;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { EventEmitter } from 'events';
5
+ const testState = vi.hoisted(() => ({
6
+ stdout: null,
7
+ }));
8
+ class MockStdout extends EventEmitter {
9
+ write = vi.fn();
10
+ }
11
+ vi.mock('ink', async () => {
12
+ const actual = await vi.importActual('ink');
13
+ return {
14
+ ...actual,
15
+ useStdout: vi.fn(() => ({ stdout: testState.stdout })),
16
+ };
17
+ });
18
+ import Session from './Session.js';
19
+ describe('Session', () => {
20
+ const originalColumns = process.stdout.columns;
21
+ const originalRows = process.stdout.rows;
22
+ const originalIsTTY = process.stdin.isTTY;
23
+ beforeEach(() => {
24
+ testState.stdout = new MockStdout();
25
+ Object.defineProperty(process.stdout, 'columns', {
26
+ value: 120,
27
+ configurable: true,
28
+ });
29
+ Object.defineProperty(process.stdout, 'rows', {
30
+ value: 40,
31
+ configurable: true,
32
+ });
33
+ Object.defineProperty(process.stdin, 'isTTY', {
34
+ value: false,
35
+ configurable: true,
36
+ });
37
+ });
38
+ afterEach(() => {
39
+ testState.stdout = null;
40
+ Object.defineProperty(process.stdout, 'columns', {
41
+ value: originalColumns,
42
+ configurable: true,
43
+ });
44
+ Object.defineProperty(process.stdout, 'rows', {
45
+ value: originalRows,
46
+ configurable: true,
47
+ });
48
+ Object.defineProperty(process.stdin, 'isTTY', {
49
+ value: originalIsTTY,
50
+ configurable: true,
51
+ });
52
+ vi.restoreAllMocks();
53
+ });
54
+ it('resizes before activating and writes restore snapshots verbatim', async () => {
55
+ const listeners = new Map();
56
+ const processResize = vi.fn();
57
+ const processWrite = vi.fn();
58
+ const terminalResize = vi.fn();
59
+ const setSessionActive = vi.fn((sessionId, active) => {
60
+ if (sessionId === session.id && active) {
61
+ for (const handler of listeners.get('sessionRestore') ?? []) {
62
+ handler(session, '\nrestored');
63
+ }
64
+ }
65
+ });
66
+ const session = {
67
+ id: 'session-1',
68
+ process: {
69
+ write: processWrite,
70
+ resize: processResize,
71
+ },
72
+ terminal: {
73
+ resize: terminalResize,
74
+ },
75
+ stateMutex: {
76
+ getSnapshot: () => ({ state: 'idle' }),
77
+ },
78
+ };
79
+ const sessionManager = {
80
+ on: vi.fn((event, handler) => {
81
+ const handlers = listeners.get(event) ?? new Set();
82
+ handlers.add(handler);
83
+ listeners.set(event, handlers);
84
+ return sessionManager;
85
+ }),
86
+ off: vi.fn((event, handler) => {
87
+ listeners.get(event)?.delete(handler);
88
+ return sessionManager;
89
+ }),
90
+ setSessionActive,
91
+ cancelAutoApproval: vi.fn(),
92
+ };
93
+ render(_jsx(Session, { session: session, sessionManager: sessionManager, onReturnToMenu: vi.fn() }));
94
+ await new Promise(resolve => setTimeout(resolve, 0));
95
+ expect(processResize).toHaveBeenCalledWith(120, 40);
96
+ expect(terminalResize).toHaveBeenCalledWith(120, 40);
97
+ expect(processResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
98
+ expect(terminalResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
99
+ expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\nrestored');
100
+ });
101
+ });
@@ -52,16 +52,31 @@ vi.mock('./config/configReader.js', () => ({
52
52
  setAutoApprovalEnabled: vi.fn(),
53
53
  },
54
54
  }));
55
+ vi.mock('@xterm/addon-serialize', () => ({
56
+ SerializeAddon: vi.fn().mockImplementation(function () {
57
+ return {
58
+ serialize: vi.fn(() => ''),
59
+ activate: vi.fn(),
60
+ dispose: vi.fn(),
61
+ };
62
+ }),
63
+ }));
55
64
  vi.mock('@xterm/headless', () => ({
56
65
  default: {
57
66
  Terminal: vi.fn().mockImplementation(function () {
58
67
  return {
68
+ rows: 24,
69
+ cols: 80,
59
70
  buffer: {
60
71
  active: {
72
+ type: 'normal',
73
+ baseY: 0,
61
74
  length: 0,
62
75
  getLine: vi.fn(),
63
76
  },
64
77
  },
78
+ loadAddon: vi.fn(),
79
+ resize: vi.fn(),
65
80
  write: vi.fn(),
66
81
  };
67
82
  }),
@@ -35,6 +35,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
35
35
  private updateSessionState;
36
36
  constructor();
37
37
  private createTerminal;
38
+ private getRestoreSnapshot;
38
39
  private createSessionInternal;
39
40
  /**
40
41
  * Create session with command preset using Effect-based error handling
@@ -59,9 +60,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
59
60
  /**
60
61
  * Sets up exit handler for the session process.
61
62
  * When the process exits with code 1 and it's the primary command,
62
- * it will attempt to spawn a fallback process.
63
- * If fallbackArgs are configured, they will be used.
64
- * If no fallbackArgs are configured, the command will be retried with no arguments.
63
+ * it will attempt a single retry using the configured command with fallback args.
64
+ * If fallbackArgs are not configured, it retries the configured command with no args.
65
65
  */
66
66
  private setupExitHandler;
67
67
  private setupBackgroundHandler;
@@ -33,17 +33,32 @@ vi.mock('./config/configReader.js', () => ({
33
33
  getStatusHooks: vi.fn(() => ({})),
34
34
  },
35
35
  }));
36
+ vi.mock('@xterm/addon-serialize', () => ({
37
+ SerializeAddon: vi.fn().mockImplementation(function () {
38
+ return {
39
+ serialize: vi.fn(() => ''),
40
+ activate: vi.fn(),
41
+ dispose: vi.fn(),
42
+ };
43
+ }),
44
+ }));
36
45
  // Mock Terminal
37
46
  vi.mock('@xterm/headless', () => ({
38
47
  default: {
39
48
  Terminal: vi.fn().mockImplementation(function () {
40
49
  return {
50
+ rows: 24,
51
+ cols: 80,
41
52
  buffer: {
42
53
  active: {
54
+ type: 'normal',
55
+ baseY: 0,
43
56
  length: 0,
44
57
  getLine: vi.fn(),
45
58
  },
46
59
  },
60
+ loadAddon: vi.fn(),
61
+ resize: vi.fn(),
47
62
  write: vi.fn(),
48
63
  };
49
64
  }),
@@ -1,6 +1,7 @@
1
1
  import { spawn } from './bunTerminal.js';
2
2
  import { EventEmitter } from 'events';
3
3
  import pkg from '@xterm/headless';
4
+ import { SerializeAddon } from '@xterm/addon-serialize';
4
5
  import { spawn as childSpawn } from 'child_process';
5
6
  import { configReader } from './config/configReader.js';
6
7
  import { executeStatusHook } from '../utils/hookExecutor.js';
@@ -17,6 +18,7 @@ import { injectTeammateMode } from '../utils/commandArgs.js';
17
18
  import { preparePresetLaunch } from '../utils/presetPrompt.js';
18
19
  const { Terminal } = pkg;
19
20
  const TERMINAL_CONTENT_MAX_LINES = 300;
21
+ const TERMINAL_SCROLLBACK_LINES = 5000;
20
22
  export class SessionManager extends EventEmitter {
21
23
  sessions;
22
24
  waitingWithBottomBorder = new Map();
@@ -192,14 +194,22 @@ export class SessionManager extends EventEmitter {
192
194
  return new Terminal({
193
195
  cols: process.stdout.columns || 80,
194
196
  rows: process.stdout.rows || 24,
197
+ scrollback: TERMINAL_SCROLLBACK_LINES,
195
198
  allowProposedApi: true,
196
199
  logLevel: 'off',
197
200
  });
198
201
  }
202
+ getRestoreSnapshot(session) {
203
+ return session.serializer.serialize({
204
+ scrollback: TERMINAL_SCROLLBACK_LINES,
205
+ });
206
+ }
199
207
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
200
208
  const existingSessions = this.getSessionsForWorktree(worktreePath);
201
209
  const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
202
210
  const terminal = this.createTerminal();
211
+ const serializer = new SerializeAddon();
212
+ terminal.loadAddon(serializer);
203
213
  const detectionStrategy = options.detectionStrategy ?? 'claude';
204
214
  const stateDetector = createStateDetector(detectionStrategy);
205
215
  const session = {
@@ -207,13 +217,15 @@ export class SessionManager extends EventEmitter {
207
217
  worktreePath,
208
218
  sessionNumber: maxNumber + 1,
209
219
  sessionName: undefined,
220
+ command: options.command ?? 'claude',
221
+ fallbackArgs: options.fallbackArgs,
210
222
  lastAccessedAt: Date.now(),
211
223
  process: ptyProcess,
212
224
  output: [],
213
- outputHistory: [],
214
225
  lastActivity: new Date(),
215
226
  isActive: false,
216
227
  terminal,
228
+ serializer,
217
229
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
218
230
  isPrimaryCommand: options.isPrimaryCommand ?? true,
219
231
  presetName: options.presetName,
@@ -257,6 +269,8 @@ export class SessionManager extends EventEmitter {
257
269
  const ptyProcess = await this.spawn(command, args, worktreePath);
258
270
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
259
271
  isPrimaryCommand: true,
272
+ command,
273
+ fallbackArgs: preset.fallbackArgs,
260
274
  presetName: preset.name,
261
275
  detectionStrategy: preset.detectionStrategy,
262
276
  });
@@ -287,24 +301,6 @@ export class SessionManager extends EventEmitter {
287
301
  session.process.onData((data) => {
288
302
  // Write data to virtual terminal
289
303
  session.terminal.write(data);
290
- // Check for screen clear escape sequence (e.g., from /clear command)
291
- // When detected, clear the output history to prevent replaying old content on restore
292
- // This helps avoid excessive scrolling when restoring sessions with large output history
293
- if (data.includes('\x1B[2J')) {
294
- session.outputHistory = [];
295
- }
296
- // Store in output history as Buffer
297
- const buffer = Buffer.from(data, 'utf8');
298
- session.outputHistory.push(buffer);
299
- // Limit memory usage - keep max 10MB of output history
300
- const MAX_HISTORY_SIZE = 10 * 1024 * 1024; // 10MB
301
- let totalSize = session.outputHistory.reduce((sum, buf) => sum + buf.length, 0);
302
- while (totalSize > MAX_HISTORY_SIZE && session.outputHistory.length > 0) {
303
- const removed = session.outputHistory.shift();
304
- if (removed) {
305
- totalSize -= removed.length;
306
- }
307
- }
308
304
  session.lastActivity = new Date();
309
305
  // Only emit data events when session is active
310
306
  if (session.isActive) {
@@ -315,9 +311,8 @@ export class SessionManager extends EventEmitter {
315
311
  /**
316
312
  * Sets up exit handler for the session process.
317
313
  * When the process exits with code 1 and it's the primary command,
318
- * it will attempt to spawn a fallback process.
319
- * If fallbackArgs are configured, they will be used.
320
- * If no fallbackArgs are configured, the command will be retried with no arguments.
314
+ * it will attempt a single retry using the configured command with fallback args.
315
+ * If fallbackArgs are not configured, it retries the configured command with no args.
321
316
  */
322
317
  setupExitHandler(session) {
323
318
  session.process.onExit(async (e) => {
@@ -325,26 +320,23 @@ export class SessionManager extends EventEmitter {
325
320
  if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
326
321
  try {
327
322
  let fallbackProcess;
323
+ const fallbackArgs = injectTeammateMode(session.command, session.fallbackArgs ?? [], session.detectionStrategy);
328
324
  // Check if we're in a devcontainer session
329
325
  if (session.devcontainerConfig) {
330
326
  // Parse the exec command to extract arguments
331
327
  const execParts = session.devcontainerConfig.execCommand.split(/\s+/);
332
328
  const devcontainerCmd = execParts[0] || 'devcontainer';
333
329
  const execArgs = execParts.slice(1);
334
- // Build fallback command for devcontainer
335
- const fallbackClaudeArgs = injectTeammateMode('claude', [], session.detectionStrategy);
336
330
  const fallbackFullArgs = [
337
331
  ...execArgs,
338
332
  '--',
339
- 'claude',
340
- ...fallbackClaudeArgs,
333
+ session.command,
334
+ ...fallbackArgs,
341
335
  ];
342
336
  fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath, { rawMode: false });
343
337
  }
344
338
  else {
345
- // Regular fallback without devcontainer
346
- const fallbackArgs = injectTeammateMode('claude', [], session.detectionStrategy);
347
- fallbackProcess = await this.spawn('claude', fallbackArgs, session.worktreePath);
339
+ fallbackProcess = await this.spawn(session.command, fallbackArgs, session.worktreePath);
348
340
  }
349
341
  // Replace the process
350
342
  session.process = fallbackProcess;
@@ -479,9 +471,9 @@ export class SessionManager extends EventEmitter {
479
471
  session.isActive = active;
480
472
  if (active) {
481
473
  session.lastAccessedAt = Date.now();
482
- // Emit a restore event with the output history if available
483
- if (session.outputHistory.length > 0) {
484
- this.emit('sessionRestore', session);
474
+ const restoreSnapshot = this.getRestoreSnapshot(session);
475
+ if (restoreSnapshot.length > 0) {
476
+ this.emit('sessionRestore', session, restoreSnapshot);
485
477
  }
486
478
  }
487
479
  }
@@ -691,6 +683,8 @@ export class SessionManager extends EventEmitter {
691
683
  const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath, { rawMode: false });
692
684
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
693
685
  isPrimaryCommand: true,
686
+ command: preset.command,
687
+ fallbackArgs: preset.fallbackArgs,
694
688
  presetName: preset.name,
695
689
  detectionStrategy: preset.detectionStrategy,
696
690
  devcontainerConfig,
@@ -37,19 +37,38 @@ vi.mock('./config/configReader.js', () => ({
37
37
  setAutoApprovalEnabled: vi.fn(),
38
38
  },
39
39
  }));
40
+ vi.mock('@xterm/addon-serialize', () => ({
41
+ SerializeAddon: vi.fn().mockImplementation(function () {
42
+ return {
43
+ serialize: vi.fn(() => ''),
44
+ activate: vi.fn(),
45
+ dispose: vi.fn(),
46
+ };
47
+ }),
48
+ }));
40
49
  // Mock Terminal
41
50
  vi.mock('@xterm/headless', () => ({
42
51
  default: {
43
52
  Terminal: vi.fn(function () {
44
53
  return {
54
+ rows: 24,
55
+ cols: 80,
45
56
  buffer: {
46
57
  active: {
58
+ type: 'normal',
59
+ baseY: 0,
47
60
  length: 0,
48
61
  getLine: vi.fn(function () {
49
62
  return null;
50
63
  }),
51
64
  },
52
65
  },
66
+ loadAddon: vi.fn(function () {
67
+ return undefined;
68
+ }),
69
+ resize: vi.fn(function () {
70
+ return undefined;
71
+ }),
53
72
  write: vi.fn(function () {
54
73
  return undefined;
55
74
  }),
@@ -238,13 +257,14 @@ describe('SessionManager', () => {
238
257
  // Expect createSessionWithPresetEffect to throw the original error
239
258
  await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command not found');
240
259
  });
241
- it('should fallback to default command when main command exits with code 1', async () => {
260
+ it('should retry the configured command with fallback args when main command exits with code 1', async () => {
242
261
  // Setup mock preset with args
243
262
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
244
263
  id: '1',
245
264
  name: 'Main',
246
265
  command: 'claude',
247
266
  args: ['--invalid-flag'],
267
+ fallbackArgs: ['--safe-flag'],
248
268
  });
249
269
  // First spawn attempt - will exit with code 1
250
270
  const firstMockPty = new MockPty();
@@ -262,11 +282,13 @@ describe('SessionManager', () => {
262
282
  firstMockPty.emit('exit', { exitCode: 1 });
263
283
  // Wait for fallback to occur
264
284
  await new Promise(resolve => setTimeout(resolve, 50));
265
- // Verify fallback spawn was called (with no args since commandConfig was removed)
285
+ // Verify fallback spawn was called with the configured fallback args
266
286
  expect(spawn).toHaveBeenCalledTimes(2);
267
- expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
287
+ expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--safe-flag', '--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
268
288
  // Verify session process was replaced
269
289
  expect(session.process).toBe(secondMockPty);
290
+ expect(session.command).toBe('claude');
291
+ expect(session.fallbackArgs).toEqual(['--safe-flag']);
270
292
  expect(session.isPrimaryCommand).toBe(false);
271
293
  });
272
294
  it('should not use fallback if main command succeeds', async () => {
@@ -318,8 +340,37 @@ describe('SessionManager', () => {
318
340
  expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
319
341
  // Verify session process was replaced
320
342
  expect(session.process).toBe(secondMockPty);
343
+ expect(session.command).toBe('claude');
344
+ expect(session.fallbackArgs).toBeUndefined();
321
345
  expect(session.isPrimaryCommand).toBe(false);
322
346
  });
347
+ it('should cleanup and emit exit when fallback command also exits with code 1', async () => {
348
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
349
+ id: '1',
350
+ name: 'Main',
351
+ command: 'opencode',
352
+ args: ['run', '--bad-flag'],
353
+ fallbackArgs: ['run', '--safe-mode'],
354
+ detectionStrategy: 'opencode',
355
+ });
356
+ const firstMockPty = new MockPty();
357
+ const secondMockPty = new MockPty();
358
+ let exitedSession = null;
359
+ vi.mocked(spawn)
360
+ .mockReturnValueOnce(firstMockPty)
361
+ .mockReturnValueOnce(secondMockPty);
362
+ sessionManager.on('sessionExit', (session) => {
363
+ exitedSession = session;
364
+ });
365
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
366
+ firstMockPty.emit('exit', { exitCode: 1 });
367
+ await new Promise(resolve => setTimeout(resolve, 50));
368
+ secondMockPty.emit('exit', { exitCode: 1 });
369
+ await new Promise(resolve => setTimeout(resolve, 50));
370
+ expect(spawn).toHaveBeenNthCalledWith(2, 'opencode', ['run', '--safe-mode'], expect.objectContaining({ cwd: '/test/worktree' }));
371
+ expect(exitedSession).toBe(session);
372
+ expect(sessionManager.getSessionsForWorktree('/test/worktree')).toHaveLength(0);
373
+ });
323
374
  it('should handle custom command configuration', async () => {
324
375
  // Setup mock preset with custom command
325
376
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -655,13 +706,14 @@ describe('SessionManager', () => {
655
706
  expect(session.process).toBe(secondMockPty);
656
707
  expect(session.isPrimaryCommand).toBe(false);
657
708
  });
658
- it('should fallback to default command in devcontainer when primary command exits with code 1', async () => {
709
+ it('should retry the configured command with fallback args in devcontainer when primary command exits with code 1', async () => {
659
710
  // Setup preset with args
660
711
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
661
712
  id: '1',
662
713
  name: 'Main',
663
714
  command: 'claude',
664
715
  args: ['--bad-flag'],
716
+ fallbackArgs: ['--safe-flag'],
665
717
  });
666
718
  // First spawn attempt - will exit with code 1
667
719
  const firstMockPty = new MockPty();
@@ -690,7 +742,7 @@ describe('SessionManager', () => {
690
742
  firstMockPty.emit('exit', { exitCode: 1 });
691
743
  // Wait for fallback to occur
692
744
  await new Promise(resolve => setTimeout(resolve, 50));
693
- // Verify fallback spawn was called with teammate-mode args
745
+ // Verify fallback spawn was called with the configured fallback args
694
746
  expect(spawn).toHaveBeenCalledTimes(2);
695
747
  expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', [
696
748
  'exec',
@@ -698,6 +750,7 @@ describe('SessionManager', () => {
698
750
  '.',
699
751
  '--',
700
752
  'claude',
753
+ '--safe-flag',
701
754
  '--teammate-mode',
702
755
  'in-process',
703
756
  ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
@@ -706,63 +759,37 @@ describe('SessionManager', () => {
706
759
  expect(session.isPrimaryCommand).toBe(false);
707
760
  });
708
761
  });
709
- describe('clearHistoryOnClear', () => {
710
- it('should clear output history when screen clear escape sequence is detected', async () => {
711
- // Setup
762
+ describe('session restore snapshots', () => {
763
+ it('should emit serialized terminal output when activating a session', async () => {
712
764
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
713
765
  id: '1',
714
766
  name: 'Main',
715
767
  command: 'claude',
716
768
  });
717
769
  vi.mocked(spawn).mockReturnValue(mockPty);
718
- // Create session
719
770
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
720
- // Simulate some data output
721
- mockPty.emit('data', 'Hello World');
722
- mockPty.emit('data', 'More data');
723
- // Verify output history has data
724
- expect(session.outputHistory.length).toBe(2);
725
- // Simulate screen clear escape sequence
726
- mockPty.emit('data', '\x1B[2J');
727
- // Verify output history was cleared and only contains the clear sequence
728
- expect(session.outputHistory.length).toBe(1);
729
- expect(session.outputHistory[0]?.toString()).toBe('\x1B[2J');
730
- });
731
- it('should not clear output history for normal data', async () => {
732
- // Setup
771
+ const serializeMock = vi
772
+ .spyOn(session.serializer, 'serialize')
773
+ .mockReturnValue('\u001b[31mrestored\u001b[0m');
774
+ const restoreHandler = vi.fn();
775
+ sessionManager.on('sessionRestore', restoreHandler);
776
+ sessionManager.setSessionActive(session.id, true);
777
+ expect(serializeMock).toHaveBeenCalledWith({ scrollback: 5000 });
778
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m');
779
+ });
780
+ it('should skip restore event when serialized output is empty', async () => {
733
781
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
734
782
  id: '1',
735
783
  name: 'Main',
736
784
  command: 'claude',
737
785
  });
738
786
  vi.mocked(spawn).mockReturnValue(mockPty);
739
- // Create session
740
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
741
- // Simulate normal data output without screen clear
742
- mockPty.emit('data', 'Hello World');
743
- mockPty.emit('data', 'More data');
744
- mockPty.emit('data', 'Even more data');
745
- // Verify output history contains all data
746
- expect(session.outputHistory.length).toBe(3);
747
- });
748
- it('should clear history when screen clear is part of larger data chunk', async () => {
749
- // Setup
750
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
751
- id: '1',
752
- name: 'Main',
753
- command: 'claude',
754
- });
755
- vi.mocked(spawn).mockReturnValue(mockPty);
756
- // Create session
757
787
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
758
- // Simulate some data output
759
- mockPty.emit('data', 'Hello World');
760
- mockPty.emit('data', 'More data');
761
- // Simulate screen clear as part of larger data chunk (e.g., from /clear command)
762
- mockPty.emit('data', 'prefix\x1B[2Jsuffix');
763
- // Verify output history was cleared and only contains the new chunk
764
- expect(session.outputHistory.length).toBe(1);
765
- expect(session.outputHistory[0]?.toString()).toBe('prefix\x1B[2Jsuffix');
788
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('');
789
+ const restoreHandler = vi.fn();
790
+ sessionManager.on('sessionRestore', restoreHandler);
791
+ sessionManager.setSessionActive(session.id, true);
792
+ expect(restoreHandler).not.toHaveBeenCalled();
766
793
  });
767
794
  });
768
795
  describe('static methods', () => {
@@ -13,7 +13,11 @@ export class CursorStateDetector extends BaseStateDetector {
13
13
  /auto .* \(shift\+tab\)/.test(lowerContent) ||
14
14
  /allow .+ \(y\)/.test(lowerContent) ||
15
15
  /run .+ \(y\)/.test(lowerContent) ||
16
- lowerContent.includes('skip (esc or n)')) {
16
+ lowerContent.includes('skip (esc or n)') ||
17
+ lowerContent.includes('write to this file?') ||
18
+ lowerContent.includes('reject & propose changes') ||
19
+ (lowerContent.includes('add write(') &&
20
+ lowerContent.includes('allowlist'))) {
17
21
  return 'waiting_input';
18
22
  }
19
23
  // Check for busy state - Priority 2
@@ -114,6 +114,21 @@ describe('CursorStateDetector', () => {
114
114
  // Assert
115
115
  expect(state).toBe('waiting_input');
116
116
  });
117
+ it('should detect waiting_input state for Write to this file? prompt', () => {
118
+ // Arrange
119
+ terminal = createMockTerminal([
120
+ ' │ Write to this file? │',
121
+ ' │ in /Users/kbwo/go/projects/github.com/kbwo/ccmanager--feature-takt/src/services/stateDetector/takt.ts │',
122
+ ' │ → Proceed (y) │',
123
+ ' │ Reject & propose changes (esc or n or p) │',
124
+ ' │ Add Write(/Users/.../takt.ts) to allowlist? (tab) │',
125
+ ' │ Run Everything (shift+tab) │',
126
+ ]);
127
+ // Act
128
+ const state = detector.detectState(terminal, 'idle');
129
+ // Assert
130
+ expect(state).toBe('waiting_input');
131
+ });
117
132
  it('should detect busy state for ctrl+c to stop pattern', () => {
118
133
  // Arrange
119
134
  terminal = createMockTerminal([
@@ -1,5 +1,6 @@
1
1
  import type { IPty } from '../services/bunTerminal.js';
2
2
  import type pkg from '@xterm/headless';
3
+ import type { SerializeAddon } from '@xterm/addon-serialize';
3
4
  import { GitStatus } from '../utils/gitStatus.js';
4
5
  import { Mutex, SessionStateData } from '../utils/mutex.js';
5
6
  import type { StateDetector } from '../services/stateDetector/types.js';
@@ -20,13 +21,15 @@ export interface Session {
20
21
  worktreePath: string;
21
22
  sessionNumber: number;
22
23
  sessionName?: string;
24
+ command: string;
25
+ fallbackArgs?: string[];
23
26
  lastAccessedAt: number;
24
27
  process: IPty;
25
28
  output: string[];
26
- outputHistory: Buffer[];
27
29
  lastActivity: Date;
28
30
  isActive: boolean;
29
31
  terminal: Terminal;
32
+ serializer: SerializeAddon;
30
33
  stateCheckInterval: NodeJS.Timeout | undefined;
31
34
  isPrimaryCommand: boolean;
32
35
  presetName: string | undefined;
@@ -377,11 +377,13 @@ describe('hookExecutor Integration Tests', () => {
377
377
  id: 'test-session-123',
378
378
  worktreePath: tmpDir, // Use tmpDir as the worktree path
379
379
  sessionNumber: 1,
380
+ command: 'claude',
381
+ fallbackArgs: undefined,
380
382
  lastAccessedAt: Date.now(),
381
383
  process: {},
382
384
  terminal: {},
385
+ serializer: {},
383
386
  output: [],
384
- outputHistory: [],
385
387
  stateCheckInterval: undefined,
386
388
  isPrimaryCommand: true,
387
389
  presetName: undefined,
@@ -434,11 +436,13 @@ describe('hookExecutor Integration Tests', () => {
434
436
  id: 'test-session-456',
435
437
  worktreePath: tmpDir, // Use tmpDir as the worktree path
436
438
  sessionNumber: 1,
439
+ command: 'claude',
440
+ fallbackArgs: undefined,
437
441
  lastAccessedAt: Date.now(),
438
442
  process: {},
439
443
  terminal: {},
444
+ serializer: {},
440
445
  output: [],
441
- outputHistory: [],
442
446
  stateCheckInterval: undefined,
443
447
  isPrimaryCommand: true,
444
448
  presetName: undefined,
@@ -489,11 +493,13 @@ describe('hookExecutor Integration Tests', () => {
489
493
  id: 'test-session-789',
490
494
  worktreePath: tmpDir, // Use tmpDir as the worktree path
491
495
  sessionNumber: 1,
496
+ command: 'claude',
497
+ fallbackArgs: undefined,
492
498
  lastAccessedAt: Date.now(),
493
499
  process: {},
494
500
  terminal: {},
501
+ serializer: {},
495
502
  output: [],
496
- outputHistory: [],
497
503
  stateCheckInterval: undefined,
498
504
  isPrimaryCommand: true,
499
505
  presetName: undefined,
@@ -546,11 +552,13 @@ describe('hookExecutor Integration Tests', () => {
546
552
  id: 'test-session-failure',
547
553
  worktreePath: tmpDir,
548
554
  sessionNumber: 1,
555
+ command: 'claude',
556
+ fallbackArgs: undefined,
549
557
  lastAccessedAt: Date.now(),
550
558
  process: {},
551
559
  terminal: {},
560
+ serializer: {},
552
561
  output: [],
553
- outputHistory: [],
554
562
  stateCheckInterval: undefined,
555
563
  isPrimaryCommand: true,
556
564
  presetName: undefined,
@@ -124,13 +124,15 @@ describe('prepareSessionItems', () => {
124
124
  id: 'test-session',
125
125
  worktreePath: '/path/to/worktree',
126
126
  sessionNumber: 1,
127
+ command: 'claude',
128
+ fallbackArgs: undefined,
127
129
  lastAccessedAt: Date.now(),
128
130
  process: {},
129
131
  output: [],
130
- outputHistory: [],
131
132
  lastActivity: new Date(),
132
133
  isActive: true,
133
134
  terminal: {},
135
+ serializer: {},
134
136
  stateCheckInterval: undefined,
135
137
  isPrimaryCommand: true,
136
138
  presetName: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.0.3",
3
+ "version": "4.1.0",
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.0.3",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.0.3",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.0.3",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.0.3",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.0.3"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.0",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.0",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.0",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.0",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",
@@ -68,6 +68,7 @@
68
68
  },
69
69
  "prettier": "@vdemedes/prettier-config",
70
70
  "dependencies": {
71
+ "@xterm/addon-serialize": "^0.14.0",
71
72
  "@xterm/headless": "^6.0.0",
72
73
  "effect": "^3.18.2",
73
74
  "ink": "^6.6.0",