status usage bars should all line up

This commit is contained in:
Ian Gulliver
2026-10-18 23:26:12 -07:00
parent a11c899f14
commit 9625be25fd
2 changed files with 46 additions and 47 deletions
+1 -1
View File
@@ -242,7 +242,7 @@ Agent health is `internal/agents/health.go`:
- **Watching.** `Manager.Watch` recomputes health every `healthInterval`. When the set of problems (non-`ok` processes and zombies) changes, it logs each new one as `agent health problem` and broadcasts an unstored `admin.health` event (`seq` 0). Nothing is killed or repaired automatically.
- **Protocol errors.** `internal/claude` reports a stdout line that isn't a JSON object, doesn't parse, or has a `type` outside `knownTypes` (the line is still passed on), and the manager adds lines whose content it fails to decode. `protocol_errors.counts` counts them per vendor since byre started; `recent` holds the last `keptBadLines`, newest first, each with session, time, reason, the raw line cut to `badLineBytes` and its full `size`. They live in memory only, and each one also broadcasts `admin.health`.
The left pane's **Status** tab shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); each running turn, linking to its task, with the time since its process's last event and `stuck` or `exited` when it is; each vendor's rate-limit status with its reset time and, per window, the share used, the share of the window's time elapsed (from `resetsAt` and `windowMins`), and a pace bar whose fill is the share used and whose vertical line is the share elapsed, the fill warn-coloured when usage is ahead of the line; a Clients table, one row per entry in `clients`, with the Mac, user, the build's commit (cut to 8 characters, the full one in its tooltip), the time since it connected and the time since its last action; and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created`, `admin.health` or `admin.clients` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
The left pane's **Status** tab shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); each running turn, linking to its task, with the time since its process's last event and `stuck` or `exited` when it is; each vendor's rate-limit status with its reset time and, per window, the share used, the share of the window's time elapsed (from `resetsAt` and `windowMins`), and a pace bar whose fill is the share used and whose vertical line is the share elapsed, the fill warn-coloured when usage is ahead of the line, all vendors in one table so their columns and pace bars line up; a Clients table, one row per entry in `clients`, with the Mac, user, the build's commit (cut to 8 characters, the full one in its tooltip), the time since it connected and the time since its last action; and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created`, `admin.health` or `admin.clients` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.subagents`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall`, `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. Antigravity steps map the same way (`handleAntigravity` in `internal/agents/antigravity.go`): an `agent_response` step's `text_delta`s become `agent.delta` and its finished text `agent.message`, and a `tool` step an `agent.tool_call` when it starts and an `agent.tool_result` when it is done, with `run_command` recorded as `Bash` and `call_mcp_tool` as `mcp__<server>__<tool>` with its `Arguments` as input; `user_input` steps aren't recorded, and other step types are recorded as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `summary: true` when its `result` is the turn's summary (see above). Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
+45 -46
View File
@@ -79,15 +79,7 @@ struct StatusView: View {
section("Running turns")
TurnList(status: status)
section("Rate limits")
let vendors = status.rateLimits.additionalProperties.keys.sorted()
VStack(alignment: .leading, spacing: 8) {
ForEach(vendors, id: \.self) { v in
RateLimitCard(vendor: v, info: status.rateLimits.additionalProperties[v]!.value)
}
}
if vendors.isEmpty {
Text("No vendor has reported a rate-limit status since byre started.").foregroundStyle(Palette.muted)
}
RateLimits(limits: status.rateLimits.additionalProperties.mapValues { $0.value })
section("Clients")
ClientTable(status: status)
section("Server")
@@ -270,16 +262,33 @@ private struct TurnList: View {
}
}
private struct RateLimitCard: View {
let vendor: String
let info: JSONDict
private struct RateLimits: View {
let limits: [String: JSONDict]
var body: some View {
if limits.isEmpty {
Text("No vendor has reported a rate-limit status since byre started.").foregroundStyle(Palette.muted)
} else {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) {
GridRow {
ForEach(["Window", "Used", "Elapsed", "Pace", "Resets"], id: \.self) { h in
Text(h).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
ForEach(limits.keys.sorted(), id: \.self) { vendor in
vendorRows(vendor, limits[vendor]!)
}
}
}
}
@ViewBuilder
private func vendorRows(_ vendor: String, _ info: JSONDict) -> some View {
let statusText = info["status"].map { jsString($0) } ?? "unknown"
let tone = statusText == "allowed" ? Palette.ok : statusText == "rejected" ? Palette.danger : Palette.warn
let windows = dict(info, "unifiedWindows") ?? [:]
let rest = info.filter { !["status", "rateLimitType", "resetsAt", "unifiedWindows"].contains($0.key) }.sorted { $0.key < $1.key }
VStack(alignment: .leading, spacing: 8) {
GridRow {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(vendor).font(Fonts.font(13, weight: 700)).foregroundStyle(Palette.text)
Text(statusText).font(Fonts.font(11, weight: 700)).foregroundStyle(tone)
@@ -293,40 +302,30 @@ private struct RateLimitCard: View {
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if !windows.isEmpty {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) {
GridRow {
ForEach(["Window", "Used", "Elapsed", "Pace", "Resets"], id: \.self) { h in
Text(h).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
Divider().overlay(Palette.border)
ForEach(windows.keys.sorted(), id: \.self) { name in
let w = dict(windows, name) ?? [:]
let used = number(w["utilization"])
let elapsed = elapsedFraction(w)
GridRow {
Text(name)
Text(used.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
.foregroundStyle(used.map { $0 >= 0.9 ? Palette.danger : $0 >= 0.7 ? Palette.warn : Palette.text } ?? Palette.text)
Text(elapsed.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
PaceBar(used: used, elapsed: elapsed)
.gridColumnAlignment(.leading)
Text(number(w["resetsAt"]).map(resetText) ?? "—")
}
Divider().overlay(Palette.border)
}
}
.padding(.top, 12)
.gridCellColumns(5)
}
Divider().overlay(Palette.border)
ForEach(windows.keys.sorted(), id: \.self) { name in
let w = dict(windows, name) ?? [:]
let used = number(w["utilization"])
let elapsed = elapsedFraction(w)
GridRow {
Text(name)
Text(used.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
.foregroundStyle(used.map { $0 >= 0.9 ? Palette.danger : $0 >= 0.7 ? Palette.warn : Palette.text } ?? Palette.text)
Text(elapsed.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
PaceBar(used: used, elapsed: elapsed)
Text(number(w["resetsAt"]).map(resetText) ?? "—")
}
if !rest.isEmpty {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 2) {
ForEach(rest, id: \.key) { k, v in
GridRow {
Text(k).foregroundStyle(Palette.muted)
Text(v is JSONDict || v is [(any Sendable)?] ? compactJSONText(v) : jsString(v)).textSelection(.enabled)
}
}
}
Divider().overlay(Palette.border)
}
ForEach(rest, id: \.key) { k, v in
GridRow {
Text(k).foregroundStyle(Palette.muted)
Text(v is JSONDict || v is [(any Sendable)?] ? compactJSONText(v) : jsString(v))
.textSelection(.enabled)
.gridCellColumns(4)
}
}
}