# term_scope — Detailed Implementation Plan

## Context

The project is a Rust TUI for quickly monitoring live EPICS (Channel Access) signals from a
plain-text channel list — a fast alternative to building an MEDM/caQtDM screen. The specs are
written (top-level `CLAUDE.md`, detailed spec in `term_scope/CLAUDE.md`); this plan turns
them into an executable, phased plan.

Already in place and reusable:
- `epics_rs_sys` — bindgen sys crate (links `ca`/`Com` from `/usr/lib/epics`).
- `term_scope/src/epics.rs` — safe-ish CA wrapper: `Context` (preemptive callbacks enabled),
  `Channel`, connection + subscription callback trampolines with correct double-Box cleanup.
- `term_scope/src/error.rs` — `EpicsError`/`EpicsResult` with `ca_message` texts.
- `term_scope/src/main.rs` — hello world; does not yet declare the modules, so `epics.rs`
  and `error.rs` currently don't compile as part of the crate.

Agreed decisions:
- **Thread communication:** `Arc<Mutex<HashMap<String, SignalValue>>>` shared store; CA
  callbacks write into it, the TUI snapshots it each frame. Shutdown via a small mpsc
  command channel.
- **Clipboard (Phase 4):** OSC 52 terminal escape (works over SSH, no native deps).
- Subscribe with `DBR_TIME_*` native types so timestamps come from the IOC
  (EPICS 1990 epoch → Unix epoch: +631,152,000 s).
- Arrays unsupported: take element 0 only. CA3 has no unsigned/64-bit transfer types; the
  unused `SignalData` variants stay for future backends.
- CLI: `term_scope <channel_file>` parsed with clap (derive pattern).
- Unsafe code is minimized and every unsafe block carries a `SAFETY:` comment,
  enforced by the `clippy::undocumented_unsafe_blocks` lint.

Workflow: each phase on a branch; tests pass, clippy clean, cargo fmt; merge back to main
and delete the branch.

## Phase 1 — Wire up the crate & channel-list parsing (branch: `phase1-parser`)

Files: `src/main.rs`, new `src/signal.rs`, new `src/parse.rs`, touch-ups in `src/epics.rs`.

- `main.rs`: declare `mod epics; mod error; mod signal; mod parse;` and
  `pub use error::{EpicsError, EpicsResult};` (epics.rs imports these via `crate::`).
- `signal.rs`: the backend-agnostic types from the spec —
  `enum SignalData { Int8..UInt64, Float32, Float64, String, Enumeration(String) }` with a
  `Display` impl (default Rust formatting; `Enumeration` already holds its
  `"<int val>, label"` text), and `struct SignalValue { data: Option<SignalData>, time: SystemTime }`.
- `parse.rs`: `fn parse_channel_list(input: &str) -> Vec<String>` —
  strip from first `#`, trim, take first whitespace-separated word, validate
  (non-empty, chars in `[A-Za-z0-9:_-]`), skip invalid silently, dedupe preserving order.
- Temporary CLI: read the file named by the single positional arg, print the parsed list
  (placeholder until Phase 3).
- Fix warnings the wiring exposes (e.g. `&String` param and stray parens in `epics.rs`).
- Tests: parser unit tests including the exact sample block from `term_scope/CLAUDE.md`
  (expects `X1:SINE_1`, `X1:SINE_2`, `X1:SINE_4`), plus name-validation edge cases.

## Phase 2 — Backend acquisition thread (branch: `phase2-backend`)

Files: `src/epics.rs` (extend wrapper), new `src/capture.rs`, new `src/decode.rs`; possibly
`epics_rs_sys/include/epics-sys-wrapper.h` if a needed symbol is missing from the bindings.

- Extend the CA wrapper (`epics.rs`):
  - `Context::pend_event(seconds: f64)`.
  - `Channel::field_type()` / `Channel::element_count()` (`ca_field_type`, `ca_element_count`).
  - One-shot `Channel::get_with_callback(dbr_type, cb)` (`ca_array_get_callback`) — needed to
    fetch enum labels via `DBR_GR_ENUM`.
  - Add `+ Send` bounds on the boxed callbacks: preemptive CA threads invoke them.
