From 15d99790b2cc2b95d93913525662d38fe1845317 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Tue, 20 Oct 2026 10:20:25 -0700 Subject: [PATCH] Align rate limit table: one-line cells, separate Used percentage --- docs/operations.md | 2 +- mac/Sources/Byre/Status.swift | 56 ++++++++++++++++++++++++----------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 45c549c..f4b5bd8 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -270,7 +270,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 usage, headed `rejected` (with the `rejected` reason when there is one) when `rejected` is set or any window's `used` has reached its `limit` (else `allowed`) and the time since its `updated_at`, and per window its label, the amount used formatted by `unit` (`$150.94 / $200.00 · 75%` for `usd`, `36 / 40 requests · 90%`, `1.2M tokens` without a limit, `42%` for `percent`), the share of the window's time elapsed (from `resets_at` and `window_seconds`, `—` without both), 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`. +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 usage, headed `rejected` (with the `rejected` reason when there is one) when `rejected` is set or any window's `used` has reached its `limit` (else `allowed`) and the time since its `updated_at`, and per window its label, the amount used formatted by `unit` (`$150.94 / $200.00` for `usd`, `36 / 40 requests`, `1.2M tokens` without a limit, empty for `percent`), the share used in its own right-aligned column (`75%`; `used` itself for `percent`, `—` without a limit), the share of the window's time elapsed (from `resets_at` and `window_seconds`, `—` without both), 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, each cell on one line and the table scrolling sideways when it is wider than the pane; 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` (as `mcp____`, so byre's tools carry the same names as Claude's), `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 5ae8679..cac691b 100644 --- a/mac/Sources/Byre/Status.swift +++ b/mac/Sources/Byre/Status.swift @@ -60,9 +60,9 @@ private func percentText(_ v: Double) -> String { "\(v.formatted(.number.precision(.fractionLength(0)).grouping(.never)))%" } -private func usedText(_ w: UsageWindow, _ fraction: Double?) -> String { +private func amountText(_ w: UsageWindow) -> String { if w.unit == "percent" { - return percentText(w.used) + return "" } var s = amount(w.used, w.unit) if let limit = w.limit { @@ -71,12 +71,16 @@ private func usedText(_ w: UsageWindow, _ fraction: Double?) -> String { if w.unit != "usd" { s += " \(w.unit)" } - if let fraction { - s += " · \(percentText(fraction * 100))" - } return s } +private func usedText(_ w: UsageWindow, _ fraction: Double?) -> String { + if w.unit == "percent" { + return percentText(w.used) + } + return fraction.map { percentText($0 * 100) } ?? "—" +} + struct StatusView: View { @Environment(Store.self) private var store @ViewState private var status: ServerStatus? @@ -287,19 +291,35 @@ private struct RateLimits: View { if limits.isEmpty { Text("No vendor has reported usage 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]!) + ViewThatFits(in: .horizontal) { + grid + ScrollView(.horizontal) { + grid } } } } + private var grid: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) { + GridRow { + header("Window") + header("Amount") + header("Used").gridColumnAlignment(.trailing) + header("Elapsed").gridColumnAlignment(.trailing) + header("Pace") + header("Resets") + } + ForEach(limits.keys.sorted(), id: \.self) { vendor in + vendorRows(vendor, limits[vendor]!) + } + } + } + + private func header(_ title: String) -> some View { + Text(title).font(Fonts.font(11)).foregroundStyle(Palette.muted) + } + @ViewBuilder private func vendorRows(_ vendor: String, _ usage: VendorUsage) -> some View { let rejected = usage.rejected != nil || usage.windows.contains { w in w.limit.map { w.used >= $0 } ?? false } @@ -311,19 +331,21 @@ private struct RateLimits: View { Text("as of \(since(usage.updatedAt))").font(Fonts.font(11)).foregroundStyle(Palette.muted) } .padding(.top, 12) - .gridCellColumns(5) + .gridCellColumns(6) } Divider().overlay(Palette.border) ForEach(Array(usage.windows.enumerated()), id: \.offset) { _, w in let used = w.limit.flatMap { $0 > 0 && (w.used / $0).isFinite ? w.used / $0 : nil } let elapsed = elapsedFraction(w) GridRow { - Text(w.label) + Text(w.label).fixedSize() + Text(amountText(w)).fixedSize() Text(usedText(w, used)) .foregroundStyle(used.map { $0 >= 0.9 ? Palette.danger : $0 >= 0.7 ? Palette.warn : Palette.text } ?? Palette.text) - Text(elapsed.map { percentText($0 * 100) } ?? "—") + .fixedSize() + Text(elapsed.map { percentText($0 * 100) } ?? "—").fixedSize() PaceBar(used: used, elapsed: elapsed) - Text(w.resetsAt.map(resetText) ?? "—") + Text(w.resetsAt.map(resetText) ?? "—").fixedSize() } Divider().overlay(Palette.border) }