The draft's attachments as chips above the prompt: each pasted picture, pasted text, @-mentioned file or folder and, with the issues mod, @# issue, with a…

The draft's attachments as chips above Claude Code's prompt. Each pasted picture, pasted text and @-mentioned file or folder gets a chip: a glyph for its type, its path and what it is, and a × that takes it out of the draft. With the issues mod, so does each GitHub issue the draft names with @#N.

The chips follow the draft. Claude Code still writes [Image #1], [Pasted text #2 +30 lines] and @path in the prompt and sends what the prompt holds; a chip comes and goes with its placeholder or mention. Their colors are Claude Code's own theme colors, so they follow whatever /theme picks.
claude plugin marketplace add thefuga/claude-x
claude plugin install attachments@claude-x
It has no options: disable it (claude plugin disable attachments@claude-x) to take the chips away.
@#N in the draft is a chip once that mod has loaded the issue, read from its state. Without it, @#N is plain text and has none.× leaves the draft uncolored until the next typed key, as :e does.None of them is needed.
| Attachment | Path | Then |
|---|---|---|
A pasted or dropped picture, [Image #N] | The copy Claude Code saved of it | Width x height, size |
A long paste, [Pasted text #N +L lines] | Pasted text #N | Lines |
A mentioned file, @path or @"a path" | As typed, ~ for the home folder | By its type: lines and size of a text, width x height and size of a picture, frame, length and size of a video, length and size of a sound, the size of anything else |
| A mentioned folder | As typed | Entries |
A GitHub issue or pull request, @#N, with the issues mod | #N and its title | Closed or merged, once it is |
A path longer than 44 characters keeps its first folders and its end: /tmp/claude-1000/…/images/1.png. A mention of something that is not on disk (a typo, an agent, an MCP resource) has no chip, and one that ends a sentence (@README.md,) is found without its stop. A line range (@app.ts#L10-20) stays in the path.
They are Nerd Font icons, as the statusline mod's branch icon is: a terminal font without them draws a box.
| Type | Glyph | Told by |
|---|---|---|
| Text | nf-fa-file_text_o | md, txt, log, csv…; LICENSE, README |
| Code | nf-fa-file_code_o | ts, py, go, json, yaml, sh…; Makefile, Dockerfile |
| Picture | nf-fa-file_image_o | png, jpg, gif, webp, svg… |
| Video | nf-fa-file_video_o | mp4, mov, mkv, webm… |
| Sound | nf-fa-file_audio_o | mp3, wav, flac, ogg… |
nf-fa-file_pdf_o | pdf | |
| Document, spreadsheet, slides | nf-fa-file_word_o, nf-fa-file_excel_o, nf-fa-file_powerpoint_o | docx, xlsx, pptx and their kin |
| Archive | nf-fa-file_archive_o | zip, tar.gz, jar, deb… |
| Binary | nf-oct-file_binary | exe, so, wasm, pyc… |
| Folder | nf-fa-folder_o | — |
| Pasted text | nf-fa-clipboard | — |
| Unknown | nf-fa-file_o | — |
| Issue, open or closed | nf-oct-issue_opened green, nf-oct-issue_closed purple | Its state |
| Pull request, open, merged or closed | nf-oct-git_pull_request green, nf-oct-git_merge purple, nf-oct-git_pull_request_closed red | Its state |
A file whose name tells nothing (no extension, or one not in the list) is told by its first bytes: a picture's, a PDF's, an archive's, a video's or a sound's header, a NUL byte for a binary, and text otherwise. That needs the file read, so one over 4 MB is left unknown.
Click a chip's ×: its placeholder, mention or @#N leaves the draft, and a pasted picture or text leaves with its placeholder, so it is not sent. Claude Code then puts the cursor at the end of the draft.
The click needs the fullscreen renderer (/tui fullscreen), the one that reports the mouse. Where the vim mod is installed its command line holds the band's keyboard, so the × is for the mouse.
/tmp/claude-<uid>/<project>/<session>/images/<N>.png), and where it came from reaches a mod only once the prompt is sent. The folder is Claude Code's and not documented (seen on 2.1.289, on Linux); where it is not found the chip says Image #N alone.hooks/register.tsx 241 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Chip, FileType, IssueReference } from '../types'
5import { marksOf, withoutMark } from './draft'
6import type { Mark, Mention } from './draft'
7import {
8 MAX_READ,
9 PROBE,
10 bytesOf,
11 counted,
12 expandedPath,
13 factsOf,
14 imagesFolder,
15 mediaFactsOf,
16 probed,
17 shownPath,
18 typeOfBytes,
19 typeOfName,
20 wantsBytes,
21} from './files'
22import { issueChipOf } from './issues'
23import { Chips } from './view'
24
25// How often the draft is read: a paste, a completion or a history recall changes it with no event.
26const POLL_MS = 250
27const PROBE_TIMEOUT_MS = 3000
28// How many files' descriptions are kept, each for the size and change time it was read at.
29const KEPT = 256
30
31type Described = { type: FileType; facts: string[] }
32
33const chips = atom({ plugin: 'attachments', key: 'chips' } as const, [])
34// The issues the draft names, as the issues mod has loaded them; none without that mod.
35const issueReferences = atom({ plugin: 'issues', key: 'references' } as const, [])
36
37let poll: Timer | undefined
38let home: string | undefined
39// The folder this session's pasted pictures are saved in, where it could be worked out.
40let images: string | null = null
41// The draft last read with the issues loaded for it, the chips last written for them, and which
42// reading is the latest: a reading overtaken by a newer one while it looked at the disk writes
43// nothing.
44let seenDraft: string | undefined
45let seenChips: string | undefined
46let readings = 0
47const described = new Map<string, Described>()
48
49// Nothing here is worth failing a hook over: what cannot be read is left out of the chips.
50const quietly = async ($: EngineInterface, label: string, work: Promise<unknown>) => {
51 try {
52 await work
53 } catch (error) {
54 $.ui.log(`${label}: ${String(error)}`, { to: 'debug' })
55 }
56}
57
58const probe = async ($: EngineInterface, path: string) => {
59 const run = await $.process.run(PROBE(path), { timeoutMs: PROBE_TIMEOUT_MS }).catch(() => null)
60
61 return run?.exitCode === 0 ? probed(run.stdout) : null
62}
63
64// A file's type and the facts its chip shows, read once for each size and change time it has.
65const describe = async ($: EngineInterface, path: string, size: number, mtimeMs: number): Promise<Described> => {
66 const key = `${path}\n${size}\n${mtimeMs}`
67 const held = described.get(key)
68
69 if (held !== undefined) {
70 return held
71 }
72
73 // A file that cannot be read (gone since, or not the person's to read) is told by its name and
74 // size alone, and asked again next time.
75 const named = typeOfName(path)
76 const isRead = wantsBytes(named) && size <= MAX_READ
77 const loaded = isRead ? await $.fs.read(path, { as: 'bytes' }).catch(() => null) : null
78 const bytes = loaded === null ? null : bytesOf(loaded.base64)
79 const type = named ?? (bytes === null ? 'unknown' : typeOfBytes(bytes))
80 const facts = type === 'video' || type === 'audio' ? mediaFactsOf(await probe($, path), size) : factsOf(type, bytes, size)
81 const fresh = { type, facts }
82
83 if (described.size >= KEPT) {
84 described.clear()
85 }
86
87 if (!isRead || loaded !== null) {
88 described.set(key, fresh)
89 }
90
91 return fresh
92}
93
94// A pasted picture is saved as `<N>.<format>` the moment it is pasted. That copy is its only path:
95// where it was pasted from reaches a mod only once the prompt is sent.
96const imageChip = async ($: EngineInterface, mark: Extract<Mark, { kind: 'image' }>): Promise<Chip> => {
97 const entries = images === null ? [] : await $.fs.list(images).catch(() => [])
98 const entry = entries.find(({ name }) => name.startsWith(`${mark.number}.`))
99
100 if (images === null || entry === undefined) {
101 return { type: 'image', name: `Image #${mark.number}`, facts: [], mark: mark.text, at: mark.at }
102 }
103
104 const path = `${images}/${entry.name}`
105 const { facts } = await describe($, path, entry.size, entry.mtimeMs)
106
107 return { type: 'image', name: shownPath(path, home), facts, mark: mark.text, at: mark.at }
108}
109
110// A mention is a chip only while what it names is there: a sentence's stop after it is tried
111// without, and anything else, an agent or an MCP resource, is none.
112const mentionChip = async ($: EngineInterface, mark: Extract<Mark, { kind: 'mention' }>): Promise<Chip | null> => {
113 const candidates: Mention[] = mark.bare === null ? [mark] : [mark, mark.bare]
114
115 for (const { text, name, path } of candidates) {
116 const found = expandedPath(path, home)
117 const stat = await $.fs.stat(found).catch(() => undefined)
118
119 if (stat === undefined) {
120 continue
121 }
122
123 if (stat.kind === 'dir') {
124 const entries = await $.fs.list(found).catch(() => [])
125
126 return { type: 'folder', name: shownPath(name, home), facts: [counted(entries.length, 'entry', 'entries')], mark: text, at: mark.at }
127 }
128
129 return { ...(await describe($, found, stat.size, stat.mtimeMs)), name: shownPath(name, home), mark: text, at: mark.at }
130 }
131
132 return null
133}
134
135const chipOf = async ($: EngineInterface, mark: Mark, named: readonly IssueReference[]): Promise<Chip | null> => {
136 if (mark.kind === 'image') {
137 return imageChip($, mark)
138 }
139
140 if (mark.kind === 'mention') {
141 return mentionChip($, mark)
142 }
143
144 // An issue is a chip once the issues mod has loaded it.
145 if (mark.kind === 'issue') {
146 const reference = named.find(({ number }) => number === mark.number)
147
148 return reference === undefined ? null : issueChipOf(reference, mark.text, mark.at)
149 }
150
151 return { type: 'paste', name: `Pasted text #${mark.number}`, facts: mark.lines === null ? [] : [counted(mark.lines, 'line', 'lines')], mark: mark.text, at: mark.at }
152}
153
154const sync = async ($: EngineInterface) => {
155 const [{ text }, named] = await Promise.all([$.prompt.read(), read($, issueReferences)])
156 const draft = JSON.stringify([text, named])
157
158 if (draft === seenDraft) {
159 return
160 }
161
162 seenDraft = draft
163 readings += 1
164 const reading = readings
165 const found = (await Promise.all(marksOf(text).map(mark => chipOf($, mark, named)))).filter(chip => chip !== null)
166 const seen = JSON.stringify(found)
167
168 if (reading === readings && seen !== seenChips) {
169 seenChips = seen
170 await update($, chips, () => found)
171 }
172}
173
174// The chip's placeholder or mention out of the draft. Claude Code puts the cursor at the end of a
175// draft a mod fills in.
176const detach = async ($: EngineInterface, chip: Chip) => {
177 const { text } = await $.prompt.read()
178 const next = withoutMark(text, chip.mark, chip.at)
179
180 if (next !== null) {
181 await $.prompt.fill({ text: next, mode: 'replace' })
182 }
183}
184
185// A session that starts, or another one opened in the same process (`/resume`, `/clear`): its
186// pictures are saved in a folder of its own and its chips start empty, so the draft is read afresh.
187const boot = async ($: EngineInterface, sessionId: string) => {
188 home = await $.env.get('HOME')
189 const temp = (await $.env.get('CLAUDE_CODE_TMPDIR')) ?? '/tmp'
190 const user = await $.process.run(['id', '-u']).catch(() => null)
191 images = user?.exitCode === 0 ? imagesFolder(temp, user.stdout.trim(), await $.session.root(), sessionId) : null
192 seenDraft = undefined
193 seenChips = undefined
194 poll ??= $.clock.every(POLL_MS, () => {
195 void quietly($, 'draft', sync($))
196 })
197}
198
199export const register: Register = on => {
200 on('session.start', async ($, e, next) => {
201 const started = await next(e)
202 void quietly($, 'start', $.session.id().then(id => boot($, id)))
203
204 return started
205 })
206
207 on('classic.SessionStart', ($, e, next) => {
208 void quietly($, 'session', boot($, e.session_id))
209
210 return next(e)
211 })
212
213 // The chips stand above whatever else is drawn in the band, the vim mod's command line among it,
214 // and give way to a survey.
215 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
216 const beneath = await next(e)
217
218 if (e.surface !== 'terminal' || e.props.hasSurvey) {
219 return beneath
220 }
221
222 const shown = await read($, chips)
223
224 if (shown.length === 0) {
225 return beneath
226 }
227
228 const table = $.ui.resolve(e)
229 const { Box } = table
230
231 return (
232 <Box flexDirection="column">
233 {Chips(table, shown, chip => {
234 void quietly($, 'remove', detach($, chip))
235 })}
236 {beneath}
237 </Box>
238 )
239 })
240}
241hooks/draft.ts 72 lines1// The attachments of a draft as Claude Code writes them in the prompt: `[Image #N]` for a pasted
2// or dropped picture, `[Pasted text #N +L lines]` for a paste long enough to be folded, and `@path`
3// (or `@"a path"`) for a mentioned file or folder, a line range after it (`#L10-20`) allowed; and
4// `@#N` for a GitHub issue, as the issues mod writes it. What each one is, on disk or on GitHub, is
5// the hooks' business; here they are only found, and taken out again.
6
7// Where an attachment stands in the draft (`text` from `at`) and what it is there. A mention is
8// named as typed, without its quotes; `path` is the file, without the line range. A mention that
9// ends a sentence carries the stop with it, so `bare` is the same without it.
10export type Mark =
11 | { kind: 'image'; text: string; at: number; number: number }
12 | { kind: 'paste'; text: string; at: number; number: number; lines: number | null }
13 | { kind: 'mention'; text: string; at: number; name: string; path: string; bare: Mention | null }
14 | { kind: 'issue'; text: string; at: number; number: number }
15
16export type Mention = { text: string; name: string; path: string }
17
18// An `@` opens a mention only at the start of the draft or after a space, so an address is none.
19// An issue is `@#N` standing alone, as the issues mod reads it: `(@#2)` is one, `me@#2` and `@#2a`
20// are none.
21const MARKS = /\[Image #(\d+)\]|\[Pasted text #(\d+)(?: \+(\d+) lines?)?\]|(?<![A-Za-z0-9_])@#([1-9]\d*)(?![A-Za-z0-9_])|(?<=^|\s)@("[^"\n]+"|[^\s"]+)/g
22const RANGE = /#L\d+(?:-\d+)?$/
23const STOP = /[.,;:!?)\]}'`]+$/
24
25const mentionOf = (text: string, name: string): Mention => ({ text, name, path: name.replace(RANGE, '') })
26
27export const marksOf = (draft: string): Mark[] =>
28 [...draft.matchAll(MARKS)].flatMap((match): Mark[] => {
29 const [text, image, paste, lines, issue, mention] = match
30 const at = match.index
31
32 if (image !== undefined) {
33 return [{ kind: 'image', text, at, number: Number(image) }]
34 }
35
36 if (issue !== undefined) {
37 return [{ kind: 'issue', text, at, number: Number(issue) }]
38 }
39
40 if (paste !== undefined) {
41 return [{ kind: 'paste', text, at, number: Number(paste), lines: lines === undefined ? null : Number(lines) }]
42 }
43
44 if (mention === undefined) {
45 return []
46 }
47
48 if (mention.startsWith('"')) {
49 return [{ kind: 'mention', ...mentionOf(text, mention.slice(1, -1)), at, bare: null }]
50 }
51
52 const bare = mention.replace(STOP, '')
53
54 return [{ kind: 'mention', ...mentionOf(text, mention), at, bare: bare === mention || bare === '' ? null : mentionOf(`@${bare}`, bare) }]
55 })
56
57// The draft without one attachment and a space beside it, the one after it if there is one: the
58// attachment at `at` while it still stands there, else the first of its text. Null where it is gone.
59export const withoutMark = (draft: string, text: string, at: number): string | null => {
60 const start = draft.startsWith(text, at) ? at : draft.indexOf(text)
61
62 if (start < 0) {
63 return null
64 }
65
66 const end = start + text.length
67 const after = draft[end] === ' ' ? 1 : 0
68 const before = after === 0 && draft[start - 1] === ' ' ? 1 : 0
69
70 return draft.slice(0, start - before) + draft.slice(end + after)
71}
72hooks/files.ts 246 lines1// What a chip says of a file: its type, told by its name or else by its first bytes, and the facts
2// after its path, all worked out here from what the hooks read off the disk.
3
4import type { FileType } from '../types'
5
6// The most of a file that is read: what one `$.fs.read` hands over at most.
7export const MAX_READ = 4 * 1024 * 1024
8// How many of a file's first bytes are looked through for a NUL, which tells a binary from text.
9const SNIFFED = 8000
10// The longest path a chip shows whole.
11export const MAX_PATH = 44
12
13// What a file is by its extension, or by its whole name where its extension tells nothing.
14const KNOWN: [FileType, string][] = [
15 ['text', 'txt md markdown mdx rst adoc asciidoc org log csv tsv tex'],
16 [
17 'code',
18 'ts tsx js jsx mjs cjs mts cts json jsonc json5 yaml yml toml ini cfg conf env xml html htm css scss sass less vue svelte astro py pyi rb go rs java kt kts scala swift c h cc cpp cxx hpp hh m mm cs fs fsx php pl pm lua r jl dart ex exs erl hrl elm hs clj cljs edn sql sh bash zsh fish ps1 bat cmd nix tf hcl proto graphql gql mk cmake gradle groovy vim el lisp scm zig nim sol diff patch lock',
19 ],
20 ['image', 'png jpg jpeg gif webp bmp tif tiff ico heic heif avif svg psd'],
21 ['video', 'mp4 m4v mov mkv webm avi wmv flv mpg mpeg 3gp ogv'],
22 ['audio', 'mp3 wav flac aac m4a ogg oga opus wma aif aiff mid midi'],
23 ['pdf', 'pdf'],
24 ['document', 'doc docx odt rtf pages'],
25 ['spreadsheet', 'xls xlsx ods numbers'],
26 ['slides', 'ppt pptx odp key'],
27 ['archive', 'zip tar gz tgz bz2 tbz xz txz 7z rar zst lz lzma jar war apk deb rpm dmg iso whl gem crate'],
28 ['binary', 'exe dll so dylib o a lib bin dat class pyc wasm out obj node sqlite db'],
29]
30const EXTENSIONS = Object.fromEntries(KNOWN.flatMap(([type, extensions]) => extensions.split(' ').map(extension => [extension, type] as const)))
31const NAMES: Record<string, FileType> = {
32 makefile: 'code',
33 dockerfile: 'code',
34 gemfile: 'code',
35 rakefile: 'code',
36 justfile: 'code',
37 license: 'text',
38 readme: 'text',
39 changelog: 'text',
40}
41
42// The type a file's name tells, or null where it tells none. A dotfile has no extension.
43export const typeOfName = (path: string): FileType | null => {
44 const name = (path.split('/').pop() ?? '').toLowerCase()
45 const dot = name.lastIndexOf('.')
46
47 return EXTENSIONS[dot > 0 ? name.slice(dot + 1) : ''] ?? NAMES[name.split('.')[0] ?? ''] ?? null
48}
49
50// Whether a file's bytes are worth reading: for its type where its name tells none, for an image's
51// size or a text's lines.
52export const wantsBytes = (named: FileType | null) => named === null || named === 'image' || named === 'text' || named === 'code'
53
54// A file's bytes as `$.fs.read` hands them over. The mod's environment has `Uint8Array.fromBase64`;
55// the TypeScript library the mods are checked with does not declare it yet.
56export const bytesOf = (base64: string) => (Uint8Array as unknown as { fromBase64: (text: string) => Uint8Array }).fromBase64(base64)
57
58// Width and height off the header of a PNG, GIF, BMP or WebP, or a JPEG's frame marker.
59export const dimensionsOf = (bytes: Uint8Array) => {
60 const at = (i: number) => bytes[i] ?? 0
61 const tag = (i: number) => String.fromCharCode(at(i), at(i + 1), at(i + 2), at(i + 3))
62 const little = (i: number, count: number) => Array.from({ length: count }, (_, k) => at(i + k) * 256 ** k).reduce((sum, part) => sum + part, 0)
63 const big = (i: number) => ((at(i) << 24) | (at(i + 1) << 16) | (at(i + 2) << 8) | at(i + 3)) >>> 0
64
65 if (tag(0) === '\x89PNG') {
66 return { width: big(16), height: big(20) }
67 }
68
69 if (tag(0) === 'GIF8') {
70 return { width: little(6, 2), height: little(8, 2) }
71 }
72
73 // A BMP is told by the size of its info header as well, since `BM` may start a text.
74 if (at(0) === 0x42 && at(1) === 0x4d && [12, 40, 52, 56, 108, 124].includes(little(14, 4))) {
75 return { width: Math.abs(little(18, 4) >> 0), height: Math.abs(little(22, 4) >> 0) }
76 }
77
78 if (tag(0) === 'RIFF' && tag(8) === 'WEBP') {
79 if (tag(12) === 'VP8 ') {
80 return { width: little(26, 2) & 0x3fff, height: little(28, 2) & 0x3fff }
81 }
82
83 if (tag(12) === 'VP8L') {
84 return { width: 1 + (((at(22) & 0x3f) << 8) | at(21)), height: 1 + (((at(24) & 0xf) << 10) | (at(23) << 2) | ((at(22) & 0xc0) >> 6)) }
85 }
86
87 if (tag(12) === 'VP8X') {
88 return { width: 1 + little(24, 3), height: 1 + little(27, 3) }
89 }
90 }
91
92 if (at(0) === 0xff && at(1) === 0xd8 && at(2) === 0xff) {
93 let i = 2
94
95 while (i + 9 < bytes.length) {
96 const marker = at(i + 1)
97
98 if (at(i) !== 0xff) {
99 i += 1
100 } else if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
101 i += 2
102 } else if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
103 return { width: (at(i + 7) << 8) | at(i + 8), height: (at(i + 5) << 8) | at(i + 6) }
104 } else {
105 i += 2 + ((at(i + 2) << 8) | at(i + 3))
106 }
107 }
108 }
109
110 return null
111}
112
113// The type of a file whose name tells none, from its first bytes: a header it is known by, else a
114// NUL for a binary, else text.
115export const typeOfBytes = (bytes: Uint8Array): FileType => {
116 const at = (i: number) => bytes[i] ?? 0
117 const tag = (i: number) => String.fromCharCode(at(i), at(i + 1), at(i + 2), at(i + 3))
118
119 if (dimensionsOf(bytes) !== null) {
120 return 'image'
121 }
122
123 if (tag(0) === '%PDF') {
124 return 'pdf'
125 }
126
127 if (tag(0) === 'PK\x03\x04' || (at(0) === 0x1f && at(1) === 0x8b) || tag(0) === 'Rar!' || tag(0) === '7z\xbc\xaf') {
128 return 'archive'
129 }
130
131 // MP4 and its kin name their brand after `ftyp`: HEIC and AVIF pictures and M4A sound use it too.
132 if (tag(4) === 'ftyp') {
133 const brand = tag(8)
134
135 return ['heic', 'heix', 'mif1', 'avif'].includes(brand) ? 'image' : brand.startsWith('M4A') ? 'audio' : 'video'
136 }
137
138 if (tag(0) === '\x1aE\xdf\xa3') {
139 return 'video'
140 }
141
142 if (tag(0) === 'fLaC' || tag(0) === 'OggS' || tag(0).startsWith('ID3') || (tag(0) === 'RIFF' && tag(8) === 'WAVE')) {
143 return 'audio'
144 }
145
146 return bytes.subarray(0, SNIFFED).includes(0) ? 'binary' : 'text'
147}
148
149// The lines a text has, a last one without its line feed counted.
150export const linesIn = (bytes: Uint8Array) => {
151 const feeds = bytes.reduce((count, byte) => (byte === 10 ? count + 1 : count), 0)
152
153 return bytes.length > 0 && bytes[bytes.length - 1] !== 10 ? feeds + 1 : feeds
154}
155
156export const counted = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
157
158// `730 KB`, `1.7 KB`, `5 MB`: a decimal only under ten.
159export const sizeOf = (bytes: number) => {
160 const rounded = (value: number) => (value < 10 ? Number(value.toFixed(1)) : Math.round(value))
161
162 if (bytes < 1024) {
163 return `${bytes} B`
164 }
165
166 return bytes < 1024 * 1024 ? `${rounded(bytes / 1024)} KB` : `${rounded(bytes / 1024 / 1024)} MB`
167}
168
169// `0:42`, `1:02:05`.
170export const durationOf = (seconds: number) => {
171 const whole = Math.round(seconds)
172 const two = (count: number) => String(count).padStart(2, '0')
173 const hours = Math.floor(whole / 3600)
174 const minutes = Math.floor((whole % 3600) / 60)
175
176 return hours > 0 ? `${hours}:${two(minutes)}:${two(whole % 60)}` : `${minutes}:${two(whole % 60)}`
177}
178
179// What a chip says after an image's path, a text's or the size alone.
180export const factsOf = (type: FileType, bytes: Uint8Array | null, size: number): string[] => {
181 const found = type === 'image' && bytes !== null ? dimensionsOf(bytes) : null
182
183 if (found !== null) {
184 return [`${found.width}x${found.height}`, sizeOf(size)]
185 }
186
187 return (type === 'text' || type === 'code') && bytes !== null ? [counted(linesIn(bytes), 'line', 'lines'), sizeOf(size)] : [sizeOf(size)]
188}
189
190// A video's frame and a video's or a sound's length, asked of ffprobe, which comes with ffmpeg.
191export type Probe = { frame: string | null; length: string | null }
192
193export const PROBE = (path: string) => ['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,width,height:format=duration', '-of', 'json', path]
194
195export const probed = (stdout: string): Probe | null => {
196 try {
197 const found = JSON.parse(stdout) as { streams?: { codec_type?: string; width?: number; height?: number }[]; format?: { duration?: string } }
198 const video = found.streams?.find(stream => stream.codec_type === 'video' && stream.width !== undefined && stream.height !== undefined)
199 const seconds = Number(found.format?.duration)
200
201 return { frame: video === undefined ? null : `${video.width}x${video.height}`, length: Number.isFinite(seconds) ? durationOf(seconds) : null }
202 } catch {
203 return null
204 }
205}
206
207export const mediaFactsOf = (probe: Probe | null, size: number) => [probe?.frame ?? '', probe?.length ?? '', sizeOf(size)].filter(fact => fact !== '')
208
209// A mentioned path as the file system takes it: `~` is the home folder.
210export const expandedPath = (path: string, home: string | undefined) => (home !== undefined && (path === '~' || path.startsWith('~/')) ? `${home}${path.slice(1)}` : path)
211
212// A path as a chip shows it: under the home folder from `~`, and past MAX_PATH its first folders,
213// then `…`, then as many of its last parts as fit.
214export const shownPath = (path: string, home: string | undefined) => {
215 const full = home !== undefined && home !== '' && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
216
217 if (full.length <= MAX_PATH) {
218 return full
219 }
220
221 const parts = full.split('/')
222 const lead = full.startsWith('/') ? 3 : full.startsWith('~/') ? 2 : 1
223 const head = parts.slice(0, lead).join('/')
224 let tail = parts.at(-1) ?? ''
225
226 for (let i = parts.length - 2; i >= lead; i -= 1) {
227 const longer = `${parts[i]}/${tail}`
228
229 if (`${head}/…/${longer}`.length > MAX_PATH) {
230 break
231 }
232
233 tail = longer
234 }
235
236 const short = `${head}/…/${tail}`
237
238 return short.length <= MAX_PATH ? short : `…${full.slice(-(MAX_PATH - 1))}`
239}
240
241// Where Claude Code saves what is pasted into a session's prompt, a picture as `<N>.<format>`:
242// a folder of the session's own, under one for the user in its temp directory, named by the
243// project's folder as the transcripts are. Not documented; seen on Claude Code 2.1.289.
244export const imagesFolder = (temp: string, uid: string, root: string, sessionId: string) =>
245 `${temp}/claude-${uid}/${root.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}/images`
246hooks/issues.ts 33 lines1// A GitHub issue or pull request the draft names with `@#N`, as its chip shows it. What the issue
2// is comes from the issues mod, which loads it and keeps it in its state; here it is only told by
3// its state and named.
4
5import type { Chip, FileType, IssueReference } from '../types'
6import { MAX_PATH } from './files'
7
8// The glyph an issue's chip has: GitHub's mark for what it is and how it stands.
9export const issueTypeOf = ({ state, isPull }: IssueReference): FileType => {
10 if (!isPull) {
11 return state === 'OPEN' ? 'issue' : 'issue-closed'
12 }
13
14 return state === 'OPEN' ? 'pull' : state === 'MERGED' ? 'pull-merged' : 'pull-closed'
15}
16
17// `#N` and its title, cut to the width a path has on a chip.
18export const issueNameOf = ({ number, title }: IssueReference) => {
19 const chars = [...`#${number} ${title}`]
20
21 return chars.length <= MAX_PATH ? chars.join('') : `${chars.slice(0, MAX_PATH - 1).join('')}…`
22}
23
24// The chip of an issue the draft names once the issues mod has loaded it. Its state is said once it
25// is no longer open.
26export const issueChipOf = (reference: IssueReference, mark: string, at: number): Chip => ({
27 type: issueTypeOf(reference),
28 name: issueNameOf(reference),
29 facts: reference.state === 'OPEN' ? [] : [reference.state.toLowerCase()],
30 mark,
31 at,
32})
33hooks/view.tsx 24 lines1import type { Elements } from 'claude-code'
2
3import type { Chip } from '../types'
4import { GLYPHS, theme } from './theme'
5
6type Parts = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
7
8// The draft's attachments as chips in a row that wraps, in the order they stand in the draft:
9// each the glyph of its type, its path, its facts and a × that takes it out of the draft.
10export const Chips = ({ Box, Text, Button }: Parts, chips: readonly Chip[], onRemove: (chip: Chip) => void) => (
11 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
12 {chips.map(chip => (
13 <Box key={`chip:${chip.at}`} flexDirection="row" columnGap={1} paddingX={1} backgroundColor={theme.chip}>
14 <Text color={theme.glyphs[chip.type]}>{GLYPHS[chip.type]}</Text>
15 <Text>{chip.name}</Text>
16 {chip.facts.length === 0 ? null : <Text dimColor>{chip.facts.join(' · ')}</Text>}
17 <Button key={`remove:${chip.at}`} plain dimColor onPress={() => onRemove(chip)}>
18 ×
19 </Button>
20 </Box>
21 ))}
22 </Box>
23)
24hooks/theme.ts 55 lines1import type { FileType } from '../types'
2
3// The glyph on each kind of attachment's chip, from Nerd Fonts as the statusline mod's branch icon
4// is: Font Awesome's file outlines, and Octicons' binary file. A font without them draws a box.
5export const GLYPHS: Record<FileType, string> = {
6 text: '\uf0f6', // nf-fa-file_text_o
7 code: '\uf1c9', // nf-fa-file_code_o
8 image: '\uf1c5', // nf-fa-file_image_o
9 video: '\uf1c8', // nf-fa-file_video_o
10 audio: '\uf1c7', // nf-fa-file_audio_o
11 pdf: '\uf1c1', // nf-fa-file_pdf_o
12 document: '\uf1c2', // nf-fa-file_word_o
13 spreadsheet: '\uf1c3', // nf-fa-file_excel_o
14 slides: '\uf1c4', // nf-fa-file_powerpoint_o
15 archive: '\uf1c6', // nf-fa-file_archive_o
16 binary: '\uf471', // nf-oct-file_binary
17 folder: '\uf114', // nf-fa-folder_o
18 paste: '\uf0ea', // nf-fa-clipboard
19 unknown: '\uf016', // nf-fa-file_o
20 // GitHub's own: Octicons' issue and pull request marks, by state.
21 issue: '\uf41b', // nf-oct-issue_opened
22 'issue-closed': '\uf41d', // nf-oct-issue_closed
23 pull: '\uf407', // nf-oct-git_pull_request
24 'pull-merged': '\uf419', // nf-oct-git_merge
25 'pull-closed': '\uf4dc', // nf-oct-git_pull_request_closed
26}
27
28// Every color is a key of Claude Code's theme, so the chips follow whatever `/theme` picks, light
29// or dark. The keys are chosen for their color in the built-in themes as much as for their meaning.
30export const theme = {
31 chip: 'userMessageBackground',
32 glyphs: {
33 text: 'text',
34 code: 'success',
35 image: 'suggestion',
36 video: 'autoAccept',
37 audio: 'planMode',
38 pdf: 'error',
39 document: 'suggestion',
40 spreadsheet: 'success',
41 slides: 'claude',
42 archive: 'bashBorder',
43 binary: 'inactive',
44 folder: 'claude',
45 paste: 'warning',
46 unknown: 'inactive',
47 // Green while open, purple once done, red for a pull request closed unmerged, as GitHub has them.
48 issue: 'success',
49 'issue-closed': 'autoAccept',
50 pull: 'success',
51 'pull-merged': 'autoAccept',
52 'pull-closed': 'error',
53 } satisfies Record<FileType, string>,
54}
55types/index.d.ts 47 lines1// What kind of file an attachment is, as the glyph on its chip shows it. `paste` is a long paste
2// folded into the draft; `unknown` a file whose name tells nothing and that is too big to look into.
3// The last five are a GitHub issue or pull request the draft names with `@#N`, by its state.
4export type FileType =
5 | 'text'
6 | 'code'
7 | 'image'
8 | 'video'
9 | 'audio'
10 | 'pdf'
11 | 'document'
12 | 'spreadsheet'
13 | 'slides'
14 | 'archive'
15 | 'binary'
16 | 'folder'
17 | 'paste'
18 | 'unknown'
19 | 'issue'
20 | 'issue-closed'
21 | 'pull'
22 | 'pull-merged'
23 | 'pull-closed'
24
25// One attachment of the draft as its chip shows it: what kind it is, its path (a pasted text's name
26// where there is none), the facts after it, and the placeholder or mention the × takes out of the
27// draft, with where it starts.
28export type Chip = { type: FileType; name: string; facts: string[]; mark: string; at: number }
29
30// An issue the draft names with `@#N`, as the issues mod loaded it: the value this mod reads from
31// that one, as it declares it.
32export type IssueReference = { number: number; title: string; state: string; isPull: boolean }
33
34// These outlive a reload of the mod, so a value whose shape changes takes a new key.
35declare module 'claude-code' {
36 interface PluginState {
37 attachments: {
38 // The draft's attachments, in the order they stand in it.
39 chips: Chip[]
40 }
41 // Read from the issues mod, which keeps them; nothing is kept here.
42 issues: {
43 references: IssueReference[]
44 }
45 }
46}
47