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

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