@flumecode/runner 0.22.0 → 0.23.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/dist/cli.js CHANGED
@@ -1,4 +1,652 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/version.ts
13
+ import { readFileSync as readFileSync2 } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ function readVersion() {
16
+ try {
17
+ const path = fileURLToPath(new URL("../package.json", import.meta.url));
18
+ const pkg = JSON.parse(readFileSync2(path, "utf8"));
19
+ return pkg.version ?? "unknown";
20
+ } catch {
21
+ return "unknown";
22
+ }
23
+ }
24
+ function compareVersions(a, b) {
25
+ const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
26
+ const pa = parse(a);
27
+ const pb = parse(b);
28
+ const len = Math.max(pa.length, pb.length);
29
+ for (let i = 0; i < len; i++) {
30
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
31
+ if (diff !== 0) return diff;
32
+ }
33
+ return 0;
34
+ }
35
+ var RUNNER_VERSION, RUNNER_VERSION_HEADER, RUNNER_MIN_VERSION_HEADER, RUNNER_LATEST_VERSION_HEADER;
36
+ var init_version = __esm({
37
+ "src/version.ts"() {
38
+ "use strict";
39
+ RUNNER_VERSION = readVersion();
40
+ RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
41
+ RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
42
+ RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
43
+ }
44
+ });
45
+
46
+ // src/tui-api.ts
47
+ function authHeaders(cfg) {
48
+ return {
49
+ Authorization: `Bearer ${cfg.apiToken ?? ""}`,
50
+ "x-flumecode-runner-version": RUNNER_VERSION
51
+ };
52
+ }
53
+ async function safeText2(res) {
54
+ try {
55
+ return await res.text();
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ async function get(cfg, path) {
61
+ const res = await fetch(cfg.serverUrl + path, { headers: authHeaders(cfg) });
62
+ if (!res.ok) throw new Error(await safeText2(res));
63
+ return res.json();
64
+ }
65
+ async function post(cfg, path, body) {
66
+ const res = await fetch(cfg.serverUrl + path, {
67
+ method: "POST",
68
+ headers: { ...authHeaders(cfg), "Content-Type": "application/json" },
69
+ body: JSON.stringify(body)
70
+ });
71
+ if (!res.ok) throw new Error(await safeText2(res));
72
+ return res.json();
73
+ }
74
+ var listRepositories, listAgents, listRequests, getRequest, createRequest, answerWidgets, acceptPlan, startImplementing, listJobs, listRunners;
75
+ var init_tui_api = __esm({
76
+ "src/tui-api.ts"() {
77
+ "use strict";
78
+ init_version();
79
+ listRepositories = (cfg) => get(cfg, "/api/cli/repositories").then((r) => r.repositories);
80
+ listAgents = (cfg) => get(cfg, "/api/cli/agents").then((r) => r.agents);
81
+ listRequests = (cfg, repoId) => get(cfg, `/api/cli/repositories/${repoId}/requests`).then(
82
+ (r) => r.requests
83
+ );
84
+ getRequest = (cfg, id) => get(cfg, `/api/cli/requests/${id}`);
85
+ createRequest = (cfg, input) => post(cfg, "/api/cli/requests", input);
86
+ answerWidgets = (cfg, messageId, payload) => post(cfg, `/api/cli/messages/${messageId}/answer`, payload);
87
+ acceptPlan = (cfg, messageId, target, mergeBranch) => post(cfg, `/api/cli/plans/${messageId}/accept`, { target, mergeBranch });
88
+ startImplementing = (cfg, sessionId) => post(cfg, `/api/cli/sessions/${sessionId}/implement`, {});
89
+ listJobs = (cfg) => get(cfg, "/api/cli/jobs").then((r) => r.jobs);
90
+ listRunners = (cfg) => get(cfg, "/api/cli/runners").then((r) => r.runners);
91
+ }
92
+ });
93
+
94
+ // src/tui/usePoll.ts
95
+ import { useState, useEffect, useRef } from "react";
96
+ function usePoll(fetcher, intervalMs) {
97
+ const [data, setData] = useState(null);
98
+ const fetcherRef = useRef(fetcher);
99
+ fetcherRef.current = fetcher;
100
+ useEffect(() => {
101
+ let cancelled = false;
102
+ function poll() {
103
+ fetcherRef.current().then((result) => {
104
+ if (!cancelled) setData(result);
105
+ }).catch(() => {
106
+ });
107
+ }
108
+ poll();
109
+ const id = setInterval(poll, intervalMs);
110
+ return () => {
111
+ cancelled = true;
112
+ clearInterval(id);
113
+ };
114
+ }, [intervalMs]);
115
+ return data;
116
+ }
117
+ var init_usePoll = __esm({
118
+ "src/tui/usePoll.ts"() {
119
+ "use strict";
120
+ }
121
+ });
122
+
123
+ // src/tui/StatusBar.tsx
124
+ import { Box, Text } from "ink";
125
+ import { jsx, jsxs } from "react/jsx-runtime";
126
+ function StatusBar({ jobs, runners }) {
127
+ const runningJobs = jobs?.filter((j) => j.status === "running").length ?? 0;
128
+ const onlineRunners = runners?.filter((r) => r.status === "online").length ?? 0;
129
+ return /* @__PURE__ */ jsx(Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
130
+ "Jobs running: ",
131
+ runningJobs,
132
+ " | Runners online: ",
133
+ onlineRunners
134
+ ] }) });
135
+ }
136
+ var init_StatusBar = __esm({
137
+ "src/tui/StatusBar.tsx"() {
138
+ "use strict";
139
+ }
140
+ });
141
+
142
+ // src/tui/RepoListScreen.tsx
143
+ import { useState as useState2 } from "react";
144
+ import { Box as Box2, Text as Text2, useInput } from "ink";
145
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
146
+ function RepoListScreen({ config, onSelect }) {
147
+ const repos = usePoll(() => listRepositories(config), 1e4);
148
+ const [cursor, setCursor] = useState2(0);
149
+ useInput((input, key) => {
150
+ if (!repos) return;
151
+ if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
152
+ else if (key.downArrow) setCursor((c) => Math.min(repos.length - 1, c + 1));
153
+ else if (key.return) {
154
+ const repo = repos[cursor];
155
+ if (repo) onSelect(repo);
156
+ }
157
+ });
158
+ if (!repos) return /* @__PURE__ */ jsx2(Text2, { children: "Loading repositories\u2026" });
159
+ if (repos.length === 0) return /* @__PURE__ */ jsx2(Text2, { children: "No repositories found." });
160
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
161
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "Select a repository (\u2191\u2193 Enter):" }),
162
+ repos.map((repo, i) => /* @__PURE__ */ jsxs2(Text2, { color: i === cursor ? "cyan" : void 0, children: [
163
+ i === cursor ? "\u25B6 " : " ",
164
+ repo.fullName
165
+ ] }, repo.id))
166
+ ] });
167
+ }
168
+ var init_RepoListScreen = __esm({
169
+ "src/tui/RepoListScreen.tsx"() {
170
+ "use strict";
171
+ init_usePoll();
172
+ init_tui_api();
173
+ }
174
+ });
175
+
176
+ // src/tui/RequestListScreen.tsx
177
+ import { useState as useState3 } from "react";
178
+ import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
179
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
180
+ function RequestListScreen({ config, repo, onOpen, onNew, onBack }) {
181
+ const requests = usePoll(() => listRequests(config, repo.id), 5e3);
182
+ const [cursor, setCursor] = useState3(0);
183
+ useInput2((input, key) => {
184
+ if (key.escape) {
185
+ onBack();
186
+ return;
187
+ }
188
+ if (!requests) return;
189
+ if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
190
+ else if (key.downArrow) setCursor((c) => Math.min(requests.length - 1, c + 1));
191
+ else if (key.return) {
192
+ const req = requests[cursor];
193
+ if (req) onOpen(req);
194
+ } else if (input === "n") {
195
+ onNew();
196
+ }
197
+ });
198
+ if (!requests) return /* @__PURE__ */ jsx3(Text3, { children: "Loading requests\u2026" });
199
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
200
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, children: [
201
+ repo.fullName,
202
+ " \u2014 Requests (\u2191\u2193 Enter open, n new, Esc back):"
203
+ ] }),
204
+ requests.length === 0 && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No requests yet." }),
205
+ requests.map((req, i) => /* @__PURE__ */ jsxs3(Text3, { color: i === cursor ? "cyan" : void 0, children: [
206
+ i === cursor ? "\u25B6 " : " ",
207
+ "[",
208
+ req.status,
209
+ "] ",
210
+ req.title
211
+ ] }, req.id))
212
+ ] });
213
+ }
214
+ var init_RequestListScreen = __esm({
215
+ "src/tui/RequestListScreen.tsx"() {
216
+ "use strict";
217
+ init_usePoll();
218
+ init_tui_api();
219
+ }
220
+ });
221
+
222
+ // src/tui/NewRequestScreen.tsx
223
+ import { useState as useState4 } from "react";
224
+ import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
225
+ import TextInput from "ink-text-input";
226
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
227
+ function NewRequestScreen({ config, repo, onCreated, onBack }) {
228
+ const agents = usePoll(() => listAgents(config), 3e4);
229
+ const [field, setField] = useState4("title");
230
+ const [title, setTitle] = useState4("");
231
+ const [body, setBody] = useState4("");
232
+ const [agentCursor, setAgentCursor] = useState4(0);
233
+ const [branch, setBranch] = useState4("");
234
+ const [error, setError] = useState4(null);
235
+ const [submitting, setSubmitting] = useState4(false);
236
+ useInput3((_input, key) => {
237
+ if (key.escape) {
238
+ onBack();
239
+ return;
240
+ }
241
+ if (field === "title" && key.tab) {
242
+ setField("body");
243
+ return;
244
+ }
245
+ if (field === "body" && key.tab) {
246
+ setField("agent");
247
+ return;
248
+ }
249
+ if (field === "agent") {
250
+ if (key.upArrow && agents) setAgentCursor((c) => Math.max(0, c - 1));
251
+ if (key.downArrow && agents) setAgentCursor((c) => Math.min((agents.length || 1) - 1, c + 1));
252
+ if (key.tab) setField("branch");
253
+ }
254
+ if (field === "branch" && key.return && !submitting) {
255
+ void submit();
256
+ }
257
+ });
258
+ async function submit() {
259
+ if (!title.trim() || !agents?.[agentCursor]) return;
260
+ setSubmitting(true);
261
+ setError(null);
262
+ try {
263
+ const result = await createRequest(config, {
264
+ title: title.trim(),
265
+ body: body.trim(),
266
+ agentId: agents[agentCursor].id,
267
+ repoId: repo.id,
268
+ checkoutBranch: branch.trim() || void 0
269
+ });
270
+ onCreated(result.requestId);
271
+ } catch (err) {
272
+ setError(err instanceof Error ? err.message : "Unknown error");
273
+ setSubmitting(false);
274
+ }
275
+ }
276
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
277
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
278
+ "New Request in ",
279
+ repo.fullName,
280
+ " (Tab to advance, Esc back)"
281
+ ] }),
282
+ /* @__PURE__ */ jsxs4(Box4, { children: [
283
+ /* @__PURE__ */ jsx4(Text4, { children: "Title: " }),
284
+ /* @__PURE__ */ jsx4(TextInput, { value: title, onChange: setTitle, focus: field === "title" })
285
+ ] }),
286
+ /* @__PURE__ */ jsxs4(Box4, { children: [
287
+ /* @__PURE__ */ jsx4(Text4, { children: "Body: " }),
288
+ /* @__PURE__ */ jsx4(TextInput, { value: body, onChange: setBody, focus: field === "body" })
289
+ ] }),
290
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
291
+ /* @__PURE__ */ jsxs4(Text4, { children: [
292
+ "Agent",
293
+ field === "agent" ? " (\u2191\u2193 to pick, Tab next)" : "",
294
+ ":"
295
+ ] }),
296
+ (agents ?? []).map((a, i) => /* @__PURE__ */ jsxs4(Text4, { color: field === "agent" && i === agentCursor ? "cyan" : void 0, children: [
297
+ field === "agent" && i === agentCursor ? "\u25B6 " : " ",
298
+ a.name
299
+ ] }, a.id))
300
+ ] }),
301
+ /* @__PURE__ */ jsxs4(Box4, { children: [
302
+ /* @__PURE__ */ jsx4(Text4, { children: "Checkout branch (optional): " }),
303
+ /* @__PURE__ */ jsx4(TextInput, { value: branch, onChange: setBranch, focus: field === "branch" })
304
+ ] }),
305
+ submitting && /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Creating\u2026" }),
306
+ error && /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
307
+ "Error: ",
308
+ error
309
+ ] })
310
+ ] });
311
+ }
312
+ var init_NewRequestScreen = __esm({
313
+ "src/tui/NewRequestScreen.tsx"() {
314
+ "use strict";
315
+ init_usePoll();
316
+ init_tui_api();
317
+ }
318
+ });
319
+
320
+ // src/tui/WidgetAnswerScreen.tsx
321
+ import { useState as useState5 } from "react";
322
+ import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
323
+ import TextInput2 from "ink-text-input";
324
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
325
+ function renderOptionLabel(opt, i, cursor, isSingle, checked) {
326
+ const pointer = i === cursor ? "\u25B6 " : " ";
327
+ if (isSingle) {
328
+ return `${pointer}${opt.label}`;
329
+ }
330
+ return `${pointer}[${checked.has(opt.id) ? "x" : " "}] ${opt.label}`;
331
+ }
332
+ function WidgetAnswerScreen({ config, message, onDone }) {
333
+ const [widgetIndex, setWidgetIndex] = useState5(0);
334
+ const [cursor, setCursor] = useState5(0);
335
+ const [checked, setChecked] = useState5(/* @__PURE__ */ new Set());
336
+ const [customText, setCustomText] = useState5("");
337
+ const [showCustom, setShowCustom] = useState5(false);
338
+ const [error, setError] = useState5(null);
339
+ const [submitting, setSubmitting] = useState5(false);
340
+ const widget = message.widgets[widgetIndex];
341
+ function handleSingleInput(input, key, w) {
342
+ const opts2 = [...w.options, { id: "__other__", label: "Other" }];
343
+ if (key.upArrow) {
344
+ setCursor((c) => Math.max(0, c - 1));
345
+ } else if (key.downArrow) {
346
+ setCursor((c) => Math.min(opts2.length - 1, c + 1));
347
+ } else if (key.return) {
348
+ const picked = opts2[cursor];
349
+ if (picked?.id === "__other__") {
350
+ setShowCustom(true);
351
+ } else {
352
+ void advanceOrSubmit(w, picked?.id, void 0, void 0);
353
+ }
354
+ }
355
+ }
356
+ function handleMultiInput(input, key, w) {
357
+ const opts2 = [...w.options, { id: "__other__", label: "Other" }];
358
+ if (key.upArrow) {
359
+ setCursor((c) => Math.max(0, c - 1));
360
+ } else if (key.downArrow) {
361
+ setCursor((c) => Math.min(opts2.length - 1, c + 1));
362
+ } else if (input === " ") {
363
+ const picked = opts2[cursor];
364
+ if (!picked) return;
365
+ if (picked.id === "__other__") {
366
+ setShowCustom((s) => !s);
367
+ } else {
368
+ const next = new Set(checked);
369
+ if (next.has(picked.id)) next.delete(picked.id);
370
+ else next.add(picked.id);
371
+ setChecked(next);
372
+ }
373
+ } else if (key.return) {
374
+ void advanceOrSubmit(w, void 0, [...checked], customText || void 0);
375
+ }
376
+ }
377
+ useInput4((input, key) => {
378
+ if (!widget) return;
379
+ if (widget.type === "single_select") {
380
+ handleSingleInput(input, key, widget);
381
+ } else {
382
+ handleMultiInput(input, key, widget);
383
+ }
384
+ });
385
+ async function advanceOrSubmit(w, optionId, optionIds, custom) {
386
+ const answers = [
387
+ {
388
+ widgetId: w.id,
389
+ ...optionId !== void 0 ? { optionId } : {},
390
+ ...optionIds !== void 0 ? { optionIds } : {},
391
+ ...custom ? { customText: custom } : {}
392
+ }
393
+ ];
394
+ if (widgetIndex < message.widgets.length - 1) {
395
+ setWidgetIndex((i) => i + 1);
396
+ setCursor(0);
397
+ setChecked(/* @__PURE__ */ new Set());
398
+ setCustomText("");
399
+ setShowCustom(false);
400
+ return;
401
+ }
402
+ setSubmitting(true);
403
+ setError(null);
404
+ try {
405
+ await answerWidgets(config, message.id, { requestId: message.requestId, answers });
406
+ onDone();
407
+ } catch (err) {
408
+ setError(err instanceof Error ? err.message : "Unknown error");
409
+ setSubmitting(false);
410
+ }
411
+ }
412
+ if (!widget) return /* @__PURE__ */ jsx5(Text5, { children: "No widgets to answer." });
413
+ const isSingle = widget.type === "single_select";
414
+ const opts = [...widget.options, { id: "__other__", label: "Other" }];
415
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
416
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
417
+ "Widget ",
418
+ widgetIndex + 1,
419
+ "/",
420
+ message.widgets.length,
421
+ ": ",
422
+ widget.question
423
+ ] }),
424
+ widget.body && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: widget.body }),
425
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: isSingle ? "\u2191\u2193 to navigate, Enter to select" : "\u2191\u2193 to navigate, Space to toggle, Enter to confirm" }),
426
+ opts.map((opt, i) => /* @__PURE__ */ jsx5(Text5, { color: i === cursor ? "cyan" : void 0, children: renderOptionLabel(opt, i, cursor, isSingle, checked) }, opt.id)),
427
+ showCustom && /* @__PURE__ */ jsxs5(Box5, { children: [
428
+ /* @__PURE__ */ jsx5(Text5, { children: "Custom answer: " }),
429
+ /* @__PURE__ */ jsx5(TextInput2, { value: customText, onChange: setCustomText, focus: showCustom })
430
+ ] }),
431
+ submitting && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Submitting\u2026" }),
432
+ error && /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
433
+ "Error: ",
434
+ error
435
+ ] })
436
+ ] });
437
+ }
438
+ var init_WidgetAnswerScreen = __esm({
439
+ "src/tui/WidgetAnswerScreen.tsx"() {
440
+ "use strict";
441
+ init_tui_api();
442
+ }
443
+ });
444
+
445
+ // src/tui/ThreadScreen.tsx
446
+ import { useState as useState6 } from "react";
447
+ import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
448
+ import TextInput3 from "ink-text-input";
449
+ import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
450
+ function ThreadScreen({ config, requestId, onBack }) {
451
+ const data = usePoll(() => getRequest(config, requestId), 3e3);
452
+ const [cursor, setCursor] = useState6(0);
453
+ const [overlay, setOverlay] = useState6({ kind: "none" });
454
+ const [targetBranch, setTargetBranch] = useState6("main");
455
+ const [mergeBranch, setMergeBranch] = useState6("");
456
+ const [acceptStep, setAcceptStep] = useState6("target");
457
+ const [error, setError] = useState6(null);
458
+ const messages = data?.messages ?? [];
459
+ const sessions = data?.sessions ?? [];
460
+ const selectedMsg = messages[cursor];
461
+ const implementableSession = sessions.find((s) => s.status === "planned" || s.status === "ready");
462
+ useInput5((_input, key) => {
463
+ if (overlay.kind !== "none") return;
464
+ if (key.escape) {
465
+ onBack();
466
+ return;
467
+ }
468
+ if (key.upArrow) {
469
+ setCursor((c) => Math.max(0, c - 1));
470
+ return;
471
+ }
472
+ if (key.downArrow) {
473
+ setCursor((c) => Math.min(messages.length - 1, c + 1));
474
+ return;
475
+ }
476
+ if (key.return && selectedMsg) {
477
+ if (selectedMsg.widgets?.length) {
478
+ const widgets = selectedMsg.widgets;
479
+ setOverlay({ kind: "widget", message: { ...selectedMsg, widgets, requestId } });
480
+ } else if (selectedMsg.type === "plan") {
481
+ setTargetBranch("main");
482
+ setMergeBranch("");
483
+ setAcceptStep("target");
484
+ setOverlay({ kind: "accept", messageId: selectedMsg.id });
485
+ }
486
+ }
487
+ if (_input === "i" && implementableSession) {
488
+ void doImplement(implementableSession.id);
489
+ }
490
+ });
491
+ async function doAcceptPlan() {
492
+ if (overlay.kind !== "accept") return;
493
+ try {
494
+ await acceptPlan(
495
+ config,
496
+ overlay.messageId,
497
+ targetBranch.trim() || "main",
498
+ mergeBranch.trim() || "flumecode/session"
499
+ );
500
+ setOverlay({ kind: "accepted" });
501
+ } catch (err) {
502
+ setError(err instanceof Error ? err.message : "Unknown error");
503
+ }
504
+ }
505
+ async function doImplement(sessionId) {
506
+ try {
507
+ await startImplementing(config, sessionId);
508
+ } catch (err) {
509
+ setError(err instanceof Error ? err.message : "Unknown error");
510
+ }
511
+ }
512
+ if (overlay.kind === "widget") {
513
+ return /* @__PURE__ */ jsx6(
514
+ WidgetAnswerScreen,
515
+ {
516
+ config,
517
+ message: overlay.message,
518
+ onDone: () => setOverlay({ kind: "none" })
519
+ }
520
+ );
521
+ }
522
+ if (overlay.kind === "accept") {
523
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
524
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Accept Plan" }),
525
+ /* @__PURE__ */ jsx6(Text6, { children: "Enter target branch (base):" }),
526
+ /* @__PURE__ */ jsx6(
527
+ TextInput3,
528
+ {
529
+ value: targetBranch,
530
+ onChange: setTargetBranch,
531
+ onSubmit: () => setAcceptStep("merge"),
532
+ focus: acceptStep === "target"
533
+ }
534
+ ),
535
+ acceptStep === "merge" && /* @__PURE__ */ jsxs6(Fragment, { children: [
536
+ /* @__PURE__ */ jsx6(Text6, { children: "Enter merge branch name:" }),
537
+ /* @__PURE__ */ jsx6(
538
+ TextInput3,
539
+ {
540
+ value: mergeBranch,
541
+ onChange: setMergeBranch,
542
+ onSubmit: () => void doAcceptPlan(),
543
+ focus: true
544
+ }
545
+ )
546
+ ] }),
547
+ error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
548
+ ] });
549
+ }
550
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
551
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Thread (\u2191\u2193 navigate, Enter act on selected, i implement, Esc back)" }),
552
+ !data && /* @__PURE__ */ jsx6(Text6, { children: "Loading\u2026" }),
553
+ overlay.kind === "accepted" && /* @__PURE__ */ jsx6(Text6, { color: "green", children: "Plan accepted! Job queued." }),
554
+ messages.map((msg, i) => /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", marginY: 0, children: /* @__PURE__ */ jsxs6(Text6, { color: i === cursor ? "cyan" : void 0, children: [
555
+ i === cursor ? "\u25B6 " : " ",
556
+ "[",
557
+ msg.type,
558
+ "] ",
559
+ msg.body?.slice(0, 80) ?? "",
560
+ msg.widgets?.length ? ` (${msg.widgets.length} widget${msg.widgets.length > 1 ? "s" : ""})` : ""
561
+ ] }) }, msg.id)),
562
+ implementableSession && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
563
+ "Press i to start implementing session ",
564
+ implementableSession.id.slice(0, 8),
565
+ "\u2026"
566
+ ] }),
567
+ error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
568
+ ] });
569
+ }
570
+ var init_ThreadScreen = __esm({
571
+ "src/tui/ThreadScreen.tsx"() {
572
+ "use strict";
573
+ init_usePoll();
574
+ init_tui_api();
575
+ init_WidgetAnswerScreen();
576
+ }
577
+ });
578
+
579
+ // src/tui/App.tsx
580
+ var App_exports = {};
581
+ __export(App_exports, {
582
+ App: () => App
583
+ });
584
+ import { useState as useState7 } from "react";
585
+ import { Box as Box7 } from "ink";
586
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
587
+ function renderScreen(screen, setScreen, config) {
588
+ switch (screen.name) {
589
+ case "repos":
590
+ return /* @__PURE__ */ jsx7(
591
+ RepoListScreen,
592
+ {
593
+ config,
594
+ onSelect: (repo) => setScreen({ name: "requests", repo })
595
+ }
596
+ );
597
+ case "requests":
598
+ return /* @__PURE__ */ jsx7(
599
+ RequestListScreen,
600
+ {
601
+ config,
602
+ repo: screen.repo,
603
+ onOpen: (req) => setScreen({ name: "thread", repo: screen.repo, requestId: req.id }),
604
+ onNew: () => setScreen({ name: "new-request", repo: screen.repo }),
605
+ onBack: () => setScreen({ name: "repos" })
606
+ }
607
+ );
608
+ case "new-request":
609
+ return /* @__PURE__ */ jsx7(
610
+ NewRequestScreen,
611
+ {
612
+ config,
613
+ repo: screen.repo,
614
+ onCreated: (requestId) => setScreen({ name: "thread", repo: screen.repo, requestId }),
615
+ onBack: () => setScreen({ name: "requests", repo: screen.repo })
616
+ }
617
+ );
618
+ case "thread":
619
+ return /* @__PURE__ */ jsx7(
620
+ ThreadScreen,
621
+ {
622
+ config,
623
+ requestId: screen.requestId,
624
+ onBack: () => setScreen({ name: "requests", repo: screen.repo })
625
+ }
626
+ );
627
+ }
628
+ }
629
+ function App({ config }) {
630
+ const [screen, setScreen] = useState7({ name: "repos" });
631
+ const jobs = usePoll(() => listJobs(config), 3e3);
632
+ const runners = usePoll(() => listRunners(config), 3e3);
633
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
634
+ renderScreen(screen, setScreen, config),
635
+ /* @__PURE__ */ jsx7(StatusBar, { jobs, runners })
636
+ ] });
637
+ }
638
+ var init_App = __esm({
639
+ "src/tui/App.tsx"() {
640
+ "use strict";
641
+ init_tui_api();
642
+ init_usePoll();
643
+ init_StatusBar();
644
+ init_RepoListScreen();
645
+ init_RequestListScreen();
646
+ init_NewRequestScreen();
647
+ init_ThreadScreen();
648
+ }
649
+ });
2
650
 
