@flumecode/runner 0.23.1 → 0.25.0-preview.39

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
@@ -62,29 +62,27 @@ async function get(cfg, path) {
62
62
  if (!res.ok) throw new Error(await safeText2(res));
63
63
  return res.json();
64
64
  }
65
- async function post(cfg, path, body) {
65
+ async function post(cfg, path, body2) {
66
66
  const res = await fetch(cfg.serverUrl + path, {
67
67
  method: "POST",
68
68
  headers: { ...authHeaders(cfg), "Content-Type": "application/json" },
69
- body: JSON.stringify(body)
69
+ body: JSON.stringify(body2)
70
70
  });
71
71
  if (!res.ok) throw new Error(await safeText2(res));
72
72
  return res.json();
73
73
  }
74
- var listRepositories, listAgents, listRequests, getRequest, createRequest, answerWidgets, acceptPlan, startImplementing, listJobs, listRunners;
74
+ var listRepositories, listAgents, getRequest, createRequest, addRequestComment, answerWidgets, acceptPlan, startImplementing, listJobs, listRunners;
75
75
  var init_tui_api = __esm({
76
76
  "src/tui-api.ts"() {
77
77
  "use strict";
78
78
  init_version();
79
79
  listRepositories = (cfg) => get(cfg, "/api/cli/repositories").then((r) => r.repositories);
80
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
81
  getRequest = (cfg, id) => get(cfg, `/api/cli/requests/${id}`);
85
82
  createRequest = (cfg, input) => post(cfg, "/api/cli/requests", input);
83
+ addRequestComment = (cfg, requestId, body2) => post(cfg, `/api/cli/requests/${requestId}/comments`, { body: body2 });
86
84
  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 });
85
+ acceptPlan = (cfg, messageId, mergeBranch) => post(cfg, `/api/cli/plans/${messageId}/accept`, { target: { kind: "new" }, mergeBranch });
88
86
  startImplementing = (cfg, sessionId) => post(cfg, `/api/cli/sessions/${sessionId}/implement`, {});
89
87
  listJobs = (cfg) => get(cfg, "/api/cli/jobs").then((r) => r.jobs);
90
88
  listRunners = (cfg) => get(cfg, "/api/cli/runners").then((r) => r.runners);
@@ -121,17 +119,18 @@ var init_usePoll = __esm({
121
119
  });
122
120
 
123
121
  // src/tui/StatusBar.tsx
124
- import { Box, Text } from "ink";
125
- import { jsx, jsxs } from "react/jsx-runtime";
122
+ import { Text } from "ink";
123
+ import { jsxs } from "react/jsx-runtime";
126
124
  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
- ] }) });
125
+ const running = jobs?.filter((j) => j.status === "running").length ?? 0;
126
+ const online = runners?.filter((r) => r.status === "online").length ?? 0;
127
+ return /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
128
+ "jobs ",
129
+ running,
130
+ " running \xB7 runners ",
131
+ online,
132
+ " online"
133
+ ] });
135
134
  }
136
135
  var init_StatusBar = __esm({
137
136
  "src/tui/StatusBar.tsx"() {
@@ -141,8 +140,8 @@ var init_StatusBar = __esm({
141
140
 
142
141
  // src/tui/RepoListScreen.tsx
143
142
  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";
143
+ import { Box, Text as Text2, useInput } from "ink";
144
+ import { jsx, jsxs as jsxs2 } from "react/jsx-runtime";
146
145
  function RepoListScreen({ config, onSelect }) {
147
146
  const repos = usePoll(() => listRepositories(config), 1e4);
148
147
  const [cursor, setCursor] = useState2(0);
@@ -155,10 +154,10 @@ function RepoListScreen({ config, onSelect }) {
155
154
  if (repo) onSelect(repo);
156
155
  }
157
156
  });
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):" }),
157
+ if (!repos) return /* @__PURE__ */ jsx(Text2, { children: "Loading repositories\u2026" });
158
+ if (repos.length === 0) return /* @__PURE__ */ jsx(Text2, { children: "No repositories found." });
159
+ return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
160
+ /* @__PURE__ */ jsx(Text2, { bold: true, children: "Select a repository (\u2191\u2193 Enter):" }),
162
161
  repos.map((repo, i) => /* @__PURE__ */ jsxs2(Text2, { color: i === cursor ? "cyan" : void 0, children: [
163
162
  i === cursor ? "\u25B6 " : " ",
164
163
  repo.fullName
@@ -173,188 +172,241 @@ var init_RepoListScreen = __esm({
173
172
  }
174
173
  });
175
174
 
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;
175
+ // src/tui/markdown.ts
176
+ function parseSpans(text) {
177
+ const spans = [];
178
+ let plain = "";
179
+ const flush = () => {
180
+ if (plain) {
181
+ spans.push({ text: plain });
182
+ plain = "";
187
183
  }
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();
184
+ };
185
+ let i = 0;
186
+ while (i < text.length) {
187
+ const ch = text[i];
188
+ if (ch === "`") {
189
+ const end = text.indexOf("`", i + 1);
190
+ if (end > i) {
191
+ flush();
192
+ spans.push({ text: text.slice(i + 1, end), code: true });
193
+ i = end + 1;
194
+ continue;
195
+ }
196
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();
197
+ if (ch === "*" && text[i + 1] === "*") {
198
+ const end = text.indexOf("**", i + 2);
199
+ if (end > i + 1) {
200
+ flush();
201
+ spans.push({ text: text.slice(i + 2, end), bold: true });
202
+ i = end + 2;
203
+ continue;
204
+ }
205
+ }
206
+ if (ch === "*" && text[i + 1] !== void 0 && text[i + 1] !== " " && text[i + 1] !== "*") {
207
+ const end = text.indexOf("*", i + 1);
208
+ if (end > i + 1) {
209
+ flush();
210
+ spans.push({ text: text.slice(i + 1, end), italic: true });
211
+ i = end + 1;
212
+ continue;
213
+ }
214
+ }
215
+ plain += ch;
216
+ i++;
219
217
  }
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(repo.defaultBranch ?? "");
234
- const [error, setError] = useState4(null);
235
- const [submitting, setSubmitting] = useState4(false);
236
- useInput3((_input, key) => {
237
- if (key.escape) {
238
- onBack();
239
- return;
218
+ flush();
219
+ return spans;
220
+ }
221
+ function parseBlocks(src) {
222
+ const blocks = [];
223
+ const lines2 = src.split("\n");
224
+ let para = [];
225
+ let code = null;
226
+ const flushPara = () => {
227
+ if (para.length) {
228
+ blocks.push({ t: "p", spans: parseSpans(para.join(" ")) });
229
+ para = [];
240
230
  }
241
- if (field === "title" && key.tab) {
242
- setField("body");
243
- return;
231
+ };
232
+ for (const raw of lines2) {
233
+ if (code) {
234
+ if (raw.trimEnd() === "```") {
235
+ blocks.push({ t: "code", ...code.lang ? { lang: code.lang } : {}, lines: code.lines });
236
+ code = null;
237
+ } else {
238
+ code.lines.push(raw);
239
+ }
240
+ continue;
244
241
  }
245
- if (field === "body" && key.tab) {
246
- setField("agent");
247
- return;
242
+ const line = raw.trimEnd();
243
+ const trimmed = line.trim();
244
+ if (trimmed.startsWith("```")) {
245
+ flushPara();
246
+ const lang = trimmed.slice(3).trim();
247
+ code = { ...lang ? { lang } : {}, lines: [] };
248
+ continue;
248
249
  }
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");
250
+ if (trimmed === "") {
251
+ flushPara();
252
+ continue;
253
253
  }
254
- if (field === "branch" && key.return && !submitting) {
255
- void submit();
254
+ const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
255
+ if (h && h[1] && h[2] !== void 0) {
256
+ flushPara();
257
+ blocks.push({ t: "h", level: h[1].length, spans: parseSpans(h[2]) });
258
+ continue;
256
259
  }
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
260
+ if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
261
+ flushPara();
262
+ blocks.push({ t: "rule" });
263
+ continue;
264
+ }
265
+ const indent = line.length - line.trimStart().length;
266
+ const li = /^[-*]\s+(.*)$/.exec(trimmed);
267
+ if (li && li[1] !== void 0) {
268
+ flushPara();
269
+ blocks.push({ t: "li", depth: indent >= 2 ? 1 : 0, spans: parseSpans(li[1]) });
270
+ continue;
271
+ }
272
+ const ol = /^(\d+)[.)]\s+(.*)$/.exec(trimmed);
273
+ if (ol && ol[1] && ol[2] !== void 0) {
274
+ flushPara();
275
+ blocks.push({
276
+ t: "li",
277
+ depth: indent >= 2 ? 1 : 0,
278
+ ordered: Number(ol[1]),
279
+ spans: parseSpans(ol[2])
269
280
  });
270
- onCreated(result.requestId);
271
- } catch (err) {
272
- setError(err instanceof Error ? err.message : "Unknown error");
273
- setSubmitting(false);
281
+ continue;
282
+ }
283
+ if (trimmed.startsWith("> ") || trimmed === ">") {
284
+ flushPara();
285
+ blocks.push({ t: "quote", spans: parseSpans(trimmed.replace(/^>\s?/, "")) });
286
+ continue;
274
287
  }
288
+ para.push(trimmed);
275
289
  }
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
- ] })
290
+ if (code)
291
+ blocks.push({ t: "code", ...code.lang ? { lang: code.lang } : {}, lines: code.lines });
292
+ flushPara();
293
+ return blocks;
294
+ }
295
+ function parseMarkdown(src) {
296
+ try {
297
+ return parseBlocks(src);
298
+ } catch {
299
+ return [{ t: "p", spans: [{ text: src }] }];
300
+ }
301
+ }
302
+ var init_markdown = __esm({
303
+ "src/tui/markdown.ts"() {
304
+ "use strict";
305
+ }
306
+ });
307
+
308
+ // src/tui/Markdown.tsx
309
+ import { Box as Box2, Text as Text3 } from "ink";
310
+ import { jsx as jsx2, jsxs as jsxs3 } from "react/jsx-runtime";
311
+ function Markdown({ source, width }) {
312
+ const blocks = parseMarkdown(source);
313
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: blocks.map((block, i) => renderBlock(block, i, blocks, width)) });
314
+ }
315
+ function spanNodes(spans) {
316
+ return spans.map((s, k) => /* @__PURE__ */ jsx2(Text3, { bold: s.bold, italic: s.italic, color: s.code ? "magenta" : void 0, children: s.text }, k));
317
+ }
318
+ function renderBlock(block, i, blocks, width) {
319
+ switch (block.t) {
320
+ case "h":
321
+ return /* @__PURE__ */ jsx2(Box2, { marginTop: i === 0 ? 0 : 1, children: /* @__PURE__ */ jsx2(Text3, { bold: true, underline: block.level !== 3, children: spanNodes(block.spans) }) }, i);
322
+ case "p":
323
+ return /* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(Text3, { children: spanNodes(block.spans) }) }, i);
324
+ case "li": {
325
+ const marker = block.ordered ? `${block.ordered}.` : block.depth === 1 ? "\u25E6" : "\u2022";
326
+ const isLastOfRun = blocks[i + 1]?.t !== "li";
327
+ return /* @__PURE__ */ jsxs3(Box2, { marginBottom: isLastOfRun ? 1 : 0, children: [
328
+ /* @__PURE__ */ jsx2(Box2, { width: block.depth === 1 ? 4 : 2, flexShrink: 0, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: marker }) }),
329
+ /* @__PURE__ */ jsx2(Box2, { flexGrow: 1, children: /* @__PURE__ */ jsx2(Text3, { children: spanNodes(block.spans) }) })
330
+ ] }, i);
331
+ }
332
+ case "code":
333
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: block.lines.map((line, k) => (
334
+ // An empty string renders nothing in ink, so substitute a single space.
335
+ /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: line || " " }, k)
336
+ )) }, i);
337
+ case "quote":
338
+ return /* @__PURE__ */ jsxs3(Box2, { marginBottom: 1, children: [
339
+ /* @__PURE__ */ jsx2(Box2, { width: 2, flexShrink: 0, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: "\u258C" }) }),
340
+ /* @__PURE__ */ jsx2(Box2, { flexGrow: 1, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: spanNodes(block.spans) }) })
341
+ ] }, i);
342
+ case "rule":
343
+ return /* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: "\u2500".repeat(Math.max(8, Math.min(width - 2, 60))) }) }, i);
344
+ }
345
+ }
346
+ var init_Markdown = __esm({
347
+ "src/tui/Markdown.tsx"() {
348
+ "use strict";
349
+ init_markdown();
350
+ }
351
+ });
352
+
353
+ // src/tui/Spinner.tsx
354
+ import { useEffect as useEffect2, useState as useState3 } from "react";
355
+ import { Text as Text4 } from "ink";
356
+ import { jsxs as jsxs4 } from "react/jsx-runtime";
357
+ function Spinner({ label }) {
358
+ const [frame, setFrame] = useState3(0);
359
+ useEffect2(() => {
360
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 100);
361
+ return () => clearInterval(id);
362
+ }, []);
363
+ return /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
364
+ FRAMES[frame],
365
+ " ",
366
+ label
310
367
  ] });
311
368
  }
312
- var init_NewRequestScreen = __esm({
313
- "src/tui/NewRequestScreen.tsx"() {
369
+ var FRAMES;
370
+ var init_Spinner = __esm({
371
+ "src/tui/Spinner.tsx"() {
314
372
  "use strict";
315
- init_usePoll();
316
- init_tui_api();
373
+ FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
317
374
  }
318
375
  });
319
376
 
320
377
  // 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" }];
378
+ import { useRef as useRef2, useState as useState4 } from "react";
379
+ import { Box as Box3, Text as Text5, useInput as useInput2 } from "ink";
380
+ import TextInput from "ink-text-input";
381
+ import { jsx as jsx3, jsxs as jsxs5 } from "react/jsx-runtime";
382
+ function WidgetAnswerScreen({ config, message: message2, onDone }) {
383
+ const [widgetIndex, setWidgetIndex] = useState4(0);
384
+ const [cursor, setCursor] = useState4(0);
385
+ const [checked, setChecked] = useState4(/* @__PURE__ */ new Set());
386
+ const [customText, setCustomText] = useState4("");
387
+ const [showCustom, setShowCustom] = useState4(false);
388
+ const [error, setError] = useState4(null);
389
+ const [submitting, setSubmitting] = useState4(false);
390
+ const answersRef = useRef2([]);
391
+ const summaryRef = useRef2([]);
392
+ const widget = message2.widgets[widgetIndex];
393
+ function handleSingleInput(key, w) {
394
+ const opts2 = [...w.options, OTHER];
343
395
  if (key.upArrow) {
396
+ setShowCustom(false);
344
397
  setCursor((c) => Math.max(0, c - 1));
345
398
  } else if (key.downArrow) {
399
+ setShowCustom(false);
346
400
  setCursor((c) => Math.min(opts2.length - 1, c + 1));
347
401
  } else if (key.return) {
402
+ if (showCustom) return;
348
403
  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
- }
404
+ if (picked?.id === OTHER.id) setShowCustom(true);
405
+ else if (picked) void advanceOrSubmit(w, picked.id, void 0, void 0);
354
406
  }
