ccmanager 2.8.0 → 2.9.1

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 (82) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/components/Session.js +11 -6
  26. package/dist/hooks/useGitStatus.d.ts +11 -0
  27. package/dist/hooks/useGitStatus.js +70 -12
  28. package/dist/hooks/useGitStatus.test.js +30 -23
  29. package/dist/services/configurationManager.d.ts +75 -0
  30. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  31. package/dist/services/configurationManager.effect.test.js +407 -0
  32. package/dist/services/configurationManager.js +246 -0
  33. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  34. package/dist/services/projectManager.d.ts +98 -2
  35. package/dist/services/projectManager.js +228 -59
  36. package/dist/services/projectManager.test.js +242 -2
  37. package/dist/services/sessionManager.d.ts +44 -2
  38. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  39. package/dist/services/sessionManager.effect.test.js +321 -0
  40. package/dist/services/sessionManager.js +216 -65
  41. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  42. package/dist/services/sessionManager.test.js +40 -36
  43. package/dist/services/shortcutManager.d.ts +2 -0
  44. package/dist/services/shortcutManager.js +53 -0
  45. package/dist/services/shortcutManager.test.d.ts +1 -0
  46. package/dist/services/shortcutManager.test.js +30 -0
  47. package/dist/services/worktreeService.d.ts +356 -26
  48. package/dist/services/worktreeService.js +793 -353
  49. package/dist/services/worktreeService.test.js +294 -313
  50. package/dist/types/errors.d.ts +74 -0
  51. package/dist/types/errors.js +31 -0
  52. package/dist/types/errors.test.d.ts +1 -0
  53. package/dist/types/errors.test.js +201 -0
  54. package/dist/types/index.d.ts +5 -17
  55. package/dist/utils/claudeDir.d.ts +58 -6
  56. package/dist/utils/claudeDir.js +103 -8
  57. package/dist/utils/claudeDir.test.d.ts +1 -0
  58. package/dist/utils/claudeDir.test.js +108 -0
  59. package/dist/utils/concurrencyLimit.d.ts +5 -0
  60. package/dist/utils/concurrencyLimit.js +11 -0
  61. package/dist/utils/concurrencyLimit.test.js +40 -1
  62. package/dist/utils/gitStatus.d.ts +36 -8
  63. package/dist/utils/gitStatus.js +170 -88
  64. package/dist/utils/gitStatus.test.js +12 -9
  65. package/dist/utils/hookExecutor.d.ts +41 -6
  66. package/dist/utils/hookExecutor.js +75 -32
  67. package/dist/utils/hookExecutor.test.js +73 -20
  68. package/dist/utils/terminalCapabilities.d.ts +18 -0
  69. package/dist/utils/terminalCapabilities.js +81 -0
  70. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  71. package/dist/utils/terminalCapabilities.test.js +104 -0
  72. package/dist/utils/testHelpers.d.ts +106 -0
  73. package/dist/utils/testHelpers.js +153 -0
  74. package/dist/utils/testHelpers.test.d.ts +1 -0
  75. package/dist/utils/testHelpers.test.js +114 -0
  76. package/dist/utils/worktreeConfig.d.ts +77 -2
  77. package/dist/utils/worktreeConfig.js +156 -16
  78. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  79. package/dist/utils/worktreeConfig.test.js +39 -0
  80. package/package.json +4 -4
  81. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  82. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -3,7 +3,9 @@ import { SessionManager } from './sessionManager.js';
3
3
  import { spawn } from 'node-pty';
4
4
  import { EventEmitter } from 'events';