3
651
  // src/cli.ts
4
652
  import { createInterface } from "node:readline/promises";
@@ -15,7 +663,9 @@ function readConfig() {
15
663
  try {
16
664
  const parsed = JSON.parse(readFileSync(configPath, "utf8"));
17
665
  if (!parsed.serverUrl || !parsed.token) return null;
18
- return { serverUrl: parsed.serverUrl, token: parsed.token };
666
+ const config = { serverUrl: parsed.serverUrl, token: parsed.token };
667
+ if (parsed.apiToken) config.apiToken = parsed.apiToken;
668
+ return config;
19
669
  } catch {
20
670
  return null;
21
671
  }
@@ -26,38 +676,11 @@ function writeConfig(config) {
26
676
  }
27
677
 
28
678
  // src/run.ts
29
- import { existsSync as existsSync4 } from "node:fs";
30
- import { join as join5 } from "node:path";
31
-
32
- // src/version.ts
33
- import { readFileSync as readFileSync2 } from "node:fs";
34
- import { fileURLToPath } from "node:url";
35
- function readVersion() {
36
- try {
37
- const path = fileURLToPath(new URL("../package.json", import.meta.url));
38
- const pkg = JSON.parse(readFileSync2(path, "utf8"));
39
- return pkg.version ?? "unknown";
40
- } catch {
41
- return "unknown";
42
- }
43
- }
44
- var RUNNER_VERSION = readVersion();
45
- var RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
46
- var RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
47
- var RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
48
- function compareVersions(a, b) {
49
- const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
50
- const pa = parse(a);
51
- const pb = parse(b);
52
- const len = Math.max(pa.length, pb.length);
53
- for (let i = 0; i < len; i++) {
54
- const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
55
- if (diff !== 0) return diff;
56
- }
57
- return 0;
58
- }
679
+ import { existsSync as existsSync5 } from "node:fs";
680
+ import { join as join6 } from "node:path";
59
681
 
60
682
  // src/api.ts
683
+ init_version();
61
684
  var OUTDATED_WARN_INTERVAL_MS = 60 * 6e4;
62
685
  var lastOutdatedWarnAt = 0;
63
686
  var UPDATE_NUDGE_INTERVAL_MS = 60 * 6e4;
@@ -172,6 +795,20 @@ async function fetchRelatedSessions(config, params) {
172
795
  return [];
173
796
  }
174
797
  }
