The search list survives a restart, but a restored query was only ever a
reminder: seeing what the network has now meant typing it out again. In
the Searches pane, `S` now re-runs the highlighted query in its place — no
duplicate row, no reordering — cancelling a run still collecting first and
starting the Results display over for what comes back.
The key is listed in the shortcut bar, the `?` overlay, the README and the
website. `start_search` is split into launching a search and showing it,
so both paths share one implementation.
A new `tui_e2e` suite drives the real window over a real session against
soulfind: keys in, a screen out, a sharing peer on the other end. It
restores a query from a previous run's state directory, presses `S`, and
checks the peer's file lands on screen — then again once the query has
been on the wire, proving a repeat is answered and the display does not
stack. To make that possible `MainTui` is exported with `handle_key_event`,
`render`, `poll_session` and a `state()` accessor, and CI runs the suite
with `SOULSEEK_E2E_REQUIRED=1`.
Closes#85
Four panes had grown the same dance around ratatui's table: clamp the
selection, take the visible page, hand the table a page-local state,
keep the page start. It is now page_window and render_page in a paging
module beside page_of, and the two popups page by the same count they
draw, tab bar or header included.
Five typed filters had grown the same key handling too. edit_filter now
takes a key for all of them, which also stops a control or alt chord
from typing its letter into a filter, stops Enter, which keeps a filter,
from resetting the view it is over, and lets the results and room-list
filters page like the others. Their titles and the typing shortcuts
come from one place as well.
A busy room's log scrolls past and its member list runs to a thousand
names, with nothing to narrow either. In a room / now filters the log
to lines mentioning the text and u filters the member list, the same
way the room list and the results filter: type, Enter keeps it, Esc
clears it before it leaves the room. The inbox takes / over the open
conversation. The member keys, b and m among them, act on the narrowed
list, and the titles say what is filtered and how many made it.
A room's chat log was wrapped to the pane width from the first line on
every frame, so a busy room lagged more the longer it stayed open: in
the harness 30,000 lines cost 16.6ms a frame. The wrapped rows now
live with the log and a draw only wraps the lines that arrived since
the last one, starting over when the width changes. The same cache
serves the private-message box, and the room list draws only its
visible page like the other tables. The 30,000-line room now draws in
0.4ms.
A browsed share only moved a row at a time, so a folder of a few
hundred files stood between the user and the next folder. J and K now
jump between folder rows, H and L close or open every folder, and /
filters the whole tree by path the way the results and the room list
filter: type to narrow, Enter keeps it, Esc clears it, then hides. A
folder under a filter shows the paths that match and d downloads just
those. The keys popup gains a browse section and the bar lists them.
Moving through a browsed share felt laggy for the same reason the
results did: the popup re-flattened the whole expanded tree on every
frame, cloning every row's name and path, then built a table row for
each, and every keypress flattened it twice more. A heavy sharer's
tree runs to tens of thousands of rows once a few folders are open.
The flattened rows now live on the browse state and are rebuilt only
when a folder opens or closes, and the popup draws the visible page
alone. In the harness a 30,000-row tree went from 11ms to 0.35ms a
frame and a keypress from 2.7ms to under a microsecond.
The downloads pane had the same shape (every transfer became a row
each frame, and a select-all can queue ten thousand), so it takes the
same page window, which now lives beside page_of for every pane to
share.
Hiding panes with w and zooming with z had no way back short of
pressing each hidden pane's number and Esc. W shows every pane and
leaves zoom in one go, and the shortcut bar offers it only while there
is a layout to reset.
C has cleared every search for a while, and the keys popup says so, but
the pane's own shortcut bar never mentioned it, so it read as if d on
each search were the only way. The bar now lists it next to d → remove.
Scrolling a long results list was laggy: every frame built a table row
for every result, and ratatui then drew the forty-odd that fit. At the
10,000-file cap that cost 25-32ms a frame in release (69ms in debug), in
attached and standalone mode alike, so a held key stuttered.
The pane now slices to the page ratatui would show, by its own rule:
stay where the last frame left off, pulled along only as far as keeps
the selection in view. The table gets a page-local state and the page
start is written back into the real one. The page is counted with the
same page_of that PgUp/PgDn use, so paging and drawing agree, and a
selection past the end lands on the last row as ratatui put it. Idle
frames drop to 1.5ms and a keypress to 0.6ms at the same size, measured
live in both modes.
ratatui has no lazy rows of its own: the lazy_rows proposal was not
merged and moved to the experimental ratatui-table crate, so windowing
in the caller is the recommended approach today.
The path splitter is a one-line stdlib call with a single caller, so it
lives beside that caller as a private fn, without its own tests. The pane
tests assert the two columns in one screen fragment each, the end-offset
test keeps only the two cases the existing name tests do not cover, and
the key-handling test that repeated one of them goes. The website mock
splits on the backslash the same way its Info pane already does.
The results list held the whole shared path in one column, so a search
was read through its folder: every row began with the same peer prefix
and the file name, the part being searched for, sat off the right edge.
Split the column at the last separator, the file name first with the
larger share of the width and the folder dimmed beside it, so names line
up and read at a glance.
The one scroll offset moves both columns together, and $ runs to the end
of whichever overflows further. The website's mock of the pane follows.
Suggested in #55.
Every release since 14 has been a major, because the actor mailboxes,
the share index's struct and the CLI's command enum were all exhaustive
public API: adding a peer message, a scanned field or a subcommand read
as a breaking change to the library. The actors, dispatcher, peer
plumbing and download store are how the client is built, not what a host
programs against, so they move inside the crate; PeerMessage, SharedFile
and the CLI command enums are non-exhaustive; ConnectionType is
re-exported at the root for the one thing hosts used it for. A one-time
break so that the next additions are not.
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
Every search reply we sent carried no bitrate or duration, so our files
failed every bitrate filter in other clients and sorted last in
SoulseekQt, whatever their quality. The share scan now reads each MP3's
first frame header (and Xing block for variable bitrate) and each FLAC's
STREAMINFO, advertising bitrate, duration, VBR, sample rate and bit
depth as far as the format gives them; other files stay bare. Only the
file's head is read, no crate is needed, and a file that does not parse
is shared without attributes rather than skipped.
The keys overlay scrolled with its own counter, a fixed page of ten and a
usize::MAX sentinel that the renderer clamped back; it now reads through
the same LogView as the chat and room logs, so the page is the window it
last drew and the half-page keys work there too.
One pane_area lookup and one page_of helper replace the pane-to-area match
that page_size, highlighted_name_end and the mouse hit test each repeated,
and list_jump takes its page as a usize so the callers stop converting.
The pane number keys are one range arm over FocusedPane::ALL, whose index
is the declaration order. The row layout carries each pane's weight beside
it instead of re-matching with an unreachable arm, and the help columns are
measured from the lines already built rather than built again for their
width. The shortcut legend borrows its static keys instead of allocating
three strings per entry per frame.
The sideways keys the results had (h/l, arrows, 0 and $) now scroll a
transfer's file name and a query too, each list on its own. The browse tree
and the room list take Home/End, PgUp/PgDn and the vim jumps like the main
lists, and a chat log, room or private, pages back through its history and
holds its place while new messages arrive; what arrives meanwhile counts as
unread until the reader returns to the end.
The results pane now has the whole width, where a long file name needs it,
and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab
walk the panes, z zooms the focused one to the window, w hides it so the
others widen, and its number brings it back. Every list takes the same
navigation keys, the shortcuts bar wraps instead of cutting keys off, and ?
lists every key by pane, scrolling on a short terminal.
Follows up on #55.
Scrolled rows cost 31.7ms per frame at 30k results against 13.3ms on
develop, because every row segmented its whole filename into grapheme
clusters. Walk back from the end for at most one column of cells and
forward for at most the offset, so a row costs the column width rather
than the filename, and drop unicode-truncate for unicode-width. A tail
never starts on a combining mark.
Closes#54.
A download can now be cancelled whether it is queued, in progress, or
paused. Cancelled is a terminal status: the store refuses to overwrite
it, the status channel relays nothing after it, the partial file is
deleted, and the transfer thread releases its socket at its next
one-second poll instead of the 30s read deadline. A queued download that
is cancelled declines the peer's later offer with TransferResponse
"Cancelled", and an inbound file connection for it is refused. A fresh
download of the same file evicts the dead row so it is not shadowed,
and a cancelled transfer is not re-queued after a restart.
Surfaces: Client::cancel_download, download.cancel over RPC, `transfer
cancel <user> <path>` on the CLI (which also stops an upload by that
name), and x in the transfers pane, where d dismisses the row.
Closes#55.
While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.
Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
A long-running daemon that lost its TCP connection to the server reported
session_loss "disconnected" and answered every command from the dead session
until a manual restart. Two causes:
- the daemon owned one session for its lifetime with no reconnect at all; and
- the lib's ServerActor queued a Login sent while disconnected instead of
dialing, so even asking it to log in again never re-established a connection.
Reconnect on a dedicated thread with 1s-60s capped backoff, only for a dropped
connection to a session this process owns (never a displaced or borrowed one,
which would start a login war or fight another daemon). Let a Login through the
disconnected guard so a parked actor dials again, and bound that dial with
connect_timeout so a blackholed route cannot park the actor thread. Reset the
message reader, the queued backlog and the login verdict on disconnect so a
partial frame or stale Some(false) cannot wedge the reconnected session.
Announce the drop once per outage and re-join rooms and re-watch users on
recovery; the daemon main loop is back to save-and-stop so `daemon stop` stays
responsive during an outage. Update the session_loss event summary, which no
longer means the session is gone for good.
The e2e job caught this: adding status and file columns to the text record
broke `room_users_lists_who_is_in_the_room`, which asserts the line equals
the username. That test encodes a real contract -- the text output is a
roster to pipe into other commands -- and reformatting it was scope this
change did not need. The statistics stay on the record for `--json`.
Reproduced the failure locally against soulfind, then confirmed the same
test passing with the fix.
Running the e2e suite against a real soulfind showed the per-member slots
field was implemented backwards. The wire field is `slotsfull` -- a boolean
that is true when the member's upload slots are all occupied -- not a count
of free ones. `slots_free: u32` named it, documented it and printed it as
the opposite of what the server means, so a caller reading it would pick the
busiest peer to download from.
- types: `slots_full: bool`, with the wire name in the doc comment
- the DTO, the `room users` output and SKILL.md follow
- a new e2e test joins a room with a member sharing a known three files and
asserts the counts, the roster alignment and the slot flag against
soulfind, so the parsing is checked against a real server rather than only
against frames this repo wrote itself
The statistics parsed from the JoinRoom reply were reachable only from the
library. They answer the question `room users` already exists to ask -- who
is in here, and are they worth downloading from -- so they belong on its
output rather than behind a second call.
- proto: room.member_stats, with a RoomUserStatsDto carrying status, share
counts, free slots and country
- SessionApi::room_member_stats, so local and remote sessions stay
interchangeable; the status word is decoded through one shared helper
rather than a third copy of the match
- `room users` now emits status, average_speed, shared_files,
shared_folders, slots_free and country, each null when the server said
nothing, documented in SKILL.md
- docs/openrpc.json regenerated from the wire types
The watch subscription was reachable only from the library. It is most
useful against a daemon -- that is the session that stays connected long
enough to receive the server's status pushes -- so it needs the wire.
- proto: user.watch / user.unwatch / user.watched, with a Watched response
- SessionApi gains watch_user / unwatch_user / watched_users, so a local and
a remote session are still interchangeable above the seam
- CLI: `watch add`, `watch remove`, `watch list`, documented in SKILL.md
including the caveat that a one-shot run's watch ends with the run
- docs/openrpc.json regenerated from the wire types
A completed row used to ignore d: only a queued transfer could be pulled
out of the list, so a finished one sat there until the next restart. d
now goes through one handler that drops a finished row whatever the
session thinks of it — a completed transfer can live only in this window,
restored from an earlier run, where there is nothing to forget.
The finished-status test was spelled out twice in clear_finished_downloads
and had to agree by hand; both callers now share is_finished().
In results filter mode, Up/Down move the selection over the filtered
list and Enter confirms the filter (keeps the query, returns to normal
results keys). Footer hints updated.
The peer listener died with "Too many open files (os error 24)" on any
long-lived non-daemon session: only the daemon raised RLIMIT_NOFILE, and
peer sockets were unbounded — no idle reaping, no deadlines on accepted
transfer streams, and one peer could hold two connections under split
registry identities.
- raise the open-file limit at the top of run(), for every entry point,
and warn from Client::connect when the soft limit is below 1024
- reap peer control connections idle for five minutes; a clean
disconnect no longer discards the peer's queued uploads
- give listener-supplied download streams and upload serving the same
read/write deadlines dialled sockets already had
- register inbound peers under their plain username so the registry
holds one connection per peer, and announce them like pierced ones
- hold the registry lock across actor spawn + insert and surface
thread-spawn failure, so an instantly-dead peer cannot leave a
zombie entry
A CLI one-off search leaves its result set in the daemon until someone
forgets it, and an attached TUI made that permanent state expensive:
every 100ms tick asked for the search list twice, and each answer
deep-cloned every result of every search under the context lock just to
sum file counts - stalling the thread that stores incoming results.
Worse, any active or selected search pulled its entire result set over
the socket again each tick, megabytes of JSON repeating what the window
already held.
search.list is now backed by search_file_counts, a walk of the cache
instead of a copy of it; the TUI syncs once per tick and re-fetches a
result set only when the session's file count differs from what it
holds. get_search_results_count reads the same summary, so get --pick
first against a daemon polls a count instead of the full set, and both
session kinds now count files where the local one counted peer
responses.
docs/openrpc.json lived at the repository root, outside the soulseek-rs
package, so the include_str! in proto compiled from a checkout but not
from the published tarball - cargo publish failed verification and the
v13.0.0 release stopped after publishing the lib. The document moves
into the crate's own docs/, where packaging can see it; the generator
test and every link follow.
The serving process built its arguments by hand, so in daemon mode
--shared-dir stayed on the attached run, where shares belong to the
daemon and the flag is ignored. The daemon fell back to sharing its
default folder - which on a developer machine contains the probe files
earlier buggy runs deposited, so the test only passed by finding
served.bin in leftovers. args_with routes the share to the process
whose session does the sharing in either mode.
The feature list said the same things at twice the length, and Project
Goals / Project Structure were two headings for one paragraph.
SKILL.md argued against throwaway accounts on cost grounds. The reason
that holds is standing: the agent arrives under the user's name, peers
judge that name, and inventing a second one throws it away. Exit 7 now
points at starting a daemon rather than at --username.
info.version carried the crate version, so release-plz bumping Cargo.toml
left docs/openrpc.json a version behind and failed document_is_current on
every platform. With the CI gate in front of publishing, that made every
release PR unmergeable. The protocol version is what auth negotiates and
what the contract actually describes.
A connection that has joined a room receives event notifications on the
same socket as its replies, so a notification landing between a request
and its answer was read as the answer. Only replies carry an id.
Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).
The bigger cuts:
- The NewPeer/PierceFireWall cluster: an operation variant nothing ever
sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that
wrote a peer-init frame down the server socket, and the struct only
they used. The live paths in peer/listen.rs are untouched.
- The ops loop reads `for operation in reader` instead of hand-rolling
recv/break, de-indenting the whole dispatch body.
- Dead lib surface: Message::print_hex and four unused accessors,
UploadFailed, registry and actor getters, build_watch_user,
strip_ansi_codes, delegating shims on ClientContext. Removing public
items makes this a semver major, which the release pipeline enforces.
- Never-used flexibility: the state-migration framework that never held
a migration, border-type helpers that all answered Rounded, the TUI's
max_concurrent_downloads field nothing read.
- Convergence: six poll loops into poll_until, three wrap-around
navigation blocks into cycle, hit tests into Rect::contains, char
walkers into floor/ceil_char_boundary, five Download literals into
one helper, the suites' shared scaffolding into tests/common.
Deliberately not taken: swapping the hand-rolled zlib inflate and MD5
for crates (they replace wire-facing machinery whose tolerance of
real-world peer quirks no local test exercises) and pulling chrono into
the lib for a log timestamp. Recorded for their own decision.
Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
The full-surface audit found the settings story half-true: `config set
download_dir` and the TUI's settings form edited a file a running daemon
never rereads, while the attached TUI displayed the laptop's path and
discarded the edit entirely.
- `download.set_dir`: a session-wide move, published in the contract;
the per-transfer destination stays refused. `daemon.status` reports
it and the next transfer lands there.
- `config set download_dir` and `config set shared_dirs` push to a
live daemon the way `shares add` does, through one shared helper;
the socket probe is paid only by the keys a daemon owns.
- The attached TUI seeds its settings form from the daemon's status,
pushes both folders over the socket as typed, reports a partial
refusal as exactly that, and creates no client-side directories. It
reads the daemon token from the resolved config — with the token only
in config.toml it could not attach over TCP — and settings
persistence honours --config instead of hard-coding the default path.
- `search.wishlist` records a start time, so an attached window
renders it as running rather than instantly finished.
The rest is the coverage the audit demanded: CLI-level token auth
(wrong, missing, and valid over TCP), `daemon status|stop` with
nothing running, --sort, --limit, --pick first, --password-cmd,
--log-file, --upload-slots, the config-named download folder in both
modes, and headless settings-form tests attached and local. Every
guard was checked by disabling what it protects. Both e2e modes run
green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
Leading with `get` was the wrong trade. It ranks on free slot, bitrate and peer
speed, which are facts about the transfer and say nothing about whether the
file is the track that was asked for. On a setlist of remixes, dubs and white
labels it does not miss, it succeeds with the wrong one: exit 0, a file on
disk, and nobody finds out until it plays. A miss can be retried; a confident
wrong answer cannot. The previous revision optimised the measurable cost and
paid for it in the unmeasurable one.
The token problem it was solving is real, but the answer is to narrow before
reading rather than not to look. A search writes its records to a file; search
flags and jq cut three hundred down to eight without any of them entering
context; the eight are read and judged. `get` keeps a scoped mention for when
the user has named nothing in particular.
Adds what "the right one" means, since that is now the load-bearing step: fold
both sides, require every distinctive word, reject live/radio edit/snippet/dj
mix, prefer lossless, and above all treat the version as the thing you match on
rather than the thing you dropped. The ladder drops "(Kalabrese Remix)" to find
anything at all, so the match must then require "kalabrese" or it takes the
original and reports success.
Checked against agents given only this file on a remix-heavy setlist: 2/2
refuse `get`, narrow before reading, require the remixer's name in the chosen
path, and report a track as unavailable rather than silently taking the
original when only that exists.
Feedback from a real 32-track run: 70 agents, 2.48M tokens, 18.7 minutes, most
of it avoidable. Four things, three adopted as reported and one corrected.
The skill mandated a handoff it gave no mechanism for. Subagents had to report
a pick, while the rules said remote paths are backslash-separated, may contain
anything, and must be passed back byte-identical. A path does not survive a
subagent's return, a coordinator's JSON and a child's argv, so every agent
re-derived a workaround from prose. The idiom is now written down: append every
rung to one ndjson file, select the pick by line number, feed it to `download
--stdin`. The bytes never leave the file, and a subagent reports a file and a
line rather than a path.
`get` is now the default and subagents the exception. It searches, ranks and
downloads in one command, so results never enter any context, which is where
the tokens went. The section says when to escalate: a named mix, an ambiguous
title, four dead rungs, or several runs landing on the same peer.
Browsing triggers on any artist appearing more than once, not only on a whole
album. In the reported set that was 14 of 32 tracks across six artists.
The one correction: "rung 2 empty means rungs 3 and 4 are ceremony" is not
safe, because rung 3 folds diacritics and punctuation and that is often exactly
why rung 2 found nothing. The general rules are that a rung producing the same
string as the last is not a rung, and that no results at all means the tokens
are wrong rather than too many, so jump to the widest rung instead of stepping.
Streaming picks into transfers is untouched; the report singled it out as
working.
Checked against agents given only this file on the same shape of set: both lead
with `get`, spawn 3 and 6 subagents rather than 32, keep result sets out of
context, browse the repeated artists, and hand paths over as a file and a line
number.
The clearance step read as a barrier. "Only you can see every pick" invited
collecting all of them before releasing any, so a forty track set moved no
bytes until the last search finished, and one white label nobody is sharing
held up thirty-nine tracks that people are.
Nothing about the safety needed a barrier. Answering a single pick takes two
running counts, transfers to that peer and transfers overall, so picks are now
cleared as they arrive and the first file transfers while the last track is
still on its second rung. Says so explicitly, because the previous wording did
not have to be read as batching but was.
Also points a track that survives all four rungs at the wishlist rather than
just reporting it missing: the server re-runs those on its own schedule, which
is the right home for something nobody is sharing right now.
Checked against agents given only this file, on a forty track set with
unfindable tracks in it: 2/2 start transferring before the searches finish, and
both say a missing track holds up nothing but its own subagent.