@runloop/rl-cli 1.6.0 → 1.7.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.
@@ -0,0 +1,439 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * ExecViewer - Unified execution viewer for both sync and async modes
4
+ * Supports kill, leave-early, and run-another for both modes
5
+ */
6
+ import React from "react";
7
+ import { Box, Text, useInput } from "ink";
8
+ import Spinner from "ink-spinner";
9
+ import figures from "figures";
10
+ import { Breadcrumb } from "./Breadcrumb.js";
11
+ import { NavigationTips } from "./NavigationTips.js";
12
+ import { colors } from "../utils/theme.js";
13
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
14
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
15
+ import { execCommandAsync, getExecution, killExecution, } from "../services/devboxService.js";
16
+ import { getClient } from "../utils/client.js";
17
+ export const ExecViewer = ({ devboxId, command, breadcrumbItems, onBack, onRunAnother, existingExecutionId, onExecutionStart, }) => {
18
+ // State
19
+ const [status, setStatus] = React.useState(existingExecutionId ? "running" : "starting");
20
+ const [executionId, setExecutionId] = React.useState(existingExecutionId || null);
21
+ const [stdout, setStdout] = React.useState("");
22
+ const [stderr, setStderr] = React.useState("");
23
+ const [exitCode, setExitCode] = React.useState(null);
24
+ const [error, setError] = React.useState(null);
25
+ const [scroll, setScroll] = React.useState(0);
26
+ const [copyStatus, setCopyStatus] = React.useState(null);
27
+ const [elapsedTime, setElapsedTime] = React.useState(0);
28
+ const [finalDuration, setFinalDuration] = React.useState(null);
29
+ const [autoScroll, setAutoScroll] = React.useState(true);
30
+ // Use ref for start time so we can set it when execution actually starts
31
+ const startTimeRef = React.useRef(Date.now());
32
+ // Refs for cleanup
33
+ const abortControllerRef = React.useRef(null);
34
+ const pollIntervalRef = React.useRef(null);
35
+ const streamCleanupRef = React.useRef(null);
36
+ // Ref for callback to avoid stale closures
37
+ const onExecutionStartRef = React.useRef(onExecutionStart);
38
+ onExecutionStartRef.current = onExecutionStart;
39
+ // Viewport calculation
40
+ const execViewport = useViewportHeight({ overhead: 16, minHeight: 10 });
41
+ // Handle Ctrl+C
42
+ useExitOnCtrlC();
43
+ // Elapsed time updater (in milliseconds)
44
+ React.useEffect(() => {
45
+ if (status === "running" || status === "starting") {
46
+ const timer = setInterval(() => {
47
+ setElapsedTime(Date.now() - startTimeRef.current);
48
+ }, 100);
49
+ return () => clearInterval(timer);
50
+ }
51
+ }, [status]);
52
+ // Track if execution has started to prevent re-execution on re-renders
53
+ const executionStartedRef = React.useRef(false);
54
+ // Store the initial existingExecutionId to detect true remounts vs just prop updates
55
+ const initialExistingExecutionIdRef = React.useRef(existingExecutionId);
56
+ // Start execution on mount (only once), or resume if existingExecutionId provided
57
+ React.useEffect(() => {
58
+ // Prevent re-execution if already started (e.g., during resize re-renders)
59
+ // Also ignore if existingExecutionId changed from null to a value (that's us reporting back)
60
+ if (executionStartedRef.current) {
61
+ // Only resume if this is a true remount (component was unmounted and remounted with an ID)
62
+ // Not if we just reported the ID back to parent
63
+ return;
64
+ }
65
+ executionStartedRef.current = true;
66
+ const startOrResumeExecution = async () => {
67
+ // If we have an existing execution ID on initial mount, resume
68
+ if (initialExistingExecutionIdRef.current) {
69
+ try {
70
+ // Fetch current output state before resuming
71
+ const currentState = await getExecution(devboxId, initialExistingExecutionIdRef.current);
72
+ setStdout(currentState.stdout ?? "");
73
+ setStderr(currentState.stderr ?? "");
74
+ if (currentState.status === "completed") {
75
+ setFinalDuration(Date.now() - startTimeRef.current);
76
+ setStatus("completed");
77
+ setExitCode(currentState.exit_status ?? 0);
78
+ return;
79
+ }
80
+ }
81
+ catch {
82
+ // If fetch fails, just continue with polling
83
+ }
84
+ // Reconnect to streams - they'll replay from offset 0
85
+ startStreaming(initialExistingExecutionIdRef.current);
86
+ return;
87
+ }
88
+ // Start a new execution
89
+ try {
90
+ const result = await execCommandAsync(devboxId, command);
91
+ // Reset start time to when execution actually begins
92
+ startTimeRef.current = Date.now();
93
+ setExecutionId(result.executionId);
94
+ setStatus("running");
95
+ // Report execution ID to parent so it survives remounts
96
+ if (onExecutionStartRef.current) {
97
+ onExecutionStartRef.current(result.executionId);
98
+ }
99
+ // Always use streaming for live output
100
+ startStreaming(result.executionId);
101
+ }
102
+ catch (err) {
103
+ setError(err.message);
104
+ setStatus("failed");
105
+ }
106
+ };
107
+ startOrResumeExecution();
108
+ // Cleanup on unmount only - not on dep changes since executionStartedRef prevents re-run
109
+ return () => {
110
+ if (pollIntervalRef.current) {
111
+ clearInterval(pollIntervalRef.current);
112
+ }
113
+ if (streamCleanupRef.current) {
114
+ streamCleanupRef.current();
115
+ }
116
+ if (abortControllerRef.current) {
117
+ abortControllerRef.current.abort();
118
+ }
119
+ };
120
+ }, [devboxId, command]);
121
+ // Start streaming for live output
122
+ const startStreaming = async (execId) => {
123
+ const client = getClient();
124
+ abortControllerRef.current = new AbortController();
125
+ let stdoutOffset = 0;
126
+ let stderrOffset = 0;
127
+ let isCompleted = false;
128
+ // Wait for execution to complete using long-polling
129
+ const waitForCompletion = async () => {
130
+ try {
131
+ const result = await client.devboxes.executions.awaitCompleted(devboxId, execId);
132
+ isCompleted = true;
133
+ // Get final output to ensure we have everything
134
+ setStdout(result.stdout ?? "");
135
+ setStderr(result.stderr ?? "");
136
+ setExitCode(result.exit_status ?? 0);
137
+ setFinalDuration(Date.now() - startTimeRef.current);
138
+ setStatus("completed");
139
+ }
140
+ catch {
141
+ // Ignore errors - execution may have been killed
142
+ }
143
+ };
144
+ // Start waiting for completion
145
+ waitForCompletion();
146
+ // Stream stdout
147
+ const streamStdout = async () => {
148
+ try {
149
+ const stream = await client.devboxes.executions.streamStdoutUpdates(devboxId, execId, { offset: stdoutOffset.toString() });
150
+ for await (const chunk of stream) {
151
+ if (abortControllerRef.current?.signal.aborted || isCompleted)
152
+ break;
153
+ if (chunk.output) {
154
+ setStdout((prev) => {
155
+ const newOutput = prev + chunk.output;
156
+ // Truncate if too long
157
+ if (newOutput.length > 10000) {
158
+ return newOutput.substring(newOutput.length - 10000);
159
+ }
160
+ return newOutput;
161
+ });
162
+ if (chunk.offset !== undefined) {
163
+ stdoutOffset = chunk.offset;
164
+ }
165
+ }
166
+ }
167
+ }
168
+ catch {
169
+ // Stream ended or error - fall back to polling if not completed
170
+ // Note: Don't check status here as it may be stale due to closure
171
+ if (!isCompleted) {
172
+ startPolling(execId);
173
+ }
174
+ }
175
+ };
176
+ // Stream stderr
177
+ const streamStderr = async () => {
178
+ try {
179
+ const stream = await client.devboxes.executions.streamStderrUpdates(devboxId, execId, { offset: stderrOffset.toString() });
180
+ for await (const chunk of stream) {
181
+ if (abortControllerRef.current?.signal.aborted || isCompleted)
182
+ break;
183
+ if (chunk.output) {
184
+ setStderr((prev) => {
185
+ const newOutput = prev + chunk.output;
186
+ // Truncate if too long
187
+ if (newOutput.length > 10000) {
188
+ return newOutput.substring(newOutput.length - 10000);
189
+ }
190
+ return newOutput;
191
+ });
192
+ if (chunk.offset !== undefined) {
193
+ stderrOffset = chunk.offset;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ catch {
199
+ // Stream ended or error - ignore, awaitCompleted will handle completion
200
+ }
201
+ };
202
+ // Start both streams
203
+ streamStdout();
204
+ streamStderr();
205
+ streamCleanupRef.current = () => {
206
+ abortControllerRef.current?.abort();
207
+ };
208
+ };
209
+ // Start polling (sync mode or fallback)
210
+ const startPolling = (execId) => {
211
+ const poll = async () => {
212
+ try {
213
+ const result = await getExecution(devboxId, execId);
214
+ setStdout(result.stdout ?? "");
215
+ setStderr(result.stderr ?? "");
216
+ if (result.status === "completed") {
217
+ setExitCode(result.exit_status ?? 0);
218
+ setFinalDuration(Date.now() - startTimeRef.current);
219
+ setStatus("completed");
220
+ if (pollIntervalRef.current) {
221
+ clearInterval(pollIntervalRef.current);
222
+ }
223
+ }
224
+ }
225
+ catch (err) {
226
+ setError(err.message);
227
+ setStatus("failed");
228
+ if (pollIntervalRef.current) {
229
+ clearInterval(pollIntervalRef.current);
230
+ }
231
+ }
232
+ };
233
+ // Initial poll
234
+ poll();
235
+ // Continue polling every 500ms
236
+ pollIntervalRef.current = setInterval(poll, 500);
237
+ };
238
+ // Kill execution
239
+ const handleKill = async () => {
240
+ if (!executionId || status !== "running")
241
+ return;
242
+ try {
243
+ await killExecution(devboxId, executionId);
244
+ setFinalDuration(Date.now() - startTimeRef.current);
245
+ setStatus("killed");
246
+ if (pollIntervalRef.current) {
247
+ clearInterval(pollIntervalRef.current);
248
+ }
249
+ if (streamCleanupRef.current) {
250
+ streamCleanupRef.current();
251
+ }
252
+ }
253
+ catch (err) {
254
+ setError(`Failed to kill: ${err.message}`);
255
+ }
256
+ };
257
+ // Copy output to clipboard
258
+ const handleCopy = async () => {
259
+ const output = stdout + stderr;
260
+ const { spawn } = await import("child_process");
261
+ const platform = process.platform;
262
+ let cmd;
263
+ let args;
264
+ if (platform === "darwin") {
265
+ cmd = "pbcopy";
266
+ args = [];
267
+ }
268
+ else if (platform === "win32") {
269
+ cmd = "clip";
270
+ args = [];
271
+ }
272
+ else {
273
+ cmd = "xclip";
274
+ args = ["-selection", "clipboard"];
275
+ }
276
+ const proc = spawn(cmd, args);
277
+ proc.stdin.write(output);
278
+ proc.stdin.end();
279
+ proc.on("exit", (code) => {
280
+ if (code === 0) {
281
+ setCopyStatus("Copied!");
282
+ setTimeout(() => setCopyStatus(null), 2000);
283
+ }
284
+ else {
285
+ setCopyStatus("Failed");
286
+ setTimeout(() => setCopyStatus(null), 2000);
287
+ }
288
+ });
289
+ proc.on("error", () => {
290
+ setCopyStatus("Not supported");
291
+ setTimeout(() => setCopyStatus(null), 2000);
292
+ });
293
+ };
294
+ // Handle input
295
+ useInput((input, key) => {
296
+ const isRunning = status === "running" || status === "starting";
297
+ const isComplete = status === "completed" || status === "killed" || status === "failed";
298
+ // Kill command
299
+ if (input === "k" && isRunning) {
300
+ handleKill();
301
+ return;
302
+ }
303
+ // Run another (after completion)
304
+ if (input === "r" && isComplete) {
305
+ onRunAnother();
306
+ return;
307
+ }
308
+ // Copy output (ignore if Ctrl+C for quit)
309
+ if (input === "c" && !key.ctrl) {
310
+ handleCopy();
311
+ return;
312
+ }
313
+ // Back/leave
314
+ if (input === "q" || key.escape) {
315
+ onBack();
316
+ return;
317
+ }
318
+ // Return after completion
319
+ if (key.return && isComplete) {
320
+ onBack();
321
+ return;
322
+ }
323
+ // Scrolling
324
+ const allLines = [...stdout.split("\n"), ...stderr.split("\n")].filter((line) => line !== "");
325
+ const maxScroll = Math.max(0, allLines.length - execViewport.viewportHeight);
326
+ if (key.upArrow || input === "k") {
327
+ setScroll(Math.max(0, scroll - 1));
328
+ setAutoScroll(false);
329
+ }
330
+ else if (key.downArrow || input === "j") {
331
+ const newScroll = Math.min(maxScroll, scroll + 1);
332
+ setScroll(newScroll);
333
+ setAutoScroll(newScroll >= maxScroll);
334
+ }
335
+ else if (key.pageUp) {
336
+ setScroll(Math.max(0, scroll - 10));
337
+ setAutoScroll(false);
338
+ }
339
+ else if (key.pageDown) {
340
+ const newScroll = Math.min(maxScroll, scroll + 10);
341
+ setScroll(newScroll);
342
+ setAutoScroll(newScroll >= maxScroll);
343
+ }
344
+ else if (input === "g") {
345
+ setScroll(0);
346
+ setAutoScroll(false);
347
+ }
348
+ else if (input === "G") {
349
+ setScroll(maxScroll);
350
+ setAutoScroll(true);
351
+ }
352
+ });
353
+ // Auto-scroll to bottom when new output arrives (for both modes)
354
+ // Simple approach: if autoScroll is enabled, always go to bottom
355
+ // User actions (scroll up) disable autoScroll, scroll to bottom re-enables it
356
+ React.useEffect(() => {
357
+ if (!autoScroll)
358
+ return;
359
+ const allLines = [...stdout.split("\n"), ...stderr.split("\n")].filter((line) => line !== "");
360
+ const maxScroll = Math.max(0, allLines.length - execViewport.viewportHeight);
361
+ setScroll(maxScroll);
362
+ }, [stdout, stderr, autoScroll, execViewport.viewportHeight]);
363
+ // Format elapsed time (input is milliseconds)
364
+ const formatTime = (ms) => {
365
+ if (ms < 1000)
366
+ return `${ms}ms`;
367
+ const seconds = Math.floor(ms / 1000);
368
+ if (seconds < 60)
369
+ return `${seconds}s`;
370
+ const mins = Math.floor(seconds / 60);
371
+ const secs = seconds % 60;
372
+ return `${mins}m ${secs}s`;
373
+ };
374
+ // Get status display
375
+ const getStatusDisplay = () => {
376
+ switch (status) {
377
+ case "starting":
378
+ return { text: "Starting...", color: colors.info, icon: "●" };
379
+ case "running":
380
+ return {
381
+ text: `Running (${formatTime(elapsedTime)})`,
382
+ color: colors.warning,
383
+ icon: "●",
384
+ };
385
+ case "completed":
386
+ return {
387
+ text: `Completed (exit: ${exitCode}) in ${formatTime(finalDuration ?? elapsedTime)}`,
388
+ color: exitCode === 0 ? colors.success : colors.error,
389
+ icon: exitCode === 0 ? figures.tick : figures.cross,
390
+ };
391
+ case "killed":
392
+ return {
393
+ text: `Killed after ${formatTime(finalDuration ?? elapsedTime)}`,
394
+ color: colors.warning,
395
+ icon: figures.cross,
396
+ };
397
+ case "failed":
398
+ return { text: "Failed", color: colors.error, icon: figures.cross };
399
+ default:
400
+ return { text: "Unknown", color: colors.textDim, icon: "?" };
401
+ }
402
+ };
403
+ const statusDisplay = getStatusDisplay();
404
+ const isRunning = status === "running" || status === "starting";
405
+ // Prepare output lines
406
+ const stdoutLines = stdout ? stdout.split("\n") : [];
407
+ const stderrLines = stderr ? stderr.split("\n") : [];
408
+ const allLines = [...stdoutLines, ...stderrLines].filter((line) => line !== "");
409
+ const viewportHeight = execViewport.viewportHeight;
410
+ const maxScroll = Math.max(0, allLines.length - viewportHeight);
411
+ const actualScroll = Math.min(scroll, maxScroll);
412
+ const visibleLines = allLines.slice(actualScroll, actualScroll + viewportHeight);
413
+ const hasMore = actualScroll + viewportHeight < allLines.length;
414
+ const hasLess = actualScroll > 0;
415
+ // Get navigation tips based on state
416
+ const getNavigationTips = () => {
417
+ if (isRunning) {
418
+ return [
419
+ { key: "↑↓", label: "Scroll" },
420
+ { key: "k", label: "Kill" },
421
+ { key: "q/esc", label: "Leave Running" },
422
+ ];
423
+ }
424
+ else {
425
+ return [
426
+ { key: "↑↓", label: "Scroll" },
427
+ { key: "r", label: "Run Another" },
428
+ { key: "c", label: "Copy" },
429
+ { key: "q/esc/Enter", label: "Back" },
430
+ ];
431
+ }
432
+ };
433
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Command:"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.text, children: command.length > 80 ? command.substring(0, 80) + "..." : command })] }), _jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Status:", " "] }), isRunning && (_jsxs(Text, { color: statusDisplay.color, children: [_jsx(Spinner, { type: "dots" }), " "] })), _jsxs(Text, { color: statusDisplay.color, children: [!isRunning && `${statusDisplay.icon} `, statusDisplay.text] })] })] }), error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: colors.error, children: [figures.cross, " Error: ", error] }) })), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight + 2, children: allLines.length === 0 && isRunning ? (_jsxs(Box, { children: [_jsx(Text, { color: colors.info, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: colors.textDim, children: " Waiting for output..." })] })) : allLines.length === 0 ? (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No output" })) : (visibleLines.map((line, index) => {
434
+ const actualIndex = actualScroll + index;
435
+ const isStderr = actualIndex >= stdoutLines.length;
436
+ const lineColor = isStderr ? colors.error : colors.text;
437
+ return (_jsx(Box, { children: _jsx(Text, { color: lineColor, children: line }) }, index));
438
+ })) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", allLines.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "lines"] }), allLines.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, allLines.length), " of", " ", allLines.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }))] })), stdout && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.success, dimColor: true, children: ["stdout: ", stdoutLines.length] })] })), stderr && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.error, dimColor: true, children: ["stderr: ", stderrLines.length] })] })), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(NavigationTips, { tips: getNavigationTips() })] }));
439
+ };
@@ -10,11 +10,14 @@ import { Breadcrumb } from "./Breadcrumb.js";
10
10
  import { NavigationTips } from "./NavigationTips.js";
