ccmanager 4.3.1 → 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.
Files changed (51) hide show
  1. package/README.md +54 -0
  2. package/dist/components/App.js +92 -30
  3. package/dist/components/App.test.js +86 -0
  4. package/dist/components/Dashboard.js +10 -3
  5. package/dist/components/DeleteWorktree.js +2 -13
  6. package/dist/components/Menu.js +28 -17
  7. package/dist/components/Menu.test.js +37 -1
  8. package/dist/components/RestoreSessions.d.ts +19 -0
  9. package/dist/components/RestoreSessions.js +28 -0
  10. package/dist/components/SessionActions.d.ts +17 -2
  11. package/dist/components/SessionActions.js +19 -17
  12. package/dist/components/SessionActions.test.d.ts +1 -0
  13. package/dist/components/SessionActions.test.js +94 -0
  14. package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
  15. package/dist/hooks/useAvailableLabelWidth.js +15 -0
  16. package/dist/services/config/globalConfigManager.js +3 -12
  17. package/dist/services/globalSessionOrchestrator.d.ts +7 -0
  18. package/dist/services/globalSessionOrchestrator.js +40 -0
  19. package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
  20. package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
  21. package/dist/services/projectManager.js +3 -11
  22. package/dist/services/sessionManager.d.ts +6 -0
  23. package/dist/services/sessionManager.js +16 -0
  24. package/dist/services/sessionRestoreStore.d.ts +75 -0
  25. package/dist/services/sessionRestoreStore.js +138 -0
  26. package/dist/services/sessionRestoreStore.test.d.ts +1 -0
  27. package/dist/services/sessionRestoreStore.test.js +92 -0
  28. package/dist/services/sessionRestorer.d.ts +46 -0
  29. package/dist/services/sessionRestorer.js +123 -0
  30. package/dist/services/sessionRestorer.test.d.ts +1 -0
  31. package/dist/services/sessionRestorer.test.js +163 -0
  32. package/dist/services/worktreeService.d.ts +12 -0
  33. package/dist/services/worktreeService.js +30 -0
  34. package/dist/services/worktreeService.test.js +55 -1
  35. package/dist/types/index.d.ts +3 -2
  36. package/dist/utils/configDir.d.ts +10 -0
  37. package/dist/utils/configDir.js +31 -0
  38. package/dist/utils/errorMessage.d.ts +7 -0
  39. package/dist/utils/errorMessage.js +19 -0
  40. package/dist/utils/filterByQuery.test.js +2 -0
  41. package/dist/utils/gitUtils.d.ts +1 -0
  42. package/dist/utils/gitUtils.js +15 -0
  43. package/dist/utils/hookExecutor.test.js +4 -0
  44. package/dist/utils/worktreeInclude.d.ts +33 -0
  45. package/dist/utils/worktreeInclude.js +100 -0
  46. package/dist/utils/worktreeInclude.test.d.ts +1 -0
  47. package/dist/utils/worktreeInclude.test.js +91 -0
  48. package/dist/utils/worktreeUtils.d.ts +38 -4
  49. package/dist/utils/worktreeUtils.js +76 -17
  50. package/dist/utils/worktreeUtils.test.js +82 -5
  51. package/package.json +6 -6
package/README.md CHANGED
@@ -14,7 +14,9 @@ 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
19
+ - **`.worktreeinclude` support**: carry gitignored project files (`.env`, local certs) into newly created worktrees
18
20
  - Configurable keyboard shortcuts
19
21
  - Command presets with automatic fallback support
20
22
  - Configurable state detection strategies for different CLI tools
@@ -116,6 +118,22 @@ CCManager supports per-project configuration by placing a `.ccmanager.json` file
116
118
 
117
119
  For detailed configuration options and examples, see [docs/project-config.md](docs/project-config.md).
118
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
+
119
137
  ## Supported AI Assistants
120
138
 
121
139
  CCManager supports multiple AI coding assistants with tailored state detection for each:
@@ -209,6 +227,42 @@ The default choice (copy or start fresh) will be pre-selected when creating new
209
227
  - **Context Preservation**: Maintain long conversations across multiple development branches
210
228
 
211
229
 
