ccmanager 4.1.22 → 4.1.24

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.
@@ -10,7 +10,7 @@ import { prepareSessionItems, calculateColumnPositions, assembleSessionLabel, }
10
10
  import { projectManager } from '../services/projectManager.js';
11
11
  import { useSearchMode } from '../hooks/useSearchMode.js';
12
12
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
13
- import { filterSessionItemsByQuery } from '../utils/filterByQuery.js';
13
+ import { filterSessionItemsByQuery, filterSessionItemsByState, cycleSessionStateFilter, getSessionStateFilterLabel, } from '../utils/filterByQuery.js';
14
14
  import SearchableList from './SearchableList.js';
15
15
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
16
16
  import { configReader } from '../services/config/configReader.js';
@@ -40,6 +40,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
40
40
  const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
41
41
  const [highlightedSession, setHighlightedSession] = useState(undefined);
42
42
  const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
43
+ const [stateFilter, setStateFilter] = useState('all');
43
44
  // Use the search mode hook
44
45
  const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
45
46
  isDisabled: !!error || !!loadError,
@@ -125,8 +126,10 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
125
126
  });
126
127
  const columnPositions = calculateColumnPositions(items);
127
128
  // Filter session items based on search query, matching the name shown in
128
- // the menu (branch name, " (main)", and session name) plus the path.
129
- const filteredItems = filterSessionItemsByQuery(items, searchQuery);
129
+ // the menu (branch name, " (main)", and session name) plus the path, then
130
+ // narrow to the selected session state. The two filters are independent
131
+ // dimensions and compose: both can be active at once.
132
+ const filteredItems = filterSessionItemsByState(filterSessionItemsByQuery(items, searchQuery), stateFilter);
130
133
  // Build menu items with proper alignment
131
134
  const menuItems = filteredItems.map((item, index) => {
132
135
  const baseLabel = assembleSessionLabel(item, columnPositions);
@@ -279,12 +282,13 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
279
282
  recentProjects,
280
283
  searchQuery,
281
284
  isSearchMode,
285
+ stateFilter,
282
286
  autoApprovalToggleCounter,
283
287
  sessionManager,
284
288
  worktreeConfig.sortByLastSession,
285
289
  ]);
286
290
  // Handle hotkeys
287
- useInput((input, _key) => {
291
+ useInput((input, key) => {
288
292
  // Skip in test environment to avoid stdin.ref error
289
293
  if (!process.stdin.setRawMode) {
290
294
  return;
@@ -303,6 +307,11 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
303
307
  if (isSearchMode) {
304
308
  return;
305
309
  }
310
+ // Cycle the session-state filter: Tab forward, Shift+Tab backward.
311
+ if (key.tab) {
312
+ setStateFilter(prev => cycleSessionStateFilter(prev, key.shift ? 'prev' : 'next'));
313
+ return;
314
+ }
306
315
  const keyPressed = input.toLowerCase();
307
316
  // Handle number keys 0-9 for worktree selection
308
317
  if (/^[0-9]$/.test(keyPressed)) {
@@ -429,7 +438,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
429
438
  });
430
439
  }
431
440
  };
432
- 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: raw => {
441
+ 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:" }) }), ((searchQuery && !isSearchMode) || stateFilter !== 'all') && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [searchQuery && !isSearchMode && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Filtered: " }), _jsxs(Text, { color: "cyan", bold: true, children: ["\"", searchQuery, "\""] })] })), stateFilter !== 'all' && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State filter: " }), _jsx(Text, { color: "cyan", bold: true, children: getSessionStateFilterLabel(stateFilter) }), _jsx(Text, { dimColor: true, children: " (Tab to cycle, back to All clears it)" })] }))] })), _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: raw => {
433
442
  const item = items.find(i => i.value === raw?.value);
434
443
  if (!item)
435
444
  return;
@@ -445,7 +454,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
445
454
  }, 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
446
455
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
447
456
  : searchQuery