11
11
  import { colors } from "../utils/theme.js";
12
12
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
13
14
  import { parseAnyLogEntry } from "../utils/logFormatter.js";
14
15
  export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: true }], onBack, title = "Logs", }) => {
15
16
  const [logsWrapMode, setLogsWrapMode] = React.useState(false);
16
17
  const [logsScroll, setLogsScroll] = React.useState(0);
17
18
  const [copyStatus, setCopyStatus] = React.useState(null);
19
+ // Handle Ctrl+C to exit
20
+ useExitOnCtrlC();
18
21
  // Calculate viewport for logs output:
19
22
  // - Breadcrumb (border top + content + border bottom + marginBottom): 4 lines
20
23
  // - Log box borders: 2 lines (added to height by Ink)
@@ -57,8 +60,8 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
57
60
  else if (input === "w") {
58
61
  setLogsWrapMode(!logsWrapMode);
59
62
  }
60
- else if (input === "c") {
61
- // Copy logs to clipboard using shared formatter
63
+ else if (input === "c" && !key.ctrl) {
64
+ // Copy logs to clipboard (ignore if Ctrl+C for quit)
62
65
  const logsText = logs
63
66
  .map((log) => {
64
67
  const parts = parseAnyLogEntry(log);
@@ -143,8 +143,8 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
143
143
  if (input === "q" || key.escape) {
144
144
  onBack();
145
145
  }
146
- else if (input === "c") {
147
- // Copy resource ID to clipboard
146
+ else if (input === "c" && !key.ctrl) {
147
+ // Copy resource ID to clipboard (ignore if Ctrl+C for quit)
148
148
  copyToClipboard(getId(currentResource));
149
149
  }
150
150
  else if (input === "i" && buildDetailLines) {