auto resume

This commit is contained in:
Jonas H
2026-08-17 07:47:47 +02:00
parent fc9c05ce97
commit a7d7940be3
5 changed files with 805 additions and 115 deletions

View File

@@ -68,8 +68,10 @@ src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing,
width-fitted wrapped columns) and strips heading `#` markers —
the pinned tui-markdown 0.3.5 does neither
src/sessions.rs on-disk session history: background scanner thread keeps
App::disk_sessions fresh (~1/s poll, labels re-read only on
mtime change); load_view/load_history rebuild a feed Session
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
mtime change — one pass yields the label *and* the session's
last main-chain model, which `App::resume_model` turns into the
`--model` a resume spawns with); load_view/load_history rebuild a feed Session
from a JSONL transcript (lazily, on first view); build_tree
parses uuid/parentUuid chains into a TurnTree (one node per
real user prompt; rewinds leave fork points); materialize
@@ -140,7 +142,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
transcript — but a second ctrl-↓ within 3s forces it (liveness is
unknowable: an idle claude sends no traffic; `EmbedUi::past_embeds` skips
the guard for sessions whose instance we killed ourselves). `--session-id`
cannot be combined with `--resume` (CLI rejects it without `--fork-session`).
cannot be combined with `--resume` (CLI rejects it without `--fork-session`);
`--model` can, and every resume passes it.
- **A resume continues on the session's own model**, not the CLI default:
`App::resume_model` reads the model Claude Code recorded for the session's
last main-chain assistant message (`DiskSession::model`, filled by the
scanner's `read_meta` — subagent `isSidechain` records run their own model
and `<synthetic>` error records carry no model, so both are skipped) and
`app::model_arg_for_id` maps that id to a `--model` argument: a known alias
(`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot
id, so a retired snapshot can't pin the pane; an id with no alias inside is
passed through verbatim (`--model` takes full names too). The transcript is
authoritative, so a mid-session `/model` switch is honoured. `[1m]`
(1M-context) picks are the one thing it cannot see — the transcript records
the same base id either way — so `Session::spawn_model` remembers the exact
argument the pane was spawned with in-process and wins **only** while it
still names the model the transcript reports.
- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel):
`space` (or `→`/`l`) expands the selected session's turn tree — one row per
real user prompt, abandoned rewind branches indented `⑂` under their fork
@@ -189,14 +206,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
below the bottom rule for a menu row, not just the row directly under it: the
list can start after a blank/header row and only the highlighted item carries
a glyph (unselected file rows are plain names), so checking one row collapsed
the pane whenever that row wasn't the selected item. The framed region drives the pane height too:
the pane whenever that row wasn't the selected item. Above the top rule the
frame also swallows an **active task panel** (`text_is_task_row` /
`task_block_top`): Claude Code parks the `N tasks (…)` header + `✔ ◼ ◻` rows
(and its `… +N pending` overflow line) directly above the input box, so
walking up over that block — tolerating one blank line and single wrapped /
activity rows, capped at `MAX_TASK_BLOCK` — makes task status visible with no
extra app state. Priority when the pane can't hold everything: the panel is
dropped first (`CompactFrame::ess_top`, the one-context-row frame) so the
line you're typing and an open menu never fall off screen. The framed region drives the pane height too:
`compact_rows` (called from `ui::draw`) measures box-height + tail so the
pane auto-expands as the prompt gains lines or a menu opens and shrinks back
when idle (floor `MIN_COMPACT_INNER`, cap = screen 6); `PTY_PAD` keeps the
PTY taller than the visible window so the child can still draw the rows we
crop. **The Compact PTY is sized to the *whole screen height*, not the
visible pane** (`ui::draw` passes `f.area().height` to `resize` only for
Compact): Compact's height is derived by measuring what Ink has already
crop. **A cropped PTY is sized to the *whole screen height*, not the
visible pane** (`ui::draw` passes `f.area().height` to `resize` for every
view except Full): their height is derived by measuring what Ink has already
drawn, and Ink only ever draws as many rows as the PTY reports, so tying the
PTY to the (small) visible height is a feedback loop — an `@`/`/` menu or a
big paste that suddenly needs many more rows than the current PTY+pad never
@@ -212,9 +237,20 @@ UI thread redraws on its own tick (no channel; just the mutex).
returns `None` on a transient mid-repaint (box border caught missing) so the
last height is kept. Without this the height oscillates every frame during a
subagent turn or `@`/`/` menu filtering, and each change resizes the PTY →
Ink repaints → flicker. `PaneView::Interactive` (the tap-grown AskUserQuestion / ExitPlanMode
pane, whose selection box renders *above* the input) top-anchors from row 2
instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders
Ink repaints → flicker. `PaneView::Interactive` (the tap-grown
AskUserQuestion / ExitPlanMode pane, whose selection box renders *above* the
input) is **measured the same way, never estimated**: `interactive_frame`
anchors on the rule above the header-chip row (`← ☐ Header ✔ Submit →`),
else the second-to-last rule, and runs to the last non-blank row;
`EmbeddedTerm::interactive_rows` feeds that height through the same
hysteresis. `App::ask_question_rows` (the row guess from the tool JSON) is
only the fallback for the frames before Ink has drawn the box — it can't know
how far the question text wraps, which is what used to crop the first
paragraph. Because the Interactive PTY is now screen-tall, Claude Code lays
the prompt out in full instead of switching to its own truncated form.
`interactive_view_range` top-anchors on that frame and slides down only far
enough to keep the `` option on screen when the prompt overflows the pane.
`PaneView::Full` (fullscreen) renders
the child's screen verbatim from row 0 with the PTY sized exactly to the
pane. Permission-prompt boxes (rounded borders, not rules, and not in the
API stream) aren't expanded in the compact pane — consistent with the
@@ -235,7 +271,14 @@ UI thread redraws on its own tick (no channel; just the mutex).
straight out of the installed `claude` ELF (single self-contained binary
with the JS bundle embedded). No API call, never runs claude — just resolves
`claude` on PATH and greps its bytes for the longest lowercase-token array
anchored by `opus`+`sonnet`. Tab/BackTab cycle sessions (`p` no longer
anchored by `opus`+`sonnet`. The list then gets the **1M-context variants**
appended (`sonnet[1m]` etc., Claude Code's `--model` spelling for the long
context window): `term::long_context_tokens` collects every quoted
`"<token>[1m]"` literal in the same byte scan, and only aliases that really
have one are offered (today `opus`/`sonnet`/`fable`*not* `haiku` or
`mythos`), so the suffix is never assumed. `[1m]` needs no shell quoting:
the pane spawns via `CommandBuilder` argv, not a shell.
Tab/BackTab cycle sessions (`p` no longer
mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree →
quit). n/N jump the feed scroll to the next/previous user prompt
(`App::prompt_jump`, applied in `draw` where entry heights are cached). The
@@ -275,7 +318,15 @@ UI thread redraws on its own tick (no channel; just the mutex).
`127.0.0.1:<port>/v1/messages` without auth — a relayed 401 from Anthropic
proves the round-trip. The TUI can't run in a non-tty. `CT_UPSTREAM` points
the proxy at an alternative upstream (e.g. a local fake SSE server) for
fully offline end-to-end tests with zero API usage.
fully offline end-to-end tests with zero API usage. That fake server is
`dev/fake_upstream.py`: it answers every request with canned SSE, so a **real
`claude` child** can be made to render its client-side tool UIs on demand
(`dev/.fake_scenario` = `ask | plan | todo | taskupdate | text`, switchable
mid-run) — this is how the pane's frame detector is developed against what
Ink actually draws. Drive it through tmux (`.claude/skills/tui-verify`) and
obey that skill's safety rule: **never `pkill`/`killall`**, tear down only
your own named tmux session. The child writes real task files under
`~/.claude/tasks/<its-session-id>/`; delete that directory afterwards.
## Not yet handled (known MVP limits)