ccmanager 3.3.2 → 3.5.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/README.md +11 -5
- package/dist/components/App.js +17 -3
- package/dist/components/App.test.js +5 -5
- package/dist/components/Configuration.d.ts +2 -0
- package/dist/components/Configuration.js +6 -2
- package/dist/components/ConfigureCommand.js +34 -11
- package/dist/components/ConfigureOther.js +18 -4
- package/dist/components/ConfigureOther.test.js +48 -12
- package/dist/components/ConfigureShortcuts.js +27 -85
- package/dist/components/ConfigureStatusHooks.js +19 -4
- package/dist/components/ConfigureStatusHooks.test.js +46 -12
- package/dist/components/ConfigureWorktree.js +18 -4
- package/dist/components/ConfigureWorktreeHooks.js +19 -4
- package/dist/components/ConfigureWorktreeHooks.test.js +49 -14
- package/dist/components/Menu.js +72 -14
- package/dist/components/Menu.recent-projects.test.js +2 -0
- package/dist/components/Menu.test.js +2 -0
- package/dist/components/NewWorktree.js +2 -2
- package/dist/components/NewWorktree.test.js +6 -6
- package/dist/components/PresetSelector.js +2 -2
- package/dist/constants/statusIcons.d.ts +4 -1
- package/dist/constants/statusIcons.js +10 -1
- package/dist/constants/statusIcons.test.js +42 -0
- package/dist/contexts/ConfigEditorContext.d.ts +21 -0
- package/dist/contexts/ConfigEditorContext.js +25 -0
- package/dist/services/autoApprovalVerifier.js +3 -3
- package/dist/services/autoApprovalVerifier.test.js +2 -2
- package/dist/services/config/configEditor.d.ts +46 -0
- package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
- package/dist/services/config/configEditor.js +101 -0
- package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
- package/dist/services/config/configEditor.test.d.ts +1 -0
- package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
- package/dist/services/config/configReader.d.ts +28 -0
- package/dist/services/config/configReader.js +95 -0
- package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
- package/dist/services/config/configReader.multiProject.test.js +136 -0
- package/dist/services/config/globalConfigManager.d.ts +30 -0
- package/dist/services/config/globalConfigManager.js +216 -0
- package/dist/services/config/index.d.ts +13 -0
- package/dist/services/config/index.js +13 -0
- package/dist/services/config/projectConfigManager.d.ts +41 -0
- package/dist/services/config/projectConfigManager.js +181 -0
- package/dist/services/config/projectConfigManager.test.d.ts +1 -0
- package/dist/services/config/projectConfigManager.test.js +105 -0
- package/dist/services/config/testUtils.d.ts +81 -0
- package/dist/services/config/testUtils.js +351 -0
- package/dist/services/sessionManager.autoApproval.test.js +9 -6
- package/dist/services/sessionManager.d.ts +2 -0
- package/dist/services/sessionManager.effect.test.js +27 -18
- package/dist/services/sessionManager.js +43 -40
- package/dist/services/sessionManager.statePersistence.test.js +5 -4
- package/dist/services/sessionManager.test.js +71 -49
- package/dist/services/shortcutManager.d.ts +0 -1
- package/dist/services/shortcutManager.js +5 -16
- package/dist/services/shortcutManager.test.js +2 -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/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +24 -4
- package/dist/services/worktreeService.sort.test.js +105 -109
- package/dist/services/worktreeService.test.js +5 -5
- package/dist/types/index.d.ts +47 -7
- package/dist/utils/gitUtils.d.ts +8 -0
- package/dist/utils/gitUtils.js +32 -0
- package/dist/utils/hookExecutor.js +2 -2
- package/dist/utils/hookExecutor.test.js +13 -12
- 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 -1
- package/package.json +7 -7
- package/dist/services/configurationManager.d.ts +0 -121
- package/dist/services/configurationManager.js +0 -597
- /package/dist/{services/configurationManager.effect.test.d.ts → constants/statusIcons.test.d.ts} +0 -0
- /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
- /package/dist/services/{configurationManager.test.d.ts → config/configEditor.selectPresetOnStart.test.d.ts} +0 -0
|
@@ -3,15 +3,17 @@ import { EventEmitter } from 'events';
|
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
4
|
import { exec } from 'child_process';
|
|
5
5
|
import { promisify } from 'util';
|
|
6
|
-
import {
|
|
6
|
+
import { configReader } from './config/configReader.js';
|
|
7
|
+
import { setWorktreeLastOpened } from './worktreeService.js';
|
|
7
8
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
9
|
import { createStateDetector } from './stateDetector/index.js';
|
|
9
10
|
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
10
|
-
import { Effect } from 'effect';
|
|
11
|
+
import { Effect, Either } from 'effect';
|
|
11
12
|
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
12
13
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
13
14
|
import { logger } from '../utils/logger.js';
|
|
14
15
|
import { Mutex, createInitialSessionStateData } from '../utils/mutex.js';
|
|
16
|
+
import { STATUS_TAGS } from '../constants/statusIcons.js';
|
|
15
17
|
import { getTerminalScreenContent } from '../utils/screenCapture.js';
|
|
16
18
|
const { Terminal } = pkg;
|
|
17
19
|
const execAsync = promisify(exec);
|
|
@@ -28,19 +30,19 @@ export class SessionManager extends EventEmitter {
|
|
|
28
30
|
return spawn(command, args, spawnOptions);
|
|
29
31
|
}
|
|
30
32
|
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
33
|
const stateData = session.stateMutex.getSnapshot();
|
|
35
|
-
const detectedState =
|
|
34
|
+
const detectedState = session.stateDetector.detectState(session.terminal, stateData.state);
|
|
36
35
|
// If auto-approval is enabled and state is waiting_input, convert to pending_auto_approval
|
|
37
36
|
if (detectedState === 'waiting_input' &&
|
|
38
|
-
|
|
37
|
+
configReader.isAutoApprovalEnabled() &&
|
|
39
38
|
!stateData.autoApprovalFailed) {
|
|
40
39
|
return 'pending_auto_approval';
|
|
41
40
|
}
|
|
42
41
|
return detectedState;
|
|
43
42
|
}
|
|
43
|
+
detectBackgroundTask(session) {
|
|
44
|
+
return session.stateDetector.detectBackgroundTask(session.terminal);
|
|
45
|
+
}
|
|
44
46
|
getTerminalContent(session) {
|
|
45
47
|
// Use the new screen capture utility that correctly handles
|
|
46
48
|
// both normal and alternate screen buffers
|
|
@@ -187,9 +189,11 @@ export class SessionManager extends EventEmitter {
|
|
|
187
189
|
logLevel: 'off',
|
|
188
190
|
});
|
|
189
191
|
}
|
|
190
|
-
async createSessionInternal(worktreePath, ptyProcess,
|
|
192
|
+
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
191
193
|
const id = this.createSessionId();
|
|
192
194
|
const terminal = this.createTerminal();
|
|
195
|
+
const detectionStrategy = options.detectionStrategy ?? 'claude';
|
|
196
|
+
const stateDetector = createStateDetector(detectionStrategy);
|
|
193
197
|
const session = {
|
|
194
198
|
id,
|
|
195
199
|
worktreePath,
|
|
@@ -201,16 +205,16 @@ export class SessionManager extends EventEmitter {
|
|
|
201
205
|
terminal,
|
|
202
206
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
203
207
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
204
|
-
|
|
205
|
-
detectionStrategy: options.detectionStrategy ?? 'claude',
|
|
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);
|
|
211
215
|
this.sessions.set(worktreePath, session);
|
|
212
216
|
// Record the timestamp when this worktree was opened
|
|
213
|
-
|
|
217
|
+
setWorktreeLastOpened(worktreePath, Date.now());
|
|
214
218
|
this.emit('sessionCreated', session);
|
|
215
219
|
return session;
|
|
216
220
|
}
|
|
@@ -240,12 +244,12 @@ export class SessionManager extends EventEmitter {
|
|
|
240
244
|
if (existing) {
|
|
241
245
|
return existing;
|
|
242
246
|
}
|
|
243
|
-
// Get preset configuration
|
|
247
|
+
// Get preset configuration using Either-based lookup
|
|
244
248
|
let preset = presetId
|
|
245
|
-
?
|
|
249
|
+
? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
|
|
246
250
|
: null;
|
|
247
251
|
if (!preset) {
|
|
248
|
-
preset =
|
|
252
|
+
preset = configReader.getDefaultPreset();
|
|
249
253
|
}
|
|
250
254
|
// Validate preset exists
|
|
251
255
|
if (!preset) {
|
|
@@ -259,14 +263,9 @@ export class SessionManager extends EventEmitter {
|
|
|
259
263
|
}
|
|
260
264
|
const command = preset.command;
|
|
261
265
|
const args = preset.args || [];
|
|
262
|
-
const commandConfig = {
|
|
263
|
-
command: preset.command,
|
|
264
|
-
args: preset.args,
|
|
265
|
-
fallbackArgs: preset.fallbackArgs,
|
|
266
|
-
};
|
|
267
266
|
// Spawn the process - fallback will be handled by setupExitHandler
|
|
268
267
|
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
269
|
-
return this.createSessionInternal(worktreePath, ptyProcess,
|
|
268
|
+
return this.createSessionInternal(worktreePath, ptyProcess, {
|
|
270
269
|
isPrimaryCommand: true,
|
|
271
270
|
detectionStrategy: preset.detectionStrategy,
|
|
272
271
|
});
|
|
@@ -325,8 +324,6 @@ export class SessionManager extends EventEmitter {
|
|
|
325
324
|
if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
|
|
326
325
|
try {
|
|
327
326
|
let fallbackProcess;
|
|
328
|
-
// Use fallback args if available, otherwise use empty args
|
|
329
|
-
const fallbackArgs = session.commandConfig?.fallbackArgs || [];
|
|
330
327
|
// Check if we're in a devcontainer session
|
|
331
328
|
if (session.devcontainerConfig) {
|
|
332
329
|
// Parse the exec command to extract arguments
|
|
@@ -334,17 +331,12 @@ export class SessionManager extends EventEmitter {
|
|
|
334
331
|
const devcontainerCmd = execParts[0] || 'devcontainer';
|
|
335
332
|
const execArgs = execParts.slice(1);
|
|
336
333
|
// Build fallback command for devcontainer
|
|
337
|
-
const fallbackFullArgs = [
|
|
338
|
-
...execArgs,
|
|
339
|
-
'--',
|
|
340
|
-
session.commandConfig?.command || 'claude',
|
|
341
|
-
...fallbackArgs,
|
|
342
|
-
];
|
|
334
|
+
const fallbackFullArgs = [...execArgs, '--', 'claude'];
|
|
343
335
|
fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
|
|
344
336
|
}
|
|
345
337
|
else {
|
|
346
338
|
// Regular fallback without devcontainer
|
|
347
|
-
fallbackProcess = await this.spawn(
|
|
339
|
+
fallbackProcess = await this.spawn('claude', [], session.worktreePath);
|
|
348
340
|
}
|
|
349
341
|
// Replace the process
|
|
350
342
|
session.process = fallbackProcess;
|
|
@@ -426,6 +418,14 @@ export class SessionManager extends EventEmitter {
|
|
|
426
418
|
!currentStateData.autoApprovalAbortController) {
|
|
427
419
|
this.handleAutoApproval(session);
|
|
428
420
|
}
|
|
421
|
+
// Detect and update background task flag
|
|
422
|
+
const hasBackgroundTask = this.detectBackgroundTask(session);
|
|
423
|
+
if (currentStateData.hasBackgroundTask !== hasBackgroundTask) {
|
|
424
|
+
void session.stateMutex.update(data => ({
|
|
425
|
+
...data,
|
|
426
|
+
hasBackgroundTask,
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
429
|
}, STATE_CHECK_INTERVAL_MS);
|
|
430
430
|
// Setup exit handler
|
|
431
431
|
this.setupExitHandler(session);
|
|
@@ -454,7 +454,7 @@ export class SessionManager extends EventEmitter {
|
|
|
454
454
|
session.isActive = active;
|
|
455
455
|
// If becoming active, record the timestamp when this worktree was opened
|
|
456
456
|
if (active) {
|
|
457
|
-
|
|
457
|
+
setWorktreeLastOpened(worktreePath, Date.now());
|
|
458
458
|
// Emit a restore event with the output history if available
|
|
459
459
|
if (session.outputHistory.length > 0) {
|
|
460
460
|
this.emit('sessionRestore', session);
|
|
@@ -608,12 +608,12 @@ export class SessionManager extends EventEmitter {
|
|
|
608
608
|
message: `Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`,
|
|
609
609
|
});
|
|
610
610
|
}
|
|
611
|
-
// Get preset configuration
|
|
611
|
+
// Get preset configuration using Either-based lookup
|
|
612
612
|
let preset = presetId
|
|
613
|
-
?
|
|
613
|
+
? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
|
|
614
614
|
: null;
|
|
615
615
|
if (!preset) {
|
|
616
|
-
preset =
|
|
616
|
+
preset = configReader.getDefaultPreset();
|
|
617
617
|
}
|
|
618
618
|
// Validate preset exists
|
|
619
619
|
if (!preset) {
|
|
@@ -638,12 +638,7 @@ export class SessionManager extends EventEmitter {
|
|
|
638
638
|
];
|
|
639
639
|
// Spawn the process within devcontainer
|
|
640
640
|
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
641
|
-
|
|
642
|
-
command: preset.command,
|
|
643
|
-
args: preset.args,
|
|
644
|
-
fallbackArgs: preset.fallbackArgs,
|
|
645
|
-
};
|
|
646
|
-
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
641
|
+
return this.createSessionInternal(worktreePath, ptyProcess, {
|
|
647
642
|
isPrimaryCommand: true,
|
|
648
643
|
detectionStrategy: preset.detectionStrategy,
|
|
649
644
|
devcontainerConfig,
|
|
@@ -677,6 +672,7 @@ export class SessionManager extends EventEmitter {
|
|
|
677
672
|
waiting_input: 0,
|
|
678
673
|
pending_auto_approval: 0,
|
|
679
674
|
total: sessions.length,
|
|
675
|
+
backgroundTasks: 0,
|
|
680
676
|
};
|
|
681
677
|
sessions.forEach(session => {
|
|
682
678
|
const stateData = session.stateMutex.getSnapshot();
|
|
@@ -694,6 +690,9 @@ export class SessionManager extends EventEmitter {
|
|
|
694
690
|
counts.pending_auto_approval++;
|
|
695
691
|
break;
|
|
696
692
|
}
|
|
693
|
+
if (stateData.hasBackgroundTask) {
|
|
694
|
+
counts.backgroundTasks++;
|
|
695
|
+
}
|
|
697
696
|
});
|
|
698
697
|
return counts;
|
|
699
698
|
}
|
|
@@ -711,6 +710,10 @@ export class SessionManager extends EventEmitter {
|
|
|
711
710
|
if (counts.waiting_input > 0) {
|
|
712
711
|
parts.push(`${counts.waiting_input} Waiting`);
|
|
713
712
|
}
|
|
714
|
-
|
|
713
|
+
if (parts.length === 0) {
|
|
714
|
+
return '';
|
|
715
|
+
}
|
|
716
|
+
const bgTag = counts.backgroundTasks > 0 ? ` ${STATUS_TAGS.BACKGROUND_TASK}` : '';
|
|
717
|
+
return ` (${parts.join(' / ')}${bgTag})`;
|
|
715
718
|
}
|
|
716
719
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { Either } from 'effect';
|
|
2
3
|
import { SessionManager } from './sessionManager.js';
|
|
3
4
|
import { spawn } from './bunTerminal.js';
|
|
4
5
|
import { EventEmitter } from 'events';
|
|
@@ -8,8 +9,8 @@ vi.mock('./bunTerminal.js', () => ({
|
|
|
8
9
|
return null;
|
|
9
10
|
}),
|
|
10
11
|
}));
|
|
11
|
-
vi.mock('./
|
|
12
|
-
|
|
12
|
+
vi.mock('./config/configReader.js', () => ({
|
|
13
|
+
configReader: {
|
|
13
14
|
getConfig: vi.fn().mockReturnValue({
|
|
14
15
|
commands: [
|
|
15
16
|
{
|
|
@@ -21,12 +22,12 @@ vi.mock('./configurationManager.js', () => ({
|
|
|
21
22
|
],
|
|
22
23
|
defaultCommandId: 'test',
|
|
23
24
|
}),
|
|
24
|
-
|
|
25
|
+
getPresetByIdEffect: vi.fn().mockReturnValue(Either.right({
|
|
25
26
|
id: 'test',
|
|
26
27
|
name: 'Test',
|
|
27
28
|
command: 'test',
|
|
28
29
|
args: [],
|
|
29
|
-
}),
|
|
30
|
+
})),
|
|
30
31
|
getDefaultPreset: vi.fn().mockReturnValue({
|
|
31
32
|
id: 'test',
|
|
32
33
|
name: 'Test',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import { Effect } from 'effect';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { ValidationError } from '../types/errors.js';
|
|
3
4
|
import { spawn } from './bunTerminal.js';
|
|
4
5
|
import { EventEmitter } from 'events';
|
|
5
6
|
import { exec } from 'child_process';
|
|
@@ -19,12 +20,11 @@ vi.mock('child_process', () => ({
|
|
|
19
20
|
}),
|
|
20
21
|
}));
|
|
21
22
|
// Mock configuration manager
|
|
22
|
-
vi.mock('./
|
|
23
|
-
|
|
24
|
-
getCommandConfig: vi.fn(),
|
|
23
|
+
vi.mock('./config/configReader.js', () => ({
|
|
24
|
+
configReader: {
|
|
25
25
|
getStatusHooks: vi.fn(() => ({})),
|
|
26
26
|
getDefaultPreset: vi.fn(),
|
|
27
|
-
|
|
27
|
+
getPresetByIdEffect: vi.fn(),
|
|
28
28
|
setWorktreeLastOpened: vi.fn(),
|
|
29
29
|
getWorktreeLastOpenedTime: vi.fn(),
|
|
30
30
|
getWorktreeLastOpened: vi.fn(() => ({})),
|
|
@@ -57,6 +57,9 @@ vi.mock('./worktreeService.js', () => ({
|
|
|
57
57
|
WorktreeService: vi.fn(function () {
|
|
58
58
|
return {};
|
|
59
59
|
}),
|
|
60
|
+
setWorktreeLastOpened: vi.fn(),
|
|
61
|
+
getWorktreeLastOpened: vi.fn(() => ({})),
|
|
62
|
+
getWorktreeLastOpenedTime: vi.fn(),
|
|
60
63
|
}));
|
|
61
64
|
// Create a mock IPty class
|
|
62
65
|
class MockPty extends EventEmitter {
|
|
@@ -102,14 +105,14 @@ describe('SessionManager', () => {
|
|
|
102
105
|
let sessionManager;
|
|
103
106
|
let mockPty;
|
|
104
107
|
let SessionManager;
|
|
105
|
-
let
|
|
108
|
+
let configReader;
|
|
106
109
|
beforeEach(async () => {
|
|
107
110
|
vi.clearAllMocks();
|
|
108
111
|
// Dynamically import after mocks are set up
|
|
109
112
|
const sessionManagerModule = await import('./sessionManager.js');
|
|
110
|
-
const configManagerModule = await import('./
|
|
113
|
+
const configManagerModule = await import('./config/configReader.js');
|
|
111
114
|
SessionManager = sessionManagerModule.SessionManager;
|
|
112
|
-
|
|
115
|
+
configReader = configManagerModule.configReader;
|
|
113
116
|
sessionManager = new SessionManager();
|
|
114
117
|
mockPty = new MockPty();
|
|
115
118
|
});
|
|
@@ -119,7 +122,7 @@ describe('SessionManager', () => {
|
|
|
119
122
|
describe('createSessionWithPresetEffect', () => {
|
|
120
123
|
it('should use default preset when no preset ID specified', async () => {
|
|
121
124
|
// Setup mock preset
|
|
122
|
-
vi.mocked(
|
|
125
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
123
126
|
id: '1',
|
|
124
127
|
name: 'Main',
|
|
125
128
|
command: 'claude',
|
|
@@ -140,19 +143,19 @@ describe('SessionManager', () => {
|
|
|
140
143
|
});
|
|
141
144
|
it('should use specific preset when ID provided', async () => {
|
|
142
145
|
// Setup mock preset
|
|
143
|
-
vi.mocked(
|
|
146
|
+
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.right({
|
|
144
147
|
id: '2',
|
|
145
148
|
name: 'Development',
|
|
146
149
|
command: 'claude',
|
|
147
150
|
args: ['--resume', '--dev'],
|
|
148
151
|
fallbackArgs: ['--no-mcp'],
|
|
149
|
-
});
|
|
152
|
+
}));
|
|
150
153
|
// Setup spawn mock
|
|
151
154
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
152
155
|
// Create session with specific preset
|
|
153
156
|
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', '2'));
|
|
154
|
-
// Verify
|
|
155
|
-
expect(
|
|
157
|
+
// Verify getPresetByIdEffect was called with correct ID
|
|
158
|
+
expect(configReader.getPresetByIdEffect).toHaveBeenCalledWith('2');
|
|
156
159
|
// Verify spawn was called with preset config
|
|
157
160
|
expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--dev'], {
|
|
158
161
|
name: 'xterm-256color',
|
|
@@ -164,8 +167,12 @@ describe('SessionManager', () => {
|
|
|
164
167
|
});
|
|
165
168
|
it('should fall back to default preset if specified preset not found', async () => {
|
|
166
169
|
// Setup mocks
|
|
167
|
-
vi.mocked(
|
|
168
|
-
|
|
170
|
+
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
|
|
171
|
+
field: 'presetId',
|
|
172
|
+
constraint: 'Preset not found',
|
|
173
|
+
receivedValue: 'invalid',
|
|
174
|
+
})));
|
|
175
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
169
176
|
id: '1',
|
|
170
177
|
name: 'Main',
|
|
171
178
|
command: 'claude',
|
|
@@ -175,12 +182,12 @@ describe('SessionManager', () => {
|
|
|
175
182
|
// Create session with non-existent preset
|
|
176
183
|
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid'));
|
|
177
184
|
// Verify fallback to default preset
|
|
178
|
-
expect(
|
|
185
|
+
expect(configReader.getDefaultPreset).toHaveBeenCalled();
|
|
179
186
|
expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
|
|
180
187
|
});
|
|
181
188
|
it('should throw error when spawn fails with preset', async () => {
|
|
182
189
|
// Setup mock preset with fallback
|
|
183
|
-
vi.mocked(
|
|
190
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
184
191
|
id: '1',
|
|
185
192
|
name: 'Main',
|
|
186
193
|
command: 'claude',
|
|
@@ -199,7 +206,7 @@ describe('SessionManager', () => {
|
|
|
199
206
|
});
|
|
200
207
|
it('should return existing session if already created', async () => {
|
|
201
208
|
// Setup mock preset
|
|
202
|
-
vi.mocked(
|
|
209
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
203
210
|
id: '1',
|
|
204
211
|
name: 'Main',
|
|
205
212
|
command: 'claude',
|
|
@@ -216,7 +223,7 @@ describe('SessionManager', () => {
|
|
|
216
223
|
});
|
|
217
224
|
it('should throw error when spawn fails with fallback args', async () => {
|
|
218
225
|
// Setup mock preset with fallback
|
|
219
|
-
vi.mocked(
|
|
226
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
220
227
|
id: '1',
|
|
221
228
|
name: 'Main',
|
|
222
229
|
command: 'nonexistent-command',
|
|
@@ -230,14 +237,13 @@ describe('SessionManager', () => {
|
|
|
230
237
|
// Expect createSessionWithPresetEffect to throw the original error
|
|
231
238
|
await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command not found');
|
|
232
239
|
});
|
|
233
|
-
it('should
|
|
234
|
-
// Setup mock preset with
|
|
235
|
-
vi.mocked(
|
|
240
|
+
it('should fallback to default command when main command exits with code 1', async () => {
|
|
241
|
+
// Setup mock preset with args
|
|
242
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
236
243
|
id: '1',
|
|
237
244
|
name: 'Main',
|
|
238
245
|
command: 'claude',
|
|
239
246
|
args: ['--invalid-flag'],
|
|
240
|
-
fallbackArgs: ['--resume'],
|
|
241
247
|
});
|
|
242
248
|
// First spawn attempt - will exit with code 1
|
|
243
249
|
const firstMockPty = new MockPty();
|
|
@@ -255,16 +261,16 @@ describe('SessionManager', () => {
|
|
|
255
261
|
firstMockPty.emit('exit', { exitCode: 1 });
|
|
256
262
|
// Wait for fallback to occur
|
|
257
263
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
258
|
-
// Verify fallback spawn was called
|
|
264
|
+
// Verify fallback spawn was called (with no args since commandConfig was removed)
|
|
259
265
|
expect(spawn).toHaveBeenCalledTimes(2);
|
|
260
|
-
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', [
|
|
266
|
+
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', [], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
261
267
|
// Verify session process was replaced
|
|
262
268
|
expect(session.process).toBe(secondMockPty);
|
|
263
269
|
expect(session.isPrimaryCommand).toBe(false);
|
|
264
270
|
});
|
|
265
271
|
it('should not use fallback if main command succeeds', async () => {
|
|
266
272
|
// Setup mock preset with fallback
|
|
267
|
-
vi.mocked(
|
|
273
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
268
274
|
id: '1',
|
|
269
275
|
name: 'Main',
|
|
270
276
|
command: 'claude',
|
|
@@ -283,7 +289,7 @@ describe('SessionManager', () => {
|
|
|
283
289
|
});
|
|
284
290
|
it('should use empty args as fallback when no fallback args specified', async () => {
|
|
285
291
|
// Setup mock preset without fallback args
|
|
286
|
-
vi.mocked(
|
|
292
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
287
293
|
id: '1',
|
|
288
294
|
name: 'Main',
|
|
289
295
|
command: 'claude',
|
|
@@ -316,7 +322,7 @@ describe('SessionManager', () => {
|
|
|
316
322
|
});
|
|
317
323
|
it('should handle custom command configuration', async () => {
|
|
318
324
|
// Setup mock preset with custom command
|
|
319
|
-
vi.mocked(
|
|
325
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
320
326
|
id: '1',
|
|
321
327
|
name: 'Main',
|
|
322
328
|
command: 'my-custom-claude',
|
|
@@ -333,7 +339,7 @@ describe('SessionManager', () => {
|
|
|
333
339
|
});
|
|
334
340
|
it('should throw error when spawn fails and no fallback configured', async () => {
|
|
335
341
|
// Setup mock preset without fallback
|
|
336
|
-
vi.mocked(
|
|
342
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
337
343
|
id: '1',
|
|
338
344
|
name: 'Main',
|
|
339
345
|
command: 'claude',
|
|
@@ -350,7 +356,7 @@ describe('SessionManager', () => {
|
|
|
350
356
|
describe('session lifecycle', () => {
|
|
351
357
|
it('should destroy session and clean up resources', async () => {
|
|
352
358
|
// Setup
|
|
353
|
-
vi.mocked(
|
|
359
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
354
360
|
id: '1',
|
|
355
361
|
name: 'Main',
|
|
356
362
|
command: 'claude',
|
|
@@ -365,7 +371,7 @@ describe('SessionManager', () => {
|
|
|
365
371
|
});
|
|
366
372
|
it('should handle session exit event', async () => {
|
|
367
373
|
// Setup
|
|
368
|
-
vi.mocked(
|
|
374
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
369
375
|
id: '1',
|
|
370
376
|
name: 'Main',
|
|
371
377
|
command: 'claude',
|
|
@@ -411,7 +417,7 @@ describe('SessionManager', () => {
|
|
|
411
417
|
});
|
|
412
418
|
it('should execute devcontainer up command before creating session', async () => {
|
|
413
419
|
// Setup mock preset
|
|
414
|
-
vi.mocked(
|
|
420
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
415
421
|
id: '1',
|
|
416
422
|
name: 'Main',
|
|
417
423
|
command: 'claude',
|
|
@@ -431,12 +437,12 @@ describe('SessionManager', () => {
|
|
|
431
437
|
});
|
|
432
438
|
it('should use specific preset when ID provided', async () => {
|
|
433
439
|
// Setup mock preset
|
|
434
|
-
vi.mocked(
|
|
440
|
+
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.right({
|
|
435
441
|
id: '2',
|
|
436
442
|
name: 'Development',
|
|
437
443
|
command: 'claude',
|
|
438
444
|
args: ['--resume', '--dev'],
|
|
439
|
-
});
|
|
445
|
+
}));
|
|
440
446
|
// Setup spawn mock
|
|
441
447
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
442
448
|
// Create session with devcontainer and specific preset
|
|
@@ -446,7 +452,7 @@ describe('SessionManager', () => {
|
|
|
446
452
|
};
|
|
447
453
|
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig, '2'));
|
|
448
454
|
// Verify correct preset was used
|
|
449
|
-
expect(
|
|
455
|
+
expect(configReader.getPresetByIdEffect).toHaveBeenCalledWith('2');
|
|
450
456
|
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--', 'claude', '--resume', '--dev'], expect.any(Object));
|
|
451
457
|
});
|
|
452
458
|
it('should throw error when devcontainer up fails', async () => {
|
|
@@ -462,7 +468,7 @@ describe('SessionManager', () => {
|
|
|
462
468
|
});
|
|
463
469
|
it('should return existing session if already created', async () => {
|
|
464
470
|
// Setup mock preset
|
|
465
|
-
vi.mocked(
|
|
471
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
466
472
|
id: '1',
|
|
467
473
|
name: 'Main',
|
|
468
474
|
command: 'claude',
|
|
@@ -483,7 +489,7 @@ describe('SessionManager', () => {
|
|
|
483
489
|
});
|
|
484
490
|
it('should handle complex exec commands with multiple arguments', async () => {
|
|
485
491
|
// Setup mock preset
|
|
486
|
-
vi.mocked(
|
|
492
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
487
493
|
id: '1',
|
|
488
494
|
name: 'Main',
|
|
489
495
|
command: 'claude',
|
|
@@ -514,7 +520,7 @@ describe('SessionManager', () => {
|
|
|
514
520
|
// Create a new session manager and reset mocks
|
|
515
521
|
vi.clearAllMocks();
|
|
516
522
|
sessionManager = new SessionManager();
|
|
517
|
-
vi.mocked(
|
|
523
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
518
524
|
id: '1',
|
|
519
525
|
name: 'Main',
|
|
520
526
|
command: 'claude',
|
|
@@ -605,12 +611,12 @@ describe('SessionManager', () => {
|
|
|
605
611
|
}
|
|
606
612
|
return {};
|
|
607
613
|
});
|
|
608
|
-
vi.mocked(
|
|
614
|
+
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.right({
|
|
609
615
|
id: 'claude-with-args',
|
|
610
616
|
name: 'Claude with Args',
|
|
611
617
|
command: 'claude',
|
|
612
618
|
args: ['-m', 'claude-3-opus'],
|
|
613
|
-
});
|
|
619
|
+
}));
|
|
614
620
|
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
|
|
615
621
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
616
622
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
@@ -638,7 +644,7 @@ describe('SessionManager', () => {
|
|
|
638
644
|
return {};
|
|
639
645
|
});
|
|
640
646
|
// Setup preset without fallback args
|
|
641
|
-
vi.mocked(
|
|
647
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
642
648
|
id: '1',
|
|
643
649
|
name: 'Main',
|
|
644
650
|
command: 'claude',
|
|
@@ -671,7 +677,7 @@ describe('SessionManager', () => {
|
|
|
671
677
|
expect(session.process).toBe(secondMockPty);
|
|
672
678
|
expect(session.isPrimaryCommand).toBe(false);
|
|
673
679
|
});
|
|
674
|
-
it('should
|
|
680
|
+
it('should fallback to default command in devcontainer when primary command exits with code 1', async () => {
|
|
675
681
|
const mockExec = vi.mocked(exec);
|
|
676
682
|
mockExec.mockImplementation((cmd, options, callback) => {
|
|
677
683
|
if (typeof options === 'function') {
|
|
@@ -683,13 +689,12 @@ describe('SessionManager', () => {
|
|
|
683
689
|
}
|
|
684
690
|
return {};
|
|
685
691
|
});
|
|
686
|
-
// Setup preset with
|
|
687
|
-
vi.mocked(
|
|
692
|
+
// Setup preset with args
|
|
693
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
688
694
|
id: '1',
|
|
689
695
|
name: 'Main',
|
|
690
696
|
command: 'claude',
|
|
691
697
|
args: ['--bad-flag'],
|
|
692
|
-
fallbackArgs: ['--good-flag'],
|
|
693
698
|
});
|
|
694
699
|
// First spawn attempt - will exit with code 1
|
|
695
700
|
const firstMockPty = new MockPty();
|
|
@@ -709,9 +714,9 @@ describe('SessionManager', () => {
|
|
|
709
714
|
firstMockPty.emit('exit', { exitCode: 1 });
|
|
710
715
|
// Wait for fallback to occur
|
|
711
716
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
712
|
-
// Verify fallback spawn was called
|
|
717
|
+
// Verify fallback spawn was called (with no args since commandConfig was removed)
|
|
713
718
|
expect(spawn).toHaveBeenCalledTimes(2);
|
|
714
|
-
expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'
|
|
719
|
+
expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
715
720
|
// Verify session process was replaced
|
|
716
721
|
expect(session.process).toBe(secondMockPty);
|
|
717
722
|
expect(session.isPrimaryCommand).toBe(false);
|
|
@@ -720,10 +725,10 @@ describe('SessionManager', () => {
|
|
|
720
725
|
describe('static methods', () => {
|
|
721
726
|
describe('getSessionCounts', () => {
|
|
722
727
|
// Helper to create mock session with stateMutex
|
|
723
|
-
const createMockSession = (id, state) => ({
|
|
728
|
+
const createMockSession = (id, state, hasBackgroundTask = false) => ({
|
|
724
729
|
id,
|
|
725
730
|
stateMutex: {
|
|
726
|
-
getSnapshot: () => ({ state }),
|
|
731
|
+
getSnapshot: () => ({ state, hasBackgroundTask }),
|
|
727
732
|
},
|
|
728
733
|
});
|
|
729
734
|
it('should count sessions by state', () => {
|
|
@@ -768,6 +773,7 @@ describe('SessionManager', () => {
|
|
|
768
773
|
waiting_input: 1,
|
|
769
774
|
pending_auto_approval: 0,
|
|
770
775
|
total: 4,
|
|
776
|
+
backgroundTasks: 0,
|
|
771
777
|
};
|
|
772
778
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
773
779
|
expect(formatted).toBe(' (1 Idle / 2 Busy / 1 Waiting)');
|
|
@@ -779,6 +785,7 @@ describe('SessionManager', () => {
|
|
|
779
785
|
waiting_input: 1,
|
|
780
786
|
pending_auto_approval: 0,
|
|
781
787
|
total: 3,
|
|
788
|
+
backgroundTasks: 0,
|
|
782
789
|
};
|
|
783
790
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
784
791
|
expect(formatted).toBe(' (2 Idle / 1 Waiting)');
|
|
@@ -790,6 +797,7 @@ describe('SessionManager', () => {
|
|
|
790
797
|
waiting_input: 0,
|
|
791
798
|
pending_auto_approval: 0,
|
|
792
799
|
total: 3,
|
|
800
|
+
backgroundTasks: 0,
|
|
793
801
|
};
|
|
794
802
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
795
803
|
expect(formatted).toBe(' (3 Busy)');
|
|
@@ -801,10 +809,24 @@ describe('SessionManager', () => {
|
|
|
801
809
|
waiting_input: 0,
|
|
802
810
|
pending_auto_approval: 0,
|
|
803
811
|
total: 0,
|
|
812
|
+
backgroundTasks: 0,
|
|
804
813
|
};
|
|
805
814
|
const formatted = SessionManager.formatSessionCounts(counts);
|
|
806
815
|
expect(formatted).toBe('');
|
|
807
816
|
});
|
|
817
|
+
it('should append [BG] tag when background tasks exist', () => {
|
|
818
|
+
const counts = {
|
|
819
|
+
idle: 1,
|
|
820
|
+
busy: 1,
|
|
821
|
+
waiting_input: 0,
|
|
822
|
+
pending_auto_approval: 0,
|
|
823
|
+
total: 2,
|
|
824
|
+
backgroundTasks: 1,
|
|
825
|
+
};
|
|
826
|
+
const formatted = SessionManager.formatSessionCounts(counts);
|
|
827
|
+
expect(formatted).toContain('[BG]');
|
|
828
|
+
expect(formatted).toBe(' (1 Idle / 1 Busy \x1b[2m[BG]\x1b[0m)');
|
|
829
|
+
});
|
|
808
830
|
});
|
|
809
831
|
});
|
|
810
832
|
});
|
|
@@ -5,7 +5,6 @@ export declare class ShortcutManager {
|
|
|
5
5
|
constructor();
|
|
6
6
|
private validateShortcut;
|
|
7
7
|
private isReservedKey;
|
|
8
|
-
saveShortcuts(shortcuts: ShortcutConfig): boolean;
|
|
9
8
|
getShortcuts(): ShortcutConfig;
|
|
10
9
|
private getRawShortcutCodes;
|
|
11
10
|
matchesShortcut(shortcutName: keyof ShortcutConfig, input: string, key: Key): boolean;
|