SLOPSHOPPER

prompt-rail

A rail of your session's prompts beside the transcript or above the prompt; hover to read one, click to jump to it.

newpanebandrowscommandtoast
★ 21v0.7.1MITupdated 2026-10-03oikon48/prompt-rail/plugins/prompt-rail
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-rail
│ ┃ prompt-rail ✕ › fix the failing auth test and add an audit log ╭─────────────────╮ │ ┃ ─ fix the failing auth test and add an audi │ prompt-rail │ │ ⏺ Read(src/auth.ts) │ no later prompt │ │ ⎿ Read 6 lines ╰─────────────────╯ │ ⏺ Update(src/auth.ts) ╭───────────────────╮ │ ⎿ Added 2 lines, removed 1 line │ prompt-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 │ │ › /prompt-rail │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · prompt-rail
─ fix the failing auth test and add an audit log call
README

prompt-rail

A rail of your prompts for Claude Code. Hover to read one, click to jump back to it.

Claude Code plugin Claude Code 2.1.280+ Function hooks License: MIT

<img src="docs/demo.gif" alt="prompt-rail demo: hovering the rail previews a prompt and its turn, clicking jumps the transcript to it" width="800">

Quick start

  1. Turn on function hooks (early access, Claude Code 2.1.280+) in ~/.claude/settings.json:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
  1. Install, from your shell or inside a session:
   claude plugin marketplace add oikon48/prompt-rail
   claude plugin install prompt-rail@oikon48
   /plugin marketplace add oikon48/prompt-rail
   /plugin install prompt-rail@oikon48
  1. Start a new session. The rail opens on its own.

Mouse works best with "tui": "fullscreen".

Two layouts

Horizontal, above the prompt (default)

<img src="docs/horizontal.png" alt="The horizontal rail: a text line with the hovered prompt and its turn summary over a row of bars" width="800">

Vertical, beside the transcript

<img src="docs/vertical.png" alt="The vertical rail: one row per prompt in a pane beside the transcript, the prompt being read marked with a thick tick" width="480">

Commands

Command
/prompt-rail horizontalBars above the prompt input
/prompt-rail verticalA pane beside the transcript
/prompt-rail offHide the rail
/prompt-rail nextJump to the next prompt
/prompt-rail prevJump to the previous prompt
/prompt-rail first, lastJump to the first or the newest prompt
/prompt-rail 12, #12Jump to prompt #12
/prompt-rail find <words>Jump to the newest prompt that holds the words
/prompt-rail-next, /prompt-rail-prevJump to the next or previous prompt, with no argument, for a keybinding
/prompt-railReopen the rail in the current layout

The layout is also the "Rail mode" row in /config, and it is kept across sessions.

Keyboard

Bind the step commands in ~/.claude/keybindings.json; they run mid-turn too:

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

In the horizontal layout, ctrl+x tab moves the focus to the bars: the ring starts on the prompt you are reading, ← → move it across the bars on screen and show its prompt, Enter jumps there, Esc returns to the prompt input.

Tick legend

TickMeaning
━ ┃The prompt you are reading
─ │Any other prompt
┄ ┆A prompt Claude Code refused to scroll to; next and prev skip it

How it works

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. The prompt you are reading owns the topmost row on screen. The transcript is read again only when its size or time changed.

Limitations

  • Function hooks are early access, and their API may change between Claude Code releases.
  • The hover card with the turn summary is part of the horizontal rail.
  • The Claude desktop app draws the horizontal rail and its hover cards, but a click cannot jump there. The app gives a plugin no way to scroll its transcript, so the rail says so once a session. The prompt being read is named beside the bars rather than on the line above them, since the app paints a card over that line without hiding what is under it. Checked with Claude Code 2.1.280, the app did not draw the vertical pane.
  • The dock width is shared by all plugin panes; drag its edge to narrow it, down to 24 columns.
  • A /compact command's own row cannot be jumped to; its tick turns dotted after the first try.
  • Near the end of the transcript, next cannot scroll further.

Development

claude --plugin-dir plugins/prompt-rail        # load this checkout; saving reloads it
claude plugin validate plugins/prompt-rail
claude plugin test plugins/prompt-rail

Run /plugin-types .claude/types in a session here for type declarations. Bump the version in plugins/prompt-rail/.claude-plugin/plugin.json with each release, since installed copies update only when it changes.

License

MIT

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