lists, multi-column, and multi-account - #25
Open
rrrrnmtsu wants to merge 29 commits into
Open
Conversation
Records the decisions before any code moves, because two of them are only obvious once you have read the terminal graphics code and the config write paths: iTerm2's inline-image protocol cannot survive horizontal composition, and the account key must be X's user id rather than a hash of the session cookies, since those cookies rotate on exactly the refresh path that is meant to be routine.
Two things about these operations only surface against the live endpoint. Both list reads ship in the shared timeline chunk, whose name enumerates the bundles that pull it — Bookmarks, Explore, HomeTimeline — and never mentions lists. Hinting discovery toward "List" therefore reaches only the list management bundles and finds nothing, so the hint points at the timeline chunk instead and the table test records the chunk name that made it necessary. The management operation also answers 200 with the list entries intact and a partial serialization error beside them. Treating any GraphQL error as fatal would throw away a usable response, so the error is only raised when nothing parsed — which is still the shape an auth or rate-limit rejection takes, so those stay classified as before.
Lists are fetched when the picker opens rather than cached in the config, because caching their names and ids would add schema to the very file the multi-account work is about to restructure, in exchange for saving one cheap request. Selection routes through the existing setFeed, so a list feed inherits the same state reset and stale-page guard every other feed already has instead of growing its own. The picker is overlay state, not feed state: it sits with the search prompt so the column extraction later moves the feed fields without dragging it along.
The flag is declared on both the root and timeline commands because they own separate cobra flag sets; putting it on one leaves the other silently ignoring it, which is how --following and --bookmarks are already handled. The list id rides through the relaunch loop alongside the feed, so returning from the composer or a re-auth lands back on the list that was open rather than the home timeline. An empty id keeps the picker as the entry point, so the subcommand works without knowing an id up front.
Feed state lived directly on the Model, so a second pane would have shared one set of posts, one cursor, and one sequence counter with the first. Moving it into a struct makes that impossible to write by accident rather than merely discouraged. Pages now carry a stable column id as well as the sequence number, because the two answer different questions: the sequence says whether a page predates its column's last feed switch, the id says which column asked. A shared counter can only answer the first, so pages would have been applied to whichever column happened to be at the same sequence — silently, since the guard drops rather than errors. Behaviour is unchanged with one column; no assertion in the existing tests moved, only the field paths.
Columns are composed from equal-width viewport blocks rather than a new layout engine, so the single-column render is byte-identical to before and the layout can land without re-verifying every frame. A terminal too narrow for the configured count collapses to the columns that fit rather than squeezing below the readable floor, and the visible window slides to keep the focused column on screen — an off-screen focus would make every key press act somewhere the user cannot see. The two cells the frame keeps clear on the right are preserved in the arithmetic; filling a terminal final column makes multiplexers autowrap and corrupts the frame diff. No key handler changed: every action already went through the focused column.
The smoke test showed the ASCII logo repeating once per column, which wastes two rows per column and distinguishes nothing. Fixing it belongs with the phase that lets columns carry different feeds, because that is when a header has something worth saying.
iTerm2-style inline images place pixels after walking the cursor with relative motion around reserved cells. Composing columns interleaves the neighbouring column between the reservation and the motion, and the escape sequences measure as zero width, so the padding is wrong on exactly the image rows. Multi-column runs on that path fall back to ansi with a visible note instead of erroring, because erroring would put --columns out of reach for a whole family of terminals. A test pins the assumption that the fallback leaves the full-screen-clear heuristic single-column, which is why that logic is untouched. Eviction previously built its keep-set from the focused column alone, so every preview belonging to another visible column was dropped and refetched on its next render. The keep-set now spans the visible columns and the budget scales with them. The shared post-id-keyed preview cache is only correct because every column has the same width; that dependency is now stated at both places a future change would be made — the map and the width function.
The flag takes both a count and a spec list. A bare number keeps meaning what it did — that many columns of whatever feed the other flags select — because that form was already in use; the spec list is what makes columns worth having separately. The two forms differ in what they may combine with: a count still accepts --following or --bookmarks, since "three columns of bookmarks" is coherent, while a spec list rejects them because the specs already said what each column shows. Layout is written only by an explicit columns save. Persisting on exit would race the config write path and would turn a layout someone tried once into the one they get forever. The header used to repeat the logo per column, which spent two rows per column to say nothing. The logo is now drawn once and each column shows only its feed label, with the focused column carrying a marker that survives having its colour stripped.
A column from --columns or a saved layout knows only the list id, so its header read "List: 175677451" while the same list opened through the picker read its name. One enumeration at startup names every such column, and the id stays on screen until it lands so the header is never blank. A name the picker already resolved is left alone: it describes the list the user actually chose, which a later enumeration cannot improve on and could contradict if the account stopped following that list.
Accounts are keyed by the X user id rather than a hash of the session cookies. Those cookies rotate on exactly the path the README recommends for a stale session, so a cookie-derived key would register the same account again on every routine refresh, leaving a duplicate entry and an orphaned keyring pair. The user id never changes and a rename cannot move it. Migration runs offline under a provisional key and is rekeyed once a viewer fetch names the account, because a migration that needs the network fails at exactly the moment someone is trying to recover. The new file and keyring pair are written before the old keys are deleted, so a crash mid-migration leaves a session that can still be found. Query ids are now patched in place rather than persisted as part of a whole config. Eight call sites read the config, use it, and save it back; once that file also holds accounts and the column layout, any of them holding a stale copy could have resurrected a removed account or dropped a saved layout while the user did nothing but scroll.
…saving Importing reduced every candidate session to a single winner, so someone signed into two X accounts in two browser profiles could only ever import one and was never told the other existed. That is the ordinary multi-account setup. The comparator survives to order candidates freshest-first; only the reduction is gone. Sessions whose cookie pairs are byte-identical still collapse, because those are one session copied between profiles rather than two — but the same account at two cookie ages stays two entries, since both are live. The handle used to be fetched after the save, which was harmless when there was one slot and impossible now that the X user id is the key. Verification is followed by identifying the account, and a session that cannot be identified is refused rather than written under a guessed key — that guess is exactly what would recreate the duplicate slots the schema exists to prevent. The candidate no longer inherits the active account's browser, profile, or expiry, so a second account describes itself rather than the first.
The README offers --browser as the way to authenticate without a picker, but that path still imported a single session, so a machine with two signed-in profiles could only ever reach one of them and had no way to say which. When several profiles are signed in and none is named, it now lists them and stops. Choosing the freshest on the user's behalf is precisely the behaviour that used to make a second account invisible, and a scripted caller silently authenticating as the wrong account is worse than one that asks.
Two accounts could be saved but not chosen between, so authenticating a second one stranded the first. Switching rebuilds every column rather than only the focused one, and each rebuild bumps that column's sequence, so pages already in flight for the previous account die on the guard that already exists instead of landing in a feed that now belongs to someone else. Reusing that guard is why no new mechanism was needed. The cycle is bound to @ rather than the A the plan named, because A already shows image alt text in three places and taking it would have traded one feature for another. Listing accounts reads the config file only — no keyring, no network — so it stays as cheap as doctor --offline even with several accounts saved.
Switching accounts was a whole-app mode, so comparing two accounts meant restarting and losing the layout. Pinning the account per column removes the mode: the session is resolved inside the fetch closure from the id captured at dispatch, so the existing feed sequence guard already drops a page that arrives after its column changed hands — no new locking. Likes fan out by (account, post) rather than post alone. The same post shown twice under different accounts holds two independent like states, which is what X reports, and an optimistic rollback can no longer flip a heart in a column that never made the request. An unknown @handle: is a startup error listing the accounts that do exist. Falling back to the active account would silently act as someone the user did not name, which is the failure this feature exists to stop.
Acting as a specific account meant `accounts switch` followed by the real command, which is two steps that anything else on the machine can slip between: a second shell, a TUI cycle, or a script switching back moves `active` and the post goes out as someone else. A caller that verified with whoami first was verifying a value that could still change. --account resolves inside the invocation that uses it and reads that account's session directly, so there is no window to lose. Omitting it keeps the active account, so the flag only ever narrows. The selector reuses the accounts-switch matcher, so a handle, a user id prefix, and the ambiguity error all behave the same everywhere. A named account with no keyring pair fails with its own recovery step rather than the generic first-run advice, which would be wrong once one account is already connected.
The flag closed a race, and closing it removed a check nobody had written on purpose: comparing an expected handle against the active account also caught a wrong expected handle, because the two disagreed. --account makes that value the destination, so the mistake now succeeds. Callers migrating off 'accounts switch' need to know the guard moved to their side.
An omitted flag and a flag whose value came out empty are not the same intent, but they arrive as the same empty string. Callers build the selector from an environment variable or a config field, so a variable that is unset produces `--account ""` and, until now, posted as whatever account happened to be active — the exact silent substitution the flag exists to prevent, and the one case that escaped it while unknown and ambiguous selectors were both refused. Cobra already records whether the flag was set at all, so the two cases can be told apart without changing what omitting it means.
Upstream reworked the search chrome while this branch was moving the same state onto a column struct, and the two touch the same lines from opposite directions. Nothing here is a decision: the new copy, the narrower input, and the ansi.Truncate calls are upstream's, and this only re-points them at the column being rendered instead of the model. The list header adopts the same "list · name" shape as the reworked "search · “query”" rather than keeping its old "List: name", so the two feeds read alike in a multi-column header where they sit side by side. Their tests move with them.
Column layout, per-column accounts, image mode, and theme were all locked at launch: changing any of them meant quitting the TUI and re-running with different flags. The multi-column account work made that pain sharper, since experimenting with layouts is exactly what those columns are for. The TUI now grows and shrinks panes live. n walks a kind -> detail -> account flow that reuses the search input and list picker by giving them an apply target (focused column vs. new-column draft) instead of copying them, and x drops the focused pane. s repoints a column at another saved account without touching the config's active account, and I/T switch the image renderer and palette mid-session (the theme also persists, matching xeet theme). Two latent issues surfaced along the way: a preview fetched just before an image-mode switch could land in the old renderer's format and corrupt the frame, so previews now carry their mode and stale arrivals are dropped; and the compact help had no slack before the terminal clipped its title, so the new keys are packed into the same line count instead of appended. Co-Authored-By: Claude <noreply@anthropic.com>
Reading the multi-column layout in practice showed four friction
points: columns ran together with only blank padding between them, a
column's account was invisible when juggling more than one, the
selection bar was a thin glyph easy to lose while scanning, and post
text lost its line breaks entirely -- cleanText flattened them, so
lists and verse collapsed into one dense paragraph.
The gutter now draws a muted vertical rule in the same two cells the
padding already occupied, headers name their account ("@alice · for
you") only when multiple accounts exist, the selection marker gains
weight and bold, and cleanText keeps line breaks while still
collapsing spaces and dropping blank lines so the four-row preview
budget goes to content. highlightEntities follows per line, and
stripTrailingMediaLink's trailing check learned about newlines so a
link followed by another line is no longer mistaken for the trailing
media link.
Co-Authored-By: Claude <noreply@anthropic.com>
Owner
|
Haii, thanks for the PR! There’s a lot of good work here. But it’s too big to review safely in one go (62 file changes). |
Once the TUI opened, every feed froze until a manual R, so a session left running went silently stale. Polling follows the focused column only, keeping request volume flat no matter how many columns are open, and stays off by default because always-on polling against X's web API is a rate-limit risk the user should opt into. Set refresh_interval in ~/.xeet.yaml or pass --refresh for a single run. Co-Authored-By: Claude <noreply@anthropic.com>
Browsing replies, likes, and mentions meant leaving the terminal for the browser, which defeats running xeet as the main X client. The NotificationsTimeline operation feeds a new feed kind that slots into columns, the picker, and auto-refresh like any other. Notifications impersonate their target post so enter/like/reply keep working; target-less notifications (follows) are skipped rather than shown as rows no action works on. Co-Authored-By: Claude <noreply@anthropic.com>
Reposting meant leaving the terminal for the browser, which broke the flow the like and reply keys already cover. t now toggles a repost on the selected post exactly the way l toggles a like: optimistic update, green ⟳ while held, rollback and a toast when X rejects it. The mutations are not idempotent — a replayed CreateRetweet fails as "already retweeted" — so unlike likes they never retry transient errors; the user re-presses instead. Live verification showed the endpoints answer a bodyless 404 without the transaction header (the same shape SearchTimeline documents), so every attempt mints one, and it confirmed DeleteRetweet takes the original id as source_tweet_id. Both query ids follow the discover-then-cache path rather than shipping a hardcoded fallback that would rot. Co-Authored-By: Claude <noreply@anthropic.com>
Replying was the only way to respond to a post in place; anything that wanted the quoted card attached meant going back to the browser. Quote reuses the reply composer wholesale (same editor, same state fields) so the two flows cannot drift apart — only the mode flag decides whether the target id leaves as in_reply_to_tweet_id or attachment_url. The attachment_url shape is a guess X never documents, so it lives in one function and the live test proves it by scanning the raw detail body for quoted_status_id_str; a mocked test can never catch X accepting the text while silently dropping the quote. Co-Authored-By: Claude <noreply@anthropic.com>
Seeing someone's posts still meant leaving for the browser even though every other post action already worked in place. u switches the focused column to that author's timeline, the same way f, b, L, and / already replace a feed instead of opening new UI. Posts carry only the author's handle while UserTweets pages by numeric id, so opening a profile resolves once via UserByScreenName and caches the id on the column — pagination and refresh never pay the extra hop again. The UserTweets read rides the shared fetchTimelineOp path, so query-id rotation and retries behave exactly like the other timelines. Co-Authored-By: Claude <noreply@anthropic.com>
Bookmarks were read-only: b showed the saved list, but saving a post meant leaving the terminal for the browser. B toggles CreateBookmark / DeleteBookmark on the selected post, completing the roadmap's last interaction gap. Two endpoint behaviors only the live run could settle: - The mutations answer a bodyless 404 without a transaction header, so they mint one per attempt like the retweet pair (likes don't). - DeleteBookmark is not idempotent: a replayed delete earns a GraphQL "_Missing: not found in actor's favorites" error. CreateBookmark retries transient failures; DeleteBookmark gets one attempt, same call CreateRetweet makes. The post model gains a Bookmarked flag parsed from legacy.bookmarked so the 🔖 marker and the toggle's direction survive refreshes, and the bookmark pair joins the query-id plumbing (config, env override, eager discovery) like every other operation. Live-verified against own post 1756167890796200200: flag flips true then false on TweetDetail, and the discovered ids persist to config. Co-Authored-By: Claude <noreply@anthropic.com>
Agents handed an x.com link had no way to read the post behind it: WebFetch cannot render X, and the API path needs credits. xeet already carries an authenticated session, so the shortest route to the content is the client itself. JSON is the default output because the primary caller is a script or agent, and the schema is defined in cmd rather than marshalling api.TimelinePost so internal refactors cannot break consumers. X long-form articles were previously invisible: their legacy text is just a t.co preview link, and the request never asked for the body. Only TweetDetail opts into withArticlePlainText — flipping the shared toggles would reshape every timeline request for a payload only a single-post read needs. Co-Authored-By: Claude <noreply@anthropic.com>
xeet changes kept being started from sessions rooted in other repos, which have none of this fork's hard-won constraints (QID plumbing, transaction-id coverage, idempotency-driven retries, toggle scoping). A repo-root CLAUDE.md loads automatically when the work happens here, so the rules travel with the code instead of living in one session's memory. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three features that build on each other, in 19 commits meant to be read in order. Each one is live-tested against a real account before the next starts.
Lists
Lopens a picker of the lists you follow;xeet --list <id>andxeet listsskip it.Finding the operation took a detour worth recording: the list read operations ship in a shared timeline chunk that is named for the bundles that pull it — Bookmarks, Explore, HomeTimeline — and never for List, so hinting
"List"only finds the list management bundles. The hint and the chunk name are written down indiscover.goso the next person does not repeat the search.ListsManagementPageTimelinealso answers200with the entries intact and a partial serialization error beside them, so the parse runs first and the error is only fatal when nothing parsed — which is still the shape an auth or rate-limit rejection takes.Multi-column
xeet --columns foryou,bookmarks,list:123renders up to four feeds side by side,Tabmoves focus, and a window too narrow to hold them all shows what fits plus+1 more (widen terminal)rather than truncating mid-column.Per-feed state moved onto a
columnstruct in its own commit with no behaviour change; the rendered output is byte-identical before and after for the single-column case. Columns are deliberately equal-width because the preview cache is keyed by post id — unequal panes would need a per-column cache to avoid alternating width-based refetches, and that invariant is written at both ends.Multi-account
Config schema v2 keys accounts by X user id rather than anything derived from the cookies, since cookies rotate and a rotation would otherwise fork one account into two. Migration writes the new keyring pair, writes the file, and only then deletes the legacy keys, rolling back if any step fails.
xeet accounts/switch/removemanage them,@cycles inside the TUI, and--columns @alice:foryou,@bob:followingpins a column to an account. A column resolves its session inside the fetch closure from the id captured at dispatch, so the existing feed-sequence guard already drops a page that arrives after its column changed hands — no new locking.Likes fan out by (account, post) rather than post alone: the same post under two accounts holds two independent like states, and an optimistic rollback cannot flip a heart in a column that never made the request.
xeet post --account <handle|id>andxeet whoami --account <...>select per invocation. Switching and then acting is not equivalent — anything else on the machine can moveactivein between — and this closed a real race for a caller of mine. Two things fell out of that, both in here: an unknown selector is an error listing the accounts that exist rather than a silent fall back to active, and so is an empty one, because callers build the value from an environment variable and an unset variable produces--account "".The flag decides which account acts; it does not check that the choice was right. A caller that used to compare its expected handle against the active account was getting a wrong-account check for free, and passing that same value to
--accountturns it into the destination instead.xeet post --helpsays so, and the deny-list stays with the caller, where the policy lives.Rebase note
This started before #21 and #23. The last commit re-points upstream's reworked search chrome at the column being rendered rather than the model, and moves the list header to the same
list · nameshape as the newsearch · “query”so the two read alike side by side in a multi-column header. Nothing in that commit is a design decision of mine.Verification
go build,go vet,staticcheck 2025.1.1, andgo test -race ./...are all clean on this branch — no remaining failures now that #22 has landed.Live against real accounts: lists enumerate and page (5 lists, 96 posts), columns render three different feeds with images, two then three accounts coexist in one config,
whoami --accountreports a non-active account whileactivestays put, and both the unknown and empty selectors fail as described.Behaviour-preserving commits were checked by hashing rendered output across worktrees rather than by reading the diff. The tests for the account-scoped paths were mutation-tested — the per-account session, the like fan-out constraint, and the unknown-selector error each fail when the guard they cover is removed.