ccmanager 4.1.1 → 4.1.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.
@@ -61,11 +61,13 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
61
61
  session.process.write(data);
62
62
  };
63
63
  stdin.on('data', handleStdinData);
64
- // Prevent line wrapping from drifting redraws in TUIs that rely on cursor-up clears.
65
- stdout.write('\x1b[?7l');
66
64
  // Clear screen when entering session
67
65
  stdout.write('\x1B[2J\x1B[H');
68
66
  // Restore the current terminal state from the headless xterm snapshot.
67
+ // The xterm serialize addon relies on auto-wrap (DECAWM) being enabled to
68
+ // render wrapped lines — it omits row separators for wrapped rows, expecting
69
+ // characters to naturally overflow to the next line. We therefore keep
70
+ // auto-wrap enabled while writing the snapshot and only disable it afterward.
69
71
  const handleSessionRestore = (restoredSession, restoreSnapshot) => {
70
72
  if (restoredSession.id === session.id) {
71
73
  if (restoreSnapshot.length > 0) {
@@ -107,8 +109,16 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
107
109
  /* empty */
108
110
  }
109
111
  // Mark session as active after resizing so the restore snapshot matches
110
- // the current terminal dimensions.
112
+ // the current terminal dimensions. setSessionActive synchronously emits
113
+ // the 'sessionRestore' event, so the snapshot is written to stdout before
114
+ // we proceed.
111
115
  sessionManager.setSessionActive(session.id, true);
116
+ // Prevent line wrapping from drifting redraws in TUIs that rely on
117
+ // cursor-up clears. This MUST come after the restore snapshot write
118
+ // because the xterm serialize addon relies on auto-wrap (DECAWM) being
119
+ // enabled — it omits row separators for wrapped rows, expecting characters
120
+ // to naturally overflow to the next line.
121
+ stdout.write('\x1b[?7l');
112
122
  // Handle terminal resize
113
123
  const handleResize = () => {
114
124
  const cols = process.stdout.columns || 80;
@@ -96,6 +96,7 @@ describe('Session', () => {
96
96
  expect(terminalResize).toHaveBeenCalledWith(120, 40);
97
97
  expect(processResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
98
98
  expect(terminalResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
99
- expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\nrestored');
99
+ expect(testState.stdout?.write).toHaveBeenNthCalledWith(2, '\nrestored');
100
+ expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\x1b[?7l');
100
101
  });
101
102
  });
@@ -4,6 +4,7 @@ import { SessionManager } from './sessionManager.js';
4
4
  import { spawn } from './bunTerminal.js';
5
5
  import { EventEmitter } from 'events';
6
6
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
7
+ import { IDLE_DEBOUNCE_MS } from './stateDetector/claude.js';
7
8
  vi.mock('./bunTerminal.js', () => ({
8
9
  spawn: vi.fn(function () {
9
10
  return null;
@@ -86,8 +87,9 @@ describe('SessionManager - State Persistence', () => {
86
87
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
87
88
  // Simulate output that would trigger idle state
88
89
  eventEmitter.emit('data', 'Some output without busy indicators');
89
- // Advance time less than persistence duration (use async to process mutex updates)
90
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
90
+ // Advance time past idle debounce so detector starts returning idle,
91
+ // but not enough for persistence to confirm
92
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
91
93
  // State should still be busy, but pending state should be set
92
94
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
93
95
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
@@ -103,8 +105,8 @@ describe('SessionManager - State Persistence', () => {
103
105
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
104
106
  // Simulate output that would trigger idle state
105
107
  eventEmitter.emit('data', 'Some output without busy indicators');
106
- // Advance time less than persistence duration (use async to process mutex updates)
107
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
108
+ // Advance time past idle debounce but not persistence+minimum
109
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
108
110
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
109
111
  expect(stateChangeHandler).not.toHaveBeenCalled();
110
112
  // Advance time past both persistence and minimum duration
@@ -123,8 +125,8 @@ describe('SessionManager - State Persistence', () => {
123
125
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
124
126
  // Simulate output that would trigger idle state
125
127
  eventEmitter.emit('data', 'Some output without busy indicators');
126
- // Advance time less than persistence duration (use async to process mutex updates)
127
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
128
+ // Advance time past idle debounce so detector starts returning idle
129
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
128
130
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
129
131
  // Simulate output that would trigger waiting_input state
130
132
  eventEmitter.emit('data', 'Do you want to continue?\n❯ 1. Yes');
@@ -142,8 +144,8 @@ describe('SessionManager - State Persistence', () => {
142
144
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
143
145
  // Simulate output that would trigger idle state
144
146
  eventEmitter.emit('data', 'Some output without busy indicators');
145
- // Advance time less than persistence duration (use async to process mutex updates)
146
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
147
+ // Advance time past idle debounce so detector starts returning idle
148
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
147
149
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
148
150
  expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
149
151
  // Simulate output that would trigger busy state again (back to original)
@@ -165,16 +167,16 @@ describe('SessionManager - State Persistence', () => {
165
167
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
166
168
  // Try to change to idle
167
169
  eventEmitter.emit('data', 'Some idle output\n');
168
- // Wait for detection but not full persistence (less than 200ms) (use async to process mutex updates)
169
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
170
+ // Wait past idle debounce so detector returns idle, then one check
171
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
170
172
  // Should have pending state but not confirmed
171
173
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
172
174
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
173
175
  // Now change to a different state before idle persists
174
176
  // Clear terminal first and add waiting prompt
175
177
  eventEmitter.emit('data', '\x1b[2J\x1b[HDo you want to continue?\n❯ 1. Yes');
176
- // Advance time to detect new state but still less than persistence duration from first change (use async to process mutex updates)
177
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
178
+ // Advance time to detect new state
179
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
178
180
  // Pending state should have changed to waiting_input
179
181
  expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
180
182
  expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
@@ -187,8 +189,8 @@ describe('SessionManager - State Persistence', () => {
187
189
  const eventEmitter = eventEmitters.get('/test/path');
188
190
  // Simulate output that would trigger idle state
189
191
  eventEmitter.emit('data', 'Some output without busy indicators');
190
- // Advance time less than persistence duration (use async to process mutex updates)
191
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
192
+ // Advance time past idle debounce so detector returns idle
193
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
192
194
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
193
195
  expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
194
196
  // Destroy the session
@@ -207,8 +209,10 @@ describe('SessionManager - State Persistence', () => {
207
209
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
208
210
  // Simulate output that would trigger idle state
209
211
  eventEmitter.emit('data', 'Some output without busy indicators');
210
- // Advance time less than persistence duration so transition is not yet confirmed
211
- await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS - STATE_CHECK_INTERVAL_MS);
212
+ // Advance past idle debounce but not past persistence + minimum duration
213
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS +
214
+ STATE_PERSISTENCE_DURATION_MS -
215
+ STATE_CHECK_INTERVAL_MS);
212
216
  // State should still be busy because minimum duration hasn't elapsed
213
217
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
214
218
  expect(stateChangeHandler).not.toHaveBeenCalled();
@@ -223,8 +227,8 @@ describe('SessionManager - State Persistence', () => {
223
227
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
224
228
  // Simulate output that would trigger idle state
225
229
  eventEmitter.emit('data', 'Some output without busy indicators');
226
- // Advance time past STATE_MINIMUM_DURATION_MS (which is longer than STATE_PERSISTENCE_DURATION_MS)
227
- await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
230
+ // Advance past idle debounce + persistence/minimum duration
231
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
228
232
  // State should now be idle since both durations are satisfied
229
233
  expect(session.stateMutex.getSnapshot().state).toBe('idle');
230
234
  expect(stateChangeHandler).toHaveBeenCalledWith(session);
@@ -244,14 +248,11 @@ describe('SessionManager - State Persistence', () => {
244
248
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
245
249
  // Now simulate a brief screen redraw: busy indicators disappear temporarily
246
250
  eventEmitter.emit('data', '\x1b[2J\x1b[H'); // Clear screen
247
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
248
- // Pending state should be set to idle
249
- expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
250
- // Advance past half the persistence duration but not fully
251
- // Since stateConfirmedAt was updated at ~2000ms, this is not enough
252
- // for the pending state to be confirmed
253
- await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS / 2);
254
- // State should still be busy because minimum duration since last busy detection hasn't elapsed
251
+ // Idle debounce prevents the detector from returning idle for IDLE_DEBOUNCE_MS,
252
+ // so during a brief redraw (< 1500ms), no pending idle state is set.
253
+ // Advance a short time — still within the idle debounce window
254
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3); // 300ms
255
+ // State should still be busy idle debounce hasn't elapsed
255
256
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
256
257
  expect(stateChangeHandler).not.toHaveBeenCalled();
257
258
  // Simulate busy indicators coming back (screen redraw complete)
@@ -274,18 +275,12 @@ describe('SessionManager - State Persistence', () => {
274
275
  // Simulate different outputs for each session
275
276
  // Session 1 goes to idle
276
277
  eventEmitter1.emit('data', 'Idle output for session 1');
277
- // Session 2 goes to waiting_input
278
+ // Session 2 goes to waiting_input (no idle debounce for waiting_input)
278
279
  eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
279
- // Advance time to check but not confirm (use async to process mutex updates)
280
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
281
- // Both should have pending states but not changed yet
282
- expect(session1.stateMutex.getSnapshot().state).toBe('busy');
283
- expect(session1.stateMutex.getSnapshot().pendingState).toBe('idle');
284
- expect(session2.stateMutex.getSnapshot().state).toBe('busy');
285
- expect(session2.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
286
- // Advance time to confirm both - need to exceed STATE_MINIMUM_DURATION_MS (use async to process mutex updates)
287
- await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
280
+ // Advance past idle debounce for session 1 + persistence/minimum for both
281
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
288
282
  // Both should now be in their new states
283
+ // Session 2 (waiting_input) transitions faster since it's not debounced
289
284
  expect(session1.stateMutex.getSnapshot().state).toBe('idle');
290
285
  expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
291
286
  expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
@@ -1,6 +1,18 @@
1
1
  import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
+ export declare const IDLE_DEBOUNCE_MS = 1500;
3
4
  export declare class ClaudeStateDetector extends BaseStateDetector {
5
+ private lastContentHash;
6
+ private contentStableSince;
7
+ /**
8
+ * Debounce idle transitions: only return 'idle' when the terminal
9
+ * content has been unchanged for IDLE_DEBOUNCE_MS.
10
+ * Returns currentState if output is still changing.
11
+ *
12
+ * This is a workaround for Claude Code occasionally showing idle-like
13
+ * terminal output while still busy (e.g. during screen redraws).
14
+ */
15
+ private debounceIdle;
4
16
  /**
5
17
  * Extract content above the prompt box.
6
18
  * The prompt box is delimited by ─ border lines:
@@ -4,7 +4,34 @@ const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃
4
4
  // Matches spinner activity labels like "✽ Tempering…" or "✳ Simplifying recompute_tangents…"
5
5
  const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
6
6
  const BUSY_LOOKBACK_LINES = 5;
7
+ // Workaround: Claude Code sometimes appears idle in terminal output while
8
+ // still actively processing (busy). To mitigate false idle transitions,
9
+ // require terminal output to remain unchanged for this duration before
10
+ // confirming the idle state.
11
+ export const IDLE_DEBOUNCE_MS = 1500;
7
12
  export class ClaudeStateDetector extends BaseStateDetector {
13
+ lastContentHash = '';
14
+ contentStableSince = 0;
15
+ /**
16
+ * Debounce idle transitions: only return 'idle' when the terminal
17
+ * content has been unchanged for IDLE_DEBOUNCE_MS.
18
+ * Returns currentState if output is still changing.
19
+ *
20
+ * This is a workaround for Claude Code occasionally showing idle-like
21
+ * terminal output while still busy (e.g. during screen redraws).
22
+ */
23
+ debounceIdle(terminal, currentState, now = Date.now()) {
24
+ const content = this.getTerminalContent(terminal, 30);
25
+ if (content !== this.lastContentHash) {
26
+ this.lastContentHash = content;
27
+ this.contentStableSince = now;
28
+ }
29
+ const stableDuration = now - this.contentStableSince;
30
+ if (stableDuration >= IDLE_DEBOUNCE_MS) {
31
+ return 'idle';
32
+ }
33
+ return currentState;
34
+ }
8
35
  /**
9
36
  * Extract content above the prompt box.
10
37
  * The prompt box is delimited by ─ border lines:
@@ -62,10 +89,10 @@ export class ClaudeStateDetector extends BaseStateDetector {
62
89
  return recentBlock.slice(-BUSY_LOOKBACK_LINES).join('\n');
63
90
  }
64
91
  detectState(terminal, currentState) {
65
- // Check for search prompt (⌕ Search…) within 200 lines - always idle
92
+ // Check for search prompt (⌕ Search…) within 200 lines - always idle (debounced)
66
93
  const extendedContent = this.getTerminalContent(terminal, 200);
67
94
  if (extendedContent.includes('⌕ Search…')) {
68
- return 'idle';
95
+ return this.debounceIdle(terminal, currentState);
69
96
  }
70
97
  // Full content (including prompt box) for waiting_input detection
71
98
  const fullContent = this.getTerminalContent(terminal, 30);
@@ -79,10 +106,6 @@ export class ClaudeStateDetector extends BaseStateDetector {
79
106
  if (/(?:do you want|would you like).+\n+[\s\S]*?(?:yes|❯)/.test(fullLowerContent)) {
80
107
  return 'waiting_input';
81
108
  }
82
- // Check for selection prompt with ❯ cursor indicator and numbered options
83
- if (/❯\s+\d+\./.test(fullContent)) {
84
- return 'waiting_input';
85
- }
86
109
  // Check for "esc to cancel" - indicates waiting for user input
87
110
  if (fullLowerContent.includes('esc to cancel')) {
88
111
  return 'waiting_input';
@@ -99,8 +122,8 @@ export class ClaudeStateDetector extends BaseStateDetector {
99
122
  if (SPINNER_ACTIVITY_PATTERN.test(abovePromptBox)) {
100
123
  return 'busy';
101
124
  }
102
- // Otherwise idle
103
- return 'idle';
125
+ // Otherwise idle (debounced)
126
+ return this.debounceIdle(terminal, currentState);
104
127
  }
105
128
  detectBackgroundTask(terminal) {
106
129
  const lines = this.getTerminalLines(terminal, 3);
@@ -1,12 +1,25 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { ClaudeStateDetector } from './claude.js';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { ClaudeStateDetector, IDLE_DEBOUNCE_MS } from './claude.js';
3
3
  import { createMockTerminal } from './testUtils.js';
4
4
  describe('ClaudeStateDetector', () => {
5
5
  let detector;
6
6
  let terminal;
7
7
  beforeEach(() => {
8
+ vi.useFakeTimers();
8
9
  detector = new ClaudeStateDetector();
9
10
  });
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+ /**
15
+ * Helper: call detectState, advance time past IDLE_DEBOUNCE_MS,
16
+ * then call again with the same terminal to get the debounced result.
17
+ */
18
+ const detectStateAfterDebounce = (det, term, currentState = 'idle') => {
19
+ det.detectState(term, currentState);
20
+ vi.advanceTimersByTime(IDLE_DEBOUNCE_MS);
21
+ return det.detectState(term, currentState);
22
+ };
10
23
  describe('detectState', () => {
11
24
  it('should detect busy when "ESC to interrupt" is above prompt box', () => {
12
25
  // Arrange
@@ -47,7 +60,7 @@ describe('ClaudeStateDetector', () => {
47
60
  // Assert
48
61
  expect(state).toBe('busy');
49
62
  });
50
- it('should detect idle when no specific patterns are found', () => {
63
+ it('should detect idle when no specific patterns are found (after debounce)', () => {
51
64
  // Arrange
52
65
  terminal = createMockTerminal([
53
66
  'Command completed successfully',
@@ -55,7 +68,7 @@ describe('ClaudeStateDetector', () => {
55
68
  '> ',
56
69
  ]);
57
70
  // Act
58
- const state = detector.detectState(terminal, 'idle');
71
+ const state = detectStateAfterDebounce(detector, terminal, 'idle');
59
72
  // Assert
60
73
  expect(state).toBe('idle');
61
74
  });
@@ -63,7 +76,7 @@ describe('ClaudeStateDetector', () => {
63
76
  // Arrange
64
77
  terminal = createMockTerminal([]);
65
78
  // Act
66
- const state = detector.detectState(terminal, 'idle');
79
+ const state = detectStateAfterDebounce(detector, terminal, 'idle');
67
80
  // Assert
68
81
  expect(state).toBe('idle');
69
82
  });
@@ -202,33 +215,6 @@ describe('ClaudeStateDetector', () => {
202
215
  // Assert
203
216
  expect(state).toBe('waiting_input');
204
217
  });
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
218
  it('should detect waiting_input when "esc to cancel" is above prompt box', () => {
233
219
  // Arrange
234
220
  terminal = createMockTerminal([
@@ -283,7 +269,7 @@ describe('ClaudeStateDetector', () => {
283
269
  'idle state here',
284
270
  ], { baseY: 5, rows: 3 });
285
271
  // Act
286
- const state = detector.detectState(terminal, 'busy');
272
+ const state = detectStateAfterDebounce(detector, terminal, 'busy');
287
273
  // Assert - Should detect idle because viewport shows lines 5-7
288
274
  expect(state).toBe('idle');
289
275
  });
@@ -387,7 +373,7 @@ describe('ClaudeStateDetector', () => {
387
373
  // Arrange - no "ing…" at end
388
374
  terminal = createMockTerminal(['✽ Some random text', '❯']);
389
375
  // Act
390
- const state = detector.detectState(terminal, 'idle');
376
+ const state = detectStateAfterDebounce(detector, terminal, 'idle');
391
377
  // Assert
392
378
  expect(state).toBe('idle');
393
379
  });
@@ -395,7 +381,7 @@ describe('ClaudeStateDetector', () => {
395
381
  // Arrange
396
382
  terminal = createMockTerminal(['⌕ Search…', '✽ Tempering…']);
397
383
  // Act
398
- const state = detector.detectState(terminal, 'busy');
384
+ const state = detectStateAfterDebounce(detector, terminal, 'busy');
399
385
  // Assert - Search prompt takes precedence
400
386
  expect(state).toBe('idle');
401
387
  });
@@ -403,7 +389,7 @@ describe('ClaudeStateDetector', () => {
403
389
  // Arrange - Search prompt should always be idle
404
390
  terminal = createMockTerminal(['⌕ Search…', 'Some content']);
405
391
  // Act
406
- const state = detector.detectState(terminal, 'busy');
392
+ const state = detectStateAfterDebounce(detector, terminal, 'busy');
407
393
  // Assert
408
394
  expect(state).toBe('idle');
409
395
  });
@@ -411,7 +397,7 @@ describe('ClaudeStateDetector', () => {
411
397
  // Arrange
412
398
  terminal = createMockTerminal(['⌕ Search…', 'esc to cancel']);
413
399
  // Act
414
- const state = detector.detectState(terminal, 'idle');
400
+ const state = detectStateAfterDebounce(detector, terminal, 'idle');
415
401
  // Assert - Should be idle because search prompt takes precedence
416
402
  expect(state).toBe('idle');
417
403
  });
@@ -419,7 +405,7 @@ describe('ClaudeStateDetector', () => {
419
405
  // Arrange
420
406
  terminal = createMockTerminal(['⌕ Search…', 'Press esc to interrupt']);
421
407
  // Act
422
- const state = detector.detectState(terminal, 'idle');
408
+ const state = detectStateAfterDebounce(detector, terminal, 'idle');
423
409
  // Assert - Should be idle because search prompt takes precedence
424
410
  expect(state).toBe('idle');
425
411
  });
@@ -437,7 +423,7 @@ describe('ClaudeStateDetector', () => {
437
423
  '❯',
438
424
  '──────────────────────────────',
439
425
  ]);
440
- const state = detector.detectState(terminal, 'busy');
426
+ const state = detectStateAfterDebounce(detector, terminal, 'busy');
441
427
  expect(state).toBe('idle');
442
428
  });
443
429
  it('should ignore stale interrupt text outside the latest block above the prompt box', () => {
@@ -451,7 +437,7 @@ describe('ClaudeStateDetector', () => {
451
437
  '❯',
452
438
  '──────────────────────────────',
453
439
  ]);
454
- const state = detector.detectState(terminal, 'busy');
440
+ const state = detectStateAfterDebounce(detector, terminal, 'busy');
455
441
  expect(state).toBe('idle');
456
442
  });
457
443
  it('should ignore "esc to interrupt" inside prompt box', () => {
@@ -463,7 +449,7 @@ describe('ClaudeStateDetector', () => {
463
449
  '──────────────────────────────',
464
450
  ]);
465
451
  // Act
466
- const state = detector.detectState(terminal, 'idle');
452
+ const state = detectStateAfterDebounce(detector, terminal, 'idle');
467
453
  // Assert - should be idle because "esc to interrupt" is inside prompt box
468
454
  expect(state).toBe('idle');
469
455
  });
@@ -503,10 +489,68 @@ describe('ClaudeStateDetector', () => {
503
489
  '──────────────────────────────',
504
490
  ]);
505
491
  // Act
506
- const state = detector.detectState(terminal, 'idle');
492
+ const state = detectStateAfterDebounce(detector, terminal, 'idle');
507
493
  // Assert - should be idle because spinner is inside prompt box
508
494
  expect(state).toBe('idle');
509
495
  });
496
+ describe('idle debounce', () => {
497
+ it('should not return idle immediately when output just appeared', () => {
498
+ terminal = createMockTerminal(['Command completed successfully', '> ']);
499
+ const state = detector.detectState(terminal, 'busy');
500
+ // Should remain busy because debounce hasn't elapsed
501
+ expect(state).toBe('busy');
502
+ });
503
+ it('should return idle after output is stable for IDLE_DEBOUNCE_MS', () => {
504
+ terminal = createMockTerminal(['Command completed successfully', '> ']);
505
+ // First call registers the content
506
+ detector.detectState(terminal, 'busy');
507
+ // Advance time past debounce threshold
508
+ vi.advanceTimersByTime(IDLE_DEBOUNCE_MS);
509
+ // Second call with same content should return idle
510
+ const state = detector.detectState(terminal, 'busy');
511
+ expect(state).toBe('idle');
512
+ });
513
+ it('should reset debounce timer when output changes', () => {
514
+ terminal = createMockTerminal(['Output v1', '> ']);
515
+ detector.detectState(terminal, 'busy');
516
+ // Advance almost to threshold
517
+ vi.advanceTimersByTime(IDLE_DEBOUNCE_MS - 100);
518
+ // Output changes
519
+ terminal = createMockTerminal(['Output v2', '> ']);
520
+ const state1 = detector.detectState(terminal, 'busy');
521
+ expect(state1).toBe('busy');
522
+ // Advance past original threshold but not new one
523
+ vi.advanceTimersByTime(200);
524
+ const state2 = detector.detectState(terminal, 'busy');
525
+ expect(state2).toBe('busy');
526
+ // Advance to meet new threshold
527
+ vi.advanceTimersByTime(IDLE_DEBOUNCE_MS);
528
+ const state3 = detector.detectState(terminal, 'busy');
529
+ expect(state3).toBe('idle');
530
+ });
531
+ it('should not debounce busy transitions', () => {
532
+ terminal = createMockTerminal([
533
+ 'Processing...',
534
+ 'Press ESC to interrupt',
535
+ '──────────────────────────────',
536
+ '❯',
537
+ '──────────────────────────────',
538
+ ]);
539
+ // Busy should be detected immediately without debounce
540
+ const state = detector.detectState(terminal, 'idle');
541
+ expect(state).toBe('busy');
542
+ });
543
+ it('should not debounce waiting_input transitions', () => {
544
+ terminal = createMockTerminal([
545
+ 'Do you want to continue?',
546
+ '❯ 1. Yes',
547
+ ' 2. No',
548
+ ]);
549
+ // waiting_input should be detected immediately
550
+ const state = detector.detectState(terminal, 'idle');
551
+ expect(state).toBe('waiting_input');
552
+ });
553
+ });
510
554
  });
511
555
  describe('detectBackgroundTask', () => {
512
556
  it('should return count 1 when "1 background task" is in status bar', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.1",
3
+ "version": "4.1.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": "4.1.1",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.1",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.1",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.1",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.1"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.2",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.2",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.2",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.2",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",