ccmanager 3.11.3 → 3.12.1
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/Dashboard.js +8 -6
- package/dist/components/DeleteWorktree.js +42 -19
- package/dist/components/Menu.js +22 -29
- package/dist/components/NewWorktree.js +2 -1
- package/dist/components/SearchableList.d.ts +22 -0
- package/dist/components/SearchableList.js +14 -0
- package/dist/constants/statePersistence.d.ts +1 -0
- package/dist/constants/statePersistence.js +2 -0
- package/dist/hooks/useDynamicLimit.d.ts +10 -0
- package/dist/hooks/useDynamicLimit.js +9 -0
- package/dist/services/sessionManager.autoApproval.test.js +15 -27
- package/dist/services/sessionManager.js +9 -2
- package/dist/services/sessionManager.statePersistence.test.js +72 -6
- package/dist/services/stateDetector/claude.d.ts +11 -0
- package/dist/services/stateDetector/claude.js +48 -8
- package/dist/services/stateDetector/claude.test.js +188 -9
- package/dist/services/stateDetector/codex.js +4 -0
- package/dist/services/stateDetector/codex.test.js +28 -0
- package/dist/utils/filterByQuery.d.ts +5 -0
- package/dist/utils/filterByQuery.js +13 -0
- package/dist/utils/mutex.d.ts +1 -0
- package/dist/utils/mutex.js +1 -0
- package/package.json +6 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useMemo } from 'react';
|
|
3
|
-
import { Box, Text, useInput
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import { Effect } from 'effect';
|
|
5
5
|
import SelectInput from 'ink-select-input';
|
|
6
6
|
import stripAnsi from 'strip-ansi';
|
|
@@ -10,10 +10,11 @@ import { SessionManager } from '../services/sessionManager.js';
|
|
|
10
10
|
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
|
+
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
13
14
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
14
15
|
import { truncateString, calculateColumnPositions, assembleWorktreeLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
|
|
15
16
|
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
|
|
16
|
-
import
|
|
17
|
+
import SearchableList from './SearchableList.js';
|
|
17
18
|
const MAX_BRANCH_NAME_LENGTH = 70;
|
|
18
19
|
const createSeparatorWithText = (text, totalWidth = 35) => {
|
|
19
20
|
const textWithSpaces = ` ${text} `;
|
|
@@ -72,14 +73,15 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
72
73
|
const [sessionEntries, setSessionEntries] = useState([]);
|
|
73
74
|
const [baseSessionWorktrees, setBaseSessionWorktrees] = useState([]);
|
|
74
75
|
const [sessionRefreshKey, setSessionRefreshKey] = useState(0);
|
|
75
|
-
const { stdout } = useStdout();
|
|
76
|
-
const fixedRows = 6;
|
|
77
76
|
const displayError = error || loadError;
|
|
78
77
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
79
78
|
isDisabled: !!displayError,
|
|
80
79
|
skipInTest: false,
|
|
81
80
|
});
|
|
82
|
-
const limit =
|
|
81
|
+
const limit = useDynamicLimit({
|
|
82
|
+
isSearchMode,
|
|
83
|
+
hasError: !!displayError,
|
|
84
|
+
});
|
|
83
85
|
// Git status polling for session worktrees
|
|
84
86
|
const enrichedWorktrees = useGitStatus(baseSessionWorktrees, baseSessionWorktrees.length > 0 ? 'main' : null);
|
|
85
87
|
// Discover projects on mount
|
|
@@ -440,7 +442,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
440
442
|
});
|
|
441
443
|
}
|
|
442
444
|
};
|
|
443
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { bold: true, color: "green", children: ["CCManager - Dashboard v", version] }) }),
|
|
445
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { bold: true, color: "green", children: ["CCManager - Dashboard v", version] }) }), loading ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "Discovering projects..." }) })) : projects.length === 0 && !displayError ? (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["No git repositories found in ", projectsDir] }) })) : (_jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter...", noMatchMessage: "No matches found", children: _jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), isFocused: !displayError, limit: limit, initialIndex: selectedIndex }) })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
|
|
444
446
|
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
445
447
|
: searchQuery
|
|
446
448
|
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
|
|
@@ -7,6 +7,10 @@ import { Effect } from 'effect';
|
|
|
7
7
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
8
8
|
import DeleteConfirmation from './DeleteConfirmation.js';
|
|
9
9
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
10
|
+
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
11
|
+
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
12
|
+
import { filterWorktreesByQuery } from '../utils/filterByQuery.js';
|
|
13
|
+
import SearchableList from './SearchableList.js';
|
|
10
14
|
const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
11
15
|
const [worktrees, setWorktrees] = useState([]);
|
|
12
16
|
const [selectedIndices, setSelectedIndices] = useState(new Set());
|
|
@@ -14,6 +18,15 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
14
18
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
15
19
|
const [error, setError] = useState(null);
|
|
16
20
|
const [isLoading, setIsLoading] = useState(true);
|
|
21
|
+
const [menuItems, setMenuItems] = useState([]);
|
|
22
|
+
// Use the search mode hook
|
|
23
|
+
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(menuItems.length, {
|
|
24
|
+
isDisabled: confirmMode,
|
|
25
|
+
});
|
|
26
|
+
const limit = useDynamicLimit({
|
|
27
|
+
isSearchMode,
|
|
28
|
+
hasError: !!error,
|
|
29
|
+
});
|
|
17
30
|
useEffect(() => {
|
|
18
31
|
let cancelled = false;
|
|
19
32
|
const loadWorktrees = async () => {
|
|
@@ -49,17 +62,22 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
49
62
|
cancelled = true;
|
|
50
63
|
};
|
|
51
64
|
}, [projectPath]);
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
// Build menu items from worktrees, filtering by search query
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const filteredWorktrees = filterWorktreesByQuery(worktrees, searchQuery);
|
|
68
|
+
const items = filteredWorktrees.map(worktree => {
|
|
69
|
+
const originalIndex = worktrees.indexOf(worktree);
|
|
70
|
+
const branchName = worktree.branch
|
|
71
|
+
? worktree.branch.replace('refs/heads/', '')
|
|
72
|
+
: 'detached';
|
|
73
|
+
const isSelected = selectedIndices.has(originalIndex);
|
|
74
|
+
return {
|
|
75
|
+
label: `${isSelected ? '[✓]' : '[ ]'} ${branchName} (${worktree.path})`,
|
|
76
|
+
value: originalIndex.toString(),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
setMenuItems(items);
|
|
80
|
+
}, [worktrees, searchQuery, selectedIndices]);
|
|
63
81
|
const handleSelect = (item) => {
|
|
64
82
|
// Don't toggle on Enter - this will be used to confirm
|
|
65
83
|
// We'll handle Space key separately for toggling
|
|
@@ -71,6 +89,10 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
71
89
|
// Confirmation component handles input
|
|
72
90
|
return;
|
|
73
91
|
}
|
|
92
|
+
// Don't process other keys if in search mode (handled by useSearchMode)
|
|
93
|
+
if (isSearchMode) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
74
96
|
if (input === ' ') {
|
|
75
97
|
// Toggle selection on space
|
|
76
98
|
setSelectedIndices(prev => {
|
|
@@ -111,13 +133,14 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
111
133
|
};
|
|
112
134
|
return (_jsx(DeleteConfirmation, { worktrees: selectedWorktrees, onConfirm: handleConfirm, onCancel: handleCancel }));
|
|
113
135
|
}
|
|
114
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "red", children: "Delete Worktrees" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select worktrees to delete (Space to select, Enter to confirm):" }) }), _jsx(SelectInput, { items: menuItems, onSelect: handleSelect, onHighlight: (item) => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "red", children: "Delete Worktrees" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select worktrees to delete (Space to select, Enter to confirm):" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: menuItems, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: menuItems, onSelect: handleSelect, onHighlight: (item) => {
|
|
137
|
+
const index = parseInt(item.value, 10);
|
|
138
|
+
setFocusedIndex(index);
|
|
139
|
+
}, limit: limit, indicatorComponent: ({ isSelected }) => (_jsx(Text, { color: isSelected ? 'green' : undefined, children: isSelected ? '>' : ' ' })), itemComponent: ({ isSelected, label }) => {
|
|
140
|
+
const hasCheckmark = label.includes('[✓]');
|
|
141
|
+
return (_jsx(Text, { color: isSelected ? 'green' : undefined, inverse: isSelected, dimColor: !isSelected && !hasCheckmark, children: label }));
|
|
142
|
+
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: isSearchMode
|
|
143
|
+
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
144
|
+
: `Controls: ↑↓/j/k Navigate, Space Select, Enter Confirm, /-Search, ${shortcutManager.getShortcutDisplay('cancel')} Cancel` }), selectedIndices.size > 0 && (_jsxs(Text, { color: "yellow", children: [selectedIndices.size, " worktree", selectedIndices.size > 1 ? 's' : '', ' ', "selected"] }))] })] }));
|
|
122
145
|
};
|
|
123
146
|
export default DeleteWorktree;
|
package/dist/components/Menu.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
|
-
import { Box, Text, useInput
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import SelectInput from 'ink-select-input';
|
|
5
5
|
import { Effect } from 'effect';
|
|
6
6
|
import { SessionManager } from '../services/sessionManager.js';
|
|
@@ -8,8 +8,10 @@ import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIco
|
|
|
8
8
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
9
9
|
import { prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
10
10
|
import { projectManager } from '../services/projectManager.js';
|
|
11
|
-
import TextInputWrapper from './TextInputWrapper.js';
|
|
12
11
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
12
|
+
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
13
|
+
import { filterWorktreesByQuery } from '../utils/filterByQuery.js';
|
|
14
|
+
import SearchableList from './SearchableList.js';
|
|
13
15
|
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
14
16
|
import { configReader } from '../services/config/configReader.js';
|
|
15
17
|
const createSeparatorWithText = (text, totalWidth = 35) => {
|
|
@@ -37,16 +39,14 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
37
39
|
const [recentProjects, setRecentProjects] = useState([]);
|
|
38
40
|
const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
|
|
39
41
|
const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
|
|
40
|
-
const { stdout } = useStdout();
|
|
41
|
-
const fixedRows = 6;
|
|
42
42
|
// Use the search mode hook
|
|
43
43
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
44
44
|
isDisabled: !!error || !!loadError,
|
|
45
45
|
});
|
|
46
|
-
const limit =
|
|
47
|
-
|
|
48
|
-
(
|
|
49
|
-
|
|
46
|
+
const limit = useDynamicLimit({
|
|
47
|
+
isSearchMode,
|
|
48
|
+
hasError: !!(error || loadError),
|
|
49
|
+
});
|
|
50
50
|
// Get worktree configuration for sorting
|
|
51
51
|
const worktreeConfig = configReader.getWorktreeConfig();
|
|
52
52
|
useEffect(() => {
|
|
@@ -129,14 +129,9 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
129
129
|
const items = prepareWorktreeItems(worktrees, sessions);
|
|
130
130
|
const columnPositions = calculateColumnPositions(items);
|
|
131
131
|
// Filter worktrees based on search query
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const searchLower = searchQuery.toLowerCase();
|
|
136
|
-
return (branchName.toLowerCase().includes(searchLower) ||
|
|
137
|
-
item.worktree.path.toLowerCase().includes(searchLower));
|
|
138
|
-
})
|
|
139
|
-
: items;
|
|
132
|
+
const filteredWorktrees = filterWorktreesByQuery(items.map(item => item.worktree), searchQuery);
|
|
133
|
+
const filteredWorktreeSet = new Set(filteredWorktrees);
|
|
134
|
+
const filteredItems = items.filter(item => filteredWorktreeSet.has(item.worktree));
|
|
140
135
|
// Build menu items with proper alignment
|
|
141
136
|
const menuItems = filteredItems.map((item, index) => {
|
|
142
137
|
const baseLabel = assembleWorktreeLabel(item, columnPositions);
|
|
@@ -510,19 +505,17 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
510
505
|
onSelectWorktree(item.worktree);
|
|
511
506
|
}
|
|
512
507
|
};
|
|
513
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }),
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
}, 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
|
|
508
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), onHighlight: item => {
|
|
509
|
+
// ink-select-input may call onHighlight with undefined when items are empty
|
|
510
|
+
// (e.g., during menu re-mount after returning from a session), so guard it.
|
|
511
|
+
if (!item) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const menuItem = item;
|
|
515
|
+
if (menuItem.type === 'worktree') {
|
|
516
|
+
setHighlightedWorktreePath(menuItem.worktree.path);
|
|
517
|
+
}
|
|
518
|
+
}, 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
|
|
526
519
|
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
527
520
|
: searchQuery
|
|
528
521
|
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
|
|
@@ -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 SearchableList from './SearchableList.js';
|
|
11
12
|
import { Effect } from 'effect';
|
|
12
13
|
import { describePromptInjection, getPromptInjectionMethod, } from '../utils/presetPrompt.js';
|
|
13
14
|
const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
@@ -238,7 +239,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
238
239
|
const promptMethod = selectedPreset
|
|
239
240
|
? getPromptInjectionMethod(selectedPreset)
|
|
240
241
|
: 'stdin';
|
|
241
|
-
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" })] })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }),
|
|
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" })] })] })) : 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: [
|
|
242
243
|
{
|
|
243
244
|
label: '1. Choose the branch name yourself',
|
|
244
245
|
value: 'manual',
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface ListItem {
|
|
3
|
+
label: string;
|
|
4
|
+
value: string;
|
|
5
|
+
}
|
|
6
|
+
interface SearchableListProps {
|
|
7
|
+
isSearchMode: boolean;
|
|
8
|
+
searchQuery: string;
|
|
9
|
+
onSearchQueryChange: (query: string) => void;
|
|
10
|
+
selectedIndex: number;
|
|
11
|
+
items: ListItem[];
|
|
12
|
+
limit: number;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
noMatchMessage?: string;
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Shared search mode UI: search input, filtered result list, and no-match message.
|
|
19
|
+
* Wraps a SelectInput (passed as children) which is shown when not in search mode.
|
|
20
|
+
*/
|
|
21
|
+
declare const SearchableList: React.FC<SearchableListProps>;
|
|
22
|
+
export default SearchableList;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInputWrapper from './TextInputWrapper.js';
|
|
4
|
+
/**
|
|
5
|
+
* Shared search mode UI: search input, filtered result list, and no-match message.
|
|
6
|
+
* Wraps a SelectInput (passed as children) which is shown when not in search mode.
|
|
7
|
+
*/
|
|
8
|
+
const SearchableList = ({ isSearchMode, searchQuery, onSearchQueryChange, selectedIndex, items, limit, placeholder = 'Type to filter...', noMatchMessage = 'No matches found', children, }) => {
|
|
9
|
+
if (!isSearchMode) {
|
|
10
|
+
return _jsx(_Fragment, { children: children });
|
|
11
|
+
}
|
|
12
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: onSearchQueryChange, focus: true, placeholder: placeholder })] }), items.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: noMatchMessage }) })) : (_jsx(Box, { flexDirection: "column", children: items.slice(0, limit).map((item, index) => (_jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, children: [index === selectedIndex ? '❯ ' : ' ', item.label] }, item.value))) }))] }));
|
|
13
|
+
};
|
|
14
|
+
export default SearchableList;
|
|
@@ -2,3 +2,5 @@
|
|
|
2
2
|
export const STATE_PERSISTENCE_DURATION_MS = 200;
|
|
3
3
|
// Check interval for state detection in milliseconds
|
|
4
4
|
export const STATE_CHECK_INTERVAL_MS = 100;
|
|
5
|
+
// Minimum duration in current state before allowing transition to a new state
|
|
6
|
+
export const STATE_MINIMUM_DURATION_MS = 500;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface UseDynamicLimitOptions {
|
|
2
|
+
fixedRows?: number;
|
|
3
|
+
isSearchMode?: boolean;
|
|
4
|
+
hasError?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Calculate the maximum number of list items to display based on terminal height.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useDynamicLimit(options?: UseDynamicLimitOptions): number;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useStdout } from 'ink';
|
|
2
|
+
/**
|
|
3
|
+
* Calculate the maximum number of list items to display based on terminal height.
|
|
4
|
+
*/
|
|
5
|
+
export function useDynamicLimit(options = {}) {
|
|
6
|
+
const { fixedRows = 6, isSearchMode = false, hasError = false } = options;
|
|
7
|
+
const { stdout } = useStdout();
|
|
8
|
+
return Math.max(5, stdout.rows - fixedRows - (isSearchMode ? 1 : 0) - (hasError ? 3 : 0));
|
|
9
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import { spawn } from './bunTerminal.js';
|
|
4
|
-
import {
|
|
4
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
|
|
5
5
|
import { Effect, Either } from 'effect';
|
|
6
6
|
const detectStateMock = vi.fn();
|
|
7
7
|
// Create a deferred promise pattern for controllable mock
|
|
@@ -107,24 +107,8 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
107
107
|
mockPtyInstances.set(path, mockPty);
|
|
108
108
|
return mockPty;
|
|
109
109
|
});
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
'waiting_input',
|
|
113
|
-
'waiting_input',
|
|
114
|
-
'waiting_input',
|
|
115
|
-
'busy',
|
|
116
|
-
'busy',
|
|
117
|
-
'busy',
|
|
118
|
-
'waiting_input',
|
|
119
|
-
'waiting_input',
|
|
120
|
-
'waiting_input',
|
|
121
|
-
];
|
|
122
|
-
let callIndex = 0;
|
|
123
|
-
detectStateMock.mockImplementation(() => {
|
|
124
|
-
const state = detectionStates[Math.min(callIndex, detectionStates.length - 1)];
|
|
125
|
-
callIndex++;
|
|
126
|
-
return state;
|
|
127
|
-
});
|
|
110
|
+
// Start with waiting_input; tests will change the mock return value between phases
|
|
111
|
+
detectStateMock.mockReturnValue('waiting_input');
|
|
128
112
|
const sessionManagerModule = await import('./sessionManager.js');
|
|
129
113
|
SessionManager = sessionManagerModule.SessionManager;
|
|
130
114
|
sessionManager = new SessionManager();
|
|
@@ -141,16 +125,19 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
141
125
|
...data,
|
|
142
126
|
autoApprovalFailed: true,
|
|
143
127
|
}));
|
|
144
|
-
//
|
|
145
|
-
|
|
128
|
+
// Phase 1: waiting_input (auto-approval suppressed due to prior failure)
|
|
129
|
+
detectStateMock.mockReturnValue('waiting_input');
|
|
130
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
146
131
|
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
147
132
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
|
|
148
|
-
//
|
|
149
|
-
|
|
133
|
+
// Phase 2: busy - should reset the failure flag
|
|
134
|
+
detectStateMock.mockReturnValue('busy');
|
|
135
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
150
136
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
151
137
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
|
|
152
|
-
//
|
|
153
|
-
|
|
138
|
+
// Phase 3: waiting_input again - should trigger pending_auto_approval
|
|
139
|
+
detectStateMock.mockReturnValue('waiting_input');
|
|
140
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
154
141
|
// State should now be pending_auto_approval (waiting for verification)
|
|
155
142
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
156
143
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -192,8 +179,9 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
192
179
|
expect(mockPty).toBeDefined();
|
|
193
180
|
const handler = vi.fn();
|
|
194
181
|
sessionManager.on('sessionStateChanged', handler);
|
|
195
|
-
//
|
|
196
|
-
|
|
182
|
+
// Phase 1: waiting_input → pending_auto_approval
|
|
183
|
+
detectStateMock.mockReturnValue('waiting_input');
|
|
184
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
197
185
|
// State should be pending_auto_approval (waiting for verification)
|
|
198
186
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
199
187
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -7,7 +7,7 @@ import { configReader } from './config/configReader.js';
|
|
|
7
7
|
import { setWorktreeLastOpened } from './worktreeService.js';
|
|
8
8
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
9
9
|
import { createStateDetector } from './stateDetector/index.js';
|
|
10
|
-
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
10
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
|
|
11
11
|
import { Effect, Either } from 'effect';
|
|
12
12
|
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
13
13
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
@@ -177,6 +177,7 @@ export class SessionManager extends EventEmitter {
|
|
|
177
177
|
state: newState,
|
|
178
178
|
pendingState: undefined,
|
|
179
179
|
pendingStateStart: undefined,
|
|
180
|
+
stateConfirmedAt: Date.now(),
|
|
180
181
|
...additionalUpdates,
|
|
181
182
|
}));
|
|
182
183
|
if (oldState !== newState) {
|
|
@@ -393,8 +394,11 @@ export class SessionManager extends EventEmitter {
|
|
|
393
394
|
else if (stateData.pendingState !== undefined &&
|
|
394
395
|
stateData.pendingStateStart !== undefined) {
|
|
395
396
|
// Check if the pending state has persisted long enough
|
|
397
|
+
// and that the current state has been active for the minimum duration
|
|
396
398
|
const duration = now - stateData.pendingStateStart;
|
|
397
|
-
|
|
399
|
+
const timeInCurrentState = now - stateData.stateConfirmedAt;
|
|
400
|
+
if (duration >= STATE_PERSISTENCE_DURATION_MS &&
|
|
401
|
+
timeInCurrentState >= STATE_MINIMUM_DURATION_MS) {
|
|
398
402
|
// Cancel auto-approval verification if state is changing away from pending_auto_approval
|
|
399
403
|
if (stateData.autoApprovalAbortController &&
|
|
400
404
|
detectedState !== 'pending_auto_approval') {
|
|
@@ -417,10 +421,13 @@ export class SessionManager extends EventEmitter {
|
|
|
417
421
|
}
|
|
418
422
|
else {
|
|
419
423
|
// Detected state matches current state, clear any pending state
|
|
424
|
+
// and update stateConfirmedAt so the minimum duration guard
|
|
425
|
+
// tracks "last time current state was seen" rather than "first confirmed"
|
|
420
426
|
void session.stateMutex.update(data => ({
|
|
421
427
|
...data,
|
|
422
428
|
pendingState: undefined,
|
|
423
429
|
pendingStateStart: undefined,
|
|
430
|
+
stateConfirmedAt: now,
|
|
424
431
|
}));
|
|
425
432
|
}
|
|
426
433
|
// Handle auto-approval if state is pending_auto_approval and no verification is in progress.
|
|
@@ -3,7 +3,7 @@ import { Either } from 'effect';
|
|
|
3
3
|
import { SessionManager } from './sessionManager.js';
|
|
4
4
|
import { spawn } from './bunTerminal.js';
|
|
5
5
|
import { EventEmitter } from 'events';
|
|
6
|
-
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
6
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
|
|
7
7
|
vi.mock('./bunTerminal.js', () => ({
|
|
8
8
|
spawn: vi.fn(function () {
|
|
9
9
|
return null;
|
|
@@ -97,7 +97,7 @@ describe('SessionManager - State Persistence', () => {
|
|
|
97
97
|
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
98
98
|
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
99
99
|
});
|
|
100
|
-
it('should change state after persistence duration
|
|
100
|
+
it('should change state after both persistence and minimum duration are met', async () => {
|
|
101
101
|
const { Effect } = await import('effect');
|
|
102
102
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
103
103
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
@@ -111,8 +111,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
111
111
|
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
112
112
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
113
113
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
114
|
-
// Advance time
|
|
115
|
-
await vi.advanceTimersByTimeAsync(
|
|
114
|
+
// Advance time past both persistence and minimum duration
|
|
115
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
|
|
116
116
|
// State should now be changed
|
|
117
117
|
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
118
118
|
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
@@ -201,6 +201,72 @@ describe('SessionManager - State Persistence', () => {
|
|
|
201
201
|
const destroyedSession = sessionManager.getSession('/test/path');
|
|
202
202
|
expect(destroyedSession).toBeUndefined();
|
|
203
203
|
});
|
|
204
|
+
it('should not transition state before minimum duration in current state has elapsed', async () => {
|
|
205
|
+
const { Effect } = await import('effect');
|
|
206
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
207
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
208
|
+
const stateChangeHandler = vi.fn();
|
|
209
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
210
|
+
// Initial state should be busy
|
|
211
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
212
|
+
// Simulate output that would trigger idle state
|
|
213
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
214
|
+
// Advance time enough for persistence duration but less than minimum duration
|
|
215
|
+
// STATE_PERSISTENCE_DURATION_MS (200ms) < STATE_MINIMUM_DURATION_MS (500ms)
|
|
216
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS + STATE_CHECK_INTERVAL_MS * 2);
|
|
217
|
+
// State should still be busy because minimum duration hasn't elapsed
|
|
218
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
219
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
it('should transition state after both persistence and minimum duration are met', async () => {
|
|
222
|
+
const { Effect } = await import('effect');
|
|
223
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
224
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
225
|
+
const stateChangeHandler = vi.fn();
|
|
226
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
227
|
+
// Initial state should be busy
|
|
228
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
229
|
+
// Simulate output that would trigger idle state
|
|
230
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
231
|
+
// Advance time past STATE_MINIMUM_DURATION_MS (which is longer than STATE_PERSISTENCE_DURATION_MS)
|
|
232
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
|
|
233
|
+
// State should now be idle since both durations are satisfied
|
|
234
|
+
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
235
|
+
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
236
|
+
});
|
|
237
|
+
it('should not transition during brief screen redraw even after long time in current state', async () => {
|
|
238
|
+
const { Effect } = await import('effect');
|
|
239
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
240
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
241
|
+
const stateChangeHandler = vi.fn();
|
|
242
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
243
|
+
// Initial state should be busy
|
|
244
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
245
|
+
// Keep busy state active for a long time (simulating normal operation)
|
|
246
|
+
// Each check re-detects "busy" and updates stateConfirmedAt
|
|
247
|
+
eventEmitter.emit('data', 'ESC to interrupt');
|
|
248
|
+
await vi.advanceTimersByTimeAsync(2000); // 2 seconds of confirmed busy
|
|
249
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
250
|
+
// Now simulate a brief screen redraw: busy indicators disappear temporarily
|
|
251
|
+
eventEmitter.emit('data', '\x1b[2J\x1b[H'); // Clear screen
|
|
252
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
|
|
253
|
+
// Pending state should be set to idle
|
|
254
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
255
|
+
// Advance past persistence duration (200ms) but NOT past minimum duration (500ms)
|
|
256
|
+
// Since stateConfirmedAt was updated at ~2000ms, and now is ~2200ms,
|
|
257
|
+
// timeInCurrentState = ~200ms which is < 500ms
|
|
258
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
|
|
259
|
+
// State should still be busy because minimum duration since last busy detection hasn't elapsed
|
|
260
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
261
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
262
|
+
// Simulate busy indicators coming back (screen redraw complete)
|
|
263
|
+
eventEmitter.emit('data', 'ESC to interrupt');
|
|
264
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
265
|
+
// State should still be busy and pending should be cleared
|
|
266
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
267
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
268
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
269
|
+
});
|
|
204
270
|
it('should handle multiple sessions with independent state persistence', async () => {
|
|
205
271
|
const { Effect } = await import('effect');
|
|
206
272
|
const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
|
|
@@ -222,8 +288,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
222
288
|
expect(session1.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
223
289
|
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
224
290
|
expect(session2.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
225
|
-
// Advance time to confirm both (use async to process mutex updates)
|
|
226
|
-
await vi.advanceTimersByTimeAsync(
|
|
291
|
+
// Advance time to confirm both - need to exceed STATE_MINIMUM_DURATION_MS (use async to process mutex updates)
|
|
292
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
|
|
227
293
|
// Both should now be in their new states
|
|
228
294
|
expect(session1.stateMutex.getSnapshot().state).toBe('idle');
|
|
229
295
|
expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { SessionState, Terminal } from '../../types/index.js';
|
|
2
2
|
import { BaseStateDetector } from './base.js';
|
|
3
3
|
export declare class ClaudeStateDetector extends BaseStateDetector {
|
|
4
|
+
/**
|
|
5
|
+
* Extract content above the prompt box.
|
|
6
|
+
* The prompt box is delimited by ─ border lines:
|
|
7
|
+
* content above prompt box
|
|
8
|
+
* ─────────────── (top border)
|
|
9
|
+
* ❯ (prompt line)
|
|
10
|
+
* ─────────────── (bottom border)
|
|
11
|
+
*
|
|
12
|
+
* If no prompt box is found, returns all content as fallback.
|
|
13
|
+
*/
|
|
14
|
+
private getContentAbovePromptBox;
|
|
4
15
|
detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
5
16
|
detectBackgroundTask(terminal: Terminal): number;
|
|
6
17
|
detectTeamMembers(terminal: Terminal): number;
|
|
@@ -1,30 +1,70 @@
|
|
|
1
1
|
import { BaseStateDetector } from './base.js';
|
|
2
|
+
// Spinner characters used by Claude Code during active processing
|
|
3
|
+
const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆';
|
|
4
|
+
// Matches spinner activity labels like "✽ Tempering…" or "✳ Simplifying recompute_tangents…"
|
|
5
|
+
const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
|
|
2
6
|
export class ClaudeStateDetector extends BaseStateDetector {
|
|
7
|
+
/**
|
|
8
|
+
* Extract content above the prompt box.
|
|
9
|
+
* The prompt box is delimited by ─ border lines:
|
|
10
|
+
* content above prompt box
|
|
11
|
+
* ─────────────── (top border)
|
|
12
|
+
* ❯ (prompt line)
|
|
13
|
+
* ─────────────── (bottom border)
|
|
14
|
+
*
|
|
15
|
+
* If no prompt box is found, returns all content as fallback.
|
|
16
|
+
*/
|
|
17
|
+
getContentAbovePromptBox(terminal, maxLines) {
|
|
18
|
+
const lines = this.getTerminalLines(terminal, maxLines);
|
|
19
|
+
let borderCount = 0;
|
|
20
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
21
|
+
const trimmed = lines[i].trim();
|
|
22
|
+
if (trimmed.length > 0 && /^─+$/.test(trimmed)) {
|
|
23
|
+
borderCount++;
|
|
24
|
+
if (borderCount === 2) {
|
|
25
|
+
return lines.slice(0, i).join('\n');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// No prompt box found, return all content
|
|
30
|
+
return lines.join('\n');
|
|
31
|
+
}
|
|
3
32
|
detectState(terminal, currentState) {
|
|
4
33
|
// Check for search prompt (⌕ Search…) within 200 lines - always idle
|
|
5
34
|
const extendedContent = this.getTerminalContent(terminal, 200);
|
|
6
35
|
if (extendedContent.includes('⌕ Search…')) {
|
|
7
36
|
return 'idle';
|
|
8
37
|
}
|
|
9
|
-
//
|
|
10
|
-
const
|
|
11
|
-
const
|
|
38
|
+
// Full content (including prompt box) for waiting_input detection
|
|
39
|
+
const fullContent = this.getTerminalContent(terminal, 30);
|
|
40
|
+
const fullLowerContent = fullContent.toLowerCase();
|
|
12
41
|
// Check for ctrl+r toggle prompt - maintain current state
|
|
13
|
-
if (
|
|
42
|
+
if (fullLowerContent.includes('ctrl+r to toggle')) {
|
|
14
43
|
return currentState;
|
|
15
44
|
}
|
|
16
45
|
// Check for "Do you want" or "Would you like" pattern with options
|
|
17
46
|
// Handles both simple ("Do you want...\nYes") and complex (numbered options) formats
|
|
18
|
-
if (/(?:do you want|would you like).+\n+[\s\S]*?(?:yes|❯)/.test(
|
|
47
|
+
if (/(?:do you want|would you like).+\n+[\s\S]*?(?:yes|❯)/.test(fullLowerContent)) {
|
|
48
|
+
return 'waiting_input';
|
|
49
|
+
}
|
|
50
|
+
// Check for selection prompt with ❯ cursor indicator and numbered options
|
|
51
|
+
if (/❯\s+\d+\./.test(fullContent)) {
|
|
19
52
|
return 'waiting_input';
|
|
20
53
|
}
|
|
21
54
|
// Check for "esc to cancel" - indicates waiting for user input
|
|
22
|
-
if (
|
|
55
|
+
if (fullLowerContent.includes('esc to cancel')) {
|
|
23
56
|
return 'waiting_input';
|
|
24
57
|
}
|
|
58
|
+
// Content above the prompt box only for busy detection
|
|
59
|
+
const abovePromptBox = this.getContentAbovePromptBox(terminal, 30);
|
|
60
|
+
const aboveLowerContent = abovePromptBox.toLowerCase();
|
|
25
61
|
// Check for busy state
|
|
26
|
-
if (
|
|
27
|
-
|
|
62
|
+
if (aboveLowerContent.includes('esc to interrupt') ||
|
|
63
|
+
aboveLowerContent.includes('ctrl+c to interrupt')) {
|
|
64
|
+
return 'busy';
|
|
65
|
+
}
|
|
66
|
+
// Check for spinner activity label (e.g., "✽ Tempering…", "✳ Simplifying…")
|
|
67
|
+
if (SPINNER_ACTIVITY_PATTERN.test(abovePromptBox)) {
|
|
28
68
|
return 'busy';
|
|
29
69
|
}
|
|
30
70
|
// Otherwise idle
|
|
@@ -8,19 +8,22 @@ describe('ClaudeStateDetector', () => {
|
|
|
8
8
|
detector = new ClaudeStateDetector();
|
|
9
9
|
});
|
|
10
10
|
describe('detectState', () => {
|
|
11
|
-
it('should detect busy when "ESC to interrupt" is
|
|
11
|
+
it('should detect busy when "ESC to interrupt" is above prompt box', () => {
|
|
12
12
|
// Arrange
|
|
13
13
|
terminal = createMockTerminal([
|
|
14
14
|
'Processing...',
|
|
15
15
|
'Press ESC to interrupt',
|
|
16
|
+
'──────────────────────────────',
|
|
17
|
+
'❯',
|
|
18
|
+
'──────────────────────────────',
|
|
16
19
|
]);
|
|
17
20
|
// Act
|
|
18
21
|
const state = detector.detectState(terminal, 'idle');
|
|
19
22
|
// Assert
|
|
20
23
|
expect(state).toBe('busy');
|
|
21
24
|
});
|
|
22
|
-
it('should detect busy when "esc to interrupt" is present (
|
|
23
|
-
// Arrange
|
|
25
|
+
it('should detect busy when "esc to interrupt" is present (no prompt box fallback)', () => {
|
|
26
|
+
// Arrange - no prompt box borders, falls back to all content
|
|
24
27
|
terminal = createMockTerminal([
|
|
25
28
|
'Running command...',
|
|
26
29
|
'press esc to interrupt the process',
|
|
@@ -30,11 +33,14 @@ describe('ClaudeStateDetector', () => {
|
|
|
30
33
|
// Assert
|
|
31
34
|
expect(state).toBe('busy');
|
|
32
35
|
});
|
|
33
|
-
it('should detect busy when "ctrl+c to interrupt" is
|
|
36
|
+
it('should detect busy when "ctrl+c to interrupt" is above prompt box', () => {
|
|
34
37
|
// Arrange
|
|
35
38
|
terminal = createMockTerminal([
|
|
36
39
|
'Googling. (ctrl+c to interrupt',
|
|
37
40
|
'Searching for relevant information...',
|
|
41
|
+
'──────────────────────────────',
|
|
42
|
+
'❯',
|
|
43
|
+
'──────────────────────────────',
|
|
38
44
|
]);
|
|
39
45
|
// Act
|
|
40
46
|
const state = detector.detectState(terminal, 'idle');
|
|
@@ -93,7 +99,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
93
99
|
expect(state).toBe('busy');
|
|
94
100
|
}
|
|
95
101
|
});
|
|
96
|
-
it('should detect waiting_input when "Do you want" with options prompt
|
|
102
|
+
it('should detect waiting_input when "Do you want" with options is above prompt box', () => {
|
|
97
103
|
// Arrange
|
|
98
104
|
terminal = createMockTerminal([
|
|
99
105
|
'Some previous output',
|
|
@@ -101,13 +107,16 @@ describe('ClaudeStateDetector', () => {
|
|
|
101
107
|
'❯ 1. Yes',
|
|
102
108
|
'2. Yes, allow all edits during this session (shift+tab)',
|
|
103
109
|
'3. No, and tell Claude what to do differently (esc)',
|
|
110
|
+
'──────────────────────────────',
|
|
111
|
+
'❯',
|
|
112
|
+
'──────────────────────────────',
|
|
104
113
|
]);
|
|
105
114
|
// Act
|
|
106
115
|
const state = detector.detectState(terminal, 'idle');
|
|
107
116
|
// Assert
|
|
108
117
|
expect(state).toBe('waiting_input');
|
|
109
118
|
});
|
|
110
|
-
it('should detect waiting_input when "Do you want"
|
|
119
|
+
it('should detect waiting_input when "Do you want" is present (no prompt box fallback)', () => {
|
|
111
120
|
// Arrange
|
|
112
121
|
terminal = createMockTerminal([
|
|
113
122
|
'Some output',
|
|
@@ -127,6 +136,9 @@ describe('ClaudeStateDetector', () => {
|
|
|
127
136
|
'Do you want to continue?',
|
|
128
137
|
'❯ 1. Yes',
|
|
129
138
|
'2. No',
|
|
139
|
+
'──────────────────────────────',
|
|
140
|
+
'❯',
|
|
141
|
+
'──────────────────────────────',
|
|
130
142
|
]);
|
|
131
143
|
// Act
|
|
132
144
|
const state = detector.detectState(terminal, 'idle');
|
|
@@ -190,18 +202,48 @@ describe('ClaudeStateDetector', () => {
|
|
|
190
202
|
// Assert
|
|
191
203
|
expect(state).toBe('waiting_input');
|
|
192
204
|
});
|
|
193
|
-
it('should detect waiting_input when
|
|
205
|
+
it('should detect waiting_input when plan submit prompt with ❯ cursor is present', () => {
|
|
206
|
+
// Arrange
|
|
207
|
+
terminal = createMockTerminal([
|
|
208
|
+
'Ready to submit your answers?',
|
|
209
|
+
'',
|
|
210
|
+
'❯ 1. Submit answers',
|
|
211
|
+
' 2. Cancel',
|
|
212
|
+
]);
|
|
213
|
+
// Act
|
|
214
|
+
const state = detector.detectState(terminal, 'idle');
|
|
215
|
+
// Assert
|
|
216
|
+
expect(state).toBe('waiting_input');
|
|
217
|
+
});
|
|
218
|
+
it('should detect waiting_input for generic ❯ numbered selection prompt', () => {
|
|
219
|
+
// Arrange
|
|
220
|
+
terminal = createMockTerminal([
|
|
221
|
+
'Select an option:',
|
|
222
|
+
'',
|
|
223
|
+
'❯ 1. Option A',
|
|
224
|
+
' 2. Option B',
|
|
225
|
+
' 3. Option C',
|
|
226
|
+
]);
|
|
227
|
+
// Act
|
|
228
|
+
const state = detector.detectState(terminal, 'idle');
|
|
229
|
+
// Assert
|
|
230
|
+
expect(state).toBe('waiting_input');
|
|
231
|
+
});
|
|
232
|
+
it('should detect waiting_input when "esc to cancel" is above prompt box', () => {
|
|
194
233
|
// Arrange
|
|
195
234
|
terminal = createMockTerminal([
|
|
196
235
|
'Enter your message:',
|
|
197
236
|
'Press esc to cancel',
|
|
237
|
+
'──────────────────────────────',
|
|
238
|
+
'❯',
|
|
239
|
+
'──────────────────────────────',
|
|
198
240
|
]);
|
|
199
241
|
// Act
|
|
200
242
|
const state = detector.detectState(terminal, 'idle');
|
|
201
243
|
// Assert
|
|
202
244
|
expect(state).toBe('waiting_input');
|
|
203
245
|
});
|
|
204
|
-
it('should detect waiting_input when "esc to cancel" is present (
|
|
246
|
+
it('should detect waiting_input when "esc to cancel" is present (no prompt box fallback)', () => {
|
|
205
247
|
// Arrange
|
|
206
248
|
terminal = createMockTerminal(['Waiting for input', 'ESC TO CANCEL']);
|
|
207
249
|
// Act
|
|
@@ -209,12 +251,15 @@ describe('ClaudeStateDetector', () => {
|
|
|
209
251
|
// Assert
|
|
210
252
|
expect(state).toBe('waiting_input');
|
|
211
253
|
});
|
|
212
|
-
it('should prioritize "esc to cancel" over "esc to interrupt" when both
|
|
254
|
+
it('should prioritize "esc to cancel" over "esc to interrupt" when both above prompt box', () => {
|
|
213
255
|
// Arrange
|
|
214
256
|
terminal = createMockTerminal([
|
|
215
257
|
'Press esc to interrupt',
|
|
216
258
|
'Some input prompt',
|
|
217
259
|
'Press esc to cancel',
|
|
260
|
+
'──────────────────────────────',
|
|
261
|
+
'❯',
|
|
262
|
+
'──────────────────────────────',
|
|
218
263
|
]);
|
|
219
264
|
// Act
|
|
220
265
|
const state = detector.detectState(terminal, 'idle');
|
|
@@ -273,6 +318,87 @@ describe('ClaudeStateDetector', () => {
|
|
|
273
318
|
// Assert - Should detect waiting_input from viewport
|
|
274
319
|
expect(state).toBe('waiting_input');
|
|
275
320
|
});
|
|
321
|
+
it('should detect busy when spinner activity label "✽ Tempering…" is present', () => {
|
|
322
|
+
// Arrange
|
|
323
|
+
terminal = createMockTerminal([
|
|
324
|
+
'✽ Tempering…',
|
|
325
|
+
'──────────────────────────────',
|
|
326
|
+
'❯',
|
|
327
|
+
'──────────────────────────────',
|
|
328
|
+
]);
|
|
329
|
+
// Act
|
|
330
|
+
const state = detector.detectState(terminal, 'idle');
|
|
331
|
+
// Assert
|
|
332
|
+
expect(state).toBe('busy');
|
|
333
|
+
});
|
|
334
|
+
it('should detect busy when spinner activity label "✳ Simplifying…" is present', () => {
|
|
335
|
+
// Arrange
|
|
336
|
+
terminal = createMockTerminal([
|
|
337
|
+
'✳ Simplifying recompute_tangents… (2m 18s · ↓ 4.8k tokens)',
|
|
338
|
+
' ⎿ ◻ task list items...',
|
|
339
|
+
'──────────────────────────────',
|
|
340
|
+
'❯',
|
|
341
|
+
]);
|
|
342
|
+
// Act
|
|
343
|
+
const state = detector.detectState(terminal, 'idle');
|
|
344
|
+
// Assert
|
|
345
|
+
expect(state).toBe('busy');
|
|
346
|
+
});
|
|
347
|
+
it('should detect busy with various spinner characters', () => {
|
|
348
|
+
const spinnerChars = [
|
|
349
|
+
'✱',
|
|
350
|
+
'✲',
|
|
351
|
+
'✳',
|
|
352
|
+
'✴',
|
|
353
|
+
'✵',
|
|
354
|
+
'✶',
|
|
355
|
+
'✷',
|
|
356
|
+
'✸',
|
|
357
|
+
'✹',
|
|
358
|
+
'✺',
|
|
359
|
+
'✻',
|
|
360
|
+
'✼',
|
|
361
|
+
'✽',
|
|
362
|
+
'✾',
|
|
363
|
+
'✿',
|
|
364
|
+
'❀',
|
|
365
|
+
'❁',
|
|
366
|
+
'❂',
|
|
367
|
+
'❃',
|
|
368
|
+
'❇',
|
|
369
|
+
'❈',
|
|
370
|
+
'❉',
|
|
371
|
+
'❊',
|
|
372
|
+
'❋',
|
|
373
|
+
'✢',
|
|
374
|
+
'✣',
|
|
375
|
+
'✤',
|
|
376
|
+
'✥',
|
|
377
|
+
'✦',
|
|
378
|
+
'✧',
|
|
379
|
+
];
|
|
380
|
+
for (const char of spinnerChars) {
|
|
381
|
+
terminal = createMockTerminal([`${char} Kneading…`, '❯']);
|
|
382
|
+
const state = detector.detectState(terminal, 'idle');
|
|
383
|
+
expect(state).toBe('busy');
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
it('should not detect busy for spinner-like line without ing… suffix', () => {
|
|
387
|
+
// Arrange - no "ing…" at end
|
|
388
|
+
terminal = createMockTerminal(['✽ Some random text', '❯']);
|
|
389
|
+
// Act
|
|
390
|
+
const state = detector.detectState(terminal, 'idle');
|
|
391
|
+
// Assert
|
|
392
|
+
expect(state).toBe('idle');
|
|
393
|
+
});
|
|
394
|
+
it('should detect idle when "⌕ Search…" is present even with spinner activity', () => {
|
|
395
|
+
// Arrange
|
|
396
|
+
terminal = createMockTerminal(['⌕ Search…', '✽ Tempering…']);
|
|
397
|
+
// Act
|
|
398
|
+
const state = detector.detectState(terminal, 'busy');
|
|
399
|
+
// Assert - Search prompt takes precedence
|
|
400
|
+
expect(state).toBe('idle');
|
|
401
|
+
});
|
|
276
402
|
it('should detect idle when "⌕ Search…" is present', () => {
|
|
277
403
|
// Arrange - Search prompt should always be idle
|
|
278
404
|
terminal = createMockTerminal(['⌕ Search…', 'Some content']);
|
|
@@ -297,6 +423,59 @@ describe('ClaudeStateDetector', () => {
|
|
|
297
423
|
// Assert - Should be idle because search prompt takes precedence
|
|
298
424
|
expect(state).toBe('idle');
|
|
299
425
|
});
|
|
426
|
+
it('should ignore "esc to interrupt" inside prompt box', () => {
|
|
427
|
+
// Arrange - "esc to interrupt" is inside the prompt box, not above it
|
|
428
|
+
terminal = createMockTerminal([
|
|
429
|
+
'Some idle output',
|
|
430
|
+
'──────────────────────────────',
|
|
431
|
+
'esc to interrupt',
|
|
432
|
+
'──────────────────────────────',
|
|
433
|
+
]);
|
|
434
|
+
// Act
|
|
435
|
+
const state = detector.detectState(terminal, 'idle');
|
|
436
|
+
// Assert - should be idle because "esc to interrupt" is inside prompt box
|
|
437
|
+
expect(state).toBe('idle');
|
|
438
|
+
});
|
|
439
|
+
it('should detect "esc to cancel" inside prompt box as waiting_input', () => {
|
|
440
|
+
// Arrange - waiting_input detection uses full content including prompt box
|
|
441
|
+
terminal = createMockTerminal([
|
|
442
|
+
'Some idle output',
|
|
443
|
+
'──────────────────────────────',
|
|
444
|
+
'esc to cancel',
|
|
445
|
+
'──────────────────────────────',
|
|
446
|
+
]);
|
|
447
|
+
// Act
|
|
448
|
+
const state = detector.detectState(terminal, 'idle');
|
|
449
|
+
// Assert - waiting_input is not restricted to above prompt box
|
|
450
|
+
expect(state).toBe('waiting_input');
|
|
451
|
+
});
|
|
452
|
+
it('should detect "Do you want" inside prompt box as waiting_input', () => {
|
|
453
|
+
// Arrange - waiting_input detection uses full content including prompt box
|
|
454
|
+
terminal = createMockTerminal([
|
|
455
|
+
'Some idle output',
|
|
456
|
+
'──────────────────────────────',
|
|
457
|
+
'Do you want to proceed?',
|
|
458
|
+
'❯ 1. Yes',
|
|
459
|
+
'──────────────────────────────',
|
|
460
|
+
]);
|
|
461
|
+
// Act
|
|
462
|
+
const state = detector.detectState(terminal, 'idle');
|
|
463
|
+
// Assert - waiting_input is not restricted to above prompt box
|
|
464
|
+
expect(state).toBe('waiting_input');
|
|
465
|
+
});
|
|
466
|
+
it('should ignore spinner activity label inside prompt box', () => {
|
|
467
|
+
// Arrange - spinner label is inside the prompt box
|
|
468
|
+
terminal = createMockTerminal([
|
|
469
|
+
'Some idle output',
|
|
470
|
+
'──────────────────────────────',
|
|
471
|
+
'✽ Tempering…',
|
|
472
|
+
'──────────────────────────────',
|
|
473
|
+
]);
|
|
474
|
+
// Act
|
|
475
|
+
const state = detector.detectState(terminal, 'idle');
|
|
476
|
+
// Assert - should be idle because spinner is inside prompt box
|
|
477
|
+
expect(state).toBe('idle');
|
|
478
|
+
});
|
|
300
479
|
});
|
|
301
480
|
describe('detectBackgroundTask', () => {
|
|
302
481
|
it('should return count 1 when "1 background task" is in status bar', () => {
|
|
@@ -8,6 +8,10 @@ export class CodexStateDetector extends BaseStateDetector {
|
|
|
8
8
|
/confirm with .+ enter/i.test(content)) {
|
|
9
9
|
return 'waiting_input';
|
|
10
10
|
}
|
|
11
|
+
// Check for plan/question prompts
|
|
12
|
+
if (lowerContent.includes('| enter to submit answer')) {
|
|
13
|
+
return 'waiting_input';
|
|
14
|
+
}
|
|
11
15
|
// Check for waiting prompts
|
|
12
16
|
if (lowerContent.includes('allow command?') ||
|
|
13
17
|
lowerContent.includes('[y/n]') ||
|
|
@@ -135,6 +135,34 @@ describe('CodexStateDetector', () => {
|
|
|
135
135
|
// Assert
|
|
136
136
|
expect(state).toBe('waiting_input');
|
|
137
137
|
});
|
|
138
|
+
it('should detect waiting_input state for plan question prompt with "| enter to submit answer"', () => {
|
|
139
|
+
// Arrange
|
|
140
|
+
terminal = createMockTerminal([
|
|
141
|
+
' Question 1/3 (3 unanswered)',
|
|
142
|
+
' › 1. 既知CLIのみ (Recommended)',
|
|
143
|
+
' 2. 全preset対応',
|
|
144
|
+
' 3. 既知CLI優先+未知は末尾引数',
|
|
145
|
+
' 4. None of the above',
|
|
146
|
+
'',
|
|
147
|
+
' tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt',
|
|
148
|
+
]);
|
|
149
|
+
// Act
|
|
150
|
+
const state = detector.detectState(terminal, 'idle');
|
|
151
|
+
// Assert
|
|
152
|
+
expect(state).toBe('waiting_input');
|
|
153
|
+
});
|
|
154
|
+
it('should prioritize plan question prompt over busy state with esc interrupt', () => {
|
|
155
|
+
// Arrange
|
|
156
|
+
terminal = createMockTerminal([
|
|
157
|
+
' Question 1/3',
|
|
158
|
+
' › 1. Option A',
|
|
159
|
+
' tab to add notes | enter to submit answer | esc to interrupt',
|
|
160
|
+
]);
|
|
161
|
+
// Act
|
|
162
|
+
const state = detector.detectState(terminal, 'idle');
|
|
163
|
+
// Assert
|
|
164
|
+
expect(state).toBe('waiting_input');
|
|
165
|
+
});
|
|
138
166
|
it('should prioritize "Confirm with ... Enter" over busy state', () => {
|
|
139
167
|
// Arrange
|
|
140
168
|
terminal = createMockTerminal(['Esc to interrupt', 'Confirm with Y Enter']);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter worktrees by matching search query against branch name and path.
|
|
3
|
+
*/
|
|
4
|
+
export function filterWorktreesByQuery(worktrees, query) {
|
|
5
|
+
if (!query)
|
|
6
|
+
return worktrees;
|
|
7
|
+
const searchLower = query.toLowerCase();
|
|
8
|
+
return worktrees.filter(worktree => {
|
|
9
|
+
const branchName = worktree.branch || '';
|
|
10
|
+
return (branchName.toLowerCase().includes(searchLower) ||
|
|
11
|
+
worktree.path.toLowerCase().includes(searchLower));
|
|
12
|
+
});
|
|
13
|
+
}
|
package/dist/utils/mutex.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export interface SessionStateData {
|
|
|
44
44
|
state: import('../types/index.js').SessionState;
|
|
45
45
|
pendingState: import('../types/index.js').SessionState | undefined;
|
|
46
46
|
pendingStateStart: number | undefined;
|
|
47
|
+
stateConfirmedAt: number;
|
|
47
48
|
autoApprovalFailed: boolean;
|
|
48
49
|
autoApprovalReason: string | undefined;
|
|
49
50
|
autoApprovalAbortController: AbortController | undefined;
|
package/dist/utils/mutex.js
CHANGED
|
@@ -82,6 +82,7 @@ export function createInitialSessionStateData() {
|
|
|
82
82
|
state: 'busy',
|
|
83
83
|
pendingState: undefined,
|
|
84
84
|
pendingStateStart: undefined,
|
|
85
|
+
stateConfirmedAt: Date.now(),
|
|
85
86
|
autoApprovalFailed: false,
|
|
86
87
|
autoApprovalReason: undefined,
|
|
87
88
|
autoApprovalAbortController: undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.1",
|
|
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": "3.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.12.1",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.12.1",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.12.1",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.12.1",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.12.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|