ccmanager 4.3.2 → 4.4.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.
- package/README.md +42 -0
- package/dist/components/App.js +46 -21
- package/dist/components/App.test.js +86 -0
- package/dist/components/Dashboard.js +10 -3
- package/dist/components/Menu.js +6 -1
- package/dist/components/RestoreSessions.d.ts +19 -0
- package/dist/components/RestoreSessions.js +28 -0
- package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
- package/dist/hooks/useAvailableLabelWidth.js +15 -0
- package/dist/services/config/globalConfigManager.js +3 -12
- package/dist/services/globalSessionOrchestrator.d.ts +7 -0
- package/dist/services/globalSessionOrchestrator.js +40 -0
- package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
- package/dist/services/projectManager.js +3 -11
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +16 -0
- package/dist/services/sessionRestoreStore.d.ts +75 -0
- package/dist/services/sessionRestoreStore.js +138 -0
- package/dist/services/sessionRestoreStore.test.d.ts +1 -0
- package/dist/services/sessionRestoreStore.test.js +92 -0
- package/dist/services/sessionRestorer.d.ts +46 -0
- package/dist/services/sessionRestorer.js +123 -0
- package/dist/services/sessionRestorer.test.d.ts +1 -0
- package/dist/services/sessionRestorer.test.js +163 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/configDir.d.ts +10 -0
- package/dist/utils/configDir.js +31 -0
- package/dist/utils/errorMessage.d.ts +7 -0
- package/dist/utils/errorMessage.js +19 -0
- package/dist/utils/filterByQuery.test.js +2 -0
- package/dist/utils/gitUtils.d.ts +1 -0
- package/dist/utils/gitUtils.js +15 -0
- package/dist/utils/hookExecutor.test.js +4 -0
- package/dist/utils/worktreeUtils.d.ts +25 -4
- package/dist/utils/worktreeUtils.js +53 -17
- package/dist/utils/worktreeUtils.test.js +62 -4
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
|
|
|
14
14
|
- Switch between sessions seamlessly
|
|
15
15
|
- Visual status indicators for session states (busy, waiting, idle)
|
|
16
16
|
- Create, merge, and delete worktrees from within the app
|
|
17
|
+
- **Restore sessions after a restart**: reopen the sessions that were running when CCManager last exited or crashed
|
|
17
18
|
- **Copy Claude Code session data** between worktrees to maintain conversation context
|
|
18
19
|
- **`.worktreeinclude` support**: carry gitignored project files (`.env`, local certs) into newly created worktrees
|
|
19
20
|
- Configurable keyboard shortcuts
|
|
@@ -117,6 +118,22 @@ CCManager supports per-project configuration by placing a `.ccmanager.json` file
|
|
|
117
118
|
|
|
118
119
|
For detailed configuration options and examples, see [docs/project-config.md](docs/project-config.md).
|
|
119
120
|
|
|
121
|
+
### Configuring with an AI coding agent
|
|
122
|
+
|
|
123
|
+
This repository doubles as a plugin marketplace providing the **`ccmanager-config`** skill, which teaches Claude Code or Codex the whole configuration schema and ships a validator for it:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Claude Code
|
|
127
|
+
claude plugin marketplace add kbwo/ccmanager
|
|
128
|
+
claude plugin install ccmanager-config@ccmanager
|
|
129
|
+
|
|
130
|
+
# Codex CLI
|
|
131
|
+
codex plugin marketplace add kbwo/ccmanager
|
|
132
|
+
codex plugin add ccmanager-config@ccmanager
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Then just ask — "set this repo up to run codex in ccmanager", "notify me when a session is waiting for input", "why is my `.ccmanager.json` being ignored?". See [plugins/ccmanager-config/README.md](plugins/ccmanager-config/README.md).
|
|
136
|
+
|
|
120
137
|
## Supported AI Assistants
|
|
121
138
|
|
|
122
139
|
CCManager supports multiple AI coding assistants with tailored state detection for each:
|
|
@@ -221,6 +238,31 @@ A new Git worktree contains only tracked files, so gitignored files a project ne
|
|
|
221
238
|
|
|
222
239
|
For pattern syntax, the exact selection rule, and troubleshooting, see [docs/worktree-include.md](docs/worktree-include.md).
|
|
223
240
|
|
|
241
|
+
## Restoring Sessions After a Restart
|
|
242
|
+
|
|
243
|
+
Closing CCManager kills the AI assistant processes it started, so reopening it normally means finding each worktree again and starting each session by hand. To avoid that, CCManager keeps a record of the sessions it currently has open, and on the next start offers to launch them again:
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
Restore previous sessions
|
|
247
|
+
|
|
248
|
+
Found 2 sessions from the last time ccmanager ran. Start them again?
|
|
249
|
+
|
|
250
|
+
feature-login — Main
|
|
251
|
+
fix-timeout (review) — Codex
|
|
252
|
+
|
|
253
|
+
> Restore
|
|
254
|
+
Don't restore
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- **What is restored**: each session's command preset is run again in its worktree, under the name you had given it. The previous terminal output and the conversation held inside the assistant are *not* restored — restoring means starting the same command again, not resuming where it left off. (To carry conversation context into a *new* worktree, see [Session Data Copying](#session-data-copying).)
|
|
258
|
+
- **Survives a crash**: the record is written as sessions come and go, not on exit, so a crash, a `kill -9`, or a closed terminal leaves it intact.
|
|
259
|
+
- **Only sessions that were still open**: a session you killed, or one whose command exited on its own, is dropped from the record and is not offered.
|
|
260
|
+
- **Declining is remembered**: choosing "Don't restore" forgets those sessions, so the offer does not come back on the next start.
|
|
261
|
+
- **Scope**: normally only the repository you are opening is considered. In [Multi-Project Mode](#multi-project-mode) the sessions of every recorded project are offered together at startup.
|
|
262
|
+
- **Other running instances**: sessions belonging to another CCManager that is still open are left alone, so they are not started a second time.
|
|
263
|
+
|
|
264
|
+
The record lives in `~/.config/ccmanager/sessions.json` (`%APPDATA%\ccmanager\sessions.json` on Windows).
|
|
265
|
+
|
|
224
266
|
## Status Change Hooks
|
|
225
267
|
|
|
226
268
|
CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
|
package/dist/components/App.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useCallback } from 'react';
|
|
3
3
|
import { useApp, useInput, Box, Text } from 'ink';
|
|
4
4
|
import { Effect } from 'effect';
|
|
@@ -15,10 +15,14 @@ import RemoteBranchSelector from './RemoteBranchSelector.js';
|
|
|
15
15
|
import LoadingSpinner from './LoadingSpinner.js';
|
|
16
16
|
import SessionRename from './SessionRename.js';
|
|
17
17
|
import SessionActions from './SessionActions.js';
|
|
18
|
+
import RestoreSessions from './RestoreSessions.js';
|
|
18
19
|
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
19
20
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
20
21
|
import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
|
|
21
22
|
import { logger } from '../utils/logger.js';
|
|
23
|
+
import { formatErrorMessage } from '../utils/errorMessage.js';
|
|
24
|
+
import { getCurrentRepositoryRoot } from '../utils/gitUtils.js';
|
|
25
|
+
import { discardRestorableSessions, listRestorableSessions, restoreSessions, } from '../services/sessionRestorer.js';
|
|
22
26
|
import { configReader } from '../services/config/configReader.js';
|
|
23
27
|
import { ENV_VARS } from '../constants/env.js';
|
|
24
28
|
import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
|
|
@@ -26,9 +30,17 @@ import { projectManager } from '../services/projectManager.js';
|
|
|
26
30
|
import { generateWorktreeDirectory, isDeletableWorktree, } from '../utils/worktreeUtils.js';
|
|
27
31
|
const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
28
32
|
const { exit } = useApp();
|
|
29
|
-
const [view, setView] = useState(multiProject ? 'project-list' : 'menu');
|
|
30
33
|
const [sessionManager, setSessionManager] = useState(() => globalSessionOrchestrator.getManagerForProject());
|
|
31
34
|
const [worktreeService, setWorktreeService] = useState(() => new WorktreeService());
|
|
35
|
+
// Sessions that were open when ccmanager last ran and can be started again.
|
|
36
|
+
// Single-project mode only considers the repository being opened;
|
|
37
|
+
// multi-project mode considers every recorded project at once.
|
|
38
|
+
const [restorableSessions, setRestorableSessions] = useState(() => listRestorableSessions(multiProject ? {} : { projectPath: getCurrentRepositoryRoot() }));
|
|
39
|
+
const [view, setView] = useState(() => restorableSessions.length > 0
|
|
40
|
+
? 'restore-sessions'
|
|
41
|
+
: multiProject
|
|
42
|
+
? 'project-list'
|
|
43
|
+
: 'menu');
|
|
32
44
|
const [activeSession, setActiveSession] = useState(null);
|
|
33
45
|
const [error, setError] = useState(null);
|
|
34
46
|
const [worktreeHookError, setWorktreeHookError] = useState(null);
|
|
@@ -87,21 +99,6 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
87
99
|
setWorktreeHookError(null);
|
|
88
100
|
handleReturnToMenu();
|
|
89
101
|
}, { isActive: view === 'worktree-hook-error' });
|
|
90
|
-
// Helper function to format error messages based on error type using _tag discrimination
|
|
91
|
-
const formatErrorMessage = (error) => {
|
|
92
|
-
switch (error._tag) {
|
|
93
|
-
case 'ProcessError':
|
|
94
|
-
return `Process error: ${error.message}`;
|
|
95
|
-
case 'ConfigError':
|
|
96
|
-
return `Configuration error (${error.reason}): ${error.details}`;
|
|
97
|
-
case 'GitError':
|
|
98
|
-
return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
|
|
99
|
-
case 'FileSystemError':
|
|
100
|
-
return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
|
|
101
|
-
case 'ValidationError':
|
|
102
|
-
return `Validation failed for ${error.field}: ${error.constraint}`;
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
102
|
const formatPostCreationHookWarning = (error) => `Post-creation hook failed: ${error.message}`;
|
|
106
103
|
const formatPreCreationHookError = (error) => error._tag === 'ProcessError'
|
|
107
104
|
? `Pre-creation hook failed: ${error.message}`
|
|
@@ -155,6 +152,31 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
155
152
|
setView('session');
|
|
156
153
|
}, 10);
|
|
157
154
|
}, []);
|
|
155
|
+
// The view the app starts on once the restore offer is out of the way.
|
|
156
|
+
const initialView = multiProject ? 'project-list' : 'menu';
|
|
157
|
+
const handleRestorePreviousSessions = useCallback(() => {
|
|
158
|
+
const records = restorableSessions;
|
|
159
|
+
setRestorableSessions([]);
|
|
160
|
+
setView('restoring-sessions');
|
|
161
|
+
void (async () => {
|
|
162
|
+
const outcome = await restoreSessions(records, {
|
|
163
|
+
multiProject: !!multiProject,
|
|
164
|
+
});
|
|
165
|
+
if (outcome.failures.length > 0) {
|
|
166
|
+
setError(`Could not restore ${outcome.failures.length} of ${records.length} sessions: ${outcome.failures
|
|
167
|
+
.map(failure => `${failure.record.worktreePath} (${failure.message})`)
|
|
168
|
+
.join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
navigateWithClear(initialView, () => {
|
|
171
|
+
setMenuKey(prev => prev + 1);
|
|
172
|
+
});
|
|
173
|
+
})();
|
|
174
|
+
}, [restorableSessions, multiProject, initialView, navigateWithClear]);
|
|
175
|
+
const handleDiscardPreviousSessions = useCallback(() => {
|
|
176
|
+
discardRestorableSessions(restorableSessions);
|
|
177
|
+
setRestorableSessions([]);
|
|
178
|
+
navigateWithClear(initialView);
|
|
179
|
+
}, [restorableSessions, initialView, navigateWithClear]);
|
|
158
180
|
const startSessionForWorktree = useCallback(async (worktree, options) => {
|
|
159
181
|
// If a specific session is provided, navigate to it directly
|
|
160
182
|
if (options?.session) {
|
|
@@ -619,6 +641,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
619
641
|
setMenuKey(prev => prev + 1);
|
|
620
642
|
});
|
|
621
643
|
};
|
|
644
|
+
if (view === 'restore-sessions') {
|
|
645
|
+
return (_jsx(RestoreSessions, { sessions: restorableSessions, showProject: multiProject, onRestore: handleRestorePreviousSessions, onDiscard: handleDiscardPreviousSessions }));
|
|
646
|
+
}
|
|
647
|
+
if (view === 'restoring-sessions') {
|
|
648
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: "Restoring previous sessions...", color: "cyan" }) }));
|
|
649
|
+
}
|
|
622
650
|
if (view === 'project-list' && multiProject) {
|
|
623
651
|
const projectsDir = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
624
652
|
if (!projectsDir) {
|
|
@@ -667,10 +695,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
667
695
|
}
|
|
668
696
|
if (view === 'rename-session' && renameTarget) {
|
|
669
697
|
return (_jsx(SessionRename, { currentName: renameTarget.name, onRename: name => {
|
|
670
|
-
|
|
671
|
-
if (session) {
|
|
672
|
-
session.sessionName = name;
|
|
673
|
-
}
|
|
698
|
+
sessionManager.renameSession(renameTarget.id, name);
|
|
674
699
|
setRenameTarget(null);
|
|
675
700
|
handleReturnToMenu();
|
|
676
701
|
}, onCancel: () => {
|
|
@@ -37,6 +37,20 @@ const configReaderMock = {
|
|
|
37
37
|
const projectManagerMock = {
|
|
38
38
|
addRecentProject: vi.fn(),
|
|
39
39
|
};
|
|
40
|
+
const listRestorableSessionsMock = vi.fn((_options) => []);
|
|
41
|
+
const restoreSessionsMock = vi.fn(async (_records, _options) => ({
|
|
42
|
+
restored: 0,
|
|
43
|
+
failures: [],
|
|
44
|
+
}));
|
|
45
|
+
const discardRestorableSessionsMock = vi.fn((_records) => { });
|
|
46
|
+
const createSessionRecord = (overrides = {}) => ({
|
|
47
|
+
id: 'record-1',
|
|
48
|
+
projectPath: '/repo',
|
|
49
|
+
worktreePath: '/repo/worktrees/feature',
|
|
50
|
+
ownerPid: 4242,
|
|
51
|
+
createdAt: 1,
|
|
52
|
+
...overrides,
|
|
53
|
+
});
|
|
40
54
|
const worktreeNameGeneratorMock = {
|
|
41
55
|
generateBranchNameEffect: vi.fn(() => Effect.succeed('fix/trim-worktree-name')),
|
|
42
56
|
};
|
|
@@ -68,6 +82,15 @@ vi.mock('../services/globalSessionOrchestrator.js', () => ({
|
|
|
68
82
|
vi.mock('../services/projectManager.js', () => ({
|
|
69
83
|
projectManager: projectManagerMock,
|
|
70
84
|
}));
|
|
85
|
+
vi.mock('../services/sessionRestorer.js', () => ({
|
|
86
|
+
listRestorableSessions: (options) => listRestorableSessionsMock(options),
|
|
87
|
+
restoreSessions: (records, options) => restoreSessionsMock(records, options),
|
|
88
|
+
discardRestorableSessions: (records) => discardRestorableSessionsMock(records),
|
|
89
|
+
describeRecordPreset: () => 'Main',
|
|
90
|
+
}));
|
|
91
|
+
vi.mock('../utils/gitUtils.js', () => ({
|
|
92
|
+
getCurrentRepositoryRoot: () => '/repo',
|
|
93
|
+
}));
|
|
71
94
|
vi.mock('../services/config/configReader.js', () => ({
|
|
72
95
|
configReader: configReaderMock,
|
|
73
96
|
}));
|
|
@@ -143,6 +166,11 @@ beforeEach(() => {
|
|
|
143
166
|
configReaderMock.getSelectPresetOnStart.mockReset();
|
|
144
167
|
configReaderMock.getSelectPresetOnStart.mockReturnValue(false);
|
|
145
168
|
projectManagerMock.addRecentProject.mockReset();
|
|
169
|
+
listRestorableSessionsMock.mockReset();
|
|
170
|
+
listRestorableSessionsMock.mockReturnValue([]);
|
|
171
|
+
restoreSessionsMock.mockReset();
|
|
172
|
+
restoreSessionsMock.mockResolvedValue({ restored: 0, failures: [] });
|
|
173
|
+
discardRestorableSessionsMock.mockReset();
|
|
146
174
|
worktreeNameGeneratorMock.generateBranchNameEffect.mockReset();
|
|
147
175
|
worktreeNameGeneratorMock.generateBranchNameEffect.mockImplementation(() => Effect.succeed('fix/trim-worktree-name'));
|
|
148
176
|
});
|
|
@@ -156,6 +184,64 @@ describe('App component view state', () => {
|
|
|
156
184
|
expect(lastFrame()).toContain('Menu View');
|
|
157
185
|
unmount();
|
|
158
186
|
});
|
|
187
|
+
it('offers to restore the sessions recorded by the previous run', async () => {
|
|
188
|
+
listRestorableSessionsMock.mockReturnValue([
|
|
189
|
+
createSessionRecord({ sessionName: 'review' }),
|
|
190
|
+
]);
|
|
191
|
+
const { lastFrame, unmount } = render(_jsx(App, { version: "test" }));
|
|
192
|
+
await flush(40);
|
|
193
|
+
expect(listRestorableSessionsMock).toHaveBeenCalledWith({
|
|
194
|
+
projectPath: '/repo',
|
|
195
|
+
});
|
|
196
|
+
expect(lastFrame()).toContain('Restore previous sessions');
|
|
197
|
+
expect(lastFrame()).toContain('feature');
|
|
198
|
+
expect(lastFrame()).toContain('review');
|
|
199
|
+
unmount();
|
|
200
|
+
});
|
|
201
|
+
it('restores the recorded sessions and then shows the menu', async () => {
|
|
202
|
+
const record = createSessionRecord();
|
|
203
|
+
listRestorableSessionsMock.mockReturnValue([record]);
|
|
204
|
+
const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
|
|
205
|
+
await flush(40);
|
|
206
|
+
stdin.write('\r');
|
|
207
|
+
await waitForCondition(() => restoreSessionsMock.mock.calls.length > 0);
|
|
208
|
+
expect(restoreSessionsMock).toHaveBeenCalledWith([record], {
|
|
209
|
+
multiProject: false,
|
|
210
|
+
});
|
|
211
|
+
await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
|
|
212
|
+
unmount();
|
|
213
|
+
});
|
|
214
|
+
it('forgets the recorded sessions when the restore offer is declined', async () => {
|
|
215
|
+
const record = createSessionRecord();
|
|
216
|
+
listRestorableSessionsMock.mockReturnValue([record]);
|
|
217
|
+
const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
|
|
218
|
+
await flush(40);
|
|
219
|
+
// Move from "Restore" to "Don't restore" before confirming.
|
|
220
|
+
stdin.write('\u001B[B');
|
|
221
|
+
await flush(10);
|
|
222
|
+
stdin.write('\r');
|
|
223
|
+
await waitForCondition(() => discardRestorableSessionsMock.mock.calls.length > 0);
|
|
224
|
+
expect(discardRestorableSessionsMock).toHaveBeenCalledWith([record]);
|
|
225
|
+
expect(restoreSessionsMock).not.toHaveBeenCalled();
|
|
226
|
+
await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
|
|
227
|
+
unmount();
|
|
228
|
+
});
|
|
229
|
+
it('considers every recorded project in multi-project mode', async () => {
|
|
230
|
+
const original = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
231
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';
|
|
232
|
+
listRestorableSessionsMock.mockReturnValue([createSessionRecord()]);
|
|
233
|
+
const { lastFrame, unmount } = render(_jsx(App, { multiProject: true, version: "test" }));
|
|
234
|
+
await flush(40);
|
|
235
|
+
expect(listRestorableSessionsMock).toHaveBeenCalledWith({});
|
|
236
|
+
expect(lastFrame()).toContain('Restore previous sessions');
|
|
237
|
+
unmount();
|
|
238
|
+
if (original === undefined) {
|
|
239
|
+
delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = original;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
159
245
|
it('renders the project list view first in multi-project mode', async () => {
|
|
160
246
|
const original = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
161
247
|
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';
|
|
@@ -11,6 +11,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
11
11
|
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
|
|
12
12
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
13
13
|
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
14
|
+
import { useAvailableLabelWidth } from '../hooks/useAvailableLabelWidth.js';
|
|
14
15
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
15
16
|
import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, displaySuffix, } from '../utils/worktreeUtils.js';
|
|
16
17
|
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
|
|
@@ -84,6 +85,9 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
|
|
|
84
85
|
isSearchMode,
|
|
85
86
|
hasError: !!displayError,
|
|
86
87
|
});
|
|
88
|
+
// Room a row label may occupy; decides whether the session state tag gets
|
|
89
|
+
// its own aligned column or is appended to the branch name instead.
|
|
90
|
+
const availableLabelWidth = useAvailableLabelWidth();
|
|
87
91
|
// Git status polling for session worktrees
|
|
88
92
|
const enrichedWorktrees = useGitStatus(baseSessionWorktrees, baseSessionWorktrees.length > 0 ? 'main' : null);
|
|
89
93
|
// Discover projects on mount
|
|
@@ -216,7 +220,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
|
|
|
216
220
|
const wt = enrichedWorktrees.find(w => w.path === entry.worktree.path) ||
|
|
217
221
|
entry.worktree;
|
|
218
222
|
const stateData = entry.session.stateMutex.getSnapshot();
|
|
219
|
-
const status = `
|
|
223
|
+
const status = `[${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
|
|
220
224
|
const fullBranchName = wt.branch
|
|
221
225
|
? wt.branch.replace('refs/heads/', '')
|
|
222
226
|
: wt.path.split('/').pop() || 'detached';
|
|
@@ -225,7 +229,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
|
|
|
225
229
|
const worktreeSessionCount = sessionEntries.filter(e => e.worktree.path === entry.worktree.path &&
|
|
226
230
|
e.projectPath === entry.projectPath).length;
|
|
227
231
|
const sessionSuffix = displaySuffix(entry.session, worktreeSessionCount > 1);
|
|
228
|
-
const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}
|
|
232
|
+
const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}`;
|
|
229
233
|
let fileChanges = '';
|
|
230
234
|
let aheadBehind = '';
|
|
231
235
|
let parentBranch = '';
|
|
@@ -245,6 +249,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
|
|
|
245
249
|
worktree: wt,
|
|
246
250
|
session: entry.session,
|
|
247
251
|
baseLabel,
|
|
252
|
+
status,
|
|
248
253
|
searchableName: `${entry.projectName} :: ${fullBranchName}${isMain}`,
|
|
249
254
|
fileChanges,
|
|
250
255
|
aheadBehind,
|
|
@@ -255,6 +260,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
|
|
|
255
260
|
error: itemError,
|
|
256
261
|
lengths: {
|
|
257
262
|
base: stripAnsi(baseLabel).length,
|
|
263
|
+
status: stripAnsi(status).length,
|
|
258
264
|
fileChanges: stripAnsi(fileChanges).length,
|
|
259
265
|
aheadBehind: stripAnsi(aheadBehind).length,
|
|
260
266
|
parentBranch: stripAnsi(parentBranch).length,
|
|
@@ -264,7 +270,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
|
|
|
264
270
|
},
|
|
265
271
|
};
|
|
266
272
|
});
|
|
267
|
-
const columns = calculateColumnPositions(sessionWorkItems);
|
|
273
|
+
const columns = calculateColumnPositions(sessionWorkItems, availableLabelWidth);
|
|
268
274
|
if (!isSearchMode) {
|
|
269
275
|
menuItems.push({
|
|
270
276
|
type: 'common',
|
|
@@ -369,6 +375,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
|
|
|
369
375
|
projectDisplayNames,
|
|
370
376
|
searchQuery,
|
|
371
377
|
isSearchMode,
|
|
378
|
+
availableLabelWidth,
|
|
372
379
|
]);
|
|
373
380
|
// Refresh handler
|
|
374
381
|
const refreshAll = () => {
|
package/dist/components/Menu.js
CHANGED
|
@@ -10,6 +10,7 @@ import { prepareSessionItems, calculateColumnPositions, assembleSessionLabel, }
|
|
|
10
10
|
import { projectManager } from '../services/projectManager.js';
|
|
11
11
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
12
12
|
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
13
|
+
import { useAvailableLabelWidth } from '../hooks/useAvailableLabelWidth.js';
|
|
13
14
|
import { filterSessionItemsByQuery, filterSessionItemsByState, cycleSessionStateFilter, getSessionStateFilterLabel, } from '../utils/filterByQuery.js';
|
|
14
15
|
import SearchableList from './SearchableList.js';
|
|
15
16
|
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
@@ -60,6 +61,9 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
|
|
|
60
61
|
});
|
|
61
62
|
// Get worktree configuration for sorting
|
|
62
63
|
const worktreeConfig = configReader.getWorktreeConfig();
|
|
64
|
+
// Room a row label may occupy; decides whether the session state tag gets
|
|
65
|
+
// its own aligned column or is appended to the branch name instead.
|
|
66
|
+
const availableLabelWidth = useAvailableLabelWidth();
|
|
63
67
|
useEffect(() => {
|
|
64
68
|
let cancelled = false;
|
|
65
69
|
// These operations are independent. Run them concurrently so the initial
|
|
@@ -154,7 +158,7 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
|
|
|
154
158
|
const items = prepareSessionItems(worktrees, sessions, {
|
|
155
159
|
sortByLastSession: worktreeConfig.sortByLastSession,
|
|
156
160
|
});
|
|
157
|
-
const columnPositions = calculateColumnPositions(items);
|
|
161
|
+
const columnPositions = calculateColumnPositions(items, availableLabelWidth);
|
|
158
162
|
// Filter session items based on search query, matching the name shown in
|
|
159
163
|
// the menu (branch name, " (main)", and session name) plus the path, then
|
|
160
164
|
// narrow to the selected session state. The two filters are independent
|
|
@@ -320,6 +324,7 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
|
|
|
320
324
|
autoApprovalToggleCounter,
|
|
321
325
|
sessionManager,
|
|
322
326
|
worktreeConfig.sortByLastSession,
|
|
327
|
+
availableLabelWidth,
|
|
323
328
|
]);
|
|
324
329
|
// Handle hotkeys
|
|
325
330
|
useInput((input, key) => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SessionRecord } from '../services/sessionRestoreStore.js';
|
|
3
|
+
interface RestoreSessionsProps {
|
|
4
|
+
sessions: SessionRecord[];
|
|
5
|
+
/** Whether to show which project each session belongs to. */
|
|
6
|
+
showProject?: boolean;
|
|
7
|
+
onRestore: () => void;
|
|
8
|
+
onDiscard: () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Startup offer to launch the sessions that were open when ccmanager last ran.
|
|
12
|
+
*
|
|
13
|
+
* Restoring runs each session's command preset in its worktree again; the
|
|
14
|
+
* previous terminal output and conversation are not brought back. Declining
|
|
15
|
+
* forgets the listed sessions, so the offer does not reappear on the next
|
|
16
|
+
* start.
|
|
17
|
+
*/
|
|
18
|
+
declare const RestoreSessions: React.FC<RestoreSessionsProps>;
|
|
19
|
+
export default RestoreSessions;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import Confirmation from './Confirmation.js';
|
|
5
|
+
import { describeRecordPreset } from '../services/sessionRestorer.js';
|
|
6
|
+
/**
|
|
7
|
+
* Startup offer to launch the sessions that were open when ccmanager last ran.
|
|
8
|
+
*
|
|
9
|
+
* Restoring runs each session's command preset in its worktree again; the
|
|
10
|
+
* previous terminal output and conversation are not brought back. Declining
|
|
11
|
+
* forgets the listed sessions, so the offer does not reappear on the next
|
|
12
|
+
* start.
|
|
13
|
+
*/
|
|
14
|
+
const RestoreSessions = ({ sessions, showProject = false, onRestore, onDiscard, }) => {
|
|
15
|
+
const message = (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Found ", sessions.length, " session", sessions.length === 1 ? '' : 's', " from the last time ccmanager ran. Start", ' ', sessions.length === 1 ? 'it' : 'them', " again?"] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: sessions.map(session => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "green", children: path.basename(session.worktreePath) }), session.sessionName ? ` (${session.sessionName})` : '', _jsxs(Text, { dimColor: true, children: [' ', "\u2014 ", describeRecordPreset(session), showProject ? ` — ${path.basename(session.projectPath)}` : ''] })] }, session.id))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Each session runs its command again in its worktree. Previous output and conversation are not restored." }) })] }));
|
|
16
|
+
return (_jsx(Confirmation, { title: _jsx(Text, { bold: true, children: "Restore previous sessions" }), message: message, options: [
|
|
17
|
+
{ label: 'Restore', value: 'restore', color: 'green' },
|
|
18
|
+
{ label: "Don't restore", value: 'discard', color: 'red' },
|
|
19
|
+
], onSelect: value => {
|
|
20
|
+
if (value === 'restore') {
|
|
21
|
+
onRestore();
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
onDiscard();
|
|
25
|
+
}
|
|
26
|
+
}, hint: _jsx(Text, { dimColor: true, children: "Use \u2191\u2193 to navigate, Enter to select. Declining forgets these sessions." }) }));
|
|
27
|
+
};
|
|
28
|
+
export default RestoreSessions;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Number of terminal columns a worktree/session row label may occupy.
|
|
3
|
+
* Passed to calculateColumnPositions, which drops the aligned session-state
|
|
4
|
+
* column when the resulting layout would not fit in it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useAvailableLabelWidth(): number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useStdout } from 'ink';
|
|
2
|
+
// Columns a list row spends before the assembled label: the SelectInput
|
|
3
|
+
// indicator ("❯ " or two spaces) plus the number prefix ("0 ❯ " / " ❯ ").
|
|
4
|
+
const ROW_PREFIX_WIDTH = 6;
|
|
5
|
+
// Terminal width assumed when stdout reports none (e.g. a non-TTY test stream).
|
|
6
|
+
const FALLBACK_TERMINAL_WIDTH = 80;
|
|
7
|
+
/**
|
|
8
|
+
* Number of terminal columns a worktree/session row label may occupy.
|
|
9
|
+
* Passed to calculateColumnPositions, which drops the aligned session-state
|
|
10
|
+
* column when the resulting layout would not fit in it.
|
|
11
|
+
*/
|
|
12
|
+
export function useAvailableLabelWidth() {
|
|
13
|
+
const { stdout } = useStdout();
|
|
14
|
+
return Math.max(0, (stdout.columns || FALLBACK_TERMINAL_WIDTH) - ROW_PREFIX_WIDTH);
|
|
15
|
+
}
|
|
@@ -3,27 +3,18 @@
|
|
|
3
3
|
* This module is for internal use within the config directory only.
|
|
4
4
|
* External code should use ConfigEditor or ConfigReader instead.
|
|
5
5
|
*/
|
|
6
|
-
import { homedir } from 'os';
|
|
7
6
|
import { join } from 'path';
|
|
8
|
-
import { existsSync,
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
8
|
import { DEFAULT_SHORTCUTS, } from '../../types/index.js';
|
|
10
9
|
import { DEFAULT_TIMEOUT_SECONDS } from '../../constants/autoApproval.js';
|
|
10
|
+
import { ensureConfigDir } from '../../utils/configDir.js';
|
|
11
11
|
class GlobalConfigManager {
|
|
12
12
|
configPath;
|
|
13
13
|
legacyShortcutsPath;
|
|
14
14
|
configDir;
|
|
15
15
|
config = {};
|
|
16
16
|
constructor() {
|
|
17
|
-
|
|
18
|
-
const homeDir = homedir();
|
|
19
|
-
this.configDir =
|
|
20
|
-
process.platform === 'win32'
|
|
21
|
-
? join(process.env['APPDATA'] || join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
22
|
-
: join(homeDir, '.config', 'ccmanager');
|
|
23
|
-
// Ensure config directory exists
|
|
24
|
-
if (!existsSync(this.configDir)) {
|
|
25
|
-
mkdirSync(this.configDir, { recursive: true });
|
|
26
|
-
}
|
|
17
|
+
this.configDir = ensureConfigDir();
|
|
27
18
|
this.configPath = join(this.configDir, 'config.json');
|
|
28
19
|
this.legacyShortcutsPath = join(this.configDir, 'shortcuts.json');
|
|
29
20
|
this.loadConfig();
|
|
@@ -12,6 +12,13 @@ declare class GlobalSessionOrchestrator {
|
|
|
12
12
|
destroyProjectSessions(projectPath: string): void;
|
|
13
13
|
getProjectPaths(): string[];
|
|
14
14
|
getProjectSessions(projectPath: string): Session[];
|
|
15
|
+
/**
|
|
16
|
+
* Keep the durable session record in step with one manager's sessions, so a
|
|
17
|
+
* later ccmanager run can offer to launch them again. This orchestrator is
|
|
18
|
+
* the only place that knows which project a manager belongs to, which is why
|
|
19
|
+
* the wiring lives here rather than inside SessionManager.
|
|
20
|
+
*/
|
|
21
|
+
private trackSessionsForRestore;
|
|
15
22
|
}
|
|
16
23
|
export declare const globalSessionOrchestrator: GlobalSessionOrchestrator;
|
|
17
24
|
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { SessionManager } from './sessionManager.js';
|
|
2
|
+
import { getCurrentRepositoryRoot } from '../utils/gitUtils.js';
|
|
3
|
+
import { sessionRestoreStore } from './sessionRestoreStore.js';
|
|
2
4
|
class GlobalSessionOrchestrator {
|
|
3
5
|
static instance;
|
|
4
6
|
projectManagers = new Map();
|
|
@@ -6,6 +8,7 @@ class GlobalSessionOrchestrator {
|
|
|
6
8
|
constructor() {
|
|
7
9
|
// Create a global session manager for single-project mode
|
|
8
10
|
this.globalManager = new SessionManager();
|
|
11
|
+
this.trackSessionsForRestore(this.globalManager);
|
|
9
12
|
}
|
|
10
13
|
static getInstance() {
|
|
11
14
|
if (!GlobalSessionOrchestrator.instance) {
|
|
@@ -22,6 +25,7 @@ class GlobalSessionOrchestrator {
|
|
|
22
25
|
let manager = this.projectManagers.get(projectPath);
|
|
23
26
|
if (!manager) {
|
|
24
27
|
manager = new SessionManager();
|
|
28
|
+
this.trackSessionsForRestore(manager, projectPath);
|
|
25
29
|
this.projectManagers.set(projectPath, manager);
|
|
26
30
|
}
|
|
27
31
|
return manager;
|
|
@@ -37,6 +41,11 @@ class GlobalSessionOrchestrator {
|
|
|
37
41
|
return sessions;
|
|
38
42
|
}
|
|
39
43
|
destroyAllSessions() {
|
|
44
|
+
// Every caller of this method is quitting ccmanager, and the sessions
|
|
45
|
+
// being torn down here are exactly the ones the next run should offer to
|
|
46
|
+
// restore. Stop updating the durable record first so the teardown does
|
|
47
|
+
// not erase them.
|
|
48
|
+
sessionRestoreStore.suspendTracking();
|
|
40
49
|
// Destroy sessions in global manager
|
|
41
50
|
this.globalManager.destroy();
|
|
42
51
|
// Destroy sessions in all project managers
|
|
@@ -63,5 +72,36 @@ class GlobalSessionOrchestrator {
|
|
|
63
72
|
}
|
|
64
73
|
return [];
|
|
65
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Keep the durable session record in step with one manager's sessions, so a
|
|
77
|
+
* later ccmanager run can offer to launch them again. This orchestrator is
|
|
78
|
+
* the only place that knows which project a manager belongs to, which is why
|
|
79
|
+
* the wiring lives here rather than inside SessionManager.
|
|
80
|
+
*/
|
|
81
|
+
trackSessionsForRestore(manager, projectPath) {
|
|
82
|
+
manager.on('sessionCreated', (session) => {
|
|
83
|
+
sessionRestoreStore.record({
|
|
84
|
+
id: session.id,
|
|
85
|
+
// The global manager has no project path of its own: its sessions
|
|
86
|
+
// belong to the repository ccmanager was started in.
|
|
87
|
+
projectPath: projectPath ?? getCurrentRepositoryRoot(),
|
|
88
|
+
worktreePath: session.worktreePath,
|
|
89
|
+
presetId: session.presetId,
|
|
90
|
+
sessionName: session.sessionName,
|
|
91
|
+
devcontainerConfig: session.devcontainerConfig,
|
|
92
|
+
ownerPid: process.pid,
|
|
93
|
+
createdAt: Date.now(),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
manager.on('sessionRenamed', (session) => {
|
|
97
|
+
sessionRestoreStore.rename(session.id, session.sessionName);
|
|
98
|
+
});
|
|
99
|
+
// A destroyed session is one that should stay gone: either the user
|
|
100
|
+
// killed it or the launched command exited on its own. The exception is
|
|
101
|
+
// the teardown on quit, which suspends tracking beforehand.
|
|
102
|
+
manager.on('sessionDestroyed', (session) => {
|
|
103
|
+
sessionRestoreStore.forget(session.id);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
66
106
|
}
|
|
67
107
|
export const globalSessionOrchestrator = GlobalSessionOrchestrator.getInstance();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|