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.
@@ -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, useStdout } from 'ink';
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 TextInputWrapper from './TextInputWrapper.js';
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 = Math.max(5, stdout.rows - fixedRows - (isSearchMode ? 1 : 0) - (displayError ? 3 : 0));
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] }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter..." })] })), 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] }) })) : isSearchMode && items.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "No matches found" }) })) : isSearchMode ? (_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))) })) : (_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
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
- // Create menu items from worktrees
53
- const menuItems = worktrees.map((worktree, index) => {
54
- const branchName = worktree.branch
55
- ? worktree.branch.replace('refs/heads/', '')
56
- : 'detached';
57
- const isSelected = selectedIndices.has(index);
58
- return {
59
- label: `${isSelected ? '[✓]' : '[ ]'} ${branchName} (${worktree.path})`,
60
- value: index.toString(),
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
- const index = parseInt(item.value, 10);
116
- setFocusedIndex(index);
117
- }, limit: 10, indicatorComponent: ({ isSelected }) => (_jsx(Text, { color: isSelected ? 'green' : undefined, children: isSelected ? '>' : ' ' })), itemComponent: ({ isSelected, label }) => {
118
- // Check if this item is actually selected (checkbox checked)
119
- const hasCheckmark = label.includes('[✓]');
120
- return (_jsx(Text, { color: isSelected ? 'green' : undefined, inverse: isSelected, dimColor: !isSelected && !hasCheckmark, children: label }));
121
- } }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Controls: \u2191\u2193/j/k Navigate, Space Select, Enter Confirm,", ' ', shortcutManager.getShortcutDisplay('cancel'), " Cancel"] }), selectedIndices.size > 0 && (_jsxs(Text, { color: "yellow", children: [selectedIndices.size, " worktree", selectedIndices.size > 1 ? 's' : '', ' ', "selected"] }))] })] }));
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;
@@ -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, useStdout } from 'ink';
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 = Math.max(5, stdout.rows -
47
- fixedRows -
48
- (isSearchMode ? 1 : 0) -
49
- (error || loadError ? 3 : 0));
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 filteredItems = searchQuery
133
- ? items.filter(item => {
134
- const branchName = item.worktree.branch || '';
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:" }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter worktrees..." })] })), isSearchMode && items.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "No worktrees match your search" }) })) : isSearchMode ? (
514
- // In search mode, show the items as a list without SelectInput
515
- _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))) })) : (_jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), onHighlight: item => {
516
- // ink-select-input may call onHighlight with undefined when items are empty
517
- // (e.g., during menu re-mount after returning from a session), so guard it.
518
- if (!item) {
519
- return;
520
- }
521
- const menuItem = item;
522
- if (menuItem.type === 'worktree') {
523
- setHighlightedWorktreePath(menuItem.worktree.path);
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:" }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter branches..." })] })), isSearchMode && branchItems.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "No branches match your search" }) })) : isSearchMode ? (_jsx(Box, { flexDirection: "column", children: branchItems.slice(0, limit).map((item, index) => (_jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, children: [index === selectedIndex ? '❯ ' : ' ', item.label] }, item.value))) })) : (_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
+ 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;
@@ -1,2 +1,3 @@
1
1
  export declare const STATE_PERSISTENCE_DURATION_MS = 200;
2
2
  export declare const STATE_CHECK_INTERVAL_MS = 100;
3
+ export declare const STATE_MINIMUM_DURATION_MS = 500;
@@ -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 { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
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
- // Detection sequence: first prompt (no auto-approval), back to busy, second prompt (should auto-approve)
111
- const detectionStates = [
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
- // First waiting_input cycle (auto-approval suppressed) (use async to process mutex updates)
145
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
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
- // Transition back to busy should reset the failure flag (use async to process mutex updates)
149
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
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
- // Next waiting_input should trigger pending_auto_approval (use async to process mutex updates)
153
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
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
- // Advance to pending_auto_approval state (use async to process mutex updates)
196
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
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
- if (duration >= STATE_PERSISTENCE_DURATION_MS) {
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 is met', async () => {
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 to exceed persistence duration
115
- await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
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(STATE_PERSISTENCE_DURATION_MS);
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
- // Existing logic with 30 lines
10
- const content = this.getTerminalContent(terminal, 30);
11
- const lowerContent = content.toLowerCase();
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 (lowerContent.includes('ctrl+r to toggle')) {
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(lowerContent)) {
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 (lowerContent.includes('esc to cancel')) {
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 (lowerContent.includes('esc to interrupt') ||
27
- lowerContent.includes('ctrl+c to interrupt')) {
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 present', () => {
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 (case insensitive)', () => {
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 present (web search)', () => {
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 is present', () => {
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" with options prompt is present (case insensitive)', () => {
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 "esc to cancel" is present', () => {
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 (case insensitive)', () => {
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 present', () => {
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,5 @@
1
+ import { Worktree } from '../types/index.js';
2
+ /**
3
+ * Filter worktrees by matching search query against branch name and path.
4
+ */
5
+ export declare function filterWorktreesByQuery(worktrees: Worktree[], query: string): Worktree[];
@@ -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
+ }
@@ -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;
@@ -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.11.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.11.3",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.11.3",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.11.3",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.11.3",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.11.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",