ccmanager 3.3.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Menu.recent-projects.test.js +2 -0
- package/dist/components/Menu.test.js +2 -0
- package/dist/components/Session.js +3 -65
- package/dist/constants/statusIcons.d.ts +4 -1
- package/dist/constants/statusIcons.js +10 -1
- package/dist/constants/statusIcons.test.d.ts +1 -0
- package/dist/constants/statusIcons.test.js +42 -0
- package/dist/services/sessionManager.autoApproval.test.js +4 -1
- package/dist/services/sessionManager.d.ts +2 -0
- package/dist/services/sessionManager.js +26 -6
- package/dist/services/sessionManager.test.js +19 -2
- package/dist/services/stateDetector/base.d.ts +1 -0
- package/dist/services/stateDetector/claude.d.ts +1 -0
- package/dist/services/stateDetector/claude.js +8 -0
- package/dist/services/stateDetector/claude.test.js +102 -0
- package/dist/services/stateDetector/cline.d.ts +1 -0
- package/dist/services/stateDetector/cline.js +3 -0
- package/dist/services/stateDetector/codex.d.ts +1 -0
- package/dist/services/stateDetector/codex.js +3 -0
- package/dist/services/stateDetector/cursor.d.ts +1 -0
- package/dist/services/stateDetector/cursor.js +3 -0
- package/dist/services/stateDetector/gemini.d.ts +1 -0
- package/dist/services/stateDetector/gemini.js +3 -0
- package/dist/services/stateDetector/github-copilot.d.ts +1 -0
- package/dist/services/stateDetector/github-copilot.js +3 -0
- package/dist/services/stateDetector/opencode.d.ts +1 -0
- package/dist/services/stateDetector/opencode.js +3 -0
- package/dist/services/stateDetector/types.d.ts +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/utils/hookExecutor.test.js +5 -0
- package/dist/utils/mutex.d.ts +1 -0
- package/dist/utils/mutex.js +1 -0
- package/dist/utils/worktreeUtils.js +3 -2
- package/dist/utils/worktreeUtils.test.js +2 -0
- package/package.json +6 -6
|
@@ -123,6 +123,7 @@ describe('Menu - Recent Projects', () => {
|
|
|
123
123
|
waiting_input: 0,
|
|
124
124
|
pending_auto_approval: 0,
|
|
125
125
|
total: 0,
|
|
126
|
+
backgroundTasks: 0,
|
|
126
127
|
});
|
|
127
128
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
128
129
|
const { lastFrame } = render(React.createElement(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true }));
|
|
@@ -153,6 +154,7 @@ describe('Menu - Recent Projects', () => {
|
|
|
153
154
|
waiting_input: 0,
|
|
154
155
|
pending_auto_approval: 0,
|
|
155
156
|
total: 0,
|
|
157
|
+
backgroundTasks: 0,
|
|
156
158
|
});
|
|
157
159
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
158
160
|
const { lastFrame } = render(React.createElement(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true }));
|
|
@@ -276,6 +276,7 @@ describe('Menu component rendering', () => {
|
|
|
276
276
|
waiting_input: 0,
|
|
277
277
|
pending_auto_approval: 0,
|
|
278
278
|
total: 0,
|
|
279
|
+
backgroundTasks: 0,
|
|
279
280
|
});
|
|
280
281
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
281
282
|
const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true }));
|
|
@@ -317,6 +318,7 @@ describe('Menu component rendering', () => {
|
|
|
317
318
|
waiting_input: 0,
|
|
318
319
|
pending_auto_approval: 0,
|
|
319
320
|
total: 0,
|
|
321
|
+
backgroundTasks: 0,
|
|
320
322
|
});
|
|
321
323
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
322
324
|
const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true }));
|
|
@@ -1,68 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useStdout } from 'ink';
|
|
3
3
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
4
4
|
const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
5
5
|
const { stdout } = useStdout();
|
|
6
6
|
const [isExiting, setIsExiting] = useState(false);
|
|
7
|
-
const deriveStatus = (currentSession) => {
|
|
8
|
-
const stateData = currentSession.stateMutex.getSnapshot();
|
|
9
|
-
// Always prioritize showing the manual approval notice when verification failed
|
|
10
|
-
if (stateData.autoApprovalFailed) {
|
|
11
|
-
const reason = stateData.autoApprovalReason
|
|
12
|
-
? ` Reason: ${stateData.autoApprovalReason}.`
|
|
13
|
-
: '';
|
|
14
|
-
return {
|
|
15
|
-
message: `Auto-approval failed.${reason} Manual approval required—respond to the prompt.`,
|
|
16
|
-
variant: 'error',
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
if (stateData.state === 'pending_auto_approval') {
|
|
20
|
-
return {
|
|
21
|
-
message: 'Auto-approval pending... verifying permissions (press any key to cancel)',
|
|
22
|
-
variant: 'pending',
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
return { message: null, variant: null };
|
|
26
|
-
};
|
|
27
|
-
const initialStatus = deriveStatus(session);
|
|
28
|
-
const [statusMessage, setStatusMessage] = useState(initialStatus.message);
|
|
29
|
-
const [statusVariant, setStatusVariant] = useState(initialStatus.variant);
|
|
30
|
-
const [columns, setColumns] = useState(() => stdout?.columns ?? process.stdout.columns ?? 80);
|
|
31
|
-
const { statusLineText, backgroundColor, textColor } = useMemo(() => {
|
|
32
|
-
if (!statusMessage || !statusVariant) {
|
|
33
|
-
return {
|
|
34
|
-
statusLineText: null,
|
|
35
|
-
backgroundColor: undefined,
|
|
36
|
-
textColor: undefined,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
const maxContentWidth = Math.max(columns - 4, 0);
|
|
40
|
-
const prefix = statusVariant === 'error'
|
|
41
|
-
? '[AUTO-APPROVAL REQUIRED]'
|
|
42
|
-
: '[AUTO-APPROVAL]';
|
|
43
|
-
const prefixed = `${prefix} ${statusMessage}`;
|
|
44
|
-
const trimmed = prefixed.length > maxContentWidth
|
|
45
|
-
? prefixed.slice(0, maxContentWidth)
|
|
46
|
-
: prefixed;
|
|
47
|
-
return {
|
|
48
|
-
statusLineText: ` ${trimmed}`.padEnd(columns, ' '),
|
|
49
|
-
backgroundColor: statusVariant === 'error' ? '#d90429' : '#ffd166',
|
|
50
|
-
textColor: statusVariant === 'error' ? 'white' : '#1c1c1c',
|
|
51
|
-
};
|
|
52
|
-
}, [columns, statusMessage, statusVariant]);
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
const handleSessionStateChange = (updatedSession) => {
|
|
55
|
-
if (updatedSession.id !== session.id)
|
|
56
|
-
return;
|
|
57
|
-
const { message, variant } = deriveStatus(updatedSession);
|
|
58
|
-
setStatusMessage(message);
|
|
59
|
-
setStatusVariant(variant);
|
|
60
|
-
};
|
|
61
|
-
sessionManager.on('sessionStateChanged', handleSessionStateChange);
|
|
62
|
-
return () => {
|
|
63
|
-
sessionManager.off('sessionStateChanged', handleSessionStateChange);
|
|
64
|
-
};
|
|
65
|
-
}, [session.id, sessionManager]);
|
|
66
7
|
const stripOscColorSequences = (input) => {
|
|
67
8
|
// Remove default foreground/background color OSC sequences that Codex emits
|
|
68
9
|
// These sequences leak as literal text when replaying buffered output
|
|
@@ -168,7 +109,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
168
109
|
const handleSessionExit = (exitedSession) => {
|
|
169
110
|
if (exitedSession.id === session.id) {
|
|
170
111
|
setIsExiting(true);
|
|
171
|
-
setStatusMessage(null);
|
|
172
112
|
// Don't call onReturnToMenu here - App component handles it
|
|
173
113
|
}
|
|
174
114
|
};
|
|
@@ -178,7 +118,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
178
118
|
const handleResize = () => {
|
|
179
119
|
const cols = process.stdout.columns || 80;
|
|
180
120
|
const rows = process.stdout.rows || 24;
|
|
181
|
-
setColumns(cols);
|
|
182
121
|
session.process.resize(cols, rows);
|
|
183
122
|
// Also resize the virtual terminal
|
|
184
123
|
if (session.terminal) {
|
|
@@ -244,7 +183,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
244
183
|
stdout.off('resize', handleResize);
|
|
245
184
|
};
|
|
246
185
|
}, [session, sessionManager, stdout, onReturnToMenu, isExiting]);
|
|
247
|
-
return
|
|
248
|
-
React.createElement(Text, { backgroundColor: backgroundColor, color: textColor, bold: true }, statusLineText))) : null;
|
|
186
|
+
return null;
|
|
249
187
|
};
|
|
250
188
|
export default Session;
|
|
@@ -10,6 +10,9 @@ export declare const STATUS_LABELS: {
|
|
|
10
10
|
readonly PENDING_AUTO_APPROVAL: "Pending Auto Approval";
|
|
11
11
|
readonly IDLE: "Idle";
|
|
12
12
|
};
|
|
13
|
+
export declare const STATUS_TAGS: {
|
|
14
|
+
readonly BACKGROUND_TASK: "\u001B[2m[BG]\u001B[0m";
|
|
15
|
+
};
|
|
13
16
|
export declare const MENU_ICONS: {
|
|
14
17
|
readonly NEW_WORKTREE: "⊕";
|
|
15
18
|
readonly MERGE_WORKTREE: "⇄";
|
|
@@ -17,4 +20,4 @@ export declare const MENU_ICONS: {
|
|
|
17
20
|
readonly CONFIGURE_SHORTCUTS: "⌨";
|
|
18
21
|
readonly EXIT: "⏻";
|
|
19
22
|
};
|
|
20
|
-
export declare const getStatusDisplay: (status: SessionState) => string;
|
|
23
|
+
export declare const getStatusDisplay: (status: SessionState, hasBackgroundTask?: boolean) => string;
|
|
@@ -9,6 +9,9 @@ export const STATUS_LABELS = {
|
|
|
9
9
|
PENDING_AUTO_APPROVAL: 'Pending Auto Approval',
|
|
10
10
|
IDLE: 'Idle',
|
|
11
11
|
};
|
|
12
|
+
export const STATUS_TAGS = {
|
|
13
|
+
BACKGROUND_TASK: '\x1b[2m[BG]\x1b[0m',
|
|
14
|
+
};
|
|
12
15
|
export const MENU_ICONS = {
|
|
13
16
|
NEW_WORKTREE: '⊕',
|
|
14
17
|
MERGE_WORKTREE: '⇄',
|
|
@@ -16,7 +19,7 @@ export const MENU_ICONS = {
|
|
|
16
19
|
CONFIGURE_SHORTCUTS: '⌨',
|
|
17
20
|
EXIT: '⏻',
|
|
18
21
|
};
|
|
19
|
-
|
|
22
|
+
const getBaseStatusDisplay = (status) => {
|
|
20
23
|
switch (status) {
|
|
21
24
|
case 'busy':
|
|
22
25
|
return `${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`;
|
|
@@ -28,3 +31,9 @@ export const getStatusDisplay = (status) => {
|
|
|
28
31
|
return `${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`;
|
|
29
32
|
}
|
|
30
33
|
};
|
|
34
|
+
export const getStatusDisplay = (status, hasBackgroundTask = false) => {
|
|
35
|
+
const display = getBaseStatusDisplay(status);
|
|
36
|
+
return hasBackgroundTask
|
|
37
|
+
? `${display} ${STATUS_TAGS.BACKGROUND_TASK}`
|
|
38
|
+
: display;
|
|
39
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getStatusDisplay, STATUS_ICONS, STATUS_LABELS, STATUS_TAGS, } from './statusIcons.js';
|
|
3
|
+
describe('getStatusDisplay', () => {
|
|
4
|
+
it('should return busy display for busy state', () => {
|
|
5
|
+
const result = getStatusDisplay('busy');
|
|
6
|
+
expect(result).toBe(`${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`);
|
|
7
|
+
});
|
|
8
|
+
it('should return waiting display for waiting_input state', () => {
|
|
9
|
+
const result = getStatusDisplay('waiting_input');
|
|
10
|
+
expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING}`);
|
|
11
|
+
});
|
|
12
|
+
it('should return pending auto approval display', () => {
|
|
13
|
+
const result = getStatusDisplay('pending_auto_approval');
|
|
14
|
+
expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.PENDING_AUTO_APPROVAL}`);
|
|
15
|
+
});
|
|
16
|
+
it('should return idle display for idle state', () => {
|
|
17
|
+
const result = getStatusDisplay('idle');
|
|
18
|
+
expect(result).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`);
|
|
19
|
+
});
|
|
20
|
+
describe('background task indicator', () => {
|
|
21
|
+
it('should append [BG] badge when idle and hasBackgroundTask is true', () => {
|
|
22
|
+
const result = getStatusDisplay('idle', true);
|
|
23
|
+
expect(result).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE} ${STATUS_TAGS.BACKGROUND_TASK}`);
|
|
24
|
+
});
|
|
25
|
+
it('should not append [BG] badge when idle and hasBackgroundTask is false', () => {
|
|
26
|
+
const result = getStatusDisplay('idle', false);
|
|
27
|
+
expect(result).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`);
|
|
28
|
+
});
|
|
29
|
+
it('should append [BG] badge when busy and hasBackgroundTask is true', () => {
|
|
30
|
+
const result = getStatusDisplay('busy', true);
|
|
31
|
+
expect(result).toBe(`${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY} ${STATUS_TAGS.BACKGROUND_TASK}`);
|
|
32
|
+
});
|
|
33
|
+
it('should append [BG] badge when waiting_input and hasBackgroundTask is true', () => {
|
|
34
|
+
const result = getStatusDisplay('waiting_input', true);
|
|
35
|
+
expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING} ${STATUS_TAGS.BACKGROUND_TASK}`);
|
|
36
|
+
});
|
|
37
|
+
it('should append [BG] badge when pending_auto_approval and hasBackgroundTask is true', () => {
|
|
38
|
+
const result = getStatusDisplay('pending_auto_approval', true);
|
|
39
|
+
expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.PENDING_AUTO_APPROVAL} ${STATUS_TAGS.BACKGROUND_TASK}`);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -15,7 +15,10 @@ vi.mock('./bunTerminal.js', () => ({
|
|
|
15
15
|
}),
|
|
16
16
|
}));
|
|
17
17
|
vi.mock('./stateDetector/index.js', () => ({
|
|
18
|
-
createStateDetector: () => ({
|
|
18
|
+
createStateDetector: () => ({
|
|
19
|
+
detectState: detectStateMock,
|
|
20
|
+
detectBackgroundTask: () => false,
|
|
21
|
+
}),
|
|
19
22
|
}));
|
|
20
23
|
vi.mock('./configurationManager.js', () => ({
|
|
21
24
|
configurationManager: {
|
|
@@ -8,6 +8,7 @@ export interface SessionCounts {
|
|
|
8
8
|
waiting_input: number;
|
|
9
9
|
pending_auto_approval: number;
|
|
10
10
|
total: number;
|
|
11
|
+
backgroundTasks: number;
|
|
11
12
|
}
|
|
12
13
|
export declare class SessionManager extends EventEmitter implements ISessionManager {
|
|
13
14
|
sessions: Map<string, Session>;
|
|
@@ -15,6 +16,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
15
16
|
private busyTimers;
|
|
16
17
|
private spawn;
|
|
17
18
|
detectTerminalState(session: Session): SessionState;
|
|
19
|
+
detectBackgroundTask(session: Session): boolean;
|
|
18
20
|
private getTerminalContent;
|
|
19
21
|
private handleAutoApproval;
|
|
20
22
|
private cancelAutoApprovalVerification;
|
|
@@ -12,6 +12,7 @@ import { ProcessError, ConfigError } from '../types/errors.js';
|
|
|
12
12
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
13
13
|
import { logger } from '../utils/logger.js';
|
|
14
14
|
import { Mutex, createInitialSessionStateData } from '../utils/mutex.js';
|
|
15
|
+
import { STATUS_TAGS } from '../constants/statusIcons.js';
|
|
15
16
|
import { getTerminalScreenContent } from '../utils/screenCapture.js';
|
|
16
17
|
const { Terminal } = pkg;
|
|
17
18
|
const execAsync = promisify(exec);
|
|
@@ -28,11 +29,8 @@ export class SessionManager extends EventEmitter {
|
|
|
28
29
|
return spawn(command, args, spawnOptions);
|
|
29
30
|
}
|
|
30
31
|
detectTerminalState(session) {
|
|
31
|
-
// Create a detector based on the session's detection strategy
|
|
32
|
-
const strategy = session.detectionStrategy || 'claude';
|
|
33
|
-
const detector = createStateDetector(strategy);
|
|
34
32
|
const stateData = session.stateMutex.getSnapshot();
|
|
35
|
-
const detectedState =
|
|
33
|
+
const detectedState = session.stateDetector.detectState(session.terminal, stateData.state);
|
|
36
34
|
// If auto-approval is enabled and state is waiting_input, convert to pending_auto_approval
|
|
37
35
|
if (detectedState === 'waiting_input' &&
|
|
38
36
|
configurationManager.isAutoApprovalEnabled() &&
|
|
@@ -41,6 +39,9 @@ export class SessionManager extends EventEmitter {
|
|
|
41
39
|
}
|
|
42
40
|
return detectedState;
|
|
43
41
|
}
|
|
42
|
+
detectBackgroundTask(session) {
|
|
43
|
+
return session.stateDetector.detectBackgroundTask(session.terminal);
|
|
44
|
+
}
|
|
44
45
|
getTerminalContent(session) {
|
|
45
46
|
// Use the new screen capture utility that correctly handles
|
|
46
47
|
// both normal and alternate screen buffers
|
|
@@ -190,6 +191,8 @@ export class SessionManager extends EventEmitter {
|
|
|
190
191
|
async createSessionInternal(worktreePath, ptyProcess, commandConfig, options = {}) {
|
|
191
192
|
const id = this.createSessionId();
|
|
192
193
|
const terminal = this.createTerminal();
|
|
194
|
+
const detectionStrategy = options.detectionStrategy ?? 'claude';
|
|
195
|
+
const stateDetector = createStateDetector(detectionStrategy);
|
|
193
196
|
const session = {
|
|
194
197
|
id,
|
|
195
198
|
worktreePath,
|
|
@@ -202,9 +205,10 @@ export class SessionManager extends EventEmitter {
|
|
|
202
205
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
203
206
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
204
207
|
commandConfig,
|
|
205
|
-
detectionStrategy
|
|
208
|
+
detectionStrategy,
|
|
206
209
|
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
207
210
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
211
|
+
stateDetector,
|
|
208
212
|
};
|
|
209
213
|
// Set up persistent background data handler for state detection
|
|
210
214
|
this.setupBackgroundHandler(session);
|
|
@@ -426,6 +430,14 @@ export class SessionManager extends EventEmitter {
|
|
|
426
430
|
!currentStateData.autoApprovalAbortController) {
|
|
427
431
|
this.handleAutoApproval(session);
|
|
428
432
|
}
|
|
433
|
+
// Detect and update background task flag
|
|
434
|
+
const hasBackgroundTask = this.detectBackgroundTask(session);
|
|
435
|
+
if (currentStateData.hasBackgroundTask !== hasBackgroundTask) {
|
|
436
|
+
void session.stateMutex.update(data => ({
|
|
437
|
+
...data,
|
|
438
|
+
hasBackgroundTask,
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
429
441
|
}, STATE_CHECK_INTERVAL_MS);
|
|
430
442
|
// Setup exit handler
|
|
431
443
|
this.setupExitHandler(session);
|
|
@@ -677,6 +689,7 @@ export class SessionManager extends EventEmitter {
|
|
|
677
689
|
waiting_input: 0,
|
|
678
690
|
pending_auto_approval: 0,
|
|
679
691
|
total: sessions.length,
|
|
692
|
+
backgroundTasks: 0,
|
|
680
693
|
};
|
|
681
694
|
sessions.forEach(session => {
|
|
682
695
|
const stateData = session.stateMutex.getSnapshot();
|
|
@@ -694,6 +707,9 @@ export class SessionManager extends EventEmitter {
|
|
|
694
707
|
counts.pending_auto_approval++;
|
|
695
708
|
break;
|
|
696
709
|
}
|
|
710
|
+
if (stateData.hasBackgroundTask) {
|
|
711
|
+
counts.backgroundTasks++;
|
|
712
|
+
}
|
|
697
713
|
});
|
|
698
714
|
return counts;
|
|
699
715
|
}
|
|
@@ -711,6 +727,10 @@ export class SessionManager extends EventEmitter {
|
|
|
711
727
|
if (counts.waiting_input > 0) {
|
|
712
728
|
parts.push(`${counts.waiting_input} Waiting`);
|
|
713
729
|
}
|
|
714
|
-
|
|
730
|
+
if (parts.length === 0) {
|
|
731
|
+
return '';
|
|
732
|
+
}
|
|
733
|
+
const bgTag = counts.backgroundTasks > 0 ? ` ${STATUS_TAGS.BACKGROUND_TASK}` : '';
|
|
734
|
+
return ` (${parts.join(' / ')}${bgTag})`;
|
|
715
735
|
}
|
|
716
736
|
}
|
|
@@ -720,10 +720,10 @@ describe('SessionManager', () => {
|
|
|
720
720
|
describe('static methods', () => {
|
|
721
721
|
describe('getSessionCounts', () => {
|
|
722
722
|
// Helper to create mock session with stateMutex
|
|
723
|
-
const createMockSession = (id, state) => ({
|
|
723
|
+
const createMockSession = (id, state, hasBackgroundTask = false) => ({
|
|
724
724
|
id,
|
|
725
725
|
stateMutex: {
|
|
726
|
-
getSnapshot: () => ({ state }),
|
|
726
|
+
getSnapshot: () => ({ state, hasBackgroundTask }),
|
|
727
727
|
},
|
|
728
728
|
});
|
|
729
729
|
it('should count sessions by state', () => {
|
|
@@ -768,6 +768,7 @@ describe('SessionManager', () => {
|
|
|
768
768
|
waiting_input: 1,
|
|
769
769
|
pending_auto_approval: 0,
|
|
770
770
|
total: 4,
|
|
771
|
+
backgroundTasks: 0,
|
|
771
772
|
};
|
|
772
773
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
773
774
|
expect(formatted).toBe(' (1 Idle / 2 Busy / 1 Waiting)');
|
|
@@ -779,6 +780,7 @@ describe('SessionManager', () => {
|
|
|
779
780
|
waiting_input: 1,
|
|
780
781
|
pending_auto_approval: 0,
|
|
781
782
|
total: 3,
|
|
783
|
+
backgroundTasks: 0,
|
|
782
784
|
};
|
|
783
785
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
784
786
|
expect(formatted).toBe(' (2 Idle / 1 Waiting)');
|
|
@@ -790,6 +792,7 @@ describe('SessionManager', () => {
|
|
|
790
792
|
waiting_input: 0,
|
|
791
793
|
pending_auto_approval: 0,
|
|
792
794
|
total: 3,
|
|
795
|
+
backgroundTasks: 0,
|
|
793
796
|
};
|
|
794
797
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
795
798
|
expect(formatted).toBe(' (3 Busy)');
|
|
@@ -801,10 +804,24 @@ describe('SessionManager', () => {
|
|
|
801
804
|
waiting_input: 0,
|
|
802
805
|
pending_auto_approval: 0,
|
|
803
806
|
total: 0,
|
|
807
|
+
backgroundTasks: 0,
|
|
804
808
|
};
|
|
805
809
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
806
810
|
expect(formatted).toBe('');
|
|
807
811
|
});
|
|
812
|
+
it('should append [BG] tag when background tasks exist', () => {
|
|
813
|
+
const counts = {
|
|
814
|
+
idle: 1,
|
|
815
|
+
busy: 1,
|
|
816
|
+
waiting_input: 0,
|
|
817
|
+
pending_auto_approval: 0,
|
|
818
|
+
total: 2,
|
|
819
|
+
backgroundTasks: 1,
|
|
820
|
+
};
|
|
821
|
+
const formatted = SessionManager.formatSessionCounts(counts);
|
|
822
|
+
expect(formatted).toContain('[BG]');
|
|
823
|
+
expect(formatted).toBe(' (1 Idle / 1 Busy \x1b[2m[BG]\x1b[0m)');
|
|
824
|
+
});
|
|
808
825
|
});
|
|
809
826
|
});
|
|
810
827
|
});
|
|
@@ -4,4 +4,5 @@ export declare abstract class BaseStateDetector implements StateDetector {
|
|
|
4
4
|
abstract detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
5
5
|
protected getTerminalLines(terminal: Terminal, maxLines?: number): string[];
|
|
6
6
|
protected getTerminalContent(terminal: Terminal, maxLines?: number): string;
|
|
7
|
+
abstract detectBackgroundTask(terminal: Terminal): boolean;
|
|
7
8
|
}
|
|
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class ClaudeStateDetector extends BaseStateDetector {
|
|
4
4
|
detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
5
|
+
detectBackgroundTask(terminal: Terminal): boolean;
|
|
5
6
|
}
|
|
@@ -24,4 +24,12 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
24
24
|
// Otherwise idle
|
|
25
25
|
return 'idle';
|
|
26
26
|
}
|
|
27
|
+
detectBackgroundTask(terminal) {
|
|
28
|
+
const lines = this.getTerminalLines(terminal, 3);
|
|
29
|
+
const content = lines.join('\n').toLowerCase();
|
|
30
|
+
// Detect background task patterns:
|
|
31
|
+
// - "N background task(s)" in status bar
|
|
32
|
+
// - "(running)" in status bar for active background commands
|
|
33
|
+
return content.includes('background task') || content.includes('(running)');
|
|
34
|
+
}
|
|
27
35
|
}
|
|
@@ -222,4 +222,106 @@ describe('ClaudeStateDetector', () => {
|
|
|
222
222
|
expect(state).toBe('waiting_input');
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
|
+
describe('detectBackgroundTask', () => {
|
|
226
|
+
it('should detect background task when pattern is in last 3 lines (status bar)', () => {
|
|
227
|
+
// Arrange
|
|
228
|
+
terminal = createMockTerminal([
|
|
229
|
+
'Previous conversation content',
|
|
230
|
+
'More content',
|
|
231
|
+
'> Some command output',
|
|
232
|
+
'1 background task | api-call',
|
|
233
|
+
]);
|
|
234
|
+
// Act
|
|
235
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
236
|
+
// Assert
|
|
237
|
+
expect(hasBackgroundTask).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
it('should detect background task with plural "background tasks"', () => {
|
|
240
|
+
// Arrange
|
|
241
|
+
terminal = createMockTerminal([
|
|
242
|
+
'Some output',
|
|
243
|
+
'More output',
|
|
244
|
+
'2 background tasks running',
|
|
245
|
+
]);
|
|
246
|
+
// Act
|
|
247
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
248
|
+
// Assert
|
|
249
|
+
expect(hasBackgroundTask).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
it('should detect background task case-insensitively', () => {
|
|
252
|
+
// Arrange
|
|
253
|
+
terminal = createMockTerminal([
|
|
254
|
+
'Output line 1',
|
|
255
|
+
'Output line 2',
|
|
256
|
+
'1 BACKGROUND TASK running',
|
|
257
|
+
]);
|
|
258
|
+
// Act
|
|
259
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
260
|
+
// Assert
|
|
261
|
+
expect(hasBackgroundTask).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
it('should return false when no background task pattern in last 3 lines', () => {
|
|
264
|
+
// Arrange
|
|
265
|
+
terminal = createMockTerminal([
|
|
266
|
+
'Command completed successfully',
|
|
267
|
+
'Ready for next command',
|
|
268
|
+
'> ',
|
|
269
|
+
]);
|
|
270
|
+
// Act
|
|
271
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
272
|
+
// Assert
|
|
273
|
+
expect(hasBackgroundTask).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
it('should not detect background task when pattern is in conversation content (not status bar)', () => {
|
|
276
|
+
// Arrange - "background task" mentioned earlier in conversation, but not in last 3 lines
|
|
277
|
+
terminal = createMockTerminal([
|
|
278
|
+
'User: Tell me about background task handling',
|
|
279
|
+
'Assistant: Background task detection works by...',
|
|
280
|
+
'The pattern "background task" appears in text but...',
|
|
281
|
+
'This is the status bar area',
|
|
282
|
+
'> idle',
|
|
283
|
+
'Ready',
|
|
284
|
+
]);
|
|
285
|
+
// Act
|
|
286
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
287
|
+
// Assert - should only check last 3 lines, not the conversation content
|
|
288
|
+
expect(hasBackgroundTask).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
it('should handle empty terminal', () => {
|
|
291
|
+
// Arrange
|
|
292
|
+
terminal = createMockTerminal([]);
|
|
293
|
+
// Act
|
|
294
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
295
|
+
// Assert
|
|
296
|
+
expect(hasBackgroundTask).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
it('should handle terminal with fewer than 3 lines', () => {
|
|
299
|
+
// Arrange
|
|
300
|
+
terminal = createMockTerminal(['1 background task']);
|
|
301
|
+
// Act
|
|
302
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
303
|
+
// Assert
|
|
304
|
+
expect(hasBackgroundTask).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
it('should detect "(running)" status bar indicator', () => {
|
|
307
|
+
// Arrange
|
|
308
|
+
terminal = createMockTerminal([
|
|
309
|
+
'Some conversation output',
|
|
310
|
+
'More output',
|
|
311
|
+
'bypass permissions on - uv run pytest tests/integration/e2e/tes... (running)',
|
|
312
|
+
]);
|
|
313
|
+
// Act
|
|
314
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
315
|
+
// Assert
|
|
316
|
+
expect(hasBackgroundTask).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
it('should detect "(running)" case-insensitively', () => {
|
|
319
|
+
// Arrange
|
|
320
|
+
terminal = createMockTerminal(['Some output', 'command name (RUNNING)']);
|
|
321
|
+
// Act
|
|
322
|
+
const hasBackgroundTask = detector.detectBackgroundTask(terminal);
|
|
323
|
+
// Assert
|
|
324
|
+
expect(hasBackgroundTask).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
225
327
|
});
|
|
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class ClineStateDetector extends BaseStateDetector {
|
|
4
4
|
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
5
|
+
detectBackgroundTask(_terminal: Terminal): boolean;
|
|
5
6
|
}
|
|
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class CodexStateDetector extends BaseStateDetector {
|
|
4
4
|
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
5
|
+
detectBackgroundTask(_terminal: Terminal): boolean;
|
|
5
6
|
}
|
|
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class CursorStateDetector extends BaseStateDetector {
|
|
4
4
|
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
5
|
+
detectBackgroundTask(_terminal: Terminal): boolean;
|
|
5
6
|
}
|
|
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class GeminiStateDetector extends BaseStateDetector {
|
|
4
4
|
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
5
|
+
detectBackgroundTask(_terminal: Terminal): boolean;
|
|
5
6
|
}
|
|
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class GitHubCopilotStateDetector extends BaseStateDetector {
|
|
4
4
|
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
5
|
+
detectBackgroundTask(_terminal: Terminal): boolean;
|
|
5
6
|
}
|
|
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class OpenCodeStateDetector extends BaseStateDetector {
|
|
4
4
|
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
5
|
+
detectBackgroundTask(_terminal: Terminal): boolean;
|
|
5
6
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IPty } from '../services/bunTerminal.js';
|
|
|
2
2
|
import type pkg from '@xterm/headless';
|
|
3
3
|
import { GitStatus } from '../utils/gitStatus.js';
|
|
4
4
|
import { Mutex, SessionStateData } from '../utils/mutex.js';
|
|
5
|
+
import type { StateDetector } from '../services/stateDetector/types.js';
|
|
5
6
|
export type Terminal = InstanceType<typeof pkg.Terminal>;
|
|
6
7
|
export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
|
|
7
8
|
export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline' | 'opencode';
|
|
@@ -33,6 +34,11 @@ export interface Session {
|
|
|
33
34
|
* Contains: state, pendingState, pendingStateStart, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController
|
|
34
35
|
*/
|
|
35
36
|
stateMutex: Mutex<SessionStateData>;
|
|
37
|
+
/**
|
|
38
|
+
* State detector instance for this session.
|
|
39
|
+
* Created once during session initialization based on detectionStrategy.
|
|
40
|
+
*/
|
|
41
|
+
stateDetector: StateDetector;
|
|
36
42
|
}
|
|
37
43
|
export interface AutoApprovalResponse {
|
|
38
44
|
needsPermission: boolean;
|
|
@@ -8,6 +8,7 @@ import { configurationManager } from '../services/configurationManager.js';
|
|
|
8
8
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
9
9
|
import { GitError } from '../types/errors.js';
|
|
10
10
|
import { Mutex, createInitialSessionStateData } from './mutex.js';
|
|
11
|
+
import { createStateDetector } from '../services/stateDetector/index.js';
|
|
11
12
|
// Mock the configurationManager
|
|
12
13
|
vi.mock('../services/configurationManager.js', () => ({
|
|
13
14
|
configurationManager: {
|
|
@@ -275,6 +276,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
275
276
|
lastActivity: new Date(),
|
|
276
277
|
isActive: true,
|
|
277
278
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
279
|
+
stateDetector: createStateDetector('claude'),
|
|
278
280
|
};
|
|
279
281
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
280
282
|
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
@@ -329,6 +331,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
329
331
|
lastActivity: new Date(),
|
|
330
332
|
isActive: true,
|
|
331
333
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
334
|
+
stateDetector: createStateDetector('claude'),
|
|
332
335
|
};
|
|
333
336
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
334
337
|
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
@@ -381,6 +384,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
381
384
|
lastActivity: new Date(),
|
|
382
385
|
isActive: true,
|
|
383
386
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
387
|
+
stateDetector: createStateDetector('claude'),
|
|
384
388
|
};
|
|
385
389
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
386
390
|
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
@@ -435,6 +439,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
435
439
|
lastActivity: new Date(),
|
|
436
440
|
isActive: true,
|
|
437
441
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
442
|
+
stateDetector: createStateDetector('claude'),
|
|
438
443
|
};
|
|
439
444
|
// Mock WorktreeService to fail with GitError
|
|
440
445
|
vi.mocked(WorktreeService).mockImplementation(function () {
|
package/dist/utils/mutex.d.ts
CHANGED
|
@@ -47,6 +47,7 @@ export interface SessionStateData {
|
|
|
47
47
|
autoApprovalFailed: boolean;
|
|
48
48
|
autoApprovalReason: string | undefined;
|
|
49
49
|
autoApprovalAbortController: AbortController | undefined;
|
|
50
|
+
hasBackgroundTask: boolean;
|
|
50
51
|
}
|
|
51
52
|
/**
|
|
52
53
|
* Create initial session state data with default values.
|
package/dist/utils/mutex.js
CHANGED
|
@@ -71,8 +71,9 @@ export function extractBranchParts(branchName) {
|
|
|
71
71
|
export function prepareWorktreeItems(worktrees, sessions) {
|
|
72
72
|
return worktrees.map(wt => {
|
|
73
73
|
const session = sessions.find(s => s.worktreePath === wt.path);
|
|
74
|
-
const
|
|
75
|
-
|
|
74
|
+
const stateData = session?.stateMutex.getSnapshot();
|
|
75
|
+
const status = stateData
|
|
76
|
+
? ` [${getStatusDisplay(stateData.state, stateData.hasBackgroundTask)}]`
|
|
76
77
|
: '';
|
|
77
78
|
const fullBranchName = wt.branch
|
|
78
79
|
? wt.branch.replace('refs/heads/', '')
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from './worktreeUtils.js';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
import { Mutex, createInitialSessionStateData } from './mutex.js';
|
|
5
|
+
import { createStateDetector } from '../services/stateDetector/index.js';
|
|
5
6
|
// Mock child_process module
|
|
6
7
|
vi.mock('child_process');
|
|
7
8
|
describe('generateWorktreeDirectory', () => {
|
|
@@ -137,6 +138,7 @@ describe('prepareWorktreeItems', () => {
|
|
|
137
138
|
...createInitialSessionStateData(),
|
|
138
139
|
state: 'idle',
|
|
139
140
|
}),
|
|
141
|
+
stateDetector: createStateDetector('claude'),
|
|
140
142
|
};
|
|
141
143
|
it('should prepare basic worktree without git status', () => {
|
|
142
144
|
const items = prepareWorktreeItems([mockWorktree], []);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kodai Kabasawa",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "3.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.4.0",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.4.0",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.4.0",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.4.0",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.4.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|