SLOPSHOPPER

attachments

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…

newbandprocesstimer
v0.1.0MITupdated 2026-10-06thefuga/claude-x/mods/attachments
A shopper browsing a rack in a slop shop
README

attachments

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.

A picture pasted and two files mentioned, each a chip above the prompt with its type's glyph, its path, its size or lines; the × on one takes its mention out of the draft

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.

Install

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.

Works with

  • vim: its command line's field is in the same band above the prompt. The chips stand over it, and the line opens as before.
  • issues: each @#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.
  • statusline and syntax: nothing is shared. A × leaves the draft uncolored until the next typed key, as :e does.

None of them is needed.

What a chip shows

AttachmentPathThen
A pasted or dropped picture, [Image #N]The copy Claude Code saved of itWidth x height, size
A long paste, [Pasted text #N +L lines]Pasted text #NLines
A mentioned file, @path or @"a path"As typed, ~ for the home folderBy 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 folderAs typedEntries
A GitHub issue or pull request, @#N, with the issues mod#N and its titleClosed 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.

Glyphs

They are Nerd Font icons, as the statusline mod's branch icon is: a terminal font without them draws a box.

TypeGlyphTold by
Textnf-fa-file_text_omd, txt, log, csv…; LICENSE, README
Codenf-fa-file_code_ots, py, go, json, yaml, sh…; Makefile, Dockerfile
Picturenf-fa-file_image_opng, jpg, gif, webp, svg…
Videonf-fa-file_video_omp4, mov, mkv, webm…
Soundnf-fa-file_audio_omp3, wav, flac, ogg…
PDFnf-fa-file_pdf_opdf
Document, spreadsheet, slidesnf-fa-file_word_o, nf-fa-file_excel_o, nf-fa-file_powerpoint_odocx, xlsx, pptx and their kin
Archivenf-fa-file_archive_ozip, tar.gz, jar, deb…
Binarynf-oct-file_binaryexe, so, wasm, pyc…
Foldernf-fa-folder_o—
Pasted textnf-fa-clipboard—
Unknownnf-fa-file_o—
Issue, open or closednf-oct-issue_opened green, nf-oct-issue_closed purpleIts state
Pull request, open, merged or closednf-oct-git_pull_request green, nf-oct-git_merge purple, nf-oct-git_pull_request_closed redIts 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.

Taking one out

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.

Limits

  • A pasted picture's path is its copy. Claude Code saves what is pasted in a folder of the session's own (/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.
  • A pasted text is not read. Its words stay with Claude Code until the prompt is sent, so its chip has their lines and nothing else.
  • No previews. Claude Code draws a mod's pictures with the kitty graphics protocol only, and never inside tmux.
  • A video's and a sound's length need ffprobe, which comes with ffmpeg. Without it they show their size.
  • A quarter of a second. A paste changes the draft without telling a mod, so the draft is read four times a second, and a chip can come up that long after its attachment.
  • One row above the box. Claude Code keeps a row of its own between the band and the prompt box for its notices, so the chips never touch the box.
  • The terminal only. The desktop app's prompt is left as it is.
Source 7 files
hooks/register.tsx 241 lines
1import { 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}
241
hooks/draft.ts 72 lines
1// 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}
72
hooks/files.ts 246 lines
1// 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`
246
hooks/issues.ts 33 lines
1// 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})
33
hooks/view.tsx 24 lines
1import 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)
24
hooks/theme.ts 55 lines
1import 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}
55
types/index.d.ts 47 lines
1// 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