Harness spec 2.0.0: background work is a job — commands and subagents alike, seen and steered
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>
This commit is contained in:
75 files changed
+3614
-437
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { useTui } from "../context.ts"
|
||||
|
||||
export interface Option {
|
||||
@@ -24,6 +24,12 @@ export interface DialogSpec {
|
||||
/** The footer in place of `↑↓ move · enter choose · esc close`, to name those keys: a
|
||||
* binding nobody is told about is one nobody finds, which is how delete went unseen in /sessions. */
|
||||
hint?: string
|
||||
/** Columns the box may take (72 by default; the terminal less four is the ceiling either way):
|
||||
* a list whose rows are columns of their own (/jobs) asks for more. */
|
||||
width?: number
|
||||
/** The dialog is gone, however it went (chosen, esc, replaced): a list that follows events
|
||||
* while it is shown stops following here. */
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const CUSTOM = "\u0000custom"
|
||||
@@ -41,6 +47,14 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
||||
})
|
||||
const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
|
||||
const [sel, setSel] = createSignal(start)
|
||||
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
|
||||
createEffect(() => {
|
||||
const n = shown().length
|
||||
if (n && sel() >= n) setSel(n - 1)
|
||||
})
|
||||
// Taken once: the spec that opened the dialog says what to do when it goes.
|
||||
const onClose = p.spec.onClose
|
||||
onCleanup(() => onClose?.())
|
||||
const visible = () => Math.max(3, Math.min(14, dims().height - 10))
|
||||
const offset = () => Math.max(0, Math.min(sel() - Math.floor(visible() / 2), shown().length - visible()))
|
||||
|
||||
@@ -80,7 +94,7 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
||||
key.stopPropagation()
|
||||
})
|
||||
|
||||
const width = () => Math.min(72, dims().width - 4)
|
||||
const width = () => Math.min(p.spec.width ?? 72, dims().width - 4)
|
||||
return (
|
||||
<box position="absolute" zIndex={100} left={0} top={0} width={dims().width} height={dims().height} alignItems="center" paddingTop={Math.max(1, Math.floor(dims().height / 6))} backgroundColor={RGBA.fromInts(0, 0, 0, 140)}>
|
||||
<box width={width()} border borderStyle="rounded" borderColor={c().border} backgroundColor={c().menu} flexDirection="column" paddingLeft={1} paddingRight={1} title={` ${p.spec.title} `} titleColor={c().title}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -113,6 +113,13 @@ function Reasoning(p: { item: Extract<Item, { kind: "reasoning" }> }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** How a job is doing, in a word or two: `running…`, `done · 1m 12s`, `stopped · 4s`. */
|
||||
export function jobWords(j: { state: string; started: number; ended?: number; exit?: number | null }): string {
|
||||
if (j.state === "running") return "running…"
|
||||
const took = duration((j.ended ?? Date.now()) - j.started, true)
|
||||
return `${j.state}${j.state !== "lost" && j.exit !== undefined && j.exit !== null && j.exit !== 0 ? ` (exit ${j.exit})` : ""} · ${took}`
|
||||
}
|
||||
|
||||
function ToolLine(p: { item: Extract<Item, { kind: "tool" }> }) {
|
||||
const { theme, view } = useTui()
|
||||
const c = () => theme().colors
|
||||
@@ -134,6 +141,14 @@ function ToolLine(p: { item: Extract<Item, { kind: "tool" }> }) {
|
||||
<span style={{ fg: c().error }}> ✗</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={p.item.job}>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: c().dim }}>{" → "}</span>
|
||||
<span style={{ fg: c().label }}>{p.item.job!.id}</span>
|
||||
<span style={{ fg: c().muted }}>{` (${p.item.job!.agent ? `${p.item.job!.agent}: ` : ""}${p.item.job!.description})`}</span>
|
||||
<span style={{ fg: p.item.job!.state === "running" ? c().accent : p.item.job!.state === "error" || p.item.job!.state === "lost" ? c().error : c().dim }}>{` ${jobWords(p.item.job!)}`}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<For each={p.item.children ?? []}>
|
||||
{(ch) => (
|
||||
<text wrapMode="none" truncate>
|
||||
|
||||
@@ -92,6 +92,8 @@ export function PermissionPrompt(p: { pending: Pending }) {
|
||||
})
|
||||
|
||||
const what = () => p.pending.request.command ?? p.pending.request.patterns.join(", ")
|
||||
// A subagent's ask says which one: `explore (job7): bash`.
|
||||
const title = () => (p.pending.source ? `${p.pending.source.agent} (${p.pending.source.jobId}): ${p.pending.tool}` : p.pending.tool)
|
||||
// Inside the border, the padding and the scrollbar.
|
||||
const width = () => Math.max(10, dims().width - 5)
|
||||
const purpose = () => (p.pending.purpose ? rowsOf(p.pending.purpose, width()).slice(0, 3) : [])
|
||||
@@ -116,7 +118,7 @@ export function PermissionPrompt(p: { pending: Pending }) {
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return (
|
||||
<box border borderStyle="rounded" borderColor={c().warn} paddingLeft={1} paddingRight={1} flexDirection="column" flexShrink={0} title={` ${p.pending.tool} `} titleColor={c().warn}>
|
||||
<box border borderStyle="rounded" borderColor={c().warn} paddingLeft={1} paddingRight={1} flexDirection="column" flexShrink={0} title={` ${title()} `} titleColor={c().warn}>
|
||||
<Show when={purpose().length}>
|
||||
<text fg={c().accent} wrapMode="none">
|
||||
{purpose().join("\n")}
|
||||
|
||||
@@ -40,6 +40,9 @@ export function Prompt(p: {
|
||||
queue?: { count(): number; take(index: number): string | undefined; putBack(index: number, text: string): void }
|
||||
/** A burst of ↑/↓ that was a mouse wheel: scroll the transcript `lines` up (-1) or down (1). */
|
||||
onWheel?: (dir: -1 | 1, lines: number) => void
|
||||
/** ctrl+b: the foreground command goes to the background now. Taken here because the textarea
|
||||
* itself binds ctrl+b (move left) and would keep the key. */
|
||||
onBackground?: () => void
|
||||
}) {
|
||||
const { theme } = useTui()
|
||||
const c = () => theme().colors
|
||||
@@ -233,6 +236,10 @@ export function Prompt(p: {
|
||||
onKeyDown={(e) => {
|
||||
if (!area) return
|
||||
if (e.ctrl && e.name === "v" && pasteClipboardImage()) return e.preventDefault()
|
||||
if (e.ctrl && e.name === "b" && p.onBackground) {
|
||||
p.onBackground()
|
||||
return e.preventDefault()
|
||||
}
|
||||
const pop = popup()
|
||||
if (pop) {
|
||||
const n = pop.items.length
|
||||
|
||||
@@ -9,8 +9,19 @@ export function fmtTokens(n: number): string {
|
||||
return n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||
}
|
||||
|
||||
/** One line: mode · model · effort on the left; context use and where on the right — or, for a
|
||||
* moment, a flash (a copy) in their place. */
|
||||
/** The status bar's word for the session's jobs: the running count, `!` when one ended unseen or
|
||||
* holds output the model has not read; nothing while there is nothing to say. */
|
||||
export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean }[]): string {
|
||||
const running = jobs.filter((j) => j.state === "running").length
|
||||
const news = jobs.some((j) => j.unseen || j.unread > 0)
|
||||
if (!running && !news) return ""
|
||||
return `${running} job${running === 1 ? "" : "s"}${news ? "!" : ""}`
|
||||
}
|
||||
|
||||
/** One line: mode · model · effort on the left; the jobs, context use and where on the right — or,
|
||||
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
|
||||
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
|
||||
* thing that must stay visible. */
|
||||
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; credits?: () => string }) {
|
||||
const { theme, view, app } = useTui()
|
||||
const c = () => theme().colors
|
||||
@@ -39,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
|
||||
when={p.flash?.()}
|
||||
fallback={
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
|
||||
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
||||
<span style={{ fg: c().statusText }}>{p.credits?.() ? `${p.credits()} · ` : ""}</span>
|
||||
<span style={{ fg: ctx().color }}>{ctx().text}</span>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { duration } from "../../duration.ts"
|
||||
import { TuiContext, useTui } from "../context.ts"
|
||||
import type { View } from "../state.ts"
|
||||
import { jobWords } from "./messages.tsx"
|
||||
import { Messages } from "./messages.tsx"
|
||||
|
||||
/** A subagent entered from /jobs: its transcript in place of the session's, under a header line
|
||||
* `subagent explore · job7 · running 1m 12s · esc back · type to message`. The items are the
|
||||
* child's own View (fed by its bus; Root keeps one per job), read-only here; what is typed goes to
|
||||
* the job through Root. The clock ticks while it runs. */
|
||||
export function SubagentView(p: { id: string; view: View; scrollRef?: (r: ScrollBoxRenderable) => void }) {
|
||||
const ctx = useTui()
|
||||
const { theme, app } = ctx
|
||||
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)
|
||||
})
|
||||
const head = () => {
|
||||
const j = job()
|
||||
if (!j) return `subagent · ${p.id} · gone · esc back`
|
||||
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
|
||||
const message = j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
|
||||
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
|
||||
}
|
||||
const what = () => job()?.description ?? ""
|
||||
return (
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<box flexDirection="column" paddingLeft={1} flexShrink={0} height={2} backgroundColor={c().panel}>
|
||||
<text fg={c().info} wrapMode="none" truncate height={1}>
|
||||
{head()}
|
||||
</text>
|
||||
<text fg={c().muted} wrapMode="none" truncate height={1}>
|
||||
{what()}
|
||||
</text>
|
||||
</box>
|
||||
<TuiContext.Provider value={{ ...ctx, view: p.view }}>
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => p.scrollRef?.(r)}
|
||||
flexGrow={1}
|
||||
stickyScroll
|
||||
stickyStart="bottom"
|
||||
viewportOptions={{ paddingRight: 1 }}
|
||||
verticalScrollbarOptions={{ trackOptions: { backgroundColor: c().panel, foregroundColor: c().dim } }}
|
||||
>
|
||||
<Messages />
|
||||
</scrollbox>
|
||||
</TuiContext.Provider>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user