ccmanager 0.0.1 โ 0.0.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/README.md +27 -2
- package/dist/components/StatusBar.d.ts +9 -0
- package/dist/components/StatusBar.js +51 -0
- package/dist/hooks/useSession.d.ts +9 -0
- package/dist/hooks/useSession.js +44 -0
- package/dist/hooks/useWorktree.d.ts +7 -0
- package/dist/hooks/useWorktree.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/services/logger.d.ts +1 -0
- package/dist/services/logger.js +10 -0
- package/dist/services/sessionManager.d.ts +1 -0
- package/dist/services/sessionManager.js +71 -9
- package/dist/services/sessionManager.test.js +62 -2
- package/dist/services/stateDetector.d.ts +9 -0
- package/dist/services/stateDetector.js +82 -0
- package/dist/utils/debug.d.ts +1 -0
- package/dist/utils/debug.js +109 -0
- package/dist/utils/promptDetector.d.ts +2 -0
- package/dist/utils/promptDetector.js +24 -0
- package/dist/utils/promptDetector.test.js +159 -1
- package/dist/utils/waitingPatternDetector.d.ts +1 -0
- package/dist/utils/waitingPatternDetector.js +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,13 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
CCManager is a TUI application for managing multiple Claude Code sessions across Git worktrees.
|
|
4
4
|
|
|
5
|
+
https://github.com/user-attachments/assets/a6d80e73-dc06-4ef8-849d-e3857f6c7024
|
|
6
|
+
|
|
5
7
|
## Features
|
|
6
8
|
|
|
7
9
|
- Run multiple Claude Code sessions in parallel across different Git worktrees
|
|
8
10
|
- Switch between sessions seamlessly
|
|
9
11
|
- Visual status indicators for session states (busy, waiting, idle)
|
|
10
12
|
- Create, merge, and delete worktrees from within the app
|
|
11
|
-
-
|
|
13
|
+
- Configurable keyboard shortcuts
|
|
14
|
+
|
|
15
|
+
## Why CCManager over Claude Squad?
|
|
16
|
+
|
|
17
|
+
Both tools solve the same problem - managing multiple Claude Code sessions - but take different approaches.
|
|
18
|
+
|
|
19
|
+
**If you love tmux-based workflows, stick with Claude Squad!** It's a great tool that leverages tmux's power for session management.
|
|
20
|
+
|
|
21
|
+
CCManager is for developers who want:
|
|
22
|
+
|
|
23
|
+
### ๐ No tmux dependency
|
|
24
|
+
CCManager is completely self-contained. No need to install or configure tmux - it works out of the box. Perfect if you don't use tmux or want to keep your tmux setup separate from Claude Code management.
|
|
25
|
+
|
|
26
|
+
### ๐๏ธ Real-time session monitoring
|
|
27
|
+
CCManager shows the actual state of each Claude Code session directly in the menu:
|
|
28
|
+
- **Waiting**: Claude is asking for user input
|
|
29
|
+
- **Busy**: Claude is processing
|
|
30
|
+
- **Idle**: Ready for new tasks
|
|
31
|
+
|
|
32
|
+
Claude Squad doesn't show session states in its menu, making it hard to know which sessions need attention. While Claude Squad offers an AutoYes feature, this bypasses Claude Code's built-in security confirmations - not recommended for safe operation.
|
|
33
|
+
|
|
34
|
+
### ๐ฏ Simple and intuitive interface
|
|
35
|
+
Following Claude Code's philosophy, CCManager keeps things minimal and intuitive. The interface is so simple you'll understand it in seconds - no manual needed.
|
|
12
36
|
|
|
13
37
|
## Install
|
|
14
38
|
|
|
@@ -28,7 +52,8 @@ $ npx ccmanager
|
|
|
28
52
|
|
|
29
53
|
### Default Shortcuts
|
|
30
54
|
|
|
31
|
-
- **Ctrl+E**: Return to menu from active session
|
|
55
|
+
- **Ctrl+E**: Return to menu from active session
|
|
56
|
+
- **Escape**: Cancel/Go back in dialogs
|
|
32
57
|
|
|
33
58
|
### Customizing Shortcuts
|
|
34
59
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SessionState, ViewType, Worktree } from '../types/index.js';
|
|
3
|
+
interface StatusBarProps {
|
|
4
|
+
view: ViewType;
|
|
5
|
+
sessionState?: SessionState;
|
|
6
|
+
worktree?: Worktree;
|
|
7
|
+
}
|
|
8
|
+
export declare const StatusBar: React.FC<StatusBarProps>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { SessionState, ViewType } from '../types/index.js';
|
|
4
|
+
export const StatusBar = ({ view, sessionState, worktree, }) => {
|
|
5
|
+
const getStateText = () => {
|
|
6
|
+
if (!sessionState)
|
|
7
|
+
return '';
|
|
8
|
+
switch (sessionState) {
|
|
9
|
+
case SessionState.Processing:
|
|
10
|
+
return 'Processing...';
|
|
11
|
+
case SessionState.WaitingForInput:
|
|
12
|
+
return 'Ready';
|
|
13
|
+
case SessionState.NeedsInteraction:
|
|
14
|
+
return 'Needs input';
|
|
15
|
+
case SessionState.Error:
|
|
16
|
+
return 'Error';
|
|
17
|
+
case SessionState.Terminated:
|
|
18
|
+
return 'Terminated';
|
|
19
|
+
default:
|
|
20
|
+
return 'Idle';
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const getStateColor = () => {
|
|
24
|
+
if (!sessionState)
|
|
25
|
+
return 'gray';
|
|
26
|
+
switch (sessionState) {
|
|
27
|
+
case SessionState.Processing:
|
|
28
|
+
return 'yellow';
|
|
29
|
+
case SessionState.WaitingForInput:
|
|
30
|
+
return 'green';
|
|
31
|
+
case SessionState.NeedsInteraction:
|
|
32
|
+
return 'cyan';
|
|
33
|
+
case SessionState.Error:
|
|
34
|
+
return 'red';
|
|
35
|
+
case SessionState.Terminated:
|
|
36
|
+
return 'gray';
|
|
37
|
+
default:
|
|
38
|
+
return 'gray';
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return (React.createElement(Box, { borderStyle: "single", borderTop: true, paddingLeft: 1, paddingRight: 1, justifyContent: "space-between" },
|
|
42
|
+
React.createElement(Box, null, worktree && (React.createElement(Text, null,
|
|
43
|
+
React.createElement(Text, { bold: true }, worktree.branch),
|
|
44
|
+
React.createElement(Text, { dimColor: true },
|
|
45
|
+
" (",
|
|
46
|
+
worktree.path,
|
|
47
|
+
")")))),
|
|
48
|
+
React.createElement(Box, { gap: 2 },
|
|
49
|
+
sessionState && React.createElement(Text, { color: getStateColor() }, getStateText()),
|
|
50
|
+
React.createElement(Text, { dimColor: true }, view === ViewType.Session ? 'Ctrl+E Return to Menu' : 'Q Quit'))));
|
|
51
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Session } from '../types/index.js';
|
|
2
|
+
export declare const useSession: () => {
|
|
3
|
+
sessions: Session[];
|
|
4
|
+
createOrActivateSession: (worktreePath: string) => Session;
|
|
5
|
+
writeToSession: (sessionId: string, data: string) => void;
|
|
6
|
+
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
|
7
|
+
terminateSession: (sessionId: string) => void;
|
|
8
|
+
getSession: (id: string) => Session | undefined;
|
|
9
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { SessionManager } from '../services/sessionManager.js';
|
|
3
|
+
export const useSession = () => {
|
|
4
|
+
const [sessions, setSessions] = useState([]);
|
|
5
|
+
const [sessionManager] = useState(() => new SessionManager());
|
|
6
|
+
const refreshSessions = useCallback(() => {
|
|
7
|
+
setSessions(sessionManager.getAllSessions());
|
|
8
|
+
}, [sessionManager]);
|
|
9
|
+
const createOrActivateSession = useCallback((worktreePath) => {
|
|
10
|
+
let session = sessionManager.findSessionByWorktree(worktreePath);
|
|
11
|
+
if (!session) {
|
|
12
|
+
session = sessionManager.createSession(worktreePath);
|
|
13
|
+
sessionManager.startSession(session.id);
|
|
14
|
+
}
|
|
15
|
+
else if (!session.process) {
|
|
16
|
+
sessionManager.startSession(session.id);
|
|
17
|
+
}
|
|
18
|
+
refreshSessions();
|
|
19
|
+
return session;
|
|
20
|
+
}, [sessionManager, refreshSessions]);
|
|
21
|
+
const writeToSession = useCallback((sessionId, data) => {
|
|
22
|
+
sessionManager.writeToSession(sessionId, data);
|
|
23
|
+
}, [sessionManager]);
|
|
24
|
+
const resizeSession = useCallback((sessionId, cols, rows) => {
|
|
25
|
+
sessionManager.resizeSession(sessionId, cols, rows);
|
|
26
|
+
}, [sessionManager]);
|
|
27
|
+
const terminateSession = useCallback((sessionId) => {
|
|
28
|
+
sessionManager.terminateSession(sessionId);
|
|
29
|
+
refreshSessions();
|
|
30
|
+
}, [sessionManager, refreshSessions]);
|
|
31
|
+
// Update sessions when they change
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const interval = setInterval(refreshSessions, 100);
|
|
34
|
+
return () => clearInterval(interval);
|
|
35
|
+
}, [refreshSessions]);
|
|
36
|
+
return {
|
|
37
|
+
sessions,
|
|
38
|
+
createOrActivateSession,
|
|
39
|
+
writeToSession,
|
|
40
|
+
resizeSession,
|
|
41
|
+
terminateSession,
|
|
42
|
+
getSession: (id) => sessionManager.getSession(id),
|
|
43
|
+
};
|
|
44
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { WorktreeService } from '../services/worktreeService.js';
|
|
3
|
+
export const useWorktree = () => {
|
|
4
|
+
const [worktrees, setWorktrees] = useState([]);
|
|
5
|
+
const [loading, setLoading] = useState(true);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const worktreeService = new WorktreeService();
|
|
8
|
+
const refreshWorktrees = async () => {
|
|
9
|
+
try {
|
|
10
|
+
setLoading(true);
|
|
11
|
+
setError(null);
|
|
12
|
+
const trees = await worktreeService.listWorktrees();
|
|
13
|
+
setWorktrees(trees);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
setError(err instanceof Error ? err.message : 'Failed to load worktrees');
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
setLoading(false);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
refreshWorktrees();
|
|
24
|
+
}, []);
|
|
25
|
+
return {
|
|
26
|
+
worktrees,
|
|
27
|
+
loading,
|
|
28
|
+
error,
|
|
29
|
+
refreshWorktrees,
|
|
30
|
+
};
|
|
31
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function log(...args: any[]): void;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
const logFile = path.join(process.cwd(), 'ccmanager.log');
|
|
4
|
+
// Clear log file on module load
|
|
5
|
+
fs.writeFileSync(logFile, '');
|
|
6
|
+
export function log(...args) {
|
|
7
|
+
const timestamp = new Date().toISOString();
|
|
8
|
+
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
|
|
9
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
10
|
+
}
|
|
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
|
|
|
3
3
|
export declare class SessionManager extends EventEmitter implements ISessionManager {
|
|
4
4
|
sessions: Map<string, Session>;
|
|
5
5
|
private waitingWithBottomBorder;
|
|
6
|
+
private busyTimers;
|
|
6
7
|
private stripAnsi;
|
|
7
8
|
detectSessionState(cleanData: string, currentState: SessionState, sessionId: string): SessionState;
|
|
8
9
|
constructor();
|
|
@@ -20,6 +20,9 @@ export class SessionManager extends EventEmitter {
|
|
|
20
20
|
const hasBottomBorder = includesPromptBoxBottomBorder(cleanData);
|
|
21
21
|
const hasWaitingPrompt = cleanData.includes('โ Do you want');
|
|
22
22
|
const wasWaitingWithBottomBorder = this.waitingWithBottomBorder.get(sessionId) || false;
|
|
23
|
+
const hasEscToInterrupt = cleanData
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.includes('esc to interrupt');
|
|
23
26
|
let newState = currentState;
|
|
24
27
|
// Check if current state is waiting and this is just a prompt box bottom border
|
|
25
28
|
if (hasWaitingPrompt) {
|
|
@@ -31,22 +34,69 @@ export class SessionManager extends EventEmitter {
|
|
|
31
34
|
else {
|
|
32
35
|
this.waitingWithBottomBorder.set(sessionId, false);
|
|
33
36
|
}
|
|
37
|
+
// Clear any pending busy timer
|
|
38
|
+
const existingTimer = this.busyTimers.get(sessionId);
|
|
39
|
+
if (existingTimer) {
|
|
40
|
+
clearTimeout(existingTimer);
|
|
41
|
+
this.busyTimers.delete(sessionId);
|
|
42
|
+
}
|
|
34
43
|
}
|
|
35
44
|
else if (currentState === 'waiting_input' &&
|
|
36
45
|
hasBottomBorder &&
|
|
37
|
-
!hasWaitingPrompt
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
!hasWaitingPrompt) {
|
|
47
|
+
if (wasWaitingWithBottomBorder) {
|
|
48
|
+
// We've already seen the bottom border, transition to idle
|
|
49
|
+
newState = 'idle';
|
|
50
|
+
this.waitingWithBottomBorder.set(sessionId, false);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// First time seeing bottom border, keep waiting state
|
|
54
|
+
newState = 'waiting_input';
|
|
55
|
+
this.waitingWithBottomBorder.set(sessionId, true);
|
|
56
|
+
}
|
|
57
|
+
// Clear any pending busy timer
|
|
58
|
+
const existingTimer = this.busyTimers.get(sessionId);
|
|
59
|
+
if (existingTimer) {
|
|
60
|
+
clearTimeout(existingTimer);
|
|
61
|
+
this.busyTimers.delete(sessionId);
|
|
62
|
+
}
|
|
42
63
|
}
|
|
43
|
-
else if (
|
|
64
|
+
else if (hasEscToInterrupt) {
|
|
65
|
+
// If "esc to interrupt" is present, set state to busy
|
|
44
66
|
newState = 'busy';
|
|
45
67
|
this.waitingWithBottomBorder.set(sessionId, false);
|
|
68
|
+
// Clear any pending timer since we're confirming busy state
|
|
69
|
+
const existingTimer = this.busyTimers.get(sessionId);
|
|
70
|
+
if (existingTimer) {
|
|
71
|
+
clearTimeout(existingTimer);
|
|
72
|
+
this.busyTimers.delete(sessionId);
|
|
73
|
+
}
|
|
46
74
|
}
|
|
47
|
-
else {
|
|
75
|
+
else if (currentState === 'busy' && !hasEscToInterrupt) {
|
|
76
|
+
// If we were busy but no "esc to interrupt" in current data,
|
|
77
|
+
// start a timer to switch to idle after 500ms
|
|
78
|
+
if (!this.busyTimers.has(sessionId)) {
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
// sessionId is actually the worktreePath
|
|
81
|
+
const session = this.sessions.get(sessionId);
|
|
82
|
+
if (session && session.state === 'busy') {
|
|
83
|
+
session.state = 'idle';
|
|
84
|
+
this.emit('sessionStateChanged', session);
|
|
85
|
+
}
|
|
86
|
+
this.busyTimers.delete(sessionId);
|
|
87
|
+
}, 500);
|
|
88
|
+
this.busyTimers.set(sessionId, timer);
|
|
89
|
+
}
|
|
90
|
+
// Keep current busy state for now
|
|
91
|
+
newState = 'busy';
|
|
92
|
+
}
|
|
93
|
+
else if (!hasWaitingPrompt && !hasEscToInterrupt && !hasBottomBorder) {
|
|
94
|
+
// No special prompts or indicators, transition to idle
|
|
48
95
|
newState = 'idle';
|
|
49
|
-
|
|
96
|
+
// Clear the waiting flag when transitioning to idle
|
|
97
|
+
if (currentState === 'waiting_input') {
|
|
98
|
+
this.waitingWithBottomBorder.set(sessionId, false);
|
|
99
|
+
}
|
|
50
100
|
}
|
|
51
101
|
return newState;
|
|
52
102
|
}
|
|
@@ -64,6 +114,12 @@ export class SessionManager extends EventEmitter {
|
|
|
64
114
|
writable: true,
|
|
65
115
|
value: new Map()
|
|
66
116
|
});
|
|
117
|
+
Object.defineProperty(this, "busyTimers", {
|
|
118
|
+
enumerable: true,
|
|
119
|
+
configurable: true,
|
|
120
|
+
writable: true,
|
|
121
|
+
value: new Map()
|
|
122
|
+
});
|
|
67
123
|
this.sessions = new Map();
|
|
68
124
|
}
|
|
69
125
|
createSession(worktreePath) {
|
|
@@ -132,7 +188,7 @@ export class SessionManager extends EventEmitter {
|
|
|
132
188
|
}
|
|
133
189
|
// Detect state based on the new data
|
|
134
190
|
const oldState = session.state;
|
|
135
|
-
const newState = this.detectSessionState(cleanData, oldState, session.
|
|
191
|
+
const newState = this.detectSessionState(cleanData, oldState, session.worktreePath);
|
|
136
192
|
// Update state if changed
|
|
137
193
|
if (newState !== oldState) {
|
|
138
194
|
session.state = newState;
|
|
@@ -173,6 +229,12 @@ export class SessionManager extends EventEmitter {
|
|
|
173
229
|
catch (_error) {
|
|
174
230
|
// Process might already be dead
|
|
175
231
|
}
|
|
232
|
+
// Clean up any pending timer
|
|
233
|
+
const timer = this.busyTimers.get(worktreePath);
|
|
234
|
+
if (timer) {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
this.busyTimers.delete(worktreePath);
|
|
237
|
+
}
|
|
176
238
|
this.sessions.delete(worktreePath);
|
|
177
239
|
this.waitingWithBottomBorder.delete(session.id);
|
|
178
240
|
this.emit('sessionDestroyed', session);
|
|
@@ -47,12 +47,13 @@ describe('SessionManager', () => {
|
|
|
47
47
|
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
48
48
|
expect(newState).toBe('busy');
|
|
49
49
|
});
|
|
50
|
-
it('should
|
|
50
|
+
it('should maintain busy state when transitioning from busy without "esc to interrupt"', () => {
|
|
51
51
|
const cleanData = 'Some regular output text';
|
|
52
52
|
const currentState = 'busy';
|
|
53
53
|
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
54
54
|
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
55
|
-
|
|
55
|
+
// With the new logic, it should remain busy and start a timer
|
|
56
|
+
expect(newState).toBe('busy');
|
|
56
57
|
});
|
|
57
58
|
it('should handle case-insensitive "esc to interrupt" detection', () => {
|
|
58
59
|
const cleanData = 'Running task... PRESS ESC TO INTERRUPT';
|
|
@@ -95,5 +96,64 @@ describe('SessionManager', () => {
|
|
|
95
96
|
const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
|
|
96
97
|
expect(newState).toBe('idle');
|
|
97
98
|
});
|
|
99
|
+
it('should transition from busy to idle after 500ms timer when no "esc to interrupt"', async () => {
|
|
100
|
+
// Create a mock session for the timer test
|
|
101
|
+
const mockWorktreePath = '/test/worktree';
|
|
102
|
+
const mockSession = {
|
|
103
|
+
id: mockSessionId,
|
|
104
|
+
worktreePath: mockWorktreePath,
|
|
105
|
+
state: 'busy',
|
|
106
|
+
process: {},
|
|
107
|
+
output: [],
|
|
108
|
+
outputHistory: [],
|
|
109
|
+
lastActivity: new Date(),
|
|
110
|
+
isActive: false,
|
|
111
|
+
};
|
|
112
|
+
// Add the session to the manager
|
|
113
|
+
sessionManager.sessions.set(mockWorktreePath, mockSession);
|
|
114
|
+
// Mock the EventEmitter emit method
|
|
115
|
+
const emitSpy = vi.spyOn(sessionManager, 'emit');
|
|
116
|
+
// First call with no esc to interrupt should maintain busy state
|
|
117
|
+
const cleanData = 'Some regular output text';
|
|
118
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
119
|
+
const newState = sessionManager.detectSessionState(cleanData, 'busy', mockWorktreePath);
|
|
120
|
+
expect(newState).toBe('busy');
|
|
121
|
+
// Wait for timer to fire (500ms + buffer)
|
|
122
|
+
await new Promise(resolve => setTimeout(resolve, 600));
|
|
123
|
+
// Check that the session state was changed to idle
|
|
124
|
+
expect(mockSession.state).toBe('idle');
|
|
125
|
+
expect(emitSpy).toHaveBeenCalledWith('sessionStateChanged', mockSession);
|
|
126
|
+
});
|
|
127
|
+
it('should cancel timer when "esc to interrupt" appears again', async () => {
|
|
128
|
+
// Create a mock session for the timer test
|
|
129
|
+
const mockWorktreePath = '/test/worktree';
|
|
130
|
+
const mockSession = {
|
|
131
|
+
id: mockSessionId,
|
|
132
|
+
worktreePath: mockWorktreePath,
|
|
133
|
+
state: 'busy',
|
|
134
|
+
process: {},
|
|
135
|
+
output: [],
|
|
136
|
+
outputHistory: [],
|
|
137
|
+
lastActivity: new Date(),
|
|
138
|
+
isActive: false,
|
|
139
|
+
};
|
|
140
|
+
// Add the session to the manager
|
|
141
|
+
sessionManager.sessions.set(mockWorktreePath, mockSession);
|
|
142
|
+
// First call with no esc to interrupt should maintain busy state and start timer
|
|
143
|
+
const cleanData1 = 'Some regular output text';
|
|
144
|
+
vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
|
|
145
|
+
const newState1 = sessionManager.detectSessionState(cleanData1, 'busy', mockWorktreePath);
|
|
146
|
+
expect(newState1).toBe('busy');
|
|
147
|
+
// Wait 200ms (less than timer duration)
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
149
|
+
// Second call with esc to interrupt should cancel timer and keep busy
|
|
150
|
+
const cleanData2 = 'Running... Press ESC to interrupt';
|
|
151
|
+
const newState2 = sessionManager.detectSessionState(cleanData2, 'busy', mockWorktreePath);
|
|
152
|
+
expect(newState2).toBe('busy');
|
|
153
|
+
// Wait another 400ms (total 600ms, more than timer duration)
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
155
|
+
// State should still be busy because timer was cancelled
|
|
156
|
+
expect(mockSession.state).toBe('busy');
|
|
157
|
+
});
|
|
98
158
|
});
|
|
99
159
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SessionState } from '../types/index.js';
|
|
2
|
+
export declare class StateDetector {
|
|
3
|
+
private readonly promptPatterns;
|
|
4
|
+
private readonly errorPatterns;
|
|
5
|
+
private readonly processingPatterns;
|
|
6
|
+
detectState(output: string, currentState: SessionState): SessionState;
|
|
7
|
+
private stripAnsi;
|
|
8
|
+
private matchesAny;
|
|
9
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { SessionState } from '../types/index.js';
|
|
2
|
+
export class StateDetector {
|
|
3
|
+
constructor() {
|
|
4
|
+
Object.defineProperty(this, "promptPatterns", {
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true,
|
|
8
|
+
value: [
|
|
9
|
+
/>\s*$/,
|
|
10
|
+
/\?\s*$/,
|
|
11
|
+
/:\s*$/,
|
|
12
|
+
/Press Enter to continue/i,
|
|
13
|
+
/\(y\/n\)/i,
|
|
14
|
+
/\[y\/N\]/i,
|
|
15
|
+
/Enter your choice/i,
|
|
16
|
+
]
|
|
17
|
+
});
|
|
18
|
+
Object.defineProperty(this, "errorPatterns", {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
writable: true,
|
|
22
|
+
value: [
|
|
23
|
+
/error:/i,
|
|
24
|
+
/failed:/i,
|
|
25
|
+
/exception:/i,
|
|
26
|
+
/traceback/i,
|
|
27
|
+
/command not found/i,
|
|
28
|
+
]
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(this, "processingPatterns", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
value: [
|
|
35
|
+
/processing/i,
|
|
36
|
+
/loading/i,
|
|
37
|
+
/running/i,
|
|
38
|
+
/executing/i,
|
|
39
|
+
/analyzing/i,
|
|
40
|
+
/searching/i,
|
|
41
|
+
/\.\.\./,
|
|
42
|
+
/in progress/i,
|
|
43
|
+
]
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
detectState(output, currentState) {
|
|
47
|
+
// Clean ANSI codes for better pattern matching
|
|
48
|
+
const cleanOutput = this.stripAnsi(output);
|
|
49
|
+
// Check for errors first
|
|
50
|
+
if (this.matchesAny(cleanOutput, this.errorPatterns)) {
|
|
51
|
+
return SessionState.Error;
|
|
52
|
+
}
|
|
53
|
+
// Check if processing
|
|
54
|
+
if (this.matchesAny(cleanOutput, this.processingPatterns)) {
|
|
55
|
+
return SessionState.Processing;
|
|
56
|
+
}
|
|
57
|
+
// Check for prompts that need interaction
|
|
58
|
+
if (this.matchesAny(cleanOutput, this.promptPatterns)) {
|
|
59
|
+
// Check if it's a yes/no prompt or choice prompt
|
|
60
|
+
if (/\(y\/n\)/i.test(cleanOutput) ||
|
|
61
|
+
/\[y\/N\]/i.test(cleanOutput) ||
|
|
62
|
+
/Enter your choice/i.test(cleanOutput)) {
|
|
63
|
+
return SessionState.NeedsInteraction;
|
|
64
|
+
}
|
|
65
|
+
return SessionState.WaitingForInput;
|
|
66
|
+
}
|
|
67
|
+
// If we're getting output but no clear patterns, assume processing
|
|
68
|
+
if (cleanOutput.trim().length > 0 &&
|
|
69
|
+
currentState === SessionState.WaitingForInput) {
|
|
70
|
+
return SessionState.Processing;
|
|
71
|
+
}
|
|
72
|
+
// Default to current state
|
|
73
|
+
return currentState;
|
|
74
|
+
}
|
|
75
|
+
stripAnsi(str) {
|
|
76
|
+
// eslint-disable-next-line no-control-regex
|
|
77
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
78
|
+
}
|
|
79
|
+
matchesAny(text, patterns) {
|
|
80
|
+
return patterns.some(pattern => pattern.test(text));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function debugLog(location: string, message: string, data?: any): void;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
class DebugLogger {
|
|
5
|
+
constructor() {
|
|
6
|
+
Object.defineProperty(this, "logFile", {
|
|
7
|
+
enumerable: true,
|
|
8
|
+
configurable: true,
|
|
9
|
+
writable: true,
|
|
10
|
+
value: void 0
|
|
11
|
+
});
|
|
12
|
+
Object.defineProperty(this, "stream", {
|
|
13
|
+
enumerable: true,
|
|
14
|
+
configurable: true,
|
|
15
|
+
writable: true,
|
|
16
|
+
value: null
|
|
17
|
+
});
|
|
18
|
+
// Create log file in user's home directory
|
|
19
|
+
const logDir = path.join(homedir(), '.ccmanager');
|
|
20
|
+
if (!fs.existsSync(logDir)) {
|
|
21
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
this.logFile = path.join(logDir, 'debug.log');
|
|
24
|
+
this.initStream();
|
|
25
|
+
}
|
|
26
|
+
initStream() {
|
|
27
|
+
this.stream = fs.createWriteStream(this.logFile, { flags: 'a' });
|
|
28
|
+
// Add separator when starting new session
|
|
29
|
+
this.stream.write('\n' + '='.repeat(80) + '\n');
|
|
30
|
+
this.stream.write(`Session started at ${new Date().toISOString()}\n`);
|
|
31
|
+
this.stream.write('='.repeat(80) + '\n\n');
|
|
32
|
+
}
|
|
33
|
+
stripAnsi(str) {
|
|
34
|
+
// Remove ANSI escape sequences
|
|
35
|
+
return str.replace(/\x1B\[[0-9;]*m/g, '') // Color codes
|
|
36
|
+
.replace(/\x1B\[\?[0-9]+[hl]/g, '') // Cursor visibility
|
|
37
|
+
.replace(/\x1B\[[0-9]*[ABCDEFGHJKST]/g, '') // Cursor movement
|
|
38
|
+
.replace(/\x1B\[[0-9]*K/g, '') // Clear line
|
|
39
|
+
.replace(/\r/g, '') // Carriage returns
|
|
40
|
+
.replace(/\x1B\[2004[hl]/g, '') // Bracketed paste mode
|
|
41
|
+
.replace(/\x1B\[1004[hl]/g, ''); // Focus tracking mode
|
|
42
|
+
}
|
|
43
|
+
formatData(data) {
|
|
44
|
+
if (typeof data === 'string') {
|
|
45
|
+
const cleaned = this.stripAnsi(data);
|
|
46
|
+
// Only show non-empty, meaningful content
|
|
47
|
+
if (cleaned.trim()) {
|
|
48
|
+
// Truncate very long outputs
|
|
49
|
+
if (cleaned.length > 200) {
|
|
50
|
+
return cleaned.substring(0, 200) + '... [truncated]';
|
|
51
|
+
}
|
|
52
|
+
return cleaned;
|
|
53
|
+
}
|
|
54
|
+
return '[empty or control sequences only]';
|
|
55
|
+
}
|
|
56
|
+
else if (typeof data === 'object' && data !== null) {
|
|
57
|
+
// Pretty print objects
|
|
58
|
+
return JSON.stringify(data, null, 2);
|
|
59
|
+
}
|
|
60
|
+
return String(data);
|
|
61
|
+
}
|
|
62
|
+
log(location, message, data) {
|
|
63
|
+
if (!this.stream)
|
|
64
|
+
return;
|
|
65
|
+
const timestampParts = new Date().toISOString().split('T');
|
|
66
|
+
const timestamp = timestampParts[1] ? timestampParts[1].replace('Z', '') : new Date().toTimeString().split(' ')[0];
|
|
67
|
+
// Format the log entry in a human-readable way
|
|
68
|
+
this.stream.write(`[${timestamp}] ${location}\n`);
|
|
69
|
+
this.stream.write(` ${message}:\n`);
|
|
70
|
+
if (data !== undefined) {
|
|
71
|
+
const formattedData = this.formatData(data);
|
|
72
|
+
const lines = formattedData.split('\n');
|
|
73
|
+
lines.forEach(line => {
|
|
74
|
+
if (this.stream) {
|
|
75
|
+
this.stream.write(` ${line}\n`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (this.stream) {
|
|
80
|
+
this.stream.write('\n');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
close() {
|
|
84
|
+
if (this.stream) {
|
|
85
|
+
this.stream.write(`\nSession ended at ${new Date().toISOString()}\n`);
|
|
86
|
+
this.stream.write('='.repeat(80) + '\n');
|
|
87
|
+
this.stream.end();
|
|
88
|
+
this.stream = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Singleton instance
|
|
93
|
+
const debugLogger = new DebugLogger();
|
|
94
|
+
// Export a simple function for logging
|
|
95
|
+
export function debugLog(location, message, data) {
|
|
96
|
+
debugLogger.log(location, message, data);
|
|
97
|
+
}
|
|
98
|
+
// Clean up on process exit
|
|
99
|
+
process.on('exit', () => {
|
|
100
|
+
debugLogger.close();
|
|
101
|
+
});
|
|
102
|
+
process.on('SIGINT', () => {
|
|
103
|
+
debugLogger.close();
|
|
104
|
+
process.exit();
|
|
105
|
+
});
|
|
106
|
+
process.on('SIGTERM', () => {
|
|
107
|
+
debugLogger.close();
|
|
108
|
+
process.exit();
|
|
109
|
+
});
|
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
export function includesPromptBoxLine(output) {
|
|
2
|
+
// Check if the output includes a prompt box line pattern (โ > [spaces])
|
|
3
|
+
return output.split('\n').some(line => /โ\s*>\s*/.test(line));
|
|
4
|
+
}
|
|
5
|
+
export function includesPromptBoxTopBorder(output) {
|
|
6
|
+
// Check if the output includes a prompt box top border
|
|
7
|
+
return output
|
|
8
|
+
.trim()
|
|
9
|
+
.split('\n')
|
|
10
|
+
.some(line => {
|
|
11
|
+
// Accept patterns:
|
|
12
|
+
// - `โโโฎ` (ends with โฎ)
|
|
13
|
+
// - `โญโโโโฎ` (starts with โญ and ends with โฎ)
|
|
14
|
+
// Reject if:
|
|
15
|
+
// - vertical line exists after โฎ
|
|
16
|
+
// - line starts with โญ but doesn't end with โฎ
|
|
17
|
+
// Check if line ends with โฎ but not followed by โ
|
|
18
|
+
if (line.endsWith('โฎ') && !line.includes('โฎ โ')) {
|
|
19
|
+
// Accept if it's just โโโฎ or โญโโโโฎ pattern
|
|
20
|
+
return /โ+โฎ$/.test(line) || /^โญโ+โฎ$/.test(line);
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
1
25
|
export function includesPromptBoxBottomBorder(output) {
|
|
2
26
|
// Check if the output includes a prompt box bottom border
|
|
3
27
|
return output
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { includesPromptBoxBottomBorder } from './promptDetector.js';
|
|
2
|
+
import { includesPromptBoxBottomBorder, includesPromptBoxTopBorder, includesPromptBoxLine, } from './promptDetector.js';
|
|
3
3
|
describe('includesPromptBoxBottomBorder', () => {
|
|
4
4
|
it('should return false for empty output', () => {
|
|
5
5
|
expect(includesPromptBoxBottomBorder('')).toBe(false);
|
|
@@ -79,3 +79,161 @@ Some other text`;
|
|
|
79
79
|
expect(includesPromptBoxBottomBorder(partialInvalid)).toBe(false);
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
describe('includesPromptBoxTopBorder', () => {
|
|
83
|
+
it('should return false for empty output', () => {
|
|
84
|
+
expect(includesPromptBoxTopBorder('')).toBe(false);
|
|
85
|
+
expect(includesPromptBoxTopBorder(' ')).toBe(false);
|
|
86
|
+
expect(includesPromptBoxTopBorder('\n\n')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
it('should accept lines ending with โฎ', () => {
|
|
89
|
+
// Basic pattern
|
|
90
|
+
expect(includesPromptBoxTopBorder('โโโฎ')).toBe(true);
|
|
91
|
+
expect(includesPromptBoxTopBorder('โโโโโโโโโฎ')).toBe(true);
|
|
92
|
+
expect(includesPromptBoxTopBorder('โโฎ')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it('should accept complete top border (โญโโโโฎ)', () => {
|
|
95
|
+
expect(includesPromptBoxTopBorder('โญโโโโฎ')).toBe(true);
|
|
96
|
+
expect(includesPromptBoxTopBorder('โญโโโโโโโโโโโโโโฎ')).toBe(true);
|
|
97
|
+
expect(includesPromptBoxTopBorder('โญโโฎ')).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
it('should accept when part of multi-line output', () => {
|
|
100
|
+
const output1 = `Some text
|
|
101
|
+
โโโฎ
|
|
102
|
+
More text`;
|
|
103
|
+
expect(includesPromptBoxTopBorder(output1)).toBe(true);
|
|
104
|
+
const output2 = `First line
|
|
105
|
+
โญโโโโโโโโโโโโโโฎ
|
|
106
|
+
Last line`;
|
|
107
|
+
expect(includesPromptBoxTopBorder(output2)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('should accept with leading/trailing whitespace', () => {
|
|
110
|
+
expect(includesPromptBoxTopBorder(' โโโฎ ')).toBe(true);
|
|
111
|
+
expect(includesPromptBoxTopBorder('\tโญโโโโฎ\t')).toBe(true);
|
|
112
|
+
expect(includesPromptBoxTopBorder('\nโโโฎ\n')).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
it('should reject when โฎ is followed by โ', () => {
|
|
115
|
+
expect(includesPromptBoxTopBorder('โโโฎ โ')).toBe(false);
|
|
116
|
+
expect(includesPromptBoxTopBorder('โญโโโโฎ โ')).toBe(false);
|
|
117
|
+
expect(includesPromptBoxTopBorder('โโโฎ โ some text')).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
it('should reject when line starts with โญ but does not end with โฎ', () => {
|
|
120
|
+
expect(includesPromptBoxTopBorder('โญโโ')).toBe(false);
|
|
121
|
+
expect(includesPromptBoxTopBorder('โญโโโโโโโโโ')).toBe(false);
|
|
122
|
+
expect(includesPromptBoxTopBorder('โญโโโ some text')).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
it('should reject lines that do not match the pattern', () => {
|
|
125
|
+
// Missing โ characters
|
|
126
|
+
expect(includesPromptBoxTopBorder('โฎ')).toBe(false);
|
|
127
|
+
expect(includesPromptBoxTopBorder('โญโฎ')).toBe(false);
|
|
128
|
+
// Wrong characters
|
|
129
|
+
expect(includesPromptBoxTopBorder('===โฎ')).toBe(false);
|
|
130
|
+
expect(includesPromptBoxTopBorder('โญ===โฎ')).toBe(false);
|
|
131
|
+
expect(includesPromptBoxTopBorder('---โฎ')).toBe(false);
|
|
132
|
+
// Bottom border pattern
|
|
133
|
+
expect(includesPromptBoxTopBorder('โฐโโโโฏ')).toBe(false);
|
|
134
|
+
// Middle line pattern
|
|
135
|
+
expect(includesPromptBoxTopBorder('โ > โ')).toBe(false);
|
|
136
|
+
// Random text
|
|
137
|
+
expect(includesPromptBoxTopBorder('Some random text')).toBe(false);
|
|
138
|
+
expect(includesPromptBoxTopBorder('Exit code: 0')).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
it('should handle complex multi-line scenarios correctly', () => {
|
|
141
|
+
const validOutput = `
|
|
142
|
+
Some status text
|
|
143
|
+
โญโโโโโโโโโโโโโโโโโโโโโฎ
|
|
144
|
+
โ > hello โ
|
|
145
|
+
โฐโโโโโโโโโโโโโโโโโโโโโฏ`;
|
|
146
|
+
expect(includesPromptBoxTopBorder(validOutput)).toBe(true);
|
|
147
|
+
const invalidOutput = `
|
|
148
|
+
Some status text
|
|
149
|
+
โโโโโโโโโโโโโโโโโโโโโฎ
|
|
150
|
+
โ > hello โ
|
|
151
|
+
โฐโโโโโโโโโโโโโโโโโโโโโฏ`;
|
|
152
|
+
expect(includesPromptBoxTopBorder(invalidOutput)).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
it('should handle partial border at end of line', () => {
|
|
155
|
+
const partialBorder = `Some output text โโโฎ`;
|
|
156
|
+
expect(includesPromptBoxTopBorder(partialBorder)).toBe(true);
|
|
157
|
+
const partialInvalid = `Some output text โโโฎ โ`;
|
|
158
|
+
expect(includesPromptBoxTopBorder(partialInvalid)).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('includesPromptBoxLine', () => {
|
|
162
|
+
it('should return false for empty output', () => {
|
|
163
|
+
expect(includesPromptBoxLine('')).toBe(false);
|
|
164
|
+
expect(includesPromptBoxLine(' ')).toBe(false);
|
|
165
|
+
expect(includesPromptBoxLine('\n\n')).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
it('should accept lines with prompt box pattern', () => {
|
|
168
|
+
// Basic patterns
|
|
169
|
+
expect(includesPromptBoxLine('โ > ')).toBe(true);
|
|
170
|
+
expect(includesPromptBoxLine('โ > ')).toBe(true);
|
|
171
|
+
expect(includesPromptBoxLine('โ > ')).toBe(true);
|
|
172
|
+
expect(includesPromptBoxLine('โ > ')).toBe(true);
|
|
173
|
+
// With spaces before >
|
|
174
|
+
expect(includesPromptBoxLine('โ > ')).toBe(true);
|
|
175
|
+
expect(includesPromptBoxLine('โ > ')).toBe(true);
|
|
176
|
+
expect(includesPromptBoxLine('โ\t> ')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
it('should accept when part of multi-line output', () => {
|
|
179
|
+
const output1 = `โญโโโโโโโโโโโโโโโโโโโโโฎ
|
|
180
|
+
โ > โ
|
|
181
|
+
โฐโโโโโโโโโโโโโโโโโโโโโฏ`;
|
|
182
|
+
expect(includesPromptBoxLine(output1)).toBe(true);
|
|
183
|
+
const output2 = `Some text before
|
|
184
|
+
โ > hello world โ
|
|
185
|
+
Some text after`;
|
|
186
|
+
expect(includesPromptBoxLine(output2)).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
it('should accept with content after the prompt', () => {
|
|
189
|
+
expect(includesPromptBoxLine('โ > hello')).toBe(true);
|
|
190
|
+
expect(includesPromptBoxLine('โ > hello world โ')).toBe(true);
|
|
191
|
+
expect(includesPromptBoxLine('โ > some command here โ')).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
it('should reject lines without the pattern', () => {
|
|
194
|
+
// Missing space after > (now accepts zero spaces)
|
|
195
|
+
expect(includesPromptBoxLine('โ >')).toBe(true);
|
|
196
|
+
expect(includesPromptBoxLine('โ>')).toBe(true);
|
|
197
|
+
// Missing >
|
|
198
|
+
expect(includesPromptBoxLine('โ ')).toBe(false);
|
|
199
|
+
expect(includesPromptBoxLine('โ hello')).toBe(false);
|
|
200
|
+
// Missing โ
|
|
201
|
+
expect(includesPromptBoxLine(' > ')).toBe(false);
|
|
202
|
+
expect(includesPromptBoxLine('> ')).toBe(false);
|
|
203
|
+
// Wrong characters
|
|
204
|
+
expect(includesPromptBoxLine('| > ')).toBe(false);
|
|
205
|
+
expect(includesPromptBoxLine('โ < ')).toBe(false);
|
|
206
|
+
expect(includesPromptBoxLine('โ ยป ')).toBe(false);
|
|
207
|
+
// Random text
|
|
208
|
+
expect(includesPromptBoxLine('Some random text')).toBe(false);
|
|
209
|
+
expect(includesPromptBoxLine('Exit code: 0')).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
it('should handle complex scenarios', () => {
|
|
212
|
+
const validPromptBox = `
|
|
213
|
+
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
|
|
214
|
+
โ Enter your message below. Press ESC to send | Type /help for help โ
|
|
215
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
|
216
|
+
โ > โ
|
|
217
|
+
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ`;
|
|
218
|
+
expect(includesPromptBoxLine(validPromptBox)).toBe(true);
|
|
219
|
+
const invalidPromptBox = `
|
|
220
|
+
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
|
|
221
|
+
โ Enter your message below. Press ESC to send | Type /help for help โ
|
|
222
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
|
223
|
+
โ โ
|
|
224
|
+
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ`;
|
|
225
|
+
expect(includesPromptBoxLine(invalidPromptBox)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
it('should handle edge cases', () => {
|
|
228
|
+
// Multiple prompt lines
|
|
229
|
+
const multiplePrompts = `โ > first
|
|
230
|
+
โ > second
|
|
231
|
+
โ > third`;
|
|
232
|
+
expect(includesPromptBoxLine(multiplePrompts)).toBe(true);
|
|
233
|
+
// Mixed valid and invalid
|
|
234
|
+
const mixed = `โ no prompt here
|
|
235
|
+
โ > valid prompt
|
|
236
|
+
โ also no prompt`;
|
|
237
|
+
expect(includesPromptBoxLine(mixed)).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isWaitingForInput(output: string): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function isWaitingForInput(output) {
|
|
2
|
+
// Don't trim - we need to check end patterns
|
|
3
|
+
if (!output) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
// Check if output ends with Claude prompt ("> " at the end)
|
|
7
|
+
if (output.trimEnd().endsWith('>')) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
// Check for user interaction
|
|
11
|
+
// โ Do you want to proceed?
|
|
12
|
+
if (output.includes('โ Do you want')) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|