SLOPSHOPPER

cc-intelligent-ui

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

newpanebandrowsguardcommand
★ 89v0.3.4MITupdated 2026-10-26razorback16/cc-intelligent-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-intelligent-ui
│ ┃ iui-page ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No PDF page to go to. Esc closes this. │ cc-intelligent-ui │ │ ⏺ Read(src/auth.ts) │ Intelligent UI: no pictures, the cache │ │ ⎿ Read 6 lines │ folder could not be made (see /iui │ │ ⏺ Update(src/auth.ts) │ details) │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ │ ⎿ 3 pass, 1 fail │ cc-intelligent-ui │ │ │ Intelligent UI: theme claude-code not │ │ ● Done. refresh now re│ found, using vscode │ │ ╰────────────────────────────────────────────╯ │ ✻ Worked for 42s · done 4:20 PM │ │ › /iui │ ⎿ cc-intelligent-ui: Intelligent UI is on: theme vscode, dark, tex │ ⎿ cc-intelligent-ui: No pictures: the cache folder could not be ma │ ⎿ cc-intelligent-ui: Typst is not installed, so answers stay text. │ ⎿ cc-intelligent-ui: Change: /iui on | off | size | width | height │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · iui-page
No PDF page to go to. Esc closes this.
README

<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%">

Intelligent UI for Claude Code

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.

Install

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).

Terminals

TerminalPictures
kitty 0.28+, Ghostty, cmuxYes
iTerm2 3.6+, herdrYes, after /iui setup (the install script does it)
Rio 0.5.27+Yes, with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 (not tested)
sshYes, from kitty or Ghostty
Warp, WezTermNot yet (waiting on warp#16312, wezterm#7924)
tmux, screen, Terminal.app, Alacritty, VS Code, othersNo: you see normal text

/iui details tells you why pictures do or do not show. All terminal details.

Claude explains why an API request is slow: a timing chart shows the DB query takes 310 of 450 ms, with Amdahl's law and a table of fixes, in a dark and a light terminal

Features

  • Rendered Markdown: headings, tables, task lists, quotes and highlighted code, in your terminal's font.
  • LaTeX math: inline and display, typeset.
  • Diagrams, charts and plots: Claude draws them live with Typst, in your theme's colours, offline.
  • Images and files: images in answers, images and Markdown files that Claude sends, and a preview of images you paste, above the prompt box.
  • Copy answers as usual: in fullscreen, drag over an answer to select and copy its text, as you do with normal text. Click a link or a file to open it (alt+click copies it); click a command to copy it. How copy works.
  • Readable plans: plan mode shows the plan rendered, with approve, edit and reject.
  • Light and dark themes: Claude Code's colours or VS Code's, or your own.

See it

<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.

Commands

CommandWhat it does
/iuiState, and what keeps pictures away
/iui on / offPictures on or off
/iui theme <name>claude-code, vscode-modern, vscode or your own
/iui size <80–200>Text size, in percent
/iui setupTurns pictures on in iTerm2 and herdr
/iui grabCopies the text of your last selection of pictures

All commands are in Usage.

Privacy

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.

Docs

Install · Usage · Themes · How it works · Limitations · Privacy · Development · Gallery · Changelog

Disclaimer

Intelligent UI is an independent project. It is not made, supported or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC.

Licence

MIT, see LICENSE. The vendored Typst packages and the VS Code theme values keep their own licences: see THIRD-PARTY-NOTICES.md.

Source 42 files
hooks/register.tsx 1608 lines
1// 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 lines
1// 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.`
228
hooks/lib/state.ts 399 lines
1// 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}
399
hooks/lib/epoch.ts 128 lines
1// 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}
128
hooks/lib/mode.ts 113 lines
1// 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')}`))
113
hooks/lib/themes.ts 163 lines
1// 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}
163
hooks/lib/command.ts 207 lines
1// 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}
207
hooks/lib/setup.ts 161 lines
1// 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}
161
hooks/lib/host.ts 854 lines
1// 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}
854
hooks/lib/typst.ts 722 lines
1// 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}
722
hooks/lib/draw.tsx 360 lines
1// 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}
360
hooks/lib/select.ts 274 lines
1// 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