SLOPSHOPPER

paste-view

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

newpanebandtoastprocesstimer
★ 1v0.1.0MITupdated 2026-10-09RemiAsselin42/claude-config/mods/paste-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paste-view
│ ┃ paste-view ✕ › fix the failing auth test and add an audit log call │ ┃ This paste is no longer in the prompt. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · paste-view
This paste is no longer in the prompt.
README

claude-config

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.sh performs 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 of agents/
  • Installs global packages (graphify, mempalace, rtk)
  • Modifies PATH — adds ~/.local/bin to ~/.bashrc / ~/.bash_profile / ~/.profile (with confirmation, or silently with -y)
  • Deletes files (graphify-out/, mempalace wings, vault folders) via exclude-from-index.sh
  • Writes git hooks and config in target repos (post-commit vault sync, pre-commit shellcheck gate in this repo, merge.ours.driver / pull.rebase false)
  • Auto-commits and pushes git repos (vault sync)

Read install.sh before running. Do not use on a machine whose ~/.claude/ is managed by another workflow.


Public / Private model

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.


Prerequisites

  • Node.js
  • curl (for auto-installing uv if missing)
  • bash 4.4+ (Git Bash on Windows and any Linux qualify; on macOS brew install bash — the stock 3.2 cannot run the script)

What install.sh does

  1. Syncs from the upstream 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)
  2. Checks Node.js, installs uv if missing, then installs/upgrades Graphify, MemPalace, chromadb, RTK, jq, shellcheck and context-mode (plus the Zilliz MCP server when MILVUS_ADDRESS is set)
  3. Asks once to add ~/.local/bin to persistent PATH (-y skips)
  4. Copies commands, scripts, templates to ~/.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
  5. Records the repo location in ~/.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
  6. Initializes MemPalace: creates the palace, selects the embedding model, checks index health. Repos are not mined here — each one is mined into its own wing during step 16
  7. Copies CLAUDE.md to ~/.claude/CLAUDE.md (substitutes ${VAULT_DIR})
  8. Registers the MCP servers in user scope via 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 Code
  9. Copies settings.json — this pins the default model/effort (fable · xhigh) and points the statusline at scripts/statusline.sh on every machine
  10. Activates RTK via setup-rtk.sh
  11. Removes the five cc-safe-setup hooks that earlier installs left behind (comment-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)
  12. Installs pinned plugins via the 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)
  13. Checks the statusline prerequisite (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 login
  14. Enables ponytail by default (terse-mode plugin) when no mode flag exists on this machine — style-toggle.sh switches between ponytail and caveman
  15. Updates .gitignore in target repos (graphify block + CLAUDE.md + mempalace.yaml + context/) using templates/gitignore.append
  16. Interactively selects sibling git repos to index. Per repo: graphify hooks + graph, LLM community naming, vault sync (report + file tree + canvas + one note per node), mempalace.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)
  17. Runs the same pipeline on the config repo itself (forced graph refresh, no .gitignore management)
  18. Installs a shellcheck pre-commit gate in the config repo — staged *.sh must pass shellcheck -S warning
  19. Commits the vault and reconciles with origin (fetch → merge → push, retried on races) via scripts/vault-sync.sh

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


Minimal setup

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.

Setting up a private repo

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

Obsidian vault

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.

Options

Nothing below is required — defaults work.

WhereOptionEffect
CLIinstall.sh -yNon-interactive: keeps each repo's current indexing state, auto-accepts the PATH change
CLIinstall.sh -vVerbose installer output
CLIinstall.sh --only claudeClaude side only: ~/.claude files, settings, MCP servers, plugins; skips the repos
CLIinstall.sh --only reposRepos side only: graph, community names, vault, MemPalace wings; skips the Claude files
PromptPATHAsked once, to add ~/.local/bin to ~/.bashrc / ~/.bash_profile / ~/.profile
PromptRepo selectionWhich sibling git repos to index (graphify + MemPalace + vault)
env.localMEMPALACE_EMBEDDING_MODELembeddinggemma (default, multilingual) or minilm (English-only, faster)
env.localMEMPALACE_PALACE_PATHMove the palace off ~/.mempalace/palace (small system drive, synced folder)
env.localGRAPHIFY_LABEL_BACKEND / _MODELWhich LLM names the graph communities (default: the claude CLI, no API key)
env.localGRAPHIFY_DEEP_EXTRACTLLM re-extraction adding INFERRED edges — slow, billed on paid backends
env.localMILVUS_ADDRESS / MILVUS_TOKEN / OPENAI_API_KEYEnables the Zilliz semantic-search MCP server

Each key is documented inline in env.local.template.


Structure

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

CommandDescription
/apply-suggestionsApply identified recommendations to code
/copilot-checkJudge Copilot review feedback on a PR before applying it
/create-commitCreate a git commit
/create-prSplit work into logical commits and open a PR
/explain-changesExplain recent changes
/featureA 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-codeFind dead code in the project
/init-contextGenerate context/architecture.md, patterns.md, constraints.md from the codebase
/init-gatesInstall 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-changesAnalyze changes since last commit
/review-codebaseEvaluate a freshly cloned repository
/review-commentsAnalyze code comment quality
/review-documentationCheck doc/code consistency
/review-qualityEvaluate code quality
/review-stackAudit the technology stack
/style-toggleSwitch terse mode: ponytail ⇄ caveman ⇄ off (empty = status)
/update-agentsUpdate AGENTS.md
/update-documentationUpdate documentation
/update-promptsAdapt prompt examples to the current project
/vibe-toggleTurn 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.

  • First time in a pro
Source 5 files
hooks/register.tsx 464 lines
1// 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}
464
hooks/inline.ts 68 lines
1// 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}
68
hooks/tags.ts 48 lines
1export 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}
48
hooks/thumbnails.ts 93 lines
1export 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}
93
types/index.d.ts 32 lines
1export 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