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

A rail of your prompts for Claude Code. Hover to read one, click to jump back to it.
<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">
~/.claude/settings.json: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
claude plugin marketplace add oikon48/prompt-rail
claude plugin install prompt-rail@oikon48
/plugin marketplace add oikon48/prompt-rail
/plugin install prompt-rail@oikon48
Mouse works best with "tui": "fullscreen".
<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">
<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">
| Command | |
|---|---|
/prompt-rail horizontal | Bars above the prompt input |
/prompt-rail vertical | A pane beside the transcript |
/prompt-rail off | Hide the rail |
/prompt-rail next | Jump to the next prompt |
/prompt-rail prev | Jump to the previous prompt |
/prompt-rail first, last | Jump to the first or the newest prompt |
/prompt-rail 12, #12 | Jump to prompt #12 |
/prompt-rail find <words> | Jump to the newest prompt that holds the words |
/prompt-rail-next, /prompt-rail-prev | Jump to the next or previous prompt, with no argument, for a keybinding |
/prompt-rail | Reopen the rail in the current layout |
The layout is also the "Rail mode" row in /config, and it is kept across sessions.
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 | Meaning |
|---|---|
━ ┃ | The prompt you are reading |
─ │ | Any other prompt |
┄ ┆ | A prompt Claude Code refused to scroll to; next and prev skip it |
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.
/compact command's own row cannot be jumped to; its tick turns dotted after the first try.next cannot scroll further.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.
hooks/index.tsx 1518 lines1import 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 lines1declare 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