ccmanager 0.0.6 → 0.1.0

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.
@@ -55,7 +55,13 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
55
55
  sessionManager.on('sessionExit', handleSessionExit);
56
56
  // Handle terminal resize
57
57
  const handleResize = () => {
58
- session.process.resize(process.stdout.columns || 80, process.stdout.rows || 24);
58
+ const cols = process.stdout.columns || 80;
59
+ const rows = process.stdout.rows || 24;
60
+ session.process.resize(cols, rows);
61
+ // Also resize the virtual terminal
62
+ if (session.terminal) {
63
+ session.terminal.resize(cols, rows);
64
+ }
59
65
  };
60
66
  stdout.on('resize', handleResize);
61
67
  // Set up raw input handling
@@ -1,11 +1,13 @@
1
1
  import { Session, SessionManager as ISessionManager, SessionState } from '../types/index.js';
2
2
  import { EventEmitter } from 'events';
3
+ import pkg from '@xterm/headless';
4
+ declare const Terminal: typeof pkg.Terminal;
3
5
  export declare class SessionManager extends EventEmitter implements ISessionManager {
4
6
  sessions: Map<string, Session>;
5
7
  private waitingWithBottomBorder;
6
8
  private busyTimers;
7
9
  private stripAnsi;
8
- detectSessionState(cleanData: string, currentState: SessionState, sessionId: string): SessionState;
10
+ detectTerminalState(terminal: InstanceType<typeof Terminal>): SessionState;
9
11
  constructor();
10
12
  createSession(worktreePath: string): Session;
11
13
  private setupBackgroundHandler;
@@ -15,3 +17,4 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
15
17
  getAllSessions(): Session[];
16
18
  destroy(): void;
17
19
  }
20
+ export {};
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node-pty';
2
2
  import { EventEmitter } from 'events';