448
- ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
449
- : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
457
+ ? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
458
+ : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
450
459
  };
451
460
  export default Menu;
@@ -7,6 +7,11 @@ import { Worktree } from '../types/index.js';
7
7
  * Both are fetched together so they appear at the same time.
8
8
  * Handles cancellation via AbortController.
9
9
  *
10
+ * Each poll cycle fetches every worktree concurrently and commits the results in
11
+ * a single state update, skipping the update entirely when nothing changed. This
12
+ * keeps a poll cycle to at most one re-render of the consumer (the menu) instead
13
+ * of one per worktree, so background polling does not stutter keyboard navigation.
14
+ *
10
15
  * @param worktrees - Array of worktrees to monitor
11
16
  * @param defaultBranch - Default branch for comparisons (null disables polling)
12
17
  * @param updateInterval - Polling interval in milliseconds (default: 5000)
@@ -9,6 +9,11 @@ import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStat
9
9
  * Both are fetched together so they appear at the same time.
10
10
  * Handles cancellation via AbortController.
11
11
  *
12
+ * Each poll cycle fetches every worktree concurrently and commits the results in
13
+ * a single state update, skipping the update entirely when nothing changed. This
14
+ * keeps a poll cycle to at most one re-render of the consumer (the menu) instead
15
+ * of one per worktree, so background polling does not stutter keyboard navigation.
16
+ *
12
17
  * @param worktrees - Array of worktrees to monitor
13
18
  * @param defaultBranch - Default branch for comparisons (null disables polling)
14
19
  * @param updateInterval - Polling interval in milliseconds (default: 5000)
@@ -20,67 +25,103 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
20
25
  if (!defaultBranch) {
21
26
  return;
22
27
  }
23
- const timeouts = new Map();
24
- const activeRequests = new Map();
28
+ const activeRequests = new Set();
25
29
  let isCleanedUp = false;
26
- const fetchStatus = async (worktree, abortController) => {
27
- // Fetch git status and last commit date in parallel
28
- const [statusExit, dateExit] = await Promise.all([
29
- Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
30
- signal: abortController.signal,
31
- }),
32
- Effect.runPromiseExit(getLastCommitDateLimited(worktree.path), {
33
- signal: abortController.signal,
34
- }),
35
- ]);
36
- // Update worktree state with both results at once
37
- handleStatusExit(statusExit, dateExit, worktree.path, setWorktreesWithStatus);
38
- };
39
- const scheduleUpdate = (worktree) => {
30
+ let cycleTimeout;
31
+ const fetchStatus = async (worktree) => {
40
32
  const abortController = new AbortController();
41
- activeRequests.set(worktree.path, abortController);
42
- fetchStatus(worktree, abortController)
43
- .catch(() => {
44
- // Ignore errors - the fetch failed or was aborted
45
- })
46
- .finally(() => {
47
- const isActive = () => !isCleanedUp && !abortController.signal.aborted;
48
- if (isActive()) {
49
- const timeout = setTimeout(() => {
50
- if (isActive()) {
51
- scheduleUpdate(worktree);
52
- }
53
- }, updateInterval);
54
- timeouts.set(worktree.path, timeout);
55
- }
56
- });
33
+ activeRequests.add(abortController);
34
+ try {
35
+ // Fetch git status and last commit date in parallel
36
+ const [statusExit, dateExit] = await Promise.all([
37
+ Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
38
+ signal: abortController.signal,
39
+ }),
40
+ Effect.runPromiseExit(getLastCommitDateLimited(worktree.path), {
41
+ signal: abortController.signal,
42
+ }),
43
+ ]);
44
+ return { path: worktree.path, statusExit, dateExit };
45
+ }
46
+ finally {
47
+ activeRequests.delete(abortController);
48
+ }
49
+ };
50
+ const runCycle = async () => {
51
+ // Fetch all worktrees concurrently. The underlying Effects are already
52
+ // concurrency-limited, so this does not spawn an unbounded number of git
53
+ // subprocesses at once.
54
+ const results = await Promise.all(worktrees.map(worktree => fetchStatus(worktree)));
55
+ if (isCleanedUp) {
56
+ return;
57
+ }
58
+ // Apply every worktree's result in one state update so the consumer
59
+ // re-renders at most once per cycle.
60
+ applyStatusResults(results, setWorktreesWithStatus);
61
+ if (!isCleanedUp) {
62
+ cycleTimeout = setTimeout(() => {
63
+ if (!isCleanedUp) {
64
+ runCycle();
65
+ }
66
+ }, updateInterval);
67
+ }
57
68
  };
