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.
- package/README.md +54 -0
- package/dist/components/App.js +92 -30
- package/dist/components/App.test.js +86 -0
- package/dist/components/Dashboard.js +10 -3
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +28 -17
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/RestoreSessions.d.ts +19 -0
- package/dist/components/RestoreSessions.js +28 -0
- package/dist/components/SessionActions.d.ts +17 -2
- package/dist/components/SessionActions.js +19 -17
- package/dist/components/SessionActions.test.d.ts +1 -0
- package/dist/components/SessionActions.test.js +94 -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/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +30 -0
- package/dist/services/worktreeService.test.js +55 -1
- package/dist/types/index.d.ts +3 -2
- 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/worktreeInclude.d.ts +33 -0
- package/dist/utils/worktreeInclude.js +100 -0
- package/dist/utils/worktreeInclude.test.d.ts +1 -0
- package/dist/utils/worktreeInclude.test.js +91 -0
- package/dist/utils/worktreeUtils.d.ts +38 -4
- package/dist/utils/worktreeUtils.js +76 -17
- package/dist/utils/worktreeUtils.test.js +82 -5
- 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.
|
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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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 = `
|
|
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 = () => {
|
|
@@ -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
|
-
|
|
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
|
}
|
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';
|
|
@@ -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,
|
|
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 [
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
menuItems.
|
|
295
|
-
|
|
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
|
|
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() &&
|
|
375
|
-
sessionManager.toggleAutoApprovalForWorktree(
|
|
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
|
|
381
|
-
|
|
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
|
-
|
|
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-
|
|
487
|
-
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-
|
|
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;
|