798
+ async function mintUploadUrls(config, jobId, keys) {
799
+ const res = await fetch(`${config.serverUrl}/api/runner/previews/mint-upload-urls`, {
800
+ method: "POST",
801
+ headers: {
802
+ authorization: `Bearer ${config.token}`,
803
+ "content-type": "application/json",
804
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
805
+ },
806
+ body: JSON.stringify({ jobId, keys })
807
+ });
808
+ noteServerVersion(res);
809
+ if (!res.ok) throw new Error(`mint-upload-urls failed: ${res.status} ${await safeText(res)}`);
810
+ return res.json();
811
+ }
175
812
  async function safeText(res) {
176
813
  try {
177
814
  return await res.text();
@@ -1103,6 +1740,36 @@ function buildCodexPrompt(ctx) {
1103
1740
  "\n"
1104
1741
  );
1105
1742
  }
1743
+ function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
1744
+ const lines2 = [
1745
+ `You are "${ctx.agentName}" authoring an ephemeral UI showcase for ${ctx.repo.fullName}.`,
1746
+ `An implementation just ran and committed UI changes to branch "${ctx.repo.checkoutBranch}". Use the \`flumecode:preview-ui\` skill to create a temporary showcase page that imports the changed components and fills them with realistic fake data.`,
1747
+ `Write your showcase files under \`${tmpRoute}\` and write the URL path to \`${tmpRoute}/.showcase-path\`.`,
1748
+ `These files are ephemeral and git-excluded \u2014 do NOT commit or push them.`,
1749
+ "",
1750
+ "These coding guidelines apply to all code produced in this run:",
1751
+ "",
1752
+ loadRule("coding-guideline"),
1753
+ "",
1754
+ `# Request: ${ctx.request?.title ?? ""}`
1755
+ ];
1756
+ if (ctx.request?.body) {
1757
+ lines2.push("", ctx.request.body);
1758
+ }
1759
+ if (committedFileNames.trim()) {
1760
+ lines2.push(
1761
+ "",
1762
+ "UI files changed by this implementation (showcase these components):",
1763
+ "",
1764
+ committedFileNames.trim()
1765
+ );
1766
+ }
1767
+ lines2.push(
1768
+ "",
1769
+ "When done, reply with one short sentence confirming what showcase file you created."
1770
+ );
1771
+ return lines2.join("\n");
1772
+ }
1106
1773
  function buildInitPrompt(ctx) {
1107
1774
  return [
1108
1775
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -1397,6 +2064,18 @@ async function openPullRequest(ctx) {
1397
2064
  }
1398
2065
  throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
1399
2066
  }
2067
+ async function gitCommittedFiles(dir) {
2068
+ const { stdout: stdout2 } = await git([
2069
+ "-C",
2070
+ dir,
2071
+ "--no-pager",
2072
+ "show",
2073
+ "--name-only",
2074
+ "--format=",
2075
+ "HEAD"
2076
+ ]);
2077
+ return stdout2;
2078
+ }
1400
2079
  async function cleanup(dir) {
1401
2080
  await rm(dir, { recursive: true, force: true });
1402
2081
  }
@@ -1456,6 +2135,250 @@ async function prNumbersForCommit(ctx, sha) {
1456
2135
  }
1457
2136
  }
