Skip to content

feat!: port every widget to Rust and remove the Python - #31

Merged
wiiiimm (wiiiimm) merged 147 commits into
mainfrom
worktree-rust-port
Aug 26, 2026
Merged

feat!: port every widget to Rust and remove the Python#31
wiiiimm (wiiiimm) merged 147 commits into
mainfrom
worktree-rust-port

Conversation

@wiiiimm

@wiiiimm wiiiimm (wiiiimm) commented Aug 24, 2026

Copy link
Copy Markdown
Member

All fourteen widgets, ported to Rust as self-contained binaries, and the Python removed. common.py becomes toys-core; the seventeen .py files are gone and the crate now sits at the repository root.

BREAKING CHANGE: the Python implementation is removed. python3 -m terminal_toys, the *.py entry points and check.py are gone; the
fourteen widgets are Rust binaries built with cargo build --release,
and the crate now sits at the repository root rather than under rust/.
Config keys carry over, with the three renames recorded in
docs/port-decisions.md.

130 commits. 356 tests. Every binary launches and draws against real data — checked in a pty, not by reading.

What is here

core holds what common.py held: 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. widgets builds fourteen binaries against it. cargo build --release is the whole build; ldd on a release binary shows only libc, libm and libgcc, because rusqlite is taken bundled.

