ccmanager 3.8.1 → 3.10.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 (34) hide show
  1. package/dist/components/App.js +11 -2
  2. package/dist/components/App.test.js +4 -2
  3. package/dist/components/Configuration.js +14 -0
  4. package/dist/components/ConfigureMerge.d.ts +6 -0
  5. package/dist/components/ConfigureMerge.js +81 -0
  6. package/dist/components/Dashboard.d.ts +12 -0
  7. package/dist/components/Dashboard.js +443 -0
  8. package/dist/components/Dashboard.test.js +348 -0
  9. package/dist/components/MergeWorktree.js +20 -7
  10. package/dist/services/config/configEditor.d.ts +3 -1
  11. package/dist/services/config/configEditor.js +13 -0
  12. package/dist/services/config/configReader.d.ts +2 -1
  13. package/dist/services/config/configReader.js +12 -0
  14. package/dist/services/config/globalConfigManager.d.ts +3 -1
  15. package/dist/services/config/globalConfigManager.js +7 -0
  16. package/dist/services/config/projectConfigManager.d.ts +3 -1
  17. package/dist/services/config/projectConfigManager.js +8 -0
  18. package/dist/services/globalSessionOrchestrator.d.ts +1 -0
  19. package/dist/services/globalSessionOrchestrator.js +3 -0
  20. package/dist/services/projectManager.d.ts +7 -1
  21. package/dist/services/projectManager.js +26 -10
  22. package/dist/services/worktreeService.d.ts +4 -26
  23. package/dist/services/worktreeService.js +15 -32
  24. package/dist/services/worktreeService.merge.test.js +179 -0
  25. package/dist/services/worktreeService.test.js +149 -3
  26. package/dist/types/index.d.ts +9 -1
  27. package/dist/utils/worktreeUtils.d.ts +1 -2
  28. package/package.json +6 -6
  29. package/dist/components/ProjectList.d.ts +0 -10
  30. package/dist/components/ProjectList.js +0 -233
  31. package/dist/components/ProjectList.recent-projects.test.js +0 -193
  32. package/dist/components/ProjectList.test.js +0 -620
  33. /package/dist/components/{ProjectList.recent-projects.test.d.ts → Dashboard.test.d.ts} +0 -0
  34. /package/dist/{components/ProjectList.test.d.ts → services/worktreeService.merge.test.d.ts} +0 -0
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
3
3
  import { useApp, Box, Text } from 'ink';
4
4
  import { Effect } from 'effect';
5
5
  import Menu from './Menu.js';
6
- import ProjectList from './ProjectList.js';
6
+ import Dashboard from './Dashboard.js';
7
7
  import Session from './Session.js';
8
8
  import NewWorktree from './NewWorktree.js';
9
9
  import DeleteWorktree from './DeleteWorktree.js';
@@ -363,6 +363,15 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
363
363
  projectManager.addRecentProject(project);
364
364
  navigateWithClear('menu');
365
365
  };
366
+ const handleSelectSessionFromDashboard = (session, project) => {
367
+ // Set the correct session manager for this project
368
+ const projectSessionManager = globalSessionOrchestrator.getManagerForProject(project.path);
369
+ setSessionManager(projectSessionManager);
370
+ setWorktreeService(new WorktreeService(project.path));
371
+ // Don't set selectedProject so session exit returns to Dashboard
372
+ setActiveSession(session);
373
+ navigateWithClear('session');
374
+ };
366
375
  const handleBackToProjectList = () => {
367
376
  // Sessions persist in their project-specific managers
368
377
  setSelectedProject(null);
@@ -378,7 +387,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
378
387
  if (!projectsDir) {
379
388
  return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: ["Error: ", MULTI_PROJECT_ERRORS.NO_PROJECTS_DIR] }) }));
380
389
  }
