SLOPSHOPPER

chat-toc

A docked table of contents of the current Claude Code session; jump to any turn.

newpanebandspinnerrowscommand
★ 2v0.2.0no licenseupdated 2026-10-19lkangd/cc-mods/mods/chat-toc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-toc
│ ┃ ChatTOC ✕ › fix the failing auth test and add an audit log call │ ┃ 1: All 2: User 3: Agent │ ┃ ━━━━━━────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ────── ⎿ Read 6 lines │ ┃ f: Find words or a file path ⏎ jump ‹ › ⏺ Update(src/auth.ts) │ ┃ No conversation in this session yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /chat-toc │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ChatTOC ‹ › Open contents ❯ ✕ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ChatTOC ‹ › Open contents ❯ ✕
Pane · ChatTOC
1: All 2: User 3: Agent ━━━━━━────────────────────────────────────────────────── f: Find words or a file path ⏎ jump ‹ › No conversation in this session yet ──────────────────────────────────────────────────────── l: Layout: Card
README

chat-toc (developer notes)

chat-toc is a Claude Code plugin: in a fullscreen terminal it docks the current session's conversation contents to the right of the transcript, and a click on an entry jumps back to that turn.

  • How to use it (requirements, install, controls, display rules, privacy, uninstall, known limits) is written only in the root README's chat-toc section; this file does not repeat it.
  • Domain terms (Transcript, Turn group, User input, Agent reply, View filter, Layout, Current position, Jump target) are in CONTEXT.md. The code and this file use these terms; text for users always uses the words on screen.
  • Spec and tickets: the MVP (0.1) in .scratch/chat-toc/spec.md and .scratch/chat-toc/issues/, 0.2 in .scratch/chat-toc-0.2/spec.md and .scratch/chat-toc-0.2/issues/. Comments cite them as "MVP spec §N" and "0.2 spec §N".

Layout of the folder