230
+ ## Copying Gitignored Files into New Worktrees
231
+
232
+ A new Git worktree contains only tracked files, so gitignored files a project needs in order to run — `.env`, local certificates, generated local config — do not come along. If the repository root has a `.worktreeinclude` file (gitignore syntax) listing those files, CCManager copies them into every worktree it creates.
233
+
234
+ - **Shared convention**: same file name and semantics as Claude Code, Conductor, OpenAI Codex, and `git-worktreeinclude`, so an existing `.worktreeinclude` works with CCManager unchanged
235
+ - **No configuration**: the copy runs whenever a `.worktreeinclude` file exists
236
+ - **Safe by construction**: a file is copied only if it both matches a pattern and is actually gitignored, so tracked files are never duplicated
237
+ - **Hook-friendly**: files are copied before the post-creation worktree hook runs, so hook commands can rely on them
238
+
239
+ For pattern syntax, the exact selection rule, and troubleshooting, see [docs/worktree-include.md](docs/worktree-include.md).
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
+
212
266
  ## Status Change Hooks
213
267
 
214
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.
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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';
@@ -7,6 +7,7 @@ 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';
10
+ import DeleteConfirmation from './DeleteConfirmation.js';
10
11
  import MergeWorktree from './MergeWorktree.js';
11
12
  import Configuration from './Configuration.js';
12
13
  import PresetSelector from './PresetSelector.js';
@@ -14,20 +15,32 @@ import RemoteBranchSelector from './RemoteBranchSelector.js';
14
15
  import LoadingSpinner from './LoadingSpinner.js';
15
16
  import SessionRename from './SessionRename.js';
16
17
  import SessionActions from './SessionActions.js';
18
+ import RestoreSessions from './RestoreSessions.js';
17
19
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
18
20
  import { WorktreeService } from '../services/worktreeService.js';
19
21
  import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
20
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';
21
26
  import { configReader } from '../services/config/configReader.js';
22
27
  import { ENV_VARS } from '../constants/env.js';
23
28
  import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
24
29
  import { projectManager } from '../services/projectManager.js';
25
- import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
30
+ import { generateWorktreeDirectory, isDeletableWorktree, } from '../utils/worktreeUtils.js';
26
31
  const App = ({ devcontainerConfig, multiProject, version, }) => {
27
32
  const { exit } = useApp();
28
- const [view, setView] = useState(multiProject ? 'project-list' : 'menu');
29
33
  const [sessionManager, setSessionManager] = useState(() => globalSessionOrchestrator.getManagerForProject());
30
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');
31
44
  const [activeSession, setActiveSession] = useState(null);
32
45
  const [error, setError] = useState(null);
33
46
  const [worktreeHookError, setWorktreeHookError] = useState(null);
@@ -36,6 +49,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
36
49
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
37
50
  const [renameTarget, setRenameTarget] = useState(null);
38
51
  const [sessionActionsTarget, setSessionActionsTarget] = useState(null);
52
+ // Worktree awaiting confirmation of the per-row delete action
53
+ const [worktreeToDelete, setWorktreeToDelete] = useState(null);
39
54
  const [selectedProject, setSelectedProject] = useState(null); // Store selected project in multi-project mode
40
55
  const [configScope, setConfigScope] = useState('global'); // Store config scope for configuration view
41
56
  const [pendingMenuSessionLaunch, setPendingMenuSessionLaunch] = useState(null);
@@ -84,21 +99,6 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
84
99
  setWorktreeHookError(null);
85
100
  handleReturnToMenu();
86
101
  }, { isActive: view === 'worktree-hook-error' });
87
- // Helper function to format error messages based on error type using _tag discrimination
88
- const formatErrorMessage = (error) => {
89
- switch (error._tag) {
90
- case 'ProcessError':
91
- return `Process error: ${error.message}`;
92
- case 'ConfigError':
93
- return `Configuration error (${error.reason}): ${error.details}`;
94
- case 'GitError':
95
- return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
96
- case 'FileSystemError':
97
- return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
98
- case 'ValidationError':
99
- return `Validation failed for ${error.field}: ${error.constraint}`;
100
- }
101
- };
102
102
  const formatPostCreationHookWarning = (error) => `Post-creation hook failed: ${error.message}`;
