SLOPSHOPPER

cc-cli-rail

A vertical rail of your session's prompts beside the transcript; click to jump, hide it to a button above the prompt. Fork of prompt-rail by oikon48.

newpanebandrowscommandtoast
★ 1v0.1.0MITupdated 2026-10-04afu-it/cc-cli-rail
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-cli-rail
│ ┃ Prompts ✕ › fix the failing auth test and add an audit log ╭─────────────────╮ │ ┃ ─ fix the failing auth test and add an audi │ cc-cli-rail │ │ ┃ » hide ⏺ Read(src/auth.ts) │ no later prompt │ │ ┃ ⎿ Read 6 lines ╰─────────────────╯ │ ┃ ⏺ Update(src/auth.ts) ╭───────────────────╮ │ ┃ ⎿ Added 2 lines, removed 1 line │ cc-cli-rail │ │ ┃ ⏺ Bash(bun test) │ no earlier prompt │ │ ┃ ⎿ 3 pass, 1 fail ╰───────────────────╯ │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /cc-cli-rail │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompts
─ fix the failing auth test and add an audit log call » hide
README

cc-cli-rail

Every prompt of your Claude Code session, in a rail beside the transcript. Click one to jump back to it. Hide the rail when you need the room.

Claude Code plugin Claude Code 2.1.280+ Function hooks License: MIT Fork of prompt-rail

<a href="docs/demo.mp4"><img src="docs/demo.webp" alt="30-second demo: prompts land in the rail, a click jumps back to one, the rail hides to a button and comes back" width="820"></a>

<sub>Silent preview. <a href="docs/demo.mp4">Watch the MP4</a> with sound.</sub>

Install

Inside Claude Code:

/plugin marketplace add afu-it/cc-cli-rail
/plugin install cc-cli-rail@afu-it

Or from your shell:

claude plugin marketplace add afu-it/cc-cli-rail
claude plugin install cc-cli-rail@afu-it

Start a new session. The rail opens on its own.

[!NOTE] Function hooks are early access. If the rail does not show, add this to ~/.claude/settings.json and start a new session:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

[!TIP] Mouse works best with "tui": "fullscreen". On a narrow window Claude Code may hold the rail back at the start of a session; press « prompts (N) above the input to open it.

Already using prompt-rail?

Turn it off first, or two rails open side by side:

claude plugin disable prompt-rail@oikon48

Hide and show

The rail takes a column of your screen. When you need it back, hide it.

<img src="docs/rail.svg" alt="The rail docked beside the transcript: one row per prompt, the prompt being read in bold with a thick tick, a hide button in the bottom-right corner" width="820">

<img src="docs/hidden.svg" alt="The rail hidden: the transcript takes the full width and a small '« prompts (5)' button sits above the prompt input" width="820">

ToDo
Hide the railClick » hide in the bottom-right corner of the rail, or its close mark
Show it againClick « prompts (N) above the prompt input
Toggle from the keyboard/cc-cli-rail, or bind cc-cli-rail-toggle to a key

The rail stays hidden in new sessions until you show it again.

Commands

CommandWhat it does
/cc-cli-railHide the rail, or show it
/cc-cli-rail show · hide · toggleShow, hide, or flip it
/cc-cli-rail next · prevJump to the next or previous prompt
/cc-cli-rail first · lastJump to the first or newest prompt
/cc-cli-rail 12 · #12Jump to prompt #12
/cc-cli-rail find <words>Jump to the newest prompt that holds the words
/cc-cli-rail-next · -prev · -toggleThe same with no argument, for a keybinding

Keyboard

Bind the commands in ~/.claude/keybindings.json. They run mid-turn too. command:cc-cli-rail-toggle works the same way on any free key.

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "ctrl+k": "command:cc-cli-rail-prev",
        "meta+j": "command:cc-cli-rail-next"
      }
    }
  ]
}

Reading the rail

TickMeaning
━ boldThe prompt you are reading now
─Any other prompt
┄A prompt Claude Code would not scroll to, such as a /compact row. next and prev skip it

The rail follows you as you scroll: the prompt that owns the top row on screen is the one in bold.

What is different from prompt-rail