355
407
  }
356
408
  function handleMultiInput(input, key, w) {
357
- const opts2 = [...w.options, { id: "__other__", label: "Other" }];
409
+ const opts2 = [...w.options, OTHER];
358
410
  if (key.upArrow) {
359
411
  setCursor((c) => Math.max(0, c - 1));
360
412
  } else if (key.downArrow) {
@@ -362,7 +414,7 @@ function WidgetAnswerScreen({ config, message, onDone }) {
362
414
  } else if (input === " ") {
363
415
  const picked = opts2[cursor];
364
416
  if (!picked) return;
365
- if (picked.id === "__other__") {
417
+ if (picked.id === OTHER.id) {
366
418
  setShowCustom((s) => !s);
367
419
  } else {
368
420
  const next = new Set(checked);
@@ -374,24 +426,26 @@ function WidgetAnswerScreen({ config, message, onDone }) {
374
426
  void advanceOrSubmit(w, void 0, [...checked], customText || void 0);
375
427
  }
376
428
  }
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
- }
429
+ useInput2((input, key) => {
430
+ if (!widget || submitting) return;
431
+ if (widget.type === "single_select") handleSingleInput(key, widget);
432
+ else handleMultiInput(input, key, widget);
384
433
  });
434
+ const onCustomSubmit = (value) => {
435
+ if (!widget || widget.type !== "single_select" || submitting) return;
436
+ const text = value.trim();
437
+ if (!text) return;
438
+ void advanceOrSubmit(widget, void 0, void 0, text);
439
+ };
385
440
  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) {
441
+ answersRef.current.push({
442
+ widgetId: w.id,
443
+ ...optionId !== void 0 ? { optionId } : {},
444
+ ...optionIds !== void 0 ? { optionIds } : {},
445
+ ...custom ? { customText: custom } : {}
446
+ });
447
+ summaryRef.current.push(`${w.question} \u2192 ${answerLabel(w, optionId, optionIds, custom)}`);
448
+ if (widgetIndex < message2.widgets.length - 1) {
395
449
  setWidgetIndex((i) => i + 1);
396
450
  setCursor(0);
397
451
  setChecked(/* @__PURE__ */ new Set());
@@ -402,177 +456,396 @@ function WidgetAnswerScreen({ config, message, onDone }) {
402
456
  setSubmitting(true);
403
457
  setError(null);
404
458
  try {
405
- await answerWidgets(config, message.id, { requestId: message.requestId, answers });
406
- onDone();
459
+ await answerWidgets(config, message2.id, {
460
+ requestId: message2.requestId,
461
+ answers: answersRef.current
462
+ });
463
+ onDone(summaryRef.current);
407
464
  } catch (err) {
465
+ answersRef.current.pop();
466
+ summaryRef.current.pop();
408
467
  setError(err instanceof Error ? err.message : "Unknown error");
409
468
  setSubmitting(false);
410
469
  }
411
470
  }
412
- if (!widget) return /* @__PURE__ */ jsx5(Text5, { children: "No widgets to answer." });
471
+ if (!widget) return /* @__PURE__ */ jsx3(Text5, { children: "No widgets to answer." });
413
472
  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
473
+ const opts = [...widget.options, OTHER];
474
+ const total = opts.length;
475
+ const start2 = total <= WINDOW ? 0 : Math.min(Math.max(0, cursor - Math.floor(WINDOW / 2)), total - WINDOW);
476
+ const end = Math.min(total, start2 + WINDOW);
477
+ const moreAbove = start2;
478
+ const moreBelow = total - end;
479
+ return /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", children: [
480
+ /* @__PURE__ */ jsxs5(Box3, { children: [
481
+ /* @__PURE__ */ jsx3(Box3, { width: 2, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text5, { bold: true, children: "\u25CF" }) }),
482
+ /* @__PURE__ */ jsx3(Box3, { flexGrow: 1, children: /* @__PURE__ */ jsxs5(Text5, { children: [
483
+ /* @__PURE__ */ jsx3(Text5, { bold: true, children: widget.question }),
484
+ /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: ` (${widgetIndex + 1}/${message2.widgets.length})` })
485
+ ] }) })
486
+ ] }),
487
+ widget.body ? /* @__PURE__ */ jsx3(Box3, { marginTop: 1, marginLeft: 2, children: /* @__PURE__ */ jsx3(Markdown, { source: widget.body, width: 76 }) }) : null,
488
+ /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", marginTop: 1, children: [
489
+ moreAbove > 0 && /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: ` \u2026 ${moreAbove} more above` }),
490
+ opts.slice(start2, end).map((opt, vi) => {
491
+ const i = start2 + vi;
492
+ const onCursor = i === cursor;
493
+ return /* @__PURE__ */ jsxs5(Box3, { children: [
494
+ /* @__PURE__ */ jsx3(Box3, { width: 2, flexShrink: 0, children: onCursor ? /* @__PURE__ */ jsx3(Text5, { color: "cyan", bold: true, children: "\u276F " }) : /* @__PURE__ */ jsx3(Text5, { children: " " }) }),
495
+ /* @__PURE__ */ jsx3(Box3, { flexGrow: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: onCursor ? "cyan" : void 0, children: [
496
+ !isSingle && (checked.has(opt.id) ? /* @__PURE__ */ jsx3(Text5, { color: "green", children: "\u25C9 " }) : /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: "\u25EF " })),
497
+ opt.label
498
+ ] }) })
499
+ ] }, opt.id);
500
+ }),
501
+ moreBelow > 0 && /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: ` \u2026 ${moreBelow} more below` })
423
502
  ] }),
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 })
503
+ showCustom && /* @__PURE__ */ jsxs5(Box3, { marginTop: 1, children: [
504
+ /* @__PURE__ */ jsx3(Box3, { width: 2, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text5, { color: "cyan", bold: true, children: "\u203A" }) }),
505
+ /* @__PURE__ */ jsx3(
506
+ TextInput,
507
+ {
508
+ value: customText,
509
+ onChange: setCustomText,
510
+ focus: showCustom,
511
+ onSubmit: isSingle ? onCustomSubmit : void 0
512
+ }
513
+ )
430
514
  ] }),
431
- submitting && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Submitting\u2026" }),
515
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: isSingle ? "\u2191\u2193 move \xB7 enter select" : "\u2191\u2193 move \xB7 space toggle \xB7 enter confirm" }) }),
516
+ submitting && /* @__PURE__ */ jsx3(Spinner, { label: "submitting\u2026" }),
432
517
  error && /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
433
518
  "Error: ",
434
519
  error
435
520
  ] })
436
521
  ] });
437
522
  }
523
+ function answerLabel(w, optionId, optionIds, custom) {
524
+ const pickedIds = [...optionId ? [optionId] : [], ...optionIds ?? []];
525
+ const labels = pickedIds.map((id) => w.options.find((o) => o.id === id)?.label).filter((label) => label !== void 0);
526
+ if (custom) labels.push(custom);
527
+ return labels.length ? labels.join(", ") : "(no selection)";
528
+ }
529
+ var OTHER, WINDOW;
438
530
  var init_WidgetAnswerScreen = __esm({
439
531
  "src/tui/WidgetAnswerScreen.tsx"() {
440
532
  "use strict";
441
533
  init_tui_api();
534
+ init_Markdown();
535
+ init_Spinner();
536
+ OTHER = { id: "__other__", label: "Other" };
537
+ WINDOW = 10;
442
538
  }
443
539
  });
