SLOPSHOPPER

prompt-history

The prompts you typed this session, listed beside the chat with how long ago you sent each; click one to read it in full and jump back to it. /prompts opens it.

newpanecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-history
│ ┃ Prompts ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./controls.tsx │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 아직 보낸 프롬프트가 없어요. ⏺ Update(src/auth.ts) │ ┃ 프롬프트를 보내면 여기에 쌓여요. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /prompts │ ┃ ⎿ prompt-history: 0 prompts. ↑↓ pick one, Enter jumps back to it; │ ┃ ──────────────────────────────────────────── │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompts
▣ client module ./controls.tsx ──────────────────────────────────────────────────────── ▣ cli 아직 보낸 프롬프트가 없어요. 프롬프트를 보내면 여기에 쌓여요. ────────────────────────────────────────────────────────
README

💬 prompt-history

한국어 · English

이번 세션에서 내가 보낸 프롬프트만 모아 채팅 옆 패널에 보여줍니다. 언제 보냈는지 한눈에 보고, 긴 프롬프트도 전문으로 읽고, 버튼 한 번으로 트랜스크립트의 그 위치로 돌아가세요.

<img src="../../images/prompt-history-ko.png" alt="prompt-history" width="100%">

기능

  • 날짜별 목록: 오늘 · 어제 · 10월 20일 (월) 구분선 아래 번호와 한 줄 요약(줄바꿈은 ↵).
  • 상대 시간: 방금 전 → 3분 전 → 2시간 전 → 3일 전, 일주일이 지나면 10월 12일. 30초마다 갱신되고, 시간이 길어져도 줄 끝의 시간은 항상 보이게 프롬프트 쪽이 줄어듭니다.
  • 전문 미리보기: 고른 프롬프트를 오른쪽에 원문 그대로(단어 단위 줄바꿈). 길면 휠, 스크롤바, ↑ ↓로.
  • 복사: [ ⧉ 복사 ]로 고른 프롬프트 전문을 그대로 클립보드에. 다시 보내거나 다른 데 붙여 넣을 때.
  • 그 위치로 이동: [ ↗ 이동 ] 버튼이나 /prompts <번호>로 트랜스크립트를 그 프롬프트까지 스크롤.
  • 이전 프롬프트까지: 켜질 때 세션 트랜스크립트에서 그동안 보낸 프롬프트를 읽어 오고, 이후엔 보내는 즉시 추가. 슬래시 명령과 그 출력, 엔진이 끼워 넣는 메시지는 빠집니다. /clear 하면 비워져요.
  • 한국어 / English: 툴바의 한국어 │ EN으로 시간 표기를 바로 전환.

설치

/plugin install prompt-history --marketplace speardragon/ray-claude-code-mods

사용법

입력동작
/prompts패널 켜기 / 끄기 (토글). 켤 때 패널에 포커스가 가서 바로 ↑ ↓로 고를 수 있어요
/prompts on · /prompts off패널 켜기 · 끄기
/prompts 33번째 프롬프트 위치로 바로 이동
↑ ↓(패널에 포커스가 있을 때) 프롬프트 고르기, PageUp PageDown은 여러 개씩, Home End는 처음 / 끝
Enter(패널에 포커스가 있을 때) 고른 프롬프트 위치로 이동
목록 줄 클릭그 프롬프트를 골라 오른쪽에 전문 표시
[ ⧉ 복사 ]고른 프롬프트 전문을 클립보드에 복사
[ ↗ 이동 ]고른 프롬프트 위치로 이동
상태줄 ▲ ▼전문 한 화면 위 / 아래
휠 · 스크롤바커서가 올라간 컬럼만 스크롤

포커스: 패널 ↔ 프롬프트 입력창

키 입력은 한 번에 한 곳으로만 갑니다. 프롬프트 입력창이거나, 패널이거나.

하고 싶은 것방법
패널로 가기/prompts로 켤 때 자동으로, 또는 ctrl+x tab, 또는 패널의 버튼 클릭
입력창으로 돌아오기Esc (패널은 그대로 열려 있어요)
패널 닫기/prompts 다시 입력, /prompts off, 또는 ctrl+x x

패널에 포커스가 있으면 ↑ ↓가 목록을 움직이고 Enter가 이동 버튼을 누릅니다. 입력창에 포커스가 있으면 평소처럼 타이핑과 프롬프트 기록(↑)으로 동작해요.

설정

키기본값설명
localekoko(3분 전, 10월 22일, 오후 2:31) 또는 en(3m ago, Oct 22, 2:31 PM). 툴바에서 세션 동안 바꿀 수 있어요

알아두면 좋은 것

  • Claude Code는 사람이 직접 한 입력(버튼, 직접 친 명령)에 답할 때만 트랜스크립트를 움직입니다. 목록 줄은 호버를 직접 그리는 영역이라 그 클릭으로는 이동하지 않고, 이동은 [ ↗ 이동 ]과 /prompts <번호>로 합니다.
  • 시간대는 시스템(date +%z)을 따릅니다.