mods/chat-toc/
├── .claude-plugin/plugin.json   name chat-toc, version, "types": "./types/index.d.ts"
├── .claude/types/claude-code.d.ts   2.1.287's host type declarations (tsc checks the minimum version with them)
├── types/index.d.ts             the $.state contract (members written inline in PluginState['chat-toc'])
├── hooks/
│   ├── hooks.json               loads ./register.tsx only
│   ├── register.tsx             every line that touches $, and every hook; the other files are pure functions
│   ├── text.ts                  every word drawn or said, English and Chinese, picked by the locale
│   ├── transcript.ts            parses JSONL rows
│   ├── chain.ts                 the current branch's conversation chain (parentUuid; across a compaction boundary, logicalParentUuid)
│   ├── groups.ts                when a group opens, the User input text, steps, last text, ending
│   ├── facts.ts                 each group's facts for the preview, search and reuse: the input as typed, files changed/read, duration, output tokens, the last text as Markdown
│   ├── regroup.ts               incremental regrouping (only from the last group's opening row)
│   ├── compactions.ts           compaction boundaries placed between groups, their rules among the groups shown (×N fold), the fork line's condition
│   ├── model.ts                 whole regrouping; a test oracle only, register does not use it
│   ├── source.ts                whole read up to 4 MiB, 4,000,000 B chunks past it (sh dd+head / Windows PowerShell 5.1)
│   ├── reader.ts                the gate: 300 ms trailing, reads merged while one runs
│   ├── entries.ts               entries for the three Layouts and the View filter, day rules, time format, the Agent side's state words
│   ├── table.ts                 the entry table: entries cached per group, rebuilt only when the data, width, filter or Layout change; group-to-entry lookup; the table narrowed to a query's matches
│   ├── cells.ts                 terminal cell widths and folding (no closing punctuation at a line's start, English words kept whole)
│   ├── window.ts                the Pane's own visible window
│   ├── bars.ts                  the terminal Minimap: a one-cell Button per Turn group, blocks by steps, buckets past the width, the press target, the hide rule
│   ├── ring.ts                  the host's focus ring on an entry's first line, scrolling at the window's ends, stepping over ‹ › and the Minimap
│   ├── view.ts                  the View filter / Layout $.store keys, the default for a value not known
│   ├── sites.ts                 transcript site records and the four-step requestId → row match
│   ├── jump.ts                  each side's jump candidates, refusal notes and dimming
│   ├── memo.ts                  the one-entry memo a drawing keeps its work in (the matches, the narrowed and ruled list, the preview)
│   ├── position.ts              Current position: on-screen row runs, binary search, the jump lock
│   ├── perf.ts                  timing samples under CHAT_TOC_PERF (numbers only), written to the host's debug log
│   ├── lifecycle.ts             the close mark, when to reopen, placement outcomes
│   ├── locate.ts                works out the transcript path when enabled mid-session (the host's project folder naming)
│   ├── rewind.ts                after a rewind, lines the host's messages up with the file to find the groups no longer held
│   ├── preview.ts               the preview's words (meta line, files line), its size against the rows (and what gives way first), what of a group fits
│   ├── reuse.ts                 Reuse and copy: which label-row Buttons a group gets, the text each takes, how a fill keeps the draft
│   ├── search.ts                search: each group's NFKC lower-cased fields indexed at regroup, the matcher (words AND, the View filter's fields), the match nearest a position
│   ├── walk.ts                  ‹ ›: the borrowed actions, the groups the list shows, the walk from the Current position to the previous or next one with a Jump target
│   ├── pane.tsx                 the terminal Pane (filter row and tab rule, the search row, entries on the time rail, the preview region, footer)
│   ├── desktop.tsx              the Pane on Desktop / VS Code / mobile (another tree, split on e.surface); what an entry costs serialized
│   ├── span.ts                  the part of the list Desktop draws: the newest Turn groups within a character budget, moved by ↑ and ↓
│   └── minimap.ts               the SVG strip of steps per turn over the desktop list
└── tests/
    ├── *.test.ts                the claude plugin test suite
    ├── fixture.ts, host.ts      the synthetic transcript builder; the host stand-in over the test kit
    ├── acceptance/              the independent verifier, fixture generator, PTY driver and scenario scripts
    └── evidence/<version>/      release evidence (added by the release commit)

The host has a static check at load: $ may only be passed to functions declared at the top of register.tsx, spelled $.noun.method(...). Passing it to an imported function or a closure makes the module fail to load, so new code that touches $ always goes in register.tsx.

Language

The words follow the system's language (hooks/text.ts): Chinese when the first set of LC_ALL, LC_MESSAGES and LANG starts with zh, English otherwise. The host has no locale API, so register.tsx reads those variables once a load, at the first start event. A new word goes into both tables in text.ts; tests for the English words are in tests/language.test.ts. The older suites check the Chinese words: tests/host.ts starts its worlds with LANG=zh_CN.UTF-8, the pure tests call setLanguage('zh'), and the PTY worlds set LC_MESSAGES=zh_CN.UTF-8.

Loading it locally

claude --plugin-dir /path/to/cc-mods/mods/chat-toc
  • CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is not needed. Turn on the fullscreen layout with /tui fullscreen (or "tui": "fullscreen" in settings).
  • Hot reload: change (or touch) hooks/register.tsx under --plugin-dir. /reload-plugins does not reload an unchanged hooks module (debug log hooks modules unchanged, kept as loaded).
  • To see what the plugin did, start with --debug-file <path>: the log has lines like hooks module chat-toc@inline reloaded, ui.open chat-toc chat-toc (unasked|asked, N columns): …, ui.close ….
  • To time it, also set CHAT_TOC_PERF=1: the plugin writes the time spent reading, regrouping, redrawing the Pane and computing the Current position as chat-toc-perf {...} lines in the debug log, at most one batch a second. Numbers only (milliseconds and counts), no prompts, uuids or paths; without the variable nothing is timed or written.
  • Do not enable it together with a marketplace install.

Do not install the minimum host, 2.1.287, globally; either:

npm install --prefix <temp dir>/cc287 @anthropic-ai/claude-code@2.1.287   # the binary is node_modules/.bin/claude
npx -y -p @anthropic-ai/claude-code@2.1.287 claude ...                     # what the PTY driver's Host("2.1.287") does

Automated tests

The fast gate mods/chat-toc/scripts/verify-startup.sh (about 20 seconds) runs the checks below and the acceptance tools' unit tests, both versions through npx, with nothing installed globally. The repository's pre-commit hook (.githooks/pre-commit; enable it once per clone with git config core.hooksPath .githooks) runs a mod's gate when a commit touches that mod's non-Markdown files.

To run one of them, from the repository root:

claude plugin test mods/chat-toc                 # only the current version must pass
claude plugin validate mods/chat-toc             # current version
<cc287>/node_modules/.bin/claude plugin validate mods/chat-toc   # 2.1.287
(cd mods/chat-toc && npx -y -p typescript@5.6.3 tsc -p tsconfig.json)   # against 2.1.287's type declarations
  • 2.1.287's test kit has no bottom for session.append, so the tests that rely on it run on the current version only.
  • The test kit hands neither the plugin's $.ui.scroll of the transcript nor its own $.ui.focus to the test's hooks (it says no implementation), and cannot press the person's ✕. Jumps, where the focus lands and the person's close are tested on pure functions; their real effect is checked in PTY acceptance.
  • To type-check against 2.1.295 or another newer version, write a tsconfig outside the plugin that includes the types/claude-code.d.ts the plugin-authoring skill writes and mods/chat-toc/{types,hooks,tests}.

The acceptance tools' own unit tests (verifier, fixture generator, driver) use the standard library only:

cd mods/chat-toc/tests/acceptance && python3 -m unittest test_toc_verifier test_transcript_fixture test_pty_harness

PTY acceptance

The second layer runs in the real host, judged by tests/acceptance/toc_verifier.py: it works out the expected entries on its own along the transcript file's conversation chain and compares them one by one with the entries read off the Pane, sharing no code with the plugin.

Setup. Driving the terminal needs pyte, at the version in tests/acceptance/requirements.txt:

python3 -m venv <venv> && <venv>/bin/pip install --require-hashes -r mods/chat-toc/tests/acceptance/requirements.txt
PY=<venv>/bin/python

Every start is in a temporary HOME and CLAUDE_CONFIG_DIR: onboarding done, the project trusted, fullscreen, no plugin but the one given with --plugin-dir, not logged in. Your own ~/.claude is never touched. Logged out, you can --resume a synthetic transcript, click, scroll and run local slash commands, and a submitted prompt is still written to the transcript (the host answers with its own line Not logged in · Please run /login), all without calling a model. When a real turn is needed, set CHAT_TOC_PTY_TOKEN (a token from claude setup-token): the driver passes it as CLAUDE_CODE_OAUTH_TOKEN and adds --model haiku. Synthetic prompts only; no real conversation content.

Scenario scripts (run from the repository root; --host takes 2.1.287 or current, the latter the claude on PATH):

ScenarioCommand
Smoke: the whole list matches the verifier, a click jumps$PY mods/chat-toc/tests/acceptance/pty_harness.py smoke --host 2.1.287 --plugin mods/chat-toc [--fixture forms] [--terminal cmux]
CT-COMPAT-003`$PY mods/chat-toc/tests/acceptance/ct_compat.py --host 2.1.287 --host current [--scenario waiting\narrowed\asked\main\reload]`
CT-LIFE-001, 002, 005 (fork)`$PY mods/chat-toc/tests/acceptance/ct_life.py --host 2.1.287 --host current [--scenario 001\002\fork]`
CT-LIFE-007`$PY mods/chat-toc/tests/acceptance/ct_mid_session.py --host 2.1.287 --host current [--scenario cli\ui] (installs from a folder marketplace set up in the run folder, disabled first, then enabled mid-session from the CLI and from the /plugin` screen)
CT-LIFE-003, 004, 005, 008, CT-DATA-006$PY mods/chat-toc/tests/acceptance/chain_scenarios.py --host 2.1.287 [--only life-003,...]
CT-PREV-001 (the preview region, opened by p on an entry selected), CT-PREV-002 (expanded)$PY mods/chat-toc/tests/acceptance/ct_preview.py --host 2.1.287 [--host current] [--only press,close,facts,cut,short,widths,expand,cap,running] (expand, cap and running record CT-PREV-002; running runs against fake_api.py, no model call)
CT-REUSE-001 (Reuse and copy)$PY mods/chat-toc/tests/acceptance/ct_reuse.py --host 2.1.287 [--host current] [--only fill,copy,rules,closed,dialog,running,widths] (writes the system clipboard and puts the person's back at the end; running runs against fake_api.py; widths also runs in English)
CT-FIND-001, CT-FIND-002 (search)$PY mods/chat-toc/tests/acceptance/ct_search.py --host 2.1.287 [--host current] [--only narrow,filter,live,widths,keys,entry,keep,minimap] (widths also runs in English; keep makes a temporary project a repository with a change, so /diff opens)
CT-STEP-001 (‹ ›, the keys bound)$PY mods/chat-toc/tests/acceptance/ct_walk.py --host 2.1.287 [--host current] [--only pane,keys] (keys writes the world's keybindings.json binding meta+k/meta+j, runs one turn against fake_api.py on 127.0.0.1, and makes the project a repository with a change, so /diff opens)
CT-CMPCT-001 (a query's part too), CT-CTX-001, CT-LIFE-003 (live /compact)$PY mods/chat-toc/tests/acceptance/ct_compaction.py --host 2.1.287 [--only fixture,fork,live] (live runs against fake_api.py on 127.0.0.1 stating chosen usage figures; no model call)
CT-JUMP-001–003, 005, CT-UI-005$PY mods/chat-toc/tests/acceptance/jump_scenarios.py 2.1.287 [001 002 002b 003 005] (005 also records CT-UI-005: the notice line's inverse and foreground read about every 30 ms)
CT-POS-001–003, CT-COEX-001, CT-LIFE-006, CT-MAP-001$PY mods/chat-toc/tests/acceptance/position_scenarios.py 2.1.287 [pos1 pos2 pos3 coex life6 map1] (coex runs git init on a repository with changes in a temporary project, so /diff opens)
CT-PERF-001, CT-PERF-002 (20 MiB many/heavy, 3 runs each; the preview opened by a click, queries typed into the search field)$PY mods/chat-toc/tests/acceptance/perf_scenarios.py run 2.1.287 many --runs 3 (then heavy, current); the release report's tables: $PY mods/chat-toc/tests/acceptance/perf_scenarios.py table <run folder>.... The Current position and the entry table under 3000 stale on-screen records, and the search index and a keystroke's matching on 4432 turns, are measured in tests/long_session.test.ts (claude plugin test prints CT-PERF lines)
CT-COMPAT-001, CT-DATA-001–005, CT-TURN-001–005, CT-UI-001–003, CT-SEC-001$PY mods/chat-toc/tests/acceptance/gate_scenarios.py --host 2.1.287 [--only compat-001,data-002,forms,turn-004-live,ui-003,sec-001,...]
CT-COMPAT-002 (marketplace smoke)`$PY mods/chat-toc/tests/acceptance/ct_marketplace.py --host 2.1.287 --host current --source local\github (local is this repository's folder, run before a release; github is lkangd/cc-mods`, the install-level check after it)
Screens of the three Layouts (for the visual check).scratch/chat-toc/assets/25-layouts/capture.py (with check_screens.py, probe.py, remember.py, ring_check.py)
The whole release gate$PY mods/chat-toc/tests/acceptance/release_gate.py run --host 2.1.287 --gate <folder> --screens (the current version also takes --types <claude-code.d.ts>); the two versions can run in parallel. Then release_gate.py report --gate <folder> --out mods/chat-toc/tests/evidence/<version> --commit <commit under test> --screens writes the evidence folder

CT-TURN-004's in-progress state (进行中 · N 步) needs a turn actually running: gate_scenarios.py's turn-004-live uses fake_api.py (a local stand-in for the Messages API that answers from a script; ANTHROPIC_BASE_URL points at it) to run two Read steps and finish, with no model call. The fixture generator transcript_fixture.py has four kinds, with fixed seeds: forms (every row shape the spec names), turns, many (20 MiB, about 4500 turns), heavy (20 MiB, about 108 turns with large tool results).

The PTY worlds check the Chinese words (see Language). The English words are covered by tests/language.test.ts; in the PTY only ct_search.py widths and ct_reuse.py widths run in English.

cmux. The scenario scripts use pyte by default (headless, sized at will, colours readable). To watch a run in cmux, pty_harness.py smoke --terminal cmux opens a --focus false split in the current workspace and closes it at the end; it only touches the split it opened. Note:

  • A cmux split's width is not under control. Below 144 columns the pane does not dock by itself, so smoke uses /chat-toc instead (docks from 110 columns), which adds a group to the transcript.
  • The wheel and click sequences cmux sends are also taken by the host as Escape: each click may raise the rewind dialog, and wheeling the Pane during a turn interrupts it. Use pyte for whole-list comparisons.
  • The host draws dimColor as the current theme's inactive foreground (not SGR 2; the acceptance worlds use the dark theme, 999999), and a hovered entry is inverse. Move the pointer off before judging dimness in pyte.

Run records. Each run writes to mods/chat-toc/build/pty-runs/<time>-<label>/ (gitignored): run.jsonl has one line per scenario (scenario ID, Claude Code version, plugin, fixture, expected, actual, result, evidence, OS, architecture, time); screens/ holds the screen texts. A release copies only the chosen run folders into the evidence folder.

Evidence folder

  • tests/evidence/<plugin version>/, one folder per version, committed. A new version reruns the full MUST gate in a new folder; old folders are not changed. There is no CHANGELOG.
  • The folder holds three files:
  • report.md: for people; both Claude Code versions in one report, a column each, with the visual check's sign-off;
  • report.json: one record per scenario with the plugin version, Claude Code version, OS, architecture, fixture, scenario ID, expected, actual, result and evidence links, plus the hash of the commit under test;
  • marketplace.md: the local folder marketplace smoke (CT-COMPAT-002) and the GitHub install-level check after the release.
  • The scenario list and MUST levels follow .scratch/chat-toc/issues/06-grill-acceptance-contract.md §4. Any MUST that fails, is missing, is skipped or has no traceable evidence blocks the release.
  • Evidence holds synthetic prompts only. CT-SEC-001's unique marker must not appear in it.

Release checklist

Tick in order; stop at the first step that fails:

  1. ☐ version in plugin.json is the version to release; tests/evidence/<version>/ is created.
  2. ☐ On this macOS machine, run every MUST scenario once with --plugin-dir on 2.1.287 and on the current version of the day: no failures, nothing missing, nothing skipped; claude plugin test, validate on both versions and tsc on both versions are clean.
  3. ☐ Run the CT-COMPAT-002 smoke from a local folder marketplace on both versions (install, the Pane opens by itself, one click on a User input side jumps), and write it into marketplace.md.
  4. ☐ Check by eye how the three Layouts look and whether the highlight (the theme's suggestion colour) and the inverse selection cursor tell apart, and sign in report.md (does not block the release).
  5. ☐ The release commit: in one commit, the marketplace entry (.claude-plugin/marketplace.json: name/source/description/version/author, no category or tags), the root README's table row and chat-toc section (for a new version, update that row and the passing results under Development), and tests/evidence/<version>/. No git tag.
  6. ☐ After pushing, first confirm gh repo view lkangd/cc-mods --json defaultBranchRef -q .defaultBranchRef.name prints main (marketplace add lkangd/cc-mods reads the default branch; 0.1.0's first release was withdrawn because it was not main), then run an install-level check from lkangd/cc-mods: claude plugin marketplace add lkangd/cc-mods, claude plugin install chat-toc@cc-mods, the right version and enabled in claude plugin list, and the Pane opening by itself as a fullscreen session starts. Add the result to marketplace.md (this step is not a new release). A local folder marketplace runs the source folder as is; only the GitHub marketplace copies it to the cache, so this step cannot be skipped.
  7. ☐ If step 6 fails, withdraw the release commit.
Source 35 files
hooks/register.tsx 1719 lines
1/* chat-toc: a docked table of contents of the current session's
2   conversation. This module wires the host's events to the parts:
3     transcript.ts / chain.ts / groups.ts / regroup.ts  the file to Turn groups
4     compactions.ts                                     where each compaction's rule goes
5     source.ts / reader.ts / locate.ts                  how, when and where it is read
6     rewind.ts                                          what a rewind took, before the file says
7     view.ts / entries.ts / cells.ts / window.ts        what the pane draws
8     facts.ts / preview.ts                              the preview of one Turn group
9     reuse.ts                                           Reuse and copy from the preview
10     search.ts                                          which groups a query matches
11     pane.tsx / ring.ts                                 how it draws, the keyboard
12     sites.ts / jump.ts                                 where a press scrolls
13     walk.ts                                            the walk to the previous and next Turn group
14     position.ts                                        what the view is on
15     memo.ts                                            what a drawing keeps for the next
16   The host's `$` may only be handed to functions declared at the top of
17   this file, so every call on `$` is made here or in one of them. */
18
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
21import { entryKey } from './entries'
22import type { Entry } from './entries'
23import { compactionsOf, hasEarlierTurnsElsewhere, layRules } from './compactions'
24import type { Compaction, Listed } from './compactions'
25import { LIST_BUDGET, TOP_CONTROL, desktopList, drawDesktopPane } from './desktop'
26import type { ListMove } from './desktop'
27import { endOfGroup, groupsIn, spanEndingFilled, spanStarting } from './span'
28import { BAR_ROWS, barKey, barLabels, barOfGroup, barTarget, barsOf } from './bars'
29import { stripLabels } from './minimap'
30import { groupIndexOf } from './groups'
31import type { TurnGroup } from './groups'
32import { newMemo, remember } from './memo'
33import {
34  FLASHES,
35  FLASH_MS,
36  REFUSED_MS,
37  isUnscrollable,
38  compactedSides,
39  dimRefused,
40  jumpTargetsOf,
41  lastBoundary,
42  refusalFrame,
43  relisted,
44  sideKeyOf,
45} from './jump'
46import type { Refused } from './jump'
47import { carriesHistory, hidesPane, isClosedByPerson, reopensOn } from './lifecycle'
48import { CLEAR_CONTROL, PANE_COLUMNS, PANE_ID, PANE_TITLE, SEARCH_CONTROL, SEARCH_FIELD, SEARCH_ROWS, claimsKeys, drawPane, FOOT_ROWS, HEAD_ROWS } from './pane'
49import type { ExpandedView, LabelActions, PreviewView, SearchView } from './pane'
50import { PREVIEW_CONTROL, expandedBody, filesLine, metaLine, previewBody, previewPlan, typedInput } from './preview'
51import type { ExpandedBody, PathBase, PreviewBody } from './preview'
52import { FILTER_END, LAYOUT_CONTROL, entryAt, pendingShown, ringMove } from './ring'
53import { COPIED_MS, fillOf, fillRefusedText, hasReuse, isAtLeast, reusableOf } from './reuse'
54import type { CopySide } from './reuse'
55import { indexGroups, matchShown, matchedFields, matchesOf, nearestMatch, newSearchIndex, queryWords } from './search'
56import type { Match } from './search'
57import { derivedTranscriptPath } from './locate'
58import { listedGroups, walkFrom } from './walk'
59import type { WalkDirection } from './walk'
60import { languageOf, setLanguage, text as words } from './text'
61import type { BandCounts } from './text'
62import { BAND_HIDDEN_KEY, DISMISS_RIGHT_SINCE, TITLE_PRESS_SINCE, drawBand } from './band'
63import { rewoundFrom } from './rewind'
64import { FILTER_KEY, LAYOUT_KEY, filterOf, jumpSide, layoutOf, nextLayout } from './view'
65import type { Layout, ViewFilter } from './view'
66import { PERF_FLUSH_MS, newPerf, note, perfNow, takeLines } from './perf'
67import type { PerfMetric } from './perf'
68import { LATE_CHECK_MS, TRAILING_MS, gateFinish, gateFired, gateRequest, gateStart, readGate } from './reader'
69import { countUnreadable, newReading, readLines } from './regroup'
70import type { Reading } from './regroup'
71import { WHOLE_READ_LIMIT, chunkBytesFor, chunkCommand, chunkText, failReasonOf, readChunks, readCursor, takeWhole } from './source'
72import type { Chunks, FailReason, ReadCursor, SourceRead } from './source'
73import { SITE_COMPONENTS, newSites, noteSite, resolveSites, rowIndex, syncRows } from './sites'
74import type { OnScreen, RowIndex, SiteComponent, Sites } from './sites'
75import {
76  LOCK_QUIET_MS,
77  addDrawable,
78  drawableRows,
79  lastDrawnRow,
80  liveRows,
81  lockOn,
82  openerRows,
83  positionOf,
84  quietAfter,
85  siteReport,
86} from './position'
87import type { JumpLock } from './position'
88import { entryOfGroup, entryTable, narrowedTable, newEntryCache } from './table'
89import type { EntryTable } from './table'
90import { scrolledTop, topOfDrawing, topRevealing, windowOf } from './window'
91import type { SwitchAnchor, TocWindow } from './window'
92
93const transcriptPath = atom({ plugin: 'chat-toc', key: 'transcriptPath' } as const, '')
94const rev = atom({ plugin: 'chat-toc', key: 'rev' } as const, 0)
95const isClosed = atom({ plugin: 'chat-toc', key: 'isClosed' } as const, false)
96const isCarriedOver = atom({ plugin: 'chat-toc', key: 'isCarriedOver' } as const, false)
97const isPaneHidden = atom({ plugin: 'chat-toc', key: 'isPaneHidden' } as const, false)
98const currentPosition = atom({ plugin: 'chat-toc', key: 'currentPosition' } as const, null as number | null)
99const compactedAt = atom({ plugin: 'chat-toc', key: 'compactedAt' } as const, null as string | null)
100/* The search field's text: conversation text, so `$.state` only, never
101   `$.store`; it lasts the process, across closing the pane. */
102const query = atom({ plugin: 'chat-toc', key: 'query' } as const, '')
103
104type SourceState = 'unknown' | 'missing' | 'ok' | 'failed'
105
106/* Why nothing is listed, kept at the bottom of the pane while it holds. */
107const failureText = (reason: FailReason): string => (reason === 'denied' ? words.denied : words.readError)
108
109/* What the reads so far made of the file. Module state: a hot reload starts
110   it over, and `session.start` then reads again. */
111let reading: Reading = newReading()
112let cursor: ReadCursor = readCursor()
113let source: SourceState = 'unknown'
114let failure: FailReason = 'error'
115/* No path is known and none could be worked out: the pane says the next
116   submit brings one. */
117let isUnlocated = false
118/* The file's size at the last read; undefined reads it from the start. */
119let lastSize: number | undefined
120/* Whether the main agent's turn is under way: from `turn.start` until the
121   read after its `turn.complete`, so the last group shows in progress until
122   the file's account of the turn is read. A hot reload in mid-turn forgets
123   it until the next turn starts. */
124let isTurnRunning = false
125let isTurnEnding = false
126const gate = readGate()
127/* The window's top entry; undefined shows the newest. While following it
128   is wherever keeps the lit group in view. */
129let top: number | undefined
130/* On the desktop, where the host scrolls the whole tree (span.ts): the
131   last Turn group the drawn part of the list holds; undefined draws the
132   newest. */
133let drawnUpTo: number | undefined
134/* The surface the pane was last drawn on: `ui.scroll` does not say it. */
135let paneSurface: RenderSurface = 'terminal'
136/* Whether the host answered a jump that it cannot scroll its transcript
137   at all (Claude Code Desktop): a press then jumps no more, and says
138   nothing (0.2 spec §8). */
139let isTranscriptUnscrollable = false
140/* The terminal's selected entry (0.2 spec §3): the one last pressed, or the
141   one the focus ring last landed on; the row of Buttons under it opens the
142   preview. Undefined selects none. */
143let selectedKey: string | undefined
144/* The first drawn entry of the group the preview holds, where the focus
145   goes when its ✕ takes itself out of the tree; undefined when none is
146   drawn. */
147let previewEntry: string | undefined
148/* Following stops while the person scrolls the pane or it has the
149   keyboard, and starts again when the Current position next moves. */
150let isPaused = false
151/* The Current position the window last followed, and the one drawn last. */
152let positionFollowed: number | undefined
153let positionDrawn: number | undefined
154/* The opens chat-toc makes on its own, one after another, so two start
155   events at once (a process started with --resume raises both) find the
156   first one's pane and open one. */
157let opening: Promise<void> = Promise.resolve()
158
159/* The person's choices, read from `$.store` at each `session.start`. */
160let filter: ViewFilter = 'all'
161let layout: Layout = 'card'
162/* What the preview shows paths against: the session's working directory
163   and the home directory, from `session.start`. */
164let pathBase: PathBase = { cwd: undefined, home: undefined }
165/* The group the preview holds while it is open, by its opener's uuid,
166   which a regroup keeps: the entry pressed last, or the match a search's
167   submit jumped to. Neither the Current position, new turns, the View
168   filter nor the query change it; undefined is closed. In memory only: it
169   closes whenever the pane opens and when its group leaves the list. */
170let previewOpener: string | undefined
171/* Whether the person expanded the open preview over the list; closing the
172   pane or the keys leaving it return it to the region. One opened without
173   room for the region is drawn expanded anyway. */
174let isExpanded = false
175/* Whether the last drawing drew the expanded preview: the host then
176   scrolls the tree and moves the ring itself. */
177let isExpandedDrawn = false
178/* The host's release, from `$.session.version()` at `session.start`. */
179let hostBase: string | undefined
180/* The copy Button reading `Copied`: the group's opener, which Button, and
181   which copy, so only its own timer takes it back. */
182let copied: { opener: string; which: CopySide; n: number } | undefined
183let copies = 0
184
185/* What the pane drew last, for the keyboard and the next switch: the
186   entries, their keys and the table back, their heights, the rows the
187   window had, and the window. */
188type Drawing = {
189  entries: readonly Entry[]
190  keys: readonly string[]
191  indexOf: ReadonlyMap<string, number>
192  heights: readonly number[]
193  /* The heights with entry `selected` selected: its row of Buttons takes
194     a row more while the terminal's preview is closed. */
195  heightsWith: (selected: number | undefined) => readonly number[]
196  rows: number
197  window: TocWindow
198}
199let lastDrawing: Drawing = { entries: [], keys: [], indexOf: new Map(), heights: [], heightsWith: () => [], rows: 1, window: { start: 0, end: 0, maxStart: 0 } }
200/* The Button the host's focus ring is on while the pane has the keyboard,
201   as its moves and chat-toc's own sends left it. */
202let ring: string | undefined
203let isPaneFocused = false
204/* The desktop's tree as last drawn, and what it was drawn from. */
205let desktopDrawn: { from: string; tree: RenderElement } | undefined
206/* The first control the last drawing put after the entries, and the last
207   one before them. */
208let afterList = LAYOUT_CONTROL
209let beforeList = FILTER_END
210/* An entry the ring goes to once a drawing shows it: after a switch, or
211   after the window moved to bring the next entry in. That drawing sends
212   it, and sends it again a few times while the host refuses it (its new
213   frame not yet in), unless the ring moved on meanwhile. */
214let pendingRing: string | undefined
215const RING_RETRIES = 3
216const RING_RETRY_MS = 50
217/* A sent move is sent once more from a later drawing: across a redraw
218   that changes its Buttons the host puts the ring back on the same place
219   in its order once the new frame is in, at times after the send landed
220   (2.1.287, a switch to the timeline Layout). With no drawing within this
221   long, the pane asks for one. */
222let ringOwed: string | undefined
223const RING_CONFIRM_MS = 150
224/* The group a switch of filter or Layout keeps in view, resolved by the
225   next drawing. */
226let anchor: SwitchAnchor | undefined
227/* Said at the bottom while the window does not follow the Current position. */
228
229/* The chain rows by what the host draws them under, and what it drew. */
230let chainRows: RowIndex = rowIndex([])
231let sites: Sites = newSites()
232/* Sides whose every jump target the host refused, or known compacted
233   before a resume, drawn dim until it draws one of them again. */
234let refused: Refused = new Map()
235/* Whether the compacted sides are in `refused` for what was read. */
236let isCompactedMarked = false
237/* The refusal said at the bottom: the entry pressed, which saying it is
238   (its timers act only on their own), what it says, and when it began. */
239let refusal: { key: string; n: number; text: string; at: number } | undefined
240let refusals = 0
241let isRedrawQueued = false
242/* Whether the band's last drawing was chat-toc's: the host does not draw
243   the band again when the pane comes to the front by a switch of tabs, so
244   the pane's drawing asks for it. */
245let isBandDrawn = false
246/* Whether the person hid the band with its ✕, read from `$.store` at each
247   `session.start`; `/chat-toc-band` shows it again. */
248let isBandHidden = false
249
250/* For the Current position (position.ts): the drawable rows' and the group
251   openers' places on the chain, the last row drawn, the group a jump
252   locked it on, and the position as last worked out. */
253type Positioning = {
254  drawable: number[]
255  openers: number[]
256  lastDrawn: number | undefined
257  lock: JumpLock | undefined
258  position: number | undefined
259}
260const newPositioning = (): Positioning => ({ drawable: [], openers: [], lastDrawn: undefined, lock: undefined, position: undefined })
261let placing: Positioning = newPositioning()
262let isPositionQueued = false
263
264/* A rewind's turns, gone from the host's messages but still in the file
265   until it grows (rewind.ts): the opener of the first group not listed.
266   The turn count as last read, which only a rewind (or a compaction)
267   makes smaller. */
268let rewound: string | undefined
269let lastTurns: number | undefined
270let isRewindCheckQueued = false
271let isRewindRecheckQueued = false
272/* A rewind's check runs as the hint line is drawn and again this long
273   after, should the host's messages not be cut yet. */
274const REWIND_RECHECK_MS = 300
275const shownMemo = newMemo<readonly TurnGroup[]>()
276
277/* The groups the pane lists: the file's, short of a rewind's. */
278function shownGroups(): readonly TurnGroup[] {
279  const groups = reading.groups
280  const opener = rewound
281  if (opener === undefined) return groups
282  return remember(shownMemo, [groups, opener], () => {
283    const at = groupIndexOf(groups, opener)
284    return at === -1 ? groups : groups.slice(0, at)
285  })
286}
287
288/* Whether the last of `groups` is under way: only while they are the
289   file's whole list, not one a rewind cut. */
290const isRunningIn = (groups: readonly TurnGroup[]) => isTurnRunning && groups === reading.groups
291
292/* The chain's compactions as last read, whether earlier turns are not in
293   the file, and the last boundary a read found. */
294let compactions: Compaction[] = []
295let isEarlierElsewhere = false
296let lastCompaction: string | undefined
297/* The context use, a whole percent of the window, as the host last gave
298   it; undefined from a compaction until a measure brings one. */
299let contextPercent: number | undefined
300
301/* The entries kept between drawings (table.ts), and the list with the
302   compactions' rules laid on, kept while neither changes. */
303let entryCache = newEntryCache()
304let ruled = newMemo<Listed>()
305
306/* Each group's searchable text (search.ts), built as reads regroup; the
307   matches of the query last drawn; the list narrowed to them; the query
308   the last drawing showed. */
309const searchIndex = newSearchIndex()
310type Searched = { matches: Match[]; groupsMatched: number[] }
311const searched = newMemo<Searched>()
312const narrowed = newMemo<EntryTable>()
313let queryDrawn = ''
314
315function matchesNow(groups: readonly TurnGroup[], words: readonly string[], isRunning: boolean): Searched {
316  return remember(searched, [groups, words.join(' '), filter, isRunning], () => {
317    const matches = matchesOf(searchIndex, groups, words, filter, isRunning)
318    return { matches, groupsMatched: matches.map(m => m.group) }
319  })
320}
321
322/* The groups the query `value` matches among those listed now, in order. */
323function matchedGroups(value: string): number[] {
324  const groups = shownGroups()
325  const words = queryWords(value)
326  return words.length === 0 ? [] : matchesNow(groups, words, isRunningIn(groups)).groupsMatched
327}
328
329/* What a read found of the chain's compactions. A new boundary no response
330   has followed yet hides the context use, as `session.compact` does: the
331   host's figure is from before it. */
332function noteCompactions(): void {
333  compactions = compactionsOf(reading.chain, reading.groups)
334  isEarlierElsewhere = hasEarlierTurnsElsewhere(reading.chain, reading.groups)
335  const last = compactions[compactions.length - 1]
336  if (last !== undefined && last.uuid !== lastCompaction && !last.isAnswered) contextPercent = undefined
337  lastCompaction = last?.uuid
338}
339const NO_ENTRIES: EntryTable = { entries: [], heights: [], keys: [], indexOf: new Map(), firstOfGroup: [] }
340
341/* The long-session timings (perf.ts): on when the host runs with
342   CHAT_TOC_PERF set, asked once per module load; the first main-loop row
343   appended since the last read; whether the first listing was timed. */
344const perf = newPerf()
345const loadedAt = perfNow()
346let isPerfAsked = false
347let isLanguageAsked = false
348let appendedAt: number | undefined
349let isListedNoted = false
350/* The earliest change of the search field no drawing has taken yet. */
351let typedAt: number | undefined
352
353function startOver(): void {
354  reading = newReading()
355  cursor = readCursor()
356  lastSize = undefined
357}
358
359function forget(): void {
360  startOver()
361  source = 'unknown'
362  top = undefined
363  drawnUpTo = undefined
364  selectedKey = undefined
365  anchor = undefined
366  chainRows = rowIndex([])
367  sites = newSites()
368  refused = new Map()
369  rewound = undefined
370  isCompactedMarked = false
371  refusal = undefined
372  placing = newPositioning()
373  entryCache = newEntryCache()
374  compactions = []
375  isEarlierElsewhere = false
376  ruled = newMemo()
377  closePreviewNow()
378}
379
380/* The preview closed, expanded or not. */
381function closePreviewNow(): void {
382  previewOpener = undefined
383  isExpanded = false
384  /* Closed with the pane, it changes no rev: the desktop's tree goes too. */
385  desktopDrawn = undefined
386}
387
388/* The context use the host has now: at the start, after a hot reload,
389   which starts the module over, and at /clear and /resume. */
390async function readContextUse($: EngineInterface): Promise<void> {
391  const usage = await $.session.usage().catch(() => undefined)
392  contextPercent = usage?.context.percent
393}
394
395/* The host's release, which says whether the desktop offers Reuse
396   (reuse.ts); undefined while not known. */
397async function askVersion($: EngineInterface): Promise<void> {
398  const version = await $.session.version().catch(() => undefined)
399  hostBase = version?.base
400}
401
402/* The system's language, from the locale variables (text.ts). Asked once a
403   load, at the first start event, before any word is drawn or registered. */
404async function askLanguage($: EngineInterface): Promise<void> {
405  if (isLanguageAsked) return
406  isLanguageAsked = true
407  const read = (value: Promise<string | undefined>) => value.catch(() => undefined)
408  const [LC_ALL, LC_MESSAGES, LANG] = await Promise.all([read($.env.get('LC_ALL')), read($.env.get('LC_MESSAGES')), read($.env.get('LANG'))])
409  setLanguage(languageOf({ LC_ALL, LC_MESSAGES, LANG }))
410}
411
412async function askPerf($: EngineInterface): Promise<void> {
413  if (isPerfAsked) return
414  isPerfAsked = true
415  perf.isOn = Boolean(await $.env.get('CHAT_TOC_PERF').catch(() => undefined))
416}
417
418/* Keeps a timing sample; a second later debug log lines take all the
419   samples gathered meanwhile. */
420function timed($: EngineInterface, metric: PerfMetric, values: readonly number[]): void {
421  if (!note(perf, metric, values) || perf.isFlushQueued) return
422  perf.isFlushQueued = true
423  $.clock.after(PERF_FLUSH_MS, () => {
424    perf.isFlushQueued = false
425    for (const line of takeLines(perf)) $.ui.log(line, { to: 'debug' })
426  })
427}
428
429/* Redraws the pane once the drawing under way is done: a render hook may
430   not write `$.state` itself. */
431function redrawSoon($: EngineInterface): void {
432  if (isRedrawQueued) return
433  isRedrawQueued = true
434  $.clock.after(0, () => {
435    isRedrawQueued = false
436    void update($, rev, n => n + 1).catch(() => undefined)
437  })
438}
439
440/* Works the Current position out again; when it moved, the pane hears of
441   it once the drawing under way is done (a render hook may not write
442   `$.state`). */
443function place($: EngineInterface): void {
444  const began = perf.isOn ? perfNow() : 0
445  const position = positionOf({
446    live: liveRows(sites, chainRows),
447    drawable: placing.drawable,
448    openers: placing.openers,
449    lastDrawn: placing.lastDrawn,
450    groups: shownGroups().length,
451    locked: placing.lock?.group,
452  })
453  if (perf.isOn) timed($, 'position', [perfNow() - began, sites.onScreen.size, placing.drawable.length])
454  if (position === placing.position) return
455  placing.position = position
456  if (isPositionQueued) return
457  isPositionQueued = true
458  $.clock.after(0, () => {
459    isPositionQueued = false
460    void showPosition($).catch(() => undefined)
461  })
462}
463
464/* The pane reads the position from `$.state`. A tab behind another pane is
465   not drawn: a move while it is there ends the pause, so the first frame
466   back follows. */
467async function showPosition($: EngineInterface): Promise<void> {
468  const value = placing.position ?? null
469  if (value === (await read($, currentPosition))) return
470  await update($, currentPosition, () => value)
471  if (!isPaused) return
472  const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
473  if (pane !== undefined && !pane.isShown) isPaused = false
474}
475
476/* A jump landed: the position holds its group until the view changes
477   after the jump's own redraws. */
478function lockAfterJump($: EngineInterface, group: number): void {
479  const held = lockOn(group)
480  placing.lock = held
481  $.clock.after(LOCK_QUIET_MS, () => quietAfter(held, 0))
482  place($)
483}
484
485/* The transcript drew a site: remembered with where it is on screen; a row
486   drawn again brings back the sides refused for want of it; where the view
487   is is worked out again. */
488function sawSite($: EngineInterface, component: SiteComponent, requestId: string, onScreen: OnScreen): void {
489  const was = sites.onScreen.get(`${component}|${requestId}`)?.onScreen
490  const drawnBefore = sites.idOf.size
491  const uuid = noteSite(sites, chainRows, component, requestId, onScreen)
492  if (uuid !== undefined && refused.size > 0 && relisted(refused, uuid, reading.groups, chainRows, sites)) redrawSoon($)
493  const order = uuid === undefined ? undefined : chainRows.order.get(uuid)
494  if (order !== undefined && sites.idOf.size !== drawnBefore) {
495    addDrawable(placing.drawable, order)
496    if (placing.lastDrawn === undefined || order > placing.lastDrawn) placing.lastDrawn = order
497  }
498  const held = placing.lock
499  const report = siteReport(held, was, onScreen)
500  placing.lock = report.lock
501  const change = report.quietAfter
502  if (held !== undefined && change !== undefined) $.clock.after(LOCK_QUIET_MS, () => quietAfter(held, change))
503  if (report.isPlaced) place($)
504}
505
506/* One chunk of the file at `offset`, as text. */
507async function runChunk($: EngineInterface, path: string, offset: number): Promise<string> {
508  const command = chunkCommand(path, offset)
509  const ran = await $.process.run(command.argv, command.env === undefined ? undefined : { env: command.env })
510  if (ran.exitCode !== 0) throw new Error(ran.stderr)
511  return chunkText(path, ran.stdout)
512}
513
514/* Reads what is new in the file since the last read, onto `at`: the whole
515   file whole when it is read from the start and fits one `$.fs.read`, else
516   chunks from the offset. */
517async function readSource($: EngineInterface, path: string, at: ReadCursor): Promise<SourceRead> {
518  let size: number
519  try {
520    size = (await $.fs.stat(path)).size
521  } catch (error) {
522    try {
523      return (await $.fs.exists(path)) ? { kind: 'failed', reason: failReasonOf(error) } : { kind: 'missing' }
524    } catch {
525      return { kind: 'failed', reason: 'error' }
526    }
527  }
528  if (size === lastSize) return { kind: 'unchanged' }
529  const isFromStart = lastSize === undefined || size < lastSize || size < at.offset
530  if (isFromStart) Object.assign(at, readCursor())
531  try {
532    let chunks: Chunks | undefined
533    if (isFromStart && size <= WHOLE_READ_LIMIT) {
534      try {
535        chunks = takeWhole(at, await $.fs.read(path))
536      } catch {
537        /* Grown past what `$.fs.read` takes since the stat, or refused:
538           the chunks say which. */
539      }
540    }
541    chunks ??= await readChunks(at, size, chunkBytesFor(path), offset => runChunk($, path, offset))
542    return { kind: 'lines', chunks, isFromStart, size }
543  } catch (error) {
544    return { kind: 'failed', reason: failReasonOf(error) }
545  }
546}
547
548async function load($: EngineInterface): Promise<void> {
549  const path = await read($, transcriptPath)
550  if (!path) return
551  const began = perfNow()
552  const asked = appendedAt
553  appendedAt = undefined
554  const at = { ...cursor }
555  const got = await readSource($, path, at)
556  /* A classic event moved the session to another file meanwhile: what was
557     read is not its, and the read it asked for runs after this one. */
558  if (path !== (await read($, transcriptPath))) return
559  const sourced = perfNow()
560  if (got.kind === 'unchanged') {
561    appendedAt ??= asked
562    if (await markCompacted($)) await update($, rev, n => n + 1)
563    return
564  }
565  if (got.kind === 'lines') {
566    if (got.isFromStart) {
567      reading = newReading()
568      isCompactedMarked = false
569    }
570    for (const lines of got.chunks.lines) readLines(reading, lines)
571    countUnreadable(reading, got.chunks.overlong)
572    const indexing = perfNow()
573    const built = indexGroups(searchIndex, reading.groups)
574    if (perf.isOn) timed($, 'index', [perfNow() - indexing, built, reading.groups.length])
575    resolveSites(sites, chainRows, syncRows(chainRows, reading.chain))
576    noteCompactions()
577    cursor = at
578    lastSize = got.size
579    source = 'ok'
580    /* The file grew: it says again what the conversation holds. */
581    rewound = undefined
582    await markCompacted($)
583  } else {
584    startOver()
585    compactions = []
586    isEarlierElsewhere = false
587    source = got.kind
588    if (got.kind === 'failed') failure = got.reason
589  }
590  /* The rows a read brings, and a hot reload's sites drawn before it, only
591     place the view once the rows are known. */
592  placing.openers = openerRows(reading.groups, chainRows)
593  placing.drawable = drawableRows(chainRows, sites)
594  placing.lastDrawn = lastDrawnRow(chainRows, sites)
595  place($)
596  if (perf.isOn && got.kind === 'lines') {
597    const ended = perfNow()
598    const full = reading.lastRegroup === 'full' ? 1 : 0
599    timed($, got.isFromStart ? 'read.whole' : 'read.tail', [ended - began, sourced - began, got.chunks.lines.length, reading.groups.length, reading.chain.length, got.size, full])
600    if (asked !== undefined) timed($, 'append.model', [ended - asked])
601  }
602  await update($, rev, n => n + 1)
603}
604
605/* In a session this process resumed, the sides the host refuses for sure
606   are dimmed: the first read after the resume keeps the last compaction the
607   file had, and every read of the file from the start marks its sides
608   again. Whether it marked them now. */
609async function markCompacted($: EngineInterface): Promise<boolean> {
610  if (source !== 'ok') return false
611  let boundary = await read($, compactedAt)
612  if (boundary === '') {
613    const found = lastBoundary(reading.chain) ?? null
614    boundary = found
615    await update($, compactedAt, () => found)
616    isCompactedMarked = false
617  }
618  if (boundary === null || isCompactedMarked) return false
619  isCompactedMarked = true
620  for (const [key, side] of compactedSides(reading.groups, chainRows, sites, boundary)) if (!refused.has(key)) refused.set(key, side)
621  return true
622}
623
624/* The turn count dropped: the host's messages say which groups a rewind
625   took; messages that do not line up with the file leave the list be. */
626async function checkRewind($: EngineInterface): Promise<void> {
627  const turns = await $.session.turns()
628  const was = lastTurns
629  lastTurns = turns
630  if (was === undefined || turns >= was || source !== 'ok') return
631  const found = rewoundFrom(reading.groups, reading.chain, await $.session.messages())
632  if (found.kind === 'unknown') return
633  const opener = found.kind === 'all' ? undefined : reading.groups[found.group]?.openerUuid
634  if (opener === rewound) return
635  rewound = opener
636  place($)
637  await update($, rev, n => n + 1)
638}
639
640/* Asked by every drawing of the hint line, which a rewind's restore makes;
641   a drawing may not write `$.state`, so it runs after. */
642function checkRewindSoon($: EngineInterface): void {
643  if (!isRewindCheckQueued) {
644    isRewindCheckQueued = true
645    $.clock.after(0, () => {
646      isRewindCheckQueued = false
647      void checkRewind($).catch(() => undefined)
648    })
649  }
650  if (!isRewindRecheckQueued) {
651    isRewindRecheckQueued = true
652    $.clock.after(REWIND_RECHECK_MS, () => {
653      isRewindRecheckQueued = false
654      void checkRewind($).catch(() => undefined)
655    })
656  }
657}
658
659function readNow($: EngineInterface): void {
660  if (!gateStart(gate)) return
661  void load($)
662    .catch(() => undefined)
663    .finally(() => {
664      if (gateFinish(gate)) readNow($)
665      else if (isTurnEnding) endTurn($)
666    })
667    /* Nothing of a failure is said here: its text may quote the file. */
668    .catch(() => undefined)
669}
670
671function endTurn($: EngineInterface): void {
672  isTurnRunning = false
673  isTurnEnding = false
674  void update($, rev, n => n + 1).catch(() => undefined)
675}
676
677function requestRead($: EngineInterface): void {
678  if (!gateRequest(gate)) return
679  $.clock.after(TRAILING_MS, () => {
680    gateFired(gate)
681    readNow($)
682  })
683}
684
685/* A classic event's `transcript_path`: one unlike the path in use (worked
686   out, or another conversation's) replaces it and the file is read again
687   from the start. Whether it did. */
688async function adoptPath($: EngineInterface, path: string | undefined): Promise<boolean> {
689  if (!path || path === (await read($, transcriptPath))) return false
690  await update($, transcriptPath, () => path)
691  isUnlocated = false
692  forget()
693  return true
694}
695
696/* `session.start` with no path known, as when the plugin is enabled in
697   mid-session: the path is worked out (locate.ts). A classic event's path
698   that came meanwhile is kept. */
699async function locate($: EngineInterface, cwd: string): Promise<void> {
700  if (await read($, transcriptPath)) return
701  const sessionId = await $.session.id().catch(() => '')
702  const configDir = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
703  const home = await $.env.get('HOME').catch(() => undefined)
704  const userProfile = await $.env.get('USERPROFILE').catch(() => undefined)
705  const path = derivedTranscriptPath({ sessionId, cwd, configDir, home, userProfile })
706  if (path !== undefined) {
707    await update($, transcriptPath, known => known || path)
708    return
709  }
710  if (await read($, transcriptPath)) return
711  isUnlocated = true
712  await update($, rev, n => n + 1)
713}
714
715function openPane($: EngineInterface, focus?: true) {
716  return $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS, ...(focus === undefined ? {} : { focus }) })
717}
718
719/* The band's ✕ hides it here and in every later session, saying how to
720   bring it back; `/chat-toc-band` shows it. A store that refuses it only
721   means it is not remembered. */
722async function setBandHidden($: EngineInterface, isHidden: boolean): Promise<void> {
723  isBandHidden = isHidden
724  await update($, rev, n => n + 1)
725  if (isHidden) $.ui.toast(words.bandHidden)
726  await $.store.set(BAND_HIDDEN_KEY, isHidden).catch(() => undefined)
727}
728
729/* The keys asked for the open pane, as /chat-toc asks: the host grants them
730   only over an empty composer with no dialog up, else they stay put. Says
731   whether they were asked for. */
732async function claimKeys($: EngineInterface): Promise<boolean> {
733  if (isPaneFocused) return false
734  const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
735  if (pane === undefined || !pane.isShown || pane.isFocused) return false
736  await openPane($, true).catch(() => undefined)
737  return true
738}
739
740/* Opens the pane unasked, unless the person closed it in this session or it
741   is open already, as it stays across a hot reload. Where and whether it is
742   drawn is the host's call: it is kept however the host places it. While it
743   waits undrawn the band above the prompt offers it, as after a close. */
744async function openUnasked($: EngineInterface): Promise<void> {
745  if (await read($, isClosed)) return
746  const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
747  const isPlaced = pane === undefined ? (await openPane($)).isPlaced : pane.isPlaced
748  /* One kept across a hot reload takes the title this release gives it. */
749  if (pane !== undefined && pane.title !== PANE_TITLE) await openPane($).catch(() => undefined)
750  await update($, isPaneHidden, () => !isPlaced)
751}
752
753function autoOpen($: EngineInterface): Promise<void> {
754  opening = opening.then(() => openUnasked($)).catch(() => undefined)
755  return opening
756}
757
758/* /chat-toc: opens the pane, or raises the open one, shown or a tab behind
759   another, and asks for the keyboard either way: the person asked for it,
760   unlike an open of chat-toc's own. It never closes it. */
761async function openAsked($: EngineInterface): Promise<void> {
762  await update($, isClosed, () => false)
763  const opened = await openPane($, true)
764  if (opened.isPlaced) await update($, isPaneHidden, () => false)
765}
766
767/* The band's counts, once the file is read: the listed turns and the
768   agent's steps across them. */
769function bandCounts(): BandCounts | undefined {
770  if (source !== 'ok') return undefined
771  const groups = shownGroups()
772  return { turns: groups.length, steps: groups.reduce((sum, g) => sum + g.steps, 0), percent: contextPercent }
773}
774
775/* Only ever called from a press: the host moves the transcript only for
776   the person's own input. Tries the side's targets in order; when the host
777   refuses them all, says so at the bottom for a while (or, pressed while
778   the pane is not on screen, as a toast) and dims the side. */
779async function jump($: EngineInterface, entry: Pick<Entry, 'groupIndex' | 'side' | 'key'>, isPaneOnScreen = true): Promise<void> {
780  const group = reading.groups[entry.groupIndex]
781  if (!group) return
782  const key = sideKeyOf(group, entry.side)
783  if (isTranscriptUnscrollable) return
784  const wasShown = refusal !== undefined || refused.has(key)
785  const why = refused.get(key)?.why ?? 'refused'
786  refusal = undefined
787  for (const target of jumpTargetsOf(group, entry.side, chainRows, sites)) {
788    const got = await $.ui
789      .scroll({ to: { requestId: target.requestId }, block: target.block })
790      .catch((error: unknown) => ({ deny: String(error) }))
791    if (got.deny === undefined) {
792      refused.delete(key)
793      lockAfterJump($, entry.groupIndex)
794      if (wasShown) await update($, rev, n => n + 1)
795      return
796    }
797    if (isUnscrollable(got.deny)) {
798      isTranscriptUnscrollable = true
799      if (wasShown) await update($, rev, n => n + 1)
800      return
801    }
802  }
803  refused.set(key, { side: entry.side, opener: group.openerUuid, groupIndex: entry.groupIndex, why })
804  await say($, entry.key, why === 'compacted' ? words.compacted : words.refused, isPaneOnScreen)
805}
806
807/* A refusal said at the bottom of the pane, or as a toast while the pane
808   is not on screen to say it. */
809async function say($: EngineInterface, key: string, text: string, isPaneOnScreen: boolean): Promise<void> {
810  if (isPaneOnScreen) return sayRefusal($, key, text)
811  $.ui.toast(text)
812  await update($, rev, n => n + 1)
813}
814
815/* ‹ or ›, pressed by a click or by a key the person bound to its action:
816   the transcript walks from the Current position (not the selection) to
817   the Turn group before or after it among those the list shows, the View
818   filter's and, during a search, the matches' (walk.ts), on the side the
819   View filter shows, without ending the search. The jump locks the Current
820   position on its group, so presses walk group by group. Nothing at either
821   end; groups that way but none with a Jump target say the click's notice.
822   The press hands this promise back: the scroll is the person's. */
823async function walkTurn($: EngineInterface, direction: WalkDirection, isPaneOnScreen: boolean): Promise<void> {
824  if (source !== 'ok') return
825  const groups = shownGroups()
826  const isRunning = isRunningIn(groups)
827  const searchWords = queryWords(await read($, query))
828  const matches = searchWords.length === 0 ? undefined : matchesNow(groups, searchWords, isRunning).groupsMatched
829  const shown = listedGroups(groups, filter, isRunning ? groups.length - 1 : undefined, matches)
830  const side = jumpSide(filter)
831  const hasTarget = (g: number) => {
832    const group = groups[g]
833    return group !== undefined && !refused.has(sideKeyOf(group, side)) && jumpTargetsOf(group, side, chainRows, sites).length > 0
834  }
835  const to = walkFrom(shown, placing.position, direction, hasTarget)
836  if (to.kind === 'jump') return jump($, { groupIndex: to.group, side, key: entryKey(to.group, side) }, isPaneOnScreen)
837  if (to.kind === 'unjumpable') return say($, `walk.${direction}`, words.refused, isPaneOnScreen)
838}
839
840/* Says below the entries why a press did not jump, flashing, for a while. */
841async function sayRefusal($: EngineInterface, key: string, text: string): Promise<void> {
842  const n = ++refusals
843  refusal = { key, n, text, at: await $.clock.now() }
844  /* Each step of the flash is drawn. */
845  for (let step = 1; step <= FLASHES * 2; step++) {
846    $.clock.after(step * FLASH_MS, () => {
847      if (refusal?.n === n) void update($, rev, x => x + 1).catch(() => undefined)
848    })
849  }
850  $.clock.after(REFUSED_MS, () => {
851    if (refusal?.n !== n) return
852    refusal = undefined
853    void update($, rev, x => x + 1).catch(() => undefined)
854  })
855  await update($, rev, x => x + 1)
856}
857
858/* Whether a Button key is one of an entry's lines. */
859const isLineOf = (element: string | undefined, key: string) => element === key || (element?.startsWith(`${key}.`) ?? false)
860
861/* The selection moving off the entry a refusal is about takes the refusal
862   away. */
863function selectionMoved($: EngineInterface, element: string | undefined): void {
864  if (element !== undefined && lastDrawing.indexOf.has(element) && element !== selectedKey) select($, element)
865  if (refusal === undefined || isLineOf(element, refusal.key)) return
866  refusal = undefined
867  redrawSoon($)
868}
869
870/* The filter and Layout the person chose last; a store that cannot be
871   read, or a value not known, reads as the default. */
872async function loadChoices($: EngineInterface): Promise<void> {
873  filter = filterOf(await $.store.get(FILTER_KEY).catch(() => undefined))
874  layout = layoutOf(await $.store.get(LAYOUT_KEY).catch(() => undefined))
875  isBandHidden = (await $.store.get(BAND_HIDDEN_KEY).catch(() => undefined)) === true
876}
877
878/* `p`, or a press on the ✕: the preview closed, from the region or
879   expanded. Nothing is kept. */
880async function closePreview($: EngineInterface): Promise<void> {
881  /* On the desktop the ✕ leaving the tree takes the pane's focus with it,
882     and the next click would only give it back (0.2 spec §8): the focus goes
883     to the preview's entry first, or, expanded, once the list is back. */
884  const wasExpanded = isExpandedDrawn
885  const to = previewEntry ?? TOP_CONTROL
886  if (!wasExpanded) await keepFocus($, to)
887  closePreviewNow()
888  await update($, rev, n => n + 1)
889  if (wasExpanded) $.clock.after(0, () => void keepFocus($, to))
890}
891
892/* On the desktop, the focus moved to `key`, a Button the next drawing
893   keeps; the terminal moves its ring by itself. */
894async function keepFocus($: EngineInterface, key: string): Promise<void> {
895  if (paneSurface === 'terminal') return
896  const moved = await $.ui.focus({ requestId: PANE_ID, key }).catch((error: unknown) => ({ deny: String(error) }))
897  if (moved.deny === undefined) ring = key
898}
899
900/* The key of group `group`'s first entry in the drawn part `window`;
901   undefined when none of it is drawn. */
902function keyDrawnOfGroup(entries: readonly Entry[], window: TocWindow, group: number): string | undefined {
903  for (let i = window.start; i < window.end; i++) if (entries[i]?.groupIndex === group) return entries[i]!.key
904  return undefined
905}
906
907/* `↑ Back to top`: the pane scrolled to its first row, which Desktop
908   allows from a press (ticket 24's probe). */
909async function backToTop($: EngineInterface): Promise<void> {
910  await $.ui.scroll({ to: 'start', in: PANE_ID }).catch(() => undefined)
911}
912
913/* `f`: the keys into the search field. The host does not raise this
914   plugin's own move to its `ui.focus` hook, so the ring is noted here. */
915async function focusSearch($: EngineInterface): Promise<void> {
916  const moved = await $.ui.focus({ requestId: PANE_ID, key: SEARCH_FIELD }).catch((error: unknown) => ({ deny: String(error) }))
917  if (moved.deny === undefined) ring = SEARCH_FIELD
918}
919
920function setQuery($: EngineInterface, value: string): Promise<void> {
921  return update($, query, () => value).then(
922    () => undefined,
923    () => undefined,
924  )
925}
926
927/* Enter in the field: the search ends (the host empties the field) and,
928   when a group matches, the transcript jumps to the match nearest the
929   Current position, on the side the View filter shows. The host moves the
930   transcript only within the person's own submit: the Input's handler
931   hands this promise back, and the scroll goes before anything else is
932   written. */
933async function submitSearch($: EngineInterface, value: string): Promise<void> {
934  const found = matchedGroups(value)
935  const target = nearestMatch(found, (await read($, currentPosition)) ?? undefined)
936  if (target !== undefined) {
937    const side = jumpSide(filter)
938    selectedKey = entryKey(target, side)
939    await jump($, { groupIndex: target, side, key: selectedKey })
940  }
941  await setQuery($, '')
942}
943
944/* The preview opened, or moved, onto listed group `at`. */
945function openPreview(at: number): void {
946  const opener = shownGroups()[at]?.openerUuid
947  if (opener === undefined || opener === previewOpener) return
948  previewOpener = opener
949  isExpanded = false
950}
951
952/* The desktop's list drawn up to `group`, undefined the newest. */
953async function moveList($: EngineInterface, group: number | undefined): Promise<void> {
954  /* The pressed Button may leave the tree, and the pane's focus with it:
955     once redrawn, the focus goes to an entry the new part draws, the one
956     just above the old part (earlier, now the new part's last) or just
957     below it (later, now its first). */
958  const { window, entries, keys } = lastDrawing
959  const isEarlier = group !== undefined && group < (entries[window.start]?.groupIndex ?? 0)
960  const to = keys[isEarlier ? window.start - 1 : window.end]
961  /* The preview's group leaves the drawn part: its contents and the list
962     would not match (0.2 spec §8). */
963  closePreviewNow()
964  drawnUpTo = group
965  await update($, rev, n => n + 1)
966  if (to !== undefined) $.clock.after(0, () => void keepFocus($, to))
967}
968
969/* A click or Enter on an entry: the transcript jumps. The terminal
970   selects the entry, the person opening the preview from its row, and a
971   press while the preview is open moves it there; the desktop, which
972   cannot jump, opens it. The scroll goes first, within the person's
973   press; the desktop's refusal says nothing. */
974async function pressEntry($: EngineInterface, entry: Entry): Promise<void> {
975  if (paneSurface !== 'terminal' || previewOpener !== undefined) openPreview(entry.groupIndex)
976  if (paneSurface === 'terminal') select($, entry.key)
977  await jump($, entry)
978  await update($, rev, n => n + 1)
979}
980
981/* Entry `key` selected; when its row of Buttons would push it out of the
982   window's bottom, the window moves so both show. */
983function select($: EngineInterface, key: string): void {
984  selectedKey = key
985  /* The desktop draws no selection. A redraw there retires every Button's
986     handle, and a click whose focus selected the entry then reaches the
987     host with a retired one: the host drops the press. */
988  if (paneSurface !== 'terminal') return
989  const drawn = lastDrawing
990  const index = drawn.indexOf.get(key)
991  if (index !== undefined) {
992    const heights = drawn.heightsWith(index)
993    const window = windowOf(heights, drawn.rows, top ?? drawn.window.start)
994    if (index >= window.end) top = topRevealing(heights, drawn.rows, window, index)
995  }
996  redrawSoon($)
997}
998
999/* `p` or the Button under the selected entry: the preview on its group. */
1000async function openSelected($: EngineInterface, groupIndex: number): Promise<void> {
1001  openPreview(groupIndex)
1002  await update($, rev, n => n + 1)
1003}
1004
1005/* The desktop's submit (Enter, or the field's `show` button): the text
1006   stays and the preview opens on the match nearest the Current position,
1007   the newest match, the desktop having no Current position. */
1008async function showMatch($: EngineInterface, value: string): Promise<void> {
1009  const target = nearestMatch(matchedGroups(value), undefined)
1010  if (target === undefined) return
1011  openPreview(target)
1012  await update($, rev, n => n + 1)
1013}
1014
1015/* The working directory and home the preview's paths are shown against. */
1016async function learnPaths($: EngineInterface, cwd: string): Promise<void> {
1017  const home = await $.env.get('HOME').catch(() => undefined)
1018  pathBase = { cwd: cwd || undefined, home: home || undefined }
1019}
1020
1021/* The group a switch keeps in view: the selected entry's while the pane
1022   has the keyboard, else the lit one, else the window's top one while the
1023   person has scrolled it. None while following, which keeps the lit group
1024   in view itself, or follows the newest. */
1025function anchorForSwitch(): void {
1026  const wasFollowing = top === undefined
1027  const selectedAt = isPaneFocused ? entryAt(ring, lastDrawing.indexOf) : undefined
1028  const selected = selectedAt === undefined ? undefined : lastDrawing.entries[selectedAt.index]
1029  if (selected !== undefined && ring !== undefined) anchor = { by: 'ring', ring, group: selected.groupIndex, wasFollowing }
1030  else if (!isPaused && !isPaneFocused) anchor = undefined
1031  else if (positionDrawn !== undefined) anchor = { by: 'lit', group: positionDrawn, wasFollowing }
1032  else {
1033    const entry = top !== undefined ? lastDrawing.entries[lastDrawing.window.start] : undefined
1034    anchor = entry === undefined ? undefined : { by: 'top', group: entry.groupIndex, wasFollowing }
1035  }
1036}
1037
1038/* The choice applies at once; a store that refuses it only means it is
1039   not remembered. */
1040async function chooseFilter($: EngineInterface, chosen: ViewFilter): Promise<void> {
1041  if (chosen === filter) return
1042  anchorForSwitch()
1043  filter = chosen
1044  await update($, rev, n => n + 1)
1045  await $.store.set(FILTER_KEY, chosen).catch(() => undefined)
1046}
1047
1048/* The Layout after this one or, from the desktop's select, the one picked. */
1049async function chooseLayout($: EngineInterface, to?: Layout): Promise<void> {
1050  if (to === layout) return
1051  anchorForSwitch()
1052  layout = to ?? nextLayout(layout)
1053  await update($, rev, n => n + 1)
1054  await $.store.set(LAYOUT_KEY, layout).catch(() => undefined)
1055}
1056
1057/* Moves the focus ring onto an entry a drawing now shows (the drawing
1058   noted it in `ring` already: the host does not raise this plugin's own
1059   move to its `ui.focus` hook). Refused while the new frame is not in, it
1060   is sent again a few times, as long as the ring is still headed there. */
1061function sendRing($: EngineInterface, key: string, left = RING_RETRIES): void {
1062  if (ring !== key) return
1063  const again = () => {
1064    if (left > 0) $.clock.after(RING_RETRY_MS, () => sendRing($, key, left - 1))
1065  }
1066  void $.ui.focus({ requestId: PANE_ID, key }).then(
1067    moved => {
1068      if (moved.deny !== undefined) again()
1069    },
1070    () => again(),
1071  )
1072}
1073
1074/* The preview drawn last, kept while its group and the room are the same:
1075   a long reply is folded once, not at every redraw. */
1076const previewMemo = newMemo<PreviewBody>()
1077
1078/* The group a label-row Button acts on, found at the press among the
1079   groups listed now by its opener: nothing of it is kept from the drawing. */
1080function groupAtPress(opener: string): { group: TurnGroup; isRunning: boolean } | undefined {
1081  const groups = shownGroups()
1082  const at = groupIndexOf(groups, opener)
1083  const group = groups[at]
1084  return group === undefined ? undefined : { group, isRunning: isRunningIn(groups) && at === groups.length - 1 }
1085}
1086
1087/* `r`: the User input into the composer as a draft, over an empty one,
1088   else after the draft; never submitted. A fill that lands says nothing
1089   more (the keyboard moves to the composer); one refused says why. */
1090async function reuseInput($: EngineInterface, opener: string): Promise<void> {
1091  const found = groupAtPress(opener)
1092  const offered = found === undefined ? undefined : reusableOf(found.group, found.isRunning)
1093  if (offered?.fill === undefined) return
1094  const box = await $.prompt.read()
1095  const filled = await $.prompt.fill(fillOf(box.text, offered.fill))
1096  if (!filled.isFilled) {
1097    $.ui.toast(fillRefusedText(filled.refusal))
1098    return
1099  }
1100  if (offered.hasImagesLeft) $.ui.toast(words.imagesLeft)
1101}
1102
1103/* `c` or `a`: the User input or the last text, whole, on the clipboard.
1104   The Button reads `Copied` for a while; a copy refused says why. */
1105async function copyOut($: EngineInterface, opener: string, which: CopySide, surface: RenderSurface): Promise<void> {
1106  const found = groupAtPress(opener)
1107  const offered = found === undefined ? undefined : reusableOf(found.group, found.isRunning)
1108  const text = which === 'user' ? offered?.input : offered?.text
1109  if (offered === undefined || text === undefined) return
1110  const result = await $.ui.copy({ text, surface })
1111  if (!result.isCopied) {
1112    $.ui.toast(words.copyFailed(result.reason))
1113    return
1114  }
1115  const n = ++copies
1116  copied = { opener, which, n }
1117  $.clock.after(COPIED_MS, () => {
1118    if (copied?.n !== n) return
1119    copied = undefined
1120    void update($, rev, x => x + 1).catch(() => undefined)
1121  })
1122  await update($, rev, x => x + 1)
1123  if (which === 'user' && offered.hasImagesLeft) $.ui.toast(words.imagesLeft)
1124}
1125
1126/* The label rows' Buttons for a group, as it offers them. */
1127function labelActions($: EngineInterface, group: TurnGroup, isRunning: boolean, surface: RenderSurface): LabelActions {
1128  const offered = reusableOf(group, isRunning)
1129  const opener = group.openerUuid
1130  return {
1131    canReuse: offered.fill !== undefined && hasReuse(surface, hostBase),
1132    canCopyInput: offered.input !== undefined,
1133    canCopyText: offered.text !== undefined,
1134    copied: copied?.opener === opener ? copied.which : undefined,
1135    onReuse: () => reuseInput($, opener),
1136    onCopy: which => copyOut($, opener, which, surface),
1137  }
1138}
1139
1140/* What a drawing draws the preview and the expanded preview from: the
1141   groups listed, whether the last is under way, the width, the time, and
1142   the label rows' Buttons for a group. */
1143type PreviewSource = {
1144  groups: readonly TurnGroup[]
1145  isRunningNow: boolean
1146  width: number
1147  nowMs: number
1148  actionsOf: (group: TurnGroup, isRunning: boolean) => LabelActions
1149}
1150
1151function previewOf(
1152  { groups, isRunningNow, width, nowMs, actionsOf }: PreviewSource,
1153  o: {
1154    at: number
1155    rows: number
1156    search: Searched | undefined
1157    onClose: () => Promise<void>
1158    onMore: () => Promise<void>
1159  },
1160): PreviewView {
1161  const { at, search } = o
1162  const group = groups[at]
1163  const match = search?.matches.find(m => m.group === at)
1164  const isRunning = isRunningNow && at === groups.length - 1
1165  const year = new Date(nowMs).getFullYear()
1166  const body =
1167    group === undefined
1168      ? undefined
1169      : remember(previewMemo, [group, isRunning, width, o.rows, year, pathBase, matchShown(match)], () =>
1170          previewBody({
1171            meta: metaLine(group, isRunning, nowMs, width, match === undefined ? undefined : words.matched(matchedFields(match))),
1172            input: typedInput(group.facts),
1173            agent: isRunning ? group.facts.latestText : group.facts.lastText,
1174            files: filesLine(group.facts, pathBase, width, match?.paths),
1175            rows: o.rows,
1176            width,
1177          }),
1178        )
1179  return {
1180    rows: o.rows,
1181    body,
1182    actions: body === undefined || group === undefined ? undefined : actionsOf(group, isRunning),
1183    onClose: o.onClose,
1184    onMore: o.onMore,
1185  }
1186}
1187
1188/* `e`, or a press on the cut line: the preview expands over the list,
1189   holding its group; `e` again, or ← Back, returns. */
1190async function expandPreview($: EngineInterface, isOn: boolean): Promise<void> {
1191  isExpanded = isOn
1192  await update($, rev, n => n + 1)
1193}
1194
1195/* The expanded preview drawn last, kept while its group and width are. */
1196const expandedMemo = newMemo<ExpandedBody>()
1197
1198/* The expanded preview of the group the preview holds, listed group `at`. */
1199function expandedOf(
1200  { groups, isRunningNow, width, nowMs, actionsOf }: PreviewSource,
hooks/entries.ts 242 lines
1/* Entries: what the pane draws for each side of a Turn group, as lines of
2   text already folded to the pane's width, in the Layout chosen and under
3   the View filter. Every Layout hangs the sides on a rail: the User input's
4   first line on a node, the Agent reply's on a branch.
5     card      each side up to two lines, the User input's time at the right
6               of its first line;
7     compact   each side one line, the time in a column left of the rail;
8     timeline  each side up to two lines, the time in a column left of the
9               rail.
10   The Agent reply is one dim run: its state and steps, then its reply.
11   The gutter left of the lines (time column, rail) is the pane's to draw;
12   an entry carries what it needs, its `time` and whether it runs. */
13
14import { textCells, wrapCells } from './cells'
15import type { CompactionRule } from './compactions'
16import type { TurnGroup } from './groups'
17import type { Layout, ViewFilter } from './view'
18import { text as words } from './text'
19
20/* A line of an entry; `aside`, on a card's first User input line, is the
21   time the pane draws at the right of it. */
22export type EntryLine = { text: string; dim: boolean; aside?: string }
23
24export type Entry = {
25  /* The entry's key: its first line's Button key; later lines add `.<n>`. */
26  key: string
27  groupIndex: number
28  side: 'user' | 'agent'
29  /* The transcript row a press jumps to. */
30  jumpUuid: string
31  lines: EntryLine[]
32  /* The group's local `HH:MM` for the time column of the compact and
33     timeline Layouts, on the group's first side shown; '' elsewhere, and
34     when the transcript has no time for it. */
35  time: string
36  /* An Agent reply side whose turn is under way: the pane marks it ◐. */
37  isRunning: boolean
38  /* The group's day, as `dayLabel` says it; '' when it has no time. */
39  day: string
40  /* A side with no Jump target (refused, or compacted away before a
41     resume): the pane hangs it on a hollow mark. */
42  isOff?: boolean
43  /* Whether a blank line follows: one parts each Turn group from the next
44     in the card and timeline Layouts. */
45  gapAfter: boolean
46  /* A compaction's rule drawn above the entry (below its day's rule), and,
47     on the list's last entry, one drawn below it (compactions.ts). */
48  compactionBefore?: CompactionRule
49  compactionAfter?: CompactionRule
50}
51
52/* The gutter each Layout keeps left of its Buttons, in cells: the rail and
53   two spaces; in compact and timeline after `HH:MM` and a space. */
54export const GUTTER: Record<Layout, number> = { card: 3, compact: 9, timeline: 9 }
55/* What a running Agent reply side adds to the gutter: `◐ `. */
56export const RUNNING_MARK = 2
57/* The room a card keeps at the right of its first User input line for the
58   time: a space, then `HH:MM`. */
59const ASIDE = 6
60const BODY_LINES = 2
61
62const pad2 = (n: number) => String(n).padStart(2, '0')
63
64/* A transcript timestamp as a local time; undefined when there is none to
65   read. */
66function timeOf(timestamp: string | undefined): Date | undefined {
67  if (!timestamp) return undefined
68  const at = new Date(timestamp)
69  return Number.isNaN(at.getTime()) ? undefined : at
70}
71
72function dayOf(at: Date | undefined, now: Date, isTodaySaid: boolean): string {
73  if (at === undefined) return ''
74  const day = words.monthDay(at.getMonth(), at.getDate())
75  if (at.getFullYear() !== now.getFullYear()) return words.withYear(day, at.getFullYear())
76  return isTodaySaid && at.getMonth() === now.getMonth() && at.getDate() === now.getDate() ? words.today(day) : day
77}
78
79const clockOf = (at: Date | undefined) => (at === undefined ? '' : `${pad2(at.getHours())}:${pad2(at.getMinutes())}`)
80
81/* A transcript timestamp's local day for the day's rule: `Oct 9` (`10月9日`),
82   today `Today · Oct 9` unless `isTodaySaid` is false, another year's with
83   the year; '' when there is none to read. */
84export function dayLabel(timestamp: string | undefined, nowMs: number, isTodaySaid = true): string {
85  return dayOf(timeOf(timestamp), new Date(nowMs), isTodaySaid)
86}
87
88/* A transcript timestamp's local `HH:MM`; '' when there is none to read. */
89export function clockTime(timestamp: string | undefined): string {
90  return clockOf(timeOf(timestamp))
91}
92
93/* The Agent reply's state when it is not finished: in progress,
94   interrupted or ended in an error; '' when it finished. */
95export function agentStateOf(group: TurnGroup, isRunning: boolean): string {
96  return isRunning ? words.running : group.ending === 'interrupted' ? words.interrupted : group.ending === 'error' ? words.error : ''
97}
98
99/* The Agent reply's state, as its header says it: in progress, interrupted
100   or ended in an error, each with the steps so far; otherwise the steps,
101   and a note when there is no reply text. No steps, no count. */
102export function agentHeadOf(group: TurnGroup, isRunning: boolean): string {
103  const steps = group.steps > 0 ? words.steps(group.steps) : ''
104  const state = agentStateOf(group, isRunning)
105  if (state) return [state, steps].filter(Boolean).join(' · ')
106  if (!group.reply) return [steps, words.noReply].filter(Boolean).join(' · ')
107  return steps
108}
109
110/* An entry's key, `g3.u` or `g3.a`: group 3's User input or Agent reply
111   side. Its later lines' Buttons add `.<n>`. */
112export const entryKey = (groupIndex: number, side: 'user' | 'agent'): string => `g${groupIndex}.${side === 'user' ? 'u' : 'a'}`
113
114type Side = Omit<Entry, 'time' | 'gapAfter' | 'isRunning' | 'day'>
115
116function sideOf(group: TurnGroup, groupIndex: number, side: 'user' | 'agent', lines: EntryLine[]): Side {
117  return {
118    key: entryKey(groupIndex, side),
119    groupIndex,
120    side,
121    jumpUuid: side === 'user' ? group.openerUuid : (group.reply?.uuid ?? group.lastAgentUuid ?? group.openerUuid),
122    lines,
123  }
124}
125
126const linesOf = (texts: string[], dim: boolean): EntryLine[] => texts.map(text => ({ text, dim }))
127
128/* The Agent reply as one run of text: its state, then its reply. */
129function agentText(group: TurnGroup, isRunning: boolean): string {
130  return [agentHeadOf(group, isRunning), group.reply?.text ?? ''].filter(Boolean).join(' · ')
131}
132
133function userSide(group: TurnGroup, i: number, layout: Layout, room: number, clock: string): Side {
134  const lines = layout === 'compact' ? 1 : BODY_LINES
135  const time = layout === 'card' ? clock : ''
136  if (!time) return sideOf(group, i, 'user', linesOf(wrapCells(group.userText, room, lines), false))
137  const folded = linesOf(wrapCells(group.userText, room, lines, room - ASIDE), false)
138  folded[0] = { ...folded[0]!, aside: time }
139  return sideOf(group, i, 'user', folded)
140}
141
142function agentSide(group: TurnGroup, i: number, layout: Layout, room: number, isRunning: boolean): Side {
143  const lines = layout === 'compact' ? 1 : BODY_LINES
144  return sideOf(group, i, 'agent', linesOf(wrapCells(agentText(group, isRunning), isRunning ? room - RUNNING_MARK : room, lines), true))
145}
146
147export type EntryOptions = {
148  layout: Layout
149  filter: ViewFilter
150  /* The pane's body width in cells. */
151  width: number
152  nowMs: number
153  /* Whether a turn of the main agent is under way: the last group's. */
154  isRunning: boolean
155}
156
157/* One group's entries, in order: its User input side, then its Agent reply
158   side when it has one, as the filter lets them through; none when it lets
159   neither through. The group's last entry has a gap after it in the card
160   and timeline Layouts; the list's very last entry drops it. */
161export function groupEntries(group: TurnGroup, i: number, isGroupRunning: boolean, options: EntryOptions): Entry[] {
162  const { layout, filter, width, nowMs } = options
163  const room = width - GUTTER[layout]
164  const sides: ('user' | 'agent')[] = []
165  if (filter !== 'agent') sides.push('user')
166  if (filter !== 'user' && (group.hasAgentReply || isGroupRunning)) sides.push('agent')
167  const at = timeOf(group.timestamp)
168  const day = dayOf(at, new Date(nowMs), true)
169  const clock = clockOf(at)
170  return sides.map((side, n) => {
171    const drawn = side === 'user' ? userSide(group, i, layout, room, clock) : agentSide(group, i, layout, room, isGroupRunning)
172    const time = layout !== 'card' && n === 0 ? clock : ''
173    return { ...drawn, time, isRunning: side === 'agent' && isGroupRunning, day, gapAfter: layout !== 'compact' && n === sides.length - 1 }
174  })
175}
176
177/* The day's rule drawn above entry `i`: its day, when it is the first
178   entry of its group and the day is not the one the list was on. A group
179   without a time draws none and leaves the list on its day. */
180export function dayRuleBefore(entries: readonly Entry[], i: number): string | undefined {
181  const entry = entries[i]
182  if (entry === undefined || entry.day === '') return undefined
183  if (entries[i - 1]?.groupIndex === entry.groupIndex) return undefined
184  for (let j = i - 1; j >= 0; j--) {
185    const before = entries[j]!.day
186    if (before !== '') return before === entry.day ? undefined : entry.day
187  }
188  return entry.day
189}
190
191/* Whether a rule, the day's or a compaction's, is drawn above entry `i`:
192   the gap before it then breaks the rail. */
193export function isRuleBefore(entries: readonly Entry[], i: number): boolean {
194  return dayRuleBefore(entries, i) !== undefined || entries[i]?.compactionBefore !== undefined
195}
196
197/* The rows entry `i` takes: its day's rule, its compaction rules, its
198   lines, the gap after it. */
199export function entryRows(entries: readonly Entry[], i: number): number {
200  const entry = entries[i]
201  if (entry === undefined) return 0
202  const rules = (dayRuleBefore(entries, i) === undefined ? 0 : 1) + (entry.compactionBefore ? 1 : 0) + (entry.compactionAfter ? 1 : 0)
203  return rules + entry.lines.length + (entry.gapAfter ? 1 : 0)
204}
205
206/* The list's last entry, which has nothing after it to part from. */
207export function withoutTrailingGap(entries: Entry[]): Entry[] {
208  const last = entries[entries.length - 1]
209  if (last?.gapAfter) entries[entries.length - 1] = { ...last, gapAfter: false }
210  return entries
211}
212
213/* The entries the pane lists, in order: each group's, as `groupEntries`
214   gives them. A group with no side the filter lets through is not listed. */
215export function entriesOf(groups: readonly TurnGroup[], options: EntryOptions): Entry[] {
216  const entries: Entry[] = []
217  groups.forEach((group, i) => {
218    for (const entry of groupEntries(group, i, options.isRunning && i === groups.length - 1, options)) entries.push(entry)
219  })
220  return withoutTrailingGap(entries)
221}
222
223/* The first entry of group `group` or, when it shows none, of the first
224   group after it that does; else the last entry. Entries are in group
225   order, so this halves its way there. */
226export function firstEntryFrom(entries: readonly Entry[], group: number): number | undefined {
227  if (entries.length === 0) return undefined
228  let low = 0
229  let high = entries.length
230  while (low < high) {
231    const mid = (low + high) >> 1
232    if ((entries[mid]?.groupIndex ?? 0) < group) low = mid + 1
233    else high = mid
234  }
235  return Math.min(low, entries.length - 1)
236}
237
238/* Every side in the card Layout. */
239export function cardEntries(groups: readonly TurnGroup[], width: number, nowMs: number, isRunning: boolean): Entry[] {
240  return entriesOf(groups, { layout: 'card', filter: 'all', width, nowMs, isRunning })
241}
242
hooks/compactions.ts 154 lines
1/* Compactions: each `system/compact_boundary` row on the chain, with its
2   trigger and time, and where its rule goes among the Turn groups. A Turn
3   group is never cut by one: the rule goes before or after the group the
4   boundary falls in.
5     manual             after the `/compact` entry, though the chain has
6                        the boundary just before the command row;
7     auto at a submit   (the boundary in the new group before any agent
8                        row) before that group;
9     auto mid-turn      after the group (a running group's at the list's
10                        end until the next group comes);
11     auto between turns between the two groups, also after a group the
12                        model never worked on (a local slash command, a
13                        `!` shell command), which its own output answered. */
14
15import { entryRows } from './entries'
16import type { Entry } from './entries'
17import { isLocalOutput } from './groups'
18import type { TurnGroup } from './groups'
19import type { TranscriptRow } from './transcript'
20
21export type Compaction = {
22  /* The boundary row's uuid. */
23  uuid: string
24  trigger: 'manual' | 'auto'
25  /* The boundary row's timestamp (UTC ISO), when it has one. */
26  timestamp: string | undefined
27  /* The group the rule goes before; the group count when it goes after
28     the last. */
29  at: number
30  /* Whether a response follows it on the chain: until one does, the host
31     has no context use to give. */
32  isAnswered: boolean
33}
34
35const isBoundary = (row: TranscriptRow) => row.type === 'system' && row.subtype === 'compact_boundary'
36
37const triggerOf = (row: TranscriptRow): 'manual' | 'auto' =>
38  (row as { compactMetadata?: { trigger?: unknown } }).compactMetadata?.trigger === 'auto' ? 'auto' : 'manual'
39
40const isCompactCommand = (group: TurnGroup | undefined) => group?.kind === 'command' && /^\/compact(\s|$)/.test(group.userText)
41
42/* The chain's compactions, oldest first, each placed against `groups`
43   (the chain's Turn groups, as `groupTurns` gives them). */
44export function compactionsOf(chain: readonly TranscriptRow[], groups: readonly TurnGroup[]): Compaction[] {
45  const found: Compaction[] = []
46  /* The group the rows walked belong to; -1 before the first. Whether it
47     was answered yet: by the agent, or by Claude Code's own output. */
48  let g = -1
49  let isAnswered = false
50  for (const row of chain) {
51    if (row.uuid === groups[g + 1]?.openerUuid) {
52      g += 1
53      isAnswered = false
54    }
55    if (isLocalOutput(row)) isAnswered = true
56    if (row.type === 'assistant') {
57      isAnswered = true
58      for (let i = found.length - 1; i >= 0 && !found[i]!.isAnswered; i--) found[i]!.isAnswered = true
59    }
60    if (!isBoundary(row)) continue
61    const trigger = triggerOf(row)
62    let at: number
63    if (trigger === 'manual') at = isCompactCommand(groups[g]) || !isCompactCommand(groups[g + 1]) ? g + 1 : g + 2
64    else at = isAnswered ? g + 1 : Math.max(0, g)
65    found.push({ uuid: row.uuid, trigger, timestamp: row.timestamp, at, isAnswered: false })
66  }
67  return found
68}
69
70/* One rule row: the compactions that fell in one place among the groups
71   shown, folded, with the last one's trigger and time. */
72export type CompactionRule = { trigger: 'manual' | 'auto'; count: number; timestamp: string | undefined }
73
74export type PlacedRules = {
75  /* The rule drawn before a shown group, by its group index. */
76  before: Map<number, CompactionRule>
77  /* The rule drawn after the last shown group. */
78  after: CompactionRule | undefined
79}
80
81/* The rules among the groups shown, `shown` their group indices in order,
82   whatever left them (the View filter, a query): each compaction's rule
83   goes before the first shown group at or after its place, else after the
84   last shown group. With none shown there are none. Pure over its inputs,
85   so the list under a query places them the same way. */
86export function rulesAmong(compactions: readonly Compaction[], shown: readonly number[]): PlacedRules {
87  const placed: PlacedRules = { before: new Map(), after: undefined }
88  if (shown.length === 0) return placed
89  const fold = (was: CompactionRule | undefined, c: Compaction): CompactionRule => ({
90    trigger: c.trigger,
91    count: (was?.count ?? 0) + 1,
92    timestamp: c.timestamp,
93  })
94  let s = 0
95  for (const c of compactions) {
96    while (s < shown.length && shown[s]! < c.at) s++
97    const group = shown[s]
98    if (group === undefined) placed.after = fold(placed.after, c)
99    else placed.before.set(group, fold(placed.before.get(group), c))
100  }
101  return placed
102}
103
104/* What the list draws: its entries, each one's rows, and where each
105   group's entries start (a group that shows none starts where the next
106   one does), as the entry table keeps them. */
107export type Listed = { entries: readonly Entry[]; heights: readonly number[]; firstOfGroup: readonly number[] }
108
109/* The groups that show entries, by index, in order. */
110export function shownOf(listed: Listed): number[] {
111  const shown: number[] = []
112  const { firstOfGroup, entries } = listed
113  for (let g = 0; g < firstOfGroup.length; g++) {
114    if (firstOfGroup[g]! < (firstOfGroup[g + 1] ?? entries.length)) shown.push(g)
115  }
116  return shown
117}
118
119/* The list with the compactions' rules laid on: a rule before a shown
120   group goes on its first entry, one after the last on the last entry,
121   and each such entry's rows count it. The list given is not changed;
122   with no rule it is given back. Compactions placed past the groups listed
123   (a rewind's) are left out. */
124export function layRules(listed: Listed, compactions: readonly Compaction[]): Listed {
125  const groups = listed.firstOfGroup.length
126  const placed = compactions.length === 0 ? undefined : rulesAmong(compactions.filter(c => c.at <= groups), shownOf(listed))
127  if (placed === undefined || (placed.before.size === 0 && placed.after === undefined)) return listed
128  const entries = listed.entries.slice()
129  const heights = listed.heights.slice()
130  const lay = (i: number, rule: Partial<Entry>) => {
131    const entry = entries[i]
132    if (entry === undefined) return
133    entries[i] = { ...entry, ...rule }
134    heights[i] = entryRows(entries, i)
135  }
136  for (const [group, rule] of placed.before) lay(listed.firstOfGroup[group]!, { compactionBefore: rule })
137  if (placed.after !== undefined) lay(entries.length - 1, { compactionAfter: placed.after })
138  return { entries, heights, firstOfGroup: listed.firstOfGroup }
139}
140
141/* Whether earlier turns of the conversation are not in this file: the
142   chain's first boundary comes before its first group. That covers a
143   fork's file, which starts at a boundary whose `logicalParentUuid` the
144   file does not hold, and a resumed file compacted before any turn of its
145   own. The parent transcript is not read. */
146export function hasEarlierTurnsElsewhere(chain: readonly TranscriptRow[], groups: readonly TurnGroup[]): boolean {
147  const opener = groups[0]?.openerUuid
148  for (const row of chain) {
149    if (row.uuid === opener) return false
150    if (isBoundary(row)) return true
151  }
152  return false
153}
154
hooks/desktop.tsx 485 lines
1/* Drawing the pane on Claude Code Desktop and the other remote surfaces,
2   from what the desktop was measured to draw (ticket 36). A plain Button is
3   one line of bare text there, dim or not, pressable, nothing more; so each
4   line of an entry is one, the lines folded as the terminal folds them. All
5   that only decorates is an Svg: the strip of bars, and beside each run of
6   entries one rail with their times, nodes and branches, as tall as their
7   lines. A Box's width and height count cells there, as on the terminal.
8   The desktop reports no Current position: nothing is lit, nothing pauses.
9
10   The 0.2 additions (0.2 spec §8): the filters as native Buttons beside the
11   Layout Select, one native height; a native search field under them (no
12   `f` Button) with a ✕ while there is a query, the strip drawing the
13   matches in the accent colour and the context use after its count; the
14   compaction rules as dashed Svgs placed as on the terminal, and the fork
15   line; the preview, closed until a press, in a bordered Box under the
16   pressed group, its content drawn by pane.tsx, its hotkeys gone, so its ✕,
17   the cut line and Back are plain Buttons; copy without Reuse (reuse.ts);
18   a Button back to the pane's top after the list. No ‹ ›: nothing walks on
19   the desktop. */
20
21import type { ElementTable, RenderElement } from 'claude-code'
22import { textCells } from './cells'
23import { GUTTER, RUNNING_MARK, dayRuleBefore, isRuleBefore } from './entries'
24import type { Entry } from './entries'
25import type { CompactionRule } from './compactions'
26import { MINIMAP_HEIGHT, minimapOf } from './minimap'
27import type { StripLabels } from './minimap'
28import { CLEAR_CONTROL, SEARCH_FIELD, compactionLabel, expandedContent, matchCount, previewContent } from './pane'
29import type { ExpandedView, PreviewView, SearchView } from './pane'
30import { MORE_CONTROL, PREVIEW_CONTROL } from './preview'
31import { FIRST_CONTROL, LAYOUT_CONTROL } from './ring'
32import type { SpanList } from './span'
33import { FILTERS, filterLabel, layoutLabel, layoutName, layoutOf } from './view'
34import { text as words } from './text'
35import type { Layout, ViewFilter } from './view'
36import type { TocWindow } from './window'
37
38/* The Buttons moving the drawn part of the list to earlier and to later
39   turns, and the one scrolling the pane back to its top. */
40export const EARLIER_CONTROL = 'list.earlier'
41export const LATER_CONTROL = 'list.later'
42export const TOP_CONTROL = 'list.top'
43
44/* A cell's width and a row's height on the desktop, in CSS px. */
45export const CELL_PX = 8.2
46export const ROW_PX = 19
47
48const LAYOUTS: readonly Layout[] = ['card', 'compact', 'timeline']
49const TIME_CELLS = 6
50/* The rail's cells: the terminal's gutter without a time column. */
51const RAIL_CELLS = GUTTER.card
52const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','PingFang SC',sans-serif"
53
54/* The search field under the filters: a native field whose submit (Enter,
55   or its `show` button) keeps the text and puts the nearest match in the
56   preview; while there is a query, the count of matches and a ✕ emptying
57   it. */
58export type DesktopSearch = Pick<SearchView, 'query' | 'count' | 'onInput' | 'onSubmit' | 'onClear'>
59
60/* Where the drawn part of the list moves: to earlier turns from its top,
61   to later ones from its bottom; `count` Turn groups are left out there. */
62export type ListMove = { count: number; onPress: () => Promise<void> }
63
64export type DesktopView = {
65  entries: readonly Entry[]
66  /* The drawn part of the list (span.ts), not a window by rows: the host
67     scrolls the tree. */
68  window: TocWindow
69  earlier: ListMove | undefined
70  later: ListMove | undefined
71  layout: Layout
72  filter: ViewFilter
73  /* The steps of each Turn group the list holds, for the strip of bars. */
74  steps: readonly number[]
75  runningGroup: number | undefined
76  stripLabels: StripLabels
77  notice: string | undefined
78  alert: { text: string; isInverse: boolean } | undefined
79  why: string | undefined
80  count: number | undefined
81  /* Whether the list starts with the line saying earlier turns are not in
82     this session's file. */
83  isEarlierElsewhere: boolean
84  /* The context use, a whole percent; undefined leaves it out. */
85  contextPercent: number | undefined
86  search: DesktopSearch
87  /* The matching groups while a query is set, for the strip. */
88  matches: readonly number[] | undefined
89  /* The preview while it is open, drawn under `previewGroup`'s last drawn
90     entry, or at the list's top when none of it is drawn; undefined draws
91     none. */
92  preview: PreviewView | undefined
93  previewGroup: number | undefined
94  /* The expanded preview, drawn in place of the strip, the list and the
95     preview; undefined draws those. */
96  expanded: ExpandedView | undefined
97  bodyColumns: number
98  onPress: (entry: Entry) => Promise<void>
99  onFilter: (filter: ViewFilter) => Promise<void>
100  onLayout: (layout?: Layout) => Promise<void>
101  onTop: () => Promise<void>
102}
103
104const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
105const px = (n: number) => Math.round(n * 10) / 10
106
107const svgOf = (w: number, h: number, style: string, body: string) =>
108  `<svg xmlns="http://www.w3.org/2000/svg" width="${px(w)}" height="${h}" viewBox="0 0 ${px(w)} ${h}"><style>${style}</style>${body}</svg>`
109
110/* The rules' colours: the line and the words. */
111const RULE_STYLE = `.l{fill:#d3d2cd}.t{fill:#8a8985;font:12px ${FONT}}@media (prefers-color-scheme: dark){.l{fill:#41413e}.t{fill:#8f8e89}}`
112/* The rail's colours: the rail, a node's ring, the time, a hollow mark, a
113   running reply's mark. */
114const RAIL_STYLE =
115  '.l{fill:#d3d2cd}.n{fill:none;stroke:#8f8e89;stroke-width:1.5}.o{fill:none;stroke:#b8b7b2;stroke-width:1.2;stroke-dasharray:2 2}' +
116  `.t{fill:#8a8985;font:12px ${FONT}}.r{fill:#d77757;animation:p 1.4s ease-in-out infinite}@keyframes p{50%{opacity:.35}}` +
117  '@media (prefers-color-scheme: dark){.l{fill:#41413e}.n{stroke:#a3a29d}.o{stroke:#5d5c58}.t{fill:#8f8e89}}' +
118  '@media (prefers-reduced-motion: reduce){.r{animation:none}}'
119
120/* The rail's x, and its width in cells, for a Layout with or without a
121   time column; a running reply's mark takes cells after it. */
122const railX = (hasTime: boolean) => (hasTime ? TIME_CELLS * CELL_PX : 0) + 6
123const railCells = (hasTime: boolean, isRunning: boolean) => (hasTime ? TIME_CELLS : 0) + RAIL_CELLS + (isRunning ? RUNNING_MARK : 0)
124
125/* What an entry hangs on the rail, its first line `y` px down: its time
126   where the Layout has a time column, a ring for a User input or a branch
127   for an Agent reply (hollow and dashed with no Jump target), and a running
128   reply's mark. */
129function nodeOf(entry: Entry, hasTime: boolean, y: number): string {
130  const x = railX(hasTime)
131  const cy = y + ROW_PX / 2
132  const isOff = entry.isOff === true
133  const parts: string[] = []
134  if (hasTime && entry.time !== '') parts.push(`<text class="t" x="0" y="${cy + 4}">${xml(entry.time)}</text>`)
135  if (entry.side === 'user') {
136    parts.push(`<circle class="l" cx="${px(x)}" cy="${cy}" r="5"/>`)
137    parts.push(`<circle class="${isOff ? 'o' : 'n'}" cx="${px(x)}" cy="${cy}" r="4"/>`)
138  } else {
139    parts.push(isOff ? `<rect class="o" x="${px(x)}" y="${cy}" width="9" height="0.1"/>` : `<rect class="l" x="${px(x)}" y="${cy - 0.75}" width="9" height="1.5"/>`)
140  }
141  if (entry.isRunning) parts.push(`<circle class="r" cx="${px(x + RAIL_CELLS * CELL_PX)}" cy="${cy}" r="4.5"/>`)
142  return parts.join('')
143}
144
145/* A run of entries the rail goes down unbroken, and whether a blank row
146   follows each, the rail going on through it; `rows` the rows it takes. */
147type Run = { items: { entry: Entry; hasGap: boolean }[]; isStart: boolean; rows: number }
148
149/* The host refuses the whole tree for an Svg over 4,096 px either way:
150   the rows a rail run may take before another starts. */
151const SVG_MAX_PX = 4096
152const RUN_ROWS = Math.floor(SVG_MAX_PX / ROW_PX)
153
154/* One rail beside a run: down all its rows, starting at the first entry's
155   ring when the run starts the drawn part or follows a rule, with each
156   entry's node at its first line. One Svg for the run, so its style sheet
157   is written once, not once per entry. */
158function railSvg(run: Run, hasTime: boolean): { source: string; width: number; height: number } {
159  let y = 0
160  const nodes: string[] = []
161  for (const { entry, hasGap } of run.items) {
162    nodes.push(nodeOf(entry, hasTime, y))
163    y += (entry.lines.length + (hasGap ? 1 : 0)) * ROW_PX
164  }
165  const first = run.items[0]?.entry
166  const top = run.isStart && first?.side === 'user' ? ROW_PX / 2 : 0
167  const width = railCells(hasTime, run.items.some(i => i.entry.isRunning)) * CELL_PX
168  const x = railX(hasTime)
169  const line = `<rect class="l" x="${px(x - 0.75)}" y="${top}" width="1.5" height="${y - top}"/>`
170  return { source: svgOf(width, y, RAIL_STYLE, line + nodes.join('')), width, height: y }
171}
172
173/* A day's rule across the body: the day, dim, then a thin line. */
174function ruleSvg(day: string, width: number): string {
175  const end = textCells(day) * 6.4 + 10
176  return svgOf(
177    width,
178    ROW_PX,
179    RULE_STYLE,
180    `<text class="t" x="0" y="${ROW_PX / 2 + 4}">${xml(day)}</text><rect class="l" x="${px(end)}" y="${ROW_PX / 2}" width="${px(Math.max(0, width - end))}" height="1"/>`,
181  )
182}
183
184/* The ✕ closing the preview, a plain Button with no hotkey. */
185function closeButton(elements: ElementTable, onClose: () => Promise<void>): RenderElement {
186  const { Button } = elements
187  return (
188    <Button key={PREVIEW_CONTROL} plain dimColor onPress={() => onClose()}>
189      ✕
190    </Button>
191  )
192}
193
194/* The preview as on the terminal while it is open, its hotkeys gone, set
195   apart from the list in a Box with a rounded, dim border: `Preview` and
196   the ✕ closing it on its first row, the cut line a Button expanding it. */
197function drawPreview(elements: ElementTable, preview: PreviewView): RenderElement {
198  const { Box, Text, Button } = elements
199  const body = preview.body
200  return (
201    <Box key="preview.box" flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginY={1}>
202      <Box flexDirection="row" justifyContent="space-between">
203        <Text bold>{words.preview}</Text>
204        {closeButton(elements, preview.onClose)}
205      </Box>
206      {body === undefined ? null : previewContent(elements, body, preview.actions, false)}
207      {body === undefined || body.more === 0 ? null : (
208        <Button key={MORE_CONTROL} plain dimColor onPress={() => preview.onMore()}>
209          {words.moreLines(body.more) + words.expand}
210        </Button>
211      )}
212    </Box>
213  )
214}
215
216/* A compaction's rule across the body, like the day's but dashed: its
217   words, dim, then a dashed line. */
218function compactionSvg(label: string, width: number): string {
219  const end = textCells(label) * 6.4 + 10
220  return svgOf(
221    width,
222    ROW_PX,
223    RULE_STYLE + '.c{stroke:#d3d2cd;stroke-width:1;stroke-dasharray:3 3}@media (prefers-color-scheme: dark){.c{stroke:#41413e}}',
224    `<text class="t" x="0" y="${ROW_PX / 2 + 4}">${xml(label)}</text><line class="c" x1="${px(end)}" y1="${ROW_PX / 2 + 0.5}" x2="${px(width)}" y2="${ROW_PX / 2 + 0.5}"/>`,
225  )
226}
227
228/* The list's share of a tree's 100,000 serialized characters on the
229   desktop: what the drawn part of the list may take. A Turn group of two
230   two-line sides costs about 1,100 (its Buttons most of it, the rail run's
231   style sheet written once per run), so this draws about 45. The rest stays
232   under 30,000: the head and the search row about 2,000, the strip up to
233   about 8,000 (120 bars), the preview (its Markdown up to 10,000, its text
234   and Buttons) and the notes, with room to spare for the reckoning's error
235   and the first run's style sheet. */
236export const LIST_BUDGET = 50_000
237
238/* What an element costs serialized beyond its text and Svg source,
239   roughly: its type, key and props; a Button's handle too. */
240const ELEMENT_CHARS = 70
241const BUTTON_CHARS = 130
242/* A string as it serializes: quoted, its quotes and backslashes escaped. */
243const quoted = (s: string) => JSON.stringify(s).length
244/* A rail run's own cost beyond its entries' nodes: the Svg, its style
245   sheet, its line, the row and column Boxes. */
246const RUN_CHARS = quoted(svgOf(99.9, 999, RAIL_STYLE, '<rect class="l" x="99.9" y="9.5" width="1.5" height="999"/>')) + ELEMENT_CHARS * 3
247
248/* Whether a rail run starts at entry `i`: after a rule, a compaction below
249   the entry before, or at a running reply, whose mark widens its rail. */
250const startsRun = (entries: readonly Entry[], i: number) => isRuleBefore(entries, i) || entries[i - 1]?.compactionAfter !== undefined || entries[i]?.isRunning === true
251
252/* The characters entry `i` adds to the tree as drawDesktopPane draws it:
253   its rules above, its node on the rail (and a run's own cost when one
254   starts there, or its share of the runs a long stretch is cut into), its
255   lines, its gap and rule after. The run starting the drawn part is not
256   reckoned; the budget leaves room for it. */
257function entryChars(entries: readonly Entry[], i: number, hasTime: boolean, width: number): number {
258  const entry = entries[i]
259  if (entry === undefined) return 0
260  const rows = entry.lines.length + (entry.gapAfter ? 1 : 0)
261  let chars = nodeOf(entry, hasTime, 999).length + (startsRun(entries, i) ? RUN_CHARS : Math.ceil((RUN_CHARS * rows) / RUN_ROWS))
262  const rule = dayRuleBefore(entries, i)
263  if (rule !== undefined) chars += ELEMENT_CHARS + quoted(ruleSvg(rule, width)) + quoted(rule)
264  for (const rule of [entry.compactionBefore, entry.compactionAfter]) {
265    if (rule === undefined) continue
266    const label = compactionLabel(rule)
267    chars += ELEMENT_CHARS + quoted(compactionSvg(label, width)) + quoted(label)
268  }
269  for (const line of entry.lines) chars += BUTTON_CHARS + quoted(line.text) + (line.aside === undefined ? 0 : ELEMENT_CHARS * 2 + quoted(line.aside))
270  if (entry.gapAfter) chars += ELEMENT_CHARS
271  return chars
272}
273
274/* The list as span.ts measures it on the desktop, for the Layout and the
275   body's width in cells. */
276export function desktopList(entries: readonly Entry[], layout: Layout, bodyColumns: number): SpanList {
277  const hasTime = layout !== 'card'
278  const width = Math.round(bodyColumns * CELL_PX)
279  return {
280    length: entries.length,
281    cost: i => entryChars(entries, i, hasTime, width),
282    groupOf: i => entries[i]?.groupIndex ?? -1,
283  }
284}
285
286export function drawDesktopPane(elements: ElementTable, view: DesktopView): RenderElement {
287  const { Box, Text, Button } = elements
288  const hasSvg = 'Svg' in elements
289  const svg = (source: string, alt: string, width: number, height: number) =>
290    'Svg' in elements ? <elements.Svg source={source} alt={alt} width={px(width)} height={height} /> : null
291  const width = Math.round(view.bodyColumns * CELL_PX)
292  /* Native Buttons, the chosen one the primary: one height with the
293     Layout's Select. */
294  const filters = (
295    <Box flexDirection="row" columnGap={1}>
296      {FILTERS.map(f => (
297        <Button
298          key={f.filter === 'all' ? FIRST_CONTROL : `f-${f.filter}`}
299          variant={f.filter === view.filter ? 'primary' : 'secondary'}
300          onPress={() => view.onFilter(f.filter)}
301        >
302          {filterLabel(f.filter)}
303        </Button>
304      ))}
305    </Box>
306  )
307  const layoutControl =
308    'Select' in elements ? (
309      <elements.Select
310        key={LAYOUT_CONTROL}
311        options={LAYOUTS.map(l => ({ value: l, label: layoutName(l) }))}
312        value={view.layout}
313        onSelect={value => void view.onLayout(layoutOf(value))}
314      />
315    ) : (
316      <Button key={LAYOUT_CONTROL} plain onPress={() => view.onLayout()}>
317        {layoutLabel(view.layout)}
318      </Button>
319    )
320  const strip =
321    hasSvg && view.steps.length > 0
322      ? (() => {
323          const map = minimapOf(view.steps, view.runningGroup, width, view.stripLabels, { matches: view.matches, percent: view.contextPercent })
324          return svg(map.source, map.alt, width, MINIMAP_HEIGHT)
325        })()
326      : null
327  const searchRow =
328    'Input' in elements ? (
329      <Box flexDirection="row" alignItems="center" columnGap={1}>
330        <Box flexGrow={1} flexShrink={1} minWidth={8}>
331          <elements.Input
332            key={SEARCH_FIELD}
333            placeholder={words.searchPlaceholder}
334            value={view.search.query}
335            submitLabel={words.searchShow}
336            onInput={(value: string) => view.search.onInput(value)}
337            onSubmit={(value: string) => view.search.onSubmit(value)}
338          />
339        </Box>
340        {/* The field gives way in a narrow pane; the count and ✕ keep
341            their width, on one line. */}
342        {view.search.count === undefined && view.search.query === '' ? null : (
343          <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
344            {matchCount(elements, view.search.count)}
345            {view.search.query === '' ? null : (
346              <Button key={CLEAR_CONTROL} onPress={() => view.search.onClear()}>
347                ✕
348              </Button>
349            )}
350          </Box>
351        )}
352      </Box>
353    ) : null
354  const moveButton = (key: string, label: string, move: ListMove) => (
355    <Button key={key} plain dimColor onPress={() => move.onPress()}>
356      {label}
357    </Button>
358  )
359  const hasTime = view.layout !== 'card'
360  /* Not pressable, breaking the rail as the day's rule does. */
361  const compactionRow = (rule: CompactionRule) => {
362    const label = compactionLabel(rule)
363    return hasSvg ? svg(compactionSvg(label, width), label, width, ROW_PX)! : <Text dimColor>{label}</Text>
364  }
365  /* The preview goes under its group's last drawn entry, or at the top
366     when none of it is drawn (left out by the filter or the query). */
367  let previewAfter = -1
368  if (view.preview !== undefined) for (let i = view.window.start; i < view.window.end; i++) if (view.entries[i]?.groupIndex === view.previewGroup) previewAfter = i
369  const preview = view.preview === undefined ? null : drawPreview(elements, view.preview)
370  const rows: RenderElement[] = []
371  if (preview !== null && previewAfter === -1) rows.push(preview)
372  let run: Run | undefined
373  const endRun = () => {
374    if (run === undefined || run.items.length === 0) return
375    const first = run.items[0]!.entry
376    const rail = railSvg(run, hasTime)
377    rows.push(
378      <Box key={`row-${first.key}`} flexDirection="row">
379        {svg(rail.source, words.rail, rail.width, rail.height)}
380        <Box flexDirection="column" flexShrink={1}>
381          {run.items.flatMap(({ entry, hasGap }) => {
382            const press = () => view.onPress(entry)
383            const line = (text: string, n: number, dim: boolean) => (
384              <Button key={n === 0 ? entry.key : `${entry.key}.${n}`} plain dimColor={dim} onPress={press}>
385                {text || ' '}
386              </Button>
387            )
388            const lines = entry.lines.map((l, n) =>
389              l.aside === undefined ? (
390                line(l.text, n, l.dim)
391              ) : (
392                <Box flexDirection="row" width={view.bodyColumns - RAIL_CELLS} justifyContent="space-between">
393                  {line(l.text, n, l.dim)}
394                  <Text dimColor>{l.aside}</Text>
395                </Box>
396              ),
397            )
398            return hasGap ? [...lines, <Text> </Text>] : lines
399          })}
400        </Box>
401      </Box>,
402    )
403    run = undefined
404  }
405  for (let i = view.window.start; i < view.window.end; i++) {
406    const entry = view.entries[i]
407    if (!entry) continue
408    const rule = dayRuleBefore(view.entries, i)
409    const isRuled = rule !== undefined || entry.compactionBefore !== undefined
410    if (isRuled || entry.isRunning) endRun()
411    if (rule !== undefined) rows.push(hasSvg ? svg(ruleSvg(rule, width), rule, width, ROW_PX)! : <Text dimColor>{rule}</Text>)
412    if (entry.compactionBefore !== undefined) rows.push(compactionRow(entry.compactionBefore))
413    const hasNext = entry.gapAfter && i < view.window.end - 1
414    const isBeforeRule = hasNext && isRuleBefore(view.entries, i + 1)
415    const hasGap = hasNext && !isBeforeRule && i !== previewAfter
416    const height = entry.lines.length + (hasGap ? 1 : 0)
417    if (run !== undefined && run.rows + height > RUN_ROWS) endRun()
418    run ??= { items: [], isStart: i === view.window.start || isRuled, rows: 0 }
419    run.items.push({ entry, hasGap })
420    run.rows += height
421    if (i === previewAfter) {
422      endRun()
423      rows.push(preview!)
424    } else if (isBeforeRule) {
425      endRun()
426      rows.push(<Text> </Text>)
427    }
428    if (entry.compactionAfter !== undefined) {
429      endRun()
430      rows.push(compactionRow(entry.compactionAfter))
431    }
432  }
433  endRun()
434  const head = (
435    <Box flexDirection="row" justifyContent="space-between" alignItems="center">
436      {filters}
437      {layoutControl}
438    </Box>
439  )
440  if (view.expanded !== undefined) {
441    const expanded = view.expanded
442    return (
443      <Box flexDirection="column" gap={1}>
444        {head}
445        {searchRow}
446        <Box flexDirection="column">
447          <Box flexDirection="row" justifyContent="space-between">
448            {/* Under the cut line's key: the pressed Button stays in the
449                tree, so the pane keeps the focus (0.2 spec §8). */}
450            <Button key={MORE_CONTROL} plain onPress={() => expanded.onBack()}>
451              {words.back}
452            </Button>
453            {closeButton(elements, expanded.onClose)}
454          </Box>
455          {expandedContent(elements, expanded, false)}
456        </Box>
457      </Box>
458    )
459  }
460  return (
461    <Box flexDirection="column" gap={1}>
462      {head}
463      {searchRow}
464      {strip}
465      {view.isEarlierElsewhere ? <Text dimColor>{words.earlierElsewhere}</Text> : null}
466      {view.notice === undefined ? null : <Text dimColor>{view.notice}</Text>}
467      <Box flexDirection="column">
468        {view.earlier === undefined ? null : moveButton(EARLIER_CONTROL, words.earlierTurns(view.earlier.count), view.earlier)}
469        {rows}
470        {rows.length === 0 ? null : (
471          <Box flexDirection="row" columnGap={3} marginTop={1}>
472            {view.later === undefined ? null : moveButton(LATER_CONTROL, words.laterTurns(view.later.count), view.later)}
473            <Button key={TOP_CONTROL} plain dimColor onPress={() => view.onTop()}>
474              {words.backToTop}
475            </Button>
476          </Box>
477        )}
478      </Box>
479      {view.alert === undefined ? null : <Text color="warning">{`⚠ ${view.alert.text}`}</Text>}
480      {view.why === undefined ? null : <Text dimColor>{view.why}</Text>}
481      {strip === null && view.count !== undefined ? <Text dimColor>{words.turns(view.count)}</Text> : null}
482    </Box>
483  )
484}
485
hooks/span.ts 87 lines
1/* The part of the list the desktop draws (0.2 spec §8, Scrolling). There the
2   host scrolls the Pane's whole tree, and a tree is bounded at 100,000
3   serialized characters, Svg sources included, so the list is not windowed
4   by rows: it draws a run of entries whose characters fit a budget, the
5   newest unless the person moved it to earlier turns. A run starts and ends
6   on a Turn group's edge, so its earlier and later parts count whole
7   groups. */
8
9import type { TocWindow } from './window'
10
11/* What a run draws of an entry list: `cost(i)` the characters entry i
12   draws, `groupOf(i)` its Turn group. */
13export type SpanList = {
14  length: number
15  cost: (i: number) => number
16  groupOf: (i: number) => number
17}
18
19/* The run whose last entry is `end - 1`, as many entries back as fit
20   `budget`, starting on a group's first entry; at least that last entry's
21   group, whatever it costs. */
22export function spanEnding(list: SpanList, end: number, budget: number): TocWindow {
23  const last = Math.max(0, Math.min(end, list.length))
24  let start = last
25  let used = 0
26  while (start > 0) {
27    const next = used + list.cost(start - 1)
28    if (next > budget && start < last) break
29    used = next
30    start -= 1
31  }
32  /* A group cut at the top is left out, unless it is the only one. */
33  const cut = start
34  while (start > 0 && start < last && list.groupOf(start) === list.groupOf(start - 1)) start += 1
35  if (start === last) {
36    start = cut
37    while (start > 0 && list.groupOf(start) === list.groupOf(start - 1)) start -= 1
38  }
39  return { start, end: last, maxStart: start }
40}
41
42/* The run ending at `end - 1` as spanEnding draws it, unless that reaches
43   the list's first entry with budget to spare: then the run from the first
44   entry filling the budget, so the earliest part is drawn as full as any
45   other (0.2 spec §8). */
46export function spanEndingFilled(list: SpanList, end: number, budget: number): TocWindow {
47  const run = spanEnding(list, end, budget)
48  return run.start === 0 ? spanStarting(list, 0, budget) : run
49}
50
51/* The run whose first entry is `start`, as many entries on as fit
52   `budget`, ending on a group's last entry; at least `start`'s group. */
53export function spanStarting(list: SpanList, start: number, budget: number): TocWindow {
54  const first = Math.max(0, Math.min(start, list.length))
55  let end = first
56  let used = 0
57  while (end < list.length) {
58    const next = used + list.cost(end)
59    if (next > budget && end > first) break
60    used = next
61    end += 1
62  }
63  const cut = end
64  while (end < list.length && end > first && list.groupOf(end) === list.groupOf(end - 1)) end -= 1
65  if (end === first) {
66    end = cut
67    while (end < list.length && list.groupOf(end) === list.groupOf(end - 1)) end += 1
68  }
69  return { start: first, end, maxStart: first }
70}
71
72/* The Turn groups among entries `from` to `to` (one past). */
73export function groupsIn(list: SpanList, from: number, to: number): number {
74  let n = 0
75  for (let i = from; i < to; i++) if (i === from || list.groupOf(i) !== list.groupOf(i - 1)) n += 1
76  return n
77}
78
79/* One past the last entry of the list's groups up to `group`; the list's
80   end for undefined (the newest). */
81export function endOfGroup(list: SpanList, group: number | undefined): number {
82  if (group === undefined) return list.length
83  let end = list.length
84  while (end > 0 && list.groupOf(end - 1) > group) end -= 1
85  return end
86}
87
hooks/bars.ts 91 lines
1/* The terminal's Minimap: a row of one-cell Buttons over the list, one cell
2   per Turn group, as tall (eight levels of block) as the steps of its Agent
3   reply, a row of times under it and a blank row. The cells the groups do
4   not reach (the conversation has not got there) are drawn as a dim
5   baseline, so the row is as wide as the pane. Past the cells the pane's width holds,
6   a cell stands for several consecutive groups, as tall as the most steps
7   among them. A Button has no colour, so the cell holding the Current
8   position is told by the others being dim. */
9
10import { textCells } from './cells'
11import type { StripLabels } from './minimap'
12
13const LEVELS = '▁▂▃▄▅▆▇█'
14/* Where a cell with nothing to press shows: the lowest block. */
15const FLOOR = LEVELS[0] ?? '▁'
16
17/* The rows the Minimap takes: the bars, the times, a blank row to part it
18   from the list. */
19export const BAR_ROWS = 3
20/* Fewer groups than this draw no Minimap. */
21export const MIN_BAR_GROUPS = 2
22/* The list keeps at least this many rows, or the Minimap leaves. */
23export const MIN_LIST_ROWS = 8
24
25/* A cell's Button key: `m.<cell>`. */
26export const barKey = (cell: number) => `m.${cell}`
27export const isBarKey = (key: unknown) => typeof key === 'string' && /^m\.\d+$/.test(key)
28
29/* One cell: the groups `from` up to (not including) `to`. */
30export type Bar = { glyph: string; from: number; to: number }
31
32/* A cell's block: none for no steps, else a square-root scale up to the
33   most steps of any group, so a long outlier leaves the rest low but not
34   all alike. */
35function levelOf(steps: number, most: number): string {
36  if (steps <= 0 || most <= 0) return FLOOR
37  return LEVELS[1 + Math.min(6, Math.floor(6 * Math.sqrt(steps / most)))] ?? FLOOR
38}
39
40/* The cells for groups with these steps, at most `room` of them. `isOff`
41   says a group has no Jump target; a cell of such groups only is the
42   floor. */
43export function barsOf(steps: readonly number[], room: number, isOff: (group: number) => boolean): Bar[] {
44  const n = steps.length
45  if (n === 0 || room < 1) return []
46  const per = Math.max(1, Math.ceil(n / room))
47  const most = steps.reduce((a, b) => Math.max(a, b), 0)
48  const bars: Bar[] = []
49  for (let from = 0; from < n; from += per) {
50    const to = Math.min(n, from + per)
51    let high = 0
52    let isAllOff = true
53    for (let g = from; g < to; g++) {
54      high = Math.max(high, steps[g] ?? 0)
55      if (!isOff(g)) isAllOff = false
56    }
57    bars.push({ glyph: isAllOff ? FLOOR : levelOf(high, most), from, to })
58  }
59  return bars
60}
61
62/* The cell holding a group. */
63export function barOfGroup(bars: readonly Bar[], group: number | undefined): number | undefined {
64  if (group === undefined) return undefined
65  const at = bars.findIndex(bar => group >= bar.from && group < bar.to)
66  return at < 0 ? undefined : at
67}
68
69/* The group a press on a cell jumps to: the first of its groups that has a
70   Jump target, else the first, whose press says why it cannot. */
71export function barTarget(bar: Bar, isOff: (group: number) => boolean): number {
72  for (let g = bar.from; g < bar.to; g++) if (!isOff(g)) return g
73  return bar.from
74}
75
76/* Whether the Minimap is drawn: two groups or more, and the list left at
77   least MIN_LIST_ROWS rows without them taken. */
78export function hasBars(groups: number, listRows: number): boolean {
79  return groups >= MIN_BAR_GROUPS && listRows - BAR_ROWS >= MIN_LIST_ROWS
80}
81
82/* The times under the cells: the first group's at the left, the last
83   group's at the right. `first` is its day and time; when both do not fit
84   the width, the day goes first. */
85export function barLabels({ first, last }: StripLabels, width: number): { left: string; right: string; gap: number } {
86  const fits = (left: string) => textCells(left) + (left && last ? 1 : 0) + textCells(last) <= width
87  const shortened = first.slice(first.lastIndexOf(' ') + 1)
88  const left = fits(first) ? first : fits(shortened) ? shortened : ''
89  return { left, right: last, gap: Math.max(0, width - textCells(left) - textCells(last)) }
90}
91
hooks/minimap.ts 87 lines
1/* The desktop's strip of bars over the list: one bar per Turn group, as
2   tall as the steps its agent took, a running one's in Claude's colour,
3   pulsing; under the bars the first group's day and time, the count and the
4   last group's time. An SVG the surface draws as an image; it carries its
5   own light and dark colours and stops pulsing under reduced motion. Past
6   MAX_BARS groups a bar stands for several, as tall as the most steps among
7   them. The desktop reports no Current position, so no bar is lit; while
8   a query is set the bars holding a match are drawn in the accent colour
9   and the rest dim. The context use, when the host gives one, follows the
10   count on the line under the bars. */
11
12import { textCells } from './cells'
13import { clockTime, dayLabel } from './entries'
14import { text as words, turnsAndContextUse } from './text'
15
16const BARS_HEIGHT = 36
17const LABEL_HEIGHT = 20
18export const MINIMAP_HEIGHT = BARS_HEIGHT + LABEL_HEIGHT
19const MAX_BARS = 120
20/* Fewer groups than this still draw bars this narrow, from the left. */
21const MIN_SLOTS = 10
22const MOST = 32
23const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','PingFang SC',sans-serif"
24
25export type Minimap = { source: string; alt: string }
26export type StripLabels = { first: string; last: string }
27
28const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
29
30/* The first group's day (never as today) and time, and the last group's
31   `HH:MM`; '' where the transcript has no time. */
32export function stripLabels(first: string | undefined, last: string | undefined, nowMs: number): StripLabels {
33  const day = dayLabel(first, nowMs, false)
34  return { first: [day, clockTime(first)].filter(Boolean).join(' '), last: clockTime(last) }
35}
36
37/* What the strip marks besides the bars: a query's matching groups (in
38   order; undefined with no query) and the context use. */
39export type StripMarks = { matches?: readonly number[] | undefined; percent?: number | undefined }
40
41/* A label's width in px at 11 px: a CJK character a full em, others a
42   little over half. */
43const labelPx = (s: string) => [...s].reduce((w, ch) => w + (textCells(ch) === 2 ? 11 : 6.3), 0)
44
45/* The middle label: the count, then the context use, long while the three
46   labels fit the width with room between them, else short, else none. */
47function middleLabel(n: number, labels: StripLabels, width: number, percent: number | undefined): string {
48  const sides = 2 * Math.max(labelPx(labels.first), labelPx(labels.last))
49  return turnsAndContextUse(n, percent, middle => sides + labelPx(middle) + 24 <= width)
50}
51
52export function minimapOf(steps: readonly number[], running: number | undefined, width: number, labels: StripLabels, marks: StripMarks = {}): Minimap {
53  const n = steps.length
54  const matched = marks.matches === undefined ? undefined : new Set(marks.matches)
55  const per = Math.max(1, Math.ceil(n / MAX_BARS))
56  const bars = Math.ceil(n / per)
57  const slot = width / Math.max(MIN_SLOTS, bars)
58  const rects: string[] = []
59  for (let b = 0; b < bars; b++) {
60    const from = b * per
61    const to = Math.min(n, from + per)
62    let most = 0
63    for (let i = from; i < to; i++) most = Math.max(most, steps[i] ?? 0)
64    const h = most === 0 ? 4 : Math.min(MOST, 6 + most * 3)
65    let isMatched = false
66    for (let i = from; i < to && matched !== undefined && !isMatched; i++) isMatched = matched.has(i)
67    const cls = running !== undefined && running >= from && running < to ? 'r' : matched === undefined ? 'b' : isMatched ? 'm' : 'd'
68    rects.push(`<rect class="${cls}" x="${(b * slot + slot * 0.18).toFixed(1)}" y="${BARS_HEIGHT - h}" width="${Math.max(1, slot * 0.64).toFixed(1)}" height="${h}" rx="3"/>`)
69  }
70  const y = BARS_HEIGHT + 15
71  const text = (x: number, anchor: string, s: string) =>
72    `<text class="t" x="${x.toFixed(1)}" y="${y}" text-anchor="${anchor}" font-size="11" font-family="${FONT}">${xml(s)}</text>`
73  const style =
74    '<style>.b{fill:#dcdbd6}.d{fill:#dcdbd6;opacity:.45}.m{fill:#d77757}.r{fill:#d77757;animation:p 1.4s ease-in-out infinite}.t{fill:#8a8985}' +
75    '@keyframes p{50%{opacity:.35}}' +
76    '@media (prefers-color-scheme: dark){.b,.d{fill:#3b3b38}.t{fill:#8f8e89}}' +
77    '@media (prefers-reduced-motion: reduce){.r{animation:none}}</style>'
78  const source =
79    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${MINIMAP_HEIGHT}" viewBox="0 0 ${width} ${MINIMAP_HEIGHT}">${style}${rects.join('')}` +
80    text(0, 'start', labels.first) +
81    text(width / 2, 'middle', middleLabel(n, labels, width, marks.percent)) +
82    text(width, 'end', labels.last) +
83    '</svg>'
84  const alt = words.stripAlt(n, per)
85  return { source, alt }
86}
87
hooks/groups.ts 246 lines
1/* Turn groups: a User input and what the main agent did from it until the
2   next User input. This file holds the rule for which chain rows open a
3   group, what the group shows of its User input, and what it counts of its
4   Agent reply. */
5
6import type { TranscriptRow } from './transcript'
7import { text as words } from './text'
8import { newTally, settle, tallyRow, typedPrompt } from './facts'
9import type { GroupFacts, Tally } from './facts'
10
11/* How the User input was given: a prompt (text or images), a slash command
12   (a prompt-type one or a local one), or a `!` shell command. */
13export type InputKind = 'prompt' | 'command' | 'shell'
14
15export type TurnGroup = {
16  /* The uuid of the row that opened the group: the User input's row. */
17  openerUuid: string
18  kind: InputKind
19  /* The User input's text as the TOC shows it. */
20  userText: string
21  /* The User input row's timestamp (UTC ISO), when the row has one. */
22  timestamp: string | undefined
23  /* Every chain row in the group, opener first. */
24  rowUuids: string[]
25  /* Whether the file holds an Agent reply for the group: the agent wrote in
26     it, or the person interrupted it. A local command, or a shell command
27     the agent did no work on, has none; neither has a prompt the file holds
28     nothing after yet (a running turn shows one regardless). */
29  hasAgentReply: boolean
30  /* The main agent's tool calls in the group; a subagent is one. */
31  steps: number
32  /* The last text the agent wrote after its last tool call, the parts of
33     one message joined, and the row its first part is on. */
34  reply: { text: string; uuid: string } | undefined
35  /* The group's last row the agent wrote. */
36  lastAgentUuid: string | undefined
37  /* How the group's turn ended as the file has it: an interruption marker
38     in the group, or the agent's last row an API error. */
39  ending: 'done' | 'interrupted' | 'error'
40  /* What the preview, search and Reuse read beyond the entries (facts.ts). */
41  facts: GroupFacts
42}
43
44type Block = { type?: unknown; text?: unknown }
45
46function blocksOf(content: unknown): Block[] {
47  return Array.isArray(content) ? content.filter((b): b is Block => typeof b === 'object' && b !== null) : []
48}
49
50function textOf(content: unknown): string {
51  if (typeof content === 'string') return content
52  return blocksOf(content)
53    .filter(b => b.type === 'text' && typeof b.text === 'string')
54    .map(b => b.text as string)
55    .join('\n')
56}
57
58const hasBlock = (content: unknown, type: string) => blocksOf(content).some(b => b.type === type)
59
60/* The text a row carries: a user row's message, a system row's own. */
61function rowText(row: TranscriptRow): string {
62  return row.type === 'system' ? (typeof row.content === 'string' ? row.content : '') : textOf(row.message?.content)
63}
64
65const isCommandText = (text: string) => /^\s*<command-(name|message)>/.test(text) && text.includes('<command-name>')
66const isShellText = (text: string) => /^\s*<bash-input>/.test(text)
67
68/* The kind of User input a chain row is, or undefined when the row does not
69   open a Turn group: tool results, injected reminders and hook rows
70   (`isMeta`), compact summaries, command and shell output, interruptions,
71   notifications and anything else nobody typed. */
72export function inputKindOf(row: TranscriptRow): InputKind | undefined {
73  if (row.type === 'system') return row.subtype === 'local_command' && isCommandText(rowText(row)) ? 'command' : undefined
74  if (row.type !== 'user' || row.isMeta || row.isCompactSummary) return undefined
75  const content = row.message?.content
76  if (hasBlock(content, 'tool_result')) return undefined
77  const text = rowText(row)
78  if (isShellText(text)) return 'shell'
79  if (isCommandText(text)) return 'command'
80  if (row.origin?.kind !== 'human') return undefined
81  return text.trim() || hasBlock(content, 'image') ? 'prompt' : undefined
82}
83
84/* Whether a chain row is the output Claude Code itself gave a local slash
85   command or a `!` shell command: the input was answered without the
86   model. */
87export function isLocalOutput(row: TranscriptRow): boolean {
88  if (row.type !== 'user' && row.type !== 'system') return false
89  return /^\s*<(local-command-stdout|local-command-stderr|bash-stdout|bash-stderr)>/.test(rowText(row))
90}
91
92/* Whether a chain row is a User input that opens a Turn group. */
93export function opensGroup(row: TranscriptRow): boolean {
94  return inputKindOf(row) !== undefined
95}
96
97const tagged = (text: string, tag: string) => new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text)?.[1] ?? ''
98const collapse = (text: string) => text.replace(/\s+/g, ' ').trim()
99
100/* Text with what does not read as prose taken out: fenced code, images and
101   their `[Image #n]` placeholders, Markdown marks, the tags around pasted
102   content; then whitespace collapsed. A text that was nothing but code keeps
103   its code. */
104export function plainText(text: string): string {
105  const prose = text
106    .replace(/<\/?pasted_content\b[^>]*>/g, ' ')
107    .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
108    .replace(/\[Image #\d+\]/g, ' ')
109  const plain = prose
110    .replace(/```[^\n]*\n?[\s\S]*?(```|$)/g, ' ')
111    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
112    .replace(/^[ \t]*(#{1,6}|>+|[-*+])[ \t]+/gm, '')
113    .replace(/(\*\*|__|~~)(\S(?:[\s\S]*?\S)?)\1/g, '$2')
114    .replace(/(^|[^\p{L}\p{N}*_])([*_])([\p{L}\p{N}](?:[^\n*_]*?[\p{L}\p{N}])?)\2(?![\p{L}\p{N}])/gu, '$1$3')
115    .replace(/`([^`\n]*)`/g, '$1')
116  const shown = collapse(plain)
117  if (shown) return shown
118  return collapse(prose.replace(/```[^\n]*/g, ' '))
119}
120
121/* The User input as the TOC lists it: a slash command as `/name args`, a
122   shell command as `!cmd`, a prompt as plain text, or a note that it held
123   only images. */
124export function userTextOf(row: TranscriptRow, kind: InputKind): string {
125  const text = rowText(row)
126  if (kind === 'command') {
127    const name = tagged(text, 'command-name').trim()
128    const args = collapse(tagged(text, 'command-args'))
129    return `${name.startsWith('/') ? name : `/${name}`}${args ? ` ${args}` : ''}`
130  }
131  if (kind === 'shell') return `!${collapse(tagged(text, 'bash-input'))}`
132  const shown = plainText(text)
133  if (shown) return shown
134  return hasBlock(row.message?.content, 'image') ? words.imageOnly : ''
135}
136
137const isInterruption = (row: TranscriptRow) => row.type === 'user' && /^\s*\[Request interrupted by user/.test(rowText(row))
138const isApiError = (row: TranscriptRow) => (row as { isApiErrorMessage?: unknown }).isApiErrorMessage === true
139
140/* The User input as typed (facts.ts): a command's and a shell command's
141   text with their own spacing, a prompt's with code and pasted content; an
142   input of only images is empty, its words drawn only at display time. */
143function typedInputOf(row: TranscriptRow, kind: InputKind): { text: string; hasImages: boolean } {
144  const text = rowText(row)
145  const hasImages = hasBlock(row.message?.content, 'image')
146  if (kind === 'command') {
147    const name = tagged(text, 'command-name').trim()
148    const args = tagged(text, 'command-args').trim()
149    return { text: `${name.startsWith('/') ? name : `/${name}`}${args ? ` ${args}` : ''}`, hasImages }
150  }
151  if (kind === 'shell') return { text: `!${tagged(text, 'bash-input').trim()}`, hasImages }
152  const typed = typedPrompt(text)
153  return { text: typed, hasImages }
154}
155
156/* A text block the agent wrote after its latest tool call. */
157type Part = { uuid: string; messageId: string; text: string }
158
159/* The group being read: the group, the tool calls counted so far, the
160   text since the latest one, the latest message's text whatever came after
161   it, and the facts' tally. */
162type Open = { group: TurnGroup; toolIds: Set<string>; tail: Part[]; latest: Part[]; tally: Tally; input: { text: string; hasImages: boolean } }
163
164function openGroup(row: TranscriptRow, kind: InputKind): Open {
165  const group: TurnGroup = {
166    openerUuid: row.uuid,
167    kind,
168    userText: userTextOf(row, kind),
169    timestamp: row.timestamp,
170    rowUuids: [],
171    hasAgentReply: false,
172    steps: 0,
173    reply: undefined,
174    lastAgentUuid: undefined,
175    ending: 'done',
176    facts: settle(newTally(), { text: '', hasImages: false }, undefined, undefined),
177  }
178  return { group, toolIds: new Set(), tail: [], latest: [], tally: newTally(), input: typedInputOf(row, kind) }
179}
180
181function addRow(open: Open, row: TranscriptRow): void {
182  const { group } = open
183  group.rowUuids.push(row.uuid)
184  tallyRow(open.tally, row)
185  if (isInterruption(row)) {
186    group.ending = 'interrupted'
187    group.hasAgentReply = true
188    return
189  }
190  if (row.type !== 'assistant') return
191  group.hasAgentReply = true
192  group.lastAgentUuid = row.uuid
193  if (group.ending !== 'interrupted') group.ending = isApiError(row) ? 'error' : 'done'
194  const messageId = row.message?.id ?? row.uuid
195  blocksOf(row.message?.content).forEach((block, n) => {
196    if (block.type === 'tool_use') {
197      const id = (block as { id?: unknown }).id
198      open.toolIds.add(typeof id === 'string' ? id : `${row.uuid}#${n}`)
199      open.tail = []
200    } else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
201      const part = { uuid: row.uuid, messageId, text: block.text }
202      open.tail.push(part)
203      if (open.latest[0]?.messageId !== messageId) open.latest = []
204      open.latest.push(part)
205    }
206  })
207}
208
209const joined = (parts: readonly Part[]) => (parts.length === 0 ? undefined : parts.map(p => p.text).join('\n'))
210
211function closeGroup(open: Open): void {
212  const { group, tail } = open
213  group.steps = open.toolIds.size
214  const last = tail[tail.length - 1]
215  const parts = last === undefined ? [] : tail.filter(p => p.messageId === last.messageId)
216  group.facts = settle(open.tally, open.input, joined(parts), joined(open.latest))
217  if (!last) return
218  group.reply = { text: plainText(joined(parts) ?? ''), uuid: parts[0]?.uuid ?? last.uuid }
219}
220
221/* Where the group a uuid opened is among `groups`, which a regroup keeps;
222   -1 when none is. Looked for from the newest. */
223export function groupIndexOf(groups: readonly TurnGroup[], opener: string | undefined): number {
224  let at = groups.length - 1
225  while (at >= 0 && groups[at]!.openerUuid !== opener) at--
226  return at
227}
228
229/* The chain's Turn groups, in order. Rows before the first User input
230   belong to no group. */
231export function groupTurns(chain: readonly TranscriptRow[]): TurnGroup[] {
232  const groups: TurnGroup[] = []
233  let open: Open | undefined
234  for (const row of chain) {
235    const kind = inputKindOf(row)
236    if (kind !== undefined) {
237      if (open) closeGroup(open)
238      open = openGroup(row, kind)
239      groups.push(open.group)
240    }
241    if (open) addRow(open, row)
242  }
243  if (open) closeGroup(open)
244  return groups
245}
246
hooks/memo.ts 21 lines
1/* A one-entry memo: what a drawing worked out last, kept while what it was
2   worked out from is the same, so a redraw that changed none of it does
3   not work it out again. Keys compare by identity (`Object.is`): a key
4   that is a fresh object every drawing never matches. */
5
6export type Memo<V> = { last: { keys: readonly unknown[]; value: V } | undefined }
7
8export function newMemo<V>(): Memo<V> {
9  return { last: undefined }
10}
11
12/* The value for `keys`: the one kept when every key is the last call's,
13   else `compute()`'s, kept for next time. */
14export function remember<V>(memo: Memo<V>, keys: readonly unknown[], compute: () => V): V {
15  const last = memo.last
16  if (last !== undefined && last.keys.length === keys.length && last.keys.every((key, i) => Object.is(key, keys[i]))) return last.value
17  const value = compute()
18  memo.last = { keys, value }
19  return value
20}
21
hooks/jump.ts 184 lines
1/* Jump targets: which transcript rows a press on an entry scrolls to, in
2   the order tried. `$.ui.scroll` reaches any row the host lists, drawn or
3   not, and refuses one it does not list (rows compacted away before a
4   resume, a rewound-away branch); then the next row is tried, up to three.
5
6   - A prompt: its own row, its top at the top of the view.
7   - An Agent reply: the row its last text starts on; without one, the
8     group's last rows the agent drew (text or tool calls), latest first.
9   - A local command, a prompt-type command or a `!` shell command: the
10     host draws its row with no site to scroll to, so the nearest drawable
11     rows after it, then before it, centred, which keeps the row typed in
12     view above the one jumped to.
13
14   A side refused is drawn dim. In a session this process resumed, the host
15   lists none of the rows before the last compaction the file had then, so
16   the sides whose every target lies there are dim before anyone presses
17   them (a fork's file starts at that compaction: it has no such rows).
18
19   The scroll itself is `jump` in register.tsx, which may hold the host's
20   `$`. */
21
22import type { UiScrollBlock } from 'claude-code'
23import type { Entry, EntryLine } from './entries'
24import type { TurnGroup } from './groups'
25import { inputKindOf } from './groups'
26import { hostIdOf, isDrawn } from './sites'
27import type { RowIndex, Sites } from './sites'
28import type { TranscriptRow } from './transcript'
29
30export type JumpTarget = { uuid: string; requestId: string; block: UiScrollBlock }
31
32export const MAX_TRIES = 3
33
34/* What the pane says at its bottom after a ⚠ in the theme's warning colour
35   (text.ts): `refused` when every row of a side was refused, `compacted` for
36   a side known refused as compacted before a resume, `unscrollable` when the
37   surface cannot scroll its transcript at all, as Claude Code Desktop
38   answers (2.1.295): no row is refused, nothing is dimmed. */
39export const isUnscrollable = (deny: string) => deny.includes('transcript not scrollable here')
40/* How long it stays, unless a jump or a move of the selection clears it. */
41export const REFUSED_MS = 5000
42/* As it appears it flashes inverse this many times, lit and unlit this
43   long each, then stays lit. */
44export const FLASH_MS = 150
45export const FLASHES = 3
46
47export function refusalFrame(elapsedMs: number): { isInverse: boolean } {
48  return { isInverse: elapsedMs < FLASH_MS * FLASHES * 2 && Math.floor(elapsedMs / FLASH_MS) % 2 === 0 }
49}
50
51type Block = { type?: unknown; text?: unknown }
52
53const blocksOf = (content: unknown): Block[] => (Array.isArray(content) ? (content as Block[]) : [])
54export const hasText = (row: TranscriptRow) => blocksOf(row.message?.content).some(b => b?.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '')
55const hasCall = (row: TranscriptRow) => blocksOf(row.message?.content).some(b => b?.type === 'tool_use')
56
57/* A local command's output with something in it, as a system row or the
58   user row older hosts wrote. */
59function isCommandOutput(row: TranscriptRow): boolean {
60  const content = row.type === 'system' ? row.content : row.type === 'user' ? row.message?.content : undefined
61  if (typeof content !== 'string') return false
62  return /<local-command-stdout>[\s\S]*?\S[\s\S]*?<\/local-command-stdout>/.test(content)
63}
64
65/* A row the agent drew: an `AssistantMessage` with text, or a tool row. */
66const isAgentRow = (row: TranscriptRow) => row.type === 'assistant' && (hasText(row) || hasCall(row))
67
68/* A row the host draws at a site it can scroll to: a prompt, a row the
69   agent drew, a command's output, or any row a site was seen drawing. */
70function isDrawable(row: TranscriptRow, sites: Sites): boolean {
71  if (isDrawn(sites, row.uuid)) return true
72  if (row.type === 'user') return inputKindOf(row) === 'prompt' || isCommandOutput(row)
73  if (row.type === 'system') return isCommandOutput(row)
74  return isAgentRow(row)
75}
76
77function target(rows: RowIndex, sites: Sites, uuid: string, block: UiScrollBlock): JumpTarget {
78  return { uuid, requestId: hostIdOf(sites, rows, uuid), block }
79}
80
81/* The nearest drawable rows after the group's opener, then before it. */
82function neighbours(group: TurnGroup, rows: RowIndex, sites: Sites): JumpTarget[] {
83  const at = rows.order.get(group.openerUuid)
84  if (at === undefined) return []
85  const found: JumpTarget[] = []
86  const take = (i: number) => {
87    const row = rows.chain[i]
88    if (row && isDrawable(row, sites)) found.push(target(rows, sites, row.uuid, 'center'))
89    return found.length < MAX_TRIES
90  }
91  for (let i = at + 1; i < rows.chain.length; i++) if (!take(i)) return found
92  for (let i = at - 1; i >= 0; i--) if (!take(i)) return found
93  return found
94}
95
96/* The group's last rows the agent drew, latest first. */
97function lastAgentRows(group: TurnGroup, rows: RowIndex, sites: Sites): JumpTarget[] {
98  const found: JumpTarget[] = []
99  for (let i = group.rowUuids.length - 1; i >= 0 && found.length < MAX_TRIES; i--) {
100    const uuid = group.rowUuids[i]
101    const row = uuid === undefined ? undefined : rows.chain[rows.order.get(uuid) ?? -1]
102    if (row && row.type === 'assistant' && (isAgentRow(row) || isDrawn(sites, row.uuid))) found.push(target(rows, sites, row.uuid, 'start'))
103  }
104  return found
105}
106
107/* The rows a press on one side of a group tries, in order; none when
108   there is nowhere to jump. */
109export function jumpTargetsOf(group: TurnGroup, side: 'user' | 'agent', rows: RowIndex, sites: Sites): JumpTarget[] {
110  if (side === 'agent') {
111    if (group.reply) return [target(rows, sites, group.reply.uuid, 'start')]
112    return lastAgentRows(group, rows, sites)
113  }
114  if (group.kind === 'prompt') return [target(rows, sites, group.openerUuid, 'start')]
115  return neighbours(group, rows, sites)
116}
117
118/* The sides refused, by side key, each with the group's place then (a
119   hint: regrouping may move it) and whether the host refused a press or
120   the side was known compacted before a resume. */
121export type RefusedSide = { side: 'user' | 'agent'; opener: string; groupIndex: number; why: 'refused' | 'compacted' }
122export type Refused = Map<string, RefusedSide>
123
124/* The last compaction's boundary row on the chain. */
125export function lastBoundary(chain: readonly TranscriptRow[]): string | undefined {
126  for (let i = chain.length - 1; i >= 0; i--) {
127    const row = chain[i]
128    if (row?.type === 'system' && row.subtype === 'compact_boundary') return row.uuid
129  }
130  return undefined
131}
132
133/* The sides whose every jump target lies before `boundary` on the chain:
134   in a session resumed with that boundary last, refused for sure. */
135export function compactedSides(groups: readonly TurnGroup[], rows: RowIndex, sites: Sites, boundary: string | undefined): Refused {
136  const known: Refused = new Map()
137  const end = boundary === undefined ? undefined : rows.order.get(boundary)
138  if (end === undefined) return known
139  for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
140    const group = groups[groupIndex]!
141    if ((rows.order.get(group.openerUuid) ?? end) >= end) break
142    for (const side of ['user', 'agent'] as const) {
143      const targets = jumpTargetsOf(group, side, rows, sites)
144      if (targets.length > 0 && targets.every(t => (rows.order.get(t.uuid) ?? end) < end)) {
145        known.set(sideKeyOf(group, side), { side, opener: group.openerUuid, groupIndex, why: 'compacted' })
146      }
147    }
148  }
149  return known
150}
151
152function groupOf(groups: readonly TurnGroup[], opener: string, hint: number): TurnGroup | undefined {
153  const at = groups[hint]
154  if (at?.openerUuid === opener) return at
155  return groups.find(g => g.openerUuid === opener)
156}
157
158/* A row the host drew again: every refused side it is a target of comes
159   back. True when one did. */
160export function relisted(refused: Refused, uuid: string, groups: readonly TurnGroup[], rows: RowIndex, sites: Sites): boolean {
161  let isBack = false
162  for (const [key, was] of refused) {
163    const group = groupOf(groups, was.opener, was.groupIndex)
164    if (!group || jumpTargetsOf(group, was.side, rows, sites).some(t => t.uuid === uuid)) {
165      refused.delete(key)
166      isBack = true
167    }
168  }
169  return isBack
170}
171
172/* Which side of which group: an entry's identity across regrouping. */
173export const sideKeyOf = (group: TurnGroup, side: 'user' | 'agent') => `${group.openerUuid}.${side}`
174
175/* Entries with the sides refused drawn dim, every line, and marked off. */
176export function dimRefused(entries: readonly Entry[], groups: readonly TurnGroup[], refused: Refused): Entry[] {
177  if (refused.size === 0) return entries as Entry[]
178  return entries.map(entry => {
179    const group = groups[entry.groupIndex]
180    if (!group || !refused.has(sideKeyOf(group, entry.side))) return entry
181    return { ...entry, isOff: true, lines: entry.lines.map((line): EntryLine => ({ ...line, dim: true })) }
182  })
183}
184
hooks/lifecycle.ts 34 lines
1/* When the pane opens and stays closed (MVP spec §2), as plain decisions;
2   register.tsx makes the calls on `$`. */
3
4import type { PaneCloseInput } from 'claude-code'
5import { PANE_ID } from './pane'
6
7/* The one close that keeps the pane closed for the rest of the session:
8   the person closing chat-toc's own pane. A plugin's close, an unload and
9   every other pane's close leave the mark alone. */
10export function isClosedByPerson(e: PaneCloseInput): boolean {
11  return e.id === PANE_ID && e.origin.kind === 'person'
12}
13
14/* A close that takes chat-toc's pane off screen while the plugin stays:
15   the person's, or any but an unload. The band above the prompt then
16   offers it again. */
17export function hidesPane(e: PaneCloseInput): boolean {
18  return e.id === PANE_ID && e.origin.kind !== 'unload'
19}
20
21/* `classic.SessionStart` sources that begin another conversation in this
22   process, which `session.start` does not hear of: the pane opens again for
23   it. A compaction continues the same one. */
24export function reopensOn(source: string): boolean {
25  return source === 'clear' || source === 'resume'
26}
27
28/* Sources of a session that takes over another's history: it shows in the
29   transcript while the session's own file may not exist yet (a fork's is
30   written at its first submit). */
31export function carriesHistory(source: string): boolean {
32  return source === 'resume' || source === 'fork'
33}
34
hooks/pane.tsx 650 lines
1/* Drawing the pane in the terminal: the filter row over its tab rule; the
2   entries in the window, each hung on the rail (its time column and rail
3   marks as `Text`s) beside a column of `plain` Buttons, one Button per line,
4   every line of a side pressing the same jump and lighting up together on
5   hover; the footer at the bottom with the count and the Layout Button.
6   Button takes only a label on 2.1.287, so each line is one string, and
7   every colour beside it is a `Text`'s. */
8
9import type { ElementTable, RenderElement } from 'claude-code'
10import { BAR_ROWS, barKey } from './bars'
11import type { Bar } from './bars'
12import { textCells } from './cells'
13import type { CompactionRule } from './compactions'
14import { GUTTER, clockTime, dayRuleBefore, entryRows, isRuleBefore } from './entries'
15import type { Entry } from './entries'
16import { FIRST_CONTROL, LAYOUT_CONTROL, actionKey } from './ring'
17import { FILTERS, LAYOUT_HOTKEY, filterLabel, layoutLabel } from './view'
18import type { Layout, ViewFilter } from './view'
19import type { TocWindow } from './window'
20import { text as words, turnsAndContextUse } from './text'
21import { BACK_CONTROL, EXPAND_HOTKEY, MORE_CONTROL, PREVIEW_CONTROL, PREVIEW_HOTKEY } from './preview'
22import { NEXT_ACTION, NEXT_CONTROL, PREVIOUS_ACTION, PREVIOUS_CONTROL, isWalkKey } from './walk'
23import type { WalkDirection } from './walk'
24import type { ExpandedBody, PreviewBody } from './preview'
25import { COPY_INPUT_CONTROL, COPY_INPUT_HOTKEY, COPY_TEXT_CONTROL, COPY_TEXT_HOTKEY, REUSE_CONTROL, REUSE_HOTKEY } from './reuse'
26import type { CopySide } from './reuse'
27
28export const PANE_ID = 'chat-toc'
29/* The pane's title, the plugin's name in every language: the desktop draws
30   it at the pane's top, the terminal on its tab beside other panes. */
31export const PANE_TITLE = 'ChatTOC'
32export const PANE_COLUMNS = 44
33
34/* 2.1.287 draws the pane's ✕ at the right end of the body's first row: the
35   filter row keeps these cells free for it. */
36const CLOSE_ROOM = 3
37
38/* The rows an entry takes: its day's rule, its lines, the gap after it. */
39export function entryHeight(entries: readonly Entry[], i: number): number {
40  return entryRows(entries, i)
41}
42
43/* A plain Button with a hotkey draws as `1: label`. */
44const controlCells = (label: string) => textCells(`x: ${label}`)
45
46/* The cells between two filter Buttons. */
47const FILTER_GAP = 3
48
49/* The head's rows: the filter row, the tab rule. */
50export const HEAD_ROWS = 2
51/* The footer's rows: a rule, then the count or the pause and the Layout Button. */
52export const FOOT_ROWS = 2
53/* The search row under the tab rule, always drawn. */
54export const SEARCH_ROWS = 1
55
56/* The search row's controls: the `f` Button, the field, the clear mark. */
57export const SEARCH_CONTROL = 'search.focus'
58export const SEARCH_FIELD = 'search'
59export const CLEAR_CONTROL = 'search.clear'
60export const SEARCH_HOTKEY = 'f'
61
62/* The search row: the `f` Button moving the keys into the field, the field
63   holding the query, while there is a query the count of matches among all
64   groups and the ✕ that clears it, then ‹ › walking to the previous or
65   next Turn group (walk.ts). */
66export type SearchView = {
67  query: string
68  /* Undefined without a query. */
69  count: { matches: number; of: number } | undefined
70  onFocusSearch: () => Promise<void>
71  onInput: (value: string) => void
72  /* Hands back its promise: the host lets the transcript move only
73     while the person's submit is under way. */
74  onSubmit: (value: string) => Promise<void>
75  onClear: () => Promise<void>
76  /* Hands back its promise, as `onSubmit` does. */
77  onWalk: (direction: WalkDirection) => Promise<void>
78}
79
80/* The count of matches after the field while there is a query, dim. */
81export function matchCount(elements: ElementTable, count: SearchView['count']): RenderElement | null {
82  const { Text } = elements
83  return count === undefined ? null : <Text dimColor>{' ' + words.matchCount(count.matches, count.of)}</Text>
84}
85
86function drawSearch(elements: ElementTable<'terminal'>, search: SearchView): RenderElement {
87  const { Box, Text, Button, Input } = elements
88  const hasQuery = search.query !== ''
89  return (
90    <Box flexDirection="row">
91      <Button key={SEARCH_CONTROL} plain hotkey={SEARCH_HOTKEY} onPress={() => search.onFocusSearch()}>
92        {words.find}
93      </Button>
94      <Text> </Text>
95      <Box flexGrow={1} flexShrink={1}>
96        <Input
97          key={SEARCH_FIELD}
98          placeholder={words.searchPlaceholder}
99          value={search.query}
100          submitLabel={words.searchSubmit}
101          onInput={(value: string) => search.onInput(value)}
102          onSubmit={(value: string) => search.onSubmit(value)}
103        />
104      </Box>
105      {matchCount(elements, search.count)}
106      {hasQuery ? <Text> </Text> : null}
107      {hasQuery ? (
108        <Button key={CLEAR_CONTROL} plain dimColor onPress={() => search.onClear()}>
109          ✕
110        </Button>
111      ) : null}
112      <Text> </Text>
113      <Button key={PREVIOUS_CONTROL} plain action={PREVIOUS_ACTION} onPress={() => search.onWalk('previous')}>
114        ‹
115      </Button>
116      <Text> </Text>
117      <Button key={NEXT_CONTROL} plain action={NEXT_ACTION} onPress={() => search.onWalk('next')}>
118        ›
119      </Button>
120    </Box>
121  )
122}
123
124/* The tab rule under the filter row, as runs: heavy under the chosen
125   filter's Button, thin elsewhere, across the body's width. */
126export function tabRule(bodyColumns: number, filter: ViewFilter): { text: string; isChosen: boolean }[] {
127  let start = 0
128  for (const f of FILTERS) {
129    const cells = controlCells(filterLabel(f.filter))
130    if (f.filter === filter) {
131      const end = Math.min(bodyColumns, start + cells)
132      return [
133        { text: '─'.repeat(start), isChosen: false },
134        { text: '━'.repeat(Math.max(0, end - start)), isChosen: true },
135        { text: '─'.repeat(Math.max(0, bodyColumns - end)), isChosen: false },
136      ].filter(run => run.text !== '')
137    }
138    start += cells + FILTER_GAP
139  }
140  return [{ text: '─'.repeat(bodyColumns), isChosen: false }]
141}
142
143/* The rail beside each line of an entry: a node `●` on a User input's
144   first line, a branch `├╴` on an Agent reply's, the rail `│` beside the
145   lines after; when its group is lit, `◉`, `┣╸` and `┃`. A side with no
146   Jump target hangs on a hollow mark, `○` or `├┄`. */
147export function railOf(entry: Entry, isLit: boolean): string[] {
148  const user = entry.isOff ? '○  ' : isLit ? '◉  ' : '●  '
149  const agent = entry.isOff ? '├┄ ' : isLit ? '┣╸ ' : '├╴ '
150  const first = entry.side === 'user' ? user : agent
151  return entry.lines.map((_, n) => (n === 0 ? first : isLit ? '┃  ' : '│  '))
152}
153
154/* The Buttons under the selected entry, ` · ` between them, each drawn
155   only when it can act: `p: Preview`, `r: Reuse` on a User input that can
156   be filled, `c: Copy` of the entry's own side. */
157function actionButtons(elements: ElementTable, entry: Entry, selected: SelectedView): RenderElement {
158  const { Box, Text, Button } = elements
159  const { actions } = selected
160  const side: CopySide = entry.side
161  const canCopy = side === 'user' ? actions.canCopyInput : actions.canCopyText
162  const buttons: RenderElement[] = [
163    <Button key={actionKey(entry.key, OPEN_ACTION)} plain dimColor hotkey={PREVIEW_HOTKEY} onPress={() => selected.onOpen()}>
164      {words.preview}
165    </Button>,
166  ]
167  if (actions.canReuse)
168    buttons.push(
169      <Button key={actionKey(entry.key, REUSE_ACTION)} plain dimColor hotkey={REUSE_HOTKEY} onPress={() => actions.onReuse()}>
170        {words.reuse}
171      </Button>,
172    )
173  if (canCopy)
174    buttons.push(
175      <Button key={actionKey(entry.key, COPY_ACTION)} plain dimColor={actions.copied !== side} hotkey={COPY_INPUT_HOTKEY} onPress={() => actions.onCopy(side)}>
176        {actions.copied === side ? words.copied : words.copy}
177      </Button>,
178    )
179  return <Box flexDirection="row">{buttons.flatMap((b, i) => (i === 0 ? [b] : [<Text dimColor>{' · '}</Text>, b]))}</Box>
180}
181
182/* The time column of the compact and timeline Layouts: `HH:MM` and a space. */
183const TIME_COLUMN = 6
184
185/* The time column beside an entry: its time on its first line, blank below. */
186export function timeColumnOf(entry: Entry): string {
187  return entry.lines.map((_, n) => (n === 0 ? entry.time : '').padEnd(TIME_COLUMN)).join('\n')
188}
189
190/* The Buttons on the preview's label rows (reuse.ts), acting on the group
191   it shows; each is drawn only when the group offers it. */
192export type LabelActions = {
193  canReuse: boolean
194  canCopyInput: boolean
195  canCopyText: boolean
196  /* The copy Button that reads `Copied` now. */
197  copied: CopySide | undefined
198  onReuse: () => Promise<void>
199  onCopy: (which: CopySide) => Promise<void>
200}
201
202/* A dim label row, `User` or `Agent`, with its Buttons at the right end,
203   ` · ` between them; on the desktop, which has no hotkeys, without them. */
204export function labelRow(elements: ElementTable, label: string, side: 'user' | 'agent', actions: LabelActions | undefined, hasHotkeys = true): RenderElement {
205  const { Box, Text, Button } = elements
206  const key = (hotkey: string) => (hasHotkeys ? hotkey : undefined)
207  const copy = (which: CopySide, control: string, hotkey: string) => (
208    <Button key={control} plain hotkey={key(hotkey)} dimColor={actions?.copied !== which} onPress={() => actions?.onCopy(which) ?? Promise.resolve()}>
209      {actions?.copied === which ? words.copied : words.copy}
210    </Button>
211  )
212  const buttons: RenderElement[] = []
213  if (side === 'user' && actions?.canReuse)
214    buttons.push(
215      <Button key={REUSE_CONTROL} plain dimColor hotkey={key(REUSE_HOTKEY)} onPress={() => actions.onReuse()}>
216        {words.reuse}
217      </Button>,
218    )
219  if (side === 'user' && actions?.canCopyInput) buttons.push(copy('user', COPY_INPUT_CONTROL, COPY_INPUT_HOTKEY))
220  if (side === 'agent' && actions?.canCopyText) buttons.push(copy('agent', COPY_TEXT_CONTROL, COPY_TEXT_HOTKEY))
221  if (buttons.length === 0) return <Text dimColor>{label}</Text>
222  return (
223    <Box flexDirection="row" justifyContent="space-between">
224      <Text dimColor>{label}</Text>
225      <Box flexDirection="row">{buttons.flatMap((b, n) => (n === 0 ? [b] : [<Text dimColor>{' · '}</Text>, b]))}</Box>
226    </Box>
227  )
228}
229
230/* The preview region over the footer (preview.ts), drawn only while it is
231   open: its rule with the ✕ closing it, then the group's content. */
232export type PreviewView = {
233  /* The rows it takes, its rule included. */
234  rows: number
235  body: PreviewBody | undefined
236  /* Undefined draws the label rows bare. */
237  actions?: LabelActions | undefined
238  onClose: () => Promise<void>
239  onMore: () => Promise<void>
240}
241
242/* The ✕ closing the preview, hotkey `p`: at the right end of its rule, and
243   of the expanded preview's Back row. */
244function closeButton(elements: ElementTable, onClose: () => Promise<void>): RenderElement {
245  const { Button } = elements
246  return (
247    <Button key={PREVIEW_CONTROL} plain dimColor hotkey={PREVIEW_HOTKEY} onPress={() => onClose()}>
248      ✕
249    </Button>
250  )
251}
252
253/* The region: the rule line, `Preview` and the ✕ at its right end, then
254   the meta line, the User input under its label, the last text under its
255   label, the files line, blank rows, and the cut line. The last text is a
256   `Markdown`, which 2.1.287 draws as 2.1.295 does; its Box clips it at the
257   rows it was given. */
258function drawPreview(elements: ElementTable, preview: PreviewView, bodyColumns: number): RenderElement {
259  const { Box, Text, Button } = elements
260  const label = words.preview
261  const ruleRest = Math.max(0, bodyColumns - 2 - textCells(label) - 2 - controlCells('✕'))
262  const rule = (
263    <Box flexDirection="row">
264      <Text color="subtle">{'─ '}</Text>
265      <Text>{label}</Text>
266      <Text color="subtle">{' ' + '─'.repeat(ruleRest) + ' '}</Text>
267      {closeButton(elements, preview.onClose)}
268    </Box>
269  )
270  const body = preview.body
271  if (body === undefined)
272    return (
273      <Box flexDirection="column">
274        {rule}
275        {preview.rows > 1 ? <Box height={preview.rows - 1} /> : null}
276      </Box>
277    )
278  return (
279    <Box flexDirection="column">
280      {rule}
281      {previewContent(elements, body, preview.actions, true)}
282      {body.more === 0 ? null : (
283        <Box flexDirection="row">
284          <Text dimColor>{words.moreLines(body.more)}</Text>
285          <Button key={MORE_CONTROL} plain hotkey={EXPAND_HOTKEY} dimColor onPress={() => preview.onMore()}>
286            {words.expand}
287          </Button>
288        </Box>
289      )}
290    </Box>
291  )
292}
293
294/* The region's content under its rule, down to the blank rows: the meta
295   line, the User input under its label, the last text under its label in a
296   `Markdown` its Box clips at the rows it was given, the files line. */
297export function previewContent(elements: ElementTable, body: PreviewBody, actions: LabelActions | undefined, hasHotkeys: boolean): RenderElement {
298  const { Box, Text, Markdown } = elements
299  const agent = body.agent
300  return (
301    <Box flexDirection="column">
302      <Text dimColor>{body.meta}</Text>
303      {body.user === undefined ? null : labelRow(elements, words.previewUser, 'user', actions, hasHotkeys)}
304      {body.user === undefined || body.user.length === 0 ? null : <Text>{body.user.join('\n')}</Text>}
305      {agent === undefined ? null : labelRow(elements, words.previewAgent, 'agent', actions, hasHotkeys)}
306      {agent === undefined || agent.rows === 0 ? null : (
307        <Box height={agent.rows} overflow="hidden" flexDirection="column">
308          <Markdown text={agent.text} />
309        </Box>
310      )}
311      {body.files === undefined ? null : <Text dimColor>{body.files}</Text>}
312      {body.blank > 0 ? <Box height={body.blank} /> : null}
313    </Box>
314  )
315}
316
317/* The expanded preview (preview.ts): one group whole in the list's place,
318   the Back Button at its top. */
319export type ExpandedView = {
320  body: ExpandedBody
321  actions?: LabelActions | undefined
322  /* Back to the region, or, when it was opened expanded for want of rows,
323     closed. */
324  onBack: () => Promise<void>
325  onClose: () => Promise<void>
326}
327
328/* The Back Button with the ✕ at the row's right end, the meta line, the
329   User input under its label, the last text in one `Markdown` per part
330   under its label, a line after a part the cap cut, then the files one per
331   line under their headings. Drawn uncut: the host scrolls the tree. */
332function drawExpanded(elements: ElementTable, expanded: ExpandedView): RenderElement {
333  const { Box, Button } = elements
334  return (
335    <Box flexDirection="column">
336      <Box flexDirection="row" justifyContent="space-between">
337        <Button key={BACK_CONTROL} plain hotkey={EXPAND_HOTKEY} onPress={() => expanded.onBack()}>
338          {words.back}
339        </Button>
340        {closeButton(elements, expanded.onClose)}
341      </Box>
342      {expandedContent(elements, expanded, true)}
343    </Box>
344  )
345}
346
347/* The expanded preview under its Back Button: the meta line, the User
348   input under its label, the last text in one `Markdown` per part under
349   its label, a line after a part the cap cut, then the files one per line
350   under their headings. */
351export function expandedContent(elements: ElementTable, expanded: ExpandedView, hasHotkeys: boolean): RenderElement {
352  const { Box, Text, Markdown } = elements
353  const { body } = expanded
354  const files = (heading: string, paths: readonly string[]) =>
355    paths.length === 0 ? null : (
356      <Box flexDirection="column">
357        <Text dimColor>{heading}</Text>
358        {paths.map(path => (
359          <Text>{path}</Text>
360        ))}
361      </Box>
362    )
363  return (
364    <Box flexDirection="column">
365      <Text dimColor>{body.meta}</Text>
366      {labelRow(elements, words.previewUser, 'user', expanded.actions, hasHotkeys)}
367      {body.user.parts.map(part => (part === '' ? null : <Text>{part}</Text>))}
368      {body.user.cut === undefined ? null : <Text dimColor>{body.user.cut}</Text>}
369      {body.agent === undefined ? null : labelRow(elements, words.previewAgent, 'agent', expanded.actions, hasHotkeys)}
370      {body.agent === undefined ? null : body.agent.parts.map(part => <Markdown text={part} />)}
371      {body.agent?.cut === undefined ? null : <Text dimColor>{body.agent.cut}</Text>}
372      {files(words.changedHeading, body.changed)}
373      {files(words.readHeading, body.read)}
374    </Box>
375  )
376}
377
378/* A compaction's words, as its rule says them. */
379export function compactionLabel(rule: CompactionRule): string {
380  return words.compactionRule(rule.trigger === 'auto', rule.count, clockTime(rule.timestamp))
381}
382
383/* The gap between the footer's left and the Layout Button. */
384const FOOT_GAP = 2
385
386/* The footer's left: the turn count, then the context use as long as it
387   fits beside the Layout Button, shorter, else left out. */
388export function footerStatus(count: number, percent: number | undefined, bodyColumns: number, layout: Layout): string {
389  const room = bodyColumns - FOOT_GAP - controlCells(layoutLabel(layout))
390  return turnsAndContextUse(count, percent, status => textCells(status) <= room)
391}
392
393/* The selected entry while the preview is closed: under it, a row of
394   Buttons opening the preview on its group, reusing its User input and
395   copying its side (0.2 spec §3). */
396export type SelectedView = {
397  key: string
398  actions: LabelActions
399  onOpen: () => Promise<void>
400}
401
402/* The actions under the selected entry, by their key's suffix. */
403export const OPEN_ACTION = 'preview'
404export const REUSE_ACTION = 'reuse'
405export const COPY_ACTION = 'copy'
406
407/* A click on a terminal Button presses it and leaves the keys in the
408   composer; after these presses the pane asks for them, so its hotkeys
409   work at once. Not Reuse, which hands the keys to the composer, nor ‹ ›,
410   which the person's chord presses from the composer as well. */
411export function claimsKeys(element: string): boolean {
412  if (isWalkKey(element) || element === REUSE_CONTROL) return false
413  return !element.endsWith(`:${REUSE_ACTION}`)
414}
415
416export type PaneView = {
417  entries: readonly Entry[]
418  window: TocWindow
419  layout: Layout
420  filter: ViewFilter
421  /* The group the highlight is on; undefined lights none. */
422  litGroup: number | undefined
423  /* The body's width in cells. */
424  bodyColumns: number
425  /* One line instead of entries, when there are none to show. */
426  notice: string | undefined
427  /* A refusal said below the entries, after a ⚠ in the theme's warning
428     colour, inverse while it flashes. */
429  alert: { text: string; isInverse: boolean } | undefined
430  /* Lines below the list: why it is empty or incomplete. */
431  why: string | undefined
432  /* The Turn groups the list holds, for the footer; undefined says none. */
433  count: number | undefined
434  /* The context use, a whole percent; undefined leaves it out. */
435  contextPercent: number | undefined
436  /* Whether the list starts with the line saying earlier turns are not in
437     this session's file. */
438  isEarlierElsewhere: boolean
439  /* The body's rows: the footer sits on the last two. */
440  bodyRows: number
441  /* The Minimap's cells; none draws no Minimap. */
442  bars: readonly Bar[]
443  /* The cells drawn lit: the Current position's, or a query's matches. */
444  litBars: ReadonlySet<number>
445  /* The times under the cells, and the spaces between them. */
446  barLabels: { left: string; right: string; gap: number }
447  /* The preview region while it is open; undefined draws none. */
448  preview: PreviewView | undefined
449  /* The expanded preview, drawn in place of the Minimap, the list and the
450     region; undefined draws those. */
451  expanded?: ExpandedView | undefined
452  /* The selected entry's row of Buttons; undefined draws none. */
453  selected?: SelectedView | undefined
454  search: SearchView
455  onBar: (cell: number) => Promise<void>
456  onPress: (entry: Entry) => Promise<void>
457  onFilter: (filter: ViewFilter) => Promise<void>
458  onLayout: () => Promise<void>
459}
460
461export function drawPane(elements: ElementTable<'terminal'>, view: PaneView): RenderElement {
462  const { Box, Text, Button } = elements
463  const filters = (
464    <Box flexDirection="row" columnGap={FILTER_GAP}>
465      {FILTERS.map(f => (
466        <Button
467          key={f.filter === 'all' ? FIRST_CONTROL : `f-${f.filter}`}
468          plain
469          hotkey={f.hotkey}
470          dimColor={f.filter !== view.filter}
471          onPress={() => view.onFilter(f.filter)}
472        >
473          {filterLabel(f.filter)}
474        </Button>
475      ))}
476    </Box>
477  )
478  const layoutButton = (
479    <Button key={LAYOUT_CONTROL} plain hotkey={LAYOUT_HOTKEY} onPress={() => view.onLayout()}>
480      {layoutLabel(view.layout)}
481    </Button>
482  )
483  const head = (
484    <Box flexDirection="column">
485      <Box flexDirection="row" paddingRight={CLOSE_ROOM}>
486        {filters}
487      </Box>
488      <Box flexDirection="row">
489        {tabRule(view.bodyColumns, view.filter).map(run => (
490          <Text color={run.isChosen ? 'suggestion' : 'subtle'}>{run.text}</Text>
491        ))}
492      </Box>
493    </Box>
494  )
495  const status = view.count === undefined ? (
496    <Text> </Text>
497  ) : (
498    <Text dimColor>{footerStatus(view.count, view.contextPercent, view.bodyColumns, view.layout)}</Text>
499  )
500  const footer = (
501    <Box flexDirection="column">
502      <Text color="subtle">{'─'.repeat(view.bodyColumns)}</Text>
503      <Box flexDirection="row" justifyContent="space-between">
504        {status}
505        {layoutButton}
506      </Box>
507    </Box>
508  )
509  if (view.expanded !== undefined) {
510    const spare = Math.max(0, view.bodyRows - HEAD_ROWS - SEARCH_ROWS - view.expanded.body.rows - FOOT_ROWS)
511    return (
512      <Box flexDirection="column">
513        {head}
514        {drawSearch(elements, view.search)}
515        {drawExpanded(elements, view.expanded)}
516        {spare > 0 ? <Box height={spare} /> : null}
517        {footer}
518      </Box>
519    )
520  }
521  const hasBars = view.bars.length > 0
522  const barRows = hasBars ? (
523    <Box flexDirection="column">
524      <Box flexDirection="row">
525        {view.bars.map((bar, cell) => (
526          <Button key={barKey(cell)} plain dimColor={!view.litBars.has(cell)} onPress={() => view.onBar(cell)}>
527            {bar.glyph}
528          </Button>
529        ))}
530        {view.bars.length < view.bodyColumns ? <Text color="subtle">{'▁'.repeat(view.bodyColumns - view.bars.length)}</Text> : null}
531      </Box>
532      <Text dimColor>{`${view.barLabels.left}${' '.repeat(view.barLabels.gap)}${view.barLabels.right}` || ' '}</Text>
533      <Text> </Text>
534    </Box>
535  ) : null
536  /* A compaction's rule: dashed, breaking the rail, not pressable. */
537  const compactionRow = (key: string, rule: CompactionRule) => {
538    const label = compactionLabel(rule)
539    return (
540      <Box key={key} flexDirection="row">
541        <Text color="subtle">{'╌ '}</Text>
542        <Text dimColor>{label}</Text>
543        <Text color="subtle">{' ' + '╌'.repeat(Math.max(0, view.bodyColumns - 3 - textCells(label)))}</Text>
544      </Box>
545    )
546  }
547  const rows: RenderElement[] = []
548  let drawnRows = 0
549  const hasTimeColumn = view.layout !== 'card'
550  const room = view.bodyColumns - GUTTER[view.layout]
551  for (let i = view.window.start; i < view.window.end; i++) {
552    const entry = view.entries[i]
553    if (!entry) continue
554    const press = () => view.onPress(entry)
555    const hover = { scope: `e-${entry.key}`, color: 'suggestion' }
556    const isLit = entry.groupIndex === view.litGroup
557    const rule = dayRuleBefore(view.entries, i)
558    const isOff = entry.isOff === true
559    const rails = railOf(entry, isLit)
560    const hasGap = entry.gapAfter && i < view.window.end - 1
561    const railTone = (n: number) =>
562      isLit ? (
563        <Text color="suggestion">{rails[n]}</Text>
564      ) : n === 0 && entry.side === 'user' && !isOff ? (
565        <Text dimColor>{rails[n]}</Text>
566      ) : (
567        <Text color="subtle">{rails[n]}</Text>
568      )
569    const timeColumn = hasTimeColumn ? isLit ? <Text color="suggestion">{timeColumnOf(entry)}</Text> : <Text dimColor>{timeColumnOf(entry)}</Text> : null
570    /* The gap goes on with the rail, unless a new day's rule or a
571       compaction's comes next. */
572    const isRuleNext = isRuleBefore(view.entries, i + 1)
573    const gap = !hasGap ? null : isRuleNext ? (
574      <Text> </Text>
575    ) : (
576      <Text color={isLit ? 'suggestion' : 'subtle'}>{(hasTimeColumn ? ' '.repeat(TIME_COLUMN) : '') + (isLit ? '┃' : '│')}</Text>
577    )
578    const line = (text: string, n: number, dim: boolean) => (
579      <Button key={n === 0 ? entry.key : `${entry.key}.${n}`} plain dimColor={dim} hover={hover} onPress={press}>
580        {text || ' '}
581      </Button>
582    )
583    const selected = view.selected?.key === entry.key ? view.selected : undefined
584    const actionRow =
585      selected === undefined ? null : (
586        <Box flexDirection="row">
587          <Text color={isLit ? 'suggestion' : 'subtle'}>{(hasTimeColumn ? ' '.repeat(TIME_COLUMN) : '') + (isLit ? '┃  ' : '│  ')}</Text>
588          {actionButtons(elements, entry, selected)}
589        </Box>
590      )
591    rows.push(
592      <Box key={`row-${entry.key}`} flexDirection="column">
593        {rule === undefined ? null : (
594          <Box flexDirection="row">
595            <Text color="subtle">{'─ '}</Text>
596            <Text dimColor>{rule}</Text>
597            <Text color="subtle">{' ' + '─'.repeat(Math.max(0, view.bodyColumns - 3 - textCells(rule)))}</Text>
598          </Box>
599        )}
600        {entry.compactionBefore === undefined ? null : compactionRow(`compaction-${entry.key}`, entry.compactionBefore)}
601        <Box flexDirection="row">
602          {timeColumn}
603          <Box flexDirection="column">{entry.lines.map((_, n) => railTone(n))}</Box>
604          {entry.isRunning ? <Text color="claude">{entry.lines.map((_, n) => (n === 0 ? '◐ ' : '  ')).join('\n')}</Text> : null}
605          <Box flexDirection="column">
606            {entry.lines.map((l, n) =>
607              l.aside === undefined ? (
608                line(l.text, n, l.dim)
609              ) : (
610                <Box flexDirection="row" width={room} justifyContent="space-between">
611                  {line(l.text, n, l.dim)}
612                  {isLit ? <Text color="suggestion">{l.aside}</Text> : <Text dimColor>{l.aside}</Text>}
613                </Box>
614              ),
615            )}
616          </Box>
617        </Box>
618        {actionRow}
619        {gap}
620        {entry.compactionAfter === undefined ? null : compactionRow(`compaction-after-${entry.key}`, entry.compactionAfter)}
621      </Box>,
622    )
623    const compactionRows = (entry.compactionBefore === undefined ? 0 : 1) + (entry.compactionAfter === undefined ? 0 : 1)
624    drawnRows += (rule === undefined ? 0 : 1) + compactionRows + entry.lines.length + (hasGap ? 1 : 0) + (actionRow === null ? 0 : 1)
625  }
626  const whyLines = view.why === undefined ? 0 : view.why.split('\n').length
627  const previewRows = view.preview?.rows ?? 0
628  const used = HEAD_ROWS + SEARCH_ROWS + (hasBars ? BAR_ROWS : 0) + (view.isEarlierElsewhere ? 1 : 0) + (view.notice === undefined ? 0 : 1) + drawnRows + (view.alert === undefined ? 0 : 1) + whyLines + previewRows + FOOT_ROWS
629  const spare = Math.max(0, view.bodyRows - used)
630  return (
631    <Box flexDirection="column">
632      {head}
633      {drawSearch(elements, view.search)}
634      {barRows}
635      {view.isEarlierElsewhere ? <Text dimColor>{words.earlierElsewhere}</Text> : null}
636      {view.notice === undefined ? null : <Text dimColor>{view.notice}</Text>}
637      {rows}
638      {view.alert === undefined ? null : (
639        <Text color="warning" inverse={view.alert.isInverse}>
640          {`⚠ ${view.alert.text}`}
641        </Text>
642      )}
643      {view.why === undefined ? null : <Text dimColor>{view.why}</Text>}
644      {spare > 0 ? <Box height={spare} /> : null}
645      {view.preview === undefined ? null : drawPreview(elements, view.preview, view.bodyColumns)}
646      {footer}
647    </Box>
648  )
649}
650