Tracking TUI for github state
  • Julia 99.9%
  • Shell 0.1%
Find a file
Jameson Nash d6c09a7cc4 worklog: the TermIFrame pointer names a commit that exists somewhere else too
`f9d7c0a` moved this to `f593378`, which is on no branch in that repository and
never left this machine. A fresh clone of the dashboard could not have checked
it out - `git submodule update` would have asked the remote for a commit the
remote has never heard of - so the pointer was a local-only reference dressed up
as a shared one.

`54b1794` is the same change. It is that commit rebased onto `6db0fa0`, the note
about why there is no native Windows backend yet, which had landed on `main`
underneath it in the meantime; the whole of the difference between the two trees
is those 33 lines of README. It is the tip of `main` and it is pushed, which is
the entire point of moving.

Nothing here depends on the difference. The border weights already came through
`TermInput.CHROME` in both, which is what `f9d7c0a` was for, and the suite and
`wl` were run against this checkout before the pointer was written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENn2yfTHJWw7G43iQM8rUj
2026-09-11 20:50:35 +00:00
cli worklog: a failed spawn is not free, and gh_run was paying for two 2026-09-11 20:42:15 +00:00
TermIFrame.jl@54b1794c95 worklog: the TermIFrame pointer names a commit that exists somewhere else too 2026-09-11 20:50:35 +00:00
TermInput.jl@90637db21f worklog: the borders are the theme's too, which took a change in both packages 2026-09-11 15:17:20 +00:00
themes worklog: GitHub's own colours, for a terminal that will not follow 2026-09-11 16:37:52 +00:00
.gitignore worklog: GitHub's own colours, for a terminal that will not follow 2026-09-11 16:37:52 +00:00
.gitmodules worklog: the two halves it builds against are submodules, not gaps 2026-09-10 15:59:30 +00:00
config.toml worklog: GitHub's own colours, for a terminal that will not follow 2026-09-11 16:37:52 +00:00
README.md worklog: GitHub's own colours, for a terminal that will not follow 2026-09-11 16:37:52 +00:00
TODO.md worklog: a failed spawn is not free, and gh_run was paying for two 2026-09-11 20:42:15 +00:00

worklog

A dashboard for tracking ongoing work across every repo, sorted into lanes by what the work actually needs next.

Nothing off-the-shelf did this. gh-dash is stateless — every section is a live query, so there is no snooze, no note, no memory of what changed. Octobox has real snooze but triages notifications, and GET /notifications is 403 for the sandbox's GitHub App token. GitHub Projects v2 can hold the state but cannot populate or classify a couple of thousand items. The missing piece in all of them is judgement: "needs edits" vs "needs an agent" is a fact about content that no query language expresses.

Design

The split that makes it safe to let a model touch this:

