Draws Claude's answers as rendered Markdown pictures in the terminal, with live Typst for diagrams, charts and math

<img alt="Claude Code: a prompt asks Claude to show what Markdown it can draw, and the answer draws as one rendered picture: a cover image, a heading, a table, LaTeX math, a code block with syntax colours, a task list and a note" src="outcomes/out/hero-light.gif" width="100%">
Claude's answers, rendered. Real Markdown, math, charts and diagrams, right in your terminal.
<sub>An independent, community-made mod for Claude Code. Not affiliated with Anthropic.</sub>
Intelligent UI draws each answer from Claude as a typeset picture, with Typst. Tables line up, equations look like a paper, and Claude can draw a chart or a diagram when a picture explains more than words. Only the drawing changes. The saved answer stays Markdown.
On macOS or Linux:
curl -fsSL https://intelligentui.art/install.sh | bash
It installs Typst if needed, installs the mod and turns on auto-update. Then restart Claude Code. Run it again to update. What the script does.
By hand: install Typst 0.15 (brew install typst), then in Claude Code type /plugin install cc-intelligent-ui --marketplace razorback16/cc-intelligent-ui, and turn on auto-update in /plugin. Install guide.
Needs Claude Code 2.1.298 or later. Works best in fullscreen mode (/tui fullscreen).
| Terminal | Pictures |
|---|---|
| kitty 0.28+, Ghostty, cmux | Yes |
| iTerm2 3.6+, herdr | Yes, after /iui setup (the install script does it) |
| Rio 0.5.27+ | Yes, with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 (not tested) |
| ssh | Yes, from kitty or Ghostty |
| Warp, WezTerm | Not yet (waiting on warp#16312, wezterm#7924) |
| tmux, screen, Terminal.app, Alacritty, VS Code, others | No: you see normal text |
/iui details tells you why pictures do or do not show. All terminal details.

<table> <tr> <td width="50%"> <img alt="A flow chart of an agent loop and a commutative diagram of the First Isomorphism Theorem, drawn with fletcher in a Claude answer" src="outcomes/out/6-diagrams-light.png" width="100%"> </td> <td width="50%"> <img alt="Typeset LaTeX math in Claude Code: the Gaussian integral, Maxwell's equations, a rotation matrix, a piecewise function, the Fourier transform and the Schrödinger equation" src="outcomes/out/2-math-light.png" width="100%"> </td> </tr> </table>
More in the gallery: plots, fractals and a tour of all the Markdown.
| Command | What it does |
|---|---|
/iui | State, and what keeps pictures away |
/iui on / off | Pictures on or off |
/iui theme <name> | claude-code, vscode-modern, vscode or your own |
/iui size <80–200> | Text size, in percent |
/iui setup | Turns pictures on in iTerm2 and herdr |
/iui grab | Copies the text of your last selection of pictures |
All commands are in Usage.
Nothing about you or your sessions leaves your computer. The mod makes one request a day at most, for its own public version number (/iui updates off stops it). It reads the clipboard only to help a copy or to find a pasted image path, skips password manager copies, and writes it only when you copy. Typst code in an answer cannot read your files. Full details.
Install · Usage · Themes · How it works · Limitations · Privacy · Development · Gallery · Changelog
Intelligent UI is an independent project. It is not made, supported or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC.
MIT, see LICENSE. The vendored Typst packages and the VS Code theme values keep their own licences: see THIRD-PARTY-NOTICES.md.
hooks/register.tsx 1608 lines1// Intelligent UI: draws Claude's answers (AssistantMessage blocks) as pictures
2// of rendered Markdown, made by Typst from the template in ../render/md.typ.
3// The stored message never changes; only its drawing does.
4//
5// Flow: the render hook makes a key from the text, the width, the cell size,
6// the theme and the template. A known key draws its PNGs. A block whose text
7// is still streaming ("live") draws nothing; it is final when its model
8// request (`turn.step`) moves on to another block or ends, or, with no step
9// seen, when its text has been still for SETTLE_MS. A final block's key is
10// compiled at once (at most MAX_RUNNING at once), and the `done` atom goes up
11// so every reader draws again and finds the PNGs.
12//
13// The work is in ./lib, one topic per module: settings, state (the shared
14// mutable state `S`), markdown, terminal, terminals, mode, themes, command,
15// setup, host, typst, draw, stream, answer (one text through the pipeline),
16// env, images, pictures, composer (the previews of the images pasted in the
17// draft), plan (the plan approval flow), copy (the text under
18// a mouse copy of the pictures), probe (the drawn pages, and the words of a
19// page a copy reads) and updates (the daily check for a new version). This
20// file joins them to the hooks, and gives the tests the names they import.
21import { atom, memberOf, read, update } from 'claude-code'
22import type { EngineInterface, Register, RenderElement, RenderSurface, RenderViewport, UiCopyArgs } from 'claude-code'
23
24import {
25 DEFAULT_THEME, gridText, heightText, IMAGE_MAX_COLUMNS, MARGIN_TOP, MAX_HEIGHT, MAX_WIDTH, MIN_HEIGHT, MIN_WIDTH, RESULTS_KEPT, TYPST_PROMPT,
26 SELECT_SINCE, selectText, typstHintOf, widthText,
27} from './lib/settings'
28import { clip, DEFAULT_METRICS, drawnAnswers, editedPlans, jobEnds, keep, ownCopy, pending, queue, refresh, running, S, settingsSet, subagentPlans, writesLive, type Env, type Job } from './lib/state'
29import { beginEpoch } from './lib/epoch'
30import { escapeControls, luminanceOf, modeLine, modeOfLuminance, parseOsc11, settingOf } from './lib/mode'
31import { applyTheme, themeListing, userThemesOf } from './lib/themes'
32import { commandSuggestions, parseCommand, registerCommand } from './lib/command'
33import { runSetup } from './lib/setup'
34import { pollHost, PROBE_RUNS, probeOsc11, showMode, stopHost, USE_OSC11, watchHost } from './lib/host'
35import { measureFonts } from './lib/typst'
36import { drawPages, linkRowsOf, linksFor, takesSelect, type ShownLink } from './lib/draw'
37import {
38 clickLinks, hovered, litOf, needsSelect, picked, postOf, redrawSelection, runsOf, sameLit, selectedRows, selectionAfter, selectTrace, stopNative, textOfRuns,
39 topsOf, watchNative, type Hover, type SelectPost, type Selection,
40} from './lib/select'
41import { targetAt, type Target } from './lib/targets'
42import { blits, selectColor } from './lib/blit'
43import { forgetPages, needFrames, notePages } from './lib/pagecache'
44import { traceDebug } from './lib/log'
45import { finalizeAll } from './lib/stream'
46import { knownTargets, knownWords, noteDrawn, noteDrawnAgain, noteOnScreen, wordsOf, type PageWords } from './lib/probe'
47import { codePointsOf, decodeSelection, hasPlaceholders, joinSegments, pageTopsOf, pickPages, textOfRun, type Drawn, type Pick, type Segment } from './lib/copy'
48import { pagesOf, type Drawing } from './lib/answer'
49import { clipboardNow, clipboardRead, clipWatchArgs, clipWayReady, hintText } from './lib/clipboard'
50import { setUp } from './lib/env'
51import { frameFor, noteView, stopWatch, watchFrame, watchState } from './lib/frame'
52import { herdrText, isFlipHere, roomColumns } from './lib/terminal'
53import { onScreenOf, pastedNumbers, sentFiles } from './lib/images'
54import { drawPageField, pasteDir, pdfPagesOf, rowsAfter, sentOf, underRow, type Under } from './lib/pictures'
55import { drawPreviews, forgetPaths, keepPaths, pathPaste, pathsOf, previewRetry, previewsOf, previewsUnder, sameNumbers, type Preview } from './lib/composer'
56import { checkUpdates, loadUpdates, updateLine, updateNotice, type UpdateState } from './lib/updates'
57import { nowMs, write, surf, save, copy, uiSelection, toast, sid, exec, delay } from './lib/api'
58import { detachedArgv, editorNow, OPEN_TIMEOUT_MS, openArgv, openedText, useName, type Use } from './lib/open'
59import {
60 currentPlan, decidePlan, decisionOf, drawAskLine, drawPlanResult, drawPlanUse, isPlanQuestion, planFileOf, planInputOf, planOf,
61 planShownOf, PLAN_DRAW_WAIT_MS, scheduleClear, type PlanVerdict,
62} from './lib/plan'
63
64// The names this module exported before the split (the tests import them).
65export {
66 BASE_THEME, DEFAULT_CELL, DEFAULT_HEIGHT, DEFAULT_SIZE, DEFAULT_THEME, DEFAULT_WIDTH, gridText, heightText, IMAGE_MAX_COLUMNS, MAX_HEIGHT, MAX_SIZE, MAX_WIDTH,
67 MIN_HEIGHT, MIN_SIZE, MIN_WIDTH, PICTURE_MAX_ROWS, PICTURE_MIN_ROWS, PICTURE_ROOM_ROWS, PATH_RECHECK_MS, PREVIEW_RETRY_MS, PREVIEW_ROWS, SEGMENT_ROWS, TYPST_PROMPT, widthText, type Height, type Width,
68 COMMIT_MAX_MS, COMMIT_MIN_MS, DESIGNS_KEPT_DAYS, RESIZE_QUIET_MS, RESULTS_KEPT, TOUCH_FLUSH_MS,
69} from './lib/settings'
70export { DEFAULT_METRICS, keep, type Cell, type Design, type FontMetrics, type Frame, type Look, type Metrics, type Room } from './lib/state'
71export {
72 composeFrame, followWatcher, frameChange, frameFor, frameOfLine, lineSplitter, noteTty, pictureColumns, quietTimer, shownFor, watchLineOf, type View,
73 type WatchLine,
74} from './lib/frame'
75export { designKey, designPath, designText, loadDesign, parseDesign, recordDesign } from './lib/designs'
76export {
77 blocksOf, firstLine, imagePathOf, isTypstInfo, localImages, needsRoom, plainOf, rowsOfBlock, splitInTwo, splitSegments, type Block,
78} from './lib/markdown'
79export * from './lib/terminal'
80export * from './lib/mode'
81export * from './lib/terminals'
82export {
83 chooseTheme, isSyntaxPath, isThemeName, pickThemes, themeEntry, themeFiles, themeListing,
84 type ThemeInfo, type ThemeProblem, type ThemeSyntax,
85} from './lib/themes'
86export { commandSuggestions, parseCommand, type Command, type Suggestion } from './lib/command'
87export { hasNewSetup, hasSetup, SETUP_END, SETUP_NOT_MAC, SETUP_START, setupBlock, setupWordsOf, shellRcOf, withoutSetup, withSetup, type ShellRc } from './lib/setup'
88export { fcPatternOf, HERDR_CLIENT_SH, HERDR_TICK_SH, OSC11_PL, perlFor, PROGRAM_FONTS_PL, PROGRAM_FONTS_SH, WATCH_PL } from './lib/host'
89export {
90 convertCall, fillCall, isSafePage, measureCall, noNetworkEnv, pageRowsOf, parseOd, pngSize, probeCall, resultOf, roomInputs, shouldRetrySafe, typstCall, typstInputs,
91} from './lib/typst'
92export {
93 alignFirstLine, fileHref, firstLineOf, fontFit, hashOf, renderKey, resolveLinks, scaledBulletRow, sizing, type FirstLine, type ShownLink,
94} from './lib/draw'
95export { nextJob } from './lib/stream'
96export * from './lib/images'
97export { rowsAfter, type Under } from './lib/pictures'
98export { altOf, cellPixels, filledPath, labelColumns, labelOf, linesOf, previewLayout, sameNumbers } from './lib/composer'
99export * from './lib/copy'
100export { targetAt, targetsOf, type PageTarget, type Target } from './lib/targets'
101export {
102 CLIP_TOOLS, clipToolsOf, clipWayOf, FIND_SH, GTK_READ_PY, GTK_WATCH_PY, hintText, READ_PL, SECRET_TARGET, SECRET_TYPES, WL_READ_SH, WL_WATCH_SH, X11_WATCH_SH, XCLIP_READ_SH,
103 type ClipTool, type ClipWay,
104} from './lib/clipboard'
105export {
106 agoText, checkedOf, compareVersions, isDue, isNewer, parseVersion, shouldTell, toldOf, UPDATE_EVERY_MS, UPDATE_FETCH_MS, UPDATE_STEPS, UPDATE_TOAST_MS,
107 UPDATE_URL, updateLine, updateNotice, updateToast, versionOf,
108} from './lib/updates'
109export {
110 choiceOf, decisionOf, editorArgv, editorWaits, feedbackOf, guiEditorOf, LINUX_EDITORS, isPlanQuestion, normalPath, PLAN_ASK_LINE, PLAN_AUTO, PLAN_CLEAR, PLAN_DONE,
111 PLAN_DRAW_WAIT_MS, PLAN_DRAWING, PLAN_EDIT, PLAN_EDITED_UNDRAWN, PLAN_EDITING, PLAN_FEEDBACK, PLAN_HEADER, PLAN_IMPLEMENT, PLAN_MANUAL, PLAN_QUESTION, PLAN_QUESTION_EDITED,
112 PLAN_REJECTED, PLAN_REOPEN, planFileOf, planInputOf, planOf, planOptions, planShownOf, shortPath, verdictOf, type PlanChoice, type PlanVerdict,
113} from './lib/plan'
114
115// The atoms the hooks read and write (a copy of each in this file: see state.ts).
116const isOn = atom({ plugin: 'cc-intelligent-ui', key: 'isOn' } as const, true)
117const done = atom({ plugin: 'cc-intelligent-ui', key: 'done' } as const, 0)
118const draft = atom({ plugin: 'cc-intelligent-ui', key: 'draft' } as const, [])
119const filled = atom({ plugin: 'cc-intelligent-ui', key: 'filled' } as const, 0)
120
121// ---- Pictures under rows ----------------------------------------------------
122
123// The env to draw pictures with: Intelligent UI on, in a terminal that draws;
124// null otherwise (no Typst needed), with the `done` value read. Here and not
125// in pictures.tsx, as it reads the atoms. The sizes come from the Frame
126// (frameFor, shownFor); the viewport is noted for it (a change is settled
127// with the tty's).
128async function pictureEnv($: EngineInterface, e: { viewport?: RenderViewport }): Promise<{ env: Env; done: number } | null> {
129 if (!(await read($, isOn))) return null
130 if (e.viewport) noteView($, e.viewport)
131 if (typeof e.viewport?.isFullscreen === 'boolean') S.isFullscreen = e.viewport.isFullscreen
132 // Read so the end of setUp, a compile and a commit draw this row again.
133 const now = await read($, done)
134 if (!S.env) {
135 void setUp($).catch(() => undefined)
136 return null
137 }
138 if (S.env.canDraw && S.isFullscreen === false) tellFullscreen($)
139 return S.env.canDraw ? { env: S.env, done: now } : null
140}
141
142// Once a session, where pictures draw on the main screen: fullscreen gives
143// picture select (a mouse drag over a picture copies its text).
144function tellFullscreen($: EngineInterface) {
145 if (S.fullscreenTold) return
146 S.fullscreenTold = true
147 toast($, 'Intelligent UI works best in fullscreen mode: run /tui fullscreen', { timeoutMs: 12000 })
148}
149
150// The env to draw Markdown pictures with (an answer, a plan): as
151// pictureEnv, and Typst found.
152async function answerEnv($: EngineInterface, e: { viewport?: RenderViewport }): Promise<{ env: Env; done: number } | null> {
153 const ready = await pictureEnv($, e)
154 return ready?.env.typst ? ready : null
155}
156
157// The draft's `[Image #N]` numbers to the `draft` atom, only when they
158// changed (the band above the prompt draws again): a key that keeps them
159// writes nothing. The numbers that are new, in order. Never throws.
160// `draftSeq` counts the box changes seen: an edit's box that comes after a
161// newer change is not noted.
162let draftSeq = 0
163function noteDraft($: EngineInterface, text: string): number[] {
164 try {
165 const now = pastedNumbers(text)
166 if (S.draftImages && sameNumbers(S.draftImages, now)) return []
167 const was = S.draftImages ?? []
168 S.draftImages = now
169 keepPaths(now)
170 // The newest numbers at the write, should two writes cross.
171 void update($, draft, () => S.draftImages ?? []).catch(() => undefined)
172 return now.filter(n => !was.includes(n))
173 } catch {
174 return []
175 }
176}
177
178// New image marks of a paste: those with no file are a pasted path, whose
179// file the clipboard names (composer.tsx pathPaste); the band draws again
180// once one is found. Only while pictures draw here (not over ssh).
181function pastedPaths($: EngineInterface, fresh: number[]) {
182 const envOf = async () => ((await read($, isOn)) && S.env?.canDraw && !S.isRemote ? S.env : null)
183 void pathPaste($, fresh, envOf)
184 .then(found => (found ? update($, draft, ns => [...ns]) : undefined))
185 .catch(() => undefined)
186}
187
188// The change kinds that are no paste: a recall, a clear, an undo, a fill.
189const NOT_PASTES: readonly string[] = ['history', 'clear', 'undo', 'plugin', 'app']
190
191// ---- Rows drawn before ----------------------------------------------------------
192
193// What a drawn row depends on besides its own inputs: `done` (every result
194// stored, setting, theme, mode and commit redraws through it), the epochs
195// (the target and shown Frames change only with them, but at setFrame and
196// at the first viewport noted, both before a row is kept), the env, and the
197// viewport's columns (the room: the engine draws every row again once a
198// width change settles; a scroll keeps them), and picture select's state
199// (a Client failed, fullscreen or not: its Client is there or not).
200const stampOf = (ready: { env: Env; done: number }, view?: RenderViewport) =>
201 `${ready.done}|${S.epoch}|${S.shownEpoch}|${ready.env.inline ? 1 : 0}|${view?.columns ?? ''}|${roomColumns(view)}|` +
202 `${S.select ? 1 : 0}${picked.isBroken ? 1 : 0}${view?.isFullscreen === true ? 1 : 0}`
203
204// Each row's last drawing, by its requestId: a redraw with the same inputs
205// and the same stamp (a scroll step, a new onScreen) returns the same thing,
206// with no hash, no file and no process. Only drawn pictures are kept (a
207// blank or a text row runs the pipeline again: its timers move it on).
208// `text` the row's text (a string kept as given, so the compare is mostly
209// one pointer), `extra` its other inputs.
210type Memo<T> = { text: string; extra: string; stamp: string; onScreen: boolean | undefined; value: T }
211// An answer's tree is kept with the link rows drawn under its pages (the
212// copy counts them; none where they take picture select), a plan's drawing
213// with its link rows.
214type AnswerMemo = { tree: RenderElement; after: number }
215const answers = new Map<string, Memo<AnswerMemo>>()
216const plans = new Map<string, Memo<{ drawing: Drawing; links: ShownLink[] }>>()
217const unders = new Map<string, Memo<Under[]>>()
218const pasted = new Map<string, Memo<Preview[][]>>()
219const drafts = new Map<string, Memo<Preview[][]>>()
220
221// The kept value when the inputs and the stamp are the same. A new onScreen
222// is given to the row's jobs that wait or run (their order and whether they
223// hold the commit), with nothing drawn again. `isJob`: which jobs are the
224// row's, by their requestId (by default the row's id).
225type MemoKey = { text: string; extra: string; stamp: string; onScreen: boolean | undefined }
226function memoOf<T>(memo: Map<string, Memo<T>>, id: string, k: MemoKey, isJob = (requestId: string) => requestId === id): T | undefined {
227 const kept = memo.get(id)
228 if (!kept || kept.text !== k.text || kept.extra !== k.extra || kept.stamp !== k.stamp) return undefined
229 const { onScreen } = k
230 if (kept.onScreen !== onScreen) {
231 kept.onScreen = onScreen
232 for (const job of queue) if (isJob(job.requestId)) job.onScreen = onScreen
233 for (const { job } of running.values()) if (isJob(job.requestId)) job.onScreen = onScreen
234 }
235 return kept.value
236}
237
238function memoSet<T>(memo: Map<string, Memo<T>>, id: string, k: MemoKey, value: T) {
239 keep(memo, id, { ...k, value }, RESULTS_KEPT)
240}
241
242// A plan's drawing (both its rows: ToolUse and ToolResult) and the link
243// rows under its pages, kept while it is drawn pages (memoOf).
244async function planDrawing(
245 $: EngineInterface, e: { viewport?: RenderViewport; props: { onScreen?: unknown } }, toolUseId: string, plan: string, ready: { env: Env; done: number },
246): Promise<{ drawing: Drawing; links: ShownLink[] }> {
247 const id = `plan:${toolUseId}`
248 const onScreen = onScreenOf(e.props.onScreen)
249 const k = { text: plan, extra: '', stamp: stampOf(ready, e.viewport), onScreen }
250 const kept = memoOf(plans, id, k)
251 if (kept) return kept
252 const drawing = await pagesOf($, { id, text: plan, frame: frameFor(e.viewport), env: ready.env, canStream: false, onScreen })
253 if (drawing.kind !== 'pages') {
254 plans.delete(id)
255 return { drawing, links: [] }
256 }
257 const drawn = { drawing, links: await linksFor($, plan, ready.env.cwd) }
258 memoSet(plans, id, k, drawn)
259 return drawn
260}
261
262// Whether a plan's pictures show (pages; over ssh each page can go): its
263// compile is waited for, at most PLAN_DRAW_WAIT_MS. False for a compile that
264// failed, a page too big for `{ png }`, or none in time: the engine's dialog
265// asks, as it shows the plan's text. The pages are the ToolUse row's (the
266// same text, id and Frame, at the last viewport noted: one compile).
267async function planShows($: EngineInterface, toolUseId: string, plan: string): Promise<boolean> {
268 const viewport = S.viewColumns === null ? undefined : { columns: S.viewColumns, rows: S.viewRows ?? 48 }
269 let isOver = false
270 let timer = { cancel: () => {} }
271 const late = new Promise<boolean>(resolve => {
272 timer = delay($, PLAN_DRAW_WAIT_MS, () => resolve(false))
273 })
274 const drawn = async (): Promise<boolean> => {
275 while (!isOver) {
276 // Set first: a compile that ends while this draws is not missed.
277 const ended = new Promise<void>(resolve => jobEnds.add(resolve))
278 const ready = await answerEnv($, {})
279 if (!ready) return false
280 const { drawing } = await planDrawing($, { ...(viewport ? { viewport } : {}), props: {} }, toolUseId, plan, ready)
281 if (drawing.kind === 'pages') return true
282 // Text with nothing pending is final (the compile failed); with it, the compile is slow.
283 if (drawing.kind === 'text' && !pending.has(`plan:${toolUseId}`)) return false
284 await ended
285 }
286 return false
287 }
288 try {
289 return await Promise.race([drawn(), late])
290 } finally {
291 isOver = true
292 timer.cancel()
293 }
294}
295
296// Intelligent UI's own plan question: only on the main loop of a session drawn
297// in a terminal, with Intelligent UI on, Typst found and a terminal that draws
298// (the plan's pictures need all of them). Null otherwise: the engine's
299// dialog asks, as with no mod.
300async function planEnv($: EngineInterface): Promise<Env | null> {
301 if (!(await read($, isOn))) return null
302 const surfaces = await surf($).catch(() => [] as readonly string[])
303 if (!surfaces.includes('terminal')) return null
304 const ready = S.env ?? (await setUp($).catch(() => null))
305 return ready?.typst && ready.canDraw ? ready : null
306}
307
308// ---- Copy -----------------------------------------------------------------
309// Picture select (below) copies the text of a drag over the pictures. A
310// copy that still puts kitty placeholder cells on the clipboard (the main
311// screen's, a native drag over text and pictures) is seen by the watcher
312// (lib/frame.ts), which prints a line when the clipboard changes: the
313// clipboard is read once (perl on macOS, wl-paste, xclip or GTK; never a
314// password manager's copy), and cells that fit a page drawn here get a
315// toast that names /iui grab, which reads them (see lib/copy.ts; the drawn
316// pages and the words of a page are in lib/probe.ts). The clipboard is
317// never written but by a copy the person asked for, and never over a newer
318// copy (isNewest). No timer reads the clipboard; the selection is read
319// every 250 ms only while a picture selection shows (watchNative).
320
321// Notes a drawing for a copy (noteDrawn, noteDrawnAgain), turns on the
322// watcher's clipboard part and queues its pages for picture select's
323// cache; a failure never stops the row from drawing.
324function noteCopy($: EngineInterface, id: string, note: () => void) {
325 try {
326 note()
327 watchClipboard($)
328 queueSelect($, id)
329 } catch {}
330}
331
332// A drawing whose pages take picture select (drawn with its Client): its
333// pages on screen to the cache (lib/pagecache.ts), the others out of it.
334function queueSelect($: EngineInterface, id: string) {
335 const d = drawnAnswers.get(id)
336 if (d && S.select && S.isFullscreen !== false && !picked.isBroken && blits.sites.has(id)) notePages($, d, selectColor(S.theme))
337}
338
339type CopyRead = { text: string; segments: Segment[]; picks: Pick[]; runs: number; resolved: number }
340
341// The text for a clipboard (or selection) text with placeholders. A page
342// not probed yet is probed now, with no toast: only the copy has one.
343async function readCopy($: EngineInterface, raw: string, requestId?: string): Promise<CopyRead> {
344 const segments = decodeSelection(raw)
345 const picks = pickPages(segments, [...drawnAnswers.values()], requestId)
346 const texts: (string | null)[] = []
347 let resolved = 0
348 for (const [i, seg] of segments.entries()) {
349 const pick = picks[i]
350 const d = pick ? drawnAnswers.get(pick.id) : undefined
351 const page = pick && d ? d.pages[pick.page] : undefined
352 if (seg.kind !== 'image' || !pick || !d || !page) {
353 texts.push(null)
354 continue
355 }
356 const words = await wordsOf($, d, pick.page)
357 const text = words ? textOfRun(seg.lines, words.items, words.blocks, page) : ''
358 if (text) resolved++
359 texts.push(text || null)
360 }
361 const runs = segments.filter(seg => seg.kind === 'image').length
362 const read = { text: joinSegments(segments, texts), segments, picks, runs, resolved }
363 clip.last = { requestId: requestId ?? null, ...read, drawn: [...drawnAnswers.values()].map(({ job: _, ...d }) => d) }
364 return read
365}
366
367// A watcher line `clip <count>` (null: `clip none`, no PerlObjCBridge or
368// the Linux notifier ended: the /iui grab hint stays off). The first count
369// of a child (at its start) is only noted: the clipboard then is no new copy
370// (after a hot reload, it can be an old copy of picture cells).
371function clipChanged($: EngineInterface, count: number | null, first: boolean) {
372 if (count === null) {
373 clip.none = true
374 return
375 }
376 clip.count = count
377 if (first) return
378 changed = ++asked
379 // Undecided until a read that began after it ends: a copy that claims waits for it.
380 let end = () => {}
381 undecided.set(changed, new Promise<void>(r => (end = r)))
382 unread.set(changed, end)
383 void readClipboard($).catch(() => undefined)
384}
385
386// Picture cells on the clipboard (a native copy: their session is not
387// known), never written. Cells that fit a page drawn here get one toast that
388// names /iui grab.
389function hintGrab($: EngineInterface, text: string) {
390 if (!pickPages(decodeSelection(text), [...drawnAnswers.values()]).some(Boolean)) return
391 clip.raw = text
392 clip.rawRow = undefined
393 if (S.debug) traceDebug($, `select: native copy seen: ${decodeSelection(text).filter(seg => seg.kind === 'image').length} picture run(s) on the clipboard`)
394 toast($, 'Intelligent UI: picture cells copied; /iui grab puts their text on the clipboard')
395}
396
397// The clipboard changed: one read (clipboardRead). A clipboard with no
398// placeholders, and our own write, are never touched. A text that no
399// recent $.ui.copy wrote (another app's copy, Claude Code's copy on select, a
400// password manager's: null), even the same text again, is a newer copy; a
401// failed read is none. A change while a read runs reads once more after it.
402// A read decides the changes before it began (`unread`), failed or not.
403const unread = new Map<number, () => void>()
404function readsEnded(upTo: number) {
405 for (const [n, end] of unread) if (n <= upTo) (unread.delete(n), undecided.delete(n), end())
406}
407async function readClipboard($: EngineInterface) {
408 if (clip.busy) {
409 clip.again = true
410 return
411 }
412 clip.busy = true
413 try {
414 do {
415 clip.again = false
416 const n = changed
417 try {
418 const text = await clipboardRead($)
419 if (text === undefined) continue
420 if (text === null || !isOwnChange(text, n)) copies = Math.max(copies, n)
421 if (text === clip.seen) continue
422 clip.seen = text
423 if (text === null || text === clip.wrote || !hasPlaceholders(text)) continue
424 hintGrab($, text)
425 } finally {
426 readsEnded(n)
427 }
428 } while (clip.again)
429 } finally {
430 clip.busy = false
431 readsEnded(Infinity)
432 }
433}
434
435// The watcher's clipboard part: on macOS, or on Linux with a tool that sees
436// the changes (lib/clipboard.ts), on this computer (over ssh the clipboard
437// read is the remote one's), only while Intelligent UI is on (its callers
438// draw, or read the atom) and a picture is drawn. It is a new child (the
439// flag is an argument): its first count is only noted. A child that runs
440// is kept; one that ended is started again (watchFrame caps it). With no
441// PerlObjCBridge (or a notifier that ended) the child goes on as it is
442// (size only).
443function watchClipboard($: EngineInterface) {
444 if (!S.env || S.isRemote || !drawnAnswers.size || clip.none || clipWatchArgs(S.env.isMac) === null) return
445 watchFrame($, (count, first) => clipChanged($, count, first))
446}
447
448// Intelligent UI off, or a /clear (the process goes on): the watcher again
449// with no clipboard part (no reads), and no copy begun before it copies.
450function stopClipboard($: EngineInterface) {
451 copies = ++asked
452 if (watchState()?.clip && !clip.none) watchFrame($, null)
453}
454
455// The /iui grab hint now, for /iui details and dump.
456function hintOf(isOn: boolean): string {
457 const now = watchState()
458 return hintText({
459 isMac: !!S.env?.isMac, hostOs: S.hostOs, isRemote: S.isRemote, isOn, none: clip.none, drawn: drawnAnswers.size > 0,
460 running: !!now?.clip && !now.ended, way: S.clipWay,
461 })
462}
463
464// The update check's state now, for /iui, /iui details and /iui updates.
465async function updateState($: EngineInterface): Promise<UpdateState> {
466 await loadUpdates($)
467 const now = await nowMs($).catch(() => Date.now())
468 return { on: S.updates, off: S.updatesOff, own: S.ownVersion, checked: S.updateChecked, now }
469}
470
471// Each copy (a grab, a click, an up, any $.ui.copy, or a copy from outside
472// that the watcher sees) gets a number when it begins (`asked`), and claims
473// the clipboard once it has a text (`copies`: the newest claim). An older
474// one that still reads its text when a newer one claimed copies nothing; a
475// click or an up with nothing to copy claims nothing. `copiedTexts`: the texts of the
476// last 4 $.ui.copy calls whose change the watcher has not read, each with `asked`
477// when it was made (its change, even read late, is no copy from outside).
478let asked = 0
479let copies = 0
480// The number of the last clipboard change the watcher saw (taken when its line came).
481let changed = 0
482const copiedTexts: { text: string; at: number }[] = []
483function noteCopied(text: string) {
484 const own = { text, at: asked }
485 copiedTexts.push(own)
486 if (copiedTexts.length > 4) copiedTexts.shift()
487 return own
488}
489// Whether change n's text is an own write's: the change after the write takes it, once.
490function isOwnChange(text: string, n: number): boolean {
491 const i = copiedTexts.findIndex(own => own.text === text)
492 if (i < 0) return false
493 // A read of an older change that came late: the write's own change is still to come.
494 if (copiedTexts[i]!.at < n) copiedTexts.splice(i, 1)
495 return true
496}
497ownCopy.note = text => {
498 noteCopied(text)
499 copies = ++asked
500}
501
502// The mod's own write, noted here: a $.ui.copy made after an await does not
503// pass the mod's own ui.copy hook. While it runs (`writing`), the hook passes it with no new claim.
504const writing: string[] = []
505// Its change is not read again (clip.wrote, clip.seen); a write that fails leaves them as they were.
506async function copyOwn($: EngineInterface, args: UiCopyArgs) {
507 const own = noteCopied(args.text)
508 const was = { wrote: clip.wrote, seen: clip.seen }
509 clip.wrote = args.text
510 clip.seen = args.text
511 writing.push(args.text)
512 let isCopied = false
513 try {
514 const copied = await copy($, args)
515 isCopied = copied.isCopied
516 return copied
517 } finally {
518 writing.splice(writing.indexOf(args.text), 1)
519 if (!isCopied) {
520 if (copiedTexts.includes(own)) copiedTexts.splice(copiedTexts.indexOf(own), 1)
521 if (clip.wrote === args.text) clip.wrote = was.wrote
522 if (clip.seen === args.text) clip.seen = was.seen
523 }
524 }
525}
526
527// A copy's turn: its number, the native selection when it began, and its
528// end of not knowing yet whether it copies anything (`done`).
529type Turn = { n: number; native: Promise<string>; done: () => void }
530const nativeKey = (s: { text: string; requestId?: string } | undefined) => (s ? `${s.requestId ?? ''}|${s.text}` : '')
531function nativeNow($: EngineInterface): Promise<string> {
532 try {
533 // A failed read is no selection, as grab and the ui.copy hook take it.
534 return Promise.resolve(uiSelection($)).then(nativeKey, () => nativeKey(undefined))
535 } catch {
536 return Promise.resolve(nativeKey(undefined))
537 }
538}
539// The turns that do not know yet whether they copy anything.
540const undecided = new Map<number, Promise<void>>()
541function takeTurn($: EngineInterface, native?: Promise<string>): Turn {
542 const n = ++asked
543 let end = () => {}
544 undecided.set(n, new Promise<void>(r => (end = r)))
545 return { n, native: native ?? nativeNow($), done: () => (undecided.delete(n), end()) }
546}
547
548// A copy that has its text claims the clipboard: false when a newer one
549// claimed. A newer one that does not know yet whether it copies is waited for.
550async function newerDecided(turn: Turn) {
551 for (const [n, decided] of undecided) if (n > turn.n) await decided
552}
553async function claim(turn: Turn): Promise<boolean> {
554 await newerDecided(turn)
555 if (copies > turn.n) return false
556 copies = turn.n
557 return true
558}
559
560// A copy that has its text when it begins (a grab, a $.ui.copy): it claims at once.
561function claimNow($: EngineInterface, native: Promise<string>): Turn {
562 const n = ++asked
563 copies = n
564 return { n, native, done: () => {} }
565}
566
567// Whether a copy is still the newest when its text is read: no newer copy
568// came, and no new native selection (Claude Code copies that itself, past
569// every hook; no poll runs for a click).
570async function isNewest($: EngineInterface, turn: Turn): Promise<boolean> {
571 if (turn.n !== copies) return false
572 const [was, now] = await Promise.all([turn.native, nativeNow($)])
573 return turn.n === copies && was === now
574}
575
576// /iui grab: the last mouse selection, read and copied. With no selection
577// (the main screen), the picture cells on the clipboard: the command says
578// that the copy is this session's.
579async function grab($: EngineInterface): Promise<string> {
580 const selected = await uiSelection($).catch(() => undefined)
581 let raw = selected?.text
582 if (raw === undefined) {
583 const now = await clipboardNow($)
584 if (now === null) return 'Intelligent UI: no mouse selection, and the clipboard can not be read (see /iui details).'
585 if (clip.wrote && now === clip.wrote) return 'Intelligent UI: the clipboard has the text already.'
586 if (!hasPlaceholders(now)) return 'Intelligent UI: no mouse selection and no picture cells on the clipboard; select over a picture and copy first.'
587 raw = now
588 }
589 if (!hasPlaceholders(raw)) return `Intelligent UI: the selection has no picture cells; it is ${raw.length} chars of text already.`
590 const row = selected?.requestId
591 clip.raw = raw
592 clip.rawRow = row
593 // Only a grab that copies takes a turn.
594 const turn = takeTurn($, Promise.resolve(nativeKey(selected)))
595 let read: Awaited<ReturnType<typeof readCopy>>
596 try {
597 read = await readCopy($, raw, row)
598 if (!read.resolved) return `Intelligent UI: found ${read.runs} picture run(s) but could not read them (see /iui dump).`
599 const mine = await claim(turn)
600 turn.done()
601 if (!mine || !(await isNewest($, turn))) return 'Intelligent UI: a newer copy came first; this one copied nothing.'
602 } finally {
603 turn.done()
604 }
605 const copied = await copyOwn($, { text: read.text })
606 return copied.isCopied
607 ? `Intelligent UI: copied ${read.text.length} chars (${read.resolved} of ${read.runs} picture runs read${row ? `, row ${row}` : ''}).`
608 : `Intelligent UI: the copy failed: ${copied.reason}.`
609}
610
611// For dump: the rows of a pick's message on screen and its page's top row
612// there (pageTopsOf), which chose the page.
613function screenText(pick: { id: string; page: number }): string {
614 const d = drawnAnswers.get(pick.id)
615 if (!d || d.onScreen === undefined) return ' (rows on screen not known)'
616 if (d.onScreen === null) return ' (off screen)'
617 const tops = pageTopsOf(d)
618 const top = tops ? `page top ${tops[pick.page]}` : 'page tops not known'
619 return ` (rows ${d.onScreen.first}-${d.onScreen.last} of ${d.onScreen.of} on screen, ${top})`
620}
621
622// /iui dump: the last placeholder text seen, as code points, how it was
623// read, and the same in full in <cache>/copy-debug.json. `on`: Intelligent
624// UI is on.
625async function dump($: EngineInterface, on: boolean): Promise<string> {
626 await clipWayReady()
627 const hint = hintOf(on)
628 if (!clip.raw) return `Intelligent UI: no clipboard text with picture cells seen yet (grab hint ${hint}; ${drawnAnswers.size} pictures drawn).`
629 const read = await readCopy($, clip.raw, clip.rawRow)
630 const runs = read.segments
631 .map((seg, i) => {
632 if (seg.kind === 'text') return `text: ${JSON.stringify(seg.text.slice(0, 60))}`
633 const rows = seg.lines.map(l => `${l.row}:${l.colStart}-${l.colEnd}`).join(' ')
634 const pick = read.picks[i]
635 return `run rows ${rows} -> ${pick ? `answer ${pick.id} page ${pick.page}${screenText(pick)}` : 'no page fits'}`
636 })
637 .join('\n')
638 const cache = S.env?.cache ?? ''
639 if (cache) {
640 const full = { raw: clip.raw, codePoints: codePointsOf(clip.raw, 100000), ...(clip.last as object) }
641 await write($, `${cache}/copy-debug.json`, JSON.stringify(full, null, 1)).catch(() => undefined)
642 }
643 return [
644 `Intelligent UI: last clipboard with picture cells (${Array.from(clip.raw).length} code points; grab hint ${hint}):`,
645 codePointsOf(clip.raw, 40),
646 runs,
647 `Read as ${read.text.length} chars: ${JSON.stringify(read.text.slice(0, 200))}`,
648 cache ? `Full dump: ${cache}/copy-debug.json` : '',
649 ].filter(Boolean).join('\n')
650}
651
652// ---- Picture select -------------------------------------------------------
653// The Client over each page (hooks/sel.tsx) posts the selection; the pages
654// show it (a blit of each page's picture, lib/blit.ts) and its text is
655// copied on the button's up, read as a copy of its cells is (wordsOf, textOfRun).
656// See lib/select.ts.
657
658// A Client's post: the selection after it, shown (once a frame at most, the
659// newest selection). A down puts its page first in the cache (lib/pagecache.ts)
660// and probes its words if they are not there, for the drag's frames and the
661// copy at the up, lets a page whose blit was refused try again, and takes
662// the hover's tint down. A down is always taken (a reloaded sel.tsx counts
663// from 0 again). A hover or a leave moves the tint (hoverAt), after the up
664// or clear it carries; a clear (a click) on a target copies it.
665function onSelect($: EngineInterface, post: SelectPost, surface: RenderSurface) {
666 if (post.end) onSelect($, post.end, surface)
667 if (post.seq <= picked.seq && post.phase !== 'down') return
668 picked.seq = post.seq
669 const d = drawnAnswers.get(post.id)
670 if (post.phase === 'hover' || post.phase === 'leave') {
671 // A leave of a page the pointer is no longer on (it is on another page) changes nothing.
672 const at = hovered.at
673 if (post.phase === 'leave' && at && (at.id !== post.id || at.page !== post.page)) return
674 hoverAt($, post.phase === 'hover' && d ? { id: post.id, page: post.page, x: post.head.x, y: post.head.y } : null)
675 return
676 }
677 const was = picked.now
678 picked.now = d ? selectionAfter(post, topsOf(d.pages)) : null
679 if (S.debug) traceDebug($, selectTrace(post, d, picked.now))
680 const page = d?.pages[post.page]
681 if (post.phase === 'down') {
682 blits.refused.clear()
683 hovered.at = null
684 hovered.lit = null
685 if (d && page && S.env) {
686 void needFrames($, page, selectColor(S.theme), { id: d.id, index: post.page, seq: d.seq }).catch(() => undefined)
687 void wordsOf($, d, post.page).catch(() => undefined)
688 }
689 }
690 if (was || picked.now) redrawSelection($)
691 watchNative($, () => clearSelection($))
692 // The copy waits for the page's words when they are not there: the next posts do not.
693 if (post.phase === 'up' && d && picked.now) void copySelection($, d, picked.now, surface).catch(() => undefined)
694 if (post.phase === 'clear' && d) {
695 void clickAt($, d, post, surface).catch(() => undefined)
696 // The pointer is still there: its target tinted again.
697 hoverAt($, { id: post.id, page: post.page, x: post.head.x, y: post.head.y })
698 }
699}
700
701// No links of a drawing known (its pages drawn with no picture select yet).
702const NO_LINKS: readonly ShownLink[] = []
703
704// The pointer over a page with no button down (null: off it): the target
705// under it tinted, while no selection shows. A new page's words are probed
706// when they are not known (the tint once they are); the tint's blit asks for
707// the page's frames (needFrames), as a down does.
708function hoverAt($: EngineInterface, at: Hover | null) {
709 const was = hovered.at
710 hovered.at = at
711 const d = at ? drawnAnswers.get(at.id) : undefined
712 if (at && d && (was?.id !== at.id || was.page !== at.page) && !knownWords(d, at.page)) {
713 void wordsOf($, d, at.page).then(() => lightHover($), () => undefined)
714 }
715 lightHover($)
716}
717
718// The hover's target tinted (none while a selection shows), redrawn when it changed.
719function lightHover($: EngineInterface) {
720 const at = hovered.at
721 const d = at ? drawnAnswers.get(at.id) : undefined
722 const targets = at && d && !picked.now ? knownTargets(d, at.page, clickLinks.get(at.id) ?? NO_LINKS) : undefined
723 const lit = at && targets ? litOf(at, targets) : null
724 if (sameLit(lit, hovered.lit)) return
725 hovered.lit = lit
726 if (S.debug) traceDebug($, lit ? `select: hover ${lit.id} page ${lit.page} target ${lit.index} (${targets?.[lit.index]?.target.kind}) rows ${lit.rows.map(r => `${r.row}:${r.x0}-${r.x1}`).join(',')}` : 'select: hover none')
727 redrawSelection($)
728}
729
730// A click (a clear) on a target of the page opens a web link or a file
731// (openTarget); an alt+click, a command, a code block's copy button or a
732// click over ssh copies its text (a link's URL, a file's path, a command, a
733// code block's code). The page's words are probed first when they are not
734// known; a newer copy while they are (or a new native selection): the newer
735// one copies. A click on no target, or one that opens, copies nothing and
736// cancels no other copy.
737async function clickAt($: EngineInterface, d: Drawn & { job?: Job }, post: SelectPost, surface: RenderSurface) {
738 const turn = takeTurn($)
739 try {
740 const links = () => clickLinks.get(d.id) ?? NO_LINKS
741 let targets = knownTargets(d, post.page, links())
742 if (!targets) {
743 await wordsOf($, d, post.page)
744 targets = knownTargets(d, post.page, links())
745 }
746 const target = targets?.[targetAt(targets, post.anchor.x, post.anchor.y)]?.target
747 const opens = !!target && (target.kind === 'web' || target.kind === 'file') && !post.alt && !S.isRemote
748 if (S.debug) traceDebug($, `select: click ${d.id} page ${post.page} at ${post.anchor.x},${post.anchor.y} target ${target ? target.kind : 'none'}${target ? (opens ? ' open' : ' copy') : ''}`)
749 if (!target) return
750 if (opens) {
751 // An open writes no clipboard: the turn is not claimed.
752 turn.done()
753 await openTarget($, target, surface, turn)
754 return
755 }
756 const mine = await claim(turn)
757 turn.done()
758 if (!mine || !(await isNewest($, turn))) return
759 await copyTarget($, target, surface)
760 } finally {
761 turn.done()
762 }
763}
764
765// A web link or a file opened (clickAt), with its toast. One that does not
766// open (no program, an exit code not 0) is copied in the click's turn: a
767// newer copy (or a new native selection) while it opened keeps the clipboard.
768async function openTarget($: EngineInterface, target: Target, surface: RenderSurface, turn: Turn) {
769 const use: Use = target.kind === 'file' ? await editorNow($) : { kind: 'app' }
770 const argv = openArgv(target, { isMac: !!S.env?.isMac, use })
771 const got = argv ? await exec($, detachedArgv(argv), { timeoutMs: OPEN_TIMEOUT_MS }).catch(() => null) : null
772 if (S.debug) traceDebug($, `select: open ${argv?.join(' ') ?? 'none'} exit ${got ? got.exitCode : 'failed'}`)
773 if (got?.exitCode === 0) return void toast($, openedText(target, use))
774 // No argv for a file: the system app could run it (openArgv), so it only copies.
775 const why = argv ? 'failed' : 'type'
776 if (!(await claim(turn)) || !(await isNewest($, turn))) {
777 return void toast($, `Intelligent UI: ${why === 'type' ? NO_OPEN : `could not open the ${target.kind === 'web' ? 'link' : 'file'}`}`)
778 }
779 await copyTarget($, target, surface, why)
780}
781
782const NO_OPEN = 'this file type does not open on click'
783
784// A target's text on the clipboard (clickAt), with its toast; `notOpened`:
785// it is copied as an open of it failed, or as its file type does not open.
786async function copyTarget($: EngineInterface, target: Target, surface: RenderSurface, notOpened?: 'failed' | 'type') {
787 const text = target.kind === 'web' ? target.href : target.kind === 'file' ? target.path : target.text
788 const name = target.kind === 'web' ? 'link' : target.kind === 'file' ? 'path' : target.kind === 'code' ? 'code' : 'command'
789 const copied = await copyOwn($, { text, surface })
790 if (notOpened === 'type') return void toast($, `Intelligent UI: ${copied.isCopied ? `copied the path (${NO_OPEN})` : `${NO_OPEN}, and the copy failed: ${copied.reason}`}`)
791 const failed = notOpened ? `could not open the ${target.kind === 'web' ? 'link' : 'file'}` : ''
792 const done = failed ? `${failed}, so copied ${target.kind === 'web' ? 'it' : 'its path'}` : `copied the ${name}`
793 toast($, copied.isCopied ? `Intelligent UI: ${done}` : `Intelligent UI: ${failed ? `${failed}, and ` : ''}the copy failed: ${copied.reason}`)
794}
795
796// The text of a selection's cells, page by page, as a copy of those cells
797// reads it (textOfRuns); null when a page's words can not be read.
798// `probed`: the pages whose words were not there yet (a probe ran or ran on).
799async function selectionText($: EngineInterface, d: Drawn & { job?: Job }, sel: Omit<Selection, 'id' | 'isUp'>, probed: number[] = []): Promise<string | null> {
800 const runs = runsOf(selectedRows(sel, d.pages))
801 const words = new Map<number, PageWords | null>()
802 for (const run of runs) {
803 const known = knownWords(d, run.page)
804 if (!known) probed.push(run.page)
805 words.set(run.page, known ?? (await wordsOf($, d, run.page)))
806 }
807 return textOfRuns(runs, page => words.get(page) ?? null, d.pages)
808}
809
810// The button came up: the selection's text on the clipboard, with the
811// toast of a copy. No text under it: nothing is copied (and no other copy
812// is cancelled); a newer up while
813// its words are read (or any newer copy, or a new native selection): the newer one copies.
814async function copySelection($: EngineInterface, d: Drawn & { job?: Job }, sel: Selection, surface: RenderSurface) {
815 const t = performance.now()
816 const turn = takeTurn($)
817 const probed: number[] = []
818 let text: string | null
819 try {
820 text = await selectionText($, d, sel, probed)
821 // A newer copy claimed first: no toast either.
822 if (copies > turn.n) return
823 if (text === null) {
824 toast($, 'Intelligent UI: could not read the selected picture')
825 return
826 }
827 if (!text) return
828 const mine = await claim(turn)
829 turn.done()
830 if (!mine || !(await isNewest($, turn))) return
831 } finally {
832 turn.done()
833 }
834 const read = performance.now() - t
835 const copied = await copyOwn($, { text, surface })
836 toast($, copied.isCopied ? `Intelligent UI: copied ${text.length} chars of text` : `Intelligent UI: the copy failed: ${copied.reason}`)
837 if (S.debug) void traceCopy($, text, performance.now() - t, read, probed)
838}
839
840// The trace of a copy: the time from the up to the copy, whether the words
841// were there, and whether Claude Code has a selection of its own with
842// picture cells (a native selection: it copies them itself).
843async function traceCopy($: EngineInterface, text: string, total: number, read: number, probed: readonly number[]) {
844 const native = await uiSelection($).catch(() => undefined)
845 const seen = native && hasPlaceholders(native.text) ? `native selection seen (${Array.from(native.text).length} code points with picture cells)` : 'no native selection'
846 const words = probed.length ? `words probed for page ${probed.join(', ')}` : 'words cached'
847 traceDebug($, `select: copy ${text.length} chars ${Math.round(total)}ms after the up (text ${Math.round(read)}ms, ${words}); ${seen}`)
848}
849
850// Picture select now, for /iui details: on, or why it is off.
851function selectState(): string {
852 if (S.cacheProblem) return `off (${S.cacheProblem})`
853 if (!S.select) return 'off (/iui select off)'
854 if (picked.isBroken) return 'off until a reload (its Client failed: see the debug log)'
855 if (!needsSelect(S.engine)) return `off (Claude Code ${S.engine || 'of an unknown version'}; it needs ${SELECT_SINCE} or newer)`
856 if (S.env && (!S.env.canDraw || S.env.inline)) return 'off (no pictures drawn from files here)'
857 if (S.isFullscreen === false) return 'off on the main screen (use /tui fullscreen, or /iui grab)'
858 return S.isFullscreen === null ? 'on in fullscreen (the screen is not known yet)' : 'on'
859}
860
861// No selection drawn, and no hover tint (a new turn, typing in the prompt,
862// a native selection, Intelligent UI or select off).
863function clearSelection($: EngineInterface) {
864 stopNative()
865 hovered.at = null
866 if (!picked.now && !hovered.lit) return
867 picked.now = null
868 hovered.lit = null
869 redrawSelection($)
870}
871
872// Intelligent UI or picture select off: no selection, and the page cache
873// let go (its pages, its build, its pump).
874function releaseSelect($: EngineInterface) {
875 clearSelection($)
876 forgetPages()
877}
878
879// `/iui theme [name]`, one at a time (`themeRuns`): an older one still
880// reading the themes or measuring fonts would write its look, metrics,
881// epoch and stored name over a newer one's.
882let themeRuns: Promise<unknown> = Promise.resolve()
883function runTheme($: EngineInterface, name: string | undefined): Promise<string> {
884 const run = themeRuns.then(() => setTheme($, name))
885 themeRuns = run.catch(() => undefined)
886 return run
887}
888
889async function setTheme($: EngineInterface, name: string | undefined): Promise<string> {
890 const ready = await setUp($)
891 const was = S.look
892 const wanted = name ?? S.look?.name ?? DEFAULT_THEME
893 const { themes, problems, chosen } = await applyTheme($, ready.root, ready.home, wanted)
894 if (name && chosen.isFallback) {
895 if (was) S.look = was
896 return `No usable theme named ${name}.\n\n${themeListing(themes, problems, was?.name ?? DEFAULT_THEME, userThemesOf(ready.home), ready.home)}`
897 }
898 if (S.look?.name !== was?.name || S.look?.dir !== was?.dir || S.look?.stamp !== was?.stamp) {
899 // The theme's fonts set the column width and the code size; measured again
900 // when the terminal font changed meanwhile (refont), never for an older font.
901 if (ready.typst && S.look) {
902 let font: string
903 do {
904 font = ready.termFont
905 const metrics = await measureFonts($, ready.typst, ready.root, ready.cache, font, S.look.dir)
906 if (font === ready.termFont) ready.metrics = metrics
907 } while (font !== ready.termFont)
908 }
909 beginEpoch($)
910 }
911 if (name) await save($, 'theme-name', chosen.name)
912 const head = name ? `Theme set to ${chosen.name}.\n\n` : ''
913 return `${head}${themeListing(themes, problems, chosen.name, userThemesOf(ready.home), ready.home)}`
914}
915
916// ---- Hooks ----------------------------------------------------------------
917
918export const register: Register = on => {
919 on('session.start', async ($, e, next) => {
920 await registerCommand($)
921 void setUp($).catch(() => undefined)
922 return next(e)
923 })
924
925 // A /clear or a resume: the process goes on, with no clipboard reads until
926 // the next picture. Any other end (/exit): the watcher stops, and no new
927 // child starts.
928 on('session.end', async ($, e, next) => {
929 forgetPaths()
930 if (e.reason === 'clear' || e.reason === 'resume') stopClipboard($)
931 else {
932 stopWatch()
933 stopHost()
934 }
935 return next(e)
936 }).catch(($, e, next) => next(e))
937
938 // A copy with placeholders (a copy of picture cells, when the engine sends
939 // one through `$.ui.copy`): the words under them instead. Our own copies
940 // hold no placeholders and pass on as they are. Any copy is the newest:
941 // one with placeholders whose words are read when a newer one came is refused.
942 on('ui.copy', async ($, e, next) => {
943 if (writing.includes(e.text)) return next(e)
944 // The text that goes on the clipboard is noted (copiedTexts), only it.
945 if (!hasPlaceholders(e.text) || !drawnAnswers.size) {
946 noteCopied(e.text)
947 copies = ++asked
948 return next(e)
949 }
950 if (S.debug) traceDebug($, 'select: native copy seen: a $.ui.copy with picture cells (read as their text)')
951 const selection = $.ui.selection().catch(() => undefined)
952 const turn = claimNow($, selection.then(nativeKey))
953 const selected = await selection
954 clip.raw = e.text
955 clip.rawRow = selected?.text === e.text ? selected.requestId : undefined
956 const read = await readCopy($, e.text, clip.rawRow)
957 await newerDecided(turn)
958 if (!(await isNewest($, turn))) return { value: { isCopied: false, reason: 'refused' } }
959 if (!read.resolved) {
960 noteCopied(e.text)
961 return next(e)
962 }
963 // The clipboard change of this write is not read again.
964 clip.wrote = read.text
965 clip.seen = read.text
966 noteCopied(read.text)
967 return next({ ...e, text: read.text })
968 }).catch(($, e, next) => next(e))
969
970 // Picture select's posts (hooks/sel.tsx); any other post passes on.
971 on('ui.message', async ($, e, next) => {
972 const post = e.surface === 'terminal' ? postOf(e.data) : null
973 if (!post) return next(e)
974 try {
975 onSelect($, post, e.surface)
976 } catch {}
977 return {}
978 }).catch(($, e, next) => next(e))
979
980 // A Client that failed: picture select is off until a reload (the engine
981 // draws the row again, with no Client).
982 on('ui.fault', async ($, e, next) => {
983 if (e.module.endsWith('sel.tsx')) {
984 picked.isBroken = true
985 releaseSelect($)
986 // The rows drawn again with no Client.
987 refresh($)
988 void Promise.resolve($.ui.log(`Intelligent UI: picture select is off (${e.phase}: ${e.reason})`, { to: 'debug' })).catch(() => undefined)
989 }
990 return next(e)
991 }).catch(($, e, next) => next(e))
992
993 // Typing in the main prompt takes a picture selection down (a key that
994 // edits, not a bare cursor move); the edit passes on as it is. The box
995 // after it gives the draft's pasted images (noteDraft).
996 on('prompt.edit', async ($, e, next) => {
997 const isEdit = !!e.inputText || e.end > e.start
998 if (picked.now && isEdit) clearSelection($)
999 void Promise.resolve().then(() => nowMs($)).then(t => void (S.keyAt = t), () => undefined)
1000 const seq = ++draftSeq
1001 const box = await next(e)
1002 if (isEdit && e.origin.kind === 'composer' && seq === draftSeq) {
1003 const fresh = noteDraft($, box.text)
1004 if (fresh.length && e.inputText.includes('[Image #')) pastedPaths($, fresh)
1005 }
1006 return box
1007 }).catch(($, e, next) => next(e))
1008
1009 // A change of the prompt box no edit carried (a recalled entry, an undo, a
1010 // clear): the same, but not another plugin's fill. Observe only. Any
1011 // change gives the draft's pasted images (a submit clears them).
1012 on('prompt.change', async ($, e, next) => {
1013 if (picked.now && e.origin.kind !== 'plugin' && e.origin.kind !== 'app') clearSelection($)
1014 draftSeq++
1015 const fresh = noteDraft($, e.text)
1016 // A path pasted (an engine change): its file from the clipboard.
1017 if (fresh.length && !NOT_PASTES.includes(e.origin.kind)) pastedPaths($, fresh)
1018 return next(e)
1019 }).catch(($, e, next) => next(e))
1020
1021 // Typeahead for /iui arguments; the theme list is the last scan's.
1022 on('prompt.autocomplete', async ($, e, next) => {
1023 const got = await next(e)
1024 if (!/^\s*\/iui(\s|$)/.test(e.text)) return got
1025 if (!S.themeChoices.length) await setUp($).catch(() => null)
1026 await loadUpdates($)
1027 const mine = commandSuggestions(e.text, e.token, e.start, {
1028 themes: S.themeChoices, active: S.look?.name ?? DEFAULT_THEME, size: S.size, width: S.width, height: S.height, grid: S.grid, updates: S.updates, select: S.select,
1029 editor: S.editor, onPath: S.onPath, autoEditor: S.autoEditor,
1030 })
1031 return mine.length ? { suggestions: [...got.suggestions, ...mine] } : got
1032 })
1033
1034 on('command.run', { command: 'iui' }, async ($, e) => {
1035 const asked = parseCommand(e.args)
1036 if (asked.kind === 'grab') return { text: await grab($) }
1037 if (asked.kind === 'dump') return { text: await dump($, await read($, isOn)) }
1038 if (asked.kind === 'on' || asked.kind === 'off') {
1039 settingsSet.add('enabled')
1040 S.isOff = asked.kind === 'off'
1041 // Under herdr, its client is checked only while on.
1042 watchHost($)
1043 await update($, isOn, () => asked.kind === 'on')
1044 // On: the clipboard part starts with the next picture drawn.
1045 if (asked.kind === 'off') {
1046 stopClipboard($)
1047 releaseSelect($)
1048 }
1049 await $.store.set('enabled', asked.kind === 'on')
1050 return { text: `Intelligent UI is ${asked.kind}.` }
1051 }
1052 if (asked.kind === 'size') {
1053 // The epoch begins with the setting, so no row draws it with no epoch
1054 // open (the store write waits for the host).
1055 S.size = asked.percent
1056 settingsSet.add('size')
1057 beginEpoch($)
1058 await $.store.set('size', S.size)
1059 return { text: `Intelligent UI draws text at ${S.size}% of the terminal's text size (100% matches it).` }
1060 }
1061 if (asked.kind === 'width') {
1062 if (asked.width === undefined) await setUp($).catch(() => null)
1063 else {
1064 S.width = asked.width
1065 settingsSet.add('width')
1066 beginEpoch($)
1067 await $.store.set('width', S.width)
1068 }
1069 const most = typeof S.width === 'number' && S.width > IMAGE_MAX_COLUMNS ? ` (a picture is ${IMAGE_MAX_COLUMNS} columns at most)` : ''
1070 const head = asked.width === undefined ? 'The most columns a picture takes' : 'Intelligent UI pictures now take at most'
1071 return { text: `${head}: ${widthText(S.width)}${most}. Use /iui width <${MIN_WIDTH}-${MAX_WIDTH}> for a cap, or /iui width full (the default) for no cap.` }
1072 }
1073 if (asked.kind === 'height') {
1074 if (asked.height === undefined) await setUp($).catch(() => null)
1075 else {
1076 S.height = asked.height
1077 settingsSet.add('height')
1078 beginEpoch($)
1079 await $.store.set('height', S.height)
1080 }
1081 const head = asked.height === undefined ? 'The most rows an image or a Typst drawing takes' : 'Intelligent UI images and Typst drawings now take at most'
1082 return { text: `${head}: ${heightText(S.height)}. This applies to images that Claude sends and to Typst drawings in answers (images that you paste show small, as their preview above the prompt box). Use /iui height half (the default), /iui height full, or /iui height <${MIN_HEIGHT}-${MAX_HEIGHT}> (never more than full).` }
1083 }
1084 if (asked.kind === 'grid') {
1085 if (asked.grid === undefined) await setUp($).catch(() => null)
1086 else {
1087 S.grid = asked.grid
1088 settingsSet.add('grid')
1089 beginEpoch($)
1090 await $.store.set('grid', S.grid)
1091 }
1092 const head = asked.grid === undefined ? 'The row grid is' : 'The row grid is now'
1093 return { text: `${head} ${gridText(S.grid)}. With the grid on, a mouse selection over a picture covers the lines it copies. Use /iui grid on (the default), or /iui grid off.` }
1094 }
1095 if (asked.kind === 'select') {
1096 if (asked.on === undefined) await setUp($).catch(() => null)
1097 else {
1098 S.select = asked.on
1099 settingsSet.add('select')
1100 if (!S.select) releaseSelect($)
1101 refresh($)
1102 await $.store.set('select', S.select)
1103 }
1104 const head = asked.on === undefined ? 'Picture select is' : 'Picture select is now'
1105 return { text: `${head} ${selectText(S.select)}. When on, a mouse drag over a picture selects its text and copies it. Use /iui select on (the default), or /iui select off.` }
1106 }
1107 if (asked.kind === 'editor') {
1108 if (asked.editor === undefined) await setUp($).catch(() => null)
1109 else {
1110 S.editor = asked.editor
1111 settingsSet.add('editor')
1112 await $.store.set('editor', S.editor)
1113 }
1114 const use = await editorNow($)
1115 const head = asked.editor === undefined ? 'The editor for file paths is' : 'The editor for file paths is now'
1116 const missed = use.kind !== 'command' && use.missed ? ` ${use.missed} is not on the PATH, so a click uses ${useName(use)}.` : ''
1117 return { text: `${head}: ${S.editor} (${useName(use)}).${missed} Use /iui editor auto (the default), /iui editor <name>, /iui editor default (the system app), or /iui editor <command with {path} {line} {column}>.` }
1118 }
1119 if (asked.kind === 'updates') {
1120 await loadUpdates($)
1121 if (asked.on !== undefined) {
1122 S.updates = asked.on
1123 await $.store.set('updates', S.updates)
1124 // On: the check of this load, if it did not run (it waits 24 h after the last one).
1125 if (S.updates) void checkUpdates($)
1126 }
1127 const head = asked.on === undefined ? 'Update checks' : 'Update checks are now'
1128 return { text: `${head}: ${updateLine(await updateState($))}. When on, Intelligent UI gets its plugin.json from GitHub once a day at most (one GET, no data sent) and shows a toast when a new version is available. Use /iui updates on (the default), or /iui updates off.` }
1129 }
1130 if (asked.kind === 'setup') {
1131 await setUp($).catch(() => null)
1132 return { text: await runSetup($, asked.remove) }
1133 }
1134 if (asked.kind === 'probe') {
1135 // A debug check: does the terminal's OSC 11 answer reach the mod?
1136 const lines: string[] = []
1137 for (let i = 1; i <= PROBE_RUNS; i++) {
1138 const got = await probeOsc11($)
1139 const bg = got ? parseOsc11(got.reply) : null
1140 const what = !got ? 'could not open /dev/tty' : !got.reply ? 'no answer in 300 ms' : `"${escapeControls(got.reply)}"`
1141 const mode = bg ? ` -> luminance ${luminanceOf(bg).toFixed(3)}, ${modeOfLuminance(luminanceOf(bg))}` : got?.reply ? ' -> not an OSC 11 answer' : ''
1142 lines.push(`${i}: ${what}${mode}`)
1143 }
1144 return { text: `OSC 11 probe (the terminal's background), ${PROBE_RUNS} runs:\n${lines.join('\n')}\nThe poll uses it: ${USE_OSC11 ? 'yes' : S.isHerdr ? 'in herdr, at the start and at a change of client only' : 'no (off)'}.` }
1145 }
1146 if (asked.kind === 'theme') return { text: await runTheme($, asked.name) }
1147 const state = (await read($, isOn)) ? 'on' : 'off'
1148 const ready = await setUp($)
1149 const bad = asked.kind === 'bad' ? `Not understood: "${e.args.trim()}".\n` : ''
1150 const theme = S.look?.name ?? DEFAULT_THEME
1151 if (asked.kind === 'details') {
1152 // A measure with no term metrics: Typst does not have the font (it would fall back).
1153 const missing = !!ready.typst && ready.metrics !== DEFAULT_METRICS && !ready.metrics.term
1154 const font = !ready.termFont
1155 ? `terminal font not known (${ready.isMac ? 'Menlo' : "the theme's code font"} assumed)`
1156 : `terminal font ${ready.termFont}${missing ? " (Typst does not have this font, so the pictures use the theme's fonts)" : ''}`
1157 const typst = ready.typst ? `Typst: ${ready.typst}` : `Typst: not found (${typstHintOf(ready.isMac)})`
1158 const terminal = S.cacheProblem
1159 ? `Intelligent UI draws no pictures: ${S.cacheProblem} (it never uses a shared folder instead)`
1160 : ready.canDraw
1161 ? `this terminal draws pictures${ready.inline ? ' (over ssh, sent as bytes)' : ''}`
1162 : S.isHerdr && S.setupNeeded
1163 ? 'Claude Code draws no pictures in herdr until CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 is set when it starts (run /iui setup), so answers stay text'
1164 : 'Claude Code draws no pictures here (it does in kitty and Ghostty, in iTerm2 3.6+, Rio 0.5.27+ and herdr with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1, over ssh when TERM names kitty or Ghostty or with that variable; never in tmux, screen or a background session; not in WezTerm, Warp, Konsole or others), so answers stay text'
1165 // In herdr: its version, the terminal it runs in and where that came from (and flip mode).
1166 const herdr = S.isHerdr ? `terminal ${herdrText(S.herdrVersion, S.herdrHost, S.herdrClient ? (S.herdrBy === 'front' ? 'front' : 'client') : 'server', isFlipHere(ready))}: ` : ''
1167 await clipWayReady()
1168 const auto = `Picture select: ${selectState()}\n/iui grab hint: ${hintOf(state === 'on')}`
1169 const updates = `Update checks: ${updateLine(await updateState($))}`
1170 return { text: `Intelligent UI is ${state}, theme ${theme}, ${modeLine(S.theme, S.setting, S.settingRaw, S.host)}, text size ${S.size}%, width ${widthText(S.width)}, image and drawing height ${heightText(S.height)}, row grid ${S.grid ? 'on' : 'off'}, picture select ${S.select ? 'on' : 'off'}, ${font}. ${typst}; ${herdr}${terminal}.\n${auto}\n${updates}` }
1171 }
1172 // The short state: what is set, and only the problems that keep pictures away.
1173 const sized = (v: number | 'full' | 'half', unit: string) => (typeof v === 'number' ? `${v} ${unit}` : v)
1174 const lines = [`Intelligent UI is ${state}: theme ${theme}, ${S.theme}, text ${S.size}%, width ${sized(S.width, 'columns')}, height ${sized(S.height, 'rows')}, grid ${S.grid ? 'on' : 'off'}, editor ${S.editor}.`]
1175 if (S.cacheProblem) lines.push(`No pictures: ${S.cacheProblem}. See /iui details.`)
1176 else if (S.setupNeeded) lines.push(`In ${S.isHerdr ? 'herdr' : 'iTerm2'}, run /iui setup once to show pictures.`)
1177 else if (!ready.canDraw) {
1178 const where = ready.isMac
1179 ? 'kitty, Ghostty and iTerm2 3.6+, and in herdr with /iui setup'
1180 : 'kitty and Ghostty, in herdr with /iui setup, and in Rio with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1'
1181 lines.push(`This terminal shows no pictures, so answers stay text. Pictures show in ${where}.`)
1182 }
1183 if (!ready.typst) lines.push(`Typst is not installed, so answers stay text. Install it: ${typstHintOf(ready.isMac)}`)
1184 const notice = updateNotice(await updateState($))
1185 if (notice) lines.push(notice)
1186 lines.push('Change: /iui on | off | size | width | height | grid | select | editor | updates | theme. More: /iui details')
1187 return { text: bad + lines.join('\n') }
1188 }).catch(($, e, next) => next(e))
1189
1190 on('config.set', { key: 'theme' }, async ($, e, next) => {
1191 const set = await next(e)
1192 if (set.value === undefined) return set
1193 S.settingRaw = typeof set.value === 'string' ? set.value : ''
1194 S.setting = settingOf(set.value)
1195 // Not awaited: the setting is saved at once; the OS read redraws later.
1196 if (S.setting === 'auto') void pollHost($, true).catch(() => undefined)
1197 else showMode($)
1198 watchHost($)
1199 return set
1200 }).catch(($, e, next) => next(e))hooks/lib/settings.ts 228 lines1// Intelligent UI's settings: sizes, limits, timings and the text it adds to
2// Claude's system prompt.
3import type { Cell } from './state'
4
5// ---- Settings -------------------------------------------------------------
6
7// `/iui width`: the most columns a picture takes (in this range), or
8// `full`, the default: the terminal width less the bullet, as Claude Code's
9// own text. An Image is at most IMAGE_MAX_COLUMNS wide, so no width goes
10// past it.
11export type Width = number | 'full'
12export const DEFAULT_WIDTH: Width = 'full'
13export const MIN_WIDTH = 40
14export const MAX_WIDTH = 400
15export const IMAGE_MAX_COLUMNS = 255
16// The width setting as words: "160 columns", or "full (the terminal width)".
17export const widthText = (width: Width) => (width === 'full' ? 'full (the terminal width)' : `${width} columns`)
18// The bullet column to the left of an answer.
19export const GUTTER = 2
20// Claude Code's own reply bullet: ⏺ on macOS, ● elsewhere.
21export const bulletOf = (isMac: boolean) => (isMac ? '⏺' : '●')
22// The engine's answer row has a blank row above it; ours keeps it.
23export const MARGIN_TOP = 1
24// The template's body text size in pt; the page width is chosen so that this
25// text looks as big as the terminal's own (see fontFit).
26export const BODY_PT = 11
27// `/iui size`: a percent on top of that match.
28export const DEFAULT_SIZE = 100
29export const MIN_SIZE = 80
30export const MAX_SIZE = 200
31// A block whose text changes outside a model request we saw (a hot reload
32// mid-stream) is final once its text has been still this long.
33export const SETTLE_MS = 400
34// Inside a request, a block whose text has been still this long is final
35// too (a stalled stream never keeps it hidden).
36export const LIVE_STILL_MS = 2000
37// How long a final block that is not drawn yet stays blank before its text
38// shows: after it streamed, and when it was never live (a resumed transcript).
39export const BLANK_AFTER_LIVE_MS = 2000
40export const BLANK_RESUMED_MS = 1000
41export const MAX_RUNNING = 2
42// A segment is cut to be about this many rows; an Image is 255 rows at most.
43export const SEGMENT_ROWS = 160
44// The rows a live Typst block is counted as when its room is not known: the
45// largest room of the default height (PICTURE_MAX_ROWS).
46export const DRAWING_ROWS = 24
47export const MAX_IMAGE_ROWS = 255
48// The cell in device pixels when the tty reports none (tmux, a relay): an
49// 8 x 16 point cell at Retina scale 2. A reported cell outside 4 to 64 px
50// wide is not trusted.
51export const DEFAULT_CELL: Cell = { width: 16, height: 32 }
52// Part of every key: bump it when the interface with the template changes.
53// The template's inputs for the room of a drawing (all in pt): `height` the
54// room height now, `design-w` and `design-h` the design (the space at the
55// first compile of the text). Its live Typst blocks scale by
56// min(content width / design-w, height / design-h).
57export const TEMPLATE_VERSION = '9'
58// Part of the key of a picture with the row grid only: bump it when such a
59// picture or its bullet row changes, so the keys with the grid off stay.
60export const GRID_VERSION = '2'
61export const COMPILE_TIMEOUT_MS = 20000
62export const ALT_CHARS = 200
63// Where Typst is when the login shell's PATH does not find it. `~` is $HOME.
64export const TYPST_FALLBACKS = [
65 '/opt/homebrew/bin/typst', '/usr/local/bin/typst', '/usr/bin/typst', '/snap/bin/typst', '~/.cargo/bin/typst', '~/.local/bin/typst',
66 '/home/linuxbrew/.linuxbrew/bin/typst', '~/.linuxbrew/bin/typst',
67]
68export const NO_TYPST = 'Intelligent UI: install Typst (brew install typst) to see rendered answers'
69// How to install Typst: brew on macOS; on Linux, cargo or a release file
70// (docs/install.md has more ways).
71export const typstHintOf = (isMac: boolean) =>
72 isMac ? 'brew install typst' : 'cargo install --locked typst-cli, or a release from github.com/typst/typst/releases'
73export const noTypstOf = (isMac: boolean) => (isMac ? NO_TYPST : `Intelligent UI: install Typst (${typstHintOf(false)}) to see rendered answers`)
74// Typst downloads a package it doesn't find under --package-path; a proxy
75// nobody listens on makes that fail at once, so a live block never fetches.
76export const NO_NETWORK = 'http://127.0.0.1:9'
77// With Claude Code's theme on auto: how often the OS appearance is read, and
78// how long one read may take.
79export const HOST_POLL_MS = 3000
80export const HOST_CHECK_MS = 2000
81// Under herdr: how often the light client check runs (the app in front and
82// the known clients' tty times), and the most time between two full scans
83// (the process list: new or gone clients).
84export const HERDR_POLL_MS = 1000
85export const HERDR_SCAN_MS = 10000
86// Under herdr the OSC 11 probe (at the start and for a new client) reads the
87// pane's tty, which Claude Code reads too: a key typed then can go to the
88// probe. So it does not run within this time of a key in the prompt box.
89export const OSC11_QUIET_MS = 2000
90// Copy: how often the watcher reads the clipboard's change count (macOS;
91// one call in the watcher, no process), and the word positions of how
92// many pages are kept in memory (a copy probes only the pages it reads).
93export const CLIP_WAKE_MS = 300
94export const PROBED_KEPT = 64
95// The base every theme merges over (its theme.json and syntax files).
96export const BASE_THEME = 'vscode'
97// The theme used when none is chosen, or when the chosen one is not usable.
98export const DEFAULT_THEME = 'claude-code'
99// The two modes a theme has syntax colours for.
100export const MODES = ['dark', 'light'] as const
101// A picture under a row (an image Claude sends, an image the user pastes)
102// and a drawing's room: at most this many rows tall, and at most half the
103// terminal's rows.
104export const PICTURE_MAX_ROWS = 24
105export const PICTURE_MIN_ROWS = 6
106// `/iui height`: the most rows an image Claude sends or a Typst
107// drawing (the room of a live block or a local image in Markdown; not a
108// whole Markdown page) takes. `half`, the default: as above. `full`: the
109// terminal's rows less PICTURE_ROOM_ROWS. A number: that many rows, at most
110// as many as `full`. Never under PICTURE_MIN_ROWS.
111export type Height = number | 'half' | 'full'
112export const DEFAULT_HEIGHT: Height = 'half'
113export const MIN_HEIGHT = PICTURE_MIN_ROWS
114export const MAX_HEIGHT = 200
115// The rows an image leaves free: the row of the file, the text around it
116// and the prompt.
117export const PICTURE_ROOM_ROWS = 16
118// The height setting as words.
119export const heightText = (height: Height) =>
120 height === 'half' ? `half (half the terminal, ${PICTURE_MIN_ROWS} to ${PICTURE_MAX_ROWS} rows)`
121 : height === 'full' ? `full (the terminal height less ${PICTURE_ROOM_ROWS} rows)`
122 : `${height} rows`
123// The grid setting as words.
124export const gridText = (grid: boolean) => (grid ? 'on (each line of text on whole terminal rows)' : 'off')
125// `/iui select`: picture select (select.ts), on by default, from the oldest
126// Claude Code the mod supports (the Client, ui.message, ui.blit and ui.copy are there).
127export const SELECT_SINCE = '2.1.298'
128export const selectText = (on: boolean) => (on ? 'on (select and copy picture text with the mouse, fullscreen)' : 'off')
129export const PICTURES_KEPT = 64
130// The previews of pasted images (composer.tsx: the band and the rows): about
131// this many rows tall, this many columns apart, and a file not there yet
132// looked for again after PREVIEW_RETRY_MS, PREVIEW_TRIES times at most.
133export const PREVIEW_ROWS = 5
134export const PREVIEW_GAP = 2
135export const PREVIEW_RETRY_MS = 250
136export const PREVIEW_TRIES = 8
137// A pasted path (an `[Image #N]` with no file): the file looked for once
138// more after PATH_RECHECK_MS before the clipboard is read; a clipboard text
139// over PATH_TEXT_MAX chars, or with more than PATHS_MOST paths, is no path list.
140export const PATH_RECHECK_MS = 200
141export const PATH_TEXT_MAX = 4096
142export const PATHS_MOST = 20
143// The file paths of the link rows under a picture: whether each one is a
144// file, kept for this many paths (the oldest go).
145export const LINK_FILES_KEPT = 512
146// The compile results kept in memory (an LRU); the oldest go past this.
147export const RESULTS_KEPT = 1024
148// A resize or a font size change is settled when no new tty line and no
149// new viewport came for this long (one compile wave for each drag).
150export const RESIZE_QUIET_MS = 450
151// An epoch (a new target Frame, theme or setting) shows its pictures all at
152// once: when its jobs on screen are done, at least COMMIT_MIN_MS after it
153// began, or at COMMIT_MAX_MS whatever is still running.
154export const COMMIT_MIN_MS = 100
155export const COMMIT_MAX_MS = 5000
156// The cache folders and design files read are touched together this often
157// (one `touch -c` for all), not at each read.
158export const TOUCH_FLUSH_MS = 5000
159// A design file not read in this many days is deleted.
160export const DESIGNS_KEPT_DAYS = 180
161// A Markdown file Claude sends (SendUserFile) draws when it is at most this
162// many bytes.
163export const SENT_TEXT_MAX_BYTES = 256 * 1024
164// The PNG Typst makes of a JPEG, WebP or SVG (never made larger), or of a
165// PDF page, fits the space of the picture, and each side is at most this
166// many pixels.
167export const CONVERT_MAX_SIDE = 4096
168
169// What Claude is told while the mod draws its answers (a system prompt
170// section, added last).
171export const TYPST_PROMPT = `Your replies in this terminal are drawn as rendered Markdown pictures (VS Code preview look), so use Markdown freely: headings, tables, task lists, code blocks, images by absolute path, and LaTeX math (\`$…$\` inline, \`$$…$$\` for display).
172Make your answers visual where a picture explains more than words: draw a process, a structure, a timeline or data in a fenced \`\`\`typst block. It is rendered live with Typst 0.15 (full Typst markup; begin code lines with #). For what Markdown shows well, such as tables and lists, use Markdown.
173Each typst block compiles alone, with only \`theme\` in scope, so start it with the #import of each package it uses. These are available offline:
174#import "@preview/cetz:0.5.2"
175#import "@preview/cetz-plot:0.1.4": plot, chart
176#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge
177\`theme\` has: theme.fg, theme.muted, theme.link, theme.border, theme.accent, theme.bg, theme.code-bg, theme.red/green/blue/yellow/orange/purple, theme.palette (6 colours), theme.width (content width) and theme.height (the height a drawing should fit in), both lengths, theme.text-size, theme.sans and theme.mono (font lists), theme.dark (bool).
178Rules (the examples follow them):
179- Size: fit theme.width × theme.height; a taller drawing is not made smaller. The terminal is wide and short, so lay flows out left to right.
180- Colours: text already has theme.fg and the body font. Default strokes are black and invisible on a dark theme, so always pass theme colours as the examples do. Fill shapes with a theme colour \`.transparentize(80%)\`, colour data with theme.palette in order, use theme.muted for secondary text.
181- fletcher makes no space for edge labels and does not route edges around nodes: keep labels short, make each gap wider than its label, break a long label with \`\\\`, and go around nodes with corner points, not \`bend\`.
182- #page, #pagebreak and #set page are errors. A block that fails to compile shows as code.
183Only for a complex or unusual drawing (beyond these examples), or after a block failed, load the cc-intelligent-ui:intelligent-ui skill first; normal answers and drawings like these need no skill.
184A flow chart (fletcher; \`node-shape: rect\` keeps small nodes from becoming circles, \`stroke: none\` is a text-only node):
185\`\`\`typst
186#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge
187#diagram(
188 node-stroke: theme.fg, edge-stroke: theme.fg, node-shape: rect,
189 crossing-fill: theme.bg, spacing: (3em, 2em),
190 node((0, 0), [Build], fill: theme.blue.transparentize(80%), name: <b>),
191 node((1, 0), [Test], fill: theme.orange.transparentize(80%), name: <t>),
192 node((2, 0), [Deploy], fill: theme.green.transparentize(80%), name: <d>),
193 node((3, 0), [live], stroke: none, name: <l>),
194 edge(<b>, <t>, "->"),
195 edge(<t>, <d>, "->", [pass]),
196 edge(<d>, <l>, "-->"),
197 edge(<t>, (1, 1), (0, 1), <b>, "->", [fail], label-side: right),
198)
199\`\`\`
200A timeline (cetz; lay the drawing out in a W × H unit box, here 10 × 2):
201\`\`\`typst
202#import "@preview/cetz:0.5.2"
203#cetz.canvas(length: calc.min(theme.width / 10, theme.height / 2), {
204 import cetz.draw: *
205 set-style(stroke: theme.fg)
206 line((0, 0), (10, 0), mark: (end: ">"))
207 for (x, name, date, c) in ((2, [v1.0], [Jan], theme.blue), (7, [v2.0], [Oct], theme.green)) {
208 circle((x, 0), radius: 0.15, fill: c, stroke: none)
209 content((x, 0.5), name)
210 content((x, -0.5), text(fill: theme.muted, date))
211 }
212})
213\`\`\`
214A plot (cetz-plot; the axes, ticks and legend need the set-style line):
215\`\`\`typst
216#import "@preview/cetz:0.5.2"
217#import "@preview/cetz-plot:0.1.4": plot
218#cetz.canvas(length: calc.min(theme.width / 14, theme.height / 6), {
219 cetz.draw.set-style(axes: (stroke: theme.fg, tick: (stroke: theme.fg)), legend: (fill: theme.bg, stroke: theme.border))
220 plot.plot(size: (12, 4), x-label: [requests], y-label: [ms], legend: "inner-north-west", {
221 plot.add(((1, 20), (4, 90), (8, 190)), label: [no cache], style: (stroke: theme.palette.at(0)))
222 plot.add(((1, 20), (4, 30), (8, 38)), label: [cache], style: (stroke: theme.palette.at(1)))
223 })
224})
225\`\`\`
226A plan (the plan file you write in plan mode) is drawn the same way when ExitPlanMode asks for approval, so the same Markdown, $math$ and typst blocks work there.
227The stored text stays Markdown, so keep replies readable as plain text too.`
228hooks/lib/state.ts 399 lines1// Intelligent UI's state: the types the modules share, the values that change
2// (`S`), the caches, and refresh (the `done` atom).
3import { atom, update } from 'claude-code'
4import type { EngineInterface } from 'claude-code'
5
6import type { IuiTheme } from '../../types'
7import type { ThemeInfo } from './themes'
8import type { HostMode, Osc11Kept, ThemeSetting } from './mode'
9import type { PlanVerdict } from './plan'
10import type { Drawn } from './copy'
11import type { GridMetrics } from './grid'
12import type { Checked } from './updates'
13import type { ClipWay } from './clipboard'
14import type { PdfFacts, PixelBox } from './images'
15import type { HerdrClient, HerdrHost } from './terminal'
16import { DEFAULT_HEIGHT, DEFAULT_SIZE, DEFAULT_WIDTH, type Height, type Width } from './settings'
17import { redraw } from './api'
18
19// The `done` atom (see types/index.d.ts) for refresh. The state scan of
20// `claude plugin validate` reads an atom only from a const in the file that
21// reads or writes it, so each such file has a copy of its own (atom() is a
22// plain description: the copies are one value).
23const done = atom({ plugin: 'cc-intelligent-ui', key: 'done' } as const, 0)
24
25export type Cell = { width: number; height: number }
26// The terminal now (one TIOCGWINSZ read): its columns, rows and cell in
27// device pixels. A font size change is a change of the cell.
28export type Frame = { columns: number; rows: number; cell: Cell }
29// The drawing space in pt at the first compile of a text: the page width
30// (the template's `width`) and the room height. Its live Typst blocks scale from it.
31export type Design = { widthPt: number; heightPt: number }
32// The room for a drawing: its height in pt now, and the design of the text.
33// `designKey` names the design's file; `isNew`: no file yet (the design is
34// the space now, recorded once a final compile succeeds).
35export type Room = { heightPt: number; design: Design; designKey: string; isNew: boolean }
36// Per 1 em (render/measure.typ): `adv` the width of "0", `xh` the x-height,
37// `avg` the mean advance over a lowercase pangram.
38export type FontMetrics = { adv: number; xh: number; avg: number }
39// The theme's font lists (merged over vscode), and `plain`: its list
40// markers and numbering are ASCII (fonts.ts: the lean font set).
41export type LeanFonts = { body: string[]; code: string[]; plain: boolean }
42// `term` the terminal's font (null when unknown or not installed), `menlo`
43// the template's code font, `body` its text font, `grid` the row grid's
44// numbers (DEFAULT_GRID when not given), `fonts` the theme's font lists
45// (none: no lean font set), `termBold` whether Typst has a bold face of the
46// terminal's font (none: not known).
47export type Metrics = { term: FontMetrics | null; menlo: FontMetrics; body: FontMetrics; grid?: GridMetrics; fonts?: LeanFonts; termBold?: boolean }
48// The terminal's font is known and has no bold face: md.typ draws a fake bold (term-bold=0).
49export const fauxBoldOf = (m: Metrics) => !!m.term && m.termBold === false
50// Measured with Typst on macOS: Menlo and SF Pro Text.
51export const DEFAULT_METRICS: Metrics = {
52 term: null,
53 menlo: { adv: 0.602, xh: 0.547, avg: 0.602 },
54 body: { adv: 0.63, xh: 0.526, avg: 0.483 },
55}
56export type Env = {
57 typst: string | null
58 canDraw: boolean
59 // Over ssh: pictures go as `{ png }` bytes, not as a file name.
60 inline: boolean
61 // Under herdr: pages are cut smaller (herdrBudget), so herdr sends them.
62 herdr?: boolean
63 cache: string
64 cwd: string
65 root: string
66 isMac: boolean
67 stamp: string
68 home: string
69 // Claude Code's temp folder of this user (claudeTmpOf; '' when not known).
70 claudeTmp: string
71 // The terminal's font family ('' when not known) and the fonts' metrics.
72 termFont: string
73 metrics: Metrics
74 // The designs folder (designs.ts), outside the 2-day tmp cleanup.
75 designs: string
76}
77// `columns` and `rows`: the cell box the page was compiled for (it is drawn
78// at that box, never stretched). `flip`: stored for flip mode (flip.ts).
79export type Page = { file: string; width: number; height: number; alt: string; columns: number; rows: number; flip?: boolean }
80export type Result = { pages: Page[]; bulletRow: number; note?: string }
81export type Job = {
82 key: string
83 requestId: string
84 text: string
85 columns: number
86 cell: Cell
87 theme: IuiTheme
88 size: number
89 // `/iui grid`: each text line on the terminal's row grid.
90 grid: boolean
91 themeDir: string
92 env: Env
93 // The folder relative images start from (a Markdown file's); else env.cwd.
94 base?: string
95 // The room for its drawings (a text with a live Typst block or a local image).
96 room?: Room
97 // Flip mode (herdr in iTerm2): its pages are stored flipped (flip.ts).
98 flip?: boolean
99 // The epoch (S.epoch) it was enqueued in: only the open epoch's jobs hold
100 // its commit (set by enqueue).
101 epoch: number
102 // The queue's order: a row on screen first, then the newest (`order`, set
103 // by enqueue). `onScreen` false: the row is known to be off screen (it
104 // does not hold the commit); undefined: not known.
105 onScreen?: boolean
106 order?: number
107}
108// The active theme: its name, folder, and the mtimes of its files and the
109// base theme's, which are part of every key.
110export type Look = { name: string; dir: string; stamp: string }
111
112// Module state: lost on a hot reload, which is fine. The PNGs stay on disk
113// with a pages.json beside them, so a key compiled before is read back.
114// The values that change are in one object, so every module sees the same.
115export const S = {
116 env: null as Env | null,
117 envLoading: null as Promise<Env> | null,
118 // The target: the terminal now, settled (the tty's Frame with its columns
119 // at most the viewport's). Keys and compiles use it. Only frame.ts and
120 // beginEpoch set it. Null until the first read.
121 frame: null as Frame | null,
122 // The Frame whose pictures are on screen: the target once its epoch
123 // committed (all its pictures swap in one redraw). Pictures size from it.
124 shown: null as Frame | null,
125 // The epoch: one for each new target (a settled resize, a setting, a mode).
126 // It is open while `epoch` is not `shownEpoch` (epochOpen).
127 epoch: 0,
128 shownEpoch: 0,
129 // A burst of tty lines or viewports is not quiet yet: no job starts, and
130 // the epoch does not commit. `missed`: a job ended while it was so.
131 resizing: false,
132 missed: false,
133 // The last good tty read and the last viewport a render hook saw, not
134 // settled yet (frame.ts settles them into `frame`).
135 tty: null as Frame | null,
136 viewColumns: null as number | null,
137 viewRows: null as number | null,
138 // The terminal's layout (a viewport's isFullscreen; fixed per session),
139 // null before a render hook says.
140 isFullscreen: null as boolean | null,
141 // The fullscreen advice was shown (once, on the main screen).
142 fullscreenTold: false,
143 // Why there is no cache folder ('' when there is one): nothing is drawn.
144 cacheProblem: '',
145 // The key folders in the cache at setUp: a key found there is read back
146 // at once (a reopened session), with no queue and no blank.
147 onDisk: new Set<string>(),
148 theme: 'dark' as IuiTheme,
149 size: DEFAULT_SIZE,
150 // `/iui width`: the most columns a picture takes, or full.
151 width: DEFAULT_WIDTH as Width,
152 // `/iui height`: the most rows an image or a Typst drawing takes (half, full or a number).
153 height: DEFAULT_HEIGHT as Height,
154 // The height setting of the pictures on screen: `height` once its epoch
155 // committed (with `shown`), so an image box changes in one step.
156 shownHeight: DEFAULT_HEIGHT as Height,
157 // `/iui grid`: each text line of a page on whole terminal rows, so a
158 // mouse selection (whole cells) covers the text it copies. On by default.
159 grid: true,
160 // `/iui select`: picture select (select.ts), on by default; and Claude
161 // Code's version ('' not known), read in setUp.
162 select: true,
163 engine: '',
164 // `/iui editor`: what a click on a file opens it in (open.ts: parseEditor),
165 // `auto` by default; the known editor `auto` found (null: not looked up
166 // yet; '': none); and the paths of programs on the PATH by name ('': none).
167 editor: 'auto',
168 autoEditor: null as string | null,
169 onPath: new Map<string, string>(),
170 look: null as Look | null,
171 // The usable themes the last scan found, for the typeahead.
172 themeChoices: [] as ThemeInfo[],
173 // Claude Code's theme setting as set (`settingRaw`) and read (`setting`); with
174 // auto, `host` is the OS appearance last read and `watch` the poll timer. A
175 // hot reload cancels the old module's timers (`$.clock` docs), so `watch`
176 // starts empty with the module and at most one poll runs (`checking`).
177 setting: 'dark' as ThemeSetting,
178 settingRaw: '',
179 host: null as HostMode | null,
180 hostOs: '',
181 colorFgBg: '',
182 termProgram: '',
183 termName: '',
184 itermProfile: undefined as string | undefined,
185 // kitty: TERM=xterm-kitty, or its KITTY_WINDOW_ID / KITTY_PID (set in setUp; setOuter).
186 isKitty: false,
187 // iTerm2 (isItermIn) and cmux (Ghostty inside, with its CMUX_* variables), set in setUp (cmux: setOuter).
188 isIterm: false,
189 isCmux: false,
190 // herdr (isHerdrIn), its version ('' not known), the client in use
191 // (pickHerdrClient: in the terminal app in front, or typed in last; null:
192 // none found, so the server's variables count) and how it was chosen, the
193 // terminal it runs in ('' outside herdr or not known; setOuter), all set
194 // in setUp and kept by the poll; and the last OSC 11 answer of its pane
195 // (keptOsc11) and the probes still allowed (OSC11_TRIES: the start or a new client).
196 isHerdr: false,
197 herdrVersion: '',
198 herdrClient: null as HerdrClient | null,
199 herdrBy: 'typed' as 'front' | 'typed',
200 herdrHost: '' as HerdrHost,
201 // The clients, the app in front and the time of the last full scan (null: none yet), for the light check.
202 herdrClients: [] as HerdrClient[],
203 herdrScanFront: null as string | null,
204 herdrScanAt: null as number | null,
205 // Polls under herdr since the last mode read (it runs every HOST_POLL_MS).
206 hostTicks: 0,
207 osc11Kept: null as Osc11Kept,
208 osc11Tries: 0,
209 // A font folder for Typst (TYPST_FONT_PATHS): the font a terminal carries
210 // in its program (Ghostty's, Rio's), when it draws it. '' for none.
211 fontPaths: '',
212 // The user's own TYPST_FONT_PATHS (set in setUp); fontPaths goes after it.
213 userFontPaths: '',
214 // Over ssh (set in setUp): the terminal's config and the OS appearance here
215 // are the remote computer's, not those of the user's screen.
216 isRemote: false,
217 // IUI_DEBUG is set (setUp): trace lines go to the debug log.
218 debug: false,
219 // iTerm2 or herdr with the engine's pictures off at start: /iui setup can help
220 // (`setupNeeded`); the question is asked once per load (`setupAsked`).
221 setupNeeded: false,
222 setupAsked: false,
223 watch: null as { cancel: () => void } | null,
224 // The poll timer's period (watchHost).
225 watchMs: 0,
226 // herdr's client check is of use: herdr, with pictures drawn (setUp), and
227 // the mod not turned off (/iui off, or off when it starts). The poll ends
228 // with the session (stopHost).
229 herdrDraws: false,
230 isOff: false,
231 hostEnded: false,
232 // The clock time of the last key in the prompt box (register.tsx; OSC11_QUIET_MS).
233 keyAt: Number.NEGATIVE_INFINITY,
234 checking: false,
235 // Linux: the tool that reads the desktop portal's colour scheme (host.ts;
236 // null: not tried yet, '': none works, so GNOME's gsettings is read).
237 portalTool: null as 'gdbus' | 'busctl' | '' | null,
238 // Linux: how the clipboard is read and watched (clipboard.ts; null: not
239 // looked up, as on macOS and over ssh).
240 clipWay: null as ClipWay | null,
241 // Streaming: model requests of the main loop in flight.
242 stepsLive: 0,
243 // The plan (plan.tsx): the main loop's running turn (`turn.start`), the
244 // verdict the mod's question gave for the PermissionRequest that follows
245 // (null: none, the engine's dialog asks), the main loop's ExitPlanMode
246 // calls in flight (a verdict is used only while one runs), the question
247 // asked now ('': none), and the `code` command's path (null: not looked
248 // up yet; '': none), and on Linux a GUI editor that waits (its path and
249 // flag; [] none).
250 mainTurnId: '',
251 planVerdict: null as PlanVerdict | null,
252 plansLive: 0,
253 planAsking: '',
254 planCode: null as string | null,
255 planGui: null as string[] | null,
256 // The update check (updates.ts), read once per load (`updatesLoading`):
257 // `/iui updates` (on by default), the variable that turns the check off
258 // ('' none), this mod's version ('' not known) and the last check (null:
259 // none stored). `updateDone`: this load's check ran or was not due;
260 // `updateBusy`: one runs now.
261 updates: true,
262 updatesOff: '',
263 ownVersion: '',
264 updateChecked: null as Checked | null,
265 updatesLoading: null as Promise<void> | null,
266 updateDone: false,
267 updateBusy: false,
268 // The `[Image #N]` numbers last written to the `draft` atom (register.tsx;
269 // null: none written in this load).
270 draftImages: null as number[] | null,
271}
272// The settings an /iui command set in this load (`size`, `enabled`, ...): the
273// start (setUp) does not read them from the store, which may answer with the
274// value from before the command.
275export const settingsSet = new Set<string>()
276// The compile results by key (an LRU: set with `keep` at RESULTS_KEPT).
277export const results = new Map<string, Result | 'failed'>()
278// Streaming: model requests of the main loop in flight (`S.stepsLive`), the
279// text each block last drew, the blocks drawn blank while their text streams
280// (with the job to compile once final), and the blocks a request made final
281// (they stay final). The texts and the final ones go past RESULTS_KEPT, as
282// `results` (an id gone draws as one not seen yet).
283export const lastText = new Map<string, string>()
284export const live = new Map<string, { job: Job; still: { cancel: () => void } }>()
285export const finalized = new Set<string>()
286// A final block not drawn yet: blank until `showText` (its deadline passed).
287export const pending = new Map<string, { textHash: string; showText: boolean; timer: { cancel: () => void } }>()
288// Called once when the next compile ends, cancelled or not (the plan waits on it).
289export const jobEnds = new Set<() => void>()
290// The jobs waiting, and the jobs that compile now by key (with the cancel
291// of each: beginEpoch stops them; and its signal: an entry cancelled still
292// holds its slot until its compile ends, but its key may be enqueued again).
293export const queue: Job[] = []
294export const running = new Map<string, { job: Job; cancel: () => void; signal: AbortSignal }>()
295// An epoch is open: its target is not on screen yet.
296export const epochOpen = () => S.epoch !== S.shownEpoch
297// The key last drawn per message (and its text), so a new size keeps the old
298// picture (at its own box) until the new one is ready instead of the text.
299// `job`: the Job of that key as it drew (its env as then, no text), for the
300// probe of a copy. The oldest go past RESULTS_KEPT, as `results`.
301export const lastKey = new Map<string, { key: string; textHash: string; job: Job }>()
302// Over ssh: each page file's PNG as base64 (null: too large to send), read
303// once so a redraw does not read it again; the oldest go past INLINE_KEPT.
304export const inlined = new Map<string, string | null>()
305export const INLINE_KEPT = 64
306// Pictures under rows: each file's PNG size by its stat stamp (null: not a
307// PNG), and the pasted-images folder of each session once found.
308export const pictures = new Map<string, { stamp: string; size: { width: number; height: number } | null }>()
309export const pasteDirs = new Map<string, string>()
310// SendUserFile: the PNG Typst made of each JPEG, WebP or SVG, or PDF page,
311// by its file, stat stamp, box and page (null: it failed), the ones Typst
312// makes now (same keys), the last PNG shown of each file (a PDF's by its
313// row; any box or page: it draws while a new box is made), and the text of
314// each Markdown file by its stat stamp (null: it does not draw).
315export const converted = new Map<string, string | null>()
316export const converting = new Map<string, Promise<string | null>>()
317export const lastConverted = new Map<string, { stamp: string; png: string }>()
318export const sentTexts = new Map<string, { stamp: string; text: string | null }>()
319// SendUserFile PDFs: what is known of each file's pages (by `<file>@<stat
320// stamp>`), and the page each row shows of each PDF (by
321// `<tool_use_id>\n<path>`; page 1 when not set). In memory only.
322export const pdfFacts = new Map<string, PdfFacts>()
323export const pdfPages = new Map<string, number>()
324// The page field (pictures.tsx): the row and PDF it is for, the box of its
325// picture, the text it holds and its note (''); null while it is closed.
326export const pageAsk = {
327 now: null as { row: string; file: string; box: PixelBox; value: string; note: string } | null,
328}
329// The link rows (draw.tsx): whether each absolute path is a file (at most
330// LINK_FILES_KEPT).
331export const linkFiles = new Map<string, boolean>()
332// The plan: the text edited in Intelligent UI's flow, by the call's tool_use_id
333// (its rows draw it), and the ExitPlanMode calls of subagents (drawn as the
334// engine draws them).
335export const editedPlans = new Map<string, string>()
336export const subagentPlans = new Set<string>()
337// The main loop's Write and Edit calls still running, by file path (the plan
338// waits for its file's).
339export const writesLive = new Map<string, number>()
340
341// Copy (copy.ts, probe.ts): the pages drawn this session by their id (an
342// answer's requestId, `plan:<tool_use_id>`, `file:<tool_use_id>:<path>`;
343// oldest first, at most DRAWN_KEPT), each with the Job that drew it (the
344// probe's inputs; none when not known).
345export const drawnAnswers = new Map<string, Drawn & { job?: Job }>()
346export const DRAWN_KEPT = 64
347// The copy gate's hook for a mod copy no ui.copy hook sees (register.tsx sets it).
348export const ownCopy = { note: (_text: string) => {} }
349
350// The clipboard (register.tsx): the change count last seen (null: none
351// yet; the first of each watcher child is only noted), whether the watcher
352// found no PerlObjCBridge (`none`), a read that runs (`busy`) and a change
353// that came during it (`again`), the clipboard text last seen (null: none
354// could be read) and last written (so a write is never read back as new),
355// the last text with placeholders with its row (`rawRow`, when known) and
356// how it was read (for dump), and the order of the drawings (`seq`).
357export const clip = {
358 count: null as number | null,
359 none: false,
360 busy: false,
361 again: false,
362 seen: '' as string | null,
363 wrote: '',
364 raw: '',
365 rawRow: undefined as string | undefined,
366 seq: 0,
367 last: null as unknown,
368}
369
370// The lean font sets (fonts.ts) by the fonts they are for (Typst, the
371// terminal font and the theme's lists): the folder once made ('' while it
372// is made, or when the lean set is not used for these fonts).
373export const leanDirs = new Map<string, string>()
374
375// Sets a cache entry as the newest; the oldest go past `max`.
376export function keep<T>(cache: Map<string, T>, key: string, value: T, max: number) {
377 cache.delete(key)
378 cache.set(key, value)
379 for (const old of cache.keys()) {
380 if (cache.size <= max) break
381 cache.delete(old)
382 }
383}
384
385// The same for a set: `key` as the newest; the oldest go past `max`.
386export function keepIn(set: Set<string>, key: string, max: number) {
387 set.delete(key)
388 set.add(key)
389 for (const old of set) {
390 if (set.size <= max) break
391 set.delete(old)
392 }
393}
394
395// Redraws every reader of `done`; an invalidate where the write is refused.
396export function refresh($: EngineInterface) {
397 void update($, done, n => n + 1).catch(() => redraw($, 'ui.render'))
398}
399hooks/lib/epoch.ts 128 lines1// The epoch: one for each new target (a settled resize, a font size change,
2// a setting, the light or dark mode). It stops the compiles of the old
3// target, and its pictures swap in all at once: a job of the open epoch that
4// ends does not redraw; the epoch commits (S.shown = S.frame, with the
5// height setting of the target, one redraw) when its jobs on screen are done. A burst (a drag not quiet yet) holds the
6// jobs and the commit.
7import type { EngineInterface } from 'claude-code'
8
9import { COMMIT_MAX_MS, COMMIT_MIN_MS } from './settings'
10import { converting, epochOpen, queue, refresh, running, S, type Frame, type Job } from './state'
11import { traceDebug } from './log'
12import { delay } from './api'
13
14// The open epoch: `ready` once COMMIT_MIN_MS passed with no new activity
15// (a row drawn, a job enqueued: extendEpoch), `overdue` once COMMIT_MAX_MS
16// passed since it began (it commits with jobs still running). A new epoch
17// has new flags and cancels the old timers.
18let ready = false
19let overdue = false
20let minTimer: { cancel: () => void } | null = null
21let maxTimer: { cancel: () => void } | null = null
22// For the trace: when the open epoch began, and the jobs enqueued in it.
23let begunAt = 0
24let jobsIn = 0
25
26// Begins a new epoch for `frame` (else the target now): stops every compile
27// that runs (it does not wait for them), empties the queue, sets the target
28// and redraws once. The rows draw their old pictures and enqueue the target.
29export function beginEpoch($: EngineInterface, frame?: Frame | null) {
30 S.resizing = false
31 S.missed = false
32 const cancelled = running.size
33 const dropped = queue.length
34 for (const { cancel } of running.values()) {
35 try {
36 cancel()
37 } catch {}
38 }
39 queue.length = 0
40 const target = frame ?? S.frame
41 if (target) S.frame = { ...target }
42 const epoch = ++S.epoch
43 begunAt = Date.now()
44 jobsIn = 0
45 const f = S.frame
46 traceDebug($, `epoch ${epoch} begin: target ${f ? `${f.columns}x${f.rows} cell ${f.cell.width}x${f.cell.height}` : 'none'}; cancelled ${cancelled} running, dropped ${dropped} queued`)
47 ready = false
48 overdue = false
49 maxTimer?.cancel()
50 maxTimer = delay($, COMMIT_MAX_MS, () => {
51 if (S.epoch !== epoch) return
52 overdue = true
53 maybeCommit($)
54 })
55 armReady($)
56 refresh($)
57}
58
59// The rows of the open epoch still come (each draws again after the
60// epoch's redraw, after round trips to the host): its COMMIT_MIN_MS starts
61// again, so a row drawn late still swaps at the commit. COMMIT_MAX_MS still
62// counts from the begin. Nothing once it is ready.
63export function extendEpoch($: EngineInterface) {
64 if (epochOpen() && !ready) armReady($)
65}
66
67// A job was enqueued (for the trace).
68export function jobQueued(job: Job) {
69 if (job.epoch === S.epoch && epochOpen()) jobsIn++
70}
71
72// (Re)starts the open epoch's COMMIT_MIN_MS timer.
73function armReady($: EngineInterface) {
74 const epoch = S.epoch
75 minTimer?.cancel()
76 minTimer = delay($, COMMIT_MIN_MS, () => {
77 if (S.epoch !== epoch) return
78 ready = true
79 maybeCommit($)
80 })
81}
82
83// A burst began: no job starts and the epoch does not commit until
84// releaseJobs.
85export function holdJobs() {
86 S.resizing = true
87}
88
89// The burst ended with no new target: a job that ended during it redraws
90// now (with no epoch open), and a commit that came due happens now.
91export function releaseJobs($: EngineInterface) {
92 S.resizing = false
93 if (S.missed && !epochOpen()) refresh($)
94 S.missed = false
95 maybeCommit($)
96}
97
98// A job ended (its result is stored): during a burst it is noted; in an open
99// epoch the epoch may commit; else its row redraws.
100export function jobDone($: EngineInterface) {
101 if (S.resizing) S.missed = true
102 else if (epochOpen()) maybeCommit($)
103 else refresh($)
104}
105
106// A job holds the commit when it is of the open epoch and its row is not
107// known to be off screen.
108const holds = (job: Job) => job.epoch === S.epoch && job.onScreen !== false
109
110// Commits the open epoch when no burst runs and it is overdue, or it is
111// ready and no job of it on screen waits or runs and no JPEG, WebP, SVG or
112// PDF page converts.
113function maybeCommit($: EngineInterface) {
114 if (S.resizing || !epochOpen()) return
115 if (!overdue) {
116 if (!ready || converting.size > 0 || queue.some(holds)) return
117 for (const { job } of running.values()) if (holds(job)) return
118 }
119 if (S.debug) {
120 const left = (jobs: Iterable<Job>) => [...jobs].filter(job => job.epoch === S.epoch).length
121 traceDebug($, `epoch ${S.epoch} commit after ${Date.now() - begunAt} ms (${overdue ? 'overdue' : 'ready'}): ${jobsIn} jobs queued, ${left(queue)} waiting, ${left([...running.values()].map(r => r.job))} running, ${converting.size} converting`)
122 }
123 S.shown = S.frame
124 S.shownHeight = S.height
125 S.shownEpoch = S.epoch
126 refresh($)
127}
128hooks/lib/mode.ts 113 lines1// Light or dark: Claude Code's theme setting, the OS appearance, COLORFGBG,
2// a colour's luminance and the terminal's OSC 11 answer (pure; the reads on
3// the host are in host.ts).
4import type { IuiTheme } from '../../types'
5
6export function themeOf(value: unknown): IuiTheme {
7 return typeof value === 'string' && /light/i.test(value) ? 'light' : 'dark'
8}
9
10// Claude Code's theme setting: dark, light, dark-daltonized, light-ansi, …
11// are fixed; `auto`, or any other text, follows the OS. No value is Claude
12// Code's default, dark.
13export type ThemeSetting = IuiTheme | 'auto'
14export function settingOf(value: unknown): ThemeSetting {
15 if (typeof value !== 'string' || !value.trim()) return 'dark'
16 if (/light/i.test(value)) return 'light'
17 if (/dark/i.test(value)) return 'dark'
18 return 'auto'
19}
20
21// The OS appearance, and where it was read.
22export type HostMode = {
23 mode: IuiTheme
24 source: 'terminal' | 'herdr' | 'iTerm2' | 'Ghostty' | 'cmux' | 'kitty' | 'Rio' | 'macOS' | 'GNOME' | 'desktop portal' | 'COLORFGBG'
25 detail?: string
26}
27
28// `defaults read -g AppleInterfaceStyle`: "Dark" in dark mode; in light mode
29// the key does not exist and the command fails.
30export function modeOfMacAppearance(exitCode: number, stdout: string): IuiTheme {
31 return exitCode === 0 && /^\s*dark\s*$/i.test(stdout) ? 'dark' : 'light'
32}
33
34// `gsettings get org.gnome.desktop.interface color-scheme`: 'prefer-dark',
35// 'prefer-light' or 'default'; null for anything else.
36export function modeOfGnome(stdout: string): IuiTheme | null {
37 const v = stdout.trim().replace(/^'|'$/g, '')
38 if (v === 'prefer-dark') return 'dark'
39 if (v === 'prefer-light' || v === 'default') return 'light'
40 return null
41}
42
43// The freedesktop portal's `org.freedesktop.appearance color-scheme`
44// (Settings.Read), as gdbus prints it (`(<<uint32 1>>,)`) or busctl (`v v u
45// 1`): 1 dark, 2 light, 0 no preference; null for anything else.
46export function schemeOfPortal(stdout: string): 'dark' | 'light' | 'no-preference' | null {
47 const m = /(?:\buint32|\bu)\s+(\d+)\b/.exec(stdout)
48 const v = m ? Number(m[1]) : -1
49 return v === 1 ? 'dark' : v === 2 ? 'light' : v === 0 ? 'no-preference' : null
50}
51
52// COLORFGBG, "fg;bg" or "fg;x;bg": the last field is the background colour
53// index. 0-6 and 8 are dark, 7 and 15 light; anything else is not known.
54export function modeOfColorFgBg(value: string | undefined): IuiTheme | null {
55 const last = (value ?? '').split(';').pop()?.trim() ?? ''
56 if (!/^\d+$/.test(last)) return null
57 const bg = Number(last)
58 if ((bg >= 0 && bg <= 6) || bg === 8) return 'dark'
59 if (bg === 7 || bg === 15) return 'light'
60 return null
61}
62
63// The mode to draw: a fixed setting as set; auto as the OS is, dark when it
64// was never read.
65export function resolveMode(setting: ThemeSetting, seen: HostMode | null): IuiTheme {
66 return setting === 'auto' ? (seen?.mode ?? 'dark') : setting
67}
68
69// What the status line says about the mode.
70export function modeLine(mode: IuiTheme, setting: ThemeSetting, raw: string, seen: HostMode | null): string {
71 if (setting !== 'auto') return `mode ${mode} (Claude Code theme ${raw || mode})`
72 return `mode ${mode} (auto, ${seen ? `from ${seen.source}${seen.detail ? `: ${seen.detail}` : ''}` : 'OS appearance not known, dark assumed'})`
73}
74
75export type Rgb = { r: number; g: number; b: number }
76// Relative luminance of an sRGB colour (components 0..1).
77export function luminanceOf(c: Rgb): number {
78 const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
79 return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
80}
81export const modeOfLuminance = (l: number): IuiTheme => (l > 0.4 ? 'light' : 'dark')
82
83// A terminal's answer to OSC 11 (`ESC ] 11 ; ?`), the query Claude Code
84// itself sends: `ESC ] 11 ; rgb:R/G/B` with 1-4 hex digits per channel,
85// ended by BEL or ST (`ESC \`). The colour in 0..1, or null.
86export function parseOsc11(text: string): Rgb | null {
87 const m = /\x1b\]11;rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\/[0-9a-f]{1,4})?(?:\x07|\x1b\\)/i.exec(text)
88 if (!m) return null
89 const part = (h: string) => parseInt(h, 16) / (16 ** h.length - 1)
90 return { r: part(m[1]!), g: part(m[2]!), b: part(m[3]!) }
91}
92
93// Under herdr, the OSC 11 answer (the background of the terminal in front).
94// The probe reads the tty that Claude Code reads, so it runs only at the
95// start and for a new herdr client, at most OSC11_TRIES times (an answer
96// can go to Claude Code). `answer`: a probe's mode (undefined: no probe this
97// time); `base`: the mode of the other sources now. The answer stays while
98// those give the mode they gave when it came (null: not known, no change);
99// a change there (the OS, the terminal's config) wins.
100export const OSC11_TRIES = 3
101export type Osc11Kept = { seen: HostMode; base: IuiTheme | null } | null
102export function keptOsc11(last: Osc11Kept, base: HostMode | null, answer?: HostMode | null): { kept: Osc11Kept; use: HostMode | null } {
103 if (answer) return { kept: { seen: answer, base: base?.mode ?? null }, use: answer }
104 if (!last) return { kept: null, use: base }
105 if (!base) return { kept: last, use: last.seen }
106 if (last.base === null) return { kept: { seen: last.seen, base: base.mode }, use: last.seen }
107 return last.base === base.mode ? { kept: last, use: last.seen } : { kept: null, use: base }
108}
109
110// Control characters shown as text (\e, \a, \xNN), for /iui probe.
111export const escapeControls = (text: string) =>
112 text.replace(/[\x00-\x1f\x7f]/g, c => (c.charCodeAt(0) === 27 ? '\\e' : c.charCodeAt(0) === 7 ? '\\a' : `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`))
113hooks/lib/themes.ts 163 lines1// Themes: a theme folder's theme.json, the built-in and user themes, the one
2// to draw with, what `/iui theme` lists, and the scan on the host.
3import type { EngineInterface } from 'claude-code'
4
5import { BASE_THEME, DEFAULT_THEME, MODES } from './settings'
6import { S } from './state'
7import { exists, ls, cat, stat } from './api'
8
9// A theme folder's name: letters, digits, `.`, `_`, `-`.
10export const isThemeName = (name: string) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
11
12// `syntax`: the tmTheme file theme.json names for a mode, relative to the
13// theme folder; a mode it does not name uses the base theme's file.
14export type ThemeSyntax = Partial<Record<(typeof MODES)[number], string>>
15export type ThemeInfo = { name: string; dir: string; isUser: boolean; title: string; description: string; syntax: ThemeSyntax }
16export type ThemeProblem = { name: string; dir: string; isUser: boolean; reason: string }
17
18// One theme folder from its theme.json text: usable, or why not.
19export function themeEntry(name: string, dir: string, isUser: boolean, json: string): ThemeInfo | ThemeProblem {
20 if (!isThemeName(name)) return { name, dir, isUser, reason: 'the folder name has characters other than letters, digits, ., _ and -' }
21 let data: unknown
22 try {
23 data = JSON.parse(json)
24 } catch (err) {
25 return { name, dir, isUser, reason: `theme.json is not valid JSON (${err instanceof Error ? err.message : String(err)})` }
26 }
27 if (!data || typeof data !== 'object' || Array.isArray(data)) return { name, dir, isUser, reason: 'theme.json is not a JSON object' }
28 const field = (key: string) => {
29 const v = (data as Record<string, unknown>)[key]
30 return typeof v === 'string' ? v : ''
31 }
32 const named = (data as Record<string, unknown>).syntax
33 const syntax: ThemeSyntax = {}
34 if (named !== undefined) {
35 if (!named || typeof named !== 'object' || Array.isArray(named)) return { name, dir, isUser, reason: '"syntax" is not an object of file names' }
36 for (const mode of MODES) {
37 const file = (named as Record<string, unknown>)[mode]
38 if (file === undefined) continue
39 if (!isSyntaxPath(file)) return { name, dir, isUser, reason: `"syntax.${mode}" must be a file name in the theme folder (no "/" at the start, no "..")` }
40 syntax[mode] = file
41 }
42 }
43 return { name, dir, isUser, title: field('name') || name, description: field('description'), syntax }
44}
45
46// A tmTheme path in theme.json: relative to the theme folder and inside it.
47export const isSyntaxPath = (file: unknown): file is string =>
48 typeof file === 'string' && file.trim() !== '' && !file.startsWith('/') && !file.includes('\\') && !file.split('/').includes('..')
49
50// The files a theme's pictures depend on: its theme.json, the base
51// theme.json it merges over, and for each mode the tmTheme it names, else
52// the base theme's. A missing `theme` (not found yet) counts as the base.
53export function themeFiles(theme: ThemeInfo | undefined, base: ThemeInfo | undefined, root: string): string[] {
54 const baseDir = base?.dir ?? `${root}/render/themes/${BASE_THEME}`
55 const files = [`${theme?.dir ?? baseDir}/theme.json`, `${baseDir}/theme.json`]
56 for (const mode of MODES) {
57 const own = theme?.syntax[mode]
58 files.push(own ? `${theme!.dir}/${own}` : `${baseDir}/${base?.syntax[mode] ?? `${mode}.tmTheme`}`)
59 }
60 return files
61}
62
63// Built-in themes, then user themes: a usable user theme replaces the
64// built-in of the same name; a broken one is only reported.
65export function pickThemes(found: readonly (ThemeInfo | ThemeProblem)[]): { themes: Map<string, ThemeInfo>; problems: ThemeProblem[] } {
66 const themes = new Map<string, ThemeInfo>()
67 const problems: ThemeProblem[] = []
68 for (const isUser of [false, true]) {
69 for (const one of found) {
70 if (one.isUser !== isUser) continue
71 if ('reason' in one) problems.push(one)
72 else themes.set(one.name, one)
73 }
74 }
75 return { themes, problems }
76}
77
78// The theme to draw with: the wanted one, else the default theme, else the
79// base theme (`isFallback`). The base theme's own folder is used even before
80// it is found on disk.
81export function chooseTheme(themes: ReadonlyMap<string, ThemeInfo>, wanted: string, root: string): { name: string; dir: string; isFallback: boolean } {
82 const got = themes.get(wanted)
83 if (got) return { name: got.name, dir: got.dir, isFallback: false }
84 const fallback = themes.get(DEFAULT_THEME)
85 if (fallback) return { name: fallback.name, dir: fallback.dir, isFallback: true }
86 const base = themes.get(BASE_THEME)
87 return { name: BASE_THEME, dir: base?.dir ?? `${root}/render/themes/${BASE_THEME}`, isFallback: wanted !== BASE_THEME }
88}
89
90// What `/iui theme` lists. The output shows as Markdown, so the theme in use
91// is marked in words (a `*` would turn into a list bullet), and the home
92// folder shows as `~`.
93export function themeListing(themes: ReadonlyMap<string, ThemeInfo>, problems: readonly ThemeProblem[], active: string, userDir: string, home = ''): string {
94 const marks = (t: { name: string; isUser: boolean }) => [t.isUser ? 'yours' : '', t.name === active ? 'in use' : ''].filter(Boolean).join(', ')
95 const item = (t: ThemeInfo) => `- ${t.name}${marks(t) ? ` (${marks(t)})` : ''}${t.description ? `: ${t.description}` : ''}`
96 const lines = [...themes.values()].sort((a, b) => a.name.localeCompare(b.name)).map(item)
97 const skipped = problems.map(p => `- ${p.name}${p.isUser ? ' (yours)' : ''}: not usable, ${p.reason}`)
98 const dir = home && userDir.startsWith(`${home}/`) ? `~${userDir.slice(home.length)}` : userDir
99 return [
100 'Themes:',
101 ...(lines.length ? lines : ['- none found']),
102 ...skipped,
103 '',
104 `Change it with /iui theme <name>. Your own themes go in ${dir}/<name>/theme.json.`,
105 ].join('\n')
106}
107
108// The built-in themes under render/themes and the user's under
109// ~/.claude/cc-intelligent-ui/themes, read fresh each call.
110export async function scanThemes($: EngineInterface, root: string, home: string) {
111 const found: (ThemeInfo | ThemeProblem)[] = []
112 for (const [base, isUser] of [[`${root}/render/themes`, false], [userThemesOf(home), true]] as const) {
113 const entries = await ls($, base).catch(() => [])
114 for (const entry of entries) {
115 if (entry.kind === 'file') continue
116 const dir = `${base}/${entry.name}`
117 const json = await cat($, `${dir}/theme.json`).catch(() => null)
118 if (json === null) continue
119 const one = themeEntry(entry.name, dir, isUser, json)
120 const lost = 'reason' in one ? [] : await missingSyntax($, one)
121 found.push(lost.length ? { name: one.name, dir, isUser, reason: `theme.json names syntax files that do not exist: ${lost.join(', ')}` } : one)
122 }
123 }
124 return pickThemes(found)
125}
126
127// The syntax files a theme names that are not on disk (they fail the compile).
128export async function missingSyntax($: EngineInterface, theme: ThemeInfo) {
129 const lost: string[] = []
130 for (const mode of MODES) {
131 const file = theme.syntax[mode]
132 if (file && !(await exists($, `${theme.dir}/${file}`).catch(() => false))) lost.push(file)
133 }
134 return lost
135}
136
137export const userThemesOf = (home: string) => `${home || '~'}/.claude/cc-intelligent-ui/themes`
138
139// The mtimes of the files a theme's pictures depend on (themeFiles): a
140// change to any of them makes new keys.
141export async function stampTheme($: EngineInterface, files: readonly string[]): Promise<string> {
142 const times: string[] = []
143 for (const file of files) {
144 const st = await stat($, file).catch(() => null)
145 times.push(String(st?.mtimeMs ?? 0))
146 }
147 return times.join(',')
148}
149
150// Picks the stored theme (or the default one), sets `look`, and says when the
151// stored one was not usable.
152export async function applyTheme($: EngineInterface, root: string, home: string, wanted: string) {
153 const { themes, problems } = await scanThemes($, root, home)
154 S.themeChoices = [...themes.values()]
155 const chosen = chooseTheme(themes, wanted, root)
156 // theme.typ always merges over the built-in vscode, also when a user theme
157 // has that name, so the stamp uses the built-in one.
158 const builtIn = themes.get(BASE_THEME)
159 const files = themeFiles(themes.get(chosen.name), builtIn?.isUser ? undefined : builtIn, root)
160 S.look = { name: chosen.name, dir: chosen.dir, stamp: await stampTheme($, files) }
161 return { themes, problems, chosen }
162}
163hooks/lib/command.ts 207 lines1// The /iui command: its arguments, the typeahead rows for them, and
2// its registration.
3import type { EngineInterface } from 'claude-code'
4
5import {
6 DEFAULT_HEIGHT, DEFAULT_SIZE, DEFAULT_WIDTH, IMAGE_MAX_COLUMNS, MAX_HEIGHT, MAX_SIZE, MAX_WIDTH, MIN_HEIGHT, MIN_SIZE, MIN_WIDTH, PICTURE_ROOM_ROWS,
7 type Height, type Width,
8} from './settings'
9import { isThemeName, type ThemeInfo } from './themes'
10import { cmd } from './api'
11import { EDITOR_NAMES, parseEditor } from './open'
12
13// `/iui` arguments: on, off, size <80-200>, width [40-400|full],
14// height [6-200|half|full], grid [on|off], select [on|off], updates
15// [on|off], editor [auto|default|name|command], details (all Intelligent UI found), grab (copy the text of the
16// last mouse selection of pictures), dump (how the last clipboard with
17// picture cells was read), or nothing (show state). A `width`, `height`,
18// `grid`, `select`, `editor` or `updates` with no value shows it.
19export type Command =
20 | { kind: 'on' | 'off' | 'show' | 'details' | 'probe' | 'grab' | 'dump' }
21 | { kind: 'size'; percent: number }
22 | { kind: 'width'; width?: Width }
23 | { kind: 'height'; height?: Height }
24 | { kind: 'grid'; grid?: boolean }
25 | { kind: 'updates'; on?: boolean }
26 | { kind: 'select'; on?: boolean }
27 | { kind: 'editor'; editor?: string }
28 | { kind: 'theme'; name?: string }
29 | { kind: 'setup'; remove: boolean }
30 | { kind: 'bad' }
31export function parseCommand(args: string): Command {
32 const typed = args.trim().split(/\s+/).filter(Boolean)
33 const words = typed.map(w => w.toLowerCase())
34 if (!words.length) return { kind: 'show' }
35 if (words[0] === 'theme') {
36 if (typed.length === 1) return { kind: 'theme' }
37 if (typed.length === 2 && isThemeName(typed[1]!)) return { kind: 'theme', name: typed[1]! }
38 return { kind: 'bad' }
39 }
40 if (words.length === 1 && (words[0] === 'on' || words[0] === 'off' || words[0] === 'details' || words[0] === 'probe' || words[0] === 'grab' || words[0] === 'dump')) {
41 return { kind: words[0] }
42 }
43 if (words[0] === 'setup' && words.length === 1) return { kind: 'setup', remove: false }
44 if (words[0] === 'setup' && words.length === 2 && words[1] === 'remove') return { kind: 'setup', remove: true }
45 if (words[0] === 'size' && words.length === 2 && /^\d+%?$/.test(words[1]!)) {
46 const percent = Number.parseInt(words[1]!, 10)
47 if (percent >= MIN_SIZE && percent <= MAX_SIZE) return { kind: 'size', percent }
48 }
49 if (words[0] === 'width' && words.length === 1) return { kind: 'width' }
50 if (words[0] === 'width' && words.length === 2 && words[1] === 'full') return { kind: 'width', width: 'full' }
51 if (words[0] === 'width' && words.length === 2 && /^\d+$/.test(words[1]!)) {
52 const width = Number.parseInt(words[1]!, 10)
53 if (width >= MIN_WIDTH && width <= MAX_WIDTH) return { kind: 'width', width }
54 }
55 if (words[0] === 'height' && words.length === 1) return { kind: 'height' }
56 if (words[0] === 'height' && words.length === 2 && (words[1] === 'half' || words[1] === 'full')) return { kind: 'height', height: words[1] }
57 if (words[0] === 'height' && words.length === 2 && /^\d+$/.test(words[1]!)) {
58 const height = Number.parseInt(words[1]!, 10)
59 if (height >= MIN_HEIGHT && height <= MAX_HEIGHT) return { kind: 'height', height }
60 }
61 if (words[0] === 'grid' && words.length === 1) return { kind: 'grid' }
62 if (words[0] === 'grid' && words.length === 2 && (words[1] === 'on' || words[1] === 'off')) return { kind: 'grid', grid: words[1] === 'on' }
63 if (words[0] === 'select' && words.length === 1) return { kind: 'select' }
64 if (words[0] === 'select' && words.length === 2 && (words[1] === 'on' || words[1] === 'off')) return { kind: 'select', on: words[1] === 'on' }
65 if (words[0] === 'editor') {
66 // The value is the rest of the text (a command has spaces); `auto`, `default` and a known name are lower case, other words stay as typed.
67 const value = typed.slice(1).join(' ')
68 if (!value) return { kind: 'editor' }
69 const low = value.toLowerCase()
70 const one = typed.length === 2 && (low === 'auto' || low === 'default' || EDITOR_NAMES.includes(low)) ? low : value
71 if (parseEditor(one)) return { kind: 'editor', editor: one }
72 return { kind: 'bad' }
73 }
74 if (words[0] === 'updates' && words.length === 1) return { kind: 'updates' }
75 if (words[0] === 'updates' && words.length === 2 && (words[1] === 'on' || words[1] === 'off')) return { kind: 'updates', on: words[1] === 'on' }
76 return { kind: 'bad' }
77}
78
79// The /iui arguments, and the sizes, widths, heights, grid and updates
80// values the typeahead offers.
81const SUBCOMMANDS = [
82 { name: 'on', description: 'draw answers as pictures' },
83 { name: 'off', description: 'show answers as text' },
84 { name: 'size', description: `text size, ${MIN_SIZE}-${MAX_SIZE}%` },
85 { name: 'width', description: `the most columns a picture takes, ${MIN_WIDTH}-${MAX_WIDTH} or full` },
86 { name: 'height', description: `the most rows an image or a Typst drawing takes, ${MIN_HEIGHT}-${MAX_HEIGHT}, half or full` },
87 { name: 'grid', description: 'each line of text on whole terminal rows, on or off' },
88 { name: 'select', description: 'select and copy picture text with the mouse, on or off' },
89 { name: 'editor', description: 'what a click on a file path opens it in: auto, default, an editor or a command' },
90 { name: 'updates', description: 'look for a new version once a day, on or off' },
91 { name: 'theme', description: 'list the themes, or switch to one' },
92 { name: 'setup', description: 'iTerm2 and herdr: add the shell line that turns pictures on' },
93 { name: 'details', description: 'the mode source, the terminal font, Typst and the terminal' },
94 { name: 'grab', description: 'copy the text of the pictures you last selected (or copied, on the main screen)' },
95 { name: 'dump', description: 'show what the clipboard held and how it was read' },
96] as const
97const SIZES = [80, 90, 100, 110, 120, 125, 150, 175, 200]
98const WIDTHS: readonly Width[] = [80, 100, 120, 160, 200, 'full']
99const HEIGHTS: readonly Height[] = ['half', 'full', 12, 24, 36]
100const GRIDS = ['on', 'off'] as const
101const UPDATES = ['on', 'off'] as const
102const SELECTS = ['on', 'off'] as const
103
104// Typeahead rows for `/iui` arguments: the word at the cursor (`token`,
105// starting at `start` in `text`) completed as a subcommand, a theme name, a
106// size, a width, a height, a grid or an updates value. The typeahead asks
107// only while a word is at the cursor (never after a space), and again after
108// a row is taken. So each word offers its next step while it is at the
109// cursor: `/iui` its subcommands, and `theme`, `size`, `width`, `height`,
110// `grid` or `updates` their values, as rows that hold the word too. A Tab
111// then takes a row and the next rows show at once. The value in use is
112// marked `in use` in each list, as in `/iui theme`.
113export type Suggestion = { text: string; label?: string; description?: string }
114export function commandSuggestions(
115 text: string, token: string, start: number,
116 o: { themes: readonly ThemeInfo[]; active: string; size: number; width?: Width; height?: Height; grid?: boolean; updates?: boolean; select?: boolean; editor?: string; onPath?: ReadonlyMap<string, string>; autoEditor?: string | null },
117): Suggestion[] {
118 if (token === '/iui' && /^\s*$/.test(text.slice(0, start))) {
119 return SUBCOMMANDS.map(c => ({ text: `/iui ${c.name}`, label: c.name, description: c.description }))
120 }
121 const head = /^\s*\/iui\s+/.exec(text)
122 if (!head || start < head[0].length) return []
123 const before = text.slice(head[0].length, start).trim().split(/\s+/).filter(Boolean).map(w => w.toLowerCase())
124 const word = token.toLowerCase()
125 const themeRows = (prefix: string, lead: string) => [...o.themes]
126 .filter(t => t.name.toLowerCase().startsWith(prefix))
127 .sort((a, b) => a.name.localeCompare(b.name))
128 .map(t => ({
129 text: `${lead}${t.name}`,
130 label: t.name,
131 description: [[t.isUser ? 'yours' : '', t.name === o.active ? 'in use' : ''].filter(Boolean).join(', '), t.description].filter(Boolean).join(': ') || undefined,
132 }))
133 const sizeRows = (prefix: string, lead: string) => SIZES
134 .filter(n => String(n).startsWith(prefix))
135 .map(n => ({ text: `${lead}${n}`, label: `${n}%`, description: n === o.size ? 'in use' : n === DEFAULT_SIZE ? 'the terminal text size' : undefined }))
136 const width = o.width ?? DEFAULT_WIDTH
137 const widthRows = (prefix: string, lead: string) => WIDTHS
138 .filter(w => String(w).startsWith(prefix))
139 .map(w => ({
140 text: `${lead}${w}`,
141 label: w === 'full' ? 'full' : `${w} columns`,
142 description: w === width ? 'in use' : w === DEFAULT_WIDTH ? 'the default' : w === 'full' ? `the terminal width (${IMAGE_MAX_COLUMNS} at most)` : undefined,
143 }))
144 const height = o.height ?? DEFAULT_HEIGHT
145 const heightRows = (prefix: string, lead: string) => HEIGHTS
146 .filter(h => String(h).startsWith(prefix))
147 .map(h => ({
148 text: `${lead}${h}`,
149 label: typeof h === 'number' ? `${h} rows` : h,
150 description: h === height ? 'in use' : h === DEFAULT_HEIGHT ? 'the default: half the terminal'
151 : h === 'full' ? `the terminal height less ${PICTURE_ROOM_ROWS} rows` : undefined,
152 }))
153 const grid = o.grid === false ? 'off' : 'on'
154 const gridRows = (prefix: string, lead: string) => GRIDS
155 .filter(g => g.startsWith(prefix))
156 .map(g => ({ text: `${lead}${g}`, label: g, description: g === grid ? 'in use' : g === 'on' ? 'the default' : undefined }))
157 const updates = o.updates === false ? 'off' : 'on'
158 const updateRows = (prefix: string, lead: string) => UPDATES
159 .filter(u => u.startsWith(prefix))
160 .map(u => ({ text: `${lead}${u}`, label: u, description: u === updates ? 'in use' : u === 'on' ? 'the default' : undefined }))
161 const select = o.select === false ? 'off' : 'on'
162 const selectRows = (prefix: string, lead: string) => SELECTS
163 .filter(v => v.startsWith(prefix))
164 .map(v => ({ text: `${lead}${v}`, label: v, description: v === select ? 'in use' : v === 'on' ? 'the default' : undefined }))
165 const editor = o.editor ?? 'auto'
166 const found = o.autoEditor ? `, found ${o.autoEditor}` : ''
167 const editorRows = (prefix: string, lead: string) => {
168 // The editors on the PATH first (known after the first lookup).
169 const names = [...EDITOR_NAMES].sort((a, b) => Number(!!o.onPath?.get(b)) - Number(!!o.onPath?.get(a)))
170 return [
171 { name: 'auto', description: `${editor === 'auto' ? 'in use, ' : ''}the default: the first editor on the PATH${found}` },
172 { name: 'default', description: `${editor === 'default' ? 'in use, ' : ''}the system app` },
173 ...names.map(name => ({ name, description: name === editor ? 'in use' : o.onPath?.get(name) ? 'on the PATH' : undefined })),
174 ].filter(r => r.name.startsWith(prefix)).map(r => ({ text: `${lead}${r.name}`, label: r.name, description: r.description }))
175 }
176 if (before.length === 0) {
177 if (word === 'editor') return editorRows('', 'editor ')
178 if (word === 'theme') return themeRows('', 'theme ')
179 if (word === 'height') return heightRows('', 'height ')
180 if (word === 'size') return sizeRows('', 'size ')
181 if (word === 'width') return widthRows('', 'width ')
182 if (word === 'grid') return gridRows('', 'grid ')
183 if (word === 'updates') return updateRows('', 'updates ')
184 if (word === 'select') return selectRows('', 'select ')
185 return SUBCOMMANDS.filter(c => c.name.startsWith(word)).map(c => ({ text: c.name, description: c.description }))
186 }
187 if (before.length === 1 && before[0] === 'theme') return themeRows(word, '')
188 if (before.length === 1 && before[0] === 'size') return sizeRows(word.replace(/%$/, ''), '')
189 if (before.length === 1 && before[0] === 'width') return widthRows(word, '')
190 if (before.length === 1 && before[0] === 'height') return heightRows(word, '')
191 if (before.length === 1 && before[0] === 'grid') return gridRows(word, '')
192 if (before.length === 1 && before[0] === 'updates') return updateRows(word, '')
193 if (before.length === 1 && before[0] === 'select') return selectRows(word, '')
194 if (before.length === 1 && before[0] === 'editor') return editorRows(word, '')
195 return []
196}
197
198// /iui; registered at session start and again in setUp, so it is
199// there after a hot reload too.
200export async function registerCommand($: EngineInterface) {
201 await cmd($, {
202 name: 'iui',
203 description: "Draw Claude's answers as rendered Markdown pictures: on, off, size <80-200> (text size in % of the terminal's), width [40-400|full] (the most columns a picture takes), height [6-200|half|full] (the most rows an image or a Typst drawing takes), grid [on|off] (each line of text on whole terminal rows), select [on|off] (select and copy picture text with the mouse), editor [auto|default|name|command] (what a click on a file path opens it in), updates [on|off] (look for a new version once a day), theme [name] (list or switch themes), grab (copy the text of the selected pictures, or of the picture cells on the clipboard), dump (copy diagnostics)",
204 argumentHint: '[on|off|size <percent>|width [columns|full]|height [rows|half|full]|grid [on|off]|select [on|off]|editor [auto|default|name|command]|updates [on|off]|theme [name]|grab|dump]',
205 }).catch(() => undefined)
206}
207hooks/lib/setup.ts 161 lines1// iTerm2 and herdr setup: the marked block /iui setup adds to (or removes
2// from) the shell's start file, and the question asked once per load.
3import type { EngineInterface } from 'claude-code'
4
5import { S } from './state'
6import { readText } from './host'
7import { getenv, exists, write, exec, load, save, ask, log, toast } from './api'
8import { isSet } from './terminal'
9
10// Claude Code turns pictures on by itself in kitty and Ghostty only; iTerm2
11// and herdr need CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 when Claude Code starts
12// (a mod's $.env.set comes after its terminal check, too late). So the setup
13// adds a marked block to the shell's start file that sets it in iTerm2 and
14// herdr alone, not in tmux or screen (Claude Code cannot draw there). The
15// markers stay as they were (an old block set it in iTerm2 only).
16export const SETUP_START = '# >>> cc-intelligent-ui: Claude Code pictures in iTerm2 >>>'
17export const SETUP_END = '# <<< cc-intelligent-ui <<<'
18export type ShellRc = { path: string; kind: 'sh' | 'fish' }
19// The start files of a login bash, in its order: it reads only the first that exists.
20export const BASH_LOGIN = ['.bash_profile', '.bash_login', '.profile']
21// The start file the setup writes for the login shell (`SHELL`); null for a
22// shell it does not know. iTerm2 (and herdr on macOS) starts login shells:
23// bash on macOS gets the first of BASH_LOGIN in `loginFiles` (those in HOME),
24// a new .bash_profile only when none is there. fish gets a file of its own in conf.d.
25export function shellRcOf(o: { shell: string; home: string; zdotdir?: string; xdgConfig?: string; isMac: boolean; loginFiles?: readonly string[] }): ShellRc | null {
26 const name = o.shell.split('/').pop() ?? ''
27 if (name === 'zsh') return { path: `${o.zdotdir || o.home}/.zshrc`, kind: 'sh' }
28 if (name === 'bash') return { path: `${o.home}/${o.isMac ? (BASH_LOGIN.find(f => o.loginFiles?.includes(f)) ?? BASH_LOGIN[0]) : '.bashrc'}`, kind: 'sh' }
29 if (name === 'fish') return { path: `${o.xdgConfig || `${o.home}/.config`}/fish/conf.d/cc-intelligent-ui.fish`, kind: 'fish' }
30 return null
31}
32export function setupBlock(kind: ShellRc['kind']): string {
33 const line = kind === 'fish'
34 ? 'if test "$TERM_PROGRAM" = "iTerm.app" -o "$TERM_PROGRAM" = "herdr"; and not set -q TMUX; and not set -q STY; set -gx CLAUDE_CODE_FORCE_TERMINAL_IMAGES 1; end'
35 : 'if { [ "$TERM_PROGRAM" = "iTerm.app" ] || [ "$TERM_PROGRAM" = "herdr" ]; } && [ -z "$TMUX" ] && [ -z "$STY" ]; then export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1; fi'
36 return `${SETUP_START}\n${line}\n${SETUP_END}\n`
37}
38export const hasSetup = (text: string) => text.includes(SETUP_START)
39// The text has this version's block (an old one has no herdr).
40export const hasNewSetup = (text: string, kind: ShellRc['kind']) => text.includes(setupBlock(kind))
41// The text with no setup block (and no blank line left where it was).
42export function withoutSetup(text: string): string {
43 const lines = text.split('\n')
44 const out: string[] = []
45 for (let i = 0; i < lines.length; i++) {
46 if (lines[i] !== SETUP_START) {
47 out.push(lines[i]!)
48 continue
49 }
50 const end = lines.indexOf(SETUP_END, i)
51 if (end < 0) {
52 out.push(lines[i]!)
53 continue
54 }
55 if (out.length && out[out.length - 1] === '') out.pop()
56 i = end
57 }
58 return out.join('\n')
59}
60// The text with one setup block at its end.
61export function withSetup(text: string, kind: ShellRc['kind']): string {
62 const base = withoutSetup(text).replace(/\n*$/, '')
63 return `${base}${base ? '\n\n' : ''}${setupBlock(kind)}`
64}
65
66// The shell start file for this user, or null when the shell is not known.
67async function shellRc($: EngineInterface): Promise<ShellRc | null> {
68 const shell = (await getenv($, 'SHELL')) ?? ''
69 const home = (await getenv($, 'HOME')) ?? ''
70 const isMac = S.hostOs === 'Darwin'
71 // Which login files of bash are there (macOS only).
72 const loginFiles: string[] = []
73 if (isMac && shell.split('/').pop() === 'bash') {
74 for (const f of BASH_LOGIN) if (await exists($, `${home}/${f}`).catch(() => false)) loginFiles.push(f)
75 }
76 return shellRcOf({
77 shell,
78 home,
79 zdotdir: (await getenv($, 'ZDOTDIR')) || undefined,
80 xdgConfig: (await getenv($, 'XDG_CONFIG_HOME')) || undefined,
81 isMac,
82 loginFiles,
83 })
84}
85
86// The start file's text: '' when it is confirmed missing, null when it
87// exists (or may exist) but cannot be read. A write over null would lose it.
88async function readRc($: EngineInterface, path: string): Promise<string | null> {
89 const there = await exists($, path).catch(() => true)
90 if (!there) return ''
91 return readText($, path)
92}
93
94// What /iui setup says where neither iTerm2 (macOS only) nor herdr runs: it changes no file.
95export const SETUP_NOT_MAC = '/iui setup is for iTerm2 and herdr. iTerm2 runs on macOS only, and this terminal is not herdr, so Intelligent UI changed no file. ' +
96 'On Linux, kitty and Ghostty show pictures with no setup. In herdr, run /iui setup in a herdr pane. ' +
97 'For Rio, set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 before you start Claude Code. Run /iui details to see if this terminal shows pictures.'
98
99// The words for the terminal in use: herdr opens panes, iTerm2 tabs.
100export const setupWordsOf = (isHerdr: boolean) => (isHerdr ? { app: 'herdr', tab: 'pane' } : { app: 'iTerm2', tab: 'tab' })
101
102// Adds (or removes) the setup block. Returns what to tell the person. A
103// block of an older version (no herdr) is replaced.
104export async function runSetup($: EngineInterface, remove: boolean): Promise<string> {
105 if (S.hostOs && S.hostOs !== 'Darwin' && !S.isHerdr) return SETUP_NOT_MAC
106 const rc = await shellRc($)
107 const { app, tab } = setupWordsOf(S.isHerdr)
108 const manual = `export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 (only in iTerm2 and herdr, not in tmux or screen)`
109 if (!rc) return `Intelligent UI does not know your shell, so it changed no file. Add this to your shell's start file: ${manual}`
110 const text = await readRc($, rc.path)
111 if (text === null) return `Intelligent UI could not read ${rc.path}, so it changed no file. Add this to it yourself: ${manual}`
112 // Pictures on in this session already: no new tab needed.
113 const restart = isSet(await getenv($, 'CLAUDE_CODE_FORCE_TERMINAL_IMAGES'))
114 ? `Pictures are on in this ${tab} already.`
115 : `Close this ${tab}, open a new ${app} ${tab}, and start Claude Code there to see pictures.`
116 if (remove) {
117 if (!hasSetup(text)) return `${rc.path} has no Intelligent UI block.`
118 await write($, rc.path, withoutSetup(text))
119 return `Removed the Intelligent UI block from ${rc.path}. New ${app} ${tab}s show answers as text.`
120 }
121 if (hasNewSetup(text, rc.kind)) return `${rc.path} already has the Intelligent UI block. ${restart}`
122 if (rc.kind === 'fish') await exec($, ['mkdir', '-p', rc.path.replace(/\/[^/]*$/, '')]).catch(() => null)
123 await write($, rc.path, withSetup(text, rc.kind))
124 const done = hasSetup(text) ? `Updated the Intelligent UI block in ${rc.path}` : `Added the Intelligent UI block to ${rc.path}`
125 return `${done}. ${restart} Undo with /iui setup remove.`
126}
127
128// Asks once per load whether to add the setup block; "Never ask" is kept.
129export async function offerSetup($: EngineInterface) {
130 if (S.setupAsked) return
131 S.setupAsked = true
132 if ((await load($, 'setup').catch(() => undefined)) === 'never') return
133 const rc = await shellRc($)
134 const text = rc ? await readRc($, rc.path) : ''
135 if (text === null) return
136 // A block is enough for iTerm2; herdr needs this version's (an old one sets the variable in iTerm2 only).
137 const isOld = !!rc && hasSetup(text) && !hasNewSetup(text, rc.kind)
138 if (rc && hasSetup(text) && !(S.isHerdr && isOld)) return
139 const home = (await getenv($, 'HOME')) ?? ''
140 const file = rc ? (home ? rc.path.replace(home, '~') : rc.path) : "your shell's start file"
141 const { app, tab } = setupWordsOf(S.isHerdr)
142 const yes = isOld ? 'Update the line' : 'Add the line'
143 const question = isOld
144 ? `Update the Intelligent UI line in ${file} so that it sets it in herdr too (not in tmux or screen)?`
145 : `Add one line to ${file} that sets it in iTerm2 and herdr only (not in tmux or screen)?`
146 const answer = await ask($,
147 `Intelligent UI draws answers as pictures, but in ${app} Claude Code needs CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 when it starts. ${question}`,
148 { header: 'iui', options: [yes, 'Not now', 'Never ask'] },
149 )
150 if (answer === 'Never ask') await save($, 'setup', 'never')
151 if (answer !== yes) return
152 const said = await runSetup($, false)
153 // The toast box is small: a short line there, the whole text in the transcript.
154 try {
155 log($, said)
156 } catch {}
157 try {
158 toast($, /^(Added|Updated)/.test(said) ? `Close this ${tab} and open a new ${app} ${tab} to see pictures.` : 'See the transcript for the setup.', { timeoutMs: 10000 })
159 } catch {}
160}
161hooks/lib/host.ts 854 lines1// Work on the host: the shell snippets Intelligent UI runs, the tty size (one
2// read, and the watcher's loop: the size and the clipboard), the terminal's font, and light or dark for auto (the terminal's settings, then
3// COLORFGBG, then the OS appearance), read again by a poll.
4import type { EngineInterface } from 'claude-code'
5
6import type { IuiTheme } from '../../types'
7import { CLIP_WAKE_MS, HERDR_POLL_MS, HERDR_SCAN_MS, HOST_CHECK_MS, HOST_POLL_MS, MODES, OSC11_QUIET_MS } from './settings'
8import { S } from './state'
9import { beginEpoch } from './epoch'
10import { keptOsc11, luminanceOf, OSC11_TRIES, modeOfColorFgBg, modeOfLuminance, modeOfMacAppearance, parseOsc11, resolveMode, schemeOfPortal, type HostMode } from './mode'
11import {
12 cmuxAppearanceOf, cmuxConfigOf, cmuxGhosttyConfigPath, cmuxSettingsOf, familyOfFc, GHOSTTY_DEFAULT_FONT, ghosttyBackgroundOf, ghosttyFontsOf, ghosttyHasColors,
13 ghosttyThemePaths, ghosttyThemesOf,
14 ghosttyValueOf, hexColorOf, itermAppearanceOf, itermFontOf, itermModeOf, jsoncOf, kittyAutoFileOf, kittyBackgroundOf,
15 kittyConfigDirsOf, kittyEntriesOf, kittyFontOf, kittySchemeOfGnome, kittyValueOf, KITTY_MAC_FONT, parseFontSpec, rioBackgroundOf,
16 rioConfigDirOf, RIO_DEFAULT_FONT, RIO_OLD_FONT, rioFontOf, rioForcedOf, rioThemeNamesOf, rioThemePath, tomlStringsOf, type CmuxAppearance, type KittyEntries,
17} from './terminals'
18import { firstLine } from './markdown'
19import {
20 frontKindOf, herdrClientChanged, herdrClientsOf, herdrHostOf, herdrTickOf, herdrTickPick, isCmuxIn, isFlipHere, isGhosttyHere, isGhosttyIn, isKittyIn, isRioIn, pickHerdrClient,
21 ttyNameOf, type HerdrClient, type HerdrTick, type OuterName,
22} from './terminal'
23import { refont } from './frame'
24import { every, nowMs, getenv, exists, cat, exec } from './api'
25
26// One tty size read: `rows cols xpixel ypixel` (TIOCGWINSZ); exit 1 with no tty.
27export const CELL_SH = `perl -e 'open(T, "</dev/tty") or exit 1; my $w = " " x 8; ioctl(T, ($^O eq "darwin" ? 0x40087468 : 0x5413), $w) or exit 1; print join(" ", unpack("S4", $w))'`
28// The watcher (frame.ts): one long-lived perl for the tty size and the
29// clipboard. It prints `pid <n>` first, then a line `size rows cols xpixel
30// ypixel` at start and at each change. With the argument `clip` alone
31// (macOS: /usr/bin/perl, which has PerlObjCBridge) it also prints `clip
32// <n>`, the clipboard's change count, at start and at each change, read
33// every CLIP_WAKE_MS; `clip none` once when the bridge does not load (then
34// only the size). With `clip` and a command (Linux: the notifier of
35// clipboard.ts), a child of its own process group runs that command and
36// prints `clip <n>` (0, 1, ...) for each line the command prints, and `clip
37// none` when it ends; the watcher stops that group when it exits. It sleeps
38// in sigsuspend with SIGWINCH and SIGALRM blocked outside it, so a SIGWINCH
39// that comes while it reads is kept for the next sleep (never lost), and it
40// does no work between signals. With no bridge, an alarm each 10 s wakes it
41// only to see that its parent still runs. Exit 1 when /dev/tty does not
42// open (and no clip); exit 0 when the parent dies (its parent id changes),
43// stdout breaks, or (with a notifier) at SIGTERM.
44export const WATCH_PL = [
45 'use POSIX (); use Config; use Time::HiRes ();',
46 'my %n; @n{split " ", $Config{sig_name}} = split " ", $Config{sig_num};',
47 'my $tty = open(my $t, "<", "/dev/tty");',
48 'my $req = $^O eq "darwin" ? 0x40087468 : 0x5413;',
49 'my $parent = getppid(); my $last = ""; my $seen = ""; $| = 1;',
50 '$SIG{PIPE} = sub { exit 0 }; $SIG{WINCH} = sub {}; $SIG{ALRM} = sub {};',
51 'print "pid $$\\n" or exit 0;',
52 'my $pb; my $relay;',
53 'if (($ARGV[0] // "") eq "clip" && @ARGV > 1) {',
54 ' $relay = fork();',
55 ' if (defined $relay && !$relay) {',
56 ' $SIG{PIPE} = "IGNORE"; $SIG{WINCH} = "DEFAULT"; $SIG{ALRM} = "DEFAULT"; setpgrp(0, 0); my $c; my $k = 0;',
57 ' if (open($c, "-|", @ARGV[1 .. $#ARGV])) { while (<$c>) { print "clip $k\\n" or last; $k++; } }',
58 ' print "clip none\\n"; kill "TERM", -$$; POSIX::_exit(0);',
59 ' }',
60 ' if ($relay) { setpgrp($relay, $relay); $SIG{TERM} = sub { exit 0 }; } else { print "clip none\\n" or exit 0; }',
61 '} elsif (($ARGV[0] // "") eq "clip") {',
62 ' $pb = eval { require Foundation; Foundation->import;',
63 ' NSBundle->bundleWithPath_("/System/Library/Frameworks/AppKit.framework")->load;',
64 ' @NSPasteboard::ISA = ("PerlObjCBridge"); my $p = NSPasteboard->generalPasteboard; defined $p->changeCount ? $p : undef };',
65 ' print "clip none\\n" or exit 0 unless $pb;',
66 '}',
67 'END { kill "TERM", -$relay if $relay; }',
68 'exit 1 unless $tty || $pb || $relay;',
69 'POSIX::sigprocmask(POSIX::SIG_BLOCK(), POSIX::SigSet->new($n{WINCH}, $n{ALRM}));',
70 'my $none = POSIX::SigSet->new();',
71 'while (1) {',
72 ' my $w = " " x 8;',
73 ' if ($tty && ioctl($t, $req, $w)) {',
74 ' my $line = join(" ", unpack("S4", $w));',
75 ' if ($line ne $last) { print "size $line\\n" or exit 0; $last = $line; }',
76 ' }',
77 ' if ($pb) {',
78 ' my $c = eval { $pb->changeCount };',
79 ' if (defined $c && "$c" ne $seen) { print "clip $c\\n" or exit 0; $seen = "$c"; }',
80 ' }',
81 ' exit 0 if getppid() != $parent;',
82 ` if ($pb) { Time::HiRes::ualarm(${CLIP_WAKE_MS * 1000}); } else { alarm(10); }`,
83 ' POSIX::sigsuspend($none);',
84 '}',
85].join(' ')
86// The perl that runs the watcher: on macOS the system's (a Homebrew perl
87// has no PerlObjCBridge), else the one on the PATH.
88export const perlFor = (isMac: boolean) => (isMac ? '/usr/bin/perl' : 'perl')
89const ITERM_BOOKMARKS_SH = `defaults export com.googlecode.iterm2 - | plutil -extract "New Bookmarks" json -o - -`
90const ITERM_DEFAULT_SH = `defaults read com.googlecode.iterm2 "Default Bookmark Guid"`
91// A PostScript name (argv[0]) to its family name; with argv[1] `user`, a
92// name that is not installed gives the user's fixed-pitch font (iTerm2 then
93// draws it: Menlo, unless it was changed).
94const FAMILY_JXA = 'function run(argv) { ObjC.import("AppKit"); var f = argv[0] ? $.NSFont.fontWithNameSize(argv[0], 12) : null; if ((!f || f.isNil()) && argv[1] === "user") f = $.NSFont.userFixedPitchFontOfSize(12); return !f || f.isNil() ? "" : f.familyName.js }'
95// Whether a family (argv[0]) is installed, as CoreText finds it by name:
96// yes or no; with argv[1] `mono`, `proportional` when no style of it is fixed-pitch.
97const HAS_FAMILY_JXA = 'function run(argv) { ObjC.import("AppKit"); var m = $.NSFontManager.sharedFontManager.availableMembersOfFontFamily(argv[0]); if (m.isNil()) return "no"; if (argv[1] !== "mono") return "yes"; for (var i = 0; i < m.count; i++) if (m.objectAtIndex(i).objectAtIndex(3).js & 0x400) return "yes"; return "proportional" }'
98// The installed family (`=Name`) whose name is argv[0] in any case, else `no`.
99const FAMILY_NOCASE_JXA = 'function run(argv) { ObjC.import("AppKit"); var a = $.NSFontManager.sharedFontManager.availableFontFamilies, w = argv[0].toLowerCase(); for (var i = 0; i < a.count; i++) { var f = a.objectAtIndex(i).js; if (f.toLowerCase() === w) return "=" + f } return "no" }'
100// iTerm2's appearance, the default profile's GUID and the profiles, split by "@" lines.
101const ITERM_MODE_SH = `defaults read com.googlecode.iterm2 TabStyleWithAutomaticOption 2>/dev/null; echo @; ${ITERM_DEFAULT_SH} 2>/dev/null; echo @; ${ITERM_BOOKMARKS_SH}`
102const GHOSTTY_SH = `"\${GHOSTTY_BIN_DIR:+\$GHOSTTY_BIN_DIR/}ghostty" +show-config`
103// Copies the font that a terminal carries in its program (no font files:
104// the TrueType data is inside it; Ghostty's JetBrains Mono, Rio's Cascadia
105// Code NF) to a folder that Typst can read, once per build: the variable
106// Regular and Italic (an `fvar` table and the family, argument 2, in the
107// `name` table), each size once (a universal program has a copy per CPU).
108// The folder is in the user cache, by the tag (argument 1) and the
109// program's path, size and mtime; it prints the folder. Exit 3 when the
110// program has no such font (kept as a `.none` marker, so the next run does
111// not read the program again); exit 1 for other failures. Made in a
112// temporary folder first, so a compile never sees half. Each new copy or
113// marker removes the tag's temporary folders of 1 day and its other folders
114// and markers of 30 days (an older build's). The key has the scanner's
115// version and the family, so a change to either reads the program again.
116// A write that fails leaves no folder and no marker (exit 1).
117export const PROGRAM_FONTS_PL = [
118 'use strict;',
119 'my ($tag, $family, $f) = @ARGV; $f or exit 1;',
120 'my @st = stat($f) or exit 1;',
121 'my $base = ($ENV{XDG_CACHE_HOME} || "$ENV{HOME}/.cache") . "/cc-intelligent-ui/fonts";',
122 'my $key = "v2-" . unpack("%32C*", "$family/$f") . "-$st[7]-$st[9]";',
123 'my $d = "$base/$tag-$key";',
124 'if (-d $d) { utime(undef, undef, $d); print "$d\\n"; exit 0 }',
125 'exit 3 if -e "$d.none";',
126 'sub sweep {',
127 ' opendir(my $dh, $base) or return;',
128 ' for my $e (readdir $dh) {',
129 ' next unless index($e, "$tag-") == 0; my $p = "$base/$e";',
130 ' next if $p eq $d || $p eq "$d.none"; my $age = -M $p; next unless defined $age;',
131 ' next unless $e =~ /\\.\\d+$/ ? $age > 1 : $age > 30;',
132 ' if (-d $p) { system("rm", "-rf", $p) } else { unlink $p }',
133 ' }',
134 '}',
135 'open(my $h, "<:raw", $f) or exit 1; my $b = do { local $/; <$h> }; close $h;',
136 'my $want = "\\0" . join("\\0", split(//, $family));',
137 'system("mkdir", "-p", $base) == 0 or exit 1;',
138 'my $t = "$d.$$"; system("rm", "-rf", $t); mkdir($t) or exit 1;',
139 'sub fail { system("rm", "-rf", $t); exit 1 }',
140 'my %seen; my $n = 0;',
141 'while ($b =~ /\\x00\\x01\\x00\\x00/g) {',
142 ' my $o = pos($b) - 4; my $k = unpack("n", substr($b, $o + 4, 2));',
143 ' next if $k < 4 || $k > 40;',
144 ' my ($end, $fvar, $name, $bad) = (0, 0, "", 0);',
145 ' for my $i (0 .. $k - 1) {',
146 ' my ($tt, $cs, $off, $len) = unpack("a4NNN", substr($b, $o + 12 + 16 * $i, 16));',
147 ' if ($tt !~ m{^[A-Za-z0-9/ ]{4}$} || $off + $len > 20000000) { $bad = 1; last }',
148 ' $end = $off + $len if $off + $len > $end;',
149 ' $fvar = 1 if $tt eq "fvar";',
150 ' $name = substr($b, $o + $off, $len) if $tt eq "name";',
151 ' }',
152 ' next if $bad || !$fvar || index($name, $want) < 0 || $seen{$end}++;',
153 ' open(my $w, ">:raw", "$t/$tag-" . ($n + 1) . ".ttf") or fail();',
154 ' (print $w substr($b, $o, $end)) && close($w) or fail(); $n++;',
155 '}',
156 'if (!$n) { rmdir $t; if (open(my $m, ">", "$d.none")) { close $m } sweep(); exit 3 }',
157 'rename($t, $d) or system("rm", "-rf", $t);',
158 '-d $d or exit 1; sweep(); print "$d\\n";',
159].join('\n')
160// Runs PROGRAM_FONTS_PL (with the perl of perlFor) with a tag and a family
161// on the first program found: for ghostty the one of GHOSTTY_BIN_DIR, then
162// the PATH's command of that name, then each other path (the apps' places).
163// Exit 2 when no program is found.
164export const PROGRAM_FONTS_SH = [
165 'perl=$1; pl=$2; tag=$3; family=$4; name=$5; shift 5',
166 'first=; [ "$name" = ghostty ] && first=${GHOSTTY_BIN_DIR:+$GHOSTTY_BIN_DIR/ghostty}',
167 'for f in "$first" "$(command -v "$name")" "$@"; do',
168 ' [ -n "$f" ] && [ -f "$f" ] && exec "$perl" -e "$pl" "$tag" "$family" "$f"',
169 'done',
170 'exit 2',
171].join('\n')
172// Where the apps keep their Ghostty program.
173const GHOSTTY_PROGRAMS = ['/Applications/cmux.app/Contents/Resources/bin/ghostty', '/Applications/Ghostty.app/Contents/MacOS/ghostty']
174// Where Rio's program can be (after the PATH's): the macOS app (rio.app), cargo's, Flatpak's.
175const rioProgramsOf = (home: string) => [
176 '/Applications/rio.app/Contents/MacOS/rio', `${home}/Applications/rio.app/Contents/MacOS/rio`, `${home}/.cargo/bin/rio`,
177 '/var/lib/flatpak/app/com.rioterm.Rio/current/active/files/bin/rio', `${home}/.local/share/flatpak/app/com.rioterm.Rio/current/active/files/bin/rio`,
178]
179// Asks the terminal for its background (OSC 11) and prints the answer. It
180// opens the tty itself, non-blocking, changes no tty mode, and waits 300 ms at
181// most. Claude Code reads the same tty, so the answer may go to it instead.
182export const OSC11_PL = [
183 'use Fcntl; use IO::Select; use Time::HiRes qw(time);',
184 'sysopen(my $t, "/dev/tty", O_RDWR | O_NONBLOCK | O_NOCTTY) or exit 1;',
185 'syswrite($t, "\\e]11;?\\a");',
186 'my $s = IO::Select->new($t); my $r = ""; my $end = time + 0.3;',
187 'while ((my $left = $end - time) > 0) {',
188 ' last unless $s->can_read($left);',
189 ' my $n = sysread($t, my $b, 256);',
190 ' last if !defined $n && !$!{EAGAIN}; last if defined $n && $n == 0;',
191 ' $r .= $b if $n; last if $r =~ /\\a|\\e\\\\/;',
192 '}',
193 'print $r;',
194].join(' ')
195// Off until /iui probe shows the answer reaches the mod: the poll
196// then asks the terminal (source 1 of auto) every period.
197export const USE_OSC11 = false
198export const PROBE_RUNS = 5
199export const HEADERS_SH = `for f in "$@"; do head -c 24 "$f" | od -An -v -tx1; echo @; done`
200// herdr: the clients of this pane's server. One ps lists the processes;
201// this pane's server is the `herdr server` above this shell. A client is a
202// `herdr` of this user with a tty (no arguments, `--session <name>`,
203// `session attach <name>` or `client`). With more than one server, lsof
204// keeps the clients whose socket peer is one of this server's sockets (no
205// lsof, or no peer column as on Linux: all stay). On macOS it prints first
206// `front` and the bundle id of the app in front (lsappinfo; no line when
207// that can't be read), also when no client is found. Then for each client:
208// `pid`, `atime` (its tty's access time, a key read there; macOS: with a
209// fraction), `tty`, `cmd` (its command) and `app` (its nearest ancestor
210// with no tty: the terminal app) lines, then `env` and of the client's
211// variables only CLIENT_NAMES (Linux: from /proc/<pid>/environ,
212// NUL-separated; macOS: from `ps eww`, its command then the variables, cut
213// as envOfText does). pickHerdrClient chooses one.
214export const HERDR_CLIENT_SH = `s=$$
215u=$(uname -s)
216e='COLORFGBG|GHOSTTY_BIN_DIR|GHOSTTY_RESOURCES_DIR|ITERM_PROFILE|KITTY_CONFIG_DIRECTORY|RIO_CONFIG_HOME|CMUX_SOCKET_PATH|CMUX_SURFACE_ID|CMUX_WORKSPACE_ID|ITERM_SESSION_ID|KITTY_INSTALLATION_DIR|KITTY_PID|KITTY_WINDOW_ID|LC_TERMINAL|TERM|TERM_PROGRAM'
217if [ "$u" = Darwin ] && command -v lsappinfo >/dev/null 2>&1; then
218 f=$(lsappinfo info -only bundleid "$(lsappinfo front 2>/dev/null)" 2>/dev/null | sed -n 's/^"CFBundleIdentifier"="\\(.*\\)"$/\\1/p')
219 [ -n "$f" ] && printf 'front %s\\n' "$f"
220fi
221case $u in
222 Darwin|*BSD) t=$(ps -A -o pid= -o ppid= -o uid= -o tdev= -o command= 2>/dev/null) || exit 0
223 f='%Fa'; case $(stat -f %Fa /dev/null 2>/dev/null) in [0-9]*.[0-9]*) ;; *) f='%a' ;; esac
224 a=$(stat -f "%Hr/%Lr $f %N" /dev/ttys* 2>/dev/null) ;;
225 *) t=$(ps -A -o pid= -o ppid= -o uid= -o tty= -o command= 2>/dev/null) || exit 0; a=$(stat -c '%n %X %n' /dev/pts/[0-9]* /dev/tty[0-9]* 2>/dev/null | sed 's|^/dev/||') ;;
226esac
227c=$({ printf '%s\\n' "$a" | sed 's/^/@ /'; printf '%s\\n' "$t"; } | awk -v me="$(id -u)" -v self="$s" '
228 $1 == "@" { at[$2] = $3; nm[$2] = $4; sub(/^\\/dev\\//, "", nm[$2]); next }
229 { p = $1; pp[p] = $2; ty[p] = $4; x = $0; sub(/^ *[0-9]+ +[0-9]+ +[0-9]+ +[^ ]+ +/, "", x); cm[p] = x; if ($3 != me) next
230 n = split(x, w, " "); b = w[1]; sub(/.*\\//, "", b); if (b != "herdr") next
231 if (n == 2 && w[2] == "server") { srv[p] = 1; ns++; next }
232 r = x; sub(/^[^ ]+ ?/, "", r); if ((ty[p] in at) && r ~ /^(--session[= ][^ ]+|session attach [^ ]+|client)?$/) cl[p] = 1 }
233 END {
234 q = self; for (j = 0; j < 64 && q > 1; j++) { if (q in srv) break; q = pp[q] }
235 printf "srv\\t%d\\t%s\\n", ns, (q in srv) ? q : ""
236 for (p in cl) {
237 a = pp[p]; for (j = 0; j < 64 && a > 1 && ty[a] !~ /^[?-]+$/; j++) a = pp[a]
238 printf "cl\\t%s\\t%s\\t%s\\t%s\\t%s\\n", at[ty[p]], p, nm[ty[p]], cm[p], (a > 1 ? cm[a] : "") } }')
239n=$(printf '%s\\n' "$c" | awk -F '\\t' '$1 == "srv" { print $2 }')
240v=$(printf '%s\\n' "$c" | awk -F '\\t' '$1 == "srv" { print $3 }')
241l=$(printf '%s\\n' "$c" | grep '^cl')
242[ -n "$l" ] || exit 0
243if [ "\${n:-0}" -gt 1 ]; then
244 [ -n "$v" ] || exit 0
245 if command -v lsof >/dev/null 2>&1; then
246 k=$(lsof -a -U -p "$v,$(printf '%s\\n' "$l" | cut -f3 | paste -sd, -)" 2>/dev/null | awk -v v="$v" '$2 == v { a[$6] = 1 } $NF ~ /^->0x/ { e[NR] = substr($NF, 3); o[NR] = $2 } END { for (i in e) { f = 1; if (e[i] in a) print o[i] } if (f) print "peers" }')
247 case $k in *peers*) l=$({ printf '%s\\n@\\n' "$k"; printf '%s\\n' "$l"; } | awk -F '\\t' 'f == 0 { if ($0 == "@") f = 1; else m[$0] = 1; next } $3 in m') ;; esac
248 fi
249fi
250printf '%s\\n' "$l" | cut -f3 | while read -r p; do
251 printf '%s\\n' "$l" | awk -F '\\t' -v p="$p" '$3 == p { printf "pid %s\\natime %s\\ntty %s\\ncmd %s\\napp %s\\nenv\\n", $3, $2, $4, $5, $6 }'
252 if [ -r "/proc/$p/environ" ]; then tr '\\0' '\\n' < "/proc/$p/environ" | grep -E "^($e)=" | tr '\\n' '\\0'; echo
253 else ps eww -o command= -p "$p" | awk -v c="$(printf '%s\\n' "$l" | awk -F '\\t' -v p="$p" '$3 == p { print $5 }')" -v k="^($e)=" '
254 { x = $0; if (x == c || index(x, c " ") == 1) x = substr(x, length(c) + 1); else if (match(x, / [A-Za-z_][A-Za-z0-9_]*=/)) x = substr(x, RSTART); else x = ""
255 o = c; while (x != "") { x = substr(x, 2); w = x; x = ""; if (match(w, / [A-Za-z_][A-Za-z0-9_]*=/)) { x = substr(w, RSTART); w = substr(w, 1, RSTART - 1) }
256 if (w ~ k) o = o " " w }
257 print o }'; fi
258done
259exit 0`
260// herdr's light check, each HERDR_POLL_MS: argument 1 is the OS (uname -s),
261// the others the known clients' ttys (/dev/...). On macOS it prints `front`
262// and the bundle id of the app in front (as HERDR_CLIENT_SH), then an
263// `atime <tty> <time>` line per tty (with a fraction on macOS; none for a
264// tty that is gone). No ps: it costs a tenth of the full scan.
265export const HERDR_TICK_SH = `o=$1; shift
266if [ "$o" = Darwin ] && command -v lsappinfo >/dev/null 2>&1; then
267 f=$(lsappinfo info -only bundleid "$(lsappinfo front 2>/dev/null)" 2>/dev/null)
268 case $f in '"CFBundleIdentifier"="'*'"') f=\${f#*=\\"}; printf 'front %s\\n' "\${f%\\"}" ;; esac
269fi
270[ $# -gt 0 ] || exit 0
271case $o in
272 Darwin|*BSD) a=$(stat -f 'atime %N %Fa' "$@" 2>/dev/null)
273 case $a in *[0-9].[0-9]*) ;; *) a=$(stat -f 'atime %N %a' "$@" 2>/dev/null) ;; esac ;;
274 *) a=$(stat -c 'atime %n %X' "$@" 2>/dev/null) ;;
275esac
276[ -n "$a" ] && printf '%s\\n' "$a"
277exit 0`
278
279// The terminal's font family: iTerm2's profile font, Ghostty's font-family,
280// kitty's font_family, Rio's [fonts] family; '' elsewhere or when it can't
281// be read. A family that the terminal does not find is the one it draws in
282// its place (its own font, Menlo, or fontconfig's nearest). On Linux, when
283// the terminal's config names none: fontconfig's monospace font (fc-match),
284// which kitty and most terminals use.
285export async function termFontOf($: EngineInterface, isMac: boolean): Promise<string> {
286 // Over ssh, a config here is not the one of the user's terminal.
287 if (S.isRemote) return ''
288 const font = await configFontOf($, isMac)
289 if (font || isMac) return font
290 return fcFamily($, 'monospace')
291}
292
293// A family as a fontconfig pattern: its `-`, `:`, `,` and `\` escaped.
294export const fcPatternOf = (family: string) => family.replace(/[\\\-:,]/g, '\\$&')
295
296// A family fontconfig gives for a pattern (the first of its names), '' when
297// fc-match fails. `fc-match` always gives a font, the nearest one.
298async function fcFamily($: EngineInterface, pattern: string): Promise<string> {
299 const got = await exec($, ['fc-match', pattern, '-f', '%{family}'], { timeoutMs: 5000 }).catch(() => null)
300 return got?.exitCode === 0 ? familyOfFc(got.stdout) : ''
301}
302
303// The family of the first font that fontconfig lists for a pattern (only
304// fonts that match it), '' for none.
305async function fcListFamily($: EngineInterface, pattern: string): Promise<string> {
306 const got = await exec($, ['fc-list', pattern, 'family'], { timeoutMs: 5000 }).catch(() => null)
307 return got?.exitCode === 0 ? familyOfFc(firstLine(got.stdout)) : ''
308}
309
310// What CoreText says of a family (HAS_FAMILY_JXA), '' when it can't be read.
311async function macFamily($: EngineInterface, family: string, mono = false): Promise<string> {
312 const got = await exec($, ['osascript', '-l', 'JavaScript', '-e', HAS_FAMILY_JXA, family, ...(mono ? ['mono'] : [])], { timeoutMs: 5000 }).catch(() => null)
313 return got?.exitCode === 0 ? firstLine(got.stdout) : ''
314}
315
316// The installed family of a name, as Typst must name it: CoreText on
317// macOS, fontconfig on Linux (in any case and spacing; `exact`: in any
318// case only, as Rio's font database matches it). '' when it is not
319// installed; null when that can't be read (then the name is taken as is).
320async function installedFamily($: EngineInterface, isMac: boolean, family: string, exact = false): Promise<string | null> {
321 if (isMac && exact) {
322 const got = await exec($, ['osascript', '-l', 'JavaScript', '-e', FAMILY_NOCASE_JXA, family], { timeoutMs: 5000 }).catch(() => null)
323 const line = got?.exitCode === 0 ? firstLine(got.stdout) : ''
324 return line.startsWith('=') ? line.slice(1) : line === 'no' ? '' : null
325 }
326 if (isMac) {
327 const seen = await macFamily($, family)
328 return seen === 'yes' ? family : seen === 'no' ? '' : null
329 }
330 const got = await exec($, ['fc-list', exact ? ':' : fcPatternOf(family), 'family'], { timeoutMs: 5000 }).catch(() => null)
331 if (got?.exitCode !== 0) return null
332 // Each line is a font's family names, split by commas, with `\` escapes.
333 const names = got.stdout.split('\n').flatMap(line => line.split(/(?<!\\),/).map(name => name.replace(/\\(.)/g, '$1').trim())).filter(Boolean)
334 const loose = (n: string) => (exact ? n : n.replace(/\s/g, '')).toLowerCase()
335 return names.find(n => loose(n) === loose(family)) ?? (exact ? '' : names[0] ?? '')
336}
337
338// Copies the font a terminal carries in its program (PROGRAM_FONTS_PL) and
339// gives it to Typst (S.fontPaths): 'copied', 'none' (the program has no
340// such font) or 'failed' (no program, or the copy failed).
341async function programFont($: EngineInterface, isMac: boolean, o: { tag: string; family: string; name: string; paths: readonly string[] }): Promise<'copied' | 'none' | 'failed'> {
342 const argv = ['sh', '-c', PROGRAM_FONTS_SH, 'sh', perlFor(isMac), PROGRAM_FONTS_PL, o.tag, o.family, o.name, ...o.paths]
343 const made = await exec($, argv, { timeoutMs: 10000, ...ghosttyInit() }).catch(() => null)
344 const dir = made?.exitCode === 0 ? firstLine(made.stdout) : ''
345 // No copy now: an older folder (maybe gone) is not given to Typst.
346 if (!dir) S.fontPaths = ''
347 if (made?.exitCode === 3) return 'none'
348 if (!dir) return 'failed'
349 S.fontPaths = dir
350 return 'copied'
351}
352
353// Ghostty draws its own JetBrains Mono when no font-family is set (and on
354// macOS when it finds none of them). Typst draws it from the system's fonts
355// when fontconfig has it (Linux), else from a copy of the one in Ghostty's
356// program (S.fontPaths).
357async function ghosttyDefaultFont($: EngineInterface, isMac: boolean): Promise<string> {
358 const got = await exec($, ['fc-list', '-q', GHOSTTY_DEFAULT_FONT], { timeoutMs: 5000 }).catch(() => null)
359 if (got?.exitCode === 0) return GHOSTTY_DEFAULT_FONT
360 const o = { tag: 'ghostty', family: GHOSTTY_DEFAULT_FONT, name: 'ghostty', paths: GHOSTTY_PROGRAMS }
361 return (await programFont($, isMac, o)) === 'copied' ? GHOSTTY_DEFAULT_FONT : ''
362}
363
364// Ghostty's font: the first family of its font-family list that it finds.
365// None found: on macOS its own JetBrains Mono; on Linux fontconfig's
366// nearest font for the first family (Ghostty takes the first font of
367// fontconfig's sorted list). No list: its own JetBrains Mono.
368async function ghosttyFont($: EngineInterface, isMac: boolean, names: readonly string[]): Promise<string> {
369 if (!names.length) return ghosttyDefaultFont($, isMac)
370 for (const name of names) {
371 const found = await installedFamily($, isMac, name)
372 if (found !== '') return found ?? name
373 }
374 if (isMac) return ghosttyDefaultFont($, isMac)
375 return (await fcFamily($, fcPatternOf(names[0]!))) || names[0]!
376}
377
378// Rio draws its own Cascadia Code NF when the config names no font (or
379// `cascadiacode`, or one it does not find by its exact name): a copy of
380// the one in Rio's program. A Rio up to 0.4 carries a Cascadia Code with
381// no `fvar` (none found): Cascadia Code. No program: the system's Cascadia
382// Code (the same letters) when it is installed.
383async function rioFont($: EngineInterface, isMac: boolean): Promise<string> {
384 const named = rioFontOf((await readRioConfig($)).config)
385 const found = named === RIO_DEFAULT_FONT ? '' : await installedFamily($, isMac, named, true)
386 if (found !== '') return found ?? named
387 const home = (await getenv($, 'HOME')) ?? ''
388 const copy = await programFont($, isMac, { tag: 'rio', family: RIO_DEFAULT_FONT, name: 'rio', paths: rioProgramsOf(home) })
389 if (copy === 'copied') return RIO_DEFAULT_FONT
390 if (copy === 'none') return RIO_OLD_FONT
391 return (await installedFamily($, isMac, RIO_OLD_FONT)) ? RIO_OLD_FONT : RIO_DEFAULT_FONT
392}
393
394// iTerm2, or herdr in iTerm2 (its profile and font; S.isIterm stays false
395// there, so the setup question for iTerm2 is not asked).
396const isItermHere = () => S.isIterm || S.herdrHost === 'iterm'
397
398// A variable of the terminal the user sees: under herdr the client's
399// (S.herdrClient; not set there is not set), else this process's.
400export function outerEnv($: EngineInterface, name: OuterName): Promise<string | undefined> {
401 const client = S.herdrClient
402 return client ? Promise.resolve(client.env[name] || undefined) : getenv($, name)
403}
404// GHOSTTY_SH and PROGRAM_FONTS_SH run the client's Ghostty under herdr ('': the PATH's).
405const ghosttyInit = () => (S.herdrClient ? { env: { GHOSTTY_BIN_DIR: S.herdrClient.env.GHOSTTY_BIN_DIR ?? '' } } : {})
406
407// The terminal values the font and mode readers use (S.itermProfile,
408// S.colorFgBg, S.herdrHost, S.isKitty, S.isCmux): under herdr the client's
409// when one is found, else this process's variables (under herdr: those of
410// the terminal that started its server, the fallback).
411export async function setOuter($: EngineInterface) {
412 S.itermProfile = (await outerEnv($, 'ITERM_PROFILE')) || undefined
413 S.colorFgBg = (await outerEnv($, 'COLORFGBG')) ?? ''
414 const client = S.herdrClient
415 if (client) {
416 S.herdrHost = client.kind
417 S.isKitty = client.kind === 'kitty'
418 S.isCmux = client.kind === 'cmux'
419 return
420 }
421 S.isCmux = isCmuxIn({ surface: await getenv($, 'CMUX_SURFACE_ID'), workspace: await getenv($, 'CMUX_WORKSPACE_ID'), socket: await getenv($, 'CMUX_SOCKET_PATH') })
422 S.herdrHost = S.isHerdr
423 ? herdrHostOf({
424 cmux: S.isCmux,
425 ghosttyResources: await getenv($, 'GHOSTTY_RESOURCES_DIR'),
426 ghosttyBin: await getenv($, 'GHOSTTY_BIN_DIR'),
427 kittyPid: await getenv($, 'KITTY_PID'),
428 kittyDir: await getenv($, 'KITTY_INSTALLATION_DIR'),
429 itermProfile: S.itermProfile,
430 })
431 : ''
432 S.isKitty = isKittyIn({ term: S.termName, windowId: await getenv($, 'KITTY_WINDOW_ID'), pid: await getenv($, 'KITTY_PID') }) || S.herdrHost === 'kitty'
433}
434
435// herdr: reads the clients (HERDR_CLIENT_SH; the full scan) and follows the
436// one that pickHerdrClient chooses (`was`: the client of before, kept while
437// the app in front has none). True when its terminal changed
438// (herdrClientChanged); a read that finds none keeps the last one. The
439// clients and the app in front are kept for the light check.
440export async function readHerdrClient($: EngineInterface, was: HerdrClient | null = S.herdrClient): Promise<boolean> {
441 const got = await exec($, ['sh', '-c', HERDR_CLIENT_SH], { timeoutMs: HOST_CHECK_MS }).catch(() => null)
442 const read = got?.exitCode === 0 ? herdrClientsOf(got.stdout) : null
443 // A failed scan counts too, so the next one waits HERDR_SCAN_MS.
444 S.herdrScanAt = await nowMs($).catch(() => Date.now())
445 if (read) {
446 S.herdrClients = read.clients
447 S.herdrScanFront = read.front
448 }
449 const pick = read ? pickHerdrClient(read.clients, read.front === null ? null : frontKindOf(read.front), was) : null
450 if (!pick) return false
451 if (pick.by !== 'kept') S.herdrBy = pick.by
452 const changed = herdrClientChanged(S.herdrClient, pick.client)
453 S.herdrClient = pick.client
454 return changed
455}
456
457// herdr's check each HERDR_POLL_MS: the light check (HERDR_TICK_SH, then
458// herdrTickPick on the clients of the last scan), else the full scan
459// (readHerdrClient). True when the terminal changed.
460export async function checkHerdrClient($: EngineInterface): Promise<boolean> {
461 const now = await nowMs($).catch(() => Date.now())
462 const ageMs = S.herdrScanAt === null ? null : now - S.herdrScanAt
463 let tick: HerdrTick | null = null
464 if (ageMs !== null && ageMs < HERDR_SCAN_MS) {
465 const ttys = S.herdrClients.map(c => `/dev/${ttyNameOf(c.tty)}`)
466 // No client (a detached or remote pane): nothing to read, no process.
467 const got = ttys.length ? await exec($, ['sh', '-c', HERDR_TICK_SH, 'sh', S.hostOs, ...ttys], { timeoutMs: HOST_CHECK_MS }).catch(() => null) : { exitCode: 0, stdout: '' }
468 tick = got?.exitCode === 0 ? herdrTickOf(got.stdout) : null
469 }
470 const light = herdrTickPick({ clients: S.herdrClients, tick, was: S.herdrClient, scanFront: S.herdrScanFront, ageMs })
471 if (!light) return readHerdrClient($)
472 S.herdrClients = light.clients
473 if (!light.pick) return false
474 if (light.pick.by !== 'kept') S.herdrBy = light.pick.by
475 const changed = herdrClientChanged(S.herdrClient, light.pick.client)
476 S.herdrClient = light.pick.client
477 return changed
478}
479
480// The font that the terminal's own config or profile names.
481async function configFontOf($: EngineInterface, isMac: boolean): Promise<string> {
482 if (isItermHere() && isMac) {
483 const profile = S.itermProfile
484 const [marks, guid] = await Promise.all([
485 exec($, ['sh', '-c', ITERM_BOOKMARKS_SH], { timeoutMs: 5000 }).catch(() => null),
486 exec($, ['sh', '-c', ITERM_DEFAULT_SH], { timeoutMs: 5000 }).catch(() => null),
487 ])
488 // A read that failed or no profile: not known.
489 const font = marks?.exitCode === 0 ? itermFontOf(marks.stdout, profile, guid?.stdout ?? '') : null
490 if (font === null) return ''
491 // An empty or bad profile font, or one that is not installed: iTerm2 draws the user's fixed-pitch font.
492 const named = await exec($, ['osascript', '-l', 'JavaScript', '-e', FAMILY_JXA, parseFontSpec(font)?.name ?? '', 'user'], { timeoutMs: 5000 }).catch(() => null)
493 return named?.exitCode === 0 ? firstLine(named.stdout) : ''
494 }
495 if (isGhosttyHere()) {
496 const shown = await exec($, ['sh', '-c', GHOSTTY_SH], { timeoutMs: 5000, ...ghosttyInit() }).catch(() => null)
497 // cmux loads its own config after Ghostty's; +show-config does not read it.
498 const own = S.isCmux ? ((await readText($, cmuxGhosttyConfigPath((await getenv($, 'HOME')) ?? ''))) ?? '') : ''
499 return ghosttyFont($, isMac, ghosttyFontsOf(cmuxConfigOf(shown?.stdout ?? '', own)))
500 }
501 if (S.isKitty) {
502 // As kitty finds a font: a family name, else a full or PostScript name
503 // (macOS: AppKit; Linux: fontconfig), else the nearest font (macOS:
504 // Menlo; Linux: fontconfig's monospace match). kitty looks only at
505 // fixed-pitch fonts. No font_family: the system's monospace (macOS:
506 // Menlo; Linux: fontconfig's, in termFontOf).
507 const font = kittyFontOf(kittyValueOf((await readKittyConfig($)).entries, 'font_family'))
508 if (!font) return isMac ? KITTY_MAC_FONT : ''
509 const name = 'family' in font ? font.family : font.postscript
510 if (isMac) {
511 const seen = 'family' in font ? await macFamily($, name, true) : 'no'
512 if (seen === 'yes' || seen === '') return name
513 if (seen === 'proportional') return KITTY_MAC_FONT
514 const named = await exec($, ['osascript', '-l', 'JavaScript', '-e', FAMILY_JXA, name], { timeoutMs: 5000 }).catch(() => null)
515 return named?.exitCode === 0 ? firstLine(named.stdout) || KITTY_MAC_FONT : ''
516 }
517 const found = 'family' in font ? await installedFamily($, isMac, name) : ''
518 if (found !== '') return found ?? name
519 const exact = (await fcListFamily($, `:postscriptname=${fcPatternOf(name)}`)) || (await fcListFamily($, `:fullname=${fcPatternOf(name)}`))
520 return exact || (await fcFamily($, `${'family' in font ? fcPatternOf(name) : `:postscriptname=${fcPatternOf(name)}`}:spacing=mono`))
521 }
522 if (isRioHere()) return rioFont($, isMac)
523 return ''
524}
525
526// The mode for auto now, or null when nothing can be read (keep the last
527// one): 0. under herdr, local or remote, its pane's OSC 11 answer (the
528// background of the terminal in front), asked only with `ask` (osc11Due)
529// and kept while the sources below do not change (keptOsc11); 1. the
530// terminal's OSC 11 answer (off, see USE_OSC11); 2. the terminal's own
531// settings (iTerm2's profile, Ghostty's, kitty's or Rio's config); 3.
532// COLORFGBG at start; 4. the OS appearance; 5. dark (resolveMode). The
533// terminal comes before the OS: a dark terminal on a light Mac is dark.
534// Over ssh, only COLORFGBG (when ssh sends it): the configs and the OS
535// here are the remote computer's.
536export async function readHostMode($: EngineInterface, ask = false): Promise<HostMode | null> {
537 if (!S.isHerdr) return readOwnMode($)
538 let answer: HostMode | null | undefined
539 if (ask) {
540 S.osc11Tries = Math.max(0, S.osc11Tries - 1)
541 const bg = parseOsc11((await probeOsc11($).catch(() => null))?.reply ?? '')
542 answer = bg ? { mode: modeOfLuminance(luminanceOf(bg)), source: 'herdr', detail: 'OSC 11 background of the terminal in front' } : null
543 if (answer) S.osc11Tries = 0
544 }
545 const { kept, use } = keptOsc11(S.osc11Kept, await readOwnMode($).catch(() => null), answer)
546 S.osc11Kept = kept
547 return use
548}
549
550// Under herdr, an OSC 11 probe is due: tries are left (the start or a new
551// client, with auto), the mod draws herdr pictures and is on, and no key
552// came in the prompt box for OSC11_QUIET_MS.
553export async function osc11Due($: EngineInterface, off = S.isOff): Promise<boolean> {
554 if (S.osc11Tries <= 0 || !S.isHerdr || !S.herdrDraws || off || S.setting !== 'auto') return false
555 return (await nowMs($).catch(() => Date.now())) - S.keyAt >= OSC11_QUIET_MS
556}
557
558// Sources 1 to 5 of readHostMode.
559async function readOwnMode($: EngineInterface): Promise<HostMode | null> {
560 if (USE_OSC11) {
561 const bg = parseOsc11((await probeOsc11($))?.reply ?? '')
562 if (bg) return { mode: modeOfLuminance(luminanceOf(bg)), source: 'terminal', detail: 'OSC 11 background' }
563 }
564 if (S.isRemote) {
565 const mode = modeOfColorFgBg(S.colorFgBg)
566 return mode ? { mode, source: 'COLORFGBG' } : null
567 }
568 const term =
569 (await readItermMode($).catch(() => null)) ??
570 (await readGhosttyMode($).catch(() => null)) ??
571 (await readKittyMode($).catch(() => null)) ??
572 (await readRioMode($).catch(() => null))
573 if (term) return term
574 const mode = modeOfColorFgBg(S.colorFgBg)
575 if (mode) return { mode, source: 'COLORFGBG' }
576 return readOsMode($).catch(() => null)
577}
578
579// The background of the iTerm2 profile in use, on a Mac.
580async function readItermMode($: EngineInterface): Promise<HostMode | null> {
581 if (!isItermHere() || S.hostOs !== 'Darwin') return null
582 const got = await exec($, ['sh', '-c', ITERM_MODE_SH], { timeoutMs: HOST_CHECK_MS }).catch(() => null)
583 if (!got || got.exitCode !== 0) return null
584 const [style = '', guid = '', marks = ''] = got.stdout.split(/^@\n/m)
585 const appearance = itermAppearanceOf(style) ?? (await readOsMode($).catch(() => null))?.mode ?? null
586 const mode = itermModeOf(marks, S.itermProfile, guid, appearance)
587 return mode ? { mode, source: 'iTerm2', detail: `profile ${S.itermProfile || 'default'} background` } : null
588}
589
590// One OSC 11 question to the terminal: its raw answer ('' for none), or null
591// when the tty could not be opened.
592export async function probeOsc11($: EngineInterface): Promise<{ reply: string } | null> {
593 const got = await exec($, ['perl', '-e', OSC11_PL], { timeoutMs: HOST_CHECK_MS }).catch(() => null)
594 return got && got.exitCode === 0 ? { reply: got.stdout } : null
595}
596
597// The background of Ghostty's config. A `light:A,dark:B` theme follows the OS
598// (in cmux, cmux's appearance). In cmux, cmux's own config is read as well;
599// herdr in Ghostty reads Ghostty's.
600async function readGhosttyMode($: EngineInterface): Promise<HostMode | null> {
601 if (!isGhosttyIn(S.termProgram, S.termName) && S.herdrHost !== 'ghostty' && S.herdrHost !== 'cmux') return null
602 const got = await exec($, ['sh', '-c', GHOSTTY_SH], { timeoutMs: HOST_CHECK_MS, ...ghosttyInit() }).catch(() => null)
603 if (!got || got.exitCode !== 0) return null
604 const home = (await getenv($, 'HOME')) ?? ''
605 const own = S.isCmux ? ((await readText($, cmuxGhosttyConfigPath(home))) ?? '') : ''
606 const config = cmuxConfigOf(got.stdout, own)
607 if (S.isCmux && !ghosttyHasColors(config)) {
608 const adaptive = await readCmuxMode($).catch(() => null)
609 if (adaptive) return adaptive
610 }
611 const pair = ghosttyThemesOf(ghosttyValueOf(config, 'theme'))
612 const themeBg: { light?: string; dark?: string } = {}
613 let appearance: IuiTheme | null = null
614 if (pair) {
615 appearance = S.isCmux ? ((await readCmuxAppearance($).catch(() => null))?.mode ?? null) : ((await readOsMode($).catch(() => null))?.mode ?? null)
616 const where = {
617 xdgConfig: (await getenv($, 'XDG_CONFIG_HOME')) || undefined,
618 home,
619 resources: (await outerEnv($, 'GHOSTTY_RESOURCES_DIR')) || undefined,
620 }
621 for (const mode of MODES) {
622 for (const path of ghosttyThemePaths(pair[mode], where)) {
623 const text = await cat($, path).catch(() => null)
624 if (typeof text !== 'string') continue
625 themeBg[mode] = ghosttyValueOf(text, 'background')
626 break
627 }
628 }
629 }
630 const bg = hexColorOf(ghosttyBackgroundOf(config, appearance, themeBg) ?? '')
631 if (!bg) return null
632 const shown = ghosttyValueOf(config, 'background')
633 const detail = `${pair && appearance ? `theme ${pair[appearance]}` : !shown ? 'default' : 'config'} background`
634 return { mode: modeOfLuminance(luminanceOf(bg)), source: ghosttyValueOf(own, 'theme') ? 'cmux' : 'Ghostty', detail }
635}
636
637// cmux with no colours in the Ghostty config: its adaptive default theme
638// (terminal.adaptiveDefaultTheme, on unless cmux.json turns it off) follows
639// cmux's appearance, the OS's when that is system. Null when it is off.
640async function readCmuxMode($: EngineInterface): Promise<HostMode | null> {
641 const home = (await getenv($, 'HOME')) ?? ''
642 const settings = cmuxSettingsOf(jsoncOf((await readText($, `${home}/.config/cmux/cmux.json`)) ?? ''))
643 if (settings.adaptive === false) return null
644 const seen = await readCmuxAppearance($, settings.appearance)
645 return seen ? { mode: seen.mode, source: 'cmux', detail: `adaptive default theme, appearance ${seen.appearance}` } : null
646}
647
648// cmux's appearance (cmux.json's, else the one saved in Settings) and the
649// mode it gives: the OS's when it is system. Null when that is not known.
650async function readCmuxAppearance($: EngineInterface, file?: CmuxAppearance): Promise<{ mode: IuiTheme; appearance: CmuxAppearance } | null> {
651 if (file === undefined) {
652 const home = (await getenv($, 'HOME')) ?? ''
653 file = cmuxSettingsOf(jsoncOf((await readText($, `${home}/.config/cmux/cmux.json`)) ?? '')).appearance
654 }
655 let saved = ''
656 if (!file && S.hostOs === 'Darwin') {
657 const got = await exec($, ['defaults', 'read', 'com.cmuxterm.app', 'appearanceMode'], { timeoutMs: HOST_CHECK_MS }).catch(() => null)
658 saved = got?.exitCode === 0 ? got.stdout : ''
659 }
660 const appearance = cmuxAppearanceOf(file, saved)
661 const mode = appearance === 'system' ? ((await readOsMode($).catch(() => null))?.mode ?? null) : appearance
662 return mode ? { mode, appearance } : null
663}
664
665// Rio sets TERM_PROGRAM=rio, and TERM=rio when its terminfo is installed; or herdr in Rio.
666const isRioHere = () => isRioIn(S.termProgram, S.termName) || S.herdrHost === 'rio'
667
668// A file's text, or null when it can't be read.
669export async function readText($: EngineInterface, path: string): Promise<string | null> {
670 const text = await cat($, path).catch(() => null)
671 return typeof text === 'string' ? text : null
672}
673
674// kitty's config folder and kitty.conf with its includes: the first folder
675// that has a kitty.conf, else the first folder (auto theme files can be there).
676async function readKittyConfig($: EngineInterface): Promise<{ dir: string; entries: KittyEntries; home: string }> {
677 const home = (await getenv($, 'HOME')) ?? ''
678 const dirs = kittyConfigDirsOf({
679 kittyDir: (await outerEnv($, 'KITTY_CONFIG_DIRECTORY')) || undefined,
680 xdgConfig: (await getenv($, 'XDG_CONFIG_HOME')) || undefined,
681 home,
682 isMac: S.hostOs === 'Darwin',
683 })
684 const o = { home, vars: { KITTY_OS: S.hostOs === 'Darwin' ? 'macos' : S.hostOs.toLowerCase() } }
685 for (const dir of dirs) {
686 if (!(await exists($, `${dir}/kitty.conf`).catch(() => false))) continue
687 return { dir, entries: await kittyEntriesOf(`${dir}/kitty.conf`, path => readText($, path), o), home }
688 }
689 return { dir: dirs[0]!, entries: [], home }
690}
691
692// The background of kitty's config. Auto theme files (kitty 0.38+) follow
693// the OS: the OS is read only when one of them exists.
694async function readKittyMode($: EngineInterface): Promise<HostMode | null> {
695 if (!S.isKitty) return null
696 const { dir, entries, home } = await readKittyConfig($)
697 const schemes = ['dark', 'light', 'no-preference'] as const
698 const found = await Promise.all(schemes.map(s => exists($, `${dir}/${kittyAutoFileOf(s)}`).catch(() => false)))
699 let auto: { file: string; entries: KittyEntries } | null = null
700 if (found.some(Boolean)) {
701 let scheme: (typeof schemes)[number] | null = null
702 if (S.hostOs === 'Linux') scheme = (await readLinuxScheme($).catch(() => null))?.scheme ?? null
703 else scheme = (await readOsMode($).catch(() => null))?.mode ?? null
704 if (scheme && found[schemes.indexOf(scheme)]) {
705 const file = kittyAutoFileOf(scheme)
706 auto = { file, entries: await kittyEntriesOf(`${dir}/${file}`, path => readText($, path), { home }) }
707 }
708 }
709 const got = kittyBackgroundOf(entries, auto)
710 const bg = hexColorOf(got.bg)
711 return bg ? { mode: modeOfLuminance(luminanceOf(bg)), source: 'kitty', detail: got.detail } : null
712}
713
714// Rio's config folder and its config.toml's string values.
715async function readRioConfig($: EngineInterface): Promise<{ dir: string; config: Record<string, string> }> {
716 const dir = rioConfigDirOf({
717 rioHome: (await outerEnv($, 'RIO_CONFIG_HOME')) || undefined,
718 xdgConfig: (await getenv($, 'XDG_CONFIG_HOME')) || undefined,
719 home: (await getenv($, 'HOME')) ?? '',
720 isMac: S.hostOs === 'Darwin',
721 })
722 return { dir, config: tomlStringsOf((await readText($, `${dir}/config.toml`)) ?? '') }
723}
724
725// The background of Rio's config. An `[adaptive-theme]` follows the OS
726// unless `force-theme` is set: the OS is read only then.
727async function readRioMode($: EngineInterface): Promise<HostMode | null> {
728 if (!isRioHere()) return null
729 const { dir, config } = await readRioConfig($)
730 const themes: Record<string, string | null> = {}
731 for (const name of rioThemeNamesOf(config)) themes[name] = await readText($, rioThemePath(dir, name))
732 const adaptive = !!config['adaptive-theme.light'] && !!config['adaptive-theme.dark'] && !rioForcedOf(config)
733 const appearance = adaptive ? ((await readOsMode($).catch(() => null))?.mode ?? null) : null
734 const got = rioBackgroundOf(config, appearance, themes)
735 const bg = hexColorOf(got.bg)
736 return bg ? { mode: modeOfLuminance(luminanceOf(bg)), source: 'Rio', detail: got.detail } : null
737}
738
739// The OS appearance: macOS, or on Linux the desktop's colour scheme
740// (readLinuxScheme; no preference is light, as GNOME's default is).
741async function readOsMode($: EngineInterface): Promise<HostMode | null> {
742 if (S.hostOs === 'Darwin') {
743 const got = await exec($, ['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: HOST_CHECK_MS }).catch(() => null)
744 return got ? { mode: modeOfMacAppearance(got.exitCode, got.stdout), source: 'macOS' } : null
745 }
746 if (S.hostOs === 'Linux') {
747 const seen = await readLinuxScheme($).catch(() => null)
748 if (seen) return { mode: seen.scheme === 'dark' ? 'dark' : 'light', source: seen.source }
749 }
750 return null
751}
752
753// The freedesktop portal's colour scheme (GNOME, KDE and others: the value
754// kitty reads too), by gdbus, else busctl. The first tool that works is
755// kept, so each check runs one process; when neither works, GNOME's
756// gsettings is read in its place.
757const PORTAL_ARGS = {
758 gdbus: ['gdbus', 'call', '--session', '--dest', 'org.freedesktop.portal.Desktop', '--object-path', '/org/freedesktop/portal/desktop',
759 '--method', 'org.freedesktop.portal.Settings.Read', 'org.freedesktop.appearance', 'color-scheme'],
760 busctl: ['busctl', '--user', 'call', 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', 'org.freedesktop.portal.Settings',
761 'Read', 'ss', 'org.freedesktop.appearance', 'color-scheme'],
762} as const
763type LinuxScheme = { scheme: 'dark' | 'light' | 'no-preference'; source: 'desktop portal' | 'GNOME' }
764async function readLinuxScheme($: EngineInterface): Promise<LinuxScheme | null> {
765 const tools = S.portalTool === null ? (['gdbus', 'busctl'] as const) : S.portalTool ? [S.portalTool] : []
766 for (const tool of tools) {
767 const got = await exec($, PORTAL_ARGS[tool], { timeoutMs: HOST_CHECK_MS }).catch(() => null)
768 const scheme = got && got.exitCode === 0 ? schemeOfPortal(got.stdout) : null
769 if (scheme) {
770 S.portalTool = tool
771 return { scheme, source: 'desktop portal' }
772 }
773 }
774 if (S.portalTool === null) S.portalTool = ''
775 if (S.portalTool) return null
776 const got = await exec($, ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], { timeoutMs: HOST_CHECK_MS }).catch(() => null)
777 const scheme = got && got.exitCode === 0 ? kittySchemeOfGnome(got.stdout) : null
778 return scheme ? { scheme, source: 'GNOME' } : null
779}
780
781// One poll (never two at once): under herdr the client first (another
782// terminal sets the terminal values again, then the font and its metrics
783// are read again), then with auto the mode. A changed mode, font or flip
784// mode (isFlipHere: iTerm2 in or out) redraws. Under herdr the mode is read
785// each HOST_POLL_MS (every third poll), at once for another terminal, and
786// with `modeNow`. No timed OSC 11 probe: only one that osc11Due allows
787// (the start or a new client), never for `modeNow` (a dialog can be open).
788export async function pollHost($: EngineInterface, modeNow = false) {
789 const herdr = herdrPolled()
790 if (S.checking || (S.setting !== 'auto' && !herdr)) return
791 S.checking = true
792 let refonted = false
793 const flipWas = isFlipHere()
794 try {
795 const moved = herdr && (await checkHerdrClient($).catch(() => false))
796 if (moved) {
797 await setOuter($)
798 // The answer was the terminal of before: a new client is asked again.
799 S.osc11Kept = null
800 S.osc11Tries = S.setting === 'auto' ? OSC11_TRIES : 0
801 }
802 const ask = herdr && !modeNow && (await osc11Due($))
803 const due = !herdr || modeNow || moved || ask || ++S.hostTicks >= HOST_POLL_MS / HERDR_POLL_MS
804 if (due) S.hostTicks = 0
805 if (S.setting === 'auto' && due) S.host = (await readHostMode($, ask).catch(() => null)) ?? S.host
806 if (moved) refonted = await refont($).catch(() => false)
807 } finally {
808 S.checking = false
809 }
810 if (!refonted && isFlipHere() === flipWas) return showMode($)
811 S.theme = resolveMode(S.setting, S.host)
812 beginEpoch($)
813}
814
815// Draws in the mode the setting and the OS give, when it changed: a new
816// epoch, so all pictures swap to the new mode in one redraw.
817export function showMode($: EngineInterface) {
818 const next = resolveMode(S.setting, S.host)
819 if (next === S.theme) return
820 S.theme = next
821 beginEpoch($)
822}
823
824// herdr's client is checked: under herdr, with pictures drawn and the mod on.
825const herdrPolled = () => S.isHerdr && S.herdrDraws && !S.isOff
826
827// Polls while the setting is auto or herdr's client is checked (it can
828// change), and only then, until the session ends: each HOST_POLL_MS, while
829// herdr's client is checked each HERDR_POLL_MS.
830export function watchHost($: EngineInterface) {
831 const wanted = !S.hostEnded && (S.setting === 'auto' || herdrPolled())
832 const ms = herdrPolled() ? HERDR_POLL_MS : HOST_POLL_MS
833 // Started before setUp knew of herdr: started again with its period.
834 if (S.watch && (!wanted || S.watchMs !== ms)) {
835 S.watch.cancel()
836 S.watch = null
837 }
838 if (wanted && !S.watch) {
839 try {
840 S.watch = every($, ms, () => void pollHost($).catch(() => undefined))
841 S.watchMs = ms
842 } catch {
843 S.watch = null
844 }
845 }
846}
847
848// The session ends (not a /clear): no more polls.
849export function stopHost() {
850 S.hostEnded = true
851 S.watch?.cancel()
852 S.watch = null
853}
854hooks/lib/typst.ts 722 lines1// Typst on the host: finding it, measuring the fonts, and compiling an
2// answer's segments to PNG pages (with a retry in safe mode).
3import type { EngineInterface } from 'claude-code'
4
5import { COMPILE_TIMEOUT_MS, MAX_IMAGE_ROWS, NO_NETWORK, TOUCH_FLUSH_MS, TYPST_FALLBACKS } from './settings'
6import { DEFAULT_METRICS, fauxBoldOf, S, type Cell, type FontMetrics, type Job, type LeanFonts, type Metrics, type Page, type Result } from './state'
7import { firstLine, imagePathOf, localImages, plainOf, seamsOf, splitInTwo, splitSegments } from './markdown'
8import { segmentBudget } from './terminal'
9import { fontFit, placeFirstLine, sizing } from './draw'
10import { DEFAULT_GRID, gridOf } from './grid'
11import { HEADERS_SH, perlFor } from './host'
12import { logDebug } from './log'
13import { positionsOf, type Mark, type Positions } from './copy'
14import { hasBoldFace, leanFontsFor } from './fonts'
15import { flipFile } from './flip'
16import { delay, getenv, exists, ls, cat, write, exec, spawn } from './api'
17
18// The typst command and its environment. Network off (see NO_NETWORK) and a
19// package cache of our own, so only the vendored packages load, and the
20// fonts of S.fontPaths (Ghostty's own font). `fonts`:
21// the lean font folder (fonts.ts) in place of all system fonts. `run`: the
22// root that STAGE_SH made, so the answer's code reads no other file.
23export function typstCall(o: {
24 typst: string
25 root: string
26 run: string
27 cache: string
28 ppi: number
29 inputs: Readonly<Record<string, string | number>>
30 out: string
31 fonts?: string
32}): { argv: string[]; env: Record<string, string> } {
33 const argv = [
34 o.typst, 'compile',
35 '--root', o.run,
36 '--package-path', `${o.root}/render/packages`,
37 ...fontArgs(o.fonts),
38 '--format', 'png',
39 '--diagnostic-format', 'short',
40 '--ppi', o.ppi.toFixed(4),
41 ...Object.entries(o.inputs).flatMap(([k, v]) => ['--input', `${k}=${v}`]),
42 templateIn(o.run, o.root),
43 o.out,
44 ]
45 return { argv, env: noNetworkEnv(o.cache) }
46}
47
48// ---- The root of a compile: only the files the mod puts there -------------
49// md.typ evals the answer's ```typst blocks (and math), and Typst lets code
50// read any file under its root. So a compile and a probe of answer text run
51// with a root of their own: a new private folder in the key folder, with
52// each file the template needs at its own path (so the inputs and doc.json
53// keep their paths). STAGE_SH makes it, from $1 the folder, $2 the plugin
54// root, $3 the doc JSON's path (its text on stdin), $4 the theme folder and
55// then the allowed images (all paths as rootPathOf gives them):
56// - render/: md.typ copied (Typst wants its main file inside the root) and
57// links to the rest of the plugin's render folder;
58// - the doc JSON, a file of its own (a compile of the same key at the same
59// time has its own folder);
60// - a link to the theme folder (a built-in one is in render/ already);
61// - a link to each image (and each image its SVG links to) that is a file
62// with an image name, also after its links (so a link named .png to
63// another file is not one). An image under a linked folder is there
64// already, and no folder is made through a link.
65// Typst follows the links; any other path is not found, and `..` can not
66// leave the root.
67export const STAGE_SH = [
68 'umask 077',
69 'r=$1 p=$2 d=$3 t=$4',
70 'shift 4',
71 'mkdir "$r" && mkdir -p "$r$p/render" "$r${d%/*}" || exit 1',
72 'ln -s "$p"/render/* "$r$p/render/" && rm "$r$p/render/md.typ" && cp "$p/render/md.typ" "$r$p/render/md.typ" && cat > "$r$d" || exit 1',
73 'case $t in',
74 ' "$p/render/"*) ;;',
75 ' /?*) if [ -d "$t" ] && [ ! -e "$r$t" ]; then mkdir -p "$r${t%/*}" && ln -s "$t" "$r$t" || exit 1; fi ;;',
76 'esac',
77 'for f; do',
78 ' case $f in',
79 ' *//*|*/./*|*/../*|*/.|*/..|"$p/render/"*|"$t/"*) continue ;;',
80 ' /?*) ;;',
81 ' *) continue ;;',
82 ' esac',
83 ' g=$f n=0',
84 ' while [ -L "$g" ] && [ $n -lt 40 ]; do',
85 ' l=$(readlink "$g") || break',
86 ' case $l in /*) g=$l ;; *) g=${g%/*}/$l ;; esac',
87 ' n=$((n + 1))',
88 ' done',
89 ' [ -L "$g" ] && continue',
90 ' case $(printf %s "${g##*/}" | tr A-Z a-z) in',
91 ' *.png|*.jpg|*.jpeg|*.gif|*.svg|*.webp) [ -f "$g" ] && [ ! -e "$r$f" ] && mkdir -p "$r${f%/*}" && ln -s "$g" "$r$f" ;;',
92 ' esac',
93 'done',
94 'exit 0',
95].join('\n')
96
97// A path as Typst reads it in its root: `.`, `..` and empty parts resolved
98// by name; null when `..` goes above the root.
99export function rootPathOf(path: string): string | null {
100 const parts: string[] = []
101 for (const part of path.split('/')) {
102 if (part === '' || part === '.') continue
103 if (part !== '..') parts.push(part)
104 else if (parts.pop() === undefined) return null
105 }
106 return `/${parts.join('/')}`
107}
108
109// The template's main file in a run folder.
110export const templateIn = (run: string, root: string) => `${run}${rootPathOf(root) ?? ''}/render/md.typ`
111
112// The doc JSON the template reads: the base folder, the segments and the
113// allow-list of images (path and full path of each, existingImages), and
114// the seams (the gaps at the breaks) when there is more than one segment.
115export type Doc = { base: string; segments: readonly string[]; images: readonly string[]; seams?: ([string, string] | null)[] }
116
117// The doc of a compile (and of its probe: the same seams).
118export const docOf = (base: string, segments: readonly string[], images: readonly string[]): Doc =>
119 ({ base, segments, images, ...(segments.length > 1 ? { seams: seamsOf(segments) } : {}) })
120
121// The full paths of a doc's allowed images (the odd entries), as in the root.
122export const imageFullsOf = (images: readonly string[]) =>
123 images.filter((_, i) => i % 2 === 1).map(rootPathOf).filter((p): p is string => !!p)
124
125// The arguments of STAGE_SH for a run folder: the images by their full
126// paths (and the images their SVGs link to), each once.
127export function stageArgs(run: string, root: string, docPath: string, themeDir: string, images: readonly string[], linked: readonly string[] = []): string[] {
128 return [run, rootPathOf(root) ?? '', rootPathOf(docPath) ?? '', rootPathOf(themeDir) ?? '', ...new Set([...imageFullsOf(images), ...linked])]
129}
130
131const IMAGE_NAME = /\.(png|jpe?g|gif|svg|webp)$/i
132
133// The images an SVG at `full` links to by a relative path (Typst loads them
134// from the SVG's folder in the root; it refuses absolute ones), as paths in
135// the root.
136export function svgLinksOf(svg: string, full: string): string[] {
137 const dir = full.replace(/\/[^/]*$/, '')
138 const found: string[] = []
139 for (const m of svg.matchAll(/\bhref\s*=\s*(["'])([^"']*)\1/g)) {
140 const href = m[2]!.trim()
141 if (!href || href.startsWith('#') || href.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(href)) continue
142 const path = rootPathOf(`${dir}/${href}`)
143 if (path && IMAGE_NAME.test(path)) found.push(path)
144 }
145 return found
146}
147
148// The images the allowed SVGs of `images` link to.
149async function svgLinks($: EngineInterface, images: readonly string[]): Promise<string[]> {
150 const found: string[] = []
151 for (const full of new Set(imageFullsOf(images).filter(p => /\.svg$/i.test(p)))) {
152 const svg = await cat($, full).catch(() => '')
153 if (typeof svg === 'string') found.push(...svgLinksOf(svg, full))
154 }
155 return found
156}
157
158let runs = 0
159// A new run folder's name in key folder `dir` (unique in this load, and
160// random across loads and sessions).
161const runFolderIn = (dir: string) => `${dir}/r-${(++runs).toString(36)}${Math.random().toString(36).slice(2, 10)}`
162
163// Runs `use` with a root of its own for `doc` (STAGE_SH), then removes it.
164// When the root can not be made, `use` still runs: Typst then finds no
165// template there and fails (never a wider root); the folder is left to the
166// 2-day sweep (it may be another run's, when its name was taken).
167export async function inRoot<T>($: EngineInterface, job: Job, dir: string, doc: Doc, use: (run: string) => Promise<T>): Promise<T> {
168 const run = runFolderIn(dir)
169 const args = stageArgs(run, job.env.root, `${dir}/doc.json`, job.themeDir, doc.images, await svgLinks($, doc.images))
170 const made = await exec($, ['sh', '-c', STAGE_SH, 'sh', ...args], { stdin: JSON.stringify(doc), timeoutMs: 10000 }).catch(() => null)
171 if (made?.exitCode !== 0) await logDebug($, job.env.cache, `the root ${run} could not be made: ${made?.stderr ?? 'sh did not run'}`)
172 try {
173 return await use(run)
174 } finally {
175 if (made?.exitCode === 0) void Promise.resolve(exec($, ['rm', '-rf', run], { timeoutMs: 10000 })).catch(() => undefined)
176 }
177}
178
179// The arguments that load only the fonts of a lean folder (and Typst's own).
180export const fontArgs = (fonts?: string) => (fonts ? ['--ignore-system-fonts', '--font-path', fonts] : [])
181
182// The typst command that makes a PNG of a JPEG, WebP or SVG (render/convert.typ)
183// that fits `box` (in pixels), never larger than its own pixel size: at 72
184// ppi, 1 pt is 1 pixel. For a PDF, `page` is the page (it fits the box,
185// larger too), and the errors are in the human format: only it has the
186// hint with the page count of a page that does not exist. It runs no
187// answer code, so it keeps the root at / (it only reads the file).
188export function convertCall(o: {
189 typst: string
190 root: string
191 cache: string
192 file: string
193 box: { width: number; height: number }
194 out: string
195 page?: number
196}): { argv: string[]; env: Record<string, string> } {
197 const argv = [
198 o.typst, 'compile',
199 '--root', '/',
200 '--format', 'png',
201 '--diagnostic-format', o.page ? 'human' : 'short',
202 '--ppi', '72',
203 '--input', `f=${o.file}`,
204 '--input', `maxw=${o.box.width}`,
205 '--input', `maxh=${o.box.height}`,
206 ...(o.page ? ['--input', `page=${o.page}`] : []),
207 `${o.root}/render/convert.typ`,
208 o.out,
209 ]
210 return { argv, env: noNetworkEnv(o.cache) }
211}
212
213// A pasted image's preview made to fill `box` pixels (the cells it is drawn
214// in) exactly (render/fill.typ), in the root `run` that FILL_SH made: the
215// pasted file is /image.png there, and Typst reads no other file.
216export function fillCall(o: { typst: string; run: string; cache: string; box: { width: number; height: number }; fit?: 'cover' | 'contain'; out: string }): {
217 argv: string[]
218 env: Record<string, string>
219} {
220 const argv = [
221 o.typst, 'compile',
222 '--root', o.run,
223 '--format', 'png',
224 '--diagnostic-format', 'short',
225 '--ppi', '72',
226 '--input', 'f=/image.png',
227 '--input', `maxw=${o.box.width}`,
228 '--input', `maxh=${o.box.height}`,
229 ...(o.fit === 'contain' ? ['--input', 'fit=contain'] : []),
230 `${o.run}/fill.typ`,
231 o.out,
232 ]
233 return { argv, env: noNetworkEnv(o.cache) }
234}
235
236// The root of a fill: a new private folder ($1) with fill.typ copied from
237// the plugin root ($2) (Typst wants its main file inside the root) and a
238// link image.png to the pasted file ($3), which must be a .png file, not a
239// link. A copy or link that fails removes the new folder.
240export const FILL_SH = [
241 'umask 077',
242 'r=$1 p=$2 f=$3',
243 'case $f in /*.png) ;; *) exit 1 ;; esac',
244 '[ -f "$f" ] && [ ! -L "$f" ] || exit 1',
245 'mkdir "$r" || exit 1',
246 'cp "$p/render/fill.typ" "$r/fill.typ" && ln -s "$f" "$r/image.png" || { rm -rf "$r"; exit 1; }',
247].join('\n')
248
249export function noNetworkEnv(cache: string): Record<string, string> {
250 return {
251 HTTPS_PROXY: NO_NETWORK,
252 HTTP_PROXY: NO_NETWORK,
253 ALL_PROXY: NO_NETWORK,
254 https_proxy: NO_NETWORK,
255 http_proxy: NO_NETWORK,
256 all_proxy: NO_NETWORK,
257 NO_PROXY: '',
258 no_proxy: '',
259 TYPST_PACKAGE_CACHE_PATH: `${cache}/pkg-cache`,
260 // The user's own folders stay (':' is the separator on macOS and Linux).
261 ...fontPathsEnv(cache),
262 }
263}
264
265// TYPST_FONT_PATHS with no empty entries (Typst stops at one); a folder that
266// is not there when the user's value has only empty entries.
267function fontPathsEnv(cache: string): { TYPST_FONT_PATHS?: string } {
268 const paths = [...S.userFontPaths.split(':'), S.fontPaths].filter(Boolean).join(':')
269 return paths ? { TYPST_FONT_PATHS: paths } : S.userFontPaths ? { TYPST_FONT_PATHS: `${cache}/no-fonts` } : {}
270}
271
272// Width and height from a PNG's first 24 bytes (signature, then IHDR).
273export function pngSize(bytes: readonly number[]): { width: number; height: number } | null {
274 const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
275 if (bytes.length < 24 || sig.some((v, i) => bytes[i] !== v)) return null
276 const at = (i: number) => ((bytes[i]! << 24) | (bytes[i + 1]! << 16) | (bytes[i + 2]! << 8) | bytes[i + 3]!) >>> 0
277 const width = at(16)
278 const height = at(20)
279 return width && height ? { width, height } : null
280}
281
282// `od -An -v -tx1` output for several files, each followed by a line `@`.
283export function parseOd(stdout: string): number[][] {
284 return stdout
285 .split(/^@$/m)
286 .slice(0, -1)
287 .map(part => part.split(/\s+/).filter(Boolean).map(hex => Number.parseInt(hex, 16)))
288}
289
290// Any failed compile is tried once more with `safe=1`: the template then
291// shows typst fences and math as code and images as alt boxes, which is how
292// a bad typst block or an unknown LaTeX command still draws.
293export function shouldRetrySafe(exitCode: number): boolean {
294 return exitCode !== 0
295}
296
297export async function findTypst($: EngineInterface): Promise<string | null> {
298 for (const flag of ['-lc', '-c']) {
299 const got = await exec($, ['sh', flag, 'command -v typst'], { timeoutMs: 5000 }).catch(() => null)
300 const path = got?.exitCode === 0 ? firstLine(got.stdout) : ''
301 if (path.startsWith('/')) return path
302 }
303 const home = (await getenv($, 'HOME').catch(() => undefined)) ?? ''
304 for (const each of TYPST_FALLBACKS) {
305 if (each.startsWith('~/') && !home) continue
306 const path = each.startsWith('~/') ? `${home}${each.slice(1)}` : each
307 if (await exists($, path).catch(() => false)) return path
308 }
309 return null
310}
311
312// The argv that measures the fonts: the active theme's body and code fonts
313// (theme.typ reads theme.json, which needs `--root /`).
314export const measureCall = (typst: string, root: string, family: string, themeDir: string) => [
315 typst, 'eval', 'query(<m>).first().value', '--in', `${root}/render/measure.typ`, '--root', '/',
316 '--input', `term-font=${family}`, '--input', `theme-dir=${themeDir}`,
317]
318
319// The theme's font lists of a measure, when they are lists of names.
320export function leanFontsOf(v: unknown): LeanFonts | undefined {
321 const f = v as Partial<LeanFonts> | null | undefined
322 const names = (x: unknown): x is string[] => Array.isArray(x) && x.every(n => typeof n === 'string')
323 return f && names(f.body) && names(f.code) && typeof f.plain === 'boolean' ? { body: f.body, code: f.code, plain: f.plain } : undefined
324}
325
326// The fonts' metrics from render/measure.typ; the macOS defaults when that
327// fails. With the font list (in the same time): whether the terminal's font
328// has a bold face.
329export async function measureFonts($: EngineInterface, typst: string, root: string, cache: string, family: string, themeDir: string): Promise<Metrics> {
330 const argv = measureCall(typst, root, family, themeDir)
331 const env = noNetworkEnv(cache)
332 const [got, listed] = await Promise.all([
333 exec($, argv, { timeoutMs: 10000, env }).catch(() => null),
334 family ? exec($, [typst, 'fonts', '--variants'], { timeoutMs: 10000, env }).catch(() => null) : null,
335 ])
336 try {
337 const m = JSON.parse(got?.stdout ?? '') as Metrics
338 const ok = (x: FontMetrics | null | undefined) => !!x && x.adv > 0 && x.xh > 0 && x.avg > 0
339 const fonts = leanFontsOf(m.fonts)
340 const bold = listed?.exitCode === 0 ? hasBoldFace(listed.stdout, family) : null
341 if (ok(m.menlo) && ok(m.body)) {
342 return { term: ok(m.term) ? m.term : null, menlo: m.menlo, body: m.body, grid: gridOf(m.grid) ?? DEFAULT_GRID, ...(fonts ? { fonts } : {}), ...(bold === null ? {} : { termBold: bold }) }
343 }
344 } catch {}
345 return DEFAULT_METRICS
346}
347
348export { logDebug } from './log'
349
350// The images that exist, for the template's allow-list: it draws any other
351// as its alt, so one bad path doesn't fail the page.
352export async function existingImages($: EngineInterface, segments: readonly string[], base: string): Promise<string[]> {
353 const found: string[] = []
354 for (const path of new Set(segments.flatMap(localImages))) {
355 const full = imagePathOf(path, base)
356 if (await exists($, full).catch(() => false)) found.push(path, full)
357 }
358 return found
359}
360
361// The template's inputs for the room of a drawing (pt): the room height, and
362// the design's page width and room height.
363export function roomInputs(room: Job['room']): Record<string, number> {
364 if (!room) return {}
365 const pt = (v: number) => Math.round(v * 100) / 100
366 return { height: pt(room.heightPt), 'design-w': pt(room.design.widthPt), 'design-h': pt(room.design.heightPt) }
367}
368
369// The rows a page of `height` pixels takes in cells of `cell` (the template
370// pads each page to whole rows, so this rounds).
371export const pageRowsOf = (height: number, cell: Cell) => Math.max(1, Math.round(height / cell.height))
372
373// A kept pages.json as a Result; null when it is not one (an older format).
374export function resultOf(text: string): Result | null {
375 try {
376 const r = JSON.parse(text) as Result
377 const isPage = (p: Page) => !!p && typeof p.file === 'string' && p.columns > 0 && p.rows > 0
378 return Array.isArray(r.pages) && r.pages.length > 0 && r.pages.every(isPage) ? r : null
379 } catch {
380 return null
381 }
382}
383
384// Paths in use (key folders, design files): their mtime is set to now, so
385// the cleanups go by use. touchLater only notes the path (no process in a
386// render path); one `touch -c` runs per TOUCH_FLUSH_MS for at most
387// TOUCH_BATCH paths, the rest wait for the next run.
388export const TOUCH_BATCH = 200
389const TOUCH_KEPT = 4096
390// The paths not touched yet (empty after each flush; answer.ts keeps the
391// keys touched in this load, so a redraw does not note them again).
392const toTouch = new Set<string>()
393let touchTimer: { cancel: () => void } | null = null
394
395export function touchLater($: EngineInterface, path: string) {
396 if (toTouch.size >= TOUCH_KEPT && !toTouch.has(path)) return
397 toTouch.add(path)
398 if (touchTimer) return
399 touchTimer = delay($, TOUCH_FLUSH_MS, () => {
400 touchTimer = null
401 const paths = [...toTouch].slice(0, TOUCH_BATCH)
402 for (const each of paths) toTouch.delete(each)
403 if (paths.length) void Promise.resolve(exec($, ['touch', '-c', ...paths], { timeoutMs: 5000 })).catch(() => undefined)
404 if (toTouch.size) touchLater($, toTouch.values().next().value!)
405 })
406}
407
408// Clears the paths not touched yet and the timer (for tests).
409export function forgetTouches() {
410 touchTimer?.cancel()
411 touchTimer = null
412 toTouch.clear()
413}
414
415// A command with a PID line first: `sh` writes its own PID, then becomes
416// the command (exec keeps the PID), so `kill <pid>` stops the command.
417export const wrapPid = (argv: readonly string[]): string[] => ['sh', '-c', 'echo $$; exec "$@"', 'sh', ...argv]
418
419// How a run ended: `isCancelled` when `signal` stopped it (its output does
420// not count).
421export type Ran = { exitCode: number; stdout: string; stderr: string; isCancelled?: boolean }
422
423// Runs `argv` through wrapPid with $.process.spawn: its stdout (less the PID
424// line) and stderr, and its exit code (1 when a signal ended it). `signal`
425// and the timeout (`timeoutMs`, on $.clock) kill it by its PID; a timeout
426// ends with exit 1 and stderr `timed out`.
427export async function runKillable(
428 $: EngineInterface,
429 argv: readonly string[],
430 o: { env?: Record<string, string>; signal?: AbortSignal; timeoutMs: number },
431): Promise<Ran> {
432 if (o.signal?.aborted) return { exitCode: 1, stdout: '', stderr: 'cancelled', isCancelled: true }
433 let pid = ''
434 let head = ''
435 let stdout = ''
436 let stderr = ''
437 let why: 'cancel' | 'timeout' | null = null
438 let isKillSent = false
439 const killNow = () => {
440 if (!why || !pid || isKillSent) return
441 isKillSent = true
442 void Promise.resolve(exec($, ['kill', pid], { timeoutMs: 5000 })).catch(() => undefined)
443 }
444 const stop = (reason: 'cancel' | 'timeout') => {
445 if (why) return
446 why = reason
447 killNow()
448 }
449 const onAbort = () => stop('cancel')
450 o.signal?.addEventListener('abort', onAbort, { once: true })
451 const timer = delay($, o.timeoutMs, () => stop('timeout'))
452 let code: number | null = 1
453 try {
454 const child = spawn($, { argv: wrapPid(argv), ...(o.env ? { env: o.env } : {}) })
455 for (;;) {
456 const got = await child.next()
457 if (got.done) {
458 code = got.value?.code ?? 1
459 break
460 }
461 const { stream, text } = got.value
462 if (stream === 'stderr') stderr += text
463 else if (pid) stdout += text
464 else {
465 head += text
466 const at = head.indexOf('\n')
467 if (at >= 0) {
468 pid = head.slice(0, at).trim()
469 stdout += head.slice(at + 1)
470 head = ''
471 if (!/^\d+$/.test(pid)) pid = ''
472 killNow()
473 }
474 }
475 }
476 } catch (err: unknown) {
477 stderr += `${stderr ? '\n' : ''}typst did not run: ${String(err)}`
478 code = 1
479 } finally {
480 timer.cancel()
481 o.signal?.removeEventListener('abort', onAbort)
482 }
483 // A cancel that came after typst ended (before its end was read) drops a
484 // whole result too: the key compiles again (rare, and accepted).
485 if (why === 'cancel') return { exitCode: 1, stdout, stderr, isCancelled: true }
486 if (why === 'timeout') return { exitCode: 1, stdout, stderr: 'timed out' }
487 return { exitCode: code, stdout, stderr }
488}
489
490// The first page's top margin and bullet row of a job's text.
491function firstLinePlace(job: Job, segments: readonly string[]) {
492 const fit = fontFit(job.env.metrics, job.size)
493 const { rowPt } = sizing(job.columns, job.cell, fit.columnPt)
494 const metrics = job.env.metrics.grid ?? DEFAULT_GRID
495 const grid = job.grid ? { metrics, code: fit.code } : undefined
496 return placeFirstLine(segments[0] ?? '', rowPt, grid, metrics)
497}
498
499// The template's inputs for a job and its ppi (the PNG compile and the
500// probe share them). With the row grid, `grid` is 1 and `top` 0.
501export function typstInputs(job: Job, segments: readonly string[], dir: string, isSafe: boolean) {
502 const fit = fontFit(job.env.metrics, job.size)
503 const { widthPt, ppi, rowPt } = sizing(job.columns, job.cell, fit.columnPt)
504 const { topPt } = firstLinePlace(job, segments)
505 const inputs: Record<string, string | number> = {
506 theme: job.theme,
507 width: widthPt,
508 top: topPt,
509 row: Math.round(rowPt * 10000) / 10000,
510 ...(job.grid ? { grid: 1 } : {}),
511 code: fit.code,
512 ...(job.env.termFont ? { 'term-font': job.env.termFont } : {}),
513 ...(fauxBoldOf(job.env.metrics) ? { 'term-bold': 0 } : {}),
514 'theme-dir': job.themeDir,
515 doc: `${dir}/doc.json`,
516 ...roomInputs(job.room),
517 ...(isSafe ? { safe: 1 } : {}),
518 }
519 return { inputs, ppi }
520}
521
522// The lean font folder for a job's segments ('' none: all fonts), noted as in use.
523async function leanFor($: EngineInterface, job: Job, segments: readonly string[]): Promise<string> {
524 const fonts = await leanFontsFor($, job.env, segments, noNetworkEnv(job.env.cache)).catch(() => '')
525 if (fonts) touchLater($, fonts)
526 return fonts
527}
528
529// Runs a typst command with the lean fonts, else with all fonts. A lean
530// run that fails (not one that was cancelled or timed out) runs once more
531// with all fonts, so the lean set never makes a failure of its own.
532async function runFonts($: EngineInterface, job: Job, fonts: string, run: (fonts?: string) => Promise<Ran>): Promise<Ran> {
533 if (!fonts) return run()
534 const ran = await run(fonts)
535 if (ran.exitCode === 0 || ran.isCancelled || ran.stderr === 'timed out') return ran
536 await logDebug($, job.env.cache, `typst for ${job.key} with the lean fonts failed (now with all fonts):\n${ran.stderr}`)
537 return run()
538}
539
540export async function runTypst($: EngineInterface, job: Job, doc: Doc, dir: string, tag: string, isSafe: boolean, signal?: AbortSignal): Promise<Ran> {
541 const { inputs, ppi } = typstInputs(job, doc.segments, dir, isSafe)
542 const fonts = await leanFor($, job, doc.segments)
543 return inRoot($, job, dir, doc, run => runFonts($, job, fonts, fonts => {
544 const { argv, env: vars } = typstCall({ typst: job.env.typst!, root: job.env.root, run, cache: job.env.cache, ppi, inputs, out: `${dir}/${tag}-{0p}.png`, ...(fonts ? { fonts } : {}) })
545 return runKillable($, argv, { env: vars, timeoutMs: COMPILE_TIMEOUT_MS, ...(signal ? { signal } : {}) })
546 }))
547}
548
549// ---- The probe: where the words are ---------------------------------------
550
551// `typst query` of the template's <mdpos> marks with probe=1: the PNG
552// compile's own inputs (and `page`), root (a run folder of its own), package
553// path, fonts and env (no ppi, format or out).
554export function probeCall(o: { typst: string; root: string; run: string; cache: string; inputs: Readonly<Record<string, string | number>>; fonts?: string }): { argv: string[]; env: Record<string, string> } {
555 const argv = [
556 o.typst, 'query',
557 '--root', o.run,
558 '--package-path', `${o.root}/render/packages`,
559 ...fontArgs(o.fonts),
560 '--diagnostic-format', 'short',
561 ...Object.entries({ ...o.inputs, probe: 1 }).flatMap(([k, v]) => ['--input', `${k}=${v}`]),
562 templateIn(o.run, o.root),
563 '<mdpos>',
564 '--field', 'value',
565 ]
566 return { argv, env: noNetworkEnv(o.cache) }
567}
568
569// A drawn page's file says how it was compiled: `b` and `d` (the split
570// retry) are the safe mode's tags (`f` before them: stored for flip mode).
571export const isSafePage = (file: string) => /\/f?[bd]-\d+\.png$/.test(file)
572
573// The word positions of page `page` (0-based) of a key folder: the probe
574// of that page only (page=<n>), with the inputs of the PNG compile, built
575// from the Job that drew it and the segments in its doc.json. Null when it
576// fails, or when doc.json does not hold one segment per page drawn (a split
577// retry that failed keeps the pages of the first compile).
578export async function probePage(
579 $: EngineInterface, job: Job, dir: string, o: { page: number; pages: number; isSafe: boolean },
580): Promise<Positions | null> {
581 let kept: Partial<Doc> | null = null
582 try {
583 kept = JSON.parse((await cat($, `${dir}/doc.json`)) as string) as Partial<Doc>
584 } catch {
585 return null
586 }
587 const segments = kept?.segments
588 if (!Array.isArray(segments) || segments.length !== o.pages || !job.env.typst) return null
589 const typst = job.env.typst
590 const images = Array.isArray(kept!.images) ? kept!.images.filter((p): p is string => typeof p === 'string') : []
591 const doc = docOf(typeof kept!.base === 'string' ? kept!.base : '', segments, images)
592 const { inputs, ppi } = typstInputs(job, segments, dir, o.isSafe)
593 const fonts = await leanFor($, job, segments)
594 const ran = await inRoot($, job, dir, doc, run => runFonts($, job, fonts, fonts => {
595 const { argv, env } = probeCall({ typst, root: job.env.root, run, cache: job.env.cache, inputs: { ...inputs, page: o.page + 1 }, ...(fonts ? { fonts } : {}) })
596 return runKillable($, argv, { env, timeoutMs: COMPILE_TIMEOUT_MS })
597 }))
598 if (ran.exitCode !== 0) {
599 await logDebug($, job.env.cache, `probe ${dir} page ${o.page + 1} failed:\n${ran.stderr}`)
600 return null
601 }
602 try {
603 return positionsOf(JSON.parse(ran.stdout) as Mark[], ppi)
604 } catch {
605 return null
606 }
607}
608
609// The pages of one run: its PNGs (by tag) in order, with their sizes and
610// the box each takes. Null when there are none or a size can not be read.
611async function pagesOfRun($: EngineInterface, job: Job, dir: string, tag: string, segments: readonly string[]): Promise<Page[] | null> {
612 const files = (await ls($, dir))
613 .filter(f => f.kind === 'file' && f.name.startsWith(`${tag}-`) && f.name.endsWith('.png'))
614 .map(f => f.name)
615 .sort((x, y) => Number(x.replace(/\D/g, '')) - Number(y.replace(/\D/g, '')))
616 .map(name => `${dir}/${name}`)
617 if (!files.length) return null
618 const heads = await exec($, ['sh', '-c', HEADERS_SH, 'sh', ...files])
619 const sizes = parseOd(heads.stdout).map(pngSize)
620 if (sizes.length !== files.length || sizes.some(s => !s)) {
621 await logDebug($, job.env.cache, `compile ${job.key}: could not read PNG sizes`)
622 return null
623 }
624 return files.map((file, i) => ({
625 file, ...sizes[i]!, alt: plainOf(segments[i] ?? job.text), columns: job.columns, rows: pageRowsOf(sizes[i]!.height, job.cell),
626 }))
627}
628
629// Compiles a job's text to its pages (kept in its key folder). `signal`
630// stops it: a run killed by it, or a retry not begun, is 'cancelled' (not a
631// result: nothing is kept).
632export async function compile($: EngineInterface, job: Job, signal?: AbortSignal): Promise<Result | 'failed' | 'cancelled'> {
633 const dir = `${job.env.cache}/${job.key}`
634 const kept = await cat($, `${dir}/pages.json`).catch(() => null)
635 const keptResult = kept ? resultOf(kept) : null
636 if (keptResult) {
637 touchLater($, dir)
638 return keptResult
639 }
640 if (signal?.aborted) return 'cancelled'
641
642 const { rowPt } = sizing(job.columns, job.cell, fontFit(job.env.metrics, job.size).columnPt)
643 // The room height of a drawing in rows (less the pt rounding); unknown
644 // without a room (the default applies).
645 const roomRows = job.room ? Math.ceil(job.room.heightPt / rowPt - 0.01) : undefined
646 let segments = splitSegments(job.text, job.columns, segmentBudget(job.env, job.columns, job.cell), job.size, roomRows)
647 const base = job.base ?? job.env.cwd
648 const images = await existingImages($, segments, base)
649 const doc = docOf(base, segments, images)
650 await write($, `${dir}/doc.json`, JSON.stringify(doc))
651
652 let tag = 'a'
653 let isSafe = false
654 let ran = await runTypst($, job, doc, dir, tag, false, signal)
655 let note: string | undefined
656 if (ran.isCancelled) return 'cancelled'
657 if (ran.exitCode !== 0) {
658 if (signal?.aborted) return 'cancelled'
659 await logDebug($, job.env.cache, `compile ${job.key} failed:\n${ran.stderr}`)
660 if (!shouldRetrySafe(ran.exitCode)) return 'failed'
661 note = firstLine(ran.stderr.split('\n').find(l => /error:/i.test(l)) ?? ran.stderr).replace(/^.*error:\s*/i, '')
662 tag = 'b'
663 isSafe = true
664 ran = await runTypst($, job, doc, dir, tag, true, signal)
665 if (ran.isCancelled) return 'cancelled'
666 if (ran.exitCode !== 0) {
667 await logDebug($, job.env.cache, `compile ${job.key} with safe=1 failed:\n${ran.stderr}`)
668 return 'failed'
669 }
670 }
671
672 let pages = await pagesOfRun($, job, dir, tag, segments)
673 if (!pages) return 'failed'
674 // A page taller than an Image can be (the row estimate was short): its
675 // segment is cut in two and the whole text compiled once more. If that
676 // fails, the first pages stay (drawn at most MAX_IMAGE_ROWS tall).
677 if (pages.some(p => p.rows > MAX_IMAGE_ROWS)) {
678 const cut = segments.flatMap((seg, i) => (pages![i]!.rows > MAX_IMAGE_ROWS ? splitInTwo(seg, job.columns, job.size, roomRows) : [seg]))
679 if (cut.length > segments.length) {
680 if (signal?.aborted) return 'cancelled'
681 const cutDoc = docOf(base, cut, images)
682 await write($, `${dir}/doc.json`, JSON.stringify(cutDoc))
683 const again = await runTypst($, job, cutDoc, dir, isSafe ? 'd' : 'c', isSafe, signal)
684 if (again.isCancelled) return 'cancelled'
685 const split = again.exitCode === 0 ? await pagesOfRun($, job, dir, isSafe ? 'd' : 'c', cut) : null
686 if (split) {
687 pages = split
688 segments = cut
689 } else await logDebug($, job.env.cache, `compile ${job.key}: the split retry failed:\n${again.stderr}`)
690 }
691 pages = pages.map(p => ({ ...p, rows: Math.min(MAX_IMAGE_ROWS, p.rows) }))
692 }
693 // Flip mode: each page stored for herdr in iTerm2 (fa-1.png from a-1.png); a page that can not be stored fails the compile (the text shows).
694 if (job.flip) {
695 const flipped = await flipPages($, job, pages, signal)
696 if (typeof flipped === 'string') return flipped
697 pages = flipped
698 }
699 const { bulletRow } = firstLinePlace(job, segments)
700 const result: Result = { pages, bulletRow, ...(note ? { note } : {}) }
701 await write($, `${dir}/pages.json`, JSON.stringify(result))
702 return result
703}
704
705// The pages of a compile stored for flip mode (flip.ts: rows × B pixels
706// tall, the bands reversed), each as a file of its own (`f` before its
707// name: a flip of a flipped page would undo it), with the height its rows map to (flipSteps).
708async function flipPages($: EngineInterface, job: Job, pages: readonly Page[], signal?: AbortSignal): Promise<Page[] | 'failed' | 'cancelled'> {
709 const flipped: Page[] = []
710 for (const p of pages) {
711 const out = p.file.replace(/\/([^/]*)$/, '/f$1')
712 const made = await flipFile($, { src: p.file, out, rows: p.rows, isPage: true, perl: perlFor(job.env.isMac), ...(signal ? { signal } : {}) })
713 if (signal?.aborted) return 'cancelled'
714 if (!made) {
715 await logDebug($, job.env.cache, `compile ${job.key}: could not store ${p.file} for herdr in iTerm2`)
716 return 'failed'
717 }
718 flipped.push({ ...p, file: out, height: made.height, flip: true })
719 }
720 return flipped
721}
722hooks/lib/draw.tsx 360 lines1// The pictures of an answer: the key of its inputs, the page width and ppi
2// that match the terminal's text, where its first line sits, and the
3// drawing of its pages beside the reply bullet, with the rows of its links
4// under them.
5import type { EngineInterface, RenderComponent, RenderSurface, RenderViewport, ResolveInput } from 'claude-code'
6
7import type { IuiTheme } from '../../types'
8import { BODY_PT, bulletOf, GRID_VERSION, GUTTER, LINK_FILES_KEPT, MARGIN_TOP, RESULTS_KEPT, TEMPLATE_VERSION } from './settings'
9import { INLINE_KEPT, inlined, keep, linkFiles, ownCopy, S, type Cell, type Look, type Metrics, type Result, type Room } from './state'
10import { DEFAULT_GRID, gridFirstRow, type GridMetrics } from './grid'
11import { FENCE, imagePathOf } from './markdown'
12import { linksOf, type LinkItem } from './links'
13import { opensPlain } from './open'
14import { fitBox, fitsInline, roomColumns } from './terminal'
15import { clickLinks, selectOn } from './select'
16import { noteSite } from './blit'
17import { stat, cat, copy, uiResolve, toast } from './api'
18
19// FNV-1a over the parts, twice with different offsets: 16 hex digits.
20export function hashOf(parts: readonly (string | number)[]): string {
21 const s = parts.join('\u241f')
22 let a = 0x811c9dc5
23 let b = 0x050c5d1f
24 for (let i = 0; i < s.length; i++) {
25 const c = s.charCodeAt(i)
26 a = Math.imul(a ^ c, 0x01000193) >>> 0
27 b = Math.imul(b ^ c, 0x01000193) >>> 0
28 }
29 return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
30}
31
32// How wide a column is in the page's pt so the template's 11 pt body text
33// looks as big as the terminal's text, `sizePercent` on top. A terminal em is
34// a cell / the advance of its font; the body font is matched to it by
35// x-height and average width together (sqrt of the two ratios), since a
36// proportional font at the same em looks smaller. Code is set in the
37// terminal's font at the terminal's size: `code` is its size / body size.
38// The template uses `code` only for a theme that sets "size": "terminal"
39// for code; the vscode themes draw code at body size, as VS Code does.
40export function fontFit(m: Metrics, sizePercent = 100) {
41 const t = m.term ?? m.menlo
42 const ratio = Math.sqrt((t.xh / m.body.xh) * (t.avg / m.body.avg))
43 const scale = sizePercent / 100
44 return {
45 columnPt: Math.round(((BODY_PT * t.adv) / (ratio * scale)) * 10000) / 10000,
46 code: Math.round((1 / ratio) * 10000) / 10000,
47 ratio,
48 }
49}
50
51// The page width in pt and the ppi that make the PNG exactly as wide as the
52// box in device pixels; `rowPt` is one terminal row in the page's pt.
53export function sizing(columns: number, px: Cell, columnPt: number) {
54 const widthPt = Math.round(columns * columnPt * 100) / 100
55 const widthPx = Math.round(columns * px.width)
56 const ppi = widthPx / (widthPt / 72)
57 return { widthPt, widthPx, ppi, rowPt: (px.height * 72) / ppi }
58}
59
60// Every input a picture depends on, as one key.
61export function renderKey(o: {
62 textHash: string
63 columns: number
64 cell: Cell
65 size: number
66 mode: IuiTheme
67 termFont: string
68 columnPt: number
69 templateStamp: string
70 look: Look
71 // Over ssh: pages are cut shorter (inlineBudget), so they are other PNGs.
72 inline?: boolean
73 // Under herdr: pages are cut shorter too (herdrBudget).
74 herdr?: boolean
75 // Flip mode (herdr in iTerm2): pages stored with their bands moved (flip.ts).
76 flip?: boolean
77 // The folder relative images start from, when it is not the session's (a
78 // Markdown file's): the same text there shows other images.
79 base?: string
80 // Only for a text with a live Typst block or a local image (needsRoom):
81 // the room height and the design. A plain text's key does not change
82 // with the rows.
83 room?: Room
84 // `/iui grid` on: the lines on the row grid, another picture (with
85 // GRID_VERSION). Off adds nothing, so the keys of before stay.
86 grid?: boolean
87 // The fake bold of a terminal font with no bold face (fauxBoldOf).
88 fauxBold?: boolean
89}): string {
90 const { room } = o
91 return hashOf([
92 o.textHash, o.columns, o.cell.width, o.cell.height, o.size, o.mode, o.termFont, o.columnPt,
93 o.templateStamp, o.look.name, o.look.dir, o.look.stamp, TEMPLATE_VERSION, ...(o.inline ? ['inline'] : []), ...(o.herdr ? ['herdr'] : []),
94 ...(o.flip ? ['flip'] : []),
95 ...(o.base ? ['base', o.base] : []),
96 ...(room ? ['room', room.heightPt, room.design.widthPt, room.design.heightPt] : []),
97 ...(o.grid ? ['grid', GRID_VERSION] : []),
98 ...(o.fauxBold ? ['faux-bold'] : []),
99 ])
100}
101
102// Where the first line of a page's text lies with no top margin, in the
103// template's pt (measured from its PNGs at 11 pt body text, vscode): the
104// centre of its ink, and the top of its ink. By what the answer starts with.
105export type FirstLine = 'para' | 'h1' | 'h2' | 'h3' | 'h4' | 'quote' | 'code' | 'table'
106const FIRST_LINE: Record<FirstLine, { centre: number; inkTop: number }> = {
107 para: { centre: 7.45, inkTop: 2.55 },
108 h1: { centre: 20.36, inkTop: 5.82 },
109 h2: { centre: 13.09, inkTop: 3.64 },
110 h3: { centre: 9.09, inkTop: 2.55 },
111 h4: { centre: 7.27, inkTop: 2.18 },
112 quote: { centre: 13.45, inkTop: 8.36 },
113 code: { centre: 17.09, inkTop: 12.36 },
114 table: { centre: 11.27, inkTop: 6.18 },
115}
116
117const HEADING = /^ {0,3}(#{1,6})\s/
118const firstTextLine = (md: string) => md.split('\n').find(l => l.trim()) ?? ''
119export function firstLineOf(md: string): FirstLine {
120 const line = firstTextLine(md)
121 const heading = HEADING.exec(line)
122 if (heading) return (['h1', 'h2', 'h3'] as const)[heading[1]!.length - 1] ?? 'h4'
123 if (/^ {0,3}>/.test(line)) return 'quote'
124 if (FENCE.test(line)) return 'code'
125 if (/^ {0,3}\|/.test(line)) return 'table'
126 return 'para'
127}
128
129// FIRST_LINE in a theme (its grid numbers): a quote's, code block's or
130// table's first line moves by the theme's padding less vscode's. A "grid"
131// table's ink starts at its box's top rule, so the box is not cut.
132function firstLineIn(kind: FirstLine, g: GridMetrics): { centre: number; inkTop: number } {
133 const at = FIRST_LINE[kind]
134 if (kind !== 'quote' && kind !== 'code' && kind !== 'table') return at
135 const more = g.pad[kind] - DEFAULT_GRID.pad[kind]
136 return { centre: at.centre + more, inkTop: kind === 'table' && g.tableStyle === 'grid' ? 0 : at.inkTop + more }
137}
138
139// The first page's top margin (pt, may be negative) that puts the centre of
140// the first line of text on the centre of a terminal row, and which row that
141// is: row 0 when the line fits there, else the first row its ink can reach
142// without being cut at the picture's top. The bullet is drawn on that row.
143// `g`: the theme's grid numbers (its padding).
144export function alignFirstLine(kind: FirstLine, rowPt: number, g: GridMetrics = DEFAULT_GRID): { topPt: number; bulletRow: number } {
145 const { centre, inkTop } = firstLineIn(kind, g)
146 const bulletRow = Math.max(0, Math.ceil((centre - inkTop + 0.5) / rowPt - 0.5 - 1e-9))
147 return { topPt: Math.round(((bulletRow + 0.5) * rowPt - centre) * 100) / 100, bulletRow }
148}
149
150// The first page's top margin and bullet row. With the row grid (the
151// fonts' grid numbers and the job's code size): no margin, and the row of
152// the first line's ink centre (gridFirstRow). Off the grid, `metrics` gives
153// the theme's padding.
154export function placeFirstLine(
155 md: string, rowPt: number, grid?: { metrics: GridMetrics; code: number }, metrics: GridMetrics = grid?.metrics ?? DEFAULT_GRID,
156): { topPt: number; bulletRow: number } {
157 const kind = firstLineOf(md)
158 if (!grid) return alignFirstLine(kind, rowPt, metrics)
159 const level = HEADING.exec(firstTextLine(md))?.[1]!.length ?? 0
160 return { topPt: 0, bulletRow: gridFirstRow(kind, level, rowPt, grid.metrics, grid.code) }
161}
162
163// Over ssh: every page's PNG as base64 for a `{ png }` source, or null when
164// a page cannot go (over INLINE_MAX_SIDE or INLINE_MAX_BYTES, or not read),
165// so the message shows as text.
166export async function inlineOf($: EngineInterface, result: Result): Promise<string[] | null> {
167 const out: string[] = []
168 for (const page of result.pages) {
169 if (!fitsInline(page)) return null
170 if (!inlined.has(page.file)) {
171 const got = await cat($, page.file, { as: 'bytes' }).catch(() => null)
172 const base64 = got && fitsInline(page, got.base64) ? got.base64 : null
173 if (got) inlined.set(page.file, base64)
174 for (const old of inlined.keys()) {
175 if (inlined.size <= INLINE_KEPT) break
176 inlined.delete(old)
177 }
178 if (!base64) return null
179 out.push(base64)
180 continue
181 }
182 const base64 = inlined.get(page.file)
183 if (!base64) return null
184 out.push(base64)
185 }
186 return out
187}
188
189// How a set of pages sits: `bullet` the reply bullet in the gutter (on the
190// first line's row), `gutter` its width (0: none), `marginTop` the rows above,
191// `keys` a prefix for its Images' and Buttons' keys (several sets in one
192// tree), `links` the link rows under the pages (one row each; none drawn
193// when the pages take picture select: a click on the picture copies or
194// opens them), `id` the drawing's id in drawnAnswers (its pages take
195// picture select, its Images swapped by key under the render's requestId:
196// blit.ts).
197export type PagesLayout = { bullet?: boolean; gutter?: number; marginTop?: number; keys?: string; links?: readonly ShownLink[]; id?: string }
198
199// The bullet's row when the page is drawn `factor` as wide.
200export function scaledBulletRow(bulletRow: number, factor: number): number {
201 if (factor >= 1) return bulletRow
202 return Math.max(0, Math.round((bulletRow + 0.5) * factor - 0.5))
203}
204
205// Whether pages drawn in this render take picture select (with their
206// Client): from files (not `inline`, as bytes over ssh), in an engine with
207// the Client element, and selectOn.
208export function takesSelect($: EngineInterface, e: ResolveInput<RenderComponent, 'terminal'> & { viewport?: RenderViewport }, inline?: readonly string[]): boolean {
209 const table = uiResolve($, e)
210 const Client: typeof table.Client | undefined = table.Client
211 return !inline && !!Client && selectOn({ hasClient: true, isFullscreen: e.viewport?.isFullscreen })
212}
213
214// The link rows drawn under pages (one each): none when they take picture select.
215export const linkRowsOf = (links: readonly ShownLink[] | undefined, isSelect: boolean) => (isSelect ? 0 : (links?.length ?? 0))
216
217// `inline`: each page's PNG as base64 (over ssh); else the terminal reads the
218// file. Each page draws at its own box, never stretched. A page wider than
219// the room now (an old one in an open epoch) draws smaller to fit, the
220// bullet scaled too: the engine shifts a wider Image, it does not clip it.
221export function drawPages(
222 $: EngineInterface, e: ResolveInput<RenderComponent, 'terminal'> & { viewport?: RenderViewport; requestId?: string }, result: Result, inline?: readonly string[], layout: PagesLayout = {},
223) {
224 const table = uiResolve($, e)
225 const { Box, Text, Image } = table
226 // Absent in an engine with no Client element.
227 const Client: typeof table.Client | undefined = table.Client
228 const gutter = layout.gutter ?? GUTTER
229 const room = roomColumns(e.viewport)
230 const first = result.pages[0]
231 const factor = first && first.columns > room ? room / first.columns : 1
232 const keys = layout.keys ?? ''
233 const boxes = result.pages.map(page => fitBox(page, room))
234 // Picture select: each page in a Box of its size with the Client that
235 // takes the pointer over it, in the flow, pulled up over the page
236 // (marginTop): never absolute (the engine moves an absolute Box whose top
237 // is above the screen down to row 0, its pointer too). The highlight
238 // swaps the Image's picture (blit.ts); the layout stays.
239 const id = layout.id
240 const isSelect = !!id && !!Client && takesSelect($, e, inline)
241 if (isSelect && e.requestId) noteSite(id, e.requestId, keys)
242 if (isSelect) keep(clickLinks, id, layout.links ?? [], RESULTS_KEPT)
243 return (
244 <Box flexDirection="row" marginTop={layout.marginTop ?? MARGIN_TOP}>
245 {gutter > 0 ? (
246 <Box width={gutter} flexShrink={0} marginTop={scaledBulletRow(result.bulletRow, factor)}>
247 <Text>{layout.bullet ? bulletOf(S.env?.isMac ?? true) : ' '}</Text>
248 </Box>
249 ) : null}
250 <Box flexDirection="column" flexShrink={0}>
251 {result.pages.map((page, i) => {
252 const box = boxes[i]!
253 const shrunk = box.columns !== page.columns
254 const image = (
255 <Image
256 key={`${keys}p${i}`}
257 source={inline ? { png: inline[i]! } : { file: page.file, format: 'png', ...(shrunk ? { generation: box.columns * 1000 + box.rows } : {}) }}
258 columns={box.columns}
259 rows={box.rows}
260 alt={page.alt}
261 />
262 )
263 if (!isSelect || !Client || !id) return image
264 return (
265 <Box key={`${keys}w${i}`} flexDirection="column" width={box.columns} height={box.rows} flexShrink={0}>
266 {image}
267 <Box key={`${keys}c${i}`} marginTop={-box.rows} width={box.columns} height={box.rows} flexShrink={0}>
268 <Client key={`${keys}sel${i}`} module="../sel.tsx" width={box.columns} height={box.rows} props={{ id, page: i }} />
269 </Box>
270 </Box>
271 )
272 })}
273 {result.note ? <Text dimColor wrap="truncate-end">Typst error: {result.note}</Text> : null}
274 {layout.links?.length && !isSelect ? drawLinks($, e, layout.links, keys) : null}
275 </Box>
276 </Box>
277 )
278}
279
280// ---- Links ------------------------------------------------------------------
281// A click on a picture cannot open a link (an Image is a leaf: no Link holds
282// it, and a press on it gives no cell). So the links, file paths and commands
283// of a text are drawn under its pages, one row each; where the pages take
284// picture select, its Client finds them on the picture (targets.ts) instead.
285
286// A link row: a web link, a file found (`path` its absolute path, `href` its
287// file:// URL), or a command.
288export type ShownLink = Exclude<LinkItem, { kind: 'file' }> | { kind: 'file'; label: string; path: string; href: string }
289
290// The file:// URL of an absolute path, percent-encoded (`#` and `?` too).
291export const fileHref = (path: string) => `file://${encodeURI(path).replace(/[?#]/g, encodeURIComponent)}`
292
293// The items as link rows: each file path made absolute from `base` (the
294// folder relative paths start from; `~/` is the home folder) and kept only
295// when it is a regular file now: no folder (an .app), no symbolic link
296// (each path checked once: linkFiles).
297export async function resolveLinks($: EngineInterface, items: readonly LinkItem[], base: string, home = S.env?.home ?? ''): Promise<ShownLink[]> {
298 const shown: ShownLink[] = []
299 for (const item of items) {
300 if (item.kind !== 'file') {
301 shown.push(item)
302 continue
303 }
304 const path = home && item.path.startsWith('~/') ? `${home.replace(/\/+$/, '')}${item.path.slice(1)}` : imagePathOf(item.path, base)
305 let isFile = linkFiles.get(path)
306 if (isFile === undefined) {
307 const st = await stat($, path).catch(() => null)
308 isFile = st?.kind === 'file' && !st.isLink
309 keep(linkFiles, path, isFile, LINK_FILES_KEPT)
310 }
311 if (isFile) shown.push({ kind: 'file', label: item.label, path, href: fileHref(path) })
312 }
313 return shown
314}
315
316// The link rows of a Markdown text (`base` as resolveLinks); none when it
317// cannot be read for them.
318export async function linksFor($: EngineInterface, text: string, base: string): Promise<ShownLink[]> {
319 try {
320 return await resolveLinks($, linksOf(text), base)
321 } catch {
322 return []
323 }
324}
325
326// Copies a command at a press of its row, with a toast.
327async function copyCommand($: EngineInterface, text: string, surface: RenderSurface) {
328 ownCopy.note(text)
329 const copied = await copy($, { text, surface }).catch(() => null)
330 toast($, copied?.isCopied ? 'Copied' : `Could not copy${copied ? `: ${copied.reason}` : ''}`)
331}
332
333// One row per link, all dim and cut at the row's end (the copy counts them
334// as one row each): a web link (its URL after it when the label is not the
335// URL), a file, or a command as a Button that copies it.
336export function drawLinks($: EngineInterface, e: ResolveInput<RenderComponent, 'terminal'>, links: readonly ShownLink[], keys: string) {
337 const { Box, Text, Link, Button } = uiResolve($, e)
338 return links.map(link => {
339 if (link.kind === 'command') {
340 const { text } = link
341 return (
342 <Box height={1} overflow="hidden">
343 <Button key={`${keys}link-${hashOf([text]).slice(0, 8)}`} plain dimColor onPress={press => void copyCommand($, text, press.surface)}>
344 <Text wrap="truncate-end">$ {link.label}</Text>
345 </Button>
346 </Box>
347 )
348 }
349 const href = link.href
350 // A file a plain open could run (the terminal opens a file:// link so): its path, no link.
351 if (link.kind === 'file' && !opensPlain(link.path)) return <Text dimColor wrap="truncate-end">· {link.label}</Text>
352 return (
353 <Text dimColor wrap="truncate-end">
354 ↗ <Link href={href}>{link.label}</Link>
355 {link.kind === 'web' && link.label !== href ? ` ${href}` : null}
356 </Text>
357 )
358 })
359}
360hooks/lib/select.ts 274 lines1// Picture select: a mouse selection over the pictures of an answer, a plan
2// or a Markdown file Claude sent. Claude Code's own selection copies an
3// Image's cells as kitty placeholders (2.1.305 and newer skip them), so a
4// Client (hooks/sel.tsx) over each page takes the pointer and posts the
5// selection; the hooks module shows it on the page and copies its text on
6// the button's up. The highlight swaps the page's picture (blit.ts); the page's
7// layout never changes. The pure parts are here, with the selection now.
8import type { EngineInterface } from 'claude-code'
9
10import { SELECT_SINCE } from './settings'
11import { drawnAnswers, S } from './state'
12import { compareVersions } from './updates'
13import { joinSegments, pageTopsOf, textOfRun, type Drawn, type Item, type RunLine, type TextBlock } from './copy'
14import { endFold, foldNew, FRAME_MS, pokeFold, showSelection, startFold } from './blit'
15import { delay, every, uiSelection } from './api'
16import { targetAt, type PageTarget } from './targets'
17import type { ShownLink } from './draw'
18
19export type Point = { x: number; y: number }
20export type SelectPhase = 'down' | 'move' | 'up' | 'clear' | 'hover' | 'leave'
21// A post of the Client (hooks/sel.tsx): the drawing's id (drawnAnswers),
22// the page whose region posted, the anchor (where the button went down)
23// and the head (the pointer now) in that region's cells, and the post's
24// order (one count for every instance). `size`: the region's columns and
25// rows as the Client has them (for the trace). A hover (the pointer with no
26// button down; anchor and head its cell) or a leave carries the up or clear
27// posted before it (`end`): a later post of one frame replaces an earlier
28// one. `alt`: a clear whose down had alt held (the click copies).
29export type SelectPost = { id: string; page: number; anchor: Point; head: Point; phase: SelectPhase; seq: number; size?: Box; alt?: boolean; end?: SelectPost }
30// The selection: anchor and head in the drawing's rows (the page tops
31// added), `isUp` once the button came up.
32export type Selection = { id: string; anchor: Point; head: Point; isUp: boolean }
33// One selected row of a page: its row in the page and its first and last
34// columns (inclusive).
35export type SelRow = { row: number; x0: number; x1: number }
36type Box = { columns: number; rows: number }
37
38// The one selection over the pictures (null: none), the order of the last
39// post taken, and a Client that failed (`isBroken`: no select until a reload).
40export const picked = { now: null as Selection | null, seq: 0, isBroken: false }
41
42// The hover: the cell under the pointer with no button down (`at`, null:
43// off the pictures) and the target tinted now (`lit`: its drawing, page,
44// index in the page's targets and cells; null: none). The tint shows only
45// while no selection does, through the selection's blits.
46export type Hover = { id: string; page: number; x: number; y: number }
47export type Lit = { id: string; page: number; index: number; rows: SelRow[] }
48export const hovered = { at: null as Hover | null, lit: null as Lit | null }
49
50// Each drawing's links when its pages take picture select (drawPages: its
51// link rows are then not drawn), by its id (the RESULTS_KEPT drawn last):
52// the files a click copies.
53export const clickLinks = new Map<string, readonly ShownLink[]>()
54
55// Shows the selection now on the pages (blit.ts), at most once a frame: the
56// posts between fold into one, the newest.
57let fold = foldNew()
58export function redrawSelection($: EngineInterface) {
59 const next = pokeFold(fold, performance.now(), FRAME_MS)
60 if (next.kind === 'wait') delay($, next.ms, () => void runSelection($))
61 else if (next.kind === 'run') void runSelection($)
62}
63
64// A page shown in part (its tinted rows not all made) or drawn again
65// meanwhile: one more run, a frame later.
66async function runSelection($: EngineInterface) {
67 startFold(fold, performance.now())
68 const again = await showSelection($, selectedNow()).catch(() => false)
69 if (endFold(fold) || again) redrawSelection($)
70}
71
72// The selection's drawing, its pages and their selected rows; with no
73// selection, the hovered target's (none: null).
74export function selectedNow() {
75 const sel = picked.now
76 if (sel) {
77 const d = drawnAnswers.get(sel.id)
78 return d ? { id: sel.id, pages: d.pages, rows: selectedRows(sel, d.pages) } : null
79 }
80 const lit = hovered.lit
81 const d = lit ? drawnAnswers.get(lit.id) : undefined
82 return lit && d ? { id: lit.id, pages: d.pages, rows: d.pages.map((_, i) => (i === lit.page ? lit.rows : [])) } : null
83}
84
85// Forgets the fold, the selection, the hover and the native watch (for tests).
86export function forgetRedraws() {
87 fold = foldNew()
88 stopNative()
89 picked.now = null
90 picked.seq = 0
91 picked.isBroken = false
92 hovered.at = null
93 hovered.lit = null
94 clickLinks.clear()
95}
96
97// The target under a hover, tinted (null: none).
98export function litOf(at: Hover, targets: readonly PageTarget[]): Lit | null {
99 const index = targetAt(targets, at.x, at.y)
100 const t = targets[index]
101 return t ? { id: at.id, page: at.page, index, rows: t.rows } : null
102}
103
104// Whether two tints are the same target (its cells by reference: one page's targets are kept).
105export const sameLit = (a: Lit | null, b: Lit | null) => a === b || (!!a && !!b && a.id === b.id && a.page === b.page && a.index === b.index && a.rows === b.rows)
106
107// A native selection (Claude Code's own) made while a picture selection
108// shows takes ours down (`clear`): no event says so, so `$.ui.selection()`
109// is read every NATIVE_POLL_MS while ours shows, one timer at most. Its
110// answer stays the last one made, so only a new one counts (one there
111// before ours stays as it is).
112export const NATIVE_POLL_MS = 250
113// Not read yet; no nativeOf value is this (those hold a '|').
114const UNREAD = 'unread'
115const native = { timer: null as { cancel: () => void } | null, last: UNREAD as string | undefined }
116const nativeOf = (s: { text: string; requestId?: string } | undefined) => (s ? `${s.requestId ?? ''}|${s.text}` : undefined)
117
118export function watchNative($: EngineInterface, clear: () => void) {
119 if (!picked.now) return stopNative()
120 if (native.timer) return
121 native.last = UNREAD
122 try {
123 native.timer = every($, NATIVE_POLL_MS, () => void pollNative($, clear).catch(() => undefined))
124 } catch {
125 native.timer = null
126 return
127 }
128 void Promise.resolve(uiSelection($)).then(got => {
129 if (native.last === UNREAD) native.last = nativeOf(got)
130 }, () => undefined)
131}
132
133async function pollNative($: EngineInterface, clear: () => void) {
134 const now = nativeOf(await uiSelection($).catch(() => undefined))
135 if (!native.timer) return
136 const was = native.last
137 native.last = now
138 if (now !== undefined && was !== UNREAD && now !== was) clear()
139}
140
141export function stopNative() {
142 native.timer?.cancel()
143 native.timer = null
144}
145
146const isInt = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v)
147const pointOf = (v: unknown): Point | null => {
148 const p = v as Partial<Point> | null
149 return p && isInt(p.x) && isInt(p.y) ? { x: p.x, y: p.y } : null
150}
151
152const PHASES: readonly unknown[] = ['down', 'move', 'up', 'clear', 'hover', 'leave'] satisfies SelectPhase[]
153
154// A Client's post as a SelectPost; null when it is not one (data from code).
155// Its `end` only as an up or a clear (with no end of its own).
156export function postOf(data: unknown, isEnd = false): SelectPost | null {
157 const d = data as Partial<Record<keyof SelectPost, unknown>> | null
158 if (!d || typeof d !== 'object' || typeof d.id !== 'string' || !isInt(d.page) || d.page < 0 || !isInt(d.seq)) return null
159 const anchor = pointOf(d.anchor)
160 const head = pointOf(d.head)
161 const phase = d.phase as SelectPhase
162 if (!anchor || !head || !PHASES.includes(phase) || (isEnd && phase !== 'up' && phase !== 'clear')) return null
163 const size = d.size as Partial<Box> | null | undefined
164 const known = size && isInt(size.columns) && isInt(size.rows) ? { size: { columns: size.columns, rows: size.rows } } : {}
165 const end = !isEnd && (phase === 'hover' || phase === 'leave') && d.end ? postOf(d.end, true) : null
166 const alt = phase === 'clear' && d.alt === true ? { alt: true } : {}
167 return { id: d.id, page: d.page, anchor, head, phase, seq: d.seq, ...known, ...alt, ...(end ? { end } : {}) }
168}
169
170// The row each page starts at in its drawing (the pages one under the other).
171export function topsOf(pages: readonly Box[]): number[] {
172 let at = 0
173 return pages.map(p => (at += p.rows) - p.rows)
174}
175
176// The selection after a post: a down, a move or an up selects from its
177// anchor to its head (a down on another picture drops the old one); a
178// clear (a click with no drag) leaves none, and a hover or a leave (no
179// drag) none either. `tops`: the drawing's page tops.
180export function selectionAfter(post: SelectPost, tops: readonly number[]): Selection | null {
181 const top = tops[post.page]
182 if (post.phase === 'clear' || post.phase === 'hover' || post.phase === 'leave' || top === undefined) return null
183 const at = (p: Point) => ({ x: p.x, y: p.y + top })
184 return { id: post.id, anchor: at(post.anchor), head: at(post.head), isUp: post.phase === 'up' }
185}
186
187// The selected cells of each page in stream order: the first row from the
188// first point's column to its end, the rows between whole, the last row to
189// the last point's column. A point above the drawing is its first cell, one
190// below it its last. Anchor and head on one cell select nothing.
191export function selectedRows(sel: { anchor: Point; head: Point }, pages: readonly Box[]): SelRow[][] {
192 const out = pages.map((): SelRow[] => [])
193 const total = pages.reduce((n, p) => n + p.rows, 0)
194 if (!total || (sel.anchor.x === sel.head.x && sel.anchor.y === sel.head.y)) return out
195 const before = (p: Point, q: Point) => p.y < q.y || (p.y === q.y && p.x < q.x)
196 const [first, last] = before(sel.head, sel.anchor) ? [sel.head, sel.anchor] : [sel.anchor, sel.head]
197 const inside = (p: Point): Point => (p.y < 0 ? { x: 0, y: 0 } : p.y >= total ? { x: Number.MAX_SAFE_INTEGER, y: total - 1 } : p)
198 const a = inside(first)
199 const b = inside(last)
200 topsOf(pages).forEach((top, i) => {
201 const { columns, rows } = pages[i]!
202 const clamp = (x: number) => Math.max(0, Math.min(columns - 1, x))
203 for (let y = Math.max(a.y, top); y <= Math.min(b.y, top + rows - 1); y++) {
204 const x0 = y === a.y ? clamp(a.x) : 0
205 const x1 = y === b.y ? clamp(b.x) : columns - 1
206 if (x1 >= x0) out[i]!.push({ row: y - top, x0, x1 })
207 }
208 })
209 return out
210}
211
212// The rows of a page scrolled away above the viewport: the message's first
213// row on screen less the page's top in the message, 0 to the page's rows.
214// Undefined when either is not known.
215export function rowsAbove(first: number | undefined, top: number | undefined, rows: number): number | undefined {
216 return first === undefined || top === undefined ? undefined : Math.max(0, Math.min(rows, first - top))
217}
218
219// The IUI_DEBUG trace of a post: the post as the Client sent it (its
220// region's size), the drawing's last onScreen, the page's top in the
221// message and its rows scrolled away (the Client's y counts from the
222// page's top, scrolled or not: no offset is added), and the rows selected.
223export function selectTrace(post: SelectPost, d: Drawn | undefined, sel: Selection | null): string {
224 const p = (q: Point) => `${q.x},${q.y}`
225 const size = post.size ? `${post.size.columns}x${post.size.rows}` : '?'
226 const head = `select: ${post.phase} #${post.seq} ${post.id} page ${post.page} anchor ${p(post.anchor)} head ${p(post.head)} size ${size}`
227 if (!d) return `${head} (no drawing)`
228 const box = d.pages[post.page]
229 const on = d.onScreen === undefined ? '?' : d.onScreen === null ? 'off' : `${d.onScreen.first}-${d.onScreen.last}/${d.onScreen.of}`
230 const top = pageTopsOf(d)?.[post.page]
231 const above = rowsAbove(d.onScreen?.first, top, box?.rows ?? 0)
232 const drawn = box ? `${box.columns}x${box.rows}` : '?'
233 const rows = sel ? selectedRows(sel, d.pages).flatMap((lines, i) => lines.length ? [`p${i} ${lines[0]!.row}..${lines[lines.length - 1]!.row} (${lines.length})`] : []) : []
234 return `${head} drawn ${drawn} onScreen ${on} top ${top ?? '?'} above ${above ?? '?'} rows ${rows.join(', ') || 'none'}`
235}
236
237// The selection's runs, page by page: the lines a copy of the same cells
238// decodes to (decodeSelection), so its text is read the same way.
239export function runsOf(rows: readonly (readonly SelRow[])[]): { page: number; lines: RunLine[] }[] {
240 return rows.flatMap((lines, page) => (lines.length ? [{ page, lines: lines.map(l => ({ row: l.row, colStart: l.x0, colEnd: l.x1 })) }] : []))
241}
242
243// The text of the runs, as a copy of their cells reads it: each page's
244// words under its run (textOfRun), joined by new lines (joinSegments).
245// Null when a page's words are not known.
246export function textOfRuns(
247 runs: readonly { page: number; lines: RunLine[] }[], words: (page: number) => { items: Item[]; blocks: TextBlock[] } | null,
248 pages: readonly { width: number; height: number; columns: number; rows: number }[],
249): string | null {
250 const texts: (string | null)[] = []
251 for (const run of runs) {
252 const got = words(run.page)
253 const page = pages[run.page]
254 if (!got || !page) return null
255 texts.push(textOfRun(run.lines, got.items, got.blocks, page) || null)
256 }
257 return joinSegments(runs.map(run => ({ kind: 'image', lines: run.lines })), texts)
258}
259
260// Whether Claude Code has what picture select uses (SELECT_SINCE or newer;
261// a dev build by its release): the Client, ui.message, ui.blit and ui.copy.
262export function needsSelect(version: string): boolean {
263 const release = /^\d+\.\d+\.\d+/.exec(version.trim())?.[0]
264 return !!release && (compareVersions(release, SELECT_SINCE) ?? -1) >= 0
265}
266
267// Whether a drawing's pages take picture select now: `/iui select` on, in
268// fullscreen (on the main screen the terminal selects), pages drawn from
269// files (not as bytes over ssh), the Client element there and a Claude Code
270// that needs it.
271export function selectOn(o: { hasClient: boolean; isFullscreen?: boolean }): boolean {
272 return S.select && !picked.isBroken && o.hasClient && o.isFullscreen === true && !!S.env?.canDraw && !S.env.inline && needsSelect(S.engine)
273}
274