A rail of this session's prompts above the prompt input, in the style of beUI's Preview Rail: hover a tick to preview a prompt, click it to jump there.

入力欄の上に、このセッションのプロンプトを tick の列で並べる Claude Code の mod。 tick にマウスを乗せるとプロンプトとターンの要約をカードで表示し、クリックでその位置へジャンプする。
"tui": "fullscreen" が向くclaude --plugin-dir ~/.dotfiles/.claude/marketplace/plugins/prompt-rail # 保存で読み込み直す
claude plugin validate ~/.dotfiles/.claude/marketplace/plugins/prompt-rail
claude plugin test ~/.dotfiles/.claude/marketplace/plugins/prompt-rail
トランスクリプト行へのスクロールはテストキットが応答しないため、クリックでの着地は実機で確認する。
hooks/index.tsx 513 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { cleanText, indexRows, isPromptText, parseLine, type Row, type TranscriptIndex, type Turn } from './transcript.ts'
3import {
4 ICON,
5 RAIL_INSET,
6 TICK_GAP,
7 tickGlyph,
8 cardLeft,
9 cardWidth,
10 cellWidth,
11 chooseLayout,
12 fit,
13 headlineParts,
14 railWindow,
15 turnLine,
16} from './layout.ts'
17
18// 帯を描き直すためだけのカウンタ。描画中に読み、変化を書くと帯だけが描き直される
19const MOVED = { plugin: 'prompt-rail', key: 'moved' } as const
20// 本人が送った行(端末の入力欄・Remote Control・SDK)
21const PROMPT_KINDS = new Set(['composer', 'bridge', 'sdk'])
22// 保存前のプロンプト行が一時的に描かれる id
23const PROVISIONAL_ID = 'placeholder'
24const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
25// `transcript:<session id>` → トランスクリプトのパス。hot reload 後の session.start はパスを持たないため
26const TRANSCRIPT_KEY = 'transcript:'
27const KEPT_TRANSCRIPTS = 20
28// 画面上の位置の報告が落ち着いてから描き直す。スクロール中も最長 SETTLE_MAX_MS で一度は描く
29const SETTLE_MS = 40
30const SETTLE_MAX_MS = 120
31
32const encoder = new TextEncoder()
33
34// 1つのメッセージが複数行に分かれて描かれると、保存時の uuid の最終グループを
35// 行番号に置き換えた id になる。uuid は先頭4グループで突き合わせる
36export const rowKey = (id: string) => (UUID.test(id) ? id.slice(0, 24) : id)
37
38type Entry = { id: string; text: string; turn?: Turn }
39
40// セッション中に保つ状態。module を読み込み直すと作り直される
41type Rail = {
42 index: TranscriptIndex
43 // 描かれたが、トランスクリプトにはまだ無いプロンプト行(送った直後など)
44 extras: Entry[]
45 list: Entry[]
46 byKey: Map<string, number>
47 ownerByKey: Map<string, number>
48 knownKeys: Set<string>
49 reader: { path: string; offset: number; rows: Row[] }
50 isReading: boolean
51 isReadAgain: boolean
52 // プロンプト行が最後に描かれた id(ジャンプ先)
53 drawn: Map<string, string>
54 // 画面に見えている行(コンポーネントと id)→ 行のキー。見えなくなった行は報告で消える
55 screen: Map<string, string>
56 // 読んでいるプロンプトのキー。見えている行が無い間は保つ
57 reading: string | undefined
58 drawnCurrent: number
59 settle: { quiet?: Timer; latest?: Timer }
60 // 表示中のトランスクリプトがサブエージェントのものなら、その id
61 viewAgent: string | undefined
62 isRunning: boolean
63 // turn_duration 行が書かれる前に、エンジンが報告したターンの所要時間
64 reported: Map<string, number>
65 // 行が描かれていないため移動できなかったプロンプトのキー。その行が描かれたら外す
66 unreachable: Set<string>
67}
68
69const emptyIndex = (): TranscriptIndex => ({ prompts: [], owners: new Map(), known: new Set() })
70
71export const newRail = (): Rail => ({
72 index: emptyIndex(),
73 extras: [],
74 list: [],
75 byKey: new Map(),
76 ownerByKey: new Map(),
77 knownKeys: new Set(),
78 reader: { path: '', offset: 0, rows: [] },
79 isReading: false,
80 isReadAgain: false,
81 drawn: new Map(),
82 screen: new Map(),
83 reading: undefined,
84 drawnCurrent: -1,
85 settle: {},
86 viewAgent: undefined,
87 isRunning: false,
88 reported: new Map(),
89 unreachable: new Set(),
90})
91
92function rebuild(rail: Rail) {
93 rail.list = [...rail.index.prompts, ...rail.extras]
94 rail.byKey = new Map(rail.list.map((entry, i) => [rowKey(entry.id), i]))
95 rail.ownerByKey = new Map([...rail.index.owners].map(([id, i]) => [rowKey(id), i]))
96 rail.knownKeys = new Set([...rail.index.known].map(rowKey))
97}
98
99export function applyIndex(rail: Rail, index: TranscriptIndex) {
100 rail.index = index
101 const known = new Set([...index.known].map(rowKey))
102 // 保存された行は索引から載せる。同じ文面が最近保存されていれば、それも同じプロンプトとみなす
103 const recent = new Set(index.prompts.slice(-(rail.extras.length + 2)).map(prompt => prompt.text))
104 rail.extras = rail.extras.filter(entry => !known.has(rowKey(entry.id)) && !recent.has(entry.text))
105 rebuild(rail)
106}
107
108// 描かれたプロンプト行を記録する。まだ一覧に無ければ載せ、変わったら true
109export function noteDrawn(rail: Rail, id: string, text: string) {
110 const key = rowKey(id)
111 rail.drawn.set(key, id)
112 const wasUnreachable = rail.unreachable.delete(key)
113 if (rail.byKey.has(key) || rail.knownKeys.has(key)) return wasUnreachable
114 rail.extras = [...rail.extras, { id, text }]
115 rebuild(rail)
116 return true
117}
118
119function resetRail(rail: Rail) {
120 Object.assign(rail, newRail())
121}
122
123const indexOfKey = (rail: Rail, key: string) => rail.byKey.get(key) ?? rail.ownerByKey.get(key) ?? rail.list.length - 1
124
125// 読んでいる位置: 画面にプロンプトの行が見えていれば、そのうち一番古いもの。
126// 見えていなければ、見えている応答やツールの行のうち一番古いプロンプトに属するもの
127export function currentIndex(rail: Rail) {
128 let prompt = -1
129 let any = -1
130 for (const [name, key] of rail.screen) {
131 const i = indexOfKey(rail, key)
132 if (i < 0) continue
133 if (any < 0 || i < any) any = i
134 if (name.startsWith('UserMessage\u0000') && (prompt < 0 || i < prompt)) prompt = i
135 }
136 const best = prompt >= 0 ? prompt : any
137 if (best >= 0) rail.reading = rowKey(rail.list[best]!.id)
138 const i = rail.reading === undefined ? -1 : (rail.byKey.get(rail.reading) ?? -1)
139 return i >= 0 ? i : rail.list.length - 1
140}
141
142function turnOf(rail: Rail, i: number) {
143 const entry = rail.list[i]
144 if (!entry) return ''
145 const isRunning = rail.isRunning && i === rail.list.length - 1
146 const ms = rail.reported.get(rowKey(entry.id))
147 if (ms === undefined || entry.turn?.durationMs !== undefined) return turnLine(entry.turn, isRunning)
148 return turnLine({ tools: 0, files: [], ...entry.turn, durationMs: ms }, isRunning)
149}
150
151async function redraw($: EngineInterface) {
152 const { value = 0 } = await $.state.get(MOVED)
153 await $.state.set(MOVED, value + 1)
154}
155
156// 描画フックの中では state を書けないので、そのディスパッチが終わってから描き直す
157function redrawLater($: EngineInterface) {
158 $.clock.after(0, () => void redraw($))
159}
160
161// 前回読んだ位置から先だけを読む。$.fs.read は 4 MiB までなので、長いセッションは tail で流し読む
162async function readFrom($: EngineInterface, reader: Rail['reader']) {
163 const child = $.process.spawn({ argv: ['tail', '-c', `+${reader.offset + 1}`, reader.path] })
164 let carry = ''
165 for await (const piece of child) {
166 if (piece.stream !== 'stdout') continue
167 const lines = (carry + piece.text).split('\n')
168 // 改行で終わっていない最後の行は書き込み途中かもしれないので、次回に読む
169 carry = lines.pop() ?? ''
170 for (const line of lines) {
171 reader.offset += encoder.encode(line).length + 1
172 const row = line.trim() ? parseLine(line) : undefined
173 if (row) reader.rows.push(row)
174 }
175 }
176 const { code } = await child.result
177 if (code !== 0) throw new Error(`tail exited with ${code}`)
178}
179
180// tail の無い環境(素の Windows)向け。全体を読み直す
181async function readWhole($: EngineInterface, reader: Rail['reader']) {
182 const text = await $.fs.read(reader.path)
183 const end = text.lastIndexOf('\n') + 1
184 reader.rows = text.slice(0, end).split('\n').flatMap(line => (line.trim() ? (parseLine(line) ?? []) : []))
185 reader.offset = encoder.encode(text.slice(0, end)).length
186}
187
188async function readTranscript($: EngineInterface, rail: Rail, path: string) {
189 if (rail.isReading) {
190 rail.isReadAgain = true
191 return
192 }
193 rail.isReading = true
194 try {
195 do {
196 rail.isReadAgain = false
197 const { size } = await $.fs.stat(path)
198 const reader = rail.reader
199 if (path !== reader.path || size < reader.offset) Object.assign(reader, { path, offset: 0, rows: [] })
200 if (size === reader.offset) continue
201 try {
202 await readFrom($, reader)
203 } catch {
204 await readWhole($, reader)
205 }
206 applyIndex(rail, indexRows(reader.rows))
207 await redraw($)
208 } while (rail.isReadAgain)
209 } catch {
210 // セッション開始直後でファイルがまだ無いなど。次のイベントで読み直す
211 } finally {
212 rail.isReading = false
213 }
214}
215
216async function rememberTranscript($: EngineInterface, sessionId: string, path: string) {
217 await $.store.set(`${TRANSCRIPT_KEY}${sessionId}`, { path, at: Date.now() })
218 const keys = (await $.store.keys()).filter(key => key.startsWith(TRANSCRIPT_KEY))
219 if (keys.length <= KEPT_TRANSCRIPTS) return
220 const dated = await Promise.all(
221 keys.map(async key => {
222 const value = (await $.store.get(key)) as { at?: unknown } | undefined
223 return { key, at: typeof value?.at === 'number' ? value.at : 0 }
224 }),
225 )
226 dated.sort((a, b) => b.at - a.at)
227 await Promise.all(dated.slice(KEPT_TRANSCRIPTS).map(({ key }) => $.store.delete(key)))
228}
229
230function report($: EngineInterface, rail: Rail, component: string, id: string, onScreen: unknown) {
231 if (onScreen === undefined || rail.viewAgent !== undefined) return
232 const name = `${component}\u0000${id}`
233 if (onScreen === null) rail.screen.delete(name)
234 else rail.screen.set(name, rowKey(id))
235 const fire = () => {
236 rail.settle.quiet?.cancel()
237 rail.settle.latest?.cancel()
238 rail.settle = {}
239 if (currentIndex(rail) !== rail.drawnCurrent) void redraw($)
240 }
241 rail.settle.quiet?.cancel()
242 rail.settle.quiet = $.clock.after(SETTLE_MS, fire)
243 rail.settle.latest ??= $.clock.after(SETTLE_MAX_MS, fire)
244}
245
246// ジャンプで画面外に出た行は、出たと報告しないことがある。着地した行だけが見えているとみなす
247export function landOn(rail: Rail, target: string) {
248 rail.screen.clear()
249 rail.screen.set(`UserMessage\u0000${target}`, rowKey(target))
250 rail.reading = rowKey(target)
251}
252
253// 移動を断られたときに伝える文。行が描かれていない断りなら、その tick に目印を付ける
254const NOT_DRAWN = /nothing drawn/
255export function refusalNotice(rail: Rail, key: string, deny: string) {
256 if (!NOT_DRAWN.test(deny)) return deny
257 rail.unreachable.add(key)
258 return 'このプロンプトの行はまだ画面に描かれていないため、移動できません'
259}
260
261async function jump($: EngineInterface, rail: Rail, i: number) {
262 const entry = rail.list[i]
263 if (!entry) return
264 if (rail.viewAgent !== undefined) {
265 $.ui.toast('jumps move through the main conversation; switch back to it first')
266 return
267 }
268 const key = rowKey(entry.id)
269 const target = rail.drawn.get(key) ?? entry.id
270 try {
271 const result = await $.ui.scroll({ to: { requestId: target }, block: 'start' })
272 if (result.deny) {
273 $.ui.toast(refusalNotice(rail, key, result.deny))
274 await redraw($)
275 return
276 }
277 } catch (err) {
278 $.ui.toast((err as Error).message)
279 return
280 }
281 landOn(rail, target)
282 await redraw($)
283}
284
285export const register: Register = on => {
286 const rail = newRail()
287
288 on('session.start', async ($, e, next) => {
289 // hot reload 後は一覧が空から始まるので、このセッションのトランスクリプトから作り直す
290 const kept = (await $.store.get(`${TRANSCRIPT_KEY}${await $.session.id()}`)) as { path?: unknown } | undefined
291 if (typeof kept?.path === 'string') await readTranscript($, rail, kept.path)
292 return next(e)
293 })
294
295 on('classic.SessionStart', async ($, e, next) => {
296 // 一覧が作れなくても、セッションの開始は止めない
297 if (e.source === 'clear') {
298 resetRail(rail)
299 await redraw($).catch(() => {})
300 } else {
301 await readTranscript($, rail, e.transcript_path).catch(() => {})
302 }
303 await rememberTranscript($, e.session_id, e.transcript_path).catch(() => {})
304 return next(e)
305 })
306
307 on('classic.Stop', async ($, e, next) => {
308 await readTranscript($, rail, e.transcript_path).catch(() => {})
309 return next(e)
310 })
311
312 // メインのターンが始まった(サブエージェントでは起きない)。それまでのターンは保存済み
313 on('turn.start', async ($, e, next) => {
314 rail.isRunning = true
315 if (rail.reader.path) await readTranscript($, rail, rail.reader.path)
316 // rewind や compact で消えた行は、新しいターンの行と見分けがつかないので外す
317 for (const [name, key] of rail.screen) if (!rail.knownKeys.has(key) && !rail.byKey.has(key)) rail.screen.delete(name)
318 await redraw($)
319 return next(e)
320 })
321
322 on('turn.complete', async ($, e, next) => {
323 const result = await next(e)
324 if (e.agentId === undefined) {
325 rail.isRunning = false
326 const newest = rail.list.at(-1)
327 if (newest) rail.reported.set(rowKey(newest.id), (rail.reported.get(rowKey(newest.id)) ?? 0) + e.durationMs)
328 await redraw($)
329 }
330 return result
331 })
332
333 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
334 const text = cleanText(e.props.text)
335 if (PROMPT_KINDS.has(e.props.origin.kind) && isPromptText(text) && e.requestId !== PROVISIONAL_ID) {
336 if (noteDrawn(rail, e.requestId, text)) redrawLater($)
337 report($, rail, e.component, e.requestId, e.props.onScreen)
338 }
339 return next(e)
340 })
341
342 // 応答とツールの行は、それが答えているプロンプトの位置を示す
343 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
344 report($, rail, e.component, e.requestId, e.props.onScreen)
345 return next(e)
346 })
347 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
348 report($, rail, e.component, e.requestId, e.props.onScreen)
349 return next(e)
350 })
351 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
352 report($, rail, e.component, e.requestId, e.props.onScreen)
353 return next(e)
354 })
355 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
356 // まとめて畳まれたツール群は、最初の呼び出しとして数える
357 const id = e.props.calls.find(call => call.tool_use_id)?.tool_use_id
358 if (id) report($, rail, e.component, id, e.props.onScreen)
359 return next(e)
360 })
361
362 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
363 await $.state.get(MOVED)
364 if (e.surface !== 'terminal') return next(e)
365 if (e.props.view.agentId !== rail.viewAgent) rail.screen.clear()
366 rail.viewAgent = e.props.view.agentId
367 if (e.props.hasSurvey || rail.viewAgent !== undefined || rail.list.length === 0) return next(e)
368 const { Box, Text, Button } = $.ui.resolve(e)
369
370 const list = rail.list
371 const width = Math.max(12, e.props.bodyColumns)
372 const layout = chooseLayout(e.props.maxRows)
373 const current = currentIndex(rail)
374 rail.drawnCurrent = current
375 // inline のときは、tick に使う幅を半分までにしてカードの場所を残す
376 const win = railWindow(list.length, current, layout.card === 'inline' ? Math.floor(width / 2) : width)
377 const shown = Array.from({ length: win.size }, (_, k) => win.first + k)
378 const lead = RAIL_INSET + (win.isClipped ? TICK_GAP : 0)
379 const tickX = (i: number) => lead + (i - win.first) * TICK_GAP
380 const scope = (i: number) => `prompt-rail-${i}`
381
382 // ---- カード ----
383 // beUI のカード: 控えめな枠、太字のタイトル、薄い説明。番号は角丸のピルにし、
384 // ホバー中はアクセント色にする。色はテーマのキーで指定し、テーマに追従させる
385 type Seg = { text: string; dim?: boolean; bold?: boolean; color?: string; bg?: string }
386 const room = width - RAIL_INSET * 2
387 // inline のカードは、tick 列(と › の印)の右に2セル空けて置き、右端に RAIL_INSET 残す
388 const railEnd = lead + win.size * TICK_GAP + (win.isClipped ? 1 : 0)
389 const cw = layout.card === 'line' ? room : layout.card === 'inline' ? width - railEnd - 2 - RAIL_INSET : cardWidth(room)
390 const xOf = (i: number) => RAIL_INSET + (layout.card === 'line' || layout.card === 'inline' ? 0 : cardLeft(tickX(i) - RAIL_INSET, room))
391 const pill = (i: number, isHovered: boolean): Seg[] => {
392 const tone = isHovered ? 'suggestion' : 'subtle'
393 return [
394 { text: ICON.pillStart, color: tone },
395 { text: ` ${i + 1} `, color: isHovered ? 'inverseText' : 'text', bg: tone, bold: isHovered },
396 { text: ICON.pillEnd, color: tone },
397 ]
398 }
399 const pillWidth = (i: number) => cellWidth(` ${i + 1} `) + 2
400 // ピルと本文、続けて要約(幅の半分まで)を、1行 w セルに収める
401 const headline = (i: number, w: number, isHovered: boolean, withDetail: boolean): Seg[] => {
402 const parts = headlineParts(list[i]!.text, withDetail ? turnOf(rail, i) : '', w - pillWidth(i) - 1)
403 return [
404 ...pill(i, isHovered),
405 { text: ' ' },
406 { text: parts.title, bold: isHovered, dim: !isHovered },
407 { text: parts.detail, dim: true },
408 { text: parts.pad },
409 ]
410 }
411 const detailLine = (i: number, w: number): Seg[] => {
412 const indent = pillWidth(i) + 1
413 return [{ text: ' '.repeat(indent) }, { text: fit(turnOf(rail, i), w - indent), dim: true }]
414 }
415 const cardLines = (i: number, isHovered: boolean): Seg[][] => {
416 if (layout.card === 'line' || layout.card === 'inline') return [headline(i, cw, isHovered, true)]
417 if (layout.card === 'plain') return [headline(i, cw, isHovered, false), detailLine(i, cw)]
418 const inner = cw - 4
419 const side = (segs: Seg[]): Seg[] => [{ text: '│ ', dim: true }, ...segs, { text: ' │', dim: true }]
420 return [
421 [{ text: `╭${'─'.repeat(cw - 2)}╮`, dim: true }],
422 side(headline(i, inner, isHovered, false)),
423 side(detailLine(i, inner)),
424 [{ text: `╰${'─'.repeat(cw - 2)}╯`, dim: true }],
425 ]
426 }
427 const drawLine = (segs: Seg[]) => (
428 <Box height={1} flexDirection="row">
429 {segs.map(seg => (
430 <Text dimColor={seg.dim} bold={seg.bold} color={seg.color} backgroundColor={seg.bg} wrap="truncate-end">
431 {seg.text}
432 </Text>
433 ))}
434 </Box>
435 )
436
437 // 休止時は読んでいるプロンプトのカードを控えめに出す。ホバーしたカードは、その分の
438 // 幅を空白で塗りつぶして重ねる(端末ではホバーで要素を隠せないため、上から覆う)
439 const restX = xOf(current)
440 // カードが十分な幅を取れないときは、カードを出さず tick だけにする
441 const hasCard = cw >= 16
442 const inlineCard =
443 layout.card !== 'inline' || !hasCard ? null : (
444 <Box height={1} width={cw + 2} flexDirection="column">
445 {drawLine([{ text: ' ' }, ...cardLines(current, false)[0]!])}
446 {shown.map(i => (
447 <Box position="absolute" top={0} left={0} flexDirection="column" display="none" hover={{ scope: scope(i), display: 'flex' }}>
448 {drawLine([{ text: ' ' }, ...cardLines(i, true)[0]!])}
449 </Box>
450 ))}
451 </Box>
452 )
453 const cards =
454 layout.card === 'inline' || !hasCard ? null : (
455 <Box flexDirection="column" height={layout.cardRows} width={width}>
456 {cardLines(current, false).map(segs => drawLine([{ text: ' '.repeat(restX) }, ...segs]))}
457 {shown.map(i => {
458 const x = xOf(i)
459 const from = Math.min(x, restX)
460 const to = Math.max(x, restX) + cw
461 return (
462 <Box position="absolute" top={0} left={from} flexDirection="column" display="none" hover={{ scope: scope(i), display: 'flex' }}>
463 {cardLines(i, true).map(segs => drawLine([{ text: ' '.repeat(x - from) }, ...segs, { text: ' '.repeat(to - x - cw) }]))}
464 </Box>
465 )
466 })}
467 </Box>
468 )
469
470 // ---- tick ----
471 // 1行の点字。読んでいる位置だけ高く明るくし、ホバーした tick も明るくする
472 const gap = ' '.repeat(TICK_GAP - 1)
473 const rail1 = (
474 <Box height={1} flexDirection="row" width={width}>
475 <Text>{' '.repeat(RAIL_INSET)}</Text>
476 {win.isClipped ? <Text dimColor>{win.first > 0 ? '‹ ' : ' '}</Text> : null}
477 {/* Fragment は Box になり、高さ1行の中で縦に積まれて空白が消えるので、配列で平らに並べる */}
478 {shown.flatMap(i => [
479 <Button
480 key={`jump-${i}`}
481 plain
482 label={tickGlyph(i === current, rail.unreachable.has(rowKey(list[i]!.id)))}
483 dimColor={i !== current}
484 hover={{ scope: scope(i), dimColor: false, bold: true }}
485 onPress={() => {}}
486 />,
487 <Text>{gap}</Text>,
488 ])}
489 {win.isClipped && win.first + win.size < list.length ? <Text dimColor>›</Text> : null}
490 {inlineCard}
491 </Box>
492 )
493 // 余白は marginTop ではなく空行として描く(帯の高さの計算に確実に入れるため)
494 return (
495 <Box flexDirection="column">
496 {layout.margin > 0 ? (
497 <Box height={1}>
498 <Text> </Text>
499 </Box>
500 ) : null}
501 {cards}
502 {rail1}
503 </Box>
504 )
505 })
506
507 on('ui.press', { plugin: 'prompt-rail' }, async ($, e, next) => {
508 const i = Number(/^jump-(\d+)$/.exec(e.element)?.[1])
509 if (Number.isInteger(i)) await jump($, rail, i)
510 return next(e)
511 })
512}
513hooks/transcript.ts 160 lines1// トランスクリプト(JSONL)から、本人が送ったプロンプトと各ターンの要約を取り出す。
2// 外部 I/O を持たない純粋関数だけを置く。
3
4export type Turn = {
5 // turn_duration 行が記録した所要時間。無いターンは spanMs(プロンプトから最後の応答まで)
6 durationMs?: number
7 spanMs?: number
8 tools: number
9 // Edit / Write で書き換えたファイルのパス(重複なし)
10 files: string[]
11 outcome?: 'interrupted' | 'error'
12}
13
14export type Prompt = { id: string; text: string; turn: Turn }
15
16export type TranscriptIndex = {
17 prompts: Prompt[]
18 // 応答行の uuid と tool_use id → それが属するプロンプトの位置
19 owners: Map<string, number>
20 // ファイルにある全行の uuid(rewind で外れた枝も含む)
21 known: Set<string>
22}
23
24// 1行を、索引づけに要る情報だけに縮めたもの。長いセッションでも保持量を抑える
25export type Row = {
26 uuid: string
27 parent?: string
28 isSidechain: boolean
29 at: number
30} & (
31 | { kind: 'prompt' | 'queued'; text: string }
32 | { kind: 'reply'; tools: { id: string; name: string; file?: string }[]; isError: boolean }
33 | { kind: 'duration'; ms: number }
34 | { kind: 'interrupt' }
35 | { kind: 'other' }
36)
37
38// エンジンが本人の行として書く、プロンプトではない行(slash コマンド・bash モード・通知など)
39const WRAPPER = /^<(command-|local-command-|bash-|system-reminder|task-notification|user-prompt-submit-hook)/
40// artifact を開いたまま送ったとき、エンジンがプロンプトの前に付ける閲覧状態
41const VIEW_CONTEXT = /^\s*<artifact-view-context artifact="[^"]*">\n\{"context":[\s\S]*?\n<\/artifact-view-context>/
42const INTERRUPTED = /^\[Request interrupted by user/
43const EDITING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
44
45export const cleanText = (text: string) => text.replace(VIEW_CONTEXT, '').trim()
46export const isPromptText = (text: string) => text !== '' && !WRAPPER.test(text) && !text.startsWith('/')
47
48const textOf = (content: unknown) => {
49 if (typeof content === 'string') return content
50 if (!Array.isArray(content)) return ''
51 return content
52 .filter((block: any) => block?.type === 'text' && typeof block.text === 'string')
53 .map((block: any) => block.text)
54 .join('\n')
55}
56
57export function parseLine(line: string): Row | undefined {
58 let raw: any
59 try {
60 raw = JSON.parse(line)
61 } catch {
62 return undefined // 書き込み途中の最終行など
63 }
64 if (typeof raw?.uuid !== 'string') return undefined
65 const parent = raw.parentUuid ?? raw.logicalParentUuid
66 const base = {
67 uuid: raw.uuid,
68 parent: typeof parent === 'string' ? parent : undefined,
69 isSidechain: raw.isSidechain === true,
70 at: Date.parse(raw.timestamp),
71 }
72 if (raw.type === 'system' && raw.subtype === 'turn_duration' && typeof raw.durationMs === 'number') {
73 return { ...base, kind: 'duration', ms: raw.durationMs }
74 }
75 if (raw.type === 'assistant') {
76 const content = Array.isArray(raw.message?.content) ? raw.message.content : []
77 const tools = content
78 .filter((block: any) => block?.type === 'tool_use' && typeof block.id === 'string')
79 .map((block: any) => {
80 const file = block.input?.file_path ?? block.input?.notebook_path
81 return { id: block.id, name: String(block.name), ...(typeof file === 'string' ? { file } : {}) }
82 })
83 return { ...base, kind: 'reply', tools, isError: raw.isApiErrorMessage === true }
84 }
85 // 実行中のターンに差し込まれたプロンプトは、独立した user 行ではなく添付として残る
86 if (raw.type === 'attachment' && raw.attachment?.type === 'queued_command') {
87 const text = typeof raw.attachment.prompt === 'string' ? cleanText(raw.attachment.prompt) : ''
88 return isPromptText(text) ? { ...base, kind: 'queued', text } : { ...base, kind: 'other' }
89 }
90 if (raw.type !== 'user' || raw.isMeta || raw.isCompactSummary || raw.message?.role !== 'user') {
91 return { ...base, kind: 'other' }
92 }
93 const content = raw.message.content
94 const text = cleanText(textOf(content))
95 // ツール実行中の中断通知は tool_result と同じ行に乗るので、tool_result の判定より先に見る
96 if (INTERRUPTED.test(text)) return { ...base, kind: 'interrupt' }
97 if (Array.isArray(content) && content.some((block: any) => block?.type === 'tool_result')) return { ...base, kind: 'other' }
98 return isPromptText(text) ? { ...base, kind: 'prompt', text } : { ...base, kind: 'other' }
99}
100
101// 生きている枝の uuid: 最後の行から親をたどった鎖。rewind で捨てた枝はファイルに残るが含まない
102function liveBranch(rows: readonly Row[]) {
103 const byId = new Map(rows.map(row => [row.uuid, row]))
104 const live = new Set<string>()
105 let row = rows.findLast(candidate => !candidate.isSidechain)
106 while (row && !live.has(row.uuid)) {
107 live.add(row.uuid)
108 row = row.parent === undefined ? undefined : byId.get(row.parent)
109 }
110 return live
111}
112
113export function indexRows(rows: readonly Row[]): TranscriptIndex {
114 const live = liveBranch(rows)
115 const prompts: Prompt[] = []
116 const owners = new Map<string, number>()
117 // ターンを始めたプロンプト。差し込まれたプロンプトは一覧に載るが、ターンの要約は始めた側に残す
118 let starter = -1
119 const times: { start: number; last: number }[] = []
120 for (const row of rows) {
121 if (row.isSidechain || !live.has(row.uuid)) continue
122 const turn = prompts[starter]?.turn
123 switch (row.kind) {
124 case 'prompt':
125 prompts.push({ id: row.uuid, text: row.text, turn: { tools: 0, files: [] } })
126 starter = prompts.length - 1
127 times[starter] = { start: row.at, last: row.at }
128 break
129 case 'queued':
130 prompts.push({ id: row.uuid, text: row.text, turn: { tools: 0, files: [] } })
131 break
132 case 'reply': {
133 if (!turn) break
134 // 応答は、届いたかどうかに関係なく最新のプロンプトの下に並ぶ
135 const owner = prompts.length - 1
136 owners.set(row.uuid, owner)
137 if (row.isError) turn.outcome = 'error'
138 if (Number.isFinite(row.at)) times[starter]!.last = row.at
139 for (const tool of row.tools) {
140 owners.set(tool.id, owner)
141 turn.tools++
142 if (EDITING_TOOLS.has(tool.name) && tool.file !== undefined && !turn.files.includes(tool.file)) turn.files.push(tool.file)
143 }
144 break
145 }
146 case 'duration':
147 if (turn) turn.durationMs = (turn.durationMs ?? 0) + row.ms
148 break
149 case 'interrupt':
150 if (turn) turn.outcome = 'interrupted'
151 break
152 }
153 }
154 prompts.forEach((prompt, i) => {
155 const time = times[i]
156 if (time && Number.isFinite(time.start) && time.last > time.start) prompt.turn.spanMs = time.last - time.start
157 })
158 return { prompts, owners, known: new Set(rows.map(row => row.uuid)) }
159}
160hooks/layout.ts 127 lines1// 帯の見た目を決める純粋関数。Preview Rail(beUI)の寸法を端末のセルに置き換える。
2import type { Turn } from './transcript.ts'
3
4// 東アジアの全角と絵文字は2セル
5const isWide = (code: number) =>
6 (code >= 0x1100 && code <= 0x115f) ||
7 (code >= 0x2e80 && code <= 0xa4cf) ||
8 (code >= 0xac00 && code <= 0xd7a3) ||
9 (code >= 0xf900 && code <= 0xfaff) ||
10 (code >= 0xfe30 && code <= 0xfe4f) ||
11 (code >= 0xff00 && code <= 0xff60) ||
12 (code >= 0xffe0 && code <= 0xffe6) ||
13 (code >= 0x1f300 && code <= 0x1faff) ||
14 (code >= 0x20000 && code <= 0x3fffd)
15
16const cells = (char: string) => (isWide(char.codePointAt(0) ?? 0) ? 2 : 1)
17
18export const cellWidth = (text: string) => [...text].reduce((sum, char) => sum + cells(char), 0)
19
20// 1行に潰し、width セルを超える分を … で切ったうえで、width セルまで空白で埋める
21export function fit(text: string, width: number) {
22 const flat = text.replace(/\s+/g, ' ').trim()
23 if (width <= 0) return ''
24 let out = ''
25 let used = 0
26 if (cellWidth(flat) > width) {
27 for (const char of flat) {
28 if (used + cells(char) > width - 1) break
29 out += char
30 used += cells(char)
31 }
32 out += '…'
33 used += 1
34 } else {
35 out = flat
36 used = cellWidth(flat)
37 }
38 return out + ' '.repeat(Math.max(0, width - used))
39}
40
41// カードの1行: 本文のすぐ後ろに2セル空けて要約を続け、残りを空白で埋めて width セルにする。
42// 本文は TITLE_MAX セルまで、要約は width の半分まで。右端に寄せると、広い端末で本文から離れすぎる
43export const TITLE_MAX = 60
44export function headlineParts(title: string, detail: string, width: number) {
45 const detailW = detail ? Math.min(cellWidth(detail), Math.floor(width / 2)) : 0
46 const room = Math.max(1, width - (detail ? detailW + 2 : 0))
47 const titleW = Math.min(cellWidth(title.replace(/\s+/g, ' ').trim()), TITLE_MAX, room)
48 const head = fit(title, titleW)
49 const tail = detail ? ` ${fit(detail, detailW)}` : ''
50 return { title: head, detail: tail, pad: ' '.repeat(Math.max(0, width - titleW - cellWidth(tail))) }
51}
52
53export function duration(ms: number) {
54 const s = Math.floor(ms / 1000)
55 if (s < 60) return `${s}s`
56 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
57 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
58}
59
60const baseName = (path: string) => path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
61
62// Nerd Font のアイコン(端末のフォントが Nerd Font であること前提)
63export const ICON = {
64 time: '\u{f051b}', // timer-outline
65 tools: '\u{f0ad}', // wrench
66 file: '\u{f0214}', // file
67 running: '\u{f046e}', // run
68 interrupted: '\u{f03e4}', // pause
69 error: '\u{ea87}', // error
70 pillStart: '\u{e0b6}', // 丸い左端
71 pillEnd: '\u{e0b4}', // 丸い右端
72} as const
73
74// ターンの要約: ` 2m 5s 13 app.ts +2`。分からない部分は省く
75export function turnLine(turn: Turn | undefined, isRunning: boolean) {
76 const parts: string[] = []
77 const ms = turn?.durationMs ?? turn?.spanMs
78 if (isRunning) parts.push(`${ICON.running} running`)
79 else if (ms !== undefined) parts.push(`${ICON.time} ${duration(ms)}`)
80 if (turn?.outcome === 'interrupted') parts.push(`${ICON.interrupted} interrupted`)
81 if (turn?.outcome === 'error') parts.push(`${ICON.error} API error`)
82 if (turn && turn.tools > 0) parts.push(`${ICON.tools} ${turn.tools}`)
83 if (turn && turn.files.length > 0) {
84 const rest = turn.files.length - 1
85 parts.push(`${ICON.file} ${baseName(turn.files[0]!)}${rest > 0 ? ` +${rest}` : ''}`)
86 }
87 return parts.join(' ')
88}
89
90// tick は点字1文字。Preview Rail の休止時と同じく全部同じ高さにそろえ、読んでいる
91// 位置だけを高く(点4つ)する。移動できなかったプロンプトは点1つで区別する
92export const tickGlyph = (isCurrent: boolean, isUnreachable = false) =>
93 isCurrent ? '\u{2847}' : isUnreachable ? '\u{2804}' : '\u{2844}' // ⡇ / ⠄ / ⡄
94
95// 帯の行数の内訳。入力欄の上に使える行数(maxRows)は端末の高さで変わり、実際には
96// 2行ほどしか無いことが多い。トランスクリプトとの間の余白(1行)を最優先で取り、
97// 残りでカードを大きくする。2行しか無ければ、カードを tick と同じ行の右に並べる
98// ('inline')。下側はエンジンが1行空けている。
99export type Layout = { card: 'boxed' | 'plain' | 'line' | 'inline'; cardRows: number; margin: number }
100export function chooseLayout(maxRows: number): Layout {
101 if (maxRows >= 1 + 4 + 1) return { card: 'boxed', cardRows: 4, margin: 1 }
102 if (maxRows >= 1 + 2 + 1) return { card: 'plain', cardRows: 2, margin: 1 }
103 if (maxRows >= 1 + 1 + 1) return { card: 'line', cardRows: 1, margin: 1 }
104 return { card: 'inline', cardRows: 0, margin: maxRows >= 2 ? 1 : 0 }
105}
106
107// tick は2セルおき。左端は少し空ける: ウィンドウ端だとポインタが最初の tick を
108// 離れる瞬間をホバー終了として拾えない
109export const RAIL_INSET = 2
110export const TICK_GAP = 2
111// 一度に描く tick の上限。カードの重ね描きで描画の文字数上限に近づかないようにする
112export const MAX_TICKS = 60
113
114// 収まらない数のプロンプトは、読んでいる位置を中心にした範囲だけ描き、‹ › で続きを示す
115export function railWindow(count: number, center: number, width: number) {
116 const fits = Math.max(1, Math.floor((width - RAIL_INSET) / TICK_GAP))
117 if (count <= Math.min(fits, MAX_TICKS)) return { first: 0, size: count, isClipped: false }
118 const size = Math.max(1, Math.min(fits - 2, MAX_TICKS))
119 const first = Math.min(Math.max(0, center - Math.floor(size / 2)), count - size)
120 return { first, size, isClipped: true }
121}
122
123// カードの幅と、tick の真上に来る左端位置
124export const CARD_MAX = 56
125export const cardWidth = (width: number) => Math.max(12, Math.min(CARD_MAX, width))
126export const cardLeft = (tickX: number, width: number) => Math.max(0, Math.min(tickX - 3, width - cardWidth(width)))
127types/prompt-rail.d.ts 7 lines1declare module 'claude-code' {
2 interface PluginState {
3 // 読んでいる位置や一覧が変わると増やす。帯が描画中に読むので、増やすと帯だけ描き直される
4 'prompt-rail': { moved: number }
5 }
6}
7