SLOPSHOPPER

for-your-eyes

Put the final answer in focus. A rounded border highlights each turn’s final answer while preserving native interactions.

rowscommandprocesstimer
★ 5v0.4.3MITupdated 2026-10-26keshavatearth/for-your-eyes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · for-your-eyes
› fix the failing auth test and add an audit log call ⏺ 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-your-eyes ⎿ for-your-eyes: Final-answer highlight is off: final answers are drawn like every other message. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

For Your Eyes

Put the final answer in focus.

A Claude Code mod that frames each turn’s final answer, keeping links, selection, copy, and scrolling native to Claude.

Intermediate updates, thinking, and tool calls stay available. The mod only changes how final answers look; model output and saved conversations stay intact.

Claude Code with a rounded border around the final answer, while progress updates and tool activity remain visible above it.

For Your Eyes in Claude Code on macOS, using a sample conversation.

Install

Requires a Claude Code build with TypeScript mods support and Python 3 available as python3. Tested with Claude Code 2.1.300 and the Claude desktop app on macOS. This is a Claude Code mod, not a browser extension or a plugin for ordinary claude.ai chats.

claude plugin marketplace add keshavatearth/for-your-eyes
claude plugin install for-your-eyes@keshav-mods

Open a Code session in Claude Desktop or the terminal. The highlight is on by default. Run /for-your-eyes to turn it off or back on; the preference is saved locally.

Update an existing installation with:

claude plugin marketplace update keshav-mods
claude plugin update for-your-eyes@keshav-mods

Claude Desktop can reload an updated mod in an idle session. If your client does not, reopen the session. A terminal session needs to be restarted to load an installed update.

Upgrading from Final Only

Version 0.4.0 renames the mod and command to for-your-eyes. Remove the old installation before installing the new name so only one copy is active:

claude plugin uninstall final-only@keshav-mods --keep-data
claude plugin marketplace update keshav-mods
claude plugin install for-your-eyes@keshav-mods

The renamed mod starts with highlighting on. If you previously kept it off, run /for-your-eyes once. Older GitHub links redirect to this repository.

Appearance and interactions

  • Desktop: final answers have a full rounded accent border. The interior stays transparent so Claude’s selection highlight remains visible.
  • Terminal: final answers keep the rounded, accent-colored box and theme-aware background.
  • Other remote surfaces: use the unfilled outline too; VS Code and mobile have automated render-contract coverage but have not been checked manually.

The border wraps exactly one native response. The mod does not turn answers into quotes, duplicate their text, replace native controls, add click handlers, or constrain their height/scrolling. Saved responses remain intact; selection and copy use Claude’s native controls.

On Desktop, v0.4.3 also normalizes local file-link destinations written in angle brackets: [file](</path/with spaces.md>) is drawn as file. The app otherwise treats that angle-bracket form as a missing file. This applies only to active final-answer rendering; saved messages, web links, images, and code examples remain intact. Relative file targets resolve from the session’s working folder, so a link still needs the correct path.

Version 0.3.3 removes the desktop background layer that interfered with visible selection, avoids unnecessary delayed redraws of settled answers, and hardens message identification. See the mods render API for the wrapper contract.

How it works

The mod tracks main-conversation text rows and marks the last one when the turn ends. It reads the current session’s local JSONL transcript to identify answers in resumed conversations and to match Desktop’s message/block IDs to transcript rows. A temporary text match covers the delay before the transcript is written; repeated text is treated as ambiguous instead of guessing.

The transcript helper accepts compact and spaced JSON, handles multiple text blocks, and skips partial writes and subagent rows. If a message cannot be identified, it stays in Claude’s normal rendering.

The mod makes no network requests or model calls. It reads the current transcript and runs the bundled Python helper locally. It stores the on/off setting and bounded local load diagnostics (version, timestamp, turn count, and transcript path) in Claude’s plugin store. No chat data or diagnostics are included in this repository.

Development

git clone https://github.com/keshavatearth/for-your-eyes.git
cd for-your-eyes
./scripts/check.sh

To try a local checkout, start claude --plugin-dir .. Avoid loading a second copy alongside an installed copy of the same mod. Claude writes its engine-specific TypeScript declarations into .claude-plugin/types/ when it loads a development mod. These generated declarations are gitignored. After they exist, tsc --project tsconfig.json checks the source with TypeScript 5.4 or newer.

The mod tests exercise the engine’s real hook chain and returned render tree, including the native-engine response contract, live and resumed messages, summaries, ambiguous text, and the toggle. They do not emulate the desktop browser. For a release, also check a real desktop answer: select across paragraphs, copy the selection, open a web link and local file link, copy a code block, and scroll a long answer with the highlight on and off.

Mods are an evolving Claude Code API. See Anthropic’s mods reference. If an update changes the rendering contract, /for-your-eyes restores ordinary rendering immediately.

