feat!: port every widget to Rust and remove the Python - #31
Conversation
A worktree and a branch to try the thing in. A cargo workspace under rust/, with toys-core holding what common.py holds - the terminal, the keyboard, seg/pad/draw/title/pack_hints - and one binary per widget. toys-core is libc and nothing else. The widgets will need crates for JSON, HTTP and timezones when their turn comes, but the terminal is an ioctl and a termios struct, and both are already in libc. ports is first because it exercises the parts everything else needs: /proc parsing, a subprocess, a table that drops columns as the pane narrows, and a poll thread behind a mutex. Side by side against the Python at 92x24, both report 24 listening and 22 of 24 drawn lines are byte-identical. Two faults found by comparing rather than by reading. The IPv6 decoder had the bytes of each word the wrong way round, which turned ::1 into ::100:0 and split every dual-stack row in two - 27 listening against Python's 24. And the kind table was missing the python and node fallbacks the Python keeps last, so an interpreter showed as its own basename. The test vector for the first of those was itself wrong, and made a correct decoder look broken; it is now a real address out of this machine's /proc/net/tcp6, checked against what ss prints for that socket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Second widget, and the one pane 008 runs. Same two sources as the
Python - ss -tine for the per-socket counters and the inode beside them,
/proc/<pid>/fd for the process that owns it - and the same behaviour that
took several rounds to get right there: sockets opened since the last
sample count in full, a reused inode is taken at face value rather than
subtracted, cgroups name the daemons /proc will not, and only traffic
that actually leaves the machine is counted.
The braille chart came over intact, tx above and rx below, each half on
its own scale with its own unsigned label.
With both running seventy seconds in the same size terminal, polling at
the same interval:
resident cpu seconds
rust 3.1 MB 0.89 s
python 16.8 MB 1.04 s
Memory is 5.4x smaller and that is a real difference. The cpu is not:
fourteen per cent, because the work is a subprocess and a walk of /proc,
and neither of those cares what language asked for it. That is worth
recording plainly, since speed was the thing a rewrite was supposed to
buy and speed is not what it bought.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Two more, both needing nothing new from the crate list beyond what the core now carries. latency keeps one ping per target running and reads it line by line, so the numbers are still ping's rather than anything this timed itself. The statistics came over as they are: jitter is the median absolute deviation, not the standard deviation, because one 400ms spike in a thousand samples is worth knowing about and is not what the link feels like - it belongs in the worst column, and the test says so. matrix computes nothing, which makes it a fair test of the drawing path, since it repaints every cell of every frame. Its randomness is eight lines of xorshift rather than a crate, seeded from the clock so two panes started together do not fall in lockstep. The core gained config loading, which is where serde_json enters: every widget reads the same file, and hand-rolling a parser thirteen times over would be a bug farm bought for nothing. The path precedence is the Python's, so one config serves both while the collection is half translated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Fifth widget, and the fiddliest parsing so far. `ss` mixes two shapes on one line - key:value pairs and space-separated ones like `delivery_rate 45107960bps` - and both are read, with anything unrecognised left alone rather than guessed at. The traps the Python hit are carried over with it: ::ffff:10.0.0.1 is unwrapped before the loopback filter sees it, or ::ffff:127.0.0.1 walks straight past and puts a 22-microsecond local socket on a log chart, flattening every real session against the ceiling. And the chart still condenses by median rather than mean, so one stall cannot define a column. Two differences the side-by-side caught, neither visible from reading the code. The table header had drifted to columns of my own naming; it is the Python's now, word for word, because the two have to sit in a wall together and read as one widget. And ms() rounds on link.py's own scale, which drops to microseconds below one - a loopback socket reads 22us, and 0.02ms hides what that means. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Sixth widget, and the first needing a real crate. Timezones come from chrono-tz, which embeds the IANA database: London is UTC+1 in August and UTC+0 in January, and a world clock that gets that wrong is worse than no world clock. It costs 1.8MB of binary - clocks is 2.3MB against the others' 300-500KB - and that is the right trade, because DST rules are exactly the kind of thing a hand-rolled parser gets subtly wrong. The pomodoro came with it, including the part that matters: hiding suspends it rather than merely concealing it. A timer that keeps counting out of sight is worse than no timer, because you come back to a focus block that expired half an hour ago. It also does not advance itself when a phase elapses - it rings and counts up - since a break that starts while you are mid-sentence is a break you ignore, and then the session count is a lie. Both are tested. Two more things the side-by-side caught, neither of them visible from reading the code. The pomodoro was hidden on the first frame, because I read "off until you press p" as invisible when it means paused. And "Start of Office Hour" is exactly twenty characters, so a twenty-wide label field ran it into the time beside it; there is now a test that fails if a label ever grows into its gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
I ported the state machine and the bell, stopped when the widget looked right, and flagged the rest as missing instead of finishing it. There was no reason for that beyond momentum, so here is the rest. The flash is derived from one timestamp rather than driven by sleeps, which is the whole trick: the render loop keeps running, so the clock stays live and keys stay responsive while the panel blinks. A lit frame is the same frame with its colours stripped and one loud background painted across every row, and the ink is chosen by the luminance of that background - a configured flash colour cannot make the panel unreadable at the moment it is trying to get attention. The Herdr toast ported without trouble, so it is in rather than skipped: it is one guarded subprocess call, and outside Herdr it does nothing at all. That is how the Python has it too - purely additive, nobody who is not running Herdr is affected either way. Verified by driving a real pomodoro to elapse with a config that gives it a one-second focus block: one bell, ninety-six rows repainted across the two blink windows, and the frame's content still legible underneath. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
The rate column flickered - a figure, then a dash, then a figure - and each of those readings was correct. The delta over one sample interval really is zero when a bursty process happens to be between bursts, and nearly all traffic is bursty. Correct and unreadable at the same time. Rates are now averaged over four seconds: the same arithmetic over a longer span, which is just as true and can actually be read. The header says which window, so the number is not a mystery, and the divisor is the span the samples actually cover rather than the nominal window - for the first seconds after launch there is less history than that, and dividing by four would read low. Totals are untouched. Smoothing a rate is honest; smoothing a total would not be. Watched on the busiest row: 362, 396, 395, 34, 34, 176, 609, 610 KB/s across eight seconds, where before it alternated between a figure and a dash. This diverges from netwatch.py, which still flickers the same way. Worth backporting if the Python is staying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Two faults, both spotted on screen rather than in the code. The config was never being found. The Python's third search path is "beside the script", and a compiled binary has no script - its own directory is target/release, where nobody would keep a config. So it fell through to the built-in four cities while a real file with nineteen sat in the project directory, and it did that silently, which is the worst part. The working directory now stands in for "beside the script", since that is the project directory when a widget is started from a pane, and the executable's own directory is kept last for a binary shipped with a config beside it. There is a test for the search path that would have caught this. The big clock was one flat colour. It is two: the top three rows bright and the base darker, which is what gives the digits their weight. And clocks.py has its own palette - its DIM is a green-grey where the rest of the collection uses a blue-grey - so the whole thing now takes its values from that file rather than from the house set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
The countdown bars were all one colour. They are three different clocks and the Python gives each its own: cyan for the hour, amber for the office, purple for the day. A single hue made them read as three readings of one thing. The pomodoro line was missing two behaviours as well. Paused takes its own ink, because a stopped timer showing focus red reads as a running one. And once a phase is overrun the bar goes two-toned and rescales to duration plus overtime, so the red share grows the longer it is ignored - the point being that it gets harder to miss rather than sitting there. The world clock was reading light and dark where the Python reads four states: asleep, weekend, working, and the evening either side. The glyph says light or dark and the colour says whether anyone is plausibly at a desk, which is the actual question being asked. It is also sorted west to east now, so the row order is a map. And the office countdown did not know about weekends at all: on a Friday evening it counted to Saturday morning. It now walks to the next working day, and the working week itself is configurable - work_days takes names or numbers, Monday being zero, and defaults to Monday through Friday with the existing nine-to-six hours. That is a step past clocks.py, which hardcodes Mon-Fri. One of my own tests was asserting the bug: it used a Saturday evening and expected twelve hours to "tomorrow". It now uses a Thursday, and says why it changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Yes, it counts to the close during working hours - and rather than say so, here is a test that walks a Monday-to-Sunday and asserts each case: inside hours counting down to six, the hour before opening counting up to nine, a weekday evening pointing at tomorrow morning, and Friday evening through Sunday all pointing at Monday. The boundary is in there too, since it is the one that gets written the wrong way round: five in the afternoon is still inside the working day. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
The keys existed and nothing said so. space, restart and advance were all bound and none appeared in the footer, which makes them unreachable unless you read the source. They are there now, with [±] to nudge the focus length and [0]reset, which stays hidden until there is a tally worth resetting. One key for the break, named for what it will do: [b]reak start during focus, [b]reak stop during one, and [b]reak start (long) on the block before a long one - the label follows the phase rather than being a fixed word like "skip" that describes neither direction. The tips start hidden. Four extra hints is a lot of bottom line for a timer that is usually just sitting there, and [?] is always on show to bring them back; show_hints in the config still decides either way. This is a step away from clocks.py, which starts them visible. [p]off now reads [p]omodoro off, and the cities hint leads the footer as navigation does in every other widget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
[±]25min was ambiguous in a way I had not noticed until it was asked about: it could as easily mean "sets it to 25" as "adjusts a 25-minute block", and it never said what the 25 minutes was. It now reads [±]1min (focus 25min), which answers both questions. The step says what the key will do, and is the half a footer is normally for. The value is worth carrying too, because it is the only place the block length appears while the timer is running - the countdown shows what is left rather than what it started from, and the bar is a fraction of a number that is not on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Running the two side by side at the same width is the only thing that catches this class of bug, and it found several. The table's data columns sat three cells right of their own headings - the glyph carried a leading space and the name column was padded to twenty rather than eighteen - and each half looked plausible on its own, so nothing inside the file could have flagged it. The LOSS column was reporting a different number entirely: retransmits since the socket opened, rather than since the last poll. link.py measures the recent figure deliberately, because a session hours old has long since forgiven whatever went wrong at breakfast. A quiet SSH connection read 0.34% here and 0.00% there, and the higher number was the wrong question. Also restored from the original: the round-trip cell's colour, which is judged against the socket's own minrtt rather than a fixed threshold, so forty milliseconds reads as excellent from Hong Kong and poor from the next rack; the dimming of unselected names; kbps rather than Kbps; "--" for an absent reading rather than an em dash, at the column's own width so a missing value cannot shift the row; and span()'s thresholds, which had been sitting half again past where the unit changes, calling ninety seconds "90s". The detail view had not been ported at all - Enter, escape, the [r]efresh key and their two footer hints were all missing, and with them the only screen that reports lifetime loss, pacing rate, packet size, reordering and the logins behind an address. `who` now keeps the tty and the repeats it needs to name two sessions from one laptop. The tests are pinned to a row captured from link.py in an 85-column pty rather than to my own reading of the code. Two of the existing ones asserted the drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
What was ported was the table. Everything reached by a key was not: the second screen, the kill, the addresses, tailscale serve and funnel, the cloudflare tunnel, the copy. The footer already advertised [r]efresh, which was not bound to anything either. This finishes it. The kill is the same three-step it is in ports.py, and for the same reasons. It asks first, and only y consents - every other key cancels, deliberately including q, because quitting must never double as agreement to signal something. It signals the process group rather than the process, which is what Ctrl-C does and what a dev server needs, since `npm run dev` is a shell, a package manager and the server sharing a group. It refuses a pid it does not own, one at or below 1, and its own group. Then it waits three seconds and offers SIGKILL only if the thing is still up. A zombie is read out of /proc/<pid>/stat rather than trusted to signal 0, which answers for one forever. The second screen resolves where a port can actually be reached, bounded by what the socket is bound to: a server on 127.0.0.1 is not reachable at this machine's LAN address however many addresses the machine has, and offering one to copy would hand somebody a URL that cannot work. A served port is the one exception, because Tailscale proxies to it over loopback. Funnel takes the first free of 443, 8443 and 10000 rather than defaulting to 443, so a node can hold the three Tailscale actually accepts. Unserve looks up the mount that was chosen at publish time instead of assuming one, and never `serve reset`, which would clear configuration this widget never made. The operator bit is read from `tailscale debug prefs`, so a node that would refuse every write says so on the line instead of after the keypress. Two things the Python does with a regex are done by hand here, and the first is the one that matters: a proxy target names :3000 or :3000/ and not :30001, which a substring search would have accepted - it would have reported a port as served and offered a URL answering nothing. There is a test for exactly that. Also fixed: span() changed unit half again past where it should - 90 seconds read as "90s" and ninety minutes as "90m". OSC 52 clipboard and a base64 encoder now live in core, where common.py keeps them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Enter was advertised in the footer and bound to nothing. Behind it in netwatch.py is the screen that answers the question the table only raises: which host, which socket, and which file is getting bigger. That is now here - endpoints folded across the sockets that share a peer, the individual connections, the open files with how fast each is growing since the screen opened, reverse DNS off the drawing thread, and per-process and per-endpoint charts on the same axes as the machine's. Connections and endpoints needed their own accounting, so the rolling window that processes already used is now one macro the three share. That is where the bug turned up. A sample reads every socket at one instant, so a process with fifteen sockets folded fifteen entries carrying the same timestamp. The window then spanned no time at all, and dividing by the epsilon that guarded against zero turned five megabytes into 963 GB/s - which pinned the chart's axis at a scale nothing else could reach and flattened four minutes of real traffic against the baseline. With no elapsed time there is no rate to compute, so the previous one now stands until the next sample gives the window a width. netwatch.py does not have this: it divides each socket's delta by the sample interval rather than by the window, and there is a test here for the case that separates them. Reverse lookups go through `getent hosts` off the drawing thread, so they honour /etc/hosts and nsswitch like everything else on the machine, and an address with no name is remembered as having none rather than asked about every second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
A test that wants a tailnet address does not need this node's tailnet address, and one that wants a cloud-internal address does not need this host's. Together they name the machine, and the repository is public. Replaced with generic addresses from the same ranges, plus RFC 5737 documentation space for the one public peer, which is what that space is for. The link row fixture was worse: it was captured live and carried a real client IP alongside the login it belongs to. The substitute is the same width, so the column alignment the test exists to prove is unchanged. Some of these are already in the pushed history. This stops them spreading rather than pretending they were never there. Also adds RFC 4648's own vectors for the base64 in core. It is hand-rolled and its output is invisible - the copy notice shows the URL whatever actually reached the clipboard - so a wrong encoder would have looked like it worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
clocks lost page-up, page-down, home and end from the city list, and two of the three mnemonics for advancing the pomodoro - clocks.py binds s, b and e to one action on purpose, because the thing you want is called skip or break or end depending on which end of it you are at. It also acted on space, r, 0 and +/- while the pomodoro was switched off, where the Python ignores them: a key that moves a timer nobody is running should do nothing rather than something invisible. The ±1 minute step stays as it is. That was decided here rather than inherited - clocks.py steps by five - and the footer says which it does. netwatch had --plain and --external missing, and answered an unknown option by ignoring it. --plain writes one block per interval to stdout and never touches the screen, so it can be redirected to a file and left running, which is the only reason it exists. An unrecognised option now exits 2 and says so, as the Python does: a typo that silently does nothing is worse than one that complains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
These were written to describe the reduced ports of the widgets, so they listed only the keys that had been ported and quietly dropped the rest - no kill, no second screen, no serve or funnel or tunnel, no copy. A --help that under-reports is the same defect as a footer hint bound to nothing: it tells you the feature is not there. Taken from the Python docstrings they now match, with only the invocation line changed, because a binary is not a script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Terminals send either \e[H / \e[F or \e[1~ / \e[4~ for the same two keys, depending on the emulator and on whether it is in application cursor mode. common.py has always accepted both; this accepted only the first, so Home and End worked on some clients and silently did nothing on others - which reads as a broken key rather than a missing one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Both plotted one glyph per sample and filled │ between the steps, so a round trip that moved quickly read as a column of marks rather than as a line. netwatch has drawn on a braille dot canvas since it was ported - two dots to a character across and four down, with consecutive samples joined - and these two now do the same. The side effect worth having is resolution: a cell that used to hold one sample holds two, so latency's chart reaches twice as far back and link condenses its window half as hard. netwatch plots one signal at a time and keeps a single grid of masks with a single colour. These charts carry several at once, so each series is drawn on its own canvas and the canvases are laid over one another at the end. Where two traces meet in a cell the dots are merged, so no sample is lost, and the colour goes to whichever series comes later in the table above. Only one of the two can have the cell, and this way which one is hidden follows from the order of a list on the same screen rather than from which sample happened to be drawn last. The glyphs stay in the table. A braille cell has no shape to lend a row, so the hue is the only thing left that ties a line in the table to a trace in the chart, and every hue is where it was. link's chart also stops shrinking as the window narrows. Its x axis is now anchored to the number of samples the longest session has, which is the same number the "N ago" under the corner is computed from, so the left edge and the label state the same thing. At the default one-minute window that fills the width rather than the right two-fifths of it, and a session younger than the chart still sits at its own share of the axis instead of being stretched across all of it. This is a deliberate divergence from latency.py and link.py, which still plot glyphs, and it was asked for. docs/link.md's account of the chart - one glyph and hue per session - now describes the Python only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
This one was never a port. It shared a name and a subject with latency.py and almost nothing else: four columns instead of seven, no sparklines, no event log, no spike detection, no interval, grouping or column keys, and five of the seven config keys ignored. It looked finished because the screen it drew was coherent, which is the only reason it survived a side-by-side comparison of the main view. What is here now: NOW, AVG, MEDIAN, MIN, MAX, JITTER and LOSS over the retained window; the per-target sparkline, scaled to that target's own range rather than the chart's, because the question it answers is the shape of one link's variation and the shared chart cannot show that for a target that never leaves a two-millisecond band; and the event log, which exists because a link that is fine except once a minute is a different problem from one that is slow, and the median in the table will never say so. Jitter is now the mean gap between one reply and the next, which is what the word means on a link. It had been the median absolute deviation - a defensible number, and not the one the column is headed with. The graph buckets samples onto a fixed time grid before plotting, so a sample never migrates between columns and the plot steps left exactly once per bucket instead of shuffling as the clock slides. [c] sets how many seconds a column covers and [g] how the samples inside one combine - median by default, because latency is right-skewed and a mean lets one spike misrepresent a whole block. A bucket with no reply stays a gap: joining across it would draw a line where the link was down. [i] changes the ping interval and applies it now, by signalling the running pings rather than waiting for them to end - at five seconds a change would otherwise take five seconds to appear, which reads as the key not having worked. The reader tells its own SIGTERM from an outage. The braille chart from the previous commit is kept, and the table's glyph column is gone with it: latency.py distinguishes targets by hue and uses the dot beside the name only to say whether the target is answering. The palette is latency.py's own nine rather than the six the other widgets share, since colour is now the only thing telling nine traces apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
I hand-rolled a time-of-day formatter to avoid a dependency, made it UTC, and wrote a comment justifying that on the grounds the timestamp is only compared against other lines in the same log. The same function feeds the header, which sits on a wall next to a clock panel showing server time - so on this box it read 18:31 beside a pane saying 02:31, which reads as a broken widget rather than as a different timezone. latency.py has always used local time. chrono is already a workspace dependency, so the reasoning was wrong twice over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Seventh widget, and the first of the nine that were still Python only. Agents ranked by whether they want a human, the panes running something ranked by what they cost, the idle ones by directory, and Enter focusing whichever is selected - the agent's pane, or the tab holding the process, since a pane has no focus-by-id but a tab tiles its panes. Two things moved into core on the way, both of which common.py owns and this is the third widget to want: heat(), so the same load reads as the same colour in every pane, and cannot_start(), which had been copied into latency and link. A widget that exits with a message loses it - it lives in a pane nobody is watching at the moment it starts - so that screen stays up until somebody presses q. Two things worth naming. The config section is `herdr_panes` with an underscore while the file, the binary and the pane are all hyphenated; reading the hyphenated name compiles, runs, and silently finds nothing, so there is a comment on it. And ago() is this widget's own formatter rather than the span() the others share: between an hour and a day it carries the minutes, because an agent blocked for 3h and one blocked for 3h58m are the same number of hours and a very different amount of ignoring. One difference from the Python, found by a test: mem() returns five characters for a reading and six for an absent one, so a process /proc would not name pushed the workspace column one cell right of every other row. The header allots five, so five it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Eighth widget, and the one that decides how the remaining four reach an HTTP API. Through curl, for the same reason every other source in this collection is a subprocess: these already read ss, ping, tailscale and herdr that way, and a TLS stack would be forty dependencies and a megabyte to do what curl does on every machine these run on. The headers go in on curl's standard input, never in its arguments. /proc/<pid>/cmdline is world-readable, so a token on a command line is a token handed to every user on the box for as long as the request lasts - which would be a strange thing for the widget whose whole design keeps that token out of the source tree. Verified by watching every process that appeared during a poll: three curl invocations caught mid-request, the token in none of their argv. The widget itself is the Python's: teams discovered from the token so deployments are not just personal, the 48-hour activity sparkline coloured by the worst outcome in each bucket, build-time median and p95, and the detail overlay fetched on demand rather than for two hundred deployments nobody asked about. A failed round keeps the last good list and shows the error beside it, because stale rows with a message say more than an empty board. config_token_warning() moves into core alongside the loader, where common.py keeps it: any widget taking a token writes it into the same file, so the check belongs beside the thing that reads it rather than in whichever widget needed it first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
start.py reads the folder it sits in and parses each script's docstring. A compiled binary has no folder to read, so the same three things are compiled in from the same files the Python parses at run time: the summary and the paragraph under it from each widget's help text, and the picture from its doc page. Nothing is described twice either way, and a widget still cannot appear here saying something its own file does not. Compiling the doc pages in has a second effect worth having: the menu is a single executable that shows what each widget looks like with no repository anywhere on the machine. Browsing still costs nothing - the preview is a still from the docs rather than the widget itself, because starting one to look at it would ping hosts and spend API quota. matrix is the one entry with no picture. It has no doc page, deliberately - check.py exempts it by name - and it is the one widget that computes nothing, so a still frame of it would carry nothing either. Keyboard gains reclaim(): restore() hands the terminal back and forgets the settings, which is right on the way out and wrong in a launcher, which has to return to cbreak after the widget it started exits. Keystrokes typed while the child held the keyboard are dropped rather than delivered to the menu a moment later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Tenth widget. GraphQL, so core learns POST with a body and the response headers that come with it - a rate limit is only knowable from a header, and a widget polling every two minutes should be able to say how much of its hour it has left. The key rides in on stdin like the Vercel one. The chart primitives common.py shares move into core with it: stacked_bar, meter, vbars, vbars_down, dance, mix and cycle. linear is the first to need them and pr and usage will want the same ones, and the alternative is three copies drifting apart on what a half-filled cell looks like. Two behaviours worth keeping that are easy to lose in a port. Cycles are ranked by what moved in the last six days rather than by deadline, because a cycle nobody has touched in a week is not interesting however close its end date - and an empty one scores zero and sinks without a special case. And when the window key changes what is being counted, the flow chart dances rather than showing the old numbers under a new heading, then eases into the real figures when they land; showing a 14-day count labelled 30d would be a lie told smoothly. Paging is capped at twelve pages per query so one enormous team cannot spin forever, and hitting the cap is reported rather than swallowed: "truncated" beside the count, because a floor presented as a total is the failure this repo cares most about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Two defects in the launcher, both mine and both shipped. linear was ported after the menu, so the menu listed eight of the nine binaries and would have kept doing that for every widget after it. start.py globs its directory, so a widget appears there by existing; here the list is compiled in, and a widget that ships with no way to find it is a real failure. There is now a test that reads the cargo manifest and asserts every [[bin]] except the launcher is on the menu, so forgetting one is a build failure rather than something somebody has to notice. And the footer advertised [r]echeck while the key matched nothing - exactly the defect the help-text commit existed to fix, reintroduced three commits later. start.py rescans its directory on r; there is no directory here, so there is nothing a recheck could find. The hint is gone, and the help says why rather than silently dropping a line the Python has. Found by running the key-table comparison over the four newest widgets, which is the tool that found the first four half-ported ones. herdr-panes, deployments and linear came back clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Eleventh widget. The union of several GitHub searches, because search has no OR; each PR remembers which sources found it, so narrowing to one is instant and costs no request. The detail view opens in stages with a spinner on the one in flight, and reconstructs a stack from branch names when GitHub has no native one - which is a tree rather than a line, so it draws the connectors properly. While comparing it against the running Python I found a real bug in pr.py, and the numbers are not small. OPENED / DAY buckets by `date.today()` - the local calendar day - while every createdAt the API returns is a UTC date. On this UTC+8 box the two are a day apart, so the window reserves a bucket for local-today that cannot fill until midnight UTC, and drops the oldest real day off the other end. Measured against the live data just now: 51 open PRs, and the local window counts 40 of them where the UTC window counts 50. The day it gains, 2026-08-23, holds 0 PRs. The day it loses, 2026-07-24, holds 10. The peak reads 7/day instead of 10. So this port buckets by UTC, and the two will disagree by design until pr.py is fixed. That is the right way round: a chart headed "last 30d" that silently covers 29 usable days is exactly the failure this repo cares most about. linear.py has the same line and the same problem; the linear port already buckets by UTC, so it is already on the right side of it. The token check was re-run against this widget: two curl invocations caught mid-request, the token in neither argv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Twelfth widget. Every figure comes from an aliased search's issueCount rather than from reading nodes, because a search connection returns at most 100 nodes a page - a busy fortnight lost everything past the hundredth record, and the merged series sorted by update time, so those hundred were not even the hundredth most recent. A count is exact at any volume and an aliased request costs one rate-limit point however many searches are packed into it. Two things carried over that are easy to lose. The day cache: a past day cannot change, so only days never seen before plus the trailing two cost a request - widening the window buys only the days it adds, and narrowing is free. And the two-phase pass: aggregates for every account first, so the headline is live in seconds, then the per-day counts, which can cost fifty requests on a cold 90-day window and would otherwise hold the whole board grey for minutes. Rows carry the window they were fetched for, so a half-updated board shows one account's real numbers beside another's shimmer rather than summing two windows together. The scope warning is the part worth having: a classic token without `repo` still searches happily and just returns public results, so every number comes back smaller with nothing to say it did. That is worse than an error, so it is named. A fine-grained token sends no scope header at all, and the check says nothing rather than guessing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Thirteenth widget, and the last before usage. Everything comes from the local tailscaled through its own CLI - nothing here is sent anywhere, and the DERP region names come from the local map rather than a geolocation service, which is the whole reason the info view can name a peer's city. The PATH column is the point. `tailscale status` will happily tell you a peer is online while every packet to it round-trips through a relay in another continent, and the two cases look identical unless you go looking. Direct and relayed are separate counts here, and the relay is named. Three pieces of judgement carried over intact. Peer names come from the first MagicDNS label rather than HostName, because iPads, Chromecasts and Pixels all report "localhost" and two Apple TVs report the same name. Private addresses are ranked so a real LAN address beats a docker bridge - an address inside a subnet the peer advertises wins outright, then 192.168, then 10.x, and 172.16-31 last. And PrimaryRoutes is filtered of 0.0.0.0/0 before any of that, or an exit node's route would match every address and defeat the ranking entirely. One ping process at a time, following the selection: probing two dozen peers continuously would be two dozen ping processes for data nobody is looking at. History is kept per peer, so coming back to one still shows its earlier samples. The running ping is signalled when the selection moves rather than left to finish into a history nobody will read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UVyXmobqdNzwQTy45b6sUr
Cursor grants a weekly included allowance for its Grok Bot, separate from the monthly plan. Nothing here showed it. Its spend was visible all along under the names Cursor gives it - `sand-default` and `sand-automation` are the top two rows of the 30-day breakdown - but there was no bar, no percentage and no reset, so the one number that says whether it is about to run out was the one number missing. `POST cursor.com/api/dashboard/get-sand-usage-status` is where it lives. It draws as a fourth bar under the three plan lanes and as its own lane on `[+]`, which it reaches through `lanes()` like every other quota. Three things about it are not like the rest of this tab. **It needs a different credential.** The dashboard host refuses the bearer token Cursor's app leaves in `~/.config/cursor/auth.json` - both as a token and as a cookie, it answers with a redirect to the login provider. Only a browser session cookie works, so it comes from `usage.cursor_cookie` in config.json rather than off the disk. Empty by default, and an account without one is unaffected: the three plan bars render exactly as before, which is verified rather than assumed. **It keeps its own week.** The plan lanes run to the monthly billing cycle; this resets weekly on a date the response states. Handing it the cycle's dates would put its pace marker in the wrong place and print the wrong countdown, so it carries `currentPeriodStart` to `nextResetTimestampUtc` through to both the bar and the summary, and its row prints its own reset because the heading above cannot speak for it. **It is drawn only when the account has one.** Cursor states that as `hasNonZeroIncludedLimit`, and a 0% bar for an account that was never granted the allowance would invent a limit that does not exist - which is also exactly what an untouched allowance looks like, so the flag is the only thing telling the two apart. The colour was measured, not chosen. The percentage is drawn in the bar's own hue, and the ramp had no room left: api at 0.62 already measures 5.29 against the background, the next step that reads as distinct from it measures 4.10 - under AA - and the step that clears at 4.66 is indistinguishable from api by eye. So it takes the full hue at 10.74 and a blank line above it, which also says the truer thing: it is not a fourth slice of the plan, it is a separate allowance. Both gates were watched to fail: dropping the allowance flag, and giving the lane the billing cycle's window instead of its own. Not verified against the live endpoint - there is no cookie on this machine, and the shape here follows the field names CodexBar's Cursor provider decodes. Set usage.cursor_cookie and the bar appears; if the answer disagrees, the row says so rather than the tab breaking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1fec797b2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
The previous commit read Cursor's documented route for this - `POST cursor.com/api/dashboard/get-sand-usage-status` - found it cookie-authenticated, confirmed the app's bearer token is answered with a redirect to the login provider, and concluded a browser session cookie was the only way in. So it shipped a `usage.cursor_cookie` config key, a lane nobody would see until they pasted a live session credential into a file, and a note admitting it had never been run against the real thing. All three were unnecessary. The same call exists on the Connect service the three plan lanes already use, as `DashboardService/GetSandUsageStatus`, and the token Cursor leaves in `~/.config/cursor/auth.json` is enough. The website's route being cookie-only says nothing about the RPC service's, and checking took one request. So the config key is gone from the code, the example and the docs, and the lane now appears on its own for anyone with Cursor installed. Verified against the live account rather than against a fixture: the weekly allowance reads 42%, and it draws on both surfaces with its own window - `4d 20h` on the Grok Bot row beside `17d 1h` on the three plan lanes, which is the visible proof the two are not sharing a clock. The summary heading went from 10 limits to 11. The docs keep the dead end rather than quietly presenting the answer, because the website's route is the one that turns up first and following it costs a credential in config.json for nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1b33341f69
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| rows.push(tc::seg( | ||
| &[ | ||
| (ink.as_str(), format!(" {} ", glyph)), | ||
| (p.txt.as_str(), tc::pad(&city.name, 16)), |
There was a problem hiding this comment.
Allocate pane width to city names
For any configured city name longer than 16 characters, tc::pad permanently truncates the name even when the pane has ample unused width, making similarly prefixed locations indistinguishable. Compute this column from the available width (and drop less important columns when narrow) rather than imposing a fixed cap.
AGENTS.md reference: AGENTS.md:L42-L43
Useful? React with 👍 / 👎.
**Cursor's bars are in the same order on both screens.** The summary sorted every agent's lanes by percentage except Claude's, whose exemption was written down as "except where the lanes nest". Cursor's nest too - api inside auto inside included - and now carry a fourth that is not part of that ramp at all, so the summary was reordering them by number and putting Grok Bot first while the agent's own tab drew them in scope order. Two screens disagreeing about the order of the very same four bars. Cursor joins Claude in keeping its own order, and the break above Grok Bot is carried on the lane itself rather than inferred from its label, so the tab and the summary cannot drift apart on it again. **Switching tabs lands at the top.** The offset was kept per tab so that switching away and back returned you to where you were reading. In use that is the wrong trade: the tabs are different lengths and shapes, so a remembered offset opens the next one part-way down with its heading scrolled off, and the first thing anyone does on arriving somewhere new is look at the top of it. **Grok is judged by the age of its reading, not by where it came from.** This is the rule claude.rs already had, and its comment already says why: marking by source flags the fresher of two readings as the doubtful one. Here the same mistake was hiding a worse one. `quota_from` refused any answer without `creditUsagePercent`, and x.ai has stopped sending it for accounts on unified billing - both `/v1/billing` and `?format=credits` answer 200, name the current weekly period, and omit it, every other figure zero, three months of history zero. So a working ping was throwing its answer away and falling back to the newest log line, which on this machine was written eleven days ago about a window that had closed a week before that. The row said "not live" whether the ping worked or not, and turning it on changed nothing anyone could see. A named period is a reading now, percentage or not, and the server's answer wins. Where it names the window but no figure, the log's percentage is taken only if it is about that same window, because a percentage from a window that has closed is not this one's. Otherwise the row says there is no figure rather than drawing an empty gauge, which would read as nought per cent used - and the section is no longer hidden outright for it, which had left the tab blank where a reader cannot tell an account with no quota from a widget that has stopped working. With no percentage there is no bar to rank, so Grok moves to "No quota published by" on the summary. That line covered two situations wanting opposite things from the reader - nobody is asking, or the ask worked and x.ai had nothing to report - so it now says which, the way Antigravity's already did. Measured on the live account: the tab reads `live · polled x.ai just now` over `window 26 Aug → 2 Sep`, where it had read `not live` over a `12 Aug → 19 Aug` window rolled forward with a `~`. The test that asserted a live reading is "current by definition" asserted the rule this replaces; it now covers both directions and the undateable case, and was watched to fail on each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bc1d629d1a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| let title: String = if title.chars().count() > room { | ||
| format!("{}…", title.chars().take(room.saturating_sub(1)).collect::<String>()) |
There was a problem hiding this comment.
Preserve full titles in the oldest-PR list
Whenever an oldest open PR title exceeds the remaining width, this permanently replaces the unseen suffix with an ellipsis, even though this is a scrollable detail page where the title could wrap onto continuation rows. Ordinary PR titles exceed the available space at common terminal widths, so users cannot read the full item they are selecting; wrap the title and let less important columns give way instead of truncating it.
AGENTS.md reference: AGENTS.md:L42-L43
Useful? React with 👍 / 👎.
| | `↑` `↓` / `j` `k` | select a widget | | ||
| | `↵` | launch it, and come back here when it quits | | ||
| | `r` | recheck what is installed and configured | | ||
| | `↵` / `→` | launch it, and come back here when it quits | | ||
| | `q` | quit | |
There was a problem hiding this comment.
Remove the stale recheck key from the start guide
Although this updated key table correctly removes r, the same guide still advertises [r]echeck in its opening footer and says the launcher rescans the directory “on r” in the Cost section. The Rust launcher has no r match arm and its help explicitly says there is no recheck key, so readers following the example press a key that does nothing; update those remaining Python-era passages alongside this table.
AGENTS.md reference: AGENTS.md:L66-L71
Useful? React with 👍 / 👎.
The previous commit read x.ai's credits endpoint returning no
`creditUsagePercent`, checked that every other figure it sent was zero
and that three months of history were zero too, and concluded the field
had been withdrawn for accounts on unified billing. So the row said
there was no figure for the period. The true figure was nought, and the
evidence was inside the same response the whole time.
Beside the credit percentage the endpoint returns `productUsage`, one
entry per product. On this account:
[{"product":"GrokBuild","usagePercent":1.0},
{"product":"GrokChat"},{"product":"GrokImagine"}]
The product with usage carries the key. The two at nought omit it - same
array, same answer, same serialisation. It is proto3 leaving out a
scalar at its default, not a field being taken away.
Confirmed over time as well, which is the part that could not be faked:
this account's weekly window reset at 02:09 with nothing spent and the
endpoint sent no percentage at all; once something had been spent it
began sending one, rising 1.0 -> 3.0 across successive polls on the same
endpoint, the same headers and the same token.
An A/B ruled out the other candidate before it reached the code. Codexbar
sends `x-xai-token-auth: xai-grok-cli` where this did not, and our own
earlier 401 had named `x_xai_token_auth=none`, which made the missing
header look like the cause. Three variants - bare, with that header, with
`Accept: application/json` - all returned the same percentage at the same
moment. The header changes nothing; the spend had changed.
So an absent percentage against a named period reads as nought, and the
comment carries the evidence rather than the conclusion. A response
naming no period is still refused: nought is only knowable against a
window the server stated. A percentage present on the wire is taken as
sent, a real 0.0 included.
`productUsage` is kept and drawn under the window. The bar above is one
number for three different things, and which of them is spending is the
part a reader can act on.
Measured live: 3% used, window 26 Aug to 2 Sep, `by product GrokBuild
3.0% · GrokChat 0% · GrokImagine 0%`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f19b1540a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
The summary lists agents publishing no quota, and gives a reason for the one that can. Antigravity's reason came from `tier_note`, which answers a different question - what is wrong with the credential - and returns nothing at all when the credential is fine. That is the ordinary case, so the ordinary outcome was "No quota published by: antigravity." followed by silence. The real reason is not a fault and is worth stating plainly: this is the only agent here with no account-wide quota endpoint. Every other tab can report a limit from a server whatever is running locally. Antigravity's percentages come from a language server that lives inside the running app - found by matching the process and reading its listening port out of /proc - so with the app closed there is nothing to ask, on any machine, for anybody. Its own tab has always said so. The summary now does too, still deferring to the tier reason when that is what is actually wrong, so the two cannot both be printed at once. Nothing changed about what is fetched or drawn - only whether the screen says why a section is absent, which is the same rule the rest of this widget already follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 783c69ad03
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
`[+]` led its quiet block with one roll-call - "No quota published by: grok, antigravity." - and put the headings explaining them underneath. That reads backwards. A reader meets a list of names, then has to carry those names down to the paragraphs to find out which is which. And it said the same thing twice for every agent that had a reason, because each reason already opens by saying there is no quota. Each agent that can explain itself now leads with its own heading and the sentence sits under the name it is about. The roll-call is what is left over: only agents with nothing to say appear in it, and when they have all explained themselves there is no such line at all. Split into `quiet_block` to be testable. The State it would otherwise need cannot be built from another module - every agent's Data keeps its fields private - so a pure function over the notes is the only shape the ordering can be pinned in. Two tests: the heading precedes its sentence and no roll-call remains when everything is explained, and an agent with nothing to say is still named while an explained one is not named twice. Both were watched to fail against the old order. Antigravity keeps the warning tone, because its reason can be a credential that has lapsed, which is the reader's to fix. Grok's stays dim: nothing is wrong there, the server simply has nothing to report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db49a37d04
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| }; | ||
| format!("{}{}", tint, colour) | ||
| }; | ||
| let name: String = a.name.chars().take(6).collect(); |
There was a problem hiding this comment.
Allocate enough width for full agent names
Every Herdr agent name is permanently cut to six characters here, even on a wide pane, and the normal row's continuation line contains only its working directory and title. Agents with the same six-character prefix are therefore indistinguishable until one is focused; size this column from the available width or wrap the full name instead of truncating it.
AGENTS.md reference: AGENTS.md:L42-L43
Useful? React with 👍 / 👎.
An hour ago this widget said, and its doc said in the strongest terms,
that Antigravity was the one agent with no account-wide quota endpoint at
all - that with the app closed there was nothing to ask, on any machine,
for anybody. That was wrong, and reading how CodexBar does it is what
showed it: among its four sources is a Google OAuth path this had none of.
Google serves the same summary the language server does, on the endpoint
the tier already comes from:
POST cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary
Same bearer token out of the same file. The reason it looked absent is the
body. `loadCodeAssist` wants `{"metadata":{"pluginType":"GEMINI"}}`, and
sending that here answers 400 naming `metadata` as an unknown field, which
is indistinguishable from an endpoint that does not exist. It takes `{}`.
What comes back needs no parser of its own: `groups[].displayName`,
`buckets[].window`, `remainingFraction`, `resetTime` - group for group and
bucket for bucket what the local server sends. Measured against it on this
machine within a minute: Gemini weekly 0.05% local, 0.08% remote.
The local server stays preferred. It is the app's own answer and moves as
the app is used, where Google's is a record; and it is on this machine,
where the other is a request that leaves it. So the remote one is asked
only when there is no server to ask, held for an hour rather than two
minutes, and the heading says which was read - `from Google - the app is
not running` against `from the local language server`.
Verified by disabling the local probe on a build and watching the tab draw
real numbers from Google with the app still running, then restoring it.
What still cannot be recovered is a lapsed token, so the quiet note now
names both sources rather than sending a reader to open an app that would
not have helped.
Not taken from CodexBar: it also launches the `agy` CLI in a PTY to get a
server where none was running, and reaps its own stale processes. That is a
reader starting somebody else's program, which this repo has already
decided against for Grok, and the remote path makes it unnecessary here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 927b24ddd2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
…witch `usage.antigravity_remote`, on by default. It decides one thing: whether Antigravity's quota may be asked of Google when no language server is running. On rather than off, unlike `grok_ping`, because the request is not the same kind. Grok's asks a vendor for a reading nothing on this machine has; this asks for the reading the app itself serves over localhost, from the host the tier is already fetched from every hour, with the same credential. Turning it off spares nothing that request has not already spent, and costs the quota whenever the app is shut. The larger half is that "no quota" covered four situations wanting different things from the reader, and now says which: - asking Google is off - set usage.antigravity_remote to true - Antigravity has not signed in on this machine - run `agy` once - Antigravity's token expired 51m ago - it refreshes them itself - Google refused the Antigravity token: <what it said> The first three are decided before the request and before the cache, and that ordering is the fix rather than a tidiness. `cached` holds a refusal without re-running the closure that produced it, so a reason captured inside that closure is gone by the next frame. Measured: the tab reported "Google did not answer" about a token that had expired an hour earlier and was never sent. Both the tab and `[+]` carry it. The tab's sentence used to say the quota "comes from the language server while Antigravity is running, so start it" - true, and the wrong instruction for three of the four cases. Two things were tested before being relied on. `agy models` runs non-interactively and authenticates, but the token file is byte-identical afterwards: it refreshes in memory and writes nothing back, so running it cannot extend the remote path's hour. And this machine's `agy` log reads "You are not logged into Antigravity", which is why the remote ask is refused here at all - a signed-out session, not merely a lapsed hour, and exactly the case the old sentence would have sent someone to fix the wrong way. Not taken from CodexBar: launching `agy` in a PTY and holding it open for its local server. That is a reader starting somebody else's program and keeping it running, and it is the one thing here that would need to be off by default rather than on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 878ec57193
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| fn run(args: &[&str]) -> String { | ||
| // Bounded: .output() waits forever, and a wedged child used to freeze | ||
| // the poll thread with the pane still showing its last frame. | ||
| tc::run(args, RUN_TIMEOUT).unwrap_or_default() |
There was a problem hiding this comment.
Surface failures from the
ip address lookup
When ip -o addr is unavailable, times out, or fails, this converts the error into an empty address list and continues without warning. With the default external-only filter, a connection to one of the host's own public addresses is then classified as off-box traffic and included in the totals, even though it never reaches the wire; propagate the lookup failure to the pane or declare and check ip as a required tool.
AGENTS.md reference: AGENTS.md:L32-L35
Useful? React with 👍 / 👎.
`usage.antigravity_start`, on by default. When nothing else has the quota, the widget starts `agy`, reads what it serves, and stops it again. Three sources now, cheapest first: a language server already running, then Google, then the CLI started here. Being last is not being disfavoured, it is being expensive - the first costs a socket read, the second one request, the third a process and several seconds. Only the third always works, and only the third runs another program. Both switches are the reader's: `antigravity_remote` for the request that leaves the machine, `antigravity_start` for the one that starts something. Four things were measured rather than assumed, and two of them changed the design. The pseudo-terminal is required. Started with its input on /dev/null the CLI opens ports within milliseconds and answers nothing on them for as long as you wait; given a pty it serves the quota in seconds. CodexBar's note that this needs a pty is exactly right, and it is the difference between the feature working and appearing not to exist. The port that answers belonged to a *child* of the process launched, so the search covers descendants. Scoped to the pid alone it finds two ports that answer nothing and gives up - which is what the first attempt did. `agy` never persists a refreshed token, so starting it cannot extend Google's hour: this was tested by backdating the `expiry` field and running `agy models` against it, and the file came back byte-identical, mtime included. That killed the simpler design, where a short run would have refreshed the credential and the remote path would have done the rest. And it is http, not https, wrapped in a `response` envelope the parser already unwrapped for other builds. Nothing outlives the fetch. The child is killed by the pid `forkpty` returned and then reaped - never matched by name, so a CLI the reader started themselves cannot be shut down by this, and would have been found by the first source long before the third ran. Verified by counting `agy` processes either side of a frame that used it: zero both times. Not built: CodexBar's other half, its own Google OAuth login writing credentials it owns to ~/.codexbar. That needs an interactive browser flow and Antigravity's OAuth client id and secret, and it buys nothing the third source does not already give. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
The README had grown to carry three audiences at once: someone deciding whether to run any of this, someone configuring it, and someone changing it. The third is the largest and the least urgent, so it moves out. `docs/design.md` takes the conventions - width before padding, never truncate a hint, measure contrast rather than eyeball it, say what a number means. They are for whoever changes a widget, and each was paid for by something that shipped wrong first. `docs/internals.md` takes `toys-core`, the chart helpers, the two braille canvases that are deliberately not shared, and what `widgets/tests/check.rs` reads the sources for. It says which rule each check is defending, so the two pages are the same subject from either end. `docs/README.md` is new: one line per widget, taken from that page's own first sentence rather than written twice, and the four repository pages under it. `matrix` is listed as having no page on purpose - a document explaining that it computes nothing would be the joke explained. Every page under docs/ now opens with a link back to that index, which none of them had. The widget table in the README still links straight to each page, so nothing got further away. README is 209 lines down to 164, and nothing was deleted - the two new pages are where it went. Every relative link in every markdown file was resolved against the filesystem; all 60 land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08eb73b718
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| "--sort" if i + 1 < args.len() => { | ||
| sort_live = args[i + 1] == "live"; |
There was a problem hiding this comment.
When --sort is given any value other than live, this silently selects the total ordering, so a typo such as --sort livet runs successfully with different behavior than requested. The documented choices are total|live, and the replaced Python parser exited with status 2 for any other value; preserve that validation instead of treating every unknown mode as total.
Useful? React with 👍 / 👎.
| let name_w = avail | ||
| .saturating_sub(if wide { 33 } else if mid { 11 } else { 0 }) | ||
| .clamp(8, 26); |
There was a problem hiding this comment.
Let process names use the available width
On wide terminals, this hard cap leaves dozens of columns unused while every process name longer than roughly 24 characters is permanently cut off in the overview. Processes with a shared prefix become indistinguishable despite ample room to show their full names; allow this column to consume the remaining width rather than capping it at 26.
AGENTS.md reference: AGENTS.md:L42-L43
Useful? React with 👍 / 👎.
Default setup was still scanning `python` in a repository whose last .py file was deleted four days ago - which is to say scanning nothing. It cannot be pointed at Rust either: its API accepts actions, c-cpp, csharp, go, java-kotlin, javascript-typescript, python, ruby and swift, and answers 422 for `rust`. Advanced setup can. Rust is a builtin language in github/codeql-action, public preview since CodeQL 2.22.1, and it needs no build step - the extractor reads the sources, which is how the action's own Rust check runs. `actions` is scanned beside it, because this repository now publishes a release workflow that handles a tag and writes to the releases API. The action is pinned by sha, as the release workflow pins its own, with `security-extended` rather than the default suite: fourteen binaries that read a machine's state and hold three API tokens are worth the extra queries. It does not run yet, and the file says so at the top rather than looking broken. GitHub disables advanced workflows while default setup is on, and default setup here is pinned by an enforced organisation configuration, so the repository cannot turn it off - the API answers "controlled by organization administrators". Detaching the repository from that configuration would do it and is the wrong move: the same configuration enforces secret scanning, push protection, Dependabot alerts and private vulnerability reporting. Giving up push protection on a public repository whose first rule is that secrets never enter the tree, in exchange for static analysis, is a bad bargain. A sibling configuration with only code_scanning_default_setup disabled, attached to this repository alone, costs nothing and is the thing to do - it needs an organisation admin, so it is not done here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad75b06129
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| poll_teams.clone() | ||
| }; | ||
| for team in &scopes { | ||
| let mut path = format!("/v6/deployments?limit={}", limit); |
There was a problem hiding this comment.
Label deployment aggregates as capped
When a team has more deployments than deployments.limit—including more than 100 within the displayed 48-hour window—this fetches only one page, but the header, state counts, activity chart, and build-time statistics present that subset without qualification. Paginate the endpoint or label these figures as covering only the most recent configured number per team.
AGENTS.md reference: AGENTS.md:L48-L49
Useful? React with 👍 / 👎.
| let held = oldest.lock().ok().and_then(|g| g.get(&key).cloned()); | ||
| if held.is_none() { |
There was a problem hiding this comment.
After an account is opened once, this cached value remains present for the process lifetime, so neither scheduled refreshes nor r fetch the oldest PRs again. A transient failure on the initial request therefore leaves that account's detail screen permanently errored, while a successful request continues showing PRs after they are closed or merged; add a TTL or invalidate this cache during refresh.
Useful? React with 👍 / 👎.
| let last = h - 1; | ||
| rows[last] = tc::seg( | ||
| &[ | ||
| (tc::rgb(70, 100, 80).as_str(), " [q]uit".to_string()), |
There was a problem hiding this comment.
Raise the matrix quit hint contrast
The (70, 100, 80) foreground used for the only key hint measures approximately 3.20:1 even against black, below the required WCAG AA 4.5:1 threshold. This makes the sole discoverable exit instruction difficult to read; use a measured lighter color.
AGENTS.md reference: AGENTS.md:L46-L47
Useful? React with 👍 / 👎.
It ran, and it worked. The Rust extractor read the whole crate and built a database; only the submission was refused - "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled", which is the block this file already documents. That left a check that can only ever be red, on the PR now and on every push to main after the port lands. A check that cannot go green teaches nothing and trains people to ignore the colour, so the automatic triggers are parked and written down verbatim in the comment beside them, ready to be pasted back. `workflow_dispatch` stays, so it can still be run by hand to confirm the analysis end to end. Nothing about the analysis is in doubt - it is the one part already proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d981e01c4f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| } | ||
| // Cargo walks a directory watch recursively, so these two cover every | ||
| // source file in the workspace without naming one. | ||
| for dir in ["../core/src", "../widgets/src", "../Cargo.toml", "../Cargo.lock"] { |
There was a problem hiding this comment.
Watch embedded docs when computing the dirty stamp
When a clean build is followed by an uncommitted edit under docs/, Cargo rebuilds start because widgets/src/bin/start.rs embeds those pages with include_str!, but this build script does not rerun. The rebuilt binary therefore contains modified documentation while TOYS_COMMIT retains the previous clean SHA without -dirty; add the embedded documentation tree to the watched paths so --version continues to identify the source actually shipped.
AGENTS.md reference: AGENTS.md:L159-L170
Useful? React with 👍 / 👎.
|
Cursor (@cursor) /babysit |
|
I pushed This batch restores the terminal on Ctrl-C (async-signal-safe
|
Ctrl-C skipped Keyboard Drop, so the shell lost echo, and a negative refresh panicked the poller after the first read — the same silence a dead thread leaves. The handler now restores termios with async-signal-safe calls, poll_secs clamps every wait, an empty system_ports list stays empty, and a widget killed by a signal is no longer reported as success. Co-authored-by: wiiiimm <email@wiiiimm.codes>
|
Babysit triage of the open review surface, against
wiiiimm (@wiiiimm) this round is the last I will spend on review comments. Remaining open threads are polish / diminishing returns; please merge when you are happy with CI. I will not self-merge. Org CodeQL Analyze (python) stays red: there is no Python left, and that check is org-enforced, not something this PR can turn off.
88 threads that were fixed or already in the tree are resolved. 25 leftover polish threads stay open so they remain visible.
Codex has not re-reported on Pausing the auto-loop. Good to merge on substance once required checks settle and you are satisfied. |
|
wiiiimm (@wiiiimm) CodeRabbit did not re-review
Staying paused. Merge call is yours — I will not self-merge. |


All fourteen widgets, ported to Rust as self-contained binaries, and the Python removed.
common.pybecomestoys-core; the seventeen.pyfiles are gone and the crate now sits at the repository root.130 commits. 356 tests. Every binary launches and draws against real data — checked in a pty, not by reading.
What is here
coreholds whatcommon.pyheld: terminal sizing, the full-frame draw, 24-bit colour, segment clipping, hint packing, the bar and chart helpers, the non-blocking keyboard, OSC 52 clipboard.widgetsbuilds fourteen binaries against it.cargo build --releaseis the whole build;lddon a release binary shows only libc, libm and libgcc, becauserusqliteis takenbundled.usageis one file per agent behind a fixed interface, because the six agents do not agree on what usage even means — only the quota lanes are common, and only because the summary screen has to rank them against each other.Bugs this found in the Python
Porting is a close reading. These were fixed in the port and, since the originals are now deleted, are fixed for good.
pr.pyandlinear.pybucket day charts by local date while the API'screatedAtis UTC. On a UTC+8 box: 51 open PRs, the local window counts 40, UTC counts 50. The peak reads 7/day instead of 10.usage.py's Codex calendar is built from non-de-duplicated records while the totals beside it use de-duplicated ones — two numbers on one screen that cannot both be true, and the function's own comment names the fault.usage.pyfabricates a Copilot pace figure for any cycle that is not a calendar month:quota_windowrefuses in the display string, then the span is derived unconditionally anyway.usage.pypresents partials as totals — Antigravity's locked conversations are skipped from the sum and the remainder shown as the total, so nine busy conversations render asagent steps 0.usage.pykeys a NULLcreated_atas"None", which sorts above every real date and counts into today's window forever.tailnet.py'sseen()is wrong off UTC — a peer last seen 2 hours ago reads as ~22.pct_text(None),split()[0]on a whitespace-only display name, and a sort key that raises when a pool omits itsunlimitedflag.Bugs the port introduced, found by reviewing it
A Fable review of the whole tree, a key-table comparison against each Python original, and two rounds of PR review since.
core::decodeturned one unmapped key into several real ones. It advanced a single character past any escape sequence not in its table, so F9 arrived asesc,[,2,0,~— and0resets the pomodoro count,1and2reorder netwatch. Pressing an unrelated function key silently did something.ssread the clock advanced while the counter baselines did not, so the next good read divided a normal delta by a window grown across the failure and reported 1000500 where the truth was 500.iso_epochgave two answers for one instant depending on whether the zone was writtenZor+00:00, and rates are computed by dividing tokens by the gap between records..output(), which has none.catch_unwindcannot fire underpanic = "abort", so three widgets read as protected and were not.clockspromised persistence it did not have, and computed its working day by adding hours to midnight — an hour out on both DST changeover days.herdr notification showcall passed the body as a positional where the CLI takes--body. That toast had never once been shown.usizecannot hold −1, andusize::MAX % lenis only correct when len is a power of two.githubstopped at 20 orgs,linearat one page of cycles, teams and milestones,deploymentsat one page of teams,prat 25 check contexts. Where the truncated list drives later work it now paginates; where it is only displayed it says25 of 208 fetchedorat least 664 open.herdr-paneswas the worst: a failedpane process-infoexits 0 and prints an error object, so the read succeeded, the JSON parsed, and an unreadable pane was indistinguishable from one sitting at a prompt.On tests
Eight tests were found that specify a bug rather than catch it — each would have to be changed for its bug to be fixed. The worst asserted 2500 B/s for a steady 2000 B/s stream and narrated the overcount in its own comment as the intent. Fixes here were verified by reverting them and watching the test fail, because a passing test is not evidence until it has failed for the right reason.
check.pyis replaced bywidgets/tests/check.rs, which runs withcargo testand now holds ten checks — a poller that dies without recording why, a footer hint naming a key no match arm answers, a hint missing from the widget's doc, a config key absent fromconfig.example.json, a section nobody reads, and text that fails WCAG AA against the selected-row tint. Each exists because something shipped broken and looked, on screen, exactly like "there is no data". One of them was itself blind: it read a line at a time and so could not see a config read that wrapped, which is how two realclockssettings stayed undocumented.Releases
.github/workflows/release.ymlbuilds on a tag, gating on the tag matching the manifest, onpanic = "unwind", and on a cleanldd. Version and commit are stamped at build time and reported by--version. Linux x86-64 only for now; Apple Silicon is tracked separately.Deliberate deviations
Recorded in
docs/port-decisions.md. An unreadable source reports as unknown rather than zero; a quota renders even when the local half is missing, because it is a fact about the account and not the disk; netwatch averages a rate over a stated window rather than showing an unreadable instantaneous one.Not ported
__main__.py— a binary has no module entry point.🤖 Generated with Claude Code
https://claude.ai/code/session_01JSw8PExXQWsc9oP79JWFe1