ccmanager 3.12.0 → 3.12.2

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',
@@ -7,11 +7,12 @@ const PresetSelector = ({ onSelect, onCancel, }) => {
7
7
  const presetsConfig = configReader.getCommandPresets();
8
8
  const [presets] = useState(presetsConfig.presets);
9
9
  const defaultPresetId = presetsConfig.defaultPresetId;
10
- const selectItems = presets.map(preset => {
10
+ const selectItems = presets.map((preset, index) => {
11
11
  const isDefault = preset.id === defaultPresetId;
12
12
  const args = preset.args?.join(' ') || '';
13
13
  const fallback = preset.fallbackArgs?.join(' ') || '';
14
- let label = preset.name;
14
+ const numberPrefix = index < 9 ? `[${index + 1}] ` : '';
15
+ let label = numberPrefix + preset.name;
15
16
  if (isDefault)
16
17
  label += ' (default)';
17
18
  label += `\n Command: ${preset.command}`;
@@ -39,8 +40,17 @@ const PresetSelector = ({ onSelect, onCancel, }) => {
39
40
  useInput((input, key) => {
40
41
  if (key.escape) {
41
42
  onCancel();
43
+ return;
44
+ }
45
+ // Number keys 1-9: immediate launch
46
+ if (/^[1-9]$/.test(input)) {
47
+ const idx = parseInt(input) - 1;
48
+ if (idx < presets.length && presets[idx]) {
49
+ onSelect(presets[idx].id);
50
+ }
51
+ return;
42
52
  }
43
53
  });
44
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Select Command Preset" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Choose a preset to start the session with" }) }), _jsx(SelectInput, { items: selectItems, onSelect: handleSelectItem, initialIndex: initialIndex >= 0 ? initialIndex : 0 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press \u2191\u2193 to navigate, Enter to select, ESC to cancel" }) })] }));
54
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Select Command Preset" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Choose a preset to start the session with" }) }), _jsx(SelectInput, { items: selectItems, onSelect: handleSelectItem, initialIndex: initialIndex >= 0 ? initialIndex : 0 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 Navigate 1-9 Quick Select Enter Select ESC Cancel" }) })] }));
45
55
  };
46
56
  export default PresetSelector;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
4
+ // Hoist mocks to avoid top-level variable access in vi.mock factories
5
+ const { capturedHandlers } = vi.hoisted(() => {
6
+ const capturedHandlers = {
7
+ inputHandler: null,
8
+ };
9
+ return { capturedHandlers };
10
+ });
11
+ // Mock ink to avoid stdin issues and capture useInput callbacks
12
+ vi.mock('ink', async () => {
13
+ const actual = await vi.importActual('ink');
14
+ return {
15
+ ...actual,
16
+ useInput: vi.fn((handler) => {
17
+ capturedHandlers.inputHandler = handler;
18
+ }),
19
+ };
20
+ });
21
+ // Mock SelectInput
22
+ vi.mock('ink-select-input', async () => {
23
+ const React = await vi.importActual('react');
24
+ const { Text, Box } = await vi.importActual('ink');
25
+ return {
26
+ default: ({ items, onSelect: _onSelect, initialIndex = 0, }) => {
27
+ return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, `${index === initialIndex ? '❯ ' : ' '}${item.label}`)));
28
+ },
29
+ };
30
+ });
31
+ // Mock configReader
32
+ vi.mock('../services/config/configReader.js', () => ({
33
+ configReader: {
34
+ getCommandPresets: vi.fn().mockReturnValue({
35
+ presets: [
36
+ { id: 'preset-1', name: 'Claude', command: 'claude' },
37
+ { id: 'preset-2', name: 'Gemini', command: 'gemini' },
38
+ { id: 'preset-3', name: 'Cursor', command: 'cursor' },
39
+ ],
40
+ defaultPresetId: 'preset-1',
41
+ selectPresetOnStart: true,
42
+ }),
43
+ },
44
+ }));
45
+ import PresetSelector from './PresetSelector.js';
46
+ const makeKey = (overrides = {}) => ({
47
+ upArrow: false,
48
+ downArrow: false,
49
+ leftArrow: false,
50
+ rightArrow: false,
51
+ pageDown: false,
52
+ pageUp: false,
53
+ home: false,
54
+ end: false,
55
+ return: false,
56
+ escape: false,
57
+ ctrl: false,
58
+ shift: false,
59
+ tab: false,
60
+ backspace: false,
61
+ delete: false,
62
+ meta: false,
63
+ ...overrides,
64
+ });
65
+ describe('PresetSelector component', () => {
66
+ let onSelect;
67
+ let onCancel;
68
+ beforeEach(() => {
69
+ onSelect = vi.fn();
70
+ onCancel = vi.fn();
71
+ capturedHandlers.inputHandler = null;
72
+ });
73
+ afterEach(() => {
74
+ vi.clearAllMocks();
75
+ });
76
+ it('renders preset list with number prefixes and default label', () => {
77
+ const { lastFrame } = render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
78
+ const output = lastFrame();
79
+ expect(output).toContain('[1]');
80
+ expect(output).toContain('[2]');
81
+ expect(output).toContain('[3]');
82
+ expect(output).toContain('(default)');
83
+ expect(output).toContain('← Cancel');
84
+ });
85
+ it('pressing 1 calls onSelect with first preset id immediately', () => {
86
+ render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
87
+ expect(capturedHandlers.inputHandler).not.toBeNull();
88
+ capturedHandlers.inputHandler('1', makeKey());
89
+ expect(onSelect).toHaveBeenCalledWith('preset-1');
90
+ expect(onCancel).not.toHaveBeenCalled();
91
+ });
92
+ it('pressing 2 calls onSelect with second preset id immediately', () => {
93
+ render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
94
+ capturedHandlers.inputHandler('2', makeKey());
95
+ expect(onSelect).toHaveBeenCalledWith('preset-2');
96
+ });
97
+ it('pressing 3 calls onSelect with third preset id immediately', () => {
98
+ render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
99
+ capturedHandlers.inputHandler('3', makeKey());
100
+ expect(onSelect).toHaveBeenCalledWith('preset-3');
101
+ });
102
+ it('pressing a number beyond preset count does nothing', () => {
103
+ render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
104
+ capturedHandlers.inputHandler('9', makeKey());
105
+ expect(onSelect).not.toHaveBeenCalled();
106
+ expect(onCancel).not.toHaveBeenCalled();
107
+ });
108
+ it('pressing ESC calls onCancel', () => {
109
+ render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
110
+ capturedHandlers.inputHandler('', makeKey({ escape: true }));
111
+ expect(onCancel).toHaveBeenCalled();
112
+ expect(onSelect).not.toHaveBeenCalled();
113
+ });
114
+ it('displays title and subtitle', () => {
115
+ const { lastFrame } = render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
116
+ const output = lastFrame();
117
+ expect(output).toContain('Select Command Preset');
118
+ expect(output).toContain('Choose a preset to start the session with');
119
+ });
120
+ });
@@ -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;
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.12.0",
3
+ "version": "3.12.2",
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.12.0",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.12.0",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.12.0",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.12.0",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.12.0"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.12.2",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.12.2",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.12.2",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.12.2",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.12.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",