linersh 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +168 -0
  2. package/bin/liner.js +126 -0
  3. package/dist/App.js +195 -0
  4. package/dist/App.js.map +1 -0
  5. package/dist/agents/detect.js +102 -0
  6. package/dist/agents/detect.js.map +1 -0
  7. package/dist/agents/events.js +8 -0
  8. package/dist/agents/events.js.map +1 -0
  9. package/dist/agents/jtbd-clarify.js +144 -0
  10. package/dist/agents/jtbd-clarify.js.map +1 -0
  11. package/dist/agents/parsers.js +316 -0
  12. package/dist/agents/parsers.js.map +1 -0
  13. package/dist/agents/prompts.js +301 -0
  14. package/dist/agents/prompts.js.map +1 -0
  15. package/dist/agents/run-log.js +71 -0
  16. package/dist/agents/run-log.js.map +1 -0
  17. package/dist/agents/runner.js +180 -0
  18. package/dist/agents/runner.js.map +1 -0
  19. package/dist/agents/types.js +2 -0
  20. package/dist/agents/types.js.map +1 -0
  21. package/dist/bin-resolver.js +92 -0
  22. package/dist/bin-resolver.js.map +1 -0
  23. package/dist/browser-support.js +62 -0
  24. package/dist/browser-support.js.map +1 -0
  25. package/dist/colors.js +105 -0
  26. package/dist/colors.js.map +1 -0
  27. package/dist/components/Header.js +17 -0
  28. package/dist/components/Header.js.map +1 -0
  29. package/dist/components/KeyHints.js +10 -0
  30. package/dist/components/KeyHints.js.map +1 -0
  31. package/dist/components/LabeledBox.js +53 -0
  32. package/dist/components/LabeledBox.js.map +1 -0
  33. package/dist/components/NextSteps.js +18 -0
  34. package/dist/components/NextSteps.js.map +1 -0
  35. package/dist/config.js +88 -0
  36. package/dist/config.js.map +1 -0
  37. package/dist/editor.js +40 -0
  38. package/dist/editor.js.map +1 -0
  39. package/dist/gates.js +121 -0
  40. package/dist/gates.js.map +1 -0
  41. package/dist/index.js +17 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ipc.js +136 -0
  44. package/dist/ipc.js.map +1 -0
  45. package/dist/phases.js +348 -0
  46. package/dist/phases.js.map +1 -0
  47. package/dist/progress.js +185 -0
  48. package/dist/progress.js.map +1 -0
  49. package/dist/screens/AgentSetup.js +217 -0
  50. package/dist/screens/AgentSetup.js.map +1 -0
  51. package/dist/screens/BootSplash.js +159 -0
  52. package/dist/screens/BootSplash.js.map +1 -0
  53. package/dist/screens/BrowserSetup.js +181 -0
  54. package/dist/screens/BrowserSetup.js.map +1 -0
  55. package/dist/screens/CompileView.js +297 -0
  56. package/dist/screens/CompileView.js.map +1 -0
  57. package/dist/screens/FramingConfirmReview.js +193 -0
  58. package/dist/screens/FramingConfirmReview.js.map +1 -0
  59. package/dist/screens/Gate1Review.js +143 -0
  60. package/dist/screens/Gate1Review.js.map +1 -0
  61. package/dist/screens/Gate2Review.js +158 -0
  62. package/dist/screens/Gate2Review.js.map +1 -0
  63. package/dist/screens/ImportPrompt.js +65 -0
  64. package/dist/screens/ImportPrompt.js.map +1 -0
  65. package/dist/screens/LocalFilePicker.js +139 -0
  66. package/dist/screens/LocalFilePicker.js.map +1 -0
  67. package/dist/screens/NewMixtapeWizard.js +662 -0
  68. package/dist/screens/NewMixtapeWizard.js.map +1 -0
  69. package/dist/screens/NewProjectPrompt.js +66 -0
  70. package/dist/screens/NewProjectPrompt.js.map +1 -0
  71. package/dist/screens/PhaseRunner.js +1119 -0
  72. package/dist/screens/PhaseRunner.js.map +1 -0
  73. package/dist/screens/ProcessManifest.js +158 -0
  74. package/dist/screens/ProcessManifest.js.map +1 -0
  75. package/dist/screens/ProjectHub.js +546 -0
  76. package/dist/screens/ProjectHub.js.map +1 -0
  77. package/dist/screens/Settings.js +72 -0
  78. package/dist/screens/Settings.js.map +1 -0
  79. package/dist/screens/SourceModal.js +269 -0
  80. package/dist/screens/SourceModal.js.map +1 -0
  81. package/dist/screens/TapeBrowser.js +277 -0
  82. package/dist/screens/TapeBrowser.js.map +1 -0
  83. package/dist/screens/TapeDraftReview.js +440 -0
  84. package/dist/screens/TapeDraftReview.js.map +1 -0
  85. package/dist/screens/TapeEditor.js +310 -0
  86. package/dist/screens/TapeEditor.js.map +1 -0
  87. package/dist/screens/cassette-art.js +72 -0
  88. package/dist/screens/cassette-art.js.map +1 -0
  89. package/dist/store.js +10 -0
  90. package/dist/store.js.map +1 -0
  91. package/dist/types.js +3 -0
  92. package/dist/types.js.map +1 -0
  93. package/dist/yaml-io.js +345 -0
  94. package/dist/yaml-io.js.map +1 -0
  95. package/package.json +54 -0
