# 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.

## Phase 11 — Per-channel log scale (branch: `phase11-log-scale`)

Spec: the "Y-axis scale (linear/log)" section, the `log` input-file attribute,
the `Strip.Curve.<N>.Scale` key, and the `L` keybindings in `term_scope/CLAUDE.md`.
Files: `src/channel.rs` (`Scale`), `src/parse.rs` (`log` word), `src/stp.rs`
(Scale key), `src/command.rs` (`ToggleScale`), `src/app.rs` (log rendering,
toggle, labels).  No backend or history changes.

Agreed decisions:
- **Naming**: "scale" — the term StripTool (`Strip.Curve.N.Scale`), ndscope and
  matplotlib all use, and it avoids a second "mode" concept next to
  `Mode::List`/`Mode::Scope`.  `enum Scale { Linear, Log }` (default `Linear`)
  in `channel.rs`; `ChannelConfig` gains `scale: Scale`.
- **Transform at render time**: history buffers stay in linear units and
  `render_chart` maps y → log10(y) after `chart_segments`.  log10 is monotonic,
  so the downsampler's per-bucket extrema are preserved exactly — no
  `history.rs` changes, and toggling is instant on already-collected data.
- **Sources**: plain text — the word `log` among a line's attribute words;
  .stp — `Strip.Curve.<N>.Scale` (0 = linear, 1 = log, anything else ignored).
  The duplicate-line skip applies exactly as for ranges.
- **Toggle** (`L`, both modes → `Command::ToggleScale`): flips the displayed
  channel's scale (list mode: the selection; scope mode: the scoped channel)
  and **writes through to the channel's config**, so it persists for the
  session and is seen again the next time the channel is viewed; it is never
  written back to the input file.  There is no view-level scale state:
  `render_chart` reads the channel's config at draw time, so the scope and the
  preview always agree, switching selection shows each channel in its own
  scale (nothing to inherit), and the scale is deliberately **not** among the
  frozen pause copies — like pan/zoom it is a pure display transform, so
  toggling re-renders a paused trace immediately.  A status notice reports the
  change ("X1:SINE_1 scale: log").
- **Ranges** (log mode, by shape): min > 0 → the range pins the axis at
  [min, max], clamping/excursions in log space.  min ≤ 0 < max → half-pinned:
  the top is pinned at max (values above it clamp to red overflow excursions,
  as in linear mode) and the bottom autoscales from the positive samples under
  the usual log-autoscale rules — the data picks the floor, since any fixed
  epsilon either crushes the trace (too small) or silently clips it (too
  large).  max ≤ 0 → full log autoscale.  In the partial cases the range is
  kept: it still pins the axis in linear mode and drives the list-mode red
  value.
- **Non-positive samples** (log mode): rendered as red excursion runs pinned
  at the bottom axis through the existing clamp mechanism — "data here, below
  the log floor" — rather than breaking the trace like a disconnect.
- **Autoscale (log)**: extremes of the positive samples in log space, padded
  5% of the spanned decades; fallbacks mirror the linear ones: no positive
  samples → a default decade (the whole trace pins to the bottom as
  excursions), flat trace → ± half a decade.
- **Labels**: y labels stay at min/mid/max (geometric spacing in real values);
  `axis_value` learns scientific notation for magnitudes outside roughly
  [1e-3, 1e4] so 1e-6 doesn't collapse to "0".  X axis untouched.
- **Preview**: falls out of the shared `render_chart` — the miniscope passes
  the channel's scale the same way it already passes the range.

Tests (unit): parser (`log` word alone, with a range, among junk words, on a
duplicate line not applied); .stp Scale values 0/1/other/absent and
interleaving with Name/Min/Max; `L` → `ToggleScale` in both modes; a toggle
persisting across mode switches and selection round-trips, with a selection
change showing the new channel's own scale; the log transform composing with
downsampling (bucket extrema map through); the three range shapes in log mode
(min > 0 fully pinned; min ≤ 0 < max top-pinned with a data-driven floor and
overflow excursions still red at max; max ≤ 0 fully autoscaled) with each
still pinning linear mode and coloring the list; ≤ 0 samples becoming
bottom-pinned excursion runs; log autoscale fallbacks (no positive data, flat
trace); scientific `axis_value` formatting; paused toggle re-rendering the
frozen trace.
Fixtures: `tests/data/channels.txt` gains a `log`-annotated channel and
`channels.stp` a `Strip.Curve.<N>.Scale 1` line so the e2e/smoke paths parse
them.
Manual: toggle feel in both panes, a log trace against ndscope on the same
channel, label readability across several decades.

## 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`.