License

MIT. An independent project by Keshav, not affiliated with Anthropic.

Source 3 files
hooks/register.tsx 275 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import { desktopFileLinks } from './file-links'
4
5const isOn = atom({ plugin: 'for-your-eyes', key: 'isOn' } as const, true)
6const currentTurn = atom({ plugin: 'for-your-eyes', key: 'currentTurn' } as const, '')
7const textTurn = atom({ plugin: 'for-your-eyes', key: 'textTurn' } as const, '')
8const answerTurn = atom({ plugin: 'for-your-eyes', key: 'answerTurn' } as const, null)
9
10const COMMAND = 'for-your-eyes'
11// The manifest's version, recorded with each load.
12const VERSION = '0.4.3'
13
14// Theme keys keep the outline readable in light and dark modes.
15// Desktop uses an unfilled outline: a background on the shadow-tree wrapper
16// can cover native selection paint. The terminal can retain its tint.
17const ANSWER_BACKGROUND = 'memoryBackgroundColor'
18const ANSWER_BORDER = 'claude'
19
20type Block = { type: string; [field: string]: unknown }
21
22// Every finished turn's answer, from the transcript's index (hooks/index.py), read when the mod loads.
23const indexedAnswers = new Set<string>()
24const textRowsOfMessage = new Map<string, string[]>()
25
26// The desktop app names a text block `<API message id>-t<n>`; the transcript says which row that is.
27const DESKTOP_ID = /^(msg_[A-Za-z0-9_-]+)-t(\d+)$/
28const rowOfItem = new Map<string, string>()
29// This load's text rows by their text: the desktop app draws a row before the transcript file holds it.
30const rowOfText = new Map<string, string>()
31
32// Keep word boundaries and compare the actual text: removing all whitespace (or
33// using a short hash) can make different responses look like the same answer.
34function textKey(text: string) {
35  return text.replace(/\s+/g, ' ').trim()
36}
37// Desktop items the transcript did not hold yet (still streaming), by when they were last looked up.
38const missedAt = new Map<string, number>()
39let transcriptPath = ''
40let generation = 0
41
42function resetIndex() {
43  generation++
44  indexedAnswers.clear()
45  textRowsOfMessage.clear()
46  rowOfItem.clear()
47  rowOfText.clear()
48  missedAt.clear()
49  transcriptPath = ''
50}
51
52const textOf = (content: readonly Block[]) =>
53  content
54    .filter(block => block.type === 'text')
55    .map(block => String(block.text ?? ''))
56    .join('\n')
57
58// The session's transcript file: the projects folder's entry for the folder the session
59// started in, named by the session's id. A new chat's file is written with its first prompt,
60// so the path is worked out from those, not looked for.
61async function transcriptOf($: EngineInterface) {
62  try {
63    const id = await $.session.id()
64    const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
65    const slug = (await $.session.root()).replace(/[^A-Za-z0-9]/g, '-')
66    return `${config}/projects/${slug}/${id}.jsonl`
67  } catch {
68    // No session to name: earlier turns' answers then show as they are.
69    return ''
70  }
71}
72
73// Index the transcript's turns, so a resumed chat's earlier answers stand out too.
74async function loadIndex($: EngineInterface) {
75  const loadingGeneration = generation
76  if (transcriptPath === '' || !(await $.fs.exists(transcriptPath))) return 0
77  const script = `${$.plugin.root}/hooks/index.py`
78  for (const python of ['python3', '/usr/bin/python3']) {
79    try {
80      const { exitCode, stdout } = await $.process.run([python, '-I', script, transcriptPath], { timeoutMs: 20_000 })
81      if (exitCode !== 0) continue
82      const index = JSON.parse(stdout) as { turns: { final: string | null }[]; msgs: Record<string, string[]> }
83      if (loadingGeneration !== generation) return 0
84      for (const one of index.turns) if (one.final !== null) indexedAnswers.add(one.final)
85      for (const [message, rows] of Object.entries(index.msgs)) textRowsOfMessage.set(message, rows)
86      return index.turns.length
87    } catch {
88      // This python is missing or failed: try the next.
89    }
90  }
91  return 0
92}
93
94// The text row a drawing is of: the terminal names it; the desktop app names its API message.
95async function rowOf($: EngineInterface, itemId: string, text: string) {
96  const match = DESKTOP_ID.exec(itemId)
97  if (match === null) return itemId
98  const [, messageId = '', index = '0'] = match
99  const known = rowOfItem.get(itemId) ?? textRowsOfMessage.get(messageId)?.[Number(index)]
100  if (known !== undefined) return known
101  if (transcriptPath === '') transcriptPath = await transcriptOf($)
102  if (transcriptPath === '') return undefined
103  const now = await $.clock.now()
104  if (now - (missedAt.get(itemId) ?? -Infinity) < 2000) return rowOfText.get(textKey(text)) || undefined
105
106  // A row of the running turn, not in the index: find it in the transcript.
107  const found = await $.process
108    .run(['python3', '-I', `${$.plugin.root}/hooks/index.py`, transcriptPath, '--message', messageId], { timeoutMs: 2000 })
109    .catch(() => ({ stdout: '' }))
110  let rows: string[] = []
111  try { rows = JSON.parse(found.stdout) as string[] } catch { /* File may still be arriving. */ }
112  const row = rows[Number(index)]
113  if (row === undefined) {
114    missedAt.set(itemId, now)
115    // Only use text while the transcript is behind, and never cache that guess.
116    // Repeated text is marked ambiguous when appended below.
117    return rowOfText.get(textKey(text)) || undefined
118  }
119  rowOfItem.set(itemId, row)
120  missedAt.delete(itemId)
121  return row
122}
123
124// Whether a text row is its turn's answer: the live turn's last text once it ended, or the index's.
125async function isAnswer($: EngineInterface, row: string) {
126  const liveTurn = await read($, memberOf(textTurn, { requestId: row }))
127  if (liveTurn !== '') {
128    const record = await read($, memberOf(answerTurn, { requestId: liveTurn }))
129    if (record !== null) return !record.isRunning && record.finalId === row
130  }
131  return indexedAnswers.has(row)
132}
133
134export const register: Register = on => {
135  on('session.start', async ($, e, next) => {
136    resetIndex()
137    await $.command.register({
138      name: COMMAND,
139      description: "Highlight each turn's final answer with a rounded border (run again to turn it off)",
140      immediate: true,
141    })
142
143    const stored = await $.store.get('isOn')
144    if (typeof stored === 'boolean') await update($, isOn, () => stored)
145
146    await update($, currentTurn, () => '')
147    transcriptPath = await transcriptOf($)
148    const started = await next(e)
149
150    // Reading the earlier turns can take a moment on a long chat: it runs after the session is up.
151    void (async () => {
152      const turns = await loadIndex($)
153      if (turns > 0) $.ui.invalidate('ui.render')
154      // Which chat loaded which version, one key each so chats loading together keep their own;
155      // the newest 20, what a "did it load?" question is answered from.
156      await $.store.set(`load:${transcriptPath}`, { version: VERSION, at: await $.clock.now(), turns })
157      const loads = (await $.store.keys()).filter(key => key.startsWith('load:'))
158      for (const key of [...loads.slice(0, -20), 'loads', 'loaded', 'seen']) await $.store.delete(key)
159    })().catch(() => undefined)
160
161    return started
162  })
163
164  on('classic.UserPromptSubmit', ($, e, next) => {
165    if (e.transcript_path) transcriptPath = e.transcript_path
166
167    return next(e)
168  })
169
170  on('command.run', { command: COMMAND }, async $ => {
171    const now = await update($, isOn, was => !was)
172    await $.store.set('isOn', now)
173
174    return {
175      text: now
176        ? "Final-answer highlight is on: each turn's final answer is bordered."
177        : 'Final-answer highlight is off: final answers are drawn like every other message.',
178    }
179  })
180
181  on('session.end', async ($, e, next) => {
182    resetIndex()
183    await update($, currentTurn, () => '')
184    return next(e)
185  })
186
187  // Only main-loop turns raise turn.start.
188  on('turn.start', async ($, e, next) => {
189    const previous = await read($, currentTurn)
190    if (previous !== '') {
191      await update($, memberOf(answerTurn, { requestId: previous }), was =>
192        was === null || !was.isRunning ? was : { ...was, finalId: was.texts.at(-1) ?? null, isRunning: false },
193      )
194    }
195    await update($, memberOf(answerTurn, { requestId: e.turnId }), () => ({ texts: [], finalId: null, isRunning: true }))
196    await update($, currentTurn, () => e.turnId)
197
198    return next(e)
199  })
200
201  // Note each text row of the main loop's response before it is stored.
202  on('session.append', { door: 'response' }, async ($, e, next) => {
203    const hasText = e.agentId === undefined && e.message.type === 'assistant' && textOf(e.message.content).trim() !== ''
204    const turnId = hasText ? await read($, currentTurn) : ''
205    if (turnId === '') return next(e)
206
207    await update($, memberOf(textTurn, { requestId: e.uuid }), () => turnId)
208    const key = textKey(textOf(e.message.content))
209    const previousRow = rowOfText.get(key)
210    rowOfText.set(key, previousRow === undefined || previousRow === e.uuid ? e.uuid : '')
211    await update($, memberOf(answerTurn, { requestId: turnId }), was => {
212      if (was === null || was.texts.includes(e.uuid)) return was
213      const texts = [...was.texts, e.uuid]
214      // A row stored after its turn ended is the turn's last word.
215      return was.isRunning ? { ...was, texts } : { ...was, texts, finalId: e.uuid }
216    })
217
218    const stored = await next(e)
219    // A desktop item drawn while it streamed is in the transcript now: ask its drawing again.
220    if (missedAt.size > 0) {
221      missedAt.clear()
222      $.ui.invalidate('ui.render')
223    }
224
225    return stored
226  })
227
228  on('turn.complete', async ($, e, next) => {
229    if (e.agentId === undefined) {
230      await update($, memberOf(answerTurn, { requestId: e.turnId }), was =>
231        was === null ? null : { ...was, finalId: was.texts.at(-1) ?? null, isRunning: false },
232      )
233      // The answer is known now: ask every drawing again, and once more when the transcript file
234      // has caught up, so a block that could not be placed while it streamed gets its box.
235      missedAt.clear()
236      $.ui.invalidate('ui.render')
237      const completedGeneration = generation
238      $.clock.after(2000, () => {
239        // Do not redraw a settled answer while the person is selecting it.
240        if (completedGeneration === generation && missedAt.size > 0) {
241          missedAt.clear()
242          $.ui.invalidate('ui.render')
243        }
244      })
245    }
246
247    return next(e)
248  })
249
250  // The answer, in a box of its own; every other block as the engine draws it.
251  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
252    if (!(await read($, isOn)) || e.props.isSummary) return next(e)
253    const row = await rowOf($, e.requestId, e.props.text)
254    if (row === undefined || !(await isAnswer($, row))) return next(e)
255
256    const text = e.surface === 'desktop' ? desktopFileLinks(e.props.text) : e.props.text
257    const drawing = text === e.props.text ? e : { ...e, props: { ...e.props, text } }
258
259    // Decorate exactly one unchanged engine response. Do not duplicate its
260    // text, add a Markdown leaf, or put it under a scrolling/clipping container.
261    const { Box } = $.ui.resolve(e)
262    return (
263      <Box
264        flexDirection="column"
265        borderStyle="round"
266        borderColor={ANSWER_BORDER}
267        {...(e.surface === 'terminal' ? { backgroundColor: ANSWER_BACKGROUND } : {})}
268        paddingX={1}
269      >
270        {await next(drawing)}
271      </Box>
272    )
273  })
274}
275
hooks/file-links.ts 35 lines
1// Claude Desktop recognizes these as file links, but its file-open preflight
2// treats angle-bracket destinations as missing, including paths without spaces.
3// Use an ordinary Markdown destination instead, encoding characters that need
4// the angle-bracket form. This changes only the drawing, never the transcript.
5export function desktopFileLinks(text: string): string {
6  let fence = ''
7  let inline = ''
8  return text.split('\n').map(line => {
9    const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
10    if (fence !== '') {
11      if (marker && marker[1]![0] === fence[0] && marker[1]!.length >= fence.length && marker[2]!.trim() === '') fence = ''
12      return line
13    }
14    if (marker) { fence = marker[1]!; return line }
15    if (/^(?: {4}|\t)/.test(line)) return line
16
17    // Consume a whole link before inspecting backticks in its label. Code
18    // spans (including spans crossing a newline) and image syntax stay intact.
19    return line.replace(/(`+)|(?<!!)\[([^\]\r\n]*)\]\(<([^<>\r\n]+)>\)/g,
20      (whole: string, ticks: string | undefined, label: string, target: string) => {
21        if (ticks !== undefined) {
22          if (inline === '') inline = ticks
23          else if (inline === ticks) inline = ''
24          return whole
25        }
26        if (inline !== '' || target.trim() !== target) return whole
27        // Preserve web URLs, mail links, anchors, and other schemes. A Windows
28        // drive prefix is a local path, not a URI scheme.
29        if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(target) && !/^[a-z]:[\\/]/i.test(target)) return whole
30        const destination = target.replace(/[ \t()]/g, char => ({ ' ': '%20', '\t': '%09', '(': '%28', ')': '%29' })[char]!)
31        return `[${label}](${destination})`
32      })
33  }).join('\n')
34}
35
types/index.d.ts 24 lines
1/** The main loop's running turn, as the mod tracks it live. */
2export type TurnRecord = {
3  /** The turn's text rows, by row id, in order. */
4  texts: string[]
5  /** The answer: the turn's last text row, once the turn ended. */
6  finalId: string | null
7  isRunning: boolean
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'for-your-eyes': {
13      /** Whether the highlight is on at all (`/for-your-eyes` flips it). */
14      isOn: boolean
15      /** The main loop's running turn, or the last one once it ended. */
16      currentTurn: string
17      /** Which live turn a text row belongs to, by the row's id. */
18      textTurn: StateFamily<string>
19      /** Each live turn's record, by turn id. */
20      answerTurn: StateFamily<TurnRecord | null>
21    }
22  }
23}
24