howtofixcli 0.7.24 → 0.7.26

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 (167) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/acp.js +234 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +250 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +121 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +381 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/connect-prompt.js +22 -0
  24. package/dist/context-extension.js +17 -0
  25. package/dist/dcp/commands.js +172 -0
  26. package/dist/dcp/compress-state.js +139 -0
  27. package/dist/dcp/compress.js +329 -0
  28. package/dist/dcp/dcp-extension.js +295 -0
  29. package/dist/dcp/dedup.js +93 -0
  30. package/dist/dcp/ids.js +189 -0
  31. package/dist/dcp/index.js +25 -0
  32. package/dist/dcp/middle-prune.js +76 -0
  33. package/dist/dcp/prompts.js +68 -0
  34. package/dist/dcp/protected-content.js +221 -0
  35. package/dist/dcp/protected.js +161 -0
  36. package/dist/dcp/prune.js +418 -0
  37. package/dist/dcp/state.js +176 -0
  38. package/dist/errors.js +63 -0
  39. package/dist/extensions.js +48 -0
  40. package/dist/fatal.js +35 -0
  41. package/dist/feed.js +498 -0
  42. package/dist/fs-atomic.js +54 -0
  43. package/dist/gateway-errors.js +107 -0
  44. package/dist/git.js +230 -0
  45. package/dist/goal-extension.js +88 -0
  46. package/dist/goal.js +222 -0
  47. package/dist/hooks-extension.js +176 -0
  48. package/dist/hooks.js +443 -0
  49. package/dist/howtofix-account.js +834 -0
  50. package/dist/hud-line.js +158 -0
  51. package/dist/hwid.js +130 -0
  52. package/dist/interrupt.js +60 -0
  53. package/dist/json-events.js +50 -0
  54. package/dist/level-style.js +112 -0
  55. package/dist/loop-guard.js +95 -0
  56. package/dist/lsp-tools.js +32 -0
  57. package/dist/lsp.js +429 -0
  58. package/dist/main.js +573 -98
  59. package/dist/mcp-config.js +85 -0
  60. package/dist/mcp-lifecycle.js +147 -0
  61. package/dist/model-catalog.js +217 -0
  62. package/dist/model-facts.js +35 -0
  63. package/dist/model-resolve.js +65 -0
  64. package/dist/notes.js +78 -0
  65. package/dist/oauth-login.js +568 -0
  66. package/dist/paths.js +61 -0
  67. package/dist/permissions.js +298 -0
  68. package/dist/plan-mode.js +363 -0
  69. package/dist/preset-command.js +227 -0
  70. package/dist/preset-extension.js +35 -0
  71. package/dist/preset-mcp.js +292 -0
  72. package/dist/presets/mcp/rea/preset.json +28 -0
  73. package/dist/presets/mcp/rea/prompt.md +5 -0
  74. package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
  75. package/dist/presets/mcp/remotion/preset.json +27 -0
  76. package/dist/presets/mcp/remotion/prompt.md +7 -0
  77. package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
  78. package/dist/presets.js +727 -0
  79. package/dist/prompt-files.js +103 -0
  80. package/dist/prompt-text.js +41 -0
  81. package/dist/proxy.js +137 -0
  82. package/dist/quality/ast.js +196 -0
  83. package/dist/quality/cognitive.js +83 -0
  84. package/dist/quality/cyclomatic.js +30 -0
  85. package/dist/quality/halstead.js +83 -0
  86. package/dist/quality/index.js +18 -0
  87. package/dist/quality/length.js +3 -0
  88. package/dist/quality/maintainability.js +15 -0
  89. package/dist/quality/nesting.js +57 -0
  90. package/dist/quality/report.js +803 -0
  91. package/dist/quality/smells.js +67 -0
  92. package/dist/quality-tool.js +63 -0
  93. package/dist/read-only-commands.js +597 -0
  94. package/dist/repo-map.js +292 -0
  95. package/dist/roblox-connect.js +125 -0
  96. package/dist/rpc.js +91 -0
  97. package/dist/rtk.js +193 -0
  98. package/dist/sandbox.js +105 -0
  99. package/dist/schema.js +214 -0
  100. package/dist/serve.js +50 -0
  101. package/dist/session-lifecycle.js +19 -0
  102. package/dist/session-retention.js +111 -0
  103. package/dist/session.js +460 -0
  104. package/dist/sessions.js +100 -0
  105. package/dist/settings.js +148 -0
  106. package/dist/skills.js +35 -0
  107. package/dist/slop-tool.js +96 -0
  108. package/dist/spend-cap.js +43 -0
  109. package/dist/subagent.js +171 -0
  110. package/dist/tar.js +146 -0
  111. package/dist/theme.js +79 -0
  112. package/dist/thinking.js +45 -0
  113. package/dist/todo.js +114 -0
  114. package/dist/tool-loadout.js +102 -0
  115. package/dist/tool-render.js +769 -0
  116. package/dist/tools.js +176 -0
  117. package/dist/ui.js +4931 -0
  118. package/dist/update.js +551 -0
  119. package/dist/usage-segment.js +74 -0
  120. package/dist/web-tools.js +46 -0
  121. package/dist/web.js +265 -0
  122. package/dist/welcome.js +179 -0
  123. package/dist/workflow-engine.js +246 -0
  124. package/dist/workflow-progress.js +446 -0
  125. package/dist/workflow-view.js +195 -0
  126. package/dist/workflow.js +349 -0
  127. package/package.json +62 -15
  128. package/dist/chunk-27TRKD65.js +0 -2
  129. package/dist/chunk-3FYCFLNK.js +0 -1
  130. package/dist/chunk-3LZR7OEU.js +0 -1
  131. package/dist/chunk-56SEAGAQ.js +0 -1
  132. package/dist/chunk-5AWTL34S.js +0 -1
  133. package/dist/chunk-75P2SCOT.js +0 -1
  134. package/dist/chunk-7CHDNTH6.js +0 -1
  135. package/dist/chunk-7PHRNXLN.js +0 -34
  136. package/dist/chunk-AGGIYXN7.js +0 -2
  137. package/dist/chunk-AUDMRGWF.js +0 -189
  138. package/dist/chunk-C5E3SFOQ.js +0 -2
  139. package/dist/chunk-CMTKCUL5.js +0 -1
  140. package/dist/chunk-DAPXGSGD.js +0 -12
  141. package/dist/chunk-DOD7MUGX.js +0 -1
  142. package/dist/chunk-FPXVDP32.js +0 -8
  143. package/dist/chunk-GXB7RZHH.js +0 -1
  144. package/dist/chunk-I7KVLBMF.js +0 -2
  145. package/dist/chunk-ILDDDLRC.js +0 -1
  146. package/dist/chunk-JFYVRWJI.js +0 -2
  147. package/dist/chunk-LAHVHRCS.js +0 -2
  148. package/dist/chunk-LSJC73DG.js +0 -1
  149. package/dist/chunk-MNDKS6UH.js +0 -6
  150. package/dist/chunk-N345KWWK.js +0 -1
  151. package/dist/chunk-N3J2J7EQ.js +0 -5
  152. package/dist/chunk-ONZLNFQ2.js +0 -3
  153. package/dist/chunk-ORJ2QZBG.js +0 -3
  154. package/dist/chunk-PCALBZOO.js +0 -2
  155. package/dist/chunk-QPHSHL7H.js +0 -2
  156. package/dist/chunk-TKROS3BE.js +0 -5
  157. package/dist/chunk-TSEKPGK4.js +0 -1
  158. package/dist/chunk-UP7ENLEL.js +0 -117
  159. package/dist/chunk-UTYFLLSM.js +0 -1
  160. package/dist/chunk-V3R4FYTJ.js +0 -1
  161. package/dist/chunk-V656XVXV.js +0 -8
  162. package/dist/chunk-WKUJ7CFO.js +0 -1
  163. package/dist/chunk-WOT6VMZA.js +0 -1
  164. package/dist/chunk-WUPU6LTD.js +0 -4
  165. package/dist/chunk-WX3V5P7U.js +0 -5
  166. package/dist/chunk-XW6LMQGX.js +0 -1
  167. package/dist/chunk-YW7NORH3.js +0 -2
