@bamptee/aia-code 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ import React from 'react';
2
+
3
+ // Terminal component using xterm.js loaded from CDN
4
+ export function Terminal({ wsUrl, onClose }) {
5
+ const containerRef = React.useRef(null);
6
+ const termRef = React.useRef(null);
7
+ const [status, setStatus] = React.useState('connecting');
8
+ const [error, setError] = React.useState(null);
9
+
10
+ React.useEffect(() => {
11
+ let term = null;
12
+ let ws = null;
13
+ let fitAddon = null;
14
+
15
+ const initTerminal = async () => {
16
+ // Check if xterm is loaded
17
+ if (!window.Terminal) {
18
+ setError('xterm.js not loaded');
19
+ return;
20
+ }
21
+
22
+ try {
23
+ term = new window.Terminal({
24
+ cursorBlink: true,
25
+ fontSize: 13,
26
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
27
+ theme: {
28
+ background: '#0f172a',
29
+ foreground: '#e2e8f0',
30
+ cursor: '#38bdf8',
31
+ cursorAccent: '#0f172a',
32
+ selection: '#38bdf833',
33
+ black: '#1e293b',
34
+ red: '#f87171',
35
+ green: '#4ade80',
36
+ yellow: '#fbbf24',
37
+ blue: '#60a5fa',
38
+ magenta: '#c084fc',
39
+ cyan: '#22d3ee',
40
+ white: '#f1f5f9',
41
+ brightBlack: '#475569',
42
+ brightRed: '#fca5a5',
43
+ brightGreen: '#86efac',
44
+ brightYellow: '#fcd34d',
45
+ brightBlue: '#93c5fd',
46
+ brightMagenta: '#d8b4fe',
47
+ brightCyan: '#67e8f9',
48
+ brightWhite: '#f8fafc',
49
+ },
50
+ });
51
+
52
+ if (window.FitAddon) {
53
+ fitAddon = new window.FitAddon.FitAddon();
54
+ term.loadAddon(fitAddon);
55
+ }
56
+
57
+ term.open(containerRef.current);
58
+
59
+ if (fitAddon) {
60
+ fitAddon.fit();
61
+ }
62
+
63
+ // Connect WebSocket
64
+ ws = new WebSocket(wsUrl);
65
+
66
+ ws.onopen = () => {
67
+ setStatus('connected');
68
+ // Send initial size
69
+ if (fitAddon) {
70
+ const dims = fitAddon.proposeDimensions();
71
+ if (dims) {
72
+ ws.send(`\x1b[8;${dims.rows};${dims.cols}t`);
73
+ }
74
+ }
75
+ };
76
+
77
+ ws.onmessage = (event) => {
78
+ term.write(event.data);
79
+ };
80
+
81
+ ws.onclose = () => {
82
+ setStatus('disconnected');
83
+ term.write('\r\n\x1b[33m[Connection closed]\x1b[0m\r\n');
84
+ };
85
+
86
+ ws.onerror = () => {
87
+ setStatus('error');
88
+ setError('WebSocket connection failed');
89
+ };
90
+
91
+ // Send input to server
92
+ term.onData((data) => {
93
+ if (ws && ws.readyState === WebSocket.OPEN) {
94
+ ws.send(data);
95
+ }
96
+ });
97
+
98
+ // Handle resize
99
+ const handleResize = () => {
100
+ if (fitAddon) {
101
+ fitAddon.fit();
102
+ const dims = fitAddon.proposeDimensions();
103
+ if (dims && ws && ws.readyState === WebSocket.OPEN) {
104
+ ws.send(`\x1b[8;${dims.rows};${dims.cols}t`);
105
+ }
106
+ }
107
+ };
108
+
109
+ window.addEventListener('resize', handleResize);
110
+ termRef.current = { term, ws, fitAddon, handleResize };
111
+ } catch (err) {
112
+ setError(err.message);
113
+ setStatus('error');
114
+ }
115
+ };
116
+
117
+ initTerminal();
118
+
119
+ return () => {
120
+ if (termRef.current) {
121
+ if (termRef.current.handleResize) {
122
+ window.removeEventListener('resize', termRef.current.handleResize);
123
+ }
124
+ if (termRef.current.ws) {
125
+ termRef.current.ws.close();
126
+ }
127
+ if (termRef.current.term) {
128
+ termRef.current.term.dispose();
129
+ }
130
+ }
131
+ };
132
+ }, [wsUrl]);
133
+
134
+ return React.createElement('div', { className: 'relative' },
135
+ // Header bar
136
+ React.createElement('div', { className: 'flex items-center justify-between bg-slate-800 px-3 py-1.5 rounded-t border border-slate-700 border-b-0' },
137
+ React.createElement('div', { className: 'flex items-center gap-2' },
138
+ React.createElement('span', { className: 'text-xs text-slate-400' }, 'Terminal'),
139
+ React.createElement('span', {
140
+ className: `w-2 h-2 rounded-full ${status === 'connected' ? 'bg-emerald-400' : status === 'connecting' ? 'bg-amber-400 animate-pulse' : 'bg-red-400'}`,
141
+ }),
142
+ ),
143
+ onClose && React.createElement('button', {
144
+ onClick: onClose,
145
+ className: 'text-slate-400 hover:text-slate-200 text-xs',
146
+ title: 'Close terminal',
147
+ }, 'Close'),
148
+ ),
149
+
150
+ // Terminal container
151
+ React.createElement('div', {
152
+ ref: containerRef,
153
+ className: 'h-64 bg-aia-bg rounded-b border border-slate-700 overflow-hidden',
154
+ }),
155
+
156
+ // Error message
157
+ error && React.createElement('div', { className: 'absolute inset-0 flex items-center justify-center bg-black/80 rounded' },
158
+ React.createElement('div', { className: 'text-center' },
159
+ React.createElement('p', { className: 'text-red-400 text-sm mb-2' }, error),
160
+ React.createElement('p', { className: 'text-slate-500 text-xs' }, 'Make sure node-pty is installed'),
161
+ ),
162
+ ),
163
+ );
164
+ }
165
+
166
+ // Script loader for xterm.js from CDN
167
+ export function loadXtermScripts() {
168
+ return new Promise((resolve, reject) => {
169
+ // Check if already loaded
170
+ if (window.Terminal && window.FitAddon) {
171
+ resolve();
172
+ return;
173
+ }
174
+
175
+ // Load CSS
176
+ if (!document.querySelector('link[href*="xterm.css"]')) {
177
+ const link = document.createElement('link');
178
+ link.rel = 'stylesheet';
179
+ link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
180
+ document.head.appendChild(link);
181
+ }
182
+
183
+ // Load xterm.js
184
+ const xtermScript = document.createElement('script');
185
+ xtermScript.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js';
186
+ xtermScript.onload = () => {
187
+ // Load fit addon
188
+ const fitScript = document.createElement('script');
189
+ fitScript.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js';
190
+ fitScript.onload = resolve;
191
+ fitScript.onerror = reject;
192
+ document.head.appendChild(fitScript);
193
+ };
194
+ xtermScript.onerror = reject;
195
+ document.head.appendChild(xtermScript);
196
+ });
197
+ }
@@ -0,0 +1,205 @@
1
+ import React from 'react';
2
+ import { api } from '/main.js';
3
+ import { Terminal, loadXtermScripts } from '/components/terminal.js';
4
+
5
+ export function WorktrunkPanel({ featureName }) {
6
+ const [wtStatus, setWtStatus] = React.useState(null);
7
+ const [loading, setLoading] = React.useState(true);
8
+ const [actionLoading, setActionLoading] = React.useState(false);
9
+ const [error, setError] = React.useState(null);
10
+ const [showTerminal, setShowTerminal] = React.useState(false);
11
+ const [terminalReady, setTerminalReady] = React.useState(false);
12
+ const [servicesLoading, setServicesLoading] = React.useState(false);
13
+ const [servicesMessage, setServicesMessage] = React.useState(null);
14
+
15
+ const loadStatus = async () => {
16
+ try {
17
+ const status = await api.get(`/features/${featureName}/wt`);
18
+ setWtStatus(status);
19
+ } catch (err) {
20
+ setError(err.message);
21
+ }
22
+ setLoading(false);
23
+ };
24
+
25
+ React.useEffect(() => {
26
+ loadStatus();
27
+ }, [featureName]);
28
+
29
+ const handleCreateWorktree = async () => {
30
+ setActionLoading(true);
31
+ setError(null);
32
+ try {
33
+ await api.post(`/features/${featureName}/wt/create`);
34
+ await loadStatus();
35
+ } catch (err) {
36
+ setError(err.message);
37
+ }
38
+ setActionLoading(false);
39
+ };
40
+
41
+ const handleRemoveWorktree = async () => {
42
+ if (!confirm(`Remove worktree for "${featureName}"? This will delete the worktree directory.`)) {
43
+ return;
44
+ }
45
+ setActionLoading(true);
46
+ setError(null);
47
+ try {
48
+ await api.delete(`/features/${featureName}/wt`);
49
+ setShowTerminal(false);
50
+ await loadStatus();
51
+ } catch (err) {
52
+ setError(err.message);
53
+ }
54
+ setActionLoading(false);
55
+ };
56
+
57
+ const handleOpenTerminal = async () => {
58
+ if (!terminalReady) {
59
+ try {
60
+ await loadXtermScripts();
61
+ setTerminalReady(true);
62
+ } catch {
63
+ setError('Failed to load terminal scripts');
64
+ return;
65
+ }
66
+ }
67
+ setShowTerminal(true);
68
+ };
69
+
70
+ const handleStartServices = async () => {
71
+ setServicesLoading(true);
72
+ setError(null);
73
+ setServicesMessage(null);
74
+ try {
75
+ await api.post(`/features/${featureName}/wt/services/start`);
76
+ setServicesMessage('Services started');
77
+ setTimeout(() => setServicesMessage(null), 3000);
78
+ } catch (err) {
79
+ setError(err.message);
80
+ }
81
+ setServicesLoading(false);
82
+ };
83
+
84
+ const handleStopServices = async () => {
85
+ setServicesLoading(true);
86
+ setError(null);
87
+ setServicesMessage(null);
88
+ try {
89
+ await api.post(`/features/${featureName}/wt/services/stop`);
90
+ setServicesMessage('Services stopped');
91
+ setTimeout(() => setServicesMessage(null), 3000);
92
+ } catch (err) {
93
+ setError(err.message);
94
+ }
95
+ setServicesLoading(false);
96
+ };
97
+
98
+ if (loading) {
99
+ return React.createElement('div', { className: 'bg-aia-card border border-aia-border rounded p-4' },
100
+ React.createElement('p', { className: 'text-slate-500 text-sm' }, 'Loading worktrunk status...')
101
+ );
102
+ }
103
+
104
+ // Not installed state
105
+ if (!wtStatus?.installed) {
106
+ return React.createElement('div', { className: 'bg-slate-900 border border-slate-700 rounded p-4' },
107
+ React.createElement('div', { className: 'flex items-center gap-2 mb-2' },
108
+ React.createElement('span', { className: 'text-orange-400' }, 'Worktrunk'),
109
+ React.createElement('span', { className: 'bg-slate-700 text-slate-400 text-xs px-2 py-0.5 rounded' }, 'not installed'),
110
+ ),
111
+ React.createElement('p', { className: 'text-slate-500 text-xs mb-3' },
112
+ 'Worktrunk (wt) CLI is not installed. It enables isolated development environments using git worktrees.'
113
+ ),
114
+ React.createElement('div', { className: 'bg-black/30 rounded p-2' },
115
+ React.createElement('code', { className: 'text-xs text-slate-400' },
116
+ 'cargo install worktrunk'
117
+ ),
118
+ ),
119
+ );
120
+ }
121
+
122
+ // No worktree state
123
+ if (!wtStatus?.hasWorktree) {
124
+ return React.createElement('div', { className: 'bg-slate-900 border border-orange-500/30 rounded p-4' },
125
+ React.createElement('div', { className: 'flex items-center justify-between mb-3' },
126
+ React.createElement('div', { className: 'flex items-center gap-2' },
127
+ React.createElement('span', { className: 'text-orange-400 font-medium' }, 'Worktrunk'),
128
+ ),
129
+ ),
130
+ React.createElement('p', { className: 'text-slate-400 text-sm mb-3' },
131
+ 'Create an isolated worktree for this feature to work without affecting your main branch.'
132
+ ),
133
+ React.createElement('button', {
134
+ onClick: handleCreateWorktree,
135
+ disabled: actionLoading,
136
+ className: 'bg-orange-500/20 text-orange-400 border border-orange-500/30 rounded px-4 py-2 text-sm hover:bg-orange-500/30 disabled:opacity-40',
137
+ }, actionLoading ? 'Creating...' : 'Create Worktree'),
138
+ error && React.createElement('p', { className: 'text-red-400 text-xs mt-2' }, error),
139
+ );
140
+ }
141
+
142
+ // Active worktree state
143
+ // F8: Use wss:// for HTTPS, ws:// for HTTP
144
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
145
+ const wsUrl = `${wsProtocol}//${window.location.host}/api/terminal?cwd=${encodeURIComponent(wtStatus.path)}`;
146
+
147
+ return React.createElement('div', { className: 'bg-slate-900 border border-orange-500/30 rounded p-4 space-y-4' },
148
+ // Header
149
+ React.createElement('div', { className: 'flex items-center justify-between' },
150
+ React.createElement('div', { className: 'flex items-center gap-2' },
151
+ React.createElement('span', { className: 'text-orange-400 font-medium' }, 'Worktrunk'),
152
+ React.createElement('span', { className: 'bg-emerald-500/20 text-emerald-400 text-xs px-2 py-0.5 rounded' }, 'active'),
153
+ ),
154
+ React.createElement('button', {
155
+ onClick: handleRemoveWorktree,
156
+ disabled: actionLoading,
157
+ className: 'text-red-400 hover:text-red-300 text-xs disabled:opacity-40',
158
+ }, actionLoading ? 'Removing...' : 'Remove'),
159
+ ),
160
+
161
+ // Worktree info
162
+ React.createElement('div', { className: 'space-y-1' },
163
+ React.createElement('p', { className: 'text-xs text-slate-500' }, 'Branch'),
164
+ React.createElement('p', { className: 'text-sm text-slate-300 font-mono' }, wtStatus.branch),
165
+ React.createElement('p', { className: 'text-xs text-slate-500 mt-2' }, 'Path'),
166
+ React.createElement('p', { className: 'text-sm text-slate-400 font-mono truncate', title: wtStatus.path }, wtStatus.path),
167
+ ),
168
+
169
+ // Actions
170
+ React.createElement('div', { className: 'flex flex-wrap gap-2' },
171
+ React.createElement('button', {
172
+ onClick: showTerminal ? () => setShowTerminal(false) : handleOpenTerminal,
173
+ className: 'bg-slate-700 text-slate-300 border border-slate-600 rounded px-3 py-1.5 text-xs hover:bg-slate-600',
174
+ }, showTerminal ? 'Hide Terminal' : 'Open Terminal'),
175
+
176
+ // Docker services buttons (only if services exist)
177
+ wtStatus.hasServices && React.createElement(React.Fragment, null,
178
+ React.createElement('button', {
179
+ onClick: handleStartServices,
180
+ disabled: servicesLoading,
181
+ className: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 rounded px-3 py-1.5 text-xs hover:bg-emerald-500/30 disabled:opacity-40',
182
+ }, servicesLoading ? '...' : 'Start Services'),
183
+ React.createElement('button', {
184
+ onClick: handleStopServices,
185
+ disabled: servicesLoading,
186
+ className: 'bg-red-500/20 text-red-400 border border-red-500/30 rounded px-3 py-1.5 text-xs hover:bg-red-500/30 disabled:opacity-40',
187
+ }, servicesLoading ? '...' : 'Stop Services'),
188
+ ),
189
+ ),
190
+
191
+ // Terminal
192
+ showTerminal && terminalReady && React.createElement('div', { className: 'mt-4' },
193
+ React.createElement(Terminal, {
194
+ wsUrl,
195
+ onClose: () => setShowTerminal(false),
196
+ }),
197
+ ),
198
+
199
+ // F9: Services feedback message
200
+ servicesMessage && React.createElement('p', { className: 'text-emerald-400 text-xs' }, servicesMessage),
201
+
202
+ // Error
203
+ error && React.createElement('p', { className: 'text-red-400 text-xs' }, error),
204
+ );
205
+ }
@@ -29,6 +29,28 @@ export const api = {
29
29
  if (!res.ok) throw new Error((await res.json()).error || res.statusText);
30
30
  return res.json();
31
31
  },