prompt-railcc-cli-rail
LayoutHorizontal bars or vertical paneVertical pane only
Hide the rail/prompt-rail off» hide button, close mark, or /cc-cli-rail
Bring it back/prompt-rail vertical« prompts (N) button above the prompt
Prompt sent mid-turn (ctrl+enter, queued)Can show twice, the copy dottedShows once
Setting in /configRail mode rowNone; the hidden or shown choice is remembered
flowchart LR
  T[transcript .jsonl] -->|every prompt on the live branch| R[rail]
  S[rows on screen] -->|topmost row's prompt| R
  R -->|click, next/prev| J[scroll that prompt's row into view]

The rail lists the prompts of the live branch, so prompts abandoned with /rewind drop out, and prompts from before a resume are listed too. A prompt drawn on screen before the transcript stores it is matched to its stored row, and dropped once the file lists the same text. The transcript is read again only when its size or time changed.

  • Function hooks are early access, and their API may change between Claude Code releases.
  • The Claude desktop app cannot scroll its transcript for a plugin, so a click there does not jump.
  • The dock width is shared by all plugin panes. Drag its edge to narrow it, down to 24 columns. Below 12 columns the rail shows ticks only, and hovering one shows its prompt above the input.
  • A /compact row cannot be jumped to. Its tick turns dotted after the first try.
  • Near the end of the transcript, next cannot scroll further.

Development

git clone https://github.com/afu-it/cc-cli-rail
claude --plugin-dir cc-cli-rail      # load this checkout; saving a file reloads it
claude plugin validate cc-cli-rail
claude plugin test cc-cli-rail       # 101 tests

Credit

cc-cli-rail is a fork of prompt-rail by oikon48. The transcript reading, the tracking of what you are reading, and the jump logic are oikon48's work. Thank you.

License

MIT. Copyright (c) 2026 oikon48, with changes by afu-it.

Source 2 files
hooks/index.tsx 1422 lines
1// cc-cli-rail: a vertical-only fork of prompt-rail by oikon48
2// (https://github.com/oikon48/prompt-rail, MIT). The horizontal band is gone;
3// the pane can be hidden to a button above the prompt and shown again.
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6const PLUGIN = 'cc-cli-rail'
7const PANE = PLUGIN
8// Rows the person typed (terminal composer, desktop/remote bridge, SDK host).
9const PROMPT_KINDS = new Set(['composer', 'bridge', 'sdk'])
10// Columns the docked rail asks for: a tick and a little air.
11const RAIL_COLUMNS = 4
12// Below this many body columns the vertical rail has no room to reveal the
13// prompt beside a tick, so the band above the prompt shows it instead.
14const INLINE_REVEAL_MIN_COLUMNS = 12
15// Cells a hover card keeps for the prompt's text beside the turn's details.
16const MIN_CARD_TEXT = 12
17// Whether the pane is shown or hidden to the band's button, kept across
18// sessions in the store.
19const SHOWN_KEY = 'shown'
20// `transcript:<session id>` -> { path, at }, so a hot-reloaded module (whose
21// session.start carries no path) can rebuild its list. One key per session, so
22// sessions starting together never rewrite each other's; the newest few stay.
23const TRANSCRIPT_KEY_PREFIX = 'transcript:'
24const KEPT_TRANSCRIPTS = 20
25// The id a prompt row is drawn under before its message is stored; the stored
26// row follows under its uuid.
27const PROVISIONAL_ID = 'placeholder'
28// User rows the engine writes around its own output (slash commands, bash
29// mode, reminders), which are not prompts.
30const WRAPPER = /^<(command-|local-command-|bash-|system-reminder|task-notification|user-prompt-submit-hook)/
31// The viewer's state the engine puts ahead of a prompt sent while an artifact
32// is open; the typed text follows it. No row field tells it from typed text,
33// so only the engine's layout matches: the artifact id, a JSON line starting
34// with "context", and the closing tag on a line of its own.
35const VIEW_CONTEXT = /^\s*<artifact-view-context artifact="[^"]*">\n\{"context":[\s\S]*?\n<\/artifact-view-context>/
36// The notice the engine stores as a user row when the person interrupts a turn.
37const INTERRUPTED = /^\[Request interrupted by user/
38// A message uuid, as the transcript stores it (see rowKey).
39const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
40// The rail is drawn again this many ms after the last onScreen report, and no
41// later than SETTLE_MAX_MS after the first (see redrawRailSettled). Measured
42// on the terminal: a replayed row is corrected within 13 ms, a layout settles
43// within 18 ms.
44const SETTLE_MS = 40
45const SETTLE_MAX_MS = 120
46// A row added at most this many ms before a prompt's notification may be that
47// prompt's own: the engine draws a queued prompt's first row as it notifies.
48const LATELY_MS = 250
49// The engine's refusal when no row is drawn under an id, as for a slash
50// command's own row, which the transcript file holds but the surface skips.
51// Other refusals (a race with another move) pass, so they leave the tick be.
52const NOT_DRAWN = /nothing drawn/
53// The engine's refusal where no transcript viewport takes a plugin's scroll,
54// as in the desktop app; said once, in words of the rail's own.
55const UNSCROLLABLE = /not scrollable/
56const UNSCROLLABLE_TOAST = 'jumps cannot land here: this view does not let a plugin scroll the transcript'
57// A count the rail's sites (the pane and the band) read while drawing, so
58// bumping it draws them again, and them alone. `$.ui.invalidate('ui.render')`
59// also draws every transcript row this module hooks, and rows drawn again
60// while the person scrolls just after a jump move the viewport a turn away.
61const MOVED = { plugin: 'cc-cli-rail', key: 'moved' } as const
62const USAGE = '[show|hide|toggle|next|prev|first|last|<n>|find <words>]'
63// Commands that step through the prompts, each with the way it steps.
64const STEP_COMMANDS = [
65  [`${PLUGIN}-next`, 'next'],
66  [`${PLUGIN}-prev`, 'previous'],
67] as const
68const TOGGLE_COMMAND = `${PLUGIN}-toggle`
69// The pane's own button that hides it, and the band's that shows it again.
70const HIDE_ELEMENT = 'rail-hide'
71const SHOW_ELEMENT = 'rail-show'
72
73type Entry = { id: string; text: string }
74
75// The key rows are matched by. A message the engine splits into several rows
76// is drawn under ids derived from its stored uuid, the first four groups kept
77// and the last replaced by the row's index, so a uuid is matched by those
78// groups; any other id (a tool_use id, the provisional one) as it is.
79export const rowKey = (id: string) => (UUID.test(id) ? id.slice(0, 24) : id)
80
81// The id a prompt's row was last drawn under, which a jump scrolls to, from a
82// map of row key -> drawn id; its own id while it has not been drawn.
83export const drawnRow = (drawn: Map<string, string>, id: string) => drawn.get(rowKey(id)) ?? id
84
85// Terminal cells a character takes: two for East Asian wide and emoji ranges.
86const cells = (char: string) => {
87  const code = char.codePointAt(0) ?? 0
88  const isWide =
89    (code >= 0x1100 && code <= 0x115f) ||
90    (code >= 0x2e80 && code <= 0xa4cf) ||
91    (code >= 0xac00 && code <= 0xd7a3) ||
92    (code >= 0xf900 && code <= 0xfaff) ||
93    (code >= 0xfe30 && code <= 0xfe4f) ||
94    (code >= 0xff00 && code <= 0xff60) ||
95    (code >= 0xffe0 && code <= 0xffe6) ||
96    (code >= 0x1f300 && code <= 0x1faff) ||
97    (code >= 0x20000 && code <= 0x3fffd)
98  return isWide ? 2 : 1
99}
100
101const cellWidth = (text: string) => [...text].reduce((sum, char) => sum + cells(char), 0)
102
103// `text` padded with spaces to `width` cells, so a card painted over the
104// default line hides it whole.
105const padTo = (text: string, width: number) => text + ' '.repeat(Math.max(0, width - cellWidth(text)))
106
107// The prompt on one line, cut to `width` terminal cells with an ellipsis.
108const oneLine = (text: string, width: number) => {
109  const flat = text.replace(/\s+/g, ' ').trim()
110  const chars = [...flat]
111  if (chars.reduce((sum, char) => sum + cells(char), 0) <= width) return flat
112  let out = ''
113  let used = 0
114  for (const char of chars) {
115    if (used + cells(char) > width - 1) break
116    out += char
117    used += cells(char)
118  }
119  return `${out}…`
120}
121
122// A stacked rail reads rows apart; a row of ticks needs upright bars to. A
123// dotted one marks a prompt the transcript does not draw, so a jump fails.
124export const tick = (isCurrent: boolean, isUnreachable = false) => (isCurrent ? '━' : isUnreachable ? '┄' : '─')
125
126// Record what a jump to `id` answered: a landing makes it reachable, a refusal
127// for want of a drawn row unreachable, any other refusal says nothing. True
128// when the set changed.
129export const noteScroll = (unreachable: Set<string>, id: string, deny: string | undefined) => {
130  const was = unreachable.has(id)
131  if (deny === undefined) unreachable.delete(id)
132  else if (NOT_DRAWN.test(deny)) unreachable.add(id)
133  return unreachable.has(id) !== was
134}
135
136// What a refused jump says: the engine's refusal as it words it, or, where
137// the view cannot scroll the transcript at all, words of the rail's own the
138// first time; undefined once those were said.
139export const jumpNotice = (deny: string, isUnscrollableSaid: boolean) =>
140  UNSCROLLABLE.test(deny) ? (isUnscrollableSaid ? undefined : UNSCROLLABLE_TOAST) : deny
141
142// The prompt one step from `current` in direction `dir`, passing over those
143// `isSkipped` names; from an unknown place (-1), the first or the last. -1
144// when there is none that way.
145export const stepFrom = (current: number, count: number, dir: 1 | -1, isSkipped: (i: number) => boolean) => {
146  let i = current >= 0 ? current + dir : dir > 0 ? 0 : count - 1
147  for (; i >= 0 && i < count; i += dir) if (!isSkipped(i)) return i
148  return -1
149}
150
151// The prompt `/cc-cli-rail <asked>` names among `texts`: `n` or `#n` by its
152// number, `first`, `last`, or with `find <words>` the newest whose text holds
153// them (case aside). -1 when none does; undefined when `asked` names none.
154export const pickPrompt = (asked: string, texts: string[]) => {
155  const number = /^#?(\d+)$/.exec(asked)
156  if (number) {
157    const i = Number(number[1]) - 1
158    return i < texts.length ? i : -1
159  }
160  if (asked === 'first') return texts.length > 0 ? 0 : -1
161  if (asked === 'last') return texts.length - 1
162  const words = /^find\s+(.+)$/.exec(asked)?.[1]?.toLowerCase()
163  if (words === undefined) return undefined
164  for (let i = texts.length - 1; i >= 0; i--) if (texts[i]?.toLowerCase().includes(words)) return i
165  return -1
166}
167
168// What the transcript records of the turn a prompt started: how long it took
169// (`durationMs` as the engine reported it, else `spanMs` from the prompt to
170// the latest reply), how many tools it called, the paths of the files it
171// edited (Edit and Write), and, unless it simply answered, how it went.
172export type Turn = { durationMs?: number; spanMs?: number; tools: number; files: string[]; outcome?: Outcome }
173// A turn still running, stopped by the person, or ended by an API error.
174type Outcome = 'running' | 'interrupted' | 'error'
175const OUTCOME_WORDS: Record<Outcome, string> = { running: 'running', interrupted: 'interrupted', error: 'API error' }
176
177// A duration as the rail shows it: `7s`, `1m 23s`, `1h 2m`.
178const duration = (ms: number) => {
179  const seconds = Math.floor(ms / 1000)
180  if (seconds < 60) return `${seconds}s`
181  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
182  return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
183}
184
185// The files a turn line names before it counts the rest.
186const NAMED_FILES = 3
187
188const EDITING_TOOLS = new Set(['Edit', 'Write'])
189const segments = (path: string) => path.split(/[\\/]/).filter(Boolean)
190
191// Each path by its file name, or by its folder and name where two share one.
192const fileNames = (paths: string[]) => {
193  const base = (path: string) => segments(path).at(-1) ?? path
194  return paths.map(path =>
195    paths.some(other => other !== path && base(other) === base(path)) ? segments(path).slice(-2).join('/') : base(path),
196  )
197}
198
199// A turn on one line: `1m 23s · 4 tools · app.ts, README.md`, each part left
200// out when the transcript has nothing for it; empty when it has nothing.
201// Given `maxCells`, it names fewer files (down to a count) to fit in them.
202export const turnLine = (turn: Turn | undefined, maxCells = Infinity) => {
203  if (!turn) return ''
204  const parts: string[] = []
205  const ms = turn.durationMs ?? turn.spanMs
206  if (ms !== undefined) parts.push(duration(ms))
207  if (turn.outcome) parts.push(OUTCOME_WORDS[turn.outcome])
208  if (turn.tools > 0) parts.push(`${turn.tools} ${turn.tools === 1 ? 'tool' : 'tools'}`)
209  const names = fileNames(turn.files)
210  const withFiles = (named: number) => {
211    if (names.length === 0) return parts.join(' · ')
212    const rest = names.length - named
213    const files =
214      named > 0
215        ? `${names.slice(0, named).join(', ')}${rest > 0 ? ` +${rest}` : ''}`
216        : `${names.length} ${names.length === 1 ? 'file' : 'files'}`
217    return [...parts, files].join(' · ')
218  }
219  for (let named = Math.min(NAMED_FILES, names.length); named > 0; named--) {
220    const line = withFiles(named)
221    if (cellWidth(line) <= maxCells) return line
222  }
223  return withFiles(0)
224}
225
226type TranscriptIndex = {
227  prompts: Entry[]
228  // The turn each prompt started, in the same order.
229  turns: Turn[]
230  // Reply row uuid or tool_use id -> index into prompts of the prompt it answers.
231  owners: [string, number][]
232  // Every row uuid the file holds, live branch or not.
233  known: Set<string>
234}
235
236// The uuids on the live branch: the chain of parents from the last row. A
237// /rewind leaves the abandoned branch in the file; a /compact boundary starts
238// a new chain whose logicalParentUuid links back to the rows before it.
239const liveBranch = (rows: any[]) => {
240  const byId = new Map<string, any>()
241  for (const row of rows) byId.set(row.uuid, row)
242  const live = new Set<string>()
243  let row = [...rows].reverse().find(candidate => !candidate.isSidechain)
244  while (row && !live.has(row.uuid)) {
245    live.add(row.uuid)
246    const parent = row.parentUuid ?? row.logicalParentUuid
247    row = typeof parent === 'string' ? byId.get(parent) : undefined
248  }
249  return live
250}
251
252// What the index reads of a transcript row, and nothing else: a long
253// session's rows are kept between reads, and most of their bytes are tool
254// results and replies the rail never shows. Undefined for a line that is not
255// a row (a torn last line while the engine appends, or no uuid).
256const parseRow = (line: string): any => {
257  let row: any
258  try {
259    row = JSON.parse(line)
260  } catch {
261    return undefined
262  }
263  if (typeof row?.uuid !== 'string') return undefined
264  const content = row.message?.content
265  const blocks = Array.isArray(content)
266    ? content.flatMap((block: any) => {
267        if (block?.type === 'text' && typeof block.text === 'string') return [{ type: 'text', text: block.text }]
268        if (block?.type === 'tool_result') return [{ type: 'tool_result' }]
269        if (block?.type === 'tool_use' && typeof block.id === 'string') {
270          const path = block.input?.file_path
271          return [{ type: 'tool_use', id: block.id, name: block.name, input: typeof path === 'string' ? { file_path: path } : {} }]
272        }
273        return []
274      })
275    : content
276  return {
277    uuid: row.uuid,
278    parentUuid: row.parentUuid,
279    logicalParentUuid: row.logicalParentUuid,
280    isSidechain: row.isSidechain,
281    type: row.type,
282    subtype: row.subtype,
283    durationMs: row.durationMs,
284    isApiErrorMessage: row.isApiErrorMessage,
285    isMeta: row.isMeta,
286    isCompactSummary: row.isCompactSummary,
287    timestamp: row.timestamp,
288    message: row.message && { role: row.message.role, content: typeof content === 'string' ? content : blocks },
289    attachment: row.attachment?.type === 'queued_command' ? { type: row.attachment.type, prompt: row.attachment.prompt } : undefined,
290  }
291}
292
293// The rows of a transcript JSONL's text, in order.
294const parseRows = (jsonl: string) => jsonl.split('\n').flatMap(line => (line.trim() ? (parseRow(line) ?? []) : []))
295
296// The person's prompts on the live branch of a transcript's rows, in order,
297// keyed by message uuid, and the prompt each reply row and tool call answers
298// (a tool row is drawn under its tool_use id). Tool results, meta rows,
299// sidechains and the engine's wrapper rows are not prompts.
300const indexRows = (rows: any[]): TranscriptIndex => {
301  const live = liveBranch(rows)
302  const prompts: Entry[] = []
303  const owners: [string, number][] = []
304  const turns: Turn[] = []
305  // When each turn started and when its latest reply was written.
306  const times: { start: number; last: number }[] = []
307  // The prompt that started the turn being read: a prompt delivered into a
308  // turn is listed, but the turn's details stay with the one that started it.
309  let started = -1
310  for (const row of rows) {
311    if (row.isSidechain || !live.has(row.uuid)) continue
312    const turn = turns[started]
313    if (row.type === 'system' && row.subtype === 'turn_duration' && typeof row.durationMs === 'number') {
314      if (turn) turn.durationMs = (turn.durationMs ?? 0) + row.durationMs
315      continue
316    }
317    if (row.type === 'assistant') {
318      if (prompts.length === 0 || !turn) continue
319      // A reply places the reader under the latest prompt, delivered or not.
320      const owner = prompts.length - 1
321      owners.push([row.uuid, owner])
322      if (row.isApiErrorMessage === true) turn.outcome = 'error'
323      const at = Date.parse(row.timestamp)
324      if (Number.isFinite(at)) times[started]!.last = at
325      const blocks = Array.isArray(row.message?.content) ? row.message.content : []
326      for (const block of blocks) {
327        if (block?.type !== 'tool_use' || typeof block.id !== 'string') continue
328        owners.push([block.id, owner])
329        turn.tools++
330        const path = block.input?.file_path
331        if (EDITING_TOOLS.has(block.name) && typeof path === 'string' && !turn.files.includes(path)) turn.files.push(path)
332      }
333      continue
334    }
335    // A prompt typed while a turn ran and delivered into it is stored as a
336    // queued_command attachment, never as a user row of its own.
337    if (row.type === 'attachment' && row.attachment?.type === 'queued_command') {
338      const prompt = row.attachment.prompt
339      const text = typeof prompt === 'string' ? prompt.replace(VIEW_CONTEXT, '').trim() : ''
340      if (!text || WRAPPER.test(text) || text.startsWith('/')) continue
341      prompts.push({ id: row.uuid, text })
342      turns.push({ tools: 0, files: [] })
343      times.push({ start: NaN, last: NaN })
344      continue
345    }
346    if (row.type !== 'user' || row.isMeta || row.isCompactSummary || row.message?.role !== 'user') continue
347    const content = row.message.content
348    let text = ''
349    if (typeof content === 'string') {
350      text = content
351    } else if (Array.isArray(content)) {
352      text = content
353        .filter((block: any) => block?.type === 'text' && typeof block.text === 'string')
354        .map((block: any) => block.text)
355        .join('\n')
356    }
357    text = text.replace(VIEW_CONTEXT, '').trim()
358    // An interruption notice ends its turn; one mid tool call rides with the
359    // call's result, so it is read before tool results are passed over.
360    if (INTERRUPTED.test(text)) {
361      if (turn) turn.outcome = 'interrupted'
362      continue
363    }
364    if (Array.isArray(content) && content.some((block: any) => block?.type === 'tool_result')) continue
365    // A slash command's own row is not a prompt: the render hook skips it too,
366    // so it is never drawn and could not be scrolled to.
367    if (!text || WRAPPER.test(text) || text.startsWith('/')) continue
368    prompts.push({ id: row.uuid, text })
369    turns.push({ tools: 0, files: [] })
370    started = prompts.length - 1
371    const at = Date.parse(row.timestamp)
372    times.push({ start: at, last: at })
373  }
374  turns.forEach((turn, i) => {
375    const time = times[i]
376    if (time && Number.isFinite(time.start) && time.last > time.start) turn.spanMs = time.last - time.start
377  })
378  return { prompts, turns, owners, known: new Set(rows.map(row => row.uuid)) }
379}
380
381// The engine's cap on one $.fs.read; a larger transcript is streamed.
382const READ_CAP = 4 * 1024 * 1024
383const encoder = new TextEncoder()
384
385// The transcript as last read: its size and modification time, the rows of
386// its complete lines and the byte where they end, and where the last row
387// starts, which a later read of a long session's file checks is still there.
388// Whether a read is under way, and the path whose file could not be read,
389// said once.
390type Seen = {
391  path: string
392  size: number
393  mtimeMs: number
394  rows: any[]
395  offset: number
396  lastStart: number
397  lastUuid: string | undefined
398  isReading: boolean
399  warned: string
400}
401const unseen = (): Seen => ({
402  path: '',
403  size: -1,
404  mtimeMs: -1,
405  rows: [],
406  offset: 0,
407  lastStart: 0,
408  lastUuid: undefined,
409  isReading: false,
410  warned: '',
411})
412
413// Take the complete lines of `text`, which starts at byte `at` of the file,
414// into `seen`, and return the rest: a line the engine is still writing, or
415// the last of a file with no newline at its end.
416const takeLines = (seen: Seen, text: string, at: number) => {
417  const lines = text.split('\n')
418  const rest = lines.pop() ?? ''
419  for (const line of lines) {
420    const row = line.trim() ? parseRow(line) : undefined
421    if (row) {
422      seen.rows.push(row)
423      seen.lastStart = at
424      seen.lastUuid = row.uuid
425    }
426    at += encoder.encode(line).length + 1
427  }
428  seen.offset = at
429  return rest
430}
431
432// The index of the rows read, and of a last line with no newline yet when it
433// parses whole (it is read again once it has one).
434const indexSeen = (seen: Seen, rest: string) => {
435  const last = rest.trim() ? parseRow(rest) : undefined
436  return indexRows(last ? [...seen.rows, last] : seen.rows)
437}
438
439// Read the file on from the last row read, with tail, since $.fs.read takes
440// no more than READ_CAP. That row comes first and must still be there, else
441// the file was replaced: false then, and nothing is taken. Otherwise the rest
442// after the last complete line.
443async function streamRows($: EngineInterface, path: string, seen: Seen) {
444  let expected = seen.lastUuid
445  let at = expected === undefined ? seen.offset : seen.lastStart
446  let carry = ''
447  const child = $.process.spawn({ argv: ['tail', '-c', `+${at + 1}`, path] })
448  for await (const piece of child) {
449    if (piece.stream !== 'stdout') continue
450    let text = carry + piece.text
451    if (expected !== undefined) {
452      const end = text.indexOf('\n')
453      if (end < 0) {
454        carry = text
455        continue
456      }
457      const line = text.slice(0, end)
458      if (parseRow(line)?.uuid !== expected) return false
459      expected = undefined
460      at += encoder.encode(line).length + 1
461      text = text.slice(end + 1)
462    }
463    carry = takeLines(seen, text, at)
464    at = seen.offset
465  }
466  const { code } = await child.result
467  if (code !== 0) throw new Error(`tail exited with ${code}`)
468  return carry
469}
470
471// Stream a transcript too large for $.fs.read, from its start when it was
472// replaced; its index, or undefined when it could not be read, which is said
473// once for the file.
474async function readLarge($: EngineInterface, path: string, seen: Seen, size: number, mtimeMs: number) {
475  try {
476    let rest = await streamRows($, path, seen)
477    if (rest === false) {
478      Object.assign(seen, { rows: [], offset: 0, lastStart: 0, lastUuid: undefined })
479      rest = await streamRows($, path, seen)
480      if (rest === false) return undefined
481    }
482    Object.assign(seen, { path, size, mtimeMs })
483    return indexSeen(seen, rest)
484  } catch (err) {
485    if (seen.warned !== path) {
486      seen.warned = path
487      const megabytes = Math.round(size / 1024 / 1024)
488      $.ui.toast(`the transcript is ${megabytes} MB, too large to read here (${(err as Error).message}); prompts are listed as they are drawn`)
489    }
490    return undefined
491  }
492}
493
494// The transcript's index, or undefined when it is as `seen` last read it (a
495// long session's file is not parsed again for a turn that wrote nothing),
496// before the file exists (a fresh session), or while another read is under
497// way. Records what it read in `seen`. A file over READ_CAP is read on from
498// where the last read ended; when that is more than READ_CAP, as on resuming a
499// long session, it is read without holding the hook, and `whenLate` gets the
500// index once it is done.
501async function readTranscript($: EngineInterface, path: string, seen: Seen, whenLate: (index: TranscriptIndex) => void) {
502  if (seen.isReading) return undefined
503  try {
504    const { size, mtimeMs } = await $.fs.stat(path)
505    if (seen.path === path && seen.size === size && seen.mtimeMs === mtimeMs) return undefined
506    if (seen.path !== path || size < seen.offset) Object.assign(seen, { ...unseen(), warned: seen.warned })
507    if (size <= READ_CAP) {
508      const text = await $.fs.read(path)
509      Object.assign(seen, { rows: [], lastStart: 0, lastUuid: undefined })
510      const rest = takeLines(seen, text, 0)
511      Object.assign(seen, { path, size, mtimeMs })
512      return indexSeen(seen, rest)
513    }
514    seen.isReading = true
515    const reading = readLarge($, path, seen, size, mtimeMs).finally(() => {
516      seen.isReading = false
517    })
518    if (size - seen.offset <= READ_CAP) return await reading
519    void reading.then(index => index && whenLate(index))
520    return undefined
521  } catch {
522    seen.isReading = false
523    return undefined
524  }
525}
526
527// Remember this session's transcript path under its own key, then drop all
528// but the newest few sessions' keys.
529async function rememberTranscript($: EngineInterface, sessionId: string, transcriptPath: string) {
530  await $.store.set(`${TRANSCRIPT_KEY_PREFIX}${sessionId}`, { path: transcriptPath, at: Date.now() })
531  const keys = (await $.store.keys()).filter(key => key.startsWith(TRANSCRIPT_KEY_PREFIX))
532  if (keys.length <= KEPT_TRANSCRIPTS) return
533  const dated = await Promise.all(
534    keys.map(async key => {
535      const value = (await $.store.get(key)) as { at?: unknown } | undefined
536      return { key, at: typeof value?.at === 'number' ? value.at : 0 }
537    }),
538  )
539  dated.sort((x, y) => y.at - x.at)
540  await Promise.all(dated.slice(KEPT_TRANSCRIPTS).map(({ key }) => $.store.delete(key)))
541}
542
543// Open the pane while the rail is shown; close it while hidden.
544async function seatRail($: EngineInterface, isShown: boolean) {
545  if (isShown) await $.ui.open({ id: PANE, title: 'Prompts', columns: RAIL_COLUMNS })
546  else await $.ui.close({ id: PANE })
547}
548
549// Keep whether the rail is shown for later sessions, seat the pane to match,
550// and draw the band again, whose button follows.
551async function applyShown($: EngineInterface, isShown: boolean) {
552  await $.store.set(SHOWN_KEY, isShown)
553  await seatRail($, isShown)
554  await redrawRail($)
555}
556
557// Scroll the transcript to a prompt's row, drawn under `target`, from a
558// dispatch that answers the person's own input (a press, a typed command): a
559// transcript row moves only then. Records whether the prompt could be reached,
560// and returns whether the transcript scrolled to it. Where the view cannot
561// scroll at all, says so once a session (`notices.isUnscrollableSaid`).
562async function jumpTo($: EngineInterface, id: string, target: string, unreachable: Set<string>, notices: { isUnscrollableSaid: boolean }) {
563  try {
564    const result = await $.ui.scroll({ to: { requestId: target }, block: 'start' })
565    if (result.deny) {
566      const notice = jumpNotice(result.deny, notices.isUnscrollableSaid)
567      if (notice !== undefined) $.ui.toast(notice)
568      if (UNSCROLLABLE.test(result.deny)) notices.isUnscrollableSaid = true
569    }
570    if (noteScroll(unreachable, id, result.deny)) await redrawRail($)
571    return !result.deny
572  } catch (err) {
573    $.ui.toast((err as Error).message)
574    return false
575  }
576}
577
578// Draw the rail's sites again, and them alone (see MOVED).
579async function redrawRail($: EngineInterface) {
580  const { value = 0 } = await $.state.get(MOVED)
581  await $.state.set(MOVED, value + 1)
582}
583
584// The same from a render hook, which may not write state: once its dispatch ends.
585function redrawRailLater($: EngineInterface) {
586  $.clock.after(0, () => void redrawRail($))
587}
588
589// The timers of a redraw waiting for onScreen reports to settle.
590type Settle = { quiet?: Timer; latest?: Timer }
591
592// Draw the rail again once onScreen reports settle, if `isStale` then says the
593// drawing no longer shows the prompt being read: SETTLE_MS after the last
594// report, and no later than SETTLE_MAX_MS after the first, so reports that
595// never pause (a scroll held down, a running tool row) still redraw it. A
596// remounted row replays where it was last seen and a layout may settle over
597// two frames; the surface corrects both within a frame or two.
598function redrawRailSettled($: EngineInterface, settle: Settle, isStale: () => boolean) {
599  const fire = () => {
600    settle.quiet?.cancel()
601    settle.latest?.cancel()
602    settle.quiet = undefined
603    settle.latest = undefined
604    if (isStale()) void redrawRail($)
605  }
606  settle.quiet?.cancel()
607  settle.quiet = $.clock.after(SETTLE_MS, fire)
608  settle.latest ??= $.clock.after(SETTLE_MAX_MS, fire)
609}
610
611// After one onScreen report: a row that said it is cut at the viewport's top
612// is confirmed once a replay would have been corrected (see SETTLE_MS), and
613// the rail is drawn again once reports settle.
614function afterReport<Row>($: EngineInterface, settle: Settle, cut: Row | undefined, confirm: (row: Row) => void, isStale: () => boolean) {
615  if (cut !== undefined) $.clock.after(SETTLE_MS, () => confirm(cut))
616  redrawRailSettled($, settle, isStale)
617}
618
619// The transcript path remembered for this session, if any.
620async function rememberedTranscript($: EngineInterface) {
621  const value = (await $.store.get(`${TRANSCRIPT_KEY_PREFIX}${await $.session.id()}`)) as { path?: unknown } | undefined
622  return typeof value?.path === 'string' ? value.path : undefined
623}
624
625export const register: Register = on => {
626  let entries: Entry[] = []
627  // Assistant row key -> the key of the prompt it answers, from the transcript.
628  let owners = new Map<string, string>()
629  // Prompt id -> the turn it started, from the transcript.
630  let turns = new Map<string, Turn>()
631  // Prompt row key -> how long its turns took as the engine reported them on
632  // ending, for a turn whose turn_duration row the transcript lacks yet.
633  const reported = new Map<string, number>()
634  // Prompt row key -> how its turn ended as the engine reported it, and whether the
635  // newest prompt's turn is running now.
636  const ended = new Map<string, Outcome>()
637  let isRunning = false
638  // How many prompts were listed when the session last came to rest (a turn
639  // ended, or the session started), and whether the running turn is a
640  // continuation with no typed text. A new prompt's turn may start before its
641  // row is stored, so the newest entry is the running turn's own only once a
642  // prompt has been listed since the rest; text alone cannot tell, since the
643  // person may send the same text twice.
644  let listedAtRest = 0
645  let isContinuation = false
646  // The text of the prompt that started the main loop's latest turn, and the
647  // id of its entry once its row is drawn (see listDrawn).
648  let turnText = ''
649  let starter: string | undefined
650  // The entry of the prompt that started the latest turn, not the newest,
651  // since a prompt delivered into the turn comes after it, with the same text
652  // or not.
653  const turnEntry = () => {
654    const started = entries.find(entry => entry.id === starter)
655    if (started) return started
656    for (let i = entries.length - 1; i >= 0; i--) if (turnText && entries[i]!.text === turnText) return entries[i]
657    return entries[entries.length - 1]
658  }
659  const isRunningFor = (id: string) => {
660    if (!isRunning || turnEntry()?.id !== id) return false
661    return isContinuation || entries.length > listedAtRest
662  }
663  // A prompt's turn as the rail shows it: the transcript's record, completed
664  // by what the engine reported before the transcript had it.
665  const turnOf = (id: string): Turn | undefined => {
666    const turn = turns.get(id)
667    const ms = turn?.durationMs ?? reported.get(rowKey(id))
668    const outcome = isRunningFor(id) ? 'running' : (turn?.outcome ?? ended.get(rowKey(id)))
669    if (!turn && ms === undefined && outcome === undefined) return undefined
670    return { tools: 0, files: [], ...turn, ...(ms === undefined ? {} : { durationMs: ms }), ...(outcome ? { outcome } : {}) }
671  }
672  // The transcript rows (prompts, replies, tool rows) the viewport shows, by
673  // component and the id each is drawn under: the key its prompt is found by,
674  // where it is on screen, and when it said so. A row reports only when that
675  // changes, so one that stays whole in the viewport is silent while others
676  // scroll past it; it is dropped once it says it left.
677  type Shown = { name: string; key: string; first: number; order: number }
678  const onScreen = new Map<string, Shown>()
679  let reports = 0
680  // The row key of the prompt being read, kept while no known row shows.
681  let reading: string | undefined
682  // The prompt the rail last drew as being read, and the redraw waiting for
683  // reports to settle.
684  let drawnCurrent = -1
685  const settle: Settle = {}
686  // The prompt a jump landed on, read while the rows that came into view as it
687  // landed are all that show: one near the end cannot reach the viewport's
688  // top, so rows of earlier prompts stay above it. `shown` holds those rows'
689  // prompts; `isSettled` once they had SETTLE_MAX_MS to report.
690  let landed: { key: string; shown: Set<number>; isSettled: boolean } | undefined
691  // Prompts whose rows the surface does not draw, learnt from a refused jump.
692  const unreachable = new Set<string>()
693  // Notices a session gives once (see jumpTo).
694  const notices = { isUnscrollableSaid: false }
695  const seen: Seen = unseen()
696  // Row key -> the id a prompt's row was last drawn under (see drawnRow).
697  const drawn = new Map<string, string>()
698  // The ids of the prompts the last transcript read listed.
699  let filed = new Set<string>()
700
701  const addPrompt = (id: string, text: string) => {
702    // A new prompt is drawn under a provisional id before it is stored, then
703    // again under its uuid: list the stored row only, so a repeated prompt
704    // ("continue" twice) still gets an entry of its own.
705    if (id === PROVISIONAL_ID || entries.some(entry => rowKey(entry.id) === rowKey(id))) return false
706    entries = [...entries, { id, text }]
707    return true
708  }
709
710  // A prompt that does not go straight into a turn (queued behind the running
711  // one, delivered into it, or sent from Remote Control) is drawn under ids the
712  // engine never stores before its stored row comes. Its entry is pending
713  // meanwhile: rows drawn with its text are other names of it (row key ->
714  // entry id), and its stored row takes its place in the list.
715  const pending = new Set<string>()
716  const aliases = new Map<string, string>()
717  // Texts of prompts sent and not stored yet: from prompt.submit or
718  // session.receive until the stored row is drawn or the main loop rests.
719  const waiting = new Set<string>()
720  // The text of the prompt last drawn under the provisional id, until its
721  // stored row is drawn.
722  let provisional: string | undefined
723  // Entries added since the last notification, placeholder or turn edge, and
724  // when: the engine may draw a queued prompt's first row before its
725  // notification, which then takes it for that prompt's.
726  let lately: { id: string; at: number }[] = []
727  // When each waiting text's notification came, and the pending entries
728  // delivered into the running turn: a row of theirs drawn well after the
729  // notification is the attachment the turn read, and no provisional row
730  // follows, so the turn's end leaves them pending no more.
731  const notifiedAt = new Map<string, number>()
732  const delivered = new Set<string>()
733
734  const pendingWith = (text: string) => entries.find(entry => pending.has(entry.id) && entry.text === text)
735  // The entry a drawn row belongs to, by its own key or as another name.
736  const entryKeyOf = (key: string) => {
737    if (entries.some(entry => rowKey(entry.id) === key)) return key
738    const id = aliases.get(key)
739    return id === undefined ? key : rowKey(id)
740  }
741
742  // A prompt with `text` was sent: its rows are pending until the stored one
743  // is drawn. True when the list changed.
744  const noteSent = (text: string) => {
745    waiting.add(text)
746    const now = Date.now()
747    notifiedAt.set(text, now)
748    const fresh = new Set(lately.filter(item => now - item.at <= LATELY_MS).map(item => item.id))
749    lately = []
750    const own = entries.filter(entry => fresh.has(entry.id) && entry.text === text && !pending.has(entry.id))
751    const held = pendingWith(text) ?? own[0]
752    if (!held || own.length === 0) return false
753    pending.add(held.id)
754    const others = new Set(own.filter(entry => entry !== held).map(entry => entry.id))
755    for (const id of others) aliases.set(rowKey(id), held.id)
756    entries = entries.filter(entry => !others.has(entry.id))
757    return others.size > 0
758  }
759
760  // List a prompt row as it is drawn; true when the list changed.
761  const listDrawn = (id: string, text: string) => {
762    if (id === PROVISIONAL_ID) {
763      provisional = text
764      lately = []
765      return false
766    }
767    const key = rowKey(id)
768    if (entries.some(entry => rowKey(entry.id) === key)) {
769      // A stored row a transcript read listed first still ends its prompt.
770      if (provisional === text) {
771        provisional = undefined
772        waiting.delete(text)
773        if (isRunning) starter = entries.find(entry => rowKey(entry.id) === key)?.id
774      }
775      return false
776    }
777    const alias = aliases.get(key)
778    if (alias !== undefined) {
779      drawn.set(rowKey(alias), id)
780      return false
781    }
782    if (provisional === text) {
783      provisional = undefined
784      waiting.delete(text)
785      if (isRunning) starter = id
786      const held = pendingWith(text)
787      if (!held) return addPrompt(id, text)
788      // The stored row takes the place of the entry that waited for it.
789      entries = entries.map(entry => (entry === held ? { id, text } : entry))
790      pending.delete(held.id)
791      for (const [name, target] of aliases) if (target === held.id) aliases.set(name, id)
792      aliases.set(rowKey(held.id), id)
793      return true
794    }
795    if (waiting.has(text)) {
796      const held = pendingWith(text)
797      if (held) {
798        // Another row of the waiting prompt; a jump goes to the one drawn last.
799        aliases.set(key, held.id)
800        drawn.set(rowKey(held.id), id)
801        if (isRunning && Date.now() - (notifiedAt.get(text) ?? Date.now()) > LATELY_MS) delivered.add(held.id)
802        return false
803      }
804      pending.add(id)
805      return addPrompt(id, text)
806    }
807    const isAdded = addPrompt(id, text)
808    if (isAdded) lately = [...lately, { id, at: Date.now() }]
809    // A turn's prompt drawn with no provisional row, as the session's first.
810    if (isAdded && isRunning && starter === undefined && text === turnText) starter = id
811    return isAdded
812  }
813
814  // Record one onScreen report: where a row is on screen, or null once it left.
815  // Rows drawn while a subagent's transcript is in view are not the main
816  // conversation's, which alone the rail lists. Returns the row when it says
817  // it is cut at the viewport's top, for dropAbove to confirm.
818  const see = (component: string, id: string, place: { first: number } | null) => {
819    if (viewAgent !== undefined) return undefined
820    const name = `${component}\u0000${id}`
821    if (place === null) {
822      onScreen.delete(name)
823      if (landed && !isLandedShown()) landed = undefined
824      return undefined
825    }
826    const row = { name, key: rowKey(id), first: place.first, order: ++reports }
827    onScreen.set(name, row)
828    noteLandedRow(row.key)
829    return row.first > 0 ? row : undefined
830  }
831
832  // The index of the prompt a drawn row belongs to, a reply counting as its
833  // prompt's; undefined for a row that places no one.
834  const indexOfRow = (key: string, promptIndex: Map<string, number>) => {
835    if (key === PROVISIONAL_ID) return undefined
836    const id = entryKeyOf(key)
837    const ownerId = owners.get(id)
838    // A reply or tool row the transcript read does not know was written
839    // after it: the Stop hook reads before the turn's last reply is stored,
840    // and a running turn's rows come later still. A turn's start reads the
841    // file again, so only the newest turn can own it.
842    return promptIndex.get(id) ?? (ownerId === undefined ? entries.length - 1 : promptIndex.get(ownerId))
843  }
844  const promptIndexes = () => new Map(entries.map((entry, i) => [rowKey(entry.id), i]))
845
846  const landedIndex = () => (landed ? entries.findIndex(entry => rowKey(entry.id) === landed?.key) : -1)
847  const isLandedShown = () => {
848    const i = landedIndex()
849    const promptIndex = promptIndexes()
850    return i >= 0 && [...onScreen.values()].some(row => indexOfRow(row.key, promptIndex) === i)
851  }
852  // A row of a prompt that was not in view as the jump landed: the person
853  // scrolled, so the top row says who is read again.
854  const noteLandedRow = (key: string) => {
855    if (!landed) return
856    const i = indexOfRow(key, promptIndexes())
857    if (i === undefined) return
858    if (!landed.isSettled) landed.shown.add(i)
859    else if (!landed.shown.has(i)) landed = undefined
860  }
861
862  // The prompt of the row that last said it is cut at the viewport's top: that
863  // row is the top one, so a row of an earlier prompt still listed left
864  // without saying so, as rows do in a jump. Unless a row of an earlier prompt
865  // said it is on screen since: the viewport then moved above the cut row,
866  // which left without saying so itself.
867  const topIndex = (promptIndex: Map<string, number>) => {
868    const rows = [...onScreen.values()].flatMap(row => {
869      const i = indexOfRow(row.key, promptIndex)
870      return i === undefined ? [] : [{ i, first: row.first, order: row.order }]
871    })
872    let top: (typeof rows)[number] | undefined
873    for (const row of rows) {
874      if (row.first <= 0 || (top && row.order < top.order)) continue
875      if (rows.some(other => other.i < row.i && other.order > row.order)) continue
876      top = row
877    }
878    return top?.i
879  }
880
881  // A row still cut at the viewport's top a while after it said so is the top
882  // one: drop the rows of earlier prompts for good, so they cannot come back
883  // when it turns whole. Not sooner: a remounted row replays where it was last
884  // seen and says where it is a frame later. Not when a row of an earlier
885  // prompt said it is on screen since, as then the viewport moved above it.
886  const dropAbove = (cut: Shown) => {
887    const row = onScreen.get(cut.name)
888    if (!row || row.first <= 0) return
889    const promptIndex = promptIndexes()
890    const top = indexOfRow(row.key, promptIndex)
891    if (top === undefined) return
892    const isAbove = (other: Shown) => (indexOfRow(other.key, promptIndex) ?? top) < top
893    if ([...onScreen.values()].some(other => other.order > row.order && isAbove(other))) return
894    for (const [name, other] of onScreen) if (isAbove(other)) onScreen.delete(name)
895  }
896
897  // Where the person is reading: the prompt that the topmost row on screen
898  // belongs to. Kept while no known row shows.
899  const currentIndex = () => {
900    const jumped = landedIndex()
901    if (jumped >= 0) {
902      reading = landed?.key
903      return jumped
904    }
905    landed = undefined
906    const promptIndex = promptIndexes()
907    // Rows above the top one are read past until it is confirmed (see dropAbove).
908    const top = topIndex(promptIndex)
909    let best = -1
910    for (const row of onScreen.values()) {
911      const i = indexOfRow(row.key, promptIndex)
912      if (i === undefined || (top !== undefined && i < top)) continue
913      if (best < 0 || i < best) best = i
914    }
915    const entry = entries[best]
916    if (entry) reading = rowKey(entry.id)
917    if (reading === undefined) return -1
918    // By key, and through another name, so a stored row that takes a pending
919    // entry's place, or a rewind that drops an earlier prompt, keeps it.
920    const key = entryKeyOf(reading)
921    return entries.findIndex(entry => rowKey(entry.id) === key)
922  }
923  // Whether the rail's drawing no longer shows the prompt being read.
924  const isDrawnStale = () => currentIndex() !== drawnCurrent
925
926  // After a jump lands, the rows before it left and the prompt jumped to is
927  // at the top, though rows may not say so: a row that stays whole is silent,
928  // and one that unmounts may never report it left.
929  const landOn = (id: string, target: string, index: number) => {
930    onScreen.clear()
931    const name = `UserMessage\u0000${target}`
932    onScreen.set(name, { name, key: rowKey(target), first: 0, order: ++reports })
933    reading = rowKey(id)
934    const jump = { key: rowKey(id), shown: new Set([index]), isSettled: false }
935    landed = jump
936    return jump
937  }
938
939  // Whether the transcript read knows a drawn row, as a prompt or a reply.
940  const isKnownRow = (key: string) => {
941    const id = entryKeyOf(key)
942    return entries.some(entry => rowKey(entry.id) === id) || owners.has(id)
943  }
944
945  // The rail's sites say whose transcript is in view; the rows a switch
946  // leaves were another transcript's, and those it brings report anew.
947  const noteView = (agentId: string | undefined) => {
948    if (agentId !== viewAgent) {
949      onScreen.clear()
950      landed = undefined
951    }
952    viewAgent = agentId
953  }
954
955  // Whether the rail is shown (the pane open) or hidden to the band's button,
956  // and whether the pane has drawn since it was last shown: one the engine
957  // has not seated (too narrow, unasked) leaves the band's button up too.
958  let isShown = true
959  let isPaneDrawn = false
960  // The subagent whose transcript is in view, as the rail's sites last drew;
961  // undefined for the main conversation, whose rows alone the rail lists.
962  let viewAgent: string | undefined
963  let railColumns = 0
964  // Show or hide the rail; applyShown then seats the pane and keeps it.
965  const setShown = (shown: boolean) => {
966    isShown = shown
967    if (!shown) isPaneDrawn = false
968    return shown
969  }
970
971  on('session.start', async ($, e, next) => {
972    await $.command.register({
973      // Named after the plugin: a plugin's commands share one namespace with
974      // every other plugin's and the built-ins, so a generic name would collide.
975      name: PLUGIN,
976      description:
977        'Show, hide or toggle the prompt rail beside the transcript; next, prev, first, last, a number or find <words> jumps to a prompt.',
978      argumentHint: USAGE,
979      // Runs while a turn streams, so next and prev move through it then too.
980      immediate: true,
981    })
982    // Argument-free, so a keybinding can name them (`command:cc-cli-rail-next`):
983    // a binding runs a command bare.
984    for (const [name, way] of STEP_COMMANDS) {
985      await $.command.register({ name, description: `Jump to the ${way} prompt in the prompt rail.`, immediate: true })
986    }
987    await $.command.register({ name: TOGGLE_COMMAND, description: 'Show or hide the prompt rail.', immediate: true })
988    isShown = (await $.store.get(SHOWN_KEY)) !== false
989    // Also fired after a hot reload, when the list starts empty: rebuild it from
990    // the transcript this session's classic SessionStart remembered.
991    const transcriptPath = await rememberedTranscript($)
992    const index = transcriptPath === undefined ? undefined : await readTranscript($, transcriptPath, seen, index => {
993      if (merge(index)) void redrawRail($)
994    })
995    if (index && merge(index)) await redrawRail($)
996    if (!isRunning) listedAtRest = entries.length
997    // Unasked, the engine seats a pane only from 144 columns (110 once the
998    // person has opened it); below that it waits undrawn and the band's
999    // button shows it.
1000    await seatRail($, isShown)
1001    return next(e)
1002  })
1003
1004  // The prompt a command asks for: its index among the entries, or why none
1005  // (undefined when the command asks for no prompt).
1006  const askedIndex = (command: string, asked: string): number | undefined => {
1007    const way = STEP_COMMANDS.find(([name]) => name === command)?.[1]
1008    if (way !== undefined || asked === 'next' || asked === 'prev') {
1009      const dir = way === 'next' || asked === 'next' ? 1 : -1
1010      return stepFrom(currentIndex(), entries.length, dir, isUnreachable)
1011    }
1012    return pickPrompt(asked, entries.map(entry => entry.text))
1013  }
1014  const missingFor = (command: string, asked: string) => {
1015    if (command === STEP_COMMANDS[0][0] || asked === 'next') return 'no later prompt'
1016    if (command === STEP_COMMANDS[1][0] || asked === 'prev') return 'no earlier prompt'
1017    return `no prompt ${asked}`
1018  }
1019
1020  on('command.run', async ($, e, next) => {
1021    const isStep = STEP_COMMANDS.some(([name]) => name === e.command)
1022    if (e.command === TOGGLE_COMMAND) {
1023      await applyShown($, setShown(!isShown || !isPaneDrawn))
1024      return {}
1025    }
1026    if (!isStep && e.command !== PLUGIN) return next(e)
1027    const asked = isStep ? '' : e.args.trim()
1028    const index = askedIndex(e.command, asked)
1029    if (index !== undefined) {
1030      // The main conversation's rows are not drawn beside a subagent's, so a
1031      // jump would be refused and wrongly dot a prompt that can be reached.
1032      if (viewAgent !== undefined) {
1033        $.ui.toast('jumps move through the main conversation; switch back to it first')
1034        return {}
1035      }
1036      const entry = entries[index]
1037      const target = entry && drawnRow(drawn, entry.id)
1038      if (entry && target && (await jumpTo($, entry.id, target, unreachable, notices))) {
1039        const jump = landOn(entry.id, target, index)
1040        $.clock.after(SETTLE_MAX_MS, () => {
1041          jump.isSettled = true
1042        })
1043        if (isDrawnStale()) await redrawRail($)
1044      }
1045      if (!entry) $.ui.toast(missingFor(e.command, asked))
1046      return {}
1047    }
1048    // Bare, or `toggle`: hide a rail on screen, show one that is not.
1049    if (asked === '' || asked === 'toggle') {
1050      await applyShown($, setShown(!isShown || !isPaneDrawn))
1051      return {}
1052    }
1053    if (asked === 'show' || asked === 'hide') {
1054      await applyShown($, setShown(asked === 'show'))
1055      return {}
1056    }
1057    $.ui.toast(`/${PLUGIN} ${USAGE}`)
1058    return {}
1059  })
1060
1061  // Rebuild the list from the transcript file, whose uuids are the ids the
1062  // transcript rows are drawn under. A resumed session so lists prompts the
1063  // surface has not drawn yet; a row drawn but not stored yet stays after them,
1064  // and one the file holds off the live branch (rewound away) drops out. True
1065  // when the list or the prompt being read changed, so the rail needs a redraw.
1066  const merge = (index: TranscriptIndex) => {
1067    // A turn's details count as the list's: a turn that ends changes its card.
1068    const listed = (list: Entry[]) => list.map(entry => `${entry.id}\u0000${entry.text}\u0000${turnLine(turnOf(entry.id))}`).join('\u0001')
1069    const before = { list: listed(entries), current: currentIndex() }
1070    // An entry whose row, or another name of it, the file holds is listed
1071    // from the file.
1072    const known = new Set([...index.known].map(rowKey))
1073    for (const [name, id] of aliases) if (known.has(name)) known.add(rowKey(id))
1074    // An entry the last read listed and this one does not is gone from the
1075    // file (replaced under the rail), not a row drawn before it was stored.
1076    // AFU patch: a prompt sent mid-turn (queued, ctrl+enter) can leave its
1077    // drawn-only entry behind once the file lists it; drop a leftover whose
1078    // text the file already holds. A new repeat shows once it is stored.
1079    const fileTexts = new Set(index.prompts.map(entry => entry.text))
1080    entries = [...index.prompts, ...entries.filter(entry => !known.has(rowKey(entry.id)) && !filed.has(entry.id) && !fileTexts.has(entry.text))]
1081    filed = new Set(index.prompts.map(entry => entry.id))
1082    const ids = new Set(entries.map(entry => entry.id))
1083    for (const id of pending) if (!ids.has(id) || known.has(rowKey(id))) pending.delete(id)
1084    for (const [name, id] of aliases) if (!ids.has(id)) aliases.delete(name)
1085    owners = new Map(index.owners.map(([id, i]) => [rowKey(id), rowKey(index.prompts[i]?.id ?? '')]))
1086    turns = new Map(index.prompts.map((entry, i) => [entry.id, index.turns[i] ?? { tools: 0, files: [] }]))
1087    return listed(entries) !== before.list || currentIndex() !== before.current
1088  }
1089
1090  on('classic.SessionStart', async ($, e, next) => {
1091    if (e.source === 'clear') {
1092      entries = []
1093      owners = new Map()
1094      turns = new Map()
1095      reported.clear()
1096      ended.clear()
1097      isRunning = false
1098      onScreen.clear()
1099      landed = undefined
1100      reading = undefined
1101      drawnCurrent = -1
1102      unreachable.clear()
1103      notices.isUnscrollableSaid = false
1104      drawn.clear()
1105      filed = new Set()
1106      pending.clear()
1107      aliases.clear()
1108      waiting.clear()
1109      provisional = undefined
1110      lately = []
1111      turnText = ''
1112      starter = undefined
1113      notifiedAt.clear()
1114      delivered.clear()
1115      Object.assign(seen, unseen())
1116      await redrawRail($)
1117    } else {
1118      const index = await readTranscript($, e.transcript_path, seen, index => {
1119      if (merge(index)) void redrawRail($)
1120    })
1121      if (index && merge(index)) await redrawRail($)
1122    }
1123    listedAtRest = entries.length
1124    await rememberTranscript($, e.session_id, e.transcript_path)
1125    return next(e)
1126  })
1127
1128  on('classic.Stop', async ($, e, next) => {
1129    const index = await readTranscript($, e.transcript_path, seen, index => {
1130      if (merge(index)) void redrawRail($)
1131    })
1132    if (index && merge(index)) await redrawRail($)
1133    return next(e)
1134  })
1135
1136  // A main-loop turn starts (a subagent's run raises none): the newest
1137  // prompt's turn is running.
1138  on('turn.start', async ($, e, next) => {
1139    isRunning = true
1140    // The transcript follows the new turn down from the prompt jumped to.
1141    landed = undefined
1142    isContinuation = e.text.trim() === ''
1143    turnText = e.text.replace(VIEW_CONTEXT, '').trim()
1144    starter = undefined
1145    lately = []
1146    // Every row of the turns before is stored by now: read them, so a row the
1147    // index does not know can only be this turn's (see currentIndex).
1148    const index = seen.path
1149      ? await readTranscript($, seen.path, seen, index => {
1150          if (merge(index)) void redrawRail($)
1151        })
1152      : undefined
1153    if (index) {
1154      merge(index)
1155      // A row the file no longer holds (rewound or compacted away) would
1156      // count as this turn's.
1157      for (const [name, row] of onScreen) if (!isKnownRow(row.key)) onScreen.delete(name)
1158    }
1159    await redrawRail($)
1160    return next(e)
1161  })
1162
1163  // A main-loop turn ended: its prompt is the one that started it (see
1164  // turnEntry). The transcript's
1165  // turn_duration row is written after the Stop hook reads the file, so keep
1166  // the engine's figure and how the turn ended until a later read has them.
1167  on('turn.complete', async ($, e, next) => {
1168    const result = await next(e)
1169    const entry = turnEntry()
1170    if (e.agentId === undefined) {
1171      isRunning = false
1172      // A prompt still waiting now is queued behind this turn; its rows to
1173      // come follow its provisional row.
1174      waiting.clear()
1175      notifiedAt.clear()
1176      for (const id of delivered) pending.delete(id)
1177      delivered.clear()
1178      lately = []
1179      listedAtRest = entries.length
1180      if (entry) {
1181        // By row key: a prompt drawn under a derived id is listed under its
1182        // stored uuid once the transcript is read.
1183        const key = rowKey(entry.id)
1184        reported.set(key, (reported.get(key) ?? 0) + e.durationMs)
1185        if (e.reason === 'aborted') ended.set(key, 'interrupted')
1186        if (e.reason === 'error') ended.set(key, 'error')
1187      }
1188      await redrawRail($)
1189    }
1190    return result
1191  })
1192
1193  // A prompt was sent: typed, or delivered from Remote Control. Its rows drawn
1194  // before the stored one are pending (see listDrawn).
1195  on('prompt.submit', async ($, e, next) => {
1196    if (PROMPT_KINDS.has(e.origin.kind) && noteSent(e.text.replace(VIEW_CONTEXT, '').trim())) await redrawRail($)
1197    return next(e)
1198  })
1199  on('session.receive', async ($, e, next) => {
1200    if (PROMPT_KINDS.has(e.origin.kind) && noteSent(e.text.replace(VIEW_CONTEXT, '').trim())) await redrawRail($)
types/index.d.ts 8 lines
1declare module 'claude-code' {
2  interface PluginState {
3    // Bumped when the prompt being read moves or the rail is shown or hidden;
4    // the rail's sites read it while drawing, so a bump draws them again.
5    'cc-cli-rail': { moved: number }
6  }
7}
8