ccmanager 1.4.4 → 2.0.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 (44) hide show
  1. package/README.md +34 -1
  2. package/dist/cli.d.ts +4 -0
  3. package/dist/cli.js +30 -2
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +67 -0
  6. package/dist/components/App.d.ts +1 -0
  7. package/dist/components/App.js +107 -37
  8. package/dist/components/Menu.d.ts +6 -1
  9. package/dist/components/Menu.js +227 -50
  10. package/dist/components/Menu.recent-projects.test.d.ts +1 -0
  11. package/dist/components/Menu.recent-projects.test.js +159 -0
  12. package/dist/components/Menu.test.d.ts +1 -0
  13. package/dist/components/Menu.test.js +196 -0
  14. package/dist/components/ProjectList.d.ts +10 -0
  15. package/dist/components/ProjectList.js +231 -0
  16. package/dist/components/ProjectList.recent-projects.test.d.ts +1 -0
  17. package/dist/components/ProjectList.recent-projects.test.js +186 -0
  18. package/dist/components/ProjectList.test.d.ts +1 -0
  19. package/dist/components/ProjectList.test.js +501 -0
  20. package/dist/components/Session.js +4 -14
  21. package/dist/constants/env.d.ts +3 -0
  22. package/dist/constants/env.js +4 -0
  23. package/dist/constants/error.d.ts +6 -0
  24. package/dist/constants/error.js +7 -0
  25. package/dist/hooks/useSearchMode.d.ts +15 -0
  26. package/dist/hooks/useSearchMode.js +67 -0
  27. package/dist/services/configurationManager.d.ts +1 -0
  28. package/dist/services/configurationManager.js +14 -7
  29. package/dist/services/globalSessionOrchestrator.d.ts +16 -0
  30. package/dist/services/globalSessionOrchestrator.js +73 -0
  31. package/dist/services/globalSessionOrchestrator.test.d.ts +1 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +180 -0
  33. package/dist/services/projectManager.d.ts +60 -0
  34. package/dist/services/projectManager.js +418 -0
  35. package/dist/services/projectManager.test.d.ts +1 -0
  36. package/dist/services/projectManager.test.js +342 -0
  37. package/dist/services/sessionManager.d.ts +8 -0
  38. package/dist/services/sessionManager.js +41 -7
  39. package/dist/services/sessionManager.test.js +79 -0
  40. package/dist/services/worktreeService.d.ts +1 -0
  41. package/dist/services/worktreeService.js +20 -5
  42. package/dist/services/worktreeService.test.js +72 -0
  43. package/dist/types/index.d.ts +55 -0
  44. package/package.json +1 -1
