create-harness-vibe-coding 0.8.18 → 0.8.19

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -0
  3. package/package.json +5 -2
  4. package/src/index.js +383 -267
  5. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  6. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  7. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  8. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  9. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  10. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  11. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  12. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  13. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  14. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  15. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  16. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  17. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  18. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  19. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  20. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  21. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  22. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  23. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  24. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  25. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  26. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  27. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  28. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  29. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  30. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  31. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  32. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  33. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  34. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  35. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  36. package/src/ui/dist/index.html +18 -0
  37. package/src/ui/index.html +17 -0
  38. package/src/ui/package.json +33 -0
  39. package/src/ui/pnpm-lock.yaml +1582 -0
  40. package/src/ui/pnpm-workspace.yaml +2 -0
  41. package/src/ui/src/App.tsx +86 -0
  42. package/src/ui/src/api.ts +93 -0
  43. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  44. package/src/ui/src/components/Footer.tsx +69 -0
  45. package/src/ui/src/components/Header.tsx +175 -0
  46. package/src/ui/src/components/LoadingView.tsx +22 -0
  47. package/src/ui/src/components/RolesRoute.tsx +169 -0
  48. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  49. package/src/ui/src/components/TaskList.tsx +388 -0
  50. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  51. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  52. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  53. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  54. package/src/ui/src/index.css +281 -0
  55. package/src/ui/src/main.tsx +11 -0
  56. package/src/ui/src/types.ts +108 -0
  57. package/src/ui/tsconfig.json +21 -0
  58. package/src/ui/vite.config.ts +19 -0
  59. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  60. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  61. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  62. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  63. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  64. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  65. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  66. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  67. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  68. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  69. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  70. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  71. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  72. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  73. package/src/wf-ui-server/a2a-store.mjs +296 -0
  74. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  75. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  76. package/src/wf-ui-server/runtime-config.mjs +153 -0
  77. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  78. package/src/wf-ui-server/security.mjs +67 -0
  79. package/src/wf-ui-server/server.mjs +849 -0
  80. package/src/wf-ui-server/session-registry.mjs +204 -0
  81. package/src/wf-ui-server/settings.mjs +100 -0
  82. package/src/wf-ui-server/task-parser.mjs +133 -0
  83. package/src/wf-ui-server/terminal-store.mjs +225 -0
  84. package/src/wf-ui-server/token.mjs +41 -0
  85. package/src/wf-ui-server/ws-events.mjs +354 -0
  86. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  87. package/templates/common/.claude/commands/wf-help.md +1 -0
  88. package/templates/common/.claude/commands/wf-task-list.md +2 -0
  89. package/templates/common/.claude/commands/wf-ui.md +26 -0
  90. package/templates/common/.claude/commands/wf-update.md +54 -9
  91. package/templates/common/.claude/rules/ecc/common.md +1 -1
  92. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  93. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  94. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  95. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  96. package/templates/common/.harness-version +63 -39
  97. package/templates/common/.opencode/commands/wf-help.md +1 -0
  98. package/templates/common/.opencode/commands/wf-task-list.md +2 -0
  99. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  100. package/templates/common/.opencode/commands/wf-update.md +54 -9
  101. package/templates/common/CLAUDE.md +1 -1
  102. package/templates/common/Harness/MEMORY.md +2 -0
  103. package/templates/common/Harness/README.md +6 -4
  104. package/templates/common/Harness/a2a/role-graph.json +79 -0
  105. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  106. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  107. package/templates/common/Harness/ownership.manifest.json +57 -2
  108. package/templates/common/Harness/scripts/README.md +134 -0
  109. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  110. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  111. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  112. package/templates/common/Harness/scripts/task-state.mjs +556 -23
  113. package/templates/common/Harness/scripts/validate-harness.mjs +229 -19
  114. package/templates/common/Harness/scripts/wf-remove.mjs +8 -3
  115. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  116. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  117. package/templates/common/Harness/settings.json +35 -0
  118. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +7 -2
  119. package/templates/common/Harness/specs/runtime/command-surface.json +14 -0
  120. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  121. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  122. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  123. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  124. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  125. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  126. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  127. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  128. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,611 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { PointerEvent } from 'react';
