From a92ea57d75653b07e768542f1a2a86ab2ba10520 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:20:54 +0000 Subject: [PATCH] Inspector: size to the terminal, page it, and a full-screen view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspector capped each field at a fixed 8 (Payload) / 10 (Result) / 14 (Text) lines whatever the terminal, so a tall window sat half empty under a dead "… 61 more lines (y to copy)", and it had no scroll state at all. Below 110 columns the side pane does not fit and `i` flipped a flag that rendered nothing and said nothing, so the inspector was simply dead on an 80-column terminal. Three changes, in the order they matter: 1. The window is sized from height() instead of a constant. Every line is materialised (bounded by INSPECTOR_MAX_LINES only so a pathological payload cannot build an unbounded array per render), so a taller terminal shows more with no new keys at all. 2. PgUp/PgDn page it, with no focus mode: j/k must keep driving the row selection, because that is what chooses the inspector's content, and a focus concept would add modality to a route that has none. The arithmetic is core/navigation.ts#paneWindow / scrollPane — clamped in the getter, so a resize or a shorter row cannot strand the view past the end of the new content, and pages overlap by two lines. The foot of the pane reads "12–40 of 118 · PgUp/PgDn · y copy · I full"; the offset resets when the selected row changes. 3. shift+i opens it full screen, and from a closed inspector opens it there directly. This is also what `i` now does below 110 columns, which fixes the dead key and delivers the full-screen inspect view DESIGN.md §7.1 has promised since 0.1. esc returns it to the pane. y stays the answer for actually reading a large payload — a ~40-column pane is not a JSON viewer. The scroller is for "there were twelve more lines and I want to glance at them". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BrwAnTfTmJSmFwVn3DDFxr --- CHANGELOG.md | 16 +++++++ DESIGN.md | 26 ++++++++++- docs/USAGE.md | 5 ++- src/core/navigation.ts | 22 ++++++++++ src/tui/route.tsx | 96 ++++++++++++++++++++++++++++++++++------- test/navigation.test.ts | 44 ++++++++++++++++++- 6 files changed, 189 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0a1e4..7e16e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## 0.2.3 (unreleased) +- **The inspector shows the whole field now, and pages through it.** It used to cap each field + at a fixed 8 (Payload) / 10 (Result) / 14 (Text) lines whatever the terminal, so a tall + window sat half empty under a dead `… 61 more lines (y to copy)`, and it had no scroll state + at all. + - The window is sized from the terminal, so a taller terminal shows more with no keys at all. + - `PgUp` / `PgDn` page it — no focus mode, since `j`/`k` must keep choosing which row the + inspector is describing. The foot of the pane reads `12–40 of 118 · PgUp/PgDn · y copy · + I full`, and the offset resets when you move to another row. + - `shift+i` opens the inspector **full screen** (and from closed, opens it there directly); + `esc` returns it to the side pane. A ~40-column pane is not a JSON viewer, so this is the + "show me all of it" answer; `y` remains the answer for actually reading a large payload + somewhere with search and folding. +- **Fixed:** below 110 columns the side pane does not fit, and `i` flipped a flag that rendered + nothing and gave no feedback — the inspector was simply dead on an 80-column terminal. It now + opens full screen there, which is what DESIGN.md §7.1 promised and never shipped. + - **Turns and Calls drew the same chart.** The lane modes only ever differed by one blank cell at each turn boundary — everything else (which events, which lanes, glyphs, colours) was identical, so switching between them looked like nothing happened. Checking DeepSeek Harness diff --git a/DESIGN.md b/DESIGN.md index f86260d..f91aef3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -536,7 +536,8 @@ the branch structure (ancestry axis). That is the layout: size, red if error). Cursor position is mirrored in the lanes; - **inspector on the right = DSH inspector**: Summary / Payload / Result / Schema / Timing / Crop for the selected row; toggled with `i`; below 110 columns it becomes a - full-screen view instead (this is `pi-context-tree`'s *inspect* view). + full-screen view instead (this is `pi-context-tree`'s *inspect* view) — **implemented in + 0.2.3**, along with `shift+i` to ask for it on any width and `PgUp`/`PgDn` to page it. ### 7.2 Mockup (≥110 columns) @@ -611,6 +612,29 @@ how the Turns/Calls bug shipped. the inspector's *Crop* facet explains protection (latest per tool, current turn, decision, `keep` glob). + +**Reading a long field (0.2.3).** The inspector used to cap each field at a fixed 8 / 10 / 14 +lines whatever the terminal, ending in a dead `… 61 more lines (y to copy)`; it had no scroll +state at all, and below 110 columns `i` flipped a flag that rendered nothing and said nothing. +Three changes, in the order they matter: + +1. **The caps follow the pane.** Every line is materialised (bounded by `INSPECTOR_MAX_LINES` + only so a pathological payload cannot build an unbounded array per render) and the *window* + is sized from `height()`, so a tall terminal simply shows more instead of leaving the pane + half empty under a truncation notice. +2. **`PgUp` / `PgDn` page it**, with no focus mode: `j`/`k` must keep driving the row selection, + because that is what chooses the inspector's content, and a focus concept would add modality + to a route that has none. `core/navigation.ts#paneWindow`/`scrollPane` hold the arithmetic + (clamped in the getter, so a resize or a shorter row cannot strand the view past the end); + the foot of the pane reads `12–40 of 118 · PgUp/PgDn · y copy · I full`. The offset resets + when the selected row changes — new content, new top. +3. **`shift+i` is full screen**, and is also what `i` does below 110 columns. A ~40-column pane + is not a JSON viewer; this is the "view all of it" answer, and it retires the narrow-terminal + dead end in the same mechanism. + +`y` stays the answer for actually *reading* a large payload — copy it somewhere with search and +folding. The scroller is for "there were twelve more lines and I want to glance at them". + ### 7.5 Filters and search (from Pi) `f` cycles `default → no-tools → user-only → labeled → all` (default hides diff --git a/docs/USAGE.md b/docs/USAGE.md index c675894..03e7b2f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -36,8 +36,8 @@ OpenCode storage — off by default); `keybinds` overrides any route key by comm e.g. `{ "keybinds": { "open": "ctrl+t", "up": "k,up", "copy": "none" } }` — names are `open up down jump_up jump_down half_up half_down first last prev_branch next_branch fold unfold toggle go branch label filter_pick filter_prev search search_next search_prev back -crop crop_toggle_mode mark auto undo merge inspector consumers copy mode_duration mode_turns -lanes_off decisions export help`. +crop crop_toggle_mode mark auto undo merge inspector inspector_full inspector_up inspector_down +consumers copy mode_duration mode_turns lanes_off decisions export help`. ## Upgrading @@ -113,6 +113,7 @@ appended to the trunk as a normal message.* | `D` `E` | decisions panel, export `ctree-decisions.md` | | `s` | consumers: what is filling the context (`⏎` opens a bucket, `space` marks one entry for crop) | | `i` | inspector pane on/off (auto-hidden under 110 columns) | +| `i` `I` `PgUp` `PgDn` | inspector in the side pane / full screen; page through a long payload or result. The pane shows every line it has, sized to your terminal, with `12–40 of 118` at the foot when there is more; `y` copies the untruncated text. Below 110 columns the side pane does not fit, so `i` opens full screen directly | | `1 2` `0` | timeline lanes, x-axis by duration / one cell per event; `0` off. `│` marks a turn boundary, and the lanes show whatever the `f` filter shows — so `f` → `tools-only` is the "what did I run" view in both the rows and the lanes | | `L` | label the selected message | | `f` `F` | filter picker (default → no-tools → user-only → labeled → all); `F` steps back | diff --git a/src/core/navigation.ts b/src/core/navigation.ts index 2ade090..b66492b 100644 --- a/src/core/navigation.ts +++ b/src/core/navigation.ts @@ -122,3 +122,25 @@ export function resolveSelection( return land(0) } + +/** + * Window into a scrollable pane (the inspector, DESIGN.md §7.4): `room` lines drawn out of + * `total`, starting at `top`. `start` is clamped so the last page sits flush with the end + * rather than scrolling past it into blank space, and never below 0 when the content is + * shorter than the pane. `from`/`to` are 1-based and inclusive, for a `12–40 of 118` readout. + * + * Clamping lives here, not in the setter, so a resize or a shorter row cannot strand the view + * past the end of the new content. + */ +export function paneWindow(total: number, room: number, top: number): { start: number; from: number; to: number } { + const r = Math.max(1, room) + const start = Math.max(0, Math.min(Math.max(0, Math.floor(top)), total - r)) + return { start, from: total === 0 ? 0 : start + 1, to: Math.min(total, start + r) } +} + +/** One page up or down, overlapping by two lines so the eye keeps its place. */ +export function scrollPane(total: number, room: number, top: number, dir: 1 | -1): number { + const r = Math.max(1, room) + const step = Math.max(1, r - 2) + return paneWindow(total, r, paneWindow(total, r, top).start + dir * step).start +} diff --git a/src/tui/route.tsx b/src/tui/route.tsx index 0ab3b12..e46e418 100644 --- a/src/tui/route.tsx +++ b/src/tui/route.tsx @@ -8,7 +8,7 @@ import { PLUGIN_VERSION } from "../shared/version.js" import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { abandonedTail, planJump, type AbandonedTail, type JumpPlan } from "../core/actions.js" import { foldJournal, type TreeState } from "../core/journal.js" -import { firstIndex, lastIndex, moveSelection, nextBranchIndex, resolveSelection, toggleExpanded } from "../core/navigation.js" +import { firstIndex, lastIndex, moveSelection, nextBranchIndex, paneWindow, resolveSelection, scrollPane, toggleExpanded } from "../core/navigation.js" import { contextSizeOf, formatContext, formatK, type MinimalMessage } from "../core/tokens.js" import { buildSpineMap, buildTreeView, currentChainOf, formatPromptAt, promptAtRow, type Filter, type Row, type StepRow, type TurnRow } from "../core/tree.js" import { ContextGauge } from "./gauge.js" @@ -168,6 +168,9 @@ const DEFAULT_KEYS: Record = { undo: ["u", "x"], merge: ["m"], inspector: ["i"], + inspector_full: ["shift+i"], + inspector_up: ["pageup"], + inspector_down: ["pagedown"], consumers: ["s"], copy: ["y"], mode_duration: ["1"], @@ -186,6 +189,10 @@ const DEFAULT_KEYS: Record = { back: ["q", "escape"], } +/** Ceiling on the lines the inspector materialises for one field. The pane scrolls, so this is + * only a guard against building a huge array each render; `y` copies the untruncated text. */ +const INSPECTOR_MAX_LINES = 2000 + /** Placeholder for the strip while no session is loaded. */ const EMPTY_TRANSCRIPT: Transcript = { sessionID: "", title: "", status: "available", messages: [] } @@ -204,7 +211,8 @@ const HELP = [ " b branch · m merge · c crop mode (space mark · a auto · t result⇄turn · ⏎ apply · esc leave)", " u undo (alias x) · L label · y copy · E export decisions", "Views", - " i inspector · 1 2 lanes (duration/turns x-axis) · 0 off · s consumers · D decisions · f F filter", + " i inspector · I full screen · PgUp/PgDn scroll it · 1 2 lanes (duration/turns x-axis) · 0 off", + " s consumers · D decisions · f F filter", "Legend", " ● user · ○ assistant · ⚙ tool step · ◆ decision · ≣ summary · ⎇ branch (a real OpenCode session)", " │ ├ ╰ draw the topology · ▾ open ▸ folded · ← here is the session you are in", @@ -261,6 +269,11 @@ export function TreeRoute(props: TreeRouteProps) { // DSH lanes and inspector are first-class but off by default, so the first screen reads as // Pi's clean outline (header + tree + footer); `1/2/3` and `i` bring them in, one keystroke. const [lanesOn, setLanesOn] = createSignal(api.kv.get("ctree.lanesOn", false)) + /** Full-screen inspector (`shift+i`), and the only inspector below 110 columns where the + * side pane does not fit — DESIGN.md §7.1's promised `pi-context-tree` inspect view. */ + const [inspectorFull, setInspectorFull] = createSignal(false) + /** First inspector line drawn: `PgUp`/`PgDn` move it, a new row resets it. */ + const [inspectorTop, setInspectorTop] = createSignal(0) const [inspector, setInspector] = createSignal(api.kv.get("ctree.inspector", false)) const [consumerIndex, setConsumerIndex] = createSignal(0) const [consumerOpen, setConsumerOpen] = createSignal>(new Set()) @@ -669,7 +682,12 @@ export function TreeRoute(props: TreeRouteProps) { } // ---- inspector ----------------------------------------------------------- - const showInspector = () => inspector() && panel() === "tree" && cols() >= 110 + /** The inspector has content to show — it may land in the side pane or full screen. */ + const inspectorOpen = () => inspector() && panel() === "tree" + /** Full screen when asked for, and whenever the side pane cannot fit: `i` on an 80-column + * terminal used to flip a flag that rendered nothing and said nothing. */ + const showInspectorFull = () => inspectorOpen() && (inspectorFull() || cols() < 110) + const showInspector = () => inspectorOpen() && !showInspectorFull() const inspectorWidth = () => Math.min(56, Math.max(36, Math.floor(width() * 0.4))) const rowWidth = () => (showInspector() ? width() - inspectorWidth() - 2 : width()) - (cropMode() ? 4 : 0) // wraps badly next to the inspector, so break it at the ";" rather than mid-clause @@ -677,17 +695,19 @@ export function TreeRoute(props: TreeRouteProps) { const inspectorLines = createMemo((): { fg: unknown; text: string }[] => { const row = current() if (!row || row.kind === "separator") return [] - const w = inspectorWidth() - 3 + const w = (showInspectorFull() ? width() : inspectorWidth()) - 3 const clip = (x: string) => (x.length > w ? `${x.slice(0, w - 1)}…` : x) const out: { fg: unknown; text: string }[] = [] const head = (x: string) => out.push({ fg: t.primary, text: clip(x) }) const kv = (k: string, v: string) => out.push({ fg: t.text, text: clip(`${k.padEnd(10)}${v}`) }) const muted = (x: string) => out.push({ fg: t.textMuted, text: clip(x) }) - const block = (label: string, text: string, max: number) => { + // every line, for the scroller to window — bounded only so a pathological payload cannot + // build an unbounded array on each render; `y` still copies the untruncated original + const block = (label: string, text: string) => { const lines = text.split("\n").filter((l) => l.length) kv(label, lines[0] ?? "") - for (const l of lines.slice(1, max)) out.push({ fg: t.text, text: clip(` ${l}`) }) - if (lines.length > max) muted(` … ${lines.length - max} more lines (y to copy)`) + for (const l of lines.slice(1, INSPECTOR_MAX_LINES)) out.push({ fg: t.text, text: clip(` ${l}`) }) + if (lines.length > INSPECTOR_MAX_LINES) muted(` … ${lines.length - INSPECTOR_MAX_LINES} more lines (y to copy)`) } if (row.kind === "branch") { head(`⎇ ${row.name}`) @@ -711,8 +731,8 @@ export function TreeRoute(props: TreeRouteProps) { head(`◆ ${decisionSummary(text).title}`) kv("Tokens", `~${formatK(row.tokens)}`) const lines = renderDecision(text, w) - for (const l of lines.slice(0, 16)) out.push({ fg: t.text, text: l }) - if (lines.length > 16) muted(`… ${lines.length - 16} more lines (y to copy)`) + for (const l of lines.slice(0, INSPECTOR_MAX_LINES)) out.push({ fg: t.text, text: l }) + if (lines.length > INSPECTOR_MAX_LINES) muted(`… ${lines.length - INSPECTOR_MAX_LINES} more lines (y to copy)`) return out } head(`${row.isSummary ? "◇ summary" : "● user"} · T${row.turn}`) @@ -720,7 +740,7 @@ export function TreeRoute(props: TreeRouteProps) { kv("Tokens", `~${formatK(row.tokens)}`) kv("At", msg ? new Date(msg.time.created).toISOString().slice(11, 19) : "?") if (!row.inContext) muted("not in this branch's context") - block("Text", text, 14) + block("Text", text) return out } const part = msg?.parts.find((p) => p.id === row.partID) @@ -740,8 +760,8 @@ export function TreeRoute(props: TreeRouteProps) { const dur = st?.time?.start !== undefined && st?.time?.end !== undefined ? `${st.time.end - st.time.start} ms` : "?" kv("Status", `${st?.status ?? "?"} · ${dur}`) kv("Tokens", `~${formatK(row.tokens)} · ${view().totalTokens ? `${((row.tokens / view().totalTokens) * 100).toFixed(1)}% of context` : ""}`) - block("Payload", JSON.stringify(st?.input ?? {}, null, 1), 8) - block("Result", String(st?.output ?? ""), 10) + block("Payload", JSON.stringify(st?.input ?? {}, null, 1)) + block("Result", String(st?.output ?? "")) kv("Timing", st?.time?.start ? `started ${new Date(st.time.start).toISOString().slice(11, 23)} · ${dur} · session ts` : "n/a") const cand = resultCands().find((c) => c.partID === (currentPartOf(row) ?? row.partID)) kv("Crop", row.isCropped ? `✂ cropped (${UNDO_KEY} to restore)` : cand ? (cand.protections.length ? `protected: ${cand.protections.join(", ")}` : "c then space to stub this result") : "n/a") @@ -749,11 +769,30 @@ export function TreeRoute(props: TreeRouteProps) { kv("Tokens", `~${formatK(row.tokens)}`) if (row.durationMs !== undefined) kv("Duration", `${(row.durationMs / 1000).toFixed(1)} s`) if (row.thinkingMs !== undefined) kv("Thought", `${(row.thinkingMs / 1000).toFixed(1)} s`) - block("Text", part?.text ?? row.preview, 14) + block("Text", part?.text ?? row.preview) } return out }) + /** Lines the inspector can draw; one is given up to the position line when it overflows. */ + const inspectorRoom = () => Math.max(1, height() - (inspectorOverflow() ? 1 : 0)) + const inspectorOverflow = () => inspectorLines().length > height() + /** Clamped here rather than in the setter, so a resize or a shorter row cannot strand the + * view past the end of the content. */ + const inspectorPane = () => paneWindow(inspectorLines().length, inspectorRoom(), inspectorTop()) + const inspectorVisible = createMemo(() => inspectorLines().slice(inspectorPane().start, inspectorPane().start + inspectorRoom())) + function scrollInspector(dir: 1 | -1) { + setInspectorTop(scrollPane(inspectorLines().length, inspectorRoom(), inspectorTop(), dir)) + } + /** `12–40 of 118 · PgUp/PgDn scroll · y copy · I full` — replaces the old per-field + * "… 61 more lines" dead end with where you are and how to see the rest. */ + const inspectorStatus = () => { + const { from, to } = inspectorPane() + return clipTo(`${from}–${to} of ${inspectorLines().length} · PgUp/PgDn · y copy · ${inspectorFull() ? "I pane" : "I full"}`, (showInspectorFull() ? width() : inspectorWidth()) - 3) + } + // a new row is new content: keep the reader at its top rather than mid-way down a payload + createEffect(on(() => `${current()?.id ?? ""}:${showInspectorFull()}`, () => setInspectorTop(0))) + // ---- consumers ------------------------------------------------------------- const consumerRows = createMemo(() => (live() ? consumers(live()!, { cropped: alreadyCropped(), limit: contextLimit() }) : [])) /** Buckets plus the entries of every expanded one, flattened so ↑↓ walks both. */ @@ -1306,6 +1345,21 @@ export function TreeRoute(props: TreeRouteProps) { { name: "ctree.undo", hidden: true, enabled: treeIdle, run: () => void undo() }, { name: "ctree.merge", hidden: true, enabled: treeIdle, run: () => void merge() }, { name: "ctree.inspector", hidden: true, enabled: () => !inCrop(), run: () => { setInspector(!inspector()); api.kv.set("ctree.inspector", inspector()) } }, + { + name: "ctree.inspector_full", + hidden: true, + enabled: () => !inCrop(), + // from a closed inspector this opens it full screen, so `shift+i` is one key to "show me all of it" + run: () => { + if (!inspector()) { + setInspector(true) + api.kv.set("ctree.inspector", true) + } else setInspectorFull(!inspectorFull()) + setInspectorTop(0) + }, + }, + { name: "ctree.inspector_up", hidden: true, enabled: inspectorOpen, run: () => scrollInspector(-1) }, + { name: "ctree.inspector_down", hidden: true, enabled: inspectorOpen, run: () => scrollInspector(1) }, { name: "ctree.consumers", hidden: true, enabled: () => !inCrop(), run: () => setPanel(panel() === "consumers" ? "tree" : "consumers") }, { name: "ctree.copy", hidden: true, enabled: treeIdle, run: () => copySelected() }, { name: "ctree.mode_duration", hidden: true, enabled: treePanel, run: () => setLane("duration") }, @@ -1325,6 +1379,10 @@ export function TreeRoute(props: TreeRouteProps) { notify("cancelling the branch summary…") return } + if (showInspectorFull() && inspectorFull()) { + setInspectorFull(false) + return + } if (panel() !== "tree") { setPanel("tree") return @@ -1422,6 +1480,7 @@ export function TreeRoute(props: TreeRouteProps) { } const footer = () => { + if (showInspectorFull()) return `PgUp/PgDn scroll y copy ${cols() >= 110 ? "I pane " : ""}i close q back` if (cropMode()) return "space mark a auto t result⇄turn ⏎ apply esc leave" if (panel() === "decisions") return "⏎ jump to record E export q back" if (panel() === "consumers") return "⏎ expand space mark c crop q back" @@ -1529,6 +1588,7 @@ export function TreeRoute(props: TreeRouteProps) { │ {emptyText()} + │ {hiddenAbove() > 0 ? `↑ ${hiddenAbove()} more` : ""} @@ -1593,9 +1653,13 @@ export function TreeRoute(props: TreeRouteProps) { {/* the help pane sits under the rows, so the tree it explains stays on screen */} {(l) => │ {l}} - - - {(l) => ┃ {l.text}} + + + + {(l) => ┃ {l.text}} + + ┃ {inspectorStatus()} + diff --git a/test/navigation.test.ts b/test/navigation.test.ts index d3bfa9b..3dc9bfd 100644 --- a/test/navigation.test.ts +++ b/test/navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { cycleFilter, firstIndex, lastIndex, moveSelection, nextBranchIndex, resolveSelection, toggleExpanded } from "../src/core/navigation.js" +import { cycleFilter, firstIndex, lastIndex, moveSelection, nextBranchIndex, paneWindow, resolveSelection, scrollPane, toggleExpanded } from "../src/core/navigation.js" import { buildTreeView } from "../src/core/tree.js" import { buildFixture, OPEN, TRUNK } from "./fixtures/tree.js" @@ -65,3 +65,45 @@ describe("separator rows are decoration, never the cursor", () => { expect(branchView.rows[i]!.kind).toBe("branch") }) }) + +describe("paneWindow — the inspector scroller", () => { + test("content shorter than the pane never scrolls", () => { + expect(paneWindow(5, 20, 0)).toEqual({ start: 0, from: 1, to: 5 }) + expect(paneWindow(5, 20, 99)).toEqual({ start: 0, from: 1, to: 5 }) + }) + test("the last page sits flush with the end instead of scrolling into blank space", () => { + expect(paneWindow(118, 20, 999)).toEqual({ start: 98, from: 99, to: 118 }) + expect(paneWindow(118, 20, 98)).toEqual({ start: 98, from: 99, to: 118 }) + }) + test("mid-scroll reads as the screenshot's missing figure would", () => { + expect(paneWindow(118, 29, 11)).toEqual({ start: 11, from: 12, to: 40 }) + }) + test("empty content reports nothing rather than 1–0 of 0", () => { + expect(paneWindow(0, 20, 0)).toEqual({ start: 0, from: 0, to: 0 }) + }) + test("a shorter row cannot strand the view past the end (clamping is in the getter)", () => { + const deep = paneWindow(500, 20, 400).start + expect(paneWindow(30, 20, deep)).toEqual({ start: 10, from: 11, to: 30 }) + }) +}) + +describe("scrollPane", () => { + test("a page down overlaps by two lines so the eye keeps its place", () => { + expect(scrollPane(118, 20, 0, 1)).toBe(18) + expect(scrollPane(118, 20, 18, 1)).toBe(36) + }) + test("up and down are symmetric, and both clamp", () => { + expect(scrollPane(118, 20, 18, -1)).toBe(0) + expect(scrollPane(118, 20, 0, -1)).toBe(0) + expect(scrollPane(118, 20, 98, 1)).toBe(98) + }) + test("paging down repeatedly lands on the last page and stops", () => { + let top = 0 + for (let i = 0; i < 50; i++) top = scrollPane(118, 20, top, 1) + expect(top).toBe(98) + expect(paneWindow(118, 20, top).to).toBe(118) + }) + test("a pane of one line still advances", () => { + expect(scrollPane(10, 1, 0, 1)).toBe(1) + }) +})