ccmanager 4.0.3 → 4.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.
- package/dist/components/Confirmation.d.ts +2 -3
- package/dist/components/PresetSelector.test.js +4 -0
- package/dist/components/Session.js +7 -18
- package/dist/components/Session.test.d.ts +1 -0
- package/dist/components/Session.test.js +101 -0
- package/dist/services/sessionManager.autoApproval.test.js +15 -0
- package/dist/services/sessionManager.d.ts +3 -3
- package/dist/services/sessionManager.effect.test.js +15 -0
- package/dist/services/sessionManager.js +26 -32
- package/dist/services/sessionManager.test.js +75 -48
- package/dist/services/stateDetector/cursor.js +5 -1
- package/dist/services/stateDetector/cursor.test.js +15 -0
- package/dist/types/index.d.ts +4 -1
- package/dist/utils/hookExecutor.test.js +12 -4
- package/dist/utils/worktreeUtils.test.js +3 -1
- package/package.json +7 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { Key } from 'ink';
|
|
2
3
|
export interface ConfirmationOption {
|
|
3
4
|
label: string;
|
|
4
5
|
value: string;
|
|
@@ -14,9 +15,7 @@ interface ConfirmationProps {
|
|
|
14
15
|
hint?: React.ReactNode;
|
|
15
16
|
onCancel?: () => void;
|
|
16
17
|
onEscape?: () => void;
|
|
17
|
-
onCustomInput?: (input: string, key:
|
|
18
|
-
[key: string]: boolean;
|
|
19
|
-
}) => boolean;
|
|
18
|
+
onCustomInput?: (input: string, key: Key) => boolean;
|
|
20
19
|
}
|
|
21
20
|
/**
|
|
22
21
|
* Reusable confirmation component with SelectInput UI pattern
|
|
@@ -65,23 +65,11 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
65
65
|
stdout.write('\x1b[?7l');
|
|
66
66
|
// Clear screen when entering session
|
|
67
67
|
stdout.write('\x1B[2J\x1B[H');
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
const handleSessionRestore = (restoredSession) => {
|
|
68
|
+
// Restore the current terminal state from the headless xterm snapshot.
|
|
69
|
+
const handleSessionRestore = (restoredSession, restoreSnapshot) => {
|
|
71
70
|
if (restoredSession.id === session.id) {
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
// Concatenate all history buffers and write at once for better performance
|
|
75
|
-
const allHistory = Buffer.concat(restoredSession.outputHistory);
|
|
76
|
-
const historyStr = allHistory.toString('utf8');
|
|
77
|
-
// Normalize the output
|
|
78
|
-
const normalized = normalizeLineEndings(historyStr);
|
|
79
|
-
// Remove leading clear screen sequences to avoid double-clear
|
|
80
|
-
const cleaned = normalized
|
|
81
|
-
.replace(/^\x1B\[2J/g, '')
|
|
82
|
-
.replace(/^\x1B\[H/g, '');
|
|
83
|
-
if (cleaned.length > 0) {
|
|
84
|
-
stdout.write(cleaned);
|
|
71
|
+
if (restoreSnapshot.length > 0) {
|
|
72
|
+
stdout.write(restoreSnapshot);
|
|
85
73
|
}
|
|
86
74
|
}
|
|
87
75
|
};
|
|
@@ -102,8 +90,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
102
90
|
};
|
|
103
91
|
sessionManager.on('sessionData', handleSessionData);
|
|
104
92
|
sessionManager.on('sessionExit', handleSessionExit);
|
|
105
|
-
// Mark session as active (this will trigger the restore event)
|
|
106
|
-
sessionManager.setSessionActive(session.id, true);
|
|
107
93
|
// Immediately resize the PTY and terminal to current dimensions
|
|
108
94
|
// This fixes rendering issues when terminal width changed while in menu
|
|
109
95
|
// https://github.com/kbwo/ccmanager/issues/2
|
|
@@ -120,6 +106,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
120
106
|
catch {
|
|
121
107
|
/* empty */
|
|
122
108
|
}
|
|
109
|
+
// Mark session as active after resizing so the restore snapshot matches
|
|
110
|
+
// the current terminal dimensions.
|
|
111
|
+
sessionManager.setSessionActive(session.id, true);
|
|
123
112
|
// Handle terminal resize
|
|
124
113
|
const handleResize = () => {
|
|
125
114
|
const cols = process.stdout.columns || 80;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
const testState = vi.hoisted(() => ({
|
|
6
|
+
stdout: null,
|
|
7
|
+
}));
|
|
8
|
+
class MockStdout extends EventEmitter {
|
|
9
|
+
write = vi.fn();
|
|
10
|
+
}
|
|
11
|
+
vi.mock('ink', async () => {
|
|
12
|
+
const actual = await vi.importActual('ink');
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
useStdout: vi.fn(() => ({ stdout: testState.stdout })),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
import Session from './Session.js';
|
|
19
|
+
describe('Session', () => {
|
|
20
|
+
const originalColumns = process.stdout.columns;
|
|
21
|
+
const originalRows = process.stdout.rows;
|
|
22
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
testState.stdout = new MockStdout();
|
|
25
|
+
Object.defineProperty(process.stdout, 'columns', {
|
|
26
|
+
value: 120,
|
|
27
|
+
configurable: true,
|
|
28
|
+
});
|
|
29
|
+
Object.defineProperty(process.stdout, 'rows', {
|
|
30
|
+
value: 40,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
34
|
+
value: false,
|
|
35
|
+
configurable: true,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
testState.stdout = null;
|
|
40
|
+
Object.defineProperty(process.stdout, 'columns', {
|
|
41
|
+
value: originalColumns,
|
|
42
|
+
configurable: true,
|
|
43
|
+
});
|
|
44
|
+
Object.defineProperty(process.stdout, 'rows', {
|
|
45
|
+
value: originalRows,
|
|
46
|
+
configurable: true,
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
49
|
+
value: originalIsTTY,
|
|
50
|
+
configurable: true,
|
|
51
|
+
});
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
});
|
|
54
|
+
it('resizes before activating and writes restore snapshots verbatim', async () => {
|
|
55
|
+
const listeners = new Map();
|
|
56
|
+
const processResize = vi.fn();
|
|
57
|
+
const processWrite = vi.fn();
|
|
58
|
+
const terminalResize = vi.fn();
|
|
59
|
+
const setSessionActive = vi.fn((sessionId, active) => {
|
|
60
|
+
if (sessionId === session.id && active) {
|
|
61
|
+
for (const handler of listeners.get('sessionRestore') ?? []) {
|
|
62
|
+
handler(session, '\nrestored');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const session = {
|
|
67
|
+
id: 'session-1',
|
|
68
|
+
process: {
|
|
69
|
+
write: processWrite,
|
|
70
|
+
resize: processResize,
|
|
71
|
+
},
|
|
72
|
+
terminal: {
|
|
73
|
+
resize: terminalResize,
|
|
74
|
+
},
|
|
75
|
+
stateMutex: {
|
|
76
|
+
getSnapshot: () => ({ state: 'idle' }),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const sessionManager = {
|
|
80
|
+
on: vi.fn((event, handler) => {
|
|
81
|
+
const handlers = listeners.get(event) ?? new Set();
|
|
82
|
+
handlers.add(handler);
|
|
83
|
+
listeners.set(event, handlers);
|
|
84
|
+
return sessionManager;
|
|
85
|
+
}),
|
|
86
|
+
off: vi.fn((event, handler) => {
|
|
87
|
+
listeners.get(event)?.delete(handler);
|
|
88
|
+
return sessionManager;
|
|
89
|
+
}),
|
|
90
|
+
setSessionActive,
|
|
91
|
+
cancelAutoApproval: vi.fn(),
|
|
92
|
+
};
|
|
93
|
+
render(_jsx(Session, { session: session, sessionManager: sessionManager, onReturnToMenu: vi.fn() }));
|
|
94
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
95
|
+
expect(processResize).toHaveBeenCalledWith(120, 40);
|
|
96
|
+
expect(terminalResize).toHaveBeenCalledWith(120, 40);
|
|
97
|
+
expect(processResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
|
|
98
|
+
expect(terminalResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
|
|
99
|
+
expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\nrestored');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -52,16 +52,31 @@ vi.mock('./config/configReader.js', () => ({
|
|
|
52
52
|
setAutoApprovalEnabled: vi.fn(),
|
|
53
53
|
},
|
|
54
54
|
}));
|
|
55
|
+
vi.mock('@xterm/addon-serialize', () => ({
|
|
56
|
+
SerializeAddon: vi.fn().mockImplementation(function () {
|
|
57
|
+
return {
|
|
58
|
+
serialize: vi.fn(() => ''),
|
|
59
|
+
activate: vi.fn(),
|
|
60
|
+
dispose: vi.fn(),
|
|
61
|
+
};
|
|
62
|
+
}),
|
|
63
|
+
}));
|
|
55
64
|
vi.mock('@xterm/headless', () => ({
|
|
56
65
|
default: {
|
|
57
66
|
Terminal: vi.fn().mockImplementation(function () {
|
|
58
67
|
return {
|
|
68
|
+
rows: 24,
|
|
69
|
+
cols: 80,
|
|
59
70
|
buffer: {
|
|
60
71
|
active: {
|
|
72
|
+
type: 'normal',
|
|
73
|
+
baseY: 0,
|
|
61
74
|
length: 0,
|
|
62
75
|
getLine: vi.fn(),
|
|
63
76
|
},
|
|
64
77
|
},
|
|
78
|
+
loadAddon: vi.fn(),
|
|
79
|
+
resize: vi.fn(),
|
|
65
80
|
write: vi.fn(),
|
|
66
81
|
};
|
|
67
82
|
}),
|
|
@@ -35,6 +35,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
35
35
|
private updateSessionState;
|
|
36
36
|
constructor();
|
|
37
37
|
private createTerminal;
|
|
38
|
+
private getRestoreSnapshot;
|
|
38
39
|
private createSessionInternal;
|
|
39
40
|
/**
|
|
40
41
|
* Create session with command preset using Effect-based error handling
|
|
@@ -59,9 +60,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
59
60
|
/**
|
|
60
61
|
* Sets up exit handler for the session process.
|
|
61
62
|
* When the process exits with code 1 and it's the primary command,
|
|
62
|
-
* it will attempt
|
|
63
|
-
* If fallbackArgs are configured,
|
|
64
|
-
* If no fallbackArgs are configured, the command will be retried with no arguments.
|
|
63
|
+
* it will attempt a single retry using the configured command with fallback args.
|
|
64
|
+
* If fallbackArgs are not configured, it retries the configured command with no args.
|
|
65
65
|
*/
|
|
66
66
|
private setupExitHandler;
|
|
67
67
|
private setupBackgroundHandler;
|
|
@@ -33,17 +33,32 @@ vi.mock('./config/configReader.js', () => ({
|
|
|
33
33
|
getStatusHooks: vi.fn(() => ({})),
|
|
34
34
|
},
|
|
35
35
|
}));
|
|
36
|
+
vi.mock('@xterm/addon-serialize', () => ({
|
|
37
|
+
SerializeAddon: vi.fn().mockImplementation(function () {
|
|
38
|
+
return {
|
|
39
|
+
serialize: vi.fn(() => ''),
|
|
40
|
+
activate: vi.fn(),
|
|
41
|
+
dispose: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
36
45
|
// Mock Terminal
|
|
37
46
|
vi.mock('@xterm/headless', () => ({
|
|
38
47
|
default: {
|
|
39
48
|
Terminal: vi.fn().mockImplementation(function () {
|
|
40
49
|
return {
|
|
50
|
+
rows: 24,
|
|
51
|
+
cols: 80,
|
|
41
52
|
buffer: {
|
|
42
53
|
active: {
|
|
54
|
+
type: 'normal',
|
|
55
|
+
baseY: 0,
|
|
43
56
|
length: 0,
|
|
44
57
|
getLine: vi.fn(),
|
|
45
58
|
},
|
|
46
59
|
},
|
|
60
|
+
loadAddon: vi.fn(),
|
|
61
|
+
resize: vi.fn(),
|
|
47
62
|
write: vi.fn(),
|
|
48
63
|
};
|
|
49
64
|
}),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from './bunTerminal.js';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
|
+
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
4
5
|
import { spawn as childSpawn } from 'child_process';
|
|
5
6
|
import { configReader } from './config/configReader.js';
|
|
6
7
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
@@ -17,6 +18,7 @@ import { injectTeammateMode } from '../utils/commandArgs.js';
|
|
|
17
18
|
import { preparePresetLaunch } from '../utils/presetPrompt.js';
|
|
18
19
|
const { Terminal } = pkg;
|
|
19
20
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
21
|
+
const TERMINAL_SCROLLBACK_LINES = 5000;
|
|
20
22
|
export class SessionManager extends EventEmitter {
|
|
21
23
|
sessions;
|
|
22
24
|
waitingWithBottomBorder = new Map();
|
|
@@ -192,14 +194,22 @@ export class SessionManager extends EventEmitter {
|
|
|
192
194
|
return new Terminal({
|
|
193
195
|
cols: process.stdout.columns || 80,
|
|
194
196
|
rows: process.stdout.rows || 24,
|
|
197
|
+
scrollback: TERMINAL_SCROLLBACK_LINES,
|
|
195
198
|
allowProposedApi: true,
|
|
196
199
|
logLevel: 'off',
|
|
197
200
|
});
|
|
198
201
|
}
|
|
202
|
+
getRestoreSnapshot(session) {
|
|
203
|
+
return session.serializer.serialize({
|
|
204
|
+
scrollback: TERMINAL_SCROLLBACK_LINES,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
199
207
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
200
208
|
const existingSessions = this.getSessionsForWorktree(worktreePath);
|
|
201
209
|
const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
|
|
202
210
|
const terminal = this.createTerminal();
|
|
211
|
+
const serializer = new SerializeAddon();
|
|
212
|
+
terminal.loadAddon(serializer);
|
|
203
213
|
const detectionStrategy = options.detectionStrategy ?? 'claude';
|
|
204
214
|
const stateDetector = createStateDetector(detectionStrategy);
|
|
205
215
|
const session = {
|
|
@@ -207,13 +217,15 @@ export class SessionManager extends EventEmitter {
|
|
|
207
217
|
worktreePath,
|
|
208
218
|
sessionNumber: maxNumber + 1,
|
|
209
219
|
sessionName: undefined,
|
|
220
|
+
command: options.command ?? 'claude',
|
|
221
|
+
fallbackArgs: options.fallbackArgs,
|
|
210
222
|
lastAccessedAt: Date.now(),
|
|
211
223
|
process: ptyProcess,
|
|
212
224
|
output: [],
|
|
213
|
-
outputHistory: [],
|
|
214
225
|
lastActivity: new Date(),
|
|
215
226
|
isActive: false,
|
|
216
227
|
terminal,
|
|
228
|
+
serializer,
|
|
217
229
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
218
230
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
219
231
|
presetName: options.presetName,
|
|
@@ -257,6 +269,8 @@ export class SessionManager extends EventEmitter {
|
|
|
257
269
|
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
258
270
|
const session = await this.createSessionInternal(worktreePath, ptyProcess, {
|
|
259
271
|
isPrimaryCommand: true,
|
|
272
|
+
command,
|
|
273
|
+
fallbackArgs: preset.fallbackArgs,
|
|
260
274
|
presetName: preset.name,
|
|
261
275
|
detectionStrategy: preset.detectionStrategy,
|
|
262
276
|
});
|
|
@@ -287,24 +301,6 @@ export class SessionManager extends EventEmitter {
|
|
|
287
301
|
session.process.onData((data) => {
|
|
288
302
|
// Write data to virtual terminal
|
|
289
303
|
session.terminal.write(data);
|
|
290
|
-
// Check for screen clear escape sequence (e.g., from /clear command)
|
|
291
|
-
// When detected, clear the output history to prevent replaying old content on restore
|
|
292
|
-
// This helps avoid excessive scrolling when restoring sessions with large output history
|
|
293
|
-
if (data.includes('\x1B[2J')) {
|
|
294
|
-
session.outputHistory = [];
|
|
295
|
-
}
|
|
296
|
-
// Store in output history as Buffer
|
|
297
|
-
const buffer = Buffer.from(data, 'utf8');
|
|
298
|
-
session.outputHistory.push(buffer);
|
|
299
|
-
// Limit memory usage - keep max 10MB of output history
|
|
300
|
-
const MAX_HISTORY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
301
|
-
let totalSize = session.outputHistory.reduce((sum, buf) => sum + buf.length, 0);
|
|
302
|
-
while (totalSize > MAX_HISTORY_SIZE && session.outputHistory.length > 0) {
|
|
303
|
-
const removed = session.outputHistory.shift();
|
|
304
|
-
if (removed) {
|
|
305
|
-
totalSize -= removed.length;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
304
|
session.lastActivity = new Date();
|
|
309
305
|
// Only emit data events when session is active
|
|
310
306
|
if (session.isActive) {
|
|
@@ -315,9 +311,8 @@ export class SessionManager extends EventEmitter {
|
|
|
315
311
|
/**
|
|
316
312
|
* Sets up exit handler for the session process.
|
|
317
313
|
* When the process exits with code 1 and it's the primary command,
|
|
318
|
-
* it will attempt
|
|
319
|
-
* If fallbackArgs are configured,
|
|
320
|
-
* If no fallbackArgs are configured, the command will be retried with no arguments.
|
|
314
|
+
* it will attempt a single retry using the configured command with fallback args.
|
|
315
|
+
* If fallbackArgs are not configured, it retries the configured command with no args.
|
|
321
316
|
*/
|
|
322
317
|
setupExitHandler(session) {
|
|
323
318
|
session.process.onExit(async (e) => {
|
|
@@ -325,26 +320,23 @@ export class SessionManager extends EventEmitter {
|
|
|
325
320
|
if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
|
|
326
321
|
try {
|
|
327
322
|
let fallbackProcess;
|
|
323
|
+
const fallbackArgs = injectTeammateMode(session.command, session.fallbackArgs ?? [], session.detectionStrategy);
|
|
328
324
|
// Check if we're in a devcontainer session
|
|
329
325
|
if (session.devcontainerConfig) {
|
|
330
326
|
// Parse the exec command to extract arguments
|
|
331
327
|
const execParts = session.devcontainerConfig.execCommand.split(/\s+/);
|
|
332
328
|
const devcontainerCmd = execParts[0] || 'devcontainer';
|
|
333
329
|
const execArgs = execParts.slice(1);
|
|
334
|
-
// Build fallback command for devcontainer
|
|
335
|
-
const fallbackClaudeArgs = injectTeammateMode('claude', [], session.detectionStrategy);
|
|
336
330
|
const fallbackFullArgs = [
|
|
337
331
|
...execArgs,
|
|
338
332
|
'--',
|
|
339
|
-
|
|
340
|
-
...
|
|
333
|
+
session.command,
|
|
334
|
+
...fallbackArgs,
|
|
341
335
|
];
|
|
342
336
|
fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath, { rawMode: false });
|
|
343
337
|
}
|
|
344
338
|
else {
|
|
345
|
-
|
|
346
|
-
const fallbackArgs = injectTeammateMode('claude', [], session.detectionStrategy);
|
|
347
|
-
fallbackProcess = await this.spawn('claude', fallbackArgs, session.worktreePath);
|
|
339
|
+
fallbackProcess = await this.spawn(session.command, fallbackArgs, session.worktreePath);
|
|
348
340
|
}
|
|
349
341
|
// Replace the process
|
|
350
342
|
session.process = fallbackProcess;
|
|
@@ -479,9 +471,9 @@ export class SessionManager extends EventEmitter {
|
|
|
479
471
|
session.isActive = active;
|
|
480
472
|
if (active) {
|
|
481
473
|
session.lastAccessedAt = Date.now();
|
|
482
|
-
|
|
483
|
-
if (
|
|
484
|
-
this.emit('sessionRestore', session);
|
|
474
|
+
const restoreSnapshot = this.getRestoreSnapshot(session);
|
|
475
|
+
if (restoreSnapshot.length > 0) {
|
|
476
|
+
this.emit('sessionRestore', session, restoreSnapshot);
|
|
485
477
|
}
|
|
486
478
|
}
|
|
487
479
|
}
|
|
@@ -691,6 +683,8 @@ export class SessionManager extends EventEmitter {
|
|
|
691
683
|
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath, { rawMode: false });
|
|
692
684
|
const session = await this.createSessionInternal(worktreePath, ptyProcess, {
|
|
693
685
|
isPrimaryCommand: true,
|
|
686
|
+
command: preset.command,
|
|
687
|
+
fallbackArgs: preset.fallbackArgs,
|
|
694
688
|
presetName: preset.name,
|
|
695
689
|
detectionStrategy: preset.detectionStrategy,
|
|
696
690
|
devcontainerConfig,
|
|
@@ -37,19 +37,38 @@ vi.mock('./config/configReader.js', () => ({
|
|
|
37
37
|
setAutoApprovalEnabled: vi.fn(),
|
|
38
38
|
},
|
|
39
39
|
}));
|
|
40
|
+
vi.mock('@xterm/addon-serialize', () => ({
|
|
41
|
+
SerializeAddon: vi.fn().mockImplementation(function () {
|
|
42
|
+
return {
|
|
43
|
+
serialize: vi.fn(() => ''),
|
|
44
|
+
activate: vi.fn(),
|
|
45
|
+
dispose: vi.fn(),
|
|
46
|
+
};
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
40
49
|
// Mock Terminal
|
|
41
50
|
vi.mock('@xterm/headless', () => ({
|
|
42
51
|
default: {
|
|
43
52
|
Terminal: vi.fn(function () {
|
|
44
53
|
return {
|
|
54
|
+
rows: 24,
|
|
55
|
+
cols: 80,
|
|
45
56
|
buffer: {
|
|
46
57
|
active: {
|
|
58
|
+
type: 'normal',
|
|
59
|
+
baseY: 0,
|
|
47
60
|
length: 0,
|
|
48
61
|
getLine: vi.fn(function () {
|
|
49
62
|
return null;
|
|
50
63
|
}),
|
|
51
64
|
},
|
|
52
65
|
},
|
|
66
|
+
loadAddon: vi.fn(function () {
|
|
67
|
+
return undefined;
|
|
68
|
+
}),
|
|
69
|
+
resize: vi.fn(function () {
|
|
70
|
+
return undefined;
|
|
71
|
+
}),
|
|
53
72
|
write: vi.fn(function () {
|
|
54
73
|
return undefined;
|
|
55
74
|
}),
|
|
@@ -238,13 +257,14 @@ describe('SessionManager', () => {
|
|
|
238
257
|
// Expect createSessionWithPresetEffect to throw the original error
|
|
239
258
|
await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command not found');
|
|
240
259
|
});
|
|
241
|
-
it('should
|
|
260
|
+
it('should retry the configured command with fallback args when main command exits with code 1', async () => {
|
|
242
261
|
// Setup mock preset with args
|
|
243
262
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
244
263
|
id: '1',
|
|
245
264
|
name: 'Main',
|
|
246
265
|
command: 'claude',
|
|
247
266
|
args: ['--invalid-flag'],
|
|
267
|
+
fallbackArgs: ['--safe-flag'],
|
|
248
268
|
});
|
|
249
269
|
// First spawn attempt - will exit with code 1
|
|
250
270
|
const firstMockPty = new MockPty();
|
|
@@ -262,11 +282,13 @@ describe('SessionManager', () => {
|
|
|
262
282
|
firstMockPty.emit('exit', { exitCode: 1 });
|
|
263
283
|
// Wait for fallback to occur
|
|
264
284
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
265
|
-
// Verify fallback spawn was called
|
|
285
|
+
// Verify fallback spawn was called with the configured fallback args
|
|
266
286
|
expect(spawn).toHaveBeenCalledTimes(2);
|
|
267
|
-
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
287
|
+
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--safe-flag', '--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
268
288
|
// Verify session process was replaced
|
|
269
289
|
expect(session.process).toBe(secondMockPty);
|
|
290
|
+
expect(session.command).toBe('claude');
|
|
291
|
+
expect(session.fallbackArgs).toEqual(['--safe-flag']);
|
|
270
292
|
expect(session.isPrimaryCommand).toBe(false);
|
|
271
293
|
});
|
|
272
294
|
it('should not use fallback if main command succeeds', async () => {
|
|
@@ -318,8 +340,37 @@ describe('SessionManager', () => {
|
|
|
318
340
|
expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
319
341
|
// Verify session process was replaced
|
|
320
342
|
expect(session.process).toBe(secondMockPty);
|
|
343
|
+
expect(session.command).toBe('claude');
|
|
344
|
+
expect(session.fallbackArgs).toBeUndefined();
|
|
321
345
|
expect(session.isPrimaryCommand).toBe(false);
|
|
322
346
|
});
|
|
347
|
+
it('should cleanup and emit exit when fallback command also exits with code 1', async () => {
|
|
348
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
349
|
+
id: '1',
|
|
350
|
+
name: 'Main',
|
|
351
|
+
command: 'opencode',
|
|
352
|
+
args: ['run', '--bad-flag'],
|
|
353
|
+
fallbackArgs: ['run', '--safe-mode'],
|
|
354
|
+
detectionStrategy: 'opencode',
|
|
355
|
+
});
|
|
356
|
+
const firstMockPty = new MockPty();
|
|
357
|
+
const secondMockPty = new MockPty();
|
|
358
|
+
let exitedSession = null;
|
|
359
|
+
vi.mocked(spawn)
|
|
360
|
+
.mockReturnValueOnce(firstMockPty)
|
|
361
|
+
.mockReturnValueOnce(secondMockPty);
|
|
362
|
+
sessionManager.on('sessionExit', (session) => {
|
|
363
|
+
exitedSession = session;
|
|
364
|
+
});
|
|
365
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
366
|
+
firstMockPty.emit('exit', { exitCode: 1 });
|
|
367
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
368
|
+
secondMockPty.emit('exit', { exitCode: 1 });
|
|
369
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
370
|
+
expect(spawn).toHaveBeenNthCalledWith(2, 'opencode', ['run', '--safe-mode'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
371
|
+
expect(exitedSession).toBe(session);
|
|
372
|
+
expect(sessionManager.getSessionsForWorktree('/test/worktree')).toHaveLength(0);
|
|
373
|
+
});
|
|
323
374
|
it('should handle custom command configuration', async () => {
|
|
324
375
|
// Setup mock preset with custom command
|
|
325
376
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -655,13 +706,14 @@ describe('SessionManager', () => {
|
|
|
655
706
|
expect(session.process).toBe(secondMockPty);
|
|
656
707
|
expect(session.isPrimaryCommand).toBe(false);
|
|
657
708
|
});
|
|
658
|
-
it('should
|
|
709
|
+
it('should retry the configured command with fallback args in devcontainer when primary command exits with code 1', async () => {
|
|
659
710
|
// Setup preset with args
|
|
660
711
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
661
712
|
id: '1',
|
|
662
713
|
name: 'Main',
|
|
663
714
|
command: 'claude',
|
|
664
715
|
args: ['--bad-flag'],
|
|
716
|
+
fallbackArgs: ['--safe-flag'],
|
|
665
717
|
});
|
|
666
718
|
// First spawn attempt - will exit with code 1
|
|
667
719
|
const firstMockPty = new MockPty();
|
|
@@ -690,7 +742,7 @@ describe('SessionManager', () => {
|
|
|
690
742
|
firstMockPty.emit('exit', { exitCode: 1 });
|
|
691
743
|
// Wait for fallback to occur
|
|
692
744
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
693
|
-
// Verify fallback spawn was called with
|
|
745
|
+
// Verify fallback spawn was called with the configured fallback args
|
|
694
746
|
expect(spawn).toHaveBeenCalledTimes(2);
|
|
695
747
|
expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', [
|
|
696
748
|
'exec',
|
|
@@ -698,6 +750,7 @@ describe('SessionManager', () => {
|
|
|
698
750
|
'.',
|
|
699
751
|
'--',
|
|
700
752
|
'claude',
|
|
753
|
+
'--safe-flag',
|
|
701
754
|
'--teammate-mode',
|
|
702
755
|
'in-process',
|
|
703
756
|
], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
|
|
@@ -706,63 +759,37 @@ describe('SessionManager', () => {
|
|
|
706
759
|
expect(session.isPrimaryCommand).toBe(false);
|
|
707
760
|
});
|
|
708
761
|
});
|
|
709
|
-
describe('
|
|
710
|
-
it('should
|
|
711
|
-
// Setup
|
|
762
|
+
describe('session restore snapshots', () => {
|
|
763
|
+
it('should emit serialized terminal output when activating a session', async () => {
|
|
712
764
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
713
765
|
id: '1',
|
|
714
766
|
name: 'Main',
|
|
715
767
|
command: 'claude',
|
|
716
768
|
});
|
|
717
769
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
718
|
-
// Create session
|
|
719
770
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
});
|
|
731
|
-
it('should not clear output history for normal data', async () => {
|
|
732
|
-
// Setup
|
|
771
|
+
const serializeMock = vi
|
|
772
|
+
.spyOn(session.serializer, 'serialize')
|
|
773
|
+
.mockReturnValue('\u001b[31mrestored\u001b[0m');
|
|
774
|
+
const restoreHandler = vi.fn();
|
|
775
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
776
|
+
sessionManager.setSessionActive(session.id, true);
|
|
777
|
+
expect(serializeMock).toHaveBeenCalledWith({ scrollback: 5000 });
|
|
778
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m');
|
|
779
|
+
});
|
|
780
|
+
it('should skip restore event when serialized output is empty', async () => {
|
|
733
781
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
734
782
|
id: '1',
|
|
735
783
|
name: 'Main',
|
|
736
784
|
command: 'claude',
|
|
737
785
|
});
|
|
738
786
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
739
|
-
// Create session
|
|
740
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
741
|
-
// Simulate normal data output without screen clear
|
|
742
|
-
mockPty.emit('data', 'Hello World');
|
|
743
|
-
mockPty.emit('data', 'More data');
|
|
744
|
-
mockPty.emit('data', 'Even more data');
|
|
745
|
-
// Verify output history contains all data
|
|
746
|
-
expect(session.outputHistory.length).toBe(3);
|
|
747
|
-
});
|
|
748
|
-
it('should clear history when screen clear is part of larger data chunk', async () => {
|
|
749
|
-
// Setup
|
|
750
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
751
|
-
id: '1',
|
|
752
|
-
name: 'Main',
|
|
753
|
-
command: 'claude',
|
|
754
|
-
});
|
|
755
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
756
|
-
// Create session
|
|
757
787
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
// Verify output history was cleared and only contains the new chunk
|
|
764
|
-
expect(session.outputHistory.length).toBe(1);
|
|
765
|
-
expect(session.outputHistory[0]?.toString()).toBe('prefix\x1B[2Jsuffix');
|
|
788
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('');
|
|
789
|
+
const restoreHandler = vi.fn();
|
|
790
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
791
|
+
sessionManager.setSessionActive(session.id, true);
|
|
792
|
+
expect(restoreHandler).not.toHaveBeenCalled();
|
|
766
793
|
});
|
|
767
794
|
});
|
|
768
795
|
describe('static methods', () => {
|
|
@@ -13,7 +13,11 @@ export class CursorStateDetector extends BaseStateDetector {
|
|
|
13
13
|
/auto .* \(shift\+tab\)/.test(lowerContent) ||
|
|
14
14
|
/allow .+ \(y\)/.test(lowerContent) ||
|
|
15
15
|
/run .+ \(y\)/.test(lowerContent) ||
|
|
16
|
-
lowerContent.includes('skip (esc or n)')
|
|
16
|
+
lowerContent.includes('skip (esc or n)') ||
|
|
17
|
+
lowerContent.includes('write to this file?') ||
|
|
18
|
+
lowerContent.includes('reject & propose changes') ||
|
|
19
|
+
(lowerContent.includes('add write(') &&
|
|
20
|
+
lowerContent.includes('allowlist'))) {
|
|
17
21
|
return 'waiting_input';
|
|
18
22
|
}
|
|
19
23
|
// Check for busy state - Priority 2
|
|
@@ -114,6 +114,21 @@ describe('CursorStateDetector', () => {
|
|
|
114
114
|
// Assert
|
|
115
115
|
expect(state).toBe('waiting_input');
|
|
116
116
|
});
|
|
117
|
+
it('should detect waiting_input state for Write to this file? prompt', () => {
|
|
118
|
+
// Arrange
|
|
119
|
+
terminal = createMockTerminal([
|
|
120
|
+
' │ Write to this file? │',
|
|
121
|
+
' │ in /Users/kbwo/go/projects/github.com/kbwo/ccmanager--feature-takt/src/services/stateDetector/takt.ts │',
|
|
122
|
+
' │ → Proceed (y) │',
|
|
123
|
+
' │ Reject & propose changes (esc or n or p) │',
|
|
124
|
+
' │ Add Write(/Users/.../takt.ts) to allowlist? (tab) │',
|
|
125
|
+
' │ Run Everything (shift+tab) │',
|
|
126
|
+
]);
|
|
127
|
+
// Act
|
|
128
|
+
const state = detector.detectState(terminal, 'idle');
|
|
129
|
+
// Assert
|
|
130
|
+
expect(state).toBe('waiting_input');
|
|
131
|
+
});
|
|
117
132
|
it('should detect busy state for ctrl+c to stop pattern', () => {
|
|
118
133
|
// Arrange
|
|
119
134
|
terminal = createMockTerminal([
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IPty } from '../services/bunTerminal.js';
|
|
2
2
|
import type pkg from '@xterm/headless';
|
|
3
|
+
import type { SerializeAddon } from '@xterm/addon-serialize';
|
|
3
4
|
import { GitStatus } from '../utils/gitStatus.js';
|
|
4
5
|
import { Mutex, SessionStateData } from '../utils/mutex.js';
|
|
5
6
|
import type { StateDetector } from '../services/stateDetector/types.js';
|
|
@@ -20,13 +21,15 @@ export interface Session {
|
|
|
20
21
|
worktreePath: string;
|
|
21
22
|
sessionNumber: number;
|
|
22
23
|
sessionName?: string;
|
|
24
|
+
command: string;
|
|
25
|
+
fallbackArgs?: string[];
|
|
23
26
|
lastAccessedAt: number;
|
|
24
27
|
process: IPty;
|
|
25
28
|
output: string[];
|
|
26
|
-
outputHistory: Buffer[];
|
|
27
29
|
lastActivity: Date;
|
|
28
30
|
isActive: boolean;
|
|
29
31
|
terminal: Terminal;
|
|
32
|
+
serializer: SerializeAddon;
|
|
30
33
|
stateCheckInterval: NodeJS.Timeout | undefined;
|
|
31
34
|
isPrimaryCommand: boolean;
|
|
32
35
|
presetName: string | undefined;
|
|
@@ -377,11 +377,13 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
377
377
|
id: 'test-session-123',
|
|
378
378
|
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
379
379
|
sessionNumber: 1,
|
|
380
|
+
command: 'claude',
|
|
381
|
+
fallbackArgs: undefined,
|
|
380
382
|
lastAccessedAt: Date.now(),
|
|
381
383
|
process: {},
|
|
382
384
|
terminal: {},
|
|
385
|
+
serializer: {},
|
|
383
386
|
output: [],
|
|
384
|
-
outputHistory: [],
|
|
385
387
|
stateCheckInterval: undefined,
|
|
386
388
|
isPrimaryCommand: true,
|
|
387
389
|
presetName: undefined,
|
|
@@ -434,11 +436,13 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
434
436
|
id: 'test-session-456',
|
|
435
437
|
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
436
438
|
sessionNumber: 1,
|
|
439
|
+
command: 'claude',
|
|
440
|
+
fallbackArgs: undefined,
|
|
437
441
|
lastAccessedAt: Date.now(),
|
|
438
442
|
process: {},
|
|
439
443
|
terminal: {},
|
|
444
|
+
serializer: {},
|
|
440
445
|
output: [],
|
|
441
|
-
outputHistory: [],
|
|
442
446
|
stateCheckInterval: undefined,
|
|
443
447
|
isPrimaryCommand: true,
|
|
444
448
|
presetName: undefined,
|
|
@@ -489,11 +493,13 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
489
493
|
id: 'test-session-789',
|
|
490
494
|
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
491
495
|
sessionNumber: 1,
|
|
496
|
+
command: 'claude',
|
|
497
|
+
fallbackArgs: undefined,
|
|
492
498
|
lastAccessedAt: Date.now(),
|
|
493
499
|
process: {},
|
|
494
500
|
terminal: {},
|
|
501
|
+
serializer: {},
|
|
495
502
|
output: [],
|
|
496
|
-
outputHistory: [],
|
|
497
503
|
stateCheckInterval: undefined,
|
|
498
504
|
isPrimaryCommand: true,
|
|
499
505
|
presetName: undefined,
|
|
@@ -546,11 +552,13 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
546
552
|
id: 'test-session-failure',
|
|
547
553
|
worktreePath: tmpDir,
|
|
548
554
|
sessionNumber: 1,
|
|
555
|
+
command: 'claude',
|
|
556
|
+
fallbackArgs: undefined,
|
|
549
557
|
lastAccessedAt: Date.now(),
|
|
550
558
|
process: {},
|
|
551
559
|
terminal: {},
|
|
560
|
+
serializer: {},
|
|
552
561
|
output: [],
|
|
553
|
-
outputHistory: [],
|
|
554
562
|
stateCheckInterval: undefined,
|
|
555
563
|
isPrimaryCommand: true,
|
|
556
564
|
presetName: undefined,
|
|
@@ -124,13 +124,15 @@ describe('prepareSessionItems', () => {
|
|
|
124
124
|
id: 'test-session',
|
|
125
125
|
worktreePath: '/path/to/worktree',
|
|
126
126
|
sessionNumber: 1,
|
|
127
|
+
command: 'claude',
|
|
128
|
+
fallbackArgs: undefined,
|
|
127
129
|
lastAccessedAt: Date.now(),
|
|
128
130
|
process: {},
|
|
129
131
|
output: [],
|
|
130
|
-
outputHistory: [],
|
|
131
132
|
lastActivity: new Date(),
|
|
132
133
|
isActive: true,
|
|
133
134
|
terminal: {},
|
|
135
|
+
serializer: {},
|
|
134
136
|
stateCheckInterval: undefined,
|
|
135
137
|
isPrimaryCommand: true,
|
|
136
138
|
presetName: undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.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",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "4.0
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.0
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.0
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.0
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.0
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.0",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.0",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.0",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.0",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"prettier": "@vdemedes/prettier-config",
|
|
70
70
|
"dependencies": {
|
|
71
|
+
"@xterm/addon-serialize": "^0.14.0",
|
|
71
72
|
"@xterm/headless": "^6.0.0",
|
|
72
73
|
"effect": "^3.18.2",
|
|
73
74
|
"ink": "^6.6.0",
|