ccmanager 4.0.2 → 4.0.3

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.
@@ -42,6 +42,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
42
42
  const [pendingWorktreeCreation, setPendingWorktreeCreation] = useState(null);
43
43
  // State for loading context - track flags for message composition
44
44
  const [loadingContext, setLoadingContext] = useState({});
45
+ // State for streaming devcontainer up logs
46
+ const [devcontainerLogs, setDevcontainerLogs] = useState([]);
45
47
  // Helper function to format error messages based on error type using _tag discrimination
46
48
  const formatErrorMessage = (error) => {
47
49
  switch (error._tag) {
@@ -59,8 +61,15 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
59
61
  };
60
62
  // Helper function to create session with Effect-based error handling
61
63
  const createSessionWithEffect = useCallback(async (worktreePath, presetId, initialPrompt) => {
64
+ setDevcontainerLogs([]);
62
65
  const sessionEffect = devcontainerConfig
63
- ? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt)
66
+ ? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt, (line) => {
67
+ setDevcontainerLogs(prev => {
68
+ const next = [...prev, line];
69
+ // Keep only the last 10 lines to avoid unbounded growth
70
+ return next.length > 10 ? next.slice(-10) : next;
71
+ });
72
+ })
64
73
  : sessionManager.createSessionWithPresetEffect(worktreePath, presetId, initialPrompt);
65
74
  const result = await Effect.runPromise(Effect.either(sessionEffect));
66
75
  if (result._tag === 'Left') {
@@ -449,6 +458,11 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
449
458
  setLoadingContext({ deleteBranch });
450
459
  setView('deleting-worktree');
451
460
  setError(null);
461
+ // Yield to the event loop so Ink can paint `deleting-worktree` before git work runs.
462
+ // Otherwise the confirmation UI stays visible until deletion finishes (no spinner).
463
+ await new Promise(resolve => {
464
+ setTimeout(resolve, 0);
465
+ });
452
466
  // Delete the worktrees sequentially using Effect
453
467
  let hasError = false;
454
468
  for (const path of worktreePaths) {
@@ -619,7 +633,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
619
633
  // Use yellow color for devcontainer operations (longer duration),
620
634
  // cyan for standard session creation
621
635
  const color = devcontainerConfig ? 'yellow' : 'cyan';
622
- return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: message, color: color }) }));
636
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LoadingSpinner, { message: message, color: color }), devcontainerLogs.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: devcontainerLogs.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) }))] }));
623
637
  }
624
638
  if (view === 'creating-session-preset') {
625
639
  // Always display preset-specific message
@@ -631,7 +645,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
631
645
  : 'Creating session with preset...';
632
646
  // Use yellow color for devcontainer, cyan for standard
633
647
  const color = devcontainerConfig ? 'yellow' : 'cyan';
634
- return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: message, color: color }) }));
648
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LoadingSpinner, { message: message, color: color }), devcontainerLogs.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: devcontainerLogs.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) }))] }));
635
649
  }
636
650
  if (view === 'clearing') {
637
651
  // Render nothing during the clearing phase to ensure clean transition
@@ -27,6 +27,7 @@ export interface IPtyForkOptions {
27
27
  rows?: number;
28
28
  cwd?: string;
29
29
  env?: Record<string, string | undefined>;
30
+ rawMode?: boolean;
30
31
  }
31
32
  /**
32
33
  * Interface for interacting with a pseudo-terminal (PTY) instance.
@@ -57,9 +57,10 @@ class BunTerminal {
57
57
  this._processBuffer();
58
58
  },
59
59
  });
60
- // Match node-pty behavior by starting in raw mode (no canonical input/echo),
61
- // while keeping Bun's output processing defaults intact.
62
- this._terminal.setRawMode(true);
60
+ // Most interactive CLIs work best when the PTY starts in raw mode, but
61
+ // terminal proxy commands such as `devcontainer exec` manage termios
62
+ // themselves and break if the outer PTY is forced raw first.
63
+ this._terminal.setRawMode(options.rawMode ?? true);
63
64
  // Disable ONLCR in the PTY output flags to avoid double CRLF translation
64
65
  // when forwarding PTY output to the real stdout TTY.
65
66
  const ONLCR_FLAG = 0x0002;
@@ -96,7 +96,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
96
96
  * Create session with devcontainer integration using Effect-based error handling
97
97
  * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
98
98
  */
99
- createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string, initialPrompt?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
99
+ createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string, initialPrompt?: string, onLog?: (line: string) => void): Effect.Effect<Session, ProcessError | ConfigError, never>;
100
100
  destroy(): void;
