hjworktree-cli 2.0.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/.context-snapshots/context-snapshot-20260106-110353.md +66 -0
- package/.context-snapshots/context-snapshot-20260106-110441.md +66 -0
- package/.context-snapshots/context-snapshot-20260106-220000.md +99 -0
- package/AGENTS.md +29 -0
- package/CLAUDE.md +88 -0
- package/bin/cli.js +85 -0
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/routes/api.d.ts +3 -0
- package/dist/server/routes/api.d.ts.map +1 -0
- package/dist/server/routes/api.js +101 -0
- package/dist/server/routes/api.js.map +1 -0
- package/dist/server/services/gitService.d.ts +13 -0
- package/dist/server/services/gitService.d.ts.map +1 -0
- package/dist/server/services/gitService.js +84 -0
- package/dist/server/services/gitService.js.map +1 -0
- package/dist/server/services/worktreeService.d.ts +17 -0
- package/dist/server/services/worktreeService.d.ts.map +1 -0
- package/dist/server/services/worktreeService.js +161 -0
- package/dist/server/services/worktreeService.js.map +1 -0
- package/dist/server/socketHandlers.d.ts +4 -0
- package/dist/server/socketHandlers.d.ts.map +1 -0
- package/dist/server/socketHandlers.js +118 -0
- package/dist/server/socketHandlers.js.map +1 -0
- package/dist/shared/constants.d.ts +10 -0
- package/dist/shared/constants.d.ts.map +1 -0
- package/dist/shared/constants.js +31 -0
- package/dist/shared/constants.js.map +1 -0
- package/dist/shared/types/index.d.ts +67 -0
- package/dist/shared/types/index.d.ts.map +1 -0
- package/dist/shared/types/index.js +3 -0
- package/dist/shared/types/index.js.map +1 -0
- package/dist/web/assets/index-C61yAbey.css +32 -0
- package/dist/web/assets/index-WEdVUKxb.js +53 -0
- package/dist/web/assets/index-WEdVUKxb.js.map +1 -0
- package/dist/web/index.html +16 -0
- package/package.json +63 -0
- package/server/index.ts +75 -0
- package/server/routes/api.ts +108 -0
- package/server/services/gitService.ts +91 -0
- package/server/services/worktreeService.ts +181 -0
- package/server/socketHandlers.ts +157 -0
- package/shared/constants.ts +35 -0
- package/shared/types/index.ts +92 -0
- package/tsconfig.json +20 -0
- package/web/index.html +15 -0
- package/web/src/App.tsx +65 -0
- package/web/src/components/Layout/Header.tsx +29 -0
- package/web/src/components/Layout/LeftNavBar.tsx +67 -0
- package/web/src/components/Layout/MainLayout.tsx +23 -0
- package/web/src/components/Layout/StepContainer.tsx +71 -0
- package/web/src/components/Setup/AgentSelector.tsx +27 -0
- package/web/src/components/Setup/BranchSelector.tsx +28 -0
- package/web/src/components/Setup/SetupPanel.tsx +32 -0
- package/web/src/components/Setup/WorktreeCountSelector.tsx +30 -0
- package/web/src/components/Steps/AgentStep.tsx +20 -0
- package/web/src/components/Steps/BranchStep.tsx +20 -0
- package/web/src/components/Steps/WorktreeStep.tsx +41 -0
- package/web/src/components/Terminal/TerminalPanel.tsx +113 -0
- package/web/src/components/Terminal/XTerminal.tsx +203 -0
- package/web/src/hooks/useSocket.ts +80 -0
- package/web/src/main.tsx +10 -0
- package/web/src/stores/useAppStore.ts +348 -0
- package/web/src/styles/global.css +695 -0
- package/web/tsconfig.json +23 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useAppStore } from '../../stores/useAppStore.js';
|
|
3
|
+
import { useSocket } from '../../hooks/useSocket.js';
|
|
4
|
+
import XTerminal from './XTerminal.js';
|
|
5
|
+
|
|
6
|
+
function TerminalPanel() {
|
|
7
|
+
const { terminals, activeTerminalIndex, setActiveTerminal, removeTerminal, clearAllTerminals, reset } = useAppStore();
|
|
8
|
+
const { emit } = useSocket();
|
|
9
|
+
|
|
10
|
+
const handleCloseTerminal = async (sessionId: string, e: React.MouseEvent) => {
|
|
11
|
+
e.stopPropagation();
|
|
12
|
+
|
|
13
|
+
// Kill the terminal via socket
|
|
14
|
+
emit('terminal:kill', { sessionId });
|
|
15
|
+
|
|
16
|
+
// Find the worktree name to delete
|
|
17
|
+
const terminal = terminals.find(t => t.sessionId === sessionId);
|
|
18
|
+
if (terminal) {
|
|
19
|
+
try {
|
|
20
|
+
await fetch(`/api/worktrees/${terminal.worktreeName}`, {
|
|
21
|
+
method: 'DELETE',
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('Failed to delete worktree:', error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
removeTerminal(sessionId);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handleCloseAll = async () => {
|
|
32
|
+
// Kill all terminals via socket
|
|
33
|
+
for (const terminal of terminals) {
|
|
34
|
+
emit('terminal:kill', { sessionId: terminal.sessionId });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Delete all worktrees
|
|
38
|
+
try {
|
|
39
|
+
await fetch('/api/worktrees', {
|
|
40
|
+
method: 'DELETE',
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('Failed to delete worktrees:', error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clearAllTerminals();
|
|
47
|
+
reset();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleBackToSetup = () => {
|
|
51
|
+
if (terminals.length > 0) {
|
|
52
|
+
const confirmed = window.confirm(
|
|
53
|
+
'This will terminate all running agents and delete all worktrees. Are you sure you want to go back to setup?'
|
|
54
|
+
);
|
|
55
|
+
if (confirmed) {
|
|
56
|
+
handleCloseAll();
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
reset();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="terminal-panel">
|
|
65
|
+
<div className="terminal-tabs">
|
|
66
|
+
{terminals.map((terminal, index) => (
|
|
67
|
+
<button
|
|
68
|
+
key={terminal.sessionId}
|
|
69
|
+
className={`terminal-tab ${index === activeTerminalIndex ? 'active' : ''}`}
|
|
70
|
+
onClick={() => setActiveTerminal(index)}
|
|
71
|
+
>
|
|
72
|
+
<span className={`status ${terminal.status}`} />
|
|
73
|
+
<span>{terminal.worktreeName}</span>
|
|
74
|
+
<button
|
|
75
|
+
className="close-btn"
|
|
76
|
+
onClick={(e) => handleCloseTerminal(terminal.sessionId, e)}
|
|
77
|
+
title="Close terminal"
|
|
78
|
+
>
|
|
79
|
+
×
|
|
80
|
+
</button>
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
|
|
84
|
+
<div className="terminal-actions">
|
|
85
|
+
<button onClick={handleBackToSetup}>
|
|
86
|
+
Back to Setup
|
|
87
|
+
</button>
|
|
88
|
+
<button className="danger" onClick={handleCloseAll}>
|
|
89
|
+
Close All
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="terminal-container">
|
|
95
|
+
{terminals.map((terminal, index) => (
|
|
96
|
+
<div
|
|
97
|
+
key={terminal.sessionId}
|
|
98
|
+
className={`terminal-wrapper ${index !== activeTerminalIndex ? 'hidden' : ''}`}
|
|
99
|
+
>
|
|
100
|
+
<XTerminal
|
|
101
|
+
sessionId={terminal.sessionId}
|
|
102
|
+
worktreePath={terminal.worktreePath}
|
|
103
|
+
agentType={terminal.agentType}
|
|
104
|
+
isActive={index === activeTerminalIndex}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default TerminalPanel;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { Terminal } from '@xterm/xterm';
|
|
3
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
4
|
+
import { useSocket } from '../../hooks/useSocket';
|
|
5
|
+
import { useAppStore } from '../../stores/useAppStore';
|
|
6
|
+
import type { AgentId, TerminalOutputData } from '../../../../shared/types/index';
|
|
7
|
+
import '@xterm/xterm/css/xterm.css';
|
|
8
|
+
|
|
9
|
+
interface XTerminalProps {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
worktreePath: string;
|
|
12
|
+
agentType: AgentId;
|
|
13
|
+
isActive: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function XTerminal({ sessionId, worktreePath, agentType, isActive }: XTerminalProps) {
|
|
17
|
+
const terminalRef = useRef<HTMLDivElement>(null);
|
|
18
|
+
const termRef = useRef<Terminal | null>(null);
|
|
19
|
+
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
20
|
+
const initializedRef = useRef(false);
|
|
21
|
+
const { emit, on, off, connected } = useSocket();
|
|
22
|
+
const updateTerminalStatus = useAppStore((state) => state.updateTerminalStatus);
|
|
23
|
+
|
|
24
|
+
// Initialize terminal
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!terminalRef.current || initializedRef.current) return;
|
|
27
|
+
|
|
28
|
+
const term = new Terminal({
|
|
29
|
+
cursorBlink: true,
|
|
30
|
+
fontSize: 14,
|
|
31
|
+
fontFamily: "'JetBrains Mono', Menlo, Monaco, 'Courier New', monospace",
|
|
32
|
+
theme: {
|
|
33
|
+
background: '#0d1117',
|
|
34
|
+
foreground: '#c9d1d9',
|
|
35
|
+
cursor: '#58a6ff',
|
|
36
|
+
cursorAccent: '#0d1117',
|
|
37
|
+
black: '#0d1117',
|
|
38
|
+
red: '#f85149',
|
|
39
|
+
green: '#3fb950',
|
|
40
|
+
yellow: '#d29922',
|
|
41
|
+
blue: '#58a6ff',
|
|
42
|
+
magenta: '#a371f7',
|
|
43
|
+
cyan: '#39c5cf',
|
|
44
|
+
white: '#c9d1d9',
|
|
45
|
+
brightBlack: '#6e7681',
|
|
46
|
+
brightRed: '#ff7b72',
|
|
47
|
+
brightGreen: '#56d364',
|
|
48
|
+
brightYellow: '#e3b341',
|
|
49
|
+
brightBlue: '#79c0ff',
|
|
50
|
+
brightMagenta: '#bc8cff',
|
|
51
|
+
brightCyan: '#56d4dd',
|
|
52
|
+
brightWhite: '#f0f6fc',
|
|
53
|
+
},
|
|
54
|
+
allowProposedApi: true,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const fitAddon = new FitAddon();
|
|
58
|
+
term.loadAddon(fitAddon);
|
|
59
|
+
term.open(terminalRef.current);
|
|
60
|
+
|
|
61
|
+
termRef.current = term;
|
|
62
|
+
fitAddonRef.current = fitAddon;
|
|
63
|
+
initializedRef.current = true;
|
|
64
|
+
|
|
65
|
+
// Initial fit
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
fitAddon.fit();
|
|
68
|
+
}, 0);
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
term.dispose();
|
|
72
|
+
initializedRef.current = false;
|
|
73
|
+
};
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
// Handle socket connection and terminal creation
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!termRef.current || !connected) return;
|
|
79
|
+
|
|
80
|
+
const term = termRef.current;
|
|
81
|
+
const fitAddon = fitAddonRef.current;
|
|
82
|
+
|
|
83
|
+
// Handle terminal output
|
|
84
|
+
const handleOutput = (data: unknown) => {
|
|
85
|
+
const outputData = data as TerminalOutputData;
|
|
86
|
+
if (outputData.sessionId === sessionId) {
|
|
87
|
+
term.write(outputData.data);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Handle terminal created
|
|
92
|
+
const handleCreated = (data: unknown) => {
|
|
93
|
+
const { sessionId: createdId } = data as { sessionId: string };
|
|
94
|
+
if (createdId === sessionId) {
|
|
95
|
+
updateTerminalStatus(sessionId, 'running');
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Handle terminal exit
|
|
100
|
+
const handleExit = (data: unknown) => {
|
|
101
|
+
const { sessionId: exitedId } = data as { sessionId: string };
|
|
102
|
+
if (exitedId === sessionId) {
|
|
103
|
+
updateTerminalStatus(sessionId, 'stopped');
|
|
104
|
+
term.write('\r\n[Process exited]\r\n');
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Handle terminal error
|
|
109
|
+
const handleError = (data: unknown) => {
|
|
110
|
+
const { sessionId: errorId, error } = data as { sessionId: string; error: string };
|
|
111
|
+
if (errorId === sessionId) {
|
|
112
|
+
updateTerminalStatus(sessionId, 'error');
|
|
113
|
+
term.write(`\r\n[Error: ${error}]\r\n`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Register event handlers
|
|
118
|
+
on('terminal:output', handleOutput);
|
|
119
|
+
on('terminal:created', handleCreated);
|
|
120
|
+
on('terminal:exit', handleExit);
|
|
121
|
+
on('terminal:error', handleError);
|
|
122
|
+
|
|
123
|
+
// Send input to server
|
|
124
|
+
const onDataDisposable = term.onData((data) => {
|
|
125
|
+
emit('terminal:input', { sessionId, data });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Create terminal session
|
|
129
|
+
emit('terminal:create', {
|
|
130
|
+
sessionId,
|
|
131
|
+
worktreePath,
|
|
132
|
+
agentType,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Send initial resize
|
|
136
|
+
if (fitAddon) {
|
|
137
|
+
emit('terminal:resize', {
|
|
138
|
+
sessionId,
|
|
139
|
+
cols: term.cols,
|
|
140
|
+
rows: term.rows,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
off('terminal:output', handleOutput);
|
|
146
|
+
off('terminal:created', handleCreated);
|
|
147
|
+
off('terminal:exit', handleExit);
|
|
148
|
+
off('terminal:error', handleError);
|
|
149
|
+
onDataDisposable.dispose();
|
|
150
|
+
};
|
|
151
|
+
}, [sessionId, worktreePath, agentType, connected, emit, on, off, updateTerminalStatus]);
|
|
152
|
+
|
|
153
|
+
// Handle resize
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!terminalRef.current || !fitAddonRef.current) return;
|
|
156
|
+
|
|
157
|
+
const fitAddon = fitAddonRef.current;
|
|
158
|
+
const term = termRef.current;
|
|
159
|
+
|
|
160
|
+
const handleResize = () => {
|
|
161
|
+
if (isActive && term && fitAddon) {
|
|
162
|
+
fitAddon.fit();
|
|
163
|
+
emit('terminal:resize', {
|
|
164
|
+
sessionId,
|
|
165
|
+
cols: term.cols,
|
|
166
|
+
rows: term.rows,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
172
|
+
handleResize();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
resizeObserver.observe(terminalRef.current);
|
|
176
|
+
|
|
177
|
+
// Also fit when becoming active
|
|
178
|
+
if (isActive) {
|
|
179
|
+
setTimeout(handleResize, 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
resizeObserver.disconnect();
|
|
184
|
+
};
|
|
185
|
+
}, [sessionId, isActive, emit]);
|
|
186
|
+
|
|
187
|
+
// Focus terminal when active
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (isActive && termRef.current) {
|
|
190
|
+
termRef.current.focus();
|
|
191
|
+
}
|
|
192
|
+
}, [isActive]);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
ref={terminalRef}
|
|
197
|
+
className="terminal"
|
|
198
|
+
style={{ height: '100%', width: '100%' }}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default XTerminal;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { io, Socket } from 'socket.io-client';
|
|
3
|
+
|
|
4
|
+
interface SocketState {
|
|
5
|
+
socket: Socket | null;
|
|
6
|
+
connected: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Singleton socket instance
|
|
10
|
+
let globalSocket: Socket | null = null;
|
|
11
|
+
|
|
12
|
+
export function useSocket(): SocketState & {
|
|
13
|
+
emit: (event: string, data: unknown) => void;
|
|
14
|
+
on: (event: string, callback: (data: unknown) => void) => void;
|
|
15
|
+
off: (event: string, callback?: (data: unknown) => void) => void;
|
|
16
|
+
} {
|
|
17
|
+
const [connected, setConnected] = useState(false);
|
|
18
|
+
const socketRef = useRef<Socket | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
// Create singleton socket
|
|
22
|
+
if (!globalSocket) {
|
|
23
|
+
globalSocket = io(window.location.origin, {
|
|
24
|
+
reconnection: true,
|
|
25
|
+
reconnectionAttempts: 5,
|
|
26
|
+
reconnectionDelay: 1000,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
socketRef.current = globalSocket;
|
|
31
|
+
|
|
32
|
+
const handleConnect = () => {
|
|
33
|
+
console.log('Socket connected');
|
|
34
|
+
setConnected(true);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleDisconnect = () => {
|
|
38
|
+
console.log('Socket disconnected');
|
|
39
|
+
setConnected(false);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
globalSocket.on('connect', handleConnect);
|
|
43
|
+
globalSocket.on('disconnect', handleDisconnect);
|
|
44
|
+
|
|
45
|
+
// Check initial connection state
|
|
46
|
+
if (globalSocket.connected) {
|
|
47
|
+
setConnected(true);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
// Don't disconnect on unmount - keep singleton alive
|
|
52
|
+
globalSocket?.off('connect', handleConnect);
|
|
53
|
+
globalSocket?.off('disconnect', handleDisconnect);
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const emit = useCallback((event: string, data: unknown) => {
|
|
58
|
+
socketRef.current?.emit(event, data);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const on = useCallback((event: string, callback: (data: unknown) => void) => {
|
|
62
|
+
socketRef.current?.on(event, callback);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const off = useCallback((event: string, callback?: (data: unknown) => void) => {
|
|
66
|
+
if (callback) {
|
|
67
|
+
socketRef.current?.off(event, callback);
|
|
68
|
+
} else {
|
|
69
|
+
socketRef.current?.off(event);
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
socket: socketRef.current,
|
|
75
|
+
connected,
|
|
76
|
+
emit,
|
|
77
|
+
on,
|
|
78
|
+
off,
|
|
79
|
+
};
|
|
80
|
+
}
|
package/web/src/main.tsx
ADDED