See what you paste into Claude Code: image thumbnails and long-text previews above the prompt instead of bare [Image #1] and [Pasted text #2] tags

Shared Claude Code configuration: slash-commands, scripts and hooks, persistent memory (MemPalace), and token optimization (RTK). Clone once, install everywhere, stay in sync.
[!WARNING] These scripts modify your system environment.
install.shperforms persistent, potentially destructive operations:
- Writes to
~/.claude/(commands, scripts, templates, mods, settings, CLAUDE.md)- Prunes
~/.claude/commands/and~/.claude/agents/: anything there without a source file in the repo is deleted on every run —~/.claude/agents/ends up holding exactly the three subagents ofagents/- Installs global packages (
graphify,mempalace,rtk)- Modifies PATH — adds
~/.local/binto~/.bashrc/~/.bash_profile/~/.profile(with confirmation, or silently with-y)- Deletes files (
graphify-out/, mempalace wings, vault folders) viaexclude-from-index.sh- Writes git hooks and config in target repos (post-commit vault sync,
pre-commitshellcheck gate in this repo,merge.ours.driver/pull.rebase false)- Auto-commits and pushes git repos (vault sync)
Read
install.shbefore running. Do not use on a machine whose~/.claude/is managed by another workflow.
This repo is the shared base. It contains everything that is useful to anyone: commands, scripts, settings templates. It does not contain personal data (no vault, no env secrets).
For a personal setup with a versioned Obsidian vault and private overrides, fork or extend this repo privately:
claude-config (this repo, public)
└── upstream ── your-claude-config (private fork)
├── vault/ # personal Obsidian vault
└── env.local # machine-specific secrets
Your private repo stays in sync with this one automatically — see Minimal setup.
curl (for auto-installing uv if missing)brew install bash — the stock 3.2 cannot run the script)install.sh doesupstream remote first: a fork that lacks the remote gets it, pointing at the public repo (CLAUDE_CONFIG_UPSTREAM_URL in install.sh), whatever its name; a checkout of the public repo itself gets none. If the sync brings changes, the script re-executes itself so the rest of the run uses the updated version, and syncs once more when those changes touched the sync script: a path added to its list arrives in the same run. Skipped, with a message, while the repo has uncommitted changes or is not on its default branch (what origin/HEAD names, else main or master: the sync commits upstream's files into the current branch)MILVUS_ADDRESS is set)~/.local/bin to persistent PATH (-y skips)~/.claude/ — commands/ and agents/ are mirrored (deployed files with no source in the repo are pruned), scripts/ and templates/ are additive. agents/ holds the three subagents /feature spawns, pinned to another model; a subagent dropped from the repo disappears from every machine at the next install. mods/ goes to ~/.claude/mods/ the same additive way: settings.json names the deployed paste-view in CLAUDE_CODE_PLUGIN_DIRS, so every new session shows a preview of what is pasted (a pasted text as one line that gives its size and opens it whole; for an image, a line that opens it in the system viewer under a coarse mosaic of half blocks on Windows, or a real thumbnail where the terminal draws kitty graphics, which Windows Terminal and VS Code do not) with nothing fetched from a marketplace~/.claude/claude-config.path; hooks, scripts/session-start.sh (SessionStart hook: newest MemPalace diary entries for the repo + head of TODO.md, ~200 tokens) and scripts/session-stop.sh (Stop hook: graphify update + mining the repo into its MemPalace wing + vault sync, run detached) resolve the repo through this pointer instead of hardcoded absolute paths~/.claude/CLAUDE.md (substitutes ${VAULT_DIR})claude mcp add — mempalace (mempalace-mcp), context-mode and figma. Claude Code reads MCP servers from ~/.claude.json or a project .mcp.json only, never from settings.json. Figma authenticates over OAuth: run /mcp once inside Claude Codesettings.json — this pins the default model/effort (fable · xhigh) and points the statusline at scripts/statusline.sh on every machinesetup-rtk.shcomment-strip, syntax-check, context-monitor, cd-git-allow, api-error-alert) and fails if one is still on disk or in the deployed settings.json; then checks that the four guards (hooks/*.sh, protect-gates.js) are on disk and registered there, since a registered hook whose file is missing guards nothing. The blocking hooks now ship in hooks/ (see Quality harness below) and are registered by settings.json; comment-strip was the real cause of the "heredoc bug" (docs/pitfall.md)claude CLI (ponytail, upstream caveman, official context7 + frontend-design, hono, vibe-wise), and warns when python3 does not run (the vibe-wise hook calls it)jq) — scripts/statusline.sh renders model, context, 5h/7d rate limits and git from the payload Claude Code pipes in, plus the active terse-mode badge and the project's VibeWise learning mode; no network, no loginstyle-toggle.sh switches between ponytail and caveman.gitignore in target repos (graphify block + CLAUDE.md + mempalace.yaml + context/) using templates/gitignore.appendmempalace.yaml generation and mining into the repo's own wing, and a local CLAUDE.md re-rendered from templates/CLAUDE.project.md on every run — so a machine still holding an older generation catches up. Anything written below the template's last line is kept, and a CLAUDE.md install.sh never generated is left untouched (tests/claude-md-refresh.sh covers all four cases).gitignore management)*.sh must pass shellcheck -S warningorigin (fetch → merge → push, retried on races) via scripts/vault-sync.shinstall.sh --only claude runs steps 4 and 7–14 only; install.sh --only repos runs steps 15–19 only. Steps 1–3, 5 and 6 (sync, dependencies, repo pointer, MemPalace health) run under either scope — both halves need them.
install.sh on a fresh clone already gives you the shared config. Two more things turn it into a personal setup: a private repo to hold your vault and overrides, and Obsidian to read what Graphify writes.
# Clone the public repo as your private base
git clone https://github.com/RemiAsselin42/claude-config my-claude-config
cd my-claude-config
# Point origin to your private repo, keep public as upstream
git remote rename origin upstream
git remote add origin https://github.com/<you>/my-claude-config
git push -u origin main
From then on, scripts/sync-upstream.sh pulls shared files from upstream into your private repo without touching personal files (vault/, env.local, .claude/). It runs at the start of every install.sh and nowhere else: no hook calls it between installs, so the script's 8-hour debounce (~/.claude/.upstream-sync-stamp) only matters if you wire one up yourself. It never touches a fork with uncommitted changes on a synced path, and it cannot sync an unreachable upstream: in both cases it exits 3 with the reason on stderr, and install.sh prints a yellow ⚠ upstream sync skipped — the files it then deploys come from the fork as it is, possibly behind upstream — instead of the green ✓ upstream synced.
The public repo ships no vault/ — install.sh creates it in your private repo and writes, per indexed repo, Projets/<repo>/ with the graph report, the file tree, a <repo>.canvas community map and one note per graph node. To read it: Obsidian → Open folder as vault → select <your-repo>/vault.
scripts/vault-sync.sh commits it and reconciles with origin (fetch → merge → push) at the end of every install and every session, so several machines can write to the same vault.
Nothing below is required — defaults work.
| Where | Option | Effect |
|---|---|---|
| CLI | install.sh -y | Non-interactive: keeps each repo's current indexing state, auto-accepts the PATH change |
| CLI | install.sh -v | Verbose installer output |
| CLI | install.sh --only claude | Claude side only: ~/.claude files, settings, MCP servers, plugins; skips the repos |
| CLI | install.sh --only repos | Repos side only: graph, community names, vault, MemPalace wings; skips the Claude files |
| Prompt | PATH | Asked once, to add ~/.local/bin to ~/.bashrc / ~/.bash_profile / ~/.profile |
| Prompt | Repo selection | Which sibling git repos to index (graphify + MemPalace + vault) |
env.local | MEMPALACE_EMBEDDING_MODEL | embeddinggemma (default, multilingual) or minilm (English-only, faster) |
env.local | MEMPALACE_PALACE_PATH | Move the palace off ~/.mempalace/palace (small system drive, synced folder) |
env.local | GRAPHIFY_LABEL_BACKEND / _MODEL | Which LLM names the graph communities (default: the claude CLI, no API key) |
env.local | GRAPHIFY_DEEP_EXTRACT | LLM re-extraction adding INFERRED edges — slow, billed on paid backends |
env.local | MILVUS_ADDRESS / MILVUS_TOKEN / OPENAI_API_KEY | Enables the Zilliz semantic-search MCP server |
Each key is documented inline in env.local.template.
claude-config/
├── install.sh # Main installation script
├── env.local.template # Machine-specific variables (Figma key, embedder, label backend…)
├── CLAUDE.md # Global instructions for Claude Code
├── settings.json # Permissions, hooks, effort level, attribution
├── mempalace.yaml # This repo's own MemPalace wing + mining exclusions
├── .graphifyignore # Keeps vault/ (generated) out of this repo's own graph
├── .gitignore # env.local, vault/, context/, graphify-out/: the per-machine and generated side
├── .gitattributes # LF everywhere; vault/ and graphify-out/ merge "ours", no eol conversion
├── README.md # This file
├── README.fr.md # Same, in French
│
├── .github/workflows/
│ ├── arch-gates-python.yml # Reusable CI workflow: import cycles + layer contracts of a Python package
│ ├── arch-gates-frontend.yml # Reusable CI workflow: import cycles + layer contracts under src/ (dependency-cruiser)
│ ├── baseline-ratchet.yml # Reusable CI workflow: a gate baseline may only shrink
│ ├── mutation-gate.yml # Reusable CI workflow: mutmut on what [tool.mutmut] names, then the not-killed mutants against the baseline
│ ├── quality-python.yml # Reusable CI workflow: ruff C901 and jscpd on a Python package, then both against their baselines
│ ├── quality-frontend.yml # Reusable CI workflow: the project's eslint with the complexity rule and jscpd under src/, then both against their baselines
│ └── ci.yml # This repo's own CI: shellcheck + every test under tests/, on ubuntu and macos
├── gates/python/
│ ├── check_imports.py # Python cycles + layers gate (grimp), run by arch-gates-python.yml at the pinned tag
│ ├── check_mutation.py # Mutation gate: the mutants the tests do not kill (mutmut) may only shrink, run by mutation-gate.yml
│ └── check_quality.py # Complexity (ruff C901, eslint complexity) and duplication (jscpd) gates: both may only shrink, run by quality-*.yml
├── agents/ # Subagents → ~/.claude/agents/ (mirrored), pinned to another model, spawned by /feature
│ ├── plan-reviewer.md # Reviews the plan against the spec before any code, read-only
│ ├── spec-tester.md # Writes the acceptance tests from the spec, before the code exists
│ └── diff-reviewer.md # Adversarial review of the diff against spec, tests and gate output, read-only
├── commands/ # Slash-commands → ~/.claude/commands/
├── hooks/ # PreToolUse guards → ~/.claude/hooks/ (Bash and PowerShell tools)
│ ├── protect-gates.js # Blocks Claude's edits to gate configs, baselines, workflows, hook bypasses, merge, labels, ref deletion
│ ├── destructive-guard.sh # rm -rf on broad paths, git reset --hard, git clean, forced checkout (vendored, cc-safe-setup)
│ ├── branch-guard.sh # Push to main/master, force push (vendored, cc-safe-setup)
│ └── secret-guard.sh # git add of .env / keys / credentials (vendored, cc-safe-setup)
├── scripts/ # Utility scripts → ~/.claude/scripts/
│ ├── baseline-ratchet.cjs # Compares baselines between two refs; run by the workflow above (.cjs: callers may be ESM packages)
│ ├── repo-identity.sh # Shared lib: canonical_repo_name()
│ ├── session-start.sh # SessionStart hook: starts the MemPalace daemon, diary + TODO.md head, one line when MemPalace is down
│ ├── session-stop.sh # Stop hook: graphify update + wing mine + vault sync
│ ├── statusline.sh # Statusline: model, context, rate limits, mode, VibeWise, git
│ ├── style-toggle.sh # Switch terse mode: ponytail ⇄ caveman ⇄ off
│ ├── vibe-toggle.sh # VibeWise learning mode of the current project: on ⇄ off, status
│ ├── setup-rtk.sh # Install RTK
│ ├── sync-upstream.sh # Sync shared files from upstream remote
│ ├── sync-graph-to-vault.sh # Sync Graphify → Obsidian vault
│ ├── vault-sync.sh # Commit + fetch/merge/push the vault (multi-machine safe)
│ └── exclude-from-index.sh # Remove a repo from graphify + mempalace
├── templates/
│ ├── CLAUDE.project.md # Per-repo CLAUDE.md, re-rendered on every install
│ ├── gitignore.append # .gitignore entries appended by install.sh
│ └── gates/ # What /init-gates creates in a repo: dependency-cruiser config, the CI callers of the three gate families
├── mods/paste-view/ # Claude Code mod → ~/.claude/mods/: pasted images and long texts previewed above the prompt (vendored, Amorfx/claude-paste-view, Windows added); claude plugin test mods/paste-view
├── docs/
│ └── pitfall.md # Append-only log of traps Claude Code hit in this repo
└── tests/
├── claude-md-refresh.sh # Self-check for the per-repo CLAUDE.md refresh
├── statusline.sh # Pins the statusline line format against a fixture payload
├── vibe-toggle.sh # vibe-toggle.sh on throwaway projects (lookup, on/off, CRLF, symlinks), the VibeWise statusline line, the plugin's wiring
├── vibe-toggle-write.sh # What vibe-toggle.sh writes: only the plugin's markers, line endings and a missing final newline kept, a failed write leaves the notes whole
├── legacy-hooks.sh # install.sh must remove the dropped cc-safe-setup hooks and notice a leftover
├── install-scope.sh # install.sh --only: usage names both halves, bad values are refused, the guard answers right
├── sync-upstream.sh # The upstream sync on two throwaway repos: dirty or unreachable = exit 3 (skipped), clean = pulled and committed, a path added to the list = brought by install.sh's second pass
├── readme-structure.sh # Both READMEs against git ls-files: every tree entry tracked, every tracked entry in the tree, command table = commands/
├── workflows-yaml.sh # Every workflow and gate template parses as YAML (js-yaml): a broken one runs nothing and reaches no PR
├── mempalace-health.sh # install.sh looks for venv holders before uv, trusts an import over a version, fails closed on a broken venv; session-start.sh starts the daemon and says when MemPalace is down
├── hooks.test.js # Every guard in hooks/, fed Bash and PowerShell payloads (node --test "tests/*.test.js")
├── baseline-ratchet.test.js # The ratchet on real baselines from a pilot repository
├── gates-template.test.js # The dependency-cruiser template on a toy project: layers, cycle, module in no layer, path alias
├── python/conftest.py # load_gate(name): a gate script loaded from gates/python by file, the way CI runs it
├── python/test_check_imports.py # The Python gates on toy graphs, temp trees and end to end (uv run --no-project --with grimp==3.14 --with pytest pytest tests/python)
├── python/test_check_mutation.py # The mutation gate on written .meta files, and on a real mutmut run of a toy package (Linux and macOS: mutmut refuses native Windows)
├── python/test_check_quality.py # The complexity and duplication gates on reports cut from real runs, and on real ruff, eslint and jscpd runs over toy projects
└── fixtures/ # Real baselines and pyproject.toml from a pilot repository, anonymized
| Command | Description |
|---|---|
/apply-suggestions | Apply identified recommendations to code |
/copilot-check | Judge Copilot review feedback on a PR before applying it |
/create-commit | Create a git commit |
/create-pr | Split work into logical commits and open a PR |
/explain-changes | Explain recent changes |
/feature | A feature end to end on a local branch: plan, plan review by another model, tests from the spec seen red, code, gates, tests frozen by git, adversarial review, report; no PR (human-only) |
/find-dead-code | Find dead code in the project |
/init-context | Generate context/architecture.md, patterns.md, constraints.md from the codebase |
/init-gates | Install the gates in a repo, architecture, quality and mutation: propose layers, thresholds and the mutation target, wait for approval, create configs, baselines and CI callers, open a PR (human-only) |
/review-changes | Analyze changes since last commit |
/review-codebase | Evaluate a freshly cloned repository |
/review-comments | Analyze code comment quality |
/review-documentation | Check doc/code consistency |
/review-quality | Evaluate code quality |
/review-stack | Audit the technology stack |
/style-toggle | Switch terse mode: ponytail ⇄ caveman ⇄ off (empty = status) |
/update-agents | Update AGENTS.md |
/update-documentation | Update documentation |
/update-prompts | Adapt prompt examples to the current project |
/vibe-toggle | Turn VibeWise learning mode on or off for the current project (empty = status) |
Two pinned plugins reduce token consumption: ponytail (YAGNI decision ladder — less generated code) and caveman (prose compression). Running both is redundant, so exactly one is active at a time — ponytail by default. Switch in one command:
bash ~/.claude/scripts/style-toggle.sh [ponytail|caveman|off|status] [level]
Also available as a slash command inside Claude Code: /style-toggle [same arguments] (empty = status).
Ponytail levels: lite, full (default), ultra. Caveman adds wenyan-lite, wenyan-full, wenyan-ultra.
Persistent state is each plugin's user config (defaultMode in %APPDATA%\<plugin>\config.json, or $XDG_CONFIG_HOME/~/.config): their SessionStart hooks re-read it and rewrite the session flag (~/.claude/.ponytail-active / .caveman-active) on every session start — the builtin default is full, so the switch must write the config, not just the flag. style-toggle.sh writes both (config for persistence, flag for an immediate statusline update). Both plugins stay installed — the inactive one is simply dormant; /ponytail and /caveman remain available for per-session tweaks. On a new machine, install.sh enables ponytail (full) when neither plugin config exists. scripts/statusline.sh renders whichever mode is active.
The pinned vibe-wise plugin turns work on a project into a learning session: Claude asks for your design first, explains what is unfamiliar, and writes the code once you approve the step. Unlike the terse modes it is per project: its state lives in <project>/.vibe-wise/ (learner notes, to keep out of git), and installing the plugin changes nothing until a project is started.
hooks/register.tsx 464 lines1// Vendored from github.com/Amorfx/claude-paste-view at a71ba10 (MIT, see ../LICENSE)
2// on 2026-10-06: claude-config ships the mod itself instead of installing it from
3// a third-party marketplace at whatever commit that holds on install day.
4// Local changes. Windows, which upstream does not cover: the image cache is looked
5// for under %TEMP%\claude, the clipboard is read and an image opened through
6// PowerShell, and CRLF line ends are folded to LF. On every platform: a clipboard
7// read that fails is not remembered, and a session with nobody at the prompt is not
8// polled. On Windows where no picture is drawn, an image shows as a mosaic of half
9// blocks (inline.ts, local). A text is its label alone, its size and no excerpt. The
10// hint names the click under the fullscreen layout alone. The pane of a text never
11// gets the keyboard when it opens, so the band carries the keys that scroll and close
12// it. tags.ts and thumbnails.ts are upstream's, untouched.
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { PastedImage, PastedText } from '../types'
17import { MOSAIC_COLUMNS, MOSAIC_COMMAND, MOSAIC_ROWS, mosaicRaster, parseMosaic } from './inline'
18import { charCount, draftTags, lineBreaks, matchesTag } from './tags'
19import type { TextTag } from './tags'
20import { drawsImages, pngDimensions, thumbnailBoxes } from './thumbnails'
21import type { Dimensions } from './thumbnails'
22
23const PANE = 'paste-view'
24// A paste raises no prompt.edit, and a collapsed text paste reaches no hook before it is
25// sent: the draft is watched on a timer, and a new text tag is matched against the
26// clipboard the moment it appears.
27const POLL_MS = 200
28
29const images = atom({ plugin: 'paste-view', key: 'images' } as const, [] as PastedImage[])
30const texts = atom({ plugin: 'paste-view', key: 'texts' } as const, [] as PastedText[])
31const viewing = atom({ plugin: 'paste-view', key: 'viewing' } as const, null as number | null)
32
33const POWERSHELL = ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command'] as const
34// The text goes out as UTF-8 bytes: written as text it would come back in the console's
35// code page, accents lost, with a newline added.
36const WINDOWS_CLIPBOARD = [
37 ...POWERSHELL,
38 '$t = Get-Clipboard -Raw; if ($t) { $b = [Text.Encoding]::UTF8.GetBytes($t); [Console]::OpenStandardOutput().Write($b, 0, $b.Length) }',
39] as const
40
41// Tried in order on first use; the first that runs is kept.
42const CLIPBOARD_COMMANDS: readonly (readonly string[])[] = [
43 ['pbpaste'],
44 ['wl-paste', '--no-newline'],
45 ['xclip', '-selection', 'clipboard', '-o'],
46]
47
48let cwd = ''
49let hasGraphics = false
50let clipboardCommand: readonly string[] | undefined
51let viewerCommand: string | undefined
52let imagesDir: { session: string; path: string } | undefined
53const dimensions = new Map<string, Dimensions | null>()
54// The mosaic of each image, by path; a scaling that failed leaves none.
55const grids = new Map<string, string[]>()
56// How many times each image's scaling was tried. A cold PowerShell or a file still being
57// written fails once and is worth another try; a format System.Drawing cannot read fails
58// every time, and a poll must not start PowerShell for it five times a second.
59const scalings = new Map<string, number>()
60const SCALINGS = 3
61// The cells a mosaic may take: the band's room when it was last drawn.
62let room = { columns: MOSAIC_COLUMNS, rows: MOSAIC_ROWS }
63// Pasted texts by tag number, each read once, when its tag first shows up.
64const pastes = new Map<number, string | null>()
65// What the draft held at the last refresh; undefined forces the next one to redo the work.
66let lastSignature: string | undefined
67// What was last written to state, so a refresh that finds the same pastes doesn't redraw.
68let written: string | undefined
69let isRefreshing = false
70
71// Windows has no `id`, `uname` or `open`; it always sets OS.
72const isWindows = async ($: EngineInterface) => (await $.env.get('OS')) === 'Windows_NT'
73
74async function tmpRoot($: EngineInterface): Promise<string> {
75 const configured = await $.env.get('CLAUDE_CODE_TMPDIR')
76 if (configured !== undefined) return configured
77 // There the folder is %TEMP%\claude, with no user id in its name.
78 if (await isWindows($)) return `${await $.env.get('TEMP')}/claude`
79 const { stdout } = await $.process.run(['id', '-u'])
80 return `/tmp/claude-${stdout.trim()}`
81}
82
83// Claude Code keeps a session's pasted images in <tmp>/<project>/<session>/images/<n>.<ext>,
84// <project> being the working directory with every character but letters and digits
85// turned into '-'. That folder is tried first; the session id alone finds it otherwise.
86async function findImagesDir($: EngineInterface): Promise<string | undefined> {
87 const session = await $.session.id()
88 if (imagesDir?.session === session) return imagesDir.path
89 const root = await tmpRoot($)
90 const projects = await $.fs.list(root).catch(() => [])
91 const candidates = [
92 cwd.replace(/[^a-zA-Z0-9]/g, '-'),
93 ...projects.filter(entry => entry.kind === 'dir').map(entry => entry.name),
94 ]
95 for (const project of candidates) {
96 const path = `${root}/${project}/${session}/images`
97 if (await $.fs.exists(path)) {
98 imagesDir = { session, path }
99 return path
100 }
101 }
102 return undefined
103}
104
105// An image is cached in the format it was pasted in: <n>.png for a screenshot, but
106// <n>.jpg or <n>.webp for others.
107async function findImage($: EngineInterface, dir: string, n: number): Promise<string | undefined> {
108 const png = `${dir}/${n}.png`
109 if (await $.fs.exists(png)) return png
110 const entries = await $.fs.list(dir).catch(() => [])
111 const other = entries.find(entry => entry.kind === 'file' && entry.name.startsWith(`${n}.`))
112 return other === undefined ? undefined : `${dir}/${other.name}`
113}
114
115const isPng = (path: string) => path.endsWith('.png')
116
117// Where the terminal draws no pictures, the band draws the image itself, as half blocks.
118// Windows only: PowerShell's System.Drawing does the scaling. The images that have no
119// mosaic yet are scaled one after the other; true when the next poll has one to draw or
120// a failed one to try again.
121async function scaleMissing($: EngineInterface, list: readonly PastedImage[]): Promise<boolean> {
122 if (hasGraphics || !(await isWindows($))) return false
123 let isChanged = false
124 for (const { path } of list) {
125 const tried = path === null ? SCALINGS : (scalings.get(path) ?? 0)
126 if (path === null || grids.has(path) || tried >= SCALINGS) continue
127 scalings.set(path, tried + 1)
128 const env = { PASTE_VIEW_PATH: path, PASTE_VIEW_COLUMNS: String(room.columns), PASTE_VIEW_ROWS: String(room.rows) }
129 const ran = await $.process.run([...POWERSHELL, MOSAIC_COMMAND], { env, timeoutMs: 5000 }).catch(() => undefined)
130 const pixels = ran?.exitCode === 0 ? parseMosaic(ran.stdout) : null
131 if (pixels !== null) grids.set(path, pixels)
132 isChanged = true
133 }
134 return isChanged
135}
136
137async function describeImage($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
138 const path = dir === undefined ? undefined : await findImage($, dir, n)
139 if (path === undefined) return { n, path: null, size: null, pixels: null }
140 const pixels = grids.get(path) ?? null
141 if (!isPng(path)) return { n, path, size: null, pixels }
142 if (!dimensions.has(path)) {
143 // A file past the read cap is still drawn, in a default shape.
144 const head = await $.fs.read(path, { as: 'bytes' }).catch(() => undefined)
145 dimensions.set(path, head === undefined ? null : pngDimensions(head.base64))
146 }
147 return { n, path, size: dimensions.get(path) ?? null, pixels }
148}
149
150async function readClipboard($: EngineInterface): Promise<string | null> {
151 const onWindows = await isWindows($)
152 // Windows is asked through PowerShell alone: a pbpaste or xclip left on its PATH by
153 // MSYS2 or Cygwin must not be the one that answers.
154 const firstUse = onWindows ? [WINDOWS_CLIPBOARD] : CLIPBOARD_COMMANDS
155 // PowerShell starts in 0.3 to 0.4s once warm (measured 2026-10); a cold start can pass a second.
156 const timeoutMs = onWindows ? 5000 : 1000
157 for (const argv of clipboardCommand === undefined ? firstUse : [clipboardCommand]) {
158 // pbpaste decodes by the locale, which a bare child process may lack.
159 const ran = await $.process.run(argv, { env: { LANG: 'en_US.UTF-8' }, timeoutMs }).catch(() => undefined)
160 if (ran?.exitCode === 0 && !ran.isStdoutTruncated) {
161 clipboardCommand = argv
162 // The Windows clipboard ends lines with CRLF: a carriage return must not reach the pane.
163 return ran.stdout.replace(/\r\n?/g, '\n')
164 }
165 }
166 // A read that failed is that paste's alone (a timeout, a clipboard past the read cap):
167 // nothing is remembered of it, and the next paste is read again.
168 return null
169}
170
171async function capturePastes($: EngineInterface, tags: readonly TextTag[]) {
172 for (const n of pastes.keys()) if (!tags.some(tag => tag.n === n)) pastes.delete(n)
173 const fresh = tags.filter(tag => !pastes.has(tag.n))
174 if (fresh.length === 0) return
175 // One clipboard can only stand for one paste: tags arriving together (a draft brought
176 // back from history) are left without a preview rather than guessed.
177 const clipboard = fresh.length === 1 ? await readClipboard($) : null
178 for (const tag of fresh) pastes.set(tag.n, clipboard !== null && matchesTag(clipboard, tag.lines) ? clipboard : null)
179}
180
181async function refresh($: EngineInterface, draft: string) {
182 const tags = draftTags(draft)
183 await capturePastes($, tags.texts)
184
185 const signature = JSON.stringify([tags.images, tags.texts.map(tag => tag.n)])
186 if (signature === lastSignature) return
187
188 const dir = tags.images.length > 0 ? await findImagesDir($) : undefined
189 const imageList = await Promise.all(tags.images.map(n => describeImage($, dir, n)))
190 // An image whose file hasn't landed yet is looked for again on the next poll.
191 lastSignature = imageList.some(image => image.path === null) ? undefined : signature
192
193 const textList = tags.texts.map(tag => ({ ...tag, text: pastes.get(tag.n) ?? null }))
194 // Each write redraws the band: an image still missing must not redraw it on every poll.
195 const json = JSON.stringify([imageList, textList])
196 if (json !== written) {
197 await update($, images, () => imageList)
198 await update($, texts, () => textList)
199 written = json
200 }
201
202 const shown = await read($, viewing)
203 if (shown !== null && !tags.texts.some(tag => tag.n === shown)) await closePane($)
204
205 // The lines are written above before any image is scaled: 0.4s of PowerShell an image
206 // must not hold them back. The next poll draws what was scaled, or tries a failure again.
207 // ponytail: the poll waits while PowerShell runs, so a text pasted meanwhile is seen that
208 // much later; run the scaling apart from the poll if that ever shows.
209 if (await scaleMissing($, imageList)) lastSignature = undefined
210}
211
212async function poll($: EngineInterface) {
213 if (isRefreshing) return
214 isRefreshing = true
215 try {
216 await refresh($, (await $.prompt.read()).text)
217 } finally {
218 isRefreshing = false
219 }
220}
221
222// Without pictures in the terminal, an image opens in the system's own viewer.
223async function openImage($: EngineInterface, path: string) {
224 viewerCommand ??= (await isWindows($))
225 ? 'Invoke-Item'
226 : (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
227 ? 'open'
228 : 'xdg-open'
229 // Invoke-Item is PowerShell's. The path travels in the environment, never in the command:
230 // PowerShell ends a quoted string at ' and at the typographic ‘ ’ ‚ ‛ alike, and runs
231 // what follows (seen with a path holding ’, 2026-10).
232 const ran = await (viewerCommand === 'Invoke-Item'
233 ? $.process.run([...POWERSHELL, 'Invoke-Item -LiteralPath $env:PASTE_VIEW_PATH'], { env: { PASTE_VIEW_PATH: path }, timeoutMs: 5000 })
234 : $.process.run([viewerCommand, path], { timeoutMs: 5000 })
235 ).catch(() => undefined)
236 if (ran?.exitCode !== 0) $.ui.toast(`paste-view: couldn't open the image with ${viewerCommand}`)
237}
238
239// The pane draws a text this many lines a part, each part a place the band can scroll to.
240const PART_LINES = 10
241// The part the band last brought to the top of the pane.
242// ponytail: counts its own moves, so a wheel scroll in between is not seen; read the
243// window's offset from the pane's props if that ever matters.
244let panePart = 0
245
246const partCount = (text: string) => Math.ceil((lineBreaks(text) + 1) / PART_LINES)
247
248const paneParts = (text: string) => {
249 const lines = text.split(/\r\n|\r|\n/)
250 return Array.from({ length: partCount(text) }, (_, i) => lines.slice(i * PART_LINES, (i + 1) * PART_LINES).join('\n'))
251}
252
253// Asks the pane to show a part from its first line, the very top for the first one so
254// that the header above it shows again. False when the engine refused or could not.
255async function showPart($: EngineInterface, part: number): Promise<boolean> {
256 const move = part === 0 ? { in: PANE, to: 'start' as const } : { in: PANE, to: { key: `part-${part}` }, block: 'start' as const }
257 const moved = await $.ui.scroll(move).catch(() => ({ deny: 'failed' }))
258 return moved.deny === undefined
259}
260
261async function openPane($: EngineInterface, paste: PastedText) {
262 await update($, viewing, () => paste.n)
263 // `focus` is a request the engine refuses while the prompt holds text or the band holds
264 // the keyboard, which is every time here: the pane opens without the keys, the arrows
265 // never reach it, and the band's own keys move it instead (scrollPane).
266 await $.ui.open({ id: PANE, title: `Pasted text #${paste.n}`, focus: true, closeOnEscape: true })
267 // The pane keeps its window from one text to the next: a text opened while another was
268 // scrolled down would start part-way through.
269 panePart = 0
270 await showPart($, 0)
271}
272
273async function scrollPane($: EngineInterface, by: -1 | 1) {
274 const shown = await read($, viewing)
275 const paste = (await read($, texts)).find(one => one.n === shown)
276 if (paste?.text == null) return
277 const part = Math.max(0, Math.min(partCount(paste.text) - 1, panePart + by))
278 // A move the engine refuses leaves the pane, and the count of where it is, as they were.
279 if (await showPart($, part)) panePart = part
280}
281
282async function closePane($: EngineInterface) {
283 await update($, viewing, () => null)
284 if ((await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
285}
286
287/** Lines a paste spans on screen, a trailing newline not counted. */
288const shownLines = (paste: PastedText) =>
289 paste.text === null ? (paste.lines ?? 0) + 1 : lineBreaks(paste.text.trimEnd()) + 1
290
291const imageLabel = (image: PastedImage, path: string) => {
292 if (image.size !== null) return `#${image.n} · image ${image.size.width}×${image.size.height}`
293 return isPng(path) ? `#${image.n} · image` : `#${image.n} · image · ${path.slice(path.lastIndexOf('.') + 1)}`
294}
295
296export const register: Register = on => {
297 on('session.start', async ($, e, next) => {
298 // A `-p` run or the SDK has no prompt box: nothing to watch, so no timer.
299 if (!e.isInteractive) return next(e)
300 cwd = e.cwd
301 hasGraphics = drawsImages({
302 term: await $.env.get('TERM'),
303 termProgram: await $.env.get('TERM_PROGRAM'),
304 kittyWindowId: await $.env.get('KITTY_WINDOW_ID'),
305 forceImages: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
306 sessionKind: await $.env.get('CLAUDE_CODE_SESSION_KIND'),
307 multiplexer: (await $.env.get('TMUX')) ?? (await $.env.get('STY')),
308 })
309 // A reload keeps the state: the texts already read stay matched to their tags.
310 for (const paste of await read($, texts)) pastes.set(paste.n, paste.text)
311 $.clock.every(POLL_MS, () => poll($))
312 return next(e)
313 })
314
315 on('ui.close', { id: PANE }, async ($, e, next) => {
316 await update($, viewing, () => null)
317 return next(e)
318 })
319
320 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
321 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
322 // Kept for the next image to be scaled: four rows are left to the lines below it.
323 room = {
324 columns: Math.min(MOSAIC_COLUMNS, e.props.bodyColumns),
325 rows: Math.max(2, Math.min(MOSAIC_ROWS, e.props.maxRows - 4)),
326 }
327 const imageList = await read($, images)
328 const textList = await read($, texts)
329 if (imageList.length === 0 && textList.length === 0) return next(e)
330 // The text open in the pane, if any: the band then carries the keys that move it.
331 const shown = await read($, viewing)
332
333 const { Box, Button, Image, Raster, Text } = $.ui.resolve(e)
334 const width = e.props.bodyColumns
335 // The Image element draws a PNG file only: any other format is a line that opens it.
336 const pictures = hasGraphics ? imageList.filter(image => image.path !== null && isPng(image.path)) : []
337 const imageLines = imageList.filter(image => !pictures.includes(image))
338 const openable = imageLines.filter(image => image.path !== null)
339 // One row per line below the thumbnails, plus the hint and the pane's keys while it
340 // is open; the thumbnails get the rest.
341 const lineRows = imageLines.length + textList.length + 1 + (shown === null ? 0 : 1)
342 const boxes = thumbnailBoxes(pictures.map(image => image.size), e.props.maxRows - lineRows, width)
343 // Number keys run down the lines that open something, images first.
344 const hotkey = (i: number) => (i >= 0 && i < 9 ? { hotkey: String(i + 1) } : {})
345 // Where no picture is drawn, the images scaled to a mosaic sit above the lines, side by
346 // side while the band is wide enough. A mosaic was scaled for the room the band had
347 // then: none is drawn when the band is now too short for it and every line, since a
348 // line scrolled out of the band's window no longer answers its key.
349 let across = 0
350 const mosaics = imageLines.flatMap(image => {
351 if (image.pixels == null) return []
352 const mosaic = mosaicRaster(image.pixels)
353 if (mosaic.rows + 1 + lineRows > e.props.maxRows || across + mosaic.columns > width) return []
354 across += mosaic.columns + 1
355 return [{ n: image.n, ...mosaic }]
356 })
357 const below = await next(e)
358
359 return (
360 <Box flexDirection="column">
361 {pictures.length > 0 && (
362 <Box flexDirection="row" columnGap={1} alignItems="flex-end">
363 {pictures.map((image, i) => (
364 <Box flexDirection="column" alignItems="center">
365 <Box borderStyle="round" borderDimColor>
366 <Image
367 key={`picture-${image.n}`}
368 source={{ file: image.path ?? '', format: 'png' }}
369 columns={boxes[i]?.columns ?? 4}
370 rows={boxes[i]?.rows ?? 1}
371 alt={`[Image #${image.n}]`}
372 />
373 </Box>
374 <Text dimColor>#{image.n}</Text>
375 </Box>
376 ))}
377 </Box>
378 )}
379 {mosaics.length > 0 && (
380 <Box flexDirection="row" columnGap={1} alignItems="flex-end">
381 {mosaics.map(mosaic => (
382 <Box flexDirection="column" alignItems="center">
383 <Raster key={`mosaic-${mosaic.n}`} columns={mosaic.columns} rows={mosaic.rows} cells={mosaic.cells} />
384 <Text dimColor>#{mosaic.n}</Text>
385 </Box>
386 ))}
387 </Box>
388 )}
389 {imageLines.map(image => {
390 const path = image.path
391 if (path === null) {
392 return <Text dimColor wrap="truncate">{`#${image.n} · image · no preview`}</Text>
393 }
394 return (
395 <Button
396 key={`image-${image.n}`}
397 plain
398 {...hotkey(openable.indexOf(image))}
399 label={`${imageLabel(image, path)} — open`}
400 onPress={() => openImage($, path)}
401 />
402 )
403 })}
404 {textList.map((paste, i) => {
405 const head = `#${paste.n} · ${shownLines(paste)} lines`
406 if (paste.text === null) {
407 return <Text dimColor wrap="truncate">{`${head} · no preview (clipboard changed)`}</Text>
408 }
409 // The label alone, its size and nothing of the text: its first lines under it, and
410 // upstream's first line after it, were seen on screen and taken out (Rémi, 2026-10-06).
411 return (
412 <Button
413 key={`text-${paste.n}`}
414 plain
415 {...hotkey(openable.length + i)}
416 label={`${head} · ${charCount(paste.text.length)}`}
417 onPress={() => openPane($, paste)}
418 />
419 )
420 })}
421 {(openable.length > 0 || textList.some(paste => paste.text !== null)) && (
422 // A click reaches a line under the fullscreen layout alone: on the main screen
423 // naming it sent people clicking at nothing.
424 <Text dimColor wrap="truncate">
425 {e.viewport?.isFullscreen === true
426 ? 'to open one: click its line, or press Ctrl+X, then Tab, then its number'
427 : 'to open one: press Ctrl+X, then Tab, then its number'}
428 </Text>
429 )}
430 {shown !== null && (
431 <Box flexDirection="row" columnGap={2}>
432 <Text dimColor>{`pasted text #${shown}:`}</Text>
433 <Button key="pane-down" plain hotkey="j" label="down" onPress={() => scrollPane($, 1)} />
434 <Button key="pane-up" plain hotkey="k" label="up" onPress={() => scrollPane($, -1)} />
435 <Button key="pane-close" plain hotkey="x" label="close" onPress={() => closePane($)} />
436 </Box>
437 )}
438 {below}
439 </Box>
440 )
441 })
442
443 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
444 const { Box, Text } = $.ui.resolve(e)
445 const shown = await read($, viewing)
446 const paste = (await read($, texts)).find(one => one.n === shown)
447 if (paste?.text == null) return <Text dimColor>This paste is no longer in the prompt.</Text>
448
449 // The arrows and Escape are the pane's only while it holds the keyboard; otherwise
450 // the keys are the band's.
451 const keys = e.props.isFocused ? '↑↓ scroll · esc close' : 'j k scroll · x close'
452 return (
453 <Box flexDirection="column">
454 <Text dimColor wrap="truncate">{`${shownLines(paste)} lines · ${charCount(paste.text.length)} · ${keys}`}</Text>
455 {paneParts(paste.text).map((part, i) => (
456 <Box key={`part-${i}`}>
457 <Text>{part}</Text>
458 </Box>
459 ))}
460 </Box>
461 )
462 })
463}
464hooks/inline.ts 68 lines1// Local to claude-config, not upstream's: an image shown in the band with no key
2// pressed, as a mosaic of half blocks, for the terminals that draw no pictures. Windows
3// Terminal and VS Code speak Sixel, Claude Code's Image element the kitty protocol only.
4
5// The most cells a mosaic takes. A cell is a half block: two pixels, one above the
6// other, each its own colour. Sextants (two by three pixels a cell, in two colours)
7// were tried for the sharper picture: in the VS Code terminal they drew stray grey
8// pixels all over it, for little more detail (Rémi, 2026-10-06).
9export const MOSAIC_COLUMNS = 48
10export const MOSAIC_ROWS = 10
11
12// PowerShell scales the image to fit the room it is given, to an even number of pixel
13// rows, and writes one line of hex per row. The path and the room travel in the
14// environment, never in the command line. 0.35 to 0.4s a run (measured 2026-10).
15export const MOSAIC_COMMAND = [
16 'Add-Type -AssemblyName System.Drawing',
17 '$src = [Drawing.Image]::FromFile($env:PASTE_VIEW_PATH)',
18 '$scale = [Math]::Min([double]$env:PASTE_VIEW_COLUMNS / $src.Width, 2 * [double]$env:PASTE_VIEW_ROWS / $src.Height)',
19 '$w = [Math]::Max(1, [int]($src.Width * $scale))',
20 '$h = 2 * [Math]::Max(1, [int]($src.Height * $scale / 2))',
21 '$bmp = New-Object Drawing.Bitmap $w, $h',
22 '$g = [Drawing.Graphics]::FromImage($bmp)',
23 "$g.InterpolationMode = 'HighQualityBicubic'",
24 "$g.PixelOffsetMode = 'HighQuality'",
25 // Without it the pixels on the edges are blended with the nothing beyond them.
26 '$edges = New-Object Drawing.Imaging.ImageAttributes',
27 "$edges.SetWrapMode('TileFlipXY')",
28 "$g.DrawImage($src, (New-Object Drawing.Rectangle 0, 0, $w, $h), 0, 0, $src.Width, $src.Height, 'Pixel', $edges)",
29 '$out = New-Object Text.StringBuilder',
30 "for ($y = 0; $y -lt $h; $y++) { for ($x = 0; $x -lt $w; $x++) { $p = $bmp.GetPixel($x, $y); [void]$out.AppendFormat('{0:x2}{1:x2}{2:x2}', $p.R, $p.G, $p.B) }; [void]$out.Append([char]10) }",
31 '[Console]::Out.Write($out.ToString())',
32].join('; ')
33
34/** The pixel rows PowerShell wrote, six hex digits a pixel; null when they are not a whole grid. */
35export function parseMosaic(stdout: string): string[] | null {
36 const rows = stdout.split(/\r?\n/).filter(row => row !== '')
37 const width = rows[0]?.length ?? 0
38 const isGrid =
39 rows.length >= 2 &&
40 rows.length % 2 === 0 &&
41 width >= 6 &&
42 width % 6 === 0 &&
43 rows.every(row => row.length === width && /^[0-9a-f]+$/.test(row))
44 return isGrid ? rows : null
45}
46
47/** A mosaic as the Raster element takes it: its size in cells, and the cells packed. */
48export type Mosaic = { columns: number; rows: number; cells: string }
49
50const HALF_BLOCK = 0x2580
51
52/**
53 * A grid of pixels as one grid of half blocks: for each cell the block's code point,
54 * the upper pixel as its ink and the lower as its background, each a little-endian u32,
55 * row after row, in base64.
56 */
57export function mosaicRaster(pixels: readonly string[]): Mosaic {
58 const columns = (pixels[0]?.length ?? 0) / 6
59 const rows = Math.floor(pixels.length / 2)
60 const u32 = (value: number) => String.fromCharCode(value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff, value >>> 24)
61 const colour = (row: number, x: number) => parseInt((pixels[row] ?? '').slice(x * 6, x * 6 + 6), 16)
62 let bytes = ''
63 for (let y = 0; y < rows; y++) {
64 for (let x = 0; x < columns; x++) bytes += u32(HALF_BLOCK) + u32(colour(2 * y, x)) + u32(colour(2 * y + 1, x))
65 }
66 return { columns, rows, cells: btoa(bytes) }
67}
68hooks/tags.ts 48 lines1export type TextTag = { n: number; lines: number | null }
2export type DraftTags = { images: number[]; texts: TextTag[] }
3
4// `[Image #3]`, `[Pasted text #2 +9 lines]`, or `[Pasted text #2]` for one long line.
5const TAG = /\[(?:Image #(\d+)|Pasted text #(\d+)(?: \+(\d+) lines?)?)\]/g
6
7/** The image and pasted-text tags a draft holds, each once, in the order they first appear. */
8export function draftTags(draft: string): DraftTags {
9 const images: number[] = []
10 const texts: TextTag[] = []
11 for (const [, image, text, lines] of draft.matchAll(TAG)) {
12 if (image !== undefined && !images.includes(Number(image))) images.push(Number(image))
13 if (text !== undefined && !texts.some(tag => tag.n === Number(text))) {
14 texts.push({ n: Number(text), lines: lines === undefined ? null : Number(lines) })
15 }
16 }
17 return { images, texts }
18}
19
20/** Line breaks in a text, counted the way Claude Code counts them for a paste's tag. */
21export function lineBreaks(text: string): number {
22 return text.match(/\r\n|\r|\n/g)?.length ?? 0
23}
24
25/**
26 * Whether a clipboard text is the paste a tag stands for: same line count as the tag
27 * announces, allowing for a trailing newline the terminal may have dropped.
28 */
29export function matchesTag(text: string, lines: number | null): boolean {
30 if (text.trim() === '') return false
31 const expected = lines ?? 0
32 return lineBreaks(text) === expected || lineBreaks(text.trimEnd()) === expected
33}
34
35/** The first non-blank line, whitespace collapsed, cut to `width` with an ellipsis. */
36export function firstLine(text: string, width: number): string {
37 const line = text.split(/\r\n|\r|\n/).find(l => l.trim() !== '')?.replace(/\s+/g, ' ').trim() ?? ''
38 if (width < 1) return ''
39 return line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`
40}
41
42/** A short character count: `840 chars`, `2.3k chars`, `1.2M chars`. */
43export function charCount(length: number): string {
44 if (length < 1000) return `${length} chars`
45 if (length < 1_000_000) return `${(length / 1000).toFixed(1).replace(/\.0$/, '')}k chars`
46 return `${(length / 1_000_000).toFixed(1).replace(/\.0$/, '')}M chars`
47}
48hooks/thumbnails.ts 93 lines1export type Dimensions = { width: number; height: number }
2export type Box = { columns: number; rows: number }
3
4// A thumbnail is at most this many rows tall and columns wide, and never narrower than MIN.
5const MAX_ROWS = 6
6const MAX_COLUMNS = 32
7const MIN_COLUMNS = 4
8// Terminal cells are roughly twice as tall as they are wide.
9const CELL_RATIO = 2
10// Each thumbnail's frame takes a cell on each side, a border row above and below, plus
11// its label row; neighbours sit one column apart.
12const FRAME_COLUMNS = 2
13const FRAME_ROWS = 3
14const GAP = 1
15// The shape assumed when an image's size is unknown.
16const UNKNOWN: Dimensions = { width: 3, height: 2 }
17
18const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]
19const IHDR = [0x49, 0x48, 0x44, 0x52]
20
21/**
22 * Width and height from the start of a PNG file (base64), read from its IHDR chunk;
23 * null when the bytes aren't a PNG.
24 */
25export function pngDimensions(base64: string): Dimensions | null {
26 // Bytes 0-7 are the signature, 12-15 the IHDR tag, 16-23 width and height (big-endian).
27 const raw = atob(base64.slice(0, 32))
28 if (raw.length < 24) return null
29 const byte = (i: number) => raw.charCodeAt(i)
30 if (PNG_MAGIC.some((b, i) => byte(i) !== b) || IHDR.some((b, i) => byte(12 + i) !== b)) return null
31 const uint32 = (at: number) => ((byte(at) << 24) | (byte(at + 1) << 16) | (byte(at + 2) << 8) | byte(at + 3)) >>> 0
32 const width = uint32(16)
33 const height = uint32(20)
34 return width > 0 && height > 0 ? { width, height } : null
35}
36
37function boxAt(rows: number, size: Dimensions | null): Box {
38 const { width, height } = size ?? UNKNOWN
39 const columns = Math.round((rows * CELL_RATIO * width) / height)
40 if (columns <= MAX_COLUMNS) return { columns: Math.max(MIN_COLUMNS, columns), rows }
41 // Too wide: keep the column cap and give back the rows the picture no longer needs.
42 return { columns: MAX_COLUMNS, rows: Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_RATIO * width))) }
43}
44
45const rowWidth = (boxes: readonly Box[]) =>
46 boxes.reduce((sum, box) => sum + box.columns + FRAME_COLUMNS, 0) + GAP * Math.max(0, boxes.length - 1)
47
48/**
49 * Picture boxes for a single row of thumbnails, each keeping its image's proportions,
50 * as tall as `maxRows` allows and scaled down until the row fits in `columns`.
51 */
52export function thumbnailBoxes(sizes: readonly (Dimensions | null)[], maxRows: number, columns: number): Box[] {
53 let rows = Math.min(MAX_ROWS, Math.max(1, maxRows - FRAME_ROWS))
54 let boxes = sizes.map(size => boxAt(rows, size))
55 while (rows > 1 && rowWidth(boxes) > columns) {
56 // Jump straight to the height the overflow suggests, then settle by single rows.
57 const scaled = Math.floor((rows * columns) / rowWidth(boxes))
58 rows = Math.max(1, Math.min(rows - 1, scaled))
59 boxes = sizes.map(size => boxAt(rows, size))
60 }
61 return boxes
62}
63
64export type TerminalEnv = {
65 term?: string
66 termProgram?: string
67 kittyWindowId?: string
68 /** CLAUDE_CODE_FORCE_TERMINAL_IMAGES */
69 forceImages?: string
70 /** CLAUDE_CODE_SESSION_KIND */
71 sessionKind?: string
72 /** TMUX or STY, set inside tmux or screen */
73 multiplexer?: string
74}
75
76/**
77 * Whether the terminal draws Claude Code's `Image` element, which needs the kitty
78 * graphics protocol (kitty, Ghostty); elsewhere it only draws the `alt` text. Claude Code
79 * itself turns pictures off in background sessions and inside tmux or screen, unless
80 * CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set.
81 */
82export function drawsImages(env: TerminalEnv): boolean {
83 if (env.forceImages) return true
84 if (env.sessionKind === 'bg' || env.multiplexer) return false
85 const term = env.term?.toLowerCase() ?? ''
86 return (
87 env.kittyWindowId !== undefined ||
88 env.termProgram?.toLowerCase() === 'ghostty' ||
89 term.includes('kitty') ||
90 term.includes('ghostty')
91 )
92}
93types/index.d.ts 32 lines1export type PastedImage = {
2 n: number
3 /** Where Claude Code cached the image; null until its file is found. */
4 path: string | null
5 /** Its size in pixels, from the PNG header; null when that couldn't be read. */
6 size: { width: number; height: number } | null
7 /**
8 * The image scaled down to a mosaic, one string of hex per pixel row, six digits a
9 * pixel; null where the terminal draws pictures itself or the scaling failed.
10 */
11 pixels: string[] | null
12}
13
14export type PastedText = {
15 n: number
16 /** The line count the tag announces (`+9 lines`); null when the tag gives none. */
17 lines: number | null
18 /** The pasted text, read from the clipboard; null when it couldn't be confirmed. */
19 text: string | null
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'paste-view': {
25 images: PastedImage[]
26 texts: PastedText[]
27 /** The pasted text shown in the pane, by tag number; null while it is closed. */
28 viewing: number | null
29 }
30 }
31}
32