opencode-ultracode 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.
@@ -0,0 +1,1517 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // opencode-ultracode — TUI plugin: /workflows command + progress routes.
3
+ //
4
+ // Routes:
5
+ // workflows — list of runs
6
+ // workflow — two-pane progress view (phases | agents) + log/result strip
7
+ // workflow-agent — agent detail (live feed / prompt / activity / outcome)
8
+ // workflow-result — full-screen scrollable run result
9
+ //
10
+ // State comes from /tmp/opencode-workflows/<project>/<id>/state.json, polled by the
11
+ // store and merged fine-grained so only changed cells redraw.
12
+
13
+ import type { TuiPluginApi, TuiPluginModule, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
14
+ import type { RGBA, ScrollBoxRenderable } from "@opentui/core"
15
+ import { createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
16
+ import { createSignal } from "solid-js"
17
+ import {
18
+ cell,
19
+ cellR,
20
+ clip,
21
+ fmtAgo,
22
+ fmtClock,
23
+ fmtCost,
24
+ fmtDuration,
25
+ fmtElapsed,
26
+ fmtTok,
27
+ fmtTokens,
28
+ oneLine,
29
+ shortModel,
30
+ wrapWords,
31
+ } from "../shared/format.ts"
32
+ import type { AgentState, PhaseState, RunState } from "../shared/state.ts"
33
+ import { createStore, isLive, type WorkflowStore } from "./store.ts"
34
+ import { createRequestStore, describeRequest, type PendingRequest, type PermissionReply, type RequestStore } from "./requests.ts"
35
+
36
+ // opentui TextAttributes bit flags (avoid a runtime import of @opentui/core)
37
+ const BOLD = 1
38
+ const DIM = 2
39
+
40
+ type Tone = "success" | "error" | "warning" | "info" | "muted" | "accent" | "text" | "primary"
41
+ type Pane = "phases" | "agents"
42
+
43
+ export const plugin: TuiPluginModule = {
44
+ id: "opencode-ultracode",
45
+ tui: async (api) => {
46
+ const store = createStore(api, (fn) => api.lifecycle.onDispose(fn))
47
+ // pending permission / question requests of sub-agent sessions (see requests.ts)
48
+ const reqs = createRequestStore(api, (fn) => api.lifecycle.onDispose(fn))
49
+
50
+ // selection state shared across views
51
+ const [listSel, setListSel] = createSignal(0)
52
+ const [pane, setPane] = createSignal<Pane>("phases")
53
+ const [agentOpenId, setAgentOpenId] = createSignal("")
54
+ let origin: { name: string; params?: Record<string, unknown> } = { name: "home" }
55
+
56
+ // scroll containers of the detail screens (driven from the keymap)
57
+ let agentScroll: ScrollBoxRenderable | undefined
58
+ let resultScroll: ScrollBoxRenderable | undefined
59
+
60
+ const runs = () => store.runs()
61
+ const selectedRun = (): RunState | undefined => runs()[Math.min(listSel(), Math.max(0, runs().length - 1))]
62
+ const activeRun = (): RunState | undefined => store.activeRun()
63
+
64
+ // keep the list cursor inside bounds when runs disappear
65
+ createEffect(() => {
66
+ const n = runs().length
67
+ if (listSel() > Math.max(0, n - 1)) setListSel(Math.max(0, n - 1))
68
+ })
69
+
70
+ // --- navigation -----------------------------------------------------------
71
+
72
+ const isOurRoute = (name: string) => name === "workflows" || name.startsWith("workflow")
73
+
74
+ const openWorkflows = () => {
75
+ const cur = api.route.current
76
+ if (!isOurRoute(cur.name)) origin = cur as any
77
+ api.route.navigate("workflows")
78
+ }
79
+
80
+ const goBack = () => {
81
+ const cur = api.route.current.name
82
+ if (cur === "workflow-result") {
83
+ api.route.navigate(store.activeRunId() ? "workflow" : "workflows")
84
+ return
85
+ }
86
+ if (cur === "workflow-agent" || agentOpenId()) {
87
+ setAgentOpenId("")
88
+ api.route.navigate("workflow")
89
+ return
90
+ }
91
+ if (cur === "workflow" || store.activeRunId()) {
92
+ store.closeRun()
93
+ api.route.navigate("workflows")
94
+ return
95
+ }
96
+ api.route.navigate(origin.name, origin.params ?? {})
97
+ }
98
+
99
+ const openRun = (r: RunState) => {
100
+ store.openRun(r.runId)
101
+ setPane("phases")
102
+ setAgentOpenId("")
103
+ api.route.navigate("workflow")
104
+ }
105
+
106
+ const currentPhase = (r: RunState): PhaseState | undefined => r.phases[store.selPhase()] ?? r.phases[0]
107
+
108
+ const movePhase = (dir: number) => {
109
+ const r = activeRun()
110
+ if (!r || !r.phases.length) return
111
+ const next = Math.max(0, Math.min(r.phases.length - 1, store.selPhase() + dir))
112
+ if (next !== store.selPhase()) {
113
+ store.setSelPhase(next)
114
+ store.setSelAgent("")
115
+ }
116
+ }
117
+
118
+ const moveAgentInPhase = (dir: number) => {
119
+ const r = activeRun()
120
+ if (!r) return
121
+ const ids = currentPhase(r)?.agentIds ?? []
122
+ if (!ids.length) return
123
+ const curSel = store.selAgent()
124
+ const cur = curSel ? ids.indexOf(curSel) : -1
125
+ const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? 0 : cur) + dir))
126
+ store.setSelAgent(ids[next] ?? "")
127
+ }
128
+
129
+ const focusAgents = () => {
130
+ const r = activeRun()
131
+ if (!r) return
132
+ const ids = currentPhase(r)?.agentIds ?? []
133
+ if (!ids.length) {
134
+ api.ui.toast({ variant: "info", message: "This phase has no agents yet" })
135
+ return
136
+ }
137
+ if (!store.selAgent() || !ids.includes(store.selAgent()!)) store.setSelAgent(ids[0])
138
+ setPane("agents")
139
+ }
140
+
141
+ const openAgent = (id: string) => {
142
+ setAgentOpenId(id)
143
+ store.setSelAgent(id)
144
+ api.route.navigate("workflow-agent")
145
+ }
146
+
147
+ /** ←/→ in the agent view: previous / next agent of the same phase */
148
+ const stepAgent = (dir: number) => {
149
+ const r = activeRun()
150
+ const a = r?.agents[agentOpenId()]
151
+ if (!r || !a) return
152
+ const phase = r.phases.find((p) => p.title === a.phase)
153
+ const ids = phase?.agentIds?.length ? phase.agentIds : r.agentOrder
154
+ const cur = ids.indexOf(a.id)
155
+ const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? 0 : cur) + dir))
156
+ const id = ids[next]
157
+ if (id && id !== a.id) {
158
+ setAgentOpenId(id)
159
+ store.setSelAgent(id)
160
+ agentScroll?.scrollTo(0)
161
+ }
162
+ }
163
+
164
+ const openResult = (r: RunState | undefined) => {
165
+ if (!r) return
166
+ if (!r.result && !r.error) {
167
+ api.ui.toast({ variant: "info", message: isLive(r.status) ? "Run is still in progress — no result yet" : "This run produced no result" })
168
+ return
169
+ }
170
+ if (store.activeRunId() !== r.runId) store.openRun(r.runId)
171
+ api.route.navigate("workflow-result")
172
+ }
173
+
174
+ const scrollBy = (sb: ScrollBoxRenderable | undefined, lines: number) => {
175
+ if (!sb) return
176
+ try {
177
+ sb.scrollBy({ x: 0, y: lines })
178
+ } catch {}
179
+ }
180
+ const pageOf = (sb: ScrollBoxRenderable | undefined) => Math.max(3, (sb?.viewport?.height ?? sb?.height ?? 10) - 2)
181
+
182
+ // --- actions ----------------------------------------------------------------
183
+
184
+ const doStop = (run: RunState | undefined) => {
185
+ if (!run) return
186
+ if (run.status === "running" || run.status === "paused") {
187
+ store.control(run.runId, "stop")
188
+ api.ui.toast({ variant: "warning", message: `Stopping ${run.name}…` })
189
+ } else {
190
+ api.ui.toast({ variant: "info", message: `${run.name} is not running` })
191
+ }
192
+ }
193
+ const doPause = (run: RunState | undefined) => {
194
+ if (!run) return
195
+ if (store.isStale(run) || run.status === "stopped" || run.status === "failed") {
196
+ // no live engine: the server plugin picks the request up and restarts
197
+ // the run in place (completed agents replay from the journal)
198
+ store.control(run.runId, "resume")
199
+ api.ui.toast({ variant: "info", message: `Resume requested for ${run.name} — completed agents replay, the rest run again` })
200
+ } else if (run.status === "running") {
201
+ store.control(run.runId, "pause")
202
+ api.ui.toast({ variant: "info", message: `Pausing ${run.name}…` })
203
+ } else if (run.status === "paused") {
204
+ store.control(run.runId, "resume")
205
+ api.ui.toast({ variant: "info", message: `Resuming ${run.name}…` })
206
+ } else {
207
+ api.ui.toast({ variant: "info", message: `${run.name} is not running` })
208
+ }
209
+ }
210
+ const doSave = (run: RunState | undefined) => {
211
+ if (!run) return
212
+ const dest = store.saveScript(run)
213
+ api.ui.toast(
214
+ dest
215
+ ? { variant: "success", message: `Saved as workflow: ${dest}` }
216
+ : { variant: "error", message: "No saved script for this run" },
217
+ )
218
+ }
219
+ const doDelete = (run: RunState | undefined) => {
220
+ if (!run) return
221
+ if (isLive(run.status) && !store.isStale(run)) {
222
+ api.ui.toast({ variant: "error", message: "Run is active — stop it first (x), then delete (d)" })
223
+ return
224
+ }
225
+ store.deleteRun(run.runId)
226
+ api.ui.toast({ variant: "success", message: `Deleted run: ${run.name}` })
227
+ }
228
+
229
+ // --- permission / question requests -------------------------------------------
230
+
231
+ /** the run and agent that own a request's session, if it is one of ours */
232
+ const ownerOf = (p: PendingRequest): { run: RunState; agent: AgentState } | undefined => {
233
+ for (const r of runs()) {
234
+ for (const id of r.agentOrder) {
235
+ const a = r.agents[id]
236
+ if (a?.sessionId === p.sessionID) return { run: r, agent: a }
237
+ }
238
+ }
239
+ return undefined
240
+ }
241
+
242
+ /** first agent (newest run first) that is blocked on a request */
243
+ const firstWaiting = (): { run: RunState; agent: AgentState } | undefined => {
244
+ for (const r of runs()) {
245
+ const a = reqs.waitingAgents(r)[0]
246
+ if (a) return { run: r, agent: a }
247
+ }
248
+ return undefined
249
+ }
250
+
251
+ /** `!` — jump to the first agent that needs an answer and open the dialog */
252
+ const gotoWaiting = () => {
253
+ const hit = firstWaiting()
254
+ if (!hit) {
255
+ const other = reqs.unattributed(runs()).length
256
+ api.ui.toast({
257
+ variant: "info",
258
+ message: other ? `No workflow agent is waiting — ${other} request${other === 1 ? "" : "s"} pending in the chat session (esc to go back)` : "No agent is waiting for permission",
259
+ })
260
+ return
261
+ }
262
+ if (store.activeRunId() !== hit.run.runId) store.openRun(hit.run.runId)
263
+ const phaseIdx = hit.run.phases.findIndex((p) => p.title === hit.agent.phase)
264
+ if (phaseIdx >= 0) store.setSelPhase(phaseIdx)
265
+ openAgent(hit.agent.id)
266
+ respond(hit.agent)
267
+ }
268
+
269
+ const sendPermission = (agent: AgentState, p: PendingRequest, reply: PermissionReply) => {
270
+ api.ui.dialog.clear()
271
+ reqs.replyPermission(p.id, reply).then((ok) => {
272
+ if (!ok) return
273
+ const verb = reply === "reject" ? "Rejected" : reply === "always" ? "Allowed (always)" : "Allowed once"
274
+ api.ui.toast({ variant: reply === "reject" ? "warning" : "success", message: `${verb}: ${clip(describeRequest(p), 60)} — ${agent.label}` })
275
+ })
276
+ }
277
+
278
+ const permissionDialog = (agent: AgentState, p: Extract<PendingRequest, { kind: "permission" }>) => {
279
+ const patterns = (p.req.patterns ?? []).filter(Boolean)
280
+ const remember = (p.req.always ?? []).filter(Boolean)
281
+ const what = patterns.join(", ") || p.req.permission
282
+ const meta = p.req.metadata ?? {}
283
+ const detail = [meta.description, meta.hint, meta.command, meta.filepath, meta.path]
284
+ .filter((v) => typeof v === "string" && v.trim())
285
+ .map((v) => String(v))
286
+ .join(" · ")
287
+ api.ui.dialog.replace(() => (
288
+ <api.ui.DialogSelect
289
+ title={`${p.req.permission} · ${clip(agent.label, 32)}`}
290
+ placeholder={clip(detail || what, 70)}
291
+ flat
292
+ skipFilter
293
+ options={[
294
+ { title: "Allow once", value: "once", description: clip(what, 80) },
295
+ {
296
+ title: "Allow always",
297
+ value: "always",
298
+ description: remember.length ? `remember ${clip(remember.join(", "), 70)}` : "remember this permission for the rest of the session",
299
+ },
300
+ { title: "Reject", value: "reject", description: "the agent is told no and continues without it" },
301
+ ]}
302
+ onSelect={(o) => sendPermission(agent, p, o.value as PermissionReply)}
303
+ />
304
+ ))
305
+ }
306
+
307
+ /** ask the agent's questions one after another, then send all answers */
308
+ const questionDialog = (agent: AgentState, p: Extract<PendingRequest, { kind: "question" }>, idx = 0, answers: string[][] = []) => {
309
+ const qs = p.req.questions ?? []
310
+ const q = qs[idx]
311
+ if (!q) {
312
+ api.ui.dialog.clear()
313
+ reqs.replyQuestion(p.id, answers).then((ok) => ok && api.ui.toast({ variant: "success", message: `Answered ${agent.label}` }))
314
+ return
315
+ }
316
+ const next = (answer: string[]) => questionDialog(agent, p, idx + 1, [...answers, answer])
317
+ const options: Array<{ title: string; value: string; description?: string }> = q.options.map((o) => ({ title: o.label, value: `opt:${o.label}`, description: o.description }))
318
+ if (q.custom !== false) options.push({ title: "Type an answer…", value: "__custom", description: "free-text reply" })
319
+ options.push({ title: "Reject question", value: "__reject", description: "the agent continues without an answer" })
320
+ api.ui.dialog.replace(() => (
321
+ <api.ui.DialogSelect
322
+ title={`${q.header || "Question"}${qs.length > 1 ? ` (${idx + 1}/${qs.length})` : ""} · ${clip(agent.label, 28)}`}
323
+ placeholder={clip(q.question, 90)}
324
+ flat
325
+ skipFilter
326
+ options={options}
327
+ onSelect={(o) => {
328
+ if (o.value === "__reject") {
329
+ api.ui.dialog.clear()
330
+ reqs.rejectQuestion(p.id).then((ok) => ok && api.ui.toast({ variant: "warning", message: `Rejected question from ${agent.label}` }))
331
+ return
332
+ }
333
+ if (o.value === "__custom") {
334
+ api.ui.dialog.replace(() => (
335
+ <api.ui.DialogPrompt
336
+ title={clip(q.question, 70)}
337
+ placeholder="your answer"
338
+ onConfirm={(v) => next([v])}
339
+ onCancel={() => api.ui.dialog.clear()}
340
+ />
341
+ ))
342
+ return
343
+ }
344
+ next([o.value.slice("opt:".length)])
345
+ }}
346
+ />
347
+ ))
348
+ }
349
+
350
+ /** ⏎ in the agent view: answer the oldest request this agent is blocked on */
351
+ const respond = (agent: AgentState | undefined) => {
352
+ if (!agent) return
353
+ const p = reqs.forAgent(agent)[0]
354
+ if (!p) {
355
+ api.ui.toast({ variant: "info", message: `${agent.label} is not waiting for anything` })
356
+ return
357
+ }
358
+ if (p.kind === "permission") permissionDialog(agent, p)
359
+ else questionDialog(agent, p)
360
+ }
361
+
362
+ // a request that arrives while we are on screen: say so, point at the key
363
+ reqs.onNew((p) => {
364
+ if (!isOurRoute(api.route.current.name)) return
365
+ const owner = ownerOf(p)
366
+ api.ui.toast({
367
+ variant: "warning",
368
+ title: owner ? `${owner.agent.label} needs permission` : "Permission needed in chat",
369
+ message: owner ? `${clip(describeRequest(p), 70)} — press ! to answer` : `${clip(describeRequest(p), 70)} — esc returns to the session`,
370
+ duration: 8000,
371
+ })
372
+ })
373
+
374
+ // --- keymap -------------------------------------------------------------------
375
+
376
+ // while one of our dialogs is up, the view keys underneath must stay quiet
377
+ const guarded = <T extends { run: () => unknown }>(cmds: T[]): T[] =>
378
+ cmds.map((c) => ({
379
+ ...c,
380
+ run: () => {
381
+ if (api.ui.dialog.open) return
382
+ return c.run()
383
+ },
384
+ }))
385
+
386
+ api.keymap.registerLayer({
387
+ commands: [
388
+ {
389
+ name: "workflows.open",
390
+ title: "Workflows",
391
+ category: "Plugin",
392
+ namespace: "palette",
393
+ slashName: "workflows",
394
+ desc: "Open workflow runs",
395
+ run: () => openWorkflows(),
396
+ },
397
+ ...guarded([
398
+ // requests
399
+ { name: "wf.waiting", run: () => gotoWaiting() },
400
+ { name: "wf.agent.respond", run: () => respond(activeRun()?.agents[agentOpenId()]) },
401
+ // list
402
+ { name: "wf.list.up", run: () => void setListSel((v) => Math.max(0, v - 1)) },
403
+ { name: "wf.list.down", run: () => void setListSel((v) => Math.min(Math.max(0, runs().length - 1), v + 1)) },
404
+ { name: "wf.list.top", run: () => void setListSel(0) },
405
+ { name: "wf.list.bottom", run: () => void setListSel(Math.max(0, runs().length - 1)) },
406
+ { name: "wf.list.open", run: () => selectedRun() && openRun(selectedRun()!) },
407
+ { name: "wf.list.stop", run: () => doStop(selectedRun()) },
408
+ { name: "wf.list.pause", run: () => doPause(selectedRun()) },
409
+ { name: "wf.list.save", run: () => doSave(selectedRun()) },
410
+ { name: "wf.list.delete", run: () => doDelete(selectedRun()) },
411
+ { name: "wf.list.result", run: () => openResult(selectedRun()) },
412
+ { name: "wf.list.back", run: () => goBack() },
413
+ // run
414
+ { name: "wf.run.up", run: () => (pane() === "phases" ? movePhase(-1) : moveAgentInPhase(-1)) },
415
+ { name: "wf.run.down", run: () => (pane() === "phases" ? movePhase(1) : moveAgentInPhase(1)) },
416
+ { name: "wf.run.left", run: () => void setPane("phases") },
417
+ { name: "wf.run.right", run: () => focusAgents() },
418
+ { name: "wf.run.toggle", run: () => void (pane() === "phases" ? focusAgents() : setPane("phases")) },
419
+ {
420
+ name: "wf.run.open",
421
+ run: () => {
422
+ if (pane() === "phases") return focusAgents()
423
+ const aid = store.selAgent()
424
+ if (activeRun() && aid) openAgent(aid)
425
+ },
426
+ },
427
+ { name: "wf.run.stop", run: () => doStop(activeRun()) },
428
+ { name: "wf.run.pause", run: () => doPause(activeRun()) },
429
+ { name: "wf.run.save", run: () => doSave(activeRun()) },
430
+ { name: "wf.run.result", run: () => openResult(activeRun()) },
431
+ { name: "wf.run.back", run: () => goBack() },
432
+ // agent
433
+ { name: "wf.agent.prev", run: () => stepAgent(-1) },
434
+ { name: "wf.agent.next", run: () => stepAgent(1) },
435
+ { name: "wf.agent.scrollUp", run: () => scrollBy(agentScroll, -2) },
436
+ { name: "wf.agent.scrollDown", run: () => scrollBy(agentScroll, 2) },
437
+ { name: "wf.agent.pageUp", run: () => scrollBy(agentScroll, -pageOf(agentScroll)) },
438
+ { name: "wf.agent.pageDown", run: () => scrollBy(agentScroll, pageOf(agentScroll)) },
439
+ { name: "wf.agent.expand", run: () => store.toggleExpand() },
440
+ { name: "wf.agent.prompt", run: () => store.toggleFullPrompt() },
441
+ { name: "wf.agent.back", run: () => goBack() },
442
+ // result
443
+ { name: "wf.result.scrollUp", run: () => scrollBy(resultScroll, -2) },
444
+ { name: "wf.result.scrollDown", run: () => scrollBy(resultScroll, 2) },
445
+ { name: "wf.result.pageUp", run: () => scrollBy(resultScroll, -pageOf(resultScroll)) },
446
+ { name: "wf.result.pageDown", run: () => scrollBy(resultScroll, pageOf(resultScroll)) },
447
+ { name: "wf.result.top", run: () => resultScroll?.scrollTo(0) },
448
+ { name: "wf.result.back", run: () => goBack() },
449
+ ]),
450
+ ],
451
+ bindings: [{ key: "ctrl+shift+w", cmd: "workflows.open", desc: "Open workflows" }],
452
+ })
453
+
454
+ api.keymap.registerLayer({
455
+ mode: "wf.list",
456
+ bindings: [
457
+ { key: "up", cmd: "wf.list.up" },
458
+ { key: "down", cmd: "wf.list.down" },
459
+ { key: "k", cmd: "wf.list.up" },
460
+ { key: "j", cmd: "wf.list.down" },
461
+ { key: "g", cmd: "wf.list.top" },
462
+ { key: "shift+g", cmd: "wf.list.bottom" },
463
+ { key: "enter", cmd: "wf.list.open" },
464
+ { key: "right", cmd: "wf.list.open" },
465
+ { key: "l", cmd: "wf.list.open" },
466
+ { key: "x", cmd: "wf.list.stop" },
467
+ { key: "p", cmd: "wf.list.pause" },
468
+ { key: "s", cmd: "wf.list.save" },
469
+ { key: "d", cmd: "wf.list.delete" },
470
+ { key: "r", cmd: "wf.list.result" },
471
+ { key: "!", cmd: "wf.waiting" },
472
+ { key: "escape", cmd: "wf.list.back" },
473
+ ],
474
+ })
475
+ api.keymap.registerLayer({
476
+ mode: "wf.run",
477
+ bindings: [
478
+ { key: "up", cmd: "wf.run.up" },
479
+ { key: "down", cmd: "wf.run.down" },
480
+ { key: "k", cmd: "wf.run.up" },
481
+ { key: "j", cmd: "wf.run.down" },
482
+ { key: "left", cmd: "wf.run.left" },
483
+ { key: "right", cmd: "wf.run.right" },
484
+ { key: "h", cmd: "wf.run.left" },
485
+ { key: "l", cmd: "wf.run.right" },
486
+ { key: "tab", cmd: "wf.run.toggle" },
487
+ { key: "enter", cmd: "wf.run.open" },
488
+ { key: "x", cmd: "wf.run.stop" },
489
+ { key: "p", cmd: "wf.run.pause" },
490
+ { key: "s", cmd: "wf.run.save" },
491
+ { key: "r", cmd: "wf.run.result" },
492
+ { key: "!", cmd: "wf.waiting" },
493
+ { key: "escape", cmd: "wf.run.back" },
494
+ ],
495
+ })
496
+ api.keymap.registerLayer({
497
+ mode: "wf.agent",
498
+ bindings: [
499
+ { key: "up", cmd: "wf.agent.scrollUp" },
500
+ { key: "down", cmd: "wf.agent.scrollDown" },
501
+ { key: "k", cmd: "wf.agent.scrollUp" },
502
+ { key: "j", cmd: "wf.agent.scrollDown" },
503
+ { key: "pageup", cmd: "wf.agent.pageUp" },
504
+ { key: "pagedown", cmd: "wf.agent.pageDown" },
505
+ { key: "left", cmd: "wf.agent.prev" },
506
+ { key: "right", cmd: "wf.agent.next" },
507
+ { key: "h", cmd: "wf.agent.prev" },
508
+ { key: "l", cmd: "wf.agent.next" },
509
+ { key: "e", cmd: "wf.agent.expand" },
510
+ { key: "p", cmd: "wf.agent.prompt" },
511
+ { key: "enter", cmd: "wf.agent.respond" },
512
+ { key: "!", cmd: "wf.waiting" },
513
+ { key: "escape", cmd: "wf.agent.back" },
514
+ ],
515
+ })
516
+ api.keymap.registerLayer({
517
+ mode: "wf.result",
518
+ bindings: [
519
+ { key: "up", cmd: "wf.result.scrollUp" },
520
+ { key: "down", cmd: "wf.result.scrollDown" },
521
+ { key: "k", cmd: "wf.result.scrollUp" },
522
+ { key: "j", cmd: "wf.result.scrollDown" },
523
+ { key: "pageup", cmd: "wf.result.pageUp" },
524
+ { key: "pagedown", cmd: "wf.result.pageDown" },
525
+ { key: "g", cmd: "wf.result.top" },
526
+ { key: "escape", cmd: "wf.result.back" },
527
+ ],
528
+ })
529
+
530
+ // --- routes ----------------------------------------------------------------
531
+
532
+ api.route.register([
533
+ {
534
+ name: "workflows",
535
+ render: () => {
536
+ onCleanup(api.mode.push("wf.list"))
537
+ return (<ListScreen api={api} store={store} reqs={reqs} sel={listSel} />) as any
538
+ },
539
+ },
540
+ {
541
+ name: "workflow",
542
+ render: () => {
543
+ onCleanup(api.mode.push("wf.run"))
544
+ return (<RunScreen api={api} store={store} reqs={reqs} pane={pane} />) as any
545
+ },
546
+ },
547
+ {
548
+ name: "workflow-agent",
549
+ render: () => {
550
+ onCleanup(api.mode.push("wf.agent"))
551
+ onCleanup(() => (agentScroll = undefined))
552
+ return (
553
+ <AgentScreen api={api} store={store} reqs={reqs} agentId={agentOpenId} scrollRef={(el) => (agentScroll = el)} />
554
+ ) as any
555
+ },
556
+ },
557
+ {
558
+ name: "workflow-result",
559
+ render: () => {
560
+ onCleanup(api.mode.push("wf.result"))
561
+ onCleanup(() => (resultScroll = undefined))
562
+ return (<ResultScreen api={api} store={store} reqs={reqs} scrollRef={(el) => (resultScroll = el)} />) as any
563
+ },
564
+ },
565
+ ])
566
+
567
+ // --- attention on terminal transition ------------------------------------------
568
+
569
+ createEffect(() => {
570
+ for (const r of runs()) {
571
+ const terminal = r.status === "completed" || r.status === "failed" || r.status === "stopped"
572
+ if (!terminal || !r.endedAt) continue
573
+ if (Date.now() - r.endedAt > 10 * 60_000) continue // don't notify for old runs
574
+ if (store.wasNotified(r.runId)) continue
575
+ store.markNotified(r.runId)
576
+ try {
577
+ api.attention.notify({
578
+ title: `Workflow ${r.status}`,
579
+ message: `${r.name} — ${r.agentDone}/${r.agentCount} agents, ${fmtCtx(r.totalContextTokens)} context, ${fmtTokens(r.totalTokens)} billed, ${fmtCost(r.totalCost)}`,
580
+ sound: { name: r.status === "failed" ? "error" : "done" },
581
+ })
582
+ } catch {}
583
+ }
584
+ })
585
+ },
586
+ }
587
+
588
+ // =============================================================================
589
+ // shared bits
590
+ // =============================================================================
591
+
592
+ interface ScreenProps {
593
+ api: TuiPluginApi
594
+ store: WorkflowStore
595
+ reqs: RequestStore
596
+ }
597
+
598
+ /** agent status as the UI presents it — a running agent blocked on a request is "waiting" */
599
+ function agentShownStatus(reqs: RequestStore, a: AgentState | undefined): string | undefined {
600
+ if (!a) return undefined
601
+ return reqs.forAgent(a).length ? "waiting" : a.status
602
+ }
603
+
604
+ /** ⚠ N agents need permission */
605
+ function waitingLabel(n: number): string {
606
+ return `⚠ ${n} agent${n === 1 ? "" : "s"} need${n === 1 ? "s" : ""} permission`
607
+ }
608
+
609
+ /**
610
+ * One step dimmer than textMuted: blend it ~45% toward the background. Used for
611
+ * secondary numbers (billed tokens) that should be readable but not compete
612
+ * with the headline value. Falls back to textMuted for non-RGB theme colors.
613
+ */
614
+ function faintColor(c: TuiThemeCurrent): string | RGBA {
615
+ const m = c.textMuted
616
+ const bg = c.background
617
+ try {
618
+ if (!m || !bg || m.intent !== "rgb" || bg.intent !== "rgb") return m
619
+ const [mr, mg, mb] = m.toInts()
620
+ const [br, bgr, bb] = bg.toInts()
621
+ const mix = (a: number, b: number) => Math.round(a * 0.55 + b * 0.45)
622
+ const hex = (v: number) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")
623
+ return `#${hex(mix(mr, br))}${hex(mix(mg, bgr))}${hex(mix(mb, bb))}`
624
+ } catch {
625
+ return m
626
+ }
627
+ }
628
+
629
+ /** context size for headline use; "—" for state files written before it was tracked */
630
+ function fmtCtx(n: number | undefined): string {
631
+ return n == null ? "— ctx" : fmtTokens(n)
632
+ }
633
+ function fmtCtxCell(n: number | undefined): string {
634
+ return n == null ? "—" : fmtTok(n)
635
+ }
636
+
637
+ function useTheme(api: TuiPluginApi) {
638
+ const t = () => api.theme.current
639
+ const faint = () => faintColor(api.theme.current)
640
+ const tone = (which: Tone) => {
641
+ const c = t()
642
+ switch (which) {
643
+ case "success":
644
+ return c.success
645
+ case "error":
646
+ return c.error
647
+ case "warning":
648
+ return c.warning
649
+ case "info":
650
+ return c.info
651
+ case "accent":
652
+ return c.accent
653
+ case "primary":
654
+ return c.primary
655
+ case "text":
656
+ return c.text
657
+ default:
658
+ return c.textMuted
659
+ }
660
+ }
661
+ return { t, tone, faint }
662
+ }
663
+
664
+ function statusTone(status: string | undefined): Tone {
665
+ switch (status) {
666
+ case "completed":
667
+ return "success"
668
+ case "failed":
669
+ return "error"
670
+ case "stopped":
671
+ case "cancelled":
672
+ case "paused":
673
+ case "stale":
674
+ case "waiting":
675
+ return "warning"
676
+ case "running":
677
+ return "accent"
678
+ default:
679
+ return "muted"
680
+ }
681
+ }
682
+
683
+ function statusGlyph(status: string | undefined, spinner: string): string {
684
+ switch (status) {
685
+ case "completed":
686
+ return "✓"
687
+ case "failed":
688
+ return "✗"
689
+ case "stopped":
690
+ case "cancelled":
691
+ return "■"
692
+ case "paused":
693
+ return "‖"
694
+ case "stale":
695
+ return "?"
696
+ case "waiting":
697
+ return "⚠"
698
+ case "running":
699
+ return spinner
700
+ default:
701
+ return "○"
702
+ }
703
+ }
704
+
705
+ function activityTitle(a: AgentState): string {
706
+ const thoughts = a.activity.filter((x) => x.kind === "think").length
707
+ const tools = a.activity.length - thoughts
708
+ if (!thoughts) return `Activity · ${tools}`
709
+ return `Activity · ${tools} tool${tools === 1 ? "" : "s"} · ${thoughts} thought${thoughts === 1 ? "" : "s"}`
710
+ }
711
+
712
+ function statusLabel(status: string | undefined): string {
713
+ return String(status ?? "pending")
714
+ }
715
+
716
+ /** what `p` does for this run */
717
+ function pauseLabel(store: WorkflowStore, run: RunState | undefined): string {
718
+ if (!run) return "pause"
719
+ if (store.isStale(run) || run.status === "paused" || run.status === "stopped" || run.status === "failed") return "resume"
720
+ return "pause"
721
+ }
722
+
723
+ /** status as the UI should present it — a live run with a dead engine is "stale" */
724
+ function shownStatus(store: WorkflowStore, run: RunState): string {
725
+ return store.isStale(run) ? "stale" : run.status
726
+ }
727
+
728
+ /** `key label · key label …` footer */
729
+ function Hints(props: { api: TuiPluginApi; items: Array<[string, string]> }) {
730
+ const { t } = useTheme(props.api)
731
+ return (
732
+ <box flexDirection="row" style={{ paddingLeft: 2, paddingRight: 1, height: 1 }}>
733
+ <For each={props.items}>
734
+ {([key, label], i) => (
735
+ <box flexDirection="row">
736
+ <text style={{ fg: t().accent }}>{key}</text>
737
+ <text style={{ fg: t().textMuted }}>{` ${label}${i() < props.items.length - 1 ? " " : ""}`}</text>
738
+ </box>
739
+ )}
740
+ </For>
741
+ </box>
742
+ )
743
+ }
744
+
745
+ /** fixed-width progress bar */
746
+ function Bar(props: { api: TuiPluginApi; done: () => number; total: () => number; width: number; tone?: () => Tone }) {
747
+ const { t, tone } = useTheme(props.api)
748
+ const filled = () => {
749
+ const total = props.total()
750
+ if (total <= 0) return 0
751
+ return Math.max(0, Math.min(props.width, Math.round((props.done() / total) * props.width)))
752
+ }
753
+ return (
754
+ <box flexDirection="row" style={{ width: props.width, height: 1 }}>
755
+ <text style={{ fg: tone(props.tone?.() ?? "accent") }}>{"█".repeat(filled())}</text>
756
+ <text style={{ fg: t().borderSubtle ?? t().textMuted }}>{"░".repeat(props.width - filled())}</text>
757
+ </box>
758
+ )
759
+ }
760
+
761
+ /** status glyph — the spinner keeps turning while running */
762
+ function Glyph(props: { api: TuiPluginApi; store: WorkflowStore; status: () => string | undefined; width?: number }) {
763
+ const { tone } = useTheme(props.api)
764
+ return (
765
+ <text style={{ fg: tone(statusTone(props.status())), width: props.width ?? 2 }}>
766
+ {statusGlyph(props.status(), props.store.spinner())}
767
+ </text>
768
+ )
769
+ }
770
+
771
+ /** section heading inside a detail body */
772
+ function SectionTitle(props: { api: TuiPluginApi; title: string; hint?: () => string }) {
773
+ const { t } = useTheme(props.api)
774
+ return (
775
+ <box flexDirection="row" style={{ paddingTop: 1 }}>
776
+ <text style={{ fg: t().primary, attributes: BOLD }}>{props.title}</text>
777
+ <Show when={props.hint?.()}>
778
+ <text style={{ fg: t().textMuted }}>{` ${props.hint!()}`}</text>
779
+ </Show>
780
+ </box>
781
+ )
782
+ }
783
+
784
+ /** word-wrapped text block, capped at maxLines with a "… N more lines" tail */
785
+ function TextBlock(props: { api: TuiPluginApi; text: () => string; width: () => number; maxLines?: number; fg?: () => any; dim?: boolean }) {
786
+ const { t } = useTheme(props.api)
787
+ const lines = createMemo(() => wrapWords(props.text(), props.width()))
788
+ const shown = () => (props.maxLines ? lines().slice(0, props.maxLines) : lines())
789
+ const hidden = () => lines().length - shown().length
790
+ return (
791
+ <box flexDirection="column">
792
+ <For each={shown()}>{(line) => <text style={{ fg: props.fg?.() ?? t().text, attributes: props.dim ? DIM : 0 }}>{line || " "}</text>}</For>
793
+ <Show when={hidden() > 0}>
794
+ <text style={{ fg: t().textMuted }}>{`… ${hidden()} more line${hidden() === 1 ? "" : "s"}`}</text>
795
+ </Show>
796
+ </box>
797
+ )
798
+ }
799
+
800
+ function keepInView(sb: ScrollBoxRenderable | undefined, index: number, rowHeight = 1) {
801
+ if (!sb || index < 0) return
802
+ try {
803
+ const h = sb.viewport?.height || sb.height || 0
804
+ if (h <= 0) return
805
+ const top = sb.scrollTop
806
+ const y = index * rowHeight
807
+ if (y < top) sb.scrollTo({ x: 0, y })
808
+ else if (y + rowHeight > top + h) sb.scrollTo({ x: 0, y: y + rowHeight - h })
809
+ } catch {}
810
+ }
811
+
812
+ function projectName(api: TuiPluginApi): string {
813
+ const p = api.state.path.worktree || api.state.path.directory || ""
814
+ const parts = p.split("/").filter(Boolean)
815
+ return parts.slice(-2).join("/") || p
816
+ }
817
+
818
+ function lastLog(run: RunState): { at: number; message: string } | undefined {
819
+ return run.logs[run.logs.length - 1]
820
+ }
821
+
822
+ function modelsOf(run: RunState): string {
823
+ const seen = new Set<string>()
824
+ for (const id of run.agentOrder) {
825
+ const m = run.agents[id]?.model
826
+ if (m) seen.add(shortModel(m))
827
+ }
828
+ return [...seen].join(", ")
829
+ }
830
+
831
+ // =============================================================================
832
+ // list screen
833
+ // =============================================================================
834
+
835
+ function ListScreen(props: ScreenProps & { sel: () => number }) {
836
+ const { api, store, reqs } = props
837
+ const { t, tone, faint } = useTheme(api)
838
+ const runs = store.runs
839
+ const live = () => runs().filter((r) => isLive(r.status) && !store.isStale(r)).length
840
+ const waiting = () => runs().reduce((n, r) => n + reqs.waitingAgents(r).length, 0)
841
+ const waitingElsewhere = () => reqs.unattributed(runs()).length
842
+ /** run status with a blocked agent surfaced as "waiting" */
843
+ const rowStatus = (run: RunState) => (run.status === "running" && !store.isStale(run) && reqs.waitingAgents(run).length ? "waiting" : shownStatus(store, run))
844
+ const wide = () => store.size().width >= 124
845
+ // CONTEXT = live prompt size (headline); BILLED = cumulative tokens sent, faint
846
+ const W = { icon: 2, status: 11, bar: 10, count: 8, tok: 9, billed: 9, cost: 9, time: 9, ago: 11 }
847
+ const fixed = () => W.icon + W.status + W.bar + W.count + W.tok + W.time + (wide() ? W.billed + W.cost + W.ago : 0) + 4
848
+ const nameW = () => Math.max(14, store.size().width - fixed() - 4)
849
+ let sb: ScrollBoxRenderable | undefined
850
+ createEffect(on(props.sel, (i) => keepInView(sb, i)))
851
+
852
+ const selected = () => runs()[props.sel()]
853
+ // the host does not constrain a route's height, so size the table from the
854
+ // terminal: paddingTop(1) + title(1) + margin(1) + [table] + margin(1) + details(6) + hints(2) + host status line(1)
855
+ const tableH = () => Math.max(5, store.size().height - 13)
856
+
857
+ return (
858
+ <box flexDirection="column" style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
859
+ {/* title bar */}
860
+ <box flexDirection="row" style={{ paddingLeft: 1, paddingRight: 1, height: 1 }}>
861
+ <text style={{ fg: t().primary, attributes: BOLD }}>Workflows</text>
862
+ <text style={{ fg: t().textMuted }}>{` ${clip(projectName(api), 40)}`}</text>
863
+ <text style={{ flexGrow: 1 }} />
864
+ <Show when={waiting() > 0}>
865
+ <text style={{ fg: t().warning, attributes: BOLD }}>{`${waitingLabel(waiting())} — ! answers `}</text>
866
+ </Show>
867
+ <Show when={waiting() === 0 && waitingElsewhere() > 0}>
868
+ <text style={{ fg: t().warning }}>{`⚠ ${waitingElsewhere()} permission${waitingElsewhere() === 1 ? "" : "s"} pending in chat — esc `}</text>
869
+ </Show>
870
+ <Show when={live() > 0}>
871
+ <text style={{ fg: t().accent }}>{`${store.spinner()} ${live()} running `}</text>
872
+ </Show>
873
+ <Show when={runs().some((r) => store.isStale(r))}>
874
+ <text style={{ fg: t().warning }}>{`? ${runs().filter((r) => store.isStale(r)).length} stale (engine gone — d deletes) `}</text>
875
+ </Show>
876
+ <text style={{ fg: t().textMuted }}>{`${runs().length} run${runs().length === 1 ? "" : "s"}`}</text>
877
+ </box>
878
+
879
+ {/* table */}
880
+ <box flexDirection="column" style={{ height: tableH(), border: true, borderStyle: "rounded", borderColor: t().borderActive, marginTop: 1, overflow: "hidden" }}>
881
+ <box flexDirection="row" style={{ paddingLeft: 1, paddingRight: 1, height: 1 }}>
882
+ <text style={{ fg: t().textMuted, width: W.icon }} />
883
+ <text style={{ fg: t().textMuted, width: nameW() }}>NAME</text>
884
+ <text style={{ fg: t().textMuted, width: W.status }}>STATUS</text>
885
+ <text style={{ fg: t().textMuted, width: W.bar + W.count }}>AGENTS</text>
886
+ <text style={{ fg: t().textMuted, width: W.tok }}>{cellR("CONTEXT", W.tok)}</text>
887
+ <Show when={wide()}>
888
+ <text style={{ fg: faint(), width: W.billed }}>{cellR("BILLED", W.billed)}</text>
889
+ <text style={{ fg: t().textMuted, width: W.cost }}>{cellR("COST", W.cost)}</text>
890
+ </Show>
891
+ <text style={{ fg: t().textMuted, width: W.time }}>{cellR("TIME", W.time)}</text>
892
+ <Show when={wide()}>
893
+ <text style={{ fg: t().textMuted, width: W.ago }}>{cellR("STARTED", W.ago)}</text>
894
+ </Show>
895
+ </box>
896
+
897
+ <Show when={runs().length === 0}>
898
+ <box flexDirection="column" style={{ paddingLeft: 2, paddingTop: 1 }}>
899
+ <text style={{ fg: t().text }}>No workflow runs yet.</text>
900
+ <text style={{ fg: t().textMuted }}>Ask the assistant to "run a workflow" (or say "ultracode") on a large task.</text>
901
+ <text style={{ fg: t().textMuted }}>Runs appear here live: phases, agents, tokens, and the final result.</text>
902
+ </box>
903
+ </Show>
904
+
905
+ <scrollbox ref={(el) => (sb = el)} style={{ flexGrow: 1 }} scrollY={true} scrollX={false}>
906
+ <For each={runs()}>
907
+ {(run, idx) => {
908
+ const isSel = () => idx() === props.sel()
909
+ const dim = () => (isSel() ? t().text : t().textMuted)
910
+ const elapsed = () => (run.endedAt ? run.endedAt - run.startedAt : store.now() - run.startedAt)
911
+ return (
912
+ <box
913
+ flexDirection="row"
914
+ style={{ paddingLeft: 1, paddingRight: 1, height: 1, backgroundColor: isSel() ? t().backgroundElement : "transparent" }}
915
+ >
916
+ <Glyph api={api} store={store} status={() => rowStatus(run)} width={W.icon} />
917
+ <text style={{ fg: isSel() ? t().accent : t().text, width: nameW(), attributes: isSel() ? BOLD : 0 }}>
918
+ {cell(run.name, nameW() - 1)}
919
+ </text>
920
+ <text style={{ fg: tone(statusTone(rowStatus(run))), width: W.status, attributes: rowStatus(run) === "waiting" ? BOLD : 0 }}>{cell(statusLabel(rowStatus(run)), W.status)}</text>
921
+ <Bar api={api} done={() => run.agentDone} total={() => run.agentCount} width={W.bar - 1} tone={() => (run.status === "failed" ? "error" : run.status === "completed" ? "success" : "accent")} />
922
+ <text style={{ fg: dim(), width: W.count + 1 }}>{` ${run.agentDone}/${run.agentCount}`}</text>
923
+ <text style={{ fg: dim(), width: W.tok }}>{cellR(fmtCtxCell(run.totalContextTokens), W.tok)}</text>
924
+ <Show when={wide()}>
925
+ <text style={{ fg: faint(), width: W.billed }}>{cellR(fmtTok(run.totalTokens), W.billed)}</text>
926
+ <text style={{ fg: dim(), width: W.cost }}>{cellR(fmtCost(run.totalCost), W.cost)}</text>
927
+ </Show>
928
+ <text style={{ fg: dim(), width: W.time }}>{cellR(fmtDuration(elapsed()), W.time)}</text>
929
+ <Show when={wide()}>
930
+ <text style={{ fg: dim(), width: W.ago }}>{cellR(fmtAgo(run.startedAt, store.now()), W.ago)}</text>
931
+ </Show>
932
+ </box>
933
+ )
934
+ }}
935
+ </For>
936
+ </scrollbox>
937
+ </box>
938
+
939
+ {/* details of the selected run */}
940
+ <Show when={selected()} keyed>
941
+ {(run: RunState) => (
942
+ <box
943
+ flexDirection="column"
944
+ style={{ border: true, borderStyle: "rounded", borderColor: t().border, paddingLeft: 1, paddingRight: 1, marginTop: 1, height: 6 }}
945
+ title={` ${clip(run.name, 40)} `}
946
+ titleColor={t().textMuted}
947
+ >
948
+ <text style={{ fg: t().text }}>{clip(oneLine(run.description) || "—", store.size().width - 8)}</text>
949
+ <box flexDirection="row">
950
+ <text style={{ fg: t().textMuted }}>phases </text>
951
+ <text style={{ fg: t().text }}>{clip(run.phases.map((p: PhaseState) => `${p.title} ${p.done}/${p.agentIds.length}`).join(" ▸ ") || "—", store.size().width - 40)}</text>
952
+ <text style={{ flexGrow: 1 }} />
953
+ <text style={{ fg: t().textMuted }}>{clip(modelsOf(run), 28)}</text>
954
+ </box>
955
+ <box flexDirection="row">
956
+ <text style={{ fg: t().textMuted }}>started </text>
957
+ <text style={{ fg: t().text }}>{fmtClock(run.startedAt)}</text>
958
+ <text style={{ fg: t().textMuted }}>{run.endedAt ? " ended " : ""}</text>
959
+ <text style={{ fg: t().text }}>{run.endedAt ? fmtClock(run.endedAt) : ""}</text>
960
+ <text style={{ fg: t().textMuted }}> context </text>
961
+ <text style={{ fg: t().text }}>{fmtCtx(run.totalContextTokens)}</text>
962
+ <text style={{ fg: faint() }}>{` billed ${fmtTokens(run.totalTokens)}`}</text>
963
+ <text style={{ fg: t().textMuted }}> cost </text>
964
+ <text style={{ fg: t().text }}>{fmtCost(run.totalCost)}</text>
965
+ </box>
966
+ <Show
967
+ when={run.error}
968
+ fallback={
969
+ <box flexDirection="row">
970
+ <text style={{ fg: t().textMuted }}>{run.result ? "result " : "log "}</text>
971
+ <text style={{ fg: run.result ? t().success : t().textMuted }}>
972
+ {clip(run.result ? `${oneLine(run.result)}` : lastLog(run) ? `${fmtClock(lastLog(run)!.at)} ${oneLine(lastLog(run)!.message)}` : "—", store.size().width - 16)}
973
+ </text>
974
+ </box>
975
+ }
976
+ >
977
+ <box flexDirection="row">
978
+ <text style={{ fg: t().textMuted }}>error </text>
979
+ <text style={{ fg: t().error }}>{clip(oneLine(run.error), store.size().width - 16)}</text>
980
+ </box>
981
+ </Show>
982
+ </box>
983
+ )}
984
+ </Show>
985
+
986
+ <box style={{ paddingTop: 1 }}>
987
+ <Hints
988
+ api={api}
989
+ items={[
990
+ ["↑↓", "select"],
991
+ ["⏎", "open"],
992
+ ["r", "result"],
993
+ ["x", "stop"],
994
+ ["p", pauseLabel(store, selected())],
995
+ ["s", "save script"],
996
+ ["d", "delete"],
997
+ ...(waiting() > 0 ? ([["!", "answer permission"]] as Array<[string, string]>) : []),
998
+ ["esc", "back"],
999
+ ]}
1000
+ />
1001
+ </box>
1002
+ </box>
1003
+ )
1004
+ }
1005
+
1006
+ // =============================================================================
1007
+ // run screen
1008
+ // =============================================================================
1009
+
1010
+ function RunScreen(props: ScreenProps & { pane: () => Pane }) {
1011
+ const { api, store } = props
1012
+ const { t } = useTheme(api)
1013
+ return (
1014
+ <box flexDirection="column" style={{ flexGrow: 1 }}>
1015
+ <Show
1016
+ when={store.activeRun()}
1017
+ keyed
1018
+ fallback={
1019
+ <box style={{ paddingLeft: 2, paddingTop: 1 }}>
1020
+ <text style={{ fg: t().textMuted }}>no run selected — press esc to go back</text>
1021
+ </box>
1022
+ }
1023
+ >
1024
+ {(run: RunState) => <RunView api={api} store={store} reqs={props.reqs} run={run} pane={props.pane} />}
1025
+ </Show>
1026
+ </box>
1027
+ )
1028
+ }
1029
+
1030
+ function RunView(props: ScreenProps & { run: RunState; pane: () => Pane }) {
1031
+ const { api, store, reqs, run } = props
1032
+ const { t, tone, faint } = useTheme(api)
1033
+ const width = () => store.size().width
1034
+ const narrow = () => width() < 110
1035
+ const phase = (): PhaseState | undefined => run.phases[store.selPhase()] ?? run.phases[0]
1036
+ const elapsed = () => (run.endedAt ? run.endedAt - run.startedAt : store.now() - run.startedAt)
1037
+ const live = () => isLive(run.status)
1038
+ const phasesW = () => (narrow() ? 26 : 32)
1039
+ const waiting = () => reqs.waitingAgents(run)
1040
+ const waitingIn = (p: PhaseState) => p.agentIds.some((id) => reqs.forAgent(run.agents[id]).length > 0)
1041
+
1042
+ let phaseScroll: ScrollBoxRenderable | undefined
1043
+ let agentScroll: ScrollBoxRenderable | undefined
1044
+ createEffect(on(store.selPhase, (i) => keepInView(phaseScroll, i)))
1045
+ createEffect(() => {
1046
+ const ids = phase()?.agentIds ?? []
1047
+ const sel = store.selAgent()
1048
+ keepInView(agentScroll, sel ? ids.indexOf(sel) : 0)
1049
+ })
1050
+
1051
+ // agent whose latest activity is shown under the table: the selected one,
1052
+ // otherwise the most recently active running agent of the phase
1053
+ const liveAgent = createMemo((): AgentState | undefined => {
1054
+ const ids = phase()?.agentIds ?? []
1055
+ const sel = store.selAgent()
1056
+ if (sel && ids.includes(sel)) return run.agents[sel]
1057
+ let best: AgentState | undefined
1058
+ for (const id of ids) {
1059
+ const a = run.agents[id]
1060
+ if (!a || a.status !== "running") continue
1061
+ const at = a.liveFeed?.[a.liveFeed.length - 1]?.at ?? a.startedAt ?? 0
1062
+ const bestAt = best?.liveFeed?.[best.liveFeed.length - 1]?.at ?? best?.startedAt ?? 0
1063
+ if (!best || at > bestAt) best = a
1064
+ }
1065
+ return best
1066
+ })
1067
+ const liveLine = () => {
1068
+ const a = liveAgent()
1069
+ if (!a) return undefined
1070
+ const blocked = reqs.forAgent(a)[0]
1071
+ if (blocked) return { label: a.label, text: `waiting for permission — ${describeRequest(blocked)}`, at: blocked.at, kind: "tool" as const, status: "waiting" }
1072
+ const f = a.liveFeed?.[a.liveFeed.length - 1]
1073
+ if (f) return { label: a.label, text: f.text, at: f.at, kind: f.kind, status: a.status }
1074
+ if (a.status === "running") return { label: a.label, text: "waiting for first output…", at: a.startedAt ?? 0, kind: "text" as const, status: a.status }
1075
+ if (a.error) return { label: a.label, text: a.error, at: a.endedAt ?? 0, kind: "text" as const, status: a.status }
1076
+ if (a.outcomeText) return { label: a.label, text: oneLine(a.outcomeText), at: a.endedAt ?? 0, kind: "text" as const, status: a.status }
1077
+ return { label: a.label, text: statusLabel(a.status), at: a.endedAt ?? a.startedAt ?? 0, kind: "text" as const, status: a.status }
1078
+ }
1079
+ const age = (at: number) => (at ? fmtAgo(at, store.now()) : "")
1080
+
1081
+ // tok = CONTEXT (headline), billed = cumulative sent tokens (faint, wide only)
1082
+ const A = { icon: 2, model: 20, tok: 9, billed: 9, tools: 10, time: 9 }
1083
+ const labelW = () => Math.max(12, width() - phasesW() - 3 - 6 - A.icon - (narrow() ? 0 : A.model + A.billed) - A.tok - A.tools - A.time)
1084
+
1085
+ const bottomTitle = () => (run.error ? " Error " : run.result ? " Result " : " Log ")
1086
+ // paddingTop(1) + header(4) + margin(1) + [body] + margin(1) + bottom(4) + hints(2) + host status line(1)
1087
+ const bodyH = () => Math.max(6, store.size().height - 14)
1088
+ const logLines = () => run.logs.slice(-2)
1089
+
1090
+ return (
1091
+ <box flexDirection="column" style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
1092
+ {/* header */}
1093
+ <box
1094
+ flexDirection="column"
1095
+ style={{ border: true, borderStyle: "rounded", borderColor: t().border, paddingLeft: 1, paddingRight: 1, height: 4 }}
1096
+ title={` ${statusGlyph(shownStatus(store, run), store.spinner())} ${clip(run.name, 48)} `}
1097
+ titleColor={tone(statusTone(shownStatus(store, run)))}
1098
+ >
1099
+ <box flexDirection="row" style={{ height: 1 }}>
1100
+ <text style={{ fg: t().text }}>{clip(oneLine(run.description) || "—", Math.max(20, width() - 28))}</text>
1101
+ <text style={{ flexGrow: 1 }} />
1102
+ <text style={{ fg: tone(statusTone(shownStatus(store, run))), attributes: BOLD }}>{statusLabel(shownStatus(store, run)).toUpperCase()}</text>
1103
+ </box>
1104
+ <box flexDirection="row" style={{ height: 1 }}>
1105
+ <Bar api={api} done={() => run.agentDone} total={() => run.agentCount} width={narrow() ? 14 : 24} tone={() => (run.status === "failed" ? "error" : run.status === "completed" ? "success" : "accent")} />
1106
+ <text style={{ fg: t().text }}>{` ${run.agentDone}/${run.agentCount} agents`}</text>
1107
+ <text style={{ fg: t().textMuted }}>{` ${fmtDuration(elapsed())} ${fmtCtx(run.totalContextTokens)}`}</text>
1108
+ <text style={{ fg: faint() }}>{` billed ${fmtTokens(run.totalTokens)}`}</text>
1109
+ <text style={{ fg: t().textMuted }}>{` ${fmtCost(run.totalCost)}`}</text>
1110
+ <text style={{ flexGrow: 1 }} />
1111
+ <Show when={waiting().length > 0}>
1112
+ <text style={{ fg: t().warning, attributes: BOLD }}>{`${waitingLabel(waiting().length)} — ! answers `}</text>
1113
+ </Show>
1114
+ <Show when={store.pendingControl(run.runId) === "resume"}>
1115
+ <text style={{ fg: t().warning }}>resume requested — waiting for the engine…</text>
1116
+ </Show>
1117
+ <Show when={store.pendingControl(run.runId) !== "resume" && store.isStale(run)}>
1118
+ <text style={{ fg: t().warning }}>engine gone — p resumes</text>
1119
+ </Show>
1120
+ <Show when={store.pendingControl(run.runId) !== "resume" && !store.isStale(run) && (run.status === "paused" || run.status === "stopped")}>
1121
+ <text style={{ fg: t().warning }}>{`${run.status} — p resumes`}</text>
1122
+ </Show>
1123
+ <Show when={run.status !== "paused" && !store.isStale(run) && !narrow()}>
1124
+ <text style={{ fg: t().textMuted }}>{clip(modelsOf(run), 40)}</text>
1125
+ </Show>
1126
+ </box>
1127
+ </box>
1128
+
1129
+ {/* body: two panes */}
1130
+ <box flexDirection="row" style={{ height: bodyH(), marginTop: 1, gap: 1 }}>
1131
+ {/* left: phases */}
1132
+ <box
1133
+ flexDirection="column"
1134
+ style={{ width: phasesW(), border: true, borderStyle: "rounded", borderColor: props.pane() === "phases" ? t().borderActive : t().border, overflow: "hidden" }}
1135
+ title={` Phases ${run.phases.length ? `${run.phases.filter((p) => p.agentIds.length && p.done === p.agentIds.length).length}/${run.phases.length}` : ""} `}
1136
+ titleColor={props.pane() === "phases" ? t().accent : t().textMuted}
1137
+ >
1138
+ <scrollbox ref={(el) => (phaseScroll = el)} style={{ flexGrow: 1 }} scrollY={true} scrollX={false}>
1139
+ <Show when={run.phases.length === 0}>
1140
+ <box style={{ paddingLeft: 1 }}>
1141
+ <text style={{ fg: t().textMuted }}>{live() ? `${store.spinner()} starting…` : "no phases"}</text>
1142
+ </box>
1143
+ </Show>
1144
+ <For each={run.phases}>
1145
+ {(p, i) => {
1146
+ const isSel = () => store.selPhase() === i()
1147
+ const total = () => p.agentIds.length
1148
+ const running = () => p.agentIds.some((id) => run.agents[id]?.status === "running")
1149
+ const done = () => total() > 0 && p.done === total()
1150
+ const failed = () => p.agentIds.some((id) => run.agents[id]?.status === "failed")
1151
+ const blocked = () => waitingIn(p)
1152
+ const glyph = () => (blocked() ? "⚠" : running() ? store.spinner() : done() ? (failed() ? "✗" : "✓") : total() ? "◔" : "○")
1153
+ const gTone = (): Tone => (blocked() ? "warning" : running() ? "accent" : done() ? (failed() ? "error" : "success") : "muted")
1154
+ const titleW = () => phasesW() - 2 - 2 - 3 - 8 - 1
1155
+ return (
1156
+ <box flexDirection="row" style={{ paddingLeft: 1, paddingRight: 1, height: 1, backgroundColor: isSel() ? t().backgroundElement : "transparent" }}>
1157
+ <text style={{ fg: tone(gTone()), width: 2 }}>{glyph()}</text>
1158
+ <text style={{ fg: t().textMuted, width: 3 }}>{`${p.index}.`.padEnd(3)}</text>
1159
+ <text style={{ fg: isSel() ? t().accent : t().text, width: titleW(), attributes: isSel() ? BOLD : 0 }}>{cell(p.title, titleW() - 1)}</text>
1160
+ <text style={{ fg: done() ? t().success : t().textMuted, width: 8 }}>{cellR(`${p.done}/${total()}`, 8)}</text>
1161
+ </box>
1162
+ )
1163
+ }}
1164
+ </For>
1165
+ </scrollbox>
1166
+ </box>
1167
+
1168
+ {/* right: agents of the selected phase */}
1169
+ <box
1170
+ flexDirection="column"
1171
+ style={{ flexGrow: 1, border: true, borderStyle: "rounded", borderColor: props.pane() === "agents" ? t().borderActive : t().border, overflow: "hidden" }}
1172
+ title={` ${clip(phase()?.title ?? "Agents", 30)} · ${phase()?.agentIds.length ?? 0} agent${(phase()?.agentIds.length ?? 0) === 1 ? "" : "s"} `}
1173
+ titleColor={props.pane() === "agents" ? t().accent : t().textMuted}
1174
+ >
1175
+ <Show when={phase()?.detail}>
1176
+ <box style={{ paddingLeft: 1, height: 1 }}>
1177
+ <text style={{ fg: t().textMuted }}>{clip(oneLine(phase()!.detail), width() - phasesW() - 8)}</text>
1178
+ </box>
1179
+ </Show>
1180
+ <Show when={(phase()?.agentIds.length ?? 0) === 0}>
1181
+ <box style={{ paddingLeft: 1, paddingTop: 1 }}>
1182
+ <text style={{ fg: t().textMuted }}>{live() ? `${store.spinner()} waiting for agents in this phase…` : "no agents ran in this phase"}</text>
1183
+ </box>
1184
+ </Show>
1185
+ <scrollbox ref={(el) => (agentScroll = el)} style={{ flexGrow: 1 }} scrollY={true} scrollX={false}>
1186
+ <For each={phase()?.agentIds ?? []}>
1187
+ {(aid) => {
1188
+ const a = () => run.agents[aid]
1189
+ const isSel = () => props.pane() === "agents" && store.selAgent() === aid
1190
+ const dim = () => (isSel() ? t().text : t().textMuted)
1191
+ return (
1192
+ <Show when={a()}>
1193
+ <box flexDirection="row" style={{ paddingLeft: 1, paddingRight: 1, height: 1, backgroundColor: isSel() ? t().backgroundElement : "transparent" }}>
1194
+ <Glyph api={api} store={store} status={() => agentShownStatus(reqs, a())} width={A.icon} />
1195
+ <text style={{ fg: agentShownStatus(reqs, a()) === "waiting" ? t().warning : isSel() ? t().accent : t().text, width: labelW(), attributes: isSel() || agentShownStatus(reqs, a()) === "waiting" ? BOLD : 0 }}>{cell(a()!.label, labelW() - 1)}</text>
1196
+ <Show when={!narrow()}>
1197
+ <text style={{ fg: dim(), width: A.model }}>{cell(shortModel(a()!.model), A.model - 1)}</text>
1198
+ </Show>
1199
+ <text style={{ fg: dim(), width: A.tok }}>{cellR(fmtCtxCell(a()!.contextTokens), A.tok)}</text>
1200
+ <Show when={!narrow()}>
1201
+ <text style={{ fg: faint(), width: A.billed }}>{cellR(fmtTok(a()!.tokens), A.billed)}</text>
1202
+ </Show>
1203
+ <text style={{ fg: dim(), width: A.tools }}>{cellR(a()!.toolCalls ? `${a()!.toolCalls} tool${a()!.toolCalls === 1 ? "" : "s"}` : "", A.tools)}</text>
1204
+ <text style={{ fg: agentShownStatus(reqs, a()) === "waiting" ? t().warning : a()!.status === "running" ? t().accent : dim(), width: A.time }}>
1205
+ {cellR(agentShownStatus(reqs, a()) === "waiting" ? "waiting" : fmtElapsed(a()!.startedAt, a()!.endedAt, store.now()), A.time)}
1206
+ </text>
1207
+ </box>
1208
+ </Show>
1209
+ )
1210
+ }}
1211
+ </For>
1212
+ </scrollbox>
1213
+ {/* live strip */}
1214
+ <Show when={liveLine()} keyed={false}>
1215
+ <box flexDirection="column" style={{ paddingLeft: 1, paddingRight: 1, height: 2, marginTop: 0 }}>
1216
+ <text style={{ fg: t().borderSubtle ?? t().border }}>{"╴".repeat(Math.max(4, width() - phasesW() - 7))}</text>
1217
+ <box flexDirection="row" style={{ height: 1 }}>
1218
+ <Glyph api={api} store={store} status={() => liveLine()?.status} width={2} />
1219
+ <text style={{ fg: t().accent }}>{clip(liveLine()!.label, 22)}</text>
1220
+ <text style={{ fg: liveLine()!.kind === "tool" ? t().text : t().textMuted }}>{` ${clip(liveLine()!.text, Math.max(10, width() - phasesW() - 46))}`}</text>
1221
+ <text style={{ flexGrow: 1 }} />
1222
+ <text style={{ fg: t().textMuted }}>{age(liveLine()!.at)}</text>
1223
+ </box>
1224
+ </box>
1225
+ </Show>
1226
+ </box>
1227
+ </box>
1228
+
1229
+ {/* bottom strip: error / result / log */}
1230
+ <box
1231
+ flexDirection="column"
1232
+ style={{ border: true, borderStyle: "rounded", borderColor: run.error ? t().error : t().border, paddingLeft: 1, paddingRight: 1, marginTop: 1, height: 4, overflow: "hidden" }}
1233
+ title={bottomTitle()}
1234
+ titleColor={run.error ? t().error : run.result ? t().success : t().textMuted}
1235
+ >
1236
+ <Show when={run.error}>
1237
+ <text style={{ fg: t().error }}>{clip(oneLine(run.error), width() - 8)}</text>
1238
+ <text style={{ fg: t().textMuted }}>{lastLog(run) ? `${fmtClock(lastLog(run)!.at)} ${clip(oneLine(lastLog(run)!.message), width() - 20)}` : ""}</text>
1239
+ </Show>
1240
+ <Show when={!run.error && run.result}>
1241
+ <text style={{ fg: t().text }}>{clip(oneLine(run.result), width() - 8)}</text>
1242
+ <text style={{ fg: t().textMuted }}>{`${wrapWords(run.result, width() - 8).length} lines · press r to read the full result`}</text>
1243
+ </Show>
1244
+ <Show when={!run.error && !run.result}>
1245
+ <Show when={logLines().length === 0}>
1246
+ <text style={{ fg: t().textMuted }}>{live() ? `${store.spinner()} starting…` : "no log lines"}</text>
1247
+ </Show>
1248
+ <For each={logLines()}>
1249
+ {(l) => (
1250
+ <box flexDirection="row" style={{ height: 1 }}>
1251
+ <text style={{ fg: t().textMuted, width: 10 }}>{fmtClock(l.at)}</text>
1252
+ <text style={{ fg: t().text }}>{clip(oneLine(l.message), width() - 18)}</text>
1253
+ </box>
1254
+ )}
1255
+ </For>
1256
+ </Show>
1257
+ </box>
1258
+
1259
+ <box style={{ paddingTop: 1 }}>
1260
+ <Hints
1261
+ api={api}
1262
+ items={[
1263
+ ["↑↓", props.pane() === "phases" ? "phase" : "agent"],
1264
+ ["←→", "pane"],
1265
+ ["⏎", props.pane() === "phases" ? "agents" : "open agent"],
1266
+ ["r", "result"],
1267
+ ["x", "stop"],
1268
+ ["p", pauseLabel(store, run)],
1269
+ ["s", "save"],
1270
+ ...(waiting().length ? ([["!", "answer permission"]] as Array<[string, string]>) : []),
1271
+ ["esc", "back"],
1272
+ ]}
1273
+ />
1274
+ </box>
1275
+ </box>
1276
+ )
1277
+ }
1278
+
1279
+ // =============================================================================
1280
+ // agent screen
1281
+ // =============================================================================
1282
+
1283
+ function AgentScreen(props: ScreenProps & { agentId: () => string; scrollRef: (el: ScrollBoxRenderable) => void }) {
1284
+ const { api, store } = props
1285
+ const { t } = useTheme(api)
1286
+ const agent = () => store.activeRun()?.agents[props.agentId()]
1287
+ return (
1288
+ <box flexDirection="column" style={{ flexGrow: 1 }}>
1289
+ <Show
1290
+ when={agent()}
1291
+ keyed
1292
+ fallback={
1293
+ <box style={{ paddingLeft: 2, paddingTop: 1 }}>
1294
+ <text style={{ fg: t().textMuted }}>agent not found — press esc to go back</text>
1295
+ </box>
1296
+ }
1297
+ >
1298
+ {(a: AgentState) => <AgentView api={api} store={store} reqs={props.reqs} run={store.activeRun()!} agent={a} scrollRef={props.scrollRef} />}
1299
+ </Show>
1300
+ </box>
1301
+ )
1302
+ }
1303
+
1304
+ function AgentView(props: ScreenProps & { run: RunState; agent: AgentState; scrollRef: (el: ScrollBoxRenderable) => void }) {
1305
+ const { api, store, reqs, run, agent: a } = props
1306
+ const { t, tone, faint } = useTheme(api)
1307
+ const width = () => store.size().width
1308
+ const bodyW = () => Math.max(30, width() - 8)
1309
+ const running = () => a.status === "running" || a.status === "queued"
1310
+ const pending = () => reqs.forAgent(a)
1311
+ const shown = () => agentShownStatus(reqs, a)
1312
+ const outcome = () => a.outcomeText ?? (typeof a.outcome === "string" ? a.outcome : a.outcome ? JSON.stringify(a.outcome, null, 2) : "")
1313
+ const age = (at: number) => fmtDuration(Math.max(0, store.now() - at))
1314
+ const phaseIds = () => run.phases.find((p) => p.title === a.phase)?.agentIds ?? run.agentOrder
1315
+ const pos = () => `${Math.max(0, phaseIds().indexOf(a.id)) + 1}/${phaseIds().length}`
1316
+ const feed = () => [...(a.liveFeed ?? [])].slice(-8).reverse()
1317
+ // paddingTop(1) + header(4) + margin(1) + [body] + hints(2) + host status line(1)
1318
+ const bodyH = () => Math.max(6, store.size().height - 9)
1319
+
1320
+ return (
1321
+ <box flexDirection="column" style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
1322
+ {/* header */}
1323
+ <box
1324
+ flexDirection="column"
1325
+ style={{ border: true, borderStyle: "rounded", borderColor: pending().length ? t().warning : t().border, paddingLeft: 1, paddingRight: 1, height: 4 }}
1326
+ title={` ${statusGlyph(shown(), store.spinner())} ${clip(a.label, 48)} `}
1327
+ titleColor={tone(statusTone(shown()))}
1328
+ >
1329
+ <box flexDirection="row" style={{ height: 1 }}>
1330
+ <text style={{ fg: t().textMuted }}>phase </text>
1331
+ <text style={{ fg: t().text }}>{clip(a.phase || "—", 24)}</text>
1332
+ <text style={{ fg: t().textMuted }}>{` · agent ${pos()} · model `}</text>
1333
+ <text style={{ fg: t().text }}>{clip(a.model || "default", 40)}</text>
1334
+ <text style={{ flexGrow: 1 }} />
1335
+ <text style={{ fg: tone(statusTone(shown())), attributes: BOLD }}>{shown() === "waiting" ? "NEEDS PERMISSION" : statusLabel(a.status).toUpperCase()}</text>
1336
+ </box>
1337
+ <box flexDirection="row" style={{ height: 1 }}>
1338
+ <text style={{ fg: t().textMuted }}>context </text>
1339
+ <text style={{ fg: t().text }}>{fmtCtx(a.contextTokens)}</text>
1340
+ <text style={{ fg: faint() }}>{` (billed ${fmtTok(a.tokens)} · out ${fmtTok(a.outputTokens)})`}</text>
1341
+ <text style={{ fg: t().textMuted }}>{` · `}</text>
1342
+ <text style={{ fg: t().text }}>{fmtCost(a.cost)}</text>
1343
+ <text style={{ fg: t().textMuted }}>{` · ${a.toolCalls} tool call${a.toolCalls === 1 ? "" : "s"} · `}</text>
1344
+ <text style={{ fg: running() ? t().accent : t().text }}>{fmtElapsed(a.startedAt, a.endedAt, store.now())}</text>
1345
+ <text style={{ flexGrow: 1 }} />
1346
+ <text style={{ fg: t().textMuted }}>{a.startedAt ? `started ${fmtClock(a.startedAt)}` : "queued"}</text>
1347
+ </box>
1348
+ </box>
1349
+
1350
+ {/* body */}
1351
+ <box flexDirection="column" style={{ height: bodyH(), border: true, borderStyle: "rounded", borderColor: t().border, marginTop: 1, overflow: "hidden" }}>
1352
+ <scrollbox ref={props.scrollRef} style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1 }} scrollY={true} scrollX={false}>
1353
+ {/* blocked on a permission / question — the agent cannot continue until answered */}
1354
+ <Show when={pending().length > 0}>
1355
+ <box flexDirection="row" style={{ paddingTop: 1 }}>
1356
+ <text style={{ fg: t().warning, attributes: BOLD }}>{`⚠ Waiting for your answer · ${pending().length}`}</text>
1357
+ <text style={{ fg: t().textMuted }}>{" ⏎ opens the dialog"}</text>
1358
+ </box>
1359
+ <For each={pending()}>
1360
+ {(p) => (
1361
+ <box flexDirection="column">
1362
+ <box flexDirection="row" style={{ height: 1 }}>
1363
+ <text style={{ fg: t().textMuted, width: 8 }}>{cellR(age(p.at), 7)}</text>
1364
+ <text style={{ fg: t().warning, width: 2 }}>{p.kind === "permission" ? "⚿" : "?"}</text>
1365
+ <text style={{ fg: t().text }}>{clip(describeRequest(p), bodyW() - 12)}</text>
1366
+ </box>
1367
+ <Show when={p.kind === "permission" && typeof (p as any).req.metadata?.description === "string"}>
1368
+ <text style={{ fg: t().textMuted, attributes: DIM }}>{` ${clip(String((p as any).req.metadata.description), bodyW() - 12)}`}</text>
1369
+ </Show>
1370
+ </box>
1371
+ )}
1372
+ </For>
1373
+ </Show>
1374
+
1375
+ <Show when={a.error}>
1376
+ <SectionTitle api={api} title="Error" />
1377
+ <TextBlock api={api} text={() => a.error ?? ""} width={bodyW} fg={() => t().error} />
1378
+ </Show>
1379
+
1380
+ {/* live feed while running */}
1381
+ <Show when={running()}>
1382
+ <SectionTitle api={api} title={pending().length ? "⚠ Live" : `${store.spinner()} Live`} hint={() => (pending().length ? "blocked until the request above is answered" : "newest first")} />
1383
+ <Show when={feed().length === 0}>
1384
+ <text style={{ fg: t().textMuted }}>waiting for first output…</text>
1385
+ </Show>
1386
+ <For each={feed()}>
1387
+ {(e) => (
1388
+ <box flexDirection="row" style={{ height: 1 }}>
1389
+ <text style={{ fg: t().textMuted, width: 8 }}>{cellR(`${age(e.at)}`, 7)}</text>
1390
+ <text style={{ fg: e.kind === "tool" ? t().accent : t().textMuted, width: 2 }}>{e.kind === "tool" ? "⚙" : e.kind === "think" ? "∴" : "…"}</text>
1391
+ <text style={{ fg: e.kind === "tool" ? t().text : t().textMuted }}>{clip(e.text, bodyW() - 12)}</text>
1392
+ </box>
1393
+ )}
1394
+ </For>
1395
+ </Show>
1396
+ <Show when={!running() && a.liveText && !outcome()}>
1397
+ <SectionTitle api={api} title="Last output" />
1398
+ <TextBlock api={api} text={() => a.liveText ?? ""} width={bodyW} maxLines={6} dim />
1399
+ </Show>
1400
+
1401
+ {/* prompt */}
1402
+ <SectionTitle api={api} title="Prompt" hint={() => (store.fullPrompt() ? "p collapses" : "p expands")} />
1403
+ <TextBlock api={api} text={() => a.prompt} width={bodyW} maxLines={store.fullPrompt() ? undefined : 6} fg={() => t().textMuted} />
1404
+
1405
+ {/* activity */}
1406
+ <SectionTitle
1407
+ api={api}
1408
+ title={activityTitle(a)}
1409
+ hint={() => (a.activity.length ? (store.expandActivity() ? "e hides previews" : "e shows previews") : "no activity yet")}
1410
+ />
1411
+ <For each={a.activity}>
1412
+ {(act) => (
1413
+ <box flexDirection="column">
1414
+ <box flexDirection="row" style={{ height: 1 }}>
1415
+ <text style={{ fg: act.endedAt ? t().success : t().accent, width: 2 }}>{act.endedAt ? "✓" : store.spinner()}</text>
1416
+ <text style={{ fg: act.kind === "think" ? t().textMuted : t().text }}>{act.tool}</text>
1417
+ <text style={{ fg: t().textMuted }}>{act.title && act.title !== act.tool ? ` ${clip(oneLine(act.title), Math.max(10, bodyW() - act.tool.length - 14))}` : ""}</text>
1418
+ <text style={{ flexGrow: 1 }} />
1419
+ <text style={{ fg: t().textMuted }}>{fmtElapsed(act.startedAt, act.endedAt, store.now())}</text>
1420
+ </box>
1421
+ <Show when={store.expandActivity() && act.preview}>
1422
+ <box style={{ paddingLeft: 2 }}>
1423
+ <TextBlock api={api} text={() => act.preview ?? ""} width={() => bodyW() - 2} maxLines={4} dim />
1424
+ </box>
1425
+ </Show>
1426
+ </box>
1427
+ )}
1428
+ </For>
1429
+
1430
+ {/* outcome */}
1431
+ <SectionTitle api={api} title="Outcome" />
1432
+ <Show when={outcome()} fallback={<text style={{ fg: t().textMuted }}>{running() ? `${store.spinner()} working…` : a.error ? "failed — see error above" : "no outcome"}</text>}>
1433
+ <TextBlock api={api} text={() => String(outcome())} width={bodyW} maxLines={store.fullPrompt() ? undefined : 40} />
1434
+ </Show>
1435
+ <text> </text>
1436
+ </scrollbox>
1437
+ </box>
1438
+
1439
+ <box style={{ paddingTop: 1 }}>
1440
+ <Hints
1441
+ api={api}
1442
+ items={[
1443
+ ...(pending().length ? ([["⏎", pending()[0]?.kind === "question" ? "answer question" : "allow / reject"]] as Array<[string, string]>) : []),
1444
+ ["↑↓", "scroll"],
1445
+ ["←→", "prev/next agent"],
1446
+ ["e", store.expandActivity() ? "hide previews" : "show previews"],
1447
+ ["p", store.fullPrompt() ? "collapse" : "expand text"],
1448
+ ["esc", "back"],
1449
+ ]}
1450
+ />
1451
+ </box>
1452
+ </box>
1453
+ )
1454
+ }
1455
+
1456
+ // =============================================================================
1457
+ // result screen
1458
+ // =============================================================================
1459
+
1460
+ function ResultScreen(props: ScreenProps & { scrollRef: (el: ScrollBoxRenderable) => void }) {
1461
+ const { api, store } = props
1462
+ const { t, tone, faint } = useTheme(api)
1463
+ const width = () => store.size().width
1464
+ return (
1465
+ <box flexDirection="column" style={{ flexGrow: 1 }}>
1466
+ <Show
1467
+ when={store.activeRun()}
1468
+ keyed
1469
+ fallback={
1470
+ <box style={{ paddingLeft: 2, paddingTop: 1 }}>
1471
+ <text style={{ fg: t().textMuted }}>no run selected — press esc to go back</text>
1472
+ </box>
1473
+ }
1474
+ >
1475
+ {(run: RunState) => {
1476
+ const text = () => run.error ? `${run.error}${run.result ? `\n\n${run.result}` : ""}` : run.result ?? ""
1477
+ const lines = createMemo(() => wrapWords(text(), Math.max(30, width() - 8)))
1478
+ // paddingTop(1) + title(1) + margin(1) + [body] + hints(2) + host status line(1)
1479
+ const bodyH = () => Math.max(6, store.size().height - 6)
1480
+ return (
1481
+ <box flexDirection="column" style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
1482
+ <box flexDirection="row" style={{ paddingLeft: 1, height: 1 }}>
1483
+ <text style={{ fg: t().primary, attributes: BOLD }}>Result</text>
1484
+ <text style={{ fg: t().textMuted }}>{` ${clip(run.name, 48)} · ${run.agentDone}/${run.agentCount} agents · ${fmtDuration(run.endedAt ? run.endedAt - run.startedAt : 0)} · ${fmtCtx(run.totalContextTokens)}`}</text>
1485
+ <text style={{ fg: faint() }}>{` · billed ${fmtTokens(run.totalTokens)}`}</text>
1486
+ <text style={{ fg: t().textMuted }}>{` · ${fmtCost(run.totalCost)}`}</text>
1487
+ <text style={{ flexGrow: 1 }} />
1488
+ <text style={{ fg: tone(statusTone(run.status)), attributes: BOLD }}>{statusLabel(run.status).toUpperCase()}</text>
1489
+ </box>
1490
+ <box flexDirection="column" style={{ height: bodyH(), border: true, borderStyle: "rounded", borderColor: run.error ? t().error : t().border, marginTop: 1, overflow: "hidden" }}>
1491
+ <scrollbox ref={props.scrollRef} style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1 }} scrollY={true} scrollX={false}>
1492
+ <Show when={lines().length === 0}>
1493
+ <text style={{ fg: t().textMuted }}>no result</text>
1494
+ </Show>
1495
+ <For each={lines()}>{(line) => <text style={{ fg: run.error && !run.result ? t().error : t().text }}>{line || " "}</text>}</For>
1496
+ </scrollbox>
1497
+ </box>
1498
+ <box style={{ paddingTop: 1 }}>
1499
+ <Hints
1500
+ api={api}
1501
+ items={[
1502
+ ["↑↓", "scroll"],
1503
+ ["pgup/pgdn", "page"],
1504
+ ["g", "top"],
1505
+ ["esc", "back"],
1506
+ ]}
1507
+ />
1508
+ </box>
1509
+ </box>
1510
+ )
1511
+ }}
1512
+ </Show>
1513
+ </box>
1514
+ )
1515
+ }
1516
+
1517
+ export default plugin