ccmanager 4.0.1 → 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
@@ -105,7 +105,7 @@ const ConfigureStatusHooks = ({ onComplete, }) => {
105
105
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "green", children: "\u2713 Configuration saved successfully!" }) }));
106
106
  }
107
107
  if (view === 'edit') {
108
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_DIR, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
108
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_DIR, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID, CCMANAGER_PRESET_NAME` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
109
109
  }
110
110
  const scopeLabel = scope === 'project' ? 'Project' : 'Global';
111
111
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Status Hooks (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "\uD83D\uDCCB Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Set commands to run when session status changes:" }) }), _jsx(SelectInput, { items: getMenuItems(), onSelect: handleMenuSelect, isFocused: true, limit: 10 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to go back" }) })] }));
@@ -4,11 +4,6 @@ import { shortcutManager } from '../services/shortcutManager.js';
4
4
  const Session = ({ session, sessionManager, onReturnToMenu, }) => {
5
5
  const { stdout } = useStdout();
6
6
  const isExitingRef = useRef(false);
7
- const stripOscColorSequences = (input) => {
8
- // Remove default foreground/background color OSC sequences that Codex emits
9
- // These sequences leak as literal text when replaying buffered output
10
- return input.replace(/\x1B\](?:10|11);[^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
11
- };
12
7
  const normalizeLineEndings = (input) => {
13
8
  // Ensure LF moves to column 0 to prevent cursor drift when ONLCR is disabled.
14
9
  let normalized = '';
@@ -32,22 +27,40 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
32
27
  // protocol / modifyOtherKeys / focus tracking) so they don't leak into other
33
28
  // sessions after we detach.
34
29
  stdout.write('\x1b[>0u'); // Disable kitty keyboard protocol (CSI u sequences)
35
- stdout.write('\x1b[>4m'); // Disable xterm modifyOtherKeys extensions
30
+ stdout.write('\x1b[>4;0m'); // Disable xterm modifyOtherKeys extensions
36
31
  stdout.write('\x1b[?1004l'); // Disable focus reporting
37
32
  stdout.write('\x1b[?2004l'); // Disable bracketed paste (can interfere with shortcuts)
38
33
  stdout.write('\x1b[?7h'); // Re-enable auto-wrap
39
34
  };
40
- const sanitizeReplayBuffer = (input) => {
41
- // Remove terminal mode toggles emitted by Codex so replay doesn't re-enable them
42
- // on our own TTY when restoring the session view.
43
- return stripOscColorSequences(input)
44
- .replace(/\x1B\[>4;?\d*m/g, '') // modifyOtherKeys set/reset
45
- .replace(/\x1B\[>[0-9;]*u/g, '') // kitty keyboard protocol enables
46
- .replace(/\x1B\[\?1004[hl]/g, '') // focus tracking
47
- .replace(/\x1B\[\?2004[hl]/g, ''); // bracketed paste
35
+ // Set up raw input handling
36
+ const stdin = process.stdin;
37
+ // Configure stdin for PTY passthrough
38
+ if (stdin.isTTY) {
39
+ stdin.setRawMode(true);
40
+ stdin.resume();
41
+ }
42
+ stdin.setEncoding('utf8');
43
+ const handleStdinData = (data) => {
44
+ if (isExitingRef.current)
45
+ return;
46
+ // Check for return to menu shortcut
47
+ if (shortcutManager.matchesRawInput('returnToMenu', data)) {
48
+ // Disable any extended input modes that might have been enabled by the PTY
49
+ if (stdout) {
50
+ resetTerminalInputModes();
51
+ }
52
+ // Remove our listener — Ink will reconfigure stdin when Menu mounts
53
+ stdin.removeListener('data', handleStdinData);
54
+ onReturnToMenu();
55
+ return;
56
+ }
57
+ if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
58
+ sessionManager.cancelAutoApproval(session.id, 'User input received during auto-approval');
59
+ }
60
+ // Pass all other input directly to the PTY
61
+ session.process.write(data);
48
62
  };
49
- // Reset modes immediately on entry in case a previous session left them on
50
- resetTerminalInputModes();
63
+ stdin.on('data', handleStdinData);
51
64
  // Prevent line wrapping from drifting redraws in TUIs that rely on cursor-up clears.
52
65
  stdout.write('\x1b[?7l');
53
66
  // Clear screen when entering session
@@ -61,9 +74,8 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
61
74
  // Concatenate all history buffers and write at once for better performance
62
75
  const allHistory = Buffer.concat(restoredSession.outputHistory);
63
76
  const historyStr = allHistory.toString('utf8');
64
- // Sanitize and normalize the output
65
- const sanitized = sanitizeReplayBuffer(historyStr);
66
- const normalized = normalizeLineEndings(sanitized);
77
+ // Normalize the output
78
+ const normalized = normalizeLineEndings(historyStr);
67
79
  // Remove leading clear screen sequences to avoid double-clear
68
80
  const cleaned = normalized
69
81
  .replace(/^\x1B\[2J/g, '')
@@ -75,6 +87,21 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
75
87
  };
76
88
  // Listen for restore event first
77
89
  sessionManager.on('sessionRestore', handleSessionRestore);
90
+ // Listen for session data events
91
+ const handleSessionData = (activeSession, data) => {
92
+ // Only handle data for our session
93
+ if (activeSession.id === session.id && !isExitingRef.current) {
94
+ stdout.write(normalizeLineEndings(data));
95
+ }
96
+ };
97
+ const handleSessionExit = (exitedSession) => {
98
+ if (exitedSession.id === session.id) {
99
+ isExitingRef.current = true;
100
+ // Don't call onReturnToMenu here - App component handles it
101
+ }
102
+ };
103
+ sessionManager.on('sessionData', handleSessionData);
104
+ sessionManager.on('sessionExit', handleSessionExit);
78
105
  // Mark session as active (this will trigger the restore event)
79
106
  sessionManager.setSessionActive(session.id, true);
80
107
  // Immediately resize the PTY and terminal to current dimensions
@@ -93,21 +120,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
93
120
  catch {
94
121
  /* empty */
95
122
  }
96
- // Listen for session data events
97
- const handleSessionData = (activeSession, data) => {
98
- // Only handle data for our session
99
- if (activeSession.id === session.id && !isExitingRef.current) {
100
- stdout.write(normalizeLineEndings(data));
101
- }
102
- };
103
- const handleSessionExit = (exitedSession) => {
104
- if (exitedSession.id === session.id) {
105
- isExitingRef.current = true;
106
- // Don't call onReturnToMenu here - App component handles it
107
- }
108
- };
109
- sessionManager.on('sessionData', handleSessionData);
110
- sessionManager.on('sessionExit', handleSessionExit);
111
123
  // Handle terminal resize
112
124
  const handleResize = () => {
113
125
  const cols = process.stdout.columns || 80;
@@ -119,35 +131,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
119
131
  }
120
132
  };
121
133
  stdout.on('resize', handleResize);
122
- // Set up raw input handling
123
- const stdin = process.stdin;
124
- // Configure stdin for PTY passthrough
125
- if (stdin.isTTY) {
126
- stdin.setRawMode(true);
127
- stdin.resume();
128
- }
129
- stdin.setEncoding('utf8');
130
- const handleStdinData = (data) => {
131
- if (isExitingRef.current)
132
- return;
133
- // Check for return to menu shortcut
134
- if (shortcutManager.matchesRawInput('returnToMenu', data)) {
135
- // Disable any extended input modes that might have been enabled by the PTY
136
- if (stdout) {
137
- resetTerminalInputModes();
138
- }
139
- // Remove our listener — Ink will reconfigure stdin when Menu mounts
140
- stdin.removeListener('data', handleStdinData);
141
- onReturnToMenu();
142
- return;
143
- }
144
- if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
145
- sessionManager.cancelAutoApproval(session.id, 'User input received during auto-approval');
146
- }
147
- // Pass all other input directly to the PTY
148
- session.process.write(data);
149
- };
150
- stdin.on('data', handleStdinData);
151
134
  return () => {
152
135
  // Remove our stdin listener
153
136
  stdin.removeListener('data', handleStdinData);
@@ -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
  }
@@ -217,6 +216,7 @@ export class SessionManager extends EventEmitter {
217
216
  terminal,
218
217
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
219
218
  isPrimaryCommand: options.isPrimaryCommand ?? true,
219
+ presetName: options.presetName,
220
220
  detectionStrategy,
221
221
  devcontainerConfig: options.devcontainerConfig ?? undefined,
222
222
  stateMutex: new Mutex(createInitialSessionStateData()),
@@ -257,6 +257,7 @@ export class SessionManager extends EventEmitter {
257
257
  const ptyProcess = await this.spawn(command, args, worktreePath);
258
258
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
259
259
  isPrimaryCommand: true,
260
+ presetName: preset.name,
260
261
  detectionStrategy: preset.detectionStrategy,
261
262
  });
262
263
  if (launch.stdinPayload) {
@@ -338,7 +339,7 @@ export class SessionManager extends EventEmitter {
338
339
  'claude',
339
340
  ...fallbackClaudeArgs,
340
341
  ];
341
- fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
342
+ fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath, { rawMode: false });
342
343
  }
343
344
  else {
344
345
  // Regular fallback without devcontainer
@@ -633,12 +634,43 @@ export class SessionManager extends EventEmitter {
633
634
  * Create session with devcontainer integration using Effect-based error handling
634
635
  * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
635
636
  */
636
- createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt) {
637
+ createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt, onLog) {
637
638
  return Effect.tryPromise({
638
639
  try: async () => {
639
- // Execute devcontainer up command first
640
+ // Execute devcontainer up command, streaming output in real-time
640
641
  try {
641
- 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
+ });
642
674
  }
643
675
  catch (error) {
644
676
  throw new ProcessError({
@@ -656,9 +688,10 @@ export class SessionManager extends EventEmitter {
656
688
  const presetArgs = launch.args;
657
689
  const fullArgs = [...execArgs, '--', preset.command, ...presetArgs];
658
690
  // Spawn the process within devcontainer
659
- const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
691
+ const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath, { rawMode: false });
660
692
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
661
693
  isPrimaryCommand: true,
694
+ presetName: preset.name,
662
695
  detectionStrategy: preset.detectionStrategy,
663
696
  devcontainerConfig,
664
697
  });
@@ -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']);
@@ -29,6 +29,7 @@ export interface Session {
29
29
  terminal: Terminal;
30
30
  stateCheckInterval: NodeJS.Timeout | undefined;
31
31
  isPrimaryCommand: boolean;
32
+ presetName: string | undefined;
32
33
  detectionStrategy: StateDetectionStrategy | undefined;
33
34
  devcontainerConfig: DevcontainerConfig | undefined;
34
35
  /**
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
- import { dirname } from 'path';
2
+ import { basename } from 'path';
3
3
  import { Effect } from 'effect';
4
4
  import { ProcessError } from '../types/errors.js';
5
5
  import { WorktreeService } from '../services/worktreeService.js';
@@ -149,12 +149,13 @@ export function executeStatusHook(oldState, newState, session) {
149
149
  // Build environment for status hook
150
150
  const environment = {
151
151
  CCMANAGER_WORKTREE_PATH: session.worktreePath,
152
- CCMANAGER_WORKTREE_DIR: dirname(session.worktreePath),
152
+ CCMANAGER_WORKTREE_DIR: basename(session.worktreePath),
153
153
  CCMANAGER_WORKTREE_BRANCH: branch,
154
154
  CCMANAGER_GIT_ROOT: session.worktreePath, // For status hooks, we use worktree path as cwd
155
155
  CCMANAGER_OLD_STATE: oldState,
156
156
  CCMANAGER_NEW_STATE: newState,
157
157
  CCMANAGER_SESSION_ID: session.id,
158
+ CCMANAGER_PRESET_NAME: session.presetName || '',
158
159
  };
159
160
  yield* Effect.catchAll(executeHook(hook.command, session.worktreePath, environment), error => {
160
161
  // Log error but don't throw - hooks should not break the main flow
@@ -384,6 +384,7 @@ describe('hookExecutor Integration Tests', () => {
384
384
  outputHistory: [],
385
385
  stateCheckInterval: undefined,
386
386
  isPrimaryCommand: true,
387
+ presetName: undefined,
387
388
  detectionStrategy: 'claude',
388
389
  devcontainerConfig: undefined,
389
390
  lastActivity: new Date(),
@@ -440,6 +441,7 @@ describe('hookExecutor Integration Tests', () => {
440
441
  outputHistory: [],
441
442
  stateCheckInterval: undefined,
442
443
  isPrimaryCommand: true,
444
+ presetName: undefined,
443
445
  detectionStrategy: 'claude',
444
446
  devcontainerConfig: undefined,
445
447
  lastActivity: new Date(),
@@ -494,6 +496,7 @@ describe('hookExecutor Integration Tests', () => {
494
496
  outputHistory: [],
495
497
  stateCheckInterval: undefined,
496
498
  isPrimaryCommand: true,
499
+ presetName: undefined,
497
500
  detectionStrategy: 'claude',
498
501
  devcontainerConfig: undefined,
499
502
  lastActivity: new Date(),
@@ -550,6 +553,7 @@ describe('hookExecutor Integration Tests', () => {
550
553
  outputHistory: [],
551
554
  stateCheckInterval: undefined,
552
555
  isPrimaryCommand: true,
556
+ presetName: undefined,
553
557
  detectionStrategy: 'claude',
554
558
  devcontainerConfig: undefined,
555
559
  lastActivity: new Date(),
@@ -133,6 +133,7 @@ describe('prepareSessionItems', () => {
133
133
  terminal: {},
134
134
  stateCheckInterval: undefined,
135
135
  isPrimaryCommand: true,
136
+ presetName: undefined,
136
137
  detectionStrategy: 'claude',
137
138
  devcontainerConfig: undefined,
138
139
  stateMutex: new Mutex({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.0.1",
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.1",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.0.1",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.0.1",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.0.1",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.0.1"
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",