444
540
 
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, repo, 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(repo.defaultBranch ?? "main");
455
- const [mergeBranch, setMergeBranch] = useState6(repo.defaultBranch ?? "");
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;
541
+ // src/tui/Message.tsx
542
+ import { Box as Box4, Text as Text6 } from "ink";
543
+ import { Fragment, jsx as jsx4, jsxs as jsxs6 } from "react/jsx-runtime";
544
+ function Message({ msg, width }) {
545
+ return /* @__PURE__ */ jsxs6(Box4, { marginBottom: 1, flexDirection: "row", children: [
546
+ /* @__PURE__ */ jsx4(Box4, { width: 2, flexShrink: 0, children: glyph(msg) }),
547
+ /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", flexGrow: 1, children: body(msg, width) })
548
+ ] });
549
+ }
550
+ function glyph(msg) {
551
+ if (msg.role === "user") {
552
+ return /* @__PURE__ */ jsx4(Text6, { color: "cyan", bold: true, children: "\u203A" });
553
+ }
554
+ if (msg.kind === "success") return /* @__PURE__ */ jsx4(Text6, { color: "green", children: "\u2713" });
555
+ if (msg.kind === "error") return /* @__PURE__ */ jsx4(Text6, { color: "red", children: "\u2717" });
556
+ if (msg.kind === "info" || msg.role === "system") return /* @__PURE__ */ jsx4(Text6, { dimColor: true, children: "\xB7" });
557
+ return /* @__PURE__ */ jsx4(Text6, { bold: true, children: "\u25CF" });
558
+ }
559
+ function body(msg, width) {
560
+ if (msg.role === "user") return /* @__PURE__ */ jsx4(Text6, { color: "cyan", children: msg.text });
561
+ if (msg.kind === "markdown") return /* @__PURE__ */ jsx4(Markdown, { source: msg.text, width: width - 2 });
562
+ if (msg.kind === "plan") {
563
+ return /* @__PURE__ */ jsxs6(Fragment, { children: [
564
+ /* @__PURE__ */ jsx4(Text6, { bold: true, children: "Plan" }),
565
+ /* @__PURE__ */ jsx4(Text6, { dimColor: true, children: "\u2500".repeat(Math.max(8, Math.min(width - 4, 50))) }),
566
+ /* @__PURE__ */ jsx4(Markdown, { source: msg.text, width: width - 2 })
567
+ ] });
568
+ }
569
+ if (msg.kind === "info" || msg.kind === "success" || msg.kind === "error") {
570
+ return /* @__PURE__ */ jsx4(Text6, { dimColor: true, children: msg.text });
571
+ }
572
+ return /* @__PURE__ */ jsx4(Text6, { children: msg.text });
573
+ }
574
+ var init_Message = __esm({
575
+ "src/tui/Message.tsx"() {
576
+ "use strict";
577
+ init_Markdown();
578
+ }
579
+ });
580
+
581
+ // src/tui/ConversationScreen.tsx
582
+ import { useCallback, useEffect as useEffect3, useRef as useRef3, useState as useState5 } from "react";
583
+ import { Box as Box5, Static, Text as Text7, useInput as useInput3, useStdout } from "ink";
584
+ import TextInput2 from "ink-text-input";
585
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs7 } from "react/jsx-runtime";
586
+ function deriveTitle(text) {
587
+ const firstLine = (text.split("\n")[0] ?? "").trim();
588
+ const t = firstLine.length > 0 ? firstLine : text.trim();
589
+ return t.length > 72 ? `${t.slice(0, 69).trimEnd()}\u2026` : t;
590
+ }
591
+ function ConversationScreen({ config, repo, onBack }) {
592
+ const idRef = useRef3(0);
593
+ const [msgs, setMsgs] = useState5([
594
+ {
595
+ id: 0,
596
+ role: "system",
597
+ kind: "info",
598
+ text: `${repo.fullName} \u2014 describe what you'd like to build. Your message becomes a request; the FlumeCode agent (your runner) takes it from there.`
467
599
  }
468
- if (key.upArrow) {
469
- setCursor((c) => Math.max(0, c - 1));
470
- return;
600
+ ]);
601
+ const [input, setInput] = useState5("");
602
+ const historyRef = useRef3([]);
603
+ const [histIndex, setHistIndex] = useState5(null);
604
+ const [phase, setPhase] = useState5("compose");
605
+ const [error, setError] = useState5(null);
606
+ const [agentPending, setAgentPending] = useState5(false);
607
+ const [requestId, setRequestId] = useState5(null);
608
+ const planMessageId = useRef3(null);
609
+ const shownIds = useRef3(/* @__PURE__ */ new Set());
610
+ const [widgetMsg, setWidgetMsg] = useState5(null);
611
+ const { stdout: stdout2 } = useStdout();
612
+ const width = Math.min(stdout2?.columns ?? 80, 100);
613
+ const runners = usePoll(() => listRunners(config), 15e3);
614
+ const noRunnerOnline = runners !== null && !runners.some((r) => r.status === "online");
615
+ const push = useCallback((role, text, kind = "text") => {
616
+ setMsgs((prev) => [...prev, { id: ++idRef.current, role, kind, text }]);
617
+ }, []);
618
+ const createFromIdea = useCallback(
619
+ async (idea) => {
620
+ setPhase("creating");
621
+ try {
622
+ const agents = await listAgents(config);
623
+ const agentId = agents[0]?.id;
624
+ if (!agentId) throw new Error("No agent is configured for this repository.");
625
+ const { requestId: id } = await createRequest(config, {
626
+ title: deriveTitle(idea),
627
+ body: idea,
628
+ agentId,
629
+ repoId: repo.id,
630
+ // Fixed to the repo's default branch (same as the merge target below).
631
+ checkoutBranch: repo.defaultBranch ?? "main"
632
+ });
633
+ setRequestId(id);
634
+ setError(null);
635
+ push("system", `Request created \u2014 ${config.serverUrl}/requests/${id}`, "success");
636
+ push(
637
+ "system",
638
+ "The agent (your runner) is working on it. Needs a connected runner; Esc to leave.",
639
+ "info"
640
+ );
641
+ setPhase("thread");
642
+ } catch (err) {
643
+ setError(
644
+ `Could not create the request: ${err instanceof Error ? err.message : String(err)}`
645
+ );
646
+ setPhase("compose");
647
+ }
648
+ },
649
+ [config, push, repo]
650
+ );
651
+ const postFollowUp = useCallback(
652
+ async (text) => {
653
+ if (!requestId) return;
654
+ try {
655
+ await addRequestComment(config, requestId, text);
656
+ setError(null);
657
+ } catch (err) {
658
+ setError(`Could not send: ${err instanceof Error ? err.message : String(err)}`);
659
+ }
660
+ },
661
+ [config, requestId]
662
+ );
663
+ const doImplement = useCallback(async () => {
664
+ const msgId = planMessageId.current;
665
+ if (!msgId) return;
666
+ setPhase("working");
667
+ try {
668
+ const mergeBranch = repo.defaultBranch ?? "main";
669
+ const { session } = await acceptPlan(config, msgId, mergeBranch);
670
+ push(
671
+ "system",
672
+ `Coding session created \u2014 ${config.serverUrl}/repositories/${repo.id}/sessions/${session.id}`,
673
+ "success"
674
+ );
675
+ await startImplementing(config, session.id);
676
+ push(
677
+ "system",
678
+ "Implementation started \u2014 the runner will build it and open a PR. Track it in the status bar or the web.",
679
+ "success"
680
+ );
681
+ } catch (err) {
682
+ push(
683
+ "system",
684
+ `Could not start implementing: ${err instanceof Error ? err.message : String(err)}`,
685
+ "error"
686
+ );
471
687
  }
472
- if (key.downArrow) {
473
- setCursor((c) => Math.min(messages.length - 1, c + 1));
688
+ setPhase("thread");
689
+ }, [config, push, repo]);
690
+ const onWidgetDone = useCallback(
691
+ (summary) => {
692
+ setWidgetMsg(null);
693
+ for (const line of summary) push("system", line, "info");
694
+ push("system", "the agent is continuing\u2026", "info");
695
+ setPhase("thread");
696
+ },
697
+ [push]
698
+ );
699
+ useEffect3(() => {
700
+ if (phase !== "thread" || !requestId) return;
701
+ let live = true;
702
+ const poll = async () => {
703
+ try {
704
+ const data = await getRequest(config, requestId);
705
+ if (!live) return;
706
+ let pending = false;
707
+ for (const m of data.messages) {
708
+ if (shownIds.current.has(m.id)) continue;
709
+ if (m.authorType && m.authorType !== "agent") {
710
+ shownIds.current.add(m.id);
711
+ continue;
712
+ }
713
+ if (m.type === "plan" && m.body?.trim()) {
714
+ shownIds.current.add(m.id);
715
+ planMessageId.current = m.id;
716
+ push("assistant", m.body.trim(), "plan");
717
+ setAgentPending(false);
718
+ setPhase("confirm-implement");
719
+ live = false;
720
+ return;
721
+ }
722
+ const ws = m.widgets;
723
+ if (ws && ws.length) {
724
+ shownIds.current.add(m.id);
725
+ if (m.body?.trim()) push("assistant", m.body.trim(), "markdown");
726
+ push("system", "The agent is asking \u2014 pick an option below.", "info");
727
+ setWidgetMsg({ id: m.id, widgets: ws, requestId });
728
+ setAgentPending(false);
729
+ setPhase("answering-widget");
730
+ live = false;
731
+ return;
732
+ }
733
+ if (m.type === "comment" && m.body?.trim()) {
734
+ shownIds.current.add(m.id);
735
+ push("assistant", m.body.trim(), "markdown");
736
+ continue;
737
+ }
738
+ if (m.authorType === "agent" && !m.body?.trim()) pending = true;
739
+ }
740
+ setAgentPending(pending);
741
+ } catch {
742
+ }
743
+ };
744
+ void poll();
745
+ const iv = setInterval(() => void poll(), 3e3);
746
+ return () => {
747
+ live = false;
748
+ clearInterval(iv);
749
+ };
750
+ }, [phase, requestId, config, push]);
751
+ useInput3((ch, key) => {
752
+ if (phase === "answering-widget") return;
753
+ if (key.escape) {
754
+ if (phase === "compose" || phase === "thread") onBack();
755
+ else if (phase === "confirm-implement") {
756
+ push("system", "Holding off on implementation.", "info");
757
+ setPhase("thread");
758
+ }
474
759
  return;
475
760
  }
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(repo.defaultBranch ?? "main");
482
- setMergeBranch(repo.defaultBranch ?? "");
483
- setAcceptStep("target");
484
- setOverlay({ kind: "accept", messageId: selectedMsg.id });
761
+ if ((phase === "compose" || phase === "thread") && (key.upArrow || key.downArrow)) {
762
+ const h = historyRef.current;
763
+ if (h.length === 0) return;
764
+ if (key.upArrow) {
765
+ const idx = histIndex === null ? h.length - 1 : Math.max(0, histIndex - 1);
766
+ setHistIndex(idx);
767
+ setInput(h[idx] ?? "");
768
+ } else if (histIndex !== null && histIndex < h.length - 1) {
769
+ const idx = histIndex + 1;
770
+ setHistIndex(idx);
771
+ setInput(h[idx] ?? "");
772
+ } else {
773
+ setHistIndex(null);
774
+ setInput("");
485
775
  }
776
+ return;
486
777
  }
487
- if (_input === "i" && implementableSession) {
488
- void doImplement(implementableSession.id);
778
+ if (phase === "confirm-implement") {
779
+ if (ch === "y" || ch === "Y") void doImplement();
780
+ else if (ch === "n" || ch === "N") {
781
+ push("system", "Holding off on implementation.", "info");
782
+ setPhase("thread");
783
+ }
489
784
  }
490
785
  });
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()
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");
786
+ const onSubmit = () => {
787
+ const text = input.trim();
788
+ if (!text) return;
789
+ const h = historyRef.current;
790
+ if (h[h.length - 1] !== text) h.push(text);
791
+ setHistIndex(null);
792
+ setInput("");
793
+ if (phase === "compose") {
794
+ push("user", text);
795
+ void createFromIdea(text);
796
+ } else if (phase === "thread") {
797
+ push("user", text);
798
+ void postFollowUp(text);
510
799
  }
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,
800
+ };
801
+ const inputActive = phase === "compose" || phase === "thread";
802
+ const staticItems = [{ kind: "header", id: -1 }, ...msgs];
803
+ return /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", children: [
804
+ /* @__PURE__ */ jsx5(Static, { items: staticItems, children: (item) => item.kind === "header" ? /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
805
+ "\u273B FlumeCode \xB7 ",
806
+ repo.fullName,
807
+ " ",
808
+ /* @__PURE__ */ jsx5(Text7, { dimColor: true, children: "esc to go back" })
809
+ ] }) }, String(item.id)) : /* @__PURE__ */ jsx5(Message, { msg: item, width }, String(item.id)) }),
810
+ phase === "creating" && /* @__PURE__ */ jsx5(Spinner, { label: "creating request\u2026" }),
811
+ phase === "working" && /* @__PURE__ */ jsx5(Spinner, { label: "working\u2026" }),
812
+ phase === "confirm-implement" && /* @__PURE__ */ jsxs7(Text7, { children: [
813
+ /* @__PURE__ */ jsx5(Text7, { color: "green", bold: true, children: "\u276F Start implementing?" }),
814
+ /* @__PURE__ */ jsx5(Text7, { dimColor: true, children: " y = yes \xB7 n = keep refining" })
815
+ ] }),
816
+ phase === "answering-widget" && widgetMsg && /* @__PURE__ */ jsx5(WidgetAnswerScreen, { config, message: widgetMsg, onDone: onWidgetDone }),
817
+ phase === "thread" && agentPending && /* @__PURE__ */ jsx5(Spinner, { label: "waiting for the agent\u2026" }),
818
+ phase === "thread" && agentPending && noRunnerOnline && /* @__PURE__ */ jsx5(Text7, { color: "yellow", children: "\u26A0 no runner online \u2014 start one with `flumecode start`" }),
819
+ inputActive && /* @__PURE__ */ jsxs7(Fragment2, { children: [
820
+ error && /* @__PURE__ */ jsx5(Text7, { color: "red", children: error }),
821
+ /* @__PURE__ */ jsxs7(Box5, { borderStyle: "round", borderColor: inputActive ? "cyan" : "gray", paddingX: 1, children: [
822
+ /* @__PURE__ */ jsx5(Text7, { color: "cyan", bold: true, children: "\u203A " }),
823
+ /* @__PURE__ */ jsx5(
824
+ TextInput2,
539
825
  {
540
- value: mergeBranch,
541
- onChange: setMergeBranch,
542
- onSubmit: () => void doAcceptPlan(),
826
+ value: input,
827
+ onChange: (v) => {
828
+ setInput(v);
829
+ if (histIndex !== null) setHistIndex(null);
830
+ },
831
+ onSubmit,
543
832
  focus: true
544
- }
833
+ },
834
+ `input-${histIndex ?? "live"}`
545
835
  )
546
836
  ] }),
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 })
837
+ /* @__PURE__ */ jsx5(Text7, { dimColor: true, children: phase === "compose" ? "enter to create a request \xB7 esc back" : "enter send \xB7 esc back" })
838
+ ] })
568
839
  ] });
