A VS Code-style side panel for the files Claude changes: a file tree with Material-style icons and a GitHub-dark diff preview. /diffs opens it.

한국어 · English
Claude가 이번 세션에서 바꾼 파일을 채팅 옆 패널에 VS Code처럼 보여줍니다. 왼쪽은 바뀐 파일만 모은 탐색 트리, 오른쪽은 고른 파일의 diff를 GitHub 다크 톤으로. 터미널을 떠나지 않고 "방금 뭐가 바뀌었지?"에 답할 수 있어요.
<img src="../../images/diff-explorer.png" alt="diff-explorer" width="100%">
M 수정 · A 새 파일 · D 삭제). 하나뿐인 하위 폴더는 src/hooks처럼 한 줄로 합칩니다.⋯ N unchanged lines.NEW, DELETED 배지와 취소선.v로 diff와 구문 강조된 파일 전체 보기를 전환.w)을 끄면 가로 스크롤바와 ◀ ▶로 옆으로 이동.Write Edit MultiEdit NotebookEdit로 바뀐 파일을 잡습니다./plugin install diff-explorer --marketplace speardragon/ray-claude-code-mods
| 입력 | 클릭할 곳 | 동작 |
|---|---|---|
/diffs | 패널 켜기 / 끄기 (토글) | |
/diffs on · /diffs off | 패널 켜기 · 끄기 | |
/diffs clear | 추적 기록 비우기 (지금 내용이 새 기준점) | |
t | 툴바 Explorer | 탐색기 접기 / 펴기 |
c | 탐색기 제목 줄 오른쪽 | 폴더 모두 접기 / 모두 펴기 |
[ / ] | 탐색기와 미리보기 사이 경계선 | 탐색기 폭 좁히기 / 넓히기. 경계선은 드래그로 조절하고, 더블클릭하면 기본 폭으로 돌아갑니다 (조절한 폭은 다음 세션에도 유지) |
p / n | 파일 헤더 ‹ › | 이전 / 다음 파일 |
v | 상태줄 Diff │ File | diff ↔ 파일 전체 |
w | 상태줄 Wrap | 긴 줄 줄바꿈 켜기 / 끄기 |
k / j | 상태줄 ▲ ▼ | 한 화면 위 / 아래 |
h / l | 상태줄 ◀ ▶ | 가로 스크롤 (줄바꿈을 껐을 때) |
| 트리 줄 | 파일 고르기, 폴더 접기 / 펴기 | |
| 휠 · 스크롤바 | 커서가 올라간 컬럼만 스크롤, 스크롤바는 드래그 · 클릭 |
단축키는 패널 안(툴바, 트리, 헤더, 상태줄, 스크롤바)을 한 번 클릭한 뒤 동작합니다. 패널이 72칸보다 좁으면 트리가 자동으로 숨고, 파일 헤더의 ‹ ›와 현재/전체로 이동해요.
/config의 플러그인 항목에서 바꿉니다.
| 키 | 기본값 | 설명 |
|---|---|---|
icons | nerd | nerd: Nerd Font 글리프 아이콘. plain: 색 점, 어떤 폰트에서나 보임 |
autoOpen | true | 세션의 첫 수정 때 패널을 자동으로 열기 (전체 화면 터미널에서 옆에 붙을 때만) |
Bash로 바꾼 파일(sed -i, rm 등)은 아직 추적하지 않아요. 단, 추적 중인 파일이 Bash로 지워지면 DELETED로 보입니다.v로 파일 내용은 볼 수 있어요.h l로 합니다.hooks/register.tsx 445 lines1// diff-explorer: the files Claude changes this session, in a VS Code-style pane beside the chat.
2// Left, an explorer tree of the changed files with Material-style icons and git-style status;
3// right, the selected file's diff against how it was before Claude first touched it, in GitHub dark.
4// /diffs opens it; n/p walk the files, v swaps diff and file view, w wraps, t shows or hides the tree;
5// the wheel scrolls whichever column it is over, the arrow keys the preview.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { Scroll, TreeMode, Tracked, View } from '../types'
10import { unified } from './diff'
11import type { Diff } from './diff'
12import type { IconStyle } from './icons'
13import { buildTree, fileOrder, relative, treeRows } from './tree'
14import { contentWidth, diffRows, lineRoom } from './rows'
15import type { VRow } from './rows'
16import { HOTKEYS } from './chrome'
17import type { Action } from './chrome'
18import { dropCells, expandTabs, textWidth } from './text'
19import { layout, Pane, TREE_MIN, treeMax } from './view'
20import type { Entry, Handlers, Layout } from './view'
21
22const PANE = 'diff-explorer'
23const TITLE = 'Changes'
24const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
25const BEFORE_MAX = 1_000_000 // longer earlier texts are not kept: such a file shows no diff
26const MAX_FILES = 200
27const FOLLOW_AFTER_PICK_MS = 60_000 // a file the person picked holds the preview this long against new edits
28const DOCK_COLUMNS = 96
29const READ_TTL_MS = 5_000 // a file read for drawing is read again after this, or once Claude edits it
30
31// Held by the host, so a hot reload keeps them.
32const files = atom({ plugin: 'diff-explorer', key: 'files' } as const, [] as Tracked[])
33const selected = atom({ plugin: 'diff-explorer', key: 'selected' } as const, null as string | null)
34const view = atom({ plugin: 'diff-explorer', key: 'view' } as const, 'diff' as View)
35const wrap = atom({ plugin: 'diff-explorer', key: 'wrap' } as const, true)
36const tree = atom({ plugin: 'diff-explorer', key: 'tree' } as const, 'auto' as TreeMode)
37const collapsed = atom({ plugin: 'diff-explorer', key: 'collapsed' } as const, [] as string[])
38const scroll = atom({ plugin: 'diff-explorer', key: 'scroll' } as const, { tree: 0, preview: 0, x: 0 } as Scroll)
39const cwd = atom({ plugin: 'diff-explorer', key: 'cwd' } as const, '')
40const treeWidth = atom({ plugin: 'diff-explorer', key: 'treeWidth' } as const, null as number | null)
41
42// The explorer width the person dragged to, kept across sessions.
43const WIDTH_KEY = 'treeWidth'
44
45// The module's own bookkeeping; a reload starts it over.
46const session = { home: '', command: 'diffs', isFullscreen: undefined as boolean | undefined, hasAutoOpened: false, pickedAt: 0 }
47// The last drawing's measures, for the scroll and the hotkeys to clamp against.
48const drawn = { layout: null as Layout | null, treeRows: 0, previewRows: 0, maxX: 0, wideView: 0, order: [] as string[], dirs: [] as string[] }
49// Each file as last read for drawing, so scrolling and pressing read nothing from disk.
50const reads = new Map<string, { text: string | null; readAt: number }>()
51// Diffs by file, reused while neither side changed.
52const diffs = new Map<string, { before: string; after: string; diff: Diff }>()
53const vrows = new Map<string, { diff: Diff; width: number; isWrapped: boolean; rows: VRow[] }>()
54
55// Which window each scrollbar moves, by its Client's key.
56const BARS: Record<string, 'tree' | 'preview' | 'x'> = { tbar: 'tree', vbar: 'preview', hbar: 'x' }
57
58const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
59const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
60
61async function readText($: EngineInterface, path: string): Promise<string | null> {
62 const text = await $.fs.read(path).catch(() => null)
63 return typeof text === 'string' ? text : null
64}
65
66async function currentText($: EngineInterface, t: Tracked): Promise<string | null> {
67 const kept = reads.get(t.path)
68 const now = Date.now()
69 if (kept && kept.readAt >= t.at && now - kept.readAt < READ_TTL_MS) return kept.text
70 const text = await readText($, t.path)
71 reads.set(t.path, { text, readAt: now })
72 return text
73}
74
75function diffOf(path: string, before: string, after: string): Diff {
76 const kept = diffs.get(path)
77 if (kept && kept.before === before && kept.after === after) return kept.diff
78 const diff = unified(before, after)
79 diffs.set(path, { before, after, diff })
80 return diff
81}
82
83function rowsOf(path: string, diff: Diff, width: number, isWrapped: boolean): VRow[] {
84 const kept = vrows.get(path)
85 if (kept && kept.diff === diff && kept.width === width && kept.isWrapped === isWrapped) return kept.rows
86 const rows = diffRows(diff, width, isWrapped)
87 vrows.set(path, { diff, width, isWrapped, rows })
88 return rows
89}
90
91// What the tree and the header say of each file, read from disk as it is now.
92async function entriesOf($: EngineInterface, list: Tracked[], root: string) {
93 return Promise.all(
94 list.map(async t => {
95 const after = await currentText($, t)
96 const status = t.before === null ? 'A' : after === null ? 'D' : 'M'
97 const diff = t.isTooBig ? null : diffOf(t.path, t.before ?? '', after ?? '')
98 const entry: Entry = {
99 path: t.path,
100 rel: relative(t.path, root, session.home),
101 name: base(t.path),
102 status,
103 adds: diff?.adds ?? 0,
104 dels: diff?.dels ?? 0,
105 isSame: diff !== null && !diff.isBinary && diff.rows.length === 0 && status === 'M',
106 isTooBig: t.isTooBig,
107 }
108 return { entry, diff, after }
109 }),
110 )
111}
112
113async function openPane($: EngineInterface, focus: boolean) {
114 return $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS, ...(focus ? { focus: true as const } : {}) })
115}
116
117// A file Claude is about to change: keep how it was, the first time only.
118async function track($: EngineInterface, path: string, before: string | null) {
119 const isTooBig = before !== null && before.length > BEFORE_MAX
120 const now = Date.now()
121 await update($, files, list => {
122 const known = list.find(t => t.path === path)
123 if (known) return list.map(t => (t === known ? { ...t, at: now, edits: t.edits + 1 } : t))
124 const fresh: Tracked = { path, before: isTooBig ? null : before, isTooBig, at: now, edits: 1 }
125 return [...list, fresh].slice(-MAX_FILES)
126 })
127 if (now - session.pickedAt > FOLLOW_AFTER_PICK_MS) {
128 await update($, selected, () => path)
129 await update($, scroll, s => ({ ...s, preview: 0 }))
130 }
131}
132
133async function maybeAutoOpen($: EngineInterface, isOn: boolean) {
134 if (!isOn || session.hasAutoOpened) return
135 session.hasAutoOpened = true
136 const panes = await $.ui.panes().catch(() => [])
137 if (panes.some(p => p.id === PANE)) return
138 // Unasked, only where it would dock as a sidebar; elsewhere a toast says how to open it.
139 if (session.isFullscreen === true) await openPane($, false).catch(() => undefined)
140 else $.ui.toast(`diff-explorer: /${session.command} shows the files Claude changed`)
141}
142
143async function move($: EngineInterface, by: number) {
144 const order = drawn.order
145 if (order.length === 0) return
146 session.pickedAt = Date.now()
147 const now = await read($, selected)
148 const at = now ? order.indexOf(now) : -1
149 const next = order[clamp(at < 0 ? 0 : at + by, 0, order.length - 1)]!
150 await update($, selected, () => next)
151 await update($, scroll, s => ({ ...s, preview: 0, x: 0 }))
152}
153
154// The furthest each window can go, from the last drawing.
155function maxOf(region: 'tree' | 'preview' | 'x') {
156 const at = drawn.layout
157 if (!at) return 0
158 if (region === 'tree') return Math.max(0, drawn.treeRows - at.treeBody)
159 if (region === 'preview') return Math.max(0, drawn.previewRows - at.previewBody)
160 return drawn.maxX
161}
162
163async function scrollBy($: EngineInterface, region: 'tree' | 'preview' | 'x', by: number) {
164 const max = maxOf(region)
165 await update($, scroll, s => ({ ...s, [region]: clamp((s[region] ?? 0) + by, 0, max) }))
166}
167
168async function scrollTo($: EngineInterface, region: 'tree' | 'preview' | 'x', offset: number) {
169 const max = maxOf(region)
170 await update($, scroll, s => ({ ...s, [region]: clamp(offset, 0, max) }))
171}
172
173async function pickFile($: EngineInterface, path: string) {
174 session.pickedAt = Date.now()
175 await update($, selected, () => path)
176 await update($, scroll, s => ({ ...s, preview: 0, x: 0 }))
177}
178
179const toggleDir = ($: EngineInterface, key: string) =>
180 update($, collapsed, list => (list.includes(key) ? list.filter(k => k !== key) : [...list, key]))
181
182// Collapse every folder while any is open; expand them all once none is.
183const collapseAll = ($: EngineInterface) =>
184 update($, collapsed, list => (drawn.dirs.some(d => !list.includes(d)) ? [...drawn.dirs] : []))
185
186// What each control and its key does.
187function actions($: EngineInterface): Record<Action, () => Promise<unknown>> {
188 const page = () => Math.max(1, (drawn.layout?.previewBody ?? 10) - 2)
189 const half = () => Math.max(4, Math.round(drawn.wideView / 2))
190 return {
191 collapseAll: () => collapseAll($).then(() => update($, scroll, s => ({ ...s, tree: 0 }))),
192 next: () => move($, 1),
193 prev: () => move($, -1),
194 view: () => update($, view, v => (v === 'diff' ? 'file' : 'diff')).then(() => update($, scroll, s => ({ ...s, preview: 0, x: 0 }))),
195 wrap: () => update($, wrap, w => !w).then(() => update($, scroll, s => ({ ...s, x: 0 }))),
196 tree: () => update($, tree, () => (drawn.layout?.showTree ? 'hidden' : 'shown')),
197 down: () => scrollBy($, 'preview', page()),
198 up: () => scrollBy($, 'preview', -page()),
199 left: () => scrollBy($, 'x', -half()),
200 right: () => scrollBy($, 'x', half()),
201 }
202}
203
204function handlers($: EngineInterface): Handlers {
205 const run = actions($)
206 const press = (action: Action) => () => void run[action]()
207 return {
208 pick: path => void pickFile($, path),
209 toggle: key => void toggleDir($, key),
210 collapseAll: press('collapseAll'),
211 next: press('next'),
212 prev: press('prev'),
213 view: press('view'),
214 wrap: press('wrap'),
215 tree: press('tree'),
216 down: press('down'),
217 up: press('up'),
218 left: press('left'),
219 right: press('right'),
220 }
221}
222
223const BY_KEY = new Map(Object.entries(HOTKEYS).map(([action, key]) => [key, action as Action]))
224
225// A key a surface module passed on: the pane's letters anywhere; in the explorer the arrows walk
226// the files, elsewhere they scroll the preview a row or a few columns at a time.
227async function pressKey($: EngineInterface, element: string, key: string) {
228 const action = BY_KEY.get(key.toLowerCase())
229 if (action) return actions($)[action]()
230 if (element === 'explorer' && (key === 'up' || key === 'down')) return move($, key === 'up' ? -1 : 1)
231 if (key === 'up' || key === 'down') return scrollBy($, 'preview', key === 'up' ? -1 : 1)
232 if (key === 'pageup') return actions($).up()
233 if (key === 'pagedown') return actions($).down()
234 if (key === 'left' || key === 'right') return scrollBy($, 'x', key === 'left' ? -4 : 4)
235 if (key === '[' || key === ']') return resizeBy($, key === '[' ? -2 : 2)
236 return undefined
237}
238
239// The explorer's width: a number the person chose (kept in the store once settled), or null for the default.
240async function resizeTo($: EngineInterface, width: number | null, isDone: boolean) {
241 const at = drawn.layout
242 const next = width === null || !at ? width : Math.max(TREE_MIN, Math.min(treeMax(at.width), Math.round(width)))
243 await update($, treeWidth, () => next)
244 if (!isDone) return
245 if (next === null) await $.store.delete(WIDTH_KEY).catch(() => undefined)
246 else await $.store.set(WIDTH_KEY, next).catch(() => undefined)
247}
248
249const resizeBy = ($: EngineInterface, by: number) => resizeTo($, (drawn.layout?.treeWidth ?? 28) + by, true)
250
251const isOpen = async ($: EngineInterface) => (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
252
253// /diffs toggles the pane; on and off say which; clear forgets what was tracked.
254async function command($: EngineInterface, args: string) {
255 const arg = args.trim().toLowerCase()
256 if (arg === 'clear') {
257 await update($, files, () => [])
258 await update($, selected, () => null)
259 await update($, scroll, () => ({ tree: 0, preview: 0, x: 0 }))
260 diffs.clear()
261 vrows.clear()
262 reads.clear()
263 return { text: 'Cleared. Files Claude changes from now on show up again.' }
264 }
265 if (arg === 'off' || arg === 'close' || (arg === '' && (await isOpen($)))) {
266 await $.ui.close({ id: PANE }).catch(() => undefined)
267 return { text: `Closed. /${session.command} opens it again.` }
268 }
269 const opened = await openPane($, true)
270 const count = (await read($, files)).length
271 if (!opened.isPlaced) return { text: `The pane could not be shown (${opened.reason}).` }
272 return { text: `${count} changed file${count === 1 ? '' : 's'}. n/p walk them, v swaps diff and file, w wraps, t toggles the tree.` }
273}
274
275export const register: Register = (on, options) => {
276 const icons: IconStyle = options.icons === 'plain' ? 'plain' : 'nerd'
277 const autoOpen = options.autoOpen !== false
278
279 on('session.start', async ($, e, next) => {
280 const r = await next(e)
281 session.home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
282 await update($, cwd, () => e.cwd)
283 const kept = await $.store.get(WIDTH_KEY).catch(() => undefined)
284 if (typeof kept === 'number') await update($, treeWidth, () => kept)
285 // /diffs, or /diff-explorer if a built-in or another plugin ever takes /diffs.
286 for (const name of ['diffs', 'diff-explorer']) {
287 const ok = await $.command
288 .register({ name, description: 'Show the files Claude changed, as a tree with their diffs', argumentHint: '[on | off | clear]' })
289 .then(() => true)
290 .catch(() => false)
291 if (ok) {
292 session.command = name
293 break
294 }
295 }
296 return r
297 })
298
299 // Reads what Claude writes, the main agent's and every subagent's; never blocks or changes the call.
300 on('tool.call', async ($, e, next) => {
301 if (!EDIT_TOOLS.has(e.tool)) return next(e)
302 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
303 const path = typeof input.file_path === 'string' ? input.file_path : input.notebook_path
304 if (typeof path !== 'string' || !path.startsWith('/')) return next(e)
305 const before = await readText($, path) // before the write lands
306 const r = await next(e)
307 if (r.deny !== undefined || r.isError === true) return r
308 await track($, path, before).catch(() => undefined)
309 await maybeAutoOpen($, autoOpen).catch(() => undefined)
310 return r
311 })
312
313 on('command.run', { command: 'diffs' }, async ($, e) => command($, e.args))
314 on('command.run', { command: 'diff-explorer' }, async ($, e) => command($, e.args))
315
316 // Only to learn whether the terminal docks panes, so an unasked open lands as a sidebar.
317 on('ui.render', { component: 'AbovePrompt' }, async (_$, e, next) => {
318 if (e.viewport?.isFullscreen !== undefined) session.isFullscreen = e.viewport.isFullscreen
319 return next(e)
320 })
321
322 // What the pane's surface modules post: a click on an explorer row, or where a scrollbar's drag
323 // or click wants its window to stand.
324 on('ui.message', { requestId: PANE }, async ($, e) => {
325 const data = (e.data ?? {}) as { press?: unknown; action?: unknown }
326 if (typeof data.press === 'string') {
327 await pressKey($, e.element, data.press)
328 return {}
329 }
330 if (typeof data.action === 'string' && data.action in HOTKEYS) {
331 await actions($)[data.action as Action]()
332 return {}
333 }
334 if (e.element === 'sash') {
335 const message = e.data as { width?: unknown; isDone?: unknown } | null
336 if (typeof message?.width === 'number' || message?.width === null) await resizeTo($, message.width, message.isDone === true)
337 return {}
338 }
339 if (e.element === 'explorer') {
340 const message = e.data as { type?: unknown; key?: unknown } | null
341 if (typeof message?.key !== 'string') return {}
342 if (message.type === 'pick') await pickFile($, message.key)
343 else if (message.type === 'toggle') await toggleDir($, message.key)
344 return {}
345 }
346 const region = BARS[e.element]
347 const message = e.data as { offset?: unknown } | null
348 if (region !== undefined && typeof message?.offset === 'number') await scrollTo($, region, message.offset)
349 return {}
350 })
351
352 // The wheel scrolls the column under it; the keys (no pointer) scroll the preview.
353 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
354 const at = drawn.layout
355 const isTree = at !== null && at.showTree && e.pointer !== undefined && e.pointer.column < at.treeWidth
356 await scrollBy($, isTree ? 'tree' : 'preview', e.by)
357 return {}
358 })
359
360 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
361 const els = $.ui.resolve(e)
362 if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') session.isFullscreen ??= true
363 const list = await read($, files)
364 const root = (await read($, cwd)) || '/'
365 const isWrapped = await read($, wrap)
366 const mode = await read($, view)
367 const at = layout(e.props.bodyColumns, e.props.scroll.bodyRows, await read($, tree), mode === 'file' || !isWrapped, await read($, treeWidth))
368 const offsets = await read($, scroll)
369
370 const known = await entriesOf($, list, root)
371 const nodes = buildTree(known.map(k => ({ rel: k.entry.rel, path: k.entry.path })))
372 const shut = new Set(await read($, collapsed))
373 const rows = treeRows(nodes, shut)
374 const dirs = treeRows(nodes, new Set()).flatMap(row => (row.kind === 'dir' ? [row.key] : []))
375 const order = fileOrder(nodes)
376 const want = await read($, selected)
377 const pick = want && order.includes(want) ? want : (order[order.length - 1] ?? null)
378 const chosen = known.find(k => k.entry.path === pick) ?? null
379
380 let preview: VRow[] = []
381 let code: { source: string; startLine: number } | null = null
382 let wide = { total: 0, view: at.previewInner }
383 if (chosen) {
384 const { entry, diff, after } = chosen
385 if (mode === 'file' && after !== null) {
386 const lines = after.split('\n').map(line => expandTabs(line))
387 const start = clamp(offsets.preview, 0, Math.max(0, lines.length - at.previewBody))
388 // The engine's gutter is the last line number and some room: an estimate is enough for the bar.
389 wide = { total: lines.reduce((n, l) => Math.max(n, textWidth(l)), 0), view: Math.max(1, at.previewInner - String(lines.length).length - 3) }
390 const x = clamp(offsets.x ?? 0, 0, Math.max(0, wide.total - wide.view))
391 const window = lines.slice(start, start + at.previewBody).map(line => dropCells(line, x))
392 code = { source: window.join('\n'), startLine: start + 1 }
393 drawn.previewRows = lines.length
394 } else if (entry.isTooBig) {
395 preview = [{ kind: 'note', text: ' Too large to diff (over 1 MB). v shows the file.', color: '#7d8590' }]
396 } else if (diff?.isBinary) {
397 preview = [{ kind: 'note', text: ' Binary file: no text diff.', color: '#7d8590' }]
398 } else if (entry.isSame) {
399 preview = [{ kind: 'note', text: ' No net changes: the file is back to how it was.', color: '#7d8590' }]
400 } else if (diff) {
401 preview = rowsOf(entry.path, diff, at.previewInner, isWrapped)
402 wide = { total: contentWidth(preview), view: lineRoom(preview, at.previewInner) }
403 }
404 if (code === null) drawn.previewRows = preview.length
405 } else {
406 drawn.previewRows = 0
407 }
408 drawn.layout = at
409 drawn.treeRows = rows.length
410 drawn.order = order
411 drawn.dirs = dirs
412 drawn.maxX = at.hasHbar ? Math.max(0, wide.total - wide.view) : 0
413 drawn.wideView = wide.view
414
415 const clamped = {
416 tree: clamp(offsets.tree, 0, Math.max(0, rows.length - at.treeBody)),
417 preview: clamp(offsets.preview, 0, Math.max(0, drawn.previewRows - at.previewBody)),
418 x: clamp(offsets.x ?? 0, 0, drawn.maxX),
419 }
420 return (
421 <Pane
422 m={{
423 els,
424 at,
425 icons,
426 entries: known.map(k => k.entry),
427 tree: rows,
428 canCollapse: dirs.some(d => !shut.has(d)),
429 previewTotal: drawn.previewRows,
430 wideTotal: wide.total,
431 wideView: wide.view,
432 selected: chosen?.entry ?? null,
433 position: pick ? order.indexOf(pick) + 1 : 0,
434 view: mode,
435 isWrapped,
436 rows: preview,
437 code,
438 scroll: clamped,
439 on: handlers($),
440 }}
441 />
442 )
443 })
444}
445hooks/diff.ts 188 lines1// Line diff (Myers), unified hunks with context, and the changed part of each paired line.
2
3export type Op = { kind: 'eq' | 'add' | 'del'; text: string; oldNo?: number; newNo?: number }
4
5export type Row =
6 | { kind: 'hunk'; oldStart: number; oldLines: number; newStart: number; newLines: number; skipped: number }
7 | { kind: 'eq' | 'add' | 'del'; text: string; oldNo?: number; newNo?: number; mark?: [number, number] }
8
9export type Diff = { rows: Row[]; adds: number; dels: number; isBinary: boolean }
10
11const CONTEXT = 3
12const MAX_EDIT = 1000 // past this many differing lines, the middle is shown as all removed then all added
13
14export const splitLines = (text: string) => {
15 if (text === '') return []
16 const lines = text.split(/\r?\n/)
17 if (lines[lines.length - 1] === '') lines.pop()
18 return lines
19}
20
21// Myers' O(ND) shortest edit script over the lines between a common head and tail.
22export function diffLines(a: string[], b: string[]): Op[] {
23 let head = 0
24 while (head < a.length && head < b.length && a[head] === b[head]) head++
25 let tail = 0
26 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
27
28 const midA = a.slice(head, a.length - tail)
29 const midB = b.slice(head, b.length - tail)
30 const mid = middle(midA, midB)
31
32 const ops: Op[] = []
33 for (let i = 0; i < head; i++) ops.push({ kind: 'eq', text: a[i]!, oldNo: i + 1, newNo: i + 1 })
34 let oldNo = head
35 let newNo = head
36 for (const op of mid) {
37 if (op.kind === 'eq') ops.push({ kind: 'eq', text: op.text, oldNo: ++oldNo, newNo: ++newNo })
38 else if (op.kind === 'del') ops.push({ kind: 'del', text: op.text, oldNo: ++oldNo })
39 else ops.push({ kind: 'add', text: op.text, newNo: ++newNo })
40 }
41 for (let i = a.length - tail; i < a.length; i++) ops.push({ kind: 'eq', text: a[i]!, oldNo: ++oldNo, newNo: ++newNo })
42 return ops
43}
44
45function middle(a: string[], b: string[]): Op[] {
46 const n = a.length
47 const m = b.length
48 if (n === 0) return b.map(text => ({ kind: 'add' as const, text }))
49 if (m === 0) return a.map(text => ({ kind: 'del' as const, text }))
50
51 const max = Math.min(n + m, MAX_EDIT)
52 const offset = max + 1
53 let v = new Int32Array(2 * max + 3)
54 const trace: Int32Array[] = []
55 let found = -1
56 for (let d = 0; d <= max && found < 0; d++) {
57 trace.push(v.slice())
58 for (let k = -d; k <= d; k += 2) {
59 const down = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!)
60 let x = down ? v[offset + k + 1]! : v[offset + k - 1]! + 1
61 let y = x - k
62 while (x < n && y < m && a[x] === b[y]) {
63 x++
64 y++
65 }
66 v[offset + k] = x
67 if (x >= n && y >= m) {
68 found = d
69 break
70 }
71 }
72 }
73 if (found < 0) {
74 return [...a.map(text => ({ kind: 'del' as const, text })), ...b.map(text => ({ kind: 'add' as const, text }))]
75 }
76
77 // Walk the trace back from (n, m), collecting the script in reverse.
78 const out: Op[] = []
79 let x = n
80 let y = m
81 for (let d = found; d > 0; d--) {
82 const prev = trace[d]!
83 const k = x - y
84 const down = k === -d || (k !== d && prev[offset + k - 1]! < prev[offset + k + 1]!)
85 const prevK = down ? k + 1 : k - 1
86 const prevX = prev[offset + prevK]!
87 const prevY = prevX - prevK
88 while (x > prevX && y > prevY) out.push({ kind: 'eq', text: a[--x]! }), y--
89 if (down) out.push({ kind: 'add', text: b[--y]! })
90 else out.push({ kind: 'del', text: a[--x]! })
91 }
92 while (x > 0 && y > 0) out.push({ kind: 'eq', text: a[--x]! }), y--
93 return out.reverse()
94}
95
96// The changed middle of a removed and an added line: what the two do not share at either end.
97export function changedRange(a: string, b: string): { a: [number, number]; b: [number, number] } | null {
98 if (a === b) return null
99 let head = 0
100 while (head < a.length && head < b.length && a[head] === b[head]) head++
101 let tail = 0
102 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
103 const shared = head + tail
104 // Lines that share almost nothing are told apart better without a mark.
105 if (shared < Math.min(a.length, b.length) * 0.3) return null
106 return { a: [head, a.length - tail], b: [head, b.length - tail] }
107}
108
109// Pairs each run of removed lines with the added run after it, line by line, and marks what changed.
110function markPairs(ops: Op[]): (Op & { mark?: [number, number] })[] {
111 const out: (Op & { mark?: [number, number] })[] = ops.map(op => ({ ...op }))
112 let i = 0
113 while (i < out.length) {
114 if (out[i]!.kind !== 'del') {
115 i++
116 continue
117 }
118 const delStart = i
119 while (i < out.length && out[i]!.kind === 'del') i++
120 const addStart = i
121 while (i < out.length && out[i]!.kind === 'add') i++
122 const pairs = Math.min(addStart - delStart, i - addStart)
123 for (let p = 0; p < pairs; p++) {
124 const del = out[delStart + p]!
125 const add = out[addStart + p]!
126 const range = changedRange(del.text, add.text)
127 if (range) {
128 out[delStart + p] = { ...del, mark: range.a }
129 out[addStart + p] = { ...add, mark: range.b }
130 }
131 }
132 }
133 return out
134}
135
136const looksBinary = (text: string) => text.slice(0, 8000).includes('\u0000')
137
138// Unified hunks of `before` → `after`, each with up to CONTEXT unchanged lines around it.
139export function unified(before: string, after: string): Diff {
140 if (looksBinary(before) || looksBinary(after)) return { rows: [], adds: 0, dels: 0, isBinary: true }
141 const ops = markPairs(diffLines(splitLines(before), splitLines(after)))
142 const adds = ops.filter(op => op.kind === 'add').length
143 const dels = ops.filter(op => op.kind === 'del').length
144
145 const rows: Row[] = []
146 const changed = ops.map((op, i) => (op.kind === 'eq' ? -1 : i)).filter(i => i >= 0)
147 if (changed.length === 0) return { rows, adds, dels, isBinary: false }
148
149 // Merge each change's window [i - CONTEXT, i + CONTEXT] into runs.
150 const spans: [number, number][] = []
151 for (const i of changed) {
152 const lo = Math.max(0, i - CONTEXT)
153 const hi = Math.min(ops.length - 1, i + CONTEXT)
154 const last = spans[spans.length - 1]
155 if (last && lo <= last[1] + 1) last[1] = Math.max(last[1], hi)
156 else spans.push([lo, hi])
157 }
158
159 let shownTo = 0
160 for (const [lo, hi] of spans) {
161 const slice = ops.slice(lo, hi + 1)
162 const firstOld = slice.find(op => op.oldNo !== undefined)?.oldNo
163 const firstNew = slice.find(op => op.newNo !== undefined)?.newNo
164 const oldLines = slice.filter(op => op.kind !== 'add').length
165 const newLines = slice.filter(op => op.kind !== 'del').length
166 rows.push({
167 kind: 'hunk',
168 oldStart: firstOld ?? lineBefore(ops, lo, 'oldNo'),
169 oldLines,
170 newStart: firstNew ?? lineBefore(ops, lo, 'newNo'),
171 newLines,
172 skipped: lo - shownTo,
173 })
174 for (const op of slice) rows.push(op)
175 shownTo = hi + 1
176 }
177 return { rows, adds, dels, isBinary: false }
178}
179
180// For a hunk with no line on one side (a pure insertion), the line it follows there.
181function lineBefore(ops: Op[], at: number, side: 'oldNo' | 'newNo') {
182 for (let i = at - 1; i >= 0; i--) {
183 const no = ops[i]![side]
184 if (no !== undefined) return no
185 }
186 return 0
187}
188hooks/icons.ts 147 lines1// File and folder icons in the manner of VS Code's Material Icon Theme: one glyph and color per kind.
2// `nerd` draws Nerd Font glyphs (seti, devicons, codicons); `plain` draws colored dots any font has.
3
4export type IconStyle = 'nerd' | 'plain'
5export type Icon = { glyph: string; color: string }
6
7type Kind = { nerd: string; color: string }
8
9const K = {
10 ts: { nerd: '\ue628', color: '#3178c6' },
11 tsx: { nerd: '\ue7ba', color: '#4fc3f7' },
12 js: { nerd: '\ue60c', color: '#ffca28' },
13 jsx: { nerd: '\ue7ba', color: '#ffca28' },
14 dts: { nerd: '\ue628', color: '#0288d1' },
15 json: { nerd: '\ue60b', color: '#f9a825' },
16 md: { nerd: '\ue609', color: '#42a5f5' },
17 py: { nerd: '\ue606', color: '#ffd43b' },
18 html: { nerd: '\ue60e', color: '#e44d26' },
19 css: { nerd: '\ue614', color: '#42a5f5' },
20 sass: { nerd: '\ue603', color: '#ec407a' },
21 go: { nerd: '\ue627', color: '#00acc1' },
22 rust: { nerd: '\ue68b', color: '#ff7043' },
23 java: { nerd: '\ue66d', color: '#f44336' },
24 kotlin: { nerd: '\ue634', color: '#7f52ff' },
25 swift: { nerd: '\ue699', color: '#fe5722' },
26 c: { nerd: '\ue649', color: '#0288d1' },
27 cpp: { nerd: '\ue646', color: '#0288d1' },
28 ruby: { nerd: '\ue605', color: '#e53935' },
29 php: { nerd: '\ue608', color: '#8892bf' },
30 lua: { nerd: '\ue620', color: '#42a5f5' },
31 dart: { nerd: '\ue64c', color: '#29b6f6' },
32 shell: { nerd: '\ue691', color: '#4caf50' },
33 yaml: { nerd: '\ue6a8', color: '#ff5252' },
34 toml: { nerd: '\ue615', color: '#9e9e9e' },
35 xml: { nerd: '\ue619', color: '#8bc34a' },
36 sql: { nerd: '\ue64d', color: '#ffca28' },
37 csv: { nerd: '\ue64a', color: '#8bc34a' },
38 graphql: { nerd: '\ue662', color: '#e535ab' },
39 prisma: { nerd: '\ue684', color: '#5c6bc0' },
40 vue: { nerd: '\ue6a0', color: '#41b883' },
41 svelte: { nerd: '\ue697', color: '#ff5722' },
42 image: { nerd: '\ue60d', color: '#26a69a' },
43 svg: { nerd: '\ue698', color: '#ffb300' },
44 pdf: { nerd: '\ue67d', color: '#ef5350' },
45 zip: { nerd: '\ue6aa', color: '#afb42b' },
46 font: { nerd: '\ue659', color: '#f44336' },
47 lock: { nerd: '\ue672', color: '#ffd54f' },
48 git: { nerd: '\ue65d', color: '#e64a19' },
49 docker: { nerd: '\ue650', color: '#0288d1' },
50 npm: { nerd: '\ue616', color: '#e53935' },
51 yarn: { nerd: '\ue6a7', color: '#2c8ebb' },
52 tsconfig: { nerd: '\ue69d', color: '#0288d1' },
53 eslint: { nerd: '\ue655', color: '#7e57c2' },
54 babel: { nerd: '\ue639', color: '#fdd835' },
55 webpack: { nerd: '\ue6a3', color: '#8ed6fb' },
56 make: { nerd: '\ue673', color: '#ef5350' },
57 license: { nerd: '\ue60a', color: '#ef5350' },
58 env: { nerd: '\ue615', color: '#fbc02d' },
59 config: { nerd: '\ue615', color: '#90a4ae' },
60 test: { nerd: '\u{f0668}', color: '#00bfa5' },
61 text: { nerd: '\ue64e', color: '#90a4ae' },
62} satisfies Record<string, Kind>
63
64const BY_EXT: Record<string, Kind> = {
65 ts: K.ts, mts: K.ts, cts: K.ts, tsx: K.tsx, js: K.js, mjs: K.js, cjs: K.js, jsx: K.jsx,
66 json: K.json, jsonc: K.json, json5: K.json, md: K.md, mdx: K.md, markdown: K.md,
67 py: K.py, pyi: K.py, html: K.html, htm: K.html, css: K.css, scss: K.sass, sass: K.sass, less: K.css,
68 go: K.go, rs: K.rust, java: K.java, kt: K.kotlin, kts: K.kotlin, swift: K.swift,
69 c: K.c, h: K.c, cc: K.cpp, cpp: K.cpp, cxx: K.cpp, hpp: K.cpp, rb: K.ruby, php: K.php,
70 lua: K.lua, dart: K.dart, sh: K.shell, bash: K.shell, zsh: K.shell, fish: K.shell,
71 yml: K.yaml, yaml: K.yaml, toml: K.toml, ini: K.config, conf: K.config, xml: K.xml,
72 sql: K.sql, csv: K.csv, tsv: K.csv, graphql: K.graphql, gql: K.graphql, prisma: K.prisma,
73 vue: K.vue, svelte: K.svelte, png: K.image, jpg: K.image, jpeg: K.image, gif: K.image,
74 webp: K.image, ico: K.image, svg: K.svg, pdf: K.pdf, zip: K.zip, gz: K.zip, tar: K.zip,
75 woff: K.font, woff2: K.font, ttf: K.font, otf: K.font, lock: K.lock, txt: K.text, log: K.text,
76}
77
78const BY_NAME: Record<string, Kind> = {
79 'package.json': K.npm, 'package-lock.json': K.npm, '.npmrc': K.npm,
80 'yarn.lock': K.yarn, 'pnpm-lock.yaml': K.lock, 'bun.lockb': K.lock,
81 'dockerfile': K.docker, 'docker-compose.yml': K.docker, 'docker-compose.yaml': K.docker,
82 '.gitignore': K.git, '.gitattributes': K.git, '.gitmodules': K.git,
83 'makefile': K.make, 'license': K.license, 'license.md': K.license,
84 '.babelrc': K.babel, 'babel.config.js': K.babel,
85}
86
87const FOLDER_COLORS: Record<string, string> = {
88 src: '#4caf50', lib: '#c0ca33', app: '#ef5350', components: '#ffc107', hooks: '#7e57c2',
89 test: '#00bfa5', tests: '#00bfa5', __tests__: '#00bfa5', spec: '#00bfa5', docs: '#0288d1',
90 scripts: '#ffa000', config: '#00bcd4', public: '#29b6f6', assets: '#ffb300', images: '#26a69a',
91 styles: '#ec407a', utils: '#ffca28', types: '#0288d1', api: '#ffb300', server: '#ff7043',
92 '.github': '#78909c', '.claude': '#d97757', '.claude-plugin': '#d97757', node_modules: '#8bc34a',
93}
94
95const FOLDER = '#90a4ae'
96
97function kindOf(name: string): Kind {
98 const low = name.toLowerCase()
99 if (BY_NAME[low]) return BY_NAME[low]!
100 if (/^tsconfig.*\.json$/.test(low)) return K.tsconfig
101 if (/^\.?eslint/.test(low)) return K.eslint
102 if (/^webpack\./.test(low)) return K.webpack
103 if (/^\.env(\.|$)/.test(low)) return K.env
104 if (/\.d\.ts$/.test(low)) return K.dts
105 if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(low)) return K.test
106 if (/(^|\.)(config|rc)\.[cm]?[jt]s$/.test(low) || /^\.\w+rc$/.test(low)) return K.config
107 const ext = low.includes('.') ? low.slice(low.lastIndexOf('.') + 1) : ''
108 return BY_EXT[ext] ?? K.text
109}
110
111export function fileIcon(name: string, style: IconStyle): Icon {
112 const kind = kindOf(name)
113 return { glyph: style === 'nerd' ? kind.nerd : '●', color: kind.color }
114}
115
116// The last part of a compacted name (`src/hooks`) picks the color.
117export function folderIcon(name: string, isOpen: boolean, style: IconStyle): Icon {
118 const last = name.slice(name.lastIndexOf('/') + 1).toLowerCase()
119 const color = FOLDER_COLORS[last] ?? FOLDER
120 if (style === 'plain') return { glyph: '■', color }
121 return { glyph: isOpen ? '\ue5fe' : '\ue5ff', color }
122}
123
124// Codicon chevrons in nerd mode, plain triangles otherwise.
125export const chevron = (isOpen: boolean, style: IconStyle) =>
126 style === 'nerd' ? (isOpen ? '\ueab4' : '\ueab6') : isOpen ? '▾' : '▸'
127
128// The pane's own controls, as codicons in nerd mode and plain symbols otherwise.
129const UI = {
130 sidebar: ['\uebf3', '◧'],
131 collapseAll: ['\ueac5', '⊟'],
132 expandAll: ['\ueb95', '⊞'],
133 prev: ['\ueab5', '‹'],
134 next: ['\ueab6', '›'],
135 up: ['\ueab7', '▲'],
136 down: ['\ueab4', '▼'],
137 left: ['\ueab5', '◀'],
138 right: ['\ueab6', '▶'],
139 wrap: ['\ueb80', '↩'],
140 diff: ['\ueae1', '±'],
141 file: ['\ueae9', '≡'],
142} as const
143
144export type UiGlyph = keyof typeof UI
145
146export const uiGlyph = (name: UiGlyph, style: IconStyle) => UI[name][style === 'nerd' ? 0 : 1]
147hooks/tree.ts 70 lines1// The changed files as a VS Code explorer tree: folders first, chains of lone folders compacted
2// into one row (`src/hooks`), each folder open unless the person collapsed it.
3
4export type TreeRow =
5 | { kind: 'dir'; key: string; name: string; depth: number; isOpen: boolean }
6 | { kind: 'file'; key: string; name: string; depth: number; path: string }
7
8type Node = { name: string; key: string; dirs: Map<string, Node>; files: { name: string; path: string }[] }
9
10const node = (name: string, key: string): Node => ({ name, key, dirs: new Map(), files: [] })
11
12// The path as the tree shows it: under the session's folder, under home as `~`, else from the root.
13export function relative(path: string, cwd: string, home: string): string {
14 const under = (root: string) => root && path.startsWith(root.replace(/\/$/, '') + '/')
15 if (under(cwd)) return path.slice(cwd.replace(/\/$/, '').length + 1)
16 if (under(home)) return '~/' + path.slice(home.replace(/\/$/, '').length + 1)
17 return path.replace(/^\/+/, '')
18}
19
20const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
21
22export function buildTree(entries: { rel: string; path: string }[]): Node {
23 const root = node('', '')
24 for (const { rel, path } of entries) {
25 const parts = rel.split('/').filter(Boolean)
26 const name = parts.pop() ?? rel
27 let at = root
28 for (const part of parts) {
29 const key = at.key ? `${at.key}/${part}` : part
30 if (!at.dirs.has(part)) at.dirs.set(part, node(part, key))
31 at = at.dirs.get(part)!
32 }
33 at.files.push({ name, path })
34 }
35 return root
36}
37
38// A folder holding one folder and no files takes its child's place, named `a/b`.
39function compact(dir: Node): Node {
40 let at = dir
41 let name = dir.name
42 while (at.files.length === 0 && at.dirs.size === 1) {
43 const [only] = at.dirs.values()
44 at = only!
45 name = `${name}/${at.name}`
46 }
47 return at === dir ? dir : { ...at, name }
48}
49
50export function treeRows(root: Node, collapsed: ReadonlySet<string>): TreeRow[] {
51 const rows: TreeRow[] = []
52 const walk = (dir: Node, depth: number) => {
53 for (const child of [...dir.dirs.values()].map(compact).sort(byName)) {
54 const isOpen = !collapsed.has(child.key)
55 rows.push({ kind: 'dir', key: child.key, name: child.name, depth, isOpen })
56 if (isOpen) walk(child, depth + 1)
57 }
58 for (const file of [...dir.files].sort(byName)) {
59 rows.push({ kind: 'file', key: file.path, name: file.name, depth, path: file.path })
60 }
61 }
62 walk(root, 0)
63 return rows
64}
65
66// The files in the order the tree lists them, collapsed folders included: what next and previous walk.
67export function fileOrder(root: Node): string[] {
68 return treeRows(root, new Set()).flatMap(row => (row.kind === 'file' ? [row.path] : []))
69}
70hooks/rows.tsx 132 lines1// The preview's rows in diff view: each diff line wrapped (or cut) to the room right of its gutter,
2// painted in GitHub's diff colors, the changed part of a paired line marked.
3import type { Elements } from 'claude-code'
4
5import type { Diff } from './diff'
6import { expandTabs, pad, padStart, segsWidth, sliceSegs, textWidth, truncate, wrapSegs } from './text'
7import type { Seg } from './text'
8import { C } from './theme'
9
10type Els = Pick<Elements['terminal'], 'Text'>
11
12// One row of the preview: a diff line (or its wrapped continuation), a hunk header, or a note.
13export type VRow =
14 | { kind: 'line'; type: 'eq' | 'add' | 'del'; nums: string; marker: string; segs: Seg[] }
15 | { kind: 'hunk'; text: string }
16 | { kind: 'note'; text: string; color: string }
17
18const TAB = 4
19
20
21// Tabs expanded across segments with one running column, so tab stops stay where they were.
22function expandSegs(segs: Seg[]): Seg[] {
23 let col = 0
24 return segs.map(seg => {
25 let text = ''
26 for (const ch of seg.text) {
27 if (ch === '\t') {
28 const n = TAB - (col % TAB)
29 text += ' '.repeat(n)
30 col += n
31 } else {
32 const one = expandTabs(ch)
33 text += one
34 col += textWidth(one)
35 }
36 }
37 return { text, isMarked: seg.isMarked }
38 })
39}
40
41function segsOf(text: string, mark?: [number, number]): Seg[] {
42 if (!mark) return expandSegs([{ text, isMarked: false }])
43 const [lo, hi] = mark
44 return expandSegs(
45 [
46 { text: text.slice(0, lo), isMarked: false },
47 { text: text.slice(lo, hi), isMarked: true },
48 { text: text.slice(hi), isMarked: false },
49 ].filter(s => s.text !== ''),
50 )
51}
52
53// The diff as rows `width` cells wide: each line wrapped to the room right of its gutter, or whole
54// (one row each) for the window to show a sideways slice of.
55export function diffRows(diff: Diff, width: number, isWrapped: boolean): VRow[] {
56 const maxNo = diff.rows.reduce((n, r) => (r.kind === 'hunk' ? n : Math.max(n, r.oldNo ?? 0, r.newNo ?? 0)), 0)
57 const gw = Math.max(2, String(maxNo).length)
58 const gutter = gw * 2 + 2
59 const room = Math.max(4, width - gutter - 2)
60 const out: VRow[] = []
61 for (const row of diff.rows) {
62 if (row.kind === 'hunk') {
63 const head = `@@ -${row.oldStart},${row.oldLines} +${row.newStart},${row.newLines} @@`
64 const skipped = row.skipped > 0 ? ` ⋯ ${row.skipped} unchanged line${row.skipped === 1 ? '' : 's'}` : ''
65 out.push({ kind: 'hunk', text: head + skipped })
66 continue
67 }
68 const nums = `${padStart(row.oldNo ? String(row.oldNo) : '', gw)} ${padStart(row.newNo ? String(row.newNo) : '', gw)} `
69 const marker = row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '
70 const segs = segsOf(row.text, row.mark)
71 const parts = isWrapped ? wrapSegs(segs, room) : [segs]
72 parts.forEach((part, i) => {
73 out.push({ kind: 'line', type: row.kind, nums: i === 0 ? nums : ' '.repeat(gutter), marker: i === 0 ? marker : ' ', segs: part })
74 })
75 }
76 return out
77}
78
79// The widest line's cells, right of the gutter: how far the window can scroll sideways.
80export const contentWidth = (rows: VRow[]) =>
81 rows.reduce((n, row) => (row.kind === 'line' ? Math.max(n, segsWidth(row.segs)) : n), 0)
82
83// The room right of the gutter a line row has in `width` cells.
84export const lineRoom = (rows: VRow[], width: number) => {
85 const line = rows.find(row => row.kind === 'line')
86 return line && line.kind === 'line' ? Math.max(4, width - textWidth(line.nums) - 2) : width
87}
88
89// One row, the window `x` cells into its line; a hunk header and a note never move.
90export function PreviewRow({ els, row, width, x = 0 }: { els: Els; row: VRow; width: number; x?: number }) {
91 const { Text } = els
92 if (row.kind === 'hunk') {
93 return (
94 <Text backgroundColor={C.hunkLine} color={C.hunkFg} wrap="truncate-end">
95 {pad(truncate(` ${row.text}`, width), width)}
96 </Text>
97 )
98 }
99 if (row.kind === 'note') {
100 return (
101 <Text backgroundColor={C.canvas} color={row.color} wrap="truncate-end">
102 {pad(truncate(row.text, width), width)}
103 </Text>
104 )
105 }
106 const lineBg = row.type === 'add' ? C.addLine : row.type === 'del' ? C.delLine : C.canvas
107 const numBg = row.type === 'add' ? C.addNum : row.type === 'del' ? C.delNum : C.canvas
108 const wordBg = row.type === 'add' ? C.addWord : C.delWord
109 const markerFg = row.type === 'add' ? C.addFg : row.type === 'del' ? C.delFg : C.muted
110 const numFg = row.type === 'eq' ? C.subtle : C.muted
111 const room = Math.max(0, width - textWidth(row.nums) - 2)
112 const segs = sliceSegs(row.segs, x, room)
113 const used = textWidth(row.nums) + 2 + segsWidth(segs)
114 return (
115 <Text wrap="truncate-end">
116 <Text backgroundColor={numBg} color={numFg}>
117 {row.nums}
118 </Text>
119 <Text backgroundColor={lineBg} color={markerFg}>
120 {row.marker + ' '}
121 </Text>
122 {segs.map(seg => (
123 <Text backgroundColor={seg.isMarked ? wordBg : lineBg} color={C.fg}>
124 {seg.text}
125 </Text>
126 ))}
127 <Text backgroundColor={lineBg}>{' '.repeat(Math.max(0, width - used))}</Text>
128 </Text>
129 )
130}
131
132hooks/chrome.tsx 201 lines1// The pane's chrome rows: the toolbar, the explorer's title, the file's header and the status row.
2// Each is one row of items (text, controls, spacers), drawn by the controls surface module where
3// the surface draws a Client, else as Text and plain Buttons (which keep their hotkeys).
4import { cellsOf, spans } from './controls'
5import type { ControlsProps, Item, Part } from './controls'
6import { fileIcon, uiGlyph } from './icons'
7import type { UiGlyph } from './icons'
8import { truncate, truncateStart } from './text'
9import { C } from './theme'
10import type { Handlers, PaneModel } from './view'
11
12export type Action = Exclude<keyof Handlers, 'pick' | 'toggle'>
13
14// The pane's keys, for a Button's hotkey and for keys a surface module passes on.
15export const HOTKEYS: Record<Action, string> = {
16 tree: 't',
17 collapseAll: 'c',
18 prev: 'p',
19 next: 'n',
20 view: 'v',
21 wrap: 'w',
22 up: 'k',
23 down: 'j',
24 left: 'h',
25 right: 'l',
26}
27
28const HINT = '#6e7681' // a control's key, drawn quiet beside its label
29
30const text = (parts: Part[]): Item => ({ parts })
31const spacer: Item = { spacer: true }
32
33// A control, padded a cell each side so the pointer's background reads as a button.
34function control(m: PaneModel, action: Action, glyph: UiGlyph, label?: string, isOn = false): Item {
35 const hint: Part[] = label ? [{ text: ` ${HOTKEYS[action]}`, color: HINT }] : []
36 return { action, isOn, parts: [{ text: ` ${uiGlyph(glyph, m.icons)}${label ? ` ${label}` : ''}` }, ...hint, { text: ' ' }] }
37}
38
39const width = (items: Item[]) => items.reduce((n, item) => n + ('spacer' in item ? 0 : item.parts.reduce((k, p) => k + cellsOf(p.text), 0)), 0)
40
41function ChromeRow({ m, id, w, bg, items }: { m: PaneModel; id: string; w: number; bg: string; items: Item[] }) {
42 const { Box, Text, Button, Client } = m.els
43 const props: ControlsProps = {
44 width: w,
45 bg,
46 items,
47 colors: { hover: C.controlHover, pressed: C.controlPressed, on: C.accent, fg: C.fg },
48 }
49 if (Client) return <Client key={id} module="./controls.tsx" props={props} width={w} height={1} />
50 const layout = spans(items, w)
51 const paint = (parts: Part[]) =>
52 parts.map(part => (
53 <Text color={part.color ?? C.fg} backgroundColor={part.bg ?? bg} weight={part.bold ? 'bold' : undefined} strikethrough={part.strike}>
54 {part.text}
55 </Text>
56 ))
57 return (
58 <Box flexDirection="row" width={w} height={1} overflow="hidden" backgroundColor={bg}>
59 {items.map((item, i) => {
60 const [a, b] = layout[i]!
61 if ('spacer' in item) return <Text backgroundColor={bg}>{' '.repeat(b - a)}</Text>
62 if (!item.action) return <Text backgroundColor={bg}>{paint(item.parts)}</Text>
63 const action = item.action as Action
64 return (
65 <Button key={action} plain hotkey={HOTKEYS[action]} onPress={() => m.on[action]()}>
66 {paint(item.parts)}
67 </Button>
68 )
69 })}
70 </Box>
71 )
72}
73
74export function Toolbar({ m }: { m: PaneModel }) {
75 const { Box, Text } = m.els
76 const adds = m.entries.reduce((n, e) => n + e.adds, 0)
77 const dels = m.entries.reduce((n, e) => n + e.dels, 0)
78 const count = `${m.entries.length} file${m.entries.length === 1 ? '' : 's'}`
79 const isTight = m.at.width < 56
80 const items: Item[] = [
81 text([{ text: ' ' }]),
82 control(m, 'tree', 'sidebar', isTight ? undefined : 'Explorer', m.at.showTree),
83 text([
84 { text: ' │ ', color: C.subtle },
85 { text: 'CHANGES', color: C.fg, bold: true },
86 ...(isTight
87 ? []
88 : [
89 { text: ` ${count} `, color: C.muted },
90 { text: `+${adds} `, color: C.addFg },
91 { text: `−${dels}`, color: C.delFg },
92 ]),
93 ]),
94 spacer,
95 ]
96 return (
97 <Box flexDirection="column" width={m.at.width}>
98 <ChromeRow m={m} id="toolbar" w={m.at.width} bg={C.header} items={items} />
99 <Text color={C.border} backgroundColor={C.canvas}>
100 {'─'.repeat(m.at.width)}
101 </Text>
102 </Box>
103 )
104}
105
106export function ExplorerHead({ m }: { m: PaneModel }) {
107 const items: Item[] = [
108 text([{ text: ' EXPLORER', color: C.muted, bold: true }]),
109 spacer,
110 control(m, 'collapseAll', m.canCollapse ? 'collapseAll' : 'expandAll'),
111 ]
112 return <ChromeRow m={m} id="explorer-head" w={m.at.treeWidth} bg={C.sidebar} items={items} />
113}
114
115// A GitHub diffstat: five blocks split between added and removed.
116function diffstat(adds: number, dels: number) {
117 const total = adds + dels
118 if (total === 0) return { green: 0, red: 0, grey: 5 }
119 const green = Math.round((adds / total) * 5)
120 return { green, red: 5 - green, grey: 0 }
121}
122
123export function FileHead({ m }: { m: PaneModel }) {
124 const { Box, Text } = m.els
125 const w = m.at.previewWidth
126 const sel = m.selected!
127 const icon = fileIcon(sel.name, m.icons)
128 const stat = diffstat(sel.adds, sel.dels)
129 const right: Item[] = [
130 text([
131 { text: `+${sel.adds} `, color: C.addFg },
132 { text: `−${sel.dels} `, color: C.delFg },
133 { text: '■'.repeat(stat.green), color: C.addFg },
134 { text: '■'.repeat(stat.red), color: C.delFg },
135 { text: '■'.repeat(stat.grey), color: C.subtle },
136 { text: ' ' },
137 ]),
138 control(m, 'prev', 'prev'),
139 control(m, 'next', 'next'),
140 text([{ text: ` ${m.position}/${m.entries.length} `, color: C.muted }]),
141 ]
142 const badge: Part[] =
143 sel.status === 'A'
144 ? [{ text: ' ' }, { text: ' NEW ', color: C.canvas, bg: C.addFg, bold: true }]
145 : sel.status === 'D'
146 ? [{ text: ' ' }, { text: ' DELETED ', color: C.canvas, bg: C.delFg, bold: true }]
147 : []
148 const lead = ` ${icon.glyph} `
149 const room = w - width(right) - cellsOf(lead) - badge.reduce((n, p) => n + cellsOf(p.text), 0) - 1
150 const crumbs = truncateStart(sel.rel.split('/').join(' › '), Math.max(4, room))
151 const dir = crumbs.includes(' › ') ? crumbs.slice(0, crumbs.lastIndexOf(' › ') + 3) : ''
152 const items: Item[] = [
153 text([
154 { text: lead, color: icon.color },
155 { text: dir, color: C.muted },
156 { text: crumbs.slice(dir.length), color: C.fg, bold: true },
157 ...badge,
158 ]),
159 spacer,
160 ...right,
161 ]
162 return (
163 <Box flexDirection="column" width={w}>
164 <ChromeRow m={m} id="file-head" w={w} bg={C.canvas} items={items} />
165 <Text color={C.border} backgroundColor={C.canvas}>
166 {'─'.repeat(w)}
167 </Text>
168 </Box>
169 )
170}
171
172// The status row: where the window stands, and the view, wrap and scroll controls.
173export function StatusRow({ m }: { m: PaneModel }) {
174 const w = m.at.previewWidth
175 const isTight = w < 72
176 const isDiff = m.view === 'diff'
177 const segment = (glyph: UiGlyph, label: string, isOn: boolean): Part =>
178 isOn
179 ? { text: ` ${uiGlyph(glyph, m.icons)} ${label} `, color: C.fg, bg: C.segment, bold: true }
180 : { text: ` ${uiGlyph(glyph, m.icons)} ${label} `, color: C.muted }
181 const controls: Item[] = [
182 { action: 'view', parts: [{ text: ' ' }, segment('diff', 'Diff', isDiff), segment('file', 'File', !isDiff), { text: ' v', color: HINT }, { text: ' ' }] },
183 control(m, 'wrap', 'wrap', isTight ? undefined : 'Wrap', m.isWrapped),
184 text([{ text: ' ' }]),
185 control(m, 'up', 'up'),
186 control(m, 'down', 'down'),
187 ...(m.at.hasHbar ? [control(m, 'left', 'left'), control(m, 'right', 'right')] : []),
188 text([{ text: ' ' }]),
189 ]
190 const first = m.previewTotal === 0 ? 0 : m.scroll.preview + 1
191 const last = Math.min(m.previewTotal, m.scroll.preview + m.at.previewBody)
192 const unit = isDiff ? 'rows' : 'lines'
193 const cols =
194 m.at.hasHbar && m.wideTotal > m.wideView
195 ? ` · col ${m.scroll.x + 1}–${Math.min(m.wideTotal, m.scroll.x + m.wideView)}/${m.wideTotal}`
196 : ''
197 const where = isTight ? ` ${first}–${last}/${m.previewTotal}${cols}` : ` ${unit} ${first}–${last}/${m.previewTotal}${cols}`
198 const items: Item[] = [text([{ text: truncate(where, Math.max(0, w - width(controls))), color: C.muted }]), spacer, ...controls]
199 return <ChromeRow m={m} id="status" w={w} bg={C.header} items={items} />
200}
201hooks/text.ts 146 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('')
146hooks/view.tsx 349 lines1// What the pane draws: a toolbar, the explorer on the left and the preview on the right, each
2// column a window of its own over its rows, so the two scroll apart as in VS Code. The chrome rows
3// and their controls are in chrome.tsx; this file lays the columns out.
4import type { Elements } from 'claude-code'
5
6import { ExplorerHead, FileHead, StatusRow, Toolbar } from './chrome'
7import { chevron, fileIcon, folderIcon } from './icons'
8import type { IconStyle } from './icons'
9import { PreviewRow } from './rows'
10import type { VRow } from './rows'
11import { thumbOf } from './scrollbar'
12import type { BarProps } from './scrollbar'
13import type { SashProps } from './sash'
14import { pad, textWidth, truncate } from './text'
15import { C } from './theme'
16import type { TreeRow } from './tree'
17import type { TreeItem, TreeProps } from './tree-client'
18
19export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Code'> & {
20 Client?: Elements['terminal']['Client'] // the terminal and desktop draw one; the others fall back to Buttons
21}
22
23export type Status = 'A' | 'M' | 'D'
24
25export type Entry = {
26 path: string
27 rel: string
28 name: string
29 status: Status
30 adds: number
31 dels: number
32 isSame: boolean // back to how it was
33 isTooBig: boolean
34}
35
36export type Layout = {
37 width: number
38 height: number
39 showTree: boolean
40 treeWidth: number
41 previewWidth: number
42 treeBody: number // rows the tree's window shows
43 previewBody: number // rows the preview's window shows
44 treeInner: number // the tree's columns left of its scrollbar
45 previewInner: number // the preview's columns left of its scrollbar
46 hasHbar: boolean // lines are not wrapped, so the preview scrolls sideways and shows a bar for it
47}
48
49export type Handlers = {
50 pick: (path: string) => void
51 toggle: (dirKey: string) => void
52 collapseAll: () => void
53 next: () => void
54 prev: () => void
55 view: () => void
56 wrap: () => void
57 tree: () => void
58 down: () => void
59 up: () => void
60 left: () => void
61 right: () => void
62}
63
64const TOOLBAR_ROWS = 2 // the toolbar and the rule under it
65const TREE_HEAD = 1 // the explorer's title row
66const PREVIEW_HEAD = 2 // the file's header and the rule under it
67const PREVIEW_FOOT = 1 // the status row under the preview
68const TREE_MIN_PANE = 72 // narrower panes hide the tree unless it is forced on
69export const TREE_MIN = 16 // the narrowest the explorer can be dragged
70const PREVIEW_MIN = 32 // the room a drag leaves the preview
71
72// The widest the explorer may be in a pane `width` cells across.
73export const treeMax = (width: number) => Math.max(TREE_MIN, width - 1 - PREVIEW_MIN)
74
75// `pref` is the width the person dragged the explorer to; null sizes it to the pane.
76export function layout(width: number, height: number, tree: 'auto' | 'shown' | 'hidden', hasHbar: boolean, pref: number | null = null): Layout {
77 const showTree = tree === 'shown' || (tree === 'auto' && width >= TREE_MIN_PANE)
78 const auto = Math.max(20, Math.min(36, Math.round(width * 0.32)))
79 const treeWidth = showTree ? Math.max(TREE_MIN, Math.min(treeMax(width), pref ?? auto)) : 0
80 const previewWidth = showTree ? width - treeWidth - 1 : width
81 const body = Math.max(1, height - TOOLBAR_ROWS)
82 return {
83 width,
84 height,
85 showTree,
86 treeWidth,
87 previewWidth,
88 treeBody: Math.max(1, body - TREE_HEAD),
89 previewBody: Math.max(1, body - PREVIEW_HEAD - PREVIEW_FOOT - (hasHbar ? 1 : 0)),
90 treeInner: Math.max(1, treeWidth - 1),
91 previewInner: Math.max(1, previewWidth - 1),
92 hasHbar,
93 }
94}
95
96export const statusColor = (s: Status) => (s === 'A' ? C.gitAdded : s === 'D' ? C.gitDeleted : C.gitModified)
97
98export type PaneModel = {
99 els: Els
100 at: Layout
101 icons: IconStyle
102 entries: Entry[]
103 tree: TreeRow[]
104 canCollapse: boolean // some folder is open, so collapse all would close it
105 selected: Entry | null
106 position: number // the selected file's place among the files, 1-based
107 view: 'diff' | 'file'
108 isWrapped: boolean
109 rows: VRow[] // the preview's rows in diff view
110 code: { source: string; startLine: number } | null // the preview's window in file view
111 previewTotal: number // rows (diff) or lines (file) the preview holds
112 wideTotal: number // the widest line's cells, for the sideways bar
113 wideView: number // the cells of a line the window shows
114 scroll: { tree: number; preview: number; x: number }
115 on: Handlers
116}
117
118const BAR_COLORS = { track: C.track, thumb: C.thumb, active: C.thumbActive, bg: C.canvas }
119
120// A scrollbar: a Client the pointer drags where the surface draws one, else a picture of one.
121function Bar({ m, id, axis, length, total, view, offset, bg }: {
122 m: PaneModel
123 id: 'vbar' | 'hbar' | 'tbar'
124 axis: 'v' | 'h'
125 length: number
126 total: number
127 view: number
128 offset: number
129 bg: string
130}) {
131 const { Box, Text, Client } = m.els
132 const props: BarProps = { axis, length, total, view, offset, colors: { ...BAR_COLORS, bg } }
133 if (Client) {
134 return axis === 'v' ? (
135 <Client key={id} module="./scrollbar.tsx" props={props} width={1} height={length} />
136 ) : (
137 <Client key={id} module="./scrollbar.tsx" props={props} width={length} height={1} />
138 )
139 }
140 const { start, size, max } = thumbOf(props)
141 const isThumb = (i: number) => max > 0 && i >= start && i < start + size
142 if (axis === 'v') {
143 return (
144 <Box flexDirection="column" width={1}>
145 {Array.from({ length }, (_, i) => (
146 <Text color={isThumb(i) ? C.thumb : C.track} backgroundColor={bg}>
147 {isThumb(i) ? '┃' : '│'}
148 </Text>
149 ))}
150 </Box>
151 )
152 }
153 return (
154 <Text backgroundColor={bg}>
155 {Array.from({ length }, (_, i) => (
156 <Text color={isThumb(i) ? C.thumb : C.track}>{isThumb(i) ? '━' : '─'}</Text>
157 ))}
158 </Text>
159 )
160}
161
162function treeItems(m: PaneModel, rows: TreeRow[], width: number): TreeItem[] {
163 const byPath = new Map(m.entries.map(e => [e.path, e]))
164 return rows.map(row => {
165 const lead = ' '.repeat(1 + row.depth * 2) + (row.kind === 'dir' ? `${chevron(row.isOpen, m.icons)} ` : ' ')
166 if (row.kind === 'dir') {
167 const icon = folderIcon(row.name, row.isOpen, m.icons)
168 return {
169 key: row.key,
170 kind: 'dir',
171 lead,
172 glyph: icon.glyph,
173 glyphColor: icon.color,
174 name: truncate(row.name, width - textWidth(lead) - 3),
175 nameColor: C.fg,
176 status: '',
177 statusColor: C.muted,
178 isSelected: false,
179 isStruck: false,
180 }
181 }
182 const entry = byPath.get(row.path)
183 const status = entry?.status ?? 'M'
184 const icon = fileIcon(row.name, m.icons)
185 const color = entry?.isSame ? C.subtle : statusColor(status)
186 return {
187 key: row.path,
188 kind: 'file',
189 lead,
190 glyph: icon.glyph,
191 glyphColor: icon.color,
192 name: truncate(row.name, width - textWidth(lead) - 7),
193 nameColor: color,
194 status: ` ${status} `,
195 statusColor: color,
196 isSelected: m.selected?.path === row.path,
197 isStruck: status === 'D',
198 }
199 })
200}
201
202// Where no Client is drawn (VS Code, mobile): each row a plain Button.
203function TreeButtons({ m, items, width }: { m: PaneModel; items: TreeItem[]; width: number }) {
204 const { Button, Text } = m.els
205 return (
206 <>
207 {items.map(item => {
208 const bg = item.isSelected ? C.selected : C.sidebar
209 const used = textWidth(item.lead) + 1 + textWidth(item.name) + textWidth(item.status)
210 const press = () => (item.kind === 'dir' ? m.on.toggle(item.key) : m.on.pick(item.key))
211 return (
212 <Button key={`${item.kind}:${item.key}`} plain onPress={press}>
213 <Text backgroundColor={bg} color={C.muted}>
214 {item.lead}
215 </Text>
216 <Text backgroundColor={bg} color={item.glyphColor}>
217 {item.glyph}
218 </Text>
219 <Text backgroundColor={bg} color={item.nameColor} strikethrough={item.isStruck}>
220 {` ${item.name}${' '.repeat(Math.max(1, width - used - 1))}`}
221 </Text>
222 <Text backgroundColor={bg} color={item.statusColor}>
223 {item.status}
224 </Text>
225 </Button>
226 )
227 })}
228 </>
229 )
230}
231
232function TreeColumn({ m }: { m: PaneModel }) {
233 const { Box, Text, Client } = m.els
234 const w = m.at.treeWidth
235 const shown = m.tree.slice(m.scroll.tree, m.scroll.tree + m.at.treeBody)
236 const items = treeItems(m, shown, m.at.treeInner)
237 const props: TreeProps = {
238 width: m.at.treeInner,
239 rows: items,
240 colors: { bg: C.sidebar, hover: C.hover, selected: C.selected, accent: C.accent },
241 }
242 return (
243 <Box flexDirection="column" width={w} height={m.at.treeBody + TREE_HEAD} overflow="hidden" backgroundColor={C.sidebar}>
244 <ExplorerHead m={m} />
245 <Box flexDirection="row" width={w} height={m.at.treeBody} backgroundColor={C.sidebar}>
246 <Box flexDirection="column" width={m.at.treeInner} height={m.at.treeBody} backgroundColor={C.sidebar}>
247 {Client ? (
248 <Client key="explorer" module="./tree-client.tsx" props={props} width={m.at.treeInner} height={m.at.treeBody} />
249 ) : (
250 <TreeButtons m={m} items={items} width={m.at.treeInner} />
251 )}
252 </Box>
253 <Bar m={m} id="tbar" axis="v" length={m.at.treeBody} total={m.tree.length} view={m.at.treeBody} offset={m.scroll.tree} bg={C.sidebar} />
254 </Box>
255 </Box>
256 )
257}
258
259// The sash between the columns: a Client the pointer drags where the surface draws one, else a line.
260function Divider({ m }: { m: PaneModel }) {
261 const { Box, Text, Client } = m.els
262 const height = m.at.treeBody + TREE_HEAD
263 if (Client) {
264 const props: SashProps = {
265 height,
266 treeWidth: m.at.treeWidth,
267 min: TREE_MIN,
268 max: treeMax(m.at.width),
269 colors: { line: C.border, hot: C.accent, bg: C.canvas },
270 }
271 return <Client key="sash" module="./sash.tsx" props={props} width={1} height={height} />
272 }
273 return (
274 <Box flexDirection="column" width={1}>
275 {Array.from({ length: height }, () => (
276 <Text color={C.border} backgroundColor={C.canvas}>
277 │
278 </Text>
279 ))}
280 </Box>
281 )
282}
283
284function PreviewColumn({ m }: { m: PaneModel }) {
285 const { Box, Text, Code } = m.els
286 const w = m.at.previewWidth
287 const tall = m.at.previewBody + PREVIEW_HEAD + PREVIEW_FOOT + (m.at.hasHbar ? 1 : 0)
288 if (!m.selected) {
289 const lines = ['', ' No changes yet.', '', ' Files Claude edits this session', ' show up here, with their diff.']
290 return (
291 <Box flexDirection="column" width={w} height={tall} backgroundColor={C.canvas}>
292 {lines.map((line, i) => (
293 <Text backgroundColor={C.canvas} color={i === 1 ? C.fg : C.muted}>
294 {pad(line, w)}
295 </Text>
296 ))}
297 </Box>
298 )
299 }
300 const inner = m.at.previewInner
301 const content =
302 m.view === 'file' && m.code ? (
303 <Box width={inner} height={m.at.previewBody} overflow="hidden" backgroundColor={C.canvas}>
304 <Code source={m.code.source || ' '} path={m.selected.path} startLine={m.code.startLine} wrap="truncate-end" />
305 </Box>
306 ) : (
307 <Box flexDirection="column" width={inner} height={m.at.previewBody} overflow="hidden" backgroundColor={C.canvas}>
308 {m.rows.slice(m.scroll.preview, m.scroll.preview + m.at.previewBody).map(row => (
309 <PreviewRow els={m.els} row={row} width={inner} x={m.at.hasHbar ? m.scroll.x : 0} />
310 ))}
311 </Box>
312 )
313 const body = (
314 <Box flexDirection="column" width={w} backgroundColor={C.canvas}>
315 <Box flexDirection="row" width={w} height={m.at.previewBody} backgroundColor={C.canvas}>
316 {content}
317 <Bar m={m} id="vbar" axis="v" length={m.at.previewBody} total={m.previewTotal} view={m.at.previewBody} offset={m.scroll.preview} bg={C.canvas} />
318 </Box>
319 {m.at.hasHbar && (
320 <Box flexDirection="row" width={w} height={1} backgroundColor={C.canvas}>
321 <Bar m={m} id="hbar" axis="h" length={inner} total={m.wideTotal} view={m.wideView} offset={m.scroll.x} bg={C.canvas} />
322 <Text backgroundColor={C.canvas}> </Text>
323 </Box>
324 )}
325 </Box>
326 )
327 return (
328 <Box flexDirection="column" width={w} height={tall} overflow="hidden" backgroundColor={C.canvas}>
329 <FileHead m={m} />
330 {body}
331 <StatusRow m={m} />
332 </Box>
333 )
334}
335
336export function Pane({ m }: { m: PaneModel }) {
337 const { Box } = m.els
338 return (
339 <Box flexDirection="column" width={m.at.width} height={m.at.height} overflow="hidden" backgroundColor={C.canvas}>
340 <Toolbar m={m} />
341 <Box flexDirection="row" width={m.at.width} overflow="hidden" backgroundColor={C.canvas}>
342 {m.at.showTree && <TreeColumn m={m} />}
343 {m.at.showTree && <Divider m={m} />}
344 <PreviewColumn m={m} />
345 </Box>
346 </Box>
347 )
348}
349hooks/theme.ts 37 lines1// GitHub's dark theme (Primer dark default). Translucent diff colors are flattened onto the canvas,
2// since a terminal cell takes one solid color.
3export const C = {
4 canvas: '#0d1117',
5 sidebar: '#010409',
6 border: '#30363d',
7 fg: '#e6edf3',
8 muted: '#7d8590',
9 subtle: '#484f58',
10 accent: '#2f81f7',
11 selected: '#21262d', // a row picked in the explorer, beside the accent bar
12 hover: '#161b22', // the row under the pointer
13 segment: '#1c3a5e', // the active half of the Diff | File switch
14 header: '#161b22',
15 controlHover: '#262c36', // a control under the pointer
16 controlPressed: '#30363d', // a control while pressed
17 track: '#21262d', // a scrollbar's rail
18 thumb: '#484f58', // its thumb at rest
19 thumbActive: '#8b949e', // under the pointer or dragged
20
21 addLine: '#12261e',
22 addNum: '#1c4428',
23 addWord: '#1f6034',
24 addFg: '#3fb950',
25 delLine: '#25171c',
26 delNum: '#542426',
27 delWord: '#7d2a2e',
28 delFg: '#f85149',
29 hunkLine: '#111d2e',
30 hunkFg: '#8b949e',
31
32 // VS Code's git decorations, for names in the tree.
33 gitModified: '#e2c08d',
34 gitAdded: '#73c991',
35 gitDeleted: '#c74e39',
36} as const
37hooks/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/sash.tsx 69 lines1// The line between the explorer and the preview, drawn by a surface module so it can be dragged
2// as VS Code's sash is: it lights in the accent color under the pointer, a drag posts the explorer's
3// new width, a double click puts it back to the default, and once clicked ← → resize it too.
4import type { ClientModule } from 'claude-code'
5
6export type SashProps = {
7 height: number
8 treeWidth: number // the explorer's width now, the sash standing just right of it
9 min: number
10 max: number
11 colors: { line: string; hot: string; bg: string }
12}
13
14// `width: null` puts the default back; `isDone` marks the end of a drag, the width to keep.
15export type SashMessage = { width: number | null; isDone: boolean } | { press: string }
16
17// `lastClick`: when the last press that did not move ended, for telling a double click.
18type State = { isHot: boolean; isDragging: boolean; hasMoved: boolean; lastClick: number }
19
20const DOUBLE_CLICK_MS = 400
21const KEY_STEP = 2
22
23const Sash: ClientModule<SashProps, State> = (props, surface) => {
24 const { Box, Text } = surface.elements
25 const state = surface.state ?? { isHot: false, isDragging: false, hasMoved: false, lastClick: 0 }
26 const clamp = (n: number) => Math.max(props.min, Math.min(props.max, n))
27 const send = (width: number | null, isDone: boolean) => surface.post({ width, isDone } satisfies SashMessage)
28
29 surface.onPointer(event => {
30 if (event.type === 'enter' && !state.isHot) surface.setState({ ...state, isHot: true })
31 else if (event.type === 'leave' && !state.isDragging && state.isHot) surface.setState({ ...state, isHot: false })
32 else if (event.type === 'down' && event.button === 'left') surface.setState({ ...state, isHot: true, isDragging: true, hasMoved: false })
33 else if (event.type === 'move' && state.isDragging) {
34 // The region sits where the explorer ends, so the pointer's column is how far to move it.
35 const next = clamp(props.treeWidth + event.x)
36 if (next !== props.treeWidth) {
37 if (!state.hasMoved) surface.setState({ ...state, hasMoved: true })
38 send(next, false)
39 }
40 } else if (event.type === 'up' && state.isDragging) {
41 const now = Date.now()
42 const isClick = !state.hasMoved
43 const isDouble = isClick && now - state.lastClick < DOUBLE_CLICK_MS
44 surface.setState({ isHot: event.x === 0, isDragging: false, hasMoved: false, lastClick: isClick && !isDouble ? now : 0 })
45 if (isDouble) send(null, true)
46 else if (!isClick) send(props.treeWidth, true)
47 }
48 })
49
50 surface.onKey(event => {
51 if (event.key === 'left') send(clamp(props.treeWidth - KEY_STEP), true)
52 else if (event.key === 'right') send(clamp(props.treeWidth + KEY_STEP), true)
53 else if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies SashMessage)
54 })
55
56 const isLit = state.isHot || state.isDragging
57 return (
58 <Box flexDirection="column" width={1}>
59 {Array.from({ length: props.height }, () => (
60 <Text color={isLit ? props.colors.hot : props.colors.line} backgroundColor={props.colors.bg}>
61 {isLit ? '┃' : '│'}
62 </Text>
63 ))}
64 </Box>
65 )
66}
67
68export default Sash
69