1458
2137
 
2138
+ // src/preview.ts
2139
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
2140
+ import { createServer } from "node:net";
2141
+ import { join as join5, relative } from "node:path";
2142
+ import { spawn as spawn2 } from "node:child_process";
2143
+ var PREVIEW_MAX_TURNS = 60;
2144
+ var CAPTURE_TIMEOUT_MS = 3e4;
2145
+ var DEV_SERVER_READY_TIMEOUT_MS = 6e4;
2146
+ var VIEWPORTS = [
2147
+ { name: "mobile", width: 375, height: 812 },
2148
+ { name: "tablet", width: 768, height: 1024 },
2149
+ { name: "desktop", width: 1280, height: 800 }
2150
+ ];
2151
+ var UI_EXTENSIONS = /* @__PURE__ */ new Set([
2152
+ ".tsx",
2153
+ ".jsx",
2154
+ ".vue",
2155
+ ".svelte",
2156
+ ".astro",
2157
+ ".css",
2158
+ ".scss",
2159
+ ".sass",
2160
+ ".less"
2161
+ ]);
2162
+ function changedFilesTouchUi(stat) {
2163
+ for (const line of stat.split("\n")) {
2164
+ const trimmed = line.trim();
2165
+ if (!trimmed) continue;
2166
+ const dot = trimmed.lastIndexOf(".");
2167
+ if (dot === -1) continue;
2168
+ const ext = trimmed.slice(dot);
2169
+ if (UI_EXTENSIONS.has(ext)) return true;
2170
+ }
2171
+ return false;
2172
+ }
2173
+ function detectFramework(dir) {
2174
+ const pm = detectPackageManager(dir);
2175
+ const pkgPath = join5(dir, "package.json");
2176
+ if (!existsSync4(pkgPath)) return { pm, family: null, devCmd: null };
2177
+ let pkg;
2178
+ try {
2179
+ pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
2180
+ } catch {
2181
+ return { pm, family: null, devCmd: null };
2182
+ }
2183
+ const deps = {
2184
+ ...pkg.dependencies,
2185
+ ...pkg.devDependencies
2186
+ };
2187
+ const family = pickFamily(deps);
2188
+ const port = 0;
2189
+ const devCmd = family ? pickDevCmd(family, port) : null;
2190
+ return { pm, family, devCmd };
2191
+ }
2192
+ async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
2193
+ const tmpRoute = mkTempRouteDir(dir);
2194
+ try {
2195
+ gitExclude(dir, tmpRoute);
2196
+ await runClaudeCode({
2197
+ cwd: dir,
2198
+ prompt: buildPreviewPrompt(ctx, committedFileNames, tmpRoute),
2199
+ permissionMode: ctx.permissionMode,
2200
+ abortController: abort,
2201
+ maxTurns: PREVIEW_MAX_TURNS
2202
+ });
2203
+ const showcasePath = readShowcasePath(tmpRoute);
2204
+ if (!showcasePath) {
2205
+ throw new Error("preview-ui skill did not write .showcase-path \u2014 skipping preview");
2206
+ }
2207
+ const framework = detectFramework(dir);
2208
+ if (!framework.family) {
2209
+ throw new Error("unsupported framework \u2014 skipping preview");
2210
+ }
2211
+ const port = await findFreePort();
2212
+ const devServer = await startDevServer(dir, framework, port);
2213
+ try {
2214
+ const url = `http://localhost:${port}${showcasePath}`;
2215
+ const shots = await captureWithChromium(url, VIEWPORTS);
2216
+ const { previewId, urls } = await mintUploadUrls(config, ctx.jobId, shots.keys);
2217
+ await uploadAll(urls, shots.keys, shots.bytes);
2218
+ return { previewId, entrypoint: shots.entrypointKey };
2219
+ } finally {
2220
+ await devServer.stop();
2221
+ }
2222
+ } finally {
2223
+ rmSync(tmpRoute, { recursive: true, force: true });
2224
+ }
2225
+ }
2226
+ function pickFamily(deps) {
2227
+ if (deps["next"]) return "next";
2228
+ if (deps["@sveltejs/kit"]) return "svelte-kit";
2229
+ if (deps["nuxt"]) return "nuxt";
2230
+ if (deps["astro"]) return "astro";
2231
+ if (deps["react-scripts"]) return "cra";
2232
+ if (deps["@remix-run/react"] || deps["@remix-run/node"]) return "remix";
2233
+ if (deps["vite"]) {
2234
+ if (deps["vue"]) return "vite-vue";
2235
+ if (deps["svelte"]) return "vite-svelte";
2236
+ if (deps["react"]) return "vite-react";
2237
+ return "vite-react";
2238
+ }
2239
+ return null;
2240
+ }
2241
+ function pickDevCmd(family, port) {
2242
+ switch (family) {
2243
+ case "next":
2244
+ return ["next", "dev", "-p", String(port)];
2245
+ case "vite-react":
2246
+ case "vite-vue":
2247
+ case "vite-svelte":
2248
+ return ["vite", "--port", String(port)];
2249
+ case "nuxt":
2250
+ return ["nuxt", "dev", "--port", String(port)];
2251
+ case "svelte-kit":
2252
+ return ["vite", "--port", String(port)];
2253
+ case "astro":
2254
+ return ["astro", "dev", "--port", String(port)];
2255
+ case "cra":
2256
+ return ["react-scripts", "start"];
2257
+ case "remix":
2258
+ return ["remix", "dev", "--port", String(port)];
2259
+ }
2260
+ }
2261
+ function mkTempRouteDir(dir) {
2262
+ const suffix = Math.random().toString(36).slice(2, 10);
2263
+ const tmpRoute = join5(dir, `__flumecode_preview_${suffix}`);
2264
+ mkdirSync2(tmpRoute, { recursive: true });
2265
+ return tmpRoute;
2266
+ }
2267
+ function gitExclude(dir, tmpRoute) {
2268
+ const excludePath = join5(dir, ".git", "info", "exclude");
2269
+ const rel = relative(dir, tmpRoute);
2270
+ appendFileSync(excludePath, `
2271
+ ${rel}
2272
+ `);
2273
+ }
2274
+ function readShowcasePath(tmpRoute) {
2275
+ const sentinelPath = join5(tmpRoute, ".showcase-path");
2276
+ if (!existsSync4(sentinelPath)) return null;
2277
+ const content = readFileSync5(sentinelPath, "utf8").trim();
2278
+ return content.length > 0 ? content : null;
2279
+ }
2280
+ function findFreePort() {
2281
+ return new Promise((resolve, reject) => {
2282
+ const server = createServer();
2283
+ server.listen(0, "127.0.0.1", () => {
2284
+ const addr = server.address();
2285
+ if (!addr || typeof addr === "string") {
2286
+ server.close();
2287
+ reject(new Error("Could not determine free port"));
2288
+ return;
2289
+ }
2290
+ const port = addr.port;
2291
+ server.close((err) => {
2292
+ if (err) reject(err);
2293
+ else resolve(port);
2294
+ });
2295
+ });
2296
+ server.on("error", reject);
2297
+ });
2298
+ }
2299
+ function startDevServer(dir, framework, port) {
2300
+ return new Promise((resolve, reject) => {
2301
+ const pm = framework.pm ?? "npm";
2302
+ const devCmd = pickDevCmd(framework.family, port);
2303
+ const argv = [pm === "npm" ? "npx" : "exec", ...devCmd];
2304
+ const cmd = pm === "npm" ? "npx" : pm;
2305
+ const args = pm === "npm" ? devCmd : ["exec", ...devCmd];
2306
+ const proc = spawn2(cmd, args, {
2307
+ cwd: dir,
2308
+ env: { ...process.env, PORT: String(port) },
2309
+ stdio: "ignore"
2310
+ });
2311
+ proc.on("error", reject);
2312
+ const baseUrl = `http://localhost:${port}`;
2313
+ const deadline = Date.now() + DEV_SERVER_READY_TIMEOUT_MS;
2314
+ const poll = () => {
2315
+ fetch(baseUrl).then((res) => {
2316
+ if (res.ok || res.status < 500) {
2317
+ resolve({
2318
+ stop: () => new Promise((res2) => {
2319
+ proc.kill();
2320
+ proc.on("close", () => res2());
2321
+ setTimeout(res2, 500);
2322
+ })
2323
+ });
2324
+ } else {
2325
+ scheduleNext();
2326
+ }
2327
+ }).catch(() => scheduleNext());
2328
+ };
2329
+ const scheduleNext = () => {
2330
+ if (Date.now() > deadline) {
2331
+ proc.kill();
2332
+ reject(
2333
+ new Error(`Dev server did not become ready within ${DEV_SERVER_READY_TIMEOUT_MS}ms`)
2334
+ );
2335
+ return;
2336
+ }
2337
+ setTimeout(poll, 1e3);
2338
+ };
2339
+ setTimeout(poll, 2e3);
2340
+ });
2341
+ }
2342
+ async function captureWithChromium(url, viewports) {
2343
+ const { chromium } = await import("playwright");
2344
+ const browser = await chromium.launch({ headless: true });
2345
+ const keys = [];
2346
+ const bytes = [];
2347
+ try {
2348
+ for (const vp of viewports) {
2349
+ const context = await browser.newContext({
2350
+ viewport: { width: vp.width, height: vp.height }
2351
+ });
2352
+ const page = await context.newPage();
2353
+ await page.goto(url, { timeout: CAPTURE_TIMEOUT_MS, waitUntil: "networkidle" });
2354
+ const screenshot = await page.screenshot({ fullPage: true, type: "png" });
2355
+ keys.push(`${vp.name}.png`);
2356
+ bytes.push(Buffer.from(screenshot));
2357
+ await context.close();
2358
+ }
2359
+ } finally {
2360
+ await browser.close();
2361
+ }
2362
+ return {
2363
+ keys,
2364
+ bytes,
2365
+ entrypointKey: keys[keys.length - 1] ?? "desktop.png"
2366
+ };
2367
+ }
2368
+ async function uploadAll(urls, keys, bytes) {
2369
+ await Promise.all(
2370
+ keys.map((key, i) => {
2371
+ const url = urls[key];
2372
+ if (!url) return Promise.resolve();
2373
+ return fetch(url, {
2374
+ method: "PUT",
2375
+ headers: { "content-type": "image/png" },
2376
+ body: bytes[i]
2377
+ });
2378
+ })
2379
+ );
2380
+ }
2381
+
1459
2382
  // src/run.ts
