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

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.
CONTEXT.md. The code and this file use these terms; text for users always uses the words on screen..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".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.
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.
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).hooks/register.tsx under --plugin-dir. /reload-plugins does not reload an unchanged hooks module (debug log hooks modules unchanged, kept as loaded).--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 ….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 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
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
session.append, so the tests that rely on it run on the current version only.$.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.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
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):
| Scenario | Command | ||||
|---|---|---|---|---|---|
| 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:
/chat-toc instead (docks from 110 columns), which adds a group to the transcript.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.
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.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..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.Tick in order; stop at the first step that fails:
version in plugin.json is the version to release; tests/evidence/<version>/ is created.--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.marketplace.md.suggestion colour) and the inverse selection cursor tell apart, and sign in report.md (does not block the release)..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.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.hooks/register.tsx 1719 lines1/* 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 lines1/* 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}
242hooks/compactions.ts 154 lines1/* 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}
154hooks/desktop.tsx 485 lines1/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
485hooks/span.ts 87 lines1/* 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}
87hooks/bars.ts 91 lines1/* 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}
91hooks/minimap.ts 87 lines1/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
87hooks/groups.ts 246 lines1/* 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}
246hooks/memo.ts 21 lines1/* 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}
21hooks/jump.ts 184 lines1/* 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}
184hooks/lifecycle.ts 34 lines1/* 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}
34hooks/pane.tsx 650 lines1/* 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