Skip to content

lists, multi-column, and multi-account - #25

Open
rrrrnmtsu wants to merge 29 commits into
melqtx:mainfrom
rrrrnmtsu:feat/lists-multi-column-multi-account
Open

lists, multi-column, and multi-account#25
rrrrnmtsu wants to merge 29 commits into
melqtx:mainfrom
rrrrnmtsu:feat/lists-multi-column-multi-account

Conversation

@rrrrnmtsu

Copy link
Copy Markdown
Contributor

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

L opens a picker of the lists you follow; xeet --list <id> and xeet lists skip 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 in discover.go so the next person does not repeat the search.

ListsManagementPageTimeline also answers 200 with 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:123 renders up to four feeds side by side, Tab moves 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 column struct 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 / remove manage them, @ cycles inside the TUI, and --columns @alice:foryou,@bob:following pins 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> and xeet whoami --account <...> select per invocation. Switching and then acting is not equivalent — anything else on the machine can move active in 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 --account turns it into the destination instead. xeet post --help says 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 · name shape as the new search · “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, and go 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 --account reports a non-active account while active stays 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.

rrrrnmtsu and others added 21 commits July 28, 2026 08:22
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>
@melqtx

melqtx commented Jul 28, 2026

Copy link
Copy Markdown
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).
Can you split it into smaller PRs? start with lists, then stack multi-column and multi-account on top.

rrrrnmtsu and others added 8 commits July 29, 2026 11:03
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>
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