1460
2383
  var IDLE_MS = 5e3;
1461
2384
  var CANCEL_POLL_MS = 2500;
@@ -1650,7 +2573,7 @@ async function processChatJob(ctx, dir, config, abort) {
1650
2573
  console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1651
2574
  return { text: reply, widgets: result.widgets };
1652
2575
  }
1653
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2576
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1654
2577
  let documented = false;
1655
2578
  if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
1656
2579
  try {
@@ -1739,7 +2662,7 @@ ${reply}`;
1739
2662
 
1740
2663
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1741
2664
  }
1742
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2665
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1743
2666
  let documented = false;
1744
2667
  if (wikiExists && await hasChanges(dir)) {
1745
2668
  try {
@@ -1764,11 +2687,24 @@ ${reply}`;
1764
2687
  });
1765
2688
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1766
2689
  const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
2690
+ let preview;
2691
+ if (outcome.kind !== "none") {
2692
+ const committedFileNames = await gitCommittedFiles(dir);
2693
+ if (changedFilesTouchUi(committedFileNames)) {
2694
+ try {
2695
+ console.log(` \u2026generating UI preview for implement ${ctx.jobId}`);
2696
+ preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
2697
+ } catch (err) {
2698
+ console.warn(` preview skipped: ${errorMessage2(err)}`);
2699
+ }
2700
+ }
2701
+ }
1767
2702
  return {
1768
2703
  text: reply,
1769
2704
  widgets: [],
1770
2705
  ...finalReport ? { report: finalReport } : {},
1771
- ...outcome.kind === "pr" ? { pr: outcome.pr } : {}
2706
+ ...outcome.kind === "pr" ? { pr: outcome.pr } : {},
2707
+ ...preview ? { preview } : {}
1772
2708
  };
