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>
70 lines
3.9 KiB
TypeScript
70 lines
3.9 KiB
TypeScript
import { basename } from "node:path"
|
|
import { Show } from "solid-js"
|
|
import { isLocal } from "../../config/paths.ts"
|
|
import { useTui } from "../context.ts"
|
|
|
|
const MODE_LABEL = { manual: "manual", edit: "edit", auto: "AUTO", plan: "plan" } as const
|
|
|
|
export function fmtTokens(n: number): string {
|
|
return n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
|
}
|
|
|
|
/** 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
|
|
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
|
|
const ctx = () => {
|
|
const u = view.state.usage
|
|
if (!u) return { text: "ctx —", color: c().statusDim }
|
|
const used = view.state.used ?? u.input + u.output
|
|
const total = view.state.context
|
|
if (!total) return { text: `ctx ${fmtTokens(used)}${u.estimated ? "~" : ""}`, color: c().statusText }
|
|
const pct = Math.round((used / total) * 100)
|
|
const color = pct >= 90 ? c().statusCritical : pct >= 75 ? c().statusBad : pct >= 50 ? c().statusWarn : c().statusGood
|
|
return { text: `ctx ${fmtTokens(used)}${u.estimated ? "~" : ""}/${fmtTokens(total)} ${pct}%`, color }
|
|
}
|
|
return (
|
|
<box height={1} flexDirection="row" justifyContent="space-between" backgroundColor={c().panel} paddingLeft={1} paddingRight={1}>
|
|
<text wrapMode="none" truncate>
|
|
<span style={{ fg: modeColor() }}>{MODE_LABEL[p.mode() as keyof typeof MODE_LABEL] ?? p.mode()}</span>
|
|
<span style={{ fg: c().statusDim }}> · </span>
|
|
<span style={{ fg: c().statusStrong }}>{p.model()}</span>
|
|
<span style={{ fg: c().statusDim }}>{p.effort() ? ` · ${p.effort()}` : ""}</span>
|
|
<span style={{ fg: p.voice?.().startsWith("●") ? c().statusCritical : c().statusText }}>{p.voice?.() ? ` · ${p.voice()}` : ""}</span>
|
|
<span style={{ fg: c().info }}>{p.remote?.() ? ` · ${p.remote()}` : ""}</span>
|
|
</text>
|
|
<Show
|
|
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>
|
|
<span style={{ fg: c().statusDim }}> · </span>
|
|
<span style={{ fg: c().info }}>{isLocal() ? "local " : ""}</span>
|
|
<span style={{ fg: c().statusDim }}>{basename(app.project.root)}{p.branch ? `@${p.branch}` : ""}</span>
|
|
</text>
|
|
}
|
|
>
|
|
<text wrapMode="none" fg={c().statusGood}>
|
|
{p.flash!()}
|
|
</text>
|
|
</Show>
|
|
</box>
|
|
)
|
|
}
|