ccmanager 2.8.0 → 2.9.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 (77) 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/hooks/useGitStatus.d.ts +11 -0
  26. package/dist/hooks/useGitStatus.js +70 -12
  27. package/dist/hooks/useGitStatus.test.js +30 -23
  28. package/dist/services/configurationManager.d.ts +75 -0
  29. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  30. package/dist/services/configurationManager.effect.test.js +407 -0
  31. package/dist/services/configurationManager.js +246 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  33. package/dist/services/projectManager.d.ts +98 -2
  34. package/dist/services/projectManager.js +228 -59
  35. package/dist/services/projectManager.test.js +242 -2
  36. package/dist/services/sessionManager.d.ts +44 -2
  37. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  38. package/dist/services/sessionManager.effect.test.js +321 -0
  39. package/dist/services/sessionManager.js +216 -65
  40. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  41. package/dist/services/sessionManager.test.js +40 -36
  42. package/dist/services/worktreeService.d.ts +356 -26
  43. package/dist/services/worktreeService.js +793 -353
  44. package/dist/services/worktreeService.test.js +294 -313
  45. package/dist/types/errors.d.ts +74 -0
  46. package/dist/types/errors.js +31 -0
  47. package/dist/types/errors.test.d.ts +1 -0
  48. package/dist/types/errors.test.js +201 -0
  49. package/dist/types/index.d.ts +5 -17
  50. package/dist/utils/claudeDir.d.ts +58 -6
  51. package/dist/utils/claudeDir.js +103 -8
  52. package/dist/utils/claudeDir.test.d.ts +1 -0
  53. package/dist/utils/claudeDir.test.js +108 -0
  54. package/dist/utils/concurrencyLimit.d.ts +5 -0
  55. package/dist/utils/concurrencyLimit.js +11 -0
  56. package/dist/utils/concurrencyLimit.test.js +40 -1
  57. package/dist/utils/gitStatus.d.ts +36 -8
  58. package/dist/utils/gitStatus.js +170 -88
  59. package/dist/utils/gitStatus.test.js +12 -9
  60. package/dist/utils/hookExecutor.d.ts +41 -6
  61. package/dist/utils/hookExecutor.js +75 -32
  62. package/dist/utils/hookExecutor.test.js +73 -20
  63. package/dist/utils/terminalCapabilities.d.ts +18 -0
  64. package/dist/utils/terminalCapabilities.js +81 -0
  65. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  66. package/dist/utils/terminalCapabilities.test.js +104 -0
  67. package/dist/utils/testHelpers.d.ts +106 -0
  68. package/dist/utils/testHelpers.js +153 -0
  69. package/dist/utils/testHelpers.test.d.ts +1 -0
  70. package/dist/utils/testHelpers.test.js +114 -0
  71. package/dist/utils/worktreeConfig.d.ts +77 -2
  72. package/dist/utils/worktreeConfig.js +156 -16
  73. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  74. package/dist/utils/worktreeConfig.test.js +39 -0
  75. package/package.json +4 -4
  76. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  77. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -1,11 +1,44 @@
1
1
  import { spawn } from 'child_process';
2
+ import { Effect } from 'effect';
3
+ import { ProcessError } from '../types/errors.js';
2
4
  import { WorktreeService } from '../services/worktreeService.js';
3
5
  import { configurationManager } from '../services/configurationManager.js';