1773
2709
  }
1774
2710
  async function processReviseJob(ctx, dir, resumed, config, abort) {
@@ -1795,7 +2731,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1795
2731
  console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1796
2732
  return { text: reply, widgets: result.widgets };
1797
2733
  }
1798
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2734
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1799
2735
  let documented = false;
1800
2736
  if (wikiExists && await hasChanges(dir)) {
1801
2737
  try {
@@ -1822,12 +2758,25 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1822
2758
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1823
2759
  }
1824
2760
  const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
2761
+ let preview;
2762
+ if (outcome.kind !== "none") {
2763
+ const committedFileNames = await gitCommittedFiles(dir);
2764
+ if (changedFilesTouchUi(committedFileNames)) {
2765
+ try {
2766
+ console.log(` \u2026generating UI preview for revise ${ctx.jobId}`);
2767
+ preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
2768
+ } catch (err) {
2769
+ console.warn(` preview skipped: ${errorMessage2(err)}`);
2770
+ }
2771
+ }
2772
+ }
1825
2773
  return {
1826
2774
  text: reply,
1827
2775
  widgets: [],
1828
2776
  ...finalReport ? { report: finalReport } : {},
1829
2777
  ...outcome.kind === "pr" ? { pr: outcome.pr } : {},
1830
- ...result.plans?.length ? { plans: result.plans } : {}
2778
+ ...result.plans?.length ? { plans: result.plans } : {},
2779
+ ...preview ? { preview } : {}
1831
2780
  };
1832
2781
  }
