hjworktree-cli 2.3.0 → 2.5.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-20250107-221530.md +95 -0
- package/.context-snapshots/context-snapshot-20260106-211500.md +85 -0
- package/README.md +39 -10
- package/bin/cli.js +64 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +10 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/socketHandlers.d.ts.map +1 -1
- package/dist/server/socketHandlers.js +19 -18
- package/dist/server/socketHandlers.js.map +1 -1
- package/dist/shared/constants.d.ts +2 -3
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +2 -24
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types/index.d.ts +3 -15
- package/dist/shared/types/index.d.ts.map +1 -1
- package/dist/shared/types/index.js +1 -1
- package/dist/shared/types/index.js.map +1 -1
- package/dist/web/assets/index-Dgl6wRHk.css +32 -0
- package/dist/web/assets/index-Jm7djWxU.js +53 -0
- package/dist/web/assets/index-Jm7djWxU.js.map +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/server/index.ts +11 -0
- package/server/socketHandlers.ts +24 -23
- package/shared/constants.ts +2 -27
- package/shared/types/index.ts +6 -21
- package/web/src/App.tsx +8 -6
- package/web/src/components/Layout/LeftNavBar.tsx +6 -17
- package/web/src/components/Modals/AddWorktreeModal.tsx +1 -21
- package/web/src/components/Steps/WorktreeStep.tsx +1 -8
- package/web/src/components/Terminal/SplitTerminalView.tsx +64 -0
- package/web/src/components/Terminal/TerminalPanel.tsx +3 -69
- package/web/src/components/Terminal/XTerminal.tsx +4 -6
- package/web/src/stores/useAppStore.ts +77 -35
- package/web/src/styles/global.css +127 -77
- package/dist/web/assets/index-CsixHL-D.css +0 -32
- package/dist/web/assets/index-De6xm4hO.js +0 -53
- package/dist/web/assets/index-De6xm4hO.js.map +0 -1
- package/web/src/components/Setup/AgentSelector.tsx +0 -27
- package/web/src/components/Steps/AgentStep.tsx +0 -20
package/dist/web/index.html
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-Jm7djWxU.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Dgl6wRHk.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hjworktree-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Web-based git worktree parallel AI coding agent runner",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server/index.js",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"cors": "^2.8.5",
|
|
40
40
|
"express": "^4.21.0",
|
|
41
|
+
"get-port": "^7.1.0",
|
|
41
42
|
"node-pty": "^1.1.0",
|
|
42
43
|
"open": "^10.1.0",
|
|
43
44
|
"simple-git": "^3.27.0",
|
package/server/index.ts
CHANGED
|
@@ -55,6 +55,17 @@ process.on('SIGTERM', () => {
|
|
|
55
55
|
process.exit(0);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
// Handle port in use error (race condition protection)
|
|
59
|
+
httpServer.on('error', (error: NodeJS.ErrnoException) => {
|
|
60
|
+
if (error.code === 'EADDRINUSE') {
|
|
61
|
+
console.error(`\nError: Port ${PORT} is already in use.`);
|
|
62
|
+
console.error('This may happen if another process started using the port just now.');
|
|
63
|
+
console.error('Please try again or specify a different port: PORT=4000 hjWorktree');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
});
|
|
68
|
+
|
|
58
69
|
// Start server
|
|
59
70
|
httpServer.listen(PORT, () => {
|
|
60
71
|
console.log(`
|
package/server/socketHandlers.ts
CHANGED
|
@@ -6,9 +6,7 @@ import type {
|
|
|
6
6
|
TerminalInputData,
|
|
7
7
|
TerminalResizeData,
|
|
8
8
|
TerminalKillData,
|
|
9
|
-
AgentId
|
|
10
9
|
} from '../shared/types/index.js';
|
|
11
|
-
import { AI_AGENTS } from '../shared/constants.js';
|
|
12
10
|
import fs, { realpathSync } from 'fs';
|
|
13
11
|
import path from 'path';
|
|
14
12
|
import { promisify } from 'util';
|
|
@@ -108,17 +106,11 @@ async function waitForSpawnInterval(): Promise<void> {
|
|
|
108
106
|
interface TerminalSession {
|
|
109
107
|
pty: IPty;
|
|
110
108
|
worktreePath: string;
|
|
111
|
-
|
|
112
|
-
socketId: string;
|
|
109
|
+
socketId: string | null; // null when disconnected but session alive
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
const sessions = new Map<string, TerminalSession>();
|
|
116
113
|
|
|
117
|
-
function getAgentCommand(agentType: AgentId): string {
|
|
118
|
-
const agent = AI_AGENTS.find(a => a.id === agentType);
|
|
119
|
-
return agent?.command || 'bash';
|
|
120
|
-
}
|
|
121
|
-
|
|
122
114
|
function getShell(): string {
|
|
123
115
|
if (process.platform === 'win32') {
|
|
124
116
|
return 'powershell.exe';
|
|
@@ -142,13 +134,29 @@ export function setupSocketHandlers(io: Server, cwd: string) {
|
|
|
142
134
|
|
|
143
135
|
// Terminal create
|
|
144
136
|
socket.on('terminal:create', async (data: TerminalCreateData) => {
|
|
145
|
-
const { sessionId, worktreePath
|
|
137
|
+
const { sessionId, worktreePath } = data;
|
|
138
|
+
|
|
139
|
+
console.log(`Creating terminal session: ${sessionId} at ${worktreePath}`);
|
|
140
|
+
|
|
141
|
+
// Check if session already exists (reattach scenario)
|
|
142
|
+
const existingSession = sessions.get(sessionId);
|
|
143
|
+
if (existingSession && existingSession.pty) {
|
|
144
|
+
console.log(`Reattaching to existing session: ${sessionId}`);
|
|
146
145
|
|
|
147
|
-
|
|
146
|
+
// Update socketId for the existing session
|
|
147
|
+
existingSession.socketId = socket.id;
|
|
148
|
+
|
|
149
|
+
// Re-register output handler for new socket
|
|
150
|
+
existingSession.pty.onData((output: string) => {
|
|
151
|
+
socket.emit('terminal:output', { sessionId, data: output });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
socket.emit('terminal:created', { sessionId, reattached: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
148
157
|
|
|
149
158
|
try {
|
|
150
159
|
const shell = getShell();
|
|
151
|
-
const agentCommand = getAgentCommand(agentType);
|
|
152
160
|
|
|
153
161
|
// Validate path before spawning
|
|
154
162
|
const pathValidation = await validatePath(worktreePath);
|
|
@@ -184,7 +192,6 @@ export function setupSocketHandlers(io: Server, cwd: string) {
|
|
|
184
192
|
sessions.set(sessionId, {
|
|
185
193
|
pty: ptyProcess,
|
|
186
194
|
worktreePath,
|
|
187
|
-
agentType,
|
|
188
195
|
socketId: socket.id,
|
|
189
196
|
});
|
|
190
197
|
|
|
@@ -200,11 +207,6 @@ export function setupSocketHandlers(io: Server, cwd: string) {
|
|
|
200
207
|
sessions.delete(sessionId);
|
|
201
208
|
});
|
|
202
209
|
|
|
203
|
-
// Start the AI agent after a short delay
|
|
204
|
-
setTimeout(() => {
|
|
205
|
-
ptyProcess.write(`${agentCommand}\r`);
|
|
206
|
-
}, 500);
|
|
207
|
-
|
|
208
210
|
socket.emit('terminal:created', { sessionId });
|
|
209
211
|
} catch (error) {
|
|
210
212
|
console.error(`Failed to create terminal session: ${sessionId}`, error);
|
|
@@ -252,7 +254,7 @@ export function setupSocketHandlers(io: Server, cwd: string) {
|
|
|
252
254
|
const socketSessions = Array.from(sessions.entries())
|
|
253
255
|
.filter(([, session]) => session.socketId === socket.id);
|
|
254
256
|
|
|
255
|
-
for (const [
|
|
257
|
+
for (const [, session] of socketSessions) {
|
|
256
258
|
session.pty.write(data.data);
|
|
257
259
|
}
|
|
258
260
|
});
|
|
@@ -261,14 +263,13 @@ export function setupSocketHandlers(io: Server, cwd: string) {
|
|
|
261
263
|
socket.on('disconnect', () => {
|
|
262
264
|
console.log(`Client disconnected: ${socket.id}`);
|
|
263
265
|
|
|
264
|
-
//
|
|
266
|
+
// Keep sessions alive but mark socketId as null for potential reattach
|
|
265
267
|
const socketSessions = Array.from(sessions.entries())
|
|
266
268
|
.filter(([, session]) => session.socketId === socket.id);
|
|
267
269
|
|
|
268
270
|
for (const [sessionId, session] of socketSessions) {
|
|
269
|
-
console.log(`
|
|
270
|
-
session.
|
|
271
|
-
sessions.delete(sessionId);
|
|
271
|
+
console.log(`Detaching terminal session (keeping alive): ${sessionId}`);
|
|
272
|
+
session.socketId = null;
|
|
272
273
|
}
|
|
273
274
|
});
|
|
274
275
|
});
|
package/shared/constants.ts
CHANGED
|
@@ -1,29 +1,3 @@
|
|
|
1
|
-
import type { AgentType } from './types/index.js';
|
|
2
|
-
|
|
3
|
-
export const AI_AGENTS: AgentType[] = [
|
|
4
|
-
{
|
|
5
|
-
id: 'codex',
|
|
6
|
-
name: 'Codex CLI',
|
|
7
|
-
command: 'codex',
|
|
8
|
-
installCommand: 'npm install -g @openai/codex',
|
|
9
|
-
description: 'OpenAI Codex CLI - AI-powered coding assistant'
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
id: 'claude',
|
|
13
|
-
name: 'Claude Code',
|
|
14
|
-
command: 'claude',
|
|
15
|
-
installCommand: 'npm install -g @anthropic-ai/claude-code',
|
|
16
|
-
description: 'Anthropic Claude Code - Advanced AI coding assistant'
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
id: 'gemini',
|
|
20
|
-
name: 'Gemini CLI',
|
|
21
|
-
command: 'gemini',
|
|
22
|
-
installCommand: 'npm install -g @google/gemini-cli',
|
|
23
|
-
description: 'Google Gemini CLI - Multi-modal AI assistant'
|
|
24
|
-
}
|
|
25
|
-
];
|
|
26
|
-
|
|
27
1
|
export const MAX_PARALLEL_COUNT = 10;
|
|
28
2
|
export const MIN_PARALLEL_COUNT = 1;
|
|
29
3
|
export const DEFAULT_PARALLEL_COUNT = 3;
|
|
@@ -31,5 +5,6 @@ export const DEFAULT_PARALLEL_COUNT = 3;
|
|
|
31
5
|
export const BRANCH_POLL_INTERVAL = 5000; // 5 seconds
|
|
32
6
|
|
|
33
7
|
export const APP_NAME = 'hjWorktree CLI';
|
|
34
|
-
export const APP_VERSION = '2.
|
|
8
|
+
export const APP_VERSION = '2.5.0';
|
|
35
9
|
export const DEFAULT_PORT = 3847;
|
|
10
|
+
export const PORT_RANGE_SIZE = 10; // Will try ports 3847-3856
|
package/shared/types/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Navigation step types (
|
|
2
|
-
export type NavigationStep = 'branch' | '
|
|
1
|
+
// Navigation step types (3-step wizard)
|
|
2
|
+
export type NavigationStep = 'branch' | 'worktree' | 'running';
|
|
3
3
|
|
|
4
4
|
// Step status for LNB display
|
|
5
5
|
export type StepStatus = 'pending' | 'current' | 'completed';
|
|
@@ -13,18 +13,7 @@ export interface StepConfig {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
// Step order constant
|
|
16
|
-
export const STEP_ORDER: NavigationStep[] = ['branch', '
|
|
17
|
-
|
|
18
|
-
// Agent types
|
|
19
|
-
export type AgentId = 'codex' | 'claude' | 'gemini';
|
|
20
|
-
|
|
21
|
-
export interface AgentType {
|
|
22
|
-
id: AgentId;
|
|
23
|
-
name: string;
|
|
24
|
-
command: string;
|
|
25
|
-
installCommand: string;
|
|
26
|
-
description: string;
|
|
27
|
-
}
|
|
16
|
+
export const STEP_ORDER: NavigationStep[] = ['branch', 'worktree', 'running'];
|
|
28
17
|
|
|
29
18
|
// Branch types
|
|
30
19
|
export interface Branch {
|
|
@@ -34,16 +23,15 @@ export interface Branch {
|
|
|
34
23
|
lastCommit?: string;
|
|
35
24
|
}
|
|
36
25
|
|
|
37
|
-
//
|
|
38
|
-
export type
|
|
26
|
+
// Terminal status types
|
|
27
|
+
export type TerminalStatus = 'initializing' | 'running' | 'stopped' | 'error';
|
|
39
28
|
|
|
40
29
|
export interface TerminalInfo {
|
|
41
30
|
sessionId: string;
|
|
42
31
|
worktreePath: string;
|
|
43
32
|
worktreeName: string;
|
|
44
33
|
branchName: string;
|
|
45
|
-
|
|
46
|
-
status: AgentStatus;
|
|
34
|
+
status: TerminalStatus;
|
|
47
35
|
}
|
|
48
36
|
|
|
49
37
|
// Worktree types
|
|
@@ -58,7 +46,6 @@ export interface Worktree {
|
|
|
58
46
|
export interface TerminalCreateData {
|
|
59
47
|
sessionId: string;
|
|
60
48
|
worktreePath: string;
|
|
61
|
-
agentType: AgentId;
|
|
62
49
|
}
|
|
63
50
|
|
|
64
51
|
export interface TerminalOutputData {
|
|
@@ -85,7 +72,6 @@ export interface TerminalKillData {
|
|
|
85
72
|
export interface CreateWorktreesRequest {
|
|
86
73
|
branch: string;
|
|
87
74
|
count: number;
|
|
88
|
-
agentType: AgentId;
|
|
89
75
|
}
|
|
90
76
|
|
|
91
77
|
export interface CreateWorktreesResponse {
|
|
@@ -95,7 +81,6 @@ export interface CreateWorktreesResponse {
|
|
|
95
81
|
// Single worktree creation
|
|
96
82
|
export interface CreateSingleWorktreeRequest {
|
|
97
83
|
branch: string;
|
|
98
|
-
agentType: AgentId;
|
|
99
84
|
}
|
|
100
85
|
|
|
101
86
|
export interface CreateSingleWorktreeResponse {
|
package/web/src/App.tsx
CHANGED
|
@@ -4,7 +4,6 @@ import { useSocket } from './hooks/useSocket.js';
|
|
|
4
4
|
import Header from './components/Layout/Header.js';
|
|
5
5
|
import MainLayout from './components/Layout/MainLayout.js';
|
|
6
6
|
import BranchStep from './components/Steps/BranchStep.js';
|
|
7
|
-
import AgentStep from './components/Steps/AgentStep.js';
|
|
8
7
|
import WorktreeStep from './components/Steps/WorktreeStep.js';
|
|
9
8
|
import TerminalPanel from './components/Terminal/TerminalPanel.js';
|
|
10
9
|
import ModalContainer from './components/Modals/ModalContainer.js';
|
|
@@ -13,18 +12,21 @@ function App() {
|
|
|
13
12
|
const { step, isLoading, loadingMessage, error } = useAppStore();
|
|
14
13
|
const { connected } = useSocket();
|
|
15
14
|
|
|
16
|
-
// Fetch initial data
|
|
15
|
+
// Fetch initial data and restore existing sessions
|
|
17
16
|
useEffect(() => {
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
const initApp = async () => {
|
|
18
|
+
await useAppStore.getState().fetchProjectInfo();
|
|
19
|
+
await useAppStore.getState().fetchBranches();
|
|
20
|
+
// Restore existing worktrees if any (for page refresh persistence)
|
|
21
|
+
await useAppStore.getState().restoreExistingWorktrees();
|
|
22
|
+
};
|
|
23
|
+
initApp();
|
|
20
24
|
}, []);
|
|
21
25
|
|
|
22
26
|
const renderStepContent = () => {
|
|
23
27
|
switch (step) {
|
|
24
28
|
case 'branch':
|
|
25
29
|
return <BranchStep />;
|
|
26
|
-
case 'agent':
|
|
27
|
-
return <AgentStep />;
|
|
28
30
|
case 'worktree':
|
|
29
31
|
return <WorktreeStep />;
|
|
30
32
|
case 'running':
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { useAppStore } from '../../stores/useAppStore.js';
|
|
3
|
-
import type { NavigationStep, StepStatus,
|
|
4
|
-
import { AI_AGENTS } from '../../../../shared/constants.js';
|
|
3
|
+
import type { NavigationStep, StepStatus, TerminalStatus } from '../../../../shared/types/index.js';
|
|
5
4
|
|
|
6
5
|
// Step configuration
|
|
7
6
|
const STEP_CONFIG = [
|
|
8
7
|
{ id: 'branch' as const, number: 1, label: 'Branch' },
|
|
9
|
-
{ id: '
|
|
10
|
-
{ id: 'worktree' as const, number: 3, label: 'Count' },
|
|
8
|
+
{ id: 'worktree' as const, number: 2, label: 'Count' },
|
|
11
9
|
];
|
|
12
10
|
|
|
13
11
|
interface StepItemProps {
|
|
@@ -79,7 +77,7 @@ function SetupSection() {
|
|
|
79
77
|
onClick={() => goToStep('running')}
|
|
80
78
|
type="button"
|
|
81
79
|
>
|
|
82
|
-
<span className="step-number">
|
|
80
|
+
<span className="step-number">3</span>
|
|
83
81
|
<span className="step-label">Running</span>
|
|
84
82
|
</button>
|
|
85
83
|
)}
|
|
@@ -90,10 +88,9 @@ function SetupSection() {
|
|
|
90
88
|
}
|
|
91
89
|
|
|
92
90
|
// Status indicator component
|
|
93
|
-
function StatusIndicator({ status }: { status:
|
|
94
|
-
const statusColors: Record<
|
|
91
|
+
function StatusIndicator({ status }: { status: TerminalStatus }) {
|
|
92
|
+
const statusColors: Record<TerminalStatus, string> = {
|
|
95
93
|
initializing: '#f59e0b',
|
|
96
|
-
installing: '#f59e0b',
|
|
97
94
|
running: '#22c55e',
|
|
98
95
|
stopped: '#6b7280',
|
|
99
96
|
error: '#ef4444',
|
|
@@ -112,8 +109,7 @@ function StatusIndicator({ status }: { status: AgentStatus }) {
|
|
|
112
109
|
interface SessionItemProps {
|
|
113
110
|
sessionId: string;
|
|
114
111
|
worktreeName: string;
|
|
115
|
-
|
|
116
|
-
status: AgentStatus;
|
|
112
|
+
status: TerminalStatus;
|
|
117
113
|
isActive: boolean;
|
|
118
114
|
isSelected: boolean;
|
|
119
115
|
onSelect: () => void;
|
|
@@ -124,7 +120,6 @@ interface SessionItemProps {
|
|
|
124
120
|
function SessionItem({
|
|
125
121
|
sessionId,
|
|
126
122
|
worktreeName,
|
|
127
|
-
agentType,
|
|
128
123
|
status,
|
|
129
124
|
isActive,
|
|
130
125
|
isSelected,
|
|
@@ -132,8 +127,6 @@ function SessionItem({
|
|
|
132
127
|
onToggleSelect,
|
|
133
128
|
onClose,
|
|
134
129
|
}: SessionItemProps) {
|
|
135
|
-
const agent = AI_AGENTS.find(a => a.id === agentType);
|
|
136
|
-
|
|
137
130
|
return (
|
|
138
131
|
<div
|
|
139
132
|
className={`session-item ${isActive ? 'active' : ''} ${isSelected ? 'selected' : ''}`}
|
|
@@ -152,9 +145,6 @@ function SessionItem({
|
|
|
152
145
|
<span className="session-name" title={worktreeName}>
|
|
153
146
|
{worktreeName}
|
|
154
147
|
</span>
|
|
155
|
-
<span className="session-agent" title={agent?.name}>
|
|
156
|
-
{agentType.charAt(0).toUpperCase()}
|
|
157
|
-
</span>
|
|
158
148
|
<button
|
|
159
149
|
className="session-close"
|
|
160
150
|
onClick={onClose}
|
|
@@ -256,7 +246,6 @@ function SessionsSection() {
|
|
|
256
246
|
key={session.sessionId}
|
|
257
247
|
sessionId={session.sessionId}
|
|
258
248
|
worktreeName={session.worktreeName}
|
|
259
|
-
agentType={session.agentType}
|
|
260
249
|
status={session.status}
|
|
261
250
|
isActive={session.sessionId === activeSessionId}
|
|
262
251
|
isSelected={selectedSessionIds.includes(session.sessionId)}
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { useAppStore } from '../../stores/useAppStore.js';
|
|
3
|
-
import { AI_AGENTS } from '../../../../shared/constants.js';
|
|
4
|
-
import type { AgentId } from '../../../../shared/types/index.js';
|
|
5
3
|
|
|
6
4
|
function AddWorktreeModal() {
|
|
7
5
|
const { branches, closeModal, createSingleWorktree, isLoading } = useAppStore();
|
|
8
6
|
const [selectedBranch, setSelectedBranch] = useState<string>('');
|
|
9
|
-
const [selectedAgent, setSelectedAgent] = useState<AgentId>('claude');
|
|
10
7
|
|
|
11
8
|
const handleCreate = async () => {
|
|
12
9
|
if (!selectedBranch) return;
|
|
13
|
-
await createSingleWorktree(selectedBranch
|
|
10
|
+
await createSingleWorktree(selectedBranch);
|
|
14
11
|
};
|
|
15
12
|
|
|
16
13
|
return (
|
|
@@ -42,23 +39,6 @@ function AddWorktreeModal() {
|
|
|
42
39
|
))}
|
|
43
40
|
</select>
|
|
44
41
|
</div>
|
|
45
|
-
|
|
46
|
-
<div className="form-group">
|
|
47
|
-
<label>Agent</label>
|
|
48
|
-
<div className="agent-options">
|
|
49
|
-
{AI_AGENTS.map((agent) => (
|
|
50
|
-
<button
|
|
51
|
-
key={agent.id}
|
|
52
|
-
type="button"
|
|
53
|
-
className={`agent-option ${selectedAgent === agent.id ? 'selected' : ''}`}
|
|
54
|
-
onClick={() => setSelectedAgent(agent.id)}
|
|
55
|
-
disabled={isLoading}
|
|
56
|
-
>
|
|
57
|
-
<span className="agent-name">{agent.name}</span>
|
|
58
|
-
</button>
|
|
59
|
-
))}
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
42
|
</div>
|
|
63
43
|
|
|
64
44
|
<div className="modal-footer">
|
|
@@ -2,12 +2,9 @@ import React from 'react';
|
|
|
2
2
|
import StepContainer from '../Layout/StepContainer.js';
|
|
3
3
|
import WorktreeCountSelector from '../Setup/WorktreeCountSelector.js';
|
|
4
4
|
import { useAppStore } from '../../stores/useAppStore.js';
|
|
5
|
-
import { AI_AGENTS } from '../../../../shared/constants.js';
|
|
6
5
|
|
|
7
6
|
function WorktreeStep() {
|
|
8
|
-
const { execute, isLoading, selectedBranch,
|
|
9
|
-
|
|
10
|
-
const agentName = AI_AGENTS.find(a => a.id === selectedAgent)?.name || selectedAgent;
|
|
7
|
+
const { execute, isLoading, selectedBranch, worktreeCount } = useAppStore();
|
|
11
8
|
|
|
12
9
|
return (
|
|
13
10
|
<StepContainer
|
|
@@ -25,10 +22,6 @@ function WorktreeStep() {
|
|
|
25
22
|
<span className="summary-label">Branch:</span>
|
|
26
23
|
<span className="summary-value">{selectedBranch}</span>
|
|
27
24
|
</div>
|
|
28
|
-
<div className="summary-item">
|
|
29
|
-
<span className="summary-label">Agent:</span>
|
|
30
|
-
<span className="summary-value">{agentName}</span>
|
|
31
|
-
</div>
|
|
32
25
|
<div className="summary-item">
|
|
33
26
|
<span className="summary-label">Worktrees:</span>
|
|
34
27
|
<span className="summary-value">{worktreeCount}</span>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { useAppStore } from '../../stores/useAppStore.js';
|
|
3
|
+
import XTerminal from './XTerminal.js';
|
|
4
|
+
import type { TerminalInfo } from '../../../../shared/types/index.js';
|
|
5
|
+
|
|
6
|
+
function SplitTerminalView() {
|
|
7
|
+
const {
|
|
8
|
+
terminals,
|
|
9
|
+
activeSessionId,
|
|
10
|
+
setActiveSession,
|
|
11
|
+
} = useAppStore();
|
|
12
|
+
|
|
13
|
+
// Handle single click to activate
|
|
14
|
+
const handleClick = useCallback((sessionId: string) => {
|
|
15
|
+
setActiveSession(sessionId);
|
|
16
|
+
}, [setActiveSession]);
|
|
17
|
+
|
|
18
|
+
// Render a terminal pane
|
|
19
|
+
const renderPane = (terminal: TerminalInfo) => {
|
|
20
|
+
const isActive = terminal.sessionId === activeSessionId;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
key={terminal.sessionId}
|
|
25
|
+
className={`terminal-pane ${isActive ? 'active' : ''}`}
|
|
26
|
+
onClick={() => handleClick(terminal.sessionId)}
|
|
27
|
+
>
|
|
28
|
+
<div className="pane-header">
|
|
29
|
+
<span className={`pane-status ${terminal.status}`} />
|
|
30
|
+
<span className="pane-title">{terminal.worktreeName}</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="pane-terminal">
|
|
33
|
+
<XTerminal
|
|
34
|
+
sessionId={terminal.sessionId}
|
|
35
|
+
worktreePath={terminal.worktreePath}
|
|
36
|
+
isActive={isActive}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Render split grid view
|
|
44
|
+
if (terminals.length === 0) {
|
|
45
|
+
return (
|
|
46
|
+
<div className="terminal-grid-container">
|
|
47
|
+
<div className="empty-grid-message">
|
|
48
|
+
<p>No active sessions</p>
|
|
49
|
+
<p>Use the Setup wizard or click "+ Add Session" in the sidebar</p>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="terminal-grid-container scrollable">
|
|
57
|
+
<div className="terminal-grid cols-2">
|
|
58
|
+
{terminals.map(terminal => renderPane(terminal))}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default SplitTerminalView;
|
|
@@ -1,52 +1,18 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { useAppStore } from '../../stores/useAppStore.js';
|
|
3
|
-
import
|
|
4
|
-
import XTerminal from './XTerminal.js';
|
|
3
|
+
import SplitTerminalView from './SplitTerminalView.js';
|
|
5
4
|
|
|
6
5
|
function TerminalPanel() {
|
|
7
6
|
const {
|
|
8
7
|
terminals,
|
|
9
|
-
activeTerminalIndex,
|
|
10
|
-
activeSessionId,
|
|
11
|
-
setActiveSession,
|
|
12
|
-
removeTerminal,
|
|
13
|
-
clearAllTerminals,
|
|
14
8
|
openModal,
|
|
15
9
|
} = useAppStore();
|
|
16
|
-
const { emit } = useSocket();
|
|
17
|
-
|
|
18
|
-
const handleCloseTerminal = async (sessionId: string, e: React.MouseEvent) => {
|
|
19
|
-
e.stopPropagation();
|
|
20
|
-
|
|
21
|
-
// Kill the terminal via socket
|
|
22
|
-
emit('terminal:kill', { sessionId });
|
|
23
|
-
|
|
24
|
-
// Find the worktree name to delete
|
|
25
|
-
const terminal = terminals.find(t => t.sessionId === sessionId);
|
|
26
|
-
if (terminal) {
|
|
27
|
-
try {
|
|
28
|
-
await fetch(`/api/worktrees/${terminal.worktreeName}`, {
|
|
29
|
-
method: 'DELETE',
|
|
30
|
-
});
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.error('Failed to delete worktree:', error);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
removeTerminal(sessionId);
|
|
37
|
-
};
|
|
38
10
|
|
|
39
11
|
const handleCloseAll = () => {
|
|
40
12
|
const sessionIds = terminals.map(t => t.sessionId);
|
|
41
13
|
openModal('confirmDelete', { sessionIds });
|
|
42
14
|
};
|
|
43
15
|
|
|
44
|
-
// Find active index based on sessionId
|
|
45
|
-
const currentActiveIndex = activeSessionId
|
|
46
|
-
? terminals.findIndex(t => t.sessionId === activeSessionId)
|
|
47
|
-
: activeTerminalIndex;
|
|
48
|
-
const effectiveActiveIndex = currentActiveIndex >= 0 ? currentActiveIndex : 0;
|
|
49
|
-
|
|
50
16
|
if (terminals.length === 0) {
|
|
51
17
|
return (
|
|
52
18
|
<div className="terminal-panel empty">
|
|
@@ -60,25 +26,7 @@ function TerminalPanel() {
|
|
|
60
26
|
|
|
61
27
|
return (
|
|
62
28
|
<div className="terminal-panel">
|
|
63
|
-
<div className="terminal-
|
|
64
|
-
{terminals.map((terminal, index) => (
|
|
65
|
-
<button
|
|
66
|
-
key={terminal.sessionId}
|
|
67
|
-
className={`terminal-tab ${terminal.sessionId === activeSessionId || index === effectiveActiveIndex ? 'active' : ''}`}
|
|
68
|
-
onClick={() => setActiveSession(terminal.sessionId)}
|
|
69
|
-
>
|
|
70
|
-
<span className={`status ${terminal.status}`} />
|
|
71
|
-
<span>{terminal.worktreeName}</span>
|
|
72
|
-
<button
|
|
73
|
-
className="close-btn"
|
|
74
|
-
onClick={(e) => handleCloseTerminal(terminal.sessionId, e)}
|
|
75
|
-
title="Close terminal"
|
|
76
|
-
>
|
|
77
|
-
×
|
|
78
|
-
</button>
|
|
79
|
-
</button>
|
|
80
|
-
))}
|
|
81
|
-
|
|
29
|
+
<div className="terminal-header">
|
|
82
30
|
<div className="terminal-actions">
|
|
83
31
|
<button className="danger" onClick={handleCloseAll}>
|
|
84
32
|
Close All
|
|
@@ -86,21 +34,7 @@ function TerminalPanel() {
|
|
|
86
34
|
</div>
|
|
87
35
|
</div>
|
|
88
36
|
|
|
89
|
-
<
|
|
90
|
-
{terminals.map((terminal, index) => (
|
|
91
|
-
<div
|
|
92
|
-
key={terminal.sessionId}
|
|
93
|
-
className={`terminal-wrapper ${index !== effectiveActiveIndex ? 'hidden' : ''}`}
|
|
94
|
-
>
|
|
95
|
-
<XTerminal
|
|
96
|
-
sessionId={terminal.sessionId}
|
|
97
|
-
worktreePath={terminal.worktreePath}
|
|
98
|
-
agentType={terminal.agentType}
|
|
99
|
-
isActive={index === effectiveActiveIndex}
|
|
100
|
-
/>
|
|
101
|
-
</div>
|
|
102
|
-
))}
|
|
103
|
-
</div>
|
|
37
|
+
<SplitTerminalView />
|
|
104
38
|
</div>
|
|
105
39
|
);
|
|
106
40
|
}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import React, { useEffect, useRef
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
2
|
import { Terminal } from '@xterm/xterm';
|
|
3
3
|
import { FitAddon } from '@xterm/addon-fit';
|
|
4
4
|
import { useSocket } from '../../hooks/useSocket';
|
|
5
5
|
import { useAppStore } from '../../stores/useAppStore';
|
|
6
|
-
import type {
|
|
6
|
+
import type { TerminalOutputData } from '../../../../shared/types/index';
|
|
7
7
|
import '@xterm/xterm/css/xterm.css';
|
|
8
8
|
|
|
9
9
|
interface XTerminalProps {
|
|
10
10
|
sessionId: string;
|
|
11
11
|
worktreePath: string;
|
|
12
|
-
agentType: AgentId;
|
|
13
12
|
isActive: boolean;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
function XTerminal({ sessionId, worktreePath,
|
|
15
|
+
function XTerminal({ sessionId, worktreePath, isActive }: XTerminalProps) {
|
|
17
16
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
18
17
|
const termRef = useRef<Terminal | null>(null);
|
|
19
18
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
@@ -129,7 +128,6 @@ function XTerminal({ sessionId, worktreePath, agentType, isActive }: XTerminalPr
|
|
|
129
128
|
emit('terminal:create', {
|
|
130
129
|
sessionId,
|
|
131
130
|
worktreePath,
|
|
132
|
-
agentType,
|
|
133
131
|
});
|
|
134
132
|
|
|
135
133
|
// Send initial resize
|
|
@@ -148,7 +146,7 @@ function XTerminal({ sessionId, worktreePath, agentType, isActive }: XTerminalPr
|
|
|
148
146
|
off('terminal:error', handleError);
|
|
149
147
|
onDataDisposable.dispose();
|
|
150
148
|
};
|
|
151
|
-
}, [sessionId, worktreePath,
|
|
149
|
+
}, [sessionId, worktreePath, connected, emit, on, off, updateTerminalStatus]);
|
|
152
150
|
|
|
153
151
|
// Handle resize
|
|
154
152
|
useEffect(() => {
|