Skip to content

Faster lists, no wait for Cursor, clearer narrow and TUI output - #29

Merged
lr00rl merged 9 commits into
mainfrom
fix/20261024_faster-lists-and-clearer-tui
Oct 24, 2026
Merged

lr00rl merged 9 commits into
mainfrom
fix/20261024_faster-lists-and-clearer-tui

Conversation

@lr00rl

@lr00rl lr00rl commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-ups from a review of performance and UI/UX, one commit each.

  • Errors are reported, not shown as partial totals. The joins that put a Cursor subagent's billed usage on its session were loaded in a sync.Once that dropped SQL errors and kept the empty result for the life of the DB. They now return the error, and a failed load is retried. Seven row loops that closed without checking rows.Err() now check it.
  • The session list reads usage only for the sessions it keeps. It used to sum every response twice (totals and models) and filter afterwards in Go. Sessions now come from files first and are filtered, then responses are summed in one pass per session and model through the (account, session_id) index. Models that tie on cost come by call count, then by name. Cursor rows order models by billed cost and list their subagents' billed models.
  • The Cursor IDE database is not listed when unchanged. A stamp (sizes, times, the DB header's change counter, the WAL header's salt) is compared with the one saved by the last full read. Rows a newer parser must reread, a run with read errors, and --rebuild all force a full read.
  • Stale Cursor usage shows at once. A command that finds a stale cache with usage in it shows the cache and starts a detached ccli cursor-refresh, with a lock file so only one runs. Only a command with no cache waits. Usage over a day old, or a failed update, is said on stderr.
  • Narrow terminals: when the table cannot fit, sessions take two lines each, and the totals and legend lines break between items.
  • TUI: the footer offers only the keys that do something for the selected row, and o or y with nothing to act on says so. The hide question and the tag prompt say they apply in every account.
  • A flaky test fixture (the copy could be born in the same millisecond as its original) and the order of dashboard shares that tie are fixed.

Timings

On a 418k-response index, with the index refresh included:

Command Before After
sessions (30 days) 1.20s 0.41s
sessions -a <one account> 1.22s 0.14s
sessions --dir <folder> 1.18s 0.13s
show <id> 0.29s 0.13s
accounts 1.33s 0.60s
index with nothing new 0.22s 0.08s

A list over all time still reads every response, in about 1.1s.

Not done

  • A per-session usage table kept by the indexer. Responses are deduplicated across files, repriced, and adjusted by Cursor's ledger, so keeping the sums right would touch every write path.
  • Refreshing only the accounts named by -a. A bare id needs every account to find its copies, and the IDE's composers decide which Cursor CLI sessions must not be billed twice. Skipping the unchanged IDE database removes most of that cost anyway.

Test plan

  • go test ./..., go vet ./...
  • JSON from sessions (8 filter sets), accounts, stats and show, compared before and after on a frozen copy of the real index. Only float rounding and the intended model-order changes differ.
  • The IDE skip test fails with the skip disabled.
  • A stale cache on a copy of the real data printed in 195ms. The detached read updated the cache and removed its lock.
  • sessions and a duplicate-id show at 48 and 160 columns.

The joins that put a Cursor subagent's billed usage on its session, and
that keep an IDE composer's CLI copy from being billed twice, were loaded
in a sync.Once that dropped SQL and scan errors and kept the empty result
for the life of the DB, the TUI's included. A failure then showed totals
short of every subagent's usage, or a conversation billed twice, as if
nothing had gone wrong. The joins now load together, the error reaches the
command, and a failed load is tried again by the next caller.

Seven row loops in the dashboard, session detail, account totals, reprice
and reconcile closed their rows without checking the error that ended the
loop, so a read cut short counted as the whole. They check it now.

Tested: go test ./...; go vet ./...
The session list summed every response in the index, twice (once for
totals, once for the model list), and only then applied the account,
folder, tool, project and search filters in Go. It now lists sessions from
their files first, filters them, and sums responses in one pass per session
and model for the ones left, through the (account, session_id) index. The
model filter, which needs the responses, runs after that pass.

On a 418k-response index: the default 30-day list 1.08s to 0.44s, -a with
one account 1.08s to 0.08s, --dir 1.13s to 0.09s, accounts 1.42s to 0.70s,
stats 2.07s to 1.30s. An all-time list stays at about 1.1s; it reads every
response either way, and a full scan measured slower than the index.