381
- return (_jsx(ProjectList, { projectsDir: projectsDir, onSelectProject: handleSelectProject, error: error, onDismissError: () => setError(null) }));
390
+ return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, error: error, onDismissError: () => setError(null), version: version }));
382
391
  }
383
392
  if (view === 'menu') {
384
393
  return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: handleSelectWorktree, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
@@ -54,6 +54,8 @@ vi.mock('../services/globalSessionOrchestrator.js', () => ({
54
54
  globalSessionOrchestrator: {
55
55
  getManagerForProject: getManagerForProjectMock,
56
56
  destroyAllSessions: vi.fn(),
57
+ getProjectPaths: vi.fn(() => []),
58
+ getProjectSessions: vi.fn(() => []),
57
59
  },
58
60
  }));
59
61
  vi.mock('../services/projectManager.js', () => ({
@@ -71,7 +73,7 @@ vi.mock('../services/worktreeService.js', () => ({
71
73
  }),
72
74
  }));
73
75
  vi.mock('./Menu.js', createInkMock('Menu View', props => (menuProps = props)));
74
- vi.mock('./ProjectList.js', createInkMock('Project List View', () => { }));
76
+ vi.mock('./Dashboard.js', createInkMock('Dashboard View', () => { }));
75
77
  vi.mock('./NewWorktree.js', createInkMock('New Worktree View', props => {
76
78
  newWorktreeProps = props;
77
79
  }));
@@ -139,7 +141,7 @@ describe('App component view state', () => {
139
141
  process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';
140
142
  const { lastFrame, unmount } = render(_jsx(App, { multiProject: true, version: "test" }));
141
143
  await flush();
142
- expect(lastFrame()).toContain('Project List View');
144
+ expect(lastFrame()).toContain('Dashboard View');
143
145
  unmount();
144
146
  if (original !== undefined) {
145
147
  process.env[ENV_VARS.MULTI_PROJECT_ROOT] = original;
@@ -7,6 +7,7 @@ import ConfigureStatusHooks from './ConfigureStatusHooks.js';
7
7
  import ConfigureWorktreeHooks from './ConfigureWorktreeHooks.js';
8
8
  import ConfigureWorktree from './ConfigureWorktree.js';
9
9
  import ConfigureCommand from './ConfigureCommand.js';
10
+ import ConfigureMerge from './ConfigureMerge.js';
10
11
  import ConfigureOther from './ConfigureOther.js';
11
12
  import { shortcutManager } from '../services/shortcutManager.js';
12
13
  import { ConfigEditorProvider } from '../contexts/ConfigEditorContext.js';
@@ -34,6 +35,10 @@ const ConfigurationContent = ({ scope, onComplete }) => {
34
35
  label: 'C 🚀 Configure Command Presets',
35
36
  value: 'presets',
36
37
  },
38
+ {
39
+ label: 'M 🔀 Configure Merge/Rebase',
40
+ value: 'mergeConfig',
41
+ },
37
42
  {
38
43
  label: 'O 🧪 Other & Experimental',
39
44
  value: 'other',
@@ -62,6 +67,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
62
67
  else if (item.value === 'presets') {
63
68
  setView('presets');
64
69
  }
70
+ else if (item.value === 'mergeConfig') {
71
+ setView('mergeConfig');
72
+ }
65
73
  else if (item.value === 'other') {
66
74
  setView('other');
67
75
  }
@@ -90,6 +98,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
90
98
  case 'c':
91
99
  setView('presets');
92
100
  break;
101
+ case 'm':
102
+ setView('mergeConfig');
103
+ break;
93
104
  case 'o':
94
105
  setView('other');
95
106
  break;
@@ -117,6 +128,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
117
128
  if (view === 'presets') {
118
129
  return _jsx(ConfigureCommand, { onComplete: handleSubMenuComplete });
119
130
  }
131
+ if (view === 'mergeConfig') {
132
+ return _jsx(ConfigureMerge, { onComplete: handleSubMenuComplete });
133
+ }
120
134
  if (view === 'other') {
121
135
  return _jsx(ConfigureOther, { onComplete: handleSubMenuComplete });
122
136
  }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ interface ConfigureMergeProps {
3
+ onComplete: () => void;
4
+ }
5
+ declare const ConfigureMerge: React.FC<ConfigureMergeProps>;
6
+ export default ConfigureMerge;
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import SelectInput from 'ink-select-input';
5
+ import TextInputWrapper from './TextInputWrapper.js';
6
+ import { useConfigEditor } from '../contexts/ConfigEditorContext.js';
7
+ import { shortcutManager } from '../services/shortcutManager.js';
8
+ const DEFAULT_MERGE_ARGS = ['--no-ff'];
9
+ const DEFAULT_REBASE_ARGS = [];
10
+ const ConfigureMerge = ({ onComplete }) => {
11
+ const configEditor = useConfigEditor();
12
+ const scope = configEditor.getScope();
13
+ const currentConfig = configEditor.getMergeConfig() || {};
14
+ const [mergeConfig, setMergeConfig] = useState(currentConfig);
15
+ const [editField, setEditField] = useState(null);
16
+ const [inputValue, setInputValue] = useState('');
17
+ const isInheriting = scope === 'project' && !configEditor.hasProjectOverride('mergeConfig');
18
+ const getMergeArgs = () => mergeConfig.mergeArgs ?? DEFAULT_MERGE_ARGS;
19
+ const getRebaseArgs = () => mergeConfig.rebaseArgs ?? DEFAULT_REBASE_ARGS;
20
+ const formatArgs = (args) => args.length > 0 ? args.join(' ') : '(none)';
21
+ const menuItems = [
22
+ {
23
+ label: `Merge Arguments: ${formatArgs(getMergeArgs())}`,
24
+ value: 'mergeArgs',
25
+ },
26
+ {
27
+ label: `Rebase Arguments: ${formatArgs(getRebaseArgs())}`,
28
+ value: 'rebaseArgs',
29
+ },
30
+ { label: '-----', value: 'separator' },
31
+ { label: '<- Back', value: 'back' },
32
+ ];
33
+ const handleSelect = (item) => {
34
+ if (item.value === 'separator')
35
+ return;
36
+ if (item.value === 'back') {
37
+ onComplete();
38
+ return;
39
+ }
40
+ const field = item.value;
41
+ setEditField(field);
42
+ switch (field) {
43
+ case 'mergeArgs':
44
+ setInputValue(getMergeArgs().join(' '));
45
+ break;
46
+ case 'rebaseArgs':
47
+ setInputValue(getRebaseArgs().join(' '));
48
+ break;
49
+ }
50
+ };
51
+ const handleFieldUpdate = (value) => {
52
+ const args = value.trim() ? value.trim().split(/\s+/) : [];
53
+ const updated = { ...mergeConfig, [editField]: args };
54
+ setMergeConfig(updated);
55
+ configEditor.setMergeConfig(updated);
56
+ setEditField(null);
57
+ setInputValue('');
58
+ };
59
+ useInput((input, key) => {
60
+ if (shortcutManager.matchesShortcut('cancel', input, key)) {
61
+ if (editField) {
62
+ setEditField(null);
63
+ setInputValue('');
64
+ }
65
+ else {
66
+ onComplete();
67
+ }
68
+ return;
69
+ }
70
+ });
71
+ if (editField) {
72
+ const titles = {
73
+ mergeArgs: 'Enter merge arguments (space-separated, default: --no-ff):',
74
+ rebaseArgs: 'Enter rebase arguments (space-separated, default: none):',
75
+ };
76
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Edit Merge Config" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: titles[editField] }) }), _jsx(Box, { children: _jsx(TextInputWrapper, { value: inputValue, onChange: setInputValue, onSubmit: handleFieldUpdate, placeholder: "e.g., --no-ff or leave empty" }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press Enter to save, ", shortcutManager.getShortcutDisplay('cancel'), ' ', "to cancel"] }) })] }));
77
+ }
78
+ const scopeLabel = scope === 'project' ? 'Project' : 'Global';
79
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Merge/Rebase (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Configure arguments for merge and rebase operations" }) }), _jsx(SelectInput, { items: menuItems, onSelect: handleSelect }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press Enter to edit, ", shortcutManager.getShortcutDisplay('cancel'), " to go back"] }) })] }));
80
+ };
81
+ export default ConfigureMerge;
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { GitProject, Session as ISession } from '../types/index.js';
3
+ interface DashboardProps {
4
+ projectsDir: string;
5
+ onSelectSession: (session: ISession, project: GitProject) => void;
6
+ onSelectProject: (project: GitProject) => void;
7
+ error: string | null;
8
+ onDismissError: () => void;
9
+ version: string;
10
+ }
11
+ declare const Dashboard: React.FC<DashboardProps>;
12
+ export default Dashboard;
@@ -0,0 +1,443 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { Effect } from 'effect';
5
+ import SelectInput from 'ink-select-input';
6
+ import stripAnsi from 'strip-ansi';
7
+ import { projectManager } from '../services/projectManager.js';
8
+ import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
9
+ import { SessionManager } from '../services/sessionManager.js';
10
+ import { WorktreeService } from '../services/worktreeService.js';
11
+ import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
12
+ import { useSearchMode } from '../hooks/useSearchMode.js';
13
+ import { useGitStatus } from '../hooks/useGitStatus.js';
14
+ import { truncateString, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
15
+ import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
16
+ import TextInputWrapper from './TextInputWrapper.js';
17
+ const MAX_BRANCH_NAME_LENGTH = 70;
18
+ const createSeparatorWithText = (text, totalWidth = 35) => {
19
+ const textWithSpaces = ` ${text} `;
20
+ const textLength = textWithSpaces.length;
21
+ const remainingWidth = totalWidth - textLength;
22
+ const leftDashes = Math.floor(remainingWidth / 2);
23
+ const rightDashes = Math.ceil(remainingWidth / 2);
24
+ return '─'.repeat(leftDashes) + textWithSpaces + '─'.repeat(rightDashes);
25
+ };
26
+ const formatErrorMessage = (error) => {
27
+ switch (error._tag) {
28
+ case 'ProcessError':
29
+ return `Process error: ${error.message}`;
30
+ case 'ConfigError':
31
+ return `Configuration error (${error.reason}): ${error.details}`;
32
+ case 'GitError':
33
+ return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
34
+ case 'FileSystemError':
35
+ return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
36
+ case 'ValidationError':
37
+ return `Validation failed for ${error.field}: ${error.constraint}`;
38
+ }
39
+ };
40
+ /** Sort sessions: busy first, then waiting/pending, then idle. Within same state, by lastActivity desc. */
41
+ function sessionSortKey(session) {
42
+ const stateData = session.stateMutex.getSnapshot();
43
+ switch (stateData.state) {
44
+ case 'busy':
45
+ return 0;
46
+ case 'waiting_input':
47
+ case 'pending_auto_approval':
48
+ return 1;
49
+ case 'idle':
50
+ return 2;
51
+ }
52
+ }
53
+ /** Resolve the display name for a project, using relativePath if names collide. */
54
+ function resolveProjectDisplayNames(projects) {
55
+ const nameCount = new Map();
56
+ for (const p of projects) {
57
+ nameCount.set(p.name, (nameCount.get(p.name) || 0) + 1);
58
+ }
59
+ const displayNames = new Map();
60
+ for (const p of projects) {
61
+ displayNames.set(p.path, nameCount.get(p.name) > 1 ? p.relativePath : p.name);
62
+ }
63
+ return displayNames;
64
+ }
65
+ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDismissError, version, }) => {
66
+ const [projects, setProjects] = useState([]);
67
+ const [recentProjects, setRecentProjects] = useState([]);
68
+ const [loading, setLoading] = useState(true);
69
+ const [loadError, setLoadError] = useState(null);
70
+ const [items, setItems] = useState([]);
71
+ // Session-related state
72
+ const [sessionEntries, setSessionEntries] = useState([]);
73
+ const [baseSessionWorktrees, setBaseSessionWorktrees] = useState([]);
74
+ const [sessionRefreshKey, setSessionRefreshKey] = useState(0);
75
+ const { stdout } = useStdout();
76
+ const fixedRows = 6;
77
+ const displayError = error || loadError;
78
+ const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
79
+ isDisabled: !!displayError,
80
+ skipInTest: false,
81
+ });
82
+ const limit = Math.max(5, stdout.rows - fixedRows - (isSearchMode ? 1 : 0) - (displayError ? 3 : 0));
83
+ // Git status polling for session worktrees
84
+ const enrichedWorktrees = useGitStatus(baseSessionWorktrees, baseSessionWorktrees.length > 0 ? 'main' : null);
85
+ // Discover projects on mount
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ const loadProjects = async () => {
89
+ setLoading(true);
90
+ setLoadError(null);
91
+ const result = await Effect.runPromise(Effect.either(projectManager.instance.discoverProjectsEffect(projectsDir)));
92
+ if (cancelled)
93
+ return;
94
+ if (result._tag === 'Left') {
95
+ setLoadError(formatErrorMessage(result.left));
96
+ setLoading(false);
97
+ return;
98
+ }
99
+ setProjects(result.right);
100
+ setRecentProjects(projectManager.getRecentProjects(0));
101
+ setLoading(false);
102
+ };
103
+ loadProjects();
104
+ return () => {
105
+ cancelled = true;
106
+ };
107
+ }, [projectsDir]);
108
+ // Load session worktree data on mount + sessionRefreshKey
109
+ useEffect(() => {
110
+ let cancelled = false;
111
+ const loadSessionData = async () => {
112
+ const projectPaths = globalSessionOrchestrator.getProjectPaths();
113
+ const entries = [];
114
+ const worktrees = [];
115
+ // Build a project lookup map
116
+ const projectByPath = new Map();
117
+ for (const p of projects) {
118
+ projectByPath.set(p.path, p);
119
+ }
120
+ const displayNames = resolveProjectDisplayNames(projects);
121
+ for (const projectPath of projectPaths) {
122
+ const sessions = globalSessionOrchestrator.getProjectSessions(projectPath);
123
+ if (sessions.length === 0)
124
+ continue;
125
+ // Load worktrees for this project to resolve branch names
126
+ const ws = new WorktreeService(projectPath);
127
+ const result = await Effect.runPromise(Effect.either(ws.getWorktreesEffect()));
128
+ if (cancelled)
129
+ return;
130
+ if (result._tag === 'Left')
131
+ continue;
132
+ const projectWorktrees = result.right;
133
+ const project = projectByPath.get(projectPath);
134
+ const projectName = displayNames.get(projectPath) ||
135
+ project?.name ||
136
+ projectPath.split('/').pop() ||
137
+ projectPath;
138
+ // Mark worktrees that have sessions
139
+ for (const wt of projectWorktrees) {
140
+ wt.hasSession = sessions.some(s => s.worktreePath === wt.path);
141
+ }
142
+ for (const session of sessions) {
143
+ const wt = projectWorktrees.find(w => w.path === session.worktreePath);
144
+ if (!wt)
145
+ continue;
146
+ entries.push({
147
+ session,
148
+ projectPath,
149
+ projectName,
150
+ worktree: wt,
151
+ });
152
+ worktrees.push(wt);
153
+ }
154
+ }
155
+ if (cancelled)
156
+ return;
157
+ // Sort sessions: busy > waiting > idle, then by lastActivity desc
158
+ entries.sort((a, b) => {
159
+ const keyA = sessionSortKey(a.session);
160
+ const keyB = sessionSortKey(b.session);
161
+ if (keyA !== keyB)
162
+ return keyA - keyB;
163
+ return (b.session.lastActivity.getTime() - a.session.lastActivity.getTime());
164
+ });
165
+ setSessionEntries(entries);
166
+ setBaseSessionWorktrees(prev => {
167
+ // Avoid restarting git status polling if the set of paths hasn't changed
168
+ const prevPaths = prev
169
+ .map(w => w.path)
170
+ .sort()
171
+ .join('\0');
172
+ const newPaths = worktrees
173
+ .map(w => w.path)
174
+ .sort()
175
+ .join('\0');
176
+ return prevPaths === newPaths ? prev : worktrees;
177
+ });
178
+ };
179
+ loadSessionData();
180
+ return () => {
181
+ cancelled = true;
182
+ };
183
+ }, [sessionRefreshKey, projects]);
184
+ // Subscribe to session events from all managers
185
+ useEffect(() => {
186
+ const refresh = () => setSessionRefreshKey(k => k + 1);
187
+ const projectPaths = globalSessionOrchestrator.getProjectPaths();
188
+ const managers = projectPaths.map(p => globalSessionOrchestrator.getManagerForProject(p));
189
+ for (const mgr of managers) {
190
+ mgr.on('sessionCreated', refresh);
191
+ mgr.on('sessionDestroyed', refresh);
192
+ mgr.on('sessionStateChanged', refresh);
193
+ }
194
+ return () => {
195
+ for (const mgr of managers) {
196
+ mgr.off('sessionCreated', refresh);
197
+ mgr.off('sessionDestroyed', refresh);
198
+ mgr.off('sessionStateChanged', refresh);
199
+ }
200
+ };
201
+ }, [sessionRefreshKey]);
202
+ // Build display items
203
+ const projectDisplayNames = useMemo(() => resolveProjectDisplayNames(projects), [projects]);
204
+ useEffect(() => {
205
+ const menuItems = [];
206
+ let currentIndex = 0;
207
+ // --- Active Sessions section ---
208
+ if (sessionEntries.length > 0) {
209
+ // Build WorktreeItems for column alignment
210
+ const sessionWorkItems = sessionEntries.map(entry => {
211
+ // Use enriched worktree if available (has git status)
212
+ const wt = enrichedWorktrees.find(w => w.path === entry.worktree.path) ||
213
+ entry.worktree;
214
+ const stateData = entry.session.stateMutex.getSnapshot();
215
+ const status = ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
216
+ const fullBranchName = wt.branch
217
+ ? wt.branch.replace('refs/heads/', '')
218
+ : wt.path.split('/').pop() || 'detached';
219
+ const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
220
+ const isMain = wt.isMainWorktree ? ' (main)' : '';
221
+ const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${status}`;
222
+ let fileChanges = '';
223
+ let aheadBehind = '';
224
+ let parentBranch = '';
225
+ let itemError = '';
226
+ if (wt.gitStatus) {
227
+ fileChanges = formatGitFileChanges(wt.gitStatus);
228
+ aheadBehind = formatGitAheadBehind(wt.gitStatus);
229
+ parentBranch = formatParentBranch(wt.gitStatus.parentBranch, fullBranchName);
230
+ }
231
+ else if (wt.gitStatusError) {
232
+ itemError = `\x1b[31m[git error]\x1b[0m`;
233
+ }
234
+ else {
235
+ fileChanges = '\x1b[90m[fetching...]\x1b[0m';
236
+ }
237
+ return {
238
+ worktree: wt,
239
+ session: entry.session,
240
+ baseLabel,
241
+ fileChanges,
242
+ aheadBehind,
243
+ parentBranch,
244
+ error: itemError,
245
+ lengths: {
246
+ base: stripAnsi(baseLabel).length,
247
+ fileChanges: stripAnsi(fileChanges).length,
248
+ aheadBehind: stripAnsi(aheadBehind).length,
249
+ parentBranch: stripAnsi(parentBranch).length,
250
+ },
251
+ };
252
+ });
253
+ const columns = calculateColumnPositions(sessionWorkItems);
254
+ if (!isSearchMode) {
255
+ menuItems.push({
256
+ type: 'common',
257
+ label: createSeparatorWithText('Active Sessions'),
258
+ value: 'separator-sessions',
259
+ });
260
+ }
261
+ // Filter by search query
262
+ const filteredEntries = searchQuery
263
+ ? sessionEntries.filter((_entry, i) => {
264
+ const item = sessionWorkItems[i];
265
+ return stripAnsi(item.baseLabel)
266
+ .toLowerCase()
267
+ .includes(searchQuery.toLowerCase());
268
+ })
269
+ : sessionEntries;
270
+ filteredEntries.forEach(entry => {
271
+ const itemIndex = sessionEntries.indexOf(entry);
272
+ const workItem = sessionWorkItems[itemIndex];
273
+ const label = assembleWorktreeLabel(workItem, columns);
274
+ const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
275
+ const project = {
276
+ path: entry.projectPath,
277
+ name: entry.projectName,
278
+ relativePath: projects.find(p => p.path === entry.projectPath)?.relativePath ||
279
+ entry.projectPath,
280
+ isValid: true,
281
+ };
282
+ menuItems.push({
283
+ type: 'session',
284
+ label: numberPrefix + label,
285
+ value: `session-${entry.session.id}`,
286
+ session: entry.session,
287
+ project,
288
+ });
289
+ currentIndex++;
290
+ });
291
+ }
292
+ // --- Projects section ---
293
+ const filteredRecentProjects = searchQuery
294
+ ? recentProjects.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
295
+ : recentProjects;
296
+ const filteredProjects = searchQuery
297
+ ? projects.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
298
+ : projects;
299
+ // Deduplicate: recent projects first, then remaining
300
+ const recentPaths = new Set(filteredRecentProjects.map(rp => rp.path));
301
+ const nonRecentProjects = filteredProjects.filter(p => !recentPaths.has(p.path));
302
+ // Build ordered project list: recent first, then alphabetical
303
+ const orderedProjects = [];
304
+ for (const rp of filteredRecentProjects) {
305
+ const full = projects.find(p => p.path === rp.path);
306
+ if (full)
307
+ orderedProjects.push(full);
308
+ }
309
+ orderedProjects.push(...nonRecentProjects);
310
+ if (orderedProjects.length > 0 && !isSearchMode) {
311
+ menuItems.push({
312
+ type: 'common',
313
+ label: createSeparatorWithText('Projects'),
314
+ value: 'separator-projects',
315
+ });
316
+ }
317
+ orderedProjects.forEach(project => {
318
+ const projectSessions = globalSessionOrchestrator.getProjectSessions(project.path);
319
+ const counts = SessionManager.getSessionCounts(projectSessions);
320
+ const countsFormatted = SessionManager.formatSessionCounts(counts);
321
+ const displayName = projectDisplayNames.get(project.path) || project.name;
322
+ const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
323
+ menuItems.push({
324
+ type: 'project',
325
+ label: numberPrefix + displayName + countsFormatted,
326
+ value: project.path,
327
+ project,
328
+ });
329
+ currentIndex++;
330
+ });
331
+ // --- Other section ---
332
+ if (!isSearchMode) {
333
+ menuItems.push({
334
+ type: 'common',
335
+ label: createSeparatorWithText('Other'),
336
+ value: 'separator-other',
337
+ });
338
+ menuItems.push({
339
+ type: 'common',
340
+ label: `R 🔄 Refresh`,
341
+ value: 'refresh',
342
+ });
343
+ menuItems.push({
344
+ type: 'common',
345
+ label: `Q ${MENU_ICONS.EXIT} Exit`,
346
+ value: 'exit',
347
+ });
348
+ }
349
+ setItems(menuItems);
350
+ }, [
351
+ sessionEntries,
352
+ enrichedWorktrees,
353
+ projects,
354
+ recentProjects,
355
+ projectDisplayNames,
356
+ searchQuery,
357
+ isSearchMode,
358
+ ]);
359
+ // Refresh handler
360
+ const refreshAll = () => {
361
+ setLoading(true);
362
+ setLoadError(null);
363
+ Effect.runPromise(Effect.either(projectManager.instance.discoverProjectsEffect(projectsDir))).then(result => {
364
+ if (result._tag === 'Left') {
365
+ setLoadError(formatErrorMessage(result.left));
366
+ setLoading(false);
367
+ return;
368
+ }
369
+ setProjects(result.right);
370
+ setRecentProjects(projectManager.getRecentProjects(0));
371
+ setLoading(false);
372
+ setSessionRefreshKey(k => k + 1);
373
+ });
374
+ };
375
+ // Handle hotkeys
376
+ useInput((input, _key) => {
377
+ if (!process.stdin.setRawMode)
378
+ return;
379
+ if (displayError && onDismissError) {
380
+ onDismissError();
381
+ return;
382
+ }
383
+ if (isSearchMode)
384
+ return;
385
+ const keyPressed = input.toLowerCase();
386
+ // Number keys 0-9 for quick selection
387
+ if (/^[0-9]$/.test(keyPressed)) {
388
+ const index = parseInt(keyPressed);
389
+ const selectableItems = items.filter(item => item.type === 'session' || item.type === 'project');
390
+ if (index < selectableItems.length && selectableItems[index]) {
391
+ const selected = selectableItems[index];
392
+ if (selected.type === 'session') {
393
+ onSelectSession(selected.session, selected.project);
394
+ }
395
+ else if (selected.type === 'project') {
396
+ onSelectProject(selected.project);
397
+ }
398
+ }
399
+ return;
400
+ }
401
+ switch (keyPressed) {
402
+ case 'r':
403
+ refreshAll();
404
+ break;
405
+ case 'q':
406
+ case 'x':
407
+ onSelectProject({
408
+ path: 'EXIT_APPLICATION',
409
+ name: '',
410
+ relativePath: '',
411
+ isValid: false,
412
+ });
413
+ break;
414
+ }
415
+ });
416
+ const handleSelect = (item) => {
417
+ if (item.value.startsWith('separator'))
418
+ return;
419
+ if (item.type === 'session') {
420
+ onSelectSession(item.session, item.project);
421
+ }
422
+ else if (item.type === 'project') {
423
+ onSelectProject(item.project);
424
+ }
425
+ else if (item.value === 'refresh') {
426
+ refreshAll();
427
+ }
428
+ else if (item.value === 'exit') {
429
+ onSelectProject({
430
+ path: 'EXIT_APPLICATION',
431
+ name: '',
432
+ relativePath: '',
433
+ isValid: false,
434
+ });
435
+ }
436
+ };
437
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { bold: true, color: "green", children: ["CCManager - Dashboard v", version] }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter..." })] })), loading ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "Discovering projects..." }) })) : projects.length === 0 && !displayError ? (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["No git repositories found in ", projectsDir] }) })) : isSearchMode && items.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "No matches found" }) })) : isSearchMode ? (_jsx(Box, { flexDirection: "column", children: items.slice(0, limit).map((item, index) => (_jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, children: [index === selectedIndex ? '❯ ' : ' ', item.label] }, item.value))) })) : (_jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), isFocused: !displayError, limit: limit, initialIndex: selectedIndex })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
438
+ ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
439
+ : searchQuery
440
+ ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
441
+ : 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit' })] })] }));
442
+ };
443
+ export default Dashboard;