@@ -0,0 +1,446 @@
1
+ /**
2
+ * The workflow progress surface: the live dock line and the two-pane
3
+ * "Phases | Agents" view.
4
+ *
5
+ * Ported from the reference implementations rather than invented. Two sources
6
+ * were read before this file was written:
7
+ *
8
+ * 1. Claude Code's documented progress view (code.claude.com/docs/en/workflows):
9
+ * "The progress view shows each phase with its agent counts, token totals,
10
+ * and elapsed time."; "Each call shows its state, such as still running or
11
+ * failed."; past 25 agents or 1.5M projected tokens the progress line shows
12
+ * a "Large workflow" warning.
13
+ * 2. `ultracode-workflows`' statusline (`scripts/workflow-statusline.mjs`),
14
+ * which fixes the live-line shape: the run name, the phase plan, then
15
+ * `●running ✓done <elapsed>`.
16
+ *
17
+ * Node conventions: the phase mark is the 1-based position, or
18
+ * a check once every agent in it is done; agent states are ○ queued / ● running
19
+ * / ✓ done / ✗ failed; the running agent is accented; each row is a left/right
20
+ * split with the metric right-aligned and clipped to the column budget.
21
+ *
22
+ * Everything here is a pure function of the run record, so the whole surface is
23
+ * testable without a session, a model or a terminal.
24
+ */
25
+ import { truncateToWidth, visibleWidth } from "@netrodev/hxf-tui";
26
+ import { fg, ink } from "./theme.js";
27
+ const ROLE = {
28
+ text: ink.text,
29
+ muted: ink.mute,
30
+ dim: ink.dim,
31
+ accent: ink.accent,
32
+ success: ink.tool,
33
+ error: ink.bad,
34
+ border: ink.line,
35
+ };
36
+ /** Paint text in a semantic role. */
37
+ const t = (role, text, bold = false) => (bold ? fg(text, ROLE[role], { bold: true }) : fg(text, ROLE[role]));
38
+ /** The agent and token thresholds the reference uses for its size warning. */
39
+ export const LARGE_AGENT_THRESHOLD = 25;
40
+ export const LARGE_TOKEN_THRESHOLD = 1_500_000;
41
+ /**
42
+ * How long a completed phase stays bright. Shorter than the entrance fade: this
43
+ * is a punctuation mark on a surface the user is already looking at, not an
44
+ * arrival.
45
+ */
46
+ export const POP_MS = 420;
47
+ // ---------------------------------------------------------------------------
48
+ // Formatting
49
+ // ---------------------------------------------------------------------------
50
+ /** Format a duration the way the reference does: `42s` / `18m45s` / `2h15m`. */
51
+ export function formatElapsed(ms) {
52
+ const total = Math.max(0, Math.round(ms / 1000));
53
+ const hours = Math.floor(total / 3600);
54
+ const minutes = Math.floor((total % 3600) / 60);
55
+ const seconds = total % 60;
56
+ if (hours > 0)
57
+ return `${hours}h${String(minutes).padStart(2, "0")}m`;
58
+ if (minutes > 0)
59
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
60
+ return `${seconds}s`;
61
+ }
62
+ /**
63
+ * A single-unit duration for a live clock: `12s`, `3m`, `2h`.
64
+ *
65
+ * The HUD shows this next to the spinner, where the value changes every second
66
+ * and the width must not jump: `formatElapsed` grew a field per unit
67
+ * (`1m05s` → `1h05m`), which shifts everything after it on the row as the run
68
+ * gets older. One unit keeps the clock three characters wide until a run passes
69
+ * ten hours, and the exact figure is still in `/stats` and the workflow panel.
70
+ */
71
+ export function formatAge(ms) {
72
+ const total = Math.max(0, Math.floor(ms / 1000));
73
+ if (total < 60)
74
+ return `${total}s`;
75
+ if (total < 3600)
76
+ return `${Math.floor(total / 60)}m`;
77
+ return `${Math.floor(total / 3600)}h`;
78
+ }
79
+ /** Compact token formatter: `940` / `1.7k` / `1.5M`. */
80
+ export function formatTokens(count) {
81
+ if (count >= 1_000_000)
82
+ return `${(count / 1_000_000).toFixed(1)}M`;
83
+ if (count >= 1_000)
84
+ return `${(count / 1_000).toFixed(1)}k`;
85
+ return String(count);
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Layout primitives (ported from the reference's layout module)
89
+ // ---------------------------------------------------------------------------
90
+ const clamp = (value, lo, hi) => Math.max(lo, Math.min(hi, value));
91
+ /** Clip a string to an exact visible width, adding an ellipsis when truncated. */
92
+ const clipEllipsis = (text, width) => (visibleWidth(text) > width ? truncateToWidth(text, Math.max(0, width), "…") : text);
93
+ /** Pad (or truncate) to an EXACT visible width; ANSI-safe via visibleWidth. */
94
+ export function padTo(text, width, align = "left") {
95
+ if (width <= 0)
96
+ return "";
97
+ const measured = visibleWidth(text);
98
+ if (measured > width)
99
+ return truncateToWidth(text, width);
100
+ const padding = " ".repeat(width - measured);
101
+ return align === "right" ? padding + text : text + padding;
102
+ }
103
+ /** Lay a left and right fragment on one line: right-aligned, left clipped. */
104
+ export function splitRow(left, right, width, gap = 1) {
105
+ if (width <= 0)
106
+ return "";
107
+ const rightWidth = visibleWidth(right);
108
+ if (rightWidth >= width)
109
+ return truncateToWidth(right, width);
110
+ const leftMax = width - rightWidth - gap;
111
+ if (leftMax <= 0)
112
+ return padTo(right, width, "right");
113
+ const leftClipped = visibleWidth(left) > leftMax ? truncateToWidth(left, leftMax) : left;
114
+ const filler = " ".repeat(width - visibleWidth(leftClipped) - rightWidth);
115
+ return leftClipped + filler + right;
116
+ }
117
+ /** A titled box; every line is exactly `innerWidth + 4` columns. */
118
+ export function box(title, rows, innerWidth) {
119
+ const label = clipEllipsis(title, Math.max(0, innerWidth - 2));
120
+ const dashes = Math.max(1, innerWidth - 1 - visibleWidth(label));
121
+ const top = t("border", "┌─ ") + t("accent", label) + t("border", ` ${"─".repeat(dashes)}┐`);
122
+ const bottom = t("border", `└${"─".repeat(innerWidth + 2)}┘`);
123
+ const bar = t("border", "│");
124
+ const body = rows.map((row) => `${bar} ${padTo(row, innerWidth)} ${bar}`);
125
+ return [top, ...body, bottom];
126
+ }
127
+ /** Two titled boxes side by side, forced to equal height. */
128
+ export function twoPane(left, right, width, opts) {
129
+ const gap = opts?.gap ?? 1;
130
+ const leftWidth = opts?.leftWidth ?? clamp(Math.round(width * 0.38), 28, 44);
131
+ const rightWidth = width - leftWidth - gap;
132
+ const height = Math.max(left.rows.length, right.rows.length);
133
+ const pad = (rows) => {
134
+ const out = [...rows];
135
+ while (out.length < height)
136
+ out.push("");
137
+ return out;
138
+ };
139
+ const leftBox = box(left.title, pad(left.rows), leftWidth - 4);
140
+ const rightBox = box(right.title, pad(right.rows), rightWidth - 4);
141
+ const gapText = " ".repeat(gap);
142
+ return leftBox.map((line, index) => line + gapText + (rightBox[index] ?? padTo("", rightWidth)));
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Icons (ported: position or check for a phase, lifecycle glyph per agent)
146
+ // ---------------------------------------------------------------------------
147
+ /** Glyph for an agent's lifecycle state, painted by role. */
148
+ export function agentStateIcon(status, selected, active) {
149
+ switch (status) {
150
+ case "running":
151
+ return t(selected || active ? "accent" : "muted", "●");
152
+ case "failed":
153
+ return t("error", "✗");
154
+ default:
155
+ return t("success", "✓");
156
+ }
157
+ }
158
+ /** The phase mark: the 1-based position, or a check once the phase is done. */
159
+ export function phaseMark(position, allDone, selected, now = Date.now(), finishedAt = 0) {
160
+ if (!allDone)
161
+ return t(selected ? "accent" : "dim", String(position));
162
+ // The check lands bright and settles: one frame of emphasis is what makes a
163
+ // completion readable in a pane the eye is not staring at.
164
+ return now - finishedAt < POP_MS ? t("success", "✓", true) : t("success", "✓");
165
+ }
166
+ /** The phase a step with no phase of its own is attributed to. */
167
+ const UNPHASED = "Workflow";
168
+ /**
169
+ * Group the run's steps by phase.
170
+ *
171
+ * Declared phases come first, in `meta` order, so the view reads as planned. A
172
+ * phase that only ever appeared on an agent is appended rather than dropped: the
173
+ * API contract warns that a mismatched title "silently produces an unplanned
174
+ * progress group", and hiding it would hide real work.
175
+ */
176
+ export function groupPhases(progress, now = Date.now()) {
177
+ const titles = progress.phases.map((phase) => phase.title);
178
+ const details = new Map(progress.phases.map((phase) => [phase.title, phase.detail]));
179
+ for (const step of progress.steps) {
180
+ const title = step.phase || UNPHASED;
181
+ if (!titles.includes(title))
182
+ titles.push(title);
183
+ }
184
+ return titles.map((title, phaseIndex) => {
185
+ const agents = progress.steps.filter((step) => (step.phase || UNPHASED) === title).map((step) => ({ ...step, phaseIndex }));
186
+ const done = agents.filter((agent) => agent.status !== "running").length;
187
+ const failed = agents.filter((agent) => agent.status === "failed").length;
188
+ const elapsedMs = agents.reduce((longest, agent) => {
189
+ const end = agent.endedAt ?? now;
190
+ return Math.max(longest, end - agent.startedAt);
191
+ }, 0);
192
+ let anchor = -1;
193
+ for (let index = 0; index < agents.length; index++) {
194
+ const agent = agents[index];
195
+ if (agent.status !== "running")
196
+ continue;
197
+ if (anchor < 0 || agent.startedAt >= agents[anchor].startedAt)
198
+ anchor = index;
199
+ }
200
+ const detail = details.get(title);
201
+ const allDone = agents.length > 0 && done === agents.length && failed === 0;
202
+ // The moment the phase finished, which is when its last agent settled. The
203
+ // progress view uses it to mark a completion briefly; nothing else needs it.
204
+ const finishedAt = allDone ? agents.reduce((latest, agent) => Math.max(latest, agent.endedAt ?? 0), 0) : 0;
205
+ return {
206
+ title,
207
+ ...(detail ? { detail } : {}),
208
+ agents,
209
+ done,
210
+ failed,
211
+ outputTokens: agents.reduce((sum, agent) => sum + agent.outputTokens, 0),
212
+ elapsedMs,
213
+ allDone,
214
+ finishedAt,
215
+ anchor,
216
+ };
217
+ });
218
+ }
219
+ /** Total output tokens the run has spent. */
220
+ export function totalTokens(progress) {
221
+ return progress.steps.reduce((sum, step) => sum + step.outputTokens, 0);
222
+ }
223
+ /** Total tool calls the run's agents made, when the backend reports them. */
224
+ export function totalToolCalls(progress) {
225
+ return progress.steps.reduce((sum, step) => sum + (step.toolCalls ?? 0), 0);
226
+ }
227
+ /** Elapsed time of the run, live while it is still going. */
228
+ export function runElapsedMs(progress, now = Date.now()) {
229
+ return Math.max(0, (progress.endedAt ?? now) - progress.startedAt);
230
+ }
231
+ /** Whether the run is big enough that the reference says to warn. */
232
+ export function isLargeWorkflow(progress) {
233
+ return progress.steps.length > LARGE_AGENT_THRESHOLD || totalTokens(progress) > LARGE_TOKEN_THRESHOLD;
234
+ }
235
+ /** The phase to auto-follow: the one holding the most recent running agent. */
236
+ export function activePhaseIndex(groups) {
237
+ if (groups.length === 0)
238
+ return 0;
239
+ const running = groups.map((group, index) => ({ group, index })).filter((entry) => entry.group.anchor >= 0);
240
+ if (running.length > 0) {
241
+ return running.reduce((best, entry) => {
242
+ const candidate = entry.group.agents[entry.group.anchor];
243
+ const current = best.group.agents[best.group.anchor];
244
+ return candidate.startedAt >= current.startedAt ? entry : best;
245
+ }).index;
246
+ }
247
+ for (let index = groups.length - 1; index >= 0; index--) {
248
+ if (groups[index].agents.length > 0)
249
+ return index;
250
+ }
251
+ return groups.length - 1;
252
+ }
253
+ // ---------------------------------------------------------------------------
254
+ // The live line (the dock's activity row, under the input box)
255
+ // ---------------------------------------------------------------------------
256
+ /**
257
+ * The one-line live summary.
258
+ *
259
+ * Shaped after the reference's status line
260
+ * (`⚡ deep-code-review · Scope › Review › Verify · ●3 ✓2 1m04s`): the run name,
261
+ * the phase in flight, then agents, tokens and elapsed, with failures last so a
262
+ * long run cannot bury them.
263
+ */
264
+ export function formatProgressLine(progress, now = Date.now()) {
265
+ const groups = groupPhases(progress, now);
266
+ const total = progress.steps.length;
267
+ const done = progress.steps.filter((step) => step.status !== "running").length;
268
+ const failed = progress.steps.filter((step) => step.status === "failed").length;
269
+ const parts = [progress.name];
270
+ const active = groups.find((group) => group.anchor >= 0);
271
+ if (active)
272
+ parts.push(`${active.title} ${active.done}/${active.agents.length}`);
273
+ else if (groups.length > 0)
274
+ parts.push(`${groups[groups.length - 1].title} done`);
275
+ parts.push(`${done}/${total} agents`);
276
+ parts.push(`${formatTokens(totalTokens(progress))} tok`);
277
+ parts.push(formatElapsed(runElapsedMs(progress, now)));
278
+ if (failed > 0)
279
+ parts.push(`${failed} failed`);
280
+ const line = parts.join(" · ");
281
+ return isLargeWorkflow(progress) ? `large workflow · ${line}` : line;
282
+ }
283
+ // ---------------------------------------------------------------------------
284
+ // The two-pane view
285
+ // ---------------------------------------------------------------------------
286
+ const AGENT_WINDOW = 16;
287
+ const PHASE_WINDOW = 12;
288
+ const LABEL_CAP = 18;
289
+ const FALLBACK_WIDTH = 72;
290
+ const MIN_RIGHT_INNER = 24;
291
+ /** Candidate metric strings for an agent, fullest first; the widest that fits wins. */
292
+ function metricVariants(agent, now) {
293
+ if (agent.status === "running" && agent.outputTokens === 0) {
294
+ return [`idle ${formatElapsed(now - agent.startedAt)}`, "idle"];
295
+ }
296
+ const tokens = formatTokens(agent.outputTokens);
297
+ const tools = agent.toolCalls ?? 0;
298
+ const duration = formatElapsed((agent.endedAt ?? now) - agent.startedAt);
299
+ return [`${tokens} tok · ${tools} tools · ${duration}`, `${tokens} tok · ${tools} tools`, `${tokens} tok`];
300
+ }
301
+ /** Whether a metric paints dim (an agent that has produced nothing yet). */
302
+ const isDimMetric = (agent) => agent.status === "running" && agent.outputTokens === 0;
303
+ /** Window a list to keep `selected` visible, reserving a "…+K more" row. */
304
+ function windowRows(items, selected, max) {
305
+ if (items.length <= max)
306
+ return { slice: items, start: 0, more: 0 };
307
+ const contentMax = Math.max(1, max - 1);
308
+ const start = clamp(selected - Math.floor(contentMax / 2), 0, items.length - contentMax);
309
+ const slice = items.slice(start, start + contentMax);
310
+ return { slice, start, more: items.length - slice.length };
311
+ }
312
+ /** One phase row: position mark, title, and the done/total count on the right. */
313
+ function phaseRow(group, position, selected, innerWidth, now) {
314
+ const counts = `${group.done}/${group.agents.length}`;
315
+ const prefix = selected ? t("accent", "❯") : " ";
316
+ // A phase that has just finished is painted bright for a moment, so the
317
+ // change is visible without watching the counter: `justFinished` is the same
318
+ // fact the mark already encodes, animated.
319
+ const mark = phaseMark(position + 1, group.allDone, selected, now, group.finishedAt);
320
+ const budget = Math.max(0, innerWidth - 4 - visibleWidth(counts));
321
+ const title = visibleWidth(group.title) > budget ? truncateToWidth(group.title, budget, "…") : group.title;
322
+ const popping = group.allDone && now - group.finishedAt < POP_MS;
323
+ const body = popping && !selected ? t("success", title) : t(selected ? "accent" : "text", title);
324
+ const left = `${prefix}${mark} ${body}`;
325
+ const right = t(selected ? "accent" : "muted", counts);
326
+ return splitRow(left, right, innerWidth, 1);
327
+ }
328
+ /** One agent row: state glyph, label, and a right-aligned metric. */
329
+ function agentRow(agent, selected, active, labelCol, innerWidth, now) {
330
+ const prefix = selected ? t("accent", "❯") : " ";
331
+ const icon = agentStateIcon(agent.status, selected, active);
332
+ const label = t(selected ? "accent" : "text", padTo(agent.label, labelCol));
333
+ const left = `${prefix}${icon} ${label}`;
334
+ const available = Math.max(0, innerWidth - visibleWidth(left) - 1);
335
+ const metric = metricVariants(agent, now).find((variant) => visibleWidth(variant) <= available) ?? "";
336
+ const right = t(selected ? "accent" : isDimMetric(agent) ? "dim" : "muted", metric);
337
+ return splitRow(left, right, innerWidth, 1);
338
+ }
339
+ /** Narrow-width fallback: a single-column phase/agent list. */
340
+ function singleColumn(groups, width, selectedPhase, now) {
341
+ const lines = [];
342
+ groups.forEach((group, index) => {
343
+ const mark = phaseMark(index + 1, group.allDone, index === selectedPhase, now, group.finishedAt);
344
+ const head = `${mark} ${t(index === selectedPhase ? "accent" : "text", group.title)}` + ` ${t("muted", `${group.done}/${group.agents.length}`)}`;
345
+ lines.push(clipEllipsis(head, width));
346
+ const { slice, more } = windowRows(group.agents, 0, AGENT_WINDOW);
347
+ slice.forEach((agent, position) => {
348
+ const icon = agentStateIcon(agent.status, false, position === group.anchor);
349
+ const metric = metricVariants(agent, now)[0] ?? "";
350
+ lines.push(clipEllipsis(` ${icon} ${t("text", agent.label)} ${t("muted", metric)}`, width));
351
+ });
352
+ if (more > 0)
353
+ lines.push(clipEllipsis(` ${t("dim", `…+${more} more`)}`, width));
354
+ });
355
+ return lines;
356
+ }
357
+ /**
358
+ * Render the two-pane view: a two-line header (name + totals, then the
359
+ * description), a left "Phases" box and a right box for the selected phase's
360
+ * agents, and an optional key hint. Falls back to a single column when the
361
+ * terminal is too narrow to align two boxes.
362
+ */
363
+ export function renderWorkflowPanes(progress, width, opts) {
364
+ const now = opts?.now ?? Date.now();
365
+ const groups = groupPhases(progress, now);
366
+ const agents = progress.steps;
367
+ const done = agents.filter((step) => step.status !== "running").length;
368
+ const failed = agents.filter((step) => step.status === "failed").length;
369
+ const rightMeta = [`${done}/${agents.length} agents`, `${formatTokens(totalTokens(progress))} tok`, `${totalToolCalls(progress)} tools`, formatElapsed(runElapsedMs(progress, now)), ...(failed > 0 ? [`${failed} failed`] : [])].join(" · ");
370
+ const header = [splitRow(t("accent", progress.name, true), t("muted", rightMeta), width, 2), ...(progress.description ? [t("muted", clipEllipsis(progress.description, width))] : [])];
371
+ const footer = opts?.footer ? [t("muted", clipEllipsis(opts.footerText ?? progressFooter(), width))] : [];
372
+ if (groups.length === 0) {
373
+ return [...header, ...box("Phases", ["(no agents yet)"], Math.max(0, width - 4)), ...footer];
374
+ }
375
+ const selectedPhase = clamp(opts?.selectedPhase ?? activePhaseIndex(groups), 0, Math.max(0, groups.length - 1));
376
+ const leftWidth = clamp(Math.round(width * 0.38), 28, 44);
377
+ const rightInner = width - leftWidth - 1 - 4;
378
+ if (width < FALLBACK_WIDTH || rightInner < MIN_RIGHT_INNER) {
379
+ return [...header, ...singleColumn(groups, width, selectedPhase, now), ...footer];
380
+ }
381
+ const selected = groups[selectedPhase];
382
+ const { slice, start, more } = windowRows(groups, selectedPhase, PHASE_WINDOW);
383
+ const phaseRows = slice.map((group, index) => phaseRow(group, start + index, start + index === selectedPhase, leftWidth - 4, now));
384
+ if (more > 0)
385
+ phaseRows.push(t("dim", `…+${more} more`));
386
+ // The `f` filter narrows the agent pane, not the phase list: the phases are
387
+ // the run's shape and stay visible so the user keeps their bearings.
388
+ const shown = opts?.filter ? opts.filter(selected.agents) : selected.agents;
389
+ const labelCol = shown.length ? clamp(Math.max(...shown.map((agent) => visibleWidth(agent.label))), 6, LABEL_CAP) : 6;
390
+ const { slice: agentSlice, more: agentsMore } = windowRows(shown, 0, opts?.agentWindow ?? AGENT_WINDOW);
391
+ const agentRows = agentSlice.map((agent, index) => agentRow(agent, false, index === selected.anchor, labelCol, rightInner, now));
392
+ if (agentsMore > 0)
393
+ agentRows.push(t("dim", `…+${agentsMore} more`));
394
+ const body = twoPane({ title: "Phases", rows: phaseRows }, {
395
+ title: `${selected.title} · ${shown.length} agents${opts?.filterLabel ?? ""}`,
396
+ rows: agentRows,
397
+ }, width, { leftWidth, gap: 1 });
398
+ return [...header, ...body, ...footer];
399
+ }
400
+ /**
401
+ * The full view at a fixed width, as `string[]`.
402
+ *
403
+ * `expanded` names agents whose own detail (task list, last note) should be
404
+ * printed under the view — the reference's "Enter drill in". `selectedPhase`
405
+ * overrides auto-follow, which the interactive viewer needs and the frame
406
+ * renderer uses to pin a phase for the screenshot.
407
+ */
408
+ export function formatProgressView(progress, expanded = [], width = 96, selectedPhase, opts) {
409
+ const lines = renderWorkflowPanes(progress, width, {
410
+ now: progress.endedAt ?? Date.now(),
411
+ footer: true,
412
+ ...(selectedPhase === undefined ? {} : { selectedPhase }),
413
+ ...(opts?.filter ? { filter: opts.filter } : {}),
414
+ ...(opts?.footerText ? { footerText: opts.footerText } : {}),
415
+ ...(opts?.filterLabel ? { filterLabel: opts.filterLabel } : {}),
416
+ });
417
+ const wanted = new Set(expanded);
418
+ // `drill` is the interactive "Enter drill in": with it, the selected phase's
419
+ // agents are expanded without the caller naming each one.
420
+ if (opts?.drill) {
421
+ const groups = groupPhases(progress, progress.endedAt ?? Date.now());
422
+ const selected = groups[Math.max(0, Math.min(groups.length - 1, selectedPhase ?? 0))];
423
+ for (const agent of selected?.agents ?? [])
424
+ wanted.add(agent.label);
425
+ }
426
+ if (wanted.size === 0)
427
+ return lines;
428
+ const extra = [];
429
+ for (const step of progress.steps) {
430
+ if (!wanted.has(step.label))
431
+ continue;
432
+ for (const task of step.tasks ?? []) {
433
+ const box = task.status === "completed" ? "[x]" : task.status === "in_progress" ? "[>]" : "[ ]";
434
+ extra.push(` ${t("muted", box)} ${t("text", task.content)}`);
435
+ }
436
+ if (step.detail)
437
+ extra.push(` ${t("dim", step.detail)}`);
438
+ }
439
+ if (extra.length === 0)
440
+ return lines;
441
+ return [...lines.slice(0, -1), ...extra, lines[lines.length - 1]];
442
+ }
443
+ /** The key hint under the view, mirroring the reference's key list. */
444
+ export function progressFooter() {
445
+ return "↑/↓ phase · Enter drill in · Esc back out · f filter by status · p pause · x stop · s save script";
446
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The interactive half of the workflow panel: the key map and the view state.
3
+ *
4
+ * The panel prints a footer (`↑/↓ phase · Enter drill in · …`) advertising keys,
5
+ * so the keys have to be real. A footer that describes a different tool than the
6
+ * one on screen is a lie the user can see.
7
+ *
8
+ * Two design points are load-bearing:
9
+ *
10
+ * 1. **The keys are only live while the panel is pinned.** `p`, `x` and `s` are
11
+ * ordinary characters; if they were always intercepted, typing "please" in
12
+ * the editor would pause the run. The panel therefore owns the keyboard only
13
+ * while it is on screen, and `Esc` gives the keyboard back.
14
+ * 2. **The decision is pure.** `workflowAction` maps raw terminal input to an
15
+ * action and `applyView` mutates nothing, so the whole key map is testable
16
+ * without a terminal, a session or a model.
17
+ */
18
+ import { mkdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { matchesKey } from "@netrodev/hxf-tui";
21
+ import { writeFileAtomicSync } from "./fs-atomic.js";
22
+ export const WORKFLOW_FILTERS = ["all", "running", "failed", "done"];
23
+ export function createWorkflowView() {
24
+ return { selectedPhase: 0, filter: "all", paused: false, expanded: false, follow: true };
25
+ }
26
+ /**
27
+ * Map raw terminal input to an action, or `undefined` to let the editor have it.
28
+ *
29
+ * Arrow keys are matched through the SDK's `matchesKey` so the kitty protocol and
30
+ * legacy escape sequences both work; the letters are matched as bare characters,
31
+ * which is what the terminal delivers for a plain key press.
32
+ */
33
+ export function workflowAction(data) {
34
+ if (matchesKey(data, "up"))
35
+ return "up";
36
+ if (matchesKey(data, "down"))
37
+ return "down";
38
+ if (matchesKey(data, "enter"))
39
+ return "enter";
40
+ if (matchesKey(data, "escape"))
41
+ return "escape";
42
+ if (data === "f")
43
+ return "filter";
44
+ if (data === "p")
45
+ return "pause";
46
+ if (data === "x")
47
+ return "stop";
48
+ if (data === "s")
49
+ return "save";
50
+ return undefined;
51
+ }
52
+ /** Move the selected phase, clamped to the list. */
53
+ export function movePhase(view, groups, delta) {
54
+ if (groups.length === 0)
55
+ return view;
56
+ const selected = Math.max(0, Math.min(groups.length - 1, view.selectedPhase + delta));
57
+ // Choosing a phase by hand is a statement of intent: stop following the run.
58
+ return { ...view, selectedPhase: selected, follow: false };
59
+ }
60
+ /** The next filter in the cycle. */
61
+ export function nextFilter(current) {
62
+ const index = WORKFLOW_FILTERS.indexOf(current);
63
+ return WORKFLOW_FILTERS[(index + 1) % WORKFLOW_FILTERS.length];
64
+ }
65
+ /** Apply an action to the view. Pure: the caller assigns the result. */
66
+ export function applyView(view, action, groups) {
67
+ switch (action) {
68
+ case "up":
69
+ return movePhase(view, groups, -1);
70
+ case "down":
71
+ return movePhase(view, groups, 1);
72
+ case "enter":
73
+ return { ...view, expanded: !view.expanded };
74
+ case "filter":
75
+ return { ...view, filter: nextFilter(view.filter) };
76
+ case "pause":
77
+ return { ...view, paused: !view.paused };
78
+ default:
79
+ // `escape`, `stop` and `save` are handled by the caller: they act on the
80
+ // run or the panel, not on the view.
81
+ return view;
82
+ }
83
+ }
84
+ /** Keep auto-follow on while the user has not chosen a phase. */
85
+ export function follow(view, groups, active) {
86
+ return view.follow ? { ...view, selectedPhase: active } : view;
87
+ }
88
+ /** The agents to draw in the right pane, after the filter. */
89
+ export function filterAgents(agents, filter) {
90
+ switch (filter) {
91
+ case "running":
92
+ return agents.filter((agent) => agent.status === "running");
93
+ case "failed":
94
+ return agents.filter((agent) => agent.status === "failed");
95
+ case "done":
96
+ return agents.filter((agent) => agent.status !== "running");
97
+ default:
98
+ return [...agents];
99
+ }
100
+ }
101
+ /** How the filter is named in the pane title. */
102
+ export function filterLabel(filter) {
103
+ return filter === "all" ? "" : ` · ${filter}`;
104
+ }
105
+ /**
106
+ * The key that hands the panel the keyboard.
107
+ *
108
+ * The letters alone cannot do it: `p`, `x`, `s` and `f` are ordinary characters,
109
+ * and a pinned panel that swallowed them made the editor unusable — typing
110
+ * "Reply with a numbered list" lost the `p`, `s` and `f`, and Enter was eaten so
111
+ * the message could not even be sent. So the panel's keys are opt-in, and the
112
+ * footer says which mode it is in.
113
+ */
114
+ export const WORKFLOW_KEY_TOGGLE = "ctrl+f";
115
+ /**
116
+ * The key hint, reflecting what the keys will do right now.
117
+ *
118
+ * `x` is only offered while the run is live (stopping a finished run is
119
+ * meaningless), the pause hint says which way it will go, and while the panel
120
+ * does *not* hold the keyboard the only hint is how to give it the keyboard.
121
+ */
122
+ export function viewFooter(view, running, keysActive = true) {
123
+ if (!keysActive)
124
+ return `Ctrl+F panel keys · Esc unpin`;
125
+ const parts = ["↑/↓ phase", view.expanded ? "Enter collapse" : "Enter drill in"];
126
+ parts.push(view.expanded ? "Esc back out" : "Esc unpin");
127
+ parts.push(`f filter${view.filter === "all" ? "" : ` (${view.filter})`}`);
128
+ if (running) {
129
+ parts.push(view.paused ? "p resume" : "p pause");
130
+ parts.push("x stop");
131
+ }
132
+ else if (view.paused) {
133
+ parts.push("p resume");
134
+ }
135
+ parts.push("s save script");
136
+ if (view.paused)
137
+ parts.unshift("paused");
138
+ return parts.join(" · ");
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Saving the script behind a run
142
+ // ---------------------------------------------------------------------------
143
+ /**
144
+ * A filename safe to write into the workflows directory.
145
+ *
146
+ * The name comes from the script's own `meta.name`, which a model wrote, so it
147
+ * is untrusted: `../../etc/x` or an absolute path would otherwise escape the
148
+ * directory. Returns `undefined` for anything that cannot be a plain name.
149
+ */
150
+ export function safeScriptName(name) {
151
+ const trimmed = name.trim();
152
+ if (!trimmed || trimmed.length > 64)
153
+ return undefined;
154
+ if (trimmed === "." || trimmed === "..")
155
+ return undefined;
156
+ // Letters, digits, dot, dash, underscore — and never a leading dot, so the
157
+ // file cannot be hidden or shadow a directory entry.
158
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed))
159
+ return undefined;
160
+ if (trimmed.includes(".."))
161
+ return undefined;
162
+ return trimmed;
163
+ }
164
+ /**
165
+ * Write a run's script into the workflows directory and return its path.
166
+ *
167
+ * Only `code` runs can be saved. A run started with `path` is already a file
168
+ * somewhere the user chose, so re-saving it would just make a second copy that
169
+ * can drift from the original.
170
+ *
171
+ * The name must be kept exactly as it was: the workflow tool refuses to run a
172
+ * script whose `meta.name` does not match its filename, so renaming on save
173
+ * would produce a file that cannot be run again.
174
+ */
175
+ export function saveScript(script, dir) {
176
+ if (!script) {
177
+ return {
178
+ ok: false,
179
+ reason: "this run was started from a file, so it is already saved where you pointed the tool",
180
+ };
181
+ }
182
+ const safe = safeScriptName(script.name);
183
+ if (!safe) {
184
+ return { ok: false, reason: `"${script.name}" is not a usable filename, so the script was not written` };
185
+ }
186
+ const path = join(dir, `${safe}.js`);
187
+ try {
188
+ mkdirSync(dir, { recursive: true });
189
+ writeFileAtomicSync(path, script.code);
190
+ }
191
+ catch (cause) {
192
+ return { ok: false, reason: cause instanceof Error ? cause.message : String(cause) };
193
+ }
194
+ return { ok: true, path };
195
+ }