ccmanager 4.1.6 → 4.1.8
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/dist/components/ConfigureWorktree.js +9 -0
- package/dist/components/NewWorktree.js +38 -13
- package/dist/components/Session.js +7 -10
- package/dist/services/sessionManager.autoApproval.test.js +14 -2
- package/dist/services/sessionManager.d.ts +3 -0
- package/dist/services/sessionManager.effect.test.js +14 -2
- package/dist/services/sessionManager.js +64 -5
- package/dist/services/sessionManager.test.js +85 -4
- package/dist/services/stateDetector/claude.js +1 -3
- package/dist/services/stateDetector/claude.test.js +21 -0
- package/dist/services/worktreeService.d.ts +11 -0
- package/dist/services/worktreeService.js +37 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/utils/hookExecutor.test.js +4 -0
- package/dist/utils/worktreeUtils.test.js +1 -0
- package/package.json +6 -6
|
@@ -16,6 +16,7 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
16
16
|
const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
|
|
17
17
|
const [sortByLastSession, setSortByLastSession] = useState(worktreeConfig.sortByLastSession ?? false);
|
|
18
18
|
const [autoUseDefaultBranch, setAutoUseDefaultBranch] = useState(worktreeConfig.autoUseDefaultBranch ?? false);
|
|
19
|
+
const [includeRemoteBranches, setIncludeRemoteBranches] = useState(worktreeConfig.includeRemoteBranches ?? false);
|
|
19
20
|
const [editMode, setEditMode] = useState('menu');
|
|
20
21
|
const [tempPattern, setTempPattern] = useState(pattern);
|
|
21
22
|
// Show if inheriting from global (for project scope)
|
|
@@ -50,6 +51,10 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
50
51
|
label: `Auto Use Default Branch: ${autoUseDefaultBranch ? '✅ Enabled' : '❌ Disabled'}`,
|
|
51
52
|
value: 'toggleAutoUseDefault',
|
|
52
53
|
},
|
|
54
|
+
{
|
|
55
|
+
label: `Include Remote Branches: ${includeRemoteBranches ? '✅ Enabled' : '❌ Disabled'}`,
|
|
56
|
+
value: 'toggleIncludeRemote',
|
|
57
|
+
},
|
|
53
58
|
{
|
|
54
59
|
label: '💾 Save Changes',
|
|
55
60
|
value: 'save',
|
|
@@ -77,6 +82,9 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
77
82
|
case 'toggleAutoUseDefault':
|
|
78
83
|
setAutoUseDefaultBranch(!autoUseDefaultBranch);
|
|
79
84
|
break;
|
|
85
|
+
case 'toggleIncludeRemote':
|
|
86
|
+
setIncludeRemoteBranches(!includeRemoteBranches);
|
|
87
|
+
break;
|
|
80
88
|
case 'save':
|
|
81
89
|
// Save the configuration
|
|
82
90
|
configEditor.setWorktreeConfig({
|
|
@@ -85,6 +93,7 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
85
93
|
copySessionData,
|
|
86
94
|
sortByLastSession,
|
|
87
95
|
autoUseDefaultBranch,
|
|
96
|
+
includeRemoteBranches,
|
|
88
97
|
});
|
|
89
98
|
onComplete();
|
|
90
99
|
break;
|
|
@@ -8,6 +8,7 @@ import { configReader } from '../services/config/configReader.js';
|
|
|
8
8
|
import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
|
|
9
9
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
10
10
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
11
|
+
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
11
12
|
import SearchableList from './SearchableList.js';
|
|
12
13
|
import { Effect } from 'effect';
|
|
13
14
|
import { describePromptInjection, getPromptInjectionMethod, } from '../utils/presetPrompt.js';
|
|
@@ -16,7 +17,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
16
17
|
const presetsConfig = configReader.getCommandPresets();
|
|
17
18
|
const isAutoDirectory = worktreeConfig.autoDirectory;
|
|
18
19
|
const isAutoUseDefaultBranch = worktreeConfig.autoUseDefaultBranch ?? false;
|
|
19
|
-
const
|
|
20
|
+
const includeRemoteBranches = worktreeConfig.includeRemoteBranches ?? false;
|
|
20
21
|
const getInitialStep = () => {
|
|
21
22
|
if (isAutoDirectory) {
|
|
22
23
|
return 'base-branch';
|
|
@@ -34,20 +35,28 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
34
35
|
const [isLoadingBranches, setIsLoadingBranches] = useState(true);
|
|
35
36
|
const [branchLoadError, setBranchLoadError] = useState(null);
|
|
36
37
|
const [branches, setBranches] = useState([]);
|
|
38
|
+
const [remoteBranches, setRemoteBranches] = useState([]);
|
|
37
39
|
const [defaultBranch, setDefaultBranch] = useState('main');
|
|
38
40
|
useEffect(() => {
|
|
39
41
|
let cancelled = false;
|
|
40
42
|
const service = new WorktreeService(projectPath);
|
|
41
43
|
const loadBranches = async () => {
|
|
42
|
-
const
|
|
44
|
+
const branchesEffect = includeRemoteBranches
|
|
45
|
+
? service.getBranchesWithRemotesEffect()
|
|
46
|
+
: Effect.map(service.getAllBranchesEffect(), (list) => ({
|
|
47
|
+
local: list,
|
|
48
|
+
remote: [],
|
|
49
|
+
}));
|
|
50
|
+
const workflow = Effect.all([branchesEffect, service.getDefaultBranchEffect()], { concurrency: 2 });
|
|
43
51
|
const result = await Effect.runPromise(Effect.match(workflow, {
|
|
44
52
|
onFailure: (error) => ({
|
|
45
53
|
type: 'error',
|
|
46
54
|
message: formatError(error),
|
|
47
55
|
}),
|
|
48
|
-
onSuccess: ([
|
|
56
|
+
onSuccess: ([branchData, defaultBr]) => ({
|
|
49
57
|
type: 'success',
|
|
50
|
-
|
|
58
|
+
local: branchData.local,
|
|
59
|
+
remote: branchData.remote,
|
|
51
60
|
defaultBranch: defaultBr,
|
|
52
61
|
}),
|
|
53
62
|
}));
|
|
@@ -57,7 +66,8 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
57
66
|
setIsLoadingBranches(false);
|
|
58
67
|
}
|
|
59
68
|
else {
|
|
60
|
-
setBranches(result.
|
|
69
|
+
setBranches(result.local);
|
|
70
|
+
setRemoteBranches(result.remote);
|
|
61
71
|
setDefaultBranch(result.defaultBranch);
|
|
62
72
|
setIsLoadingBranches(false);
|
|
63
73
|
if (isAutoUseDefaultBranch && result.defaultBranch) {
|
|
@@ -76,16 +86,31 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
76
86
|
return () => {
|
|
77
87
|
cancelled = true;
|
|
78
88
|
};
|
|
79
|
-
}, [projectPath, isAutoUseDefaultBranch]);
|
|
80
|
-
const allBranchItems = useMemo(() =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
}, [projectPath, isAutoUseDefaultBranch, includeRemoteBranches]);
|
|
90
|
+
const allBranchItems = useMemo(() => {
|
|
91
|
+
const defaultRemoteSuffix = `/${defaultBranch}`;
|
|
92
|
+
const defaultRemotes = remoteBranches.filter(br => br.endsWith(defaultRemoteSuffix));
|
|
93
|
+
const otherRemotes = remoteBranches.filter(br => !br.endsWith(defaultRemoteSuffix));
|
|
94
|
+
return [
|
|
95
|
+
{ label: `${defaultBranch} (default)`, value: defaultBranch },
|
|
96
|
+
...defaultRemotes.map(br => ({
|
|
97
|
+
label: `${br} (default remote)`,
|
|
98
|
+
value: br,
|
|
99
|
+
})),
|
|
100
|
+
...branches
|
|
101
|
+
.filter(br => br !== defaultBranch)
|
|
102
|
+
.map(br => ({ label: br, value: br })),
|
|
103
|
+
...otherRemotes.map(br => ({ label: br, value: br })),
|
|
104
|
+
];
|
|
105
|
+
}, [branches, remoteBranches, defaultBranch]);
|
|
86
106
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(allBranchItems.length, {
|
|
87
107
|
isDisabled: step !== 'base-branch',
|
|
88
108
|
});
|
|
109
|
+
const limit = useDynamicLimit({
|
|
110
|
+
fixedRows: includeRemoteBranches ? 10 : 8,
|
|
111
|
+
isSearchMode,
|
|
112
|
+
hasError: !!branchLoadError,
|
|
113
|
+
});
|
|
89
114
|
const branchItems = useMemo(() => {
|
|
90
115
|
if (!searchQuery)
|
|
91
116
|
return allBranchItems;
|
|
@@ -239,7 +264,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
239
264
|
const promptMethod = selectedPreset
|
|
240
265
|
? getPromptInjectionMethod(selectedPreset)
|
|
241
266
|
: 'stdin';
|
|
242
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
|
|
267
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
|
|
243
268
|
{
|
|
244
269
|
label: '1. Choose the branch name yourself',
|
|
245
270
|
value: 'manual',
|
|
@@ -65,9 +65,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
65
65
|
stdout.write('\x1B[2J\x1B[H');
|
|
66
66
|
// Restore the current terminal state from the headless xterm snapshot.
|
|
67
67
|
// The xterm serialize addon relies on auto-wrap (DECAWM) being enabled to
|
|
68
|
-
// render wrapped lines
|
|
69
|
-
// characters to naturally overflow to the next line
|
|
70
|
-
//
|
|
68
|
+
// render wrapped lines. It omits row separators for wrapped rows and expects
|
|
69
|
+
// characters to naturally overflow to the next line, so auto-wrap must stay
|
|
70
|
+
// enabled while writing the snapshot and only be disabled afterward.
|
|
71
71
|
const handleSessionRestore = (restoredSession, restoreSnapshot) => {
|
|
72
72
|
if (restoredSession.id === session.id) {
|
|
73
73
|
if (restoreSnapshot.length > 0) {
|
|
@@ -109,15 +109,12 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
109
109
|
/* empty */
|
|
110
110
|
}
|
|
111
111
|
// Mark session as active after resizing so the restore snapshot matches
|
|
112
|
-
// the current terminal dimensions.
|
|
113
|
-
//
|
|
114
|
-
// we proceed.
|
|
112
|
+
// the current terminal dimensions. setSessionActive synchronously emits the
|
|
113
|
+
// restore event, so the snapshot is written to stdout before we proceed.
|
|
115
114
|
sessionManager.setSessionActive(session.id, true);
|
|
116
115
|
// Prevent line wrapping from drifting redraws in TUIs that rely on
|
|
117
|
-
// cursor-up clears.
|
|
118
|
-
//
|
|
119
|
-
// enabled — it omits row separators for wrapped rows, expecting characters
|
|
120
|
-
// to naturally overflow to the next line.
|
|
116
|
+
// cursor-up clears. This must happen after the restore snapshot write,
|
|
117
|
+
// otherwise wrapped restore content can overlap on the same row.
|
|
121
118
|
stdout.write('\x1b[?7l');
|
|
122
119
|
// Handle terminal resize
|
|
123
120
|
const handleResize = () => {
|
|
@@ -69,13 +69,25 @@ vi.mock('@xterm/addon-serialize', () => ({
|
|
|
69
69
|
vi.mock('@xterm/headless', () => ({
|
|
70
70
|
default: {
|
|
71
71
|
Terminal: vi.fn().mockImplementation(function () {
|
|
72
|
+
const normalBuffer = {
|
|
73
|
+
type: 'normal',
|
|
74
|
+
baseY: 0,
|
|
75
|
+
cursorY: 0,
|
|
76
|
+
cursorX: 0,
|
|
77
|
+
length: 0,
|
|
78
|
+
getLine: vi.fn(),
|
|
79
|
+
};
|
|
72
80
|
return {
|
|
73
81
|
rows: 24,
|
|
74
82
|
cols: 80,
|
|
75
83
|
buffer: {
|
|
76
|
-
active:
|
|
77
|
-
|
|
84
|
+
active: normalBuffer,
|
|
85
|
+
normal: normalBuffer,
|
|
86
|
+
alternate: {
|
|
87
|
+
type: 'alternate',
|
|
78
88
|
baseY: 0,
|
|
89
|
+
cursorY: 0,
|
|
90
|
+
cursorX: 0,
|
|
79
91
|
length: 0,
|
|
80
92
|
getLine: vi.fn(),
|
|
81
93
|
},
|
|
@@ -16,6 +16,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
16
16
|
private waitingWithBottomBorder;
|
|
17
17
|
private busyTimers;
|
|
18
18
|
private autoApprovalDisabledWorktrees;
|
|
19
|
+
private restoringSessions;
|
|
20
|
+
private bufferedRestoreData;
|
|
19
21
|
private spawn;
|
|
20
22
|
private resolvePreset;
|
|
21
23
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -35,6 +37,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
35
37
|
private updateSessionState;
|
|
36
38
|
constructor();
|
|
37
39
|
private createTerminal;
|
|
40
|
+
private shouldResetRestoreScrollback;
|
|
38
41
|
private getRestoreSnapshot;
|
|
39
42
|
private createSessionInternal;
|
|
40
43
|
/**
|
|
@@ -46,13 +46,25 @@ vi.mock('@xterm/addon-serialize', () => ({
|
|
|
46
46
|
vi.mock('@xterm/headless', () => ({
|
|
47
47
|
default: {
|
|
48
48
|
Terminal: vi.fn().mockImplementation(function () {
|
|
49
|
+
const normalBuffer = {
|
|
50
|
+
type: 'normal',
|
|
51
|
+
baseY: 0,
|
|
52
|
+
cursorY: 0,
|
|
53
|
+
cursorX: 0,
|
|
54
|
+
length: 0,
|
|
55
|
+
getLine: vi.fn(),
|
|
56
|
+
};
|
|
49
57
|
return {
|
|
50
58
|
rows: 24,
|
|
51
59
|
cols: 80,
|
|
52
60
|
buffer: {
|
|
53
|
-
active:
|
|
54
|
-
|
|
61
|
+
active: normalBuffer,
|
|
62
|
+
normal: normalBuffer,
|
|
63
|
+
alternate: {
|
|
64
|
+
type: 'alternate',
|
|
55
65
|
baseY: 0,
|
|
66
|
+
cursorY: 0,
|
|
67
|
+
cursorX: 0,
|
|
56
68
|
length: 0,
|
|
57
69
|
getLine: vi.fn(),
|
|
58
70
|
},
|
|
@@ -20,11 +20,14 @@ import { preparePresetLaunch } from '../utils/presetPrompt.js';
|
|
|
20
20
|
const { Terminal } = pkg;
|
|
21
21
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
22
22
|
const TERMINAL_SCROLLBACK_LINES = 5000;
|
|
23
|
+
const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
|
|
23
24
|
export class SessionManager extends EventEmitter {
|
|
24
25
|
sessions;
|
|
25
26
|
waitingWithBottomBorder = new Map();
|
|
26
27
|
busyTimers = new Map();
|
|
27
28
|
autoApprovalDisabledWorktrees = new Set();
|
|
29
|
+
restoringSessions = new Set();
|
|
30
|
+
bufferedRestoreData = new Map();
|
|
28
31
|
async spawn(command, args, worktreePath, options = {}) {
|
|
29
32
|
const spawnOptions = {
|
|
30
33
|
name: 'xterm-256color',
|
|
@@ -197,10 +200,36 @@ export class SessionManager extends EventEmitter {
|
|
|
197
200
|
logLevel: 'off',
|
|
198
201
|
});
|
|
199
202
|
}
|
|
203
|
+
shouldResetRestoreScrollback(data) {
|
|
204
|
+
return (data.includes('\x1b[2J') ||
|
|
205
|
+
data.includes('\x1b[3J') ||
|
|
206
|
+
data.includes('\x1bc'));
|
|
207
|
+
}
|
|
200
208
|
getRestoreSnapshot(session) {
|
|
201
|
-
|
|
202
|
-
|
|
209
|
+
const activeBuffer = session.terminal.buffer.active;
|
|
210
|
+
if (activeBuffer.type !== 'normal') {
|
|
211
|
+
return session.serializer.serialize({
|
|
212
|
+
scrollback: 0,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
216
|
+
const bufferLength = normalBuffer.length;
|
|
217
|
+
if (bufferLength === 0) {
|
|
218
|
+
return '';
|
|
219
|
+
}
|
|
220
|
+
const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
|
|
221
|
+
const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
|
|
222
|
+
const rangeEnd = bufferLength - 1;
|
|
223
|
+
const snapshot = session.serializer.serialize({
|
|
224
|
+
range: {
|
|
225
|
+
start: rangeStart,
|
|
226
|
+
end: rangeEnd,
|
|
227
|
+
},
|
|
228
|
+
excludeAltBuffer: true,
|
|
203
229
|
});
|
|
230
|
+
const cursorRow = normalBuffer.cursorY + 1;
|
|
231
|
+
const cursorCol = normalBuffer.cursorX + 1;
|
|
232
|
+
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
204
233
|
}
|
|
205
234
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
206
235
|
const existingSessions = this.getSessionsForWorktree(worktreePath);
|
|
@@ -224,6 +253,7 @@ export class SessionManager extends EventEmitter {
|
|
|
224
253
|
isActive: false,
|
|
225
254
|
terminal,
|
|
226
255
|
serializer,
|
|
256
|
+
restoreScrollbackBaseLine: 0,
|
|
227
257
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
228
258
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
229
259
|
presetName: options.presetName,
|
|
@@ -299,9 +329,19 @@ export class SessionManager extends EventEmitter {
|
|
|
299
329
|
session.process.onData((data) => {
|
|
300
330
|
// Write data to virtual terminal
|
|
301
331
|
session.terminal.write(data);
|
|
332
|
+
if (this.shouldResetRestoreScrollback(data)) {
|
|
333
|
+
session.restoreScrollbackBaseLine =
|
|
334
|
+
session.terminal.buffer.normal.baseY;
|
|
335
|
+
}
|
|
302
336
|
session.lastActivity = new Date();
|
|
303
337
|
// Only emit data events when session is active
|
|
304
338
|
if (session.isActive) {
|
|
339
|
+
if (this.restoringSessions.has(session.id)) {
|
|
340
|
+
const bufferedData = this.bufferedRestoreData.get(session.id) ?? [];
|
|
341
|
+
bufferedData.push(data);
|
|
342
|
+
this.bufferedRestoreData.set(session.id, bufferedData);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
305
345
|
this.emit('sessionData', session, data);
|
|
306
346
|
}
|
|
307
347
|
});
|
|
@@ -436,10 +476,27 @@ export class SessionManager extends EventEmitter {
|
|
|
436
476
|
session.isActive = active;
|
|
437
477
|
if (active) {
|
|
438
478
|
session.lastAccessedAt = Date.now();
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
this.
|
|
479
|
+
this.restoringSessions.add(session.id);
|
|
480
|
+
try {
|
|
481
|
+
const restoreSnapshot = this.getRestoreSnapshot(session);
|
|
482
|
+
if (restoreSnapshot.length > 0) {
|
|
483
|
+
this.emit('sessionRestore', session, restoreSnapshot);
|
|
484
|
+
}
|
|
442
485
|
}
|
|
486
|
+
finally {
|
|
487
|
+
this.restoringSessions.delete(session.id);
|
|
488
|
+
const bufferedData = this.bufferedRestoreData.get(session.id);
|
|
489
|
+
if (bufferedData && bufferedData.length > 0) {
|
|
490
|
+
this.bufferedRestoreData.delete(session.id);
|
|
491
|
+
for (const chunk of bufferedData) {
|
|
492
|
+
this.emit('sessionData', session, chunk);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
this.restoringSessions.delete(session.id);
|
|
499
|
+
this.bufferedRestoreData.delete(session.id);
|
|
443
500
|
}
|
|
444
501
|
}
|
|
445
502
|
}
|
|
@@ -512,6 +569,8 @@ export class SessionManager extends EventEmitter {
|
|
|
512
569
|
}
|
|
513
570
|
this.sessions.delete(sessionId);
|
|
514
571
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
572
|
+
this.restoringSessions.delete(sessionId);
|
|
573
|
+
this.bufferedRestoreData.delete(sessionId);
|
|
515
574
|
this.emit('sessionDestroyed', session);
|
|
516
575
|
}
|
|
517
576
|
}
|
|
@@ -50,13 +50,27 @@ vi.mock('@xterm/addon-serialize', () => ({
|
|
|
50
50
|
vi.mock('@xterm/headless', () => ({
|
|
51
51
|
default: {
|
|
52
52
|
Terminal: vi.fn(function () {
|
|
53
|
+
const normalBuffer = {
|
|
54
|
+
type: 'normal',
|
|
55
|
+
baseY: 0,
|
|
56
|
+
cursorY: 0,
|
|
57
|
+
cursorX: 0,
|
|
58
|
+
length: 0,
|
|
59
|
+
getLine: vi.fn(function () {
|
|
60
|
+
return null;
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
53
63
|
return {
|
|
54
64
|
rows: 24,
|
|
55
65
|
cols: 80,
|
|
56
66
|
buffer: {
|
|
57
|
-
active:
|
|
58
|
-
|
|
67
|
+
active: normalBuffer,
|
|
68
|
+
normal: normalBuffer,
|
|
69
|
+
alternate: {
|
|
70
|
+
type: 'alternate',
|
|
59
71
|
baseY: 0,
|
|
72
|
+
cursorY: 0,
|
|
73
|
+
cursorX: 0,
|
|
60
74
|
length: 0,
|
|
61
75
|
getLine: vi.fn(function () {
|
|
62
76
|
return null;
|
|
@@ -760,7 +774,7 @@ describe('SessionManager', () => {
|
|
|
760
774
|
});
|
|
761
775
|
});
|
|
762
776
|
describe('session restore snapshots', () => {
|
|
763
|
-
it('should emit
|
|
777
|
+
it('should emit a bounded normal-buffer restore snapshot and restore the cursor position', async () => {
|
|
764
778
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
765
779
|
id: '1',
|
|
766
780
|
name: 'Main',
|
|
@@ -768,14 +782,44 @@ describe('SessionManager', () => {
|
|
|
768
782
|
});
|
|
769
783
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
770
784
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
785
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
786
|
+
normalBuffer.baseY = 260;
|
|
787
|
+
normalBuffer.length = 300;
|
|
788
|
+
normalBuffer.cursorY = 7;
|
|
789
|
+
normalBuffer.cursorX = 11;
|
|
790
|
+
session.restoreScrollbackBaseLine = 120;
|
|
771
791
|
const serializeMock = vi
|
|
772
792
|
.spyOn(session.serializer, 'serialize')
|
|
773
793
|
.mockReturnValue('\u001b[31mrestored\u001b[0m');
|
|
774
794
|
const restoreHandler = vi.fn();
|
|
775
795
|
sessionManager.on('sessionRestore', restoreHandler);
|
|
776
796
|
sessionManager.setSessionActive(session.id, true);
|
|
797
|
+
expect(serializeMock).toHaveBeenCalledWith({
|
|
798
|
+
range: {
|
|
799
|
+
start: 120,
|
|
800
|
+
end: 299,
|
|
801
|
+
},
|
|
802
|
+
excludeAltBuffer: true,
|
|
803
|
+
});
|
|
804
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
|
|
805
|
+
});
|
|
806
|
+
it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
|
|
807
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
808
|
+
id: '1',
|
|
809
|
+
name: 'Main',
|
|
810
|
+
command: 'claude',
|
|
811
|
+
});
|
|
812
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
813
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
814
|
+
session.terminal.buffer.active = session.terminal.buffer.alternate;
|
|
815
|
+
const serializeMock = vi
|
|
816
|
+
.spyOn(session.serializer, 'serialize')
|
|
817
|
+
.mockReturnValue('\u001b[31malt\u001b[0m');
|
|
818
|
+
const restoreHandler = vi.fn();
|
|
819
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
820
|
+
sessionManager.setSessionActive(session.id, true);
|
|
777
821
|
expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
|
|
778
|
-
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[
|
|
822
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31malt\u001b[0m');
|
|
779
823
|
});
|
|
780
824
|
it('should skip restore event when serialized output is empty', async () => {
|
|
781
825
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -791,6 +835,43 @@ describe('SessionManager', () => {
|
|
|
791
835
|
sessionManager.setSessionActive(session.id, true);
|
|
792
836
|
expect(restoreHandler).not.toHaveBeenCalled();
|
|
793
837
|
});
|
|
838
|
+
it('should reset restore scrollback baseline after a clear-screen sequence', async () => {
|
|
839
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
840
|
+
id: '1',
|
|
841
|
+
name: 'Main',
|
|
842
|
+
command: 'claude',
|
|
843
|
+
});
|
|
844
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
845
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
846
|
+
session.terminal.buffer.normal.baseY = 17;
|
|
847
|
+
mockPty.emit('data', '\x1b[2J\x1b[Hfresh');
|
|
848
|
+
expect(session.restoreScrollbackBaseLine).toBe(17);
|
|
849
|
+
});
|
|
850
|
+
it('should flush live session data after the restore snapshot completes', async () => {
|
|
851
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
852
|
+
id: '1',
|
|
853
|
+
name: 'Main',
|
|
854
|
+
command: 'claude',
|
|
855
|
+
});
|
|
856
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
857
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
858
|
+
session.terminal.buffer.normal.length = 1;
|
|
859
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('restored');
|
|
860
|
+
const eventOrder = [];
|
|
861
|
+
sessionManager.on('sessionRestore', restoredSession => {
|
|
862
|
+
if (restoredSession.id === session.id) {
|
|
863
|
+
eventOrder.push('restore');
|
|
864
|
+
mockPty.emit('data', 'live-output');
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
sessionManager.on('sessionData', activeSession => {
|
|
868
|
+
if (activeSession.id === session.id) {
|
|
869
|
+
eventOrder.push('data');
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
sessionManager.setSessionActive(session.id, true);
|
|
873
|
+
expect(eventOrder).toEqual(['restore', 'data']);
|
|
874
|
+
});
|
|
794
875
|
});
|
|
795
876
|
describe('static methods', () => {
|
|
796
877
|
describe('getSessionCounts', () => {
|
|
@@ -6,7 +6,6 @@ const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃
|
|
|
6
6
|
const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
|
|
7
7
|
// Session stats above the prompt, e.g. "(9m 21s · ↓ 13.7k tokens)" — requires parens, a digit, and "tokens"
|
|
8
8
|
const TOKEN_STATS_LINE_PATTERN = /\([^)]*\d[^)]*tokens\s*\)/i;
|
|
9
|
-
const BUSY_LOOKBACK_LINES = 5;
|
|
10
9
|
// Workaround: Claude Code sometimes appears idle in terminal output while
|
|
11
10
|
// still actively processing (busy). To mitigate false idle transitions,
|
|
12
11
|
// require terminal output to remain unchanged for this duration before
|
|
@@ -88,8 +87,7 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
88
87
|
}
|
|
89
88
|
start--;
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
return recentBlock.slice(-BUSY_LOOKBACK_LINES).join('\n');
|
|
90
|
+
return lines.slice(Math.max(start, 0)).join('\n');
|
|
93
91
|
}
|
|
94
92
|
detectState(terminal, currentState) {
|
|
95
93
|
// Check for search prompt (⌕ Search…) within 200 lines - always idle (debounced)
|
|
@@ -446,6 +446,27 @@ describe('ClaudeStateDetector', () => {
|
|
|
446
446
|
// Assert - Should be idle because search prompt takes precedence
|
|
447
447
|
expect(state).toBe('idle');
|
|
448
448
|
});
|
|
449
|
+
it('should detect busy when spinner + token stats header is followed by a long TodoWrite checklist', () => {
|
|
450
|
+
// Regression: the recent block contains a spinner/token header and
|
|
451
|
+
// many checklist items with no internal blank line. All lines are
|
|
452
|
+
// part of the same contiguous update and should be inspected.
|
|
453
|
+
terminal = createMockTerminal([
|
|
454
|
+
'✽ Add GitHub Actions workflow and commit… (50s · ↓ 794 tokens)',
|
|
455
|
+
' ⎿ ✔ Create docs/index.config.json',
|
|
456
|
+
' ✔ Reorganize existing docs into topic directories',
|
|
457
|
+
' ✔ Add frontmatter to existing docs',
|
|
458
|
+
' ✔ Create docs/INDEX.md, docs/README.md, templates, workflow',
|
|
459
|
+
' ✔ Create root AGENTS.md and README.md',
|
|
460
|
+
' ✔ Write manifest generation Go script',
|
|
461
|
+
' ✔ Add Makefile tasks and run first generation',
|
|
462
|
+
' ◼ Add GitHub Actions workflow and commit',
|
|
463
|
+
'──────────────────────────────',
|
|
464
|
+
'❯',
|
|
465
|
+
'──────────────────────────────',
|
|
466
|
+
]);
|
|
467
|
+
const state = detector.detectState(terminal, 'idle');
|
|
468
|
+
expect(state).toBe('busy');
|
|
469
|
+
});
|
|
449
470
|
it('should ignore stale spinner output outside the latest block above the prompt box', () => {
|
|
450
471
|
terminal = createMockTerminal([
|
|
451
472
|
'✻ Seasoning… (44s · ↓ 247 tokens)',
|
|
@@ -201,6 +201,17 @@ export declare class WorktreeService {
|
|
|
201
201
|
* @throws {GitError} When git branch command fails (but falls back to empty array)
|
|
202
202
|
*/
|
|
203
203
|
getAllBranchesEffect(): Effect.Effect<string[], GitError, never>;
|
|
204
|
+
/**
|
|
205
|
+
* Effect-based getBranchesWithRemotes operation
|
|
206
|
+
* Returns local and remote branches separately so callers can distinguish them.
|
|
207
|
+
* Remote branches keep their `<remote>/<branch>` prefix (e.g. `origin/main`).
|
|
208
|
+
*
|
|
209
|
+
* @returns {Effect.Effect<{local: string[]; remote: string[]}, GitError, never>}
|
|
210
|
+
*/
|
|
211
|
+
getBranchesWithRemotesEffect(): Effect.Effect<{
|
|
212
|
+
local: string[];
|
|
213
|
+
remote: string[];
|
|
214
|
+
}, GitError, never>;
|
|
204
215
|
/**
|
|
205
216
|
* Effect-based getCurrentBranch operation
|
|
206
217
|
* Returns Effect that may fail with GitError
|
|
@@ -499,6 +499,43 @@ export class WorktreeService {
|
|
|
499
499
|
return Effect.succeed([]);
|
|
500
500
|
});
|
|
501
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Effect-based getBranchesWithRemotes operation
|
|
504
|
+
* Returns local and remote branches separately so callers can distinguish them.
|
|
505
|
+
* Remote branches keep their `<remote>/<branch>` prefix (e.g. `origin/main`).
|
|
506
|
+
*
|
|
507
|
+
* @returns {Effect.Effect<{local: string[]; remote: string[]}, GitError, never>}
|
|
508
|
+
*/
|
|
509
|
+
getBranchesWithRemotesEffect() {
|
|
510
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
511
|
+
const self = this;
|
|
512
|
+
return Effect.catchAll(Effect.try({
|
|
513
|
+
try: () => {
|
|
514
|
+
const output = execSync("git branch -a --format='%(refname:short)' | grep -v HEAD | sort -u", {
|
|
515
|
+
cwd: self.rootPath,
|
|
516
|
+
encoding: 'utf8',
|
|
517
|
+
shell: '/bin/bash',
|
|
518
|
+
});
|
|
519
|
+
const remotes = self.getAllRemotes();
|
|
520
|
+
const remotePrefixes = remotes.map(r => `${r}/`);
|
|
521
|
+
const local = [];
|
|
522
|
+
const remote = [];
|
|
523
|
+
for (const raw of output.trim().split('\n')) {
|
|
524
|
+
const branch = raw.trim();
|
|
525
|
+
if (!branch)
|
|
526
|
+
continue;
|
|
527
|
+
if (remotePrefixes.some(prefix => branch.startsWith(prefix))) {
|
|
528
|
+
remote.push(branch);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
local.push(branch);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return { local, remote };
|
|
535
|
+
},
|
|
536
|
+
catch: (error) => error,
|
|
537
|
+
}), (_error) => Effect.succeed({ local: [], remote: [] }));
|
|
538
|
+
}
|
|
502
539
|
/**
|
|
503
540
|
* Effect-based getCurrentBranch operation
|
|
504
541
|
* Returns Effect that may fail with GitError
|
package/dist/types/index.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface Session {
|
|
|
30
30
|
isActive: boolean;
|
|
31
31
|
terminal: Terminal;
|
|
32
32
|
serializer: SerializeAddon;
|
|
33
|
+
restoreScrollbackBaseLine: number;
|
|
33
34
|
stateCheckInterval: NodeJS.Timeout | undefined;
|
|
34
35
|
isPrimaryCommand: boolean;
|
|
35
36
|
presetName: string | undefined;
|
|
@@ -123,6 +124,7 @@ export interface WorktreeConfig {
|
|
|
123
124
|
copySessionData?: boolean;
|
|
124
125
|
sortByLastSession?: boolean;
|
|
125
126
|
autoUseDefaultBranch?: boolean;
|
|
127
|
+
includeRemoteBranches?: boolean;
|
|
126
128
|
}
|
|
127
129
|
export interface MergeConfig {
|
|
128
130
|
mergeArgs?: string[];
|
|
@@ -383,6 +383,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
383
383
|
process: {},
|
|
384
384
|
terminal: {},
|
|
385
385
|
serializer: {},
|
|
386
|
+
restoreScrollbackBaseLine: 0,
|
|
386
387
|
output: [],
|
|
387
388
|
stateCheckInterval: undefined,
|
|
388
389
|
isPrimaryCommand: true,
|
|
@@ -442,6 +443,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
442
443
|
process: {},
|
|
443
444
|
terminal: {},
|
|
444
445
|
serializer: {},
|
|
446
|
+
restoreScrollbackBaseLine: 0,
|
|
445
447
|
output: [],
|
|
446
448
|
stateCheckInterval: undefined,
|
|
447
449
|
isPrimaryCommand: true,
|
|
@@ -499,6 +501,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
499
501
|
process: {},
|
|
500
502
|
terminal: {},
|
|
501
503
|
serializer: {},
|
|
504
|
+
restoreScrollbackBaseLine: 0,
|
|
502
505
|
output: [],
|
|
503
506
|
stateCheckInterval: undefined,
|
|
504
507
|
isPrimaryCommand: true,
|
|
@@ -558,6 +561,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
558
561
|
process: {},
|
|
559
562
|
terminal: {},
|
|
560
563
|
serializer: {},
|
|
564
|
+
restoreScrollbackBaseLine: 0,
|
|
561
565
|
output: [],
|
|
562
566
|
stateCheckInterval: undefined,
|
|
563
567
|
isPrimaryCommand: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.8",
|
|
4
4
|
"description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kodai Kabasawa",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.8",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.8",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.8",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.8",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.8"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|