3
+ import { FitAddon } from '@xterm/addon-fit';
4
+ import { WebLinksAddon } from '@xterm/addon-web-links';
5
+ import { Terminal as XTerm } from '@xterm/xterm';
6
+ import '@xterm/xterm/css/xterm.css';
7
+ import { Eye, EyeOff, Maximize2, Minimize2, Square, Terminal, Trash2, X } from 'lucide-react';
8
+ import { motion } from 'motion/react';
9
+ import { apiJson, wsUrl } from '../api';
10
+ import { useReducedMotion } from '../hooks/useReducedMotion';
11
+ import type { Session } from '../types';
12
+
13
+ type Props = {
14
+ sessionId: string | null;
15
+ onClose: () => void;
16
+ };
17
+
18
+ type Point = { x: number; y: number };
19
+ type Size = { width: number; height: number };
20
+ type TerminalSize = { cols: number; rows: number };
21
+
22
+ const RUNTIME_LABELS: Record<string, string> = {
23
+ claude: 'Claude Code',
24
+ cc: 'Claude Code',
25
+ codex: 'Codex',
26
+ opencode: 'OpenCode',
27
+ gemini: 'Gemini CLI',
28
+ qwen: 'Qwen Code',
29
+ deepseek: 'DeepSeek TUI',
30
+ pi: 'Pi',
31
+ aider: 'Aider',
32
+ goose: 'Goose',
33
+ amp: 'Amp',
34
+ crush: 'Crush',
35
+ cline: 'Cline CLI',
36
+ plandex: 'Plandex',
37
+ openclaw: 'OpenClaw',
38
+ chatgpt: 'ChatGPT CLI',
39
+ openai: 'OpenAI CLI',
40
+ };
41
+
42
+ function stateColor(state: string, connected: boolean) {
43
+ if (state === 'running') return 'var(--success)';
44
+ if (state === 'saved' || state === 'stopping') return 'var(--warn)';
45
+ if (state === 'blocked' || state === 'exited') return 'var(--danger)';
46
+ if (state === 'starting') return 'var(--warn)';
47
+ if (!connected && state === 'running') return 'var(--success)';
48
+ return 'var(--muted)';
49
+ }
50
+
51
+ function initialPosition(): Point {
52
+ const width = Math.min(980, Math.max(680, window.innerWidth * 0.62));
53
+ return {
54
+ x: Math.max(16, window.innerWidth - width - 28),
55
+ y: 82,
56
+ };
57
+ }
58
+
59
+ function initialSize(): Size {
60
+ return {
61
+ width: Math.min(980, Math.max(680, window.innerWidth * 0.62)),
62
+ height: Math.min(680, Math.max(460, window.innerHeight * 0.68)),
63
+ };
64
+ }
65
+
66
+ function shortId(value: string | undefined | null) {
67
+ if (!value) return '';
68
+ return value.length > 18 ? `${value.slice(0, 8)}...${value.slice(-6)}` : value;
69
+ }
70
+
71
+ function titleForSession(session: Session | null, sessionId: string | null) {
72
+ const runtime = session?.runtime || '';
73
+ const label = RUNTIME_LABELS[runtime] || runtime || 'Agent Terminal';
74
+ const peer = session?.peerId || session?.sessionId || sessionId || '';
75
+ return `${label} - ${shortId(peer)}`;
76
+ }
77
+
78
+ function isLiveState(state: string) {
79
+ return state === 'running' || state === 'starting';
80
+ }
81
+
82
+ export default function TerminalDrawer({ sessionId, onClose }: Props) {
83
+ const [attachMode, setAttachMode] = useState(true);
84
+ const [sessionState, setSessionState] = useState<string>('starting');
85
+ const [sessionMeta, setSessionMeta] = useState<Session | null>(null);
86
+ const [ptyTitle, setPtyTitle] = useState('');
87
+ const [open, setOpen] = useState(false);
88
+ const [connected, setConnected] = useState(false);
89
+ const [terminalReady, setTerminalReady] = useState(false);
90
+ const [stopping, setStopping] = useState(false);
91
+ const [minimized, setMinimized] = useState(false);
92
+ const [terminalSize, setTerminalSize] = useState<TerminalSize>({ cols: 0, rows: 0 });
93
+ const [position, setPosition] = useState<Point>(() => typeof window === 'undefined' ? { x: 40, y: 80 } : initialPosition());
94
+ const [size, setSize] = useState<Size>(() => typeof window === 'undefined' ? { width: 760, height: 480 } : initialSize());
95
+ const reducedMotion = useReducedMotion();
96
+ const terminalHostRef = useRef<HTMLDivElement>(null);
97
+ const terminalRef = useRef<XTerm | null>(null);
98
+ const fitAddonRef = useRef<FitAddon | null>(null);
99
+ const wsRef = useRef<WebSocket | null>(null);
100
+ const attachModeRef = useRef(true);
101
+ const sessionStateRef = useRef('starting');
102
+ const activeSessionRef = useRef<string | null>(null);
103
+ const reconnectTimerRef = useRef<number | null>(null);
104
+ const pollingTimerRef = useRef<number | null>(null);
105
+ const resizeSyncTimerRef = useRef<number | null>(null);
106
+ const lastResizeSentRef = useRef<TerminalSize>({ cols: 0, rows: 0 });
107
+ const lastSeqRef = useRef(0);
108
+ const dragRef = useRef<{ mode: 'move' | 'resize'; start: Point; position: Point; size: Size } | null>(null);
109
+
110
+ const interactive = attachMode && isLiveState(sessionState);
111
+ const title = titleForSession(sessionMeta, sessionId);
112
+
113
+ const sendMessage = useCallback((msg: object) => {
114
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
115
+ wsRef.current.send(JSON.stringify(msg));
116
+ return true;
117
+ }
118
+ return false;
119
+ }, []);
120
+
121
+ const schedulePtyResize = useCallback((next: TerminalSize, delay = 80) => {
122
+ if (!next.cols || !next.rows) return;
123
+ if (resizeSyncTimerRef.current) window.clearTimeout(resizeSyncTimerRef.current);
124
+ resizeSyncTimerRef.current = window.setTimeout(() => {
125
+ const term = terminalRef.current;
126
+ const target = term ? { cols: term.cols, rows: term.rows } : next;
127
+ const last = lastResizeSentRef.current;
128
+ if (target.cols === last.cols && target.rows === last.rows) return;
129
+ if (sendMessage({ type: 'pty:resize', cols: target.cols, rows: target.rows })) {
130
+ lastResizeSentRef.current = target;
131
+ }
132
+ }, delay);
133
+ }, [sendMessage]);
134
+
135
+ const fitAndSync = useCallback(() => {
136
+ const term = terminalRef.current;
137
+ const fit = fitAddonRef.current;
138
+ if (!term || !fit) return;
139
+ try {
140
+ fit.fit();
141
+ const next = { cols: term.cols, rows: term.rows };
142
+ setTerminalSize(next);
143
+ schedulePtyResize(next);
144
+ } catch {
145
+ // The host can be temporarily hidden during route transitions.
146
+ }
147
+ }, [schedulePtyResize]);
148
+
149
+ const writeInput = useCallback((data: string) => {
150
+ const sid = activeSessionRef.current;
151
+ if (!sid || !attachModeRef.current || !data) return;
152
+ if (sendMessage({ type: 'pty:input', data })) return;
153
+ apiJson(`/api/sessions/${encodeURIComponent(sid)}/input`, {
154
+ method: 'POST',
155
+ body: JSON.stringify({ data }),
156
+ }).catch((e: any) => {
157
+ terminalRef.current?.writeln(`\r\n[error] ${e?.message || 'input rejected'}`);
158
+ });
159
+ }, [sendMessage]);
160
+
161
+ const setAttach = useCallback(async (next: boolean) => {
162
+ if (!sessionId) return;
163
+ setAttachMode(next);
164
+ attachModeRef.current = next;
165
+ if (terminalRef.current) terminalRef.current.options.disableStdin = !next || !isLiveState(sessionStateRef.current);
166
+ sendMessage({ type: 'control:attach-mode', attachMode: next });
167
+ try {
168
+ await apiJson(`/api/sessions/${encodeURIComponent(sessionId)}/attach-mode`, {
169
+ method: 'POST',
170
+ body: JSON.stringify({ attachMode: next }),
171
+ });
172
+ if (next) terminalRef.current?.focus();
173
+ } catch (e: any) {
174
+ terminalRef.current?.writeln(`\r\n[error] ${e?.message || 'attach mode update failed'}`);
175
+ }
176
+ }, [sendMessage, sessionId]);
177
+
178
+ const loadSessionMeta = useCallback(async (sid: string) => {
179
+ try {
180
+ const sessions = await apiJson<Session[]>('/api/sessions');
181
+ const current = sessions.find(session => session.sessionId === sid) || null;
182
+ if (activeSessionRef.current !== sid) return;
183
+ setSessionMeta(current);
184
+ if (current?.status) {
185
+ setSessionState(current.status);
186
+ sessionStateRef.current = current.status;
187
+ }
188
+ } catch {
189
+ // Metadata is helpful, but the terminal can still render without it.
190
+ }
191
+ }, []);
192
+
193
+ const loadHistory = useCallback(async (sid: string, { reset = true }: { reset?: boolean } = {}) => {
194
+ try {
195
+ const fromSeq = reset ? undefined : lastSeqRef.current + 1;
196
+ const path = fromSeq
197
+ ? `/api/terminals/${encodeURIComponent(sid)}/range?fromSeq=${fromSeq}`
198
+ : `/api/terminals/${encodeURIComponent(sid)}/range?tail=800`;
199
+ const range = await apiJson<{ entries: { seq?: number; stream?: string; data: string }[] }>(path);
200
+ if (activeSessionRef.current !== sid) return;
201
+ const term = terminalRef.current;
202
+ if (!term) return;
203
+ if (reset) term.reset();
204
+ for (const entry of range.entries) {
205
+ lastSeqRef.current = Math.max(lastSeqRef.current, Number(entry.seq || 0));
206
+ if (entry.stream === 'stdin') continue;
207
+ term.write(String(entry.data || ''));
208
+ }
209
+ } catch {
210
+ // New sessions may not have a transcript yet.
211
+ }
212
+ }, []);
213
+
214
+ const startPollingFallback = useCallback((sid: string) => {
215
+ if (pollingTimerRef.current) window.clearInterval(pollingTimerRef.current);
216
+ pollingTimerRef.current = window.setInterval(() => {
217
+ if (wsRef.current?.readyState === WebSocket.OPEN) return;
218
+ loadHistory(sid, { reset: false });
219
+ loadSessionMeta(sid);
220
+ }, 1500);
221
+ }, [loadHistory, loadSessionMeta]);
222
+
223
+ const connectWs = useCallback((sid: string) => {
224
+ try {
225
+ wsRef.current?.close();
226
+ if (reconnectTimerRef.current) window.clearTimeout(reconnectTimerRef.current);
227
+ const ws = new WebSocket(wsUrl(`/ws/terminal/${encodeURIComponent(sid)}`));
228
+ wsRef.current = ws;
229
+ ws.onopen = () => {
230
+ if (activeSessionRef.current !== sid) return;
231
+ setConnected(true);
232
+ ws.send(JSON.stringify({ type: 'control:attach-mode', attachMode: attachModeRef.current }));
233
+ fitAndSync();
234
+ terminalRef.current?.focus();
235
+ };
236
+ ws.onmessage = (event) => {
237
+ try {
238
+ const msg = JSON.parse(event.data);
239
+ if (msg.type === 'pty:data') {
240
+ terminalRef.current?.write(String(msg.data || ''));
241
+ } else if (msg.type === 'session:state') {
242
+ const nextState = String(msg.state || 'unknown');
243
+ setSessionState(nextState);
244
+ sessionStateRef.current = nextState;
245
+ if (typeof msg.attachMode === 'boolean') {
246
+ setAttachMode(msg.attachMode);
247
+ attachModeRef.current = msg.attachMode;
248
+ }
249
+ if (terminalRef.current) {
250
+ terminalRef.current.options.disableStdin = !attachModeRef.current || !isLiveState(nextState);
251
+ }
252
+ } else if (msg.type === 'session:error') {
253
+ terminalRef.current?.writeln(`\r\n[error] ${msg.message}`);
254
+ }
255
+ } catch {
256
+ terminalRef.current?.write(String(event.data || ''));
257
+ }
258
+ };
259
+ ws.onclose = () => {
260
+ if (activeSessionRef.current !== sid) return;
261
+ setConnected(false);
262
+ if (isLiveState(sessionStateRef.current)) {
263
+ reconnectTimerRef.current = window.setTimeout(() => connectWs(sid), 1200);
264
+ }
265
+ };
266
+ ws.onerror = () => setConnected(false);
267
+ } catch {
268
+ setConnected(false);
269
+ }
270
+ }, [fitAndSync]);
271
+
272
+ useEffect(() => {
273
+ attachModeRef.current = attachMode;
274
+ const term = terminalRef.current;
275
+ if (term) term.options.disableStdin = !attachMode || !isLiveState(sessionState);
276
+ }, [attachMode, sessionState]);
277
+
278
+ useEffect(() => {
279
+ if (!sessionId) return;
280
+ activeSessionRef.current = sessionId;
281
+ setOpen(true);
282
+ setMinimized(false);
283
+ setConnected(false);
284
+ setTerminalReady(Boolean(terminalRef.current));
285
+ setStopping(false);
286
+ setAttachMode(true);
287
+ attachModeRef.current = true;
288
+ setSessionState('starting');
289
+ sessionStateRef.current = 'starting';
290
+ setSessionMeta(null);
291
+ setPtyTitle('');
292
+ lastResizeSentRef.current = { cols: 0, rows: 0 };
293
+ lastSeqRef.current = 0;
294
+ setPosition(initialPosition());
295
+ setSize(initialSize());
296
+ }, [sessionId]);
297
+
298
+ useEffect(() => {
299
+ if (!open || !terminalHostRef.current || terminalRef.current) return;
300
+ const term = new XTerm({
301
+ cursorBlink: true,
302
+ convertEol: false,
303
+ disableStdin: false,
304
+ fontFamily: '"Cascadia Mono", "Cascadia Code", Consolas, "SFMono-Regular", Menlo, monospace',
305
+ fontSize: 13,
306
+ lineHeight: 1.15,
307
+ scrollback: 8000,
308
+ theme: {
309
+ background: '#0b0d10',
310
+ foreground: '#e5e7eb',
311
+ cursor: '#f9fafb',
312
+ selectionBackground: '#374151',
313
+ black: '#111827',
314
+ red: '#f87171',
315
+ green: '#86efac',
316
+ yellow: '#fde68a',
317
+ blue: '#93c5fd',
318
+ magenta: '#d8b4fe',
319
+ cyan: '#67e8f9',
320
+ white: '#f9fafb',
321
+ brightBlack: '#6b7280',
322
+ brightRed: '#fca5a5',
323
+ brightGreen: '#bbf7d0',
324
+ brightYellow: '#fef3c7',
325
+ brightBlue: '#bfdbfe',
326
+ brightMagenta: '#e9d5ff',
327
+ brightCyan: '#a5f3fc',
328
+ brightWhite: '#ffffff',
329
+ },
330
+ });
331
+ const fit = new FitAddon();
332
+ term.loadAddon(fit);
333
+ term.loadAddon(new WebLinksAddon());
334
+ term.open(terminalHostRef.current);
335
+ const titleDisposable = term.onTitleChange(title => setPtyTitle(title));
336
+ const dataDisposable = term.onData(writeInput);
337
+ const resizeDisposable = term.onResize(next => {
338
+ setTerminalSize(next);
339
+ schedulePtyResize(next);
340
+ });
341
+ terminalRef.current = term;
342
+ fitAddonRef.current = fit;
343
+ setTerminalReady(true);
344
+ requestAnimationFrame(fitAndSync);
345
+
346
+ const observer = new ResizeObserver(() => fitAndSync());
347
+ observer.observe(terminalHostRef.current);
348
+
349
+ return () => {
350
+ observer.disconnect();
351
+ titleDisposable.dispose();
352
+ dataDisposable.dispose();
353
+ resizeDisposable.dispose();
354
+ fit.dispose();
355
+ if (resizeSyncTimerRef.current) window.clearTimeout(resizeSyncTimerRef.current);
356
+ term.dispose();
357
+ terminalRef.current = null;
358
+ fitAddonRef.current = null;
359
+ setTerminalReady(false);
360
+ };
361
+ }, [fitAndSync, open, schedulePtyResize, writeInput]);
362
+
363
+ useEffect(() => {
364
+ if (!sessionId || !terminalReady || !terminalRef.current) return;
365
+ let cancelled = false;
366
+ (async () => {
367
+ await loadSessionMeta(sessionId);
368
+ await loadHistory(sessionId);
369
+ if (cancelled || activeSessionRef.current !== sessionId) return;
370
+ connectWs(sessionId);
371
+ setAttach(true);
372
+ startPollingFallback(sessionId);
373
+ })();
374
+ return () => {
375
+ cancelled = true;
376
+ wsRef.current?.close();
377
+ if (reconnectTimerRef.current) window.clearTimeout(reconnectTimerRef.current);
378
+ if (pollingTimerRef.current) window.clearInterval(pollingTimerRef.current);
379
+ if (resizeSyncTimerRef.current) window.clearTimeout(resizeSyncTimerRef.current);
380
+ };
381
+ }, [connectWs, loadHistory, loadSessionMeta, sessionId, setAttach, startPollingFallback, terminalReady]);
382
+
383
+ const clampPosition = (next: Point, nextSize = size): Point => ({
384
+ x: Math.min(Math.max(8, next.x), Math.max(8, window.innerWidth - Math.min(240, nextSize.width))),
385
+ y: Math.min(Math.max(8, next.y), Math.max(8, window.innerHeight - 72)),
386
+ });
387
+
388
+ const clearOutput = () => {
389
+ terminalRef.current?.clear();
390
+ };
391
+
392
+ const stopSession = async () => {
393
+ if (!sessionId || stopping) return;
394
+ const previousState = sessionStateRef.current;
395
+ setStopping(true);
396
+ setSessionState('stopping');
397
+ sessionStateRef.current = 'stopping';
398
+ if (terminalRef.current) terminalRef.current.options.disableStdin = true;
399
+ window.dispatchEvent(new CustomEvent('harness:sessions-changed', {
400
+ detail: { sessionId, state: 'stopping' },
401
+ }));
402
+ try {
403
+ await apiJson(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, { method: 'POST' });
404
+ setSessionState('saved');
405
+ sessionStateRef.current = 'saved';
406
+ window.dispatchEvent(new CustomEvent('harness:sessions-changed', {
407
+ detail: { sessionId, state: 'saved' },
408
+ }));
409
+ close();
410
+ } catch (e: any) {
411
+ setSessionState(previousState);
412
+ sessionStateRef.current = previousState;
413
+ if (terminalRef.current) terminalRef.current.options.disableStdin = !attachModeRef.current || !isLiveState(previousState);
414
+ setStopping(false);
415
+ terminalRef.current?.writeln(`\r\n[error] ${e?.message || 'stop failed'}`);
416
+ }
417
+ };
418
+
419
+ const close = () => {
420
+ setOpen(false);
421
+ wsRef.current?.close();
422
+ if (reconnectTimerRef.current) window.clearTimeout(reconnectTimerRef.current);
423
+ if (pollingTimerRef.current) window.clearInterval(pollingTimerRef.current);
424
+ if (resizeSyncTimerRef.current) window.clearTimeout(resizeSyncTimerRef.current);
425
+ onClose();
426
+ };
427
+
428
+ const startMove = (event: PointerEvent<HTMLDivElement>) => {
429
+ if ((event.target as HTMLElement).closest('button')) return;
430
+ dragRef.current = { mode: 'move', start: { x: event.clientX, y: event.clientY }, position, size };
431
+ event.currentTarget.setPointerCapture(event.pointerId);
432
+ };
433
+
434
+ const startResize = (event: PointerEvent<HTMLDivElement>) => {
435
+ event.stopPropagation();
436
+ dragRef.current = { mode: 'resize', start: { x: event.clientX, y: event.clientY }, position, size };
437
+ event.currentTarget.setPointerCapture(event.pointerId);
438
+ };
439
+
440
+ const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
441
+ const drag = dragRef.current;
442
+ if (!drag) return;
443
+ const dx = event.clientX - drag.start.x;
444
+ const dy = event.clientY - drag.start.y;
445
+ if (drag.mode === 'move') {
446
+ setPosition(clampPosition({ x: drag.position.x + dx, y: drag.position.y + dy }, drag.size));
447
+ return;
448
+ }
449
+ const nextSize = {
450
+ width: Math.min(Math.max(500, drag.size.width + dx), Math.max(520, window.innerWidth - drag.position.x - 12)),
451
+ height: Math.min(Math.max(340, drag.size.height + dy), Math.max(360, window.innerHeight - drag.position.y - 12)),
452
+ };
453
+ setSize(nextSize);
454
+ requestAnimationFrame(fitAndSync);
455
+ };
456
+
457
+ const endPointer = (event: PointerEvent<HTMLDivElement>) => {
458
+ dragRef.current = null;
459
+ try {
460
+ event.currentTarget.releasePointerCapture(event.pointerId);
461
+ } catch {
462
+ // Pointer capture can be released by the browser first.
463
+ }
464
+ };
465
+
466
+ if (!open) return null;
467
+
468
+ if (minimized) {
469
+ return (
470
+ <button
471
+ data-testid="terminal-minimized"
472
+ onClick={() => setMinimized(false)}
473
+ style={{
474
+ position: 'fixed',
475
+ left: 16,
476
+ bottom: 10,
477
+ zIndex: 90,
478
+ width: 390,
479
+ maxWidth: 'calc(100vw - 32px)',
480
+ height: 36,
481
+ display: 'flex',
482
+ alignItems: 'center',
483
+ gap: 8,
484
+ padding: '0 12px',
485
+ border: '1px solid var(--border)',
486
+ borderRadius: 'var(--radius)',
487
+ background: 'rgba(255,255,255,0.96)',
488
+ boxShadow: '0 10px 28px rgba(0,0,0,0.16)',
489
+ color: 'var(--fg)',
490
+ textAlign: 'left',
491
+ }}
492
+ >
493
+ <Terminal size={13} />
494
+ <span style={{ fontSize: 11, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
495
+ <span style={{ marginLeft: 'auto', fontSize: 10, color: stateColor(sessionState, connected) }}>{sessionState}</span>
496
+ </button>
497
+ );
498
+ }
499
+
500
+ return (
501
+ <motion.div
502
+ data-testid="terminal-window"
503
+ initial={reducedMotion ? undefined : { opacity: 0, scale: 0.98 }}
504
+ animate={{ opacity: 1, scale: 1 }}
505
+ onPointerMove={handlePointerMove}
506
+ onPointerUp={endPointer}
507
+ onPointerCancel={endPointer}
508
+ style={{
509
+ position: 'fixed',
510
+ left: position.x,
511
+ top: position.y,
512
+ width: size.width,
513
+ height: size.height,
514
+ zIndex: 88,
515
+ display: 'flex',
516
+ flexDirection: 'column',
517
+ border: '1px solid var(--border)',
518
+ borderRadius: 'var(--radius)',
519
+ background: 'var(--bg)',
520
+ boxShadow: '0 24px 80px rgba(0,0,0,0.22)',
521
+ overflow: 'hidden',
522
+ }}
523
+ >
524
+ <div
525
+ onPointerDown={startMove}
526
+ style={{
527
+ height: 42,
528
+ display: 'flex',
529
+ alignItems: 'center',
530
+ padding: '0 10px',
531
+ borderBottom: '1px solid var(--border)',
532
+ gap: 8,
533
+ background: 'rgba(255,255,255,0.96)',
534
+ cursor: 'move',
535
+ flexShrink: 0,
536
+ }}
537
+ >
538
+ <Terminal size={13} />
539
+ <span data-testid="terminal-session-id" title={sessionId || ''} style={{ fontSize: 11, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
540
+ {ptyTitle && (
541
+ <span title={ptyTitle} style={{ fontSize: 10, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 160 }}>
542
+ {ptyTitle}
543
+ </span>
544
+ )}
545
+ <span style={{ fontSize: 10, color: connected ? 'var(--success)' : 'var(--warn)', flexShrink: 0 }}>
546
+ {connected ? 'ws live' : 'http fallback'}
547
+ </span>
548
+ <span style={{ fontSize: 10, color: stateColor(sessionState, connected), flexShrink: 0 }}>
549
+ pty {sessionState}
550
+ </span>
551
+ <span style={{ flex: 1 }} />
552
+ <button
553
+ data-testid="terminal-attach-toggle"
554
+ title={attachMode ? 'Attach mode' : 'Watch mode'}
555
+ onClick={() => setAttach(!attachMode)}
556
+ style={{ fontSize: 10, color: attachMode ? 'var(--success)' : 'var(--muted)', display: 'flex', alignItems: 'center', gap: 4, border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '3px 7px', flexShrink: 0 }}
557
+ >
558
+ {attachMode ? <Eye size={10} /> : <EyeOff size={10} />}
559
+ {attachMode ? 'attach' : 'watch'}
560
+ </button>
561
+ <button title="Clear terminal" onClick={clearOutput} style={{ color: 'var(--muted)', display: 'grid', placeItems: 'center', width: 26, height: 26, border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
562
+ <Trash2 size={11} />
563
+ </button>
564
+ <button title="Minimize terminal" onClick={() => setMinimized(true)} style={{ color: 'var(--muted)', display: 'grid', placeItems: 'center', width: 26, height: 26, border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
565
+ <Minimize2 size={11} />
566
+ </button>
567
+ <button data-testid="terminal-stop" title="Stop session" onClick={stopSession} disabled={stopping} style={{ color: 'var(--danger)', display: 'grid', placeItems: 'center', width: 26, height: 26, border: '1px solid var(--border)', borderRadius: 'var(--radius)', opacity: stopping ? 0.45 : 1 }}>
568
+ <Square size={11} />
569
+ </button>
570
+ <button title="Close terminal" onClick={close} style={{ color: 'var(--muted)', display: 'grid', placeItems: 'center', width: 26, height: 26, border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
571
+ <X size={12} />
572
+ </button>
573
+ </div>
574
+
575
+ <div
576
+ ref={terminalHostRef}
577
+ data-testid="terminal-output"
578
+ onPointerDown={() => terminalRef.current?.focus()}
579
+ style={{
580
+ flex: 1,
581
+ minHeight: 0,
582
+ background: '#0b0d10',
583
+ overflow: 'hidden',
584
+ }}
585
+ />
586
+
587
+ <div style={{ height: 34, padding: '4px 10px', borderTop: '1px solid var(--border)', display: 'flex', gap: 6, fontSize: 10, color: 'var(--muted)', alignItems: 'center', flexShrink: 0 }}>
588
+ <button onClick={fitAndSync} title="Fit terminal viewport" style={{ fontSize: 10, display: 'flex', alignItems: 'center', gap: 3, border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '2px 6px' }}>
589
+ <Maximize2 size={9} /> Fit
590
+ </button>
591
+ <span style={{ flex: 1 }} />
592
+ <span>{stopping ? 'stopping...' : interactive ? 'input enabled' : 'input locked'}</span>
593
+ <span>{terminalSize.cols || '--'}x{terminalSize.rows || '--'}</span>
594
+ </div>
595
+
596
+ <div
597
+ onPointerDown={startResize}
598
+ title="Resize terminal"
599
+ style={{
600
+ position: 'absolute',
601
+ right: 0,
602
+ bottom: 0,
603
+ width: 18,
604
+ height: 18,
605
+ cursor: 'nwse-resize',
606
+ background: 'linear-gradient(135deg, transparent 45%, rgba(107,114,128,0.35) 46%, rgba(107,114,128,0.35) 55%, transparent 56%)',
607
+ }}
608
+ />
609
+ </motion.div>
610
+ );
611
+ }