feat(sessions): explain and walk a hit, tty-first switch, memory chip, resizable window
Groups C, D and the small items of E/F from #144, in one PR as agreed (grimmer: review rounds are fine, a complete feature is easier to review). Closes #141, closes #146, closes #110, closes #148 (both of its steps — the window resizes, and the line caps follow the width). Partially #142 (C0 — the switch and the ·ttys tag); it stays open for C1/C2 and the generation display.
1. A search result explains itself and can be walked — #141, #146
- Every hit, not the first. The main-side search now returns up to 20 prompt hits per session, each with its time and its neighbouring prompts (
findPromptHits, prompt timestamps now kept alongside the prompt index). Thematch #Nline gains‹ 2/12 ›to step through them. - Context around a hit:
▸on the match line unfolds the prompt before (↑) and after (↓). User prompts only — the assistant's text is not in the index — which is the smallest useful version of the reader in #66 and ships without it. by matchchip while searching: order by when the match happened (latest hit) instead of the session's last activity. Off by default (#146's own leaning: "what was I just working on" is the commoner question). Rows whose match was not in a prompt fall back to last activity.- Why is this row here (#141):
explainMatchnames the fields each word / PR reference / scoped term (title:x,recap:x,msg:x→ prompt) hit: title, branch, project, path, prompt, recap, reply, assistant, pr, id. The row shows amatch path/match assistant/match recap/match replyline only when that field is not on the row — fields that render already carry the highlight. Vertical space stays the scarce resource, as the issue asked.
2. Switch by tty first when the pid is exact — #142 C0
Three /branch siblings share a title on purpose, and the title-first switch sent every row to the same iTerm2 tab. A process has exactly one controlling tty, so the AppleScript now matches tty first, title as fallback (iTerm2 and Terminal.app) — when the pid is exact. The order follows the pid's provenance (src/terminal-switch.ts, one builder for both terminals): a pid read from Claude Code's own ~/.claude/sessions/<pid>.json whose session the history knows is exact → tty first; a pid the detection had to guess (a same-cwd match or a terminal-tab title, for a process whose registration names a session the history does not know yet — a fresh claude before its first prompt, a /branch child before its first prompt, old Claude Code with no registrations) keeps the old title-first order, because a guessed tty can point at a sibling. Both keys are tried when the session has a title, only the order changes; without one, tty is the only key. isGuessedPid is the classification: whatever the last detection attached outside the registration paths (a --resume <id> on the command line, the old-CLI fallback, counts as exact too). Ghostty has no per-tab tty (#63) and keeps title-then-cwd. Running rows that share a title get a ·ttys003 tag (pid when there is no tty) — the thing the click now matches on.
Verified by hand (2026-09-06): two live sessions both titled gg (a /branch child renamed to match its parent, plus the parent resumed in another tab) show ·ttys001 / ·ttys012 and each click goes to its own tab; before this PR both went to the first tab whose title matched. Two notes: Claude Code 2.1.261 now auto-names a branch child (Branched conversation (Branch N)) instead of copying the parent title, so the identical-title case #142 recorded under 2.1.260 no longer arises from /branch alone — the fix still matters for a manual rename and for one session opened twice. And a title changed in the terminal shows up after the popup is next opened (enrichment re-reads the transcript on open), not while it stays open.
Why tty was not first before. The registration file (~/.claude/sessions/<pid>.json) has been read since #67, but it is best-effort: it can be missing while the process is alive and stale after it dies, so detection kept a "latest session in this cwd" guess as fallback, and the code could not tell a registered pid from a guessed one. When a guessed pid landed on the wrong tab (868db59, 2026-03-21, the claude -r picker briefly registering the wrong session), title was put ahead of tty as the safe key. #147's ps join is what made a registered pid exact (stale entries dropped, unregistered processes marked), so tty-first only became defensible now — and only for a registered pid, which is why the order is keyed on provenance above rather than flipped outright. docs/claude-session-integration-design.md now opens with three flow diagrams (list + detection, open, switch — every terminal) and carries this history under "Switch matching order".
Not in this PR from C0: a branch-generation / divergence-time display (waits on #142's open question 3).
3. Memory warning chip
sysctl -n vm.swapusage kern.memorystatus_vm_pressure_level is read on the same refresh as the process table (one extra spawn, ~ms). A swap 13GB chip appears beside ● live only when swap is past 8GB or macOS reports pressure — amber at warn, red at critical — and the figures are always in the live chip's tooltip. Why: the night 42 claude processes at 5.1GB pushed a 32GB machine to 18GB of swap, swap was the number that said so first; per-process RSS did not.
4. Normal app mode: resizable window, width-aware rows — #148, both steps
- Step 1 — the window resizes.
resizable+minWidth 640 / minHeight 420in normal mode; bounds saved (debounced 400 ms) toswitcher-window-boundsand restored before the first show — only if the remembered rectangle still intersects a display's work area, so a window remembered on an unplugged monitor does not open off-screen. Theresize/movelisteners are attached whatever the mode and check the mode when they fire, andset-app-mode→ normal restores once, so a window created in menu-bar mode and switched later is remembered too. The header was already a drag region, so the macOS title-bar double-click action (zoom / minimise, per System Settings) applies to it. - Step 2 — the caps follow the width. The character caps on title, messages, branch, reply, recap and the match snippet were tuned for the 800px default;
fitToRownow scales them by the list's measured width (ResizeObserver; scale ≥ 1, so the fixed-width values are the floor and menu-bar mode is unchanged). The same pure windowing functions, a differentmax— the search-window "move to the match" behaviour still knows where the cut is, as the issue asked. The Projects list grows with the window too, instead of stopping at 480px with a scrollbar mid-window, and reaches the bottom edge. ⤢in the header (normal mode only) resets the window to the default size and position and forgets the saved bounds — macOS zoom only restores the previous user size, so there was no way back. The debounced save treats the default geometry as "nothing to remember" and unsets the key, so the reset's ownsetBoundsis not written back while a resize right after it is saved like any other; centring uses the work area's origin (the menu-bar popup and the AI Assistant window use the same helper, so they centre on the work area now too).- Menu-bar mode is re-asserted at every show and at every mode switch — default size, centred, not resizable — so a window resized in normal mode does not carry its size across a switch.
5. Status stuck on working at the context-limit prompt — #110
No hook fires at that prompt, so a working status file untouched for 10 minutes is shown as idle (dotStatus, renderer, one rule for all three status paths, re-derived from the last received statuses once a minute so the dot turns without waiting for the next event or focus). Ten minutes is longer than any tool call this app has seen; the cost of being wrong is a dot that turns green early.
6. Tooltips now show
Every hint in the switcher (chips, badges, the hit controls, the per-process figures) is drawn by the app (TooltipLayer, data-tip) instead of the native title attribute, which Chromium paints only while the window is the key window — a frameless popup the pointer merely crosses usually is not, so the hints almost never appeared.
7. Docs
- README: Keeping the machine responsive — sessions grow while idle (upstream issues cited), save-list / close / open-N as the routine, the two Spotlight exclusions (
~/Library/Application Support/Claude,~/.claude) with the⌘⇧.tip, and the long-uptime compositor as a suspect of its own. The terminal-support table states the switch order rule (tty → title for a registered pid, title → tty for a guessed one). docs/claude-session-integration-design.md: opens with three flow diagrams — the list and what is running (history / registrations /psjoin, where EXACT and GUESSED part ways), open (every terminal's launch path and the resume command), switch (the provenance branch for iTerm2 / Terminal.app, then Ghostty, cmux, VS Code, CodeV). The iTerm2 "Switch matching order" section is a table plus the history above; "Where TTY is relied on" lists what to revisit when Ghostty exposes a per-tab tty (#63).- CHANGELOG 1.0.89.
Tests
23 new (findPromptHits order / neighbours / caps / limit / code-point cap, explainMatch fields, scoped terms and id, parseMemoryPressure units and nulls, the collector carrying memory, and terminal-switch: block order per provenance for both terminals, tty-only without a title, AppleScript escaping) — 191 total. tsc clean.
What to test by hand
- Search a word you typed many times in one session →
‹ 1/N ›on the match line, step through;▸unfolds the prompts around the hit. - Same search →
by matchchip: the order changes to when the hit happened; toggle back. - Search a PR the assistant mentioned but you never typed → the row shows
match assistant grimmerk/codev#N; search part of a project path →match path. - Two
/branchsiblings running with the same title → rows show·ttysNNN; clicking each goes to its own tab (iTerm2 / Terminal.app). Optional, for the guessed-pid order: start a bareclaudein a folder that has exactly one old session and type nothing — the old row gets the purple dot (its pid is a guess); clicking it logsorder=title-firstand lands on that tab via the tty fallback. ● livetooltip showsswap … · pressure normal; no chip unless swap > 8GB or pressure ≥ warn.- Normal mode: drag the window edge; quit and relaunch → same place and size. Wider window → longer titles / messages / snippets, not more empty space.
⤢in the header → default size, centred; relaunch → still default. Double-click the header → zooms (or minimises, per System Settings). - Settings → switch to menu-bar mode with a resized window → the popup comes back at 800×600, centred, not resizable; switch back to normal → the resized bounds return.
- Leave a session at the context-limit prompt > 10 min → its dot turns green, without touching the window.
- Hover a chip, a badge,
‹ ›,▸ context→ a tooltip appears even when the window is not focused. - Projects tab → the list reaches the window's bottom edge.
🤖 On behalf of @grimmerk — generated with Claude Code
Summary by cubic
Search results now explain why a row matched and step through every hit in a session, not just the first. Switching to a running session matches its terminal first when the pid is exact, and normal-mode windows are resizable with a reset button.
Search results
- Each session returns up to 20 prompt hits with timestamps and neighbouring prompts; the
match #Nline adds‹ 2/12 ›to step through them. ▸ contexton the match line unfolds the prompt before (↑) and after (↓); only user prompts are in the index.- The
by matchchip orders results by when the match happened instead of last activity; off by default. - A row matched on a field it doesn't display gets a
match path/match assistant/match recap/match replyline.
Session switching, live view, and window
- iTerm2 and Terminal.app try tty first for an exact pid, title first for a guessed one (Ghostty keeps title-then-cwd); a
--resume <id>on the real command line counts as exact, a mention inside a-pprompt does not. - Running rows sharing a title get a
·ttys003(or pid) tag — the thing the click matches on. - A
swap 13GBchip appears beside● livewhen swap passes 8GB or macOS reports warn/critical pressure; the figures are in the live tooltips. - Normal mode windows are resizable (min 640×420), restore only into a display's work area, and a
⤢header button resets — the reset drops any pending bounds save, and menu-bar geometry never saves over a pending normal-mode bounds write. - Tooltips are drawn by the app — the native
titletooltip only painted while the window was key. - A
workingstatus untouched for 10 minutes renders idle; the context-limit prompt fires no hook.
Summary by CodeRabbit
-
New Features
- Search results now show match counts, matched fields, timestamps, surrounding prompts, and navigation through up to 20 hits.
- Added memory-pressure warnings, responsive session lists, custom tooltips, and duplicate-title terminal indicators.
- Normal-mode windows can be resized, repositioned, and restored between launches.
- Added a control to reset window bounds.
-
Bug Fixes
- Improved terminal tab switching and stale “working” statuses.
- Fixed tooltips that failed to appear.
-
Documentation
- Added guidance for keeping the machine responsive with many sessions.