4
6
  /**
5
- * Execute a hook command with the provided environment variables
7
+ * Execute a hook command with the provided environment variables using Effect
8
+ *
9
+ * Spawns a shell process to run the hook command with custom environment.
10
+ * Errors are captured but hook failures don't propagate to prevent breaking main flow.
11
+ *
12
+ * @param {string} command - Shell command to execute
13
+ * @param {string} cwd - Working directory for command execution
14
+ * @param {HookEnvironment} environment - Environment variables for the hook
15
+ * @returns {Effect.Effect<void, ProcessError, never>} Effect that succeeds on hook completion or fails with ProcessError
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import {Effect} from 'effect';
20
+ * import {executeHook} from './utils/hookExecutor.js';
21
+ *
22
+ * const env = {
23
+ * CCMANAGER_WORKTREE_PATH: '/path/to/worktree',
24
+ * CCMANAGER_WORKTREE_BRANCH: 'feature-branch',
25
+ * CCMANAGER_GIT_ROOT: '/path/to/repo'
26
+ * };
27
+ *
28
+ * // Execute hook with error recovery
29
+ * const result = await Effect.runPromise(
30
+ * Effect.catchAll(
31
+ * executeHook('npm install', '/path/to/worktree', env),
32
+ * (error) => {
33
+ * console.error(`Hook failed: ${error.message}`);
34
+ * return Effect.succeed(undefined); // Continue despite error
35
+ * }
36
+ * )
37
+ * );
38
+ * ```
6
39
  */
