feat(slash): /resume picker + mid-session in-place re-init#70
Merged
feat(slash): /resume picker + mid-session in-place re-init#70
Conversation
Surveys /resume / --resume / --continue UX across Claude Code, Codex, and opencode; lays out the oxide-code design (bare /resume opens a searchable picker; /resume <id-prefix> mirrors `ox -c <prefix>`; mid-session re-init via roll_into; SearchableList primitive sibling to ListPicker).
Refactors `SessionStore::list` / `list_all` to call a new `list_paged(limit, all)` that pre-stat-and-sorts directory entries before paying the per-file JSONL parse cost. Returns a `ListPage` carrying the post-truncation rows and the pre-truncation total so the renderer can append a "... and N more" footer. Caps `ox --list` at 30 rows by default; `--limit N` widens, and `--limit 0` opts back into the previous unbounded behavior for piping. Old `list()` / `list_all()` stay as one-line wrappers so existing callers (resolver, tests) don't churn.
Adds `/resume` (alias `/continue`) — a searchable session picker that swaps the live session in place via a new `roll_into` mirror of `roll`. Bare opens the picker (substring filter, Tab toggles current-project ↔ all-projects); `/resume <id-prefix>` resolves directly. Introduces a generic `SearchableList<T>` modal primitive so future filtered pickers share the chrome. Old session is finalized after the swap so the live session stays clean if the target fails to load.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…ghten width math Review-driven fixes for the /resume picker: - Filter the live session out of the picker rows AND short-circuit roll_into when target == live id, so neither path can race the open append-writer. - Classify both forms of /resume as Mutating; bare picker also waits for idle so the agent loop never drops the submitted Resume action. - Drop the dead key_hint / select_by_hint / HINT_WIDTH surface from SearchableList; the resume picker doesn't dispatch numeric mnemonics and the gutter was a permanent 3-col leak. - Tighten title-budget arithmetic: use unicode display width on the project column (CJK paths no longer overflow), name the +2 magic separator, cap project rendering coherently with the budget. - Re-export SessionInfo / TitleInfo via session::store instead of widening the entry module to pub(crate). - Replace RollIntoOutcome's wasted ResumedSession.handle clone with a payload-only struct (messages / title / tool_metadata / finalize_failure). - Move session_write_error AFTER SessionResumed so chat.clear_history doesn't wipe the freshly-pushed error; promote dropped-event log to error level. Disable input on UserAction::Resume to avoid the wipe-while-typing window. - Doc fixes: SessionResumed / UserAction::Resume / RollIntoOutcome / seed_test_session / total field / resolve_prefix; CLAUDE.md crate tree drops internal-name leaks. - Tests: apply_session_resumed, typed-arg success path, picker filters live session, Tab widens scope and preserves query, full-id match, tracker invariance under load failure, self-resume rejection, empty-visible navigation guards, footer plural / all-projects branches.
…ware footer - consolidate ambiguity formatter in `match_in_scope` so the typed-arg path stays in lockstep with `ox -c <prefix>` - empty-list Enter stays open instead of silently dismissing (Cancelled → Consumed) so users can Tab the scope or Esc out explicitly - surface `list_paged` errors inline in the footer instead of warn-log + empty list — failures must not look like "no sessions found" - footer reflects substring filter as "X / Y matching" so a typed query doesn't mask the actual scope size - defensive `modals.clear()` in `apply_session_resumed` for future nested overlays above the picker
apply_session_resumed silently dropped pending_prompts; user-typed Enter-committed work disappeared with no UI signal. Push a system message into the resumed chat naming the discarded count so the loss is visible.
The previous code reused session_write_error for the OLD session's finalize failure, which formatted as "Session write failed: ...". A user reading that in the resumed UI would think the *current* writer broke. Route through AgentEvent::Error with an explicit "Previous session failed to finalize" prefix so the source is unambiguous. Also fix the misleading "Move once" comment over a clone — the code clones; the comment claimed otherwise.
restore_verified silently dropped drifted snapshots; the user's next Edit would reject with "must Read before Edit" and they wouldn't understand why. Return the dropped paths from restore_verified, plumb them through RollIntoOutcome, and surface a count + 3-path preview as an AgentEvent::Error after SessionResumed. CLI --continue path warn-logs the drift; mid-session Resume routes through the sink so the user sees it in chat. Extracted apply_resume helper to keep agent_loop_task under the line cap.
CLAUDE.md forbids `pub use` re-exports that obscure where items are
defined. Promote `entry` to `pub(crate)` and have callers import
directly from `session::entry::{SessionInfo, TitleInfo}`. Also drops
the workaround comment that was admitting the re-export was a hack.
The field duplicated session_id[..8] and required from_info to keep two strings in sync. Compute the prefix at render time so the invariant becomes structural. Drops the redundant haystack slot (every prefix char is already a session_id substring).
Public fields permitted constructing `ListPage { sessions: vec![full;
N], total: 0 }` — an inverted state. Make fields private, add new()
with a debug-asserted invariant, accessor pair, into_sessions for the
move case, and Default for empty fallbacks. The picker's error path
now reads `ListPage::default()` instead of a literal.
ResumePicker re-queried `UtcOffset::current_local_offset` on open — documented as unsound on multi-threaded Unix runtimes (race on localtime_r post-spawn). Move the OnceLock cache out of main.rs into util::time so the picker reads the same value the CLI listing uses, captured once before tokio spawns workers.
A title-gen task spawned for a session that subsequently finalizes (via /clear or /resume) hits actor-gone when its Haiku response finally returns. The old code routed that as `Session write failed: ...` into the sink — which, for /resume, lands in the *resumed* session's UI. Add `SessionHandle::is_actor_alive` and have title-gen warn-log post-finalize failures instead of surfacing them.
- store.rs: list_paged tests now sit after list_all (production: list → list_all → list_paged) - resume.rs: ID_WIDTH declared before TIMESTAMP_WIDTH (render paints the id slot first); footer_text + render-load-error tests merged into the ResumePicker section so production-method tests aren't scattered after resolve_prefix tests - searchable_list.rs: replace_items tests precede select_next / page_down (production order); navigation_on_empty_visible_set moved into page_down/page_up section as the cross-method edge case; replace_items_resets_cursor_and_reapplies_filter now asserts the cursor reset documented on the function
…n const
- searchable_list: fold the four `if visible.is_empty() { return; }`
prefixes through a `nonzero_visible_len()` helper; recompute_visible
separates the empty-needle fast path so the common case skips per-row
to_lowercase allocation; drop the misleading
`Vec::with_capacity(area.height)` hint
- slash/resume: promote `FIXED_PREFIX_WIDTH` to a const mirroring
list_view.rs:16; `match_in_scope` uses pop() instead of
into_iter().next(); with_isolated_xdg drops the redundant
PathBuf clone
Apply comment-analyzer's per-line verdicts: - resume.rs: drop ID_WIDTH name-restating doc, the verbose render-row paragraph, the "hex session ids" overclaim in resolve_prefix doc, the dead "Most-recent-first" test comment, and the test-name- restating "Pin the success branch" / "Two projects" comments - searchable_list.rs: tighten module doc, trait/struct/field docs; drop trait-name-restating "One row in a SearchableList" - store.rs: collapse the four-line collect_paths_all_projects doc - handle.rs: trim RollIntoOutcome doc to the load-bearing contract - app.rs: drop dead `_ = pending_call_id;` and the unused let-binding
The original draft was generated from the implementation plan and documented shape that didn't ship: ReadOnly classify on bare, complete_arg, date-grouped rows, numeric mnemonics, four-method SearchableItem, nested RollIntoOutcome, messages_for_chat. Rewrites against the shipped surface and shrinks the doc to the same length band as modals.md and commands.md so the trio reads as one voice.
The bar painted a static `▏` glyph after either the placeholder text or the typed query, which placed the visual cursor at the wrong column when the query was empty (sitting at the end of the placeholder hint instead of the prompt) and gave it a non-blinking shape inconsistent with the input panel. Drop the painted glyph and call `frame.set_cursor_position` so the terminal's native cursor anchors at the insertion point — same pattern the input panel uses, so shape and blink match the user's terminal config.
…ware project column Title-prominent layout adopted from Claude Code: each session paints a bold title row above a dim metadata row carrying id-prefix + relative time. The cwd column drops out when the picker scope is `current project` — every visible row shares it, so painting it would be visual noise and would confuse the substring filter; it appears only after Tab widens to all projects. Relative time (`3s ago`, `2h ago`, `5d ago`, ISO date for >30d) reads more naturally in a recency-sorted list than the absolute timestamp. `SearchableItem` now returns `Vec<Line>` and declares a `row_height()` constant so the list primitive can size its viewport for items wider than one terminal row. The cursor gutter only paints on the first line of a multi-row item so the layout still aligns.
Welcome screen advertised every other slash command but skipped /resume, and the tip pool only mentioned the CLI form (\`ox --continue\`). Add the slash form to both so the picker is discoverable from a fresh empty session, not just from the help index. Snapshots regenerated — adding entries shifts the seeded-shuffle output to land on different starters / tips for the fixture seeds.
Three real production paths the existing suite missed:
- util::time::{init_local_offset, local_offset} now under test for
idempotency + stable-cache contract (the OnceLock layer).
- title_generator's alive-session writer-failure branch — the
counterpart to the post-finalize-silent test. New `acks_with_failure`
handle helper enables it.
- App::apply_action_locally's `UserAction::Resume` arm — pin that input
is gated between forward and SessionResumed so a typed prompt can't
slip into chat just before `clear_history` wipes it.
…er empty ratatui_textarea reserves the first column as a phantom-cursor cell, so the placeholder text starts one column right of the textarea origin. Previously the visible cursor sat on the phantom slot, leaving a one-column gap before the placeholder text. Shift the cursor by 1 when the buffer is empty so it anchors on the placeholder's first letter, matching the searchable-list search bar.
…and new tests Reduce codecov patch-coverage gaps left by the resume picker and adjacent modules: tighten control flow so the early-return arm is reachable from tests, and pin previously untested error-paths. - file_tracker: collapse the two `Ok` arms in `restore_verified` into one `inspect_err` chain so the metadata-failure branch shares coverage with the happy path. - session/store: replace the `match` in directory iteration with an `inspect_err`/`ok`/`map` chain; widen `test_project_dir` to `pub(crate)` so resume picker tests can exercise the `list_paged` Err arm. - session/handle/testing: slim `acks_with_failure` into a focused `acks_append_ai_title_with_failure` (only the variant the title-gen test needs); rename the call site. - slash/resume: render footer guard becomes an early-return so the `< 2` rows path is no longer dead in measurement; convert two `match` arms to `let-else` for the same reason; eager-bind the footer string in the filter test so both assertion sides see the same value. - New tests: render at `height=1` (footer drop), reload sets `load_error` + zeroes `total` when `list_paged` fails, and a searchable-list area-too-short cursor-placement guard.
…m time
Iteration on the /resume picker driven by review feedback. The picker
metadata row now surfaces session weight (`N msgs`) and the git branch
the session was started on; the relative-time format swaps short codes
(`5d ago`) for long-form (`5 days ago`) with proper singular/plural.
Color and copy alignments make the picker feel native alongside /model.
- session/entry: extend `Entry::Header` and `SessionInfo` with an
optional `git_branch`. `serde(default, skip_serializing_if = ...)`
preserves backward compatibility with sessions written by previous
builds.
- session/state: capture `HEAD` via `git rev-parse --abbrev-ref HEAD`
when minting a new session header. Tests are insulated from the host
repo's branch name via a `cfg!(test)` guard.
- session/store: thread `git_branch` through `read_session_info`.
- slash/resume:
- `SessionRow` gains `message_count` + `git_branch`; metadata row
appends `· N msgs` and `· branch` when present (singular/plural via
a `pluralize` helper).
- `format_relative_time` rewritten in long-form (`1 day ago`,
`5 days ago`); negative deltas (clock skew) collapse to
`0 seconds ago` so the singular/plural axis stays sane.
- Cursor row now uses `text + bold` to match /model — the `>` gutter
already carries the accent color.
- `PICKER_DESCRIPTION` drops the redundant Tab hint (already shown in
the footer).
- main: alphabetize CLI fields; drop "(legacy behavior)" from
`--limit` description.
- tui/modal/searchable_list: capitalize "Type to filter".
- session/list_view: extend test fixture to cover the new field.
… in review - file_tracker::restore_verified now logs distinct warnings for each drop reason: stat-failure (file gone or unreadable) vs (mtime, size) drift. Previously both arms collapsed into a silent push to `dropped`, hiding why the agent had to re-Read. - session::store::find_session_path now logs a warning for read-dir iteration errors instead of silently `continue`-ing — sessions in a flapping project subdir would otherwise vanish without trace. - session::store::read_session_info bails with a clear "empty file" message when the first line is blank; prior path produced a confusing serde "invalid header line" error. - slash::resume::format_relative_time switches to `expect` for the static format-description path. Previously the impossible failure arm fell back to "unknown", masking the invariant.
…hape P1 review nits: - file_tracker::record_modify_after_write warn-logs stat failures so subsequent Edit calls aren't surprised by a stale snapshot. - session::store::read_session_info now logs when mtime is unreadable and falls back to created_at, instead of swallowing the error. - main: bump session-rolled and config-changed event-drop logs from `warn` to `error` — both leave the TUI desynced from client state, so they should stand out in the log. - util::time::init_local_offset warn-logs when localtime resolution fails (UTC fallback was previously silent). - session::title_generator: flip the dead-actor branch to early-return for a flatter happy path. - tui::modal::searchable_list: page_up uses the same `let-else` shape as page_down for symmetry.
- slash::resume::SessionRow: meta row now respects the same `TITLE_FLOOR` width floor as the title row so a tiny terminal still surfaces the id prefix instead of an empty line. - main::apply_resume: drop the redundant `messages_for_event` clone in favor of `messages.clone_from(&outcome.messages)`; the original `outcome.messages` then moves into `SessionResumed` directly. - .cspell/words.txt sorted case-insensitively so future inserts don't fight the diff.
The previous fix added `+1` to the cursor X when `is_empty()` was true, on the theory that ratatui_textarea reserves column 0 as a phantom slot and the placeholder text begins at column 1. In practice the cursor at `textarea_area.x + 0` already lands on the placeholder's first character — the prior visual that looked like "cursor before the letter" was just the cursor highlighting the dim placeholder cell. Adding `+1` overshot by one cell, parking the cursor on the second letter of the placeholder (`s` of `Ask anything...`). Drop the shift.
…ment The input panel and the modal search bar both ended with the same `cursor_x = raw_x.min(area.right().saturating_sub(1))` clamp followed by `Frame::set_cursor_position`. Lift the pattern into a shared `tui::cursor::place_clamped` helper so future input surfaces don't re-derive (or worse, drop) the right-edge clamp. - New `tui/cursor.rs` with the helper + tests covering within-area, past-right-edge, and offset-area cases. - Input panel and `SearchableList::place_terminal_cursor` switch to the helper. Behavior is identical.
Both selected and unselected rows previously used `theme.text()` (differing only by `BOLD` on the cursor row), so titles and metadata read at the same weight and the eye had to hunt for the cursor. Three-tier hierarchy now: - Cursor row title: `text + bold` - Other titles: `text + DIM` modifier — softer than full text but still brighter than metadata's `theme.dim()` foreground - Metadata: unchanged (`theme.dim()`) Drops the previous "matches /model" comment — /model deliberately keeps all rows at full text and relies on the `>` gutter alone, but /resume benefits from the extra middle tier because each row is two lines tall.
…offset lookups The two production paths flagged by codecov as low-coverage shared the same shape: a single function did both an impure system call and the parsing of its result, with the parsing arms unreachable from tests that couldn't synthesize the system-call output. Split each into a thin shell-out + a pure parser, then unit-test the parser directly with synthetic inputs. The shell-out itself is now covered too — `current_git_branch` runs against a temp git repo fixture, and the cfg(test) skip moves to the call site so snapshot tests still get `git_branch: None` without hiding the production body. - session::state: extract `parse_git_branch(success, stdout)`. New tests cover failed exit, trailing newline, detached HEAD (`HEAD` literal), empty / whitespace stdout, invalid UTF-8, and a real `git init -b fixture-branch` round-trip (skipped if `git` isn't on PATH). - util::time: extract `resolve_offset(Result)`. Tests cover the Ok and the `IndeterminateOffset` Err arms — the warn-and-fallback path was previously unreachable from tests because the host's TZ database determined which arm fired. - CLAUDE.md crate tree: add the new `tui/cursor.rs` entry from the preceding refactor.
…verride Add a sketch under .claude/plans/ and surface in the deferred slash list. The design captures the title-generator race window (manual must beat the background AI title), the persistence shape (TitleSource enum on the Title entry), and the v1 scope (current session only — picker-driven rename overlaps with the deferred delete UX).
…ext+DIM Dimming `text` with `Modifier::DIM` keeps the row in the same color family as the metadata's `dim` foreground, so titles + metadata read at roughly the same weight and the row is harder to scan. Switch unselected titles to `theme.muted()` so the picker rides the theme's pre-existing three-tier `text` / `muted` / `dim` hierarchy: selected stays `text + bold`, metadata stays `dim`, and the middle tier is now visibly distinct. Add a contrast assertion so a future regression that flattens any pair of tiers fails loudly.
CLAUDE.md says test sections must mirror the production function order in the same file, and within each section follow happy → variants → edge / error. - slash/resume.rs: split the catch-all `// ── ResumePicker ──` umbrella into per-method subsections (`new`, `reload`, `submit`, `footer_text`, `Modal::render`, `Modal::handle_key`) and group the previously scattered tests under the right one. Renamed `picker_filters_out_live_session_to_block_self_resume` to `new_filters_out_…` so it cluster-sorts with its function. - session/state.rs: hoist `// ── current_git_branch ──` and `// ── parse_git_branch ──` (recently added) up next to `flush_entries` to follow the production order `flush_entries → current_git_branch → parse_git_branch → format_current_dir`. Reorder parse_git_branch tests so the happy-path case leads. - tui/modal/searchable_list.rs: move `place_terminal_cursor_skips_when_area_is_shorter_than_search_row_offset` to the end of the `// ── render ──` section — it is the edge case, and CLAUDE.md puts edges last. No assertion or test-body changes — pure rearrangement. Suite stays green at 1817 tests.
Three small cleanups in line with CLAUDE.md test-suite guidance (prefer concise + merge tests covering the same path) and the global "comment the why, not the change" rule: - searchable_list.rs: hoist `use ratatui::Terminal/TestBackend` to the top of the test module (was repeated inside five tests). Merge the two `render_anchors_terminal_cursor_*` tests into one `render_places_cursor_at_prompt_plus_visible_query_width` that exercises both the empty-query and typed-query cursor positions in a single render setup. Drop the "Regression: bare picker used to paint a `▏` glyph..." comment — it narrates the historical fix, which belongs in git history, not the source tree. - state.rs: collapse five one-liner `parse_git_branch_*` tests into a single table-style test. The fn is a 4-branch parser; one test with six asserts reads more clearly than five 3-line tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/resume(alias/continue) opens an in-place session picker — type to filter by id, title, or project, Tab toggles current-project ↔ all-projects, Enter swaps the live session in place./resume <id-prefix>resolves directly. Adds a genericSearchableList<T>modal primitive so future searchable pickers share the chrome, plus a--limit Ncap (default 30,--limit 0unbounded) onox --listso long-running projects don't dump hundreds of rows on every invocation.Design decisions
roll_into, not process exec. Mirrors the existingroll-for-/clearshape: load + sanitize the target FIRST so a missing-file or empty-after-sanitize error leaves the live session untouched, then snapshot + clear + restore the file tracker, swap the handle, finalize the old session. The TUI keeps running — chat repopulates from the resumed transcript via the samechat.load_history(...)call theAppconstructor uses, so launch-time and mid-session resume share one population path./theme,/model,/effort. Bare/resumeisReadOnly(safe mid-turn) and opens the modal;/resume <id-prefix>isMutating(deferred mid-turn) and short-circuits toUserAction::Resume. The picker excludes the live session from typed-prefix matches — resuming yourself is a no-op.SearchableList<T: SearchableItem>primitive. Sibling toListPicker<T: PickerItem>: substring filter on a composite haystack, scrollable viewport with PageUp / PageDown, replaceable item set so a scope toggle reloads without rebuilding the modal. Concrete pickers own submit semantics — primitive stays callback-free.--all. Ambiguous prefixes list candidates with their 8-char ids.AgentEvent::SessionResumedcarries the full payload. Avoids a round-trip — the agent loop loads + sanitizes, then pushesid,title,messages,tool_metadatainto one event the App applies atomically (clear chat + load history + reset pending state + finalize idle).list_paged(limit, all) -> ListPage { sessions, total }.totalis the count before truncation so renderers can show... and N morewithout re-walking the directory.list()/list_all()stay as thin wrappers. Theox --listcap defaults to 30; the footer tells you how to widen with--limit Nor disable with--limit 0.session::store.seed_test_session(...)ispub(crate)so cross-module tests (slash / handle) can stand up sessions without exposing theentrymodule'sEntry::Title/Entry::Summaryshapes.roll_intogains tests that pin the swap-on-success / no-swap-on-failure invariants.Changes
slash/resume.rsResumeCmd(aliascontinue) +ResumePickermodal —SessionRowflattensSessionInfointo the haystack + display columns;resolve_prefixmatches in-project first, widens on no match.tui/modal/searchable_list.rsSearchableList<T: SearchableItem>— substring filter, scrollable viewport, replaceable items.session/handle.rsroll_into+RollIntoOutcome: load-target-first, then snapshot + swap + finalize-old; tests cover happy path, target-load failure, tracker-snapshot swap.session/store.rsseed_test_sessioncross-module test helper.session.rsentrytopub(crate)soSessionInfois visible toslash::resume.agent/event.rsUserAction::Resume { session_id },AgentEvent::SessionResumed { id, title, messages, tool_metadata }.agent.rsUserAction::Resumein the mid-turn unreachable arm with a warn-log.main.rsUserAction::Resume→ callroll_into→ emitSessionResumed; failure leaves the session unchanged and surfaces anErrorevent.tui/app.rsapply_session_resumedhelper forAgentEvent::SessionResumed; routeUserAction::Resumethroughapply_action_locallylikeClear.tui/modal.rssearchable_listsubmodule.slash.rs,slash/registry.rsResumeCmdinBUILT_INS(alphabetical).tui/components/input/snapshots/.../resumerow added, alphabetical sort displaces/statuspastMAX_VISIBLE_ROWS.docs/guide/slash-commands.md,docs/guide/sessions.md,docs/roadmap.md,README.md,CLAUDE.md/resume(alias/continue), the--list --limitcap, and thesearchable_listprimitive.Test plan
cargo fmt --all --checkcargo clippy --all-targets -- -D warnings— zero warningscargo test— 1782 passedcargo llvm-cov --ignore-filename-regex 'main\.rs'—slash/resume.rs94.4%,tui/modal/searchable_list.rs96.7%,session/handle.rs99.8%pnpm lint,pnpm spellcheck— clean/resumeopens picker; type to filter; Tab toggles scope; Enter resumes; chat repopulates from target's transcript/resume <id-prefix>jumps directly; ambiguous / unknown prefixes show recovery hint/resumemid-turn — picker opens (ReadOnly);/resume <id>mid-turn refuses (Mutating gate)ox --listshows 30 most-recent rows +... and N morefooter;--limit 0shows everything;--limit 100raises the cap for one run