ccmanager 4.1.1 → 4.1.3
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.autoApproval.test.js +10 -10
- package/dist/services/sessionManager.js +16 -53
- package/dist/services/sessionManager.statePersistence.test.js +10 -191
- package/dist/services/stateDetector/claude.d.ts +12 -0
- package/dist/services/stateDetector/claude.js +41 -11
- package/dist/services/stateDetector/claude.test.js +123 -42
- package/dist/types/index.d.ts +1 -1
- package/dist/utils/mutex.d.ts +0 -3
- package/dist/utils/mutex.js +0 -3
- package/package.json +6 -6
- package/dist/constants/statePersistence.d.ts +0 -3
- package/dist/constants/statePersistence.js +0 -9
|
@@ -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
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import { spawn } from './bunTerminal.js';
|
|
4
|
-
import { STATE_PERSISTENCE_DURATION_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
|
|
5
4
|
import { Effect, Either } from 'effect';
|
|
5
|
+
/**
|
|
6
|
+
* Must match `STATE_CHECK_INTERVAL_MS` in sessionManager.ts.
|
|
7
|
+
* State updates are async; the next interval tick sees the new state and runs auto-approval.
|
|
8
|
+
*/
|
|
9
|
+
const STATE_CHECK_INTERVAL_MS = 100;
|
|
10
|
+
const STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE = 2;
|
|
6
11
|
const detectStateMock = vi.fn();
|
|
7
12
|
// Create a deferred promise pattern for controllable mock
|
|
8
13
|
let verifyResolve = null;
|
|
@@ -139,17 +144,17 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
139
144
|
}));
|
|
140
145
|
// Phase 1: waiting_input (auto-approval suppressed due to prior failure)
|
|
141
146
|
detectStateMock.mockReturnValue('waiting_input');
|
|
142
|
-
await vi.advanceTimersByTimeAsync(
|
|
147
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
143
148
|
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
144
149
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
|
|
145
150
|
// Phase 2: busy - should reset the failure flag
|
|
146
151
|
detectStateMock.mockReturnValue('busy');
|
|
147
|
-
await vi.advanceTimersByTimeAsync(
|
|
152
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
148
153
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
149
154
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
|
|
150
155
|
// Phase 3: waiting_input again - should trigger pending_auto_approval
|
|
151
156
|
detectStateMock.mockReturnValue('waiting_input');
|
|
152
|
-
await vi.advanceTimersByTimeAsync(
|
|
157
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
153
158
|
// State should now be pending_auto_approval (waiting for verification)
|
|
154
159
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
155
160
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -165,8 +170,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
165
170
|
...data,
|
|
166
171
|
state: 'pending_auto_approval',
|
|
167
172
|
autoApprovalAbortController: abortController,
|
|
168
|
-
pendingState: 'pending_auto_approval',
|
|
169
|
-
pendingStateStart: Date.now(),
|
|
170
173
|
}));
|
|
171
174
|
const handler = vi.fn();
|
|
172
175
|
sessionManager.on('sessionStateChanged', handler);
|
|
@@ -181,7 +184,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
181
184
|
expect(stateData.autoApprovalAbortController).toBeUndefined();
|
|
182
185
|
expect(stateData.autoApprovalFailed).toBe(true);
|
|
183
186
|
expect(stateData.state).toBe('waiting_input');
|
|
184
|
-
expect(stateData.pendingState).toBeUndefined();
|
|
185
187
|
expect(handler).toHaveBeenCalledWith(session);
|
|
186
188
|
sessionManager.off('sessionStateChanged', handler);
|
|
187
189
|
});
|
|
@@ -193,7 +195,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
193
195
|
sessionManager.on('sessionStateChanged', handler);
|
|
194
196
|
// Phase 1: waiting_input → pending_auto_approval
|
|
195
197
|
detectStateMock.mockReturnValue('waiting_input');
|
|
196
|
-
await vi.advanceTimersByTimeAsync(
|
|
198
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
197
199
|
// State should be pending_auto_approval (waiting for verification)
|
|
198
200
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
199
201
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -204,8 +206,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
204
206
|
await vi.waitFor(() => {
|
|
205
207
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
206
208
|
});
|
|
207
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
208
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
209
209
|
// Verify Enter key was sent to approve
|
|
210
210
|
expect(mockPty.write).toHaveBeenCalledWith('\r');
|
|
211
211
|
// Verify sessionStateChanged was emitted with session containing state=busy
|
|
@@ -6,7 +6,8 @@ import { spawn as childSpawn } from 'child_process';
|
|
|
6
6
|
import { configReader } from './config/configReader.js';
|
|
7
7
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
8
|
import { createStateDetector } from './stateDetector/index.js';
|
|
9
|
-
|
|
9
|
+
/** Interval in milliseconds for polling terminal state detection. */
|
|
10
|
+
const STATE_CHECK_INTERVAL_MS = 100;
|
|
10
11
|
import { Effect, Either } from 'effect';
|
|
11
12
|
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
12
13
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
@@ -176,9 +177,6 @@ export class SessionManager extends EventEmitter {
|
|
|
176
177
|
await session.stateMutex.update(data => ({
|
|
177
178
|
...data,
|
|
178
179
|
state: newState,
|
|
179
|
-
pendingState: undefined,
|
|
180
|
-
pendingStateStart: undefined,
|
|
181
|
-
stateConfirmedAt: Date.now(),
|
|
182
180
|
...additionalUpdates,
|
|
183
181
|
}));
|
|
184
182
|
if (oldState !== newState) {
|
|
@@ -361,60 +359,27 @@ export class SessionManager extends EventEmitter {
|
|
|
361
359
|
setupBackgroundHandler(session) {
|
|
362
360
|
// Setup data handler
|
|
363
361
|
this.setupDataHandler(session);
|
|
364
|
-
// Set up interval-based state detection
|
|
362
|
+
// Set up interval-based state detection
|
|
365
363
|
session.stateCheckInterval = setInterval(() => {
|
|
366
364
|
const stateData = session.stateMutex.getSnapshot();
|
|
367
365
|
const oldState = stateData.state;
|
|
368
366
|
const detectedState = this.detectTerminalState(session);
|
|
369
|
-
const now = Date.now();
|
|
370
|
-
// If detected state is different from current state
|
|
371
367
|
if (detectedState !== oldState) {
|
|
372
|
-
//
|
|
373
|
-
if (stateData.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
pendingState: detectedState,
|
|
377
|
-
pendingStateStart: now,
|
|
378
|
-
}));
|
|
368
|
+
// Cancel auto-approval verification if state is changing away from pending_auto_approval
|
|
369
|
+
if (stateData.autoApprovalAbortController &&
|
|
370
|
+
detectedState !== 'pending_auto_approval') {
|
|
371
|
+
this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
|
|
379
372
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// Cancel auto-approval verification if state is changing away from pending_auto_approval
|
|
389
|
-
if (stateData.autoApprovalAbortController &&
|
|
390
|
-
detectedState !== 'pending_auto_approval') {
|
|
391
|
-
this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
|
|
392
|
-
}
|
|
393
|
-
// Build additional updates for auto-approval reset
|
|
394
|
-
const additionalUpdates = {};
|
|
395
|
-
// If we previously blocked auto-approval and have moved out of a user prompt,
|
|
396
|
-
// allow future auto-approval attempts.
|
|
397
|
-
if (stateData.autoApprovalFailed &&
|
|
398
|
-
detectedState !== 'waiting_input' &&
|
|
399
|
-
detectedState !== 'pending_auto_approval') {
|
|
400
|
-
additionalUpdates.autoApprovalFailed = false;
|
|
401
|
-
additionalUpdates.autoApprovalReason = undefined;
|
|
402
|
-
}
|
|
403
|
-
// Confirm the state change with hook execution
|
|
404
|
-
void this.updateSessionState(session, detectedState, additionalUpdates);
|
|
405
|
-
}
|
|
373
|
+
const additionalUpdates = {};
|
|
374
|
+
// If we previously blocked auto-approval and have moved out of a user prompt,
|
|
375
|
+
// allow future auto-approval attempts.
|
|
376
|
+
if (stateData.autoApprovalFailed &&
|
|
377
|
+
detectedState !== 'waiting_input' &&
|
|
378
|
+
detectedState !== 'pending_auto_approval') {
|
|
379
|
+
additionalUpdates.autoApprovalFailed = false;
|
|
380
|
+
additionalUpdates.autoApprovalReason = undefined;
|
|
406
381
|
}
|
|
407
|
-
|
|
408
|
-
else {
|
|
409
|
-
// Detected state matches current state, clear any pending state
|
|
410
|
-
// and update stateConfirmedAt so the minimum duration guard
|
|
411
|
-
// tracks "last time current state was seen" rather than "first confirmed"
|
|
412
|
-
void session.stateMutex.update(data => ({
|
|
413
|
-
...data,
|
|
414
|
-
pendingState: undefined,
|
|
415
|
-
pendingStateStart: undefined,
|
|
416
|
-
stateConfirmedAt: now,
|
|
417
|
-
}));
|
|
382
|
+
void this.updateSessionState(session, detectedState, additionalUpdates);
|
|
418
383
|
}
|
|
419
384
|
// Handle auto-approval if state is pending_auto_approval and no verification is in progress.
|
|
420
385
|
// This ensures auto-approval is retried when the state remains pending_auto_approval
|
|
@@ -502,8 +467,6 @@ export class SessionManager extends EventEmitter {
|
|
|
502
467
|
...data,
|
|
503
468
|
autoApprovalFailed: true,
|
|
504
469
|
autoApprovalReason: reason,
|
|
505
|
-
pendingState: undefined,
|
|
506
|
-
pendingStateStart: undefined,
|
|
507
470
|
}));
|
|
508
471
|
}
|
|
509
472
|
}
|
|
@@ -3,7 +3,9 @@ import { Either } from 'effect';
|
|
|
3
3
|
import { SessionManager } from './sessionManager.js';
|
|
4
4
|
import { spawn } from './bunTerminal.js';
|
|
5
5
|
import { EventEmitter } from 'events';
|
|
6
|
-
import {
|
|
6
|
+
import { IDLE_DEBOUNCE_MS } from './stateDetector/claude.js';
|
|
7
|
+
/** Must match `STATE_CHECK_INTERVAL_MS` in sessionManager.ts */
|
|
8
|
+
const STATE_CHECK_INTERVAL_MS = 100;
|
|
7
9
|
vi.mock('./bunTerminal.js', () => ({
|
|
8
10
|
spawn: vi.fn(function () {
|
|
9
11
|
return null;
|
|
@@ -40,7 +42,7 @@ vi.mock('./config/configReader.js', () => ({
|
|
|
40
42
|
setAutoApprovalEnabled: vi.fn(),
|
|
41
43
|
},
|
|
42
44
|
}));
|
|
43
|
-
describe('SessionManager -
|
|
45
|
+
describe('SessionManager - state detection', () => {
|
|
44
46
|
let sessionManager;
|
|
45
47
|
let mockPtyInstances;
|
|
46
48
|
let eventEmitters;
|
|
@@ -49,7 +51,6 @@ describe('SessionManager - State Persistence', () => {
|
|
|
49
51
|
sessionManager = new SessionManager();
|
|
50
52
|
mockPtyInstances = new Map();
|
|
51
53
|
eventEmitters = new Map();
|
|
52
|
-
// Create mock PTY process factory
|
|
53
54
|
spawn.mockImplementation((command, args, options) => {
|
|
54
55
|
const path = options.cwd;
|
|
55
56
|
const eventEmitter = new EventEmitter();
|
|
@@ -78,217 +79,35 @@ describe('SessionManager - State Persistence', () => {
|
|
|
78
79
|
vi.useRealTimers();
|
|
79
80
|
vi.clearAllMocks();
|
|
80
81
|
});
|
|
81
|
-
it('
|
|
82
|
+
it('transitions busy to idle after idle debounce and the next poll', async () => {
|
|
82
83
|
const { Effect } = await import('effect');
|
|
83
84
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
84
85
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
85
|
-
// Initial state should be busy
|
|
86
86
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
87
|
-
// Simulate output that would trigger idle state
|
|
88
87
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
89
|
-
|
|
90
|
-
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
91
|
-
// State should still be busy, but pending state should be set
|
|
92
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
93
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
94
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
95
|
-
});
|
|
96
|
-
it('should change state after both persistence and minimum duration are met', async () => {
|
|
97
|
-
const { Effect } = await import('effect');
|
|
98
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
99
|
-
const eventEmitter = eventEmitters.get('/test/path');
|
|
100
|
-
const stateChangeHandler = vi.fn();
|
|
101
|
-
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
102
|
-
// Initial state should be busy
|
|
103
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
104
|
-
// Simulate output that would trigger idle state
|
|
105
|
-
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
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
109
|
-
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
110
|
-
// Advance time past both persistence and minimum duration
|
|
111
|
-
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
|
|
112
|
-
// State should now be changed
|
|
88
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
113
89
|
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
114
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
115
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
116
|
-
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
117
90
|
});
|
|
118
|
-
it('
|
|
91
|
+
it('transitions busy to waiting_input on the next poll without idle debounce', async () => {
|
|
119
92
|
const { Effect } = await import('effect');
|
|
120
93
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
121
94
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
122
|
-
// Initial state should be busy
|
|
123
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
124
|
-
// Simulate output that would trigger idle state
|
|
125
|
-
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
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
129
|
-
// Simulate output that would trigger waiting_input state
|
|
130
95
|
eventEmitter.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
131
|
-
// Advance time to trigger another check (use async to process mutex updates)
|
|
132
|
-
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
133
|
-
// Pending state should now be waiting_input, not idle
|
|
134
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
135
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
136
|
-
});
|
|
137
|
-
it('should clear pending state if detected state returns to current state', async () => {
|
|
138
|
-
const { Effect } = await import('effect');
|
|
139
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
140
|
-
const eventEmitter = eventEmitters.get('/test/path');
|
|
141
|
-
// Initial state should be busy
|
|
142
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
143
|
-
// Simulate output that would trigger idle state
|
|
144
|
-
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
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
148
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
149
|
-
// Simulate output that would trigger busy state again (back to original)
|
|
150
|
-
eventEmitter.emit('data', 'ESC to interrupt');
|
|
151
|
-
// Advance time to trigger another check (use async to process mutex updates)
|
|
152
|
-
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
153
|
-
// Pending state should be cleared
|
|
154
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
155
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
156
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
157
|
-
});
|
|
158
|
-
it('should not confirm state changes that do not persist long enough', async () => {
|
|
159
|
-
const { Effect } = await import('effect');
|
|
160
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
161
|
-
const eventEmitter = eventEmitters.get('/test/path');
|
|
162
|
-
const stateChangeHandler = vi.fn();
|
|
163
|
-
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
164
|
-
// Initial state should be busy
|
|
165
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
166
|
-
// Try to change to idle
|
|
167
|
-
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
|
-
// Should have pending state but not confirmed
|
|
171
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
172
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
173
|
-
// Now change to a different state before idle persists
|
|
174
|
-
// Clear terminal first and add waiting prompt
|
|
175
|
-
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
|
-
// Pending state should have changed to waiting_input
|
|
179
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
180
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
181
|
-
// Since states kept changing before persisting, no state change should have been confirmed
|
|
182
|
-
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
183
|
-
});
|
|
184
|
-
it('should properly clean up pending state when session is destroyed', async () => {
|
|
185
|
-
const { Effect } = await import('effect');
|
|
186
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
187
|
-
const eventEmitter = eventEmitters.get('/test/path');
|
|
188
|
-
// Simulate output that would trigger idle state
|
|
189
|
-
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
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
193
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
194
|
-
// Destroy the session
|
|
195
|
-
sessionManager.destroySession(session.id);
|
|
196
|
-
// Check that pending state is cleared
|
|
197
|
-
const remainingSessions = sessionManager.getSessionsForWorktree('/test/path');
|
|
198
|
-
expect(remainingSessions).toHaveLength(0);
|
|
199
|
-
});
|
|
200
|
-
it('should not transition state before minimum duration in current state has elapsed', async () => {
|
|
201
|
-
const { Effect } = await import('effect');
|
|
202
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
203
|
-
const eventEmitter = eventEmitters.get('/test/path');
|
|
204
|
-
const stateChangeHandler = vi.fn();
|
|
205
|
-
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
206
|
-
// Initial state should be busy
|
|
207
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
208
|
-
// Simulate output that would trigger idle state
|
|
209
|
-
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
|
-
// State should still be busy because minimum duration hasn't elapsed
|
|
213
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
214
|
-
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
215
|
-
});
|
|
216
|
-
it('should transition state after both persistence and minimum duration are met', async () => {
|
|
217
|
-
const { Effect } = await import('effect');
|
|
218
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
219
|
-
const eventEmitter = eventEmitters.get('/test/path');
|
|
220
|
-
const stateChangeHandler = vi.fn();
|
|
221
|
-
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
222
|
-
// Initial state should be busy
|
|
223
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
224
|
-
// Simulate output that would trigger idle state
|
|
225
|
-
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);
|
|
228
|
-
// State should now be idle since both durations are satisfied
|
|
229
|
-
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
230
|
-
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
231
|
-
});
|
|
232
|
-
it('should not transition during brief screen redraw even after long time in current state', async () => {
|
|
233
|
-
const { Effect } = await import('effect');
|
|
234
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
235
|
-
const eventEmitter = eventEmitters.get('/test/path');
|
|
236
|
-
const stateChangeHandler = vi.fn();
|
|
237
|
-
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
238
|
-
// Initial state should be busy
|
|
239
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
240
|
-
// Keep busy state active for a long time (simulating normal operation)
|
|
241
|
-
// Each check re-detects "busy" and updates stateConfirmedAt
|
|
242
|
-
eventEmitter.emit('data', 'ESC to interrupt');
|
|
243
|
-
await vi.advanceTimersByTimeAsync(2000); // 2 seconds of confirmed busy
|
|
244
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
245
|
-
// Now simulate a brief screen redraw: busy indicators disappear temporarily
|
|
246
|
-
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
|
|
255
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
256
|
-
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
257
|
-
// Simulate busy indicators coming back (screen redraw complete)
|
|
258
|
-
eventEmitter.emit('data', 'ESC to interrupt');
|
|
259
96
|
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
260
|
-
|
|
261
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
262
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
263
|
-
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
97
|
+
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
264
98
|
});
|
|
265
|
-
it('
|
|
99
|
+
it('handles multiple sessions independently', async () => {
|
|
266
100
|
const { Effect } = await import('effect');
|
|
267
101
|
const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
|
|
268
102
|
const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path2'));
|
|
269
103
|
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
270
104
|
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
271
|
-
// Both should start as busy
|
|
272
105
|
expect(session1.stateMutex.getSnapshot().state).toBe('busy');
|
|
273
106
|
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
274
|
-
// Simulate different outputs for each session
|
|
275
|
-
// Session 1 goes to idle
|
|
276
107
|
eventEmitter1.emit('data', 'Idle output for session 1');
|
|
277
|
-
// Session 2 goes to waiting_input
|
|
278
108
|
eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
279
|
-
|
|
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);
|
|
288
|
-
// Both should now be in their new states
|
|
109
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
289
110
|
expect(session1.stateMutex.getSnapshot().state).toBe('idle');
|
|
290
|
-
expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
291
111
|
expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
292
|
-
expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
293
112
|
});
|
|
294
113
|
});
|
|
@@ -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:
|
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
import { BaseStateDetector } from './base.js';
|
|
2
|
-
// Spinner characters
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
// Spinner / activity-prefix characters (line must still match SPINNER_ACTIVITY_PATTERN: …ing + …)
|
|
3
|
+
// Includes: ornament spinners; · / • / ∙ / ⋅ bullets; ⏺ (record); ▸▹ triangles; ○● circles
|
|
4
|
+
const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆·•⏺▸▹∙⋅○●';
|
|
5
|
+
// Matches spinner activity labels like "✽ Tempering…", "✳ Simplifying…", or "· Misting…"
|
|
5
6
|
const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
|
|
7
|
+
// Session stats above the prompt, e.g. "(9m 21s · ↓ 13.7k tokens)" — requires parens, a digit, and "tokens"
|
|
8
|
+
const TOKEN_STATS_LINE_PATTERN = /\([^)]*\d[^)]*tokens\s*\)/i;
|
|
6
9
|
const BUSY_LOOKBACK_LINES = 5;
|
|
10
|
+
// Workaround: Claude Code sometimes appears idle in terminal output while
|
|
11
|
+
// still actively processing (busy). To mitigate false idle transitions,
|
|
12
|
+
// require terminal output to remain unchanged for this duration before
|
|
13
|
+
// confirming the idle state.
|
|
14
|
+
export const IDLE_DEBOUNCE_MS = 1500;
|
|
7
15
|
export class ClaudeStateDetector extends BaseStateDetector {
|
|
16
|
+
lastContentHash = '';
|
|
17
|
+
contentStableSince = 0;
|
|
18
|
+
/**
|
|
19
|
+
* Debounce idle transitions: only return 'idle' when the terminal
|
|
20
|
+
* content has been unchanged for IDLE_DEBOUNCE_MS.
|
|
21
|
+
* Returns currentState if output is still changing.
|
|
22
|
+
*
|
|
23
|
+
* This is a workaround for Claude Code occasionally showing idle-like
|
|
24
|
+
* terminal output while still busy (e.g. during screen redraws).
|
|
25
|
+
*/
|
|
26
|
+
debounceIdle(terminal, currentState, now = Date.now()) {
|
|
27
|
+
const content = this.getTerminalContent(terminal, 30);
|
|
28
|
+
if (content !== this.lastContentHash) {
|
|
29
|
+
this.lastContentHash = content;
|
|
30
|
+
this.contentStableSince = now;
|
|
31
|
+
}
|
|
32
|
+
const stableDuration = now - this.contentStableSince;
|
|
33
|
+
if (stableDuration >= IDLE_DEBOUNCE_MS) {
|
|
34
|
+
return 'idle';
|
|
35
|
+
}
|
|
36
|
+
return currentState;
|
|
37
|
+
}
|
|
8
38
|
/**
|
|
9
39
|
* Extract content above the prompt box.
|
|
10
40
|
* The prompt box is delimited by ─ border lines:
|
|
@@ -62,10 +92,10 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
62
92
|
return recentBlock.slice(-BUSY_LOOKBACK_LINES).join('\n');
|
|
63
93
|
}
|
|
64
94
|
detectState(terminal, currentState) {
|
|
65
|
-
// Check for search prompt (⌕ Search…) within 200 lines - always idle
|
|
95
|
+
// Check for search prompt (⌕ Search…) within 200 lines - always idle (debounced)
|
|
66
96
|
const extendedContent = this.getTerminalContent(terminal, 200);
|
|
67
97
|
if (extendedContent.includes('⌕ Search…')) {
|
|
68
|
-
return
|
|
98
|
+
return this.debounceIdle(terminal, currentState);
|
|
69
99
|
}
|
|
70
100
|
// Full content (including prompt box) for waiting_input detection
|
|
71
101
|
const fullContent = this.getTerminalContent(terminal, 30);
|
|
@@ -79,10 +109,6 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
79
109
|
if (/(?:do you want|would you like).+\n+[\s\S]*?(?:yes|❯)/.test(fullLowerContent)) {
|
|
80
110
|
return 'waiting_input';
|
|
81
111
|
}
|
|
82
|
-
// Check for selection prompt with ❯ cursor indicator and numbered options
|
|
83
|
-
if (/❯\s+\d+\./.test(fullContent)) {
|
|
84
|
-
return 'waiting_input';
|
|
85
|
-
}
|
|
86
112
|
// Check for "esc to cancel" - indicates waiting for user input
|
|
87
113
|
if (fullLowerContent.includes('esc to cancel')) {
|
|
88
114
|
return 'waiting_input';
|
|
@@ -99,8 +125,12 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
99
125
|
if (SPINNER_ACTIVITY_PATTERN.test(abovePromptBox)) {
|
|
100
126
|
return 'busy';
|
|
101
127
|
}
|
|
102
|
-
//
|
|
103
|
-
|
|
128
|
+
// Usage/time + token count line (often shown above the prompt while a turn is active)
|
|
129
|
+
if (TOKEN_STATS_LINE_PATTERN.test(abovePromptBox)) {
|
|
130
|
+
return 'busy';
|
|
131
|
+
}
|
|
132
|
+
// Otherwise idle (debounced)
|
|
133
|
+
return this.debounceIdle(terminal, currentState);
|
|
104
134
|
}
|
|
105
135
|
detectBackgroundTask(terminal) {
|
|
106
136
|
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
|
});
|
|
@@ -344,6 +330,43 @@ describe('ClaudeStateDetector', () => {
|
|
|
344
330
|
// Assert
|
|
345
331
|
expect(state).toBe('busy');
|
|
346
332
|
});
|
|
333
|
+
it('should detect busy for middle-dot activity label "· …ing…" (e.g. · Misting…)', () => {
|
|
334
|
+
terminal = createMockTerminal([
|
|
335
|
+
'· Misting…',
|
|
336
|
+
' ⎿ Tip: Run /terminal-setup to enable convenient terminal integration',
|
|
337
|
+
'──────────────────────────────',
|
|
338
|
+
'❯',
|
|
339
|
+
'──────────────────────────────',
|
|
340
|
+
]);
|
|
341
|
+
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
342
|
+
});
|
|
343
|
+
it('should detect busy when token stats line is above prompt box without interrupt or spinner', () => {
|
|
344
|
+
terminal = createMockTerminal([
|
|
345
|
+
'(9m 21s · ↓ 13.7k tokens)',
|
|
346
|
+
'──────────────────────────────',
|
|
347
|
+
'❯',
|
|
348
|
+
'──────────────────────────────',
|
|
349
|
+
]);
|
|
350
|
+
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
351
|
+
});
|
|
352
|
+
it('should detect busy for token stats line with varied spacing and casing', () => {
|
|
353
|
+
terminal = createMockTerminal([
|
|
354
|
+
' ( 1m · 500 TOKENS ) ',
|
|
355
|
+
'──────────────────────────────',
|
|
356
|
+
'❯',
|
|
357
|
+
'──────────────────────────────',
|
|
358
|
+
]);
|
|
359
|
+
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
360
|
+
});
|
|
361
|
+
it('should not treat parenthetical text with "tokens" but no digit as busy', () => {
|
|
362
|
+
terminal = createMockTerminal([
|
|
363
|
+
'(see tokens in docs)',
|
|
364
|
+
'──────────────────────────────',
|
|
365
|
+
'❯',
|
|
366
|
+
'──────────────────────────────',
|
|
367
|
+
]);
|
|
368
|
+
expect(detectStateAfterDebounce(detector, terminal, 'idle')).toBe('idle');
|
|
369
|
+
});
|
|
347
370
|
it('should detect busy with various spinner characters', () => {
|
|
348
371
|
const spinnerChars = [
|
|
349
372
|
'✱',
|
|
@@ -387,7 +410,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
387
410
|
// Arrange - no "ing…" at end
|
|
388
411
|
terminal = createMockTerminal(['✽ Some random text', '❯']);
|
|
389
412
|
// Act
|
|
390
|
-
const state = detector
|
|
413
|
+
const state = detectStateAfterDebounce(detector, terminal, 'idle');
|
|
391
414
|
// Assert
|
|
392
415
|
expect(state).toBe('idle');
|
|
393
416
|
});
|
|
@@ -395,7 +418,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
395
418
|
// Arrange
|
|
396
419
|
terminal = createMockTerminal(['⌕ Search…', '✽ Tempering…']);
|
|
397
420
|
// Act
|
|
398
|
-
const state = detector
|
|
421
|
+
const state = detectStateAfterDebounce(detector, terminal, 'busy');
|
|
399
422
|
// Assert - Search prompt takes precedence
|
|
400
423
|
expect(state).toBe('idle');
|
|
401
424
|
});
|
|
@@ -403,7 +426,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
403
426
|
// Arrange - Search prompt should always be idle
|
|
404
427
|
terminal = createMockTerminal(['⌕ Search…', 'Some content']);
|
|
405
428
|
// Act
|
|
406
|
-
const state = detector
|
|
429
|
+
const state = detectStateAfterDebounce(detector, terminal, 'busy');
|
|
407
430
|
// Assert
|
|
408
431
|
expect(state).toBe('idle');
|
|
409
432
|
});
|
|
@@ -411,7 +434,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
411
434
|
// Arrange
|
|
412
435
|
terminal = createMockTerminal(['⌕ Search…', 'esc to cancel']);
|
|
413
436
|
// Act
|
|
414
|
-
const state = detector
|
|
437
|
+
const state = detectStateAfterDebounce(detector, terminal, 'idle');
|
|
415
438
|
// Assert - Should be idle because search prompt takes precedence
|
|
416
439
|
expect(state).toBe('idle');
|
|
417
440
|
});
|
|
@@ -419,7 +442,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
419
442
|
// Arrange
|
|
420
443
|
terminal = createMockTerminal(['⌕ Search…', 'Press esc to interrupt']);
|
|
421
444
|
// Act
|
|
422
|
-
const state = detector
|
|
445
|
+
const state = detectStateAfterDebounce(detector, terminal, 'idle');
|
|
423
446
|
// Assert - Should be idle because search prompt takes precedence
|
|
424
447
|
expect(state).toBe('idle');
|
|
425
448
|
});
|
|
@@ -437,7 +460,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
437
460
|
'❯',
|
|
438
461
|
'──────────────────────────────',
|
|
439
462
|
]);
|
|
440
|
-
const state = detector
|
|
463
|
+
const state = detectStateAfterDebounce(detector, terminal, 'busy');
|
|
441
464
|
expect(state).toBe('idle');
|
|
442
465
|
});
|
|
443
466
|
it('should ignore stale interrupt text outside the latest block above the prompt box', () => {
|
|
@@ -451,7 +474,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
451
474
|
'❯',
|
|
452
475
|
'──────────────────────────────',
|
|
453
476
|
]);
|
|
454
|
-
const state = detector
|
|
477
|
+
const state = detectStateAfterDebounce(detector, terminal, 'busy');
|
|
455
478
|
expect(state).toBe('idle');
|
|
456
479
|
});
|
|
457
480
|
it('should ignore "esc to interrupt" inside prompt box', () => {
|
|
@@ -463,7 +486,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
463
486
|
'──────────────────────────────',
|
|
464
487
|
]);
|
|
465
488
|
// Act
|
|
466
|
-
const state = detector
|
|
489
|
+
const state = detectStateAfterDebounce(detector, terminal, 'idle');
|
|
467
490
|
// Assert - should be idle because "esc to interrupt" is inside prompt box
|
|
468
491
|
expect(state).toBe('idle');
|
|
469
492
|
});
|
|
@@ -503,10 +526,68 @@ describe('ClaudeStateDetector', () => {
|
|
|
503
526
|
'──────────────────────────────',
|
|
504
527
|
]);
|
|
505
528
|
// Act
|
|
506
|
-
const state = detector
|
|
529
|
+
const state = detectStateAfterDebounce(detector, terminal, 'idle');
|
|
507
530
|
// Assert - should be idle because spinner is inside prompt box
|
|
508
531
|
expect(state).toBe('idle');
|
|
509
532
|
});
|
|
533
|
+
describe('idle debounce', () => {
|
|
534
|
+
it('should not return idle immediately when output just appeared', () => {
|
|
535
|
+
terminal = createMockTerminal(['Command completed successfully', '> ']);
|
|
536
|
+
const state = detector.detectState(terminal, 'busy');
|
|
537
|
+
// Should remain busy because debounce hasn't elapsed
|
|
538
|
+
expect(state).toBe('busy');
|
|
539
|
+
});
|
|
540
|
+
it('should return idle after output is stable for IDLE_DEBOUNCE_MS', () => {
|
|
541
|
+
terminal = createMockTerminal(['Command completed successfully', '> ']);
|
|
542
|
+
// First call registers the content
|
|
543
|
+
detector.detectState(terminal, 'busy');
|
|
544
|
+
// Advance time past debounce threshold
|
|
545
|
+
vi.advanceTimersByTime(IDLE_DEBOUNCE_MS);
|
|
546
|
+
// Second call with same content should return idle
|
|
547
|
+
const state = detector.detectState(terminal, 'busy');
|
|
548
|
+
expect(state).toBe('idle');
|
|
549
|
+
});
|
|
550
|
+
it('should reset debounce timer when output changes', () => {
|
|
551
|
+
terminal = createMockTerminal(['Output v1', '> ']);
|
|
552
|
+
detector.detectState(terminal, 'busy');
|
|
553
|
+
// Advance almost to threshold
|
|
554
|
+
vi.advanceTimersByTime(IDLE_DEBOUNCE_MS - 100);
|
|
555
|
+
// Output changes
|
|
556
|
+
terminal = createMockTerminal(['Output v2', '> ']);
|
|
557
|
+
const state1 = detector.detectState(terminal, 'busy');
|
|
558
|
+
expect(state1).toBe('busy');
|
|
559
|
+
// Advance past original threshold but not new one
|
|
560
|
+
vi.advanceTimersByTime(200);
|
|
561
|
+
const state2 = detector.detectState(terminal, 'busy');
|
|
562
|
+
expect(state2).toBe('busy');
|
|
563
|
+
// Advance to meet new threshold
|
|
564
|
+
vi.advanceTimersByTime(IDLE_DEBOUNCE_MS);
|
|
565
|
+
const state3 = detector.detectState(terminal, 'busy');
|
|
566
|
+
expect(state3).toBe('idle');
|
|
567
|
+
});
|
|
568
|
+
it('should not debounce busy transitions', () => {
|
|
569
|
+
terminal = createMockTerminal([
|
|
570
|
+
'Processing...',
|
|
571
|
+
'Press ESC to interrupt',
|
|
572
|
+
'──────────────────────────────',
|
|
573
|
+
'❯',
|
|
574
|
+
'──────────────────────────────',
|
|
575
|
+
]);
|
|
576
|
+
// Busy should be detected immediately without debounce
|
|
577
|
+
const state = detector.detectState(terminal, 'idle');
|
|
578
|
+
expect(state).toBe('busy');
|
|
579
|
+
});
|
|
580
|
+
it('should not debounce waiting_input transitions', () => {
|
|
581
|
+
terminal = createMockTerminal([
|
|
582
|
+
'Do you want to continue?',
|
|
583
|
+
'❯ 1. Yes',
|
|
584
|
+
' 2. No',
|
|
585
|
+
]);
|
|
586
|
+
// waiting_input should be detected immediately
|
|
587
|
+
const state = detector.detectState(terminal, 'idle');
|
|
588
|
+
expect(state).toBe('waiting_input');
|
|
589
|
+
});
|
|
590
|
+
});
|
|
510
591
|
});
|
|
511
592
|
describe('detectBackgroundTask', () => {
|
|
512
593
|
it('should return count 1 when "1 background task" is in status bar', () => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface Session {
|
|
|
38
38
|
/**
|
|
39
39
|
* Mutex-protected session state data.
|
|
40
40
|
* Access via stateMutex.runExclusive() or stateMutex.update() to ensure thread-safe operations.
|
|
41
|
-
* Contains: state,
|
|
41
|
+
* Contains: state, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController, backgroundTaskCount, teamMemberCount
|
|
42
42
|
*/
|
|
43
43
|
stateMutex: Mutex<SessionStateData>;
|
|
44
44
|
/**
|
package/dist/utils/mutex.d.ts
CHANGED
|
@@ -42,9 +42,6 @@ export declare class Mutex<T> {
|
|
|
42
42
|
*/
|
|
43
43
|
export interface SessionStateData {
|
|
44
44
|
state: import('../types/index.js').SessionState;
|
|
45
|
-
pendingState: import('../types/index.js').SessionState | undefined;
|
|
46
|
-
pendingStateStart: number | undefined;
|
|
47
|
-
stateConfirmedAt: number;
|
|
48
45
|
autoApprovalFailed: boolean;
|
|
49
46
|
autoApprovalReason: string | undefined;
|
|
50
47
|
autoApprovalAbortController: AbortController | undefined;
|
package/dist/utils/mutex.js
CHANGED
|
@@ -80,9 +80,6 @@ export class Mutex {
|
|
|
80
80
|
export function createInitialSessionStateData() {
|
|
81
81
|
return {
|
|
82
82
|
state: 'busy',
|
|
83
|
-
pendingState: undefined,
|
|
84
|
-
pendingStateStart: undefined,
|
|
85
|
-
stateConfirmedAt: Date.now(),
|
|
86
83
|
autoApprovalFailed: false,
|
|
87
84
|
autoApprovalReason: undefined,
|
|
88
85
|
autoApprovalAbortController: undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.3",
|
|
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.3",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.3",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.3",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.3",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.3"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Duration in milliseconds that a detected state must persist before being confirmed.
|
|
2
|
-
// A higher value prevents transient flicker (e.g., brief "idle" during terminal re-renders)
|
|
3
|
-
// at the cost of slightly slower state transitions.
|
|
4
|
-
export const STATE_PERSISTENCE_DURATION_MS = 1000;
|
|
5
|
-
// Check interval for state detection in milliseconds
|
|
6
|
-
export const STATE_CHECK_INTERVAL_MS = 100;
|
|
7
|
-
// Minimum duration in current state before allowing transition to a new state.
|
|
8
|
-
// Prevents rapid back-and-forth flicker (e.g., busy → idle → busy).
|
|
9
|
-
export const STATE_MINIMUM_DURATION_MS = 1000;
|