From 9625be25fda69b3c3d7e6a4bdd9fcc1478372b24 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 18 Oct 2026 23:26:12 -0700 Subject: [PATCH] status usage bars should all line up --- docs/operations.md | 2 +- mac/Sources/Byre/Status.swift | 91 +++++++++++++++++------------------ 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index f2610d2..59c2948 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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____` 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. diff --git a/mac/Sources/Byre/Status.swift b/mac/Sources/Byre/Status.swift index 0f292a0..f000ef1 100644 --- a/mac/Sources/Byre/Status.swift +++ b/mac/Sources/Byre/Status.swift @@ -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) } } }