@@ -0,0 +1,1119 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { Header } from "../components/Header.js";
5
+ import { KeyHints } from "../components/KeyHints.js";
6
+ import { LabeledBox } from "../components/LabeledBox.js";
7
+ import { CURRENT, ERROR, HEADING, STRUCTURE, SUCCESS } from "../colors.js";
8
+ import { useApp } from "../store.js";
9
+ import { detectAgents, resolveSkillPath } from "../agents/detect.js";
10
+ import { resolveConfiguredAgent } from "../config.js";
11
+ import { runPhaseWithAgent } from "../agents/runner.js";
12
+ import { computePhases, readGateState, writeGateState, } from "../phases.js";
13
+ import { markPhaseComplete, readProgress } from "../progress.js";
14
+ import { projectFolder } from "../yaml-io.js";
15
+ // Tool list viewport. Always shows VIEWPORT rows; arrow keys move the cursor
16
+ // and scroll the window when the cursor hits an edge. Replaces the previous
17
+ // "press t to expand / collapse" toggle — keeps the layout perfectly stable
18
+ // regardless of how many tool calls the agent made.
19
+ const TOOLS_VIEWPORT = 8;
20
+ export function PhaseRunner({ tape, folder, phaseId, }) {
21
+ const app = useApp();
22
+ const project = useMemo(() => projectFolder(folder), [folder]);
23
+ const phase = useMemo(() => computePhases(project, tape, readProgress(folder)).find((p) => p.id === phaseId) ?? null, [project, tape, phaseId, folder]);
24
+ const skillPath = useMemo(() => resolveSkillPath(), []);
25
+ const initial = useMemo(() => initialStage(skillPath), [skillPath]);
26
+ const [stage, setStage] = useState(initial.stage);
27
+ // Structured state derived from AgentEvent stream.
28
+ const [tools, setTools] = useState([]);
29
+ const [finalText, setFinalText] = useState("");
30
+ // Refs mirror state for the handle.done.then() closure.
31
+ const finalTextRef = useRef("");
32
+ const currentTextRef = useRef("");
33
+ const [stats, setStats] = useState({ events: 0, tokens: zeroTokens() });
34
+ // Index into the full `tools` array. Drives the cursor in the done-state
35
+ // tool list and the detail panel below it. Snaps to the last tool when the
36
+ // run finishes.
37
+ const [toolCursor, setToolCursor] = useState(0);
38
+ // First tool index visible in the viewport. The arrow handlers maintain
39
+ // the invariant that `toolCursor` is always inside [scrollTop, scrollTop +
40
+ // VIEWPORT) by scrolling when the cursor hits an edge.
41
+ const [toolScrollTop, setToolScrollTop] = useState(0);
42
+ // Inspect mode is a modal toggle pressed with `t`. When OFF (default), the
43
+ // tool list is just a static viewport showing the tail end of the call
44
+ // history — no cursor, no detail panel, arrow keys don't do anything.
45
+ // When ON, arrows scroll the cursor through the full list and a detail
46
+ // panel renders below showing the selected call's full input + result.
47
+ // Press `t` again to exit. This keeps the default done view compact and
48
+ // makes the inspection ritual explicit instead of "you happen to be in
49
+ // arrow mode all the time."
50
+ const [inspectMode, setInspectMode] = useState(false);
51
+ const handleRef = useRef(null);
52
+ const handleEvent = useCallback((event) => {
53
+ setStats((s) => ({ ...s, events: s.events + 1 }));
54
+ switch (event.kind) {
55
+ case "init":
56
+ return;
57
+ case "text":
58
+ // Track the latest assistant text for the "final response" pane on
59
+ // the done state. We don't render it live anymore — that was the
60
+ // source of the scrollback laddering bug on long runs.
61
+ currentTextRef.current = currentTextRef.current
62
+ ? currentTextRef.current + "\n\n" + event.text
63
+ : event.text;
64
+ return;
65
+ case "tool_start":
66
+ setTools((prev) => [
67
+ ...prev,
68
+ {
69
+ id: event.id,
70
+ name: event.name,
71
+ input: event.input,
72
+ inputPreview: summarizeInput(event.input),
73
+ status: "running",
74
+ },
75
+ ]);
76
+ return;
77
+ case "tool_done":
78
+ setTools((prev) => prev.map((t) => t.id === event.id
79
+ ? {
80
+ ...t,
81
+ status: event.ok ? "ok" : "failed",
82
+ resultPreview: event.preview,
83
+ }
84
+ : t));
85
+ return;
86
+ case "tokens":
87
+ setStats((s) => ({
88
+ ...s,
89
+ tokens: {
90
+ input: s.tokens.input + event.inputTokens,
91
+ output: s.tokens.output + event.outputTokens,
92
+ cacheRead: s.tokens.cacheRead + event.cacheReadTokens,
93
+ cacheCreation: s.tokens.cacheCreation + event.cacheCreationTokens,
94
+ },
95
+ }));
96
+ return;
97
+ case "summary":
98
+ setStats((s) => ({
99
+ ...s,
100
+ cost: event.costUsd ?? s.cost,
101
+ durationMs: event.durationMs ?? s.durationMs,
102
+ }));
103
+ if (event.finalText) {
104
+ setFinalText(event.finalText);
105
+ finalTextRef.current = event.finalText;
106
+ }
107
+ return;
108
+ case "rate_limit":
109
+ // Surface rate limits via a notification so they don't get lost in the
110
+ // event stream, but don't dedicate UI space to them in the compact view.
111
+ app.setNotification({
112
+ kind: "error",
113
+ message: `Rate limit: ${event.status}`,
114
+ });
115
+ return;
116
+ case "raw":
117
+ // Quietly drop. Anything important we already surfaced; raw stderr
118
+ // junk was making the screen flicker.
119
+ return;
120
+ }
121
+ }, [app]);
122
+ function launch(agent, opts = {}) {
123
+ if (!skillPath) {
124
+ setStage({ kind: "missing_skill" });
125
+ return;
126
+ }
127
+ setStage({ kind: "running", agent });
128
+ setTools([]);
129
+ setFinalText("");
130
+ currentTextRef.current = "";
131
+ finalTextRef.current = "";
132
+ setStats({ events: 0, tokens: zeroTokens(), startedAt: Date.now() });
133
+ const handle = runPhaseWithAgent({
134
+ agent,
135
+ phaseId,
136
+ project: folder,
137
+ skillPath,
138
+ tape,
139
+ resume: opts.resume,
140
+ onEvent: handleEvent,
141
+ });
142
+ handleRef.current = handle;
143
+ handle.done.then(({ code }) => {
144
+ setStage({ kind: "done", agent, code });
145
+ // Surface the agent's final-line summary as a notification so it
146
+ // survives navigation. The full final response stays on this screen.
147
+ const summary = finalTextRef.current || currentTextRef.current;
148
+ if (summary) {
149
+ const oneLine = summary.split("\n").find((l) => l.trim().length > 0) ?? "";
150
+ if (oneLine) {
151
+ const truncated = oneLine.length > 140 ? oneLine.slice(0, 139) + "…" : oneLine;
152
+ app.setNotification({
153
+ kind: code === 0 ? "info" : "error",
154
+ message: `${phase?.label ?? phaseId}: ${truncated}`,
155
+ });
156
+ }
157
+ }
158
+ // On clean success, advance the progress cursor by one. Assembly is
159
+ // the exception — its "real" completion lands in TapeDraftReview when
160
+ // the curator accepts the proposed sources. Compile and the gates are
161
+ // their own screens and advance themselves.
162
+ if (code === 0 && phaseId !== "assembly") {
163
+ markPhaseComplete(folder, phaseId);
164
+ }
165
+ // Phase 7 special-case: a successful assembly run wrote
166
+ // working/07-tape-draft.yaml. Jump straight into the draft-review.
167
+ if (code === 0 && phaseId === "assembly") {
168
+ app.navigate({ kind: "tapeDraft", tape, folder });
169
+ }
170
+ });
171
+ }
172
+ function cancel() {
173
+ handleRef.current?.cancel();
174
+ }
175
+ useEffect(() => {
176
+ if (initial.autoLaunch) {
177
+ launch(initial.autoLaunch);
178
+ }
179
+ return () => {
180
+ handleRef.current?.cancel();
181
+ };
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, []);
184
+ // When entering the done state, park the tool cursor on the most recent
185
+ // call so the detail panel below the table immediately shows something
186
+ // useful (the last thing the agent did before finishing). Snap the scroll
187
+ // window so the cursor sits at the bottom of the viewport.
188
+ useEffect(() => {
189
+ if (stage.kind === "done" && tools.length > 0) {
190
+ const cursor = tools.length - 1;
191
+ setToolCursor(cursor);
192
+ setToolScrollTop(Math.max(0, cursor - TOOLS_VIEWPORT + 1));
193
+ }
194
+ // eslint-disable-next-line react-hooks/exhaustive-deps
195
+ }, [stage.kind]);
196
+ function advance() {
197
+ if (stage.kind !== "done")
198
+ return;
199
+ const next = nextScreenFor(phaseId, tape, folder);
200
+ if (next)
201
+ app.navigate(next);
202
+ else
203
+ app.navigate({ kind: "hub", tape, folder });
204
+ }
205
+ useInput((input, key) => {
206
+ if (stage.kind === "picking_agent") {
207
+ if (key.upArrow || input === "k") {
208
+ setStage({ ...stage, selectedIndex: Math.max(0, stage.selectedIndex - 1) });
209
+ }
210
+ else if (key.downArrow || input === "j") {
211
+ setStage({
212
+ ...stage,
213
+ selectedIndex: Math.min(stage.agents.length - 1, stage.selectedIndex + 1),
214
+ });
215
+ }
216
+ else if (key.return) {
217
+ const agent = stage.agents[stage.selectedIndex];
218
+ if (agent)
219
+ launch(agent);
220
+ }
221
+ else if (key.escape) {
222
+ app.navigate({ kind: "hub", tape, folder });
223
+ }
224
+ return;
225
+ }
226
+ if (stage.kind === "missing_agent" || stage.kind === "missing_skill") {
227
+ if (key.escape || input === "b") {
228
+ app.navigate({ kind: "hub", tape, folder });
229
+ }
230
+ return;
231
+ }
232
+ if (stage.kind === "running") {
233
+ if (key.escape || input === "c")
234
+ cancel();
235
+ return;
236
+ }
237
+ if (stage.kind === "done") {
238
+ const failed = stage.code !== 0;
239
+ // `t` is the modal toggle. Always available, regardless of mode.
240
+ if (input === "t") {
241
+ setInspectMode((m) => {
242
+ // Entering inspect mode: snap cursor + window to the last call so
243
+ // the detail panel renders something useful from the start.
244
+ if (!m && tools.length > 0) {
245
+ const cursor = tools.length - 1;
246
+ setToolCursor(cursor);
247
+ setToolScrollTop(Math.max(0, cursor - TOOLS_VIEWPORT + 1));
248
+ }
249
+ return !m;
250
+ });
251
+ return;
252
+ }
253
+ if (inspectMode) {
254
+ // Inspect mode: arrows scroll, esc / b / t exit, everything else
255
+ // (enter to advance, r to re-run) is intentionally locked so the
256
+ // user doesn't accidentally move forward while scanning calls.
257
+ if (key.upArrow || input === "k") {
258
+ if (tools.length === 0)
259
+ return;
260
+ setToolCursor((c) => {
261
+ const next = Math.max(0, c - 1);
262
+ setToolScrollTop((s) => (next < s ? next : s));
263
+ return next;
264
+ });
265
+ }
266
+ else if (key.downArrow || input === "j") {
267
+ if (tools.length === 0)
268
+ return;
269
+ setToolCursor((c) => {
270
+ const next = Math.min(tools.length - 1, c + 1);
271
+ setToolScrollTop((s) => next >= s + TOOLS_VIEWPORT ? next - TOOLS_VIEWPORT + 1 : s);
272
+ return next;
273
+ });
274
+ }
275
+ else if (key.pageUp) {
276
+ if (tools.length === 0)
277
+ return;
278
+ setToolCursor((c) => {
279
+ const next = Math.max(0, c - TOOLS_VIEWPORT);
280
+ setToolScrollTop(Math.max(0, next));
281
+ return next;
282
+ });
283
+ }
284
+ else if (key.pageDown) {
285
+ if (tools.length === 0)
286
+ return;
287
+ setToolCursor((c) => {
288
+ const next = Math.min(tools.length - 1, c + TOOLS_VIEWPORT);
289
+ setToolScrollTop(Math.max(0, next - TOOLS_VIEWPORT + 1));
290
+ return next;
291
+ });
292
+ }
293
+ else if (key.escape) {
294
+ // Esc inside inspect mode = exit the mode (does NOT bail to hub).
295
+ setInspectMode(false);
296
+ }
297
+ return;
298
+ }
299
+ // Default mode: enter advances, b/esc backs to hub, r retries on fail.
300
+ if (key.return) {
301
+ // On failure, Enter = resume (cheap continuation, no re-fetching).
302
+ // On success, Enter = advance to the next logical screen.
303
+ if (failed)
304
+ launch(stage.agent, { resume: true });
305
+ else
306
+ advance();
307
+ }
308
+ else if (input === "r" && failed) {
309
+ // Failure-only escape hatch: re-run from scratch.
310
+ launch(stage.agent);
311
+ }
312
+ else if (key.escape || input === "b") {
313
+ app.navigate({ kind: "hub", tape, folder });
314
+ }
315
+ return;
316
+ }
317
+ });
318
+ return (React.createElement(Box, { flexDirection: "column" },
319
+ React.createElement(Header, { title: `phase ${phase?.number ?? "?"} — ${phase?.label ?? phaseId}`, subtitle: subtitleFor(stage, skillPath) }),
320
+ stage.kind === "picking_agent" ? (React.createElement(PickingAgentView, { stage: stage })) : null,
321
+ stage.kind === "missing_agent" ? React.createElement(MissingAgentView, null) : null,
322
+ stage.kind === "missing_skill" ? React.createElement(MissingSkillView, null) : null,
323
+ stage.kind === "running" ? (React.createElement(RunningView, { agent: stage.agent, phase: phase, phaseId: phaseId, tools: tools, stats: stats })) : null,
324
+ stage.kind === "done" ? (React.createElement(DoneView, { agent: stage.agent, code: stage.code, phaseId: phaseId, phase: phase, tape: tape, folder: folder, tools: tools, finalText: finalText || currentTextRef.current, stats: stats, toolCursor: toolCursor, toolScrollTop: toolScrollTop, inspectMode: inspectMode })) : null,
325
+ app.notification ? (React.createElement(Box, { marginBottom: 1 },
326
+ React.createElement(Text, { color: app.notification.kind === "error" ? ERROR : SUCCESS }, app.notification.message))) : null,
327
+ React.createElement(KeyHints, { hints: hintsFor(stage, inspectMode) })));
328
+ }
329
+ // ---------------------------------------------------------------------------
330
+ // Sub-views
331
+ // ---------------------------------------------------------------------------
332
+ function PickingAgentView({ stage, }) {
333
+ return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
334
+ React.createElement(Text, null, "Multiple agents available \u2014 pick one:"),
335
+ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, stage.agents.map((a, i) => (React.createElement(Box, { key: a.id },
336
+ React.createElement(Text, { color: i === stage.selectedIndex ? STRUCTURE : undefined },
337
+ i === stage.selectedIndex ? "▶ " : " ",
338
+ a.name),
339
+ React.createElement(Text, { dimColor: true },
340
+ " ",
341
+ a.bin))))),
342
+ React.createElement(Box, { marginTop: 1 },
343
+ React.createElement(Text, { dimColor: true },
344
+ "Tip: pick once in the splash menu (",
345
+ React.createElement(Text, { color: STRUCTURE }, "[s]"),
346
+ " Settings) to skip this prompt forever \u2014 or set ",
347
+ React.createElement(Text, { color: STRUCTURE }, "LINER_AGENT=claude"),
348
+ " in your shell."))));
349
+ }
350
+ function MissingAgentView() {
351
+ return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
352
+ React.createElement(Text, { color: ERROR }, "No agent CLI found on PATH."),
353
+ React.createElement(Box, { marginTop: 1, flexDirection: "column" },
354
+ React.createElement(Text, null, "Liner can drive the methodology with either:"),
355
+ React.createElement(Text, null,
356
+ " - Claude Code (",
357
+ React.createElement(Text, { color: STRUCTURE }, "claude"),
358
+ ") \u2014 install: ",
359
+ React.createElement(Text, { color: STRUCTURE }, "npm install -g @anthropic-ai/claude-code")),
360
+ React.createElement(Text, null,
361
+ " - OpenAI Codex (",
362
+ React.createElement(Text, { color: STRUCTURE }, "codex"),
363
+ ") \u2014 see https://github.com/openai/codex"))));
364
+ }
365
+ function MissingSkillView() {
366
+ return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
367
+ React.createElement(Text, { color: ERROR }, "Couldn't find the curating-mixtapes skill bundle."),
368
+ React.createElement(Box, { marginTop: 1 },
369
+ React.createElement(Text, { dimColor: true },
370
+ "Set ",
371
+ React.createElement(Text, { color: STRUCTURE }, "LINER_SKILL_PATH"),
372
+ " to the directory containing SKILL.md."))));
373
+ }
374
+ /**
375
+ * Compact running view. Two lines + a bordered status block — fits any
376
+ * terminal height. The full tool list and final response wait for the done
377
+ * state so the live view never overflows the viewport.
378
+ */
379
+ function RunningView({ agent, phase, phaseId, tools, stats, }) {
380
+ const latest = tools[tools.length - 1];
381
+ const liveDuration = stats.startedAt
382
+ ? formatDuration(Date.now() - stats.startedAt)
383
+ : "";
384
+ const totalTokens = stats.tokens.input +
385
+ stats.tokens.output +
386
+ stats.tokens.cacheRead +
387
+ stats.tokens.cacheCreation;
388
+ const phaseLabel = phase
389
+ ? `Phase ${phase.number} — ${phase.label}`
390
+ : `Phase ${phaseId}`;
391
+ return (React.createElement(Box, { marginBottom: 1 },
392
+ React.createElement(LabeledBox, { label: phaseLabel, color: CURRENT, paddingX: 1, paddingY: 1 },
393
+ React.createElement(Box, null,
394
+ React.createElement(Text, { color: CURRENT },
395
+ React.createElement(Spinner, { type: "dots" }),
396
+ " "),
397
+ React.createElement(Text, null, agent.name),
398
+ React.createElement(Text, { dimColor: true }, " · running")),
399
+ React.createElement(Box, { marginTop: 1 },
400
+ React.createElement(Text, { dimColor: true },
401
+ stats.events,
402
+ " event",
403
+ stats.events === 1 ? "" : "s",
404
+ " · ",
405
+ tools.length,
406
+ " tool",
407
+ tools.length === 1 ? "" : "s",
408
+ " · ",
409
+ formatTokens(totalTokens),
410
+ " tokens",
411
+ liveDuration ? ` · ${liveDuration}` : "")),
412
+ latest ? (React.createElement(Box, { marginTop: 1 },
413
+ React.createElement(Text, { dimColor: true }, "latest: "),
414
+ React.createElement(Text, { color: latest.status === "ok" ? SUCCESS : latest.status === "failed" ? ERROR : STRUCTURE },
415
+ latest.status === "ok" ? "✓" : latest.status === "failed" ? "✗" : "⟳",
416
+ " "),
417
+ React.createElement(Text, null, latest.name.replace(/^mcp__[^_]+__/, "")),
418
+ React.createElement(Text, { dimColor: true },
419
+ " ",
420
+ previewify(latest.inputPreview, 60)))) : (React.createElement(Box, { marginTop: 1 },
421
+ React.createElement(Text, { dimColor: true }, "warming up\u2026"))))));
422
+ }
423
+ function DoneView({ agent, code, phaseId, phase, tape, folder, tools, finalText, stats, toolCursor, toolScrollTop, inspectMode, }) {
424
+ // Fixed-size scroll viewport. Cursor index drives both the highlighted
425
+ // row in the list and the content of the detail panel below.
426
+ const visibleStart = Math.max(0, Math.min(tools.length, toolScrollTop));
427
+ const visibleEnd = Math.min(tools.length, visibleStart + TOOLS_VIEWPORT);
428
+ const visibleTools = tools.slice(visibleStart, visibleEnd);
429
+ const hiddenAbove = visibleStart;
430
+ const hiddenBelow = Math.max(0, tools.length - visibleEnd);
431
+ const clampedCursor = Math.max(0, Math.min(tools.length - 1, toolCursor));
432
+ const selectedTool = tools[clampedCursor];
433
+ const totalTokens = stats.tokens.input +
434
+ stats.tokens.output +
435
+ stats.tokens.cacheRead +
436
+ stats.tokens.cacheCreation;
437
+ const next = nextScreenFor(phaseId, tape, folder);
438
+ const nextLabel = nextScreenLabel(phaseId, tape, folder);
439
+ return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
440
+ React.createElement(Box, { marginBottom: 1 },
441
+ React.createElement(Text, { color: code === 0 ? SUCCESS : ERROR, bold: true }, code === 0 ? "✓ done" : `✗ exited with code ${code}`),
442
+ React.createElement(Text, { dimColor: true },
443
+ " ",
444
+ agent.name,
445
+ " · ",
446
+ stats.events,
447
+ " events",
448
+ " · ",
449
+ tools.length,
450
+ " tools",
451
+ " · ",
452
+ formatTokens(totalTokens),
453
+ " tokens",
454
+ stats.durationMs ? ` · ${formatDuration(stats.durationMs)}` : "",
455
+ stats.cost != null ? ` · $${stats.cost.toFixed(3)}` : "")),
456
+ finalText ? (React.createElement(Box, { marginBottom: 1 },
457
+ React.createElement(LabeledBox, { label: "final response", color: code === 0 ? SUCCESS : ERROR },
458
+ React.createElement(MarkdownBody, { text: finalText, maxLines: 18 })))) : null,
459
+ tools.length > 0 ? (React.createElement(Box, { marginBottom: 1 },
460
+ React.createElement(LabeledBox, { label: inspectMode
461
+ ? `inspecting tool calls · ${clampedCursor + 1} / ${tools.length}`
462
+ : `tool calls · ${tools.length} total · press t to inspect`, color: inspectMode ? CURRENT : STRUCTURE, paddingX: 1 },
463
+ hiddenAbove > 0 ? (React.createElement(Text, { dimColor: true }, ` ↑ ${hiddenAbove} earlier call${hiddenAbove === 1 ? "" : "s"} above`)) : (React.createElement(Text, { dimColor: true }, " ")),
464
+ Array.from({ length: TOOLS_VIEWPORT }).map((_, idx) => {
465
+ const tool = visibleTools[idx];
466
+ if (!tool)
467
+ return React.createElement(Text, { key: `pad-${idx}` }, " ");
468
+ return (React.createElement(ToolCallRow, { key: tool.id, call: tool, active: inspectMode && visibleStart + idx === clampedCursor }));
469
+ }),
470
+ hiddenBelow > 0 ? (React.createElement(Text, { dimColor: true }, ` ↓ ${hiddenBelow} more call${hiddenBelow === 1 ? "" : "s"} below`)) : (React.createElement(Text, { dimColor: true }, " "))))) : null,
471
+ inspectMode ? (React.createElement(Box, { marginBottom: 1 },
472
+ React.createElement(Text, { color: CURRENT, bold: true }, "\u25B8 inspect mode "),
473
+ React.createElement(Text, { dimColor: true }, "use "),
474
+ React.createElement(Text, { color: STRUCTURE }, "\u2191\u2193"),
475
+ React.createElement(Text, { dimColor: true }, " to move, "),
476
+ React.createElement(Text, { color: STRUCTURE }, "pgup/pgdn"),
477
+ React.createElement(Text, { dimColor: true }, " to jump, "),
478
+ React.createElement(Text, { color: STRUCTURE }, "t"),
479
+ React.createElement(Text, { dimColor: true }, " or "),
480
+ React.createElement(Text, { color: STRUCTURE }, "esc"),
481
+ React.createElement(Text, { dimColor: true }, " to exit"))) : null,
482
+ selectedTool && inspectMode ? (React.createElement(Box, { marginBottom: 1 },
483
+ React.createElement(LabeledBox, { label: `detail · ${friendlyToolName(selectedTool.name)}`, color: CURRENT, paddingY: 1 },
484
+ React.createElement(ToolDetailBody, { call: selectedTool })))) : null,
485
+ React.createElement(Box, { marginBottom: 1 }, code === 0 ? (React.createElement(React.Fragment, null,
486
+ React.createElement(Text, { color: STRUCTURE }, "enter"),
487
+ React.createElement(Text, { dimColor: true }, ` → ${nextLabel}${next ? "" : " — back to the hub"}`))) : (React.createElement(React.Fragment, null,
488
+ React.createElement(Text, { color: STRUCTURE }, "enter"),
489
+ React.createElement(Text, { dimColor: true }, " → resume from where the agent stopped (no re-fetching)"))))));
490
+ }
491
+ function ToolCallRow({ call, active = false, }) {
492
+ // Guard: every now and then the agent stream emits a tool_start with no
493
+ // name and no input. Skip those rows so they don't punch holes in the box.
494
+ if (!call.name && !call.inputPreview)
495
+ return null;
496
+ const glyph = call.status === "ok" ? "✓" : call.status === "failed" ? "✗" : "⟳";
497
+ const glyphColor = call.status === "ok" ? SUCCESS : call.status === "failed" ? ERROR : STRUCTURE;
498
+ const friendlyName = friendlyToolName(call.name);
499
+ // The "effect" string leads the row — what the tool produced, not what we
500
+ // sent it. For successful calls that's the result preview; for failures
501
+ // it's the cleaned-up error; for in-flight calls it's a status line.
502
+ const effect = effectFor(call);
503
+ // The input/path becomes a dim trailing chip — the where, not the what.
504
+ const inputChip = call.inputPreview ? ` [${previewify(call.inputPreview, 40)}]` : "";
505
+ return (React.createElement(Box, null,
506
+ React.createElement(Text, { color: active ? CURRENT : undefined, bold: active }, active ? "▸ " : " "),
507
+ React.createElement(Text, { color: glyphColor, bold: true },
508
+ glyph,
509
+ " "),
510
+ React.createElement(Text, { color: active ? CURRENT : STRUCTURE, bold: active }, friendlyName.padEnd(13, " ")),
511
+ React.createElement(Text, { color: call.status === "failed" ? ERROR : undefined }, truncate(effect, 54)),
512
+ inputChip ? React.createElement(Text, { dimColor: true }, inputChip) : null));
513
+ }
514
+ // Fixed counts so the detail panel always occupies the same vertical space.
515
+ // Switching between tool calls never causes the layout below the box to shift.
516
+ // Empty rows are rendered with a single space so Ink keeps them as real
517
+ // lines (and not collapsed whitespace).
518
+ const DETAIL_INPUT_LINES = 3;
519
+ const DETAIL_RESULT_LINES = 6;
520
+ /**
521
+ * Detail panel body for the currently-selected tool call. Shows the full
522
+ * input (path/URL/command/JSON object) and the un-truncated result preview.
523
+ * The total rendered height is constant: status row + input header + 3 input
524
+ * lines + result header + 6 result lines + spacers. Padding empty rows when
525
+ * content is shorter keeps the surrounding layout (NextSteps, KeyHints)
526
+ * anchored as the user moves the cursor through different tools.
527
+ */
528
+ function ToolDetailBody({ call }) {
529
+ const statusColor = call.status === "ok" ? SUCCESS : call.status === "failed" ? ERROR : STRUCTURE;
530
+ const rawInputLines = formatInputDetail(call.name, call.input);
531
+ const inputLines = padLines(rawInputLines, DETAIL_INPUT_LINES);
532
+ const inputTruncated = rawInputLines.length > DETAIL_INPUT_LINES;
533
+ const resultText = call.resultPreview ? cleanResultPreview(call.resultPreview) : "";
534
+ const rawResultLines = resultText ? resultText.split("\n") : [];
535
+ const resultLines = padLines(rawResultLines, DETAIL_RESULT_LINES);
536
+ const resultTruncated = rawResultLines.length > DETAIL_RESULT_LINES;
537
+ return (React.createElement(Box, { flexDirection: "column" },
538
+ React.createElement(Box, null,
539
+ React.createElement(Text, { color: statusColor, bold: true },
540
+ call.status === "ok" ? "✓" : call.status === "failed" ? "✗" : "⟳",
541
+ " "),
542
+ React.createElement(Text, { bold: true }, friendlyToolName(call.name)),
543
+ React.createElement(Text, { dimColor: true },
544
+ " \u00B7 ",
545
+ call.status)),
546
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 },
547
+ React.createElement(Text, { color: HEADING, bold: true },
548
+ "input",
549
+ inputTruncated ? (React.createElement(Text, { dimColor: true }, ` (showing first ${DETAIL_INPUT_LINES} of ${rawInputLines.length})`)) : null),
550
+ inputLines.map((line, i) => (React.createElement(Text, { key: `in-${i}`, dimColor: true }, " " + (line || " "))))),
551
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 },
552
+ React.createElement(Text, { color: HEADING, bold: true },
553
+ "result",
554
+ resultTruncated ? (React.createElement(Text, { dimColor: true }, ` (showing first ${DETAIL_RESULT_LINES} of ${rawResultLines.length})`)) : null),
555
+ resultLines.map((line, i) => (React.createElement(Text, { key: `res-${i}`, dimColor: true }, " " + (line || " ")))))));
556
+ }
557
+ /**
558
+ * Truncate `lines` to `target` rows, padding with empty strings when shorter.
559
+ * Used to keep the detail panel's render height constant regardless of how
560
+ * much content the selected tool produced.
561
+ */
562
+ function padLines(lines, target) {
563
+ if (lines.length >= target)
564
+ return lines.slice(0, target);
565
+ return [...lines, ...new Array(target - lines.length).fill("")];
566
+ }
567
+ /**
568
+ * Multi-line detail for a tool's input, formatted per tool type. Returns the
569
+ * full path/URL/command (no truncation) plus secondary fields where they
570
+ * exist. Falls back to a pretty-printed JSON dump for unknown shapes.
571
+ */
572
+ function formatInputDetail(name, input) {
573
+ if (input == null)
574
+ return [];
575
+ // String inputs (e.g. a bare shell command Codex emits) still deserve to
576
+ // show up in the detail panel — fall back to a single line keyed by the
577
+ // tool's natural label rather than rendering nothing.
578
+ if (typeof input === "string") {
579
+ return input ? [`${name === "bash" ? "command" : "input"}: ${input}`] : [];
580
+ }
581
+ if (typeof input !== "object")
582
+ return [];
583
+ const o = input;
584
+ const tool = name.replace(/^mcp__[^_]+__/, "");
585
+ const lines = [];
586
+ const fieldOrder = [
587
+ ["url", "url"],
588
+ ["file_path", "path"],
589
+ ["path", "path"],
590
+ ["query", "query"],
591
+ ["pattern", "pattern"],
592
+ ["command", "command"],
593
+ ["prompt", "prompt"],
594
+ ["description", "description"],
595
+ ];
596
+ const known = new Set();
597
+ for (const [field, label] of fieldOrder) {
598
+ const v = o[field];
599
+ if (typeof v === "string" && v) {
600
+ lines.push(`${label}: ${v}`);
601
+ known.add(field);
602
+ }
603
+ }
604
+ // Anything else in the input — fall back to a one-line JSON dump per key.
605
+ // For TodoWrite specifically the `todos` array is the meaningful payload;
606
+ // render it line-by-line so the curator can read the plan.
607
+ if (tool === "TodoWrite" && Array.isArray(o["todos"])) {
608
+ lines.push("todos:");
609
+ for (const todo of o["todos"]) {
610
+ const t = (todo ?? {});
611
+ const status = String(t["status"] ?? "?");
612
+ const content = String(t["content"] ?? "");
613
+ const glyph = status === "completed" ? "✓" : status === "in_progress" ? "▸" : "○";
614
+ lines.push(` ${glyph} ${content}`);
615
+ }
616
+ known.add("todos");
617
+ }
618
+ // Any remaining fields go as one-liner key: value JSON-ish entries.
619
+ for (const [k, v] of Object.entries(o)) {
620
+ if (known.has(k))
621
+ continue;
622
+ if (v == null)
623
+ continue;
624
+ const s = typeof v === "string" ? v : JSON.stringify(v);
625
+ if (!s)
626
+ continue;
627
+ lines.push(`${k}: ${s.slice(0, 200)}`);
628
+ }
629
+ return lines;
630
+ }
631
+ /**
632
+ * One-line "what happened" string for a tool call. Falls back through the
633
+ * available signals so a row never goes empty:
634
+ * - result preview (post-call), with `<tool_use_error>` wrappers stripped
635
+ * - "fetching…" / "writing…" / etc. for in-flight calls
636
+ * - the raw input preview if nothing else is available
637
+ */
638
+ function effectFor(call) {
639
+ if (call.status === "running") {
640
+ return inFlightVerb(call.name);
641
+ }
642
+ if (call.resultPreview) {
643
+ // First line only for the row display; the detail panel shows the full
644
+ // multi-line preview when the row is selected.
645
+ return firstLineOf(cleanResultPreview(call.resultPreview));
646
+ }
647
+ // Nothing reported — fall back to a tool-typed default so the row stays
648
+ // informative rather than displaying an empty string.
649
+ if (call.status === "ok")
650
+ return defaultDoneFor(call.name);
651
+ if (call.status === "failed")
652
+ return "failed without an error message";
653
+ return "";
654
+ }
655
+ function firstLineOf(s) {
656
+ const nl = s.indexOf("\n");
657
+ return nl < 0 ? s : s.slice(0, nl);
658
+ }
659
+ /**
660
+ * Strip Claude's `<tool_use_error>...</tool_use_error>` envelope and the
661
+ * "File has not been read yet" preamble that surfaces frequently. Surfacing
662
+ * the bare message gives the curator a fighting chance at understanding what
663
+ * went wrong.
664
+ */
665
+ function cleanResultPreview(s) {
666
+ let out = s.trim();
667
+ // Strip XML-style envelope.
668
+ const m = /<tool_use_error>\s*(.*?)\s*<\/tool_use_error>/is.exec(out);
669
+ if (m && m[1])
670
+ out = m[1];
671
+ // Strip common HTTP-error preambles that don't carry useful information
672
+ // for someone watching the stream.
673
+ out = out.replace(/^Error:\s+/i, "");
674
+ return out;
675
+ }
676
+ /** Human-friendly title for a tool name. */
677
+ function friendlyToolName(raw) {
678
+ const stripped = raw.replace(/^mcp__[^_]+__/, "");
679
+ switch (stripped) {
680
+ case "WebSearch":
681
+ return "Web search";
682
+ case "WebFetch":
683
+ return "Web fetch";
684
+ case "Read":
685
+ return "Read file";
686
+ case "Write":
687
+ return "Write file";
688
+ case "Edit":
689
+ return "Edit file";
690
+ case "Glob":
691
+ return "Find files";
692
+ case "Grep":
693
+ return "Search code";
694
+ case "TodoWrite":
695
+ return "Update plan";
696
+ case "Bash":
697
+ return "Shell";
698
+ default:
699
+ return stripped;
700
+ }
701
+ }
702
+ function inFlightVerb(raw) {
703
+ const n = raw.replace(/^mcp__[^_]+__/, "");
704
+ switch (n) {
705
+ case "WebSearch":
706
+ return "searching the web…";
707
+ case "WebFetch":
708
+ return "fetching…";
709
+ case "Read":
710
+ return "reading…";
711
+ case "Write":
712
+ return "writing…";
713
+ case "Edit":
714
+ return "editing…";
715
+ case "Glob":
716
+ return "globbing…";
717
+ case "Grep":
718
+ return "searching…";
719
+ case "TodoWrite":
720
+ return "updating the plan…";
721
+ case "Bash":
722
+ return "running…";
723
+ default:
724
+ return "working…";
725
+ }
726
+ }
727
+ function defaultDoneFor(raw) {
728
+ const n = raw.replace(/^mcp__[^_]+__/, "");
729
+ switch (n) {
730
+ case "WebSearch":
731
+ return "search complete";
732
+ case "WebFetch":
733
+ return "fetched";
734
+ case "Read":
735
+ return "read";
736
+ case "Write":
737
+ return "written";
738
+ case "Edit":
739
+ return "edited";
740
+ case "Glob":
741
+ return "matched";
742
+ case "Grep":
743
+ return "matched";
744
+ case "TodoWrite":
745
+ return "plan updated";
746
+ case "Bash":
747
+ return "executed";
748
+ default:
749
+ return "done";
750
+ }
751
+ }
752
+ function initialStage(skillPath) {
753
+ if (!skillPath)
754
+ return { stage: { kind: "missing_skill" }, autoLaunch: null };
755
+ const agents = detectAgents();
756
+ if (agents.length === 0)
757
+ return { stage: { kind: "missing_agent" }, autoLaunch: null };
758
+ // Resolution order is centralized in resolveConfiguredAgent:
759
+ // LINER_AGENT env > ~/.liner/config.yaml > single-installed > null.
760
+ // If it returns a non-null agent, we auto-launch and skip the picker. If
761
+ // it returns null (multiple agents, no config, no env hint), we render
762
+ // the picker as before.
763
+ const resolved = resolveConfiguredAgent(agents);
764
+ if (resolved) {
765
+ return {
766
+ stage: {
767
+ kind: "picking_agent",
768
+ agents,
769
+ selectedIndex: agents.indexOf(resolved),
770
+ },
771
+ autoLaunch: resolved,
772
+ };
773
+ }
774
+ return {
775
+ stage: { kind: "picking_agent", agents, selectedIndex: 0 },
776
+ autoLaunch: null,
777
+ };
778
+ }
779
+ function subtitleFor(stage, skillPath) {
780
+ if (stage.kind === "running")
781
+ return `agent: ${stage.agent.name}`;
782
+ if (stage.kind === "done")
783
+ return `agent: ${stage.agent.name} · exit ${stage.code}`;
784
+ if (stage.kind === "picking_agent")
785
+ return skillPath ? `skill: ${skillPath}` : "";
786
+ return "";
787
+ }
788
+ function hintsFor(stage, inspectMode = false) {
789
+ if (stage.kind === "picking_agent") {
790
+ return [
791
+ { key: "↑↓", label: "pick" },
792
+ { key: "enter", label: "launch" },
793
+ { key: "esc", label: "cancel" },
794
+ ];
795
+ }
796
+ if (stage.kind === "running") {
797
+ return [{ key: "esc", label: "cancel run" }];
798
+ }
799
+ if (stage.kind === "done") {
800
+ // Hints split by mode: when inspecting tool calls, only the scroll keys
801
+ // and the exit keys matter; enter/b are intentionally locked so the user
802
+ // doesn't accidentally leave inspect mode by advancing.
803
+ if (inspectMode) {
804
+ return [
805
+ { key: "↑↓", label: "move cursor" },
806
+ { key: "pgup/pgdn", label: "jump page" },
807
+ { key: "t", label: "exit inspect" },
808
+ { key: "esc", label: "exit inspect" },
809
+ ];
810
+ }
811
+ const failed = stage.code !== 0;
812
+ return [
813
+ { key: "enter", label: failed ? "try again" : "continue" },
814
+ { key: "t", label: "inspect tool calls" },
815
+ { key: "b", label: "back to hub" },
816
+ ];
817
+ }
818
+ return [{ key: "b", label: "back" }];
819
+ }
820
+ /**
821
+ * After a phase run completes, what's the obvious next screen? Returns null
822
+ * when we should just go back to the hub.
823
+ *
824
+ * Mode-aware: quick mode skips the gate review screens (per CURATION.md v2.0
825
+ * — "Quick mode defaults them to continue"). We auto-accept the gate state
826
+ * file as a side effect here so the hub's progress strip still shows the
827
+ * gate as ✓ complete.
828
+ */
829
+ function nextScreenFor(phaseId, tape, folder) {
830
+ const isQuick = (tape.mode ?? "quick") !== "methodology";
831
+ const project = projectFolder(folder);
832
+ switch (phaseId) {
833
+ case "framing":
834
+ if (isQuick) {
835
+ // Quick mode skips the gate. Auto-accept gate0 + advance the cursor
836
+ // so the progress strip stays consistent (no skipped step).
837
+ const state = readGateState(project);
838
+ writeGateState(project, { ...state, gate0Accepted: true });
839
+ markPhaseComplete(folder, "gate0");
840
+ return { kind: "phaseRunner", tape, folder, phaseId: "candidates" };
841
+ }
842
+ return { kind: "gate0", tape, folder };
843
+ case "candidates":
844
+ if (isQuick) {
845
+ // Auto-accept Gate 1 — both the legacy gate-state file (so other
846
+ // surfaces still recognise it as accepted) and the progress cursor.
847
+ const state = readGateState(project);
848
+ writeGateState(project, { ...state, gate1Accepted: true });
849
+ markPhaseComplete(folder, "gate1");
850
+ return { kind: "phaseRunner", tape, folder, phaseId: "evaluation" };
851
+ }
852
+ return { kind: "gate1", tape, folder };
853
+ case "evaluation":
854
+ return { kind: "phaseRunner", tape, folder, phaseId: "quality" };
855
+ case "quality":
856
+ if (isQuick) {
857
+ const state = readGateState(project);
858
+ writeGateState(project, { ...state, gate2Accepted: true });
859
+ markPhaseComplete(folder, "gate2");
860
+ return { kind: "phaseRunner", tape, folder, phaseId: "synthesis" };
861
+ }
862
+ return { kind: "gate2", tape, folder };
863
+ case "synthesis":
864
+ return { kind: "phaseRunner", tape, folder, phaseId: "assembly" };
865
+ case "assembly":
866
+ // Handled in the .then() — auto-navigates to tape draft. If somehow we
867
+ // land on done without a draft, go back to the hub.
868
+ return null;
869
+ case "empirical":
870
+ return null;
871
+ default:
872
+ return null;
873
+ }
874
+ }
875
+ function nextScreenLabel(phaseId, tape, _folder) {
876
+ const isQuick = (tape.mode ?? "quick") !== "methodology";
877
+ switch (phaseId) {
878
+ case "framing":
879
+ return isQuick
880
+ ? "continue to Phase 2 — Candidate discovery (framing auto-confirmed in quick mode)"
881
+ : "continue to Confirm framing — review the knowledge map";
882
+ case "candidates":
883
+ return isQuick
884
+ ? "continue to Phase 3 — Evaluation (candidates auto-confirmed in quick mode)"
885
+ : "continue to Confirm candidates — review the long-list";
886
+ case "evaluation":
887
+ return "continue to Phase 4 — Quality checks";
888
+ case "quality":
889
+ return isQuick
890
+ ? "continue to Phase 5 — Synthesis (evaluation auto-confirmed in quick mode)"
891
+ : "continue to Confirm evaluation — review keep-list + checks";
892
+ case "synthesis":
893
+ return "continue to Phase 6 — Assembly";
894
+ case "assembly":
895
+ return "review the proposed sources";
896
+ case "empirical":
897
+ return "back to the hub";
898
+ default:
899
+ return "back to the hub";
900
+ }
901
+ }
902
+ function summarizeInput(input) {
903
+ if (!input || typeof input !== "object")
904
+ return "";
905
+ const o = input;
906
+ if (typeof o["file_path"] === "string")
907
+ return o["file_path"];
908
+ if (typeof o["path"] === "string")
909
+ return o["path"];
910
+ if (typeof o["url"] === "string")
911
+ return o["url"];
912
+ if (typeof o["pattern"] === "string")
913
+ return o["pattern"];
914
+ if (typeof o["command"] === "string")
915
+ return o["command"];
916
+ if (typeof o["query"] === "string")
917
+ return o["query"];
918
+ try {
919
+ return JSON.stringify(o).slice(0, 80);
920
+ }
921
+ catch {
922
+ return "";
923
+ }
924
+ }
925
+ function formatTokens(n) {
926
+ if (n < 1_000)
927
+ return String(n);
928
+ if (n < 1_000_000)
929
+ return `${(n / 1_000).toFixed(1)}k`;
930
+ return `${(n / 1_000_000).toFixed(2)}M`;
931
+ }
932
+ function formatDuration(ms) {
933
+ if (ms < 60_000)
934
+ return `${(ms / 1000).toFixed(0)}s`;
935
+ const minutes = Math.floor(ms / 60_000);
936
+ const seconds = Math.floor((ms % 60_000) / 1000);
937
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
938
+ }
939
+ function zeroTokens() {
940
+ return { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
941
+ }
942
+ function truncate(s, n) {
943
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
944
+ }
945
+ /**
946
+ * Right-anchored truncation for file paths. The basename is the load-bearing
947
+ * part of any path the agent reads/writes; keeping it visible is much more
948
+ * useful than seeing "/Users/arturo/Docu…" without the actual filename.
949
+ */
950
+ function truncatePath(s, n) {
951
+ if (s.length <= n)
952
+ return s;
953
+ return "…" + s.slice(-(n - 1));
954
+ }
955
+ /**
956
+ * Heuristic: looks like a filesystem path? Used to pick path-style vs.
957
+ * plain truncation for the tool-call preview line.
958
+ */
959
+ function looksLikePath(s) {
960
+ return s.startsWith("/") || s.startsWith("./") || s.startsWith("../") ||
961
+ /^[A-Za-z]:[\\/]/.test(s) ||
962
+ (s.includes("/") && !s.includes(" ") && !s.includes("://"));
963
+ }
964
+ /**
965
+ * Truncate a value smartly: file paths keep their basename, URLs and other
966
+ * strings get standard right-trim ellipsis.
967
+ */
968
+ function previewify(s, n) {
969
+ if (!s)
970
+ return "";
971
+ if (looksLikePath(s))
972
+ return truncatePath(s, n);
973
+ return truncate(s, n);
974
+ }
975
+ // ---------------------------------------------------------------------------
976
+ // Markdown rendering for the agent's final response.
977
+ //
978
+ // The agent produces a small markdown blob with `**heading:**` lines, `-` bullets,
979
+ // and inline `` `code` `` / `[bracketed paths]`. Rendering it raw shows the
980
+ // asterisks as literal characters, which looks unfinished. This renderer:
981
+ //
982
+ // - Strips ** and renders the bold runs with weight + a heading color for
983
+ // start-of-line headings (the ones ending with `:`).
984
+ // - Renders `-` / `*` bullets with a cyan `•` glyph and indents the rest.
985
+ // - Renders ``backtick code`` runs in cyan.
986
+ // - Detects trailing `[paths/with/slashes]` and dims them so the verb-phrase
987
+ // leads.
988
+ //
989
+ // Intentionally not a full markdown engine — just enough to make the agent's
990
+ // output legible. If the format grows beyond this, swap in `marked-terminal`
991
+ // or `ink-markdown`.
992
+ // ---------------------------------------------------------------------------
993
+ function MarkdownBody({ text, maxLines = 18, }) {
994
+ const lines = text.split("\n");
995
+ const shown = lines.slice(0, maxLines);
996
+ const truncated = lines.length > maxLines;
997
+ return (React.createElement(Box, { flexDirection: "column" },
998
+ shown.map((line, i) => (React.createElement(MarkdownLine, { key: i, line: line }))),
999
+ truncated ? (React.createElement(Box, { marginTop: 1 },
1000
+ React.createElement(Text, { dimColor: true },
1001
+ "\u2026 ",
1002
+ lines.length - maxLines,
1003
+ " more lines"))) : null));
1004
+ }
1005
+ function MarkdownLine({ line }) {
1006
+ // Empty line → one-row spacer (keep paragraphs visible).
1007
+ if (!line.trim()) {
1008
+ return React.createElement(Text, null, " ");
1009
+ }
1010
+ // Bullet line: - / * prefix.
1011
+ const bullet = /^(\s*)([-*])\s+(.*)$/.exec(line);
1012
+ if (bullet) {
1013
+ const [, indent, , rest] = bullet;
1014
+ return (React.createElement(Box, null,
1015
+ React.createElement(Text, null, indent),
1016
+ React.createElement(Text, { color: STRUCTURE }, "\u2022 "),
1017
+ React.createElement(InlineSegments, { text: rest ?? "" })));
1018
+ }
1019
+ return (React.createElement(Box, null,
1020
+ React.createElement(InlineSegments, { text: line, headingDetect: true })));
1021
+ }
1022
+ /**
1023
+ * Tokenize a single line by `**bold**` and `` `code` `` runs.
1024
+ * `**` and backticks themselves are dropped. Unclosed markers fall back to
1025
+ * plain text so we never lose content.
1026
+ */
1027
+ function tokenize(s) {
1028
+ const out = [];
1029
+ let i = 0;
1030
+ let buf = "";
1031
+ const flush = () => {
1032
+ if (buf) {
1033
+ out.push({ kind: "plain", text: buf });
1034
+ buf = "";
1035
+ }
1036
+ };
1037
+ while (i < s.length) {
1038
+ if (s[i] === "*" && s[i + 1] === "*") {
1039
+ const end = s.indexOf("**", i + 2);
1040
+ if (end > 0) {
1041
+ flush();
1042
+ out.push({ kind: "bold", text: s.slice(i + 2, end) });
1043
+ i = end + 2;
1044
+ continue;
1045
+ }
1046
+ }
1047
+ if (s[i] === "`") {
1048
+ const end = s.indexOf("`", i + 1);
1049
+ if (end > 0) {
1050
+ flush();
1051
+ out.push({ kind: "code", text: s.slice(i + 1, end) });
1052
+ i = end + 1;
1053
+ continue;
1054
+ }
1055
+ }
1056
+ buf += s[i];
1057
+ i++;
1058
+ }
1059
+ flush();
1060
+ return out;
1061
+ }
1062
+ /**
1063
+ * Render the inline segments of a line. If `headingDetect` is set and the
1064
+ * first segment is a bold-ending-with-`:`, treat it as a section heading and
1065
+ * give the bold run a cyan tint — so the agent's `**Wrote:**` style ledes
1066
+ * carry the eye.
1067
+ */
1068
+ function InlineSegments({ text, headingDetect = false, }) {
1069
+ const segs = tokenize(text);
1070
+ const firstBold = segs[0]?.kind === "bold" && segs[0].text.trim().endsWith(":");
1071
+ return (React.createElement(React.Fragment, null, segs.map((seg, i) => renderSeg(seg, i, headingDetect && firstBold && i === 0))));
1072
+ }
1073
+ function renderSeg(seg, key, isHeading) {
1074
+ if (seg.kind === "code") {
1075
+ return (React.createElement(Text, { key: key, color: STRUCTURE }, seg.text));
1076
+ }
1077
+ if (seg.kind === "bold") {
1078
+ return (React.createElement(Text, { key: key, bold: true, color: isHeading ? HEADING : undefined }, seg.text));
1079
+ }
1080
+ // Plain text — dim `[bracketed paths]` so they recede.
1081
+ return renderPlainWithBrackets(seg.text, key);
1082
+ }
1083
+ /**
1084
+ * Plain text, with `[paths/like/this.md]` substrings rendered dim. The
1085
+ * heuristic: a `[...]` run that contains at least one `/` or `.` is treated
1086
+ * as a path/citation. Anything else is rendered as-is.
1087
+ */
1088
+ function renderPlainWithBrackets(text, key) {
1089
+ const parts = [];
1090
+ let i = 0;
1091
+ let cursor = 0;
1092
+ let k = 0;
1093
+ while (i < text.length) {
1094
+ if (text[i] === "[") {
1095
+ const close = text.indexOf("]", i + 1);
1096
+ if (close > 0) {
1097
+ const inside = text.slice(i + 1, close);
1098
+ if (inside.includes("/") || inside.includes(".")) {
1099
+ if (i > cursor) {
1100
+ parts.push(React.createElement(Text, { key: `${key}-${k++}` }, text.slice(cursor, i)));
1101
+ }
1102
+ parts.push(React.createElement(Text, { key: `${key}-${k++}`, dimColor: true },
1103
+ "[",
1104
+ inside,
1105
+ "]"));
1106
+ i = close + 1;
1107
+ cursor = i;
1108
+ continue;
1109
+ }
1110
+ }
1111
+ }
1112
+ i++;
1113
+ }
1114
+ if (cursor < text.length) {
1115
+ parts.push(React.createElement(Text, { key: `${key}-${k++}` }, text.slice(cursor)));
1116
+ }
1117
+ return React.createElement(React.Fragment, { key: key }, parts);
1118
+ }
1119
+ //# sourceMappingURL=PhaseRunner.js.map