101
101
  static getSessionCounts(sessions: Session[]): SessionCounts;
102
102
  static formatSessionCounts(counts: SessionCounts): string;
@@ -9,8 +9,17 @@ vi.mock('./bunTerminal.js', () => ({
9
9
  return null;
10
10
  }),
11
11
  }));
12
+ // Helper to create a mock child process for child_process.spawn
13
+ function createMockChildProcess(exitCode = 0) {
14
+ const stdout = new EventEmitter();
15
+ const stderr = new EventEmitter();
16
+ const proc = Object.assign(new EventEmitter(), { stdout, stderr });
17
+ process.nextTick(() => proc.emit('close', exitCode));
18
+ return proc;
19
+ }
12
20
  // Mock child_process
13
21
  vi.mock('child_process', () => ({
22
+ spawn: vi.fn(() => createMockChildProcess(0)),
14
23
  exec: vi.fn(),
15
24
  execFile: vi.fn(),
16
25
  }));
@@ -167,18 +176,6 @@ describe('SessionManager Effect-based Operations', () => {
167
176
  });
168
177
  // Setup spawn mock
169
178
  vi.mocked(spawn).mockReturnValue(mockPty);
170
- // Mock exec to succeed
171
- const { exec } = await import('child_process');
172
- const mockExec = vi.mocked(exec);
173
- mockExec.mockImplementation((cmd, options, callback) => {
174
- if (typeof options === 'function') {
175
- callback = options;
176
- }
177
- if (callback && typeof callback === 'function') {
178
- callback(null, 'Container started', '');
179
- }
180
- return {};
181
- });
182
179
  const devcontainerConfig = {
183
180
  upCommand: 'devcontainer up --workspace-folder .',
184
181
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -192,18 +189,9 @@ describe('SessionManager Effect-based Operations', () => {
192
189
  expect(session.devcontainerConfig).toEqual(devcontainerConfig);
193
190
  });
194
191
  it('should return Effect that fails with ProcessError when devcontainer up fails', async () => {
195
- // Mock exec to fail
196
- const { exec } = await import('child_process');
197
- const mockExec = vi.mocked(exec);
198
- mockExec.mockImplementation((cmd, options, callback) => {
199
- if (typeof options === 'function') {
200
- callback = options;
201
- }
202
- if (callback && typeof callback === 'function') {
203
- callback(new Error('Container failed to start'), '', '');
204
- }
205
- return {};
206
- });
192
+ // Mock spawn to return a process that exits with code 1
193
+ const { spawn: childSpawn } = await import('child_process');
194
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(1));
207
195
  const devcontainerConfig = {
208
196
  upCommand: 'devcontainer up --workspace-folder .',
209
197
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -217,11 +205,14 @@ describe('SessionManager Effect-based Operations', () => {
217
205
  expect(result.left._tag).toBe('ProcessError');
218
206
  if (result.left._tag === 'ProcessError') {
219
207
  expect(result.left.command).toContain('devcontainer up');
220
- expect(result.left.message).toContain('Container failed');
208
+ expect(result.left.message).toContain('Command exited with code 1');
221
209
  }
222
210
  }
223
211
  });
224
212
  it('should return Effect that fails with ConfigError when preset not found', async () => {
213
+ // Reset childSpawn mock to succeed (devcontainer up should pass)
214
+ const { spawn: childSpawn } = await import('child_process');
215
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(0));
225
216
  // Setup mocks - getPresetByIdEffect returns Left, getDefaultPreset returns undefined
226
217
  vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
227
218
  field: 'presetId',
@@ -229,18 +220,6 @@ describe('SessionManager Effect-based Operations', () => {
229
220
  receivedValue: 'invalid-preset',
230
221
  })));
231
222
  vi.mocked(configReader.getDefaultPreset).mockReturnValue(undefined);
232
- // Mock exec to succeed (devcontainer up)
233
- const { exec } = await import('child_process');
234
- const mockExec = vi.mocked(exec);
235
- mockExec.mockImplementation((cmd, options, callback) => {
236
- if (typeof options === 'function') {
237
- callback = options;
238
- }
239
- if (callback && typeof callback === 'function') {
240
- callback(null, 'Container started', '');
241
- }
242
- return {};
243
- });
244
223
  const devcontainerConfig = {
245
224
  upCommand: 'devcontainer up',
246
225
  execCommand: 'devcontainer exec',
@@ -1,8 +1,7 @@
1
1
  import { spawn } from './bunTerminal.js';
2
2
  import { EventEmitter } from 'events';
3
3
  import pkg from '@xterm/headless';
4
- import { exec } from 'child_process';
5
- import { promisify } from 'util';
4
+ import { spawn as childSpawn } from 'child_process';
6
5
  import { configReader } from './config/configReader.js';
7
6
  import { executeStatusHook } from '../utils/hookExecutor.js';
8
7
  import { createStateDetector } from './stateDetector/index.js';
@@ -17,20 +16,20 @@ import { getTerminalScreenContent } from '../utils/screenCapture.js';
17
16
  import { injectTeammateMode } from '../utils/commandArgs.js';
18
17
  import { preparePresetLaunch } from '../utils/presetPrompt.js';
19
18
  const { Terminal } = pkg;
20
- const execAsync = promisify(exec);
21
19
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
20
  export class SessionManager extends EventEmitter {
23
21
  sessions;
24
22
  waitingWithBottomBorder = new Map();
25
23
  busyTimers = new Map();
26
24
  autoApprovalDisabledWorktrees = new Set();
27
- async spawn(command, args, worktreePath) {
25
+ async spawn(command, args, worktreePath, options = {}) {
28
26
  const spawnOptions = {
29
27
  name: 'xterm-256color',
30
28
  cols: process.stdout.columns || 80,
31
29
  rows: process.stdout.rows || 24,
32
30
  cwd: worktreePath,
33
31
  env: process.env,
32
+ ...(options.rawMode === undefined ? {} : { rawMode: options.rawMode }),
34
33
  };
35
34
  return spawn(command, args, spawnOptions);
36
35
  }
@@ -340,7 +339,7 @@ export class SessionManager extends EventEmitter {
340
339
  'claude',
341
340
  ...fallbackClaudeArgs,
342
341
  ];
343
- fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
342
+ fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath, { rawMode: false });
344
343
  }
345
344
  else {
346
345
  // Regular fallback without devcontainer
@@ -635,12 +634,43 @@ export class SessionManager extends EventEmitter {
635
634
  * Create session with devcontainer integration using Effect-based error handling
636
635
  * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
637
636
  */
638
- createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt) {
637
+ createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt, onLog) {
639
638
  return Effect.tryPromise({
640
639
  try: async () => {
641
- // Execute devcontainer up command first
640
+ // Execute devcontainer up command, streaming output in real-time
642
641
  try {
643
- await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
642
+ await new Promise((resolve, reject) => {
643
+ const parts = devcontainerConfig.upCommand.split(/\s+/);
644
+ const cmd = parts[0];
645
+ const args = parts.slice(1);
646
+ const proc = childSpawn(cmd, args, {
647
+ cwd: worktreePath,
648
+ stdio: ['ignore', 'pipe', 'pipe'],
649
+ shell: false,
650
+ });
651
+ const handleData = (data) => {
652
+ const text = data.toString();
653
+ for (const line of text.split('\n')) {
654
+ const trimmed = line.trimEnd();
655
+ if (trimmed) {
656
+ onLog?.(trimmed);
657
+ }
658
+ }
659
+ };
660
+ proc.stdout?.on('data', handleData);
661
+ proc.stderr?.on('data', handleData);
662
+ proc.on('error', err => {
663
+ reject(err);
664
+ });
665
+ proc.on('close', code => {
666
+ if (code === 0) {
667
+ resolve();
668
+ }
669
+ else {
670
+ reject(new Error(`Command exited with code ${code}`));
671
+ }
672
+ });
673
+ });
644
674
  }
645
675
  catch (error) {
646
676
  throw new ProcessError({
@@ -658,7 +688,7 @@ export class SessionManager extends EventEmitter {
658
688
  const presetArgs = launch.args;
659
689
  const fullArgs = [...execArgs, '--', preset.command, ...presetArgs];
660
690
  // Spawn the process within devcontainer
661
- const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
691
+ const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath, { rawMode: false });
662
692
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
663
693
  isPrimaryCommand: true,
664
694
  presetName: preset.name,
@@ -3,7 +3,16 @@ import { Effect, Either } from 'effect';
3
3
  import { ValidationError } from '../types/errors.js';
4
4
  import { spawn } from './bunTerminal.js';
5
5
  import { EventEmitter } from 'events';
6
- import { exec } from 'child_process';
6
+ import { spawn as childSpawn } from 'child_process';
7
+ // Helper to create a mock child process for child_process.spawn
8
+ function createMockChildProcess(exitCode = 0) {
9
+ const stdout = new EventEmitter();
10
+ const stderr = new EventEmitter();
11
+ const proc = Object.assign(new EventEmitter(), { stdout, stderr });
12
+ // Emit 'close' asynchronously so listeners can be attached
13
+ process.nextTick(() => proc.emit('close', exitCode));
14
+ return proc;
15
+ }
7
16
  // Mock bunTerminal
8
17
  vi.mock('./bunTerminal.js', () => ({
9
18
  spawn: vi.fn(function () {
@@ -12,12 +21,11 @@ vi.mock('./bunTerminal.js', () => ({
12
21
  }));
13
22
  // Mock child_process
14
23
  vi.mock('child_process', () => ({
15
- exec: vi.fn(function () {
16
- return null;
17
- }),
18
- execFile: vi.fn(function () {
19
- return null;
24
+ spawn: vi.fn(function () {
25
+ return createMockChildProcess(0);
20
26
  }),
27
+ exec: vi.fn(),
28
+ execFile: vi.fn(),
21
29
  }));
22
30
  // Mock configuration manager
23
31
  vi.mock('./config/configReader.js', () => ({
@@ -388,24 +396,8 @@ describe('SessionManager', () => {
388
396
  });
389
397
  describe('createSessionWithDevcontainerEffect', () => {
390
398
  beforeEach(() => {
391
- // Reset shouldFail flag
392
- const mockExec = vi.mocked(exec);
393
- mockExec.shouldFail = false;
394
- // Setup exec mock to work with promisify
395
- mockExec.mockImplementation(((...args) => {
396
- const [command, , callback] = args;
397
- if (callback) {
398
- // Handle callback style
399
- if (command.includes('devcontainer up')) {
400
- if (mockExec.shouldFail) {
401
- callback(new Error('Container startup failed'));
402
- }
403
- else {
404
- callback(null, '', '');
405
- }
406
- }
407
- }
408
- }));
399
+ // Setup childSpawn mock to return a successful mock child process
400
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(0));
409
401
  });
410
402
  it('should execute devcontainer up command before creating session', async () => {
411
403
  // Setup mock preset
@@ -434,7 +426,7 @@ describe('SessionManager', () => {
434
426
  '--resume',
435
427
  '--teammate-mode',
436
428
  'in-process',
437
- ], expect.objectContaining({ cwd: '/test/worktree' }));
429
+ ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
438
430
  });
439
431
  it('should use specific preset when ID provided', async () => {
440
432
  // Setup mock preset
@@ -465,15 +457,14 @@ describe('SessionManager', () => {
465
457
  ], expect.any(Object));
466
458
  });
467
459
  it('should throw error when devcontainer up fails', async () => {
468
- // Setup exec to fail
469
- const mockExec = vi.mocked(exec);
470
- mockExec.shouldFail = true;
460
+ // Setup childSpawn to return a process that exits with code 1
461
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(1));
471
462
  // Create session with devcontainer
472
463
  const devcontainerConfig = {
473
464
  upCommand: 'devcontainer up',
474
465
  execCommand: 'devcontainer exec',
475
466
  };
476
- await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Container startup failed');
467
+ await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Command exited with code 1');
477
468
  });
478
469
  it('should create a new session each time for multi-session support', async () => {
479
470
  // Setup mock preset
@@ -540,17 +531,6 @@ describe('SessionManager', () => {
540
531
  });
541
532
  // Setup spawn mock
542
533
  vi.mocked(spawn).mockReturnValue(mockPty);
543
- const mockExec = vi.mocked(exec);
544
- mockExec.mockImplementation((cmd, options, callback) => {
545
- if (typeof options === 'function') {
546
- callback = options;
547
- options = undefined;
548
- }
549
- if (callback && typeof callback === 'function') {
550
- callback(null, 'Container started', '');
551
- }
552
- return {};
553
- });
554
534
  await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree2', {
555
535
  upCommand: 'devcontainer up --workspace-folder .',
556
536
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -566,20 +546,10 @@ describe('SessionManager', () => {
566
546
  'in-process',
567
547
  ], expect.objectContaining({
568
548
  cwd: '/test/worktree2',
549
+ rawMode: false,
569
550
  }));
570
551
  });
571
552
  it('should use preset with devcontainer', async () => {
572
- const mockExec = vi.mocked(exec);
573
- mockExec.mockImplementation((cmd, options, callback) => {
574
- if (typeof options === 'function') {
575
- callback = options;
576
- options = undefined;
577
- }
578
- if (callback && typeof callback === 'function') {
579
- callback(null, 'Container started', '');
580
- }
581
- return {};
582
- });
583
553
  await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
584
554
  upCommand: 'devcontainer up --workspace-folder .',
585
555
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -594,17 +564,6 @@ describe('SessionManager', () => {
594
564
  });
595
565
  });
596
566
  it('should parse exec command and append preset command', async () => {
597
- const mockExec = vi.mocked(exec);
598
- mockExec.mockImplementation((cmd, options, callback) => {
599
- if (typeof options === 'function') {
600
- callback = options;
601
- options = undefined;
602
- }
603
- if (callback && typeof callback === 'function') {
604
- callback(null, 'Container started', '');
605
- }
606
- return {};
607
- });
608
567
  const config = {
609
568
  upCommand: 'devcontainer up --workspace-folder /path/to/project',
610
569
  execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
@@ -623,17 +582,6 @@ describe('SessionManager', () => {
623
582
  ], expect.any(Object));
624
583
  });
625
584
  it('should handle preset with args in devcontainer', async () => {
626
- const mockExec = vi.mocked(exec);
627
- mockExec.mockImplementation((cmd, options, callback) => {
628
- if (typeof options === 'function') {
629
- callback = options;
630
- options = undefined;
631
- }
632
- if (callback && typeof callback === 'function') {
633
- callback(null, 'Container started', '');
634
- }
635
- return {};
636
- });
637
585
  vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.right({
638
586
  id: 'claude-with-args',
639
587
  name: 'Claude with Args',
@@ -657,17 +605,6 @@ describe('SessionManager', () => {
657
605
  ], expect.any(Object));
658
606
  });
659
607
  it('should use empty args as fallback in devcontainer when no fallback args specified', async () => {
660
- const mockExec = vi.mocked(exec);
661
- mockExec.mockImplementation((cmd, options, callback) => {
662
- if (typeof options === 'function') {
663
- callback = options;
664
- options = undefined;
665
- }
666
- if (callback && typeof callback === 'function') {
667
- callback(null, 'Container started', '');
668
- }
669
- return {};
670
- });
671
608
  // Setup preset without fallback args
672
609
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
673
610
  id: '1',
@@ -713,23 +650,12 @@ describe('SessionManager', () => {
713
650
  'claude',
714
651
  '--teammate-mode',
715
652
  'in-process',
716
- ], expect.objectContaining({ cwd: '/test/worktree' }));
653
+ ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
717
654
  // Verify session process was replaced
718
655
  expect(session.process).toBe(secondMockPty);
719
656
  expect(session.isPrimaryCommand).toBe(false);
720
657
  });
721
658
  it('should fallback to default command in devcontainer when primary command exits with code 1', async () => {
722
- const mockExec = vi.mocked(exec);
723
- mockExec.mockImplementation((cmd, options, callback) => {
724
- if (typeof options === 'function') {
725
- callback = options;
726
- options = undefined;
727
- }
728
- if (callback && typeof callback === 'function') {
729
- callback(null, 'Container started', '');
730
- }
731
- return {};
732
- });
733
659
  // Setup preset with args
734
660
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
735
661
  id: '1',
@@ -774,7 +700,7 @@ describe('SessionManager', () => {
774
700
  'claude',
775
701
  '--teammate-mode',
776
702
  'in-process',
777
- ], expect.objectContaining({ cwd: '/test/worktree' }));
703
+ ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
778
704
  // Verify session process was replaced
779
705
  expect(session.process).toBe(secondMockPty);
780
706
  expect(session.isPrimaryCommand).toBe(false);
@@ -1,4 +1,8 @@
1
1
  import { BaseStateDetector } from './base.js';
2
+ // Spinner symbols used by Cursor during active processing
3
+ const CURSOR_SPINNER_CHARS = '⬡⬢';
4
+ // Like Claude's spinner activity: "<symbol> <word>ing…"; Cursor often uses ASCII dots (.. or …)
5
+ const SPINNER_ACTIVITY_PATTERN = new RegExp(`^\\s*[${CURSOR_SPINNER_CHARS}] \\S+ing(?:.*\u2026|.*\\.{2,})`, 'm');
2
6
  export class CursorStateDetector extends BaseStateDetector {
3
7
  detectState(terminal, _currentState) {
4
8
  const content = this.getTerminalContent(terminal, 30);
@@ -16,6 +20,10 @@ export class CursorStateDetector extends BaseStateDetector {
16
20
  if (lowerContent.includes('ctrl+c to stop')) {
17
21
  return 'busy';
18
22
  }
23
+ // Spinner activity (e.g. "⬡ Grepping..", "⬢ Reading…") — case-sensitive on original buffer
24
+ if (SPINNER_ACTIVITY_PATTERN.test(content)) {
25
+ return 'busy';
26
+ }
19
27
  // Otherwise idle - Priority 3
20
28
  return 'idle';
21
29
  }
@@ -138,6 +138,18 @@ describe('CursorStateDetector', () => {
138
138
  // Assert
139
139
  expect(state).toBe('busy');
140
140
  });
141
+ it('should detect busy state for spinner activity (⬡ …ing..)', () => {
142
+ terminal = createMockTerminal([' ⬡ Grepping..', 'Some footer']);
143
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
144
+ });
145
+ it('should detect busy state for spinner activity (⬢ …ing...)', () => {
146
+ terminal = createMockTerminal([' ⬢ Reading...']);
147
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
148
+ });
149
+ it('should detect busy state for spinner activity with Unicode ellipsis', () => {
150
+ terminal = createMockTerminal(['⬡ Searching\u2026']);
151
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
152
+ });
141
153
  it('should detect idle state when no patterns match', () => {
142
154
  // Arrange
143
155
  terminal = createMockTerminal(['Normal output', 'Some message', 'Ready']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
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.2",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.0.2",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.0.2",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.0.2",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.0.2"
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"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",