- `capture.rs` — the background thread:
  - `Backend::spawn(channels: Vec<String>, store: Arc<Mutex<HashMap<String, SignalValue>>>) -> Backend`
    holding the `JoinHandle` and an `mpsc::Sender<Command>`; `Command::Shutdown`; `Backend::shutdown()`.
  - The thread owns `Context` and all `Channel`s (chids are raw pointers — everything CA
    lives on this thread; callbacks fire on CA's threads and only touch the mutexed store).
  - Connection callback: on first CONN_UP map native `DBF_*` → `DBR_TIME_*` and subscribe;
    for `DBF_ENUM` also fire the one-shot `DBR_GR_ENUM` get to cache labels per channel.
    On CONN_DOWN set `store[name].data = None` (no stale values).
  - Value callback: decode `dbr_time_*` from `event_handler_args`, element 0 only, convert
    `epicsTimeStamp` to `SystemTime`, write `SignalValue` into the store. Enums format as
    `"<int val>, label"` (index out of label range → just the int).
  - Loop: `pend_event(0.05)` + `try_recv()` until Shutdown, then drop channels/context.
- `decode.rs`: keep the pure pieces separate and unit-tested — timestamp conversion,
  DBR struct → `SignalData` conversion (testable with hand-built dbr structs), enum formatting.

## Phase 3 — TUI (branch: `phase3-tui`)

Files: `src/main.rs`, new `src/app.rs` (or `ui.rs`).

- `ratatui::init()` / `ratatui::restore()` (0.30 installs the panic hook that restores the
  terminal automatically).
- App state per spec: ordered channel list, scroll offset (index of first displayed row),
  display snapshot map, `paused: bool`, `show_help: bool`.
- Loop:
  1. if not paused, snapshot the shared store (`lock().clone()`),
  2. render a two-column aligned table (name | value); name column width = longest name;
     disconnected values render as a dimmed `----`; a status line shows `PAUSED` and the
     help hint,
  3. `event::poll(Duration::from_millis(100))` for ~10 Hz refresh; keys: `q`/`Esc` quit,
     `Up`/`k` and `Down`/`j` scroll (clamped to list length minus visible rows), `p` toggle
     pause, `?` toggle a centered help overlay (any key closes it).
- On quit: `backend.shutdown()`, join, restore terminal.

## Phase 4 — Polish: click-to-copy & soft-IOC shakeout (branch: `phase4-polish`)

- Enable mouse capture; on left-click hit-test the row and column: name column copies the
  channel name, value column copies the rendered value.
- Copy via OSC 52: base64-encode and write `\x1b]52;c;<b64>\x07` to the terminal; show brief
  "copied" feedback in the status line.
- Whatever shakes out of live testing against a soft IOC.
- Shakeout addendum (branch `clipboard-fallback`): OSC 52 is ignored by the VTE terminals
  (GNOME Terminal etc.), so `copy` additionally pipes the text to a best-effort local
  helper first — `wl-copy` when `WAYLAND_DISPLAY` is set, `xclip`/`xsel` when `DISPLAY`
  is set — silently skipping helpers that aren't installed.  OSC 52 is still always
  emitted, keeping the SSH case working.  No new crate dependencies.

## Phase 5 — History, selection & scope mode (branch: `phase5-scope`)

Spec: the updated "Application Internals" sections of `term_scope/CLAUDE.md`.
Files: `src/app.rs` (rework), new `src/history.rs`, new `src/command.rs`,
`src/signal.rs` (numeric conversion).  No backend (`capture.rs`/`epics.rs`) changes.

Agreed decisions:
- **History**: app-side, one ring buffer per channel holding `Option<f64>`
  (`None` = disconnected or non-numeric at that tick).  Sampled every 250 ms
  from the always-updated store; capacity 3600 samples = 15 min.  Buffers keep
  filling while paused.  Simple sampling, no min/mean/max accumulation — true
  extrema would need every CA update and therefore backend changes.  Kept open:
  the sample type lives in `history.rs` so upgrading one value → min/mean/max
  triple later is contained.
- **Loop timing**: `event::poll` stays ~100 ms for input responsiveness; a
  `TimerEvent` command fires whenever ≥250 ms has elapsed since the last
  history push (deadline computed, not a second poll loop).
- **Commands** (`command.rs`): the enum from the spec — raw `KeyboardEvent`/
  `MouseEvent` are translated by a mode-aware handler into `SelectUp`,
  `SelectDown`, `Quit`, `ValueToClipboard(String)`, `TogglePause`,
  `SetScopeMode`, `SetListMode`, `TimerEvent`.  This replaces the
  `Option<String>` clipboard return from Phase 4.
- **Per-mode state**: mode-specific fields live inside the mode instead of loose
  on `App`: `enum Mode { List(ListState), Scope(ScopeState) }` — `ListState`
  holds the viewport scroll offset, `ScopeState` holds the name of the channel
  being plotted (captured at entry).  The selection index stays on `App`
  because both modes use it: scope plots the selected channel, and the list
  keeps the selection when you return.  `SetScopeMode` validates the selected
  channel is plottable and constructs the `ScopeState`; `SetListMode` rebuilds
  `ListState` with the scroll re-derived from the selection.
- **Selection** (list mode): `up/k`/`down/j` move a highlighted selection;
  the viewport follows to keep it visible (scroll offset derived, clamped).
  A left-click selects the row it hits.
  The selected row is visually distinct: angle-bracket markers (`>` … `<`) in
  leading/trailing marker columns added to the layout, plus a bold row style.
  **Copy** (list mode): a middle mouse button press on a row triggers a copy (name column → name,
  value column → rendered value) operation.  Left-click no longer copies.
- **`SignalData::as_f64()`**: numeric variants cast; `Enumeration` parses the
  leading integer of its `"<int>, label"` text; `String` → `None`.
- **Scope mode** (`s` on the selected channel): ratatui `Chart`, y-axis
  auto-scaled to the visible buffer's min/max, x-axis labeled in time deltas
  (`-15min … -5min, 0`).  When the buffer has more samples than the chart
  width, downsample peak-preserving: plot per-column min and max so spikes
  survive.  Disconnected stretches are gaps (no points).  `s` on a `String`
  channel is refused with a status-line error; if a scoped channel turns out
  to be a string (reconnect type change) show the error and drop to list mode.
- **Pause**: the displayed snapshot (values + the selected channel's history
  copy) freezes; master history keeps filling; unpausing jumps forward.
- **Clock**: `HH:MM:SS` bottom-right in the status line, both modes, wall
  clock — deliberately outside the frozen snapshot so it always ticks.
- Addendum (branch `pause-timestamp`): the PAUSED badge carries the wall-clock
  time the pause began ("PAUSED 19:02:35"), and in a paused scope view the
  x-axis "0" label is replaced by that pause time — the trace's right edge is
  no longer "now", so it is labeled with the moment it actually represents.

Tests (unit): ring-buffer push/wraparound/capacity; `as_f64` for every variant
including the enum text parse; key/mouse → command translation per mode
(left-click selects, middle-click copies); selection movement with
viewport-follow clamping; mode transitions carrying the right state
(scope entry captures the channel, list return re-derives scroll); timer-deadline
cadence; scope-entry refusal for strings; peak-preserving downsample; x-axis
label generation.
Smoke: update `run_tui_smoke.sh` for the new bindings — the copy clicks become
middle-button presses (SGR button code 1), a left-click checks the bracket
markers move, then `s` on a numeric channel, check chart output appears,
`s` back, `q`.  Manual: scope rendering quality, pause behavior in scope mode,
clock tick while paused.

## Phase 6 — Channel ranges & .stp input (branch: `phase6-ranges`)

Spec: the "input file format (plain text)", "input file StripTool .stp file", "Parser
output", list-mode red-value, and scope "Axis"/"Overflow" sections of `term_scope/CLAUDE.md`.
Files: `src/parse.rs` (rework), new `src/stp.rs`, new `src/channel.rs`, `src/main.rs`
(format detection), `src/app.rs` (list coloring, scope axis, excursion rendering).
No backend changes — `capture.rs` keeps taking plain channel names.

Agreed decisions:
- **Structs** (`channel.rs`): `Range { min: f64, max: f64 }` with a validating
  constructor (both finite, min < max — so an invalid range cannot exist by
  construction) and `ChannelConfig { name: String, range: Option<Range> }`.
  Both parsers return `Vec<ChannelConfig>`.
- **Plain-text parser rework** (`parse.rs`): first word is the channel name
  (charset now includes `.`); the remaining words are attribute candidates.
  A word of the form `number:number` (anything `f64::from_str` accepts on each
  side, finite, min < max) is a range; the **first valid** range on the line
  wins; unknown or malformed words (including inverted/equal ranges and single
  values) are skipped individually.  A line whose channel name was already seen
  is a duplicate and is **skipped entirely** — its attributes are never applied.
- **.stp parser** (`stp.rs`): lines are `key whitespace value`; only
  `Strip.Curve.<N>.Name/.Min/.Max` keys are read, everything else is ignored.
  Channels are ordered by curve index; the same name validation and
  duplicate-skip apply.  A range attaches only when both Min and Max parse as
  finite floats and min < max.
- **Format detection** (`main.rs`): `.stp` file suffix or content beginning
  with `StripConfig` → .stp parser, otherwise plain text.  The existing
  "no valid channels" error path is unchanged.
- **List mode**: the value cell is styled red when the channel has a range and
  `as_f64()` falls outside `[min, max]`; disconnected or non-numeric values are
  never colored.
- **Scope mode**: `ScopeState` gains the `Option<Range>` copied at entry (the
  spec's "auxillary information").  With a range the y-axis is fixed to
  `[min, max]` instead of autoscaling.  Excursions: plotted values are clamped
  to the range and the clamped runs are drawn as separate red flat datasets
  pinned at max (overflow) / min (underflow), so the trace runs to the edge,
  goes flat red, and rejoins when back in range.  This composes with the
  peak-preserving downsampler: a column is an excursion if its raw extremum
  left the range.

Tests (unit): both CLAUDE.md sample blocks verbatim (plain text: SINE_1's range
from its first line with the duplicate's range not applied, SINE_2's inverted
range rejected, SINE_4's duplicate line skipped, SINE_5 taking the first valid
range past junk and a malformed one; .stp: the two curves with 0–100 ranges);
range-token edge cases (min == max, single value, exponents, negatives,
multiple colons, non-finite); .stp detection by suffix and by leading
`StripConfig`, sparse/out-of-order curve indices, Min-without-Max, unknown
keys; the red-value predicate; scope y-axis bounds with and without a range;
excursion run segmentation against the downsampler.
Fixtures: `tests/data/channels.stp` committed alongside the existing
`channels.txt`, which gains range annotations, so the e2e/smoke paths exercise
both formats.

## Phase 7 — List-mode preview pane, signal info & paging (branch: `phase7-preview`)

Spec: the "Scope and information preview" section and the signal-description /
list-state additions in `term_scope/CLAUDE.md`.
Files: `src/signal.rs` (new `SignalInfo`), `src/epics.rs` (channel accessors),
`src/capture.rs` (store shape, info on connect), `src/command.rs`
(`PageUp`/`PageDown`), `src/app.rs` (pane rendering, paging).

Agreed decisions:
- **`SignalInfo`** (`signal.rs`): backend-agnostic newtype over
  `Vec<(String, String)>` — ordered key/value pairs so the display order is
  stable (the spec's reason for not using a hashmap).  For EPICS the entries
  are host, access ("read, write" / "read" / "none"), native data type
  ("DBF_DOUBLE"), request type ("DBR_TIME_DOUBLE"), element count.
  **State is deliberately not stored**: the UI derives a leading
  "state: connected/disconnected" row live from whether the snapshot entry's
  `data` is `Some`, so it can never contradict the value column.
- **Backend** — no network gets needed: everything pvinfo shows comes from
  local CA accessors on the `chid`.  `epics::Channel` gains `host_name()`
  (`ca_host_name`), `read_access()`/`write_access()`
  (`ca_read_access`/`ca_write_access`) — thin safe wrappers with safety
  comments, same pattern as `field_type()`.  `capture.rs` builds the
  `SignalInfo` inside the `Msg::Connected` handler on **every** connect event
  (not only the first: the host can change on reconnect), including the
  request type from `decode::time_dbr_for_field`, and writes it into the
  store.  This is the CLAUDE.md "only when we move from disconnected to
  connected" rule; the info is cached otherwise.
- **Store shape**: `SignalStore` becomes
  `Arc<Mutex<HashMap<String, ChannelEntry>>>` with
  `ChannelEntry { value: SignalValue, info: Option<SignalInfo> }`.  Value and
  disconnect callbacks update only `.value` (entry API instead of the
  wholesale `insert`), the connect handler updates only `.info`; cached info
  therefore survives disconnects.  One map, one lock; the per-frame clone
  cost stays negligible.
- **Preview pane** (list mode): shown when the terminal is wide enough —
  the list keeps its natural fixed width (marker + name column + ~16 for
  values + spacers) and the pane takes the remainder **only if that remainder
  is ≥ 30 cells**; below that the list is full-width exactly as before.
  The pane uses the full height: a bordered miniscope (titled with the
  channel name) fills the space above a fixed-height info block at the
  bottom (state + the `SignalInfo` rows).  `table_area` shrinks to the list
  half so the existing click hit-testing stays correct.
- **Miniscope**: plots the selected channel from the **newest 5 minutes**
  (1200 samples) of the existing app-side history — no history changes.
  Reuses the scope-mode chart path (`chart_segments`, range clamp, red
  excursions, paused x-label) by factoring `render_scope`'s body into a
  helper taking trace + range + title + area.  String channels and
  never-connected channels get **info only** (no chart); the pane never pops
  in/out as the selection moves, so the layout stays calm.
- **Pause**: per the CLAUDE.md list-state addition, `ListState` gains the
  frozen-while-paused copies for the selected channel: preview trace, range
  (aux info), and `SignalInfo`.  Refreshed each frame while live, frozen
  while paused, exactly like the scope trace.
- **Paging** (`command.rs`, `app.rs`): new `Command::PageUp`/`PageDown`.
  PageUp/ctrl-b and PageDown/ctrl-f move the **selection** by one viewport
  worth of rows, clamped to the first/last channel; the viewport follows via
  the existing `scroll_selected_into_view` invariant (selection is always
  visible — one scrolling rule, not two).  `map_key` takes the full
  `KeyEvent` so the ctrl modifier is visible.  Help overlay gains the rows.

Tests (unit): store-entry merge (value update preserves cached info,
disconnect blanks the value but not the info); page up/down selection jumps
and clamping at both ends, for both key forms; the width policy (pane shown
at/above the threshold, hidden below, `table_area` shrunk); info-only pane
for string and never-connected channels; the derived state row; the 5-minute
trace truncation; frozen preview copies under pause.
E2e (`e2e_ioc.rs`): after connect the entry carries info with a non-empty
host and the expected native type; after IOC stop the value blanks while the
info stays cached.
Smoke: `run_tui_smoke.sh` gains a PageDown press (check the row window in
the status line moved) and, with a wide pty, checks the preview border and
an info row render.  Manual: pane appearance/disappearance while resizing,
miniscope vs. full scope consistency, paused preview freezing.

## Phase 8 — Adjustable history & scope zoom/pan (branch: `phase8-zoom`)

Spec: the "Holding a history" expansion, scope-mode zoom/pan section, and the
new keybindings in `term_scope/CLAUDE.md`.
Files: `src/history.rs` (resizable buffers), `src/command.rs` (new commands),
`src/app.rs` (view window, drag pan, keys, notices), `src/main.rs` (`--lookback`).
No backend changes.

Agreed decisions:
- **History resizing** (`history.rs`): constants `MIN_HISTORY`/`MAX_HISTORY`/
  `HISTORY_STEP` = 5 min / 24 h / 5 min; 240 samples per minute.  `HistoryStore`
  gains `minutes()` and `resize(minutes)` — clamped to the limits; shrinking
  drains each buffer's oldest samples down to the new capacity, expanding just
  raises the capacity (`VecDeque` grows lazily; no data invalidated).
  `HistoryStore::new` takes the initial minutes (default 15).
- **Keys**: `>` expand, `<` shrink — **global**, both modes (new
  `Command::ExpandHistory`/`ShrinkHistory`).  Every press shows a status
  notice: `history: 20 min (~7.7 MB)` computed as
  `channels × size_of::<Sample>() × 240 × minutes` (exact for our
  `Option<f64>` samples — strings never enter history — and stays honest if
  the sample type grows later).  At a limit the notice says so ("history
  already at 5 min minimum").
- **CLI** (`main.rs`): `--lookback <minutes>` (visible alias `--history`),
  default 15, validated by clap to 5–1440; any value in range is accepted
  as-is and later `>`/`<` steps move ±5 from it.
- **Scope view window** (`app.rs`): `ScopeState` gains `view: Option<ScopeView>`
  where `None` = full history (previous behavior) and
  `ScopeView { span_secs, right_offset_secs }` is the spec's "relative range" —
  **anchored relative to the newest sample**, so a live zoomed window stays a
  fixed distance behind "now" and data flows through it; pause is the tool for
  holding a feature still.  Entering scope mode always starts at full view.
- **Zoom** (`+` and its unshifted `=` / `-`, `Command::ZoomIn`/`ZoomOut`):
  span ±5 min, clamped to [5 min, available data]; zooming out past the
  available data returns to `None` (full view).  Zooming in from full view
  anchors at the right edge (offset 0).
- **Pan** (`left`/`h`, `right`/`l`, `Command::PanLeft`/`PanRight`): right-edge
  offset ±1 min, clamped so the window stays inside available data (offset ≥ 0,
  offset + span ≤ trace length).  No-op at full view.  `0`
  (`Command::ResetView`) restores full view.
- **Mouse drag pan**: proportional — on left-button down in the chart area
  record the anchor column; each drag event converts the cell delta to seconds
  via the current window span over the plot width and pans by that
  (`Command::PanBy(secs)`), so the trace follows the mouse.  Dropped outside
  scope mode.
- **Rendering**: `render_chart` gains the view — the trace is sliced to the
  window's sample range and x values shifted by the right-edge offset, so the
  peak-preserving downsampler spends its resolution only on visible data.
  X-axis bounds become `[-(offset+span), -offset]`; the right edge is labeled
  "now"/pause-time only when offset = 0, otherwise with its delta (e.g.
  `-5min`).  Zoom cues: when a view is set, the chart title carries the window
  (`X1:SINE_1 [-12m…-5m of 30m]`) and `◀`/`▶` markers appear on whichever
  sides have more data.  The preview miniscope is untouched (no zoom/pan
  there, per spec).
- **Interactions**: pan/zoom operate on the frozen `scope_trace` while paused,
  so they naturally work under pause.  A history **shrink** clamps any active
  scope view back into the remaining data (view → full if the span no longer
  fits) but never touches the frozen paused `scope_trace` — what you're
  inspecting doesn't jump.  All view state is offsets-from-newest, so live
  buffer growth needs no fixups.
- **Keybind additions**: `enter` joins `s` for both mode toggles; help overlay
  gains rows for `<`/`>`, and the scope keys (`h/l/←/→` pan, `+/-` zoom, `0`
  full view, drag to pan).

Tests (unit): resize semantics (shrink truncates oldest only, expand preserves
data, clamping at both limits, partial-fill shrink is lossless when data
fits); the memory-estimate text; key → command mapping (`<`/`>` in both modes,
zoom/pan/reset only in scope mode, `enter` toggles); zoom sequences (full →
in → clamp at 5 min → out → back to full); pan clamping at both edges and
no-op at full view; drag cell-delta → seconds math; window slicing +
x-bounds/label generation with a nonzero offset; view clamp after a shrink;
`--lookback` validation bounds.
Smoke: `run_tui_smoke.sh` gains a `>` press (check the history notice), and in
scope mode `+` then a pan (check the title window annotation).
Manual: live zoomed window behavior (data flowing through), drag feel, paused
pan/zoom, a 24 h expand's memory notice.

Addendum (branch `phase8-zoom-mechanics`) — proportional zoom mechanics,
after using the fixed 5-minute steps:
- **Zoom** is ±20% of the current view duration instead of ±5 min: zoom in
  multiplies the span by 0.8, zoom out divides by 0.8 (exact inverses, so
  in-then-out round-trips).  The floor drops to **1 minute**; zooming out to
  or past the available data still restores the full view.
- **Key pan** scales too: 10% of the view span per press instead of a fixed
  1 minute (a fixed step is a whole window at tight zooms and useless at
  day-scale spans).  Mouse drag was already proportional.
- **`--lookback auto`** (the new **default**) selects the 24 h maximum.
  Numeric values 5–1440 still accepted.  ~5.5 MB per channel when a full
  day has accumulated (16 B x 240/min x 1440 min), accepted knowingly; the
  ring buffers now allocate **lazily** (no `with_capacity`) so a run only
  pays for the history it has actually collected.
- **Hour-scale labels**: `fmt_span`/`fmt_delta` gain an hours tier at
  ≥ 120 min ("24h", "2.5h"), since day-long axes would otherwise read
  "-1440min".  `fmt_delta` becomes "-" + `fmt_span`.
- The smoke test passes `--lookback 15` (exercising the option) so its `>`
  press still produces the "history: 20 min" notice rather than an
  at-the-maximum message.

## Phase 9 — Right-drag zoom & absolute right-edge time (branch: `phase9-zoom-drag`)

Spec: the right-mouse-drag zoom addition to "Scope Mode" and the refined
right-most-label rule in "Axis" of `term_scope/CLAUDE.md`.
Files: `src/command.rs` (new command), `src/app.rs` (drag zoom, pause moment
as a timestamp, right-edge label).  No backend or history changes.

Agreed decisions:
- **Right-drag zoom** (the ndscope/pyqtgraph gesture): a right-button press
  in the chart starts a zoom drag; dragging right zooms in, left zooms out.
  Proportional and continuous: each cell dragged right multiplies the span by
  `ZOOM_DRAG_PER_CELL` = 0.97 (3% less data per cell), dragging left is the
  exact inverse — only the net displacement matters, so an out-and-back drag
  restores the original window.  The usual clamps apply (1 min floor; at or
  past the available data the view returns to full).
- **Anchored at the grab point**: the drag-start column is converted to a
  data offset once, at press time (same plot-width approximation as the drag
  pan), and every zoom step keeps that sample at the same window fraction —
  the data under the mouse stays put, as in pyqtgraph.  New
  `Command::ZoomAbout { factor, anchor_secs }`; the keyboard zoom becomes
  the anchor-at-right-edge special case of the same `zoom_about` routine
  (behavior unchanged).  `ScopeState` carries the in-progress zoom drag
  (last column + pinned anchor offset) alongside the pan drag; release ends
  it; a press outside the chart anchors nothing.
- **Right-edge time label**: whenever the scope is paused OR the view's
  right edge is not at 0 offset, the right-most x-axis label is the absolute
  wall-clock `HH:MM:SS` that edge represents — the pause moment (paused) or
  the current time (live), minus the right-edge offset.  Live at 0 offset
  keeps the "0" label.  `App::paused` becomes `Option<jiff::Zoned>` (the
  pause moment itself, not a preformatted string) so the offset arithmetic
  is exact; the PAUSED badge and the chart label both format from it.  The
  chart title's `[-12min…-5min of 30min]` window annotation keeps its deltas —
  the axis carries the absolute time, the title the relative window.

Tests (unit): right-drag → zoom command mapping (press outside the chart
ignored, release ends the drag); proportional in/out with the anchor held
(zoom about the window center keeps the center offset); out-and-back drag
round-trips to the starting view; zoom-out drag at full view stays full;
keyboard zoom regression (anchor at the right edge, offset preserved);
right-edge label selection (live@0 → "0", live panned → now − offset,
paused@0 → pause time, paused panned → pause time − offset) with a fixed
`jiff::Zoned` for determinism.
Manual: drag feel against ndscope muscle memory, label sanity while paused
and panned.

Addendum (branch `phase9-axis-anchor`) — the timestamp is a paused-only
anchor, after living with the always-absolute right edge:
- **Live views carry no timestamp.**  Every label is relative to the
  current time (the newest sample): "0" at the live right edge, the
  window's offset (e.g. "-5min") when panned behind it — so panning a live
  view shifts all the labels, which is honest: the data keeps moving under
  a live window anyway.
- **Paused views always anchor on a timestamp**: the right-most label is
  the wall-clock `HH:MM:SS` of the newest second being viewed (pause time
  minus the window offset), and the remaining markers are deltas **from
  that timestamp** (span fractions only).  Panning while paused therefore
  changes the timestamp and leaves the delta markers alone — read the
  signal's time as "timestamp minus marker" anywhere in the window.
- `right_edge_time` keys purely off paused; `time_labels` measures its
  deltas from the right edge when given a timestamp and from "now"
  otherwise.  The title's window annotation (`[-12min…-5min of 30min]`,
  ◀/▶ cues) is unchanged and keeps carrying the newest-sample-relative
  position in both cases.

Addendum (branch `phase9-axis-cues`) — more-data cues on the axis too:
- The zoomed window's ◀/▶ cues stay in the chart title and are echoed on
  the x axis itself: ◀ prefixes the left-most label when older data exists
  past the left edge, ▶ trails the right-most label when newer data exists
  past the right edge.  A pure `cue_labels` helper decorates the output of
  `time_labels`; the conditions (`more_left` from `view_slice`, offset > 0)
  are only ever true for a zoomed window, so the full view and the preview
  miniscope stay cue-free.

## Phase 10 — List-mode mouse scrolling (branch: `phase10-list-scroll`)

Spec: the mouse-interaction additions in `term_scope/CLAUDE.md`.
Files: `src/command.rs` (new command), `src/app.rs` (viewport decoupling,
wheel + drag mapping).  No backend or history changes.

Agreed decisions:
- **Wheel moves the selection**: with the mouse over the table area,
  ScrollUp/ScrollDown map to the existing `SelectUp`/`SelectDown` — one
  row per event, viewport following via the usual invariant, exactly like
  the arrow keys.  Ignored over the preview pane and in scope mode.
- **Left-drag scroll is view-only**: a left press on a channel row selects
  it (as today) and anchors a drag; each drag event scrolls the viewport
  by the rows moved since the last one (`Command::ScrollBy(rows)`), list
  following the mouse (drag up shows later channels) — the list-mode
  analogue of the scope's drag pan.  The selection is left unchanged.
  Release ends the drag; a press that hit no row anchors nothing.
- **Viewport decoupled from the selection**: to let a drag scroll move
  the view freely, the selection may leave the screen (the preview keeps
  showing it).  The per-frame `scroll_selected_into_view` in `render_list`
  is replaced by a plain bounds clamp; every selection-moving path (keys,
  wheel, page keys, click select, returning from scope mode) still snaps
  the viewport back, so navigation behaves exactly as before.
- Help overlay gains the rows; middle-click copy is untouched (the wheel
  is a separate event kind).

Tests (unit): wheel selection moves with the viewport following, clamps
at both ends, only over the table, list mode only; drag
grab/scroll/clamp/release with the selection untouched, no-anchor drags
ignored; a selection move snapping the viewport back to an off-screen
selection.
Manual: drag-scroll direction against touch-scrolling instinct, wheel
feel.

## Future (kept open, not scheduled)

- Min/mean/max history accumulation (needs backend help; see Phase 5 notes).
- Other backends beyond CA; .stp fields beyond Name/Min/Max.

## Verification

- Every phase: `cargo test`, `cargo clippy` clean, `cargo fmt`, on the phase branch before
  merging back to main and deleting the branch.
- Phases 1–3 have real unit tests (parser; timestamp/DBR/enum conversion; app state:
  scroll clamping, pause, help, quit keys).
- End-to-end: the in-repo integration test `term_scope/tests/e2e_ioc.rs` runs a
  loopback-only pcaspy IOC (`tests/data/test_ioc.py`, lifetime controlled over its
  stdin) and drives `capture::Backend` in-process (the crate has a lib target for this),
  checking live values (enum labels included), a changing counter, a never-connected
  channel staying `None`, blanking on IOC stop, recovery on restart, and clean backend
  shutdown.  It needs python3 + pcaspy on PATH, so it is `#[ignore]`d; run it with
  `cargo test -p term_scope -- --ignored`.  `tests/run_test_ioc.sh` runs the same IOC
  for interactive testing.
- TUI smoke test: `tests/run_tui_smoke.sh` runs the real binary in a pty (via
  `script(1)`) against the soft IOC, injects SGR mouse clicks and a `q`, and checks
  the captured output for live values and the OSC 52 click-to-copy sequences (name
  and value) plus the status-line feedback.  Same python3 + pcaspy requirement as
  the e2e test.
- Still manual (Phases 3–4): the rendered TUI in a real terminal — scrolling with a
  list longer than the screen, pause/unpause freezing the display while updates
  continue, help overlay, clean exit (terminal restored), and that the terminal
  emulator actually honors OSC 52 (tmux needs `set-clipboard on`).  Check against
  `tests/run_test_ioc.sh` + `cargo run -- tests/data/channels.txt`.