usage is 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.py and linear.py bucket day charts by local date while the API's createdAt is 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.py fabricates a Copilot pace figure for any cycle that is not a calendar month: quota_window refuses in the display string, then the span is derived unconditionally anyway.
  • usage.py presents partials as totals — Antigravity's locked conversations are skipped from the sum and the remainder shown as the total, so nine busy conversations render as agent steps 0.
  • usage.py keys a NULL created_at as "None", which sorts above every real date and counts into today's window forever.
  • tailnet.py's seen() is wrong off UTC — a peer last seen 2 hours ago reads as ~22.
  • Three latent crashes that a typed port makes unreachable: pct_text(None), split()[0] on a whitespace-only display name, and a sort key that raises when a pool omits its unlimited flag.

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::decode turned one unmapped key into several real ones. It advanced a single character past any escape sequence not in its table, so F9 arrived as esc,[,2,0,~ — and 0 resets the pomodoro count, 1 and 2 reorder netwatch. Pressing an unrelated function key silently did something.
  • Every netwatch rate read ~25% high — it summed n samples' bytes over n−1 intervals of time. A later round found the other half: on a failed ss read 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_epoch gave two answers for one instant depending on whether the zone was written Z or +00:00, and rates are computed by dividing tokens by the gap between records.
  • Subprocesses could wait forever. The Pythons bound every external command; the port called .output(), which has none.
  • The poller guards did not exist in the shipped binarycatch_unwind cannot fire under panic = "abort", so three widgets read as protected and were not.
  • clocks promised persistence it did not have, and computed its working day by adding hours to midnight — an hour out on both DST changeover days.
  • A herdr notification show call passed the body as a positional where the CLI takes --body. That toast had never once been shown.
  • The left arrow moved forwards on the first tab. usize cannot hold −1, and usize::MAX % len is only correct when len is a power of two.
  • A page of results was repeatedly reported as a total. github stopped at 20 orgs, linear at one page of cycles, teams and milestones, deployments at one page of teams, pr at 25 check contexts. Where the truncated list drives later work it now paginates; where it is only displayed it says 25 of 208 fetched or at least 664 open.
  • A failed probe rendered as a quiet source. herdr-panes was the worst: a failed pane process-info exits 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.py is replaced by widgets/tests/check.rs, which runs with cargo test and 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 from config.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 real clocks settings stayed undocumented.

Releases

.github/workflows/release.yml builds on a tag, gating on the tag matching the manifest, on panic = "unwind", and on a clean ldd. 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

wiiiimm (wiiiimm) and others added 30 commits August 22, 2026 19:41
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread widgets/src/bin/pr.rs
Comment thread widgets/src/bin/linear.rs
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread core/src/lib.rs Outdated
Comment thread widgets/src/bin/clocks.rs Outdated
Comment thread widgets/src/bin/clocks.rs
rows.push(tc::seg(
&[
(ink.as_str(), format!(" {} ", glyph)),
(p.txt.as_str(), tc::pad(&city.name, 16)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread widgets/src/bin/start.rs
Comment thread widgets/src/bin/linear.rs
Comment thread widgets/src/bin/github.rs
Comment on lines +352 to +353
let title: String = if title.chars().count() > room {
format!("{}…", title.chars().take(room.saturating_sub(1)).collect::<String>())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread docs/start.md
Comment on lines 169 to 171
| `↑` `↓` / `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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread widgets/src/bin/pr.rs
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread widgets/src/bin/pr.rs
Comment thread widgets/src/bin/deployments.rs Outdated
Comment thread widgets/src/bin/github.rs
Comment thread widgets/src/bin/herdr-panes.rs Outdated
Comment thread widgets/src/bin/tailnet.rs Outdated
`[+]` 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread widgets/src/bin/pr.rs
Comment thread widgets/src/bin/pr.rs
};
format!("{}{}", tint, colour)
};
let name: String = a.name.chars().take(6).collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread widgets/src/bin/ports.rs
Comment thread widgets/src/bin/github.rs Outdated
Comment thread widgets/src/bin/linear.rs Outdated
…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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread widgets/src/bin/netwatch.rs
wiiiimm (wiiiimm) and others added 2 commits August 26, 2026 17:07
`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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1668 to +1669
"--sort" if i + 1 < args.len() => {
sort_live = args[i + 1] == "live";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unsupported sort modes

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 👍 / 👎.

Comment thread widgets/src/bin/link.rs Outdated
Comment on lines +2359 to +2361
let name_w = avail
.saturating_sub(if wide { 33 } else if mid { 11 } else { 0 })
.clamp(8, 26);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread widgets/src/bin/github.rs
Comment on lines +1873 to +1874
let held = oldest.lock().ok().and_then(|g| g.get(&key).cloned());
if held.is_none() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expire the oldest-PR cache

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 👍 / 👎.

Comment thread widgets/src/bin/matrix.rs
let last = h - 1;
rows[last] = tc::seg(
&[
(tc::rgb(70, 100, 80).as_str(), " [q]uit".to_string()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread core/build.rs
}
// 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"] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@wiiiimm wiiiimm (wiiiimm) changed the title Port every widget to Rust feat!: port every widget to Rust and remove the Python Aug 26, 2026
@wiiiimm

Copy link
Copy Markdown
Member Author

Cursor (@cursor) /babysit

@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

I pushed e1e917f on worktree-rust-port and posted the triage table on PR #31.

This batch restores the terminal on Ctrl-C (async-signal-safe tcsetattr + _exit), clamps every refresh so a broken value cannot panic a poller, treats an empty system_ports list as hide-nothing, reports a signal-killed widget as failure, and labels a truncated Linear initiative page. 88 fixed or already-addressed review threads are resolved; 25 leftover polish items stay open.

@wiiiimm is pinged to merge — I will not self-merge. Org Analyze (python) stays red (no Python left; that check is org-enforced). I am waiting on CI and the one CodeRabbit pass for e1e917f, then I will pause the loop.

Open in Web Open in Cursor 

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>
@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

Babysit triage of the open review surface, against e1e917f.

@coderabbitai review — one pass on this HEAD (reviews were paused). Not resuming auto-review.

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.

Finding Reviewer Severity Verdict Note / commit
Signal handler skipped Keyboard Drop (shell lost echo) and was not async-signal-safe Codex / CodeRabbit P1 / Major Fixed e1e917ftcsetattr + _exit(128+sig)
Negative / non-finite refresh panicked Duration::from_secs_f64 outside the poller guard Codex / CodeRabbit P2 / Critical–Major Fixed poll_secs on linear, github, deployments, pr, usage, herdr-panes, tailnet, ports, link, netwatch
Office hours 24 aborted clocks on the first frame Codex / CodeRabbit P2 / Major Fixed clamp to 0..=23
system_ports: [] restored the hardcoded hide-list; 65558 wrapped to 22 Codex P2 Fixed present-empty is hide-nothing; u16::try_from
start treated a signal-killed widget as exit 0 Codex P2 Fixed 128 + sig
netwatch poller had no catch_unwind Codex P1 Fixed reason is written to err and drawn
Non-positive link windows reached % 0 Codex P2 Fixed drop non-finite / non-positive
Non-positive window_days Codex / CodeRabbit P2 Fixed .max(1)
Linear initiative page presented as the full list Codex P2 Fixed same 5+ / honesty members already had
Duplicate Pomodoro Herdr toast Greptile P1 Verified-stale tick() already alerts; the loop only starts the flash
Empty cities fell back to four hardcoded ones Codex P1 Verified-stale fallback only when the key is absent
GraphQL errors cached as zero-activity days Codex P1 Verified-stale graphql() refuses an errors envelope; incomplete cache is not published
Failed ss cleared socket baselines Codex P1 Verified-stale absorb returns without moving last/stamp; test at the failed-poll case
POST dropped the API error body Codex P2 Verified-stale refused() keeps the body
Org / team / cycle / check / PR page-as-total Codex P1–P2 Verified-stale later commits paginate or label at least / N of M
Cached tailnet / herdr / PID / exposure failures looking healthy Codex P1–P2 Verified-stale already labelled stale or revalidated in later commits
Contrast, wrap titles, expire caches, help aliases, AGPL header, mutex-across-ss, unbounded spots Codex / CodeRabbit P2 / Major–Trivial Kept (with reason) diminishing returns — none change a number into a lie or hide a source
Paginate each project's 20 team keys Codex P2 Kept (with reason) a project owned by >20 teams is not a real workspace here
Label deployments limit as capped Codex P2 Kept (with reason) limit is the configured page the operator asked for
Org Analyze (python) GitHub CodeQL Rejected no Python remains; org default setup cannot be disabled from this repo (422)

88 threads that were fixed or already in the tree are resolved. 25 leftover polish threads stay open so they remain visible.

cargo test --workspace is green on this commit (skipped only a_token_prefers_the_config_then_the_environment, which fails whenever VERCEL_TOKEN is set in the environment — pre-existing, not this change).

Codex has not re-reported on e1e917f. It posts no status check; I am not chasing a comment-only reviewer to zero.

Pausing the auto-loop. Good to merge on substance once required checks settle and you are satisfied.

@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

wiiiimm (@wiiiimm) CodeRabbit did not re-review e1e917f. The green “Review completed” stamp is from 10:33:08, before the @coderabbitai review ask at 10:33:47, and there is no new review, issue comment, or inline thread after that. Codex also has not reported on this HEAD (last review was d981e01). I am not triggering another pass.

CHANGES_REQUESTED is leftover from CodeRabbit reviews on 24 Aug, which is why merge stays blocked. Required repo checks are green. Org Analyze (python) is still the only red and is not actionable here.

Staying paused. Merge call is yours — I will not self-merge.

@wiiiimm
wiiiimm (wiiiimm) merged commit f80ebfd into main Aug 26, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants