SLOPSHOPPER

image-links

The images Claude sends you or names in its answer, as chips above the prompt: hover one to see it, click to open it in Preview.

newbandguardpromptprocess
v0.2.0MITupdated 2026-10-21aerodiduch/claude-image-links
A shopper browsing a rack in a slop shop
README

Image Links

CI License: MIT macOS Claude Code mod TypeScript

Español

Claude Code in a terminal with three image names as chips above the prompt; the pointer is on one and the picture shows in a small window next to it

A Claude Code mod that turns the images Claude sends you into chips above the prompt. Hover one and the picture shows next to the pointer; click it and it opens in Preview. I work in Claude Code in iTerm2 all day and Claude keeps sending me screenshots, of a screen it just built or of a test run. The image-preview mod shows the images I paste, not the ones Claude sends, which stayed as file paths I had to open by hand.

  • Picks up the files Claude sends with SendUserFile and the image paths it writes in its answer (.png, .jpg, .jpeg, .gif, .webp), as long as they exist on disk.
  • Up to 8 images, the newest last. Sending your next prompt clears them.
  • The preview window never takes the focus or the pointer: you keep typing in the terminal.
  • The word before the chips is an option (Images by default).

Install

The quickest way: paste this into a Claude Code session.

Install the image-links mod from https://github.com/aerodiduch/claude-image-links by running
`claude plugin install image-links --marketplace aerodiduch/claude-image-links`.
Then check whether my Claude Code settings have "tui": "fullscreen", which the mod needs
for hovering. If they don't, tell me and ask before changing it. Finally, tell me to start
a new session so the mod loads.

Or by hand. You need Claude Code with mods (tested on 2.1.306) and its fullscreen interface: "tui": "fullscreen" in your settings, or set it with /config. Hovering needs the mouse, and the terminal only reports it in fullscreen.

At the prompt of a terminal session:

/plugin install image-links --marketplace aerodiduch/claude-image-links

Answer y to add the marketplace and press Enter to install it for your user. From a shell:

claude plugin install image-links --marketplace aerodiduch/claude-image-links

Usage

When Claude's answer names an image that exists, or Claude sends you one, its name shows above the prompt:

Images  login.png  settings.png  cover.jpg
❯
  • Hover a name: the picture opens in a small window by the pointer, up to 800 points on its longest side.
  • Click it: it opens in Preview.

To change the word before the chips, open /config and set Band label under image-links.

If something doesn't work

  • No chips show up. The interface is not fullscreen, or the path is not absolute (/… or ~/…) or does not exist. A relative path is only taken from SendUserFile, which resolves it from the project folder.
  • The chips show but hovering does nothing. The preview window is macOS only. On Linux and Windows the chips show and nothing opens.

Security

The mod runs two programs, both with the image's path and nothing else: osascript to draw the preview window, and open -a Preview when you click. It checks whether the paths in Claude's answer exist on disk, and reads nothing else. It makes no network requests.

How it works

A Claude Code hooks module in TypeScript. It reads the image paths from each answer (turn.complete) and from SendUserFile calls (tool.call), keeps them in the session's state, and draws them above the prompt (ui.render on AbovePrompt). Each chip is a small client module that reports when the pointer comes onto it, leaves it or clicks it. The window is an AppKit panel drawn by JavaScript for Automation through osascript; it and the chip are adapted from image-preview by Miguel Ángel Durán.

Development

claude plugin validate .
claude plugin test .

The tests check that an answer's image paths become chips (and a path that does not exist does not), that a new prompt clears them, that a file sent with SendUserFile shows up from a relative path, and that the band label option works.

License

MIT. hooks/window.ts and hooks/chip.tsx are adapted from better-claude, MIT, with its license in hooks/better-claude-LICENSE.

Source 4 files
hooks/register.tsx 162 lines
1// The images of the last turn as chips above the prompt: the files Claude sent (SendUserFile) and
2// the image paths in its answer. The pointer on a chip shows the picture in a window by the pointer
3// (macOS); a click opens it in Preview. Sending a new prompt clears them.
4//
5// Hovering needs the mouse, so it needs Claude Code's fullscreen interface.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { ImageList } from '../types'
10
11import { MACOS } from './window'
12
13type Engine = EngineInterface
14
15const images = atom({ plugin: 'image-links', key: 'images' } as const, [] as ImageList)
16
17const IMAGE = /\.(png|jpe?g|gif|webp)$/i
18// A path in an answer: absolute or under ~, up to a space, a quote, a backtick or a bracket. A
19// period right after it ends a sentence, it is not part of the name.
20const PATH = /(?:~\/|\/)[^\s`'"()<>[\]]+?\.(?:png|jpe?g|gif|webp)(?![\w-])/gi
21const MAX_IMAGES = 8
22const MAX_LABEL = 36
23// The longest side of the picture in its window, in points, and the most seconds it stays.
24const WINDOW_SIZE = 800
25const WINDOW_LIFE = 120
26
27let isMac = false
28let home = ''
29let cwd = ''
30let hovered: string | null = null
31let window: { path: string; isOpen: boolean } | undefined
32
33const nameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1)
34const labelOf = (path: string) => {
35  const name = nameOf(path)
36  return name.length <= MAX_LABEL ? name : `${name.slice(0, MAX_LABEL - 1)}…`
37}
38
39/** The paths that are images on this disk, absolute, each once. */
40async function imagesIn ($: Engine, paths: readonly string[]) {
41  const found: string[] = []
42  for (const raw of paths) {
43    const path = raw.startsWith('~/') ? home + raw.slice(1) : raw.startsWith('/') ? raw : `${cwd}/${raw}`
44    if (!IMAGE.test(path) || found.includes(path)) continue
45    if (await $.fs.exists(path).catch(() => false)) found.push(path)
46  }
47  return found
48}
49
50/** Adds images to the band, the newest last, keeping the last MAX_IMAGES. */
51async function add ($: Engine, paths: readonly string[]) {
52  const found = await imagesIn($, paths)
53  if (found.length === 0) return
54  await update($, images, list => [...list.filter(path => !found.includes(path)), ...found].slice(-MAX_IMAGES))
55}
56
57/** Shows the picture in a borderless window by the pointer, until closeWindow. */
58async function openWindow ($: Engine, path: string) {
59  if (!isMac || (window?.path === path && window.isOpen)) return
60  closeWindow()
61  const opening = { path, isOpen: true }
62  window = opening
63  const env = { IMAGE_PREVIEW_FILE: path, IMAGE_PREVIEW_SIZE: String(WINDOW_SIZE), IMAGE_PREVIEW_LIFE: String(WINDOW_LIFE) }
64  try {
65    // The program says `.` ten times a second: each wakes this loop to see whether it was closed,
66    // and leaving the loop ends the program.
67    for await (const chunk of $.process.spawn({ argv: ['osascript', '-l', 'JavaScript', '-e', MACOS], env })) {
68      void chunk
69      if (!opening.isOpen) break
70    }
71  } catch {
72    // The program ended on its own (its life is over, or it could not read the picture).
73  }
74  if (window === opening) window = undefined
75}
76
77function closeWindow () {
78  if (window) window.isOpen = false
79  window = undefined
80}
81
82async function clear ($: Engine) {
83  closeWindow()
84  hovered = null
85  await update($, images, () => [])
86}
87
88export const register: Register = (on, options) => {
89  const label = typeof options.label === 'string' && options.label.trim() ? options.label.trim() : 'Images'
90
91  on('session.start', async ($, e, next) => {
92    const result = await next(e)
93    home = (await $.env.get('HOME')) ?? ''
94    cwd = await $.session.cwd()
95    isMac = (await $.process.run(['uname', '-s']).catch(() => undefined))?.stdout.trim() === 'Darwin'
96    return result
97  })
98
99  // A new prompt starts a new set: what the last turn showed was for the last question.
100  on('prompt.submit', async ($, e, next) => {
101    const result = await next(e)
102    if (e.origin.kind === 'composer') await clear($)
103    return result
104  }).catch(($, e, next) => next(e))
105
106  on('tool.call', { tool: 'SendUserFile' }, async ($, e, next) => {
107    const result = await next(e)
108    if (result.deny === undefined && result.isError !== true) await add($, e.files).catch(() => undefined)
109    return result
110  }).catch(($, e, next) => next(e))
111
112  // The image paths the answer names, in the main conversation only (a subagent's answer is not shown).
113  on('turn.complete', async ($, e, next) => {
114    const result = await next(e)
115    if (e.agentId === undefined) await add($, [...e.answer.matchAll(PATH)].map(match => match[0])).catch(() => undefined)
116    return result
117  })
118
119  on('ui.message', async ($, e, next) => {
120    const result = await next(e)
121    const chip = /^img:(\d+)$/.exec(e.element)
122    if (!chip) return result
123    const path = (await read($, images))[Number(chip[1])]
124    if (!path) return result
125    const data = e.data as { isOver?: unknown; isClicked?: unknown } | null
126    if (data?.isClicked === true) {
127      // Preview takes the screen: a window left open would stand over it.
128      closeWindow()
129      hovered = null
130      if (isMac) void $.process.run(['open', '-a', 'Preview', path]).catch(() => undefined)
131      return result
132    }
133    if (data?.isOver === true) {
134      hovered = path
135      void openWindow($, path)
136    } else if (hovered === path) {
137      hovered = null
138      closeWindow()
139    }
140    return result
141  })
142
143  // Above the prompt: one chip per image. Off fullscreen the terminal reports no pointer, so there
144  // is nothing to hover and the band stays out.
145  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
146    if (e.surface !== 'terminal' || e.props.hasSurvey || e.viewport?.isFullscreen === false) return next(e)
147    const list = await read($, images)
148    if (list.length === 0) return next(e)
149    const { Box, Client, Text } = $.ui.resolve(e)
150    const below = await next(e)
151    return (
152      <Box flexDirection="column">
153        {below}
154        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
155          <Text dimColor>{label}</Text>
156          {list.map((path, i) => <Client key={`img:${i}`} module="./chip.tsx" props={{ label: labelOf(path) }} />)}
157        </Box>
158      </Box>
159    )
160  })
161}
162
hooks/window.ts 74 lines
1// Adapted from image-preview in midudev/better-claude (MIT, (c) 2026 Miguel Ángel Durán): its macOS
2// window, alone. Run by `osascript` with IMAGE_PREVIEW_FILE (the picture), IMAGE_PREVIEW_SIZE (its
3// longest side at most, in points) and IMAGE_PREVIEW_LIFE (the most seconds it stays) in the
4// environment. It writes `shown` once the window is up, then a `.` every tenth of a second, and ends
5// when nobody reads them. The window never takes the focus or the pointer.
6
7export const MACOS = String.raw`
8ObjC.import('Cocoa')
9
10function run () {
11  const env = $.NSProcessInfo.processInfo.environment
12  const read = key => { const value = env.objectForKey(key); return value.isNil() ? '' : ObjC.unwrap(value) }
13  const say = text => $.NSFileHandle.fileHandleWithStandardOutput.writeData($(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding))
14  const largest = Number(read('IMAGE_PREVIEW_SIZE')) || 800
15  // An accessory app: no Dock icon, never the active one.
16  $.NSApplication.sharedApplication.setActivationPolicy(1)
17  const image = $.NSImage.alloc.initWithContentsOfFile(read('IMAGE_PREVIEW_FILE'))
18  if (image.isNil() || image.representations.count === 0) { say('error: cannot read the image'); return }
19  const pixels = image.representations.firstObject
20  const mouse = $.NSEvent.mouseLocation
21  const screens = $.NSScreen.screens
22  let screen = $.NSScreen.mainScreen
23  for (let i = 0; i < screens.count; i++) {
24    const frame = screens.objectAtIndex(i).frame
25    if (mouse.x >= frame.origin.x && mouse.x < frame.origin.x + frame.size.width && mouse.y >= frame.origin.y && mouse.y < frame.origin.y + frame.size.height) screen = screens.objectAtIndex(i)
26  }
27  const area = screen.visibleFrame
28  let width = pixels.pixelsWide / screen.backingScaleFactor
29  let height = pixels.pixelsHigh / screen.backingScaleFactor
30  const fit = Math.min(1, largest / Math.max(width, height), (area.size.width * 0.7) / width, (area.size.height * 0.7) / height)
31  width = Math.round(width * fit)
32  height = Math.round(height * fit)
33  const pad = 6
34  const gap = 18
35  const outer = { width: width + pad * 2, height: height + pad * 2 }
36  const top = area.origin.y + area.size.height
37  const right = area.origin.x + area.size.width
38  // Above the pointer and to its right; under it, or to its left, where there is no room: never
39  // over the pointer, which stays on the terminal.
40  let x = mouse.x + gap
41  let y = mouse.y + gap
42  if (y + outer.height > top) y = mouse.y - gap - outer.height
43  if (x + outer.width > right) x = mouse.x - gap - outer.width
44  x = Math.max(area.origin.x, Math.min(x, right - outer.width))
45  y = Math.max(area.origin.y, Math.min(y, top - outer.height))
46  // Borderless and non-activating; over every app window, on every space, full-screen ones too.
47  const panel = $.NSPanel.alloc.initWithContentRectStyleMaskBackingDefer($.NSMakeRect(x, y, outer.width, outer.height), 128, 2, false)
48  panel.level = 25
49  panel.ignoresMouseEvents = true
50  panel.hasShadow = true
51  panel.opaque = false
52  panel.backgroundColor = $.NSColor.clearColor
53  panel.collectionBehavior = 1 | 16 | 256
54  const backdrop = $.NSVisualEffectView.alloc.initWithFrame($.NSMakeRect(0, 0, outer.width, outer.height))
55  backdrop.material = 13
56  backdrop.state = 1
57  backdrop.wantsLayer = true
58  backdrop.layer.cornerRadius = 10
59  backdrop.layer.masksToBounds = true
60  const view = $.NSImageView.alloc.initWithFrame($.NSMakeRect(pad, pad, width, height))
61  view.image = image
62  view.imageScaling = 3
63  backdrop.addSubview(view)
64  panel.contentView = backdrop
65  panel.orderFrontRegardless
66  say('shown')
67  const end = Date.now() + (Number(read('IMAGE_PREVIEW_LIFE')) || 120) * 1000
68  while (Date.now() < end) {
69    $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1))
70    say('.')
71  }
72}
73`
74
hooks/chip.tsx 41 lines
1// Adapted from image-preview in midudev/better-claude (MIT, (c) 2026 Miguel Ángel Durán).
2// The surface module of each chip: it says when the pointer comes onto it and leaves, so the hooks
3// module opens and closes the preview window, and when it is clicked, so the image opens.
4import type { ClientModule } from 'claude-code'
5
6type Props = { label: string }
7
8const REPEAT_MS = 50
9
10const Chip: ClientModule<Props, { isOver: boolean; isPressed?: boolean }> = (props, surface) => {
11  surface.onPointer(event => {
12    // A click is the left button let go over the chip it went down on.
13    if (event.type === 'down') {
14      if (event.button === 'left') surface.setState({ isOver: true, isPressed: true })
15      return
16    }
17    if (event.type === 'up') {
18      // A region not laid out yet (0 by 0) says nothing of where the pointer is.
19      const isInside = surface.columns === 0 || (event.x >= 0 && event.y >= 0 && event.x < surface.columns && event.y < surface.rows)
20      if (surface.state?.isPressed && isInside) surface.post({ isOver: true, isClicked: true })
21      surface.setState({ isOver: isInside })
22      return
23    }
24    const isOver = event.type !== 'leave'
25    surface.post({ isOver })
26    if (isOver !== (surface.state?.isOver ?? false)) surface.setState({ isOver, isPressed: surface.state?.isPressed })
27    // Going straight from one chip to the next, the leave and the enter post in one frame and the
28    // later replaces the other: the enter says so again a frame on.
29    if (event.type === 'enter') {
30      const stop = surface.every(REPEAT_MS, () => {
31        stop()
32        if (surface.state?.isOver) surface.post({ isOver: true })
33      })
34    }
35  })
36  const { Text } = surface.elements
37  return <Text color="claude" inverse={surface.state?.isOver === true}>{props.label}</Text>
38}
39
40export default Chip
41
types/index.d.ts 9 lines
1/** Absolute paths of the images of the last turn, oldest first. */
2export type ImageList = string[]
3
4declare module 'claude-code' {
5  interface PluginState {
6    'image-links': { images: ImageList }
7  }
8}
9