One registry per process, every job owned by a session: bash's background commands (and a command still running at its timeout, which now goes to the background instead of being killed) and subagents started with task background: true. job_list, job_output, job_stop (the former bash_list, bash_output, bash_kill) and the new job_send and report; a finished job wakes the owning session in every front end with its marked note. The TUI counts jobs, lists them (/jobs), opens a command's output, enters a subagent and messages it, moves a command to the background (ctrl+b) and asks before quitting with jobs running. The link gains the jobs capability. Subagents get a session row of their own, the spec's preamble and agent texts, and their errors reach the report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
86 lines
3.9 KiB
TypeScript
86 lines
3.9 KiB
TypeScript
import { RGBA, type ScrollBoxRenderable } from "@opentui/core"
|
|
import { useKeyboard } from "@opentui/solid"
|
|
import { createSignal, onCleanup } from "solid-js"
|
|
import { duration } from "../../duration.ts"
|
|
import { useTui } from "../context.ts"
|
|
import { jobWords } from "./messages.tsx"
|
|
|
|
/** Of a command's output, how much the view draws: the end of it (the whole buffer is 2 MB). */
|
|
const SHOWN = 32_000
|
|
|
|
/** A command job, live: `job3 · running 1m 12s · k stop · pgup/pgdn scroll · esc back` over a box
|
|
* that follows the tail of its output. Redrawn on the job's events and once a second for the
|
|
* clock. Lines longer than the box wrap by character, so nothing is wider than the terminal. */
|
|
export function JobView(p: { id: string; close: () => void }) {
|
|
const { theme, dims, app, view } = useTui()
|
|
const c = () => theme().colors
|
|
const [rev, setRev] = createSignal(0)
|
|
const bump = () => setRev((n) => n + 1)
|
|
const job = () => (rev(), app.registry.get(app.jobsSessionId(), p.id))
|
|
const off = app.bus.on((e) => e.type === "job" && e.job.id === p.id && bump())
|
|
const clock = setInterval(bump, 1000)
|
|
onCleanup(() => {
|
|
off()
|
|
clearInterval(clock)
|
|
})
|
|
// Looked at: not news any more.
|
|
view.seenJobs(p.id)
|
|
const [stopping, setStopping] = createSignal(false)
|
|
let box: ScrollBoxRenderable | undefined
|
|
|
|
useKeyboard((key) => {
|
|
if (key.name === "escape") p.close()
|
|
else if (key.name === "k" && !key.ctrl && !key.meta) {
|
|
const j = job()
|
|
if (j && j.state === "running") {
|
|
app.registry.stop(j)
|
|
setStopping(true)
|
|
}
|
|
} else if (key.name === "pageup") box?.scrollBy(-Math.max(1, Math.floor((box.height || 2) / 2)))
|
|
else if (key.name === "pagedown") box?.scrollBy(Math.max(1, Math.floor((box.height || 2) / 2)))
|
|
else return
|
|
key.preventDefault()
|
|
key.stopPropagation()
|
|
})
|
|
|
|
const head = () => {
|
|
const j = job()
|
|
if (!j) return `${p.id} · gone · esc back`
|
|
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}${stopping() ? " · stopping…" : ""}` : jobWords(j)
|
|
return [j.id, state, ...(j.state === "running" ? ["k stop"] : []), "pgup/pgdn scroll", "esc back"].join(" · ")
|
|
}
|
|
// What it is: the model's description, and the command itself where that says more.
|
|
const what = () => {
|
|
const j = job()
|
|
if (!j) return ""
|
|
if (j.kind === "subagent") return `${j.agent ?? "subagent"}: ${j.description}`
|
|
const line = (j.command ?? "").split("\n")[0]!
|
|
return line && line !== j.description ? `${j.description} · $ ${line}` : j.description
|
|
}
|
|
const tail = () => {
|
|
const out = job()?.output ?? ""
|
|
return (out.length > SHOWN ? `…${out.slice(-SHOWN)}` : out) || "(no output yet)"
|
|
}
|
|
const width = () => Math.max(20, dims().width - 4)
|
|
const height = () => Math.max(6, dims().height - 4)
|
|
return (
|
|
<box position="absolute" zIndex={100} left={0} top={0} width={dims().width} height={dims().height} alignItems="center" paddingTop={1} backgroundColor={RGBA.fromInts(0, 0, 0, 140)}>
|
|
<box width={width()} height={height()} border borderStyle="rounded" borderColor={c().border} backgroundColor={c().menu} flexDirection="column" paddingLeft={1} paddingRight={1} title={` ${p.id} `} titleColor={c().title}>
|
|
<box flexDirection="column" flexShrink={0} height={2}>
|
|
<text fg={c().info} wrapMode="none" truncate height={1}>
|
|
{head()}
|
|
</text>
|
|
<text fg={c().muted} wrapMode="none" truncate height={1}>
|
|
{what()}
|
|
</text>
|
|
</box>
|
|
<scrollbox ref={(r: ScrollBoxRenderable) => (box = r)} flexGrow={1} stickyScroll stickyStart="bottom" scrollX={false} viewportOptions={{ paddingRight: 1 }} verticalScrollbarOptions={{ trackOptions: { backgroundColor: c().panel, foregroundColor: c().dim } }}>
|
|
<text fg={c().text} wrapMode="char">
|
|
{tail()}
|
|
</text>
|
|
</scrollbox>
|
|
</box>
|
|
</box>
|
|
)
|
|
}
|