SLOPSHOPPER

prompt-rail

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.

newbandrowstoastprocesstimer
A shopper browsing a rack in a slop shop
README

prompt-rail

入力欄の上に、このセッションのプロンプトを tick の列で並べる Claude Code の mod。 tick にマウスを乗せるとプロンプトとターンの要約をカードで表示し、クリックでその位置へジャンプする。

  • 着想: oikon48/prompt-rail(MIT)。コードは流用せず書き直している
  • デザイン: beUI Preview Rail の細い tick と浮かぶカードを、端末のセルに置き換えた。tick は点字で、読んでいる位置だけ高く明るい(⡄ ⡄ ⡇ ⡄)。画面にまだ描かれていない行のプロンプトには移動できず、一度試すと tick が点1つ(⠄)になる
  • 端末(TUI)専用。マウス操作は "tui": "fullscreen" が向く
  • 番号のピルとターンの要約のアイコンに Nerd Font を使う。端末のフォントが Nerd Font でないと文字化けする

開発

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

トランスクリプト行へのスクロールはテストキットが応答しないため、クリックでの着地は実機で確認する。

Source 4 files
hooks/index.tsx 513 lines
1import 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}
513
hooks/transcript.ts 160 lines
1// トランスクリプト(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}
160
hooks/layout.ts 127 lines
1// 帯の見た目を決める純粋関数。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)))
127
types/prompt-rail.d.ts 7 lines
1declare module 'claude-code' {
2  interface PluginState {
3    // 読んでいる位置や一覧が変わると増やす。帯が描画中に読むので、増やすと帯だけ描き直される
4    'prompt-rail': { moved: number }
5  }
6}
7