5
5
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
6
- vi.mock('node-pty');
6
+ vi.mock('node-pty', () => ({
7
+ spawn: vi.fn(),
8
+ }));
7
9
  vi.mock('./configurationManager.js', () => ({
8
10
  configurationManager: {
9
11
  getConfig: vi.fn().mockReturnValue({
@@ -72,7 +74,8 @@ describe('SessionManager - State Persistence', () => {
72
74
  vi.clearAllMocks();
73
75
  });
74
76
  it('should not change state immediately when detected state changes', async () => {
75
- const session = await sessionManager.createSessionWithPreset('/test/path');
77
+ const { Effect } = await import('effect');
78
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
76
79
  const eventEmitter = eventEmitters.get('/test/path');
77
80
  // Initial state should be busy
78
81
  expect(session.state).toBe('busy');
@@ -86,7 +89,8 @@ describe('SessionManager - State Persistence', () => {
86
89
  expect(session.pendingStateStart).toBeDefined();
87
90
  });
88
91
  it('should change state after persistence duration is met', async () => {
89
- const session = await sessionManager.createSessionWithPreset('/test/path');
92
+ const { Effect } = await import('effect');
93
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
90
94
  const eventEmitter = eventEmitters.get('/test/path');
91
95
  const stateChangeHandler = vi.fn();
92
96
  sessionManager.on('sessionStateChanged', stateChangeHandler);
@@ -107,7 +111,8 @@ describe('SessionManager - State Persistence', () => {
107
111
  expect(stateChangeHandler).toHaveBeenCalledWith(session);
108
112
  });
109
113
  it('should cancel pending state if detected state changes again before persistence', async () => {
110
- const session = await sessionManager.createSessionWithPreset('/test/path');
114
+ const { Effect } = await import('effect');
115
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
111
116
  const eventEmitter = eventEmitters.get('/test/path');
112
117
  // Initial state should be busy
113
118
  expect(session.state).toBe('busy');
@@ -125,7 +130,8 @@ describe('SessionManager - State Persistence', () => {
125
130
  expect(session.pendingState).toBe('waiting_input');
126
131
  });
127
132
  it('should clear pending state if detected state returns to current state', async () => {
128
- const session = await sessionManager.createSessionWithPreset('/test/path');
133
+ const { Effect } = await import('effect');
134
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
129
135
  const eventEmitter = eventEmitters.get('/test/path');
130
136
  // Initial state should be busy
131
137
  expect(session.state).toBe('busy');
@@ -145,7 +151,8 @@ describe('SessionManager - State Persistence', () => {
145
151
  expect(session.pendingStateStart).toBeUndefined();
146
152
  });
147
153
  it('should not confirm state changes that do not persist long enough', async () => {
148
- const session = await sessionManager.createSessionWithPreset('/test/path');
154
+ const { Effect } = await import('effect');
155
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
149
156
  const eventEmitter = eventEmitters.get('/test/path');
150
157
  const stateChangeHandler = vi.fn();
151
158
  sessionManager.on('sessionStateChanged', stateChangeHandler);
@@ -170,7 +177,8 @@ describe('SessionManager - State Persistence', () => {
170
177
  expect(stateChangeHandler).not.toHaveBeenCalled();
171
178
  });
172
179
  it('should properly clean up pending state when session is destroyed', async () => {
173
- const session = await sessionManager.createSessionWithPreset('/test/path');
180
+ const { Effect } = await import('effect');
181
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
174
182
  const eventEmitter = eventEmitters.get('/test/path');
175
183
  // Simulate output that would trigger idle state
176
184
  eventEmitter.emit('data', 'Some output without busy indicators');
@@ -185,8 +193,9 @@ describe('SessionManager - State Persistence', () => {
185
193
  expect(destroyedSession).toBeUndefined();
186
194
  });
187
195
  it('should handle multiple sessions with independent state persistence', async () => {
188
- const session1 = await sessionManager.createSessionWithPreset('/test/path1');
189
- const session2 = await sessionManager.createSessionWithPreset('/test/path2');
196
+ const { Effect } = await import('effect');
197
+ const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
198
+ const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path2'));
190
199
  const eventEmitter1 = eventEmitters.get('/test/path1');
191
200
  const eventEmitter2 = eventEmitters.get('/test/path2');
192
201
  // Both should start as busy
@@ -1,12 +1,16 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { Effect } from 'effect';
2
3
  import { spawn } from 'node-pty';
3
4
  import { EventEmitter } from 'events';
4
5
  import { exec } from 'child_process';
5
6
  // Mock node-pty
6
- vi.mock('node-pty');
7
+ vi.mock('node-pty', () => ({
8
+ spawn: vi.fn(),
9
+ }));
7
10
  // Mock child_process
8
11
  vi.mock('child_process', () => ({
9
12
  exec: vi.fn(),
13
+ execFile: vi.fn(),
10
14
  }));
11
15
  // Mock configuration manager
12
16
  vi.mock('./configurationManager.js', () => ({
@@ -93,7 +97,7 @@ describe('SessionManager', () => {
93
97
  afterEach(() => {
94
98
  sessionManager.destroy();
95
99
  });
96
- describe('createSessionWithPreset', () => {
100
+ describe('createSessionWithPresetEffect', () => {
97
101
  it('should use default preset when no preset ID specified', async () => {
98
102
  // Setup mock preset
99
103
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
@@ -105,7 +109,7 @@ describe('SessionManager', () => {
105
109
  // Setup spawn mock
106
110
  vi.mocked(spawn).mockReturnValue(mockPty);
107
111
  // Create session with preset
108
- await sessionManager.createSessionWithPreset('/test/worktree');
112
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
109
113
  // Verify spawn was called with preset config
110
114
  expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
111
115
  name: 'xterm-256color',
@@ -127,7 +131,7 @@ describe('SessionManager', () => {
127
131
  // Setup spawn mock
128
132
  vi.mocked(spawn).mockReturnValue(mockPty);
129
133
  // Create session with specific preset
130
- await sessionManager.createSessionWithPreset('/test/worktree', '2');
134
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', '2'));
131
135
  // Verify getPresetById was called with correct ID
132
136
  expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
133
137
  // Verify spawn was called with preset config
@@ -150,7 +154,7 @@ describe('SessionManager', () => {
150
154
  // Setup spawn mock
151
155
  vi.mocked(spawn).mockReturnValue(mockPty);
152
156
  // Create session with non-existent preset
153
- await sessionManager.createSessionWithPreset('/test/worktree', 'invalid');
157
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid'));
154
158
  // Verify fallback to default preset
155
159
  expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
156
160
  expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
@@ -168,8 +172,8 @@ describe('SessionManager', () => {
168
172
  vi.mocked(spawn).mockImplementation(() => {
169
173
  throw new Error('Command failed');
170
174
  });
171
- // Expect createSessionWithPreset to throw
172
- await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('Command failed');
175
+ // Expect createSessionWithPresetEffect to throw
176
+ await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command failed');
173
177
  // Verify only one spawn attempt was made
174
178
  expect(spawn).toHaveBeenCalledTimes(1);
175
179
  expect(spawn).toHaveBeenCalledWith('claude', ['--bad-flag'], expect.any(Object));
@@ -184,8 +188,8 @@ describe('SessionManager', () => {
184
188
  // Setup spawn mock
185
189
  vi.mocked(spawn).mockReturnValue(mockPty);
186
190
  // Create session twice
187
- const session1 = await sessionManager.createSessionWithPreset('/test/worktree');
188
- const session2 = await sessionManager.createSessionWithPreset('/test/worktree');
191
+ const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
192
+ const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
189
193
  // Should return the same session
190
194
  expect(session1).toBe(session2);
191
195
  // Spawn should only be called once
@@ -204,8 +208,8 @@ describe('SessionManager', () => {
204
208
  vi.mocked(spawn).mockImplementation(() => {
205
209
  throw new Error('Command not found');
206
210
  });
207
- // Expect createSessionWithPreset to throw the original error
208
- await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('Command not found');
211
+ // Expect createSessionWithPresetEffect to throw the original error
212
+ await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command not found');
209
213
  });
210
214
  it('should use fallback args when main command exits with code 1', async () => {
211
215
  // Setup mock preset with fallback
@@ -224,7 +228,7 @@ describe('SessionManager', () => {
224
228
  .mockReturnValueOnce(firstMockPty)
225
229
  .mockReturnValueOnce(secondMockPty);
226
230
  // Create session
227
- const session = await sessionManager.createSessionWithPreset('/test/worktree');
231
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
228
232
  // Verify initial spawn
229
233
  expect(spawn).toHaveBeenCalledTimes(1);
230
234
  expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
@@ -251,7 +255,7 @@ describe('SessionManager', () => {
251
255
  // Setup spawn mock - process doesn't exit early
252
256
  vi.mocked(spawn).mockReturnValue(mockPty);
253
257
  // Create session
254
- await sessionManager.createSessionWithPreset('/test/worktree');
258
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
255
259
  // Wait a bit to ensure no early exit
256
260
  await new Promise(resolve => setTimeout(resolve, 600));
257
261
  // Verify only one spawn attempt
@@ -275,7 +279,7 @@ describe('SessionManager', () => {
275
279
  .mockReturnValueOnce(firstMockPty)
276
280
  .mockReturnValueOnce(secondMockPty);
277
281
  // Create session
278
- const session = await sessionManager.createSessionWithPreset('/test/worktree');
282
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
279
283
  // Verify initial spawn
280
284
  expect(spawn).toHaveBeenCalledTimes(1);
281
285
  expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
@@ -302,7 +306,7 @@ describe('SessionManager', () => {
302
306
  // Setup spawn mock
303
307
  vi.mocked(spawn).mockReturnValue(mockPty);
304
308
  // Create session
305
- await sessionManager.createSessionWithPreset('/test/worktree');
309
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
306
310
  // Verify spawn was called with custom command
307
311
  expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
308
312
  cwd: '/test/worktree',
@@ -321,7 +325,7 @@ describe('SessionManager', () => {
321
325
  throw new Error('spawn failed');
322
326
  });
323
327
  // Expect createSessionWithPreset to throw
324
- await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('spawn failed');
328
+ await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('spawn failed');
325
329
  });
326
330
  });
327
331
  describe('session lifecycle', () => {
@@ -334,7 +338,7 @@ describe('SessionManager', () => {
334
338
  });
335
339
  vi.mocked(spawn).mockReturnValue(mockPty);
336
340
  // Create and destroy session
337
- await sessionManager.createSessionWithPreset('/test/worktree');
341
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
338
342
  sessionManager.destroySession('/test/worktree');
339
343
  // Verify cleanup
340
344
  expect(mockPty.kill).toHaveBeenCalled();
@@ -354,7 +358,7 @@ describe('SessionManager', () => {
354
358
  exitedSession = session;
355
359
  });
356
360
  // Create session
357
- const createdSession = await sessionManager.createSessionWithPreset('/test/worktree');
361
+ const createdSession = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
358
362
  // Simulate process exit after successful creation
359
363
  setTimeout(() => {
360
364
  mockPty.emit('exit', { exitCode: 0 });
@@ -365,7 +369,7 @@ describe('SessionManager', () => {
365
369
  expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
366
370
  });
367
371
  });
368
- describe('createSessionWithDevcontainer', () => {
372
+ describe('createSessionWithDevcontainerEffect', () => {
369
373
  beforeEach(() => {
370
374
  // Reset shouldFail flag
371
375
  const mockExec = vi.mocked(exec);
@@ -401,7 +405,7 @@ describe('SessionManager', () => {
401
405
  upCommand: 'devcontainer up --workspace-folder .',
402
406
  execCommand: 'devcontainer exec --workspace-folder .',
403
407
  };
404
- await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
408
+ await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
405
409
  // Verify spawn was called correctly which proves devcontainer up succeeded
406
410
  // Verify spawn was called with devcontainer exec
407
411
  expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
@@ -421,7 +425,7 @@ describe('SessionManager', () => {
421
425
  upCommand: 'devcontainer up',
422
426
  execCommand: 'devcontainer exec',
423
427
  };
424
- await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig, '2');
428
+ await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig, '2'));
425
429
  // Verify correct preset was used
426
430
  expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
427
431
  expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--', 'claude', '--resume', '--dev'], expect.any(Object));
@@ -435,7 +439,7 @@ describe('SessionManager', () => {
435
439
  upCommand: 'devcontainer up',
436
440
  execCommand: 'devcontainer exec',
437
441
  };
438
- await expect(sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig)).rejects.toThrow('Failed to start devcontainer: Container startup failed');
442
+ await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Container startup failed');
439
443
  });
440
444
  it('should return existing session if already created', async () => {
441
445
  // Setup mock preset
@@ -451,8 +455,8 @@ describe('SessionManager', () => {
451
455
  execCommand: 'devcontainer exec',
452
456
  };
453
457
  // Create session twice
454
- const session1 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
455
- const session2 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
458
+ const session1 = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
459
+ const session2 = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
456
460
  // Should return the same session
457
461
  expect(session1).toBe(session2);
458
462
  // spawn should only be called once
@@ -473,7 +477,7 @@ describe('SessionManager', () => {
473
477
  upCommand: 'devcontainer up --workspace-folder . --log-level debug',
474
478
  execCommand: 'devcontainer exec --workspace-folder . --container-name mycontainer',
475
479
  };
476
- await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
480
+ await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
477
481
  // Verify spawn was called with properly parsed exec command
478
482
  expect(spawn).toHaveBeenCalledWith('devcontainer', [
479
483
  'exec',
@@ -510,10 +514,10 @@ describe('SessionManager', () => {
510
514
  }
511
515
  return {};
512
516
  });
513
- await sessionManager.createSessionWithDevcontainer('/test/worktree2', {
517
+ await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree2', {
514
518
  upCommand: 'devcontainer up --workspace-folder .',
515
519
  execCommand: 'devcontainer exec --workspace-folder .',
516
- });
520
+ }));
517
521
  // Should spawn with devcontainer exec command
518
522
  expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
519
523
  cwd: '/test/worktree2',
@@ -531,10 +535,10 @@ describe('SessionManager', () => {
531
535
  }
532
536
  return {};
533
537
  });
534
- await sessionManager.createSessionWithDevcontainer('/test/worktree', {
538
+ await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
535
539
  upCommand: 'devcontainer up --workspace-folder .',
536
540
  execCommand: 'devcontainer exec --workspace-folder .',
537
- }, 'custom-preset');
541
+ }, 'custom-preset'));
538
542
  // Should call createSessionWithPreset internally
539
543
  const session = sessionManager.getSession('/test/worktree');
540
544
  expect(session).toBeDefined();
@@ -559,7 +563,7 @@ describe('SessionManager', () => {
559
563
  upCommand: 'devcontainer up --workspace-folder /path/to/project',
560
564
  execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
561
565
  };
562
- await sessionManager.createSessionWithDevcontainer('/test/worktree', config);
566
+ await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', config));
563
567
  expect(spawn).toHaveBeenCalledWith('devcontainer', [
564
568
  'exec',
565
569
  '--workspace-folder',
@@ -588,10 +592,10 @@ describe('SessionManager', () => {
588
592
  command: 'claude',
589
593
  args: ['-m', 'claude-3-opus'],
590
594
  });
591
- await sessionManager.createSessionWithDevcontainer('/test/worktree', {
595
+ await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
592
596
  upCommand: 'devcontainer up --workspace-folder .',
593
597
  execCommand: 'devcontainer exec --workspace-folder .',
594
- }, 'claude-with-args');
598
+ }, 'claude-with-args'));
595
599
  expect(spawn).toHaveBeenCalledWith('devcontainer', [
596
600
  'exec',
597
601
  '--workspace-folder',
@@ -629,10 +633,10 @@ describe('SessionManager', () => {
629
633
  vi.mocked(spawn)
630
634
  .mockReturnValueOnce(firstMockPty)
631
635
  .mockReturnValueOnce(secondMockPty);
632
- const session = await sessionManager.createSessionWithDevcontainer('/test/worktree', {
636
+ const session = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
633
637
  upCommand: 'devcontainer up --workspace-folder .',
634
638
  execCommand: 'devcontainer exec --workspace-folder .',
635
- });
639
+ }));
636
640
  // Verify initial spawn
637
641
  expect(spawn).toHaveBeenCalledTimes(1);
638
642
  expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
@@ -675,10 +679,10 @@ describe('SessionManager', () => {
675
679
  vi.mocked(spawn)
676
680
  .mockReturnValueOnce(firstMockPty)
677
681
  .mockReturnValueOnce(secondMockPty);
678
- const session = await sessionManager.createSessionWithDevcontainer('/test/worktree', {
682
+ const session = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
679
683
  upCommand: 'devcontainer up --workspace-folder .',
680
684
  execCommand: 'devcontainer exec --workspace-folder .',
681
- });
685
+ }));
682
686
  // Verify initial spawn
683
687
  expect(spawn).toHaveBeenCalledTimes(1);
684
688
  expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--bad-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
@@ -7,8 +7,10 @@ export declare class ShortcutManager {
7
7
  private isReservedKey;
8
8
  saveShortcuts(shortcuts: ShortcutConfig): boolean;
9
9
  getShortcuts(): ShortcutConfig;
10
+ private getRawShortcutCodes;
10
11
  matchesShortcut(shortcutName: keyof ShortcutConfig, input: string, key: Key): boolean;
11
12
  getShortcutDisplay(shortcutName: keyof ShortcutConfig): string;
12
13
  getShortcutCode(shortcut: ShortcutKey): string | null;
14
+ matchesRawInput(shortcutName: keyof ShortcutConfig, input: string): boolean;
13
15
  }
14
16
  export declare const shortcutManager: ShortcutManager;
@@ -60,6 +60,51 @@ export class ShortcutManager {
60
60
  getShortcuts() {
61
61
  return configurationManager.getShortcuts();
62
62
  }
63
+ getRawShortcutCodes(shortcut) {
64
+ const codes = new Set();
65
+ // Direct control-code form (e.g. Ctrl+E -> \u0005)
66
+ const controlCode = this.getShortcutCode(shortcut);
67
+ if (controlCode) {
68
+ codes.add(controlCode);
69
+ }
70
+ // Escape key in raw mode
71
+ if (shortcut.key === 'escape' &&
72
+ !shortcut.ctrl &&
73
+ !shortcut.alt &&
74
+ !shortcut.shift) {
75
+ codes.add('\u001b');
76
+ }
77
+ // Kitty/xterm extended keyboard sequences (CSI <code>;<mod>u)
78
+ if (shortcut.ctrl &&
79
+ !shortcut.alt &&
80
+ !shortcut.shift &&
81
+ shortcut.key.length === 1) {
82
+ const lower = shortcut.key.toLowerCase();
83
+ const upperCode = lower.toUpperCase().charCodeAt(0);
84
+ const lowerCode = lower.charCodeAt(0);
85
+ // Include the CSI u format (ESC[<code>;5u) used by Kitty/WezTerm for Ctrl+letters.
86
+ if (upperCode >= 32 && upperCode <= 126) {
87
+ codes.add(`\u001b[${upperCode};5u`);
88
+ }
89
+ if (lowerCode !== upperCode && lowerCode >= 32 && lowerCode <= 126) {
90
+ codes.add(`\u001b[${lowerCode};5u`);
91
+ }
92
+ // Tmux/xterm with modifyOtherKeys emit ESC[27;5;<code>~ for the same shortcut.
93
+ if (upperCode >= 32 && upperCode <= 126) {
94
+ codes.add(`\u001b[27;5;${upperCode}~`);
95
+ }
96
+ if (lowerCode !== upperCode && lowerCode >= 32 && lowerCode <= 126) {
97
+ codes.add(`\u001b[27;5;${lowerCode}~`);
98
+ }
99
+ // Some setups (issue #82/#107 repros) send ESC[1;5<letter>; include both upper/lower.
100
+ const upperKey = lower.toUpperCase();
101
+ codes.add(`\u001b[1;5${upperKey}`);
102
+ if (upperKey !== lower) {
103
+ codes.add(`\u001b[1;5${lower}`);
104
+ }
105
+ }
106
+ return Array.from(codes);
107
+ }
63
108
  matchesShortcut(shortcutName, input, key) {
64
109
  const shortcuts = configurationManager.getShortcuts();
65
110
  const shortcut = shortcuts[shortcutName];
@@ -115,5 +160,13 @@ export class ShortcutManager {
115
160
  }
116
161
  return null;
117
162
  }
163
+ matchesRawInput(shortcutName, input) {
164
+ const shortcuts = configurationManager.getShortcuts();
165
+ const shortcut = shortcuts[shortcutName];
166
+ if (!shortcut)
167
+ return false;
168
+ const codes = this.getRawShortcutCodes(shortcut);
169
+ return codes.some(code => input === code || input.includes(code));
170
+ }
118
171
  }
119
172
  export const shortcutManager = new ShortcutManager();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
2
+ import { shortcutManager } from './shortcutManager.js';
3
+ import { configurationManager } from './configurationManager.js';
4
+ describe('shortcutManager.matchesRawInput', () => {
5
+ const shortcuts = {
6
+ returnToMenu: { ctrl: true, key: 'e', alt: false, shift: false },
7
+ cancel: { ctrl: true, key: 'c', alt: false, shift: false },
8
+ };
9
+ beforeEach(() => {
10
+ vi.spyOn(configurationManager, 'getShortcuts').mockReturnValue(shortcuts);
11
+ });
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ });
15
+ it('matches classic control code', () => {
16
+ expect(shortcutManager.matchesRawInput('returnToMenu', '\u0005')).toBe(true);
17
+ });
18
+ it('matches CSI u sequence', () => {
19
+ expect(shortcutManager.matchesRawInput('returnToMenu', '\u001b[69;5u')).toBe(true);
20
+ });
21
+ it('matches modifyOtherKeys sequence', () => {
22
+ expect(shortcutManager.matchesRawInput('returnToMenu', '\u001b[27;5;69~')).toBe(true);
23
+ });
24
+ it('matches CSI 1;5<key>', () => {
25
+ expect(shortcutManager.matchesRawInput('returnToMenu', '\u001b[1;5E')).toBe(true);
26
+ });
27
+ it('ignores unrelated input', () => {
28
+ expect(shortcutManager.matchesRawInput('returnToMenu', 'hello')).toBe(false);
29
+ });
30
+ });