103
103
  const formatPreCreationHookError = (error) => error._tag === 'ProcessError'
104
104
  ? `Pre-creation hook failed: ${error.message}`
@@ -152,6 +152,31 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
152
152
  setView('session');
153
153
  }, 10);
154
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]);
155
180
  const startSessionForWorktree = useCallback(async (worktree, options) => {
156
181
  // If a specific session is provided, navigate to it directly
157
182
  if (options?.session) {
@@ -303,8 +328,9 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
303
328
  return;
304
329
  case 'sessionActions':
305
330
  setSessionActionsTarget({
331
+ worktreePath: action.worktree.path,
306
332
  session: action.session,
307
- worktreePath: action.worktreePath,
333
+ worktree: action.worktree,
308
334
  });
309
335
  navigateWithClear('session-actions');
310
336
  return;
@@ -520,7 +546,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
520
546
  setPendingWorktreeCreation(null);
521
547
  setView('new-worktree');
522
548
  };
523
- const handleDeleteWorktrees = async (worktreePaths, deleteBranch) => {
549
+ const handleDeleteWorktrees = async (worktreePaths, deleteBranch, options) => {
524
550
  // Set loading context before showing loading view
525
551
  setLoadingContext({ deleteBranch });
526
552
  setView('deleting-worktree');
@@ -558,7 +584,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
558
584
  }
559
585
  else {
560
586
  // Show error
561
- setView('delete-worktree');
587
+ if (options?.onError) {
588
+ options.onError();
589
+ }
590
+ else {
591
+ setView('delete-worktree');
592
+ }
562
593
  }
563
594
  };
564
595
  const handleCancelDeleteWorktree = () => {
@@ -610,6 +641,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
610
641
  setMenuKey(prev => prev + 1);
611
642
  });
612
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
+ }
613
650
  if (view === 'project-list' && multiProject) {
614
651
  const projectsDir = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
615
652
  if (!projectsDir) {
@@ -658,10 +695,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
658
695
  }
659
696
  if (view === 'rename-session' && renameTarget) {
660
697
  return (_jsx(SessionRename, { currentName: renameTarget.name, onRename: name => {
661
- const session = sessionManager.getSessionById(renameTarget.id);
662
- if (session) {
663
- session.sessionName = name;
664
- }
698
+ sessionManager.renameSession(renameTarget.id, name);
665
699
  setRenameTarget(null);
666
700
  handleReturnToMenu();
667
701
  }, onCancel: () => {
@@ -670,10 +704,14 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
670
704
  } }));
671
705
  }
672
706
  if (view === 'session-actions' && sessionActionsTarget) {
673
- const { session: targetSession, worktreePath } = sessionActionsTarget;
674
- const label = targetSession.sessionName
675
- ? targetSession.sessionName
676
- : `Session #${targetSession.sessionNumber}`;
707
+ const { session: targetSession, worktreePath, worktree: targetWorktree, } = sessionActionsTarget;
708
+ // A worktree row without a session has no session name to show; the
709
+ // worktree path is rendered on its own line by SessionActions.
710
+ const label = !targetSession
711
+ ? undefined
712
+ : targetSession.sessionName
713
+ ? targetSession.sessionName
714
+ : `Session #${targetSession.sessionNumber}`;
677
715
  const handleSessionAction = async (action) => {
678
716
  setSessionActionsTarget(null);
679
717
  switch (action) {
@@ -686,6 +724,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
686
724
  }, { forceNew: true });
687
725
  return;
688
726
  case 'rename':
727
+ if (!targetSession)
728
+ return;
689
729
  setRenameTarget({
690
730
  id: targetSession.id,
691
731
  name: targetSession.sessionName,
@@ -693,16 +733,38 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
693
733
  navigateWithClear('rename-session');
694
734
  return;
695
735
  case 'kill':
736
+ if (!targetSession)
737
+ return;
696
738
  sessionManager.destroySession(targetSession.id);
697
739
  handleReturnToMenu();
698
740
  return;
741
+ case 'deleteWorktree':
742
+ if (!targetWorktree)
743
+ return;
744
+ setWorktreeToDelete(targetWorktree);
745
+ navigateWithClear('confirm-delete-worktree');
746
+ return;
699
747
  }
700
748
  };
701
- return (_jsx(SessionActions, { sessionLabel: label, worktreePath: worktreePath, onSelect: handleSessionAction, onCancel: () => {
749
+ return (_jsx(SessionActions, { sessionLabel: label, worktreePath: worktreePath, hasSession: !!targetSession, canDeleteWorktree: !!targetWorktree && isDeletableWorktree(targetWorktree), onSelect: handleSessionAction, onCancel: () => {
702
750
  setSessionActionsTarget(null);
703
751
  handleReturnToMenu();
704
752
  } }));
705
753
  }
754
+ if (view === 'confirm-delete-worktree' && worktreeToDelete) {
755
+ const target = worktreeToDelete;
756
+ return (_jsx(DeleteConfirmation, { worktrees: [target], onConfirm: deleteBranch => {
757
+ setWorktreeToDelete(null);
758
+ void handleDeleteWorktrees([target.path], deleteBranch, {
759
+ // The multi-select delete screen was never opened in this flow,
760
+ // so surface the failure on the menu instead.
761
+ onError: handleReturnToMenu,
762
+ });
763
+ }, onCancel: () => {
764
+ setWorktreeToDelete(null);
765
+ handleReturnToMenu();
766
+ } }));
767
+ }
706
768
  if (view === 'preset-selector') {
707
769
  return (_jsx(PresetSelector, { onSelect: handlePresetSelected, onCancel: handlePresetSelectorCancel }));
708
770
  }
@@ -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 = ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
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}${status}`;
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 = () => {
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
- import path from 'path';
4
3
  import { Box, Text, useInput } from 'ink';
5
4
  import SelectInput from 'ink-select-input';
6
5
  import { Effect } from 'effect';
@@ -11,6 +10,7 @@ import { useSearchMode } from '../hooks/useSearchMode.js';
11
10
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
12
11
  import { filterWorktreesByQuery } from '../utils/filterByQuery.js';
13
12
  import SearchableList from './SearchableList.js';
13
+ import { isDeletableWorktree } from '../utils/worktreeUtils.js';
14
14
  const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
15
15
  const [worktrees, setWorktrees] = useState([]);
16
16
  const [selectedIndices, setSelectedIndices] = useState(new Set());
@@ -34,18 +34,7 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
34
34
  try {
35
35
  const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
36
36
  if (!cancelled) {
37
- // Filter out main worktree and current working directory worktree
38
- const resolvedCwd = path.resolve(process.cwd());
39
- const deletableWorktrees = allWorktrees.filter(wt => {
40
- if (wt.isMainWorktree)
41
- return false;
42
- const resolvedPath = path.resolve(wt.path);
43
- if (resolvedCwd === resolvedPath ||
44
- resolvedCwd.startsWith(resolvedPath + path.sep)) {
45
- return false;
46
- }
47
- return true;
48
- });
37
+ const deletableWorktrees = allWorktrees.filter(wt => isDeletableWorktree(wt));
49
38
  setWorktrees(deletableWorktrees);
50
39
  setIsLoading(false);
51
40
  }
@@ -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';
@@ -41,11 +42,12 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
41
42
  const worktrees = useGitStatus(baseWorktrees, defaultBranch);
42
43
  // Seed from the in-memory session list so the cached snapshot renders with its
43
44
  // sessions attached. Waiting for the async git load would leave every row
44
- // session-less for that window, disabling the Space session-actions shortcut.
45
+ // session-less for that window, hiding the session entries of the Space
46
+ // actions menu.
45
47
  const [sessions, setSessions] = useState(() => sessionManager.getAllSessions());
46
48
  const [items, setItems] = useState([]);
47
49
  const [recentProjects, setRecentProjects] = useState([]);
48
- const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
50
+ const [highlightedWorktree, setHighlightedWorktree] = useState(null);
49
51
  const [highlightedSession, setHighlightedSession] = useState(undefined);
50
52
  const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
51
53
  const [stateFilter, setStateFilter] = useState('all');
@@ -59,6 +61,9 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
59
61
  });
60
62
  // Get worktree configuration for sorting
61
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();
62
67
  useEffect(() => {
63
68
  let cancelled = false;
64
69
  // These operations are independent. Run them concurrently so the initial
@@ -153,7 +158,7 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
153
158
  const items = prepareSessionItems(worktrees, sessions, {
154
159
  sortByLastSession: worktreeConfig.sortByLastSession,
155
160
  });
156
- const columnPositions = calculateColumnPositions(items);
161
+ const columnPositions = calculateColumnPositions(items, availableLabelWidth);
157
162
  // Filter session items based on search query, matching the name shown in
158
163
  // the menu (branch name, " (main)", and session name) plus the path, then
159
164
  // narrow to the selected session state. The two filters are independent
@@ -288,16 +293,20 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
288
293
  }
289
294
  }
290
295
  setItems(menuItems);
291
- // Ensure highlighted worktree path is valid for hotkey support
292
- setHighlightedWorktreePath(prev => {
293
- if (prev &&
294
- menuItems.some(item => item.type === 'worktree' && item.worktree.path === prev)) {
295
- return prev;
296
+ // Ensure highlighted worktree is valid for hotkey support
297
+ setHighlightedWorktree(prev => {
298
+ const stillListed = prev
299
+ ? menuItems.find(item => item.type === 'worktree' && item.worktree.path === prev.path)
300
+ : undefined;
301
+ if (stillListed && stillListed.type === 'worktree') {
302
+ // Re-read the item so the highlighted worktree keeps up with
303
+ // refreshed git status instead of pinning the stale object.
304
+ return stillListed.worktree;
296
305
  }
297
306
  const first = menuItems.find(item => item.type === 'worktree');
298
307
  if (first && first.type === 'worktree') {
299
308
  setHighlightedSession(first.session);
300
- return first.worktree.path;
309
+ return first.worktree;
301
310
  }
302
311
  setHighlightedSession(undefined);
303
312
  return null;
@@ -315,6 +324,7 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
315
324
  autoApprovalToggleCounter,
316
325
  sessionManager,
317
326
  worktreeConfig.sortByLastSession,
327
+ availableLabelWidth,
318
328
  ]);
319
329
  // Handle hotkeys
320
330
  useInput((input, key) => {
@@ -371,18 +381,19 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
371
381
  switch (keyPressed) {
372
382
  case 'a':
373
383
  // Toggle auto-approval for the currently highlighted worktree
374
- if (configReader.isAutoApprovalEnabled() && highlightedWorktreePath) {
375
- sessionManager.toggleAutoApprovalForWorktree(highlightedWorktreePath);
384
+ if (configReader.isAutoApprovalEnabled() && highlightedWorktree) {
385
+ sessionManager.toggleAutoApprovalForWorktree(highlightedWorktree.path);
376
386
  setAutoApprovalToggleCounter(c => c + 1);
377
387
  }
378
388
  break;
379
389
  case ' ':
380
- // Open session actions for highlighted session
381
- if (highlightedSession && highlightedWorktreePath) {
390
+ // Open the row actions for the highlighted worktree. Rows without a
391
+ // session open the same menu with only the worktree-level entries.
392
+ if (highlightedWorktree) {
382
393
  onMenuAction({
383
394
  type: 'sessionActions',
395
+ worktree: highlightedWorktree,
384
396
  session: highlightedSession,
385
- worktreePath: highlightedWorktreePath,
386
397
  });
387
398
  }
388
399
  break;
@@ -477,13 +488,13 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
477
488
  if (!item)
478
489
  return;
479
490
  if (item.type === 'worktree') {
480
- setHighlightedWorktreePath(item.worktree.path);
491
+ setHighlightedWorktree(item.worktree);
481
492
  setHighlightedSession(item.session);
482
493
  }
483
494
  }, isFocused: !error, initialIndex: selectedIndex, limit: limit }) }), (error || loadError) && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", error || loadError] }), _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, configReader.isAutoApprovalEnabled() && (_jsxs(_Fragment, { children: [' | ', _jsx(Text, { color: "green", children: "Auto Approval Enabled" })] }))] }), _jsx(Text, { dimColor: true, children: isSearchMode
484
495
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
485
496
  : searchQuery
486
- ? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
487
- : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
497
+ ? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
498
+ : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
488
499
  };
489
500
  export default Menu;