Source 11 files
hooks/register.tsx 380 lines
1// prompt-history: the prompts the person typed this session, in a pane beside the chat.
2//   Left, the list: a heading per day, each prompt's number, its text cut to the row and how long
3//   ago it was sent (ko: 3분 전, en: 3m ago, a date after a week). Right, the picked prompt in
4//   full (the newest until one is picked), with its own scrollbar. Hovering only lights a row.
5//   A click or Enter jumps the transcript to that prompt; /prompts opens the pane, /prompts <n>
6//   jumps to the nth prompt. Prompts already sent are read from the transcript file when the mod
7//   loads; new ones arrive through session.append.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, RenderSurface } from 'claude-code'
10
11import type { Locale, Prompt, Scroll } from '../types'
12import type { ListRow } from './list-client'
13import { textWidth, truncate, wrapText } from './text'
14import { clock, date, dayLabel, parseOffset, relative, sameDay } from './time'
15import { HUMAN_MARK, parseTranscript, promptText, transcriptPath } from './transcript'
16import { keyStep } from './keys'
17import { layout, Pane } from './view'
18import type { Action, Layout, Shown } from './view'
19
20const PANE = 'prompt-history'
21const TITLE = 'Prompts'
22const TICK_MS = 30_000 // how often "3분 전" is brought up to date
23const DOCK_COLUMNS = 110
24const FOLLOW = -1 // the list's offset while it keeps the newest prompt in view
25const PEOPLE = new Set(['composer', 'bridge']) // origins of a prompt a person typed
26
27// Held by the host, so a hot reload keeps them.
28const prompts = atom({ plugin: 'prompt-history', key: 'prompts' } as const, [] as Prompt[])
29const selected = atom({ plugin: 'prompt-history', key: 'selected' } as const, null as string | null)
30const scroll = atom({ plugin: 'prompt-history', key: 'scroll' } as const, { list: FOLLOW, preview: 0, previewFor: null } as Scroll)
31const locale = atom({ plugin: 'prompt-history', key: 'locale' } as const, null as Locale | null)
32const now = atom({ plugin: 'prompt-history', key: 'now' } as const, 0)
33const tzOffset = atom({ plugin: 'prompt-history', key: 'tzOffset' } as const, -new Date().getTimezoneOffset())
34const note = atom({ plugin: 'prompt-history', key: 'note' } as const, '')
35
36// The module's own bookkeeping; a reload starts it over.
37const session = { command: 'prompts', locale: 'ko' as Locale }
38const drawn = { layout: null as Layout | null, listTotal: 0, previewTotal: 0, order: [] as string[], shown: null as string | null }
39const wrapped = new Map<string, { width: number; text: string; rows: string[] }>()
40
41const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
42
43const merge = (a: Prompt[], b: Prompt[]) => {
44  const byId = new Map([...a, ...b].map(p => [p.uuid, p]))
45  return [...byId.values()].sort((x, y) => x.at - y.at)
46}
47
48// The prompts in the transcript file so far, picked out by grep (the file outgrows $.fs.read).
49async function loadTranscript($: EngineInterface, cwd: string) {
50  const id = await $.session.id()
51  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)) ?? `${(await $.env.get('HOME').catch(() => undefined)) ?? ''}/.claude`
52  const path = transcriptPath(configDir, cwd, id)
53  const run = await $.process.run(['grep', '-F', HUMAN_MARK, path]).catch(() => null)
54  if (!run || run.exitCode > 1) return
55  const found = parseTranscript(run.stdout)
56  await update($, prompts, list => merge(list, found))
57}
58
59async function loadTimezone($: EngineInterface) {
60  const run = await $.process.run(['date', '+%z']).catch(() => null)
61  const offset = run ? parseOffset(run.stdout) : null
62  if (offset !== null) await update($, tzOffset, () => offset)
63}
64
65function rowsOf(prompt: Prompt, width: number): string[] {
66  const kept = wrapped.get(prompt.uuid)
67  if (kept && kept.width === width && kept.text === prompt.text) return kept.rows
68  const rows = prompt.text.split('\n').flatMap(line => wrapText(line, width))
69  wrapped.set(prompt.uuid, { width, text: prompt.text, rows })
70  return rows
71}
72
73// Brings the transcript to a prompt. The engine moves the transcript only while a hook answers the
74// person's own input, through that hook's own `$`: a Button press (the `ui.press` hook below) or a
75// command they typed. A click a surface module posts is not one, nor is a closure drawn at render.
76// So the scroll goes first, before anything else is awaited.
77async function jump($: EngineInterface, uuid: string) {
78  const moved = await $.ui.scroll({ to: { requestId: uuid }, block: 'start' }).catch((error: unknown) => ({
79    deny: error instanceof Error ? error.message : String(error),
80  }))
81  await select($, uuid)
82  await update($, note, () => (moved.deny ? `↗ ${moved.deny}` : ''))
83  return moved
84}
85
86async function select($: EngineInterface, uuid: string) {
87  await update($, selected, () => uuid)
88  await update($, scroll, s => ({ ...s, preview: 0, previewFor: uuid }))
89  await update($, note, () => '')
90}
91
92// Puts a prompt's text on the clipboard of the surface the press came from.
93async function copy($: EngineInterface, uuid: string, surface: RenderSurface) {
94  const prompt = (await read($, prompts)).find(p => p.uuid === uuid)
95  if (!prompt) return
96  const ko = ((await read($, locale)) ?? session.locale) === 'ko'
97  const done = await $.ui.copy({ text: prompt.text, surface }).catch((error: unknown) => ({
98    isCopied: false as const,
99    reason: error instanceof Error ? error.message : String(error),
100  }))
101  const no = drawn.order.indexOf(uuid) + 1
102  await update($, note, () =>
103    done.isCopied
104      ? ko ? `✓ #${no} 복사됨 (${prompt.text.length.toLocaleString('en-US')}자)` : `✓ Copied #${no} (${prompt.text.length.toLocaleString('en-US')} chars)`
105      : ko ? `복사 못 함: ${done.reason}` : `Could not copy: ${done.reason}`,
106  )
107}
108
109// Keeps the picked prompt inside the list's window.
110async function reveal($: EngineInterface, uuid: string) {
111  const at = drawn.layout
112  if (!at) return
113  const index = drawn.order.indexOf(uuid)
114  if (index < 0) return
115  // Rows above it in the list: its own place plus one heading per day so far.
116  const list = await read($, prompts)
117  const offset = await read($, tzOffset)
118  const row = list.slice(0, index + 1).reduce((n, p, i) => n + 1 + (i === 0 || !sameDay(list[i - 1]!.at, p.at, offset) ? 1 : 0), -1)
119  await update($, scroll, s => {
120    const top = s.list === FOLLOW ? Math.max(0, drawn.listTotal - at.listRows) : s.list
121    const next = row < top ? row - 1 : row >= top + at.listRows ? row - at.listRows + 1 : top
122    return { ...s, list: clamp(next, 0, Math.max(0, drawn.listTotal - at.listRows)) }
123  })
124}
125
126async function pick($: EngineInterface, by: number) {
127  const order = drawn.order
128  if (order.length === 0) return
129  const current = (await read($, selected)) ?? drawn.shown
130  const at = current ? order.indexOf(current) : order.length
131  const next = order[clamp(at + by, 0, order.length - 1)]!
132  if (next === current) return
133  await select($, next)
134  await reveal($, next)
135}
136
137async function scrollBy($: EngineInterface, region: 'list' | 'preview', by: number) {
138  const at = drawn.layout
139  if (!at) return
140  if (region === 'list') {
141    const max = Math.max(0, drawn.listTotal - at.listRows)
142    await update($, scroll, s => {
143      const from = s.list === FOLLOW ? max : s.list
144      const to = clamp(from + by, 0, max)
145      return { ...s, list: to === max ? FOLLOW : to }
146    })
147    return
148  }
149  const max = Math.max(0, drawn.previewTotal - at.previewRows)
150  const shown = drawn.shown
151  await update($, scroll, s => ({ ...s, preview: clamp((s.previewFor === shown ? s.preview : 0) + by, 0, max), previewFor: shown }))
152}
153
154async function scrollTo($: EngineInterface, region: 'list' | 'preview', offset: number) {
155  const at = drawn.layout
156  if (!at) return
157  const current = await read($, scroll)
158  const from = region === 'list' ? (current.list === FOLLOW ? Math.max(0, drawn.listTotal - at.listRows) : current.list) : current.previewFor === drawn.shown ? current.preview : 0
159  await scrollBy($, region, offset - from)
160}
161
162async function act($: EngineInterface, action: Action) {
163  const page = Math.max(1, (drawn.layout?.previewRows ?? 10) - 2)
164  if (action === 'locale') return update($, locale, l => ((l ?? session.locale) === 'ko' ? 'en' : 'ko'))
165  if (action === 'up') return scrollBy($, 'preview', -page)
166  if (action === 'down') return scrollBy($, 'preview', page)
167  // Reached from a surface module or a closure, never the person's own input: the engine would
168  // refuse the scroll, so this only says where the jump is.
169  await update($, note, () => '')
170  return undefined
171}
172
173// A key a surface module passed on. In the list the arrows walk the prompts; elsewhere they
174// scroll the preview. Enter jumps to the picked prompt anywhere.
175async function pressKey($: EngineInterface, element: string, key: string) {
176  const inList = element === 'list' || element === 'lbar'
177  if (key === 'return') return undefined // a surface module's Enter cannot move the transcript
178  if (inList && (key === 'up' || key === 'k')) return pick($, -1)
179  if (inList && (key === 'down' || key === 'j')) return pick($, 1)
180  if (inList && key === 'home') return pick($, -drawn.order.length)
181  if (inList && key === 'end') return pick($, drawn.order.length)
182  if (key === 'up' || key === 'k') return scrollBy($, 'preview', -1)
183  if (key === 'down' || key === 'j') return scrollBy($, 'preview', 1)
184  if (key === 'pageup') return scrollBy($, inList ? 'list' : 'preview', -(drawn.layout?.listRows ?? 10))
185  if (key === 'pagedown') return scrollBy($, inList ? 'list' : 'preview', drawn.layout?.listRows ?? 10)
186  return undefined
187}
188
189async function openPane($: EngineInterface, focus: boolean) {
190  return $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS, ...(focus ? { focus: true as const } : {}) })
191}
192
193const isOpen = async ($: EngineInterface) => (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
194
195// /prompts toggles the pane; on and off say which; a number jumps to that prompt.
196async function command($: EngineInterface, args: string) {
197  const arg = args.trim().toLowerCase()
198  const wantsOff = arg === 'off' || arg === 'close' || (arg === '' && (await isOpen($)))
199  if (wantsOff) {
200    await $.ui.close({ id: PANE }).catch(() => undefined)
201    return { text: 'Closed. /prompts opens it again.' }
202  }
203  const opened = await openPane($, arg === '' || arg === 'on' || arg === 'open')
204  const list = await read($, prompts)
205  if (/^\d+$/.test(arg)) {
206    const target = list[Number(arg) - 1]
207    if (!target) return { text: `${list.length} prompt${list.length === 1 ? '' : 's'} so far; there is no #${arg}.` }
208    const moved = await jump($, target.uuid)
209    await reveal($, target.uuid)
210    return { text: moved.deny ? `Could not jump to #${arg}: ${moved.deny}` : `Jumped to prompt #${arg}.` }
211  }
212  if (!opened.isPlaced) return { text: `The pane could not be shown (${opened.reason}).` }
213  return { text: `${list.length} prompt${list.length === 1 ? '' : 's'}. ↑↓ pick one, Enter jumps back to it; Esc hands the keys back to the prompt, /${session.command} closes the pane.` }
214}
215
216export const register: Register = (on, options) => {
217  session.locale = options.locale === 'en' ? 'en' : 'ko'
218
219  on('session.start', async ($, e, next) => {
220    const r = await next(e)
221    for (const name of ['prompts', 'prompt-history']) {
222      const ok = await $.command
223        .register({ name, description: 'List the prompts you sent this session; jump back to one', argumentHint: '[on | off | number]' })
224        .then(() => true)
225        .catch(() => false)
226      if (ok) {
227        session.command = name
228        break
229      }
230    }
231    await update($, now, () => Date.now())
232    await loadTimezone($).catch(() => undefined)
233    await loadTranscript($, e.cwd).catch(() => undefined)
234    try {
235      $.clock.every(TICK_MS, () => void update($, now, () => Date.now()))
236    } catch {
237      // No clock (a host without timers): the times update as prompts arrive instead.
238    }
239    return r
240  })
241
242  // Each prompt the person sends, as the conversation keeps it.
243  on('session.append', { door: 'prompt' }, async ($, e, next) => {
244    const r = await next(e)
245    if (e.agentId || e.message.isMeta || !PEOPLE.has(e.origin.kind) || r.deny !== undefined) return r
246    const text = promptText(r.message.content as { type?: string; text?: string }[])
247    if (text !== null) {
248      const fresh: Prompt = { uuid: r.uuid, text, at: Date.now() }
249      await update($, prompts, list => merge(list, [fresh])).catch(() => undefined)
250      await update($, now, () => Date.now()).catch(() => undefined)
251    }
252    return r
253  }).catch(($, e, next) => next(e)) // never in the way of the row being kept
254
255  // A /clear starts a new conversation under a new id: its prompts start over.
256  on('session.end', async ($, e, next) => {
257    if (e.reason === 'clear') {
258      await update($, prompts, () => [])
259      await update($, selected, () => null)
260      await update($, scroll, () => ({ list: FOLLOW, preview: 0, previewFor: null }))
261    }
262    return next(e)
263  })
264
265  on('command.run', { command: 'prompts' }, async ($, e) => command($, e.args))
266  on('command.run', { command: 'prompt-history' }, async ($, e) => command($, e.args))
267
268  // What the pane's surface modules post: the picked prompt, a control's action, a scrollbar's
269  // offset, or a key.
270  on('ui.message', { requestId: PANE }, async ($, e) => {
271    const data = (e.data ?? {}) as { pick?: unknown; press?: unknown; action?: unknown; offset?: unknown }
272    if (typeof data.pick === 'string') {
273      await select($, data.pick)
274    } else if (typeof data.press === 'string') {
275      await pressKey($, e.element, data.press)
276    } else if (data.action === 'locale' || data.action === 'up' || data.action === 'down' || data.action === 'jump') {
277      await act($, data.action)
278    } else if (typeof data.offset === 'number') {
279      if (e.element === 'lbar') await scrollTo($, 'list', data.offset)
280      if (e.element === 'pbar') await scrollTo($, 'preview', data.offset)
281    }
282    return {}
283  })
284
285  // The Jump button: a press is the person's own input, so this hook's `$` may move the transcript.
286  on('ui.press', { requestId: PANE, element: 'copy' }, async ($, e, next) => {
287    const uuid = drawn.shown
288    if (uuid) await copy($, uuid, e.surface)
289    return next(e)
290  })
291
292  on('ui.press', { requestId: PANE, element: 'jump' }, async ($, e, next) => {
293    const uuid = drawn.shown
294    if (uuid) await jump($, uuid)
295    return next(e)
296  })
297
298  // The wheel scrolls the column under it. The pane's keys (a scroll with no pointer) walk the
299  // list instead, the engine's window kept still: so with the pane focused, ↑↓ pick at once.
300  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
301    if (e.origin.kind === 'person' && e.pointer === undefined) {
302      const step = keyStep(e.by, e.bodyRows, e.contentRows, (drawn.layout?.listRows ?? 10) - 2)
303      if (step !== 0) await pick($, step)
304      return {}
305    }
306    const at = drawn.layout
307    const overList =
308      at !== null && e.pointer !== undefined && (at.isStacked ? e.pointer.row < at.listRows + 2 : e.pointer.column < at.listWidth)
309    await scrollBy($, overList ? 'list' : 'preview', e.by)
310    return {}
311  })
312
313  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
314    const els = $.ui.resolve(e)
315    const at = layout(e.props.bodyColumns, e.props.scroll.bodyRows)
316    const lang = (await read($, locale)) ?? session.locale
317    const list = await read($, prompts)
318    const clockNow = Math.max(await read($, now), list[list.length - 1]?.at ?? 0)
319    const offset = await read($, tzOffset)
320    const offsets = await read($, scroll)
321    const picked = await read($, selected)
322
323    // The list: a heading per day, then each prompt cut to its row.
324    const digits = String(list.length).length
325    const inner = at.listWidth - 1
326    const rows: ListRow[] = []
327    list.forEach((p, i) => {
328      if (i === 0 || !sameDay(list[i - 1]!.at, p.at, offset)) rows.push({ kind: 'day', label: dayLabel(p.at, clockNow, offset, lang) })
329      const time = relative(p.at, clockNow, offset, lang)
330      // Cut roughly here; the list cuts it exactly so the time always shows whole.
331      const room = inner - 1 - digits - 4 - textWidth(time) - 2
332      const flat = p.text.replace(/\s*\n\s*/g, ' ↵ ')
333      rows.push({ kind: 'prompt', uuid: p.uuid, no: String(i + 1).padStart(digits), text: truncate(flat, Math.max(4, room)), time, isSelected: p.uuid === picked })
334    })
335    const maxList = Math.max(0, rows.length - at.listRows)
336    const listOffset = offsets.list === FOLLOW ? maxList : clamp(offsets.list, 0, maxList)
337
338    // The preview: the picked prompt, else the newest.
339    const byId = new Map(list.map((p, i) => [p.uuid, { p, i }]))
340    const target = (picked && byId.get(picked)) || (list.length > 0 ? { p: list[list.length - 1]!, i: list.length - 1 } : undefined)
341    let shown: Shown | null = null
342    if (target) {
343      const { p, i } = target
344      const day = sameDay(p.at, clockNow, offset) ? '' : `${date(p.at, clockNow, offset, lang)} `
345      shown = {
346        prompt: p,
347        no: i + 1,
348        rows: rowsOf(p, Math.max(8, at.previewWidth - 1 - 3)),
349        when: `${day}${clock(p.at, offset, lang)} · ${relative(p.at, clockNow, offset, lang)}`,
350      }
351    }
352    const maxPreview = Math.max(0, (shown?.rows.length ?? 0) - at.previewRows)
353    const previewOffset = shown && offsets.previewFor === shown.prompt.uuid ? clamp(offsets.preview, 0, maxPreview) : 0
354
355    drawn.layout = at
356    drawn.listTotal = rows.length
357    drawn.previewTotal = shown?.rows.length ?? 0
358    drawn.order = list.map(p => p.uuid)
359    drawn.shown = shown?.prompt.uuid ?? null
360
361    return (
362      <Pane
363        m={{
364          els,
365          at,
366          locale: lang,
367          count: list.length,
368          list: rows.slice(listOffset, listOffset + at.listRows),
369          listTotal: rows.length,
370          listOffset,
371          shown,
372          previewOffset,
373          note: await read($, note),
374          onAction: action => void act($, action),
375        }}
376      />
377    )
378  })
379}
380
hooks/list-client.tsx 107 lines
1// The prompt list, drawn by a surface module: the row under the pointer takes a quiet background,
2// a click posts the one to pick (the preview follows it) and jump to; keys go along.
3import type { ClientModule } from 'claude-code'
4
5export type ListRow =
6  | { kind: 'day'; label: string }
7  | { kind: 'prompt'; uuid: string; no: string; text: string; time: string; isSelected: boolean }
8
9export type ListProps = {
10  width: number
11  rows: ListRow[]
12  colors: { bg: string; hover: string; pressed: string; selected: string; accent: string; fg: string; muted: string; subtle: string }
13}
14
15export type ListMessage = { pick: string } | { press: string }
16
17type State = { hover: number | null; down: number | null }
18
19const cells = (s: string) =>
20  [...s].reduce((n, ch) => {
21    const cp = ch.codePointAt(0) ?? 0
22    if (cp < 0x20 || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return n
23    const isWide =
24      (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
25      (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
26      (cp >= 0x1f300 && cp <= 0x1faff)
27    return n + (isWide ? 2 : 1)
28  }, 0)
29
30// `s` cut to `width` cells, an ellipsis standing for what was cut.
31function fit(s: string, width: number) {
32  if (cells(s) <= width) return s
33  let out = ''
34  for (const ch of s) {
35    if (cells(out + ch) > width - 1) break
36    out += ch
37  }
38  return `${out}…`
39}
40
41const PromptList: ClientModule<ListProps, State> = (props, surface) => {
42  const { Box, Text } = surface.elements
43  const state = surface.state ?? { hover: null, down: null }
44  const c = props.colors
45  const uuidAt = (i: number | null) => {
46    const row = i === null ? undefined : props.rows[i]
47    return row?.kind === 'prompt' ? row.uuid : null
48  }
49  const rowAt = (x: number, y: number) =>
50    y >= 0 && y < props.rows.length && x >= 0 && x < props.width && props.rows[y]!.kind === 'prompt' ? y : null
51  const set = (next: State) => {
52    if (next.hover !== state.hover || next.down !== state.down) surface.setState(next)
53  }
54
55  surface.onPointer(event => {
56    const at = rowAt(event.x, event.y)
57    if (event.type === 'leave') set({ hover: null, down: null })
58    else if (event.type === 'move' || event.type === 'enter') set({ ...state, hover: at })
59    else if (event.type === 'down' && event.button === 'left') set({ hover: at, down: at })
60    else if (event.type === 'up' && event.button === 'left') {
61      const uuid = at !== null && at === state.down ? uuidAt(at) : null
62      set({ hover: at, down: null })
63      if (uuid) surface.post({ pick: uuid } satisfies ListMessage)
64    }
65  })
66  surface.onKey(event => {
67    if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies ListMessage)
68  })
69
70  return (
71    <Box flexDirection="column" width={props.width}>
72      {props.rows.map((row, i) => {
73        if (row.kind === 'day') {
74          const head = ` ${row.label} `
75          return (
76            <Text backgroundColor={c.bg} wrap="truncate-end">
77              <Text color={c.subtle}>{'──'}</Text>
78              <Text color={c.muted} weight="bold">
79                {head}
80              </Text>
81              <Text color={c.subtle}>{'─'.repeat(Math.max(0, props.width - 2 - cells(head)))}</Text>
82            </Text>
83          )
84        }
85        const bg = row.isSelected ? c.selected : state.down === i ? c.pressed : state.hover === i ? c.hover : c.bg
86        // The time always shows whole: the text gets what the marker, number, time and gaps leave.
87        const fixed = 1 + cells(row.no) + 2 + 1 + 1 + cells(row.time) + 1 // ▎, no, ' ❯', ' ', gap, time, ' '
88        const text = fit(row.text, Math.max(1, props.width - fixed))
89        const used = fixed - 1 + cells(text)
90        return (
91          <Text backgroundColor={bg} wrap="truncate-end">
92            <Text color={c.accent}>{row.isSelected ? '▎' : ' '}</Text>
93            <Text color={c.subtle}>{row.no}</Text>
94            <Text color={row.isSelected ? c.accent : c.subtle}>{' ❯'}</Text>
95            <Text color={row.isSelected || state.hover === i ? c.fg : '#c9d1d9'}>{' ' + text}</Text>
96            <Text>{' '.repeat(Math.max(1, props.width - used))}</Text>
97            <Text color={c.muted}>{row.time}</Text>
98            <Text> </Text>
99          </Text>
100        )
101      })}
102    </Box>
103  )
104}
105
106export default PromptList
107
hooks/text.ts 175 lines
1// Terminal cell widths: Hangul, CJK and most emoji take two cells, combining marks none.
2// Rows are padded to the column's width so their background fills it, so widths must be right.
3
4const WIDE: [number, number][] = [
5  [0x1100, 0x115f], // Hangul Jamo
6  [0x2e80, 0x303e], // CJK radicals, punctuation
7  [0x3041, 0x33ff], // kana, Hangul compatibility jamo, CJK symbols
8  [0x3400, 0x4dbf], // CJK extension A
9  [0x4e00, 0x9fff], // CJK unified
10  [0xa000, 0xa4cf], // Yi
11  [0xa960, 0xa97f], // Hangul Jamo extended A
12  [0xac00, 0xd7a3], // Hangul syllables
13  [0xf900, 0xfaff], // CJK compatibility
14  [0xfe30, 0xfe4f], // CJK compatibility forms
15  [0xff00, 0xff60], // fullwidth forms
16  [0xffe0, 0xffe6],
17  [0x1f300, 0x1f64f], // emoji
18  [0x1f900, 0x1f9ff],
19  [0x1fa70, 0x1faff],
20  [0x20000, 0x3fffd], // CJK extensions B and later
21]
22
23const ZERO: [number, number][] = [
24  [0x0300, 0x036f], // combining diacritics
25  [0x200b, 0x200f], // zero-width space, joiners, marks
26  [0xfe00, 0xfe0f], // variation selectors
27  [0x1160, 0x11ff], // Hangul medial and final jamo
28]
29
30const within = (cp: number, ranges: [number, number][]) => ranges.some(([lo, hi]) => cp >= lo && cp <= hi)
31
32export function charWidth(ch: string): number {
33  const cp = ch.codePointAt(0) ?? 0
34  if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0
35  if (within(cp, ZERO)) return 0
36  return within(cp, WIDE) ? 2 : 1
37}
38
39export const textWidth = (s: string) => [...s].reduce((n, ch) => n + charWidth(ch), 0)
40
41// Tabs to spaces at `size` stops, and every other control character dropped.
42export function expandTabs(s: string, size = 4): string {
43  let out = ''
44  let col = 0
45  for (const ch of s) {
46    const cp = ch.codePointAt(0) ?? 0
47    if (ch === '\t') {
48      const n = size - (col % size)
49      out += ' '.repeat(n)
50      col += n
51    } else if (cp >= 0x20 && !(cp >= 0x7f && cp < 0xa0)) {
52      out += ch
53      col += charWidth(ch)
54    }
55  }
56  return out
57}
58
59export const pad = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - textWidth(s)))
60
61export const padStart = (s: string, width: number) => ' '.repeat(Math.max(0, width - textWidth(s))) + s
62
63// Cut to `width` cells, an ellipsis standing for what was cut.
64export function truncate(s: string, width: number): string {
65  if (width <= 0) return ''
66  if (textWidth(s) <= width) return s
67  let out = ''
68  let used = 0
69  for (const ch of s) {
70    const w = charWidth(ch)
71    if (used + w > width - 1) break
72    out += ch
73    used += w
74  }
75  return out + '…'
76}
77
78// Cut from the left, keeping the end (a path's file name).
79export function truncateStart(s: string, width: number): string {
80  if (width <= 0) return ''
81  if (textWidth(s) <= width) return s
82  const chars = [...s]
83  let out = ''
84  let used = 0
85  for (let i = chars.length - 1; i >= 0; i--) {
86    const w = charWidth(chars[i]!)
87    if (used + w > width - 1) break
88    out = chars[i] + out
89    used += w
90  }
91  return '…' + out
92}
93
94// A run of text, marked when it is the changed part of a line.
95export type Seg = { text: string; isMarked: boolean }
96
97// Lays segments out in rows of at most `width` cells, splitting a segment where a row ends.
98export function wrapSegs(segs: Seg[], width: number): Seg[][] {
99  const rows: Seg[][] = [[]]
100  let used = 0
101  for (const seg of segs) {
102    let run = ''
103    for (const ch of seg.text) {
104      const w = charWidth(ch)
105      if (used + w > width && used > 0) {
106        if (run) rows[rows.length - 1]!.push({ text: run, isMarked: seg.isMarked })
107        rows.push([])
108        run = ''
109        used = 0
110      }
111      run += ch
112      used += w
113    }
114    if (run) rows[rows.length - 1]!.push({ text: run, isMarked: seg.isMarked })
115  }
116  return rows
117}
118
119export const segsWidth = (segs: Seg[]) => segs.reduce((n, s) => n + textWidth(s.text), 0)
120
121// The cells [from, from + width) of a row of segments: what a window scrolled sideways shows.
122// A wide character cut by either edge becomes a space, so the columns stay where they were.
123export function sliceSegs(segs: Seg[], from: number, width: number): Seg[] {
124  const out: Seg[] = []
125  let col = 0
126  const end = from + width
127  for (const seg of segs) {
128    let text = ''
129    for (const ch of seg.text) {
130      const w = charWidth(ch)
131      const next = col + w
132      if (col >= from && next <= end) text += ch
133      else if (next > from && col < end) text += ' '.repeat(Math.min(next, end) - Math.max(col, from))
134      col = next
135      if (col >= end) break
136    }
137    if (text) out.push({ text, isMarked: seg.isMarked })
138    if (col >= end) break
139  }
140  return out
141}
142
143// A line with its first `n` cells dropped.
144export const dropCells = (s: string, n: number) =>
145  n <= 0 ? s : sliceSegs([{ text: s, isMarked: false }], n, Number.MAX_SAFE_INTEGER).map(seg => seg.text).join('')
146
147// A paragraph laid out in rows of at most `width` cells: broken after a space where one falls in
148// the row's last third, else wherever the row is full (Hangul and CJK need no space to break).
149export function wrapText(line: string, width: number): string[] {
150  const text = expandTabs(line)
151  if (width <= 0) return [text]
152  const rows: string[] = []
153  let row = ''
154  let used = 0
155  for (const ch of text) {
156    const w = charWidth(ch)
157    if (used + w > width && used > 0) {
158      const cut = row.lastIndexOf(' ')
159      if (cut > 0 && textWidth(row.slice(0, cut)) >= (width * 2) / 3) {
160        rows.push(row.slice(0, cut))
161        row = row.slice(cut + 1)
162        used = textWidth(row)
163      } else {
164        rows.push(row)
165        row = ''
166        used = 0
167      }
168    }
169    row += ch
170    used += w
171  }
172  rows.push(row)
173  return rows
174}
175
hooks/time.ts 71 lines
1// When a prompt was sent, as a person reads it: "3분 전" / "3m ago", then a date once it is a week old.
2// Formatted by hand from a fixed UTC offset, so the result does not depend on the runtime's locale data.
3import type { Locale } from '../types'
4
5const MINUTE = 60_000
6const HOUR = 60 * MINUTE
7const DAY = 24 * HOUR
8const MONTHS_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
9const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토']
10const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
11
12// The wall-clock fields of `ms` at `offset` minutes east of UTC.
13function fields(ms: number, offset: number) {
14  const d = new Date(ms + offset * MINUTE)
15  return { y: d.getUTCFullYear(), mo: d.getUTCMonth(), d: d.getUTCDate(), h: d.getUTCHours(), mi: d.getUTCMinutes(), wd: d.getUTCDay() }
16}
17
18const dayNumber = (ms: number, offset: number) => Math.floor((ms + offset * MINUTE) / DAY)
19
20export function date(ms: number, now: number, offset: number, locale: Locale): string {
21  const f = fields(ms, offset)
22  const sameYear = f.y === fields(now, offset).y
23  if (locale === 'ko') return sameYear ? `${f.mo + 1}월 ${f.d}일` : `${f.y}. ${f.mo + 1}. ${f.d}.`
24  return sameYear ? `${MONTHS_EN[f.mo]} ${f.d}` : `${MONTHS_EN[f.mo]} ${f.d}, ${f.y}`
25}
26
27export function relative(ms: number, now: number, offset: number, locale: Locale): string {
28  const ago = Math.max(0, now - ms)
29  const ko = locale === 'ko'
30  if (ago < 45_000) return ko ? '방금 전' : 'just now'
31  if (ago < HOUR) {
32    const n = Math.max(1, Math.round(ago / MINUTE))
33    return ko ? `${n}분 전` : `${n}m ago`
34  }
35  if (ago < DAY) {
36    const n = Math.floor(ago / HOUR)
37    return ko ? `${n}시간 전` : `${n}h ago`
38  }
39  if (ago < 7 * DAY) {
40    const n = Math.floor(ago / DAY)
41    return ko ? `${n}일 전` : `${n}d ago`
42  }
43  return date(ms, now, offset, locale)
44}
45
46export function clock(ms: number, offset: number, locale: Locale): string {
47  const { h, mi } = fields(ms, offset)
48  const mm = String(mi).padStart(2, '0')
49  const h12 = h % 12 === 0 ? 12 : h % 12
50  return locale === 'ko' ? `${h < 12 ? '오전' : '오후'} ${h12}:${mm}` : `${h12}:${mm} ${h < 12 ? 'AM' : 'PM'}`
51}
52
53// The heading over a day's prompts in the list: 오늘 / 어제 / 10월 20일 (월).
54export function dayLabel(ms: number, now: number, offset: number, locale: Locale): string {
55  const diff = dayNumber(now, offset) - dayNumber(ms, offset)
56  if (diff === 0) return locale === 'ko' ? '오늘' : 'Today'
57  if (diff === 1) return locale === 'ko' ? '어제' : 'Yesterday'
58  const wd = fields(ms, offset).wd
59  return `${date(ms, now, offset, locale)} (${locale === 'ko' ? WEEKDAYS_KO[wd] : WEEKDAYS_EN[wd]})`
60}
61
62export const sameDay = (a: number, b: number, offset: number) => dayNumber(a, offset) === dayNumber(b, offset)
63
64// `date +%z`'s "+0900" as minutes east of UTC.
65export function parseOffset(text: string): number | null {
66  const m = text.trim().match(/^([+-])(\d{2})(\d{2})$/)
67  if (!m) return null
68  const minutes = Number(m[2]) * 60 + Number(m[3])
69  return m[1] === '-' ? -minutes : minutes
70}
71
hooks/transcript.ts 57 lines
1// The prompts already in this session's transcript file, read once when the mod loads: the rows a
2// person typed (origin `human`), less slash commands, their output and the engine's own notes.
3// A transcript soon outgrows what `$.fs.read` takes (4 MiB), so the rows are picked out with grep
4// by HUMAN_MARK first, and only those reach the parser.
5import type { Prompt } from '../types'
6
7type Block = { type?: string; text?: string }
8type Row = {
9  type?: string
10  uuid?: string
11  timestamp?: string
12  isMeta?: boolean
13  isSidechain?: boolean
14  origin?: { kind?: string }
15  message?: { role?: string; content?: string | Block[] }
16}
17
18// Where Claude Code keeps a session's transcript: the project folder is the cwd with every
19// character but letters and digits turned into `-`.
20export const HUMAN_MARK = '"origin":{"kind":"human"}'
21
22export const transcriptPath = (configDir: string, cwd: string, sessionId: string) =>
23  `${configDir.replace(/\/$/, '')}/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`
24
25// The text a person typed, from a row's content; null for a command, its output or a note.
26export function promptText(content: string | Block[] | undefined): string | null {
27  const text = typeof content === 'string'
28    ? content
29    : (content ?? []).filter(b => b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n')
30  const trimmed = text.trim()
31  if (!trimmed) return null
32  if (/^<(command-name|command-message|local-command-stdout|local-command-stderr|bash-input|bash-stdout|bash-stderr|task-notification|system-reminder)>/.test(trimmed)) return null
33  if (trimmed.startsWith('[Request interrupted')) return null
34  return trimmed
35}
36
37export function parseTranscript(jsonl: string): Prompt[] {
38  const out: Prompt[] = []
39  for (const line of jsonl.split('\n')) {
40    if (!line.includes('"type":"user"')) continue // most rows are not; skip the parse
41    let row: Row
42    try {
43      row = JSON.parse(line) as Row
44    } catch {
45      continue
46    }
47    if (row.type !== 'user' || row.isMeta || row.isSidechain || !row.uuid) continue
48    if (row.origin?.kind !== undefined && row.origin.kind !== 'human') continue
49    if (row.origin?.kind === undefined && Array.isArray(row.message?.content) && row.message.content.some(b => b.type === 'tool_result')) continue
50    const text = promptText(row.message?.content)
51    const at = row.timestamp ? Date.parse(row.timestamp) : NaN
52    if (text === null || Number.isNaN(at)) continue
53    out.push({ uuid: row.uuid, text, at })
54  }
55  return out
56}
57
hooks/keys.ts 13 lines
1// The pane's keys arrive as the engine's own scroll of the pane (the arrows a row, a page key a
2// window, Home and End the whole tree), with no pointer. The pane keeps its window still and reads
3// each as a move through the list instead: this is how far.
4export const END = 1_000_000
5
6export function keyStep(by: number, bodyRows: number, contentRows: number, page: number): number {
7  if (by === 0) return 0
8  const sign = Math.sign(by)
9  if (Math.abs(by) >= contentRows) return sign * END // Home, End
10  if (bodyRows > 1 && Math.abs(by) >= bodyRows) return sign * Math.max(1, page) // Page Up, Page Down
11  return sign // an arrow
12}
13
hooks/view.tsx 368 lines
1// What the pane draws: a toolbar, the prompt list on the left and the picked prompt in full on
2// the right, each column a window of its own with its own scrollbar. Narrow panes stack
3// the list over the preview.
4import type { Elements } from 'claude-code'
5
6import type { Locale, Prompt } from '../types'
7import { spans } from './controls'
8import type { ControlsProps, Item, Part } from './controls'
9import type { ListProps, ListRow } from './list-client'
10import { thumbOf } from './scrollbar'
11import type { BarProps } from './scrollbar'
12import { pad, textWidth, truncate } from './text'
13import { C } from './theme'
14
15export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & {
16  Client?: Elements['terminal']['Client'] // the terminal and desktop draw one
17}
18
19export type Action = 'locale' | 'up' | 'down' | 'jump'
20
21export type Layout = {
22  width: number
23  height: number
24  isStacked: boolean // too narrow for two columns: the list sits over the preview
25  listWidth: number // the list's columns, its scrollbar included
26  listRows: number // rows the list's window shows
27  previewWidth: number // the preview's columns, its scrollbar included
28  previewRows: number // rows the preview's text window shows
29}
30
31const TOOLBAR_ROWS = 2 // the toolbar and the rule under it
32const PREVIEW_HEAD = 2 // the prompt's header and the rule under it
33const PREVIEW_FOOT = 1 // the status row
34const SIDE_BY_SIDE = 70 // narrower panes stack
35
36export function layout(width: number, height: number): Layout {
37  const body = Math.max(4, height - TOOLBAR_ROWS)
38  const isStacked = width < SIDE_BY_SIDE
39  if (isStacked) {
40    const listRows = Math.max(3, Math.floor(body * 0.45))
41    return {
42      width,
43      height,
44      isStacked,
45      listWidth: width,
46      listRows,
47      previewWidth: width,
48      previewRows: Math.max(1, body - listRows - 1 - PREVIEW_HEAD - PREVIEW_FOOT),
49    }
50  }
51  const listWidth = Math.max(30, Math.min(64, Math.round(width * 0.44)))
52  return {
53    width,
54    height,
55    isStacked,
56    listWidth,
57    listRows: body,
58    previewWidth: width - listWidth - 1,
59    previewRows: Math.max(1, body - PREVIEW_HEAD - PREVIEW_FOOT),
60  }
61}
62
63// One prompt as the preview shows it.
64export type Shown = {
65  prompt: Prompt
66  no: number
67  rows: string[] // its text wrapped to the preview
68  when: string // 오후 2:31 · 12분 전
69}
70
71export type PaneModel = {
72  els: Els
73  at: Layout
74  locale: Locale
75  count: number
76  list: ListRow[] // the rows in the list's window
77  listTotal: number
78  listOffset: number
79  shown: Shown | null
80  previewOffset: number
81  note: string
82  onAction: (action: Action) => void
83}
84
85const T = {
86  ko: { title: 'PROMPTS', count: (n: number) => `${n}개`, empty: '아직 보낸 프롬프트가 없어요.', emptyHint: '프롬프트를 보내면 여기에 쌓여요.', jump: '↗ 이동', copy: '⧉ 복사', picked: '↑↓ 고르기 · Enter 이동', lines: '줄', chars: '자', pick: '목록에서 프롬프트를 고르세요.' },
87  en: { title: 'PROMPTS', count: (n: number) => `${n} prompt${n === 1 ? '' : 's'}`, empty: 'No prompts yet.', emptyHint: 'Prompts you send collect here.', jump: '↗ Jump', copy: '⧉ Copy', picked: '↑↓ pick · Enter jumps', lines: 'lines', chars: 'chars', pick: 'Pick a prompt from the list.' },
88} as const
89
90// ---- building blocks --------------------------------------------------------------------------
91
92const text = (parts: Part[]): Item => ({ parts })
93const spacer: Item = { spacer: true }
94const control = (action: Action, label: string, isOn = false): Item => ({ action, isOn, parts: [{ text: ` ${label} ` }] })
95
96// A row of chrome: the controls surface module where the surface draws a Client, else Text and Buttons.
97function ChromeRow({ m, id, w, bg, items }: { m: PaneModel; id: string; w: number; bg: string; items: Item[] }) {
98  const { Box, Text, Button, Client } = m.els
99  const props: ControlsProps = {
100    width: w,
101    bg,
102    items,
103    colors: { hover: C.controlHover, pressed: C.controlPressed, on: C.accent, fg: C.fg },
104  }
105  if (Client) return <Client key={id} module="./controls.tsx" props={props} width={w} height={1} />
106  const layout = spans(items, w)
107  const paint = (parts: Part[]) =>
108    parts.map(part => (
109      <Text color={part.color ?? C.fg} backgroundColor={part.bg ?? bg} weight={part.bold ? 'bold' : undefined}>
110        {part.text}
111      </Text>
112    ))
113  return (
114    <Box flexDirection="row" width={w} height={1} overflow="hidden" backgroundColor={bg}>
115      {items.map((item, i) => {
116        const [a, b] = layout[i]!
117        if ('spacer' in item) return <Text backgroundColor={bg}>{' '.repeat(b - a)}</Text>
118        if (!item.action) return <Text backgroundColor={bg}>{paint(item.parts)}</Text>
119        const action = item.action as Action
120        return (
121          <Button key={action} plain onPress={() => m.onAction(action)}>
122            {paint(item.parts)}
123          </Button>
124        )
125      })}
126    </Box>
127  )
128}
129
130// A vertical scrollbar: a Client the pointer drags, else a picture of one.
131function Bar({ m, id, length, total, view, offset, bg }: {
132  m: PaneModel
133  id: string
134  length: number
135  total: number
136  view: number
137  offset: number
138  bg: string
139}) {
140  const { Box, Text, Client } = m.els
141  const props: BarProps = {
142    axis: 'v',
143    length,
144    total,
145    view,
146    offset,
147    colors: { track: C.track, thumb: C.thumb, active: C.thumbActive, bg },
148  }
149  if (Client) return <Client key={id} module="./scrollbar.tsx" props={props} width={1} height={length} />
150  const { start, size, max } = thumbOf(props)
151  return (
152    <Box flexDirection="column" width={1}>
153      {Array.from({ length }, (_, i) => {
154        const isThumb = max > 0 && i >= start && i < start + size
155        return (
156          <Text color={isThumb ? C.thumb : C.track} backgroundColor={bg}>
157            {isThumb ? '┃' : '│'}
158          </Text>
159        )
160      })}
161    </Box>
162  )
163}
164
165// ---- the parts --------------------------------------------------------------------------------
166
167function Toolbar({ m }: { m: PaneModel }) {
168  const { Box, Text } = m.els
169  const t = T[m.locale]
170  const segment = (label: string, isOn: boolean): Part =>
171    isOn ? { text: ` ${label} `, color: C.fg, bg: C.segment, bold: true } : { text: ` ${label} `, color: C.muted }
172  const items: Item[] = [
173    text([
174      { text: ' ❯ ', color: C.accent, bold: true },
175      { text: t.title, color: C.fg, bold: true },
176      { text: `  ${t.count(m.count)}`, color: C.muted },
177    ]),
178    spacer,
179    { action: 'locale', parts: [{ text: ' ' }, segment('한국어', m.locale === 'ko'), segment('EN', m.locale === 'en'), { text: ' ' }] },
180    text([{ text: ' ' }]),
181  ]
182  return (
183    <Box flexDirection="column" width={m.at.width}>
184      <ChromeRow m={m} id="toolbar" w={m.at.width} bg={C.header} items={items} />
185      <Text color={C.border} backgroundColor={C.canvas}>
186        {'─'.repeat(m.at.width)}
187      </Text>
188    </Box>
189  )
190}
191
192function ListColumn({ m }: { m: PaneModel }) {
193  const { Box, Text, Client } = m.els
194  const inner = m.at.listWidth - 1
195  const t = T[m.locale]
196  const props: ListProps = {
197    width: inner,
198    rows: m.list,
199    colors: { bg: C.sidebar, hover: C.hover, pressed: C.pressed, selected: C.selected, accent: C.accent, fg: C.fg, muted: C.muted, subtle: C.subtle },
200  }
201  const body =
202    m.count === 0 ? (
203      <Box flexDirection="column" width={inner}>
204        <Text backgroundColor={C.sidebar}>{' '.repeat(inner)}</Text>
205        <Text backgroundColor={C.sidebar} color={C.fg}>
206          {pad(`  ${t.empty}`, inner)}
207        </Text>
208        <Text backgroundColor={C.sidebar} color={C.muted}>
209          {pad(`  ${t.emptyHint}`, inner)}
210        </Text>
211      </Box>
212    ) : Client ? (
213      <Client key="list" module="./list-client.tsx" props={props} width={inner} height={m.at.listRows} />
214    ) : (
215      <Box flexDirection="column" width={inner}>
216        {m.list.map(row =>
217          row.kind === 'day' ? (
218            <Text backgroundColor={C.sidebar} color={C.muted} weight="bold">
219              {pad(` ${row.label}`, inner)}
220            </Text>
221          ) : (
222            <Text backgroundColor={row.isSelected ? C.selected : C.sidebar} color={C.text} wrap="truncate-end">
223              {pad(` ${row.no} ❯ ${row.text}`, inner - textWidth(row.time) - 1)}
224              <Text color={C.muted}>{row.time} </Text>
225            </Text>
226          ),
227        )}
228      </Box>
229    )
230  return (
231    <Box flexDirection="row" width={m.at.listWidth} height={m.at.listRows} overflow="hidden" backgroundColor={C.sidebar}>
232      <Box flexDirection="column" width={inner} height={m.at.listRows} backgroundColor={C.sidebar}>
233        {body}
234      </Box>
235      <Bar m={m} id="lbar" length={m.at.listRows} total={m.listTotal} view={m.at.listRows} offset={m.listOffset} bg={C.sidebar} />
236    </Box>
237  )
238}
239
240function PreviewHead({ m }: { m: PaneModel }) {
241  const { Box, Text, Button } = m.els
242  const t = T[m.locale]
243  const w = m.at.previewWidth
244  const shown = m.shown!
245  const stats = ` ${shown.prompt.text.split('\n').length}${m.locale === 'ko' ? '' : ' '}${t.lines} · ${shown.prompt.text.length.toLocaleString('en-US')}${m.locale === 'ko' ? '' : ' '}${t.chars} `
246  const buttons = textWidth(t.copy) + 4 + 1 + textWidth(t.jump) + 4 + 1 // each `[ label ]` and a cell after
247  const lead = ` #${shown.no}  ${shown.when}`
248  // The counts go first when the buttons leave too little room for them.
249  const hasStats = textWidth(lead) + 1 + textWidth(stats) <= w - buttons
250  const items: Item[] = [
251    text([
252      { text: ` #${shown.no}`, color: C.accent, bold: true },
253      { text: `  ${shown.when}`, color: C.muted },
254    ]),
255    spacer,
256    ...(hasStats ? [text([{ text: stats, color: C.subtle }])] : []),
257  ]
258  return (
259    <Box flexDirection="column" width={w}>
260      <Box flexDirection="row" width={w} height={1} overflow="hidden" backgroundColor={C.canvas}>
261        <ChromeRow m={m} id="phead" w={Math.max(1, w - buttons)} bg={C.canvas} items={items} />
262        {/* Both handled by `ui.press` hooks, whose `$` answers the press: a closure here would not. */}
263        <Button key="copy" onPress={() => undefined}>
264          {t.copy}
265        </Button>
266        <Text backgroundColor={C.canvas}> </Text>
267        <Button key="jump" variant="primary" autoFocus onPress={() => undefined}>
268          {t.jump}
269        </Button>
270        <Text backgroundColor={C.canvas}> </Text>
271      </Box>
272      <Text color={C.border} backgroundColor={C.canvas}>
273        {'─'.repeat(w)}
274      </Text>
275    </Box>
276  )
277}
278
279function PreviewFoot({ m }: { m: PaneModel }) {
280  const t = T[m.locale]
281  const w = m.at.previewWidth
282  const shown = m.shown!
283  const total = shown.rows.length
284  const first = total === 0 ? 0 : m.previewOffset + 1
285  const last = Math.min(total, m.previewOffset + m.at.previewRows)
286  // A note starting with ✓ reports something done (a copy); any other, something refused.
287  const state = m.note.startsWith('✓')
288    ? { text: ` ${m.note}`, color: C.ok }
289    : m.note
290      ? { text: ` ⚠ ${m.note}`, color: C.warn }
291      : { text: ` ${t.picked}`, color: C.muted }
292  const controls: Item[] = [control('up', '▲'), control('down', '▼'), text([{ text: ' ' }])]
293  const where = text([{ text: `  ${first}–${last}/${total}`, color: C.muted }])
294  const room = w - 6 - 1 - 14
295  const items: Item[] = [text([{ ...state, text: truncate(state.text, Math.max(0, room)) }]), where, spacer, ...controls]
296  return <ChromeRow m={m} id="pfoot" w={w} bg={C.header} items={items} />
297}
298
299function PreviewColumn({ m }: { m: PaneModel }) {
300  const { Box, Text } = m.els
301  const w = m.at.previewWidth
302  const inner = w - 1
303  const tall = m.at.previewRows + PREVIEW_HEAD + PREVIEW_FOOT
304  if (!m.shown) {
305    const lines = ['', `  ${m.count === 0 ? '' : T[m.locale].pick}`]
306    return (
307      <Box flexDirection="column" width={w} height={tall} backgroundColor={C.canvas}>
308        {lines.map(line => (
309          <Text backgroundColor={C.canvas} color={C.muted}>
310            {pad(line, w)}
311          </Text>
312        ))}
313      </Box>
314    )
315  }
316  const window = m.shown.rows.slice(m.previewOffset, m.previewOffset + m.at.previewRows)
317  return (
318    <Box flexDirection="column" width={w} height={tall} overflow="hidden" backgroundColor={C.canvas}>
319      <PreviewHead m={m} />
320      <Box flexDirection="row" width={w} height={m.at.previewRows} backgroundColor={C.canvas}>
321        <Box flexDirection="column" width={inner} height={m.at.previewRows} overflow="hidden" backgroundColor={C.canvas}>
322          {window.map(row => (
323            <Text backgroundColor={C.canvas} color={C.text}>
324              {pad(`  ${row}`, inner)}
325            </Text>
326          ))}
327        </Box>
328        <Bar m={m} id="pbar" length={m.at.previewRows} total={m.shown.rows.length} view={m.at.previewRows} offset={m.previewOffset} bg={C.canvas} />
329      </Box>
330      <PreviewFoot m={m} />
331    </Box>
332  )
333}
334
335export function Pane({ m }: { m: PaneModel }) {
336  const { Box, Text } = m.els
337  const columns = m.at.isStacked ? (
338    <Box flexDirection="column" width={m.at.width} overflow="hidden" backgroundColor={C.canvas}>
339      <ListColumn m={m} />
340      <Text color={C.border} backgroundColor={C.canvas}>
341        {'─'.repeat(m.at.width)}
342      </Text>
343      <PreviewColumn m={m} />
344    </Box>
345  ) : (
346    <Box flexDirection="row" width={m.at.width} overflow="hidden" backgroundColor={C.canvas}>
347      <ListColumn m={m} />
348      <Box flexDirection="column" width={1}>
349        {Array.from({ length: m.at.listRows }, () => (
350          <Text color={C.border} backgroundColor={C.canvas}>
351            │
352          </Text>
353        ))}
354      </Box>
355      <PreviewColumn m={m} />
356    </Box>
357  )
358  return (
359    // One row past the body, never shown: the engine sends the pane's arrow keys only while it has
360    // rows to scroll, and the ui.scroll hook turns them into moves through the list.
361    <Box flexDirection="column" width={m.at.width} height={m.at.height + 1} overflow="hidden" backgroundColor={C.canvas}>
362      <Toolbar m={m} />
363      {columns}
364      <Text backgroundColor={C.canvas}>{' '.repeat(m.at.width)}</Text>
365    </Box>
366  )
367}
368
hooks/controls.tsx 111 lines
1// One row of the pane's chrome (the toolbar, a header, the status row), drawn by a surface module
2// so a control under the pointer takes a quiet background (a Button would invert), a press darkens
3// it, and a click posts its action. Once clicked, the row takes the pane's keys and posts those.
4import type { ClientModule } from 'claude-code'
5
6export type Part = { text: string; color?: string; bg?: string; bold?: boolean; strike?: boolean }
7
8// A run of text, a control (with an `action`), or a spacer that takes the room left over.
9export type Item = { parts: Part[]; action?: string; isOn?: boolean } | { spacer: true }
10
11export type ControlsProps = {
12  width: number
13  bg: string
14  items: Item[]
15  colors: { hover: string; pressed: string; on: string; fg: string }
16}
17
18// A click on a control, or a key pressed while the row holds the focus.
19export type ControlsMessage = { action: string } | { press: string }
20
21type State = { hover: number | null; down: number | null }
22
23export const cellsOf = (s: string) =>
24  [...s].reduce((n, ch) => {
25    const cp = ch.codePointAt(0) ?? 0
26    if (cp < 0x20 || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return n
27    const isWide =
28      (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
29      (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
30      (cp >= 0x1f300 && cp <= 0x1faff)
31    return n + (isWide ? 2 : 1)
32  }, 0)
33
34const widthOf = (item: Item) => ('spacer' in item ? 0 : item.parts.reduce((n, p) => n + cellsOf(p.text), 0))
35
36// Each item's [start, end) columns, the spacers sharing what the rest leave.
37export function spans(items: Item[], width: number): [number, number][] {
38  const fixed = items.reduce((n, item) => n + widthOf(item), 0)
39  const spacers = items.filter(item => 'spacer' in item).length
40  const spare = Math.max(0, width - fixed)
41  let at = 0
42  let given = 0
43  return items.map(item => {
44    let w = widthOf(item)
45    if ('spacer' in item) {
46      given++
47      w = given === spacers ? spare - Math.floor(spare / spacers) * (spacers - 1) : Math.floor(spare / spacers)
48    }
49    const span: [number, number] = [at, at + w]
50    at += w
51    return span
52  })
53}
54
55const ControlRow: ClientModule<ControlsProps, State> = (props, surface) => {
56  const { Text } = surface.elements
57  const state = surface.state ?? { hover: null, down: null }
58  const layout = spans(props.items, props.width)
59  const hit = (x: number, y: number) => {
60    if (y !== 0) return null
61    const i = layout.findIndex(([a, b]) => x >= a && x < b)
62    const item = props.items[i]
63    return item && !('spacer' in item) && item.action ? i : null
64  }
65  const set = (next: State) => {
66    if (next.hover !== state.hover || next.down !== state.down) surface.setState(next)
67  }
68
69  surface.onPointer(event => {
70    const at = hit(event.x, event.y)
71    if (event.type === 'leave') set({ hover: null, down: null })
72    else if (event.type === 'move' || event.type === 'enter') set({ ...state, hover: at })
73    else if (event.type === 'down' && event.button === 'left') set({ hover: at, down: at })
74    else if (event.type === 'up' && event.button === 'left') {
75      const item = at !== null && at === state.down ? props.items[at] : undefined
76      set({ hover: at, down: null })
77      if (item && !('spacer' in item) && item.action) surface.post({ action: item.action } satisfies ControlsMessage)
78    }
79  })
80  surface.onKey(event => {
81    if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies ControlsMessage)
82  })
83
84  return (
85    <Text backgroundColor={props.bg} wrap="truncate-end">
86      {props.items.map((item, i) => {
87        const [a, b] = layout[i]!
88        if ('spacer' in item) return <Text backgroundColor={props.bg}>{' '.repeat(b - a)}</Text>
89        const isActive = item.action !== undefined && (state.down === i || state.hover === i)
90        const lit = state.down === i ? props.colors.pressed : props.colors.hover
91        return (
92          <Text>
93            {item.parts.map(part => (
94              <Text
95                color={part.color ?? (item.isOn ? props.colors.on : props.colors.fg)}
96                backgroundColor={isActive && !part.bg ? lit : (part.bg ?? props.bg)}
97                weight={part.bold ? 'bold' : undefined}
98                strikethrough={part.strike}
99              >
100                {part.text}
101              </Text>
102            ))}
103          </Text>
104        )
105      })}
106    </Text>
107  )
108}
109
110export default ControlRow
111
hooks/scrollbar.tsx 102 lines
1// A thin scrollbar, vertical or horizontal, drawn by a surface module: the thumb follows a drag,
2// a click on the track jumps there, and once clicked it takes the arrow, page, Home and End keys.
3// It posts where the window should stand; the hooks module owns the offset.
4import type { ClientModule } from 'claude-code'
5
6export type BarProps = {
7  axis: 'v' | 'h'
8  length: number // cells along the bar
9  total: number // rows or columns of content
10  view: number // rows or columns the window shows
11  offset: number // where the window starts
12  colors: { track: string; thumb: string; active: string; bg: string }
13}
14
15// Where the window should stand, or a key the bar does not use, for the pane's keymap.
16export type BarMessage = { offset: number } | { press: string }
17
18type State = { isHot: boolean; grab: number | null } // grab: where on the thumb the drag took it
19
20export function thumbOf(p: Pick<BarProps, 'length' | 'total' | 'view' | 'offset'>) {
21  const max = Math.max(0, p.total - p.view)
22  if (max === 0 || p.length <= 0) return { start: 0, size: p.length, max }
23  const size = Math.max(1, Math.min(p.length, Math.round((p.length * p.view) / p.total)))
24  const start = Math.round(((p.length - size) * Math.min(p.offset, max)) / max)
25  return { start, size, max }
26}
27
28const Scrollbar: ClientModule<BarProps, State> = (props, surface) => {
29  const { Box, Text } = surface.elements
30  const state = surface.state ?? { isHot: false, grab: null }
31  const { start, size, max } = thumbOf(props)
32  const isV = props.axis === 'v'
33  const page = Math.max(1, props.view - 2)
34
35  // The offset whose thumb starts at `cell`, clamped to the content.
36  const offsetAt = (cell: number) => {
37    const room = props.length - size
38    return room <= 0 ? 0 : Math.max(0, Math.min(max, Math.round((cell / room) * max)))
39  }
40  const send = (offset: number) => {
41    const clamped = Math.max(0, Math.min(max, offset))
42    if (clamped !== props.offset) surface.post({ offset: clamped } satisfies BarMessage)
43  }
44
45  surface.onPointer(event => {
46    const at = isV ? event.y : event.x
47    if (event.type === 'enter' && !state.isHot) surface.setState({ ...state, isHot: true })
48    else if (event.type === 'leave' && state.grab === null && state.isHot) surface.setState({ ...state, isHot: false })
49    else if (event.type === 'down' && event.button === 'left' && max > 0) {
50      const onThumb = at >= start && at < start + size
51      const grab = onThumb ? at - start : Math.floor(size / 2)
52      surface.setState({ isHot: true, grab })
53      if (!onThumb) send(offsetAt(at - grab))
54    } else if (event.type === 'move' && state.grab !== null) send(offsetAt(at - state.grab))
55    else if (event.type === 'up' && state.grab !== null) surface.setState({ ...state, grab: null })
56  })
57
58  surface.onKey(event => {
59    const back = isV ? 'up' : 'left'
60    const fwd = isV ? 'down' : 'right'
61    const step = isV ? 1 : 4
62    if (event.key === back) send(props.offset - step)
63    else if (event.key === fwd) send(props.offset + step)
64    else if (event.key === 'pageup') send(props.offset - page)
65    else if (event.key === 'pagedown') send(props.offset + page)
66    else if (event.key === 'home') send(0)
67    else if (event.key === 'end') send(max)
68    else if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies BarMessage)
69  })
70
71  const thumbColor = state.grab !== null || state.isHot ? props.colors.active : props.colors.thumb
72  const cell = (i: number) => {
73    const isThumb = max > 0 && i >= start && i < start + size
74    if (isV) return isThumb ? '┃' : '│'
75    return isThumb ? '━' : '─'
76  }
77  const colorOf = (i: number) => (max > 0 && i >= start && i < start + size ? thumbColor : props.colors.track)
78
79  if (isV) {
80    return (
81      <Box flexDirection="column" width={1}>
82        {Array.from({ length: props.length }, (_, i) => (
83          <Text color={colorOf(i)} backgroundColor={props.colors.bg}>
84            {cell(i)}
85          </Text>
86        ))}
87      </Box>
88    )
89  }
90  const before = max > 0 ? start : 0
91  const thumb = max > 0 ? size : 0
92  return (
93    <Text backgroundColor={props.colors.bg}>
94      <Text color={props.colors.track}>{'─'.repeat(before)}</Text>
95      <Text color={thumbColor}>{'━'.repeat(thumb)}</Text>
96      <Text color={props.colors.track}>{'─'.repeat(Math.max(0, props.length - before - thumb))}</Text>
97    </Text>
98  )
99}
100
101export default Scrollbar
102
hooks/theme.ts 24 lines
1// GitHub's dark theme (Primer dark default), as diff-explorer uses it.
2export const C = {
3  canvas: '#0d1117',
4  sidebar: '#010409',
5  border: '#30363d',
6  fg: '#e6edf3',
7  text: '#c9d1d9', // a prompt's body
8  muted: '#7d8590',
9  subtle: '#484f58',
10  accent: '#2f81f7',
11  selected: '#21262d', // the picked prompt, beside the accent bar
12  hover: '#161b22', // the row under the pointer
13  pressed: '#262c36', // a row while pressed
14  segment: '#1c3a5e', // the active half of a switch
15  header: '#161b22',
16  controlHover: '#262c36', // a control under the pointer
17  controlPressed: '#30363d', // a control while pressed
18  track: '#21262d', // a scrollbar's rail
19  thumb: '#484f58', // its thumb at rest
20  thumbActive: '#8b949e', // under the pointer or dragged
21  warn: '#d29922', // a jump the engine refused
22  ok: '#3fb950', // a copy that went through
23} as const
24
types/index.d.ts 22 lines
1// One prompt the person sent: the transcript row's id, its text and when it was sent (ms).
2export type Prompt = { uuid: string; text: string; at: number }
3
4export type Locale = 'ko' | 'en'
5
6// Where the list's and the preview's windows start; the preview's offset is for one prompt.
7export type Scroll = { list: number; preview: number; previewFor: string | null }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'prompt-history': {
12      prompts: Prompt[]
13      selected: string | null
14      scroll: Scroll
15      locale: Locale | null
16      now: number
17      tzOffset: number
18      note: string
19    }
20  }
21}
22