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.
- package/dist/components/App.js +17 -3
- package/dist/services/bunTerminal.d.ts +1 -0
- package/dist/services/bunTerminal.js +4 -3
- package/dist/services/sessionManager.d.ts +1 -1
- package/dist/services/sessionManager.effect.test.js +16 -37
- package/dist/services/sessionManager.js +39 -9
- package/dist/services/sessionManager.test.js +23 -97
- package/dist/services/stateDetector/cursor.js +8 -0
- package/dist/services/stateDetector/cursor.test.js +12 -0
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
|
@@ -57,9 +57,10 @@ class BunTerminal {
|
|
|
57
57
|
this._processBuffer();
|
|
58
58
|
},
|
|
59
59
|
});
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
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
|
|
196
|
-
const {
|
|
197
|
-
|
|
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('
|
|
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 {
|
|
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
|
|
640
|
+
// Execute devcontainer up command, streaming output in real-time
|
|
642
641
|
try {
|
|
643
|
-
await
|
|
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 {
|
|
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
|
-
|
|
16
|
-
return
|
|
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
|
-
//
|
|
392
|
-
|
|
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
|
|
469
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.0.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.0.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.0.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.0.
|
|
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",
|