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.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/package.json +60 -0
- package/skills/workflow-authoring/SKILL.md +89 -0
- package/src/runtime/engine.ts +1020 -0
- package/src/runtime/schema.ts +92 -0
- package/src/runtime/script.ts +220 -0
- package/src/runtime/selftest.ts +321 -0
- package/src/server/index.ts +394 -0
- package/src/shared/format.ts +123 -0
- package/src/shared/state.ts +189 -0
- package/src/tui/index.tsx +1517 -0
- package/src/tui/requests.ts +265 -0
- package/src/tui/store.ts +266 -0
|
@@ -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
|