ccmanager 4.1.0 → 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.
- package/dist/components/Session.js +13 -3
- package/dist/components/Session.test.js +2 -1
- package/dist/services/sessionManager.js +1 -1
- package/dist/services/sessionManager.statePersistence.test.js +31 -36
- package/dist/services/sessionManager.test.js +1 -1
- package/dist/services/stateDetector/claude.d.ts +12 -0
- package/dist/services/stateDetector/claude.js +31 -8
- package/dist/services/stateDetector/claude.test.js +86 -42
- package/package.json +6 -6
|
@@ -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(
|
|
99
|
+
expect(testState.stdout?.write).toHaveBeenNthCalledWith(2, '\nrestored');
|
|
100
|
+
expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\x1b[?7l');
|
|
100
101
|
});
|
|
101
102
|
});
|
|
@@ -201,7 +201,7 @@ export class SessionManager extends EventEmitter {
|
|
|
201
201
|
}
|
|
202
202
|
getRestoreSnapshot(session) {
|
|
203
203
|
return session.serializer.serialize({
|
|
204
|
-
scrollback:
|
|
204
|
+
scrollback: 0,
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
@@ -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
|
|
90
|
-
|
|
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
|
|
107
|
-
await vi.advanceTimersByTimeAsync(
|
|
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
|
|
127
|
-
await vi.advanceTimersByTimeAsync(
|
|
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
|
|
146
|
-
await vi.advanceTimersByTimeAsync(
|
|
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
|
|
169
|
-
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
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
|
|
177
|
-
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
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
|
|
191
|
-
await vi.advanceTimersByTimeAsync(
|
|
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
|
|
211
|
-
await vi.advanceTimersByTimeAsync(
|
|
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
|
|
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
|
-
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
//
|
|
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
|
|
280
|
-
await vi.advanceTimersByTimeAsync(
|
|
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');
|
|
@@ -774,7 +774,7 @@ describe('SessionManager', () => {
|
|
|
774
774
|
const restoreHandler = vi.fn();
|
|
775
775
|
sessionManager.on('sessionRestore', restoreHandler);
|
|
776
776
|
sessionManager.setSessionActive(session.id, true);
|
|
777
|
-
expect(serializeMock).toHaveBeenCalledWith({ scrollback:
|
|
777
|
+
expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
|
|
778
778
|
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m');
|
|
779
779
|
});
|
|
780
780
|
it('should skip restore event when serialized output is empty', async () => {
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.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",
|