569
840
  }
570
- var init_ThreadScreen = __esm({
571
- "src/tui/ThreadScreen.tsx"() {
841
+ var init_ConversationScreen = __esm({
842
+ "src/tui/ConversationScreen.tsx"() {
572
843
  "use strict";
573
- init_usePoll();
574
844
  init_tui_api();
575
845
  init_WidgetAnswerScreen();
846
+ init_Message();
847
+ init_Spinner();
848
+ init_usePoll();
576
849
  }
577
850
  });
578
851
 
@@ -581,59 +854,37 @@ var App_exports = {};
581
854
  __export(App_exports, {
582
855
  App: () => App
583
856
  });
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";
857
+ import { useState as useState6 } from "react";
858
+ import { Box as Box6 } from "ink";
859
+ import { jsx as jsx6, jsxs as jsxs8 } from "react/jsx-runtime";
587
860
  function renderScreen(screen, setScreen, config) {
588
861
  switch (screen.name) {
589
862
  case "repos":
590
- return /* @__PURE__ */ jsx7(
863
+ return /* @__PURE__ */ jsx6(
591
864
  RepoListScreen,
592
865
  {
593
866
  config,
594
- onSelect: (repo) => setScreen({ name: "requests", repo })
867
+ onSelect: (repo) => setScreen({ name: "conversation", repo })
595
868
  }
596
869
  );
597
- case "requests":
598
- return /* @__PURE__ */ jsx7(
599
- RequestListScreen,
870
+ case "conversation":
871
+ return /* @__PURE__ */ jsx6(
872
+ ConversationScreen,
600
873
  {
601
874
  config,
602
875
  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
876
  onBack: () => setScreen({ name: "repos" })
606
877
  }
607
878
  );
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
- repo: screen.repo,
624
- requestId: screen.requestId,
625
- onBack: () => setScreen({ name: "requests", repo: screen.repo })
626
- }
627
- );
628
879
  }
629
880
  }
630
881
  function App({ config }) {
631
- const [screen, setScreen] = useState7({ name: "repos" });
882
+ const [screen, setScreen] = useState6({ name: "repos" });
632
883
  const jobs = usePoll(() => listJobs(config), 3e3);
633
884
  const runners = usePoll(() => listRunners(config), 3e3);
634
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
885
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
635
886
  renderScreen(screen, setScreen, config),
636
- /* @__PURE__ */ jsx7(StatusBar, { jobs, runners })
887
+ /* @__PURE__ */ jsx6(StatusBar, { jobs, runners })
637
888
  ] });
638
889
  }
