@tt-a1i/openpi 0.1.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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Shared workflow run model + formatting helpers, used by the tool renderers
3
+ * (index.ts) and the /workflows dashboard (dashboard.ts).
4
+ */
5
+
6
+ import * as os from "node:os";
7
+ import {
8
+ truncateHead,
9
+ type ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { formatContextUtilization } from "../shared/context-utilization.ts";
12
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
13
+ import type { WorktreeCleanup } from "../shared/worktree.ts";
14
+ import type { AcceptanceLedger } from "./acceptance.ts";
15
+ import { safeStringify } from "./serialization.ts";
16
+
17
+ export type Theme = ExtensionContext["ui"]["theme"];
18
+
19
+ export const RESULT_JSON_MAX_BYTES = 24 * 1024;
20
+ export const RESULT_JSON_MAX_LINES = 600;
21
+
22
+ export interface AgentUsage {
23
+ input: number;
24
+ output: number;
25
+ cacheRead: number;
26
+ cacheWrite: number;
27
+ cost: number;
28
+ /** Latest compaction-aware conversation occupancy, not cumulative billing. */
29
+ contextTokens?: number;
30
+ turns: number;
31
+ }
32
+
33
+ export function emptyUsage(): AgentUsage {
34
+ return {
35
+ input: 0,
36
+ output: 0,
37
+ cacheRead: 0,
38
+ cacheWrite: 0,
39
+ cost: 0,
40
+ turns: 0,
41
+ };
42
+ }
43
+
44
+ export type AgentState = "running" | "done" | "error";
45
+ export type WorkflowStatus = "running" | "completed" | "failed" | "aborted";
46
+
47
+ export type TranscriptRole =
48
+ "user" | "assistant" | "thinking" | "tool" | "toolResult";
49
+
50
+ export interface TranscriptEntry {
51
+ role: TranscriptRole;
52
+ text: string;
53
+ /** Tool name for tool calls/results. */
54
+ name?: string;
55
+ /** Stable tool-call identifier used to pair calls, results, and timings. */
56
+ toolCallId?: string;
57
+ isError?: boolean;
58
+ /** Original message timestamp, when provided by the model/session. */
59
+ timestamp?: number;
60
+ /** Tool execution lifecycle timestamps, measured by the child session. */
61
+ startedAt?: number;
62
+ finishedAt?: number;
63
+ durationMs?: number;
64
+ }
65
+
66
+ export interface AgentRecord {
67
+ index: number;
68
+ label: string;
69
+ phase?: string;
70
+ state: AgentState;
71
+ model?: string;
72
+ /** Context capacity of the active model used for this agent. */
73
+ contextWindow?: number;
74
+ startedAt: number;
75
+ finishedAt?: number;
76
+ error?: string;
77
+ preview: string;
78
+ usage: AgentUsage;
79
+ /** Replayed from a prior run's journal instead of actually executed. */
80
+ replayed?: boolean;
81
+ /** Explicit caller-supplied acceptance result; never inferred from role/task. */
82
+ acceptance?: AcceptanceLedger;
83
+ /** Branch of the isolated worktree this agent ran in, when it holds commits. */
84
+ worktreeBranch?: string;
85
+ /** Isolated worktree kept on disk because automatic cleanup was unsafe. */
86
+ worktreePath?: string;
87
+ /** Authoritative result of inspecting and reclaiming the isolated checkout. */
88
+ worktreeCleanup?: WorktreeCleanup;
89
+ /** Run-directory-relative manifest captured before cleanup. */
90
+ worktreeHandoffArtifact?: string;
91
+ /** Normalized, serializable subagent conversation shown by /workflows. */
92
+ transcript: TranscriptEntry[];
93
+ }
94
+
95
+ /** One narrator line emitted by the script's `log()`. */
96
+ export interface WorkflowLogEntry {
97
+ at: number;
98
+ text: string;
99
+ }
100
+
101
+ export interface WorkflowDetails {
102
+ runId: string;
103
+ /** Pi session that launched this run. */
104
+ sessionId?: string;
105
+ name?: string;
106
+ description?: string;
107
+ background: boolean;
108
+ status: WorkflowStatus;
109
+ startedAt: number;
110
+ finishedAt?: number;
111
+ phases: { title: string; detail?: string }[];
112
+ currentPhase?: string;
113
+ agents: AgentRecord[];
114
+ /** Narrator lines from `log()`, oldest first, bounded to MAX_LOG_ENTRIES. */
115
+ logs?: WorkflowLogEntry[];
116
+ /** Oldest lines discarded once the ring filled, reported rather than hidden. */
117
+ logsDropped?: number;
118
+ result?: unknown;
119
+ resultArtifact?: string;
120
+ transcriptArtifact?: string;
121
+ /** Run this one replayed cached agent results from, when resuming. */
122
+ resumedFrom?: string;
123
+ /** Why a requested resume produced no cache, for an honest result message. */
124
+ resumeNote?: string;
125
+ error?: string;
126
+ }
127
+
128
+ export const MAX_LOG_ENTRIES = 100;
129
+ export const MAX_LOG_TEXT = 300;
130
+
131
+ /** Only generated workflow directory names may cross the artifact boundary. */
132
+ export function isWorkflowRunId(value: string): boolean {
133
+ return /^wf_[0-9a-f]+$/i.test(value);
134
+ }
135
+
136
+ export function isWorkflowRunTarget(value: string): boolean {
137
+ return isWorkflowRunId(value) || /^[0-9a-f]+$/i.test(value);
138
+ }
139
+
140
+ const RUN_RESOLUTION_MATCH_LIMIT = 8;
141
+
142
+ function boundedRunList(runIds: readonly string[]) {
143
+ if (runIds.length === 0) return "none";
144
+ const shown = runIds
145
+ .slice(0, RUN_RESOLUTION_MATCH_LIMIT)
146
+ .map((runId) => sanitizeLine(runId, 80));
147
+ const omitted = runIds.length - shown.length;
148
+ return `${shown.join(", ")}${omitted > 0 ? `, … (+${omitted} more)` : ""}`;
149
+ }
150
+
151
+ /** Resolve a generated id exactly, or a hex suffix only when it is unique. */
152
+ export function resolveWorkflowRunTarget(
153
+ target: string,
154
+ candidates: Iterable<string>,
155
+ ) {
156
+ const trimmed = target.trim();
157
+ if (!isWorkflowRunTarget(trimmed)) {
158
+ return {
159
+ ok: false,
160
+ error: "Workflow run id must be a generated id or hex suffix",
161
+ } as const;
162
+ }
163
+
164
+ const normalizedTarget = trimmed.toLowerCase();
165
+ const runIds = [...new Set([...candidates].filter(isWorkflowRunId))].sort();
166
+ if (isWorkflowRunId(normalizedTarget) && runIds.includes(normalizedTarget)) {
167
+ return { ok: true, runId: normalizedTarget } as const;
168
+ }
169
+
170
+ const matches = runIds.filter((runId) => runId.endsWith(normalizedTarget));
171
+ if (matches.length === 1) return { ok: true, runId: matches[0]! } as const;
172
+
173
+ const displayTarget = sanitizeLine(trimmed, 80);
174
+ if (matches.length === 0) {
175
+ return {
176
+ ok: false,
177
+ error: `No workflow run matching "${displayTarget}". Available: ${boundedRunList(runIds)}.`,
178
+ } as const;
179
+ }
180
+ return {
181
+ ok: false,
182
+ error: `Workflow run suffix "${displayTarget}" is ambiguous. Matches: ${boundedRunList(matches)}. Use a longer suffix or full run id.`,
183
+ } as const;
184
+ }
185
+
186
+ /**
187
+ * Flatten model-authored text into one safe terminal line. Workflow scripts are
188
+ * written by the model, so an escape sequence in a `log()` or `phase()` string
189
+ * would otherwise repaint the user's screen; newlines would break row layout.
190
+ */
191
+ export function sanitizeWorkflowDisplayText(
192
+ value: string,
193
+ maxLength = 16 * 1024,
194
+ ): string {
195
+ return [...sanitizeTerminalText(value)].slice(0, maxLength).join("");
196
+ }
197
+
198
+ export function sanitizeWorkflowDisplayLine(
199
+ value: string,
200
+ maxLength = 2_000,
201
+ ): string {
202
+ const line = sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
203
+ return [...line].slice(0, maxLength).join("");
204
+ }
205
+
206
+ export function sanitizeLine(value: string, maxLength: number): string {
207
+ // Code-point slicing, not code-unit: cutting a surrogate pair in half leaves
208
+ // a lone surrogate that renders as a replacement glyph.
209
+ const flat = sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
210
+ const points = [...flat];
211
+ const clipped =
212
+ points.length > maxLength ? points.slice(0, maxLength).join("") : flat;
213
+ return clipped.trimEnd();
214
+ }
215
+
216
+ /**
217
+ * Append a narrator line, keeping only the most recent MAX_LOG_ENTRIES. The
218
+ * tail is what a reader needs, and a silently dropped head would read as "the
219
+ * script went quiet", so the discarded count is kept for the report.
220
+ */
221
+ export function appendLog(
222
+ details: WorkflowDetails,
223
+ text: string,
224
+ at: number,
225
+ ): void {
226
+ const clean = sanitizeLine(text, MAX_LOG_TEXT);
227
+ if (!clean) return;
228
+ const logs = (details.logs ??= []);
229
+ logs.push({ at, text: clean });
230
+ const excess = logs.length - MAX_LOG_ENTRIES;
231
+ if (excess > 0) {
232
+ logs.splice(0, excess);
233
+ details.logsDropped = (details.logsDropped ?? 0) + excess;
234
+ }
235
+ }
236
+
237
+ /** Cumulative token spend across a run's agents, as read by the script's `usage()`. */
238
+ export interface WorkflowUsageSnapshot {
239
+ input: number;
240
+ output: number;
241
+ cacheRead: number;
242
+ cacheWrite: number;
243
+ /** input + output + cacheRead + cacheWrite. */
244
+ total: number;
245
+ cost: number;
246
+ /** Agents started so far, including ones still running. */
247
+ agents: number;
248
+ }
249
+
250
+ /**
251
+ * Cumulative token spend for one run, as read by the script's `usage()`.
252
+ *
253
+ * Stateful because the underlying number is not monotonic: each agent's usage
254
+ * is RECOMPUTED from its current message list, so when a child session
255
+ * auto-compacts, the dropped messages take their tokens with them and the sum
256
+ * falls. A script that loops `while (usage().total < N)` would then run well
257
+ * past N — measured at 400k actual against a 250k intended stop — while
258
+ * honestly reporting that it stopped on budget. A high-water mark cannot make
259
+ * the reading exact, but it makes it an under-report that never reverses,
260
+ * which is the property a loop condition needs.
261
+ */
262
+ export function createUsageReader(agents: readonly AgentRecord[]) {
263
+ let peak = 0;
264
+ return (): WorkflowUsageSnapshot => {
265
+ const usage = aggregateUsage([...agents]);
266
+ const total =
267
+ usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
268
+ peak = Math.max(peak, total);
269
+ return {
270
+ input: usage.input,
271
+ output: usage.output,
272
+ cacheRead: usage.cacheRead,
273
+ cacheWrite: usage.cacheWrite,
274
+ total: peak,
275
+ cost: usage.cost,
276
+ agents: agents.length,
277
+ };
278
+ };
279
+ }
280
+
281
+ /** Colored square state indicator (no emojis/glyphs). */
282
+ export const SQUARE = "■";
283
+
284
+ export function stateSquare(state: AgentState, theme: Theme): string {
285
+ if (state === "done") return theme.fg("success", SQUARE);
286
+ if (state === "error") return theme.fg("error", SQUARE);
287
+ return theme.fg("warning", SQUARE);
288
+ }
289
+
290
+ export function statusSquare(status: WorkflowStatus, theme: Theme): string {
291
+ if (status === "completed") return theme.fg("success", SQUARE);
292
+ if (status === "running") return theme.fg("warning", SQUARE);
293
+ return theme.fg("error", SQUARE);
294
+ }
295
+
296
+ export function statusWord(status: WorkflowStatus): string {
297
+ return status === "completed" ? "done" : status;
298
+ }
299
+
300
+ export function statusColor(
301
+ status: WorkflowStatus,
302
+ ): "success" | "warning" | "error" {
303
+ if (status === "completed") return "success";
304
+ if (status === "running") return "warning";
305
+ return "error";
306
+ }
307
+
308
+ export function shortenHome(p: string): string {
309
+ const home = os.homedir();
310
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
311
+ }
312
+
313
+ export function formatTokens(count: number): string {
314
+ if (count < 1000) return count.toString();
315
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
316
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
317
+ return `${(count / 1000000).toFixed(1)}M`;
318
+ }
319
+
320
+ export function formatUsage(usage: AgentUsage, model?: string): string {
321
+ const parts: string[] = [];
322
+ if (usage.turns)
323
+ parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
324
+ if (usage.input) parts.push(`${formatTokens(usage.input)} in`);
325
+ if (usage.output) parts.push(`${formatTokens(usage.output)} out`);
326
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
327
+ if (model) parts.push(model);
328
+ return parts.join(" · ");
329
+ }
330
+
331
+ /** Current per-agent context-window utilization, e.g. "7%/372k". */
332
+ export function agentContext(agent: AgentRecord): string {
333
+ return formatContextUtilization({
334
+ tokens: agent.usage.contextTokens,
335
+ contextWindow: agent.contextWindow,
336
+ });
337
+ }
338
+
339
+ export function formatElapsed(startedAt: number, finishedAt?: number): string {
340
+ const totalSeconds = Math.max(
341
+ 0,
342
+ Math.round(((finishedAt ?? Date.now()) - startedAt) / 1000),
343
+ );
344
+ const minutes = Math.floor(totalSeconds / 60);
345
+ const seconds = totalSeconds % 60;
346
+ return minutes > 0
347
+ ? `${minutes}m${seconds.toString().padStart(2, "0")}s`
348
+ : `${seconds}s`;
349
+ }
350
+
351
+ export function aggregateUsage(agents: AgentRecord[]): AgentUsage {
352
+ const total = emptyUsage();
353
+ for (const agent of agents) {
354
+ total.input += agent.usage.input;
355
+ total.output += agent.usage.output;
356
+ total.cacheRead += agent.usage.cacheRead;
357
+ total.cacheWrite += agent.usage.cacheWrite;
358
+ total.cost += agent.usage.cost;
359
+ total.turns += agent.usage.turns;
360
+ }
361
+ return total;
362
+ }
363
+
364
+ export function countStates(details: WorkflowDetails) {
365
+ let done = 0;
366
+ let failed = 0;
367
+ let running = 0;
368
+ for (const agent of details.agents) {
369
+ if (agent.state === "done") done++;
370
+ else if (agent.state === "error") failed++;
371
+ else running++;
372
+ }
373
+ return { done, failed, running };
374
+ }
375
+
376
+ export interface PhaseGroup {
377
+ title: string;
378
+ agents: AgentRecord[];
379
+ }
380
+
381
+ /**
382
+ * Group agents by phase in declared phase order. With `includeEmpty`, phases
383
+ * that have no agents yet are still listed (used by the dashboard sidebar).
384
+ */
385
+ export function phaseGroups(
386
+ details: WorkflowDetails,
387
+ includeEmpty = false,
388
+ ): PhaseGroup[] {
389
+ const byPhase = new Map<string, AgentRecord[]>();
390
+ for (const agent of details.agents) {
391
+ const key = agent.phase ?? "(unphased)";
392
+ const list = byPhase.get(key) ?? [];
393
+ list.push(agent);
394
+ byPhase.set(key, list);
395
+ }
396
+ const groups: PhaseGroup[] = [];
397
+ for (const phase of details.phases) {
398
+ const agents = byPhase.get(phase.title);
399
+ if (agents || includeEmpty)
400
+ groups.push({ title: phase.title, agents: agents ?? [] });
401
+ byPhase.delete(phase.title);
402
+ }
403
+ for (const [title, agents] of byPhase) groups.push({ title, agents });
404
+ return groups;
405
+ }
406
+
407
+ export function resultJson(value: unknown): string {
408
+ const text =
409
+ typeof value === "string"
410
+ ? value
411
+ : safeStringify(value, {
412
+ maxBytes: RESULT_JSON_MAX_BYTES * 2,
413
+ maxDepth: 16,
414
+ maxNodes: 10_000,
415
+ });
416
+ const truncation = truncateHead(text ?? "", {
417
+ maxLines: RESULT_JSON_MAX_LINES,
418
+ maxBytes: RESULT_JSON_MAX_BYTES,
419
+ });
420
+ return truncation.truncated
421
+ ? `${truncation.content}\n…[result truncated; bounded result artifact in result.json]`
422
+ : truncation.content;
423
+ }
@@ -0,0 +1,93 @@
1
+ import type { TUI } from "@earendil-works/pi-tui";
2
+ import {
3
+ BelowEditorNavigationEditor,
4
+ BelowEditorStripState,
5
+ belowEditorStripInput,
6
+ fitNavigationSides,
7
+ } from "../shared/below-editor-navigation.ts";
8
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
9
+ import {
10
+ aggregateUsage,
11
+ countStates,
12
+ formatElapsed,
13
+ formatTokens,
14
+ statusColor,
15
+ statusSquare,
16
+ type Theme,
17
+ type WorkflowDetails,
18
+ } from "./model.ts";
19
+
20
+ /** Workflow-named aliases preserve the public seam while sharing interaction. */
21
+ export {
22
+ BelowEditorNavigationEditor as WorkflowNavigationEditor,
23
+ BelowEditorStripState as WorkflowStripState,
24
+ belowEditorStripInput as workflowStripInput,
25
+ };
26
+
27
+ export interface WorkflowStripEntry {
28
+ runId: string;
29
+ details: WorkflowDetails;
30
+ }
31
+
32
+ function cleanLine(value: string) {
33
+ return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
34
+ }
35
+
36
+ /** Live, one-line Claude-style workflow entry rendered below the editor. */
37
+ export class WorkflowStripWidget {
38
+ private readonly timer: ReturnType<typeof setInterval>;
39
+ private readonly tui: TUI;
40
+ private readonly theme: Theme;
41
+ private readonly strip: BelowEditorStripState;
42
+ private readonly getEntry: () => WorkflowStripEntry | undefined;
43
+
44
+ constructor(
45
+ tui: TUI,
46
+ theme: Theme,
47
+ strip: BelowEditorStripState,
48
+ getEntry: () => WorkflowStripEntry | undefined,
49
+ ) {
50
+ this.tui = tui;
51
+ this.theme = theme;
52
+ this.strip = strip;
53
+ this.getEntry = getEntry;
54
+ this.timer = setInterval(() => this.tui.requestRender(), 500);
55
+ this.timer.unref?.();
56
+ }
57
+
58
+ dispose() {
59
+ clearInterval(this.timer);
60
+ }
61
+
62
+ invalidate() {}
63
+
64
+ render(width: number) {
65
+ const entry = this.getEntry();
66
+ if (!entry || width <= 0) return [];
67
+ const details = entry.details;
68
+ const { done, failed } = countStates(details);
69
+ const settled = done + failed;
70
+ const usage = aggregateUsage(details.agents);
71
+ const tokenCount = usage.input + usage.output;
72
+ const marker = this.strip.focused
73
+ ? this.theme.fg("accent", "❯")
74
+ : this.theme.fg("dim", "○");
75
+ const displayName = cleanLine(details.name ?? entry.runId) || entry.runId;
76
+ const name = this.strip.focused
77
+ ? this.theme.bold(this.theme.fg("accent", displayName))
78
+ : this.theme.fg("text", displayName);
79
+ const rawContext = details.currentPhase ?? details.description;
80
+ const context = rawContext ? cleanLine(rawContext) : undefined;
81
+ const left = ` ${marker} ${statusSquare(details.status, this.theme)} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`;
82
+ const metrics = [
83
+ `${settled}/${details.agents.length} agents`,
84
+ formatElapsed(details.startedAt, details.finishedAt),
85
+ tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined,
86
+ this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
87
+ ]
88
+ .filter((part): part is string => Boolean(part))
89
+ .join(" · ");
90
+ const right = this.theme.fg(statusColor(details.status), metrics);
91
+ return [fitNavigationSides(left, right, width)];
92
+ }
93
+ }