SLOPSHOPPER

gitlogue

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…

newpanebandspinnerrowsguard
v0.1.0ISCupdated 2026-10-06abyss464/claude-gitlogue
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gitlogue
│ ┃ gitlogue ✕ │ ┃ ‹ Claude☏ ≡ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Read ▗▄▄▄▄ ⏺ Bash(bun test) │ ┃ fix ⎿ 3 pass, 1 fail │ ┃ ▝▀▀▀▀ │ ┃ │ ┃ ▐▛███▜▌ Claude │ ┃ ▝▜█████▛▘ ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▖ › /gitlogue │ ┃ ▘▘ ▝▝ ◤ I updated │ ┃ src/auth.ts to │ ┃ reject expired │ ┃ claims and added │ ┃ an audit call. │ ┃ ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▘ │ ┃ │ ┃ 14:51 ▗▄▄▄▄ │ ┃ fix │ ┃ and │ ┃ ▝▀▀▀▀ │ ┃ + ◎ ▣ Aa │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · gitlogue
‹ Claude☏ ≡ Read ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ fix the failing auth ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▐▛███▜▌ Claude ▝▜█████▛▘ ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▖ ▘▘ ▝▝ ◤ I updated src/auth.ts to reject expired claims and added an audit call. ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▘ 14:51 ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ fix the failing auth and add an audit log ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ◎ ▣ Aa ☺ ♪
README

claude-gitlogue

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.

Install

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.

What it shows

Each of Claude's actions plays as a person at a desk would do it:

ClaudeThe pane
edits or writes a filewalks 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 fileopens it and sweeps a highlighter over the lines read
runs grep, rg, git grep and the liketypes the query into a search sidebar and lists the matching files and lines
runs fd or findtypes the pattern into the Quick Open palette
runs a commandtypes it into the terminal, shows a spinner with the time running, then its output
commits or pushesmarks the committed files and shows the commit and the push
deletes a filethe file flies from the tree into a trash can
searches the weba search page: the query typed, the results listed
fetches a pagea browser: the address typed, the page shown
looks at an imagean image viewer shows it (terminals with the kitty graphics protocol)
downloads with curl, wget or aria2ca 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.

Options

Unset options take their defaults. Set them with /plugin configure gitlogue@claude-gitlogue, or as rows in /config, where a change applies at once.

OptionValuesDefault
Themegitlogue's 17 themes: tokyo-night, catppuccin, dracula, nord, gruvbox and moretokyo-night
Playback speed0.1x to 10x1x
Max lagseconds the replay may fall behind before it speeds up20
Open the paneauto (when a session starts), command (only with /gitlogue)auto
Chat viewon (the phone), off (the usual transcript)on
Languageauto, en, zh, ja; auto follows Claude Code's language setting, then the system localeauto

Requirements

  • Claude Code in a terminal, with plugin hooks modules (mods).
  • Optional: ImageMagick (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.

Credits

The look, pacing and color themes come from gitlogue by unhappychoice (ISC License).

License

ISC. See LICENSE.

Source 12 files
hooks/register.tsx 1055 lines
1// 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}
1055
hooks/bashfiles.ts 221 lines
1// 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}
221
hooks/explorer.ts 112 lines
1// 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}
112
hooks/chat.tsx 368 lines
1// 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}
368
hooks/i18n.ts 144 lines
1// 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}
144
hooks/frame.ts 897 lines
1// 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}
897
hooks/player.ts 1242 lines
1// 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.generated
hooks/themes.ts 783 lines
1// 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'
783
hooks/cells.ts 74 lines
1// 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}
74
hooks/highlight.ts 640 lines
1// 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}
640
hooks/diff.ts 142 lines
1// 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}
142
types/index.d.ts 160 lines
1// 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