7
40
  export function executeHook(command, cwd, environment) {
8
- return new Promise((resolve, reject) => {
41
+ return Effect.async(resume => {
9
42
  // Use spawn with shell to execute the command and wait for all child processes
10
43
  const child = spawn(command, [], {
11
44
  cwd,
@@ -24,29 +57,36 @@ export function executeHook(command, cwd, environment) {
24
57
  // Wait for the process and all its children to exit
25
58
  child.on('exit', (code, signal) => {
26
59
  if (code !== 0 || signal) {
27
- let errorMessage = signal
60
+ const errorMessage = signal
28
61
  ? `Hook terminated by signal ${signal}`
29
62
  : `Hook exited with code ${code}`;
30
- // Include stderr in the error message when exit code is not 0
31
- if (stderr) {
32
- errorMessage += `\nStderr: ${stderr}`;
33
- }
34
- reject(new Error(errorMessage));
63
+ resume(Effect.fail(new ProcessError({
64
+ command,
65
+ exitCode: code ?? undefined,
66
+ signal: signal ?? undefined,
67
+ message: stderr
68
+ ? `${errorMessage}\nStderr: ${stderr}`
69
+ : errorMessage,
70
+ })));
35
71
  return;
36
72
  }
37
73
  // When exit code is 0, ignore stderr and resolve successfully
38
- resolve();
74
+ resume(Effect.void);
39
75
  });
40
76
  // Handle errors in spawning the process
41
77
  child.on('error', error => {
42
- reject(error);
78
+ resume(Effect.fail(new ProcessError({
79
+ command,
80
+ message: error.message,
81
+ })));
43
82
  });
44
83
  });
45
84
  }
46
85
  /**
47
- * Execute a worktree post-creation hook
86
+ * Execute a worktree post-creation hook using Effect
87
+ * Errors are caught and logged but do not break the main flow
48
88
  */
49
- export async function executeWorktreePostCreationHook(command, worktree, gitRoot, baseBranch) {
89
+ export function executeWorktreePostCreationHook(command, worktree, gitRoot, baseBranch) {
50
90
  const environment = {
51
91
  CCMANAGER_WORKTREE_PATH: worktree.path,
52
92
  CCMANAGER_WORKTREE_BRANCH: worktree.branch || 'unknown',
@@ -55,24 +95,30 @@ export async function executeWorktreePostCreationHook(command, worktree, gitRoot
55
95
  if (baseBranch) {
56
96
  environment.CCMANAGER_BASE_BRANCH = baseBranch;
57
97
  }
58
- try {
59
- await executeHook(command, worktree.path, environment);
60
- }
61
- catch (error) {
98
+ return Effect.catchAll(executeHook(command, worktree.path, environment), error => {
62
99
  // Log error but don't throw - hooks should not break the main flow
63
- console.error(`Failed to execute post-creation hook: ${error instanceof Error ? error.message : String(error)}`);
64
- }
100
+ console.error(`Failed to execute post-creation hook: ${error.message}`);
101
+ return Effect.void;
102
+ });
65
103
  }
66
104
  /**
67
- * Execute a session status change hook
105
+ * Execute a session status change hook using Effect
106
+ * Errors are caught and logged but do not break the main flow
68
107
  */
69
- export async function executeStatusHook(oldState, newState, session) {
108
+ export function executeStatusHook(oldState, newState, session) {
70
109
  const statusHooks = configurationManager.getStatusHooks();
71
110
  const hook = statusHooks[newState];
72
- if (hook && hook.enabled && hook.command) {
73
- // Get branch information
74
- const worktreeService = new WorktreeService();
75
- const worktrees = worktreeService.getWorktrees();
111
+ if (!hook || !hook.enabled || !hook.command) {
112
+ return Effect.void;
113
+ }
114
+ const worktreeService = new WorktreeService();
115
+ return Effect.gen(function* () {
116
+ // Get branch information using Effect-based method
117
+ const worktrees = yield* Effect.catchAll(worktreeService.getWorktreesEffect(), error => {
118
+ // Log error but continue with empty array - hooks should not break main flow
119
+ console.error(`Failed to get worktrees for status hook: ${error.message || String(error)}`);
120
+ return Effect.succeed([]);
121
+ });
76
122
  const worktree = worktrees.find(wt => wt.path === session.worktreePath);
77
123
  const branch = worktree?.branch || 'unknown';
78
124
  // Build environment for status hook
@@ -84,13 +130,10 @@ export async function executeStatusHook(oldState, newState, session) {
84
130
  CCMANAGER_NEW_STATE: newState,
85
131
  CCMANAGER_SESSION_ID: session.id,
86
132
  };
87
- // Execute the hook command in the session's worktree directory
88
- try {
89
- await executeHook(hook.command, session.worktreePath, environment);
90
- }
91
- catch (error) {
133
+ yield* Effect.catchAll(executeHook(hook.command, session.worktreePath, environment), error => {
92
134
  // Log error but don't throw - hooks should not break the main flow
93
- console.error(`Failed to execute ${newState} hook: ${error instanceof Error ? error.message : String(error)}`);
94
- }
95
- }
135
+ console.error(`Failed to execute ${newState} hook: ${error.message}`);
136
+ return Effect.void;
137
+ });
138
+ });
96
139
  }
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { Effect } from 'effect';
2
3
  import { executeHook, executeWorktreePostCreationHook, executeStatusHook, } from './hookExecutor.js';
3
4
  import { mkdtemp, rm, readFile, realpath } from 'fs/promises';
4
5
  import { tmpdir } from 'os';
5
6
  import { join } from 'path';
6
7
  import { configurationManager } from '../services/configurationManager.js';
7
8
  import { WorktreeService } from '../services/worktreeService.js';
9
+ import { GitError } from '../types/errors.js';
8
10
  // Mock the configurationManager
9
11
  vi.mock('../services/configurationManager.js', () => ({
10
12
  configurationManager: {
@@ -28,7 +30,7 @@ describe('hookExecutor Integration Tests', () => {
28
30
  };
29
31
  try {
30
32
  // Act & Assert - should not throw
31
- await expect(executeHook('echo "Test successful"', tmpDir, environment)).resolves.toBeUndefined();
33
+ await expect(Effect.runPromise(executeHook('echo "Test successful"', tmpDir, environment))).resolves.toBeUndefined();
32
34
  }
33
35
  finally {
34
36
  // Cleanup
@@ -45,7 +47,7 @@ describe('hookExecutor Integration Tests', () => {
45
47
  };
46
48
  try {
47
49
  // Act & Assert
48
- await expect(executeHook('exit 1', tmpDir, environment)).rejects.toThrow();
50
+ await expect(Effect.runPromise(executeHook('exit 1', tmpDir, environment))).rejects.toThrow();
49
51
  }
50
52
  finally {
51
53
  // Cleanup
@@ -62,7 +64,7 @@ describe('hookExecutor Integration Tests', () => {
62
64
  };
63
65
  try {
64
66
  // Act & Assert - command that writes to stderr and exits with error
65
- await expect(executeHook('>&2 echo "Error details here"; exit 1', tmpDir, environment)).rejects.toThrow('Hook exited with code 1\nStderr: Error details here\n');
67
+ await expect(Effect.runPromise(executeHook('>&2 echo "Error details here"; exit 1', tmpDir, environment))).rejects.toThrow('Hook exited with code 1\nStderr: Error details here\n');
66
68
  }
67
69
  finally {
68
70
  // Cleanup
@@ -80,7 +82,7 @@ describe('hookExecutor Integration Tests', () => {
80
82
  try {
81
83
  // Test with multiline stderr
82
84
  try {
83
- await executeHook('>&2 echo "Line 1"; >&2 echo "Line 2"; exit 3', tmpDir, environment);
85
+ await Effect.runPromise(executeHook('>&2 echo "Line 1"; >&2 echo "Line 2"; exit 3', tmpDir, environment));
84
86
  expect.fail('Should have thrown');
85
87
  }
86
88
  catch (error) {
@@ -90,7 +92,7 @@ describe('hookExecutor Integration Tests', () => {
90
92
  }
91
93
  // Test with empty stderr
92
94
  try {
93
- await executeHook('exit 4', tmpDir, environment);
95
+ await Effect.runPromise(executeHook('exit 4', tmpDir, environment));
94
96
  expect.fail('Should have thrown');
95
97
  }
96
98
  catch (error) {
@@ -114,7 +116,7 @@ describe('hookExecutor Integration Tests', () => {
114
116
  try {
115
117
  // Act - command that writes to stderr but exits successfully
116
118
  // Should not throw even though there's stderr output
117
- await expect(executeHook('>&2 echo "Warning message"; exit 0', tmpDir, environment)).resolves.toBeUndefined();
119
+ await expect(Effect.runPromise(executeHook('>&2 echo "Warning message"; exit 0', tmpDir, environment))).resolves.toBeUndefined();
118
120
  }
119
121
  finally {
120
122
  // Cleanup
@@ -132,7 +134,7 @@ describe('hookExecutor Integration Tests', () => {
132
134
  };
133
135
  try {
134
136
  // Act - write current directory to file
135
- await executeHook(`pwd > "${outputFile}"`, tmpDir, environment);
137
+ await Effect.runPromise(executeHook(`pwd > "${outputFile}"`, tmpDir, environment));
136
138
  // Read the output
137
139
  const { readFile } = await import('fs/promises');
138
140
  const output = await readFile(outputFile, 'utf-8');
@@ -159,7 +161,7 @@ describe('hookExecutor Integration Tests', () => {
159
161
  };
160
162
  try {
161
163
  // Act & Assert - should not throw even with failing command
162
- await expect(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main')).resolves.toBeUndefined();
164
+ await expect(Effect.runPromise(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main'))).resolves.toBeUndefined();
163
165
  }
164
166
  finally {
165
167
  // Cleanup
@@ -179,7 +181,7 @@ describe('hookExecutor Integration Tests', () => {
179
181
  const gitRoot = '/different/git/root';
180
182
  try {
181
183
  // Act - write current directory to file
182
- await executeWorktreePostCreationHook(`pwd > "${outputFile}"`, worktree, gitRoot, 'main');
184
+ await Effect.runPromise(executeWorktreePostCreationHook(`pwd > "${outputFile}"`, worktree, gitRoot, 'main'));
183
185
  // Read the output
184
186
  const { readFile } = await import('fs/promises');
185
187
  const output = await readFile(outputFile, 'utf-8');
@@ -208,7 +210,7 @@ describe('hookExecutor Integration Tests', () => {
208
210
  };
209
211
  try {
210
212
  // Act - change to git root and write its path
211
- await executeWorktreePostCreationHook(`cd "$CCMANAGER_GIT_ROOT" && pwd > "${outputFile}"`, worktree, tmpGitRootDir, 'main');
213
+ await Effect.runPromise(executeWorktreePostCreationHook(`cd "$CCMANAGER_GIT_ROOT" && pwd > "${outputFile}"`, worktree, tmpGitRootDir, 'main'));
212
214
  // Read the output
213
215
  const { readFile } = await import('fs/promises');
214
216
  const output = await readFile(outputFile, 'utf-8');
@@ -235,7 +237,7 @@ describe('hookExecutor Integration Tests', () => {
235
237
  // Act - execute a command that spawns a background process with a delay
236
238
  // The background process writes to a file after a delay
237
239
  // We use a shell command that creates a background process and then exits
238
- await executeWorktreePostCreationHook(`(sleep 0.1 && echo "completed" > "${outputFile}") & wait`, worktree, tmpDir, 'main');
240
+ await Effect.runPromise(executeWorktreePostCreationHook(`(sleep 0.1 && echo "completed" > "${outputFile}") & wait`, worktree, tmpDir, 'main'));
239
241
  // Read the output - this should exist because we waited for the background process
240
242
  const { readFile } = await import('fs/promises');
241
243
  const output = await readFile(outputFile, 'utf-8');
@@ -273,14 +275,14 @@ describe('hookExecutor Integration Tests', () => {
273
275
  };
274
276
  // Mock WorktreeService to return a worktree with the tmpDir path
275
277
  vi.mocked(WorktreeService).mockImplementation(() => ({
276
- getWorktrees: vi.fn(() => [
278
+ getWorktreesEffect: vi.fn(() => Effect.succeed([
277
279
  {
278
280
  path: tmpDir,
279
281
  branch: 'test-branch',
280
282
  isMainWorktree: false,
281
283
  hasSession: true,
282
284
  },
283
- ]),
285
+ ])),
284
286
  }));
285
287
  // Configure mock to return a hook that writes to a file with delay
286
288
  vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
@@ -293,7 +295,7 @@ describe('hookExecutor Integration Tests', () => {
293
295
  });
294
296
  try {
295
297
  // Act - execute the hook and await it
296
- await executeStatusHook('idle', 'busy', mockSession);
298
+ await Effect.runPromise(executeStatusHook('idle', 'busy', mockSession));
297
299
  // Assert - file should exist because we awaited the hook
298
300
  const content = await readFile(outputFile, 'utf-8');
299
301
  expect(content.trim()).toBe('Hook executed');
@@ -326,14 +328,14 @@ describe('hookExecutor Integration Tests', () => {
326
328
  };
327
329
  // Mock WorktreeService to return a worktree with the tmpDir path
328
330
  vi.mocked(WorktreeService).mockImplementation(() => ({
329
- getWorktrees: vi.fn(() => [
331
+ getWorktreesEffect: vi.fn(() => Effect.succeed([
330
332
  {
331
333
  path: tmpDir,
332
334
  branch: 'test-branch',
333
335
  isMainWorktree: false,
334
336
  hasSession: true,
335
337
  },
336
- ]),
338
+ ])),
337
339
  }));
338
340
  // Configure mock to return a hook that fails
339
341
  vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
@@ -346,7 +348,7 @@ describe('hookExecutor Integration Tests', () => {
346
348
  });
347
349
  try {
348
350
  // Act & Assert - should not throw even when hook fails
349
- await expect(executeStatusHook('idle', 'busy', mockSession)).resolves.toBeUndefined();
351
+ await expect(Effect.runPromise(executeStatusHook('idle', 'busy', mockSession))).resolves.toBeUndefined();
350
352
  }
351
353
  finally {
352
354
  // Cleanup
@@ -377,14 +379,14 @@ describe('hookExecutor Integration Tests', () => {
377
379
  };
378
380
  // Mock WorktreeService to return a worktree with the tmpDir path
379
381
  vi.mocked(WorktreeService).mockImplementation(() => ({
380
- getWorktrees: vi.fn(() => [
382
+ getWorktreesEffect: vi.fn(() => Effect.succeed([
381
383
  {
382
384
  path: tmpDir,
383
385
  branch: 'test-branch',
384
386
  isMainWorktree: false,
385
387
  hasSession: true,
386
388
  },
387
- ]),
389
+ ])),
388
390
  }));
389
391
  // Configure mock to return a disabled hook
390
392
  vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
@@ -397,7 +399,7 @@ describe('hookExecutor Integration Tests', () => {
397
399
  });
398
400
  try {
399
401
  // Act
400
- await executeStatusHook('idle', 'busy', mockSession);
402
+ await Effect.runPromise(executeStatusHook('idle', 'busy', mockSession));
401
403
  // Assert - file should not exist because hook was disabled
402
404
  await expect(readFile(outputFile, 'utf-8')).rejects.toThrow();
403
405
  }
@@ -406,5 +408,56 @@ describe('hookExecutor Integration Tests', () => {
406
408
  await rm(tmpDir, { recursive: true });
407
409
  }
408
410
  });
411
+ it('should handle getWorktreesEffect failures gracefully', async () => {
412
+ // Arrange
413
+ const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
414
+ const outputFile = join(tmpDir, 'hook-output.txt');
415
+ const mockSession = {
416
+ id: 'test-session-failure',
417
+ worktreePath: tmpDir,
418
+ process: {},
419
+ terminal: {},
420
+ output: [],
421
+ outputHistory: [],
422
+ state: 'idle',
423
+ stateCheckInterval: undefined,
424
+ isPrimaryCommand: true,
425
+ commandConfig: undefined,
426
+ detectionStrategy: 'claude',
427
+ devcontainerConfig: undefined,
428
+ pendingState: undefined,
429
+ pendingStateStart: undefined,
430
+ lastActivity: new Date(),
431
+ isActive: true,
432
+ };
433
+ // Mock WorktreeService to fail with GitError
434
+ vi.mocked(WorktreeService).mockImplementation(() => ({
435
+ getWorktreesEffect: vi.fn(() => Effect.fail(new GitError({
436
+ command: 'git worktree list --porcelain',
437
+ exitCode: 128,
438
+ stderr: 'not a git repository',
439
+ }))),
440
+ }));
441
+ // Configure mock to return a hook that should execute despite worktree query failure
442
+ vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
443
+ busy: {
444
+ enabled: true,
445
+ command: `echo "Hook ran with branch: $CCMANAGER_WORKTREE_BRANCH" > "${outputFile}"`,
446
+ },
447
+ idle: { enabled: false, command: '' },
448
+ waiting_input: { enabled: false, command: '' },
449
+ });
450
+ try {
451
+ // Act - should not throw even when getWorktreesEffect fails
452
+ await expect(Effect.runPromise(executeStatusHook('idle', 'busy', mockSession))).resolves.toBeUndefined();
453
+ // Assert - hook should have executed with 'unknown' branch
454
+ const content = await readFile(outputFile, 'utf-8');
455
+ expect(content.trim()).toBe('Hook ran with branch: unknown');
456
+ }
457
+ finally {
458
+ // Cleanup
459
+ await rm(tmpDir, { recursive: true });
460
+ }
461
+ });
409
462
  });
410
463
  });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Terminal capabilities detection utilities for CCManager.
3
+ * Determines terminal feature support for optimal UI rendering.
4
+ */
5
+ /**
6
+ * Detect if the current terminal supports Unicode characters.
7
+ * This function checks various environment variables and platform indicators
8
+ * to determine if Unicode spinner characters (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) will render correctly.
9
+ *
10
+ * Detection strategy:
11
+ * 1. Check TERM environment variable for known Unicode-capable terminals
12
+ * 2. Check LANG and LC_ALL for UTF-8 encoding
13
+ * 3. Platform-specific checks (Windows Terminal, etc.)
14
+ * 4. Fallback to ASCII if no Unicode indicators present
15
+ *
16
+ * @returns true if Unicode is supported, false otherwise
17
+ */
18
+ export declare function supportsUnicode(): boolean;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Terminal capabilities detection utilities for CCManager.
3
+ * Determines terminal feature support for optimal UI rendering.
4
+ */
5
+ /**
6
+ * Detect if the current terminal supports Unicode characters.
7
+ * This function checks various environment variables and platform indicators
8
+ * to determine if Unicode spinner characters (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) will render correctly.
9
+ *
10
+ * Detection strategy:
11
+ * 1. Check TERM environment variable for known Unicode-capable terminals
12
+ * 2. Check LANG and LC_ALL for UTF-8 encoding
13
+ * 3. Platform-specific checks (Windows Terminal, etc.)
14
+ * 4. Fallback to ASCII if no Unicode indicators present
15
+ *
16
+ * @returns true if Unicode is supported, false otherwise
17
+ */
18
+ export function supportsUnicode() {
19
+ // Check for explicitly non-Unicode terminals
20
+ const term = process.env['TERM'];
21
+ if (term === 'dumb') {
22
+ return false;
23
+ }
24
+ // Windows-specific detection
25
+ if (process.platform === 'win32') {
26
+ // Windows Terminal supports Unicode
27
+ if (process.env['WT_SESSION']) {
28
+ return true;
29
+ }
30
+ // Without Windows Terminal or explicit TERM, assume no Unicode
31
+ if (!term) {
32
+ return false;
33
+ }
34
+ }
35
+ // Check TERM for known Unicode-capable terminals
36
+ if (term) {
37
+ // xterm variants, screen, alacritty all support Unicode
38
+ const unicodeTerms = [
39
+ 'xterm',
40
+ 'xterm-256color',
41
+ 'screen',
42
+ 'screen-256color',
43
+ 'alacritty',
44
+ 'vte',
45
+ 'rxvt',
46
+ ];
47
+ for (const unicodeTerm of unicodeTerms) {
48
+ if (term.includes(unicodeTerm)) {
49
+ return true;
50
+ }
51
+ }
52
+ // Linux console (not linux-utf8) has limited Unicode support
53
+ if (term === 'linux' && !hasUtf8Locale()) {
54
+ return false;
55
+ }
56
+ }
57
+ // Check locale settings for UTF-8 encoding
58
+ if (hasUtf8Locale()) {
59
+ return true;
60
+ }
61
+ // Apple Terminal historically had issues, but with UTF-8 locale it should work
62
+ if (process.env['TERM_PROGRAM'] === 'Apple_Terminal' && !hasUtf8Locale()) {
63
+ return false;
64
+ }
65
+ // CI environments often support Unicode if locale is set
66
+ if (process.env['CI'] && hasUtf8Locale()) {
67
+ return true;
68
+ }
69
+ // Default to false if no Unicode indicators found
70
+ return false;
71
+ }
72
+ /**
73
+ * Check if the system locale indicates UTF-8 encoding.
74
+ * Examines LANG and LC_ALL environment variables.
75
+ *
76
+ * @returns true if UTF-8 locale is detected
77
+ */
78
+ function hasUtf8Locale() {
79
+ const lang = process.env['LANG'] || process.env['LC_ALL'] || '';
80
+ return /utf-?8/i.test(lang);
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { supportsUnicode } from './terminalCapabilities.js';
3
+ describe('terminalCapabilities', () => {
4
+ // Store original environment variables
5
+ const originalEnv = { ...process.env };
6
+ const originalStdout = { ...process.stdout };
7
+ const originalPlatform = process.platform;
8
+ beforeEach(() => {
9
+ // Reset environment before each test
10
+ vi.resetModules();
11
+ });
12
+ afterEach(() => {
13
+ // Restore original environment
14
+ process.env = { ...originalEnv };
15
+ Object.assign(process.stdout, originalStdout);
16
+ // Restore platform if it was modified
17
+ Object.defineProperty(process, 'platform', {
18
+ value: originalPlatform,
19
+ writable: true,
20
+ configurable: true,
21
+ });
22
+ vi.restoreAllMocks();
23
+ });
24
+ describe('supportsUnicode', () => {
25
+ it('should return true when TERM environment variable indicates Unicode support', () => {
26
+ process.env['TERM'] = 'xterm-256color';
27
+ expect(supportsUnicode()).toBe(true);
28
+ });
29
+ it('should return true when TERM is set to xterm', () => {
30
+ process.env['TERM'] = 'xterm';
31
+ expect(supportsUnicode()).toBe(true);
32
+ });
33
+ it('should return true when TERM is screen with Unicode support', () => {
34
+ process.env['TERM'] = 'screen-256color';
35
+ expect(supportsUnicode()).toBe(true);
36
+ });
37
+ it('should return true when TERM is alacritty', () => {
38
+ process.env['TERM'] = 'alacritty';
39
+ expect(supportsUnicode()).toBe(true);
40
+ });
41
+ it('should return true when LANG indicates UTF-8 encoding', () => {
42
+ delete process.env['TERM'];
43
+ process.env['LANG'] = 'en_US.UTF-8';
44
+ expect(supportsUnicode()).toBe(true);
45
+ });
46
+ it('should return true when LC_ALL indicates UTF-8 encoding', () => {
47
+ delete process.env['TERM'];
48
+ delete process.env['LANG'];
49
+ process.env['LC_ALL'] = 'en_US.UTF-8';
50
+ expect(supportsUnicode()).toBe(true);
51
+ });
52
+ it('should return false when TERM is dumb', () => {
53
+ process.env['TERM'] = 'dumb';
54
+ expect(supportsUnicode()).toBe(false);
55
+ });
56
+ it('should return false when TERM is linux without Unicode support', () => {
57
+ process.env['TERM'] = 'linux';
58
+ delete process.env['LANG'];
59
+ delete process.env['LC_ALL'];
60
+ expect(supportsUnicode()).toBe(false);
61
+ });
62
+ it('should return false when no Unicode indicators are present', () => {
63
+ delete process.env['TERM'];
64
+ delete process.env['LANG'];
65
+ delete process.env['LC_ALL'];
66
+ expect(supportsUnicode()).toBe(false);
67
+ });
68
+ it('should return true on Windows when WT_SESSION is set (Windows Terminal)', () => {
69
+ Object.defineProperty(process, 'platform', {
70
+ value: 'win32',
71
+ writable: true,
72
+ });
73
+ process.env['WT_SESSION'] = 'some-session-id';
74
+ expect(supportsUnicode()).toBe(true);
75
+ });
76
+ it('should return false on Windows without Windows Terminal markers', () => {
77
+ Object.defineProperty(process, 'platform', {
78
+ value: 'win32',
79
+ writable: true,
80
+ });
81
+ delete process.env['WT_SESSION'];
82
+ delete process.env['TERM'];
83
+ expect(supportsUnicode()).toBe(false);
84
+ });
85
+ it('should return true when CI environment variable is set and LANG is UTF-8', () => {
86
+ process.env['CI'] = 'true';
87
+ process.env['LANG'] = 'en_US.UTF-8';
88
+ expect(supportsUnicode()).toBe(true);
89
+ });
90
+ it('should return false when TERM_PROGRAM is Apple_Terminal on older macOS', () => {
91
+ // Apple Terminal historically had limited Unicode support
92
+ process.env['TERM_PROGRAM'] = 'Apple_Terminal';
93
+ delete process.env['LANG'];
94
+ delete process.env['LC_ALL'];
95
+ delete process.env['TERM'];
96
+ expect(supportsUnicode()).toBe(false);
97
+ });
98
+ it('should handle case-insensitive UTF-8 check in LANG', () => {
99
+ delete process.env['TERM'];
100
+ process.env['LANG'] = 'en_US.utf-8'; // lowercase
101
+ expect(supportsUnicode()).toBe(true);
102
+ });
103
+ });
104
+ });