1833
2782
  async function processResolveJob(ctx, dir, config, abort) {
@@ -1939,7 +2888,7 @@ async function pollLoop(config) {
1939
2888
  scheduleCancelPoll();
1940
2889
  try {
1941
2890
  resetUsage();
1942
- const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
2891
+ const { text, widgets, pr, plans, report, preview } = await processJob(ctx, config, abort);
1943
2892
  const usage = getUsage();
1944
2893
  await reportJob(config, ctx.jobId, {
1945
2894
  status: "done",
@@ -1948,6 +2897,7 @@ async function pollLoop(config) {
1948
2897
  pr,
1949
2898
  ...plans?.length ? { plans } : {},
1950
2899
  ...report ? { report } : {},
2900
+ ...preview ? { preview } : {},
1951
2901
  ...usage.totalTokens > 0 ? { usage } : {}
1952
2902
  });
1953
2903
  console.log(`\u2713 Job ${ctx.jobId} done`);
@@ -2027,6 +2977,8 @@ function formatJobError(ctx, err) {
2027
2977
  }
2028
2978
 
2029
2979
  // src/cli.ts
2980
+ init_version();
2981
+ import React7 from "react";
2030
2982
  var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
2031
2983
  async function login(args) {
2032
2984
  const flags = parseFlags(args);
@@ -2061,6 +3013,21 @@ async function start() {
2061
3013
  }
2062
3014
  await pollLoop(config);
2063
3015
  }
3016
+ async function runTui(args) {
3017
+ const flags = parseFlags(args);
3018
+ let config = readConfig() ?? { serverUrl: flags.server ?? DEFAULT_SERVER, token: "" };
3019
+ if (flags.server) config.serverUrl = flags.server.replace(/\/+$/, "");
3020
+ if (flags.token) config.apiToken = flags.token;
3021
+ if (!config.apiToken) {
3022
+ const rl = createInterface({ input: stdin, output: stdout });
3023
+ config.apiToken = (await rl.question("CLI token (flcli_\u2026): ")).trim();
3024
+ rl.close();
3025
+ }
3026
+ writeConfig(config);
3027
+ const { render } = await import("ink");
3028
+ const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
3029
+ render(React7.createElement(App2, { config }));
3030
+ }
2064
3031
  function parseFlags(args) {
2065
3032
  const out = {};
2066
3033
  for (let i = 0; i < args.length; i++) {
@@ -2078,11 +3045,14 @@ if (command === "--version" || command === "-v" || command === "version") {
2078
3045
  void login(rest);
2079
3046
  } else if (command === "start") {
2080
3047
  void start();
3048
+ } else if (command === "tui") {
3049
+ void runTui(rest);
2081
3050
  } else {
2082
3051
  console.log(`FlumeCode runner v${RUNNER_VERSION}`);
2083
3052
  console.log("Usage:");
2084
3053
  console.log(" flumecode login # save server URL + token");
2085
3054
  console.log(" flumecode start # poll for and run jobs");
3055
+ console.log(" flumecode tui # open the interactive TUI");
2086
3056
  console.log(" flumecode --version # print the runner version");
2087
3057
  process.exit(command ? 1 : 0);
2088
3058
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "description": "FlumeCode local runner — claims jobs and drives your local Claude Code against a real checkout.",
6
6
  "bin": {
@@ -28,11 +28,17 @@
28
28
  "dependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.0",
30
30
  "@modelcontextprotocol/sdk": "^1",
31
+ "ink": "^5.2.1",
32
+ "ink-text-input": "^6.0.0",
33
+ "playwright": "^1.52.0",
34
+ "react": "^18.3.1",
31
35
  "zod": "4.4.3"
32
36
  },
33
37
  "devDependencies": {
34
38
  "@types/node": "^22.10.5",
39
+ "@types/react": "^19.0.0",
35
40
  "esbuild": "^0.24.2",
41
+ "ink-testing-library": "^4.0.0",
36
42
  "tsx": "^4.19.2",
37
43
  "typescript": "^5.7.3"
38
44
  }
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: preview-ui
3
+ description: >-
4
+ Author an ephemeral fake-data showcase page for changed UI components so the
5
+ runner can screenshot them in a headless browser. Use after a UI-touching
6
+ implementation, when the runner provides a tmpRoute directory. Writes the
7
+ showcase files there and records the URL path in a sentinel file. Never
8
+ commits, pushes, or modifies application code outside the tmpRoute directory.
9
+ ---
10
+
11
+ # preview-ui
12
+
13
+ You author a **temporary showcase page** that imports the recently-changed UI
14
+ components and fills them with realistic fake data, so the runner can start the
15
+ repo's dev server and take headless screenshots.
16
+
17
+ You write only inside the `<tmpRoute>` directory the runner hands you. You never
18
+ modify production code, commit, or push.
19
+
20
+ ## What you receive
21
+
22
+ The runner injects these into your prompt:
23
+
24
+ - **`<tmpRoute>`** — an absolute path to an empty temp directory inside the repo
25
+ (already git-excluded). Write your showcase files here.
26
+ - **Committed UI files** — the list of files changed by the implementation, so
27
+ you know which components to showcase.
28
+
29
+ ## Step 1 — Detect the framework
30
+
31
+ Read the repo's `package.json` (and `package.json` files in workspaces, if any)
32
+ to determine the framework:
33
+
34
+ | Framework | Key `dependencies` / `devDependencies` |
35
+ | ------------- | --------------------------------------- |
36
+ | Next.js | `next` |
37
+ | Vite + React | `vite` + `react` |
38
+ | Vite + Vue | `vite` + `vue` |
39
+ | Vite + Svelte | `vite` + `svelte` (no SvelteKit) |
40
+ | SvelteKit | `@sveltejs/kit` |
41
+ | Nuxt | `nuxt` |
42
+ | Astro | `astro` |
43
+ | CRA | `react-scripts` |
44
+ | Remix | `@remix-run/react` or `@remix-run/node` |
45
+
46
+ If the framework is not recognisable or the file list contains no importable
47
+ components, write a single plain `<tmpRoute>/index.html` file with a message
48
+ like "No supported framework detected" and write `/__flumecode_preview` to
49
+ `<tmpRoute>/.showcase-path`. Then stop — do not create a route file.
50
+
51
+ ## Step 2 — Determine the entry path
52
+
53
+ Choose a URL path unlikely to collide with real routes: `/__flumecode_preview`.
54
+ Write that string (exactly, no trailing newline) to `<tmpRoute>/.showcase-path`.
55
+
56
+ ## Step 3 — Author the showcase entry file
57
+
58
+ Create a single route/page file at the correct location under `<tmpRoute>` for
59
+ the detected framework:
60
+
61
+ | Framework | File to create |
62
+ | ---------------------- | ----------------------------------------------------- |
63
+ | Next.js (App Router) | `<tmpRoute>/page.tsx` (if the project uses `app/`) |
64
+ | Next.js (Pages Router) | `<tmpRoute>/index.tsx` (if the project uses `pages/`) |
65
+ | Vite + React | `<tmpRoute>/App.tsx` (or `.jsx`) |
66
+ | Vite + Vue | `<tmpRoute>/App.vue` |
67
+ | Vite + Svelte | `<tmpRoute>/App.svelte` |
68
+ | SvelteKit | `<tmpRoute>/+page.svelte` |
69
+ | Nuxt | `<tmpRoute>/index.vue` |
70
+ | Astro | `<tmpRoute>/index.astro` |
71
+ | CRA | `<tmpRoute>/index.tsx` |
72
+ | Remix | `<tmpRoute>/route.tsx` |
73
+
74
+ **Next.js App Router note:** The runner mounts the showcase at
75
+ `app/__flumecode_preview/page.tsx` by symlinking or copying `<tmpRoute>` to
76
+ `app/__flumecode_preview/`. You only need to produce `<tmpRoute>/page.tsx` — the
77
+ runner handles the mount.
78
+
79
+ ### Content rules
80
+
81
+ - Import the changed components using their real relative paths (calculate the
82
+ path from `<tmpRoute>` to the component's source location).
83
+ - Fill every prop with **realistic fake data** — use hardcoded literals, not
84
+ calls to external APIs or databases.
85
+ - If a component requires a provider (React context, Vuex store, Pinia, etc.),
86
+ wrap it with a minimal in-file stub provider — do not import the real app
87
+ store or data layer.
88
+ - If a component calls a route handler or fetch endpoint, stub the relevant
89
+ function or hook at the top of the file with a mock that returns realistic
90
+ hard-coded data. Do NOT import MSW or any test library; keep stubs as plain
91
+ module-level overrides.
92
+ - Export the showcase page as the default export (except Astro/SvelteKit, which
93
+ don't require a default export).
94
+ - Keep the file short: one `export default` function that renders all changed
95
+ components in a flex column, with a small amount of padding.
96
+
97
+ ### What NOT to do
98
+
99
+ - Do not call `fetch`, `axios`, `prisma`, `supabase`, or any I/O in the
100
+ showcase.
101
+ - Do not import from test utilities, MSW, Storybook, or Cypress.
102
+ - Do not add new npm dependencies.
103
+ - Do not edit any file outside `<tmpRoute>`.
104
+ - Do not commit or push.
105
+
106
+ ## Step 4 — Verify your output
107
+
108
+ Before finishing, confirm:
109
+
110
+ 1. `<tmpRoute>/.showcase-path` exists and contains the URL path string.
111
+ 2. The showcase entry file exists at the expected path under `<tmpRoute>`.
112
+ 3. The file imports only modules that already exist in the repo (no invented
113
+ paths).
114
+
115
+ If you cannot produce a valid showcase (e.g. the components have complex
116
+ dependencies you cannot stub), write only `<tmpRoute>/.showcase-path` with the
117
+ URL string and leave the entry file absent — the runner will detect the missing
118
+ file and skip the screenshot step gracefully.
119
+
120
+ ## Always
121
+
122
+ - Write the URL path to `<tmpRoute>/.showcase-path` first, before any other
123
+ file — the runner reads it even if something else fails.
124
+ - These files are ephemeral and git-excluded. They will be deleted by the runner
125
+ after screenshots are taken. Never commit or push them.
126
+ - Your final reply should be one short sentence confirming what you created (e.g.
127
+ "Wrote `<tmpRoute>/page.tsx` showcasing `Button` and `Card` with fake data.").