58
69
  setWorktreesWithStatus(worktrees);
59
- // Start fetching for each worktree
60
- worktrees.forEach(worktree => {
61
- scheduleUpdate(worktree);
70
+ runCycle().catch(() => {
71
+ // Ignore errors - the fetch failed or was aborted
62
72
  });
63
73
  return () => {
64
74
  isCleanedUp = true;
65
- timeouts.forEach(timeout => clearTimeout(timeout));
75
+ if (cycleTimeout) {
76
+ clearTimeout(cycleTimeout);
77
+ }
66
78
  activeRequests.forEach(controller => controller.abort());
67
79
  };
68
80
  }, [worktrees, defaultBranch, updateInterval]);
69
81
  return worktreesWithStatus;
70
82
  }
71
83
  /**
72
- * Handle the Exit results from Effect.runPromiseExit and update worktree state
84
+ * Apply a cycle's worth of status results in a single state update.
73
85
  *
74
- * Updates both gitStatus and lastCommitDate in a single state update so they
75
- * appear at the same time in the UI.
86
+ * Builds the next worktree array by merging each result onto the matching
87
+ * worktree, but only when the result actually changes a field. When nothing
88
+ * changed, the previous array reference is returned so React skips the re-render
89
+ * (and any downstream items rebuild) entirely.
76
90
  *
77
- * @param statusExit - Exit result from git status Effect
78
- * @param dateExit - Exit result from commit date Effect
79
- * @param worktreePath - Path of the worktree being updated
91
+ * @param results - Per-worktree status/date Exit results for this cycle
80
92
  * @param setWorktreesWithStatus - State setter function
81
93
  */
