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

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.

For Your Eyes in Claude Code on macOS, using a sample conversation.
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.
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.
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.
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.
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.
MIT. An independent project by Keshav, not affiliated with Anthropic.
hooks/register.tsx 275 lines1import { 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}
275hooks/file-links.ts 35 lines1// 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}
35types/index.d.ts 24 lines1/** 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