Models whose cost ties (Cursor's, which have none in the transcript) came
in whatever order the sort left them; they now come by how often each
answered, then by name. A Cursor row with billed usage orders its models by
billed cost and lists every billed model, its subagents' included, instead
of putting only the costliest billed one in front. Outputs on the real
index are otherwise unchanged apart from float rounding.

Rejected: a per-session usage table kept by the indexer | responses are
deduplicated across files, repriced, and adjusted by Cursor's ledger, so
keeping sums right would touch every write path
Rejected: applying -n before summing | the footer totals every matched
session
Tested: go test ./...; JSON of sessions, accounts, stats and show compared
before and after on a frozen copy of the real index
Every command listed every composer in Cursor's state.vscdb before it
showed anything, even with -a naming a Claude account. On a 3.2 GB
database that was 0.2s with a warm cache and up to 1.2s cold, most of an
index run, and Cursor was usually not running at all. A run now compares a
stamp of the database and its WAL (sizes, times, the database header with
its change counter, the WAL header with its salt) with the one saved by the
last full read, and when they match keeps the composers the index has. The
headers catch commits a coarse file clock would miss. A row a newer parser
must read again forces a full read, the stamp is saved only by a run with
no read errors so a failed composer is retried, and --rebuild always reads.

An index run with nothing new went from 0.22s to 0.08s on this machine.

Rejected: refreshing only the accounts -a names | a bare id still needs
every account to find its copies, and the IDE's composers decide which
Cursor CLI sessions are its copies and must not be billed twice
Tested: go test ./... (a test counts the listings: none for an unchanged
database, one after a write, one with --rebuild); index runs on a copy of
the real index
TestSessionsFiltersAndTitles wrote a session and its copy in another
account within the same millisecond on a busy machine. The file created
first owns a shared response and birth times are kept in milliseconds, so
on a tie the owner was whichever of the two index workers wrote first, and
the copy sometimes owned the original's tokens. The fixture now waits for
the next millisecond before writing the copy, as a real copy would be.
Once the usage cache was 15 minutes old, every command that could show a
Cursor account waited for Cursor's dashboard before printing anything, up
to the 45-second read timeout. A command that finds a stale cache with
usage in it now shows the cache and starts `ccli cursor-refresh` detached,
in a session of its own, so the next command has fresh figures. That
command takes a lock file created exclusively, so commands that each find
the cache stale start one read between them; a lock older than two minutes
counts as left behind. Only a command with no usable cache still waits.

Showing older figures is said where it matters: usage more than a day old,
or an update that failed, gets a line on stderr instead of passing for
current.

Rejected: a time limit on the foreground read | a weekly full read takes
longer than any limit worth waiting for, and a read cut off is lost
Tested: go test ./... (tests replace the process start); a stale cache on a
copy of the real data printed in 195ms, and the detached read updated it
and removed its lock
In a narrow pane (48 columns in tmux) a session row wrapped over three or
four screen lines, and cost no longer lined up with its account. Shrinking
the flexible columns cannot help there: when, account, id, a short title
and cost alone need about 59 columns. When the table cannot fit with every
flexible column at its narrowest, sessions now take two lines each: when,
account, id and tag with the cost at the right edge, then the title and as
much of project, model, active time and tokens as fits. The totals and the
legend break between items instead of mid-word. The list of copies a bare
id prints uses the same rendering. Wide terminals are unchanged.

Rejected: dropping columns until a row fits | even the essential columns
are wider than the pane
Tested: go test ./...; sessions and a duplicate-id show at 48 and 160
columns on a copy of the real index
…very account

With a session's summary in focus, the footer offered t, T, c, o and y.
The summary follows none of the first three, and o and y did nothing,
without a word. The footer now comes from the selected row: t, T and c only
for a transcript (a turn or an agent's log), o only for a row with agents,
y only for a row with a prompt. Pressing o or y on a row without them says
so.

Hiding and tagging go by session id, so they reach the copies in other
accounts, but the TUI row reads as one account's. The hide question now
says "in every account", and the tag prompt reads "tag (all accounts):".

Tested: go test ./... (a footer test for a session summary and for an
agent's transcript)
…ery run

Shares are built in a map, and two with the same cost and tokens (common
for projects with no priced usage) came out in a different order from one
run of stats to the next. They now fall back to name order.
Two commands that both found a left-behind lock could each take it, and a
holder finishing after its lock was taken over removed the new owner's.
The lock file now holds a token, and release removes it only while it is
still the holder's. The narrow session list also keeps its title width at
one cell or more in a terminal of a few columns.

Tested: go test ./... (a late release no longer frees the lock that took
over)
@lr00rl
lr00rl merged commit cd65296 into main Oct 24, 2026
2 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.

1 participant