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.
Files changed (92) hide show
  1. package/README.md +11 -5
  2. package/dist/components/App.js +17 -3
  3. package/dist/components/App.test.js +5 -5
  4. package/dist/components/Configuration.d.ts +2 -0
  5. package/dist/components/Configuration.js +6 -2
  6. package/dist/components/ConfigureCommand.js +34 -11
  7. package/dist/components/ConfigureOther.js +18 -4
  8. package/dist/components/ConfigureOther.test.js +48 -12
  9. package/dist/components/ConfigureShortcuts.js +27 -85
  10. package/dist/components/ConfigureStatusHooks.js +19 -4
  11. package/dist/components/ConfigureStatusHooks.test.js +46 -12
  12. package/dist/components/ConfigureWorktree.js +18 -4
  13. package/dist/components/ConfigureWorktreeHooks.js +19 -4
  14. package/dist/components/ConfigureWorktreeHooks.test.js +49 -14
  15. package/dist/components/Menu.js +72 -14
  16. package/dist/components/Menu.recent-projects.test.js +2 -0
  17. package/dist/components/Menu.test.js +2 -0
  18. package/dist/components/NewWorktree.js +2 -2
  19. package/dist/components/NewWorktree.test.js +6 -6
  20. package/dist/components/PresetSelector.js +2 -2
  21. package/dist/constants/statusIcons.d.ts +4 -1
  22. package/dist/constants/statusIcons.js +10 -1
  23. package/dist/constants/statusIcons.test.js +42 -0
  24. package/dist/contexts/ConfigEditorContext.d.ts +21 -0
  25. package/dist/contexts/ConfigEditorContext.js +25 -0
  26. package/dist/services/autoApprovalVerifier.js +3 -3
  27. package/dist/services/autoApprovalVerifier.test.js +2 -2
  28. package/dist/services/config/configEditor.d.ts +46 -0
  29. package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
  30. package/dist/services/config/configEditor.js +101 -0
  31. package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
  32. package/dist/services/config/configEditor.test.d.ts +1 -0
  33. package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
  34. package/dist/services/config/configReader.d.ts +28 -0
  35. package/dist/services/config/configReader.js +95 -0
  36. package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
  37. package/dist/services/config/configReader.multiProject.test.js +136 -0
  38. package/dist/services/config/globalConfigManager.d.ts +30 -0
  39. package/dist/services/config/globalConfigManager.js +216 -0
  40. package/dist/services/config/index.d.ts +13 -0
  41. package/dist/services/config/index.js +13 -0
  42. package/dist/services/config/projectConfigManager.d.ts +41 -0
  43. package/dist/services/config/projectConfigManager.js +181 -0
  44. package/dist/services/config/projectConfigManager.test.d.ts +1 -0
  45. package/dist/services/config/projectConfigManager.test.js +105 -0
  46. package/dist/services/config/testUtils.d.ts +81 -0
  47. package/dist/services/config/testUtils.js +351 -0
  48. package/dist/services/sessionManager.autoApproval.test.js +9 -6
  49. package/dist/services/sessionManager.d.ts +2 -0
  50. package/dist/services/sessionManager.effect.test.js +27 -18
  51. package/dist/services/sessionManager.js +43 -40
  52. package/dist/services/sessionManager.statePersistence.test.js +5 -4
  53. package/dist/services/sessionManager.test.js +71 -49
  54. package/dist/services/shortcutManager.d.ts +0 -1
  55. package/dist/services/shortcutManager.js +5 -16
  56. package/dist/services/shortcutManager.test.js +2 -2
  57. package/dist/services/stateDetector/base.d.ts +1 -0
  58. package/dist/services/stateDetector/claude.d.ts +1 -0
  59. package/dist/services/stateDetector/claude.js +8 -0
  60. package/dist/services/stateDetector/claude.test.js +102 -0
  61. package/dist/services/stateDetector/cline.d.ts +1 -0
  62. package/dist/services/stateDetector/cline.js +3 -0
  63. package/dist/services/stateDetector/codex.d.ts +1 -0
  64. package/dist/services/stateDetector/codex.js +3 -0
  65. package/dist/services/stateDetector/cursor.d.ts +1 -0
  66. package/dist/services/stateDetector/cursor.js +3 -0
  67. package/dist/services/stateDetector/gemini.d.ts +1 -0
  68. package/dist/services/stateDetector/gemini.js +3 -0
  69. package/dist/services/stateDetector/github-copilot.d.ts +1 -0
  70. package/dist/services/stateDetector/github-copilot.js +3 -0
  71. package/dist/services/stateDetector/opencode.d.ts +1 -0
  72. package/dist/services/stateDetector/opencode.js +3 -0
  73. package/dist/services/stateDetector/types.d.ts +1 -0
  74. package/dist/services/worktreeService.d.ts +12 -0
  75. package/dist/services/worktreeService.js +24 -4
  76. package/dist/services/worktreeService.sort.test.js +105 -109
  77. package/dist/services/worktreeService.test.js +5 -5
  78. package/dist/types/index.d.ts +47 -7
  79. package/dist/utils/gitUtils.d.ts +8 -0
  80. package/dist/utils/gitUtils.js +32 -0
  81. package/dist/utils/hookExecutor.js +2 -2
  82. package/dist/utils/hookExecutor.test.js +13 -12
  83. package/dist/utils/mutex.d.ts +1 -0
  84. package/dist/utils/mutex.js +1 -0
  85. package/dist/utils/worktreeUtils.js +3 -2
  86. package/dist/utils/worktreeUtils.test.js +2 -1
  87. package/package.json +7 -7
  88. package/dist/services/configurationManager.d.ts +0 -121
  89. package/dist/services/configurationManager.js +0 -597
  90. /package/dist/{services/configurationManager.effect.test.d.ts → constants/statusIcons.test.d.ts} +0 -0
  91. /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
  92. /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 { configurationManager } from './configurationManager.js';
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 = detector.detectState(session.terminal, stateData.state);
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
- configurationManager.isAutoApprovalEnabled() &&
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, commandConfig, options = {}) {
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
- commandConfig,
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
- configurationManager.setWorktreeLastOpened(worktreePath, Date.now());
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
- ? configurationManager.getPresetById(presetId)
249
+ ? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
246
250
  : null;
247
251
  if (!preset) {
248
- preset = configurationManager.getDefaultPreset();
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, commandConfig, {
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(session.commandConfig?.command || 'claude', fallbackArgs, session.worktreePath);
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
- configurationManager.setWorktreeLastOpened(worktreePath, Date.now());
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
- ? configurationManager.getPresetById(presetId)
613
+ ? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
614
614
  : null;
615
615
  if (!preset) {
616
- preset = configurationManager.getDefaultPreset();
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
- const commandConfig = {
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
- return parts.length > 0 ? ` (${parts.join(' / ')})` : '';
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('./configurationManager.js', () => ({
12
- configurationManager: {
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
- getPresetById: vi.fn().mockReturnValue({
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('./configurationManager.js', () => ({
23
- configurationManager: {
24
- getCommandConfig: vi.fn(),
23
+ vi.mock('./config/configReader.js', () => ({
24
+ configReader: {
25
25
  getStatusHooks: vi.fn(() => ({})),
26
26
  getDefaultPreset: vi.fn(),
27
- getPresetById: vi.fn(),
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 configurationManager;
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('./configurationManager.js');
113
+ const configManagerModule = await import('./config/configReader.js');
111
114
  SessionManager = sessionManagerModule.SessionManager;
112
- configurationManager = configManagerModule.configurationManager;
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getPresetById).mockReturnValue({
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 getPresetById was called with correct ID
155
- expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
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(configurationManager.getPresetById).mockReturnValue(undefined);
168
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).toHaveBeenCalled();
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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 use fallback args when main command exits with code 1', async () => {
234
- // Setup mock preset with fallback
235
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
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', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getPresetById).mockReturnValue({
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(configurationManager.getPresetById).toHaveBeenCalledWith('2');
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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(configurationManager.getPresetById).mockReturnValue({
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(configurationManager.getDefaultPreset).mockReturnValue({
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 use fallback args in devcontainer when primary command exits with code 1', async () => {
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 fallback
687
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
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', '--good-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
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;