file owner lifetime
config.toml you edited by hand
themes/*.toml you edited by hand; which one is read is a line in config.toml, and none being read is plain text
data/local.toml you + the model, via wl never machine-rewritten — edited key by key, block by block. Per item: your note, snooze, deadline and tracking level, and what you have done to it (seen, and the head you saw it at, touched, drafted). Plus a repo: block per local checkout. Tracked
data/fetched.json wl refresh everything GitHub can answer again: the items, the slow-lane cache, the poll's cursors and what it saw. Not tracked; ~4MB

Everything but config.toml lives in data/, which is a git repository of its own. Two files and one line between them: what can be re-fetched from GitHub is gitignored, and what records something you did is tracked.

The refresh never rewrites local.toml - it edits the keys it owns, in the blocks it names, and leaves every other line byte-identical. Every snooze and note you set survives any refresh, and a confused model cannot erase your triage.

Buckets are derived from facts by rules, not guessed: changes-requested or unresolved threads or red CI → needs-edits; CONFLICTINGneeds-stacking; approved and green → ready to merge; they pushed after your last review → needs-review. Judgement is not made here at all: what a red CI really means, what the next action is, and what is urgent are written into local.toml, by you or by a model reading the same files.

Snooze until it moves

snooze = "on-change" fingerprints the PR (head commit, review decision, mergeability, CI, unresolved threads, last comment, labels) and hides it until that fingerprint differs. For a PR waiting on a reviewer this is the right primitive — a timer is guessing, and Octobox only offers 1h/1d/1w/1mo. Once woken an item stays awake until you re-snooze, so a wake cannot scroll past you.

An on-change snooze on its own has no clock, and a pull request everybody has quietly given up on is exactly the shape whose fingerprint never differs — so it would hide forever, and that is the one worth being reminded of. Give it a deadline: snooze = "on-change/30d" wakes when it moves or after thirty days, whichever comes first, and max_days under [snooze] in config.toml is the default cap for the ones that carry none.

snooze = "2026-09-15" still works for real calendar constraints, and snooze = "3d" / "2w" / "6mo" count from when you set them.

A snooze marks it read, and waking marks it unread again. "Not now" and "unread" are the same answer twice, so an item you have put away stops sitting in the unread lane asking to be read - and when it wakes it comes back as news, by the same hand-delivery wl import uses, since the repo it is in may be one no lane polls. Both edges and only the edges: marking read every refresh would bury a comment that arrived while it slept, and marking unread every refresh would make a woken item impossible to file. Clearing a snooze by hand is not a wake - you did that on purpose, on an item in front of you.

Waking happens in wl refresh and nowhere else. It is not a predicate a browser can evaluate: deciding an item has woken also arms and records it, so two windows on one dashboard would each decide and each write, and neither would know what the other had already woken. The browser shows the answer the last refresh wrote, and the metadata pane says which trigger the item is waiting for

  • until it moves, for 2w, 9d left, until 2026-09-15 - so a snooze that has run its course comes back when you ask for a refresh, at a moment you chose.

Lanes

Fast lanes, fetched every refresh: PRs you authored, PRs awaiting your review, issues assigned to you. Slow lanes in [bulk.queries], fetched every 6h: every open PR in JuliaLang/julia, plus everything you were mentioned in or have commented on (~2500 items). Nothing from a slow lane surfaces on its own.

The one exception is needs-reply: you were mentioned within reply_days (30) and the last comment is not yours, so a question is probably owed an answer. Deliberately narrow — plain commented: never qualifies, because in the repos where you are effectively the maintainer you touch nearly every PR, and that would put forty items a week in front of you.

How closely you track an item

track is what counts as this item having moved - which decides both whether it goes unread and whether a snooze on it wakes. Two levels, because two is what anybody sets:

level counts as movement default for
normal a push, CI finishing, a review verdict, an unresolved thread resolving, any comment your unfinished work
loose a review verdict or a human reply — bot comments and CI churn are ignored everything else, and anything finished

Somebody asking you to review counts at both levels, and it is the only thing that does. Everything else in the table is a state that happens to change; a review request is a person naming you, and there is no level at which that is noise — loose exists to ignore a stranger's CI and a bot's comment, and this is the opposite of both.

cli/bin/wl track julia#62452 loose

This is a real difference in behaviour rather than a label, because the fingerprint is hashed from the level's key set: CI turning green on your own pull request makes it unread, the same on a stranger's does not, and a human reply reaches you either way.

It is also what makes a re-request arrive at all. A first request shows up as a new item and is unread for that reason; a second one, on something you have already read and decided about, changes nothing else GitHub will tell you — reviewDecision stays where it was, the review count stays where it was, and the button posts no comment — so before reviewRequests was fetched it passed in silence.

It also answers a thing GitHub cannot. updated_at does not move when a check run finishes - a pull request stamped 20:55:52 had its three suites complete at 20:56:04, :07 and :19 and the stamp never moved - and it does move when somebody relabels a pull request you have no interest in. So "has this changed since I looked" is measured against moved_at: when this program last saw a change at the item's own level.

Showing what changed, not just that something did

Knowing an item moved is half an answer. The other half is what moved, and without it, opening something you have already read means being handed the whole thread and the whole diff again with the new part somewhere in them.

Three things answer it, and all three are read off the mark r leaves behind:

The thread is one activity list. The comments and the pushes are one sequence — "they replied, then pushed, then replied" — and reading it as two is why you scroll back and forth. The commits are drawn in among the comments in the order they happened, with a run of them that nobody spoke between folded into one ↑ pushed 3 commits entry.

A rule says where the new part starts. r marks a thread read up to the moment it was fetched, so everything written before the stamp was on screen and everything written after it was not. An item that comes back unread opens on that rule with the new entries below it, rather than at the top of a forty-entry thread you have read thirty-nine of. That is also why nothing records which comment you had got to: the stamp already answers it, and a second answer is one that can disagree with the first.

p is what has been pushed since you last looked. The stamp cannot answer this one — a rebase is invisible to a clock — so the read mark also records the head commit it was made at, and p diffs that against the head now:

what the branch did what p shows
only added to it — the old head is still in its history, on the same base the plain diff between the two heads, and how many commits arrived
rebased, amended, force-pushed git range-diff, one foldable node per commit, marked unchanged / changed / gone / new

Both sides are measured from the branch it will be merged into, which is the difference between a readable answer and an unreadable one. git range-diff old...new measures from where the two heads meet, so a two-commit pull request rebased over ten commits of master reports twelve commits — ten of them somebody else's, with the one real change last. Measured from the base, it reports two, and the ten are the number in the header: rebased onto 10 newer commits. That is what baseRefName is fetched for, and the base ref is brought up to date before it is used, because a stale copy puts the commits it has not heard about back inside the answer.

It needs a pinned checkout, because there is no GitHub endpoint that compares two heads of one pull request — compare is between refs and the head you saw is not one. It also needs to have been marked read once; on an item that never has been, the pane says which key makes one rather than showing an empty box. Only r writes that sha — a snooze, an archive and wl read stamp "not now" and know nothing about what you were looking at, so they leave it alone.

A head that was force-pushed away is still fetchable, which was measured rather than assumed: orphaned heads up to fourteen months old came back from git fetch <remote> <sha>. So "the commit is gone" is not a state this has to handle — a failure there means the repository or the network is not answering.

Working the pile

The pile is about two thousand items: every open PR in JuliaLang/julia (~690), and everything you were mentioned in or commented on (~1300). It is in the corpus like everything else - the browser opens on what moved, so it is not in front of you until it does - and wl next is the other way at it: pull a batch when you want one and work through it by tagging.

cli/bin/wl next 10                 # next untriaged items, quietest first
cli/bin/wl dismiss julia#43202     # retire: loose + wake only on real movement
cli/bin/wl track   julia#43257 loose
cli/bin/wl note    julia#44005 "still relevant; rebase onto the new pass manager"

Anything you have tagged never comes back in next, so the queue drains monotonically and you can stop and resume at any point - the tag is the only record of having dealt with something, and there is no second one. next hands you your areas first (from config.toml's areas list) so a thousand-PR pile still leads with the relevant end of it.

The stale pile

Yours, quiet for 60 days, and unclaimed → the stale bucket. It is a true thing to say about a row and no longer decides whether you see it: nothing is evicted for being quiet, since a row leaving on a day nobody chose is the one thing a dashboard must not do. second_look is the live half of this - work that has gone quiet on somebody, derived every refresh and never stored.

The browser

The same program with no arguments is an interactive browser over the same data:

cli/bin/wl              # the item list, its metadata, and the detail pane
cli/bin/wl --refresh    # re-fetch first

u re-fetches from inside it - the whole dashboard, in the background, with the list rebuilt where it lands; R is the same thing for the one item under the cursor. u is Gmail's key for it, and it was free because r toggles read either way.

A fenced code block becomes a foldable block of its own rather than prose, so a pasted log folds away to one line and never gets drawn as a box wider than the pane. Inline code is a quiet grey span instead of yellow punctuation, and snake_case names keep their underscores — Julia's Markdown reads them as emphasis, which CommonMark forbids and GitHub does not do.

d is the diff, o the thread, p what has been pushed since you last looked, and c the per-check breakdown — see "Showing what changed" above for the last of those and for the rule the thread opens on.

It opens on what moved, awake and open. So an item leaves the opening list two ways - you read it, or you put it away with s or x - and comes back the same two ways, with "moved" meaning what track says it means for that item.

That list is one box on an axis that only adds. Five checkboxes - unread, awake, open, read, snoozed, filed away, closed or merged - and each brings its own kind of row beside the others rather than instead of them, so no box can take another's rows away. The number next to each is what checking it would bring, or what unchecking it would take away. All five is the corpus, and ' has it by name ("everything"); c clears every filter, which lands on the first box alone rather than on the corpus.

The first box is checked when nothing has been asked - it is what the dashboard is, and c, a fresh filter and a view that names no show all leave it on - so the screen cannot be emptied by accident. Unchecking it is how you ask for one of the other four alone: the filed work on its own, rather than beside today's. Uncheck all five and you get no rows, which is what an empty set of things to show means.

The read box is a question about awake work only: putting something away stamps it read, so snoozed and filed away bring what they name whether or not it has been read. A box that insisted on both would have been a control that did nothing.

The list itself says what has been read: unread rows are bold and read ones plain, and the cursor is a background rather than a weight - the same mark the reading pane puts on the line you are on.

Under the item list is a metadata pane: who has reviewed and who was asked, labels, the check tally, milestone, mergeable state, and the tracking level and note from local.toml. It sits there rather than beside the detail because ten item numbers at a time is plenty and the thing being read wants the height. Everything in it that fetched.json already knows is on screen immediately; the two that need a request — per-person review state and the per-check breakdown — are fetched for the selected item only. The light GraphQL query the bulk lanes use carries no reviews, so widening it would pay for ~2000 items to answer a question about the one on screen.

The list opens newest first - by when anything last happened to an item, yours or GitHub's - which is the order every other inbox has. w cycles the other two: the interaction clock, and url order - owner, project, number, descending - which keeps the grouping the fetch is written in and reads from the newest of each repo. The number is sorted as a number, not as the digits it is written with, so #6661 is below #62836 rather than above it. An order you choose lasts until the selection changes, and the [...] summary names it only while it is not the one that selection opens in.

/ searches. In the item list it narrows by title or ref, and a bare number is a jump — reaching past the filter that is hiding the item, since being unable to see it is exactly when you go looking for it by number. In the thread or the diff it marks every match and n/N step between them — matching the line as it was written rather than as the pane wrapped it, so a phrase broken across a line break is still found, and reaching into folded blocks, which then opens.

It owns the mouse rather than leaving selection to the terminal. That is not a flourish: the terminal only sees the lines we wrapped, so selecting a paragraph with it yields the wrapped fragments plus the pane borders. Dragging here selects rows, and y copies them as the lines they were written as - one line per paragraph, links whole, no colours in the paste. Clicking moves the cursor and clicking a fold marker toggles it; the wheel scrolls the pane under the pointer. A single click on a url copies it and a double click copies whatever else is under the pointer - the word, the path, the identifier without the backticks that made it code - while in the item list it copies that item's url. Every node header carries a at its right-hand end: clicking it copies that block whole, the comment with its code and its tail, the hunk without the conversation hanging off it. m gives the mouse back to the terminal when you want it - and shift-J/shift-K, or the shifted arrows, extend a selection from the keyboard, which is what m off would otherwise take away along with the drag.

M merges a pull request, on the message GitHub itself would have written - viewerMergeHeadlineText and viewerMergeBodyText, which already honour the repository's squash-title and squash-message settings. There is no picker in front of it: the composer opens on the operation and ^x changes it, rewriting the message for the new one, because the operation and the message it decides belong on one screen. The line above the message says the whole of what is about to happen - the operation, how many commits land on which branch, and what mergeStateStatus says about whether it can be merged at all, so that "blocked" or "behind master" is read before the message is written rather than out of a refusal after it.

The operation it opens on is squash where the repository allows it, then merge, then rebase. That is this program's preference and is named on screen as ours, because a repository has no default to have: viewerDefaultMergeMethod is the only field of its type in GitHub's schema and it reports what you last merged with there - the same allowed pair answers SQUASH on JuliaLang/julia and MERGE on JuliaCI/julia-buildkite. Rebasing has no commit message at all, so the composer empties and says why. ^s asks once before it sends, which nothing else that writes here does: a comment, a verdict and a label can each be answered with another one, and a merge cannot.

A composer is drawn beside what it is about rather than over it, wherever the screen is wide enough for two columns - the same split t and T put a hosted program in, and none of that machinery was ever about a child process. c, A's body and M all open in the right-hand column with the diff or the thread still on the left, and tab moves the keyboard between them; esc and q come back to the message too, since q in the browser ends the program and quitting out from under a half-written comment is what this exists to prevent. Below 150 columns there is no room for two, and a composer takes the screen the way it used to. v was already doing this - it runs $EDITOR in a pane - which is where the idea came from.

That is also why the merge composer cycles the operation with ^x and not tab: tab moves the keyboard between two things on screen, here and in the item list and in the worktree lenses and after ^], and a composer drawn beside its diff needs it to go on meaning that.

Coming back to an item lands on the line you were reading in it, per item and per mode - a comment thread and a diff of one pull request are two readings of it and two places to come back to. And when a row leaves the list under you - r, x, s, each of which takes the row out of the opening list - the cursor stays on the row it was on rather than jumping to the top, so an inbox is read by pressing r. Choosing a view, a filter or a query is asking for a different list, and those open at the top.

Everything is one Julia module under cli/src, so the comment-preserving TOML writer and the GitHub quirks below live in one place rather than two: the browser calls the same functions the commands do, rather than shelling back out to itself. Startup is about a second: 1.0s to the list pane and 1.14s to a comment thread beside it, of which 0.96s is loading the module. julia --project=cli cli/test/latency.jl measures it.

The GraphQL search lanes shell out to gh api graphql because GitHub.jl exports neither GraphQL nor search; the REST side (events.jl) uses GitHub.jl directly, though not its paginating helpers - see the --paginate note below.

Colours

config.toml names a file under themes/, and that file says what colour each role is drawn in:

theme = "default-ansi.toml"

A role is what a colour means to the program, not where it is on screen: settled, blocked, waiting, accent, diff_add, cursor_bg, and a dozen more. So one line moves every green in the dashboard - the approval, the passing check, the staged change and the attached session are one question asked in four places - and the code names no colour anywhere, which is what makes a second theme possible without re-reading it.

A value is words, in any order: an attribute (bold, dim, italic, underline, reverse), one of the eight ANSI colours or bright <name>, or a 256-colour index, with on in front of a colour to make it the background. So "bold white", "black on yellow", "on 236", "244".

Three themes ship. default-ansi.toml is the sixteen ANSI colours wherever it can be, so it follows whatever scheme the terminal is set to rather than fighting it. github-light-256.toml and github-dark-256.toml do the opposite: every colour is a fixed index into the 256-colour cube, the nearest entry to the hex in GitHub's Primer palette, so they look the same in every terminal that has 256 colours - at the price of assuming the ground they are drawn on, which is why there are two of them. They are not each other inverted: GitHub's dark palette moves the hues as well as the lightness, and the files follow it.

Two more tables in the same file are the palettes that are not this program's. [term] is Term.TERM_THEME[] - the six markdown heading levels, the block quote, the footnote, the table, the admonitions, and, not being colours at all, the box characters every box here is drawn with (box = "ROUNDED"). [code] is Term.CodeTheme, which is where the colours of a highlighted code span actually live: Term 2.2 highlights with tree-sitter, and that palette is a dictionary keyed by capture name - keyword, string, operator - rather than part of its theme. Both are written in the language above and translated on the way in, so a theme is one file rather than three.

The boxes follow as well, which is the part that is not in this repository at all: the dialogs, the composer and the pane a hosted program is drawn in come from TermInput and TermIFrame, and each took its bold, its dim and its reset as escapes written into the source. They read TermInput.CHROME now - three weights a host sets once - so bold and dim here reach every border on the screen. The one escape those keep for themselves is the block marking where the cursor is in a composer: that is not emphasis, it is the only thing saying where typing will go.

Nothing else turns colour on. An empty value, no theme line at all, or a name that is not there draws the whole program plain - not one escape printed, resets included, the borders reduced to bare line art, and a markdown body coming back as text - Term prints its own attribute resets whatever its palette says, and with no theme those are noise. So theme = "" is the way to ask for that. A file that is there and has a misspelt role or colour in it says so on stderr at startup and draws that one role as nothing; the rest of the theme still applies.

Saving

Nothing commits automatically. /root/.claude is a host bind-mount, so the repo survives sandbox restarts on its own; commit when you have something worth keeping.

Pushing needs a fine-grained PAT scoped to this repo with Contents: read/write

  • the sandbox's GitHub App token is read-only for contents everywhere, including repos you own.

Authentication

The GraphQL lanes shell out to gh, so they use whatever credential gh has. The REST lanes go through GitHub.jl, which needs the token itself; token() looks in /run/claudebox-github/token (the sandbox host refreshes it, so it beats a possibly-stale environment), then $GH_TOKEN / $GITHUB_TOKEN, then gh auth token.

That last one is what makes this work off the sandbox: there gh keeps its credential in its own config or the system keyring and exports nothing, so gh auth status succeeds while $GH_TOKEN is empty. A missing token now fails once with a message naming every place it looked, rather than once per repo.

Use

cli/bin/refresh                                # ~20s, 12 of 5000 rate points
cli/bin/wl note   julia#62452 "rebase after #62396"
cli/bin/wl snooze libuv#5212 on-change
cli/bin/wl clear  julia#62452
cli/bin/wl                                     # the browser

cli/bin/refresh is cli/bin/wl refresh; every command is a subcommand of the one entry point.

Scope

config.toml defines the lanes. Currently: PRs you authored, PRs awaiting your review, issues assigned to you, plus every open PR in JuliaLang/julia as the background pile. The areas list is a ranking signal for wl next, not a filter — nothing is excluded.

The firehose is fetched on its own 6-hour cadence (cli/bin/refresh --firehose forces it), because it is ~1000 PRs and several minutes, while a normal refresh with it cached is ~20s and 12 rate-limit points.

Two GitHub behaviours worth knowing, both of which cost real debugging:

Following Link: rel="next" is unsafe on a sort=updated list, whether the follower is gh api --paginate or GitHub.issues. It walks a collection being reordered underneath it, so an item touched mid-walk jumps to page 1 and shifts a whole page past the cursor. The same query returned 168 items on one attempt and 612 on the next. events.jl therefore uses GitHub.jl's single-request gh_get_json and pages itself with direction=asc - where a concurrent update moves an item toward the end, which can duplicate but never skip - and dedupes by id.

search(type: ISSUE) silently returns 0 for assignee: unless the query also carries is:issue or is:pr. REST has no such quirk, so 16 assigned issues were invisible until the qualifier went in. Do not remove it from the assigned lane.

A search returning Issues against a query fragment that only spreads ... on PullRequest yields bare {__typename: "Issue"} stubs with no fields and no error — the light query needs both fragments or the two is:issue bulk lanes come back as unusable husks.

GitHub's search API truncates at 1000 results and this repo is at ~993 open PRs, so the fetch partitions by creation year and unions the slices once the total crosses 950. Long paginations also hit transient 502s, so pages retry.

mergeable is computed lazily — the first read of a PR returns UNKNOWN and merely schedules the computation (94 of 145 on a cold run). Concluding from it flaps the needs-stacking lane and spuriously wakes on-change snoozes, so the last known value is carried forward until a real one arrives.