@@ -0,0 +1,501 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ // Import the actual component code but skip the useInput hook
5
+ vi.mock('ink', async () => {
6
+ const actual = await vi.importActual('ink');
7
+ return {
8
+ ...actual,
9
+ useInput: vi.fn(),
10
+ };
11
+ });
12
+ // Mock SelectInput to render items as simple text
13
+ vi.mock('ink-select-input', async () => {
14
+ const React = await vi.importActual('react');
15
+ const { Text, Box } = await vi.importActual('ink');
16
+ return {
17
+ default: ({ items }) => {
18
+ return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
19
+ },
20
+ };
21
+ });
22
+ // Mock TextInputWrapper to render as simple text
23
+ vi.mock('./TextInputWrapper.js', async () => {
24
+ const React = await vi.importActual('react');
25
+ const { Text } = await vi.importActual('ink');
26
+ return {
27
+ default: ({ value, placeholder }) => {
28
+ return React.createElement(Text, {}, value || placeholder || '');
29
+ },
30
+ };
31
+ });
32
+ // Mock the projectManager
33
+ vi.mock('../services/projectManager.js', () => ({
34
+ projectManager: {
35
+ instance: {
36
+ discoverProjects: vi.fn(),
37
+ },
38
+ getRecentProjects: vi.fn().mockReturnValue([]),
39
+ },
40
+ }));
41
+ // Now import after mocking
42
+ const { default: ProjectList } = await import('./ProjectList.js');
43
+ const { projectManager } = await import('../services/projectManager.js');
44
+ describe('ProjectList', () => {
45
+ const mockOnSelectProject = vi.fn();
46
+ const mockOnDismissError = vi.fn();
47
+ const mockProjects = [
48
+ {
49
+ name: 'project1',
50
+ path: '/projects/project1',
51
+ relativePath: 'project1',
52
+ isValid: true,
53
+ },
54
+ {
55
+ name: 'project2',
56
+ path: '/projects/project2',
57
+ relativePath: 'project2',
58
+ isValid: true,
59
+ },
60
+ {
61
+ name: 'project3',
62
+ path: '/projects/project3',
63
+ relativePath: 'project3',
64
+ isValid: true,
65
+ },
66
+ ];
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ vi.mocked(projectManager.instance.discoverProjects).mockResolvedValue(mockProjects);
70
+ });
71
+ it('should render project list with correct title', () => {
72
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
73
+ expect(lastFrame()).toContain('CCManager - Multi-Project Mode');
74
+ expect(lastFrame()).toContain('Select a project:');
75
+ });
76
+ it('should display loading state initially', () => {
77
+ // Create a promise that never resolves to keep loading state
78
+ vi.mocked(projectManager.instance.discoverProjects).mockReturnValue(new Promise(() => { }));
79
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
80
+ expect(lastFrame()).toContain('Loading projects...');
81
+ });
82
+ it('should display projects after loading', async () => {
83
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
84
+ // Wait a bit for async operations
85
+ await new Promise(resolve => setTimeout(resolve, 100));
86
+ // Force rerender
87
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
88
+ // Wait for SelectInput to render with our mock
89
+ await vi.waitFor(() => {
90
+ const frame = lastFrame();
91
+ return frame && !frame.includes('Loading projects...');
92
+ }, { timeout: 2000 });
93
+ const frame = lastFrame();
94
+ expect(frame).toContain('0 ❯ project1');
95
+ expect(frame).toContain('1 ❯ project2');
96
+ expect(frame).toContain('2 ❯ project3');
97
+ });
98
+ it('should display error when provided', () => {
99
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Failed to load projects", onDismissError: mockOnDismissError }));
100
+ expect(lastFrame()).toContain('Error: Failed to load projects');
101
+ expect(lastFrame()).toContain('Press any key to dismiss');
102
+ });
103
+ it('should handle project selection via menu', async () => {
104
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
105
+ // Wait a bit for async operations
106
+ await new Promise(resolve => setTimeout(resolve, 100));
107
+ // Force rerender
108
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
109
+ // Wait for component to update after async loading
110
+ await vi.waitFor(() => {
111
+ const frame = lastFrame();
112
+ return frame && !frame.includes('Loading projects...');
113
+ });
114
+ // Verify menu structure
115
+ const frame = lastFrame();
116
+ expect(frame).toContain('0 ❯ project1');
117
+ expect(frame).toContain('R');
118
+ expect(frame).toContain('Refresh');
119
+ expect(frame).toContain('Q');
120
+ expect(frame).toContain('Exit');
121
+ });
122
+ it('should display number shortcuts for first 10 projects', async () => {
123
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
124
+ // Wait a bit for async operations
125
+ await new Promise(resolve => setTimeout(resolve, 100));
126
+ // Force rerender
127
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
128
+ // Wait for projects to load
129
+ await vi.waitFor(() => {
130
+ const frame = lastFrame();
131
+ return frame && !frame.includes('Loading projects...');
132
+ });
133
+ // Verify number prefixes are shown
134
+ const frame = lastFrame();
135
+ expect(frame).toContain('0 ❯ project1');
136
+ expect(frame).toContain('1 ❯ project2');
137
+ expect(frame).toContain('2 ❯ project3');
138
+ });
139
+ it('should display exit option in menu', async () => {
140
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
141
+ // Wait a bit for async operations
142
+ await new Promise(resolve => setTimeout(resolve, 100));
143
+ // Force rerender
144
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
145
+ // Wait for projects to load
146
+ await vi.waitFor(() => {
147
+ const frame = lastFrame();
148
+ return frame && !frame.includes('Loading projects...');
149
+ });
150
+ // Verify exit option is shown
151
+ const frame = lastFrame();
152
+ expect(frame).toContain('Q');
153
+ expect(frame).toContain('Exit');
154
+ });
155
+ it('should display refresh option in menu', async () => {
156
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
157
+ // Wait for projects to load
158
+ await vi.waitFor(() => {
159
+ return lastFrame()?.includes('project1') ?? false;
160
+ });
161
+ // Verify refresh option is shown
162
+ const frame = lastFrame();
163
+ expect(frame).toContain('R');
164
+ expect(frame).toContain('Refresh');
165
+ });
166
+ it('should show empty state when no projects found', async () => {
167
+ vi.mocked(projectManager.instance.discoverProjects).mockResolvedValue([]);
168
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
169
+ // Wait for projects to load
170
+ await vi.waitFor(() => {
171
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
172
+ return lastFrame()?.includes('No git repositories found') ?? false;
173
+ });
174
+ expect(lastFrame()).toContain('No git repositories found in /projects');
175
+ });
176
+ it('should display error message when error prop is provided', () => {
177
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Test error", onDismissError: mockOnDismissError }));
178
+ expect(lastFrame()).toContain('Error: Test error');
179
+ expect(lastFrame()).toContain('Press any key to dismiss');
180
+ });
181
+ describe('search functionality', () => {
182
+ it.skip('should enter search mode when "/" key is pressed', async () => {
183
+ const mockUseInput = vi.mocked(await import('ink')).useInput;
184
+ const inputHandlers = [];
185
+ mockUseInput.mockImplementation(handler => {
186
+ inputHandlers.push(handler);
187
+ });
188
+ // Need to set up stdin.setRawMode for the test
189
+ const originalSetRawMode = process.stdin.setRawMode;
190
+ process.stdin.setRawMode = vi.fn();
191
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
192
+ // Wait for projects to load
193
+ await vi.waitFor(() => {
194
+ return lastFrame()?.includes('project1') ?? false;
195
+ });
196
+ // Simulate pressing "/" key on all handlers (both from useSearchMode and ProjectList)
197
+ inputHandlers.forEach(handler => {
198
+ handler('/', {
199
+ escape: false,
200
+ return: false,
201
+ leftArrow: false,
202
+ rightArrow: false,
203
+ upArrow: false,
204
+ downArrow: false,
205
+ pageDown: false,
206
+ pageUp: false,
207
+ ctrl: false,
208
+ shift: false,
209
+ tab: false,
210
+ backspace: false,
211
+ delete: false,
212
+ meta: false,
213
+ });
214
+ });
215
+ // Wait a bit for state update
216
+ await new Promise(resolve => setTimeout(resolve, 50));
217
+ // Force rerender to see updated state
218
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
219
+ // Should show search input
220
+ expect(lastFrame()).toContain('Search:');
221
+ // Restore original
222
+ process.stdin.setRawMode = originalSetRawMode;
223
+ });
224
+ it('should filter projects based on search query', async () => {
225
+ const mockUseInput = vi.mocked(await import('ink')).useInput;
226
+ let inputHandler = () => { };
227
+ mockUseInput.mockImplementation(handler => {
228
+ inputHandler = handler;
229
+ });
230
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
231
+ // Wait for projects to load
232
+ await vi.waitFor(() => {
233
+ return lastFrame()?.includes('project1') ?? false;
234
+ });
235
+ // Enter search mode
236
+ inputHandler('/', {
237
+ escape: false,
238
+ return: false,
239
+ leftArrow: false,
240
+ rightArrow: false,
241
+ upArrow: false,
242
+ downArrow: false,
243
+ pageDown: false,
244
+ pageUp: false,
245
+ ctrl: false,
246
+ shift: false,
247
+ tab: false,
248
+ backspace: false,
249
+ delete: false,
250
+ meta: false,
251
+ });
252
+ // Force rerender with search active and query
253
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
254
+ // Simulate typing "project2" in search
255
+ // This would be handled by the TextInput component
256
+ // We'll test the filtering logic separately
257
+ });
258
+ it.skip('should exit search mode but keep filter when ESC is pressed in search mode', async () => {
259
+ const mockUseInput = vi.mocked(await import('ink')).useInput;
260
+ let inputHandler = () => { };
261
+ mockUseInput.mockImplementation(handler => {
262
+ inputHandler = handler;
263
+ });
264
+ // Need to set up stdin.setRawMode for the test
265
+ const originalSetRawMode = process.stdin.setRawMode;
266
+ process.stdin.setRawMode = vi.fn();
267
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
268
+ // Wait for projects to load
269
+ await vi.waitFor(() => {
270
+ return lastFrame()?.includes('project1') ?? false;
271
+ });
272
+ // Enter search mode
273
+ inputHandler('/', {
274
+ escape: false,
275
+ return: false,
276
+ leftArrow: false,
277
+ rightArrow: false,
278
+ upArrow: false,
279
+ downArrow: false,
280
+ pageDown: false,
281
+ pageUp: false,
282
+ ctrl: false,
283
+ shift: false,
284
+ tab: false,
285
+ backspace: false,
286
+ delete: false,
287
+ meta: false,
288
+ });
289
+ // Wait a bit for state update
290
+ await new Promise(resolve => setTimeout(resolve, 50));
291
+ // Force rerender
292
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
293
+ // Should be in search mode
294
+ expect(lastFrame()).toContain('Search:');
295
+ // Press ESC
296
+ inputHandler('', {
297
+ escape: true,
298
+ return: false,
299
+ leftArrow: false,
300
+ rightArrow: false,
301
+ upArrow: false,
302
+ downArrow: false,
303
+ pageDown: false,
304
+ pageUp: false,
305
+ ctrl: false,
306
+ shift: false,
307
+ tab: false,
308
+ backspace: false,
309
+ delete: false,
310
+ meta: false,
311
+ });
312
+ // Wait a bit for state update
313
+ await new Promise(resolve => setTimeout(resolve, 50));
314
+ // Force rerender
315
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
316
+ // Should exit search mode
317
+ expect(lastFrame()).not.toContain('Search:');
318
+ // Restore original
319
+ process.stdin.setRawMode = originalSetRawMode;
320
+ });
321
+ it('should not enter search mode when "/" is pressed during error display', async () => {
322
+ const mockUseInput = vi.mocked(await import('ink')).useInput;
323
+ let inputHandler = () => { };
324
+ mockUseInput.mockImplementation(handler => {
325
+ inputHandler = handler;
326
+ });
327
+ // Need to set up stdin.setRawMode for the test
328
+ const originalSetRawMode = process.stdin.setRawMode;
329
+ process.stdin.setRawMode = vi.fn();
330
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Test error", onDismissError: mockOnDismissError }));
331
+ // Press "/" key
332
+ inputHandler('/', {
333
+ escape: false,
334
+ return: false,
335
+ leftArrow: false,
336
+ rightArrow: false,
337
+ upArrow: false,
338
+ downArrow: false,
339
+ pageDown: false,
340
+ pageUp: false,
341
+ ctrl: false,
342
+ shift: false,
343
+ tab: false,
344
+ backspace: false,
345
+ delete: false,
346
+ meta: false,
347
+ });
348
+ // Wait a bit for state update
349
+ await new Promise(resolve => setTimeout(resolve, 50));
350
+ // Force rerender
351
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Test error", onDismissError: mockOnDismissError }));
352
+ // Should not show search input, should dismiss error instead
353
+ expect(lastFrame()).not.toContain('Search:');
354
+ expect(mockOnDismissError).toHaveBeenCalled();
355
+ // Restore original
356
+ process.stdin.setRawMode = originalSetRawMode;
357
+ });
358
+ it.skip('should exit search mode when Enter is pressed but keep filter', async () => {
359
+ const mockUseInput = vi.mocked(await import('ink')).useInput;
360
+ let inputHandler = () => { };
361
+ mockUseInput.mockImplementation(handler => {
362
+ inputHandler = handler;
363
+ });
364
+ // Need to set up stdin.setRawMode for the test
365
+ const originalSetRawMode = process.stdin.setRawMode;
366
+ process.stdin.setRawMode = vi.fn();
367
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
368
+ // Wait for projects to load
369
+ await vi.waitFor(() => {
370
+ return lastFrame()?.includes('project1') ?? false;
371
+ });
372
+ // Enter search mode
373
+ inputHandler('/', {
374
+ escape: false,
375
+ return: false,
376
+ leftArrow: false,
377
+ rightArrow: false,
378
+ upArrow: false,
379
+ downArrow: false,
380
+ pageDown: false,
381
+ pageUp: false,
382
+ ctrl: false,
383
+ shift: false,
384
+ tab: false,
385
+ backspace: false,
386
+ delete: false,
387
+ meta: false,
388
+ });
389
+ // Wait a bit for state update
390
+ await new Promise(resolve => setTimeout(resolve, 50));
391
+ // Force rerender
392
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
393
+ // Should be in search mode
394
+ expect(lastFrame()).toContain('Search:');
395
+ // Press Enter
396
+ inputHandler('', {
397
+ escape: false,
398
+ return: true,
399
+ leftArrow: false,
400
+ rightArrow: false,
401
+ upArrow: false,
402
+ downArrow: false,
403
+ pageDown: false,
404
+ pageUp: false,
405
+ ctrl: false,
406
+ shift: false,
407
+ tab: false,
408
+ backspace: false,
409
+ delete: false,
410
+ meta: false,
411
+ });
412
+ // Wait a bit for state update
413
+ await new Promise(resolve => setTimeout(resolve, 50));
414
+ // Force rerender
415
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
416
+ // Should exit search mode
417
+ expect(lastFrame()).not.toContain('Search:');
418
+ // Should not have called onSelectProject
419
+ expect(mockOnSelectProject).not.toHaveBeenCalled();
420
+ // Restore original
421
+ process.stdin.setRawMode = originalSetRawMode;
422
+ });
423
+ it('should clear filter when ESC is pressed outside search mode', async () => {
424
+ const mockUseInput = vi.mocked(await import('ink')).useInput;
425
+ let inputHandler = () => { };
426
+ mockUseInput.mockImplementation(handler => {
427
+ inputHandler = handler;
428
+ });
429
+ // Need to set up stdin.setRawMode for the test
430
+ const originalSetRawMode = process.stdin.setRawMode;
431
+ process.stdin.setRawMode = vi.fn();
432
+ const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
433
+ // Wait for projects to load
434
+ await vi.waitFor(() => {
435
+ return lastFrame()?.includes('project1') ?? false;
436
+ });
437
+ // Enter search mode
438
+ inputHandler('/', {
439
+ escape: false,
440
+ return: false,
441
+ leftArrow: false,
442
+ rightArrow: false,
443
+ upArrow: false,
444
+ downArrow: false,
445
+ pageDown: false,
446
+ pageUp: false,
447
+ ctrl: false,
448
+ shift: false,
449
+ tab: false,
450
+ backspace: false,
451
+ delete: false,
452
+ meta: false,
453
+ });
454
+ await new Promise(resolve => setTimeout(resolve, 50));
455
+ // Exit search mode with Enter (keeping filter)
456
+ inputHandler('', {
457
+ escape: false,
458
+ return: true,
459
+ leftArrow: false,
460
+ rightArrow: false,
461
+ upArrow: false,
462
+ downArrow: false,
463
+ pageDown: false,
464
+ pageUp: false,
465
+ ctrl: false,
466
+ shift: false,
467
+ tab: false,
468
+ backspace: false,
469
+ delete: false,
470
+ meta: false,
471
+ });
472
+ await new Promise(resolve => setTimeout(resolve, 50));
473
+ // Now press ESC outside search mode to clear filter
474
+ inputHandler('', {
475
+ escape: true,
476
+ return: false,
477
+ leftArrow: false,
478
+ rightArrow: false,
479
+ upArrow: false,
480
+ downArrow: false,
481
+ pageDown: false,
482
+ pageUp: false,
483
+ ctrl: false,
484
+ shift: false,
485
+ tab: false,
486
+ backspace: false,
487
+ delete: false,
488
+ meta: false,
489
+ });
490
+ await new Promise(resolve => setTimeout(resolve, 50));
491
+ // Force rerender
492
+ rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
493
+ // Should display all projects (filter cleared)
494
+ expect(lastFrame()).toContain('project1');
495
+ expect(lastFrame()).toContain('project2');
496
+ expect(lastFrame()).toContain('project3');
497
+ // Restore original
498
+ process.stdin.setRawMode = originalSetRawMode;
499
+ });
500
+ });
501
+ });
@@ -73,20 +73,10 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
73
73
  const handleResize = () => {
74
74
  const cols = process.stdout.columns || 80;
75
75
  const rows = process.stdout.rows || 24;
76
- try {
77
- session.process.resize(cols, rows);
78
- // Also resize the virtual terminal
79
- if (session.terminal) {
80
- try {
81
- session.terminal.resize(cols, rows);
82
- }
83
- catch {
84
- // Suppress xterm.js parsing errors
85
- }
86
- }
87
- }
88
- catch {
89
- // Suppress PTY resize errors
76
+ session.process.resize(cols, rows);
77
+ // Also resize the virtual terminal
78
+ if (session.terminal) {
79
+ session.terminal.resize(cols, rows);
90
80
  }
91
81
  };
92
82
  stdout.on('resize', handleResize);
@@ -0,0 +1,3 @@
1
+ export declare const ENV_VARS: {
2
+ readonly MULTI_PROJECT_ROOT: "CCMANAGER_MULTI_PROJECT_ROOT";
3
+ };
@@ -0,0 +1,4 @@
1
+ // Environment variable names
2
+ export const ENV_VARS = {
3
+ MULTI_PROJECT_ROOT: 'CCMANAGER_MULTI_PROJECT_ROOT',
4
+ };
@@ -0,0 +1,6 @@
1
+ export declare const MULTI_PROJECT_ERRORS: {
2
+ readonly NO_PROJECTS_DIR: "CCMANAGER_MULTI_PROJECT_ROOT environment variable is required in multi-project mode";
3
+ readonly INVALID_PROJECTS_DIR: "CCMANAGER_MULTI_PROJECT_ROOT points to a non-existent directory";
4
+ readonly NO_PROJECTS_FOUND: "No git repositories found in the projects directory";
5
+ readonly CORRUPTED_REPO: "Git repository is corrupted or inaccessible";
6
+ };
@@ -0,0 +1,7 @@
1
+ // Error messages for multi-project mode
2
+ export const MULTI_PROJECT_ERRORS = {
3
+ NO_PROJECTS_DIR: 'CCMANAGER_MULTI_PROJECT_ROOT environment variable is required in multi-project mode',
4
+ INVALID_PROJECTS_DIR: 'CCMANAGER_MULTI_PROJECT_ROOT points to a non-existent directory',
5
+ NO_PROJECTS_FOUND: 'No git repositories found in the projects directory',
6
+ CORRUPTED_REPO: 'Git repository is corrupted or inaccessible',
7
+ };
@@ -0,0 +1,15 @@
1
+ interface UseSearchModeOptions {
2
+ onEscape?: () => void;
3
+ onEnter?: () => void;
4
+ skipInTest?: boolean;
5
+ isDisabled?: boolean;
6
+ }
7
+ interface UseSearchModeReturn {
8
+ isSearchMode: boolean;
9
+ searchQuery: string;
10
+ selectedIndex: number;
11
+ setSearchQuery: (query: string) => void;
12
+ setSelectedIndex: (index: number) => void;
13
+ }
14
+ export declare function useSearchMode(itemsLength: number, options?: UseSearchModeOptions): UseSearchModeReturn;
15
+ export {};
@@ -0,0 +1,67 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useInput } from 'ink';
3
+ export function useSearchMode(itemsLength, options = {}) {
4
+ const [isSearchMode, setIsSearchMode] = useState(false);
5
+ const [searchQuery, setSearchQuery] = useState('');
6
+ const [selectedIndex, setSelectedIndex] = useState(0);
7
+ const { onEscape, onEnter, skipInTest = true, isDisabled = false } = options;
8
+ // Reset selected index when items change in search mode
9
+ useEffect(() => {
10
+ if (isSearchMode && selectedIndex >= itemsLength) {
11
+ setSelectedIndex(Math.max(0, itemsLength - 1));
12
+ }
13
+ }, [itemsLength, isSearchMode, selectedIndex]);
14
+ // Handle keyboard input
15
+ useInput((input, key) => {
16
+ // Skip in test environment to avoid stdin.ref error
17
+ if (skipInTest && !process.stdin.setRawMode) {
18
+ return;
19
+ }
20
+ // Skip if disabled
21
+ if (isDisabled) {
22
+ return;
23
+ }
24
+ // Handle ESC key
25
+ if (key.escape) {
26
+ if (isSearchMode) {
27
+ // Exit search mode but keep filter
28
+ setIsSearchMode(false);
29
+ onEscape?.();
30
+ }
31
+ else {
32
+ // Clear filter when not in search mode
33
+ setSearchQuery('');
34
+ }
35
+ return;
36
+ }
37
+ // Handle Enter key in search mode to exit search mode but keep filter
38
+ if (key.return && isSearchMode) {
39
+ setIsSearchMode(false);
40
+ onEnter?.();
41
+ return;
42
+ }
43
+ // Handle arrow keys in search mode for navigation
44
+ if (isSearchMode) {
45
+ if (key.upArrow && selectedIndex > 0) {
46
+ setSelectedIndex(selectedIndex - 1);
47
+ }
48
+ else if (key.downArrow && selectedIndex < itemsLength - 1) {
49
+ setSelectedIndex(selectedIndex + 1);
50
+ }
51
+ return;
52
+ }
53
+ // Handle "/" key to enter search mode
54
+ if (input === '/') {
55
+ setIsSearchMode(true);
56
+ setSelectedIndex(0);
57
+ return;
58
+ }
59
+ }, { isActive: !isDisabled });
60
+ return {
61
+ isSearchMode,
62
+ searchQuery,
63
+ selectedIndex,
64
+ setSearchQuery,
65
+ setSelectedIndex,
66
+ };
67
+ }
@@ -2,6 +2,7 @@ import { ConfigurationData, StatusHookConfig, ShortcutConfig, WorktreeConfig, Co
2
2
  export declare class ConfigurationManager {
3
3
  private configPath;
4
4
  private legacyShortcutsPath;
5
+ private configDir;
5
6
  private config;
6
7
  constructor();
7
8
  private loadConfig;