32
+ async patch(path, body = {}) {
33
+ const res = await fetch(`/api${path}`, {
34
+ method: 'PATCH',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify(body),
37
+ });
38
+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
39
+ return res.json();
40
+ },
41
+ async delete(path) {
42
+ const res = await fetch(`/api${path}`, { method: 'DELETE' });
43
+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
44
+ return res.json();
45
+ },
46
+ async upload(path, formData) {
47
+ const res = await fetch(`/api${path}`, {
48
+ method: 'POST',
49
+ body: formData,
50
+ });
51
+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
52
+ return res.json();
53
+ },
32
54
  };
33
55
 
34
56
  // --- SSE stream helper ---
@@ -56,7 +78,7 @@ export async function streamPost(path, body, { onLog, onStatus }) {
56
78
  let eventType = null;
57
79
  for (const line of lines) {
58
80
  if (line.startsWith('event: ')) {
59
- eventType = line.slice(7);
81
+ eventType = line.slice(7).trim();
60
82
  } else if (line.startsWith('data: ') && eventType) {
61
83
  try {
62
84
  const data = JSON.parse(line.slice(6));
package/src/ui/router.js CHANGED
@@ -22,7 +22,7 @@ export function createRouter() {
22
22
  return null;
23
23
  }
24
24
 
25
- return { add, match, get: (p, h) => add('GET', p, h), post: (p, h) => add('POST', p, h), put: (p, h) => add('PUT', p, h), delete: (p, h) => add('DELETE', p, h) };
25
+ return { add, match, get: (p, h) => add('GET', p, h), post: (p, h) => add('POST', p, h), put: (p, h) => add('PUT', p, h), patch: (p, h) => add('PATCH', p, h), delete: (p, h) => add('DELETE', p, h) };
26
26
  }
27
27
 
28
28
  export async function parseBody(req) {
package/src/ui/server.js CHANGED
@@ -3,6 +3,7 @@ import { createConnection } from 'node:net';
3
3
  import { createReadStream, existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { WebSocketServer } from 'ws';
6
7
  import { createRouter, parseBody, json, error } from './router.js';
7
8
  import { registerApiRoutes } from './api/index.js';
8
9
 
@@ -95,6 +96,90 @@ export async function startServer(preferredPort, root = process.cwd()) {
95
96
  serveStatic(res, filePath);
96
97
  });
97
98
 
99
+ // Setup WebSocket server for terminal
100
+ const wss = new WebSocketServer({ noServer: true });
101
+
102
+ server.on('upgrade', (req, socket, head) => {
103
+ const url = new URL(req.url, `http://${req.headers.host}`);
104
+
105
+ if (url.pathname === '/api/terminal') {
106
+ wss.handleUpgrade(req, socket, head, (ws) => {
107
+ const cwd = url.searchParams.get('cwd') || root;
108
+
109
+ // Dynamically import node-pty (may not be installed)
110
+ import('node-pty').then(({ spawn: ptySpawn }) => {
111
+ // F6: Use user's preferred shell from environment
112
+ const shell = process.platform === 'win32'
113
+ ? 'powershell.exe'
114
+ : process.env.SHELL || '/bin/bash';
115
+ const ptyProcess = ptySpawn(shell, [], {
116
+ name: 'xterm-256color',
117
+ cols: 80,
118
+ rows: 24,
119
+ cwd,
120
+ env: process.env,
121
+ });
122
+
123
+ // F7: Idle timeout - close terminal after 30 minutes of inactivity
124
+ const IDLE_TIMEOUT = 30 * 60 * 1000;
125
+ let idleTimer = setTimeout(() => {
126
+ ws.send('\r\n\x1b[33m[Session closed due to inactivity]\x1b[0m\r\n');
127
+ ws.close();
128
+ }, IDLE_TIMEOUT);
129
+
130
+ const resetIdleTimer = () => {
131
+ clearTimeout(idleTimer);
132
+ idleTimer = setTimeout(() => {
133
+ ws.send('\r\n\x1b[33m[Session closed due to inactivity]\x1b[0m\r\n');
134
+ ws.close();
135
+ }, IDLE_TIMEOUT);
136
+ };
137
+
138
+ ws.on('message', (data) => {
139
+ resetIdleTimer();
140
+ const msg = data.toString();
141
+ // F5: Limit message size to 64KB
142
+ if (msg.length > 65536) return;
143
+ // Handle resize messages
144
+ if (msg.startsWith('\x1b[8;')) {
145
+ const match = msg.match(/\x1b\[8;(\d+);(\d+)t/);
146
+ if (match) {
147
+ ptyProcess.resize(parseInt(match[2], 10), parseInt(match[1], 10));
148
+ return;
149
+ }
150
+ }
151
+ ptyProcess.write(msg);
152
+ });
153
+
154
+ ptyProcess.onData((data) => {
155
+ resetIdleTimer();
156
+ try {
157
+ ws.send(data);
158
+ } catch {}
159
+ });
160
+
161
+ ws.on('close', () => {
162
+ clearTimeout(idleTimer);
163
+ ptyProcess.kill();
164
+ });
165
+
166
+ ws.on('error', () => {
167
+ clearTimeout(idleTimer);
168
+ ptyProcess.kill();
169
+ });
170
+ }).catch((err) => {
171
+ ws.send(`\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`);
172
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
173
+ ws.send(`\x1b[33mRun: npm install node-pty\x1b[0m\r\n`);
174
+ }
175
+ ws.close();
176
+ });
177
+ });
178
+ } else {
179
+ socket.destroy();
180
+ }
181
+ });
182
+
98
183
  return new Promise((resolve, reject) => {
99
184
  server.on('error', reject);
100
185
  server.listen(port, '127.0.0.1', () => {
@@ -0,0 +1,38 @@
1
+ import readline from 'node:readline';
2
+
3
+ export function isInteractive() {
4
+ return process.stdin.isTTY === true;
5
+ }
6
+
7
+ export function ask(question, defaultValue = '') {
8
+ if (!isInteractive()) {
9
+ return Promise.resolve(defaultValue);
10
+ }
11
+
12
+ const rl = readline.createInterface({
13
+ input: process.stdin,
14
+ output: process.stdout,
15
+ });
16
+ return new Promise((resolve) => {
17
+ const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
18
+ rl.question(prompt, (answer) => {
19
+ rl.close();
20
+ resolve(answer.trim() || defaultValue);
21
+ });
22
+ });
23
+ }
24
+
25
+ export async function askRequired(question) {
26
+ if (!isInteractive()) {
27
+ return '';
28
+ }
29
+
30
+ let answer = '';
31
+ while (!answer) {
32
+ answer = await ask(question);
33
+ if (!answer) {
34
+ console.log('This field is required.');
35
+ }
36
+ }
37
+ return answer;
38
+ }