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.

한국어 · English
이번 세션에서 내가 보낸 프롬프트만 모아 채팅 옆 패널에 보여줍니다. 언제 보냈는지 한눈에 보고, 긴 프롬프트도 전문으로 읽고, 버튼 한 번으로 트랜스크립트의 그 위치로 돌아가세요.
<img src="../../images/prompt-history-ko.png" alt="prompt-history" width="100%">
오늘 · 어제 · 10월 20일 (월) 구분선 아래 번호와 한 줄 요약(줄바꿈은 ↵).방금 전 → 3분 전 → 2시간 전 → 3일 전, 일주일이 지나면 10월 12일. 30초마다 갱신되고, 시간이 길어져도 줄 끝의 시간은 항상 보이게 프롬프트 쪽이 줄어듭니다.↑ ↓로.[ ⧉ 복사 ]로 고른 프롬프트 전문을 그대로 클립보드에. 다시 보내거나 다른 데 붙여 넣을 때.[ ↗ 이동 ] 버튼이나 /prompts <번호>로 트랜스크립트를 그 프롬프트까지 스크롤./clear 하면 비워져요.한국어 │ EN으로 시간 표기를 바로 전환./plugin install prompt-history --marketplace speardragon/ray-claude-code-mods
| 입력 | 동작 |
|---|---|
/prompts | 패널 켜기 / 끄기 (토글). 켤 때 패널에 포커스가 가서 바로 ↑ ↓로 고를 수 있어요 |
/prompts on · /prompts off | 패널 켜기 · 끄기 |
/prompts 3 | 3번째 프롬프트 위치로 바로 이동 |
↑ ↓ | (패널에 포커스가 있을 때) 프롬프트 고르기, PageUp PageDown은 여러 개씩, Home End는 처음 / 끝 |
Enter | (패널에 포커스가 있을 때) 고른 프롬프트 위치로 이동 |
| 목록 줄 클릭 | 그 프롬프트를 골라 오른쪽에 전문 표시 |
[ ⧉ 복사 ] | 고른 프롬프트 전문을 클립보드에 복사 |
[ ↗ 이동 ] | 고른 프롬프트 위치로 이동 |
상태줄 ▲ ▼ | 전문 한 화면 위 / 아래 |
| 휠 · 스크롤바 | 커서가 올라간 컬럼만 스크롤 |
키 입력은 한 번에 한 곳으로만 갑니다. 프롬프트 입력창이거나, 패널이거나.
| 하고 싶은 것 | 방법 |
|---|---|
| 패널로 가기 | /prompts로 켤 때 자동으로, 또는 ctrl+x tab, 또는 패널의 버튼 클릭 |
| 입력창으로 돌아오기 | Esc (패널은 그대로 열려 있어요) |
| 패널 닫기 | /prompts 다시 입력, /prompts off, 또는 ctrl+x x |
패널에 포커스가 있으면 ↑ ↓가 목록을 움직이고 Enter가 이동 버튼을 누릅니다. 입력창에 포커스가 있으면 평소처럼 타이핑과 프롬프트 기록(↑)으로 동작해요.
| 키 | 기본값 | 설명 |
|---|---|---|
locale | ko | ko(3분 전, 10월 22일, 오후 2:31) 또는 en(3m ago, Oct 22, 2:31 PM). 툴바에서 세션 동안 바꿀 수 있어요 |
[ ↗ 이동 ]과 /prompts <번호>로 합니다.date +%z)을 따릅니다.hooks/register.tsx 380 lines1// 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}
380hooks/list-client.tsx 107 lines1// 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
107hooks/text.ts 175 lines1// 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}
175hooks/time.ts 71 lines1// 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}
71hooks/transcript.ts 57 lines1// 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}
57hooks/keys.ts 13 lines1// 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}
13hooks/view.tsx 368 lines1// 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}
368hooks/controls.tsx 111 lines1// 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
111hooks/scrollbar.tsx 102 lines1// 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
102hooks/theme.ts 24 lines1// 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
24types/index.d.ts 22 lines1// 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