Skip to content

sessions: name a session, and give the picker two-line rows - #11

Merged
tdwd merged 5 commits into
mainfrom
session-titles
Sep 17, 2026
Merged

tdwd merged 5 commits into
mainfrom
session-titles

Conversation

@tdwd

@tdwd tdwd commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The problem

A one-line row put the title and every identifying detail on the same line, so a narrow terminal truncated the title away first — the part the list is actually read for.

Rows are now two lines

╔════════════════════════════════════════════════════════════╗
║   RESUME SESSION                                           ║
║ › filter…                                                  ║
║   rewriting the codex approval path                      │ ║
║     01a07c41 · cathode · gpt-6-astra · 2h ago            │ ║
║   why does the splash dial claude                        │ ║
║     9f2b1c07 · cathode · opus · yesterday                │ ║
║   [↑↓] move   [enter] choose   [esc] cancel                ║
╚════════════════════════════════════════════════════════════╝

Yes, it scrolls — it always did. It windows by item and draws that gutter, capped at 16 rows or the terminal height. Two-line rows just halve how many fit, which the existing windowing already handles once it counts items rather than screen rows.

The id moved down with the rest of the detail. It identifies a session but nobody scans a list by it.

Opt-in per picker (picker.twoLine), and only the session picker sets it. Everywhere else the trade is not worth it.

/title

Names the live session; the picker shows that instead of your first prompt, which is a poor label for what a long session turned into. Titles live in cathode's own store — neither backend has a place to put one, and a name is your note to yourself rather than anything the agent sees. An empty /title clears it, so there is no separate unset verb. Works on both backends.

Two things found by rendering, not by reading

  • The selected row's content is padded before styling. A title and its detail line are never the same length, so styling first leaves the lightbar as two ragged blocks.
  • It is padded two columns short, because approveBar carries Padding(0, 1). Pad to the full width and the styled row renders too wide, wraps, and the scrollbar gutter interleaves with the text.

The test for that counts lines, not widths. My first version measured widths, passed with the bug deliberately reintroduced, and was worthless — the surrounding box pads every line to the same width whatever happens inside it. The rewritten one fails when the bug returns.

Verified

go vet, full suite, and the picker rendered and eyeballed at 64 columns.

A one-line row put the title and every identifying detail on the same line, so
a narrow terminal truncated the title away first — the part the list is read
for. Rows are now two lines: the label on top, the id, repo, model and age
beneath. The picker was already scrollable and windows by item, so the only
change there is that half as many fit.

The id moved down with the rest. It identifies a session but nobody scans a
list by it.

/title names the live session, and the picker shows that instead of the first
prompt. A first prompt is a poor label for what a long session turned into.
Titles live in cathode's own store: neither backend has a place to put one, and
a name is the user's note to themselves rather than anything the agent sees. An
empty /title clears it, so there is no separate unset verb.

Two things about the selected row were found by rendering it rather than by
reading it. Its content is padded to a fixed width BEFORE styling, because a
title and its detail line are never the same length and styling first leaves
the lightbar as two ragged blocks. And it is padded two columns short, because
approveBar carries Padding(0, 1): pad to the full width and the styled row
renders too wide, wraps, and the scrollbar gutter interleaves with the text.

The test for that counts lines, not widths. The first version measured widths,
passed with the bug reintroduced, and was worthless — the surrounding box pads
every line to the same width whatever happens inside it.
Three things a second review found.

truncFirst sliced by byte offset, so a prompt with an em-dash or a curly quote
near the cut came back as invalid UTF-8. That is the same bug sysPromptSummary
was already fixed for, and trunc has been rune-safe all along — four other files
use it. Pre-existing, fixed by reuse rather than by a fourth implementation.

trimTitle was that fourth implementation. It now goes through trunc too, which
also means a capped title marks what it dropped: cut mid-word with no ellipsis,
it reads as one the user typed that way.

With neither a title nor a first prompt, the row fell back to the id and then
printed it again at the head of the detail line underneath.

The lightbar inset is asked of the style rather than written as a 2. A theme
that changed the padding would otherwise wrap the row and desync the scrollbar,
and the symptom is a layout glitch nobody traces back to a style definition.
Verified by setting the padding to 3: the derived version holds, the constant
would not have.
/title wrote the store, printed its confirmation, and the picker showed the
first prompt anyway. Only codex sessions were unaffected, and only because they
never reach this path — on claude, the backend most sessions run on, the feature
did nothing at all.

mergeWithStore started from the filesystem entry and copied two named fields out
of the store into it. Every claude session has a JSONL on disk, so the
filesystem entry always wins the merge, and Title was not one of the two names.
Backend had the same exposure and survived only by luck: an unset backend
already means claude.

It now starts from the stored record and lets the filesystem override what it is
authoritative for. A new sessionInfo field is carried by default, and only an
explicit override comes from the filesystem — so the next field added does not
have to be remembered here.

The tests missed it because they build a store with no filesystem sessions
behind it, which takes the store-only path and never exercises the merge. The
new one calls mergeWithStore directly with both halves present.
/title on a resumed session answered "no session to name yet — send a turn
first". The id was known all along: it is what the session was resumed with.

m.session was only ever set from claude's system/init line, and claude does not
emit that until the first turn of a process. So a resumed session had no id
until you sent a turn, and so did everything else keyed on it — the status row,
and /sysprompt's decision about whether a restart can resume anything.
newModel now seeds it from the id it resumed with.

Behind that sat a silent one. A session started outside cathode is listed from
claude's own session files and may never have been written to our store, and
SetTitle ignores an id it does not know — so the title was dropped while the
confirmation still printed. commitTitle now records the session before naming
it.

Both were found by running the command, not by reading it. The unit tests
covered a live session that had already been through a turn, which is the one
case where neither bug shows.
The title was cut at 64 or 72 runes while the row can show about 95, so a third
of a wide modal sat empty. The ellipsis came from the store, not the screen.

Those caps were a storage bound doing display duty. sessionLabelMax replaces
both and is set well past any width the picker can render, so the terminal is
what decides how much shows. A test pins that relationship, because the two
numbers live in different files and the failure is silent — a shorter label just
looks like a shorter session.

Truncating for the screen is the picker's job, and it now marks a cut with an
ellipsis rather than stopping mid-word. That applies to every picker: a command
description trimmed to fit reads as the whole of a shorter one otherwise.

No migration needed on claude: listClaudeSessions re-reads each session file and
re-truncates on every open, so those labels widen straight away. A store-only
row keeps its shorter label until it is touched again.

truncFirst's comment said it trimmed the subtitle. The first prompt became the
title row when the rows went to two lines, and the const added above it had
orphaned the comment onto the wrong declaration.
@tdwd
tdwd merged commit 899a19d into main Sep 17, 2026
1 check passed
@tdwd
tdwd deleted the session-titles branch September 17, 2026 10:56
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