82
- function handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithStatus) {
83
- // Build the update object from both results
94
+ function applyStatusResults(results, setWorktreesWithStatus) {
95
+ const updatesByPath = new Map();
96
+ for (const result of results) {
97
+ const update = buildStatusUpdate(result.statusExit, result.dateExit);
98
+ if (update) {
99
+ updatesByPath.set(result.path, update);
100
+ }
101
+ }
102
+ if (updatesByPath.size === 0) {
103
+ return;
104
+ }
105
+ setWorktreesWithStatus(prev => {
106
+ let changed = false;
107
+ const next = prev.map(wt => {
108
+ const update = updatesByPath.get(wt.path);
109
+ if (!update || isUpdateNoop(wt, update)) {
110
+ return wt;
111
+ }
112
+ changed = true;
113
+ return { ...wt, ...update };
114
+ });
115
+ return changed ? next : prev;
116
+ });
117
+ }
118
+ /**
119
+ * Build the update object for a single worktree from its Exit results.
120
+ *
121
+ * @returns A partial worktree to merge, or null when there is nothing to update
122
+ * (e.g. the status fetch was interrupted and the commit date failed).
123
+ */
124
+ function buildStatusUpdate(statusExit, dateExit) {
84
125
  const update = {};
85
126
  let hasUpdate = false;
86
127
  if (Exit.isSuccess(statusExit)) {
@@ -91,9 +132,8 @@ function handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithSt
91
132
  else if (Exit.isFailure(statusExit)) {
92
133
  const failure = Cause.failureOption(statusExit.cause);
93
134
  if (Option.isSome(failure)) {
94
- const gitError = failure.value;
95
135
  update.gitStatus = undefined;
96
- update.gitStatusError = formatGitError(gitError);
136
+ update.gitStatusError = formatGitError(failure.value);
97
137
  hasUpdate = true;
98
138
  }
99
139
  }
@@ -102,9 +142,42 @@ function handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithSt
102
142
  hasUpdate = true;
103
143
  }
104
144
  // Silently ignore commit date errors (e.g., empty repo)
105
- if (hasUpdate) {
106
- setWorktreesWithStatus(prev => prev.map(wt => (wt.path === worktreePath ? { ...wt, ...update } : wt)));
145
+ return hasUpdate ? update : null;
146
+ }
147
+ /**
148
+ * Determine whether applying `update` to `wt` would change any field, so that
149
+ * no-op updates can be dropped before they trigger a re-render.
150
+ */
151
+ function isUpdateNoop(wt, update) {
152
+ if ('gitStatus' in update &&
153
+ !isSameGitStatus(wt.gitStatus, update.gitStatus)) {
154
+ return false;
155
+ }
156
+ if ('gitStatusError' in update &&
157
+ wt.gitStatusError !== update.gitStatusError) {
158
+ return false;
159
+ }
160
+ if ('lastCommitDate' in update) {
161
+ const prevTime = wt.lastCommitDate?.getTime();
162
+ const nextTime = update.lastCommitDate?.getTime();
163
+ if (prevTime !== nextTime) {
164
+ return false;
165
+ }
166
+ }
167
+ return true;
168
+ }
169
+ function isSameGitStatus(a, b) {
170
+ if (a === b) {
171
+ return true;
172
+ }
173
+ if (!a || !b) {
174
+ return false;
107
175
  }
176
+ return (a.filesAdded === b.filesAdded &&
177
+ a.filesDeleted === b.filesDeleted &&
178
+ a.aheadCount === b.aheadCount &&
179
+ a.behindCount === b.behindCount &&
180
+ a.parentBranch === b.parentBranch);
108
181
  }
109
182
  /**
110
183
  * Format GitError into a user-friendly error message
@@ -12,6 +12,14 @@ const JSON_SCHEMA = JSON.stringify({
12
12
  additionalProperties: false,
13
13
  });
14
14
  const DEFAULT_TIMEOUT_MS = 30_000;
15
+ // Generated branch names are short by construction. When `claude -p` returns
16
+ // prose instead of a name (e.g. a refusal, or a request for an issue's
17
+ // contents it cannot fetch), normalizing that text yields an unusually long,
18
+ // many-segment slug. These bounds let us detect that case and reject it so the
19
+ // caller falls back to a generated name instead of branching off a whole
20
+ // sentence. They are also surfaced to the model in the prompt.
21
+ const MAX_GENERATED_BRANCH_NAME_LENGTH = 60;
22
+ const MAX_GENERATED_BRANCH_NAME_SEGMENTS = 10;
15
23
  const buildPrompt = (userPrompt, baseBranch) => `You generate concise git branch names.
16
24
 
17
25
  Base branch: ${baseBranch}
@@ -19,7 +27,10 @@ Task prompt:
19
27
  ${userPrompt}
20
28
 
21
29
  Return a short git branch name using lowercase letters, numbers, hyphens, and forward slashes only.
30
+ Use at most ${MAX_GENERATED_BRANCH_NAME_SEGMENTS} hyphen- or slash-separated words and keep it under ${MAX_GENERATED_BRANCH_NAME_LENGTH} characters.
22
31
  Do not include markdown, explanations, refs/heads/, or surrounding quotes.
32
+ Always output a branch name. Never refuse, never ask for clarification, and never request more information or access.
33
+ If the task prompt references context you cannot access (for example an issue number, ticket, or URL), do not mention that limitation: just produce a best-effort name from the words available in the task prompt itself.
23
34
  Examples: feature/add-prompt-mode, fix/worktree-loading-state`;
24
35
  const normalizeBranchName = (branchName) => {
25
36
  const normalized = branchName
@@ -38,6 +49,13 @@ const normalizeBranchName = (branchName) => {
38
49
  if (!normalized) {
39
50
  throw new Error('Generated branch name was empty');
40
51
  }
52
+ if (normalized.length > MAX_GENERATED_BRANCH_NAME_LENGTH) {
53
+ throw new Error(`Generated branch name was too long (${normalized.length} > ${MAX_GENERATED_BRANCH_NAME_LENGTH} chars); treating it as invalid output`);
54
+ }
55
+ const segmentCount = normalized.split(/[/-]/).filter(Boolean).length;
56
+ if (segmentCount > MAX_GENERATED_BRANCH_NAME_SEGMENTS) {
57
+ throw new Error(`Generated branch name had too many words (${segmentCount} > ${MAX_GENERATED_BRANCH_NAME_SEGMENTS}); treating it as invalid output`);
58
+ }
41
59
  return normalized;
42
60
  };
43
61
  const extractStringCandidate = (value) => {
@@ -18,6 +18,18 @@ describe('WorktreeNameGenerator output parsing', () => {
18
18
  const value = extractBranchNameFromOutput('type":"result","subtype":"success","structured_output":{"branchName":"fix/trim-worktree-name"},"uuid":"123"');
19
19
  expect(value).toBe('fix/trim-worktree-name');
20
20
  });
21
+ it('rejects prose refusals that overflow the length budget', () => {
22
+ // `claude -p` sometimes puts a refusal sentence into the branchName field
23
+ // instead of a name; the caller relies on this throwing to use a fallback.
24
+ expect(() => extractBranchNameFromOutput('{"branchName":"i can t generate an informed branch name without understanding issue 310 could you either grant permission for webfetch or paste the issue description"}')).toThrow();
25
+ });
26
+ it('rejects names with too many words even when short', () => {
27
+ expect(() => extractBranchNameFromOutput('{"branchName":"a-b-c-d-e-f-g-h-i-j-k-l"}')).toThrow();
28
+ });
29
+ it('accepts a normal multi-word branch name', () => {
30
+ const value = extractBranchNameFromOutput('{"branchName":"feature/add-prompt-mode-toggle"}');
31
+ expect(value).toBe('feature/add-prompt-mode-toggle');
32
+ });
21
33
  });
22
34
  describe('deduplicateBranchName', () => {
23
35
  it('returns the name unchanged when no conflict exists', () => {
@@ -14,3 +14,28 @@ export declare function filterWorktreesByQuery(worktrees: Worktree[], query: str
14
14
  * sessions.
15
15
  */
16
16
  export declare function filterSessionItemsByQuery(items: SessionItem[], query: string): SessionItem[];
17
+ /**
18
+ * The user-facing categories a session can be filtered by. `'all'` disables the
19
+ * filter. The four internal {@link SessionState} values collapse into three
20
+ * categories: `pending_auto_approval` is shown as `waiting` because it is a
21
+ * form of "waiting for the user" (mirrors the status display in statusIcons.ts).
22
+ */
23
+ export type SessionStateFilter = 'all' | 'busy' | 'waiting' | 'idle';
24
+ /**
25
+ * Order in which the Tab / Shift+Tab keys cycle the state filter.
26
+ * "Attention-first" so the states a user most often hunts for come up first.
27
+ */
28
+ export declare const SESSION_STATE_FILTER_CYCLE: SessionStateFilter[];
29
+ /**
30
+ * Filter session items by their current state. This is an independent dimension
31
+ * from {@link filterSessionItemsByQuery}: callers compose the two so a text
32
+ * query and a state filter can both be active at once.
33
+ *
34
+ * Rows without a session have no state, so they are excluded whenever a specific
35
+ * state (not `'all'`) is selected.
36
+ */
37
+ export declare function filterSessionItemsByState(items: SessionItem[], filter: SessionStateFilter): SessionItem[];
38
+ /** Advance the state filter one step in the cycle (Tab) or back (Shift+Tab). */
39
+ export declare function cycleSessionStateFilter(current: SessionStateFilter, direction: 'next' | 'prev'): SessionStateFilter;
40
+ /** Human-readable label (icon + word) for a state filter, used in the footer. */
41
+ export declare function getSessionStateFilterLabel(filter: SessionStateFilter): string;
@@ -1,3 +1,4 @@
1
+ import { STATUS_ICONS, STATUS_LABELS } from '../constants/statusIcons.js';
1
2
  /**
2
3
  * Filter worktrees by matching search query against branch name and path.
3
4
  */
@@ -27,3 +28,64 @@ export function filterSessionItemsByQuery(items, query) {
27
28
  return items.filter(item => item.searchableName.toLowerCase().includes(searchLower) ||
28
29
  item.worktree.path.toLowerCase().includes(searchLower));
29
30
  }
31
+ /**
32
+ * Order in which the Tab / Shift+Tab keys cycle the state filter.
33
+ * "Attention-first" so the states a user most often hunts for come up first.
34
+ */
35
+ export const SESSION_STATE_FILTER_CYCLE = [
36
+ 'all',
37
+ 'busy',
38
+ 'waiting',
39
+ 'idle',
40
+ ];
41
+ /** Map an internal session state onto its user-facing filter category. */
42
+ function stateToFilterCategory(state) {
43
+ switch (state) {
44
+ case 'busy':
45
+ return 'busy';
46
+ case 'waiting_input':
47
+ case 'pending_auto_approval':
48
+ return 'waiting';
49
+ case 'idle':
50
+ return 'idle';
51
+ }
52
+ }
53
+ /**
54
+ * Filter session items by their current state. This is an independent dimension
55
+ * from {@link filterSessionItemsByQuery}: callers compose the two so a text
56
+ * query and a state filter can both be active at once.
57
+ *
58
+ * Rows without a session have no state, so they are excluded whenever a specific
59
+ * state (not `'all'`) is selected.
60
+ */
61
+ export function filterSessionItemsByState(items, filter) {
62
+ if (filter === 'all')
63
+ return items;
64
+ return items.filter(item => {
65
+ const stateData = item.session?.stateMutex.getSnapshot();
66
+ if (!stateData)
67
+ return false;
68
+ return stateToFilterCategory(stateData.state) === filter;
69
+ });
70
+ }
71
+ /** Advance the state filter one step in the cycle (Tab) or back (Shift+Tab). */
72
+ export function cycleSessionStateFilter(current, direction) {
73
+ const cycle = SESSION_STATE_FILTER_CYCLE;
74
+ const index = cycle.indexOf(current);
75
+ const offset = direction === 'next' ? 1 : -1;
76
+ const nextIndex = (index + offset + cycle.length) % cycle.length;
77
+ return cycle[nextIndex];
78
+ }
79
+ /** Human-readable label (icon + word) for a state filter, used in the footer. */
80
+ export function getSessionStateFilterLabel(filter) {
81
+ switch (filter) {
82
+ case 'all':
83
+ return 'All';
84
+ case 'busy':
85
+ return `${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`;
86
+ case 'waiting':
87
+ return `${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING}`;
88
+ case 'idle':
89
+ return `${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`;
90
+ }
91
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { filterWorktreesByQuery, filterSessionItemsByQuery, } from './filterByQuery.js';
2
+ import { filterWorktreesByQuery, filterSessionItemsByQuery, filterSessionItemsByState, cycleSessionStateFilter, getSessionStateFilterLabel, } from './filterByQuery.js';
3
+ import { STATUS_ICONS, STATUS_LABELS } from '../constants/statusIcons.js';
3
4
  const makeItem = (searchableName, path) => ({
4
5
  worktree: {
5
6
  path,
@@ -20,6 +21,14 @@ const makeItem = (searchableName, path) => ({
20
21
  lastCommitDate: 0,
21
22
  },
22
23
  });
24
+ // Minimal session stub exposing only what filterSessionItemsByState reads:
25
+ // stateMutex.getSnapshot().state.
26
+ const makeItemWithState = (searchableName, path, state) => ({
27
+ ...makeItem(searchableName, path),
28
+ session: {
29
+ stateMutex: { getSnapshot: () => ({ state }) },
30
+ },
31
+ });
23
32
  describe('filterWorktreesByQuery', () => {
24
33
  const worktrees = [
25
34
  {
@@ -79,3 +88,59 @@ describe('filterSessionItemsByQuery', () => {
79
88
  expect(result).toHaveLength(2);
80
89
  });
81
90
  });
91
+ describe('filterSessionItemsByState', () => {
92
+ const items = [
93
+ makeItemWithState('busy-a', '/repo/busy-a', 'busy'),
94
+ makeItemWithState('waiting-a', '/repo/waiting-a', 'waiting_input'),
95
+ makeItemWithState('pending-a', '/repo/pending-a', 'pending_auto_approval'),
96
+ makeItemWithState('idle-a', '/repo/idle-a', 'idle'),
97
+ // A worktree row with no running session (no state).
98
+ makeItem('no-session', '/repo/no-session'),
99
+ ];
100
+ it('returns all items when the filter is "all"', () => {
101
+ expect(filterSessionItemsByState(items, 'all')).toEqual(items);
102
+ });
103
+ it('keeps only busy sessions', () => {
104
+ const result = filterSessionItemsByState(items, 'busy');
105
+ expect(result.map(i => i.searchableName)).toEqual(['busy-a']);
106
+ });
107
+ it('folds pending_auto_approval into the waiting category', () => {
108
+ const result = filterSessionItemsByState(items, 'waiting');
109
+ expect(result.map(i => i.searchableName)).toEqual([
110
+ 'waiting-a',
111
+ 'pending-a',
112
+ ]);
113
+ });
114
+ it('keeps only idle sessions', () => {
115
+ const result = filterSessionItemsByState(items, 'idle');
116
+ expect(result.map(i => i.searchableName)).toEqual(['idle-a']);
117
+ });
118
+ it('excludes rows without a session for any specific state', () => {
119
+ for (const filter of ['busy', 'waiting', 'idle']) {
120
+ const result = filterSessionItemsByState(items, filter);
121
+ expect(result.map(i => i.searchableName)).not.toContain('no-session');
122
+ }
123
+ });
124
+ });
125
+ describe('cycleSessionStateFilter', () => {
126
+ it('cycles forward all -> busy -> waiting -> idle -> all', () => {
127
+ expect(cycleSessionStateFilter('all', 'next')).toBe('busy');
128
+ expect(cycleSessionStateFilter('busy', 'next')).toBe('waiting');
129
+ expect(cycleSessionStateFilter('waiting', 'next')).toBe('idle');
130
+ expect(cycleSessionStateFilter('idle', 'next')).toBe('all');
131
+ });
132
+ it('cycles backward all -> idle -> waiting -> busy -> all', () => {
133
+ expect(cycleSessionStateFilter('all', 'prev')).toBe('idle');
134
+ expect(cycleSessionStateFilter('idle', 'prev')).toBe('waiting');
135
+ expect(cycleSessionStateFilter('waiting', 'prev')).toBe('busy');
136
+ expect(cycleSessionStateFilter('busy', 'prev')).toBe('all');
137
+ });
138
+ });
139
+ describe('getSessionStateFilterLabel', () => {
140
+ it('labels each filter with icon and word', () => {
141
+ expect(getSessionStateFilterLabel('all')).toBe('All');
142
+ expect(getSessionStateFilterLabel('busy')).toBe(`${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`);
143
+ expect(getSessionStateFilterLabel('waiting')).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING}`);
144
+ expect(getSessionStateFilterLabel('idle')).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`);
145
+ });
146
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.22",
3
+ "version": "4.1.24",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.22",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.22",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.22",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.22",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.22"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.24",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.24",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.24",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.24",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.24"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",