3
- import { includesPromptBoxBottomBorder } from '../utils/promptDetector.js';
3
+ import pkg from '@xterm/headless';
4
+ const { Terminal } = pkg;
4
5
  export class SessionManager extends EventEmitter {
5
6
  stripAnsi(str) {
6
7
  // Remove all ANSI escape sequences including cursor movement, color codes, etc.
@@ -16,76 +17,35 @@ export class SessionManager extends EventEmitter {
16
17
  .replace(/^[0-9;]+m/gm, '') // Orphaned color codes at line start
17
18
  .replace(/[0-9]+;[0-9]+;[0-9;]+m/g, ''); // Orphaned 24-bit color codes
18
19
  }
19
- detectSessionState(cleanData, currentState, sessionId) {
20
- const hasBottomBorder = includesPromptBoxBottomBorder(cleanData);
21
- const hasWaitingPrompt = cleanData.includes('│ Do you want') ||
22
- cleanData.includes('│ Would you like');
23
- const wasWaitingWithBottomBorder = this.waitingWithBottomBorder.get(sessionId) || false;
24
- const hasEscToInterrupt = cleanData
25
- .toLowerCase()
26
- .includes('esc to interrupt');
27
- let newState = currentState;
28
- // Check if current state is waiting and this is just a prompt box bottom border
29
- if (hasWaitingPrompt) {
30
- newState = 'waiting_input';
31
- // Check if this same data also contains the bottom border
32
- if (hasBottomBorder) {
33
- this.waitingWithBottomBorder.set(sessionId, true);
34
- }
35
- else {
36
- this.waitingWithBottomBorder.set(sessionId, false);
37
- }
38
- // Clear any pending busy timer
39
- const existingTimer = this.busyTimers.get(sessionId);
40
- if (existingTimer) {
41
- clearTimeout(existingTimer);
42
- this.busyTimers.delete(sessionId);
43
- }
44
- }
45
- else if (currentState === 'waiting_input' &&
46
- hasBottomBorder &&
47
- !hasWaitingPrompt &&
48
- !wasWaitingWithBottomBorder) {
49
- // Keep the waiting state and mark that we've seen the bottom border
50
- newState = 'waiting_input';
51
- this.waitingWithBottomBorder.set(sessionId, true);
52
- // Clear any pending busy timer
53
- const existingTimer = this.busyTimers.get(sessionId);
54
- if (existingTimer) {
55
- clearTimeout(existingTimer);
56
- this.busyTimers.delete(sessionId);
20
+ detectTerminalState(terminal) {
21
+ // Get the last 30 lines from the terminal buffer
22
+ const buffer = terminal.buffer.active;
23
+ const lines = [];
24
+ // Start from the bottom and work our way up
25
+ for (let i = buffer.length - 1; i >= 0 && lines.length < 30; i--) {
26
+ const line = buffer.getLine(i);
27
+ if (line) {
28
+ const text = line.translateToString(true);
29
+ // Skip empty lines at the bottom
30
+ if (lines.length > 0 || text.trim() !== '') {
31
+ lines.unshift(text);
32
+ }
57
33
  }
58
34
  }
59
- else if (hasEscToInterrupt) {
60
- // If "esc to interrupt" is present, set state to busy
61
- newState = 'busy';
62
- this.waitingWithBottomBorder.set(sessionId, false);
63
- // Clear any pending timer since we're confirming busy state
64
- const existingTimer = this.busyTimers.get(sessionId);
65
- if (existingTimer) {
66
- clearTimeout(existingTimer);
67
- this.busyTimers.delete(sessionId);
68
- }
35
+ // Join lines and check for patterns
36
+ const content = lines.join('\n');
37
+ const lowerContent = content.toLowerCase();
38
+ // Check for waiting prompts with box character
39
+ if (content.includes('│ Do you want') ||
40
+ content.includes('│ Would you like')) {
41
+ return 'waiting_input';
69
42
  }
70
- else if (currentState === 'busy' && !hasEscToInterrupt) {
71
- // If we were busy but no "esc to interrupt" in current data,
72
- // start a timer to switch to idle after 500ms
73
- if (!this.busyTimers.has(sessionId)) {
74
- const timer = setTimeout(() => {
75
- // sessionId is actually the worktreePath
76
- const session = this.sessions.get(sessionId);
77
- if (session && session.state === 'busy') {
78
- session.state = 'idle';
79
- this.emit('sessionStateChanged', session);
80
- }
81
- this.busyTimers.delete(sessionId);
82
- }, 500);
83
- this.busyTimers.set(sessionId, timer);
84
- }
85
- // Keep current busy state for now
86
- newState = 'busy';
43
+ // Check for busy state
44
+ if (lowerContent.includes('esc to interrupt')) {
45
+ return 'busy';
87
46
  }
88
- return newState;
47
+ // Otherwise idle
48
+ return 'idle';
89
49
  }
90
50
  constructor() {
91
51
  super();
@@ -129,6 +89,12 @@ export class SessionManager extends EventEmitter {
129
89
  cwd: worktreePath,
130
90
  env: process.env,
131
91
  });
92
+ // Create virtual terminal for state detection
93
+ const terminal = new Terminal({
94
+ cols: process.stdout.columns || 80,
95
+ rows: process.stdout.rows || 24,
96
+ allowProposedApi: true,
97
+ });
132
98
  const session = {
133
99
  id,
134
100
  worktreePath,
@@ -138,6 +104,7 @@ export class SessionManager extends EventEmitter {
138
104
  outputHistory: [],
139
105
  lastActivity: new Date(),
140
106
  isActive: false,
107
+ terminal,
141
108
  };
142
109
  // Set up persistent background data handler for state detection
143
110
  this.setupBackgroundHandler(session);
@@ -148,6 +115,8 @@ export class SessionManager extends EventEmitter {
148
115
  setupBackgroundHandler(session) {
149
116
  // This handler always runs for all data
150
117
  session.process.onData((data) => {
118
+ // Write data to virtual terminal
119
+ session.terminal.write(data);
151
120
  // Store in output history as Buffer
152
121
  const buffer = Buffer.from(data, 'utf8');
153
122
  session.outputHistory.push(buffer);
@@ -160,37 +129,26 @@ export class SessionManager extends EventEmitter {
160
129
  totalSize -= removed.length;
161
130
  }
162
131
  }
163
- // Also store for state detection
164
- session.output.push(data);
165
- // Keep only last 100 chunks for state detection
166
- if (session.output.length > 100) {
167
- session.output.shift();
168
- }
169
132
  session.lastActivity = new Date();
170
- // Strip ANSI codes for pattern matching
171
- const cleanData = this.stripAnsi(data);
172
- // Skip state monitoring if cleanData is empty
173
- if (!cleanData.trim()) {
174
- // Only emit data events when session is active
175
- if (session.isActive) {
176
- this.emit('sessionData', session, data);
177
- }
178
- return;
133
+ // Only emit data events when session is active
134
+ if (session.isActive) {
135
+ this.emit('sessionData', session, data);
179
136
  }
180
- // Detect state based on the new data
137
+ });
138
+ // Set up interval-based state detection
139
+ session.stateCheckInterval = setInterval(() => {
181
140
  const oldState = session.state;
182
- const newState = this.detectSessionState(cleanData, oldState, session.worktreePath);
183
- // Update state if changed
141
+ const newState = this.detectTerminalState(session.terminal);
184
142
  if (newState !== oldState) {
185
143
  session.state = newState;
186
144
  this.emit('sessionStateChanged', session);
187
145
  }
188
- // Only emit data events when session is active
189
- if (session.isActive) {
190
- this.emit('sessionData', session, data);
191
- }
192
- });
146
+ }, 100); // Check every 100ms
193
147
  session.process.onExit(() => {
148
+ // Clear the state check interval
149
+ if (session.stateCheckInterval) {
150
+ clearInterval(session.stateCheckInterval);
151
+ }
194
152
  // Update state to idle before destroying
195
153
  session.state = 'idle';
196
154
  this.emit('sessionStateChanged', session);
@@ -214,6 +172,10 @@ export class SessionManager extends EventEmitter {
214
172
  destroySession(worktreePath) {
215
173
  const session = this.sessions.get(worktreePath);
216
174
  if (session) {
175
+ // Clear the state check interval
176
+ if (session.stateCheckInterval) {
177
+ clearInterval(session.stateCheckInterval);
178
+ }
217
179
  try {
218
180
  session.process.kill();
219
181
  }
@@ -1,146 +1,252 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { SessionManager } from './sessionManager.js';
3
- // Mock the promptDetector module
4
- vi.mock('../utils/promptDetector.js', () => ({
5
- includesPromptBoxBottomBorder: vi.fn(),
6
- }));
7
- import { includesPromptBoxBottomBorder } from '../utils/promptDetector.js';
8
3
  describe('SessionManager', () => {
9
4
  let sessionManager;
5
+ beforeEach(() => {
6
+ sessionManager = new SessionManager();
7
+ vi.clearAllMocks();
8
+ });
9
+ // TODO: Update tests for new xterm-based state detection
10
+ it('should create session manager', () => {
11
+ expect(sessionManager).toBeDefined();
12
+ expect(sessionManager.sessions).toBeDefined();
13
+ });
14
+ });
15
+ /*
16
+ describe('SessionManager', () => {
17
+ let sessionManager: SessionManager;
10
18
  const mockSessionId = 'test-session-123';
19
+
11
20
  beforeEach(() => {
12
21
  sessionManager = new SessionManager();
13
22
  vi.clearAllMocks();
14
23
  });
15
- describe('detectSessionState', () => {
24
+
25
+ describe.skip('detectSessionState', () => {
16
26
  it('should detect waiting_input state when "Do you want" prompt is present', () => {
17
27
  const cleanData = '│ Do you want to continue?';
18
- const currentState = 'idle';
28
+ const currentState: SessionState = 'idle';
19
29
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
20
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
30
+
31
+ const newState = sessionManager.detectSessionState(
32
+ cleanData,
33
+ currentState,
34
+ mockSessionId,
35
+ );
36
+
21
37
  expect(newState).toBe('waiting_input');
22
38
  });
39
+
23
40
  it('should detect waiting_input state when "Would you like" prompt is present', () => {
24
41
  const cleanData = '│ Would you like to proceed?';
25
- const currentState = 'idle';
42
+ const currentState: SessionState = 'idle';
26
43
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
27
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
44
+
45
+ const newState = sessionManager.detectSessionState(
46
+ cleanData,
47
+ currentState,
48
+ mockSessionId,
49
+ );
50
+
28
51
  expect(newState).toBe('waiting_input');
29
52
  });
53
+
30
54
  it('should set waitingWithBottomBorder when waiting prompt and bottom border are both present', () => {
31
55
  const cleanData = '│ Do you want to continue?\n└───────────────────────┘';
32
- const currentState = 'idle';
56
+ const currentState: SessionState = 'idle';
33
57
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
34
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
58
+
59
+ const newState = sessionManager.detectSessionState(
60
+ cleanData,
61
+ currentState,
62
+ mockSessionId,
63
+ );
64
+
35
65
  expect(newState).toBe('waiting_input');
36
66
  // The internal map should have been set to true
37
67
  });
68
+
38
69
  it('should maintain waiting_input state when bottom border appears after waiting prompt', () => {
39
70
  const cleanData = '└───────────────────────┘';
40
- const currentState = 'waiting_input';
71
+ const currentState: SessionState = 'waiting_input';
41
72
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
73
+
42
74
  // First call to set up the waiting state without bottom border
43
75
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
44
- sessionManager.detectSessionState('│ Do you want to continue?', 'idle', mockSessionId);
76
+ sessionManager.detectSessionState(
77
+ '│ Do you want to continue?',
78
+ 'idle',
79
+ mockSessionId,
80
+ );
81
+
45
82
  // Now test the bottom border appearing
46
83
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
47
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
84
+ const newState = sessionManager.detectSessionState(
85
+ cleanData,
86
+ currentState,
87
+ mockSessionId,
88
+ );
89
+
48
90
  expect(newState).toBe('waiting_input');
49
91
  });
92
+
50
93
  it('should detect busy state when "esc to interrupt" is present', () => {
51
94
  const cleanData = 'Processing... Press ESC to interrupt';
52
- const currentState = 'idle';
95
+ const currentState: SessionState = 'idle';
53
96
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
54
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
97
+
98
+ const newState = sessionManager.detectSessionState(
99
+ cleanData,
100
+ currentState,
101
+ mockSessionId,
102
+ );
103
+
55
104
  expect(newState).toBe('busy');
56
105
  });
106
+
57
107
  it('should maintain busy state when transitioning from busy without "esc to interrupt"', () => {
58
108
  const cleanData = 'Some regular output text';
59
- const currentState = 'busy';
109
+ const currentState: SessionState = 'busy';
60
110
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
61
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
111
+
112
+ const newState = sessionManager.detectSessionState(
113
+ cleanData,
114
+ currentState,
115
+ mockSessionId,
116
+ );
117
+
62
118
  // With the new logic, it should remain busy and start a timer
63
119
  expect(newState).toBe('busy');
64
120
  });
121
+
65
122
  it('should handle case-insensitive "esc to interrupt" detection', () => {
66
123
  const cleanData = 'Running task... PRESS ESC TO INTERRUPT';
67
- const currentState = 'idle';
124
+ const currentState: SessionState = 'idle';
68
125
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
69
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
126
+
127
+ const newState = sessionManager.detectSessionState(
128
+ cleanData,
129
+ currentState,
130
+ mockSessionId,
131
+ );
132
+
70
133
  expect(newState).toBe('busy');
71
134
  });
135
+
72
136
  it('should clear waitingWithBottomBorder flag when transitioning to busy', () => {
73
137
  const cleanData = 'Processing... Press ESC to interrupt';
74
- const currentState = 'waiting_input';
138
+ const currentState: SessionState = 'waiting_input';
75
139
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
140
+
76
141
  // First set up waiting state with bottom border
77
142
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
78
- sessionManager.detectSessionState('│ Do you want to continue?\n└───────────────────────┘', 'idle', mockSessionId);
143
+ sessionManager.detectSessionState(
144
+ '│ Do you want to continue?\n└───────────────────────┘',
145
+ 'idle',
146
+ mockSessionId,
147
+ );
148
+
79
149
  // Now transition to busy
80
150
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
81
- const newState = sessionManager.detectSessionState(cleanData, currentState, mockSessionId);
151
+ const newState = sessionManager.detectSessionState(
152
+ cleanData,
153
+ currentState,
154
+ mockSessionId,
155
+ );
156
+
82
157
  expect(newState).toBe('busy');
83
158
  });
159
+
84
160
  it('should transition from busy to idle after 500ms timer when no "esc to interrupt"', async () => {
85
161
  // Create a mock session for the timer test
86
162
  const mockWorktreePath = '/test/worktree';
87
163
  const mockSession = {
88
164
  id: mockSessionId,
89
165
  worktreePath: mockWorktreePath,
90
- state: 'busy',
166
+ state: 'busy' as SessionState,
91
167
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
- process: {},
168
+ process: {} as any,
93
169
  output: [],
94
170
  outputHistory: [],
95
171
  lastActivity: new Date(),
96
172
  isActive: false,
97
173
  };
174
+
98
175
  // Add the session to the manager
99
176
  sessionManager.sessions.set(mockWorktreePath, mockSession);
177
+
100
178
  // Mock the EventEmitter emit method
101
179
  const emitSpy = vi.spyOn(sessionManager, 'emit');
180
+
102
181
  // First call with no esc to interrupt should maintain busy state
103
182
  const cleanData = 'Some regular output text';
104
183
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
105
- const newState = sessionManager.detectSessionState(cleanData, 'busy', mockWorktreePath);
184
+
185
+ const newState = sessionManager.detectSessionState(
186
+ cleanData,
187
+ 'busy',
188
+ mockWorktreePath,
189
+ );
190
+
106
191
  expect(newState).toBe('busy');
192
+
107
193
  // Wait for timer to fire (500ms + buffer)
108
194
  await new Promise(resolve => setTimeout(resolve, 600));
195
+
109
196
  // Check that the session state was changed to idle
110
197
  expect(mockSession.state).toBe('idle');
111
198
  expect(emitSpy).toHaveBeenCalledWith('sessionStateChanged', mockSession);
112
199
  });
200
+
113
201
  it('should cancel timer when "esc to interrupt" appears again', async () => {
114
202
  // Create a mock session for the timer test
115
203
  const mockWorktreePath = '/test/worktree';
116
204
  const mockSession = {
117
205
  id: mockSessionId,
118
206
  worktreePath: mockWorktreePath,
119
- state: 'busy',
207
+ state: 'busy' as SessionState,
120
208
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
- process: {},
209
+ process: {} as any,
122
210
  output: [],
123
211
  outputHistory: [],
124
212
  lastActivity: new Date(),
125
213
  isActive: false,
126
214
  };
215
+
127
216
  // Add the session to the manager
128
217
  sessionManager.sessions.set(mockWorktreePath, mockSession);
218
+
129
219
  // First call with no esc to interrupt should maintain busy state and start timer
130
220
  const cleanData1 = 'Some regular output text';
131
221
  vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
132
- const newState1 = sessionManager.detectSessionState(cleanData1, 'busy', mockWorktreePath);
222
+
223
+ const newState1 = sessionManager.detectSessionState(
224
+ cleanData1,
225
+ 'busy',
226
+ mockWorktreePath,
227
+ );
228
+
133
229
  expect(newState1).toBe('busy');
230
+
134
231
  // Wait 200ms (less than timer duration)
135
232
  await new Promise(resolve => setTimeout(resolve, 200));
233
+
136
234
  // Second call with esc to interrupt should cancel timer and keep busy
137
235
  const cleanData2 = 'Running... Press ESC to interrupt';
138
- const newState2 = sessionManager.detectSessionState(cleanData2, 'busy', mockWorktreePath);
236
+ const newState2 = sessionManager.detectSessionState(
237
+ cleanData2,
238
+ 'busy',
239
+ mockWorktreePath,
240
+ );
241
+
139
242
  expect(newState2).toBe('busy');
243
+
140
244
  // Wait another 400ms (total 600ms, more than timer duration)
141
245
  await new Promise(resolve => setTimeout(resolve, 400));
246
+
142
247
  // State should still be busy because timer was cancelled
143
248
  expect(mockSession.state).toBe('busy');
144
249
  });
145
250
  });
146
251
  });
252
+ */
@@ -15,6 +15,8 @@ export interface Session {
15
15
  outputHistory: Buffer[];
16
16
  lastActivity: Date;
17
17
  isActive: boolean;
18
+ terminal: any;
19
+ stateCheckInterval?: NodeJS.Timeout;
18
20
  }
19
21
  export interface SessionManager {
20
22
  sessions: Map<string, Session>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "0.0.6",
3
+ "version": "0.1.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -39,6 +39,7 @@
39
39
  "dist"
40
40
  ],
41
41
  "dependencies": {
42
+ "@xterm/headless": "^5.5.0",
42
43
  "ink": "^4.1.0",
43
44
  "ink-select-input": "^5.0.0",
44
45
  "ink-text-input": "^5.0.1",