639
890
  var init_App = __esm({
@@ -643,15 +894,17 @@ var init_App = __esm({
643
894
  init_usePoll();
644
895
  init_StatusBar();
645
896
  init_RepoListScreen();
646
- init_RequestListScreen();
647
- init_NewRequestScreen();
648
- init_ThreadScreen();
897
+ init_ConversationScreen();
649
898
  }
650
899
  });
651
900
 
652
901
  // src/cli.ts
653
902
  import { createInterface } from "node:readline/promises";
654
903
  import { stdin, stdout } from "node:process";
904
+ import { spawn as spawn4 } from "node:child_process";
905
+ import { openSync } from "node:fs";
906
+ import { homedir as homedir2 } from "node:os";
907
+ import { join as join7 } from "node:path";
655
908
 
656
909
  // src/config.ts
657
910
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -663,8 +916,8 @@ function readConfig() {
663
916
  if (!existsSync(configPath)) return null;
664
917
  try {
665
918
  const parsed = JSON.parse(readFileSync(configPath, "utf8"));
666
- if (!parsed.serverUrl || !parsed.token) return null;
667
- const config = { serverUrl: parsed.serverUrl, token: parsed.token };
919
+ if (!parsed.serverUrl || !parsed.token && !parsed.apiToken) return null;
920
+ const config = { serverUrl: parsed.serverUrl, token: parsed.token ?? "" };
668
921
  if (parsed.apiToken) config.apiToken = parsed.apiToken;
669
922
  return config;
670
923
  } catch {
@@ -676,6 +929,118 @@ function writeConfig(config) {
676
929
  writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
677
930
  }
678
931
 
932
+ // src/browser-login.ts
933
+ import { createServer } from "node:http";
934
+ import { randomBytes } from "node:crypto";
935
+ import { hostname } from "node:os";
936
+ import { spawn } from "node:child_process";
937
+ var LOGIN_TIMEOUT_MS = 5 * 6e4;
938
+ function openBrowser(url) {
939
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
940
+ try {
941
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
942
+ child.on("error", () => {
943
+ });
944
+ child.unref();
945
+ } catch {
946
+ }
947
+ }
948
+ function respondHtml(res, status, title, body2) {
949
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><style>
950
+ body{font:16px/1.5 system-ui,sans-serif;background:#0b0b0f;color:#e8e8ea;display:grid;place-items:center;height:100vh;margin:0}
951
+ .box{max-width:28rem;padding:2rem;text-align:center}
952
+ h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#a0a0a8;margin:0}
953
+ </style></head><body><div class="box"><h1>${title}</h1><p>${body2}</p></div></body></html>`;
954
+ res.statusCode = status;
955
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
956
+ res.end(html);
957
+ }
958
+ function browserLogin(opts) {
959
+ const print = opts.print ?? ((m) => console.log(m));
960
+ const open = opts.open ?? openBrowser;
961
+ const state = randomBytes(16).toString("hex");
962
+ return new Promise((resolve, reject) => {
963
+ let timer;
964
+ const server = createServer((req, res) => {
965
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
966
+ if (url.pathname !== "/" && url.pathname !== "/callback") {
967
+ res.statusCode = 404;
968
+ res.end("Not found");
969
+ return;
970
+ }
971
+ const params = url.searchParams;
972
+ if (params.get("state") !== state) {
973
+ respondHtml(
974
+ res,
975
+ 400,
976
+ "Login mismatch",
977
+ "This authorization did not match the pending login. Run `flumecode login` again."
978
+ );
979
+ return;
980
+ }
981
+ const error = params.get("error");
982
+ if (error) {
983
+ respondHtml(
984
+ res,
985
+ 200,
986
+ "Login canceled",
987
+ "You can close this tab and return to the terminal."
988
+ );
989
+ cleanup2();
990
+ reject(
991
+ new Error(
992
+ error === "denied" ? "Login was denied in the browser." : `Login failed: ${error}`
993
+ )
994
+ );
995
+ return;
996
+ }
997
+ respondHtml(
998
+ res,
999
+ 200,
1000
+ "You're signed in",
1001
+ "Authorization complete \u2014 close this tab and return to the terminal."
1002
+ );
1003
+ cleanup2();
1004
+ resolve({
1005
+ cliToken: params.get("cli") ?? void 0,
1006
+ runnerToken: params.get("runner") ?? void 0
1007
+ });
1008
+ });
1009
+ const cleanup2 = () => {
1010
+ clearTimeout(timer);
1011
+ server.close();
1012
+ };
1013
+ server.on("error", (err) => {
1014
+ cleanup2();
1015
+ reject(err);
1016
+ });
1017
+ server.listen(0, "127.0.0.1", () => {
1018
+ const addr = server.address();
1019
+ const port = typeof addr === "object" && addr ? addr.port : 0;
1020
+ if (!port) {
1021
+ cleanup2();
1022
+ reject(new Error("Could not start the local login listener."));
1023
+ return;
1024
+ }
1025
+ const authorizeUrl = `${opts.serverUrl}/cli/authorize?` + new URLSearchParams({
1026
+ port: String(port),
1027
+ state,
1028
+ host: hostname(),
1029
+ scope: opts.scope
1030
+ }).toString();
1031
+ print("Opening your browser to authorize this machine\u2026");
1032
+ print(`If it doesn't open, paste this URL:
1033
+ ${authorizeUrl}
1034
+ `);
1035
+ open(authorizeUrl);
1036
+ });
1037
+ timer = setTimeout(() => {
1038
+ cleanup2();
1039
+ reject(new Error("Timed out waiting for browser authorization (5 min)."));
1040
+ }, LOGIN_TIMEOUT_MS);
1041
+ });
1042
+ }
1043
+
679
1044
  // src/run.ts
680
1045
  import { existsSync as existsSync5 } from "node:fs";
681
1046
  import { join as join6 } from "node:path";
@@ -735,8 +1100,8 @@ async function pollJobCanceling(config, jobId) {
735
1100
  });
736
1101
  noteServerVersion(res);
737
1102
  if (!res.ok) return false;
738
- const body = await res.json();
739
- return body.canceling === true;
1103
+ const body2 = await res.json();
1104
+ return body2.canceling === true;
740
1105
  }
741
1106
  async function reportJob(config, jobId, result) {
742
1107
  const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/complete`, {
@@ -967,6 +1332,9 @@ var requirementSchema = z2.object({
967
1332
  )
968
1333
  });
969
1334
  var planInputSchema = {
1335
+ dependsOn: z2.array(z2.number().int().positive()).optional().describe(
1336
+ "1-based positions of EARLIER plans in this same submission that must be merged or closed before this one. Use ONLY when the work must be strictly serial; omit for independent plans."
1337
+ ),
970
1338
  title: z2.string().min(1).max(120).describe(
971
1339
  "A concise, descriptive name for THIS plan. Must be distinct from the request title and from any sibling plans on the same request. Keep it under 120 characters."
972
1340
  ),
@@ -1088,6 +1456,20 @@ function renderPlan(plan) {
1088
1456
  lines2.push(PLAN_MARKER);
1089
1457
  return lines2.join("\n");
1090
1458
  }
1459
+ function validateDependsOnReferences(arr, ctx) {
1460
+ for (let i = 0; i < arr.length; i++) {
1461
+ const deps = arr[i]?.dependsOn ?? [];
1462
+ for (const dep of deps) {
1463
+ if (dep > i || dep < 1) {
1464
+ ctx.addIssue({
1465
+ code: z2.ZodIssueCode.custom,
1466
+ path: [i, "dependsOn"],
1467
+ message: "dependsOn must reference an earlier plan (1-based) in this submission."
1468
+ });
1469
+ }
1470
+ }
1471
+ }
1472
+ }
1091
1473
  var submitPlanInputSchema = {
1092
1474
  plans: z2.array(requireAtLeastTwoCriteria(requireRootCauseForFix(z2.object(planInputSchema)))).min(1).refine(
1093
1475
  (arr) => {
@@ -1095,18 +1477,21 @@ var submitPlanInputSchema = {
1095
1477
  return new Set(titles).size === titles.length;
1096
1478
  },
1097
1479
  { message: "Each plan must have a distinct non-empty title" }
1098
- )
1480
+ ).superRefine(validateDependsOnReferences)
1099
1481
  };
1100
1482
  var submitPlanSchema = z2.object(submitPlanInputSchema);
1483
+ function toPlanDraft(plan) {
1484
+ return { body: renderPlan(plan), dependsOn: plan.dependsOn ?? [] };
1485
+ }
1101
1486
  function createPlanTooling() {
1102
1487
  let renderedPlans = null;
1103
1488
  const submitPlan = tool2(
1104
1489
  SUBMIT_PLAN,
1105
- `Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once. requirements is required in each plan: an array of objects, each with a requirement (plain-language intent) and an acceptanceCriteria array (machine-checkable proof for that requirement). At least 1 requirement required; at least 2 acceptance criteria total across all requirements. Each requirement's acceptanceCriteria must be non-empty (at least 1 item). The 'title' field names each specific plan \u2014 make it concise and distinct from the request title and from sibling plan titles. When a plan's scope is "fix", rootCause is required: a non-empty explanation of the underlying cause of the bug (not just the symptom). motivation is optional: the user's stated or asked-for reason for the request. `,
1490
+ `Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once. requirements is required in each plan: an array of objects, each with a requirement (plain-language intent) and an acceptanceCriteria array (machine-checkable proof for that requirement). At least 1 requirement required; at least 2 acceptance criteria total across all requirements. Each requirement's acceptanceCriteria must be non-empty (at least 1 item). The 'title' field names each specific plan \u2014 make it concise and distinct from the request title and from sibling plan titles. When a plan's scope is "fix", rootCause is required: a non-empty explanation of the underlying cause of the bug (not just the symptom). motivation is optional: the user's stated or asked-for reason for the request. dependsOn is optional: a list of 1-based positions of EARLIER plans in this same submission that must be merged or closed before this one \u2014 use only when plans must be worked strictly serially.`,
1106
1491
  submitPlanInputSchema,
1107
1492
  async (args) => {
1108
1493
  const parsed = submitPlanSchema.parse(args);
1109
- renderedPlans = parsed.plans.map(renderPlan);
1494
+ renderedPlans = parsed.plans.map(toPlanDraft);
1110
1495
  return {
1111
1496
  content: [
1112
1497
  {
@@ -1344,7 +1729,7 @@ async function runClaudeCode(opts) {
1344
1729
  const { mcpServer, collected } = createWidgetTooling();
1345
1730
  const { mcpServer: planServer, getPlans } = createPlanTooling();
1346
1731
  const { mcpServer: reportServer, getReport } = createReportTooling();
1347
- for await (const message of query({
1732
+ for await (const message2 of query({
1348
1733
  prompt: opts.prompt,
1349
1734
  options: {
1350
1735
  cwd: opts.cwd,
@@ -1368,8 +1753,8 @@ async function runClaudeCode(opts) {
1368
1753
  allowedTools: [...WIDGET_TOOL_NAMES, PLAN_TOOL_NAME, REPORT_TOOL_NAME, "Task"]
1369
1754
  }
1370
1755
  })) {
1371
- if (message.type === "assistant") {
1372
- const content = message.message?.content;
1756
+ if (message2.type === "assistant") {
1757
+ const content = message2.message?.content;
1373
1758
  if (Array.isArray(content)) {
1374
1759
  for (const block of content) {
1375
1760
  if (block && block.type === "text" && typeof block.text === "string") {
@@ -1380,8 +1765,8 @@ async function runClaudeCode(opts) {
1380
1765
  }
1381
1766
  }
1382
1767
  }
1383
- } else if (message.type === "user") {
1384
- const content = message.message?.content;
1768
+ } else if (message2.type === "user") {
1769
+ const content = message2.message?.content;
1385
1770
  if (Array.isArray(content)) {
1386
1771
  for (const block of content) {
1387
1772
  if (block && block.type === "tool_result") {
@@ -1389,10 +1774,10 @@ async function runClaudeCode(opts) {
1389
1774
  }
1390
1775
  }
1391
1776
  }
1392
- } else if (message.type === "result") {
1393
- finalText = message.result ?? "";
1777
+ } else if (message2.type === "result") {
1778
+ finalText = message2.result ?? "";
1394
1779
  logEvent("result", finalText);
1395
- const resultMsg = message;
1780
+ const resultMsg = message2;
1396
1781
  if (resultMsg.usage) {
1397
1782
  usageAcc.inputTokens += resultMsg.usage.input_tokens ?? 0;
1398
1783
  usageAcc.outputTokens += resultMsg.usage.output_tokens ?? 0;
@@ -1400,8 +1785,8 @@ async function runClaudeCode(opts) {
1400
1785
  usageAcc.cacheReadTokens += resultMsg.usage.cache_read_input_tokens ?? 0;
1401
1786
  }
1402
1787
  usageAcc.costUsd += resultMsg.total_cost_usd ?? 0;
1403
- } else if (message.type === "system") {
1404
- logEvent("system", JSON.stringify(message));
1788
+ } else if (message2.type === "system") {
1789
+ logEvent("system", JSON.stringify(message2));
1405
1790
  }
1406
1791
  }
1407
1792
  process.stdout.write("\n");
@@ -1412,7 +1797,7 @@ async function runClaudeCode(opts) {
1412
1797
  }
1413
1798
 
1414
1799
  // src/codex.ts
1415
- import { spawn } from "node:child_process";
1800
+ import { spawn as spawn2 } from "node:child_process";
1416
1801
  import { mkdtempSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "node:fs";
1417
1802
  import { join as join2 } from "node:path";
1418
1803
  import { tmpdir } from "node:os";
@@ -1422,7 +1807,7 @@ async function runCodex(opts) {
1422
1807
  const tmpDir = mkdtempSync(join2(tmpdir(), "flume-codex-"));
1423
1808
  const outFile = join2(tmpDir, "flume-mcp.jsonl");
1424
1809
  writeFileSync2(outFile, "");
1425
- const child = spawn(
1810
+ const child = spawn2(
1426
1811
  "codex",
1427
1812
  [
1428
1813
  "exec",
@@ -1475,7 +1860,8 @@ async function runCodex(opts) {
1475
1860
  }
1476
1861
  const raw = existsSync2(outFile) ? readFileSync3(outFile, "utf8") : "";
1477
1862
  const records = raw.split("\n").filter(Boolean).map(parseJsonLine).filter((r) => r !== null);
1478
- const plans = records.filter((r) => r.kind === "plans").flatMap((r) => r.plans ?? []);
1863
+ const rawPlans = records.filter((r) => r.kind === "plans").flatMap((r) => r.plans ?? []);
1864
+ const plans = rawPlans.map((body2) => ({ body: body2, dependsOn: [] }));
1479
1865
  const widgets = records.filter((r) => r.kind === "widget").map((r) => r.widget);
1480
1866
  return {
1481
1867
  text: "",
@@ -1499,14 +1885,14 @@ async function checkClaudeCode() {
1499
1885
  try {
1500
1886
  let sawResult = false;
1501
1887
  const run = (async () => {
1502
- for await (const message of query2({
1888
+ for await (const message2 of query2({
1503
1889
  prompt: "Reply with the single word: ok",
1504
1890
  options: { permissionMode: "bypassPermissions", maxTurns: 1 }
1505
1891
  })) {
1506
- if (message.type === "result") {
1892
+ if (message2.type === "result") {
1507
1893
  sawResult = true;
1508
- const isError = message.is_error === true;
1509
- const subtype = message.subtype;
1894
+ const isError = message2.is_error === true;
1895
+ const subtype = message2.subtype;
1510
1896
  if (isError)
1511
1897
  throw new Error(
1512
1898
  subtype ? `Claude Code error: ${subtype}` : "Claude Code returned an error"
@@ -1521,9 +1907,9 @@ async function checkClaudeCode() {
1521
1907
  return { ready: false, error: errorMessage(err) };
1522
1908
  }
1523
1909
  }
1524
- function withTimeout(p, ms, message) {
1910
+ function withTimeout(p, ms, message2) {
1525
1911
  return new Promise((resolve, reject) => {
1526
- const timer = setTimeout(() => reject(new Error(message)), ms);
1912
+ const timer = setTimeout(() => reject(new Error(message2)), ms);
1527
1913
  p.then(
1528
1914
  (v) => {
1529
1915
  clearTimeout(timer);
@@ -1585,6 +1971,13 @@ function appendRule(lines2, intro, ruleName) {
1585
1971
  lines2.push("", intro, "", loadRule(ruleName));
1586
1972
  }
1587
1973
  var WRITING_INTRO = "These technical-writing guidelines apply to the plan and report prose you author in this run:";
1974
+ var SETUP_PREVIEW_MARKER = "<!-- flumecode:setup-preview -->";
1975
+ function setupPreviewDirective(mode) {
1976
+ if (mode === "plan") {
1977
+ return "This request installs FlumeCode's **UI Preview plugin**. Use the `flumecode:setup-preview` skill as your authority: analyse this app read-only to determine exactly what the plugin needs \u2014 the app directory, the install and dev-server commands, any env for a hermetic boot, the globs that count as a UI change, and the showcase strategy \u2014 and fold that into the plan you submit (the precise `.flumecode/plugins/ui-preview.json` fields to write and the `.flumecode/wiki/components/ui-preview.md` record to add). Do not write files; you are read-only.";
1978
+ }
1979
+ return "This request installs FlumeCode's **UI Preview plugin**. The `flumecode:setup-preview` skill is the authority on what to produce \u2014 `.flumecode/plugins/ui-preview.json` (the machine recipe later previews execute) and a `.flumecode/wiki/components/ui-preview.md` record, plus any packages future previews need. As the orchestrator, direct your implementation subagent to follow `flumecode:setup-preview` exactly, and your review subagent to verify the manifest and wiki record against it.";
1980
+ }
1588
1981
  var LANGUAGE_DIRECTIVE = "First, determine the dominant natural language of the incoming thread (the request title/body and the user's messages). Use that one language for EVERYTHING you author this run - your reply body, any plan or report fields, AND every clarifying question and its widget options. Never mix languages: if the thread is in English, your questions and options must be in English too. Keep code identifiers, file paths, and quoted code verbatim.";
1589
1982
  function turnHeading(turn, agentName) {
1590
1983
  if (turn.role === "user") return "User";
@@ -1621,6 +2014,9 @@ function buildPrompt(ctx) {
1621
2014
  loadRule("coding-guideline")
1622
2015
  );
1623
2016
  }
2017
+ if (ctx.request?.body?.includes(SETUP_PREVIEW_MARKER)) {
2018
+ lines2.push("", setupPreviewDirective(ctx.permissionMode));
2019
+ }
1624
2020
  appendRule(lines2, WRITING_INTRO, "technical-writing");
1625
2021
  lines2.push("", `# Request: ${ctx.request?.title ?? ""}`);
1626
2022
  if (ctx.request?.body) {
@@ -1789,12 +2185,19 @@ function buildCodexPrompt(ctx) {
1789
2185
  "\n"
1790
2186
  );
1791
2187
  }
1792
- function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
2188
+ function buildPreviewPrompt(ctx, committedFileNames, manifest, sentinelPath) {
1793
2189
  const lines2 = [
1794
2190
  `You are "${ctx.agentName}" authoring an ephemeral UI showcase for ${ctx.repo.fullName}.`,
1795
- `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.`,
1796
- `Write your showcase files under \`${tmpRoute}\` and write the URL path to \`${tmpRoute}/.showcase-path\`.`,
1797
- `These files are ephemeral and git-excluded \u2014 do NOT commit or push them.`,
2191
+ `An implementation just ran and committed UI changes to branch "${ctx.repo.checkoutBranch}". Use the \`flumecode:preview-ui\` skill to create a temporary showcase that renders the changed UI with realistic fake data, following the recipe below for this repo's stack.`,
2192
+ `Record the showcase's URL path (exactly, no trailing newline) in the file \`${sentinelPath}\` \u2014 the runner reads it to navigate to your showcase.`,
2193
+ `Your showcase is EPHEMERAL: the runner reverts the working tree after capturing it, so you may edit whatever the recipe requires (including registering a temporary route in tracked files). Do NOT commit or push.`,
2194
+ "",
2195
+ "## Preview recipe (from this repo's ui-preview plugin manifest)",
2196
+ "",
2197
+ `- App directory: \`${manifest.appDir}\``,
2198
+ `- Showcase strategy: ${manifest.showcase.kind}`,
2199
+ "",
2200
+ manifest.showcase.instructions || "(no recipe instructions were recorded \u2014 infer from the app)",
1798
2201
  "",
1799
2202
  "These coding guidelines apply to all code produced in this run:",
1800
2203
  "",
@@ -1808,14 +2211,14 @@ function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
1808
2211
  if (committedFileNames.trim()) {
1809
2212
  lines2.push(
1810
2213
  "",
1811
- "UI files changed by this implementation (showcase these components):",
2214
+ "UI files changed by this implementation (showcase these):",
1812
2215
  "",
1813
2216
  committedFileNames.trim()
1814
2217
  );
1815
2218
  }
1816
2219
  lines2.push(
1817
2220
  "",
1818
- "When done, reply with one short sentence confirming what showcase file you created."
2221
+ "When done, reply with one short sentence confirming what showcase you created and its URL path."
1819
2222
  );
1820
2223
  return lines2.join("\n");
1821
2224
  }
@@ -1830,7 +2233,8 @@ function buildInitPrompt(ctx) {
1830
2233
 
1831
2234
  // src/types.ts
1832
2235
  function jobTitle(ctx) {
1833
- return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
2236
+ if (ctx.kind === "init") return "Initialize FlumeCode wiki";
2237
+ return ctx.request?.title ?? "request";
1834
2238
  }
1835
2239
 
1836
2240
  // src/workspace.ts
@@ -2093,7 +2497,7 @@ async function openPullRequest(ctx) {
2093
2497
  "content-type": "application/json"
2094
2498
  };
2095
2499
  const title = jobTitle(ctx);
2096
- const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
2500
+ const body2 = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
2097
2501
  const res = await fetch(`${apiBase}/pulls`, {
2098
2502
  method: "POST",
2099
2503
  headers,
@@ -2101,7 +2505,7 @@ async function openPullRequest(ctx) {
2101
2505
  title: `FlumeCode: ${title}`,
2102
2506
  head: checkoutBranch,
2103
2507
  base: mergeBranch,
2104
- body
2508
+ body: body2
2105
2509
  })
2106
2510
  });
2107
2511
  if (res.status === 201) {
@@ -2120,15 +2524,28 @@ async function openPullRequest(ctx) {
2120
2524
  }
2121
2525
  throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
2122
2526
  }
2123
- async function gitCommittedFiles(dir) {
2527
+ async function gitCommittedFiles(ctx, dir) {
2528
+ const { mergeBranch } = ctx.repo;
2529
+ if (!mergeBranch) {
2530
+ const { stdout: stdout3 } = await git([
2531
+ "-C",
2532
+ dir,
2533
+ "--no-pager",
2534
+ "show",
2535
+ "--name-only",
2536
+ "--format=",
2537
+ "HEAD"
2538
+ ]);
2539
+ return stdout3;
2540
+ }
2541
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
2124
2542
  const { stdout: stdout2 } = await git([
2125
2543
  "-C",
2126
2544
  dir,
2127
2545
  "--no-pager",
2128
- "show",
2546
+ "diff",
2129
2547
  "--name-only",
2130
- "--format=",
2131
- "HEAD"
2548
+ "FETCH_HEAD...HEAD"
2132
2549
  ]);
2133
2550
  return stdout2;
2134
2551
  }
@@ -2192,97 +2609,170 @@ async function prNumbersForCommit(ctx, sha) {
2192
2609
  }
2193
2610
 
2194
2611
  // src/preview.ts
2195
- import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
2196
- import { createServer } from "node:net";
2197
- import { join as join5, relative } from "node:path";
2198
- import { spawn as spawn2 } from "node:child_process";
2612
+ import { execFile as execFile2, spawn as spawn3 } from "node:child_process";
2613
+ import { existsSync as existsSync4, mkdtempSync as mkdtempSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
2614
+ import { createServer as createServer2 } from "node:net";
2615
+ import { tmpdir as tmpdir3 } from "node:os";
2616
+ import { join as join5 } from "node:path";
2617
+ import { promisify as promisify2 } from "node:util";
2618
+ var exec2 = promisify2(execFile2);
2199
2619
  var PREVIEW_MAX_TURNS = 60;
2200
2620
  var CAPTURE_TIMEOUT_MS = 3e4;
2201
2621
  var DEV_SERVER_READY_TIMEOUT_MS = 6e4;
2622
+ var INSTALL_TIMEOUT_MS = 3e5;
2623
+ var MANIFEST_REL = ".flumecode/plugins/ui-preview.json";
2202
2624
  var VIEWPORTS = [
2203
2625
  { name: "mobile", width: 375, height: 812 },
2204
2626
  { name: "tablet", width: 768, height: 1024 },
2205
2627
  { name: "desktop", width: 1280, height: 800 }
2206
2628
  ];
2207
- var UI_EXTENSIONS = /* @__PURE__ */ new Set([
2208
- ".tsx",
2209
- ".jsx",
2210
- ".vue",
2211
- ".svelte",
2212
- ".astro",
2213
- ".css",
2214
- ".scss",
2215
- ".sass",
2216
- ".less"
2217
- ]);
2629
+ var DEFAULT_UI_GLOBS = [
2630
+ "**/*.tsx",
2631
+ "**/*.jsx",
2632
+ "**/*.vue",
2633
+ "**/*.svelte",
2634
+ "**/*.astro",
2635
+ "**/*.css",
2636
+ "**/*.scss",
2637
+ "**/*.sass",
2638
+ "**/*.less",
2639
+ "**/*.html",
2640
+ "**/*.htm",
2641
+ "**/*.erb",
2642
+ "**/*.haml",
2643
+ "**/*.slim"
2644
+ ];
2218
2645
  async function buildPreview(ctx, dir, committedFileNames, config, abort) {
2219
- if (!changedFilesTouchUi(committedFileNames)) {
2646
+ let manifest;
2647
+ try {
2648
+ manifest = readPreviewManifest(dir);
2649
+ } catch (err) {
2650
+ return { status: "failed", reason: `UI Preview plugin manifest is invalid: ${message(err)}` };
2651
+ }
2652
+ if (!manifest) {
2653
+ return {
2654
+ status: "skipped",
2655
+ reason: `UI Preview plugin not initialized \u2014 a repo member can enable it in repo settings (${MANIFEST_REL} is absent).`
2656
+ };
2657
+ }
2658
+ if (!changedFilesTouchUi(committedFileNames, manifest.uiGlobs)) {
2220
2659
  return { status: "skipped", reason: "No UI files were changed in this run." };
2221
2660
  }
2661
+ if (!manifest.devServer.trim()) {
2662
+ return {
2663
+ status: "blocked",
2664
+ reason: "The UI Preview plugin needs manual setup before previews can run.",
2665
+ ...manifest.manualSteps ? { manualSteps: manifest.manualSteps } : {}
2666
+ };
2667
+ }
2222
2668
  try {
2223
2669
  const { previewId, entrypoint } = await runPreviewPass(
2224
2670
  ctx,
2225
2671
  dir,
2226
2672
  committedFileNames,
2673
+ manifest,
2227
2674
  config,
2228
2675
  abort
2229
2676
  );
2230
2677
  return { status: "ready", previewId, entrypoint };
2231
2678
  } catch (err) {
2232
- return { status: "failed", reason: err instanceof Error ? err.message : String(err) };
2679
+ if (manifest.manualSteps) {
2680
+ return { status: "blocked", reason: message(err), manualSteps: manifest.manualSteps };
2681
+ }
2682
+ return { status: "failed", reason: message(err) };
2233
2683
  }
2234
2684
  }
2235
- function changedFilesTouchUi(stat) {
2236
- for (const line of stat.split("\n")) {
2237
- const trimmed = line.trim();
2238
- if (!trimmed) continue;
2239
- const dot = trimmed.lastIndexOf(".");
2240
- if (dot === -1) continue;
2241
- const ext = trimmed.slice(dot);
2242
- if (UI_EXTENSIONS.has(ext)) return true;
2243
- }
2244
- return false;
2245
- }
2246
- function detectFramework(dir) {
2247
- const pm = detectPackageManager(dir);
2248
- const pkgPath = join5(dir, "package.json");
2249
- if (!existsSync4(pkgPath)) return { pm, family: null, devCmd: null };
2250
- let pkg;
2685
+ function readPreviewManifest(repoRoot) {
2686
+ const path = join5(repoRoot, MANIFEST_REL);
2687
+ if (!existsSync4(path)) return null;
2688
+ let raw;
2251
2689
  try {
2252
- pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
2253
- } catch {
2254
- return { pm, family: null, devCmd: null };
2690
+ raw = JSON.parse(readFileSync5(path, "utf8"));
2691
+ } catch (err) {
2692
+ throw new Error(`${MANIFEST_REL} is not valid JSON: ${message(err)}`);
2255
2693
  }
2256
- const deps = {
2257
- ...pkg.dependencies,
2258
- ...pkg.devDependencies
2694
+ if (typeof raw !== "object" || raw === null) {
2695
+ throw new Error(`${MANIFEST_REL} must be a JSON object`);
2696
+ }
2697
+ const o = raw;
2698
+ const devServer = typeof o.devServer === "string" ? o.devServer : "";
2699
+ const manualSteps = typeof o.manualSteps === "string" ? o.manualSteps : null;
2700
+ if (!devServer.trim() && !manualSteps) {
2701
+ throw new Error(`${MANIFEST_REL} must declare either "devServer" or "manualSteps"`);
2702
+ }
2703
+ const showcaseRaw = typeof o.showcase === "object" && o.showcase !== null ? o.showcase : {};
2704
+ return {
2705
+ version: typeof o.version === "number" ? o.version : 1,
2706
+ appDir: typeof o.appDir === "string" && o.appDir.trim() ? o.appDir : ".",
2707
+ install: stringArray(o.install),
2708
+ devServer,
2709
+ readyPath: typeof o.readyPath === "string" && o.readyPath.trim() ? o.readyPath : "/",
2710
+ ...isStringRecord(o.env) ? { env: o.env } : {},
2711
+ uiGlobs: stringArray(o.uiGlobs),
2712
+ showcase: {
2713
+ kind: typeof showcaseRaw.kind === "string" ? showcaseRaw.kind : "custom",
2714
+ instructions: typeof showcaseRaw.instructions === "string" ? showcaseRaw.instructions : ""
2715
+ },
2716
+ manualSteps
2259
2717
  };
2260
- const family = pickFamily(deps);
2261
- const port = 0;
2262
- const devCmd = family ? pickDevCmd(family, port) : null;
2263
- return { pm, family, devCmd };
2264
2718
  }
2265
- async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
2266
- const tmpRoute = mkTempRouteDir(dir);
2719
+ function stringArray(v) {
2720
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
2721
+ }
2722
+ function isStringRecord(v) {
2723
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
2724
+ }
2725
+ function changedFilesTouchUi(stat, uiGlobs = []) {
2726
+ const files = stat.split("\n").map((line) => line.trim()).filter(Boolean);
2727
+ const regexes = (uiGlobs.length ? uiGlobs : DEFAULT_UI_GLOBS).map(globToRegExp);
2728
+ return files.some((file) => regexes.some((re) => re.test(file)));
2729
+ }
2730
+ function globToRegExp(glob) {
2731
+ let re = "";
2732
+ for (let i = 0; i < glob.length; i++) {
2733
+ const c = glob[i];
2734
+ if (c === "*") {
2735
+ if (glob[i + 1] === "*") {
2736
+ i++;
2737
+ if (glob[i + 1] === "/") {
2738
+ i++;
2739
+ re += "(?:.*/)?";
2740
+ } else {
2741
+ re += ".*";
2742
+ }
2743
+ } else {
2744
+ re += "[^/]*";
2745
+ }
2746
+ } else if (c === "?") {
2747
+ re += "[^/]";
2748
+ } else if (".+^${}()|[]\\".includes(c)) {
2749
+ re += `\\${c}`;
2750
+ } else {
2751
+ re += c;
2752
+ }
2753
+ }
2754
+ return new RegExp(`^${re}$`);
2755
+ }
2756
+ async function runPreviewPass(ctx, dir, committedFileNames, manifest, config, abort) {
2757
+ const appDir = manifest.appDir === "." ? dir : join5(dir, manifest.appDir);
2758
+ await assertCleanTree(dir);
2759
+ const scratch = mkdtempSync2(join5(tmpdir3(), "flume-showcase-"));
2760
+ const sentinelPath = join5(scratch, ".showcase-path");
2267
2761
  try {
2268
- gitExclude(dir, tmpRoute);
2269
2762
  await runClaudeCode({
2270
2763
  cwd: dir,
2271
- prompt: buildPreviewPrompt(ctx, committedFileNames, tmpRoute),
2764
+ prompt: buildPreviewPrompt(ctx, committedFileNames, manifest, sentinelPath),
2272
2765
  permissionMode: ctx.permissionMode,
2273
2766
  abortController: abort,
2274
2767
  maxTurns: PREVIEW_MAX_TURNS
2275
2768
  });
2276
- const showcasePath = readShowcasePath(tmpRoute);
2769
+ const showcasePath = readSentinel(sentinelPath);
2277
2770
  if (!showcasePath) {
2278
- throw new Error("preview-ui skill did not write .showcase-path \u2014 skipping preview");
2279
- }
2280
- const framework = detectFramework(dir);
2281
- if (!framework.family) {
2282
- throw new Error("unsupported framework \u2014 skipping preview");
2771
+ throw new Error("preview-ui skill did not record a showcase URL path \u2014 skipping preview");
2283
2772
  }
2773
+ await runInstall(appDir, manifest.install);
2284
2774
  const port = await findFreePort();
2285
- const devServer = await startDevServer(dir, framework, port);
2775
+ const devServer = await startDevServer(appDir, manifest, port);
2286
2776
  try {
2287
2777
  const url = `http://localhost:${port}${showcasePath}`;
2288
2778
  const shots = await captureWithChromium(url, VIEWPORTS);
@@ -2293,111 +2783,52 @@ async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
2293
2783
  await devServer.stop();
2294
2784
  }
2295
2785
  } finally {
2296
- rmSync(tmpRoute, { recursive: true, force: true });
2297
- }
2298
- }
2299
- function pickFamily(deps) {
2300
- if (deps["next"]) return "next";
2301
- if (deps["@sveltejs/kit"]) return "svelte-kit";
2302
- if (deps["nuxt"]) return "nuxt";
2303
- if (deps["astro"]) return "astro";
2304
- if (deps["react-scripts"]) return "cra";
2305
- if (deps["@remix-run/react"] || deps["@remix-run/node"]) return "remix";
2306
- if (deps["vite"]) {
2307
- if (deps["vue"]) return "vite-vue";
2308
- if (deps["svelte"]) return "vite-svelte";
2309
- if (deps["react"]) return "vite-react";
2310
- return "vite-react";
2311
- }
2312
- return null;
2313
- }
2314
- function pickDevCmd(family, port) {
2315
- switch (family) {
2316
- case "next":
2317
- return ["next", "dev", "-p", String(port)];
2318
- case "vite-react":
2319
- case "vite-vue":
2320
- case "vite-svelte":
2321
- return ["vite", "--port", String(port)];
2322
- case "nuxt":
2323
- return ["nuxt", "dev", "--port", String(port)];
2324
- case "svelte-kit":
2325
- return ["vite", "--port", String(port)];
2326
- case "astro":
2327
- return ["astro", "dev", "--port", String(port)];
2328
- case "cra":
2329
- return ["react-scripts", "start"];
2330
- case "remix":
2331
- return ["remix", "dev", "--port", String(port)];
2332
- }
2333
- }
2334
- function mkTempRouteDir(dir) {
2335
- const suffix = Math.random().toString(36).slice(2, 10);
2336
- const tmpRoute = join5(dir, `__flumecode_preview_${suffix}`);
2337
- mkdirSync2(tmpRoute, { recursive: true });
2338
- return tmpRoute;
2339
- }
2340
- function gitExclude(dir, tmpRoute) {
2341
- const excludePath = join5(dir, ".git", "info", "exclude");
2342
- const rel = relative(dir, tmpRoute);
2343
- appendFileSync(excludePath, `
2344
- ${rel}
2345
- `);
2786
+ await restoreTree(dir);
2787
+ rmSync(scratch, { recursive: true, force: true });
2788
+ }
2346
2789
  }
2347
- function readShowcasePath(tmpRoute) {
2348
- const sentinelPath = join5(tmpRoute, ".showcase-path");
2349
- if (!existsSync4(sentinelPath)) return null;
2350
- const content = readFileSync5(sentinelPath, "utf8").trim();
2351
- return content.length > 0 ? content : null;
2790
+ async function assertCleanTree(dir) {
2791
+ const { stdout: stdout2 } = await exec2("git", ["-C", dir, "status", "--porcelain"]);
2792
+ if (stdout2.trim()) {
2793
+ throw new Error("working tree is not clean before the showcase pass \u2014 refusing to restore");
2794
+ }
2352
2795
  }
2353
- function findFreePort() {
2354
- return new Promise((resolve, reject) => {
2355
- const server = createServer();
2356
- server.listen(0, "127.0.0.1", () => {
2357
- const addr = server.address();
2358
- if (!addr || typeof addr === "string") {
2359
- server.close();
2360
- reject(new Error("Could not determine free port"));
2361
- return;
2362
- }
2363
- const port = addr.port;
2364
- server.close((err) => {
2365
- if (err) reject(err);
2366
- else resolve(port);
2367
- });
2368
- });
2369
- server.on("error", reject);
2370
- });
2796
+ async function restoreTree(dir) {
2797
+ try {
2798
+ await exec2("git", ["-C", dir, "checkout", "--", "."]);
2799
+ await exec2("git", ["-C", dir, "clean", "-fd"]);
2800
+ } catch (err) {
2801
+ console.warn(` preview: working-tree restore failed: ${message(err)}`);
2802
+ }
2803
+ }
2804
+ async function runInstall(appDir, commands) {
2805
+ for (const cmd of commands) {
2806
+ try {
2807
+ await runShell(cmd, appDir, {}, INSTALL_TIMEOUT_MS);
2808
+ } catch (err) {
2809
+ console.warn(` preview: install step "${cmd}" failed: ${message(err)}`);
2810
+ }
2811
+ }
2371
2812
  }
2372
- function startDevServer(dir, framework, port) {
2813
+ function startDevServer(appDir, manifest, port) {
2373
2814
  return new Promise((resolve, reject) => {
2374
- const pm = framework.pm ?? "npm";
2375
- const devCmd = pickDevCmd(framework.family, port);
2376
- const argv = [pm === "npm" ? "npx" : "exec", ...devCmd];
2377
- const cmd = pm === "npm" ? "npx" : pm;
2378
- const args = pm === "npm" ? devCmd : ["exec", ...devCmd];
2379
- const proc = spawn2(cmd, args, {
2380
- cwd: dir,
2381
- env: { ...process.env, PORT: String(port) },
2382
- stdio: "ignore"
2815
+ const command2 = manifest.devServer.replaceAll("{PORT}", String(port));
2816
+ const proc = spawn3(command2, {
2817
+ cwd: appDir,
2818
+ shell: true,
2819
+ stdio: "ignore",
2820
+ env: { ...process.env, PORT: String(port), ...manifest.env ?? {} }
2383
2821
  });
2384
2822
  proc.on("error", reject);
2385
- const baseUrl = `http://localhost:${port}`;
2823
+ const readyUrl = `http://127.0.0.1:${port}${manifest.readyPath}`;
2386
2824
  const deadline = Date.now() + DEV_SERVER_READY_TIMEOUT_MS;
2825
+ const stop = () => new Promise((res) => {
2826
+ proc.kill();
2827
+ proc.on("close", () => res());
2828
+ setTimeout(res, 500);
2829
+ });
2387
2830
  const poll = () => {
2388
- fetch(baseUrl).then((res) => {
2389
- if (res.ok || res.status < 500) {
2390
- resolve({
2391
- stop: () => new Promise((res2) => {
2392
- proc.kill();
2393
- proc.on("close", () => res2());
2394
- setTimeout(res2, 500);
2395
- })
2396
- });
2397
- } else {
2398
- scheduleNext();
2399
- }
2400
- }).catch(() => scheduleNext());
2831
+ fetch(readyUrl).then((r) => r.ok || r.status < 500 ? resolve({ stop }) : scheduleNext()).catch(() => scheduleNext());
2401
2832
  };
2402
2833
  const scheduleNext = () => {
2403
2834
  if (Date.now() > deadline) {
@@ -2412,6 +2843,50 @@ function startDevServer(dir, framework, port) {
2412
2843
  setTimeout(poll, 2e3);
2413
2844
  });
2414
2845
  }
2846
+ function runShell(command2, cwd, env, timeoutMs) {
2847
+ return new Promise((resolve, reject) => {
2848
+ const proc = spawn3(command2, {
2849
+ cwd,
2850
+ shell: true,
2851
+ stdio: "ignore",
2852
+ env: { ...process.env, ...env }
2853
+ });
2854
+ const timer = setTimeout(() => {
2855
+ proc.kill();
2856
+ reject(new Error(`"${command2}" timed out after ${timeoutMs}ms`));
2857
+ }, timeoutMs);
2858
+ proc.on("error", (err) => {
2859
+ clearTimeout(timer);
2860
+ reject(err);
2861
+ });
2862
+ proc.on("close", (code) => {
2863
+ clearTimeout(timer);
2864
+ if (code === 0) resolve();
2865
+ else reject(new Error(`"${command2}" exited with code ${code}`));
2866
+ });
2867
+ });
2868
+ }
2869
+ function readSentinel(sentinelPath) {
2870
+ if (!existsSync4(sentinelPath)) return null;
2871
+ const content = readFileSync5(sentinelPath, "utf8").trim();
2872
+ return content.length > 0 ? content : null;
2873
+ }
2874
+ function findFreePort() {
2875
+ return new Promise((resolve, reject) => {
2876
+ const server = createServer2();
2877
+ server.listen(0, "127.0.0.1", () => {
2878
+ const addr = server.address();
2879
+ if (!addr || typeof addr === "string") {
2880
+ server.close();
2881
+ reject(new Error("Could not determine free port"));
2882
+ return;
2883
+ }
2884
+ const port = addr.port;
2885
+ server.close((err) => err ? reject(err) : resolve(port));
2886
+ });
2887
+ server.on("error", reject);
2888
+ });
2889
+ }
2415
2890
  async function captureWithChromium(url, viewports) {
2416
2891
  const { chromium } = await import("playwright");
2417
2892
  const browser = await chromium.launch({ headless: true });
@@ -2451,6 +2926,9 @@ async function uploadAll(urls, keys, bytes) {
2451
2926
  })
2452
2927
  );
2453
2928
  }
2929
+ function message(err) {
2930
+ return err instanceof Error ? err.message : String(err);
2931
+ }
2454
2932
 
2455
2933
  // src/run.ts
2456
2934
  var IDLE_MS = 5e3;
@@ -2775,7 +3253,7 @@ ${reply}`;
2775
3253
  let preview;
2776
3254
  if (outcome.kind !== "none") {
2777
3255
  console.log(` \u2026checking UI preview for implement ${ctx.jobId}`);
2778
- const committedFileNames = await gitCommittedFiles(dir);
3256
+ const committedFileNames = await gitCommittedFiles(ctx, dir);
2779
3257
  preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
2780
3258
  if (preview.status !== "ready")
2781
3259
  console.log(` UI preview ${preview.status}: ${preview.reason}`);
@@ -2802,7 +3280,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
2802
3280
  });
2803
3281
  const report = result.report ?? void 0;
2804
3282
  let reply = (report ? renderReport(report) : result.text.trim()) || "(the agent produced no reply)";
2805
- if (result.plans?.length) reply = result.plans[0] ?? reply;
3283
+ if (result.plans?.length) reply = result.plans[0]?.body ?? reply;
2806
3284
  if (installResult.status === "failed") {
2807
3285
  reply += `
2808
3286
 
@@ -2842,7 +3320,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
2842
3320
  let preview;
2843
3321
  if (outcome.kind !== "none") {
2844
3322
  console.log(` \u2026checking UI preview for revise ${ctx.jobId}`);
2845
- const committedFileNames = await gitCommittedFiles(dir);
3323
+ const committedFileNames = await gitCommittedFiles(ctx, dir);
2846
3324
  preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
2847
3325
  if (preview.status !== "ready")
2848
3326
  console.log(` UI preview ${preview.status}: ${preview.reason}`);
@@ -3055,37 +3533,55 @@ function formatJobError(ctx, err) {
3055
3533
 
3056
3534
  // src/cli.ts
3057
3535
  init_version();
3058
- import React7 from "react";
3536
+ import React6 from "react";
3059
3537
  var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
3060
3538
  async function login(args) {
3061
3539
  const flags = parseFlags(args);
3062
- let serverUrl = flags.server;
3063
- let token = flags.token;
3064
- if (!serverUrl || !token) {
3065
- const rl = createInterface({ input: stdin, output: stdout });
3066
- if (!serverUrl) {
3067
- const answer = (await rl.question(`Server URL [${DEFAULT_SERVER}]: `)).trim();
3068
- serverUrl = answer || DEFAULT_SERVER;
3069
- }
3070
- if (!token) {
3071
- token = (await rl.question("Runner token (flrun_\u2026): ")).trim();
3072
- }
3073
- rl.close();
3540
+ const serverUrl = (flags.server ?? DEFAULT_SERVER).replace(/\/+$/, "");
3541
+ const explicitRunner = flags.runnerToken ?? flags.token;
3542
+ const explicitCli = flags.cliToken;
3543
+ if (explicitRunner || explicitCli) {
3544
+ saveCredentials(serverUrl, { runnerToken: explicitRunner, cliToken: explicitCli });
3545
+ return;
3074
3546
  }
3075
- serverUrl = serverUrl.replace(/\/+$/, "");
3076
- if (!token) {
3077
- console.error("A runner token is required. Create one in the web app under Runners.");
3547
+ const scope = flags.cliOnly ? "cli" : flags.runnerOnly ? "runner" : "cli,runner";
3548
+ console.log(`Logging in to ${serverUrl} (override with --server)
3549
+ `);
3550
+ let result;
3551
+ try {
3552
+ result = await browserLogin({ serverUrl, scope });
3553
+ } catch (err) {
3554
+ console.error(`
3555
+ Login failed: ${err.message}`);
3556
+ console.error("\nOr log in non-interactively with tokens from the web app:");
3557
+ console.error(" flumecode login --server <url> --runner-token flrun_\u2026 --cli-token flcli_\u2026");
3078
3558
  process.exit(1);
3079
3559
  }
3080
- writeConfig({ serverUrl, token });
3081
- console.log(`Saved runner config to ${configPath}.`);
3082
- console.log("Start the runner with: flumecode start");
3560
+ saveCredentials(serverUrl, result);
3561
+ }
3562
+ function saveCredentials(serverUrl, creds) {
3563
+ const existing = readConfig();
3564
+ const config = { serverUrl, token: creds.runnerToken ?? existing?.token ?? "" };
3565
+ const apiToken = creds.cliToken ?? existing?.apiToken;
3566
+ if (apiToken) config.apiToken = apiToken;
3567
+ writeConfig(config);
3568
+ const have = [];
3569
+ if (config.token) have.push("runner token");
3570
+ if (config.apiToken) have.push("CLI token");
3571
+ console.log(`Saved ${have.join(" + ") || "server URL"} to ${configPath}.`);
3572
+ if (config.token && config.apiToken) {
3573
+ console.log("Next: `flumecode start --tui` (worker + TUI together on this machine)");
3574
+ } else if (config.apiToken) {
3575
+ console.log("Next: `flumecode tui`");
3576
+ } else if (config.token) {
3577
+ console.log("Next: `flumecode start`");
3578
+ }
3083
3579
  }
3084
3580
  async function start() {
3085
3581
  process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
3086
3582
  const config = readConfig();
3087
- if (!config) {
3088
- console.error("Not logged in. Run: flumecode login");
3583
+ if (!config || !config.token) {
3584
+ console.error("No runner token configured. Run: flumecode login");
3089
3585
  process.exit(1);
3090
3586
  }
3091
3587
  await pollLoop(config);
@@ -3103,13 +3599,57 @@ async function runTui(args) {
3103
3599
  writeConfig(config);
3104
3600
  const { render } = await import("ink");
3105
3601
  const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
3106
- render(React7.createElement(App2, { config }));
3602
+ render(React6.createElement(App2, { config }));
3603
+ }
3604
+ async function startWithTui(args) {
3605
+ process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
3606
+ const flags = parseFlags(args);
3607
+ const existing = readConfig();
3608
+ if (!existing || !existing.token) {
3609
+ console.error("No runner token configured. Run `flumecode login` first.");
3610
+ process.exit(1);
3611
+ }
3612
+ const config = { ...existing };
3613
+ if (flags.server) config.serverUrl = flags.server.replace(/\/+$/, "");
3614
+ if (flags.token) config.apiToken = flags.token;
3615
+ if (!config.apiToken) {
3616
+ const rl = createInterface({ input: stdin, output: stdout });
3617
+ config.apiToken = (await rl.question("CLI token (flcli_\u2026): ")).trim();
3618
+ rl.close();
3619
+ }
3620
+ writeConfig(config);
3621
+ const logPath = join7(homedir2(), ".flume", "runner.log");
3622
+ const fd = openSync(logPath, "a");
3623
+ const entry = process.argv[1];
3624
+ const worker = entry ? spawn4(process.execPath, [...process.execArgv, entry, "start"], {
3625
+ stdio: ["ignore", fd, fd],
3626
+ env: process.env
3627
+ }) : null;
3628
+ const cleanup2 = () => {
3629
+ try {
3630
+ worker?.kill();
3631
+ } catch {
3632
+ }
3633
+ };
3634
+ process.on("exit", cleanup2);
3635
+ console.log(`Runner worker started in the background (logs: ${logPath}).`);
3636
+ const { render } = await import("ink");
3637
+ const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
3638
+ const app = render(React6.createElement(App2, { config }));
3639
+ await app.waitUntilExit();
3640
+ cleanup2();
3641
+ process.exit(0);
3107
3642
  }
3108
3643
  function parseFlags(args) {
3109
3644
  const out = {};
3110
3645
  for (let i = 0; i < args.length; i++) {
3111
- if (args[i] === "--server") out.server = args[i + 1];
3112
- else if (args[i] === "--token") out.token = args[i + 1];
3646
+ const a = args[i];
3647
+ if (a === "--server") out.server = args[++i];
3648
+ else if (a === "--token") out.token = args[++i];
3649
+ else if (a === "--cli-token") out.cliToken = args[++i];
3650
+ else if (a === "--runner-token") out.runnerToken = args[++i];
3651
+ else if (a === "--cli-only") out.cliOnly = true;
3652
+ else if (a === "--runner-only") out.runnerOnly = true;
3113
3653
  }
3114
3654
  return out;
3115
3655
  }
@@ -3121,15 +3661,37 @@ if (command === "--version" || command === "-v" || command === "version") {
3121
3661
  } else if (command === "login") {
3122
3662
  void login(rest);
3123
3663
  } else if (command === "start") {
3124
- void start();
3664
+ if (rest.includes("--tui")) void startWithTui(rest);
3665
+ else void start();
3125
3666
  } else if (command === "tui") {
3126
3667
  void runTui(rest);
3127
3668
  } else {
3128
3669
  console.log(`FlumeCode runner v${RUNNER_VERSION}`);
3129
- console.log("Usage:");
3130
- console.log(" flumecode login # save server URL + token");
3131
- console.log(" flumecode start # poll for and run jobs");
3132
- console.log(" flumecode tui # open the interactive TUI");
3133
- console.log(" flumecode --version # print the runner version");
3670
+ console.log("");
3671
+ console.log("Usage: flumecode <command> [--server URL] [--token TOKEN]");
3672
+ console.log("");
3673
+ console.log("Commands:");
3674
+ console.log(" login Authorize this machine in the browser (GitHub sign-in), provisioning");
3675
+ console.log(" both the runner token (flrun_\u2026) and CLI token (flcli_\u2026) into config.");
3676
+ console.log(
3677
+ " Flags: --cli-only / --runner-only \xB7 or non-interactive --runner-token /"
3678
+ );
3679
+ console.log(" --cli-token to paste tokens straight from the web app.");
3680
+ console.log(" start Run the worker: claim queued jobs and drive your local Claude Code");
3681
+ console.log(" start --tui All-in-one (single machine): run the worker in the background +");
3682
+ console.log(" the interactive TUI in the foreground");
3683
+ console.log(
3684
+ " tui Open the interactive terminal UI \u2014 talk through a repo, shape a request,"
3685
+ );
3686
+ console.log(
3687
+ " answer the agent's questions, and start implementing. Authenticates as"
3688
+ );
3689
+ console.log(" you with a CLI token (flcli_\u2026) from the web app's Settings.");
3690
+ console.log(" --version Print the runner version");
3691
+ console.log("");
3692
+ console.log(
3693
+ "`start` (the worker) and `tui` (a client) are separate roles with different tokens;"
3694
+ );
3695
+ console.log("`start --tui` just runs both together on one machine. See apps/runner/docs/tui.md.");
3134
3696
  process.exit(command ? 1 : 0);
3135
3697
  }