Watch Claude work: a gitlogue-style pane that replays its edits as live typing, reads code with a highlighter, searches, browses, runs commands and commits…

Watch Claude work. A Claude Code mod that opens a pane beside the conversation and replays everything Claude does the way gitlogue replays a commit: a file tree, an editor that types each change at a human pace, the turn's details and a terminal. The conversation itself moves into a phone-style chat on the pane's left.
At the prompt of a Claude Code session in a terminal:
/plugin install gitlogue --marketplace abyss464/claude-gitlogue
Answer y to add the marketplace, then pick a scope. The pane opens by itself when a session starts in a terminal 144 columns or wider; /gitlogue shows or hides it at any width.
Each of Claude's actions plays as a person at a desk would do it:
| Claude | The pane |
|---|---|
| edits or writes a file | walks the file tree (or the Quick Open palette) to the file and types the change; a small change to a line is retyped in place, not deleted and rewritten |
| reads a file | opens it and sweeps a highlighter over the lines read |
runs grep, rg, git grep and the like | types the query into a search sidebar and lists the matching files and lines |
runs fd or find | types the pattern into the Quick Open palette |
| runs a command | types it into the terminal, shows a spinner with the time running, then its output |
| commits or pushes | marks the committed files and shows the commit and the push |
| deletes a file | the file flies from the tree into a trash can |
| searches the web | a search page: the query typed, the results listed |
| fetches a page | a browser: the address typed, the page shown |
| looks at an image | an image viewer shows it (terminals with the kitty graphics protocol) |
downloads with curl, wget or aria2c | a progress bar |
Logs, lock files and generated files are counted, not typed. Wide characters (Chinese, Japanese, Korean, emoji) are drawn at their full width.
The phone holds the whole conversation with read receipts and a typing indicator; scroll it with the mouse wheel. It survives a reload, /resume and a compacted conversation.
/gitlogue replay [session] plays a recorded session again from its start, at the pace it ran, without running the model.
When an MCP server named hypr-screens is connected and records Claude's own screens, the pane plays those recordings, sped up, instead of showing their screenshots.
Unset options take their defaults. Set them with /plugin configure gitlogue@claude-gitlogue, or as rows in /config, where a change applies at once.
| Option | Values | Default |
|---|---|---|
| Theme | gitlogue's 17 themes: tokyo-night, catppuccin, dracula, nord, gruvbox and more | tokyo-night |
| Playback speed | 0.1x to 10x | 1x |
| Max lag | seconds the replay may fall behind before it speeds up | 20 |
| Open the pane | auto (when a session starts), command (only with /gitlogue) | auto |
| Chat view | on (the phone), off (the usual transcript) | on |
| Language | auto, en, zh, ja; auto follows Claude Code's language setting, then the system locale | auto |
magick) to show images other than PNG; ffmpeg to play screen recordings; jq to rebuild the chat of a session recorded before this mod kept it.The look, pacing and color themes come from gitlogue by unhappychoice (ISC License).
ISC. See LICENSE.
hooks/register.tsx 1055 lines1// gitlogue for Claude Code: replays every change Claude makes the way gitlogue
2// replays a commit, typed into an editor in a pane beside the conversation.
3// A turn is the commit: its prompt is the message, the files it touches form
4// the tree, the commands it runs scroll through the terminal.
5
6import type { Register } from 'claude-code'
7
8import type { GitlogueLink, GitloguePlace, GitlogueSaved, GitlogueCapture } from '../types'
9
10import { candidatePaths, commitMessageOf, downloadTargets, hitsOf, reverseApply, searchOf, type EngineHunk } from './bashfiles'
11import { chainTo, parentOf, type DirEntry } from './explorer'
12import { Chat, drawPhone, registerChat } from './chat'
13import { languageOf, speak, words } from './i18n'
14import { DEFAULT_COLOR, imageBox, layoutFor, paint, type Layout, type WideRun } from './frame'
15import { Player, type PlayerEvent } from './player'
16import { DEFAULT_THEME, THEMES } from './themes'
17
18const PANE = 'gitlogue'
19// Claude Code's own diff pane, and how long after /diff its open counts as asked.
20const DIFF_PANE = 'diff'
21const DIFF_ASK_MS = 10_000
22const SAVED = { plugin: 'gitlogue', key: 'saved' } as const
23const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
24const FAST_TICK_MS = 33
25const BLINK_MS = 500
26const MAX_SNAPSHOT_BYTES = 1_000_000
27// The tools of the server that gives Claude screens of its own.
28const SCREENS_TOOL = 'mcp__hypr-screens__'
29// Recordings play at twice their speed or faster, at most this many frames a second.
30const CAST_SPEED = 2
31const CAST_FPS_LIMIT = 30
32const CAST_LONGEST_PLAY_SECONDS = 8
33// Commands that grab screens, and those that make pictures out of pictures
34// (frames from a clip, a sheet of frames): from captures, they are captures.
35const GRABBING = /(^|[\s;&|(])(grim|import|scrot|spectacle|gnome-screenshot|hyprshot|flameshot|wf-recorder)\b|x11grab|kmsgrab/
36const KEPT_CAPTURES = 40
37// The shape a chat is kept in; one kept in another is read again.
38const CHAT_FORMAT = 2
39const TRANSCRIPT_READ =
40 'f=$(ls "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/"$1".jsonl 2>/dev/null | head -n 1); [ -n "$f" ] && jq -c "$2" "$f" 2>/dev/null'
41// The person's prompts (typed, or sent while Claude worked) and Claude's
42// words, from a transcript file: one {role, text} per line.
43const TRANSCRIPT_CHAT = `
44 if .isSidechain == true or .isMeta == true or .isCompactSummary == true then empty
45 elif .type == "user" then
46 (.message.content | if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\\n")) end) as $text
47 | select(($text | length) > 0 and ([.message.content | arrays | .[] | select(.type == "tool_result")] | length) == 0)
48 | {role: "user", text: $text}
49 elif .type == "attachment" and .attachment.type == "queued_command" and .attachment.origin.kind == "human" then
50 (.attachment.prompt | if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\\n")) end) as $text
51 | select($text | length > 0) | {role: "user", text: $text}
52 elif .type == "assistant" then
53 ([.message.content[]? | select(.type == "text") | .text] | join("\\n")) as $text
54 | select($text | length > 0) | {role: "assistant", text: $text}
55 else empty end`
56const DERIVING = /(^|[\s;&|(])(magick|montage|convert|ffmpeg)\b/
57// With the chat in it, the pane asks to take nearly the whole width: the
58// transcript beside it has nothing left to show.
59const WIDE_DOCK = 400
60// The replays kept for /resume: the newest few sessions, each within a share of
61// the 4 MiB store.
62const KEPT_SESSIONS = 3
63const MAX_RECORD_CHARS = 1_000_000
64// A session's chat as kept for /resume, beside its replay: the newest lines
65// within this many characters (the session itself holds all of them).
66const MAX_CHAT_CHARS = 200_000
67const RECENT_KEY = 'replays'
68const recordKey = (session: string) => `replay:${session}`
69
70type BashEditFile = { filePath: string; hunks: EngineHunk[]; created?: true; deleted?: true }
71
72// Rasters paint colors snapped to four bits a channel; text drawn beside them
73// snaps the same way so the two read as one surface.
74const hex = (color: number) =>
75 '#' +
76 [16, 8, 0]
77 .map(shift => (Math.round(((color >> shift) & 255) / 17) * 17).toString(16).padStart(2, '0'))
78 .join('')
79
80function stamp(ms: number): string {
81 const d = new Date(ms)
82 const two = (n: number) => String(n).padStart(2, '0')
83 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
84}
85
86// A short, stable name for a path, for the PNG copies of pictures.
87function hashOf(text: string): string {
88 let hash = 0x811c9dc5
89 for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193)
90 return (hash >>> 0).toString(16)
91}
92
93export const register: Register = (on, options) => {
94 const theme = THEMES[String(options.theme)] ?? THEMES[DEFAULT_THEME]
95 // gitlogue types at 30 ms a character; the playback option scales all of it,
96 // and how far the replay may fall behind scales with it.
97 const rate = Math.min(10, Math.max(0.1, parseFloat(String(options.playback)) || 1))
98 const speedMs = 30 / rate
99 const maxLagMs = (Math.max(1, Number(options.maxLag) || 20) * 1000) / rate
100 const openAtStart = options.open !== 'command'
101 const chatOption = options.chat !== 'off'
102 const languageOption = String(options.language ?? 'auto')
103
104 const player = new Player(speedMs, maxLagMs)
105 let cwd = ''
106 // The pane's size while it is drawn; undefined while nobody sees it.
107 let mounted: Layout | undefined
108 let shownTurn = -1
109 let shownScene = -1
110 // The conversation reads as a phone chat while the pane is drawn.
111 const isChatOn = () => chatOption && mounted !== undefined
112 let chatShown = false
113 let shownCounts = -1
114 // How wide the phone was last drawn: the wheel left of it scrolls the chat.
115 let phoneShown = 0
116 // Recordings of Claude's own screens, when the hypr-screens server is here:
117 // the pane marks that it watches, finds new clips, and plays them.
118 let castEnabled = false
119 let runtimeDir = ''
120 let viewerMark = ''
121 let lastMark = 0
122 let lastPoll = 0
123 let isDecoding = false
124 let shownCastFrame: string | undefined
125 const seenClips = new Set<string>()
126 // Where and when commands left screen captures (screenshots, frames, sheets
127 // tiled from them): looking at one shows what a recording already shows.
128 // Kept with the session's state, so a reload remembers them.
129 const captures: GitlogueCapture[] = []
130 let capturesChanged = false
131 let shownChat = -1
132 let lastTick = Date.now()
133 let sent = { tree: '', main: '', wide: '' }
134 let isBlitting = false
135 let timer: { cancel: () => void } | undefined
136 let timerMs = 0
137
138 const display = (path: string) =>
139 cwd && path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
140
141 const frames = (layout: Layout) => {
142 const canvas = paint(player, theme, layout, Math.floor(Date.now() / BLINK_MS) % 2 === 0)
143 const treeRows = layout.bottomRows > 0 ? layout.topRows + 1 : layout.height
144 const treeWide = layout.leftWidth > 0 ? canvas.wideRuns(0, 0, layout.leftWidth, treeRows) : []
145 const mainWide = canvas.wideRuns(layout.leftWidth, 0, layout.rightWidth, layout.height)
146 return {
147 treeRows,
148 tree: layout.leftWidth > 0 ? canvas.encode(0, 0, layout.leftWidth, treeRows) : '',
149 main: canvas.encode(layout.leftWidth, 0, layout.rightWidth, layout.height),
150 treeWide,
151 mainWide,
152 // Wide characters are drawn as text in the tree: a change to them redraws it.
153 wide: JSON.stringify([treeWide, mainWide]),
154 }
155 }
156
157 // The clock that plays the script, and the record that outlives a reload of
158 // this module, set up where the session's engine is in reach.
159 let schedule = () => {}
160 let persist = () => {}
161 let resume = async (_id: string) => {}
162
163 // Replay: every event the pane plays and every change to the phone, kept
164 // per session with its time, so `/gitlogue replay` plays a session again
165 // as it ran. Pictures and screen recordings are copied beside the log,
166 // since the originals change or are removed once played.
167 type ReplayEntry = { at: number; kind: 'event' | 'chat'; op?: 'add' | 'read' | 'working'; data?: unknown }
168 let replayDir = ''
169 let replayFile = ''
170 let replayLog: ReplayEntry[] = []
171 let replaying = false
172 let savePending = false
173 // Set up where the session's engine is in reach, as `schedule` is.
174 let saveLog = () => {}
175 let runCopy = (_argv: string[]) => {}
176 let replay = async (_id: string): Promise<string> => words.notStarted
177 const record = (entry: Omit<ReplayEntry, 'at'>) => {
178 if (replaying || !replayFile) return
179 let data = entry.data as PlayerEvent
180 if (entry.kind === 'event' && (data.type === 'cast' || data.type === 'image')) {
181 const keep = `${replayDir}/assets/${Date.now().toString(36)}-${replayLog.length}`
182 if (data.type === 'cast') {
183 runCopy(['sh', '-c', 'mkdir -p "$1" && cp "$@" "$1"/ 2>/dev/null; true', 'sh', keep, ...data.frames])
184 data = { ...data, frames: data.frames.map(f => `${keep}/${f.slice(f.lastIndexOf('/') + 1)}`), remove: [] }
185 } else {
186 runCopy(['sh', '-c', 'mkdir -p "$(dirname "$2")" && cp "$1" "$2"', 'sh', data.png, `${keep}.png`])
187 data = { ...data, png: `${keep}.png` }
188 }
189 }
190 replayLog.push({ at: Date.now(), ...entry, data })
191 saveLog()
192 }
193
194 const enqueue = (event: PlayerEvent) => {
195 record({ kind: 'event', data: event })
196 player.enqueue(event)
197 if (!mounted) player.flush()
198 schedule()
199 persist()
200 }
201
202 on('session.start', async ($, e, next) => {
203 cwd = e.cwd
204 // The pane's language: the option, else Claude Code's, else the locale's.
205 const setting = (await $.settings.read().catch(() => ({}) as Record<string, unknown>)).language
206 const locale =
207 (await $.env.get('LC_ALL').catch(() => undefined)) ||
208 (await $.env.get('LC_MESSAGES').catch(() => undefined)) ||
209 (await $.env.get('LANG').catch(() => undefined))
210 speak(
211 languageOf(languageOption === 'auto' ? undefined : languageOption) ??
212 languageOf(typeof setting === 'string' ? setting : undefined) ??
213 languageOf(locale ?? undefined) ??
214 'en',
215 )
216 replayDir = `${(await $.env.get('HOME')) ?? '/tmp'}/.cache/gitlogue/replays`
217 await $.process.run(['mkdir', '-p', `${replayDir}/assets`]).catch(() => undefined)
218 runtimeDir = (await $.env.get('XDG_RUNTIME_DIR')) ?? ''
219 // Nothing of the screen recordings loads unless their server is connected.
220 castEnabled = (await $.tool.list().catch(() => [])).some(tool => tool.name.startsWith(SCREENS_TOOL))
221 lastTick = Date.now()
222
223 let session = await $.session.id()
224 // A resumed session keeps adding to the log it already has.
225 const openLog = async (id: string) => {
226 replayFile = `${replayDir}/${id}.jsonl`
227 const kept = await $.fs.read(replayFile).catch(() => '')
228 replayLog = String(kept)
229 .split('\n')
230 .filter(Boolean)
231 .flatMap(line => {
232 try {
233 return [JSON.parse(line) as ReplayEntry]
234 } catch {
235 return []
236 }
237 })
238 }
239 await openLog(session)
240 saveLog = () => {
241 if (savePending || !replayFile) return
242 savePending = true
243 $.clock.after(1000, () => {
244 savePending = false
245 void $.fs.write(replayFile, replayLog.map(entry => JSON.stringify(entry)).join('\n') + '\n').catch(() => {})
246 })
247 }
248 runCopy = argv => {
249 void $.process.run(argv).catch(() => undefined)
250 }
251 // Plays a session's log again from its start, at the pace it was recorded.
252 replay = async (id: string) => {
253 let file = id ? `${replayDir}/${id.replace(/\.jsonl$/, '')}.jsonl` : ''
254 if (!file) {
255 const logs = (await $.fs.list(replayDir).catch(() => []))
256 .filter(entry => entry.name.endsWith('.jsonl') && `${replayDir}/${entry.name}` !== replayFile)
257 .sort((a, b) => b.mtimeMs - a.mtimeMs)
258 if (!logs.length) return words.nothingRecorded
259 file = `${replayDir}/${logs[0].name}`
260 }
261 const text = await $.fs.read(file).catch(() => undefined)
262 if (typeof text !== 'string') return words.noReplayLog(file)
263 const entries = text
264 .split('\n')
265 .filter(Boolean)
266 .flatMap(line => {
267 try {
268 return [JSON.parse(line) as ReplayEntry]
269 } catch {
270 return []
271 }
272 })
273 if (!entries.length) return `${file} is empty.`
274 replaying = true
275 player.reset()
276 chat.restore([])
277 chatWorking(false)
278 if (!mounted) await $.ui.open({ id: PANE, title: 'gitlogue', rows: 24, ...(chatOption ? { columns: WIDE_DOCK } : {}) })
279 const t0 = entries[0].at
280 entries.forEach((entry, i) =>
281 $.clock.after(Math.max(1, entry.at - t0 + 1500), () => {
282 if (entry.kind === 'event') enqueue(entry.data as PlayerEvent)
283 else if (entry.op === 'add') chatAdd(entry.data as Parameters<typeof chatAdd>[0])
284 else if (entry.op === 'read') chatRead()
285 else if (entry.op === 'working') chatWorking(entry.data === true)
286 if (i === entries.length - 1) replaying = false
287 $.ui.invalidate('ui.render')
288 }),
289 )
290 return words.replaying(entries.length, file)
291 }
292 const stored = await $.store.get(RECENT_KEY)
293 let recent: string[] = Array.isArray(stored) ? stored.filter((id): id is string => typeof id === 'string') : []
294 let storedAt = 0
295
296 // The replay as a record small enough to keep: everything still to play,
297 // else the panes alone, else the panes without the open file.
298 const record = (): GitlogueSaved => {
299 // The chat, its oldest lines left out past its share of the store.
300 let size = 2
301 let start = chat.lines.length
302 while (start > 0 && size + JSON.stringify(chat.lines[start - 1]).length + 1 <= MAX_CHAT_CHARS)
303 size += JSON.stringify(chat.lines[--start]).length + 1
304 const kept = { format: CHAT_FORMAT, lines: chat.lines.slice(start) }
305 const full = player.save()
306 if (JSON.stringify(full).length <= MAX_RECORD_CHARS) return { ...full, chat: kept }
307 const view = player.view()
308 const panes = { view, pending: [] }
309 if (JSON.stringify(panes).length <= MAX_RECORD_CHARS) return { ...panes, chat: kept }
310 return { view: { ...view, lines: [''], hasFile: false, currentPath: null }, pending: [], chat: kept }
311 }
312
313 // Kept for /resume, at most every couple of seconds while playing and
314 // always once the replay has caught up.
315 const keep = () => {
316 const now = Date.now()
317 if (!player.isIdle && now - storedAt < 2000) return
318 storedAt = now
319 const id = session
320 void $.store.set(recordKey(id), record()).catch(() => {})
321 if (recent[0] === id) return
322 const dropped = recent.filter(other => other !== id).slice(KEPT_SESSIONS - 1)
323 recent = [id, ...recent.filter(other => other !== id)].slice(0, KEPT_SESSIONS)
324 for (const old of dropped) void $.store.delete(recordKey(old)).catch(() => {})
325 void $.store.set(RECENT_KEY, recent).catch(() => {})
326 }
327
328 let savedVersion = -1
329 let savedChat = -1
330
331 // A session's conversation for the phone, when no chat of it was kept:
332 // from its transcript file (found by the session's id, under whichever
333 // project folder), which still holds what a compaction dropped, else from
334 // the messages the session holds.
335 const conversation = async (id: string): Promise<{ role: 'user' | 'assistant'; text: string }[]> => {
336 const ran = await $.process
337 .run(['sh', '-c', TRANSCRIPT_READ, 'sh', id, TRANSCRIPT_CHAT], { timeoutMs: 60_000 })
338 .catch(() => undefined)
339 const rows = (ran?.stdout ?? '').split('\n').flatMap(row => {
340 try {
341 const message = JSON.parse(row) as { role?: unknown; text?: unknown }
342 const role = message.role === 'user' || message.role === 'assistant' ? message.role : undefined
343 return role && typeof message.text === 'string' ? [{ role: role as 'user' | 'assistant', text: message.text }] : []
344 } catch {
345 return []
346 }
347 })
348 if (rows.length > 0) return rows
349 return id === (await $.session.id()) ? await $.session.messages().catch(() => []) : []
350 }
351 persist = () => {
352 if (player.saveVersion === savedVersion && chat.version === savedChat && !capturesChanged) return
353 savedVersion = player.saveVersion
354 savedChat = chat.version
355 capturesChanged = false
356 void $.state.set(SAVED, { ...player.save(), session, captures, chat: { format: CHAT_FORMAT, lines: chat.lines } }).catch(() => {})
357 keep()
358 }
359
360 // The session's last replay, from a reload of this module (an edit, a
361 // changed option) or from an earlier run this session resumes.
362 const load = async () => {
363 const { value: live } = await $.state.get(SAVED)
364 if (live && live.session === session) {
365 captures.splice(0, captures.length, ...(live.captures ?? []))
366 if (live.chat?.format === CHAT_FORMAT) chat.restore(live.chat.lines)
367 else chat.load(await conversation(session))
368 return player.restore(live)
369 }
370 const kept = (await $.store.get(recordKey(session))) as GitlogueSaved | undefined
371 if (kept?.chat?.format === CHAT_FORMAT) chat.restore(kept.chat.lines)
372 else chat.load(await conversation(session))
373 if (kept?.view) {
374 player.restore(kept)
375 player.flush()
376 }
377 }
378 await load()
379 savedVersion = player.saveVersion
380 resume = async (id: string) => {
381 if (id === session) return
382 session = id
383 await openLog(id)
384 const kept = (await $.store.get(recordKey(id))) as GitlogueSaved | undefined
385 if (kept?.chat?.format === CHAT_FORMAT) chat.restore(kept.chat.lines)
386 else chat.load(await conversation(id))
387 if (kept?.view) {
388 player.restore(kept)
389 player.flush()
390 } else player.reset()
391 persist()
392 schedule()
393 }
394
395 const tick = () => {
396 const now = Date.now()
397 const dt = Math.min(1000, now - lastTick)
398 lastTick = now
399 if (!mounted) {
400 player.flush()
401 schedule()
402 persist()
403 return
404 }
405 player.advance(dt)
406 persist()
407 watchScreens()
408 if (player.removals.length > 0) void $.process.run(['rm', '-rf', ...player.removals.splice(0)]).catch(() => {})
409 if (player.screen === 'cast' && player.castFrame && player.castFrame !== shownCastFrame) {
410 shownCastFrame = player.castFrame
411 void $.ui.blit({ requestId: PANE, key: 'cast', source: { file: player.castFrame, format: 'png' } }).catch(() => {})
412 }
413 // The chat coming or going, or the turn's counts moving under it, redraw
414 // the transcript and the turn info.
415 const counts = [...player.files.values()].reduce((n, f) => n + f.added * 7 + f.deleted * 13 + 1, 0)
416 if (isChatOn() !== chatShown || (isChatOn() && (counts !== shownCounts || chat.version !== shownChat))) {
417 chatShown = isChatOn()
418 shownCounts = counts
419 shownChat = chat.version
420 $.ui.invalidate('ui.render')
421 }
422 if (player.turnVersion !== shownTurn || player.sceneVersion !== shownScene) {
423 // The turn info is drawn as text and a picture as an image; a redraw
424 // remounts the rasters too.
425 shownTurn = player.turnVersion
426 shownScene = player.sceneVersion
427 $.ui.invalidate('ui.render')
428 } else if (!isBlitting) {
429 const layout = mounted
430 const frame = frames(layout)
431 if (frame.wide !== sent.wide) {
432 sent = { ...sent, wide: frame.wide }
433 $.ui.invalidate('ui.render')
434 schedule()
435 return
436 }
437 const blits: Promise<unknown>[] = []
438 if (frame.main !== sent.main)
439 blits.push($.ui.blit({ requestId: PANE, key: 'main', cells: frame.main, columns: layout.rightWidth, rows: layout.height }))
440 if (frame.tree && frame.tree !== sent.tree)
441 blits.push($.ui.blit({ requestId: PANE, key: 'tree', cells: frame.tree, columns: layout.leftWidth, rows: frame.treeRows }))
442 sent = { tree: frame.tree, main: frame.main, wide: frame.wide }
443 if (blits.length > 0) {
444 isBlitting = true
445 void Promise.allSettled(blits).then(() => (isBlitting = false))
446 }
447 }
448 schedule()
449 }
450
451 // While the pane is open and the screens' server is here: a mark that the
452 // pane watches (the server records only while one is fresh), and a look
453 // for clips it has finished.
454 const watchScreens = () => {
455 if (!castEnabled || !mounted || !runtimeDir) return
456 const now = Date.now()
457 if (now - lastMark > 10_000) {
458 lastMark = now
459 viewerMark = `${runtimeDir}/hypr-screens/viewers/gitlogue-${session}`
460 void $.fs.write(viewerMark, String(now)).catch(() => {})
461 }
462 if (isDecoding || now - lastPoll < 1000) return
463 lastPoll = now
464 isDecoding = true
465 void takeClips().finally(() => (isDecoding = false))
466 }
467
468 // Each new clip, decoded to frames at the speed it plays at, and queued.
469 const takeClips = async () => {
470 const dir = `${runtimeDir}/hypr-screens/recordings`
471 const notes = (await $.fs.list(dir).catch(() => []))
472 .map(entry => entry.name)
473 .filter(name => name.endsWith('.json') && !seenClips.has(name))
474 .sort()
475 for (const name of notes) {
476 seenClips.add(name)
477 const note = JSON.parse(String(await $.fs.read(`${dir}/${name}`).catch(() => '{}'))) as {
478 screen?: string
479 file?: string
480 width?: number
481 height?: number
482 seconds?: number
483 }
484 if (!note.file) continue
485 const seconds = note.seconds ?? 0
486 const speed = Math.max(CAST_SPEED, seconds / CAST_LONGEST_PLAY_SECONDS)
487 const fps = Math.min(CAST_FPS_LIMIT, 15 * speed)
488 const frames = note.file.replace(/\.mp4$/, '-frames')
489 const remove = [note.file, `${dir}/${name}`, frames]
490 await $.process.run(['mkdir', '-p', frames]).catch(() => undefined)
491 const made = await $.process
492 .run(['ffmpeg', '-v', 'error', '-y', '-i', note.file, '-vf', `setpts=PTS/${speed},fps=${fps},scale=1920:-2`, `${frames}/f%04d.png`], { timeoutMs: 120_000 })
493 .catch(() => undefined)
494 const list = made?.exitCode === 0 ? (await $.fs.list(frames).catch(() => [])).map(entry => entry.name).filter(n => n.endsWith('.png')).sort() : []
495 enqueue({
496 type: 'cast',
497 screen: note.screen ?? '?',
498 frames: list.map(n => `${frames}/${n}`),
499 fps,
500 speed,
501 seconds,
502 width: note.width ?? 1600,
503 height: note.height ?? 1000,
504 remove,
505 })
506 }
507 }
508
509 schedule = () => {
510 const wanted = mounted ? (player.isIdle ? (player.hasRunning ? 100 : BLINK_MS) : FAST_TICK_MS) : 0
511 if (wanted === timerMs) return
512 timer?.cancel()
513 timer = undefined
514 timerMs = wanted
515 if (wanted > 0) timer = $.clock.every(wanted, tick)
516 }
517
518 // A tool that redraws the mod with its latest code, since calling a
519 // plugin's own tool reloads it first: for checking a change to the mod.
520 await $.tool.register({
521 name: 'refresh',
522 description:
523 'Reload the gitlogue mod with its latest saved code and redraw its pane and chat view, to check a change to the mod before the turn ends. chat_scroll, when given, scrolls the chat that many rows up from its newest line (0 back to the newest).',
524 inputSchema: { type: 'object', properties: { chat_scroll: { type: 'number' } } },
525 })
526 await $.command.register({
527 name: 'gitlogue',
528 description: words.commandDescription,
529 argumentHint: '[replay [session-id]]',
530 })
531 if (openAtStart && e.isInteractive) void $.ui.open({ id: PANE, title: 'gitlogue', rows: 24, ...(chatOption ? { columns: WIDE_DOCK } : {}) })
532 // After a reload the pane may still be up, drawn by the module before this one.
533 $.ui.invalidate('ui.render')
534 return next(e)
535 })
536
537 // The words Claude Code closes each turn with (`Cogitated for 1m 57s`), by
538 // the turn's length, so the terminal ends the turn in the same words.
539 const closingWords = new Map<number, string>()
540 player.wordFor = durationMs => {
541 let best: string | undefined
542 let gap = 2000
543 for (const [ms, word] of closingWords) {
544 if (Math.abs(ms - durationMs) < gap) {
545 gap = Math.abs(ms - durationMs)
546 best = word
547 }
548 }
549 return best
550 }
551 on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
552 closingWords.set(e.props.durationMs, e.props.word)
553 if (closingWords.size > 50) closingWords.delete(closingWords.keys().next().value as number)
554 if (!isChatOn() || e.surface !== 'terminal') return next(e)
555 const { Box } = $.ui.resolve(e)
556 return <Box display="none" />
557 })
558
559 const chat = new Chat()
560 const chatAdd = chat.add.bind(chat)
561 const chatRead = chat.markRead.bind(chat)
562 const chatWorking = chat.working.bind(chat)
563 let lastWorking: boolean | undefined
564 chat.add = line => {
565 record({ kind: 'chat', op: 'add', data: line })
566 chatAdd(line)
567 }
568 chat.markRead = () => {
569 record({ kind: 'chat', op: 'read' })
570 chatRead()
571 }
572 // While a replay plays, the phone shows the replayed session's work, not this one's.
573 chat.working = isWorking => {
574 if (replaying) return
575 if (isWorking !== lastWorking) record({ kind: 'chat', op: 'working', data: isWorking })
576 lastWorking = isWorking
577 chatWorking(isWorking)
578 }
579
580 registerChat(on, { isOn: isChatOn, chat })
581
582 // /resume inside a running session switches to another one's replay.
583 on('classic.SessionStart', async ($, e, next) => {
584 if (e.source === 'resume') await resume(e.session_id)
585 return next(e)
586 })
587
588 on('tool.call', { tool: 'mcp__gitlogue__refresh' }, ($, e) => {
589 const lift = (e as { chat_scroll?: unknown }).chat_scroll
590 if (typeof lift === 'number') chat.scrollTo(lift)
591 $.ui.invalidate('ui.render')
592 return { result: 'The gitlogue mod is running its latest code; its pane and chat view are redrawn.' }
593 })
594
595 // A pane opened later takes the dock from the one shown, and Claude Code's
596 // diff pane opens by itself as Claude edits: while this pane is up, the
597 // diff pane opens only when the person asks for it with /diff.
598 let diffAskedAt = 0
599 on('command.run', { command: 'diff' }, ($, e, next) => {
600 diffAskedAt = Date.now()
601 return next(e)
602 })
603 on('ui.open', { id: DIFF_PANE }, async ($, e, next) => {
604 const isUp = (await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE)
605 if (!isUp || Date.now() - diffAskedAt < DIFF_ASK_MS) return next(e)
606 return { value: { isPlaced: false as const, reason: 'gitlogue holds the dock; /diff opens the diff pane' } }
607 })
608
609 on('command.run', { command: 'gitlogue' }, async ($, e) => {
610 const args = String((e as { args?: unknown }).args ?? '').trim().split(/\s+/)
611 if (args[0] === 'replay') {
612 const said = await replay(args[1] ?? '')
613 $.ui.toast(said)
614 return {}
615 }
616 // Shown, it closes; behind another pane's tab, it comes to the front.
617 const pane = (await $.ui.panes()).find(pane => pane.id === PANE)
618 if (pane && !pane.isShown) await $.ui.open({ id: PANE, title: 'gitlogue', focus: true })
619 else if (pane) await $.ui.close({ id: PANE })
620 else await $.ui.open({ id: PANE, title: 'gitlogue', rows: 24, ...(chatOption ? { columns: WIDE_DOCK } : {}) })
621 return {}
622 })
623
624 // The wheel over the phone, or the scroll keys while the pane has them,
625 // move the chat's messages; the pane itself stays where it is.
626 on('ui.scroll', { requestId: PANE }, ($, e, next) => {
627 if (!isChatOn() || phoneShown === 0 || (e.pointer && e.pointer.column >= phoneShown)) return next(e)
628 chat.scrollBy(-e.by * (e.pointer ? 3 : 1))
629 $.ui.invalidate('ui.render')
630 return { deny: 'the phone scrolls its own messages' }
631 })
632
633 on('ui.close', { id: PANE }, ($, e, next) => {
634 mounted = undefined
635 if (viewerMark) void $.process.run(['rm', '-f', viewerMark]).catch(() => {})
636 lastMark = 0
637 chatShown = false
638 $.ui.invalidate('ui.render')
639 schedule()
640 return next(e)
641 })
642
643 on('turn.start', ($, e, next) => {
644 chat.working(true)
645 const id = e.turnId.replace(/[^0-9a-f]/gi, '').slice(0, 7) || e.turnId.slice(0, 7)
646 const prompt = e.text.trim() || '(continued)'
647 enqueue({ type: 'turn', turn: { id, date: stamp(Date.now()), prompt: prompt.slice(0, 600) } })
648 return next(e)
649 })
650
651 on('turn.complete', ($, e, next) => {
652 if (e.agentId === undefined) chat.working(false)
653 if (e.agentId === undefined) enqueue({ type: 'done', durationMs: e.durationMs, aborted: e.isAborted })
654 return next(e)
655 })
656
657 on('tool.call', async ($, e, next) => {
658 if (String(e.tool).startsWith(SCREENS_TOOL)) castEnabled = true
659 // Where a changed file sits for the explorer: the session's folder when it
660 // is inside it, else the repository holding it, else its own folder, with
661 // each folder listed from there down.
662 const placeOf = async (file: string): Promise<GitloguePlace | undefined> => {
663 const home = (await $.env.get('HOME')) ?? ''
664 const folder = parentOf(file)
665 let root: string | undefined
666 if (cwd && cwd !== home && file.startsWith(cwd + '/')) root = cwd
667 for (let dir = folder; !root && dir !== '/' && dir !== home; dir = parentOf(dir))
668 if (await $.fs.exists(dir + '/.git').catch(() => false)) root = dir
669 root ??= folder
670 const listings: Record<string, DirEntry[]> = {}
671 for (const dir of chainTo(root, file)) {
672 const entries = await $.fs.list(dir).catch(() => undefined)
673 if (!entries) return undefined
674 listings[dir] = entries
675 .filter(entry => entry.name !== '.git')
676 .slice(0, 400)
677 .map(entry => ({ name: entry.name, dir: entry.kind === 'dir' }))
678 }
679 return { root, listings }
680 }
681 const editOf = async (file: string, before: string, after: string, created: boolean): Promise<PlayerEvent> => ({
682 type: 'edit',
683 path: display(file),
684 before,
685 after,
686 created,
687 file,
688 place: await placeOf(file).catch(() => undefined),
689 })
690
691 if (e.tool === 'Bash') {
692 const command = String((e as { command?: unknown }).command ?? '')
693 const description = (e as { description?: unknown }).description
694 const startedAt = Date.now()
695 enqueue({ type: 'command', command, description: typeof description === 'string' ? description : undefined, startedAt })
696
697 // The text of a file now: a string, null when it does not exist, or
698 // undefined when it is no text file to replay (a folder, binary, huge).
699 const look = (path: string): Promise<string | null | undefined> =>
700 $.fs.stat(path).then(
701 stat =>
702 stat.kind === 'file' && stat.size <= MAX_SNAPSHOT_BYTES
703 ? $.fs.read(path).then(text => (typeof text === 'string' && !text.includes('\u0000') ? text : undefined))
704 : undefined,
705 () => null,
706 )
707 const home = (await $.env.get('HOME')) ?? ''
708 const shellCwd = await $.session.cwd()
709 // The plugins' store is written by this mod itself while commands run.
710 const isOwn = (path: string) => path.startsWith(home + '/.claude/plugins/store/')
711 const fetched = downloadTargets(command, shellCwd, home)
712 const paths = candidatePaths(command, shellCwd, home).filter(path => !isOwn(path) && !fetched.includes(path))
713 const before = new Map<string, string | null>()
714 // What existed before, of any kind, to see what the command removes.
715 const existed = new Map<string, 'file' | 'dir' | 'other'>()
716 await Promise.all(
717 paths.map(async path => {
718 const stat = await $.fs.stat(path).catch(() => undefined)
719 if (stat) existed.set(path, stat.kind)
720 const text = await look(path).catch(() => undefined)
721 if (text !== undefined) before.set(path, text)
722 }),
723 )
724
725 const ran = await next(e)
726 const durationMs = Date.now() - startedAt
727
728 const edits: PlayerEvent[] = []
729 const seen = new Set<string>(fetched)
730 if (ran.deny === undefined && ran.isError !== true)
731 for (const file of fetched) {
732 const stat = await $.fs.stat(file).catch(() => undefined)
733 if (stat?.kind !== 'file') continue
734 edits.push({
735 type: 'download',
736 path: display(file),
737 file,
738 bytes: stat.size,
739 durationMs,
740 place: await placeOf(file).catch(() => undefined),
741 })
742 }
743 for (const [path, old] of before) {
744 const now = await look(path).catch(() => undefined)
745 if (typeof now !== 'string' || now === old) continue
746 seen.add(path)
747 edits.push(await editOf(path, old ?? '', now, old === null))
748 }
749 // What the engine saw the command change, for files the command never names.
750 const result = 'result' in ran ? (ran.result as { bashEditDiff?: { files?: BashEditFile[] } } | undefined) : undefined
751 for (const file of result?.bashEditDiff?.files ?? []) {
752 if (file.deleted || seen.has(file.filePath) || isOwn(file.filePath)) continue
753 const now = await look(file.filePath).catch(() => undefined)
754 if (typeof now !== 'string') continue
755 const old = file.created ? '' : reverseApply(now, file.hunks)
756 if (old !== now) edits.push(await editOf(file.filePath, old, now, file.created === true))
757 }
758 // Where the command left screen captures, and when.
759 const derived =
760 DERIVING.test(command) &&
761 paths.some(
762 path =>
763 /\/hypr-screens\/recordings\//.test(path) ||
764 captures.some(c => [path, parentOf(path), parentOf(parentOf(path))].some(dir => c.dirs.includes(dir))),
765 )
766 if (castEnabled && (GRABBING.test(command) || derived)) {
767 captures.push({ dirs: [...new Set(paths.flatMap(path => [path, parentOf(path)]))], from: startedAt - 1000, to: Date.now() + 1000 })
768 captures.splice(0, Math.max(0, captures.length - KEPT_CAPTURES))
769 capturesChanged = true
770 }
771
772 // What the command removed, outermost only: a folder's files go with it.
773 const gone: string[] = []
774 for (const [path, kind] of existed)
775 if (!(await $.fs.exists(path).catch(() => true))) gone.push(path + (kind === 'dir' ? '/' : ''))
776 for (const path of gone.sort()) {
777 const file = path.replace(/\/$/, '')
778 if (gone.some(other => other !== path && other.endsWith('/') && file.startsWith(other))) continue
779 edits.push({ type: 'delete', path: display(file), file, isDir: path.endsWith('/'), place: await placeOf(file).catch(() => undefined) })
780 }
781 edits.forEach(enqueue)
782
783 // A search through code or for file names, as the editor would run it.
784 const search = searchOf(command, shellCwd, home)
785 const output = ran.deny ?? ran.text ?? ''
786 if (search?.kind === 'code') {
787 const only = search.paths.length === 1 ? display(search.paths[0]) : undefined
788 const { hits, total } = hitsOf(output, only)
789 enqueue({ type: 'codesearch', query: search.query, hits: hits.map(hit => ({ ...hit, path: display(hit.path) })), total })
790 } else if (search?.kind === 'files') {
791 const files = output.split('\n').map(line => line.trim()).filter(line => line && !/^Exit code/.test(line))
792 enqueue({ type: 'findfiles', pattern: search.pattern, files: files.map(display) })
793 }
794
795 // A commit or push, as git reports it.
796 const git = ('result' in ran ? (ran.result as { gitOperation?: { commit?: { sha: string; branch?: string }; push?: { branch: string } } } | undefined) : undefined)?.gitOperation
797 if (git?.commit || git?.push) {
798 let root: string | undefined
799 for (let dir = shellCwd; dir && dir !== '/'; dir = parentOf(dir))
800 if (await $.fs.exists(dir + '/.git').catch(() => false)) {
801 root = dir
802 break
803 }
804 enqueue({
805 type: 'git',
806 commit: git.commit ? { ...git.commit, message: commitMessageOf(command, output) } : undefined,
807 push: git.push,
808 root,
809 durationMs,
810 })
811 }
812
813 const text = ran.deny ?? ran.text ?? ''
814 const exit = /^Exit code (\d+)/m.exec(text)
815 const all = text
816 .split('\n')
817 .map(line => line.trimEnd())
818 .filter(line => line.trim() !== '' && !/^Exit code \d+$/.test(line))
819 enqueue({
820 type: 'output',
821 lines: all.slice(0, 3),
822 total: all.length,
823 failed: ran.deny !== undefined || ran.isError === true,
824 exitCode: exit ? Number(exit[1]) : undefined,
825 durationMs,
826 })
827 return ran
828 }
829
830 if (e.tool === 'WebSearch') {
831 const query = String((e as { query?: unknown }).query ?? '')
832 const startedAt = Date.now()
833 const ran = await next(e)
834 if (ran.deny !== undefined || ran.isError === true) return ran
835 const result = ran.result as { results?: (string | { content?: GitlogueLink[] })[]; durationSeconds?: number } | undefined
836 const results = (result?.results ?? []).flatMap(part => (typeof part === 'string' ? [] : (part.content ?? [])))
837 const durationMs = result?.durationSeconds !== undefined ? result.durationSeconds * 1000 : Date.now() - startedAt
838 enqueue({ type: 'search', query, results, durationMs })
839 return ran
840 }
841
842 if (e.tool === 'WebFetch') {
843 const url = String((e as { url?: unknown }).url ?? '')
844 const startedAt = Date.now()
845 const ran = await next(e)
846 if (ran.deny !== undefined || ran.isError === true) return ran
847 const result = ran.result as { result?: string; url?: string; durationMs?: number } | undefined
848 enqueue({
849 type: 'fetch',
850 url: result?.url ?? url,
851 text: (result?.result ?? ran.text ?? '').slice(0, 20000),
852 durationMs: result?.durationMs ?? Date.now() - startedAt,
853 })
854 return ran
855 }
856
857 if (e.tool === 'Read') {
858 const ran = await next(e)
859 const result = 'result' in ran ? (ran.result as { type?: string; file?: { dimensions?: Record<string, number | undefined>; startLine?: number; numLines?: number; content?: string } } | undefined) : undefined
860 if (result?.type === 'text') {
861 const file = String((e as { file_path?: unknown }).file_path ?? '')
862 const whole = await $.fs.read(file).catch(() => undefined)
863 if (typeof whole === 'string' && !whole.includes('\u0000'))
864 enqueue({
865 type: 'read',
866 path: display(file),
867 file,
868 text: whole,
869 startLine: result.file?.startLine ?? 1,
870 numLines: result.file?.numLines ?? whole.split('\n').length,
871 place: await placeOf(file).catch(() => undefined),
872 })
873 return ran
874 }
875 if (result?.type !== 'image') return ran
876 const file = String((e as { file_path?: unknown }).file_path ?? '')
877 // Claude's screens are recorded: a capture of one is that recording again.
878 if (castEnabled) {
879 const stat = await $.fs.stat(file).catch(() => undefined)
880 const captured =
881 /\/hypr-screens\/recordings\//.test(file) ||
882 (stat !== undefined &&
883 captures.some(c => (c.dirs.includes(file) || c.dirs.includes(parentOf(file))) && stat.mtimeMs >= c.from && stat.mtimeMs <= c.to))
884 if (captured) return ran
885 }
886 // The terminal shows PNGs; anything else is shown through a PNG copy.
887 let png = file
888 if (!/\.png$/i.test(file)) {
889 const home = (await $.env.get('HOME')) ?? '/tmp'
890 const stat = await $.fs.stat(file).catch(() => undefined)
891 png = `${home}/.cache/gitlogue/${hashOf(file + ':' + (stat?.mtimeMs ?? 0))}.png`
892 if (!(await $.fs.exists(png).catch(() => false))) {
893 await $.process.run(['mkdir', '-p', parentOf(png)]).catch(() => undefined)
894 const made = await $.process.run(['magick', file + '[0]', '-resize', '1600x1600>', 'png:' + png]).catch(() => undefined)
895 if (made?.exitCode !== 0) return ran
896 }
897 }
898 let width = result.file?.dimensions?.originalWidth ?? 0
899 let height = result.file?.dimensions?.originalHeight ?? 0
900 if (!width || !height) {
901 const size = await $.process.run(['magick', 'identify', '-format', '%w %h', png]).catch(() => undefined)
902 ;[width, height] = (size?.stdout ?? '0 0').split(' ').map(Number)
903 }
904 if (width > 0 && height > 0)
905 enqueue({ type: 'image', path: display(file), file, png, width, height, place: await placeOf(file).catch(() => undefined) })
906 return ran
907 }
908
909 if (!EDIT_TOOLS.has(String(e.tool))) return next(e)
910 const path = String((e as { file_path?: unknown }).file_path ?? '')
911 const read = (): Promise<string | undefined> => $.fs.read(path).then(
912 text => (typeof text === 'string' ? text : undefined),
913 () => undefined,
914 )
915 const before = path ? await read() : undefined
916 const ran = await next(e)
917 if (!path || ran.deny !== undefined || ran.isError === true) return ran
918 const after = await read()
919 if (after !== undefined && after !== before) enqueue(await editOf(path, before ?? '', after, before === undefined))
920 return ran
921 })
922
923 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
924 const { Box, Text } = $.ui.resolve(e)
925 if (e.surface !== 'terminal') return <Text dimColor>{words.terminalOnly}</Text>
926 const { Raster, Image } = $.ui.resolve(e)
927
928 const width = Math.min(512, e.props.bodyColumns)
929 const height = Math.min(256, e.props.scroll.bodyRows)
930 if (width < 20 || height < 4) {
931 mounted = undefined
932 return <Text dimColor>{words.needsRoom}</Text>
933 }
934 // The phone takes the pane's left, the replay the rest.
935 const phoneWidth = chatOption ? Math.min(64, Math.max(40, Math.floor(width * 0.34))) : 0
936 phoneShown = phoneWidth
937 const layout = layoutFor(width - (phoneWidth ? phoneWidth + 1 : 0), height)
938 mounted = layout
939 shownTurn = player.turnVersion
940 shownScene = player.sceneVersion
941 const frame = frames(layout)
942 sent = { tree: frame.tree, main: frame.main, wide: frame.wide }
943 schedule()
944
945 const turn = player.turn
946 const picture = player.screen === 'image' && player.image ? imageBox(layout, player.image) : undefined
947 const castBox = player.screen === 'cast' && player.cast && player.castFrame ? imageBox(layout, player.cast) : undefined
948 shownCastFrame = castBox ? player.castFrame : undefined
949 const left = hex(theme.backgroundLeft)
950 // Each row keeps its height; what does not fit is cut at the bottom.
951 const row = (text: ReturnType<typeof Text>) => <Box flexShrink={0}>{text}</Box>
952 const label = (name: string, value: string, color: number) =>
953 row(
954 <Text backgroundColor={left}>
955 <Text>{name}</Text>
956 <Text color={hex(color)}>{value}</Text>
957 </Text>,
958 )
959
960 // With the chat up, the prompt is already in it: the turn's changes stand
961 // where the commit message would.
962 const touched = turn ? [...player.files.values()].filter(entry => entry.turn === turn.id) : []
963 const summary = touched.length === 0
964 ? words.noChanges
965 : `${words.files(touched.length)} +${touched.reduce((n, f) => n + f.added, 0)} -${touched.reduce((n, f) => n + f.deleted, 0)}`
966 const info = turn
967 ? [
968 label(words.turn, turn.id, theme.statusHash),
969 label(words.author, 'Claude', theme.statusAuthor),
970 label(words.date, turn.date, theme.statusDate),
971 ...(isChatOn() ? [label(words.changes, summary, theme.fileTreeModified)] : []),
972 ...(isChatOn() ? '' : turn.prompt)
973 .split('\n')
974 .filter(line => line.trim() !== '')
975 .map(line =>
976 row(
977 <Text color={hex(theme.statusMessage)} backgroundColor={left}>
978 {line}
979 </Text>,
980 ),
981 ),
982 ]
983 : [
984 <Text color={hex(theme.statusNoCommit)} backgroundColor={left}>
985 {words.noTurn}
986 </Text>,
987 ]
988
989 // A run of wide characters, over the blanks the Raster keeps for it.
990 const color = (value: number) => (value === DEFAULT_COLOR ? undefined : hex(value))
991 const wide = (run: WideRun) => (
992 <Box position="absolute" top={run.y} left={run.x}>
993 <Text color={color(run.fg)} backgroundColor={color(run.bg)} wrap="truncate">
994 {run.text}
995 </Text>
996 </Box>
997 )
998
999 const { Markdown } = $.ui.resolve(e)
1000 return (
1001 <Box flexDirection="row" width={width} height={height}>
1002 {phoneWidth > 0 && drawPhone({ Box, Text, Markdown }, chat, theme, hex, phoneWidth, height)}
1003 {phoneWidth > 0 && <Box width={1} />}
1004 {layout.leftWidth > 0 && (
1005 <Box flexDirection="column" width={layout.leftWidth} height={height}>
1006 <Box width={layout.leftWidth} height={frame.treeRows} flexShrink={0}>
1007 <Raster key="tree" columns={layout.leftWidth} rows={frame.treeRows} cells={frame.tree} />
1008 {frame.treeWide.map(wide)}
1009 </Box>
1010 {layout.bottomRows > 0 && (
1011 <Box
1012 flexDirection="column"
1013 width={layout.leftWidth}
1014 height={layout.bottomRows}
1015 backgroundColor={left}
1016 paddingX={2}
1017 paddingY={layout.bottomRows >= 4 ? 1 : 0}
1018 overflow="hidden"
1019 >
1020 {info}
1021 </Box>
1022 )}
1023 </Box>
1024 )}
1025 <Box width={layout.rightWidth} height={height}>
1026 <Raster key="main" columns={layout.rightWidth} rows={height} cells={frame.main} />
1027 {frame.mainWide.map(wide)}
1028 {castBox && player.castFrame && (
1029 <Box position="absolute" top={castBox.top} left={castBox.left}>
1030 <Image
1031 key="cast"
1032 source={{ file: player.castFrame, format: 'png' }}
1033 columns={castBox.columns}
1034 rows={castBox.rows}
1035 alt={`screen ${player.cast?.screen ?? ''}`}
1036 />
1037 </Box>
1038 )}
1039 {picture && player.image && (
1040 <Box position="absolute" top={picture.top} left={picture.left}>
1041 <Image
1042 key="picture"
1043 source={{ file: player.image.png, format: 'png' }}
1044 columns={picture.columns}
1045 rows={picture.rows}
1046 alt={player.image.path}
1047 />
1048 </Box>
1049 )}
1050 </Box>
1051 </Box>
1052 )
1053 })
1054}
1055hooks/bashfiles.ts 221 lines1// Which files a shell command may write, read off the command itself, and how
2// to recover a file's previous text from the hunks the engine reports.
3
4const MAX_CANDIDATES = 80
5
6function normalize(path: string): string {
7 const parts: string[] = []
8 for (const part of path.split('/')) {
9 if (part === '' || part === '.') continue
10 if (part === '..') parts.pop()
11 else parts.push(part)
12 }
13 return '/' + parts.join('/')
14}
15
16function unquote(word: string): string {
17 return word.replace(/^(['"])(.*)\1$/, '$2')
18}
19
20// The command with its simple `NAME=value` assignments substituted.
21function expandVars(command: string, cwd: string, home: string): string {
22 const vars = new Map<string, string>([['HOME', home], ['PWD', cwd]])
23 for (const m of command.matchAll(/(?:^|[\s;&|(])([A-Za-z_]\w*)=("[^"]*"|'[^']*'|[^\s;&|)]+)/g))
24 vars.set(m[1], unquote(m[2]))
25 return command.replace(/\$\{(\w+)\}|\$(\w+)/g, (all, a: string | undefined, b: string | undefined) => {
26 const value = vars.get(a ?? b ?? '')
27 return value ?? all
28 })
29}
30
31function resolver(home: string) {
32 return (word: string, base: string) => {
33 if (word.startsWith('~/')) return normalize(home + word.slice(1))
34 if (word.startsWith('/')) return normalize(word)
35 return normalize(base + '/' + word)
36 }
37}
38
39// Absolute paths of every word in the command that could name a file: an
40// argument (`sed -i s/a/b/ notes.md`), a redirect target (`> out.json`), or a
41// name inside an inline script (`open('register.tsx', 'w')`), resolved against
42// the working directory and every directory the command `cd`s into, with
43// simple `NAME=value` assignments substituted.
44export function candidatePaths(command: string, cwd: string, home: string): string[] {
45 const expanded = expandVars(command, cwd, home)
46 const resolve = resolver(home)
47
48 const bases = [cwd]
49 for (const m of expanded.matchAll(/(?:^|[\s;&|(])cd\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)) {
50 const dir = resolve(unquote(m[1]), cwd)
51 if (!bases.includes(dir)) bases.push(dir)
52 }
53
54 const out = new Set<string>()
55 for (const m of expanded.matchAll(/[^\s'"`;&|<>(){}[\],=:*?]+/g)) {
56 const word = m[0]
57 if (word.length < 2 || word.length > 300 || word.startsWith('-')) continue
58 if (!/[./]/.test(word) || /^[./]+$/.test(word) || /^\d+(\.\d+)*$/.test(word)) continue
59 const isAnchored = word.startsWith('/') || word.startsWith('~/')
60 for (const base of isAnchored ? [cwd] : bases) {
61 out.add(resolve(word, base))
62 if (out.size >= MAX_CANDIDATES) return [...out]
63 }
64 }
65 return [...out]
66}
67
68export type EngineHunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
69
70// Undoes unified-diff hunks on the file's current text, giving the text before.
71export function reverseApply(after: string, hunks: readonly EngineHunk[]): string {
72 const endsWithNewline = after.endsWith('\n')
73 const lines = (endsWithNewline ? after.slice(0, -1) : after).split('\n')
74 if (after === '') lines.length = 0
75 for (const hunk of [...hunks].sort((a, b) => b.newStart - a.newStart)) {
76 const old = hunk.lines.filter(line => line[0] === ' ' || line[0] === '-').map(line => line.slice(1))
77 const at = hunk.newLines === 0 ? hunk.newStart : hunk.newStart - 1
78 lines.splice(Math.max(0, at), hunk.newLines, ...old)
79 }
80 return lines.length === 0 ? '' : lines.join('\n') + (endsWithNewline ? '\n' : '')
81}
82
83const DOWNLOADERS = new Set(['curl', 'wget', 'aria2c'])
84
85const urlName = (url: string) => {
86 const name = url.replace(/[?#].*$/, '').replace(/\/+$/, '').split('/').pop() ?? ''
87 return name.includes('.') || name.length > 0 ? decodeURIComponent(name) : 'index.html'
88}
89
90// The files a command downloads to: curl's -o/-O, wget's -O or the address's
91// name, aria2c's -o, each under the folder the command was in or asked for.
92export function downloadTargets(command: string, cwd: string, home: string): string[] {
93 const resolve = resolver(home)
94 const out: string[] = []
95 let dir = cwd
96 for (const segment of expandVars(command, cwd, home).split(/&&|\|\||;|\n|\|/)) {
97 const words = [...segment.matchAll(/"[^"]*"|'[^']*'|\S+/g)].map(m => unquote(m[0]))
98 while (words.length > 0 && (/^\w+=/.test(words[0]) || words[0] === 'sudo' || words[0] === 'command')) words.shift()
99 const tool = (words[0] ?? '').split('/').pop() ?? ''
100 if (tool === 'cd' && words[1]) dir = resolve(words[1], dir)
101 if (!DOWNLOADERS.has(tool)) continue
102 let into = dir
103 let named: string | undefined
104 let remote = tool !== 'curl'
105 const urls: string[] = []
106 // The flag naming the output file, and the one naming its folder, per tool.
107 const nameFlags = { curl: ['-o', '--output'], wget: ['-O', '--output-document'], aria2c: ['-o', '--out'] }[tool] ?? []
108 const dirFlags = { curl: ['--output-dir'], wget: ['-P', '--directory-prefix'], aria2c: ['-d', '--dir'] }[tool] ?? []
109 for (let i = 1; i < words.length; i++) {
110 const word = words[i]
111 if (/^https?:\/\//.test(word)) {
112 urls.push(word)
113 continue
114 }
115 const long = word.match(/^(--[\w-]+)(?:=(.*))?$/)
116 if (long) {
117 const value = long[2] ?? (nameFlags.includes(long[1]) || dirFlags.includes(long[1]) ? words[++i] : undefined)
118 if (nameFlags.includes(long[1])) named = value
119 else if (dirFlags.includes(long[1])) into = value ?? into
120 else if (tool === 'curl' && long[1] === '--remote-name') remote = true
121 continue
122 }
123 // A cluster of short flags (`-sSLO`, `-qO-`): the last may take a value.
124 const short = word.match(/^-([A-Za-z]+)(.*)$/)
125 if (!short) continue
126 if (tool === 'curl' && short[1].includes('O')) remote = true
127 const last = '-' + short[1].slice(-1)
128 const value = short[2] !== '' ? short[2] : nameFlags.includes(last) || dirFlags.includes(last) ? words[++i] : undefined
129 if (nameFlags.includes(last)) named = value
130 else if (dirFlags.includes(last)) into = value ?? into
131 }
132 if (named === '-') continue
133 if (named) out.push(resolve(named, resolve(into, dir)))
134 else if (remote) for (const url of urls) out.push(resolve(urlName(url), resolve(into, dir)))
135 }
136 return [...new Set(out)]
137}
138
139const GREPS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack'])
140const FINDERS = new Set(['fd', 'fdfind', 'find'])
141
142export type ShellSearch =
143 | { kind: 'code'; query: string; paths: string[] }
144 | { kind: 'files'; pattern: string }
145
146// The search a command runs, when it is one: grep and its kin over code, or
147// fd and find over file names; read from the first such command in it.
148export function searchOf(command: string, cwd: string, home: string): ShellSearch | undefined {
149 const resolve = resolver(home)
150 let dir = cwd
151 for (const segment of expandVars(command, cwd, home).split(/&&|\|\||;|\n|\|/)) {
152 const words = [...segment.matchAll(/"[^"]*"|'[^']*'|\S+/g)].map(m => unquote(m[0]))
153 while (words.length > 0 && (/^\w+=/.test(words[0]) || words[0] === 'sudo' || words[0] === 'command')) words.shift()
154 let tool = (words[0] ?? '').split('/').pop() ?? ''
155 if (tool === 'cd' && words[1]) dir = resolve(words[1], dir)
156 if (tool === 'git' && words[1] === 'grep') {
157 words.shift()
158 tool = 'grep'
159 }
160 if (GREPS.has(tool)) {
161 let query: string | undefined
162 const paths: string[] = []
163 for (let i = 1; i < words.length; i++) {
164 const word = words[i]
165 if (word === '-e' || word === '--regexp') query ??= words[++i]
166 else if (word.startsWith('--regexp=')) query ??= word.slice(9)
167 else if (/^-(A|B|C|m|-max-count|-context|-glob|g|t|-type)$/.test(word)) i++
168 else if (word.startsWith('-')) continue
169 else if (query === undefined) query = word
170 else paths.push(resolve(word, dir))
171 }
172 if (query) return { kind: 'code', query, paths }
173 }
174 if (FINDERS.has(tool)) {
175 let pattern: string | undefined
176 for (let i = 1; i < words.length; i++) {
177 const word = words[i]
178 if (tool === 'find') {
179 // The name asked for, not a path excluded or included around it.
180 if (/^-i?name$/.test(word)) pattern ??= words[++i]
181 else if (/^-i?(path|regex)$/.test(word) && words[i - 1] !== '-not' && words[i - 1] !== '!') pattern ??= words[++i]
182 } else if (/^-(e|t|d|E|-extension|-type|-max-depth|-exclude)$/.test(word)) i++
183 else if (!word.startsWith('-') && pattern === undefined) pattern = word
184 }
185 if (pattern) return { kind: 'files', pattern }
186 }
187 }
188 return undefined
189}
190
191// A grep-like command's output as matches by file: `path:line:text` lines,
192// `line:text` ones when it searched one file, and bare paths when it listed
193// files only.
194export function hitsOf(output: string, onlyPath: string | undefined): { hits: { path: string; matches: { line: number; text: string }[] }[]; total: number } {
195 const byPath = new Map<string, { line: number; text: string }[]>()
196 let total = 0
197 for (const raw of output.split('\n')) {
198 const line = raw.replace(/\r$/, '')
199 if (!line.trim() || /^Exit code \d+$/.test(line)) continue
200 const full = /^(.+?):(\d+):(.*)$/.exec(line)
201 const single = /^(\d+):(.*)$/.exec(line)
202 if (full) {
203 byPath.set(full[1], [...(byPath.get(full[1]) ?? []), { line: Number(full[2]), text: full[3] ?? '' }])
204 total++
205 } else if (single && onlyPath) {
206 byPath.set(onlyPath, [...(byPath.get(onlyPath) ?? []), { line: Number(single[1]), text: single[2] ?? '' }])
207 total++
208 } else if (!line.includes(':') && /[./]/.test(line)) {
209 if (!byPath.has(line)) byPath.set(line, [])
210 }
211 }
212 return { hits: [...byPath].map(([path, matches]) => ({ path, matches })), total }
213}
214
215// A commit's message as the command gives it with -m, else as git reports it.
216export function commitMessageOf(command: string, output: string): string | undefined {
217 const flag = /(?:^|\s)-m\s+("([^"]*)"|'([^']*)'|(\S+))/.exec(command)
218 if (flag) return (flag[2] ?? flag[3] ?? flag[4] ?? '').split('\n')[0]
219 return /^\[[^\]]+\] (.+)$/m.exec(output)?.[1]
220}
221hooks/explorer.ts 112 lines1// The file explorer: workspace roots, the folders opened in them, what each
2// folder listed, and where the selection is. The script plans its walks on one
3// copy while the panes draw another, both moved by the same operations.
4
5import type { GitlogueDirEntry, GitlogueExplorer } from '../types'
6
7export type DirEntry = GitlogueDirEntry
8
9export type ExplorerRow = {
10 path: string
11 name: string
12 depth: number
13 kind: 'root' | 'dir' | 'file' | 'more'
14 isOpen: boolean
15}
16
17// Rows a folder shows before the rest are folded into one `… N more`.
18const FOLDER_ROWS = 24
19
20export const parentOf = (path: string) => path.slice(0, Math.max(1, path.lastIndexOf('/'))) || '/'
21export const nameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1) || path
22
23// The folders from `root` down to the one holding `file`, root first.
24export function chainTo(root: string, file: string): string[] {
25 const chain = [root]
26 const rest = file.slice(root === '/' ? 1 : root.length + 1).split('/').slice(0, -1)
27 for (const part of rest) chain.push((chain[chain.length - 1] === '/' ? '' : chain[chain.length - 1]) + '/' + part)
28 return chain
29}
30
31export class Explorer {
32 roots: string[] = []
33 expanded = new Set<string>()
34 listings = new Map<string, DirEntry[]>()
35 selected: string | undefined
36
37 static from(saved: GitlogueExplorer | undefined): Explorer {
38 const explorer = new Explorer()
39 if (!saved) return explorer
40 explorer.roots = saved.roots.slice()
41 explorer.expanded = new Set(saved.expanded)
42 explorer.listings = new Map(Object.entries(saved.listings).map(([dir, entries]) => [dir, entries.slice()]))
43 explorer.selected = saved.selected ?? undefined
44 return explorer
45 }
46
47 save(): GitlogueExplorer {
48 return {
49 roots: this.roots.slice(),
50 expanded: [...this.expanded],
51 listings: Object.fromEntries([...this.listings].map(([dir, entries]) => [dir, entries.slice()])),
52 selected: this.selected ?? null,
53 }
54 }
55
56 clone(): Explorer {
57 return Explorer.from(this.save())
58 }
59
60 addRoot(root: string) {
61 if (!this.roots.includes(root)) this.roots.push(root)
62 }
63
64 // A folder's listing as it is now, adding `ensure` when the folder was
65 // listed before that file existed (or after it was removed).
66 list(dir: string, entries: DirEntry[], ensure?: DirEntry) {
67 const byName = new Map(entries.map(entry => [entry.name, entry]))
68 if (ensure && !byName.has(ensure.name)) byName.set(ensure.name, ensure)
69 const sorted = [...byName.values()].sort((a, b) =>
70 a.dir !== b.dir ? (a.dir ? -1 : 1) : a.name.toLowerCase() < b.name.toLowerCase() ? -1 : a.name.toLowerCase() > b.name.toLowerCase() ? 1 : 0,
71 )
72 this.listings.set(dir, sorted)
73 }
74
75 // A name gone from a folder's listing, and the folder closed if it was one.
76 unlist(dir: string, name: string) {
77 const entries = this.listings.get(dir)
78 if (entries) this.listings.set(dir, entries.filter(entry => entry.name !== name))
79 const path = (dir === '/' ? '' : dir) + '/' + name
80 for (const open of [...this.expanded]) if (open === path || open.startsWith(path + '/')) this.expanded.delete(open)
81 if (this.selected === path) this.selected = dir
82 }
83
84 // The visible rows; `keep` names paths a long folder must still show.
85 rows(keep: ReadonlySet<string>): ExplorerRow[] {
86 const rows: ExplorerRow[] = []
87 const walk = (dir: string, depth: number) => {
88 const entries = this.listings.get(dir) ?? []
89 const prefix = dir === '/' ? '' : dir
90 const kept = (entry: DirEntry) => {
91 const path = prefix + '/' + entry.name
92 return keep.has(path) || this.expanded.has(path) || path === this.selected || [...keep].some(k => k.startsWith(path + '/'))
93 }
94 const shown = entries.length <= FOLDER_ROWS ? entries : entries.filter((entry, i) => i < FOLDER_ROWS - 1 || kept(entry))
95 for (const entry of shown) {
96 const path = prefix + '/' + entry.name
97 const isOpen = entry.dir && this.expanded.has(path)
98 rows.push({ path, name: entry.name, depth, kind: entry.dir ? 'dir' : 'file', isOpen })
99 if (isOpen) walk(path, depth + 1)
100 }
101 if (shown.length < entries.length)
102 rows.push({ path: prefix + '/…', name: `… ${entries.length - shown.length} more`, depth, kind: 'more', isOpen: false })
103 }
104 for (const root of this.roots) {
105 const isOpen = this.expanded.has(root)
106 rows.push({ path: root, name: nameOf(root), depth: 0, kind: 'root', isOpen })
107 if (isOpen) walk(root, 1)
108 }
109 return rows
110 }
111}
112hooks/chat.tsx 368 lines1// While the replay pane is open, the conversation moves into it as a phone
2// chat, the way two colleagues message: the person's lines in green on the
3// right, marked read; Claude's on the left under its mascot; a typing line
4// while Claude works. The transcript's own rows step aside, so the pane holds
5// the whole picture: the phone, and the work it talks about beside it.
6
7import type { On } from 'claude-code'
8
9import { cellWidth } from './cells'
10import { words } from './i18n'
11import type { Theme } from './themes'
12import type { GitlogueChatLine } from '../types'
13
14const CLAUDE = '#d77757'
15const MINE = '#06c755'
16const INK_ON_MINE = '#0a0a0a'
17const THEIRS = 0x334455
18const BAR = 0x2a3040
19const FIELD = 0x3a4256
20// Claude Code's mascot, as its welcome banner draws it.
21const MASCOT = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ ']
22const AVATAR_WIDTH = 10
23
24export type ChatLine = GitlogueChatLine
25
26// How wide a bubble for `text` is: its longest line, within the room.
27function bubbleWidth(text: string, room: number, isMarkdown: boolean): number {
28 if (isMarkdown && /```|^\s*\|/m.test(text)) return room
29 const longest = Math.max(1, ...text.split('\n').map(line => cellWidth(line.replace(/[*_`#>]/g, ''))))
30 return Math.max(4, Math.min(room, longest + 2))
31}
32
33const two = (n: number) => String(n).padStart(2, '0')
34const now = () => {
35 const d = new Date()
36 return `${two(d.getHours())}:${two(d.getMinutes())}`
37}
38
39// What a user row says to the person: the typed text, without the tags the
40// engine wraps its own notes and commands in.
41const spoken = (text: string) =>
42 text
43 .replace(/<(system-reminder|command-[\w-]+|local-command-[\w-]+|task-notification)>[\s\S]*?<\/\1>/g, '')
44 .trim()
45
46const LONGEST_MESSAGE = 4000
47
48export class Chat {
49 lines: ChatLine[] = []
50 isWorking = false
51 version = 0
52 // Read back up the chat, the message standing on the phone's floor and how
53 // many rows it has sunk below it; null follows the newest.
54 floor: number | null = null
55 lift = 0
56 // The phone as last drawn, to measure scrolling against.
57 view = { width: 60, rows: 20 }
58
59 // About how many rows a line takes in the phone, bubble edges and spacing
60 // included: its text wrapped at its bubble's width.
61 rowsOf(line: ChatLine, index: number): number {
62 const room = this.view.width - 2
63 const inner = line.role === 'me' ? Math.floor(room * 0.75) - 2 : room - AVATAR_WIDTH - 10
64 const wrapped = line.text
65 .split('\n')
66 .reduce((n, text) => n + Math.max(1, Math.ceil(cellWidth(text) / Math.max(8, inner))), 0)
67 const first = line.role === 'claude' && this.lines[index - 1]?.role !== 'claude'
68 return wrapped + 2 + (line.role === 'me' ? 1 : first ? 2 : 0)
69 }
70
71 // Sinks the messages by `rows`, bringing older ones into view (newer ones
72 // when negative). Rows are only measured around the message on the floor,
73 // so the oldest is as reachable as the newest however long the chat.
74 scrollBy(rows: number) {
75 const last = this.lines.length - 1
76 if (last < 0) return
77 const rowsAt = (i: number) => this.rowsOf(this.lines[i]!, i)
78 let at = this.floor ?? last
79 let lift = this.lift + rows
80 while (at > 0 && lift >= rowsAt(at)) lift -= rowsAt(at--)
81 while (lift < 0 && at < last) lift += rowsAt(++at)
82 // The oldest message stops a little below the top of the phone.
83 const room = this.view.rows - 2 - Math.floor(this.view.rows / 3)
84 let above = -lift
85 for (let i = 0; i <= at && above < room; i++) above += rowsAt(i)
86 if (above < room) {
87 lift -= room - above
88 while (lift < 0 && at < last) lift += rowsAt(++at)
89 }
90 const floor = at === last && lift <= 0 ? null : at
91 lift = floor === null ? 0 : lift
92 if (floor === this.floor && lift === this.lift) return
93 this.floor = floor
94 this.lift = lift
95 this.version++
96 }
97
98 // Back to the newest, then `rows` up from it.
99 scrollTo(rows: number) {
100 this.floor = null
101 this.lift = 0
102 this.version++
103 this.scrollBy(rows)
104 }
105
106 add(line: ChatLine) {
107 if (!line.text.trim()) return
108 // The terminal draws no text longer than 10000 characters in one piece.
109 if (line.text.length > LONGEST_MESSAGE) line = { ...line, text: line.text.slice(0, LONGEST_MESSAGE) + '…' }
110 // Read back up the chat, the view stays on its floor as new lines come.
111 this.lines.push(line)
112 this.version++
113 }
114
115 // The chat as it was kept, word for word: it outlives a compacted
116 // conversation, whose earlier messages the session no longer holds.
117 restore(lines: readonly ChatLine[]) {
118 this.lines = [...lines]
119 this.floor = null
120 this.lift = 0
121 this.version++
122 }
123
124 // The conversation so far, for a session with no kept chat, so the phone
125 // opens on it.
126 load(messages: readonly { role: 'user' | 'assistant'; text: string }[]) {
127 this.lines = []
128 this.floor = null
129 this.lift = 0
130 for (const message of messages) {
131 // A compacted conversation's summary is no one's message.
132 if (message.role === 'user' && message.text.startsWith('This session is being continued from a previous conversation')) continue
133 const text = message.role === 'user' ? spoken(message.text) : message.text.trim()
134 if (text) this.add({ role: message.role === 'user' ? 'me' : 'claude', text, isRead: true })
135 }
136 }
137
138 // The oldest of the person's lines still on its way has reached Claude.
139 markRead() {
140 const line = this.lines.find(other => other.role === 'me' && !other.isRead)
141 if (!line) return
142 line.isRead = true
143 this.version++
144 }
145
146 working(isWorking: boolean) {
147 if (this.isWorking === isWorking) return
148 this.isWorking = isWorking
149 this.version++
150 }
151}
152
153// The phone: a top bar, the latest messages filling up from the bottom (the
154// oldest cut off above, as a chat scrolled to its end), and the input bar.
155export function drawPhone(
156 el: { Box: any; Text: any; Markdown: any },
157 chat: Chat,
158 theme: Theme,
159 hex: (color: number) => string,
160 width: number,
161 height: number,
162) {
163 const { Box, Text, Markdown } = el
164 chat.view = { width, rows: Math.max(1, height - 2) }
165 const screen = hex(theme.backgroundLeft)
166 const bar = hex(BAR)
167 const room = width - 2
168 // Only what the phone shows, and a screen more above it, is laid out: the
169 // whole conversation would be laid out again on every frame.
170 const floor = chat.floor ?? chat.lines.length - 1
171 let start = floor + 1
172 for (let left = chat.lift + 2 * chat.view.rows; start > 0 && left > 0; ) {
173 start--
174 left -= chat.rowsOf(chat.lines[start]!, start)
175 }
176 const messages = chat.lines.slice(start, floor + 1).map((line, i) => {
177 if (line.role === 'me') {
178 const bubble = bubbleWidth(line.text, Math.floor(room * 0.75), false)
179 return (
180 <Box flexDirection="row" justifyContent="flex-end" alignItems="flex-end" marginTop={1} flexShrink={0}>
181 <Box flexDirection="column" alignItems="flex-end" marginRight={1}>
182 {line.isRead && <Text dimColor>{words.read}</Text>}
183 {line.time && <Text dimColor>{line.time}</Text>}
184 </Box>
185 <Box flexDirection="column" width={bubble}>
186 <Text color={MINE}>{'▗' + '▄'.repeat(bubble - 2) + '▖'}</Text>
187 <Box width={bubble} paddingX={1} backgroundColor={MINE}>
188 <Text color={INK_ON_MINE}>{line.text}</Text>
189 </Box>
190 <Text color={MINE}>{'▝' + '▀'.repeat(bubble - 2) + '▘'}</Text>
191 </Box>
192 <Box flexDirection="column" width={1} alignSelf="flex-start">
193 <Text> </Text>
194 <Text color={MINE}>◥</Text>
195 </Box>
196 </Box>
197 )
198 }
199 const first = chat.lines[start + i - 1]?.role !== 'claude'
200 const fill = hex(THEIRS)
201 const bubble = bubbleWidth(line.text, room - AVATAR_WIDTH - 8, true)
202 return (
203 <Box flexDirection="row" marginTop={first ? 1 : 0} flexShrink={0}>
204 <Box width={AVATAR_WIDTH} flexDirection="column">
205 {first && MASCOT.map(row => <Text color={CLAUDE}>{row}</Text>)}
206 </Box>
207 <Box flexDirection="column">
208 {first && <Text dimColor>Claude</Text>}
209 <Box flexDirection="row" alignItems="flex-end">
210 <Box flexDirection="column" width={1} alignSelf="flex-start">
211 <Text> </Text>
212 <Text color={fill}>{first ? '◤' : ' '}</Text>
213 </Box>
214 <Box flexDirection="column" width={bubble}>
215 <Text color={fill}>{'▗' + '▄'.repeat(bubble - 2) + '▖'}</Text>
216 <Box width={bubble} paddingX={1} backgroundColor={fill}>
217 <Markdown text={line.text.slice(0, 9000)} />
218 </Box>
219 <Text color={fill}>{'▝' + '▀'.repeat(bubble - 2) + '▘'}</Text>
220 </Box>
221 {line.time && (
222 <Box marginLeft={1}>
223 <Text dimColor>{line.time}</Text>
224 </Box>
225 )}
226 </Box>
227 </Box>
228 </Box>
229 )
230 })
231 if (chat.isWorking)
232 messages.push(
233 <Box flexDirection="row" marginTop={1} flexShrink={0} alignItems="center">
234 <Box width={AVATAR_WIDTH} flexDirection="column">
235 {MASCOT.map(row => <Text color={CLAUDE}>{row}</Text>)}
236 </Box>
237 <Box paddingX={1} backgroundColor={hex(THEIRS)}>
238 <Text>···</Text>
239 </Box>
240 <Text dimColor> {words.typing}</Text>
241 </Box>,
242 )
243
244 return (
245 <Box width={width} height={height} flexDirection="column" backgroundColor={screen}>
246 <Box width={width} backgroundColor={bar} paddingX={1} flexDirection="row" flexShrink={0}>
247 <Text dimColor>‹ </Text>
248 <Text bold>Claude</Text>
249 <Box flexGrow={1} />
250 <Text dimColor>☏ ≡</Text>
251 </Box>
252 <Box flexGrow={1} flexDirection="column" justifyContent="flex-end" overflow="hidden" paddingX={1}>
253 {/* The messages stand on the phone's floor; scrolled, the one on the
254 floor sinks below it, bringing older ones down into view. */}
255 <Box flexDirection="column" flexShrink={0} marginBottom={-chat.lift}>
256 {messages}
257 </Box>
258 {chat.floor !== null && (
259 <Box position="absolute" bottom={0} left={0} width={width} justifyContent="center">
260 <Box backgroundColor={bar} paddingX={1}>
261 <Text dimColor>{words.backToLatest}</Text>
262 </Box>
263 </Box>
264 )}
265 </Box>
266 <Box width={width} backgroundColor={bar} flexDirection="row" paddingX={1} flexShrink={0}>
267 <Text dimColor>+ ◎ ▣ </Text>
268 <Box flexGrow={1} backgroundColor={hex(FIELD)} paddingX={1}>
269 <Text dimColor>Aa</Text>
270 </Box>
271 <Text dimColor> ☺ ♪</Text>
272 </Box>
273 </Box>
274 )
275}
276
277export function registerChat(on: On, ctx: { isOn: () => boolean; chat: Chat }) {
278 const { chat } = ctx
279
280 // What the person sends, at once, idle or mid-turn.
281 on('prompt.submit', ($, e, next) => {
282 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
283 const images = e.attachments?.length ? ` ${words.images(e.attachments.length)}` : ''
284 chat.add({ role: 'me', text: e.text.trim() + images, time: now(), isRead: false })
285 $.ui.invalidate('ui.render')
286 }
287 return next(e)
288 })
289
290 // Each block of Claude's reply as it is stored; and the person's prompts as
291 // they reach the conversation, idle or folded into a running turn, which is
292 // when they are read.
293 on('session.append', async ($, e, next) => {
294 const stored = await next(e)
295 const sender = (e.origin as { kind?: string }).kind
296 if (
297 (e.door === 'prompt' || e.door === 'delivery') &&
298 e.agentId === undefined &&
299 e.message.role === 'user' &&
300 !e.message.isMeta &&
301 (sender === 'composer' || sender === 'bridge')
302 ) {
303 chat.markRead()
304 $.ui.invalidate('ui.render')
305 }
306 if (e.door === 'response' && e.agentId === undefined && e.message.role === 'assistant') {
307 const text = e.message.content
308 .flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
309 .join('\n')
310 .trim()
311 if (text) {
312 chat.add({ role: 'claude', text, time: now() })
313 $.ui.invalidate('ui.render')
314 }
315 }
316 return stored
317 })
318
319 // Whether Claude is at work, as the band over the prompt knows it: right
320 // even when the mod loaded in the middle of a turn.
321 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
322 chat.working(e.props.isWorking)
323 return next(e)
324 })
325
326 // The transcript's own rows step aside while the phone holds the conversation.
327 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
328 if (!ctx.isOn() || e.props.isExpanded || e.surface !== 'terminal') return next(e)
329 const { Box } = $.ui.resolve(e)
330 return <Box display="none" />
331 })
332 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
333 if (!ctx.isOn() || e.surface !== 'terminal') return next(e)
334 const { Box } = $.ui.resolve(e)
335 return <Box display="none" />
336 })
337 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
338 if (!ctx.isOn() || e.surface !== 'terminal') return next(e)
339 const { Box } = $.ui.resolve(e)
340 return <Box display="none" />
341 })
342 on('ui.render', { component: 'InfoNotice' }, ($, e, next) => {
343 if (!ctx.isOn() || e.surface !== 'terminal') return next(e)
344 const { Box } = $.ui.resolve(e)
345 return <Box display="none" />
346 })
347 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
348 if (!ctx.isOn() || e.surface !== 'terminal') return next(e)
349 const { Box } = $.ui.resolve(e)
350 return <Box display="none" />
351 })
352 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
353 if (!ctx.isOn() || e.surface !== 'terminal') return next(e)
354 const { Box } = $.ui.resolve(e)
355 return <Box display="none" />
356 })
357 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
358 if (!ctx.isOn() || e.surface !== 'terminal') return next(e)
359 const { Box } = $.ui.resolve(e)
360 return <Box display="none" />
361 })
362 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
363 if (!ctx.isOn() || e.props.isExpanded || e.surface !== 'terminal') return next(e)
364 const { Box } = $.ui.resolve(e)
365 return <Box display="none" />
366 })
367}
368hooks/i18n.ts 144 lines1// The words the pane and the phone show, in each language they speak. The
2// session picks one as it starts: the mod's own option, else Claude Code's
3// language setting, else the system locale, else English.
4
5export type Language = 'en' | 'zh' | 'ja'
6
7const en = {
8 waiting: 'Waiting for Claude...',
9 waitingEdit: 'Waiting for Claude to edit...',
10 screen: (name: string) => `screen ${name}`,
11 googleButtons: '[ Google Search ] [ I’m Feeling Lucky ]',
12 searchTitle: 'SEARCH',
13 searchResults: (results: number, files: number) => `${results} results in ${files} files`,
14 movedToTrash: 'moved to trash',
15 moreLines: (lines: number) => `… +${lines} lines`,
16 notReplayed: (why: string) => `${why}, not replayed`,
17 lockFile: 'lock file',
18 logFile: 'log',
19 generated: 'generated',
20 tooLarge: 'too large',
21 webStats: (results: number, seconds: string) => `${results} results (${seconds} seconds)`,
22 pushed: (duration: string) => `pushed · ${duration}`,
23 openFile: 'Open File...',
24 done: 'Done',
25 interrupted: 'Interrupted',
26 turn: 'turn: ',
27 author: 'author: ',
28 date: 'date: ',
29 changes: 'changes: ',
30 noChanges: 'no files changed yet',
31 files: (count: number) => `${count} file${count === 1 ? '' : 's'}`,
32 noTurn: 'No turn yet',
33 terminalOnly: 'gitlogue draws in the terminal.',
34 needsRoom: 'gitlogue needs more room.',
35 read: 'Read',
36 typing: 'typing…',
37 backToLatest: '↓ Back to the latest',
38 images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,
39 notStarted: 'gitlogue has not started yet.',
40 nothingRecorded: 'No recorded session to replay yet.',
41 noReplayLog: (file: string) => `No replay log at ${file}.`,
42 replaying: (steps: number, file: string) => `Replaying ${steps} recorded steps from ${file}.`,
43 commandDescription: "Show or hide the gitlogue pane, which replays Claude's edits as live typing; `replay [session]` plays a recorded session again from its start",
44}
45
46export type Words = typeof en
47
48const zh: Words = {
49 waiting: '等待 Claude…',
50 waitingEdit: '等待 Claude 修改…',
51 screen: name => `屏幕 ${name}`,
52 googleButtons: '[ Google 搜索 ] [ 手气不错 ]',
53 searchTitle: '搜索',
54 searchResults: (results, files) => `${files} 个文件中有 ${results} 个结果`,
55 movedToTrash: '已移到回收站',
56 moreLines: lines => `… 还有 ${lines} 行`,
57 notReplayed: why => `${why},不回放`,
58 lockFile: '锁文件',
59 logFile: '日志',
60 generated: '生成的文件',
61 tooLarge: '太大',
62 webStats: (results, seconds) => `找到约 ${results} 条结果(用时 ${seconds} 秒)`,
63 pushed: duration => `已推送 · ${duration}`,
64 openFile: '打开文件…',
65 done: '完成',
66 interrupted: '已中断',
67 turn: '轮次:',
68 author: '作者:',
69 date: '日期:',
70 changes: '改动:',
71 noChanges: '还没有改动文件',
72 files: count => `${count} 个文件`,
73 noTurn: '还没有对话',
74 terminalOnly: 'gitlogue 只在终端里绘制。',
75 needsRoom: 'gitlogue 需要更大的空间。',
76 read: '已读',
77 typing: '正在输入…',
78 backToLatest: '↓ 回到最新',
79 images: count => `[${count} 张图片]`,
80 notStarted: 'gitlogue 还没有启动。',
81 nothingRecorded: '还没有可以回放的会话记录。',
82 noReplayLog: file => `${file} 没有回放记录。`,
83 replaying: (steps, file) => `正在回放 ${file} 中记录的 ${steps} 步。`,
84 commandDescription: '显示或隐藏 gitlogue 面板,它把 Claude 的修改回放成实时打字;`replay [session]` 从头回放一段录下的会话',
85}
86
87const ja: Words = {
88 waiting: 'Claude を待っています…',
89 waitingEdit: 'Claude の編集を待っています…',
90 screen: name => `画面 ${name}`,
91 googleButtons: '[ Google 検索 ] [ I’m Feeling Lucky ]',
92 searchTitle: '検索',
93 searchResults: (results, files) => `${files} ファイルに ${results} 件の結果`,
94 movedToTrash: 'ゴミ箱に移動しました',
95 moreLines: lines => `… 他 ${lines} 行`,
96 notReplayed: why => `${why}のため再生しません`,
97 lockFile: 'ロックファイル',
98 logFile: 'ログ',
99 generated: '生成ファイル',
100 tooLarge: '大きすぎる',
101 webStats: (results, seconds) => `約 ${results} 件(${seconds} 秒)`,
102 pushed: duration => `プッシュ済み · ${duration}`,
103 openFile: 'ファイルを開く…',
104 done: '完了',
105 interrupted: '中断しました',
106 turn: 'ターン: ',
107 author: '作者: ',
108 date: '日付: ',
109 changes: '変更: ',
110 noChanges: 'まだ変更はありません',
111 files: count => `${count} ファイル`,
112 noTurn: 'まだターンはありません',
113 terminalOnly: 'gitlogue はターミナルでのみ表示されます。',
114 needsRoom: 'gitlogue を表示するには幅が足りません。',
115 read: '既読',
116 typing: '入力中…',
117 backToLatest: '↓ 最新に戻る',
118 images: count => `[画像 ${count} 枚]`,
119 notStarted: 'gitlogue はまだ起動していません。',
120 nothingRecorded: '再生できる記録はまだありません。',
121 noReplayLog: file => `${file} に再生記録がありません。`,
122 replaying: (steps, file) => `${file} に記録された ${steps} ステップを再生します。`,
123 commandDescription: 'gitlogue パネルの表示を切り替えます。Claude の編集をライブのタイピングとして再生します。`replay [session]` で記録したセッションを最初から再生します',
124}
125
126const TABLES: Record<Language, Words> = { en, zh, ja }
127
128// The words in use, changed in place so every module reads the current ones.
129export const words: Words = { ...en }
130
131export function speak(language: Language) {
132 Object.assign(words, TABLES[language])
133}
134
135// The language a name means: an option's value, a setting such as "Chinese",
136// or a locale such as "zh_CN.UTF-8".
137export function languageOf(name: string | undefined): Language | undefined {
138 const text = (name ?? '').trim().toLowerCase()
139 if (/^zh|chinese|mandarin|cantonese|中文|汉语|漢語|简体|繁體/.test(text)) return 'zh'
140 if (/^ja|japanese|日本/.test(text)) return 'ja'
141 if (/^en|english/.test(text)) return 'en'
142 return undefined
143}
144hooks/frame.ts 897 lines1// Paints the player's state as gitlogue lays it out: file tree over turn info
2// on the left (30%), editor over terminal on the right (70%), an Open File
3// dialog over the middle. Cells are packed as Raster cells: [codePoint, fg, bg].
4
5import { cellWidth, isWide, TAIL } from './cells'
6import { words } from './i18n'
7import { Tok } from './highlight'
8import type { FileEntry, Player } from './player'
9import type { Theme } from './themes'
10
11export const DEFAULT_COLOR = 0x01000000
12
13export type Layout = {
14 width: number
15 height: number
16 leftWidth: number
17 rightWidth: number
18 // Rows of the upper panes (tree, editor); the separator follows when there is a lower one.
19 topRows: number
20 bottomRows: number
21}
22
23export function layoutFor(width: number, height: number): Layout {
24 const leftWidth = width >= 72 ? Math.floor(width * 0.3) : 0
25 const hasBottom = height >= 12
26 const topRows = hasBottom ? Math.round((height - 1) * 0.8) : height
27 return {
28 width,
29 height,
30 leftWidth,
31 rightWidth: width - leftWidth,
32 topRows,
33 bottomRows: hasBottom ? height - topRows - 1 : 0,
34 }
35}
36
37// Wide characters drawn over the Raster, which holds narrow ones only: each
38// run starts at a cell and covers two cells a character.
39export type WideRun = { x: number; y: number; text: string; fg: number; bg: number }
40
41export class Canvas {
42 readonly width: number
43 readonly height: number
44 readonly cells: Uint32Array
45 // The wide characters by the cell they start at; the Raster has blanks there.
46 readonly wide = new Map<number, string>()
47
48 constructor(width: number, height: number) {
49 this.width = width
50 this.height = height
51 this.cells = new Uint32Array(width * height * 3)
52 for (let i = 0; i < this.cells.length; i += 3) {
53 this.cells[i] = 0x20
54 this.cells[i + 1] = DEFAULT_COLOR
55 this.cells[i + 2] = DEFAULT_COLOR
56 }
57 }
58
59 set(x: number, y: number, ch: string, fg: number, bg: number) {
60 if (x < 0 || y < 0 || x >= this.width || y >= this.height) return
61 const at = y * this.width + x
62 const i = at * 3
63 const code = ch.charCodeAt(0)
64 this.wide.delete(at)
65 if (code >= 0xdc00 && code <= 0xdfff) {
66 // The second half of a character past the BMP completes the first.
67 const head = this.wide.get(at - 1)
68 if (head?.length === 1 && x > 0) this.wide.set(at - 1, head + ch[0])
69 this.cells[i] = 0x20
70 } else if ((code >= 0xd800 && code <= 0xdbff) || isWide(code)) {
71 this.wide.set(at, ch.length > 1 && code >= 0xd800 ? ch.slice(0, 2) : ch[0])
72 this.cells[i] = 0x20
73 } else this.cells[i] = ch === TAIL ? 0x20 : code
74 this.cells[i + 1] = fg
75 this.cells[i + 2] = bg
76 }
77
78 fill(x: number, y: number, w: number, h: number, bg: number) {
79 for (let row = y; row < y + h; row++) for (let col = x; col < x + w; col++) this.set(col, row, ' ', bg, bg)
80 }
81
82 background(x: number, y: number, w: number, bg: number) {
83 if (y < 0 || y >= this.height) return
84 for (let col = Math.max(0, x); col < Math.min(this.width, x + w); col++) this.cells[(y * this.width + col) * 3 + 2] = bg
85 }
86
87 // Writes text from (x, y), cut at `maxX`; returns where it stopped. A wide
88 // character takes two cells, and one that would be cut leaves a blank.
89 text(x: number, y: number, s: string, fg: number, bg: number, maxX = this.width): number {
90 for (let i = 0; i < s.length && x < maxX; i++) {
91 const code = s.charCodeAt(i)
92 const isPair = code >= 0xd800 && code <= 0xdbff && i + 1 < s.length
93 if (!isPair && !isWide(code)) {
94 this.set(x++, y, s[i], fg, bg)
95 continue
96 }
97 if (x + 1 >= maxX) {
98 this.set(x++, y, ' ', fg, bg)
99 break
100 }
101 this.set(x++, y, s[i], fg, bg)
102 this.set(x++, y, isPair ? s[++i] : TAIL, fg, bg)
103 if (!isPair && s[i + 1] === TAIL) i++
104 }
105 return x
106 }
107
108 // The wide characters within one rectangle, from its corner, as runs of
109 // one color each.
110 wideRuns(x: number, y: number, w: number, h: number): WideRun[] {
111 const runs: WideRun[] = []
112 const heads = [...this.wide.keys()].sort((a, b) => a - b)
113 for (const at of heads) {
114 const col = at % this.width
115 const row = Math.floor(at / this.width)
116 const text = this.wide.get(at)!
117 if (row < y || row >= y + h || col < x || col + 1 >= x + w) continue
118 if (text.length === 1 && text.charCodeAt(0) >= 0xd800 && text.charCodeAt(0) <= 0xdbff) continue
119 const fg = this.cells[at * 3 + 1]
120 const bg = this.cells[at * 3 + 2]
121 const last = runs[runs.length - 1]
122 if (last && last.y === row - y && last.x + 2 * [...last.text].length === col - x && last.fg === fg && last.bg === bg) last.text += text
123 else runs.push({ x: col - x, y: row - y, text, fg, bg })
124 }
125 return runs
126 }
127
128 // The cells of one rectangle, base64 as a Raster takes them.
129 encode(x: number, y: number, w: number, h: number): string {
130 const out = new Uint32Array(w * h * 3)
131 for (let row = 0; row < h; row++) {
132 const from = ((y + row) * this.width + x) * 3
133 out.set(this.cells.subarray(from, from + w * 3), row * w * 3)
134 }
135 return toBase64(new Uint8Array(out.buffer))
136 }
137}
138
139const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
140
141function toBase64(bytes: Uint8Array): string {
142 const parts: string[] = []
143 let chunk = ''
144 let i = 0
145 for (; i + 2 < bytes.length; i += 3) {
146 const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
147 chunk += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63]
148 if (chunk.length >= 8192) {
149 parts.push(chunk)
150 chunk = ''
151 }
152 }
153 const rest = bytes.length - i
154 if (rest === 1) {
155 const n = bytes[i] << 16
156 chunk += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + '=='
157 } else if (rest === 2) {
158 const n = (bytes[i] << 16) | (bytes[i + 1] << 8)
159 chunk += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + '='
160 }
161 parts.push(chunk)
162 return parts.join('')
163}
164
165const MARKER = 0xffd75f
166
167// `a` laid over `b` at `share` of its strength.
168function mix(a: number, b: number, share: number): number {
169 const channel = (shift: number) => Math.round(((a >> shift) & 255) * share + ((b >> shift) & 255) * (1 - share)) << shift
170 return channel(16) | channel(8) | channel(0)
171}
172
173// Fades a color toward the background, as gitlogue dims rows away from the cursor.
174function fade(fg: number, bg: number, distance: number): number {
175 if (distance <= 0 || fg === DEFAULT_COLOR || bg === DEFAULT_COLOR) return fg
176 const opacity = 1 - (Math.min(distance, 20) / 20) * 0.4
177 const mix = (shift: number) =>
178 Math.round(((fg >> shift) & 255) * opacity + ((bg >> shift) & 255) * (1 - opacity)) << shift
179 return mix(16) | mix(8) | mix(0)
180}
181
182function tokenColor(theme: Theme, tok: number): number {
183 switch (tok) {
184 case Tok.Keyword:
185 return theme.syntaxKeyword
186 case Tok.Type:
187 return theme.syntaxType
188 case Tok.Function:
189 return theme.syntaxFunction
190 case Tok.String:
191 return theme.syntaxString
192 case Tok.Number:
193 return theme.syntaxNumber
194 case Tok.Comment:
195 return theme.syntaxComment
196 case Tok.Operator:
197 return theme.syntaxOperator
198 case Tok.Punctuation:
199 return theme.syntaxPunctuation
200 case Tok.Constant:
201 return theme.syntaxConstant
202 case Tok.Parameter:
203 return theme.syntaxParameter
204 case Tok.Property:
205 return theme.syntaxProperty
206 case Tok.Label:
207 return theme.syntaxLabel
208 default:
209 return theme.syntaxVariable
210 }
211}
212
213type Box = { x: number; y: number; w: number; h: number }
214
215// The inside of a pane after gitlogue's padding: one row top and bottom, two columns each side.
216function inner(box: Box): Box {
217 const padY = box.h >= 3 ? 1 : 0
218 const padX = box.w >= 12 ? 2 : 0
219 return { x: box.x + padX, y: box.y + padY, w: box.w - 2 * padX, h: box.h - 2 * padY }
220}
221
222export function paint(player: Player, theme: Theme, layout: Layout, cursorOn: boolean): Canvas {
223 const canvas = new Canvas(layout.width, layout.height)
224 const { leftWidth, rightWidth, topRows, bottomRows } = layout
225 if (leftWidth > 0) {
226 paintTree(canvas, player, theme, { x: 0, y: 0, w: leftWidth, h: topRows })
227 if (bottomRows > 0) {
228 canvas.text(0, topRows, '─'.repeat(leftWidth), theme.separator, theme.backgroundLeft)
229 canvas.fill(0, topRows + 1, leftWidth, bottomRows, theme.backgroundLeft)
230 }
231 }
232 const editorBox = { x: leftWidth, y: 0, w: rightWidth, h: topRows }
233 if (player.screen === 'browser') paintBrowser(canvas, player, theme, editorBox, cursorOn)
234 else if (player.screen === 'image' && player.image) paintViewer(canvas, player, theme, editorBox)
235 else if (player.screen === 'cast' && player.cast) paintCast(canvas, player, theme, editorBox)
236 else paintEditor(canvas, player, theme, editorBox, cursorOn)
237 if (bottomRows > 0) {
238 canvas.text(leftWidth, topRows, '─'.repeat(rightWidth), theme.separator, theme.backgroundRight)
239 paintTerminal(canvas, player, theme, { x: leftWidth, y: topRows + 1, w: rightWidth, h: bottomRows }, cursorOn)
240 }
241 if (player.dialog) paintDialog(canvas, player.dialog, theme)
242 if (player.palette) paintPalette(canvas, player, theme, cursorOn)
243 return canvas
244}
245
246function paintEditor(canvas: Canvas, player: Player, theme: Theme, box: Box, cursorOn: boolean) {
247 const bg = theme.backgroundRight
248 canvas.fill(box.x, box.y, box.w, box.h, bg)
249 const area = inner(box)
250 if (area.w <= 0 || area.h <= 0) return
251
252 if (!player.hasFile) {
253 const message = player.turn ? words.waitingEdit : words.waiting
254 const x = area.x + Math.max(0, Math.floor((area.w - cellWidth(message)) / 2))
255 canvas.text(x, area.y + Math.floor(area.h / 2), message, theme.statusNoCommit, bg, area.x + area.w)
256 return
257 }
258
259 const lines = player.lines
260 const numberWidth = Math.max(3, String(lines.length).length)
261 const gutter = numberWidth + 1 + 2
262 const textWidth = Math.max(1, area.w - gutter)
263 const rowsOf = (line: string) => Math.max(1, Math.ceil(line.length / textWidth))
264
265 // Keep the cursor's row in the middle, as gitlogue scrolls.
266 let cursorRow = 0
267 let total = 0
268 for (let i = 0; i < lines.length; i++) {
269 if (i === player.cursorLine) cursorRow = total + Math.min(rowsOf(lines[i]) - 1, Math.floor(player.cursorCol / textWidth))
270 total += rowsOf(lines[i])
271 }
272 const half = Math.floor(area.h / 2)
273 const top =
274 cursorRow < half ? 0 : cursorRow + half >= total ? Math.max(0, total - area.h) : cursorRow - half
275
276 let index = 0
277 let skipped = 0
278 while (index < lines.length && skipped + rowsOf(lines[index]) <= top) skipped += rowsOf(lines[index++])
279 let sub = top - skipped
280
281 const showCursor = cursorOn && player.active === 'editor'
282 for (let row = 0; row < area.h && index < lines.length; row++) {
283 const y = area.y + row
284 const line = lines[index]
285 const isCursorLine = index === player.cursorLine
286 const rowBg = isCursorLine ? theme.editorCursorLineBg : bg
287 const distance = Math.abs(index - player.cursorLine)
288 if (isCursorLine) canvas.background(box.x, y, box.w, rowBg)
289
290 if (sub === 0) {
291 const number = String(index + 1).padStart(numberWidth) + ' '
292 const color = isCursorLine ? theme.editorLineNumberCursor : fade(theme.editorLineNumber, rowBg, distance)
293 canvas.text(area.x, y, number, color, rowBg)
294 }
295
296 const toks = player.highlighter.line(lines, index)
297 const from = sub * textWidth
298 const to = Math.min(line.length, from + textWidth)
299 const x0 = area.x + gutter
300 // The highlighter: over the text of each line read, from its first word.
301 const marks = player.marks
302 const markEnd =
303 !marks || index < marks.from || index > marks.line ? 0 : index < marks.line ? line.length : marks.col
304 const markStart = line.length - line.trimStart().length
305 for (let col = from; col < to; col++) {
306 const isMarked = col >= markStart && col < markEnd
307 const cellBg = isMarked ? mix(MARKER, rowBg, 0.34) : rowBg
308 canvas.set(x0 + col - from, y, line[col], fade(tokenColor(theme, toks[col]), cellBg, isMarked ? 0 : distance), cellBg)
309 }
310
311 if (isCursorLine && showCursor) {
312 const isLastRow = sub === rowsOf(line) - 1
313 const col = player.cursorCol
314 if ((col >= from && col < from + textWidth) || (isLastRow && col >= from)) {
315 const x = x0 + Math.min(col - from, textWidth - 1)
316 canvas.set(x, y, line[col] ?? ' ', theme.editorCursorCharFg, theme.editorCursorCharBg)
317 }
318 }
319
320 if (++sub >= rowsOf(line)) {
321 sub = 0
322 index++
323 }
324 }
325}
326
327// The workspace as a file explorer: folders open along the paths Claude has
328// walked, the selection where it last went, and each changed file marked with
329// its counts; files the current turn has not touched recede.
330function paintExplorer(canvas: Canvas, player: Player, theme: Theme, area: Box) {
331 const bg = theme.backgroundLeft
332 const changed = new Map<string, FileEntry>()
333 for (const entry of player.files.values()) if (entry.file) changed.set(entry.file, entry)
334 const rows = player.explorer.rows(new Set(changed.keys()))
335 const selected = rows.findIndex(row => row.path === player.explorer.selected)
336 const offset =
337 selected >= area.h ? Math.min(selected - Math.floor(area.h / 2), rows.length - area.h) : 0
338 const turn = player.turn?.id
339 const maxX = area.x + area.w
340 const hasChanges = (dir: string) => [...changed.keys()].some(path => path.startsWith(dir + '/'))
341
342 for (let r = 0; r < area.h && offset + r < rows.length; r++) {
343 const index = offset + r
344 const row = rows[index]
345 const y = area.y + r
346 const isSelected = index === selected
347 const rowBg = isSelected ? theme.fileTreeCurrentFileBg : bg
348 if (isSelected) canvas.background(area.x, y, area.w, rowBg)
349 const entry = changed.get(row.path)
350 const isPast = entry ? turn !== undefined && entry.turn !== turn : row.kind === 'file'
351 const distance = Math.max(selected < 0 ? 0 : Math.abs(index - selected), isPast ? 12 : 0)
352 const dim = (color: number) => fade(color, rowBg, distance)
353 let x = area.x + row.depth * 2
354 const put = (text: string, color: number) => (x = canvas.text(x, y, text, dim(color), rowBg, maxX))
355
356 switch (row.kind) {
357 case 'root':
358 case 'dir': {
359 put(row.isOpen ? '▾ ' : '▸ ', theme.separator)
360 const color = hasChanges(row.path) ? theme.fileTreeModified : theme.fileTreeDirectory
361 put(row.name + (row.kind === 'dir' ? '/' : ''), row.kind === 'root' ? theme.fileTreeCurrentFileFg : color)
362 break
363 }
364 case 'more':
365 put(' ' + row.name, theme.separator)
366 break
367 case 'file':
368 if (!entry) {
369 put(' ' + row.name, theme.fileTreeDefault)
370 break
371 }
372 if (entry.committed) {
373 put('✓ ' + row.name, theme.editorLineNumber)
374 break
375 }
376 if (player.flight && row.path === player.explorer.selected) {
377 put(' ' + row.name, theme.separator)
378 break
379 }
380 put(entry.status + ' ', entry.status === '+' ? theme.fileTreeAdded : theme.fileTreeModified)
381 put(row.name, isSelected ? theme.fileTreeCurrentFileFg : theme.fileTreeDefault)
382 if (entry.note) {
383 put(' ' + entry.note, theme.editorLineNumber)
384 break
385 }
386 put(` +${entry.added}`, theme.fileTreeStatsAdded)
387 put(` -${entry.deleted}`, theme.fileTreeStatsDeleted)
388 break
389 }
390 }
391 paintTrash(canvas, player, theme, area, offset)
392}
393
394// Where the picture sits in the image viewer, in cells of the right column:
395// as large as the area allows, at the picture's own proportions (a cell is
396// about twice as tall as it is wide), centred.
397export function imageBox(layout: Layout, image: { width: number; height: number }) {
398 const area = inner({ x: 0, y: 0, w: layout.rightWidth, h: layout.topRows })
399 const top = area.y + 2
400 const room = { columns: Math.min(255, area.w), rows: Math.min(255, area.h - 2) }
401 if (room.columns < 2 || room.rows < 2 || image.width < 1 || image.height < 1) return undefined
402 let columns = room.columns
403 let rows = Math.round((columns * image.height) / image.width / 2)
404 if (rows > room.rows) {
405 rows = room.rows
406 columns = Math.min(room.columns, Math.round((rows * 2 * image.width) / image.height))
407 }
408 columns = Math.max(1, columns)
409 rows = Math.max(1, rows)
410 return {
411 left: area.x + Math.floor((room.columns - columns) / 2),
412 top: top + Math.floor((room.rows - rows) / 2),
413 columns,
414 rows,
415 }
416}
417
418// The picture's frame: its name and size above; the picture itself is laid
419// over the empty area by the pane's drawing.
420function paintViewer(canvas: Canvas, player: Player, theme: Theme, box: Box) {
421 const bg = theme.backgroundRight
422 canvas.fill(box.x, box.y, box.w, box.h, bg)
423 const area = inner(box)
424 const image = player.image
425 if (!image || area.w <= 0 || area.h <= 0) return
426 let x = canvas.text(area.x, area.y, '▣ ', theme.fileTreeModified, bg)
427 x = canvas.text(x, area.y, image.path, theme.fileTreeCurrentFileFg, bg, area.x + area.w)
428 canvas.text(x, area.y, ` ${image.width}×${image.height}`, theme.editorLineNumber, bg, area.x + area.w)
429 canvas.text(area.x, area.y + 1, '─'.repeat(area.w), theme.separator, bg)
430}
431
432// A recording of one of Claude's screens: which screen, its size and the
433// speed it plays at above; the frames themselves are laid over by the pane.
434function paintCast(canvas: Canvas, player: Player, theme: Theme, box: Box) {
435 const bg = theme.backgroundRight
436 canvas.fill(box.x, box.y, box.w, box.h, bg)
437 const area = inner(box)
438 const cast = player.cast
439 if (!cast || area.w <= 0 || area.h <= 0) return
440 let x = canvas.text(area.x, area.y, '● ', theme.fileTreeDeleted, bg)
441 x = canvas.text(x, area.y, words.screen(cast.screen), theme.fileTreeCurrentFileFg, bg, area.x + area.w)
442 canvas.text(x, area.y, ` ${cast.width}×${cast.height} ×${cast.speed}`, theme.editorLineNumber, bg, area.x + area.w)
443 canvas.text(area.x, area.y + 1, '─'.repeat(area.w), theme.separator, bg)
444}
445
446const SEARCH_MARK: [string, number][] = [
447 ['G', 0x4285f4],
448 ['o', 0xea4335],
449 ['o', 0xfbbc05],
450 ['g', 0x4285f4],
451 ['l', 0x34a853],
452 ['e', 0xea4335],
453]
454
455// The editor's area as a browser: the address bar and the line that fills as
456// a page loads, over a search page, its results, or the page being read.
457function paintBrowser(canvas: Canvas, player: Player, theme: Theme, box: Box, cursorOn: boolean) {
458 const bg = theme.backgroundRight
459 canvas.fill(box.x, box.y, box.w, box.h, bg)
460 const area = inner(box)
461 if (area.w < 12 || area.h < 4) return
462 const b = player.browser
463 const maxX = area.x + area.w
464 const typing = cursorOn && player.active === 'explorer'
465 const bar = theme.editorCursorLineBg
466
467 // The address bar.
468 let x = canvas.text(area.x, area.y, '‹ › ⟳ ', theme.separator, bg)
469 canvas.background(x, area.y, maxX - x, bar)
470 x = canvas.text(x, area.y, ' ⌕ ', theme.editorLineNumber, bar)
471 const room = maxX - x - 2
472 const url = b.url.length > room ? '…' + b.url.slice(b.url.length - room + 1) : b.url
473 x = canvas.text(x, area.y, url, theme.fileTreeCurrentFileFg, bar, maxX)
474 if (typing && b.focus === 'address') canvas.set(Math.min(x, maxX - 1), area.y, ' ', theme.editorCursorCharFg, theme.editorCursorCharBg)
475
476 // The load line under it.
477 const filled = Math.round(area.w * b.loading)
478 for (let i = 0; i < area.w; i++)
479 canvas.set(area.x + i, area.y + 1, b.loading < 1 && i < filled ? '━' : '─', b.loading < 1 && i < filled ? theme.editorCursorCharBg : theme.separator, bg)
480
481 const top = area.y + 2
482 const rows = area.h - 2
483 const mark = (mx: number, y: number, spaced: boolean) => {
484 for (const [ch, color] of SEARCH_MARK) {
485 canvas.set(mx, y, ch, color, bg)
486 mx += spaced ? 2 : 1
487 }
488 return mx
489 }
490
491 if (b.page === 'home') {
492 const width = Math.min(area.w - 4, 64)
493 const left = area.x + Math.floor((area.w - width) / 2)
494 const y = top + Math.max(0, Math.floor(rows / 2) - 4)
495 mark(area.x + Math.floor((area.w - 11) / 2), y, true)
496 searchBox(canvas, theme, left, y + 2, width, b.query, typing && b.focus === 'box', bg)
497 const buttons = words.googleButtons
498 canvas.text(area.x + Math.max(0, Math.floor((area.w - buttons.length) / 2)), y + 6, buttons, theme.editorLineNumber, bg, maxX)
499 return
500 }
501
502 if (b.page === 'results') {
503 let mx = mark(area.x, top, false)
504 mx += 2
505 canvas.background(mx, top, Math.min(maxX - mx, Math.max(20, b.query.length + 6)), bar)
506 canvas.text(mx + 1, top, '⌕ ' + b.query, theme.fileTreeCurrentFileFg, bar, maxX)
507 canvas.text(area.x, top + 1, b.loading < 1 ? '' : b.stats, theme.editorLineNumber, bg, maxX)
508 const per = 3
509 const first = Math.max(0, Math.min(b.selected - Math.floor((rows - 3) / per / 2), b.shown - Math.floor((rows - 3) / per)))
510 for (let i = first, y = top + 3; i < b.shown && y + 1 < top + rows; i++, y += per) {
511 const link = b.results[i]
512 const isSelected = i === b.selected
513 const rowBg = isSelected ? bar : bg
514 if (isSelected) {
515 canvas.background(area.x, y, area.w, rowBg)
516 canvas.background(area.x, y + 1, area.w, rowBg)
517 }
518 const host = link.url.replace(/^https?:\/\//, '')
519 canvas.text(area.x + 2, y, host.length > area.w - 2 ? host.slice(0, area.w - 3) + '…' : host, theme.statusAuthor, rowBg, maxX)
520 canvas.text(area.x + 2, y + 1, link.title.length > area.w - 2 ? link.title.slice(0, area.w - 3) + '…' : link.title, theme.syntaxFunction, rowBg, maxX)
521 if (isSelected) canvas.set(area.x, y + 1, '▸', theme.editorCursorCharBg, rowBg)
522 }
523 return
524 }
525
526 // A page being read: its address as the title, the text wrapped, the line
527 // being read a third of the way down and the rest receding from it.
528 canvas.text(area.x, top, b.url.replace(/\/.*$/, ''), theme.statusHash, bg, maxX)
529 const wrapped: { text: string; source: number }[] = []
530 // Wrapped at spaces, as a page reflows; a word longer than a line is cut.
531 b.body.forEach((line, source) => {
532 if (line.length === 0) wrapped.push({ text: '', source })
533 let rest = line
534 while (rest.length > 0) {
535 let cut = rest.length <= area.w ? rest.length : rest.lastIndexOf(' ', area.w)
536 if (cut <= 0) cut = Math.min(area.w, rest.length)
537 wrapped.push({ text: rest.slice(0, cut), source })
538 rest = rest.slice(cut).replace(/^ /, '')
539 }
540 })
541 const start = wrapped.findIndex(row => row.source >= b.scroll)
542 const view = rows - 2
543 const reading = Math.floor(view / 3)
544 const from = Math.max(0, (start < 0 ? wrapped.length : start) - reading)
545 let inFence = false
546 for (let i = 0; i < from; i++) if (/^\s*```/.test(wrapped[i].text)) inFence = !inFence
547 for (let r = 0; r < view && from + r < wrapped.length; r++) {
548 const row = wrapped[from + r]
549 const y = top + 2 + r
550 const distance = Math.abs(r - reading) * 2
551 const fence = /^\s*```/.test(row.text)
552 const color = fence || inFence
553 ? theme.syntaxString
554 : /^\s*#/.test(row.text)
555 ? theme.syntaxKeyword
556 : /^\s*([-*+]|\d+\.)\s/.test(row.text)
557 ? theme.syntaxVariable
558 : theme.terminalCommand
559 if (fence) inFence = !inFence
560 canvas.text(area.x, y, row.text, fade(color, bg, distance), bg, maxX)
561 }
562}
563
564function searchBox(canvas: Canvas, theme: Theme, x: number, y: number, width: number, query: string, cursor: boolean, bg: number) {
565 const edge = theme.editorLineNumber
566 canvas.text(x, y, '╭' + '─'.repeat(width - 2) + '╮', edge, bg)
567 canvas.set(x, y + 1, '│', edge, bg)
568 canvas.set(x + width - 1, y + 1, '│', edge, bg)
569 canvas.text(x, y + 2, '╰' + '─'.repeat(width - 2) + '╯', edge, bg)
570 const room = width - 6
571 const shown = query.length > room ? '…' + query.slice(query.length - room + 1) : query
572 const end = canvas.text(canvas.text(x + 2, y + 1, '⌕ ', theme.editorLineNumber, bg), y + 1, shown, theme.fileTreeCurrentFileFg, bg)
573 if (cursor) canvas.set(end, y + 1, ' ', theme.editorCursorCharFg, theme.editorCursorCharBg)
574}
575
576// The trash, in the explorer's lower right, once anything has gone to it: a
577// can whose lid lifts as a file comes, and the file's name on its way there.
578function paintTrash(canvas: Canvas, player: Player, theme: Theme, area: Box, offset: number) {
579 const flight = player.flight
580 if (!flight && player.trashed === 0) return
581 const bg = theme.backgroundLeft
582 const x = area.x + area.w - 7
583 const y = area.y + area.h - 4
584 if (x < area.x || y < area.y) return
585 const isOpen = flight !== undefined && flight.t > 0.45
586 const edge = theme.editorLineNumber
587 canvas.text(x, y, isOpen ? ' ▁▁▁▁╱' : ' ', edge, bg)
588 canvas.text(x, y + 1, isOpen ? ' ' : ' ▁▄▄▁ ', edge, bg)
589 canvas.text(x, y + 2, '▕┊┊┊┊▏', edge, bg)
590 canvas.text(x, y + 3, ' ▔▔▔▔ ', edge, bg)
591 if (player.trashed > 0) canvas.text(x + 6, y + 3, String(player.trashed), theme.fileTreeDeleted, bg)
592 if (!flight) return
593 // From the file's row to the can's mouth, rising and then dropping in.
594 const t = flight.t
595 const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
596 const fromX = area.x + flight.depth * 2 + 2
597 const fromY = area.y + flight.row - offset
598 const toX = x + 1
599 const toY = y + 1
600 const fx = Math.round(fromX + (toX - fromX) * eased)
601 const fy = Math.round(fromY + (toY - fromY) * eased - Math.sin(Math.PI * t) * 3)
602 const label = t > 0.85 ? '·' : flight.name.length > 20 ? flight.name.slice(0, 19) + '…' : flight.name
603 canvas.text(Math.max(area.x, Math.min(fx, area.x + area.w - label.length)), Math.max(area.y, fy), label, theme.fileTreeDeleted, bg, area.x + area.w)
604}
605
606// The sidebar's search view, as the editor's: the query in its box, the
607// count, then each file with its matching lines, the match itself marked.
608function paintSearch(canvas: Canvas, player: Player, theme: Theme, area: Box) {
609 const bg = theme.backgroundLeft
610 const search = player.search!
611 const maxX = area.x + area.w
612 canvas.text(area.x, area.y, words.searchTitle, theme.editorLineNumber, bg)
613 const box = theme.editorCursorLineBg
614 canvas.background(area.x, area.y + 1, area.w, box)
615 const room = area.w - 2
616 const query = search.query.length > room ? '…' + search.query.slice(search.query.length - room + 1) : search.query
617 const end = canvas.text(area.x + 1, area.y + 1, query, theme.fileTreeCurrentFileFg, box, maxX)
618 if (player.active === 'explorer' && search.shown === 0) canvas.set(Math.min(end, maxX - 1), area.y + 1, ' ', theme.editorCursorCharFg, theme.editorCursorCharBg)
619 const files = search.hits.slice(0, search.shown)
620 const matches = files.reduce((n, hit) => n + Math.max(1, hit.matches.length), 0)
621 if (search.shown > 0)
622 canvas.text(area.x, area.y + 2, words.searchResults(search.total || matches, search.hits.length), theme.editorLineNumber, bg, maxX)
623 let pattern: RegExp | undefined
624 try {
625 pattern = new RegExp(search.query, 'i')
626 } catch {
627 pattern = undefined
628 }
629 let y = area.y + 4
630 for (const hit of files) {
631 if (y >= area.y + area.h) break
632 const name = hit.path.slice(hit.path.lastIndexOf('/') + 1)
633 const folder = hit.path.slice(0, Math.max(0, hit.path.lastIndexOf('/')))
634 let x = canvas.text(area.x, y, '▾ ' + name, theme.fileTreeDefault, bg, maxX)
635 x = canvas.text(x + 1, y, folder, theme.editorLineNumber, bg, maxX - 4)
636 if (hit.matches.length > 0) canvas.text(maxX - String(hit.matches.length).length - 1, y, String(hit.matches.length), theme.statusHash, bg, maxX)
637 y++
638 for (const match of hit.matches) {
639 if (y >= area.y + area.h) break
640 const text = match.text.trimStart()
641 const found = pattern?.exec(text)
642 const cut = Math.max(0, (found?.index ?? 0) - 8)
643 const shown = (cut > 0 ? '…' : '') + text.slice(cut)
644 const at = found ? found.index - cut + (cut > 0 ? 1 : 0) : -1
645 canvas.text(area.x + 3, y, shown, theme.terminalOutput, bg, maxX)
646 if (found && at >= 0)
647 for (let i = 0; i < found[0].length && area.x + 3 + at + i < maxX; i++)
648 canvas.set(area.x + 3 + at + i, y, shown[at + i] ?? ' ', theme.fileTreeCurrentFileFg, mix(MARKER, bg, 0.3))
649 y++
650 }
651 }
652}
653
654// The quick-open palette, at the top middle of the pane: the pattern typed,
655// and the files it finds beneath.
656function paintPalette(canvas: Canvas, player: Player, theme: Theme, cursorOn: boolean) {
657 const palette = player.palette!
658 const width = Math.min(canvas.width - 4, 64)
659 if (width < 16) return
660 const x = Math.floor((canvas.width - width) / 2)
661 const y = 1
662 const fg = theme.fileTreeCurrentFileFg
663 const bg = theme.editorCursorLineBg
664 const rows = 1 + palette.shown
665 canvas.fill(x, y, width, rows + 2, bg)
666 canvas.text(x, y, '┌' + '─'.repeat(width - 2) + '┐', theme.separator, bg)
667 for (let r = 1; r <= rows; r++) {
668 canvas.set(x, y + r, '│', theme.separator, bg)
669 canvas.set(x + width - 1, y + r, '│', theme.separator, bg)
670 }
671 canvas.text(x, y + rows + 1, '└' + '─'.repeat(width - 2) + '┘', theme.separator, bg)
672 const end = canvas.text(x + 2, y + 1, palette.text, fg, bg, x + width - 2)
673 if (cursorOn && palette.shown === 0) canvas.set(end, y + 1, ' ', theme.editorCursorCharFg, theme.editorCursorCharBg)
674 palette.items.slice(0, palette.shown).forEach((item, i) => {
675 const rowBg = i === 0 ? theme.fileTreeCurrentFileBg : bg
676 canvas.background(x + 1, y + 2 + i, width - 2, rowBg)
677 const name = item.slice(item.lastIndexOf('/') + 1)
678 const nx = canvas.text(x + 2, y + 2 + i, name, fg, rowBg, x + width - 2)
679 canvas.text(nx + 1, y + 2 + i, item.slice(0, Math.max(0, item.lastIndexOf('/'))), theme.editorLineNumber, rowBg, x + width - 2)
680 })
681}
682
683type TreeRow = { dir: string } | { entry: FileEntry; name: string; indent: boolean }
684
685function treeRows(files: Map<string, FileEntry>): TreeRow[] {
686 const byDir = new Map<string, FileEntry[]>()
687 for (const entry of files.values()) {
688 const cut = entry.path.lastIndexOf('/')
689 const dir = cut < 0 ? '' : entry.path.slice(0, cut)
690 byDir.set(dir, [...(byDir.get(dir) ?? []), entry])
691 }
692 const rows: TreeRow[] = []
693 for (const dir of [...byDir.keys()].sort()) {
694 if (dir) rows.push({ dir })
695 const entries = byDir.get(dir)!.map(entry => ({ entry, name: entry.path.slice(dir ? dir.length + 1 : 0) }))
696 entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
697 for (const { entry, name } of entries) rows.push({ entry, name, indent: dir !== '' })
698 }
699 return rows
700}
701
702function paintTree(canvas: Canvas, player: Player, theme: Theme, box: Box) {
703 const bg = theme.backgroundLeft
704 canvas.fill(box.x, box.y, box.w, box.h, bg)
705 const area = inner(box)
706 if (area.w <= 0 || area.h <= 0) return
707 if (player.sidebar === 'search' && player.search) return paintSearch(canvas, player, theme, area)
708 if (player.explorer.roots.length > 0) return paintExplorer(canvas, player, theme, area)
709 const rows = treeRows(player.files)
710 // Files the current turn has not touched recede.
711 const turn = player.turn?.id
712 const isOld = (entry: FileEntry) => turn !== undefined && entry.turn !== turn
713 const oldDirs = new Set(
714 [...new Set([...player.files.values()].map(entry => entry.path.slice(0, Math.max(0, entry.path.lastIndexOf('/')))))].filter(
715 dir => [...player.files.values()].every(entry => !entry.path.startsWith(dir + '/') || isOld(entry)),
716 ),
717 )
718 const selected = rows.findIndex(row => 'entry' in row && row.entry.path === player.currentPath)
719 const offset =
720 selected >= area.h ? Math.min(selected - Math.floor(area.h / 2), rows.length - area.h) : 0
721 const maxX = area.x + area.w
722
723 for (let r = 0; r < area.h && offset + r < rows.length; r++) {
724 const index = offset + r
725 const row = rows[index]
726 const y = area.y + r
727 const isSelected = index === selected
728 const rowBg = isSelected ? theme.fileTreeCurrentFileBg : bg
729 const isPast = 'dir' in row ? oldDirs.has(row.dir) : isOld(row.entry)
730 const distance = Math.max(selected < 0 ? 0 : Math.abs(index - selected), isPast ? 14 : 0)
731 const dim = (color: number) => fade(color, rowBg, distance)
732 if (isSelected) canvas.background(area.x, y, area.w, rowBg)
733 if ('dir' in row) {
734 canvas.text(area.x, y, row.dir + '/', dim(theme.fileTreeDirectory), rowBg, maxX)
735 continue
736 }
737 const { entry } = row
738 const statusColor = entry.status === '+' ? theme.fileTreeAdded : theme.fileTreeModified
739 let x = area.x + (row.indent ? 2 : 0)
740 x = canvas.text(x, y, entry.status + ' ', dim(statusColor), rowBg, maxX)
741 const nameColor = isSelected ? theme.fileTreeCurrentFileFg : theme.fileTreeDefault
742 x = canvas.text(x, y, row.name, dim(nameColor), rowBg, maxX)
743 x = canvas.text(x, y, ` +${entry.added}`, dim(theme.fileTreeStatsAdded), rowBg, maxX)
744 canvas.text(x, y, ` -${entry.deleted}`, dim(theme.fileTreeStatsDeleted), rowBg, maxX)
745 }
746}
747
748function elapsed(ms: number): string {
749 const s = Math.floor(ms / 1000)
750 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
751}
752
753// Claude's own mark, the one color the terminal keeps whatever the theme.
754const CLAUDE = 0xd77757
755
756// The shell as Claude meets it: what each command is for, the command, the
757// head of what came back, the files written, and how the turn closed. Rows
758// fade as they recede, the way older context does.
759function paintTerminal(canvas: Canvas, player: Player, theme: Theme, box: Box, cursorOn: boolean) {
760 const bg = theme.backgroundRight
761 canvas.fill(box.x, box.y, box.w, box.h, bg)
762 const area = inner(box)
763 if (area.w <= 0 || area.h <= 0) return
764 const lines = player.terminal
765 const start = Math.max(0, lines.length - area.h)
766 const maxX = area.x + area.w
767 const fit = (text: string, room: number, keepTail = false) =>
768 text.length <= room ? text : room <= 1 ? '' : keepTail ? '…' + text.slice(text.length - room + 1) : text.slice(0, room - 1) + '…'
769
770 for (let i = start; i < lines.length; i++) {
771 const line = lines[i]
772 const y = area.y + i - start
773 const isLast = i === lines.length - 1
774 const age = Math.min(20, (lines.length - 1 - i) * 3)
775 const ink = (color: number) => fade(color, bg, age)
776 let x = area.x
777 const put = (text: string, color: number) => (x = canvas.text(x, y, text, ink(color), bg, maxX))
778 const right = line.right ?? ''
779 const room = (lead: number) => Math.max(0, area.w - lead - (right ? right.length + 2 : 0))
780
781 switch (line.kind) {
782 case 'rule': {
783 const [id = '', time = ''] = line.text.split(' · ')
784 put('── ', theme.separator)
785 put(id, theme.statusHash)
786 put(' · ', theme.separator)
787 put(time, theme.statusDate)
788 put(' ' + '─'.repeat(Math.max(0, maxX - x - 1)), theme.separator)
789 break
790 }
791 case 'intent':
792 put('# ' + fit(line.text, area.w - 2), theme.syntaxComment)
793 break
794 case 'command': {
795 put('❯ ', CLAUDE)
796 const isTyping = line.right === undefined
797 put(fit(line.text, room(2) - (isTyping ? 1 : 0), isTyping), theme.terminalCommand)
798 if (isLast && isTyping && cursorOn && player.active === 'terminal')
799 canvas.set(Math.min(x, maxX - 1), y, ' ', theme.terminalCursorFg, theme.terminalCursorBg)
800 if (right) {
801 const cut = right.indexOf(' ')
802 x = maxX - right.length
803 put(right.slice(0, cut + 1), theme.terminalOutput)
804 put(right.slice(cut + 1), line.ok ? theme.fileTreeAdded : theme.fileTreeDeleted)
805 } else if (line.running && line.startedAt) {
806 // Still running: a spinner and the time so far.
807 const spinner = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
808 const now = Date.now()
809 const status = `${spinner[Math.floor(now / 100) % spinner.length]} ${elapsed(now - line.startedAt)}`
810 x = maxX - status.length
811 put(status, theme.editorCursorCharBg)
812 }
813 break
814 }
815 case 'commit': {
816 put('◆ ', CLAUDE)
817 put(line.sha ?? '', theme.statusHash)
818 if (line.branch) put(` (${line.branch})`, theme.statusDate)
819 put(' ' + fit(line.text, Math.max(0, maxX - x - 1)), theme.terminalCommand)
820 break
821 }
822 case 'cast':
823 put('▶ ', theme.fileTreeDeleted)
824 put(fit(line.text, Math.max(0, maxX - x - (right ? right.length + 2 : 0))), theme.terminalCommand)
825 if (right) {
826 x = maxX - right.length
827 put(right, theme.editorLineNumber)
828 }
829 break
830 case 'trash':
831 put('✗ ', theme.fileTreeDeleted)
832 put(words.movedToTrash + ' ', theme.terminalOutput)
833 put(fit(line.text, Math.max(0, maxX - x)), theme.fileTreeDeleted)
834 break
835 case 'output':
836 put(line.first ? ' ⎿ ' : ' ', theme.separator)
837 put(fit(line.text, area.w - 5), theme.terminalOutput)
838 break
839 case 'more':
840 put(' ' + fit(line.text, area.w - 5), theme.separator)
841 break
842 case 'edit': {
843 put('✎ ', theme.fileTreeModified)
844 put(fit(line.text, room(2)), theme.terminalCommand)
845 if (right) {
846 const [added = '', deleted = ''] = right.split(' ')
847 x = maxX - right.length
848 put(added + ' ', theme.fileTreeStatsAdded)
849 put(deleted, theme.fileTreeStatsDeleted)
850 }
851 break
852 }
853 case 'done':
854 put('✻ ', CLAUDE)
855 put(fit(line.text, area.w - 2), theme.terminalOutput)
856 break
857 case 'fail':
858 put('✗ ' + fit(line.text, area.w - 2), theme.fileTreeDeleted)
859 break
860 case 'progress': {
861 const done = (line.fraction ?? 0) >= 1
862 put(done ? '✓ ' : line.up ? '↑ ' : '↓ ', done ? theme.fileTreeAdded : CLAUDE)
863 put(fit(line.text, Math.max(8, Math.floor(area.w / 3))) + ' ', theme.terminalCommand)
864 const barWidth = Math.max(4, maxX - x - right.length - 2)
865 const full = Math.round(barWidth * (line.fraction ?? 0))
866 put('━'.repeat(full), done ? theme.fileTreeAdded : theme.editorCursorCharBg)
867 put('─'.repeat(barWidth - full), theme.separator)
868 x = maxX - right.length
869 put(right, theme.terminalOutput)
870 break
871 }
872 case 'prompt':
873 put('❯ ', CLAUDE)
874 if (isLast && cursorOn) canvas.set(x, y, ' ', theme.terminalCursorFg, theme.terminalCursorBg)
875 break
876 }
877 }
878}
879
880function paintDialog(canvas: Canvas, dialog: { title: string; text: string }, theme: Theme) {
881 const width = Math.min(canvas.width, Math.max(60, dialog.text.length + 10))
882 if (width < 8 || canvas.height < 3) return
883 const x = Math.floor((canvas.width - width) / 2)
884 const y = Math.floor((canvas.height - 3) / 2)
885 const fg = theme.fileTreeCurrentFileFg
886 const bg = theme.editorCursorLineBg
887 canvas.fill(x, y, width, 3, bg)
888 canvas.text(x, y, '┌' + '─'.repeat(width - 2) + '┐', fg, bg)
889 canvas.text(x + 1, y, dialog.title.slice(0, width - 2), fg, bg)
890 canvas.set(x, y + 1, '│', fg, bg)
891 canvas.set(x + width - 1, y + 1, '│', fg, bg)
892 canvas.text(x, y + 2, '└' + '─'.repeat(width - 2) + '┘', fg, bg)
893 const room = width - 4
894 const shown = dialog.text.length > room ? '…' + dialog.text.slice(dialog.text.length - room + 1) : dialog.text
895 canvas.text(x + 2, y + 1, shown, fg, bg)
896}
897hooks/player.ts 1242 lines1// The playback engine: Claude's work arrives as events (a turn begins, a file
2// changes, a command runs, the turn ends), each becomes a script of small steps
3// the way gitlogue scripts a commit, and `advance` plays the script against the
4// clock. Pacing constants are gitlogue's, as multiples of the typing speed.
5
6import type {
7 GitlogueBrowser,
8 GitlogueEvent,
9 GitlogueFlight,
10 GitlogueImage,
11 GitloguePalette,
12 GitlogueSearch,
13 GitlogueFileEntry,
14 GitloguePlace,
15 GitlogueSaved,
16 GitlogueTermLine,
17 GitlogueTurn,
18 GitlogueView,
19} from '../types'
20import { countChanges, diffLines, type Hunk } from './diff'
21import { chainTo, Explorer, nameOf, parentOf, type DirEntry } from './explorer'
22import { cellText } from './cells'
23import { words } from './i18n'
24import { Highlighter } from './highlight'
25
26const CURSOR_MOVE_PAUSE = 0.5
27const CURSOR_MOVE_SHORT_MULTIPLIER = 1.0
28const CURSOR_MOVE_MEDIUM_MULTIPLIER = 0.3
29const CURSOR_MOVE_LONG_MULTIPLIER = 0.05
30const MAX_SCROLL_STEPS = 60
31const MIN_LOG_STEPS = 50
32const LOG_SCALE_FACTOR = 8.0
33const DELETE_LINE_PAUSE = 10.0
34const INSERT_LINE_PAUSE = 6.7
35const HUNK_PAUSE = 50.0
36const CHECKOUT_PAUSE = 16.7
37const CHECKOUT_OUTPUT_PAUSE = 33.3
38const OPEN_FILE_FIRST_PAUSE = 33.3
39const OPEN_FILE_PAUSE = 50.0
40const OPEN_CMD_PAUSE = 16.7
41const FILE_SWITCH_PAUSE = 26.7
42const GIT_ADD_PAUSE = 33.3
43const GIT_ADD_CMD_PAUSE = 16.7
44const PUSH_OUTPUT_PAUSE = 10.0
45
46const MAX_TERMINAL_LINES = 300
47const EXPAND_PAUSE = 6.0
48const SELECT_STEP_PAUSE = 1.5
49const PAGE_LOAD_STEPS = 18
50const MAX_READ_SWEEP = 120
51const FLY_STEPS = 16
52const DOWNLOAD_STEPS = 28
53
54type Screen = 'code' | 'browser' | 'image' | 'cast'
55
56// A recording of one of Claude's own screens, as it plays.
57export type Cast = { screen: string; width: number; height: number; speed: number; seconds: number }
58
59const EMPTY_BROWSER: GitlogueBrowser = {
60 url: '',
61 page: 'home',
62 query: '',
63 results: [],
64 shown: 0,
65 selected: -1,
66 stats: '',
67 body: [],
68 scroll: 0,
69 loading: 1,
70}
71// Past these a change is a machine's output, not something typed: it is
72// counted in the tree and noted in the terminal, never replayed key by key.
73const MAX_TYPED_LINE = 1000
74const MAX_TYPED_CHARS = 100_000
75const LOCK_FILES = new Set([
76 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'Cargo.lock', 'poetry.lock', 'uv.lock',
77 'Pipfile.lock', 'Gemfile.lock', 'composer.lock', 'go.sum', 'flake.lock', 'mix.lock', 'pubspec.lock',
78])
79
80export type TurnInfo = GitlogueTurn
81export type FileEntry = GitlogueFileEntry
82export type PlayerEvent = GitlogueEvent
83export type TermLine = GitlogueTermLine
84
85type FileChange = { path: string; oldLines: string[]; entry: FileEntry }
86
87type StepBody =
88 | { k: 'char'; line: number; col: number; ch: string }
89 | { k: 'backspace'; line: number; col: number }
90 | { k: 'insertLine'; line: number; text: string }
91 | { k: 'deleteLine'; line: number }
92 | { k: 'move'; line: number; col: number }
93 | { k: 'pause' }
94 | { k: 'switchFile'; change: FileChange }
95 | { k: 'count'; entry: FileEntry }
96 | { k: 'root'; root: string }
97 | { k: 'list'; dir: string; entries: DirEntry[]; ensure?: DirEntry }
98 | { k: 'expand'; dir: string }
99 | { k: 'select'; path: string }
100 | { k: 'screen'; screen: Screen }
101 | { k: 'browse'; set: Partial<GitlogueBrowser> }
102 | { k: 'boxChar'; ch: string }
103 | { k: 'addressChar'; ch: string }
104 | { k: 'image'; image: GitlogueImage }
105 | { k: 'progress'; fraction: number; right?: string }
106 | { k: 'mark'; from: number; line: number; col: number }
107 | { k: 'sidebar'; sidebar: 'explorer' | 'search' }
108 | { k: 'search'; set: Partial<GitlogueSearch> }
109 | { k: 'searchChar'; ch: string }
110 | { k: 'palette'; set: Partial<GitloguePalette> | null }
111 | { k: 'paletteChar'; ch: string }
112 | { k: 'termRunning' }
113 | { k: 'commitMark'; root: string }
114 | { k: 'flyStart'; file: string }
115 | { k: 'fly'; t: number }
116 | { k: 'trash'; file: string }
117 | { k: 'castStart'; cast: Cast }
118 | { k: 'castFrame'; file: string }
119 | { k: 'castEnd'; remove: string[] }
120 | { k: 'dialogOpen' }
121 | { k: 'dialogChar'; ch: string }
122 | { k: 'termLine'; line: TermLine }
123 | { k: 'termChar'; ch: string }
124 | { k: 'termResult'; right: string; ok: boolean }
125 | { k: 'termDone'; durationMs: number; aborted: boolean }
126 | { k: 'resetTurn'; turn: TurnInfo }
127
128type Step = StepBody & { dur: number }
129
130const splitLines = (text: string) => (text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/).map(cellText))
131const indentOf = (line: string | undefined) => (line ?? '').length - (line ?? '').trimStart().length
132
133export class Player {
134 // What the panes draw.
135 lines: string[] = ['']
136 cursorLine = 0
137 cursorCol = 0
138 active: 'editor' | 'terminal' | 'explorer' = 'terminal'
139 hasFile = false
140 terminal: TermLine[] = []
141 // The word Claude Code closed a turn of this length with (`Cogitated`), when known.
142 wordFor: (durationMs: number) => string | undefined = () => undefined
143 dialog: { title: string; text: string } | undefined
144 turn: TurnInfo | undefined
145 files = new Map<string, FileEntry>()
146 currentPath: string | undefined
147 readonly highlighter = new Highlighter()
148 // Bumped whenever the turn shown changes, for the drawing that shows it.
149 turnVersion = 0
150
151 // The explorer the panes draw, and the copy the script walks ahead on.
152 explorer = new Explorer()
153 // What the editor's area shows: code, a browser, or a picture.
154 screen: Screen = 'code'
155 browser: GitlogueBrowser = { ...EMPTY_BROWSER }
156 image: GitlogueImage | undefined
157 // Lines read with the highlighter, the sidebar's view, the quick-open
158 // palette, a file on its way to the trash, and how many went there.
159 marks: { from: number; line: number; col: number } | undefined
160 sidebar: 'explorer' | 'search' = 'explorer'
161 search: GitlogueSearch | undefined
162 palette: GitloguePalette | undefined
163 flight: GitlogueFlight | undefined
164 trashed = 0
165 // A screen recording playing in the editor's area, its current frame, and
166 // the files played through, for the pane to delete.
167 cast: Cast | undefined
168 castFrame: string | undefined
169 removals: string[] = []
170 private planSidebar: 'explorer' | 'search' = 'explorer'
171 // Bumped when the screen or its picture changes, for the drawing that holds it.
172 sceneVersion = 0
173 private planScreen: Screen = 'code'
174 private planBrowser: GitlogueBrowser = { ...EMPTY_BROWSER }
175 private plan = new Explorer()
176 private planKeep = new Set<string>()
177 // A file that starts empty shows one blank line, which its first typed line takes over.
178 private isBlank = false
179 private steps: Step[] = []
180 private next = 0
181 // Absolute index of steps[0], so event boundaries survive compaction.
182 private base = 0
183 // Events not yet fully played, each with the absolute step index it ends at,
184 // and the panes as they stood when the oldest of them began.
185 private segments: { event: PlayerEvent; end: number }[] = []
186 private checkpoint: GitlogueView = this.view()
187 // Bumped whenever what `save` returns changes.
188 saveVersion = 0
189 private wait = 0
190 private remaining = 0
191 private filesThisTurn = 0
192 // The file the script last left in the editor, and its text then.
193 private scriptedPath: string | undefined
194 private scriptedText: string | undefined
195
196 private readonly speedMs: number
197 private readonly maxLagMs: number
198
199 constructor(speedMs: number, maxLagMs: number) {
200 this.speedMs = speedMs
201 this.maxLagMs = maxLagMs
202 }
203
204 // A command still running at the bottom of the terminal.
205 get hasRunning(): boolean {
206 return this.terminal.some(line => line.kind === 'command' && line.running === true)
207 }
208
209 get isIdle(): boolean {
210 return this.next >= this.steps.length
211 }
212
213 enqueue(event: PlayerEvent) {
214 this.script(event)
215 this.segments.push({ event, end: this.base + this.steps.length })
216 this.saveVersion++
217 this.settle()
218 }
219
220 // The replay as it stands: the panes at the oldest unfinished event, and the
221 // events from there on, to pick up after a reload.
222 save(): GitlogueSaved {
223 // A recording's frames do not outlive this load: it is not kept to replay.
224 return { view: this.checkpoint, pending: this.segments.map(segment => segment.event).filter(event => event.type !== 'cast') }
225 }
226
227 restore(saved: GitlogueSaved) {
228 const view = saved.view
229 this.lines = view.lines.length > 0 ? view.lines.slice() : ['']
230 this.cursorLine = view.cursorLine
231 this.cursorCol = view.cursorCol
232 this.active = view.active
233 this.hasFile = view.hasFile
234 this.isBlank = view.isBlank
235 // Records from before the terminal had structure hold plain strings.
236 this.terminal = view.terminal.map(line =>
237 typeof line === 'string' ? { kind: 'output' as const, text: line } : { ...line },
238 )
239 this.turn = view.turn ?? undefined
240 this.files = new Map(view.files.map(entry => [entry.path, { ...entry }]))
241 this.currentPath = view.currentPath ?? undefined
242 this.explorer = Explorer.from(view.explorer)
243 this.screen = view.screen === 'cast' ? 'code' : (view.screen ?? 'code')
244 this.browser = view.browser ? { ...view.browser, results: view.browser.results.slice(), body: view.browser.body.slice() } : { ...EMPTY_BROWSER }
245 this.image = view.image ? { ...view.image } : undefined
246 this.marks = view.marks ? { ...view.marks } : undefined
247 this.sidebar = view.sidebar ?? 'explorer'
248 this.planSidebar = this.sidebar
249 this.search = view.search ? { ...view.search, hits: view.search.hits.slice() } : undefined
250 this.palette = undefined
251 this.flight = undefined
252 this.trashed = view.trashed ?? 0
253 this.planScreen = this.screen
254 this.planBrowser = { ...this.browser }
255 this.sceneVersion++
256 this.plan = this.explorer.clone()
257 this.planKeep = new Set(view.files.flatMap(entry => (entry.file ? [entry.file] : [])))
258 this.dialog = undefined
259 if (view.hasFile && view.currentPath) this.highlighter.setPath(view.currentPath)
260 this.turnVersion++
261 this.steps = []
262 this.next = 0
263 this.base = 0
264 this.wait = 0
265 this.remaining = 0
266 this.segments = []
267 this.filesThisTurn = view.files.length
268 this.scriptedPath = view.hasFile ? (view.currentPath ?? undefined) : undefined
269 this.scriptedText = view.lines.join('\n')
270 this.checkpoint = this.view()
271 this.saveVersion++
272 for (const event of saved.pending) this.enqueue(event)
273 }
274
275 private setScreen(screen: Screen) {
276 if (this.screen === screen) return
277 this.screen = screen
278 this.sceneVersion++
279 }
280
281 // A new row, taking the place of an idle prompt left at the bottom.
282 private addLine(line: TermLine) {
283 if (this.terminal[this.terminal.length - 1]?.kind === 'prompt') this.terminal.pop()
284 this.terminal.push(line)
285 }
286
287 // Back to an empty pane, as a session with nothing replayed yet.
288 reset() {
289 this.restore({
290 view: {
291 lines: [''],
292 cursorLine: 0,
293 cursorCol: 0,
294 active: 'terminal',
295 hasFile: false,
296 isBlank: false,
297 terminal: [],
298 turn: null,
299 files: [],
300 currentPath: null,
301 },
302 pending: [],
303 })
304 }
305
306 view(): GitlogueView {
307 return {
308 lines: this.lines.slice(),
309 cursorLine: this.cursorLine,
310 cursorCol: this.cursorCol,
311 active: this.active,
312 hasFile: this.hasFile,
313 isBlank: this.isBlank,
314 terminal: this.terminal.map(line => ({ ...line })),
315 turn: this.turn ?? null,
316 files: [...this.files.values()].map(entry => ({ ...entry })),
317 currentPath: this.currentPath ?? null,
318 explorer: this.explorer.save(),
319 screen: this.screen,
320 browser: { ...this.browser, results: this.browser.results.slice(), body: this.browser.body.slice() },
321 image: this.image ? { ...this.image } : undefined,
322 marks: this.marks ? { ...this.marks } : undefined,
323 sidebar: this.sidebar,
324 search: this.search ? { ...this.search, hits: this.search.hits.slice() } : undefined,
325 trashed: this.trashed,
326 }
327 }
328
329 // Retires the events whose steps have all run.
330 private settle() {
331 let moved = false
332 while (this.segments.length > 0 && this.segments[0].end <= this.base + this.next) {
333 this.segments.shift()
334 moved = true
335 }
336 if (moved) {
337 this.checkpoint = this.view()
338 this.saveVersion++
339 }
340 }
341
342 private script(event: PlayerEvent) {
343 const before = this.steps.length
344 switch (event.type) {
345 case 'turn':
346 this.scriptTurn(event.turn)
347 break
348 case 'edit':
349 this.scriptEdit(event)
350 break
351 case 'command':
352 // The intent arrives whole, as a thought does; the command is typed.
353 if (event.description?.trim()) {
354 this.push({ k: 'termLine', line: { kind: 'intent', text: event.description.trim() } })
355 this.pause(OPEN_CMD_PAUSE)
356 }
357 this.push({ k: 'termLine', line: { kind: 'command', text: '', startedAt: event.startedAt } })
358 for (const ch of cellText(event.command.replace(/\s*\n\s*/g, ' ; ')).slice(0, 240)) this.push({ k: 'termChar', ch }, this.typing())
359 this.push({ k: 'termRunning' }, 0)
360 this.pause(GIT_ADD_CMD_PAUSE)
361 break
362 case 'output': {
363 const status = event.failed ? (event.exitCode ? `✗ ${event.exitCode}` : '✗') : '✓'
364 this.push({ k: 'termResult', right: `${formatDuration(event.durationMs, true)} ${status}`, ok: !event.failed })
365 event.lines.forEach((text, i) => this.push({ k: 'termLine', line: { kind: 'output', text, first: i === 0 } }, this.speedMs / 2))
366 if (event.total > event.lines.length)
367 this.push({ k: 'termLine', line: { kind: 'more', text: words.moreLines(event.total - event.lines.length) } })
368 this.pause(PUSH_OUTPUT_PAUSE)
369 break
370 }
371 case 'search':
372 this.scriptSearch(event)
373 break
374 case 'fetch':
375 this.scriptFetch(event)
376 break
377 case 'image':
378 this.scriptImage(event)
379 break
380 case 'download':
381 this.scriptDownload(event)
382 break
383 case 'read':
384 this.scriptRead(event)
385 break
386 case 'codesearch':
387 this.scriptCodeSearch(event)
388 break
389 case 'findfiles':
390 this.scriptFindFiles(event)
391 break
392 case 'git':
393 this.scriptGit(event)
394 break
395 case 'delete':
396 this.scriptDelete(event)
397 break
398 case 'cast':
399 this.scriptCast(event)
400 break
401 case 'done':
402 this.pause(PUSH_OUTPUT_PAUSE)
403 this.push({ k: 'termDone', durationMs: event.durationMs, aborted: event.aborted })
404 break
405 }
406 for (let i = before; i < this.steps.length; i++) this.remaining += this.steps[i].dur
407 }
408
409 // Plays the script for `dtMs` of wall time, sped up so that what is queued
410 // never takes longer than the lag allowed. Returns whether anything changed.
411 advance(dtMs: number): boolean {
412 if (this.isIdle) return false
413 const speedup = Math.max(1, this.remaining / this.maxLagMs)
414 let budget = dtMs * speedup
415 let changed = false
416 let executed = 0
417 while (this.next < this.steps.length && executed < 50000) {
418 if (this.wait > budget) {
419 this.wait -= budget
420 break
421 }
422 budget -= this.wait
423 const step = this.steps[this.next++]
424 this.execute(step)
425 this.wait = step.dur
426 this.remaining = Math.max(0, this.remaining - step.dur)
427 changed = true
428 executed++
429 if (this.segments.length > 0 && this.segments[0].end <= this.base + this.next) this.settle()
430 }
431 if (this.isIdle) {
432 this.base += this.steps.length
433 this.steps = []
434 this.next = 0
435 this.wait = 0
436 this.remaining = 0
437 } else if (this.next > 4096) {
438 this.base += this.next
439 this.steps = this.steps.slice(this.next)
440 this.next = 0
441 }
442 return changed
443 }
444
445 // Applies everything queued at once: for while nobody is watching.
446 flush(): boolean {
447 if (this.isIdle) return false
448 while (!this.isIdle) this.advance(Number.MAX_SAFE_INTEGER)
449 return true
450 }
451
452 private push(step: StepBody, dur = this.speedMs) {
453 this.steps.push({ ...step, dur })
454 }
455
456 private pause(multiplier: number) {
457 this.push({ k: 'pause' }, this.speedMs * multiplier)
458 }
459
460 private typing(factor = 1) {
461 return this.speedMs * factor * (0.7 + Math.random() * 0.6)
462 }
463
464 private scriptTurn(turn: TurnInfo) {
465 this.filesThisTurn = 0
466 this.push({ k: 'termLine', line: { kind: 'rule', text: `${turn.id} · ${turn.date.slice(-8)}` } })
467 this.pause(CHECKOUT_OUTPUT_PAUSE)
468 this.push({ k: 'resetTurn', turn })
469 }
470
471 private scriptEdit(event: Extract<PlayerEvent, { type: 'edit' }>) {
472 const oldLines = splitLines(event.before)
473 const newLines = splitLines(event.after)
474 const hunks = diffLines(oldLines, newLines)
475 if (hunks.length === 0) return
476 const { added, deleted } = countChanges(hunks)
477 const skipped = untypeable(event.path, hunks)
478 if (skipped) {
479 this.pause(CHECKOUT_PAUSE)
480 this.push({ k: 'count', entry: { path: event.path, status: event.created ? '+' : '~', added, deleted, file: event.file } })
481 this.push({
482 k: 'termLine',
483 line: { kind: 'edit', text: `${cellText(event.path)} · ${words.notReplayed(skipped)}`, right: `+${added} −${deleted}`, ok: true },
484 })
485 this.pause(GIT_ADD_CMD_PAUSE)
486 return
487 }
488 const change: FileChange = {
489 path: event.path,
490 oldLines,
491 entry: { path: event.path, status: event.created ? '+' : '~', added, deleted, file: event.file },
492 }
493
494 // The file already open, as the last change left it: keep typing in place.
495 const isOpen = this.scriptedPath === event.path
496 // Back from a browser or a picture, the file is opened again even if it was the last.
497 const wasCode = this.planScreen === 'code'
498 this.planScreen = 'code'
499 if (isOpen && wasCode && this.scriptedText === oldLines.join('\n')) {
500 this.pause(CHECKOUT_PAUSE)
501 } else if (isOpen) {
502 this.pause(OPEN_CMD_PAUSE)
503 this.push({ k: 'switchFile', change })
504 this.pause(FILE_SWITCH_PAUSE)
505 } else if (event.file && event.place) {
506 this.pause(this.filesThisTurn++ === 0 ? OPEN_FILE_FIRST_PAUSE : OPEN_FILE_PAUSE)
507 this.scriptNavigate(event.file, event.place)
508 this.push({ k: 'switchFile', change })
509 this.pause(FILE_SWITCH_PAUSE)
510 } else {
511 this.pause(this.filesThisTurn++ === 0 ? OPEN_FILE_FIRST_PAUSE : OPEN_FILE_PAUSE)
512 this.push({ k: 'dialogOpen' })
513 this.pause(5.0)
514 for (const ch of cellText(event.path)) this.push({ k: 'dialogChar', ch }, this.typing(2))
515 this.pause(OPEN_CMD_PAUSE)
516 this.push({ k: 'switchFile', change })
517 this.pause(FILE_SWITCH_PAUSE)
518 }
519 this.push({ k: 'count', entry: change.entry })
520 this.scriptedPath = event.path
521 this.scriptedText = newLines.join('\n')
522 this.scriptHunks(oldLines, hunks)
523 this.pause(GIT_ADD_PAUSE)
524 this.push({ k: 'termLine', line: { kind: 'edit', text: cellText(event.path), right: `+${added} −${deleted}`, ok: true } })
525 this.pause(GIT_ADD_CMD_PAUSE)
526 }
527
528 // Walks the explorer to `file` as a person would: down from where the
529 // selection is, opening each closed folder on the way, then onto the file.
530 private scriptNavigate(file: string, place: GitloguePlace) {
531 const plan = this.plan
532 if (this.planSidebar !== 'explorer') {
533 this.push({ k: 'sidebar', sidebar: 'explorer' }, 0)
534 this.planSidebar = 'explorer'
535 this.pause(OPEN_CMD_PAUSE)
536 }
537 if (!plan.roots.includes(place.root)) {
538 this.push({ k: 'root', root: place.root })
539 plan.addRoot(place.root)
540 }
541 const chain = chainTo(place.root, file)
542 chain.forEach((dir, i) => {
543 const entries = place.listings[dir]
544 if (!entries) return
545 const ensure = { name: nameOf(chain[i + 1] ?? file), dir: i + 1 < chain.length }
546 this.push({ k: 'list', dir, entries, ensure }, 0)
547 plan.list(dir, entries, ensure)
548 })
549 this.planKeep.add(file)
550 for (const dir of chain) {
551 if (plan.expanded.has(dir)) continue
552 this.scriptSelect(dir)
553 this.pause(EXPAND_PAUSE)
554 this.push({ k: 'expand', dir })
555 plan.expanded.add(dir)
556 this.pause(EXPAND_PAUSE * 1.5)
557 }
558 this.scriptSelect(file)
559 this.pause(OPEN_CMD_PAUSE)
560 }
561
562 // The selection stepping row by row to `target`, easing in and out.
563 private scriptSelect(target: string) {
564 const plan = this.plan
565 const rows = plan.rows(this.planKeep)
566 const to = rows.findIndex(row => row.path === target)
567 const from = Math.max(0, rows.findIndex(row => row.path === plan.selected))
568 if (to >= 0 && to !== from) {
569 const distance = Math.abs(to - from)
570 const count = Math.min(distance, MAX_SCROLL_STEPS)
571 let last = from
572 for (let i = 1; i <= count; i++) {
573 const t = i / count
574 const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
575 const index = Math.round(from + (to - from) * eased)
576 if (index === last) continue
577 last = index
578 this.push({ k: 'select', path: rows[index].path }, 0)
579 this.pause(SELECT_STEP_PAUSE)
580 }
581 }
582 if (plan.selected !== target) this.push({ k: 'select', path: target }, 0)
583 plan.selected = target
584 }
585
586 private toScreen(screen: Screen) {
587 if (this.planScreen === screen) return
588 this.push({ k: 'screen', screen }, 0)
589 this.planScreen = screen
590 this.pause(FILE_SWITCH_PAUSE)
591 }
592
593 private browse(set: Partial<GitlogueBrowser>, dur = this.speedMs) {
594 this.push({ k: 'browse', set }, dur)
595 this.planBrowser = { ...this.planBrowser, ...set }
596 }
597
598 // A page coming in: the bar across the top fills, unevenly, as pages do.
599 private scriptLoad() {
600 let loaded = 0
601 for (let i = 1; i <= PAGE_LOAD_STEPS; i++) {
602 loaded = Math.min(1, loaded + (Math.random() * 2) / PAGE_LOAD_STEPS)
603 this.browse({ loading: i === PAGE_LOAD_STEPS ? 1 : loaded }, this.speedMs * (1 + Math.random() * 2))
604 }
605 }
606
607 // A search, the way a person runs one: the search page, the query typed into
608 // its box, Enter, the results page loading, then the results read down.
609 private scriptSearch(event: Extract<PlayerEvent, { type: 'search' }>) {
610 this.toScreen('browser')
611 this.browse({ url: 'google.com', page: 'home', query: '', results: [], shown: 0, selected: -1, body: [], scroll: 0, loading: 1 })
612 this.pause(CHECKOUT_OUTPUT_PAUSE)
613 for (const ch of cellText(event.query).slice(0, 200)) this.push({ k: 'boxChar', ch }, this.typing())
614 this.pause(OPEN_CMD_PAUSE)
615 const results = event.results.slice(0, 10).map(link => ({ title: cellText(link.title), url: cellText(link.url) }))
616 const seconds = (event.durationMs / 1000).toFixed(2)
617 this.browse({
618 url: `google.com/search?q=${encodeURIComponent(event.query).replace(/%20/g, '+')}`,
619 page: 'results',
620 results,
621 shown: 0,
622 stats: words.webStats(results.length, seconds),
623 loading: 0,
624 })
625 this.scriptLoad()
626 for (let i = 1; i <= results.length; i++) this.browse({ shown: i }, this.speedMs * 3)
627 this.pause(HUNK_PAUSE)
628 }
629
630 // A page opened: clicked from the results when it is one of them, else its
631 // address typed; then loaded and read down a screenful at a time.
632 private scriptFetch(event: Extract<PlayerEvent, { type: 'fetch' }>) {
633 this.toScreen('browser')
634 const plan = this.planBrowser
635 const target = plan.page === 'results' ? plan.results.findIndex(link => sameAddress(link.url, event.url)) : -1
636 if (target >= 0) {
637 const from = Math.max(0, plan.selected)
638 const step = target >= from ? 1 : -1
639 for (let i = from; i !== target + step; i += step) this.browse({ selected: i }, this.speedMs * 4)
640 this.pause(OPEN_CMD_PAUSE)
641 } else {
642 this.browse({ url: '' }, this.speedMs * 4)
643 for (const ch of cellText(event.url.replace(/^https?:\/\//, '')).slice(0, 160)) this.push({ k: 'addressChar', ch }, this.typing(0.6))
644 this.pause(OPEN_CMD_PAUSE)
645 }
646 const body = event.text.split('\n').map(cellText)
647 this.browse({ url: cellText(event.url.replace(/^https?:\/\//, '')), page: 'page', body, scroll: 0, loading: 0, selected: -1 })
648 this.scriptLoad()
649 this.pause(CHECKOUT_OUTPUT_PAUSE)
650 const reading = Math.min(body.length, 60)
651 for (let line = 1; line <= reading; line++) this.browse({ scroll: line }, this.speedMs * (line % 8 === 0 ? 12 : 3))
652 this.pause(HUNK_PAUSE)
653 }
654
655 // A picture looked at: found in the explorer, opened, and held a moment.
656 private scriptImage(event: Extract<PlayerEvent, { type: 'image' }>) {
657 if (event.place) this.scriptNavigate(event.file, event.place)
658 this.push({ k: 'image', image: { path: cellText(event.path), png: event.png, width: event.width, height: event.height } }, 0)
659 this.planScreen = 'image'
660 this.pause(HUNK_PAUSE * 1.5)
661 }
662
663 // A download: a bar under its command fills at an uneven pace over about
664 // the time it took, then the file lands in its folder.
665 private scriptDownload(event: Extract<PlayerEvent, { type: 'download' }>) {
666 const name = cellText(event.path.slice(event.path.lastIndexOf('/') + 1))
667 const total = formatBytes(event.bytes)
668 this.push({ k: 'termLine', line: { kind: 'progress', text: name, fraction: 0, right: `0 B / ${total}` } })
669 const span = Math.min(6000, Math.max(900, event.durationMs))
670 let fraction = 0
671 for (let i = 1; i <= DOWNLOAD_STEPS; i++) {
672 fraction = i === DOWNLOAD_STEPS ? 1 : Math.min(0.97, fraction + (Math.random() * 2) / DOWNLOAD_STEPS)
673 const right = `${formatBytes(event.bytes * fraction)} / ${total}`
674 this.push({ k: 'progress', fraction, right }, (span / DOWNLOAD_STEPS) * (0.4 + Math.random() * 1.2))
675 }
676 this.push({ k: 'progress', fraction: 1, right: `${total} · ${formatDuration(event.durationMs, true)}` })
677 if (event.place) {
678 const folder = event.file.slice(0, event.file.lastIndexOf('/'))
679 const entries = event.place.listings[folder]
680 if (entries) {
681 if (!this.plan.roots.includes(event.place.root)) {
682 this.push({ k: 'root', root: event.place.root })
683 this.plan.addRoot(event.place.root)
684 }
685 const ensure = { name: nameOf(event.file), dir: false }
686 this.push({ k: 'list', dir: folder, entries, ensure }, 0)
687 this.plan.list(folder, entries, ensure)
688 }
689 this.planKeep.add(event.file)
690 }
691 this.push({ k: 'count', entry: { path: event.path, status: '+', added: 0, deleted: 0, file: event.file, note: total } })
692 this.pause(GIT_ADD_CMD_PAUSE)
693 }
694
695 // Code read with a highlighter: the file opened if it is not already, the
696 // cursor brought to what was read, and the pen drawn along each line of it.
697 private scriptRead(event: Extract<PlayerEvent, { type: 'read' }>) {
698 const lines = splitLines(event.text)
699 if (lines.length === 0 || isLog(event.path)) return
700 const isOpen = this.planScreen === 'code' && this.scriptedPath === event.path && this.scriptedText === lines.join('\n')
701 this.planScreen = 'code'
702 if (!isOpen) {
703 this.pause(this.filesThisTurn === 0 ? OPEN_FILE_FIRST_PAUSE : OPEN_FILE_PAUSE)
704 if (event.place) this.scriptNavigate(event.file, event.place)
705 const entry: FileEntry = { path: event.path, status: '~', added: 0, deleted: 0, file: event.file }
706 this.push({ k: 'switchFile', change: { path: event.path, oldLines: lines, entry } })
707 this.pause(FILE_SWITCH_PAUSE)
708 this.scriptedPath = event.path
709 this.scriptedText = lines.join('\n')
710 }
711 const from = Math.min(lines.length - 1, Math.max(0, event.startLine - 1))
712 const to = Math.min(lines.length, from + Math.max(1, event.numLines))
713 this.scriptCursorMove(0, from, lines)
714 this.pause(OPEN_CMD_PAUSE)
715 const swept = Math.min(to - from, MAX_READ_SWEEP)
716 for (let line = from; line < from + swept; line++) {
717 const length = lines[line].length
718 for (const share of [0.34, 0.67, 1])
719 this.push({ k: 'mark', from, line, col: Math.round(length * share) }, this.speedMs * (length > 0 ? 1.4 : 0.3))
720 if ((line - from) % 6 === 5) this.pause(4)
721 }
722 if (to - from > swept) this.push({ k: 'mark', from, line: to - 1, col: lines[to - 1].length })
723 this.pause(HUNK_PAUSE)
724 }
725
726 // A search through code, as the editor's search view runs one: the query
727 // typed into its box, then the matching files listed one by one, each with
728 // its lines.
729 private scriptCodeSearch(event: Extract<PlayerEvent, { type: 'codesearch' }>) {
730 this.push({ k: 'sidebar', sidebar: 'search' }, 0)
731 this.planSidebar = 'search'
732 this.push({ k: 'search', set: { query: '', hits: [], shown: 0, total: event.total } })
733 this.pause(OPEN_CMD_PAUSE)
734 for (const ch of cellText(event.query).slice(0, 80)) this.push({ k: 'searchChar', ch }, this.typing())
735 this.pause(OPEN_CMD_PAUSE)
736 const hits = event.hits.slice(0, 30).map(hit => ({
737 path: cellText(hit.path),
738 matches: hit.matches.slice(0, 6).map(match => ({ line: match.line, text: cellText(match.text) })),
739 }))
740 this.push({ k: 'search', set: { hits } }, 0)
741 for (let i = 1; i <= hits.length; i++) this.push({ k: 'search', set: { shown: i } }, this.speedMs * 3)
742 this.pause(HUNK_PAUSE)
743 }
744
745 // Files found by name, as quick open finds them: the pattern typed into the
746 // palette, the matches listed, and the palette put away.
747 private scriptFindFiles(event: Extract<PlayerEvent, { type: 'findfiles' }>) {
748 this.push({ k: 'palette', set: { text: '', items: [], shown: 0 } }, this.speedMs * 4)
749 for (const ch of cellText(event.pattern).slice(0, 60)) this.push({ k: 'paletteChar', ch }, this.typing())
750 this.pause(OPEN_CMD_PAUSE)
751 const items = event.files.slice(0, 10).map(cellText)
752 this.push({ k: 'palette', set: { items } }, 0)
753 for (let i = 1; i <= items.length; i++) this.push({ k: 'palette', set: { shown: i } }, this.speedMs * 2)
754 this.pause(HUNK_PAUSE)
755 this.push({ k: 'palette', set: null })
756 }
757
758 // A commit lands in the terminal with its id and branch and the files it
759 // took settle in the explorer; a push goes up with a bar, as a download comes down.
760 private scriptGit(event: Extract<PlayerEvent, { type: 'git' }>) {
761 if (event.commit) {
762 this.push({
763 k: 'termLine',
764 line: { kind: 'commit', text: cellText(event.commit.message ?? ''), sha: event.commit.sha.slice(0, 7), branch: event.commit.branch },
765 })
766 if (event.root) this.push({ k: 'commitMark', root: event.root })
767 this.pause(GIT_ADD_CMD_PAUSE)
768 }
769 if (event.push) {
770 this.push({ k: 'termLine', line: { kind: 'progress', up: true, text: `origin/${event.push.branch}`, fraction: 0, right: 'pushing' } })
771 const span = Math.min(4000, Math.max(800, event.durationMs))
772 let fraction = 0
773 for (let i = 1; i <= DOWNLOAD_STEPS; i++) {
774 fraction = i === DOWNLOAD_STEPS ? 1 : Math.min(0.97, fraction + (Math.random() * 2) / DOWNLOAD_STEPS)
775 this.push({ k: 'progress', fraction, right: `${Math.round(fraction * 100)}%` }, (span / DOWNLOAD_STEPS) * (0.4 + Math.random() * 1.2))
776 }
777 this.push({ k: 'progress', fraction: 1, right: words.pushed(formatDuration(event.durationMs, true)) })
778 this.pause(GIT_ADD_CMD_PAUSE)
779 }
780 }
781
782 // A file thrown away: the explorer walks to it, and it flies into the trash.
783 private scriptDelete(event: Extract<PlayerEvent, { type: 'delete' }>) {
784 this.pause(OPEN_FILE_PAUSE)
785 if (event.place) {
786 const folder = parentOf(event.file)
787 const entries = event.place.listings[folder]
788 if (entries)
789 event = {
790 ...event,
791 place: {
792 ...event.place,
793 listings: { ...event.place.listings, [folder]: [...entries, { name: nameOf(event.file), dir: event.isDir }] },
794 },
795 }
796 this.scriptNavigate(event.file, event.place!)
797 }
798 this.pause(OPEN_CMD_PAUSE)
799 this.push({ k: 'flyStart', file: event.file }, 0)
800 for (let i = 1; i <= FLY_STEPS; i++) this.push({ k: 'fly', t: i / FLY_STEPS }, this.speedMs * 1.5)
801 this.push({ k: 'trash', file: event.file })
802 this.plan.unlist(parentOf(event.file), nameOf(event.file))
803 this.planKeep.delete(event.file)
804 if (this.scriptedPath === event.path) this.scriptedPath = undefined
805 this.push({ k: 'termLine', line: { kind: 'trash', text: cellText(event.path) + (event.isDir ? '/' : '') } })
806 this.pause(GIT_ADD_CMD_PAUSE)
807 }
808
809 // A recording of Claude at work on one of its screens, played in the
810 // editor's area frame by frame at its own pace, then let go.
811 private scriptCast(event: Extract<PlayerEvent, { type: 'cast' }>) {
812 if (event.frames.length === 0) {
813 this.push({ k: 'castEnd', remove: event.remove }, 0)
814 return
815 }
816 const speed = Math.round(event.speed * 10) / 10
817 this.push({
818 k: 'termLine',
819 line: { kind: 'cast', text: `${words.screen(event.screen)} · ${event.seconds.toFixed(1)}s`, right: `×${speed}` },
820 })
821 this.push({ k: 'castStart', cast: { screen: event.screen, width: event.width, height: event.height, speed, seconds: event.seconds } }, 0)
822 this.planScreen = 'cast'
823 for (const file of event.frames) this.push({ k: 'castFrame', file }, 1000 / event.fps)
824 this.pause(CHECKOUT_OUTPUT_PAUSE)
825 this.push({ k: 'castEnd', remove: event.remove }, 0)
826 this.planScreen = 'code'
827 }
828
829 private scriptHunks(oldLines: string[], hunks: Hunk[]) {
830 // The buffer as the script leaves it, for where the cursor lands.
831 const buffer = oldLines.slice()
832 let cursor = 0
833 let offset = 0
834 for (const hunk of hunks) {
835 const target = Math.max(0, hunk.oldStart - 1 + offset)
836 cursor = this.scriptCursorMove(cursor, target, buffer)
837 let line = target
838 let i = 0
839 while (i < hunk.lines.length) {
840 const change = hunk.lines[i]
841 if (change.kind === 'ctx') {
842 if (line !== cursor) {
843 this.push({ k: 'move', line, col: indentOf(change.text) })
844 this.pause(CURSOR_MOVE_PAUSE)
845 }
846 cursor = line
847 line++
848 i++
849 continue
850 }
851 // A run of removed lines and the added lines that replace them.
852 const dels: string[] = []
853 const adds: string[] = []
854 while (i < hunk.lines.length && hunk.lines[i].kind === 'del') dels.push(hunk.lines[i++].text)
855 while (i < hunk.lines.length && hunk.lines[i].kind === 'add') adds.push(hunk.lines[i++].text)
856 // Line for line, a changed line is edited where it stands when most of
857 // it survives; otherwise it goes and its replacement is typed.
858 const pairs = Math.min(dels.length, adds.length)
859 for (let k = 0; k < pairs; k++) {
860 if (isSimilar(dels[k], adds[k])) this.scriptLineEdit(line, dels[k], adds[k])
861 else {
862 this.scriptDeleteLine(line)
863 this.scriptInsertLine(line, adds[k])
864 }
865 buffer[line] = adds[k]
866 cursor = line
867 line++
868 }
869 for (let k = pairs; k < dels.length; k++) {
870 this.scriptDeleteLine(line)
871 buffer.splice(line, 1)
872 cursor = line
873 offset--
874 }
875 for (let k = pairs; k < adds.length; k++) {
876 this.scriptInsertLine(line, adds[k])
877 buffer.splice(line, 0, adds[k])
878 cursor = line
879 line++
880 offset++
881 }
882 }
883 this.pause(HUNK_PAUSE)
884 }
885 }
886
887 private scriptDeleteLine(line: number) {
888 this.push({ k: 'deleteLine', line })
889 this.pause(DELETE_LINE_PAUSE)
890 }
891
892 private scriptInsertLine(line: number, text: string) {
893 const indent = indentOf(text)
894 this.push({ k: 'insertLine', line, text: text.slice(0, indent) })
895 for (let col = indent; col < text.length; col++) this.push({ k: 'char', line, col, ch: text[col] }, this.typing())
896 this.pause(INSERT_LINE_PAUSE)
897 }
898
899 // A line changed in place, as a person edits one: the cursor goes to the end
900 // of what differs, backspaces over it, and types what replaces it.
901 private scriptLineEdit(line: number, before: string, after: string) {
902 let head = 0
903 while (head < before.length && head < after.length && before[head] === after[head]) head++
904 let tail = 0
905 while (
906 tail < before.length - head &&
907 tail < after.length - head &&
908 before[before.length - 1 - tail] === after[after.length - 1 - tail]
909 )
910 tail++
911 const removed = before.length - head - tail
912 this.push({ k: 'move', line, col: head + removed })
913 this.pause(OPEN_CMD_PAUSE)
914 for (let n = 0; n < removed; n++) this.push({ k: 'backspace', line, col: head + removed - n }, this.typing(0.6))
915 if (removed > 0) this.pause(CURSOR_MOVE_PAUSE * 4)
916 const typed = after.slice(head, after.length - tail)
917 for (let n = 0; n < typed.length; n++) this.push({ k: 'char', line, col: head + n, ch: typed[n] }, this.typing())
918 this.pause(INSERT_LINE_PAUSE)
919 }
920
921 private scriptCursorMove(from: number, to: number, buffer: string[]): number {
922 if (from === to) return to
923 const distance = Math.abs(to - from)
924 const multiplier =
925 distance <= 50
926 ? CURSOR_MOVE_SHORT_MULTIPLIER
927 : distance <= 200
928 ? CURSOR_MOVE_MEDIUM_MULTIPLIER
929 : CURSOR_MOVE_LONG_MULTIPLIER
930 const count =
931 distance <= MIN_LOG_STEPS
932 ? distance
933 : Math.min(MAX_SCROLL_STEPS, Math.max(MIN_LOG_STEPS, Math.floor(Math.log(distance) * LOG_SCALE_FACTOR)))
934 const pause = Math.max(0.01, CURSOR_MOVE_PAUSE * multiplier)
935 let last = from
936 for (let i = 0; i <= count; i++) {
937 const t = i / count
938 const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
939 const progress = Math.round(eased * distance)
940 const line = from < to ? from + progress : from - progress
941 if (line === last) continue
942 last = line
943 this.push({ k: 'move', line, col: indentOf(buffer[line]) })
944 this.pause(pause)
945 }
946 return to
947 }
948
949 private execute(step: Step) {
950 switch (step.k) {
951 case 'char': {
952 this.active = 'editor'
953 this.marks = undefined
954 const text = this.lines[step.line] ?? ''
955 this.lines[step.line] = text.slice(0, step.col) + step.ch + text.slice(step.col)
956 this.cursorLine = step.line
957 this.cursorCol = step.col + 1
958 this.highlighter.invalidate(step.line)
959 break
960 }
961 case 'mark':
962 this.active = 'editor'
963 this.marks = { from: step.from, line: step.line, col: step.col }
964 this.cursorLine = Math.min(step.line, this.lines.length - 1)
965 this.cursorCol = step.col
966 break
967 case 'sidebar':
968 this.sidebar = step.sidebar
969 break
970 case 'search':
971 this.active = 'explorer'
972 this.search = { query: '', hits: [], shown: 0, total: 0, ...this.search, ...step.set }
973 break
974 case 'searchChar':
975 this.active = 'explorer'
976 this.search = { query: '', hits: [], shown: 0, total: 0, ...this.search }
977 this.search.query += step.ch
978 break
979 case 'palette':
980 this.active = 'explorer'
981 this.palette = step.set === null ? undefined : { text: '', items: [], shown: 0, ...this.palette, ...step.set }
982 break
983 case 'paletteChar':
984 if (this.palette) this.palette = { ...this.palette, text: this.palette.text + step.ch }
985 break
986 case 'termRunning': {
987 const last = this.terminal[this.terminal.length - 1]
988 if (last?.kind === 'command' && last.right === undefined) last.running = true
989 break
990 }
991 case 'commitMark':
992 for (const entry of this.files.values()) if (entry.file?.startsWith(step.root + '/')) entry.committed = true
993 break
994 case 'flyStart': {
995 const keep = new Set([...this.files.values()].flatMap(entry => (entry.file ? [entry.file] : [])))
996 const rows = this.explorer.rows(keep)
997 const row = rows.findIndex(r => r.path === step.file)
998 this.flight = { name: nameOf(step.file), row: Math.max(0, row), depth: rows[row]?.depth ?? 1, t: 0 }
999 break
1000 }
1001 case 'fly':
1002 if (this.flight) this.flight = { ...this.flight, t: step.t }
1003 break
1004 case 'trash': {
1005 this.explorer.unlist(parentOf(step.file), nameOf(step.file))
1006 for (const [path, entry] of [...this.files])
1007 if (entry.file === step.file || entry.file?.startsWith(step.file + '/')) this.files.delete(path)
1008 if (this.hasFile && this.currentPath && step.file.endsWith('/' + this.currentPath.replace(/^\//, ''))) {
1009 this.hasFile = false
1010 this.lines = ['']
1011 }
1012 this.flight = undefined
1013 this.trashed++
1014 break
1015 }
1016 case 'castStart':
1017 this.cast = step.cast
1018 this.castFrame = undefined
1019 this.setScreen('cast')
1020 break
1021 case 'castFrame':
1022 // The first frame mounts the picture; the rest are swapped into it.
1023 if (!this.castFrame) this.sceneVersion++
1024 this.castFrame = step.file
1025 break
1026 case 'castEnd':
1027 this.cast = undefined
1028 this.castFrame = undefined
1029 this.removals.push(...step.remove)
1030 if (this.screen === 'cast') this.setScreen('code')
1031 break
1032 case 'backspace': {
1033 this.active = 'editor'
1034 const text = this.lines[step.line] ?? ''
1035 this.lines[step.line] = text.slice(0, step.col - 1) + text.slice(step.col)
1036 this.cursorLine = step.line
1037 this.cursorCol = step.col - 1
1038 this.highlighter.invalidate(step.line)
1039 break
1040 }
1041 case 'insertLine':
1042 this.active = 'editor'
1043 if (this.isBlank) this.lines[0] = step.text
1044 else this.lines.splice(Math.min(step.line, this.lines.length), 0, step.text)
1045 this.isBlank = false
1046 this.cursorLine = step.line
1047 this.cursorCol = step.text.length
1048 this.highlighter.invalidate(step.line)
1049 break
1050 case 'deleteLine':
1051 this.active = 'editor'
1052 this.marks = undefined
1053 this.lines.splice(step.line, 1)
1054 if (this.lines.length === 0) this.lines.push('')
1055 this.cursorLine = Math.min(step.line, this.lines.length - 1)
1056 this.cursorCol = indentOf(this.lines[this.cursorLine])
1057 this.highlighter.invalidate(step.line)
1058 break
1059 case 'move':
1060 this.active = 'editor'
1061 this.cursorLine = Math.min(step.line, this.lines.length - 1)
1062 this.cursorCol = step.col
1063 break
1064 case 'pause':
1065 break
1066 case 'switchFile': {
1067 this.marks = undefined
1068 this.setScreen('code')
1069 this.active = 'editor'
1070 this.dialog = undefined
1071 this.hasFile = true
1072 this.isBlank = step.change.oldLines.length === 0
1073 this.lines = this.isBlank ? [''] : step.change.oldLines.slice()
1074 this.cursorLine = 0
1075 this.cursorCol = 0
1076 this.highlighter.setPath(step.change.path)
1077 this.currentPath = step.change.path
1078 break
1079 }
1080 case 'root':
1081 this.explorer.addRoot(step.root)
1082 break
1083 case 'list':
1084 this.explorer.list(step.dir, step.entries, step.ensure)
1085 break
1086 case 'expand':
1087 this.active = 'explorer'
1088 this.explorer.expanded.add(step.dir)
1089 break
1090 case 'select':
1091 this.active = 'explorer'
1092 this.explorer.selected = step.path
1093 break
1094 case 'screen':
1095 this.setScreen(step.screen)
1096 break
1097 case 'browse':
1098 this.active = 'explorer'
1099 this.browser = { ...this.browser, focus: undefined, ...step.set }
1100 break
1101 case 'boxChar':
1102 this.active = 'explorer'
1103 this.browser = { ...this.browser, query: this.browser.query + step.ch, focus: 'box' }
1104 break
1105 case 'addressChar':
1106 this.active = 'explorer'
1107 this.browser = { ...this.browser, url: this.browser.url + step.ch, focus: 'address' }
1108 break
1109 case 'image':
1110 this.image = step.image
1111 this.setScreen('image')
1112 this.sceneVersion++
1113 break
1114 case 'progress': {
1115 for (let i = this.terminal.length - 1; i >= 0; i--) {
1116 const line = this.terminal[i]
1117 if (line.kind === 'progress' && (line.fraction ?? 0) < 1) {
1118 line.fraction = step.fraction
1119 if (step.right) line.right = step.right
1120 break
1121 }
1122 }
1123 break
1124 }
1125 case 'count': {
1126 const known = this.files.get(step.entry.path)
1127 const turn = this.turn?.id
1128 this.files.set(
1129 step.entry.path,
1130 known
1131 ? { ...known, added: known.added + step.entry.added, deleted: known.deleted + step.entry.deleted, turn }
1132 : { ...step.entry, turn },
1133 )
1134 this.currentPath = step.entry.path
1135 break
1136 }
1137 case 'dialogOpen':
1138 this.dialog = { title: words.openFile, text: '' }
1139 break
1140 case 'dialogChar':
1141 if (this.dialog) this.dialog.text += step.ch
1142 break
1143 case 'termLine':
1144 this.active = 'terminal'
1145 this.addLine({ ...step.line, text: cellText(step.line.text) })
1146 break
1147 case 'termChar': {
1148 this.active = 'terminal'
1149 const last = this.terminal[this.terminal.length - 1]
1150 if (last?.kind === 'command' && last.right === undefined) last.text += step.ch
1151 else this.addLine({ kind: 'command', text: step.ch })
1152 break
1153 }
1154 case 'termResult': {
1155 // The command still waiting on its result, newest first.
1156 for (let i = this.terminal.length - 1; i >= 0; i--) {
1157 const line = this.terminal[i]
1158 if (line.kind === 'command' && line.right === undefined) {
1159 line.right = step.right
1160 line.ok = step.ok
1161 line.running = false
1162 break
1163 }
1164 }
1165 break
1166 }
1167 case 'termDone': {
1168 this.active = 'terminal'
1169 const word = this.wordFor(step.durationMs) ?? words.done
1170 this.addLine(
1171 step.aborted
1172 ? { kind: 'fail', text: words.interrupted }
1173 : { kind: 'done', text: `${word} for ${formatDuration(step.durationMs)}` },
1174 )
1175 this.addLine({ kind: 'prompt', text: '' })
1176 break
1177 }
1178 // A new turn keeps the session's files and the open one; only what the
1179 // turn is about changes.
1180 case 'resetTurn':
1181 this.turn = step.turn
1182 this.turnVersion++
1183 this.active = 'terminal'
1184 break
1185 }
1186 if (this.terminal.length > MAX_TERMINAL_LINES) this.terminal.splice(0, this.terminal.length - MAX_TERMINAL_LINES)
1187 }
1188}
1189
1190// Logs and other output a program writes as it runs, rotated ones included.
1191const isLog = (path: string) => /\.(log|out|err|ansi|trace|pid|jsonl)(\.\d+)?$/i.test(path)
1192
1193// Why a change is not typed out, or undefined when it is.
1194function untypeable(path: string, hunks: Hunk[]): string | undefined {
1195 if (LOCK_FILES.has(path.slice(path.lastIndexOf('/') + 1))) return words.lockFile
1196 if (isLog(path)) return words.logFile
1197 let typed = 0
1198 for (const hunk of hunks)
1199 for (const line of hunk.lines) {
1200 if (line.text.length > MAX_TYPED_LINE) return words.generatedhooks/themes.ts 783 lines1// Color schemes ported from gitlogue (https://github.com/unhappychoice/gitlogue, ISC).
2// Each color is 0xRRGGBB, the form a Raster cell takes.
3
4export type Theme = {
5 backgroundLeft: number
6 backgroundRight: number
7 editorLineNumber: number
8 editorLineNumberCursor: number
9 editorSeparator: number
10 editorCursorCharBg: number
11 editorCursorCharFg: number
12 editorCursorLineBg: number
13 fileTreeAdded: number
14 fileTreeDeleted: number
15 fileTreeModified: number
16 fileTreeRenamed: number
17 fileTreeDirectory: number
18 fileTreeCurrentFileBg: number
19 fileTreeCurrentFileFg: number
20 fileTreeDefault: number
21 fileTreeStatsAdded: number
22 fileTreeStatsDeleted: number
23 terminalCommand: number
24 terminalOutput: number
25 terminalCursorBg: number
26 terminalCursorFg: number
27 statusHash: number
28 statusAuthor: number
29 statusDate: number
30 statusMessage: number
31 statusNoCommit: number
32 separator: number
33 syntaxKeyword: number
34 syntaxType: number
35 syntaxFunction: number
36 syntaxVariable: number
37 syntaxString: number
38 syntaxNumber: number
39 syntaxComment: number
40 syntaxOperator: number
41 syntaxPunctuation: number
42 syntaxConstant: number
43 syntaxParameter: number
44 syntaxProperty: number
45 syntaxLabel: number
46}
47
48export const THEMES: Record<string, Theme> = {
49 'ayu-dark': {
50 backgroundLeft: 0x0a0e14,
51 backgroundRight: 0x0f1419,
52 editorLineNumber: 0x3e4452,
53 editorLineNumberCursor: 0x59b6d7,
54 editorSeparator: 0x3e4452,
55 editorCursorCharBg: 0xffb454,
56 editorCursorCharFg: 0x0f1419,
57 editorCursorLineBg: 0x161d25,
58 fileTreeAdded: 0xbae67e,
59 fileTreeDeleted: 0xf26167,
60 fileTreeModified: 0xffb454,
61 fileTreeRenamed: 0x59b6d7,
62 fileTreeDirectory: 0x59b6d7,
63 fileTreeCurrentFileBg: 0x161d25,
64 fileTreeCurrentFileFg: 0xe6edf3,
65 fileTreeDefault: 0xe6edf3,
66 fileTreeStatsAdded: 0xbae67e,
67 fileTreeStatsDeleted: 0xf26167,
68 terminalCommand: 0xe6edf3,
69 terminalOutput: 0x3e4452,
70 terminalCursorBg: 0xffb454,
71 terminalCursorFg: 0x0f1419,
72 statusHash: 0xe5b567,
73 statusAuthor: 0xbae67e,
74 statusDate: 0x59b6d7,
75 statusMessage: 0xe6edf3,
76 statusNoCommit: 0x3e4452,
77 separator: 0x3e4452,
78 syntaxKeyword: 0xff8c63,
79 syntaxType: 0xe5b567,
80 syntaxFunction: 0xffd66f,
81 syntaxVariable: 0xe6edf3,
82 syntaxString: 0xbae67e,
83 syntaxNumber: 0xef9ede,
84 syntaxComment: 0x5c6370,
85 syntaxOperator: 0xf29718,
86 syntaxPunctuation: 0xe6edf3,
87 syntaxConstant: 0x59b6d7,
88 syntaxParameter: 0xffd66f,
89 syntaxProperty: 0x73b8cd,
90 syntaxLabel: 0xff8c63,
91 },
92 'catppuccin': {
93 backgroundLeft: 0x181825,
94 backgroundRight: 0x1e1e2e,
95 editorLineNumber: 0x6c7086,
96 editorLineNumberCursor: 0x89b4fa,
97 editorSeparator: 0x6c7086,
98 editorCursorCharBg: 0xf5c2e7,
99 editorCursorCharFg: 0x1e1e2e,
100 editorCursorLineBg: 0x313244,
101 fileTreeAdded: 0xa6e3a1,
102 fileTreeDeleted: 0xf38ba8,
103 fileTreeModified: 0xfab387,
104 fileTreeRenamed: 0x89b4fa,
105 fileTreeDirectory: 0xcba6f7,
106 fileTreeCurrentFileBg: 0x313244,
107 fileTreeCurrentFileFg: 0xcdd6f4,
108 fileTreeDefault: 0xcdd6f4,
109 fileTreeStatsAdded: 0xa6e3a1,
110 fileTreeStatsDeleted: 0xf38ba8,
111 terminalCommand: 0xcdd6f4,
112 terminalOutput: 0x6c7086,
113 terminalCursorBg: 0xf5c2e7,
114 terminalCursorFg: 0x1e1e2e,
115 statusHash: 0xf9e2af,
116 statusAuthor: 0xa6e3a1,
117 statusDate: 0x89b4fa,
118 statusMessage: 0xcdd6f4,
119 statusNoCommit: 0x6c7086,
120 separator: 0x6c7086,
121 syntaxKeyword: 0xcba6f7,
122 syntaxType: 0xf9e2af,
123 syntaxFunction: 0x89b4fa,
124 syntaxVariable: 0xcdd6f4,
125 syntaxString: 0xa6e3a1,
126 syntaxNumber: 0xfab387,
127 syntaxComment: 0x6c7086,
128 syntaxOperator: 0x94e2d5,
129 syntaxPunctuation: 0xbac2de,
130 syntaxConstant: 0xfab387,
131 syntaxParameter: 0xf5c2e7,
132 syntaxProperty: 0xa6e3a1,
133 syntaxLabel: 0xcba6f7,
134 },
135 'dracula': {
136 backgroundLeft: 0x21222c,
137 backgroundRight: 0x282a36,
138 editorLineNumber: 0x6272a4,
139 editorLineNumberCursor: 0x8be9fd,
140 editorSeparator: 0x6272a4,
141 editorCursorCharBg: 0xff79c6,
142 editorCursorCharFg: 0x282a36,
143 editorCursorLineBg: 0x44475a,
144 fileTreeAdded: 0x50fa7b,
145 fileTreeDeleted: 0xff5555,
146 fileTreeModified: 0xffb86c,
147 fileTreeRenamed: 0x8be9fd,
148 fileTreeDirectory: 0xbd93f9,
149 fileTreeCurrentFileBg: 0x44475a,
150 fileTreeCurrentFileFg: 0xf8f8f2,
151 fileTreeDefault: 0xf8f8f2,
152 fileTreeStatsAdded: 0x50fa7b,
153 fileTreeStatsDeleted: 0xff5555,
154 terminalCommand: 0xf8f8f2,
155 terminalOutput: 0x6272a4,
156 terminalCursorBg: 0xff79c6,
157 terminalCursorFg: 0x282a36,
158 statusHash: 0xf1fa8c,
159 statusAuthor: 0x50fa7b,
160 statusDate: 0x8be9fd,
161 statusMessage: 0xf8f8f2,
162 statusNoCommit: 0x6272a4,
163 separator: 0x6272a4,
164 syntaxKeyword: 0xff79c6,
165 syntaxType: 0x8be9fd,
166 syntaxFunction: 0x50fa7b,
167 syntaxVariable: 0xf8f8f2,
168 syntaxString: 0xf1fa8c,
169 syntaxNumber: 0xbd93f9,
170 syntaxComment: 0x6272a4,
171 syntaxOperator: 0xff79c6,
172 syntaxPunctuation: 0xf8f8f2,
173 syntaxConstant: 0xbd93f9,
174 syntaxParameter: 0xffb86c,
175 syntaxProperty: 0x50fa7b,
176 syntaxLabel: 0xff79c6,
177 },
178 'everforest': {
179 backgroundLeft: 0x29302a,
180 backgroundRight: 0x2d342e,
181 editorLineNumber: 0x7d8774,
182 editorLineNumberCursor: 0x83c092,
183 editorSeparator: 0x7d8774,
184 editorCursorCharBg: 0x83c092,
185 editorCursorCharFg: 0x2d342e,
186 editorCursorLineBg: 0x39403a,
187 fileTreeAdded: 0x83c092,
188 fileTreeDeleted: 0xe67e80,
189 fileTreeModified: 0xdbbc7f,
190 fileTreeRenamed: 0x7dc0c0,
191 fileTreeDirectory: 0x7dc0c0,
192 fileTreeCurrentFileBg: 0x39403a,
193 fileTreeCurrentFileFg: 0xd3c6aa,
194 fileTreeDefault: 0xd3c6aa,
195 fileTreeStatsAdded: 0x83c092,
196 fileTreeStatsDeleted: 0xe67e80,
197 terminalCommand: 0xd3c6aa,
198 terminalOutput: 0x7d8774,
199 terminalCursorBg: 0x83c092,
200 terminalCursorFg: 0x2d342e,
201 statusHash: 0xdbbc7f,
202 statusAuthor: 0x83c092,
203 statusDate: 0x7dc0c0,
204 statusMessage: 0xd3c6aa,
205 statusNoCommit: 0x7d8774,
206 separator: 0x7d8774,
207 syntaxKeyword: 0xe67e80,
208 syntaxType: 0xdbbc7f,
209 syntaxFunction: 0x83c092,
210 syntaxVariable: 0xd3c6aa,
211 syntaxString: 0xdbbc7f,
212 syntaxNumber: 0xd3869b,
213 syntaxComment: 0x7d8774,
214 syntaxOperator: 0xe67e80,
215 syntaxPunctuation: 0xb4a689,
216 syntaxConstant: 0xd3869b,
217 syntaxParameter: 0xdbbc7f,
218 syntaxProperty: 0x7dc0c0,
219 syntaxLabel: 0xe67e80,
220 },
221 'fluorite': {
222 backgroundLeft: 0x120c17,
223 backgroundRight: 0x16111b,
224 editorLineNumber: 0x2a1c36,
225 editorLineNumberCursor: 0x412b54,
226 editorSeparator: 0x2a1c36,
227 editorCursorCharBg: 0xa36dcf,
228 editorCursorCharFg: 0x16111b,
229 editorCursorLineBg: 0x19111f,
230 fileTreeAdded: 0x81b88b,
231 fileTreeDeleted: 0x5b3c75,
232 fileTreeModified: 0x885cad,
233 fileTreeRenamed: 0x8bcbff,
234 fileTreeDirectory: 0x885cad,
235 fileTreeCurrentFileBg: 0x19111f,
236 fileTreeCurrentFileFg: 0x885cad,
237 fileTreeDefault: 0x5b3c75,
238 fileTreeStatsAdded: 0x81b88b,
239 fileTreeStatsDeleted: 0x5b3c75,
240 terminalCommand: 0x885cad,
241 terminalOutput: 0x6e9bbc,
242 terminalCursorBg: 0xa36dcf,
243 terminalCursorFg: 0x16111b,
244 statusHash: 0xad85f5,
245 statusAuthor: 0x8bcbff,
246 statusDate: 0xd17b9f,
247 statusMessage: 0x6e9bbc,
248 statusNoCommit: 0x412b54,
249 separator: 0x2a1c36,
250 syntaxKeyword: 0xde7fec,
251 syntaxType: 0xad85f5,
252 syntaxFunction: 0x8bcbff,
253 syntaxVariable: 0xd7d7d7,
254 syntaxString: 0x8bd789,
255 syntaxNumber: 0xd17b9f,
256 syntaxComment: 0x5b3c75,
257 syntaxOperator: 0x6e9bbc,
258 syntaxPunctuation: 0x6e9bbc,
259 syntaxConstant: 0xd17b9f,
260 syntaxParameter: 0xad85f5,
261 syntaxProperty: 0x8bd789,
262 syntaxLabel: 0xde7fec,
263 },
264 'github-dark': {
265 backgroundLeft: 0x0d1117,
266 backgroundRight: 0x161b22,
267 editorLineNumber: 0x6e7681,
268 editorLineNumberCursor: 0x58a6ff,
269 editorSeparator: 0x30363d,
270 editorCursorCharBg: 0x58a6ff,
271 editorCursorCharFg: 0x161b22,
272 editorCursorLineBg: 0x21262d,
273 fileTreeAdded: 0x3fb950,
274 fileTreeDeleted: 0xf85149,
275 fileTreeModified: 0xdb6d28,
276 fileTreeRenamed: 0x58a6ff,
277 fileTreeDirectory: 0x58a6ff,
278 fileTreeCurrentFileBg: 0x21262d,
279 fileTreeCurrentFileFg: 0xe6edf3,
280 fileTreeDefault: 0xc9d1d9,
281 fileTreeStatsAdded: 0x3fb950,
282 fileTreeStatsDeleted: 0xf85149,
283 terminalCommand: 0xe6edf3,
284 terminalOutput: 0x6e7681,
285 terminalCursorBg: 0x58a6ff,
286 terminalCursorFg: 0x161b22,
287 statusHash: 0xdbab09,
288 statusAuthor: 0x3fb950,
289 statusDate: 0x58a6ff,
290 statusMessage: 0xe6edf3,
291 statusNoCommit: 0x6e7681,
292 separator: 0x30363d,
293 syntaxKeyword: 0xff7b72,
294 syntaxType: 0xffba4d,
295 syntaxFunction: 0xd299ff,
296 syntaxVariable: 0xc9d1d9,
297 syntaxString: 0xa8dbb5,
298 syntaxNumber: 0x79c0ff,
299 syntaxComment: 0x8b949e,
300 syntaxOperator: 0xff7b72,
301 syntaxPunctuation: 0xc9d1d9,
302 syntaxConstant: 0x79c0ff,
303 syntaxParameter: 0xffba4d,
304 syntaxProperty: 0x79c0ff,
305 syntaxLabel: 0xd299ff,
306 },
307 'gruvbox': {
308 backgroundLeft: 0x1d2021,
309 backgroundRight: 0x282828,
310 editorLineNumber: 0x928374,
311 editorLineNumberCursor: 0x83a598,
312 editorSeparator: 0x928374,
313 editorCursorCharBg: 0xfe8019,
314 editorCursorCharFg: 0x282828,
315 editorCursorLineBg: 0x3c3836,
316 fileTreeAdded: 0xb8bb26,
317 fileTreeDeleted: 0xfb4934,
318 fileTreeModified: 0xfe8019,
319 fileTreeRenamed: 0x83a598,
320 fileTreeDirectory: 0x83a598,
321 fileTreeCurrentFileBg: 0x3c3836,
322 fileTreeCurrentFileFg: 0xebdbb2,
323 fileTreeDefault: 0xd5c4a1,
324 fileTreeStatsAdded: 0xb8bb26,
325 fileTreeStatsDeleted: 0xfb4934,
326 terminalCommand: 0xebdbb2,
327 terminalOutput: 0x928374,
328 terminalCursorBg: 0xfe8019,
329 terminalCursorFg: 0x282828,
330 statusHash: 0xfabd2f,
331 statusAuthor: 0xb8bb26,
332 statusDate: 0x83a598,
333 statusMessage: 0xebdbb2,
334 statusNoCommit: 0x928374,
335 separator: 0x928374,
336 syntaxKeyword: 0xfb4934,
337 syntaxType: 0xfabd2f,
338 syntaxFunction: 0xb8bb26,
339 syntaxVariable: 0xebdbb2,
340 syntaxString: 0xb8bb26,
341 syntaxNumber: 0xd3869b,
342 syntaxComment: 0x928374,
343 syntaxOperator: 0xfb4934,
344 syntaxPunctuation: 0xd5c4a1,
345 syntaxConstant: 0xd3869b,
346 syntaxParameter: 0xfe8019,
347 syntaxProperty: 0xb8bb26,
348 syntaxLabel: 0xfb4934,
349 },
350 'material': {
351 backgroundLeft: 0x263238,
352 backgroundRight: 0x263238,
353 editorLineNumber: 0x546e7a,
354 editorLineNumberCursor: 0x80cbc4,
355 editorSeparator: 0x546e7a,
356 editorCursorCharBg: 0xffcb6b,
357 editorCursorCharFg: 0x263238,
358 editorCursorLineBg: 0x37474f,
359 fileTreeAdded: 0xc3e88d,
360 fileTreeDeleted: 0xff5370,
361 fileTreeModified: 0xffcb6b,
362 fileTreeRenamed: 0x80cbc4,
363 fileTreeDirectory: 0x89ddff,
364 fileTreeCurrentFileBg: 0x37474f,
365 fileTreeCurrentFileFg: 0xeeffff,
366 fileTreeDefault: 0xeeffff,
367 fileTreeStatsAdded: 0xc3e88d,
368 fileTreeStatsDeleted: 0xff5370,
369 terminalCommand: 0xeeffff,
370 terminalOutput: 0x546e7a,
371 terminalCursorBg: 0xffcb6b,
372 terminalCursorFg: 0x263238,
373 statusHash: 0xffcb6b,
374 statusAuthor: 0xc3e88d,
375 statusDate: 0x80cbc4,
376 statusMessage: 0xeeffff,
377 statusNoCommit: 0x546e7a,
378 separator: 0x546e7a,
379 syntaxKeyword: 0xc792ea,
380 syntaxType: 0xffcb6b,
381 syntaxFunction: 0x82aaff,
382 syntaxVariable: 0xeeffff,
383 syntaxString: 0xc3e88d,
384 syntaxNumber: 0xf78c6c,
385 syntaxComment: 0x546e7a,
386 syntaxOperator: 0x89ddff,
387 syntaxPunctuation: 0x90a4ae,
388 syntaxConstant: 0x89ddff,
389 syntaxParameter: 0xffcb6b,
390 syntaxProperty: 0x80cbc4,
391 syntaxLabel: 0xc792ea,
392 },
393 'monokai': {
394 backgroundLeft: 0x1e1e1e,
395 backgroundRight: 0x272822,
396 editorLineNumber: 0x75715e,
397 editorLineNumberCursor: 0x66d9ef,
398 editorSeparator: 0x75715e,
399 editorCursorCharBg: 0xfd971f,
400 editorCursorCharFg: 0x272822,
401 editorCursorLineBg: 0x33332d,
402 fileTreeAdded: 0xa6e22e,
403 fileTreeDeleted: 0xf92672,
404 fileTreeModified: 0xfd971f,
405 fileTreeRenamed: 0x66d9ef,
406 fileTreeDirectory: 0xae81ff,
407 fileTreeCurrentFileBg: 0x33332d,
408 fileTreeCurrentFileFg: 0xf8f8f2,
409 fileTreeDefault: 0xf8f8f2,
410 fileTreeStatsAdded: 0xa6e22e,
411 fileTreeStatsDeleted: 0xf92672,
412 terminalCommand: 0xf8f8f2,
413 terminalOutput: 0x75715e,
414 terminalCursorBg: 0xfd971f,
415 terminalCursorFg: 0x272822,
416 statusHash: 0xe6db74,
417 statusAuthor: 0xa6e22e,
418 statusDate: 0x66d9ef,
419 statusMessage: 0xf8f8f2,
420 statusNoCommit: 0x75715e,
421 separator: 0x75715e,
422 syntaxKeyword: 0xf92672,
423 syntaxType: 0x66d9ef,
424 syntaxFunction: 0xa6e22e,
425 syntaxVariable: 0xf8f8f2,
426 syntaxString: 0xe6db74,
427 syntaxNumber: 0xae81ff,
428 syntaxComment: 0x75715e,
429 syntaxOperator: 0xf92672,
430 syntaxPunctuation: 0xf8f8f2,
431 syntaxConstant: 0xae81ff,
432 syntaxParameter: 0xfd971f,
433 syntaxProperty: 0xa6e22e,
434 syntaxLabel: 0xf92672,
435 },
436 'night-owl': {
437 backgroundLeft: 0x011627,
438 backgroundRight: 0x011627,
439 editorLineNumber: 0x4e7993,
440 editorLineNumberCursor: 0x7aa2f7,
441 editorSeparator: 0x014c86,
442 editorCursorCharBg: 0x7aa2f7,
443 editorCursorCharFg: 0x011627,
444 editorCursorLineBg: 0x012948,
445 fileTreeAdded: 0xaddb67,
446 fileTreeDeleted: 0xef5350,
447 fileTreeModified: 0xffd580,
448 fileTreeRenamed: 0x7aa2f7,
449 fileTreeDirectory: 0x82aaff,
450 fileTreeCurrentFileBg: 0x012948,
451 fileTreeCurrentFileFg: 0xd6deeb,
452 fileTreeDefault: 0xd6deeb,
453 fileTreeStatsAdded: 0xaddb67,
454 fileTreeStatsDeleted: 0xef5350,
455 terminalCommand: 0xd6deeb,
456 terminalOutput: 0x4e7993,
457 terminalCursorBg: 0x7aa2f7,
458 terminalCursorFg: 0x011627,
459 statusHash: 0xffcb6b,
460 statusAuthor: 0xaddb67,
461 statusDate: 0x7aa2f7,
462 statusMessage: 0xd6deeb,
463 statusNoCommit: 0x4e7993,
464 separator: 0x014c86,
465 syntaxKeyword: 0xc792ea,
466 syntaxType: 0xffcb6b,
467 syntaxFunction: 0x82aaff,
468 syntaxVariable: 0xd6deeb,
469 syntaxString: 0xaddb67,
470 syntaxNumber: 0xf78c6c,
471 syntaxComment: 0x4e7993,
472 syntaxOperator: 0xc792ea,
473 syntaxPunctuation: 0x7f848e,
474 syntaxConstant: 0x80cbc4,
475 syntaxParameter: 0xffcb6b,
476 syntaxProperty: 0x7aa2f7,
477 syntaxLabel: 0xff5874,
478 },
479 'nord': {
480 backgroundLeft: 0x242a38,
481 backgroundRight: 0x2e3440,
482 editorLineNumber: 0x4c566a,
483 editorLineNumberCursor: 0x88c0d0,
484 editorSeparator: 0x4c566a,
485 editorCursorCharBg: 0x88c0d0,
486 editorCursorCharFg: 0x2e3440,
487 editorCursorLineBg: 0x3b4252,
488 fileTreeAdded: 0xa3be8c,
489 fileTreeDeleted: 0xbf616a,
490 fileTreeModified: 0xebcb8b,
491 fileTreeRenamed: 0x81a1c1,
492 fileTreeDirectory: 0x88c0d0,
493 fileTreeCurrentFileBg: 0x3b4252,
494 fileTreeCurrentFileFg: 0xeceff4,
495 fileTreeDefault: 0xd8dee9,
496 fileTreeStatsAdded: 0xa3be8c,
497 fileTreeStatsDeleted: 0xbf616a,
498 terminalCommand: 0xeceff4,
499 terminalOutput: 0x4c566a,
500 terminalCursorBg: 0x88c0d0,
501 terminalCursorFg: 0x2e3440,
502 statusHash: 0xebcb8b,
503 statusAuthor: 0xa3be8c,
504 statusDate: 0x81a1c1,
505 statusMessage: 0xeceff4,
506 statusNoCommit: 0x4c566a,
507 separator: 0x4c566a,
508 syntaxKeyword: 0xb48ead,
509 syntaxType: 0x88c0d0,
510 syntaxFunction: 0x88c0d0,
511 syntaxVariable: 0xeceff4,
512 syntaxString: 0xa3be8c,
513 syntaxNumber: 0xb48ead,
514 syntaxComment: 0x4c566a,
515 syntaxOperator: 0x88c0d0,
516 syntaxPunctuation: 0xd8dee9,
517 syntaxConstant: 0xb48ead,
518 syntaxParameter: 0xebcb8b,
519 syntaxProperty: 0xa3be8c,
520 syntaxLabel: 0xb48ead,
521 },
522 'one-dark': {
523 backgroundLeft: 0x21252b,
524 backgroundRight: 0x282c34,
525 editorLineNumber: 0x5c6370,
526 editorLineNumberCursor: 0x61afef,
527 editorSeparator: 0x5c6370,
528 editorCursorCharBg: 0x61afef,
529 editorCursorCharFg: 0x282c34,
530 editorCursorLineBg: 0x2f343d,
531 fileTreeAdded: 0x98c379,
532 fileTreeDeleted: 0xe06c75,
533 fileTreeModified: 0xd19a66,
534 fileTreeRenamed: 0x61afef,
535 fileTreeDirectory: 0x61afef,
536 fileTreeCurrentFileBg: 0x2f343d,
537 fileTreeCurrentFileFg: 0xdcdfe4,
538 fileTreeDefault: 0xabb2bf,
539 fileTreeStatsAdded: 0x98c379,
540 fileTreeStatsDeleted: 0xe06c75,
541 terminalCommand: 0xdcdfe4,
542 terminalOutput: 0x5c6370,
543 terminalCursorBg: 0x61afef,
544 terminalCursorFg: 0x282c34,
545 statusHash: 0xe5c07b,
546 statusAuthor: 0x98c379,
547 statusDate: 0x61afef,
548 statusMessage: 0xdcdfe4,
549 statusNoCommit: 0x5c6370,
550 separator: 0x5c6370,
551 syntaxKeyword: 0xc678dd,
552 syntaxType: 0xe5c07b,
553 syntaxFunction: 0x61afef,
554 syntaxVariable: 0xdcdfe4,
555 syntaxString: 0x98c379,
556 syntaxNumber: 0xd19a66,
557 syntaxComment: 0x5c6370,
558 syntaxOperator: 0xc678dd,
559 syntaxPunctuation: 0xabb2bf,
560 syntaxConstant: 0xd19a66,
561 syntaxParameter: 0xe5c07b,
562 syntaxProperty: 0x98c379,
563 syntaxLabel: 0xc678dd,
564 },
565 'rose-pine': {
566 backgroundLeft: 0x191724,
567 backgroundRight: 0x232136,
568 editorLineNumber: 0x6e6a86,
569 editorLineNumberCursor: 0x9ccfd8,
570 editorSeparator: 0x6e6a86,
571 editorCursorCharBg: 0xebbcba,
572 editorCursorCharFg: 0x232136,
573 editorCursorLineBg: 0x2a273f,
574 fileTreeAdded: 0x9ccfd8,
575 fileTreeDeleted: 0xeb6f92,
576 fileTreeModified: 0xf6c177,
577 fileTreeRenamed: 0xc4a7e7,
578 fileTreeDirectory: 0xc4a7e7,
579 fileTreeCurrentFileBg: 0x2a273f,
580 fileTreeCurrentFileFg: 0xe0def4,
581 fileTreeDefault: 0xe0def4,
582 fileTreeStatsAdded: 0x9ccfd8,
583 fileTreeStatsDeleted: 0xeb6f92,
584 terminalCommand: 0xe0def4,
585 terminalOutput: 0x6e6a86,
586 terminalCursorBg: 0xebbcba,
587 terminalCursorFg: 0x232136,
588 statusHash: 0xf6c177,
589 statusAuthor: 0x9ccfd8,
590 statusDate: 0xc4a7e7,
591 statusMessage: 0xe0def4,
592 statusNoCommit: 0x6e6a86,
593 separator: 0x6e6a86,
594 syntaxKeyword: 0xc4a7e7,
595 syntaxType: 0xf6c177,
596 syntaxFunction: 0x9ccfd8,
597 syntaxVariable: 0xe0def4,
598 syntaxString: 0xf6c177,
599 syntaxNumber: 0xea9a97,
600 syntaxComment: 0x6e6a86,
601 syntaxOperator: 0xeb6f92,
602 syntaxPunctuation: 0x908caa,
603 syntaxConstant: 0xebbcba,
604 syntaxParameter: 0xf6c177,
605 syntaxProperty: 0x9ccfd8,
606 syntaxLabel: 0xc4a7e7,
607 },
608 'solarized-dark': {
609 backgroundLeft: 0x002429,
610 backgroundRight: 0x002b36,
611 editorLineNumber: 0x586e75,
612 editorLineNumberCursor: 0x268bd2,
613 editorSeparator: 0x586e75,
614 editorCursorCharBg: 0x268bd2,
615 editorCursorCharFg: 0x002b36,
616 editorCursorLineBg: 0x073642,
617 fileTreeAdded: 0x859900,
618 fileTreeDeleted: 0xdc322f,
619 fileTreeModified: 0xb58900,
620 fileTreeRenamed: 0x268bd2,
621 fileTreeDirectory: 0x2aa198,
622 fileTreeCurrentFileBg: 0x073642,
623 fileTreeCurrentFileFg: 0xeee8d5,
624 fileTreeDefault: 0x839496,
625 fileTreeStatsAdded: 0x859900,
626 fileTreeStatsDeleted: 0xdc322f,
627 terminalCommand: 0xeee8d5,
628 terminalOutput: 0x586e75,
629 terminalCursorBg: 0x268bd2,
630 terminalCursorFg: 0x002b36,
631 statusHash: 0xb58900,
632 statusAuthor: 0x859900,
633 statusDate: 0x268bd2,
634 statusMessage: 0xeee8d5,
635 statusNoCommit: 0x586e75,
636 separator: 0x586e75,
637 syntaxKeyword: 0xcb4b16,
638 syntaxType: 0xb58900,
639 syntaxFunction: 0x268bd2,
640 syntaxVariable: 0x839496,
641 syntaxString: 0x2aa198,
642 syntaxNumber: 0x6c71c4,
643 syntaxComment: 0x586e75,
644 syntaxOperator: 0xcb4b16,
645 syntaxPunctuation: 0x839496,
646 syntaxConstant: 0x6c71c4,
647 syntaxParameter: 0xb58900,
648 syntaxProperty: 0x2aa198,
649 syntaxLabel: 0xd33682,
650 },
651 'solarized-light': {
652 backgroundLeft: 0xfaf5e1,
653 backgroundRight: 0xfdf6e3,
654 editorLineNumber: 0x93a1a1,
655 editorLineNumberCursor: 0x268bd2,
656 editorSeparator: 0x93a1a1,
657 editorCursorCharBg: 0x268bd2,
658 editorCursorCharFg: 0xfdf6e3,
659 editorCursorLineBg: 0xeee8d5,
660 fileTreeAdded: 0x859900,
661 fileTreeDeleted: 0xdc322f,
662 fileTreeModified: 0xb58900,
663 fileTreeRenamed: 0x268bd2,
664 fileTreeDirectory: 0x2aa198,
665 fileTreeCurrentFileBg: 0xeee8d5,
666 fileTreeCurrentFileFg: 0x073642,
667 fileTreeDefault: 0x657b83,
668 fileTreeStatsAdded: 0x859900,
669 fileTreeStatsDeleted: 0xdc322f,
670 terminalCommand: 0x073642,
671 terminalOutput: 0x93a1a1,
672 terminalCursorBg: 0x268bd2,
673 terminalCursorFg: 0xfdf6e3,
674 statusHash: 0xb58900,
675 statusAuthor: 0x859900,
676 statusDate: 0x268bd2,
677 statusMessage: 0x073642,
678 statusNoCommit: 0x93a1a1,
679 separator: 0x93a1a1,
680 syntaxKeyword: 0xcb4b16,
681 syntaxType: 0xb58900,
682 syntaxFunction: 0x268bd2,
683 syntaxVariable: 0x657b83,
684 syntaxString: 0x2aa198,
685 syntaxNumber: 0x6c71c4,
686 syntaxComment: 0x93a1a1,
687 syntaxOperator: 0xcb4b16,
688 syntaxPunctuation: 0x657b83,
689 syntaxConstant: 0x6c71c4,
690 syntaxParameter: 0xb58900,
691 syntaxProperty: 0x2aa198,
692 syntaxLabel: 0xd33682,
693 },
694 'telemetry': {
695 backgroundLeft: 0x0f1b1d,
696 backgroundRight: 0x162224,
697 editorLineNumber: 0x6b8d94,
698 editorLineNumberCursor: 0xf4ae59,
699 editorSeparator: 0x6b8d94,
700 editorCursorCharBg: 0xf4ae59,
701 editorCursorCharFg: 0x0f1b1d,
702 editorCursorLineBg: 0x1d2e31,
703 fileTreeAdded: 0x8dac8b,
704 fileTreeDeleted: 0xc27166,
705 fileTreeModified: 0xf4ae59,
706 fileTreeRenamed: 0x93bfc2,
707 fileTreeDirectory: 0xc27166,
708 fileTreeCurrentFileBg: 0x1d2e31,
709 fileTreeCurrentFileFg: 0x9abfbe,
710 fileTreeDefault: 0x9abfbe,
711 fileTreeStatsAdded: 0x8dac8b,
712 fileTreeStatsDeleted: 0xc27166,
713 terminalCommand: 0x9abfbe,
714 terminalOutput: 0x6b8d94,
715 terminalCursorBg: 0xf4ae59,
716 terminalCursorFg: 0x0f1b1d,
717 statusHash: 0xf4ae59,
718 statusAuthor: 0x8dac8b,
719 statusDate: 0x93bfc2,
720 statusMessage: 0x9abfbe,
721 statusNoCommit: 0x6b8d94,
722 separator: 0x6b8d94,
723 syntaxKeyword: 0xc27166,
724 syntaxType: 0xd49a4f,
725 syntaxFunction: 0xd49a4f,
726 syntaxVariable: 0x9abfbe,
727 syntaxString: 0x8dac8b,
728 syntaxNumber: 0xc4847a,
729 syntaxComment: 0x6b8d94,
730 syntaxOperator: 0x8fb5b3,
731 syntaxPunctuation: 0x7a9a99,
732 syntaxConstant: 0xc4847a,
733 syntaxParameter: 0x7a9a99,
734 syntaxProperty: 0x9abfbe,
735 syntaxLabel: 0xc27166,
736 },
737 'tokyo-night': {
738 backgroundLeft: 0x1e2236,
739 backgroundRight: 0x1a1b26,
740 editorLineNumber: 0x565f89,
741 editorLineNumberCursor: 0x7dcfff,
742 editorSeparator: 0x565f89,
743 editorCursorCharBg: 0x7aa2f7,
744 editorCursorCharFg: 0x1a1b26,
745 editorCursorLineBg: 0x2a2f44,
746 fileTreeAdded: 0x9ece6a,
747 fileTreeDeleted: 0xf7768e,
748 fileTreeModified: 0xff9e64,
749 fileTreeRenamed: 0x7aa2f7,
750 fileTreeDirectory: 0x7aa2f7,
751 fileTreeCurrentFileBg: 0x2a2f44,
752 fileTreeCurrentFileFg: 0xc0caf5,
753 fileTreeDefault: 0xc0caf5,
754 fileTreeStatsAdded: 0x9ece6a,
755 fileTreeStatsDeleted: 0xf7768e,
756 terminalCommand: 0xc0caf5,
757 terminalOutput: 0x565f89,
758 terminalCursorBg: 0x7aa2f7,
759 terminalCursorFg: 0x1a1b26,
760 statusHash: 0xffd580,
761 statusAuthor: 0x9ece6a,
762 statusDate: 0x7aa2f7,
763 statusMessage: 0xc0caf5,
764 statusNoCommit: 0x565f89,
765 separator: 0x565f89,
766 syntaxKeyword: 0xbb9af7,
767 syntaxType: 0x7dcfff,
768 syntaxFunction: 0x7aa2f7,
769 syntaxVariable: 0xc0caf5,
770 syntaxString: 0x9ece6a,
771 syntaxNumber: 0xff9e64,
772 syntaxComment: 0x565f89,
773 syntaxOperator: 0x7dcfff,
774 syntaxPunctuation: 0x8c94b8,
775 syntaxConstant: 0xff9e64,
776 syntaxParameter: 0xffd580,
777 syntaxProperty: 0x9ece6a,
778 syntaxLabel: 0xbb9af7,
779 },
780}
781
782export const DEFAULT_THEME = 'tokyo-night'
783hooks/cells.ts 74 lines1// Text as the terminal lays it out: how many cells each character takes.
2
3const TAB_WIDTH = 4
4
5// A wide character takes two terminal cells. A Raster cell holds one narrow
6// character, so text keeps a wide one as the character and a TAIL after it,
7// two cells as on screen, and the painter draws the character over both.
8export const TAIL = '\ue000'
9
10export function isWide(cp: number): boolean {
11 return (
12 cp > 0xffff ||
13 (cp >= 0x1100 && cp <= 0x115f) ||
14 (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
15 (cp >= 0xac00 && cp <= 0xd7a3) ||
16 (cp >= 0xf900 && cp <= 0xfaff) ||
17 (cp >= 0xfe30 && cp <= 0xfe4f) ||
18 (cp >= 0xff00 && cp <= 0xff60) ||
19 (cp >= 0xffe0 && cp <= 0xffe6) ||
20 (cp >= 0x231a && cp <= 0x231b) ||
21 (cp >= 0x23e9 && cp <= 0x23f3) ||
22 (cp >= 0x25fd && cp <= 0x25fe) ||
23 (cp >= 0x2614 && cp <= 0x2615) ||
24 (cp >= 0x2648 && cp <= 0x2653) ||
25 cp === 0x267f ||
26 cp === 0x2693 ||
27 cp === 0x26a1 ||
28 (cp >= 0x26aa && cp <= 0x26ab) ||
29 (cp >= 0x26bd && cp <= 0x26be) ||
30 (cp >= 0x26c4 && cp <= 0x26c5) ||
31 cp === 0x26ce ||
32 cp === 0x26d4 ||
33 cp === 0x26ea ||
34 (cp >= 0x26f2 && cp <= 0x26fd) ||
35 cp === 0x2705 ||
36 (cp >= 0x270a && cp <= 0x270b) ||
37 cp === 0x2728 ||
38 cp === 0x274c ||
39 cp === 0x274e ||
40 (cp >= 0x2753 && cp <= 0x2757) ||
41 (cp >= 0x2795 && cp <= 0x2797) ||
42 cp === 0x27b0 ||
43 cp === 0x27bf ||
44 (cp >= 0x2b1b && cp <= 0x2b1c) ||
45 cp === 0x2b50 ||
46 cp === 0x2b55
47 )
48}
49
50const isInvisible = (cp: number) =>
51 (cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f) || cp === 0xfeff
52
53// One line as cells: a tab spread to its stop, controls blank, a wide
54// character with its TAIL (one past the BMP is two units already).
55export function cellText(line: string): string {
56 let out = ''
57 for (const ch of line) {
58 const cp = ch.codePointAt(0)!
59 if (ch === '\t') out += ' '.repeat(TAB_WIDTH - (out.length % TAB_WIDTH))
60 else if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) out += ' '
61 else if (isInvisible(cp)) continue
62 else if (isWide(cp)) out += cp > 0xffff ? ch : ch + TAIL
63 else out += ch
64 }
65 return out
66}
67
68// How many cells a text takes on screen.
69export function cellWidth(text: string): number {
70 let width = 0
71 for (const ch of text) width += isWide(ch.codePointAt(0)!) ? 2 : 1
72 return width
73}
74hooks/highlight.ts 640 lines1// A line-at-a-time syntax highlighter: one token class per character, with the
2// state a line ends in (inside a block comment or a multi-line string) carried
3// to the next. The classes are the ones gitlogue's themes color.
4
5export const Tok = {
6 Variable: 0,
7 Keyword: 1,
8 Type: 2,
9 Function: 3,
10 String: 4,
11 Number: 5,
12 Comment: 6,
13 Operator: 7,
14 Punctuation: 8,
15 Constant: 9,
16 Parameter: 10,
17 Property: 11,
18 Label: 12,
19} as const
20export type Tok = (typeof Tok)[keyof typeof Tok]
21
22type Lang = {
23 lineComments: string[]
24 blockComments: [string, string][]
25 // Delimiters whose strings may run past the end of a line.
26 multilineStrings: string[]
27 strings: string[]
28 keywords: Set<string>
29 constants: Set<string>
30 types: Set<string>
31 caseInsensitive?: boolean
32 hashCommentNeedsSpace?: boolean
33 rustLifetimes?: boolean
34 macros?: boolean
35 sigils?: string
36 decorators?: boolean
37 preprocessor?: boolean
38 keysBeforeColon?: boolean
39 keysBeforeEquals?: boolean
40 markup?: boolean
41 markdown?: boolean
42 identChars?: RegExp
43}
44
45const words = (s: string) => new Set(s.split(/\s+/).filter(Boolean))
46
47const C_COMMENTS = { lineComments: ['//'], blockComments: [['/*', '*/']] as [string, string][] }
48const NONE = { lineComments: [], blockComments: [] as [string, string][], multilineStrings: [], strings: [] }
49
50const JS: Lang = {
51 ...C_COMMENTS,
52 multilineStrings: ['`'],
53 strings: ['"', "'"],
54 keywords: words(`break case catch class const continue debugger default delete do else export extends
55 finally for from function if import in instanceof let new of return static super switch this throw try
56 typeof var void while with yield async await as interface type enum implements declare namespace
57 readonly private public protected abstract keyof infer is satisfies get set`),
58 constants: words('true false null undefined NaN Infinity'),
59 types: words('string number boolean any unknown never object symbol bigint'),
60 decorators: true,
61}
62
63const RUST: Lang = {
64 ...C_COMMENTS,
65 multilineStrings: ['"'],
66 strings: [],
67 keywords: words(`as async await break const continue crate dyn else enum extern fn for if impl in let loop
68 match mod move mut pub ref return self static struct super trait type unsafe use where while`),
69 constants: words('true false'),
70 types: words('i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str Self'),
71 rustLifetimes: true,
72 macros: true,
73}
74
75const PYTHON: Lang = {
76 lineComments: ['#'],
77 blockComments: [],
78 multilineStrings: ['"""', "'''"],
79 strings: ['"', "'"],
80 keywords: words(`and as assert async await break class continue def del elif else except finally for from
81 global if import in is lambda nonlocal not or pass raise return try while with yield match case self cls`),
82 constants: words('True False None'),
83 types: words('int str float bool list dict set tuple bytes object complex frozenset type'),
84 decorators: true,
85}
86
87const GO: Lang = {
88 ...C_COMMENTS,
89 multilineStrings: ['`'],
90 strings: ['"', "'"],
91 keywords: words(`break case chan const continue default defer else fallthrough for func go goto if import
92 interface map package range return select struct switch type var`),
93 constants: words('true false nil iota'),
94 types: words(`int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr float32 float64
95 complex64 complex128 string bool byte rune error any`),
96}
97
98const C_FAMILY: Lang = {
99 ...C_COMMENTS,
100 multilineStrings: [],
101 strings: ['"', "'"],
102 keywords: words(`auto break case const continue default do else enum extern for goto if inline register
103 restrict return sizeof static struct switch typedef union volatile while class namespace template
104 typename public private protected virtual override final new delete this throw try catch using operator
105 constexpr noexcept static_cast dynamic_cast reinterpret_cast const_cast friend explicit mutable`),
106 constants: words('true false NULL nullptr'),
107 types: words(`void char short int long float double signed unsigned bool size_t ssize_t int8_t int16_t
108 int32_t int64_t uint8_t uint16_t uint32_t uint64_t auto`),
109 preprocessor: true,
110}
111
112const JVM_FAMILY: Lang = {
113 ...C_COMMENTS,
114 multilineStrings: ['"""'],
115 strings: ['"', "'"],
116 keywords: words(`abstract assert break case catch class const continue default do else enum extends final
117 finally for if implements import instanceof interface native new package private protected public return
118 static super switch synchronized this throw throws transient try volatile while var val fun when object
119 companion data sealed open override lateinit is in out by init let func struct protocol extension guard
120 defer where namespace using async await yield dynamic late required get set internal def lazy match trait
121 with typealias inout mutating operator record`),
122 constants: words('true false null nil'),
123 types: words('void boolean byte char short int long float double string bool dynamic Int String Bool'),
124 decorators: true,
125}
126
127const SHELL: Lang = {
128 lineComments: ['#'],
129 blockComments: [],
130 multilineStrings: ['"', "'"],
131 strings: [],
132 keywords: words(`if then else elif fi for while until do done case esac in function return local export
133 readonly declare set unset source alias exit shift trap break continue select time end begin not and or
134 switch`),
135 constants: words('true false'),
136 types: words(''),
137 hashCommentNeedsSpace: true,
138 sigils: '$',
139}
140
141const RUBY: Lang = {
142 lineComments: ['#'],
143 blockComments: [['=begin', '=end']],
144 multilineStrings: [],
145 strings: ['"', "'"],
146 keywords: words(`alias and begin break case class def defined do else elsif end ensure for if in module
147 next not or redo rescue retry return self super then undef unless until when while yield require
148 require_relative attr_accessor attr_reader attr_writer private protected public include extend`),
149 constants: words('true false nil'),
150 types: words(''),
151 sigils: '@',
152}
153
154const LUA: Lang = {
155 lineComments: ['--'],
156 blockComments: [['--[[', ']]']],
157 multilineStrings: ['[['],
158 strings: ['"', "'"],
159 keywords: words(`and break do else elseif end for function goto if in local not or repeat return then
160 until while`),
161 constants: words('true false nil'),
162 types: words(''),
163}
164
165const SQL: Lang = {
166 lineComments: ['--'],
167 blockComments: [['/*', '*/']],
168 multilineStrings: [],
169 strings: ["'", '"'],
170 keywords: words(`select from where and or not insert into values update set delete create table drop alter
171 add column index primary key foreign references join inner left right outer full on as group by order
172 having limit offset union all distinct case when then else end is null in exists between like begin
173 commit rollback transaction view trigger function returns return declare if with default unique check
174 constraint asc desc`),
175 constants: words('true false null'),
176 types: words('int integer bigint smallint text varchar char boolean bool date timestamp float real numeric decimal serial uuid json jsonb'),
177 caseInsensitive: true,
178}
179
180const HASKELL: Lang = {
181 lineComments: ['--'],
182 blockComments: [['{-', '-}']],
183 multilineStrings: [],
184 strings: ['"'],
185 keywords: words(`case class data default deriving do else if import in infix infixl infixr instance let
186 module newtype of then type where qualified as hiding forall`),
187 constants: words('True False Nothing'),
188 types: words(''),
189}
190
191const NIX: Lang = {
192 lineComments: ['#'],
193 blockComments: [['/*', '*/']],
194 multilineStrings: ["''", '"'],
195 strings: [],
196 keywords: words('let in with rec inherit if then else import assert or'),
197 constants: words('true false null'),
198 types: words(''),
199 keysBeforeEquals: true,
200}
201
202const ELIXIR: Lang = {
203 lineComments: ['#'],
204 blockComments: [],
205 multilineStrings: ['"""'],
206 strings: ['"', "'"],
207 keywords: words(`def defp defmodule defmacro defstruct defimpl defprotocol do end if else unless case cond
208 fn when with for receive try catch rescue after raise import alias require use quote unquote and or not in`),
209 constants: words('true false nil'),
210 types: words(''),
211 sigils: '@',
212}
213
214const ZIG: Lang = {
215 lineComments: ['//'],
216 blockComments: [],
217 multilineStrings: [],
218 strings: ['"', "'"],
219 keywords: words(`const var fn pub return if else while for switch break continue defer errdefer try catch
220 struct enum union error comptime inline export extern test and or orelse unreachable async await`),
221 constants: words('true false null undefined'),
222 types: words('i8 i16 i32 i64 u8 u16 u32 u64 usize isize f32 f64 bool void anytype type noreturn'),
223}
224
225const YAML: Lang = {
226 lineComments: ['#'],
227 blockComments: [],
228 multilineStrings: [],
229 strings: ['"', "'"],
230 keywords: words(''),
231 constants: words('true false null yes no on off'),
232 types: words(''),
233 hashCommentNeedsSpace: true,
234 keysBeforeColon: true,
235 identChars: /[A-Za-z0-9_\-.]/,
236}
237
238const TOML: Lang = {
239 lineComments: ['#', ';'],
240 blockComments: [],
241 multilineStrings: ['"""', "'''"],
242 strings: ['"', "'"],
243 keywords: words(''),
244 constants: words('true false'),
245 types: words(''),
246 keysBeforeEquals: true,
247 identChars: /[A-Za-z0-9_\-.]/,
248}
249
250const JSON_LANG: Lang = {
251 ...C_COMMENTS,
252 multilineStrings: [],
253 strings: ['"'],
254 keywords: words(''),
255 constants: words('true false null'),
256 types: words(''),
257 keysBeforeColon: true,
258}
259
260const CSS: Lang = {
261 ...C_COMMENTS,
262 multilineStrings: [],
263 strings: ['"', "'"],
264 keywords: words('important from to and not only'),
265 constants: words(''),
266 types: words(''),
267 keysBeforeColon: true,
268 sigils: '@$',
269 identChars: /[A-Za-z0-9_\-]/,
270}
271
272const MARKUP: Lang = {
273 lineComments: [],
274 blockComments: [['<!--', '-->']],
275 multilineStrings: [],
276 strings: ['"', "'"],
277 keywords: words(''),
278 constants: words(''),
279 types: words(''),
280 markup: true,
281 identChars: /[A-Za-z0-9_\-:.]/,
282}
283
284const MARKDOWN: Lang = {
285 ...NONE,
286 blockComments: [['<!--', '-->']],
287 keywords: words(''),
288 constants: words(''),
289 types: words(''),
290 markdown: true,
291}
292
293const PLAIN: Lang = {
294 ...NONE,
295 strings: ['"'],
296 keywords: words(''),
297 constants: words(''),
298 types: words(''),
299}
300
301const BY_EXTENSION: Record<string, Lang> = {}
302const assign = (lang: Lang, exts: string) => exts.split(' ').forEach(ext => (BY_EXTENSION[ext] = lang))
303assign(JS, 'js jsx mjs cjs ts tsx mts cts')
304assign(RUST, 'rs')
305assign(PYTHON, 'py pyi pyw')
306assign(GO, 'go')
307assign(C_FAMILY, 'c h cc cpp cxx hpp hh hxx m mm ino glsl hlsl wgsl shader gdshader')
308assign(JVM_FAMILY, 'java kt kts scala sc swift dart cs groovy gradle php gd')
309assign(SHELL, 'sh bash zsh fish ksh envrc')
310assign(RUBY, 'rb rake gemspec')
311assign(LUA, 'lua')
312assign(SQL, 'sql')
313assign(HASKELL, 'hs lhs elm purs')
314assign(NIX, 'nix')
315assign(ELIXIR, 'ex exs erl hrl')
316assign(ZIG, 'zig')
317assign(YAML, 'yaml yml')
318assign(TOML, 'toml ini cfg conf tres tscn godot properties editorconfig')
319assign(JSON_LANG, 'json jsonc json5 webmanifest')
320assign(CSS, 'css scss sass less')
321assign(MARKUP, 'html htm xml svg vue svelte astro xhtml plist xaml')
322assign(MARKDOWN, 'md markdown mdx')
323
324const BY_NAME: Record<string, Lang> = {
325 Makefile: SHELL,
326 makefile: SHELL,
327 Dockerfile: SHELL,
328 Justfile: SHELL,
329 justfile: SHELL,
330 PKGBUILD: SHELL,
331 '.bashrc': SHELL,
332 '.zshrc': SHELL,
333 '.profile': SHELL,
334 'CMakeLists.txt': SHELL,
335 '.gitignore': YAML,
336}
337
338export function languageFor(path: string): Lang {
339 const name = path.slice(path.lastIndexOf('/') + 1)
340 if (BY_NAME[name]) return BY_NAME[name]
341 const dot = name.lastIndexOf('.')
342 return (dot >= 0 && BY_EXTENSION[name.slice(dot + 1).toLowerCase()]) || PLAIN
343}
344
345export type LineTokens = { toks: Uint8Array; end: string }
346
347const LONG_LINE = 2000
348const IDENT_START = /[A-Za-z_]/
349const IDENT = /[A-Za-z0-9_]/
350const DIGIT = /[0-9]/
351const OPERATORS = '+-*/%=<>!&|^~?:'
352const PUNCTUATION = '()[]{},;.'
353const DEFINERS = words('fn def func function fun sub proc macro')
354
355function nextNonSpace(line: string, from: number): string {
356 for (let i = from; i < line.length; i++) if (line[i] !== ' ' && line[i] !== '\t') return line[i]
357 return ''
358}
359
360function prevNonSpace(line: string, before: number): string {
361 for (let i = before - 1; i >= 0; i--) if (line[i] !== ' ' && line[i] !== '\t') return line[i]
362 return ''
363}
364
365function previousWord(line: string, before: number): string {
366 let i = before - 1
367 while (i >= 0 && line[i] === ' ') i--
368 const end = i + 1
369 while (i >= 0 && IDENT.test(line[i])) i--
370 return line.slice(i + 1, end)
371}
372
373// The state a line starts in: '' plain, 'c<close>' in a block comment, 's<delim>' in a string.
374function tokenize(lang: Lang, line: string, start: string): LineTokens {
375 const toks = new Uint8Array(line.length)
376 const n = line.length
377 let i = 0
378 let state = start
379 const fill = (from: number, to: number, tok: Tok) => toks.fill(tok, from, Math.min(to, n))
380 const identChar = lang.identChars ?? IDENT
381
382 if (lang.markdown && state === '') {
383 const heading = /^(#{1,6})\s/.exec(line)
384 if (heading) return { toks: toks.fill(Tok.Keyword), end: '' }
385 if (/^\s*```/.test(line)) return { toks: toks.fill(Tok.Comment), end: 's```' }
386 if (/^\s*>/.test(line)) return { toks: toks.fill(Tok.Comment), end: '' }
387 const list = /^(\s*)([-*+]|\d+\.)\s/.exec(line)
388 if (list) fill(list[1].length, list[1].length + list[2].length, Tok.Punctuation)
389 }
390
391 while (i < n) {
392 if (state.startsWith('c')) {
393 const close = state.slice(1)
394 const at = line.indexOf(close, i)
395 if (at < 0) {
396 fill(i, n, Tok.Comment)
397 return { toks, end: state }
398 }
399 fill(i, at + close.length, Tok.Comment)
400 i = at + close.length
401 state = ''
402 continue
403 }
404 if (state.startsWith('s')) {
405 const delim = state.slice(1)
406 let at = -1
407 for (let j = i; j < n; j++) {
408 if (line[j] === '\\' && delim !== "''" && delim !== '```') {
409 j++
410 continue
411 }
412 if (line.startsWith(delim, j)) {
413 at = j
414 break
415 }
416 }
417 if (at < 0) {
418 fill(i, n, delim === '```' ? Tok.Comment : Tok.String)
419 return { toks, end: state }
420 }
421 fill(i, at + delim.length, delim === '```' ? Tok.Comment : Tok.String)
422 i = at + delim.length
423 state = ''
424 continue
425 }
426
427 const ch = line[i]
428
429 const lineComment = lang.lineComments.find(
430 c =>
431 line.startsWith(c, i) &&
432 !(lang.blockComments.some(([open]) => line.startsWith(open, i) && open.startsWith(c))) &&
433 !(c === '#' && lang.hashCommentNeedsSpace && i > 0 && !/\s/.test(line[i - 1])) &&
434 !(c === '#' && lang.sigils?.includes('$') && line[i - 1] === '$'),
435 )
436 if (lineComment) {
437 fill(i, n, Tok.Comment)
438 return { toks, end: '' }
439 }
440 const block = lang.blockComments.find(([open]) => line.startsWith(open, i) && (open !== '=begin' || i === 0))
441 if (block) {
442 fill(i, i + block[0].length, Tok.Comment)
443 i += block[0].length
444 state = 'c' + block[1]
445 continue
446 }
447 if (lang.markdown && line.startsWith('`', i)) {
448 const close = line.indexOf('`', i + 1)
449 const to = close < 0 ? n : close + 1
450 fill(i, to, Tok.String)
451 i = to
452 continue
453 }
454 if (lang.markdown) {
455 const link = /^\[[^\]]*\]\([^)]*\)/.exec(line.slice(i))
456 if (link) {
457 const label = link[0].indexOf('](')
458 fill(i, i + label + 1, Tok.Function)
459 fill(i + label + 1, i + link[0].length, Tok.String)
460 i += link[0].length
461 continue
462 }
463 const emphasis = /^(\*\*|__|\*|_)(?=\S)(.+?)\1/.exec(line.slice(i))
464 if (emphasis && (i === 0 || !IDENT.test(line[i - 1]))) {
465 fill(i, i + emphasis[0].length, Tok.Type)
466 i += emphasis[0].length
467 continue
468 }
469 i++
470 continue
471 }
472 const multi = lang.multilineStrings.find(d => line.startsWith(d, i))
473 if (multi && !(lang.rustLifetimes && multi === "'")) {
474 fill(i, i + multi.length, Tok.String)
475 i += multi.length
476 state = 's' + multi
477 continue
478 }
479 if (lang.rustLifetimes && ch === "'") {
480 const charLiteral = /^'(\\.[^']*|[^'\\])'/.exec(line.slice(i))
481 if (charLiteral) {
482 fill(i, i + charLiteral[0].length, Tok.String)
483 i += charLiteral[0].length
484 continue
485 }
486 const lifetime = /^'[A-Za-z_]\w*/.exec(line.slice(i))
487 if (lifetime) {
488 fill(i, i + lifetime[0].length, Tok.Label)
489 i += lifetime[0].length
490 continue
491 }
492 }
493 if (lang.strings.includes(ch)) {
494 let j = i + 1
495 while (j < n && line[j] !== ch) j += line[j] === '\\' ? 2 : 1
496 const to = Math.min(n, j + 1)
497 const isKey = lang.keysBeforeColon && nextNonSpace(line, to) === ':'
498 fill(i, to, isKey ? Tok.Property : Tok.String)
499 i = to
500 continue
501 }
502 if (lang.markup && ch === '<') {
503 const tag = /^<\/?([A-Za-z][\w\-:.]*)/.exec(line.slice(i))
504 if (tag) {
505 fill(i, i + 1 + (tag[0][1] === '/' ? 1 : 0), Tok.Punctuation)
506 fill(i + tag[0].length - tag[1].length, i + tag[0].length, Tok.Keyword)
507 i += tag[0].length
508 continue
509 }
510 }
511 if (lang.preprocessor && ch === '#' && prevNonSpace(line, i) === '') {
512 const directive = /^#\s*\w+/.exec(line.slice(i))
513 if (directive) {
514 fill(i, i + directive[0].length, Tok.Keyword)
515 i += directive[0].length
516 continue
517 }
518 }
519 if (lang.decorators && ch === '@' && i + 1 < n && IDENT_START.test(line[i + 1])) {
520 let j = i + 1
521 while (j < n && /[\w.]/.test(line[j])) j++
522 fill(i, j, Tok.Function)
523 i = j
524 continue
525 }
526 if (lang.sigils?.includes(ch) && i + 1 < n && /[\w{(@#?!*]/.test(line[i + 1])) {
527 let j = i + 1
528 if (line[j] === '{' || line[j] === '(') {
529 const close = line[j] === '{' ? '}' : ')'
530 const at = line.indexOf(close, j)
531 j = at < 0 ? n : at + 1
532 } else {
533 j++
534 while (j < n && /\w/.test(line[j])) j++
535 }
536 fill(i, j, ch === '$' ? Tok.Parameter : Tok.Property)
537 i = j
538 continue
539 }
540 if ((DIGIT.test(ch) || (ch === '.' && DIGIT.test(line[i + 1] ?? ''))) && (i === 0 || !identChar.test(line[i - 1]))) {
541 let j = i + 1
542 while (j < n && /[0-9a-fA-FxXoObB_.]/.test(line[j])) {
543 if (line[j] === '.' && !DIGIT.test(line[j + 1] ?? '')) break
544 j++
545 }
546 if (/[eE]/.test(line[j - 1] ?? '') && /[+-]/.test(line[j] ?? '')) {
547 j++
548 while (j < n && DIGIT.test(line[j])) j++
549 }
550 while (j < n && /[a-zA-Z]/.test(line[j])) j++
551 fill(i, j, Tok.Number)
552 i = j
553 continue
554 }
555 if (IDENT_START.test(ch) || (lang.identChars && identChar.test(ch) && !DIGIT.test(ch) && ch !== '-' && ch !== '.')) {
556 let j = i + 1
557 while (j < n && identChar.test(line[j])) j++
558 const word = line.slice(i, j)
559 const key = lang.caseInsensitive ? word.toLowerCase() : word
560 const after = nextNonSpace(line, j)
561 const before = prevNonSpace(line, i)
562 let tok: Tok
563 if (lang.markup) {
564 tok = after === '=' ? Tok.Property : Tok.Variable
565 } else if (lang.keysBeforeColon && after === ':' && line[j + 1] !== ':') {
566 tok = Tok.Property
567 } else if (lang.keysBeforeEquals && after === '=' && line[j + 1] !== '=') {
568 tok = Tok.Property
569 } else if (lang.keywords.has(key)) {
570 tok = Tok.Keyword
571 } else if (lang.constants.has(key) || (/^[A-Z][A-Z0-9_]+$/.test(word) && word.length > 1)) {
572 tok = Tok.Constant
573 } else if (lang.macros && line[j] === '!' && line[j + 1] !== '=') {
574 tok = Tok.Function
575 j++
576 } else if (after === '(' || DEFINERS.has(previousWord(line, i))) {
577 tok = Tok.Function
578 } else if (before === '.' || line.startsWith('->', i - 2)) {
579 tok = Tok.Property
580 } else if (lang.types.has(key) || /^[A-Z]/.test(word)) {
581 tok = Tok.Type
582 } else {
583 tok = Tok.Variable
584 }
585 fill(i, j, tok)
586 i = j
587 continue
588 }
589 if (OPERATORS.includes(ch)) toks[i] = Tok.Operator
590 else if (PUNCTUATION.includes(ch)) toks[i] = Tok.Punctuation
591 i++
592 }
593 return { toks, end: state }
594}
595
596// Highlights a buffer's lines, remembering each line's result by its text and
597// the state it starts in, so a frame re-tokenizes only the lines that changed.
598export class Highlighter {
599 private cache = new Map<string, LineTokens>()
600 private lang: Lang = PLAIN
601 private states: string[] = ['']
602 private valid = 0
603
604 setPath(path: string) {
605 this.lang = languageFor(path)
606 this.cache.clear()
607 this.invalidate(0)
608 }
609
610 invalidate(fromLine: number) {
611 this.valid = Math.min(this.valid, fromLine)
612 }
613
614 line(lines: string[], index: number): Uint8Array {
615 while (this.valid < index) {
616 this.states[this.valid + 1] = this.run(lines[this.valid] ?? '', this.states[this.valid] ?? '').end
617 this.valid++
618 }
619 return this.run(lines[index] ?? '', this.states[index] ?? '').toks
620 }
621
622 private run(text: string, start: string): LineTokens {
623 // A very long line colors its head only, and is never kept.
624 if (text.length > LONG_LINE) {
625 const head = tokenize(this.lang, text.slice(0, LONG_LINE), start)
626 const toks = new Uint8Array(text.length)
627 toks.set(head.toks)
628 return { toks, end: head.end }
629 }
630 const key = start + '\u0000' + text
631 let hit = this.cache.get(key)
632 if (!hit) {
633 if (this.cache.size > 20000) this.cache.clear()
634 hit = tokenize(this.lang, text, start)
635 this.cache.set(key, hit)
636 }
637 return hit
638 }
639}
640hooks/diff.ts 142 lines1// Line diff between two versions of a file, grouped into unified-diff hunks
2// the way `git diff` (three lines of context) groups them.
3
4export type HunkLine = { kind: 'ctx' | 'del' | 'add'; text: string }
5export type Hunk = { oldStart: number; lines: HunkLine[] }
6
7const CONTEXT = 3
8const MAX_EDIT_DISTANCE = 4000
9
10type Op = { kind: HunkLine['kind']; text: string; oldIndex: number }
11
12// Myers' O((N+M)D) shortest edit script over the middle that differs.
13function myers(a: string[], b: string[]): Op[] | undefined {
14 const n = a.length
15 const m = b.length
16 const max = n + m
17 const offset = max + 1
18 const v = new Int32Array(2 * max + 3)
19 const trace: Int32Array[] = []
20 for (let d = 0; d <= Math.min(max, MAX_EDIT_DISTANCE); d++) {
21 trace.push(v.slice())
22 for (let k = -d; k <= d; k += 2) {
23 let x =
24 k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
25 ? v[offset + k + 1]
26 : v[offset + k - 1] + 1
27 let y = x - k
28 while (x < n && y < m && a[x] === b[y]) {
29 x++
30 y++
31 }
32 v[offset + k] = x
33 if (x >= n && y >= m) return backtrack(a, b, trace, offset, d)
34 }
35 }
36 return undefined
37}
38
39function backtrack(a: string[], b: string[], trace: Int32Array[], offset: number, last: number): Op[] {
40 const ops: Op[] = []
41 let x = a.length
42 let y = b.length
43 for (let d = last; d >= 0; d--) {
44 const v = trace[d]
45 const k = x - y
46 const prevK =
47 k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? k + 1 : k - 1
48 const prevX = d === 0 ? 0 : v[offset + prevK]
49 const prevY = prevX - prevK
50 while (x > prevX && y > prevY) {
51 x--
52 y--
53 ops.push({ kind: 'ctx', text: a[x], oldIndex: x })
54 }
55 if (d === 0) break
56 if (x === prevX) ops.push({ kind: 'add', text: b[--y], oldIndex: x })
57 else ops.push({ kind: 'del', text: a[--x], oldIndex: x })
58 }
59 return ops.reverse()
60}
61
62// Within each run of changes, deletions come before additions, as git prints them.
63function normalize(ops: Op[]): Op[] {
64 const out: Op[] = []
65 let i = 0
66 while (i < ops.length) {
67 if (ops[i].kind === 'ctx') {
68 out.push(ops[i++])
69 continue
70 }
71 const dels: Op[] = []
72 const adds: Op[] = []
73 while (i < ops.length && ops[i].kind !== 'ctx') (ops[i].kind === 'del' ? dels : adds).push(ops[i++])
74 out.push(...dels, ...adds)
75 }
76 return out
77}
78
79export function diffLines(oldLines: string[], newLines: string[]): Hunk[] {
80 let head = 0
81 while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head++
82 let tail = 0
83 while (
84 tail < oldLines.length - head &&
85 tail < newLines.length - head &&
86 oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]
87 )
88 tail++
89 const a = oldLines.slice(head, oldLines.length - tail)
90 const b = newLines.slice(head, newLines.length - tail)
91 if (a.length === 0 && b.length === 0) return []
92
93 const middle =
94 myers(a, b) ??
95 [
96 ...a.map((text, i) => ({ kind: 'del' as const, text, oldIndex: i })),
97 ...b.map(text => ({ kind: 'add' as const, text, oldIndex: a.length })),
98 ]
99 const ops: Op[] = [
100 ...oldLines.slice(0, head).map((text, i) => ({ kind: 'ctx' as const, text, oldIndex: i })),
101 ...normalize(middle).map(op => ({ ...op, oldIndex: op.oldIndex + head })),
102 ...oldLines
103 .slice(oldLines.length - tail)
104 .map((text, i) => ({ kind: 'ctx' as const, text, oldIndex: oldLines.length - tail + i })),
105 ]
106
107 const hunks: Hunk[] = []
108 let i = 0
109 while (i < ops.length) {
110 if (ops[i].kind === 'ctx') {
111 i++
112 continue
113 }
114 const start = Math.max(0, i - CONTEXT)
115 let end = i
116 // Extend over changes, merging ones whose context would touch.
117 for (;;) {
118 while (end < ops.length && ops[end].kind !== 'ctx') end++
119 let gap = end
120 while (gap < ops.length && ops[gap].kind === 'ctx') gap++
121 if (gap < ops.length && gap - end <= CONTEXT * 2) end = gap
122 else break
123 }
124 const stop = Math.min(ops.length, end + CONTEXT)
125 const slice = ops.slice(start, stop)
126 hunks.push({ oldStart: slice[0].oldIndex + 1, lines: slice.map(({ kind, text }) => ({ kind, text })) })
127 i = stop
128 }
129 return hunks
130}
131
132export function countChanges(hunks: Hunk[]): { added: number; deleted: number } {
133 let added = 0
134 let deleted = 0
135 for (const hunk of hunks)
136 for (const line of hunk.lines) {
137 if (line.kind === 'add') added++
138 else if (line.kind === 'del') deleted++
139 }
140 return { added, deleted }
141}
142types/index.d.ts 160 lines1// What the gitlogue mod keeps in the session's state, so a reload (an edit to
2// the mod, a changed option) picks the replay up where it stood.
3
4export type GitlogueTurn = { id: string; date: string; prompt: string }
5
6// `turn` is the id of the turn that last changed the file; `file` its
7// absolute path, where the explorer shows it.
8export type GitlogueFileEntry = {
9 path: string
10 status: '+' | '~'
11 added: number
12 deleted: number
13 turn?: string
14 file?: string
15 // Shown in place of line counts, as a download's size.
16 note?: string
17 // Committed since it changed: drawn settled, without the change mark.
18 committed?: boolean
19}
20
21export type GitlogueLink = { title: string; url: string }
22
23// One file's matches in a code search, each a line number and its text.
24export type GitlogueHit = { path: string; matches: { line: number; text: string }[] }
25
26// The sidebar's search view: the query typed so far, the files whose matches
27// have come in, and how many of them show.
28export type GitlogueSearch = { query: string; hits: GitlogueHit[]; shown: number; total: number }
29
30// The quick-open palette: the pattern typed and the files it lists.
31export type GitloguePalette = { text: string; items: string[]; shown: number }
32
33// A file on its way to the trash: its row's name, where it started, how far
34// along it is (0 to 1).
35export type GitlogueFlight = { name: string; row: number; depth: number; t: number }
36
37// The editor's area as a browser: the address, what the page is (a search
38// home, a results page, a fetched page), what has been typed into its box,
39// the results shown so far and the one selected, the page's text and how far
40// down it is read, and how far the page has loaded.
41export type GitlogueBrowser = {
42 url: string
43 page: 'home' | 'results' | 'page'
44 query: string
45 results: GitlogueLink[]
46 shown: number
47 selected: number
48 stats: string
49 body: string[]
50 scroll: number
51 loading: number
52 // Where typing goes: the search box or the address bar.
53 focus?: 'box' | 'address'
54}
55
56// The editor's area as an image viewer: the picture shown and its size.
57export type GitlogueImage = { path: string; png: string; width: number; height: number }
58
59// One entry of a directory as the explorer lists it.
60export type GitlogueDirEntry = { name: string; dir: boolean }
61
62// Where a changed file sits: the workspace it belongs to (its repository, or
63// the session's folder) and the listing of each directory from there down.
64export type GitloguePlace = { root: string; listings: Record<string, GitlogueDirEntry[]> }
65
66// The file explorer: its workspace roots, the folders opened, what each
67// listed, and the row the selection is on; paths are absolute.
68export type GitlogueExplorer = {
69 roots: string[]
70 expanded: string[]
71 listings: Record<string, GitlogueDirEntry[]>
72 selected: string | null
73}
74
75export type GitlogueEvent =
76 | { type: 'turn'; turn: GitlogueTurn }
77 | { type: 'edit'; path: string; before: string; after: string; created: boolean; file?: string; place?: GitloguePlace }
78 | { type: 'command'; command: string; description?: string; startedAt?: number }
79 | { type: 'output'; lines: string[]; total: number; failed: boolean; exitCode?: number; durationMs: number }
80 | { type: 'done'; durationMs: number; aborted: boolean }
81 | { type: 'search'; query: string; results: GitlogueLink[]; durationMs: number }
82 | { type: 'fetch'; url: string; text: string; durationMs: number }
83 | { type: 'image'; path: string; file: string; png: string; width: number; height: number; place?: GitloguePlace }
84 | { type: 'download'; path: string; file: string; bytes: number; durationMs: number; place?: GitloguePlace }
85 | { type: 'read'; path: string; file: string; text: string; startLine: number; numLines: number; place?: GitloguePlace }
86 | { type: 'codesearch'; query: string; hits: GitlogueHit[]; total: number }
87 | { type: 'findfiles'; pattern: string; files: string[] }
88 | { type: 'git'; commit?: { sha: string; branch?: string; message?: string }; push?: { branch: string }; root?: string; durationMs: number }
89 | { type: 'delete'; path: string; file: string; isDir: boolean; place?: GitloguePlace }
90 | { type: 'cast'; screen: string; frames: string[]; fps: number; speed: number; seconds: number; width: number; height: number; remove: string[] }
91
92// One row of the terminal pane, as Claude meets a shell: the turn it belongs
93// to, what a command is for, the command, what came back, the files it wrote,
94// and how the turn ended.
95export type GitlogueTermLine = {
96 kind: 'rule' | 'intent' | 'command' | 'output' | 'more' | 'edit' | 'done' | 'fail' | 'prompt' | 'progress' | 'commit' | 'trash' | 'cast'
97 text: string
98 // Drawn flush right: a command's time and status, an edit's line counts.
99 right?: string
100 ok?: boolean
101 // The first line of a result, drawn under the ⎿ mark.
102 first?: boolean
103 // How far a download (or, with `up`, a push) has come, 0 to 1.
104 fraction?: number
105 up?: boolean
106 // A command still running: when it started, in epoch milliseconds.
107 startedAt?: number
108 running?: boolean
109 // A commit's id and branch.
110 sha?: string
111 branch?: string
112}
113
114// The panes as they stood when the oldest unfinished event began.
115export type GitlogueView = {
116 lines: string[]
117 cursorLine: number
118 cursorCol: number
119 active: 'editor' | 'terminal' | 'explorer'
120 hasFile: boolean
121 isBlank: boolean
122 terminal: GitlogueTermLine[]
123 turn: GitlogueTurn | null
124 files: GitlogueFileEntry[]
125 currentPath: string | null
126 explorer?: GitlogueExplorer
127 screen?: 'code' | 'browser' | 'image' | 'cast'
128 browser?: GitlogueBrowser
129 image?: GitlogueImage
130 // Lines read with the highlighter: whole lines from `from` up to `line`,
131 // and `line` itself up to `col`.
132 marks?: { from: number; line: number; col: number }
133 sidebar?: 'explorer' | 'search'
134 search?: GitlogueSearch
135 palette?: GitloguePalette
136 flight?: GitlogueFlight
137 trashed?: number
138}
139
140// `session` names the session the replay belongs to, where it is recorded.
141export type GitlogueSaved = {
142 view: GitlogueView
143 pending: GitlogueEvent[]
144 session?: string
145 captures?: GitlogueCapture[]
146 chat?: GitlogueChat
147}
148/** The phone's chat as kept; a chat kept in another format is read again from the session. */
149export type GitlogueChat = { format: number; lines: GitlogueChatLine[] }
150/** A line of the phone's chat; `isRead` marks one of the person's that reached Claude. */
151export type GitlogueChatLine = { role: 'me' | 'claude'; text: string; time?: string; isRead?: boolean }
152/** Where and when a command left screen captures. */
153export type GitlogueCapture = { dirs: string[]; from: number; to: number }
154
155declare module 'claude-code' {
156 interface PluginState {
157 gitlogue: { saved: GitlogueSaved }
158 }
159}
160