Shows the file and lines you have selected in Cursor or VS Code above the Claude Code prompt in the desktop app, and quietly tells Claude what you're looking…

Claude always knows what you're looking at in your editor.
A mod for the Claude desktop app that shows your current Cursor or VS Code file and selection in a tab above the prompt, and quietly sends it to Claude with every message.
In the terminal, Claude Code already sees your editor selection through /ide. The desktop app doesn't. So you end up pasting code and file paths into every message.
With this mod, you just select the code and ask: "why does this fail?" or "explain this".
🗂️ A tab above the prompt<br> The Editor tab shows the file you're in and its line range. Hover it for a card with everything else. Click the tab to turn sharing on or off. If other mods add tabs to the same row, they line up beside it.
👀 Live file and selection<br> The tab follows your editor as you click around: the file, the selected lines, and a clay dot while it's sharing.
💬 Sent with every message<br> Claude gets the file path and the selected code. It stays out of your chat; a small "Sent with" chip above your message shows what went with it.
💡 Explain<br> One click in the card asks Claude to explain the selection, or the whole file. It stays busy until Claude has answered, so a double click doesn't ask twice.
📌 Pin<br> Pin up to 10 selections, from any files. They're attached to every message until you unpin them. Click a pin to jump back to its lines in your editor.
💾 Unsaved warning<br> Tells you (and Claude) when the file has unsaved changes, with a Save button.
🕘 Recent files<br> Your last five files, one click to reopen. They're only listed in the card, never sent to Claude.
🔌 Connection notices<br> Says when no editor is open or the connection drops.
🎛️ Proper controls<br> The tab, menu rows and switch are drawn by the mod itself, styled after the app's own popovers.
1. Install the Claude Code extension in your editor. Search for Claude Code by Anthropic in the Extensions view, or run:
cursor --install-extension anthropic.claude-code # Cursor
code --install-extension anthropic.claude-code # VS Code
2. Install the mod. In a Claude Code session:
/plugin marketplace add talbarina/claude-editor-context
/plugin install editor-context@talbarina
3. Start a session in the desktop app, in the same folder you have open in your editor, and click the Editor tab above the prompt to turn it on.
To preview every part of the tab and its card with sample data, run /editor-context-demo.
/ide already does this there./ide on the same editor will take turns with them.Nothing shows in your chat. Claude gets a short note with each message:
The user has lines 12-30 of /path/to/project/src/components/CheckoutForm.tsx
selected in Cursor. "This", "here" or "these lines" likely refers to it:
<the selected code>
If the selection hasn't changed since your last message, Claude gets a one-line reminder instead of the code again.
Everything stays on your machine. The mod is off when a session starts: until you turn it on, it doesn't connect to your editor or send anything. Once on, it reads your editor through the Claude Code extension's local connection and adds it only to messages you send. Turn it off (click the tab, or the switch in its card) whenever you want Claude not to see what's open: it then sends nothing, and keeps the connection so turning it back on is instant.
ls ~/.claude/ide should list a .lock file./plugin and check that editor-context is listed as active.Another client took the editor's connection, usually a terminal session running /ide. The mod reconnects when the editor is free.
Install Node.js 22 or later, then start a new session.
It picks the window that has your session's folder open. Start the session in that folder, or close the other window.
The Claude Code extension runs a small local server in your editor and records its port in ~/.claude/ide/. When a desktop session starts, the mod launches a Node helper that connects to that server, listens for selection changes, and checks for unsaved changes every 1.5 seconds.
The editor accepts one client at a time, so the first session's helper holds the connection and the other sessions listen to it over a local socket. When that session closes, another takes over.
The tab sits in a row above the prompt that mods share: each tab's key carries its place in the row, so the order is the same whichever mod loads first. Its card is a hidden box the app shows as a popover while you hover the tab.
The tab, the card's rows and its switch are small custom controls (hooks/controls.tsx) that the app runs on its own. A click posts a press back to the mod, which runs that control's action. The "Sent with" chips are drawn by hooks/chips.tsx.
To see exactly which events the mod handles and what it calls before you install it:
git clone https://github.com/talbarina/claude-editor-context
claude plugin validate claude-editor-context/plugins/editor-context
claude plugin marketplace update talbarina # update
claude plugin uninstall editor-context@talbarina # remove
Icons by Tabler Icons (MIT).
MIT © 2026 Tal Barina
hooks/register.tsx 908 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Attached, EditorSelection } from '../types'
5import { type Chip, sentWith } from './chips'
6
7const selection = atom({ plugin: 'editor-context', key: 'selection' } as const, null)
8const ide = atom({ plugin: 'editor-context', key: 'ide' } as const, null)
9const isPaused = atom({ plugin: 'editor-context', key: 'isPaused' } as const, false)
10const isDirty = atom({ plugin: 'editor-context', key: 'isDirty' } as const, false)
11const pins = atom({ plugin: 'editor-context', key: 'pins' } as const, [])
12const recent = atom({ plugin: 'editor-context', key: 'recent' } as const, [])
13const isRecentHidden = atom({ plugin: 'editor-context', key: 'isRecentHidden' } as const, true)
14const isDemo = atom({ plugin: 'editor-context', key: 'isDemo' } as const, false)
15const notice = atom({ plugin: 'editor-context', key: 'notice' } as const, null)
16const isNoticeHidden = atom({ plugin: 'editor-context', key: 'isNoticeHidden' } as const, false)
17const isExplaining = atom({ plugin: 'editor-context', key: 'isExplaining' } as const, false)
18// Sharing is off until the Editor tab (or the switch in its card) turns it on: off, it sends nothing.
19const isShown = atom({ plugin: 'editor-context', key: 'isShown' } as const, false)
20const attached = atom({ plugin: 'editor-context', key: 'attached' } as const, [])
21const MAX_ATTACHED = 30
22
23const MAX_TEXT = 20000
24const MAX_RECENT = 5
25const MAX_PINS = 10
26const DOT_COLOR = '#D97757'
27const WARN_COLOR = '#E5A50A'
28const DOT_SIZE = 9
29const ICON_SIZE = 14
30const ICON_COLOR = '#8A8A8A'
31const BUSY_ICON_COLOR = '#5C5C5C'
32const DIVIDER_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="1" viewBox="0 0 4000 1" preserveAspectRatio="none"><rect width="4000" height="1" fill="#8A8A8A" fill-opacity="0.2"/></svg>'
33
34// Tabler outline icons (tabler.io/icons, MIT), the set the desktop app ships.
35const ICONS: Record<string, string> = {
36 save: '<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M10 14a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M14 4l0 4l-6 0l0 -4"/>',
37 explain: '<path d="M3 12h1m8 -9v1m8 8h1m-15.4 -6.4l.7 .7m12.1 -.7l-.7 .7"/><path d="M9 16a5 5 0 1 1 6 0a3.5 3.5 0 0 0 -1 3a2 2 0 0 1 -4 0a3.5 3.5 0 0 0 -1 -3"/><path d="M9.7 17l4.6 0"/>',
38 pin: '<path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4"/><path d="M9 15l-4.5 4.5"/><path d="M14.5 4l5.5 5.5"/>',
39 unpin: '<path d="M3 3l18 18"/><path d="M15 4.5l-3.249 3.249m-2.57 1.433l-2.181 .818l-1.5 1.5l7 7l1.5 -1.5l.82 -2.186m1.43 -2.563l3.25 -3.251"/><path d="M9 15l-4.5 4.5"/><path d="M14.5 4l5.5 5.5"/>',
40 file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/>',
41 pause: '<path d="M6 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z"/><path d="M14 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z"/>',
42 resume: '<path d="M7 4v16l13 -8z"/>',
43 recent: '<path d="M12 8l0 4l2 2"/><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5"/>',
44 close: '<path d="M18 6l-12 12"/><path d="M6 6l12 12"/>',
45}
46
47
48function iconSvg(name: string, color: string) {
49 return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`
50}
51
52// Sample rows for /editor-context-demo, so every part of the band can be seen.
53const DEMO_RECENT = [
54 '/demo/src/components/Header.tsx',
55 '/demo/src/api/client.ts',
56 '/demo/src/pages/Settings.tsx',
57]
58
59// Runs under node. Connects to the Claude Code extension's MCP websocket in
60// Cursor / VS Code (found through ~/.claude/ide/<port>.lock), prints one JSON
61// line per selection or dirty-state change, and serves POST /call on a Unix
62// socket so the mod can call the editor's tools (save, open file). Sessions
63// share one connection per editor window (see the hub notes inside).
64const BRIDGE = String.raw`
65const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), http = require('node:http'), net = require('node:net')
66const cwd = process.argv[1] || ''
67const dir = path.join(os.homedir(), '.claude', 'ide')
68const out = o => process.stdout.write(JSON.stringify(o) + '\n')
69const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
70const parse = r => { try { return JSON.parse(r.content[0].text) } catch { return r } }
71const sleep = ms => new Promise(r => setTimeout(r, ms))
72let hubSocket = null
73
74function pickLock() {
75 let names = []
76 try { names = fs.readdirSync(dir).filter(n => n.endsWith('.lock')) } catch { return null }
77 const locks = names.map(n => {
78 try {
79 const p = path.join(dir, n)
80 return { port: n.slice(0, -5), mtime: fs.statSync(p).mtimeMs, ...JSON.parse(fs.readFileSync(p, 'utf8')) }
81 } catch { return null }
82 }).filter(l => l && (!l.pid || alive(l.pid)))
83 const inWorkspace = l => (l.workspaceFolders || []).some(f => cwd === f || cwd.startsWith(f + '/') || f.startsWith(cwd + '/'))
84 locks.sort((a, b) => (inWorkspace(b) - inWorkspace(a)) || (b.mtime - a.mtime))
85 return locks[0] || null
86}
87
88// The editor's server takes one client at a time and drops the older one, so
89// every Claude Code session shares one connection per editor window: the first
90// helper becomes the hub, the rest subscribe to it over a Unix socket.
91
92// Resolves true once a hub's stream has ended, false when there was no hub.
93function subscribe(sock) {
94 return new Promise(resolve => {
95 let connected = false
96 const req = http.get({ socketPath: sock, path: '/events' }, res => {
97 connected = true
98 out({ type: 'ready', socket: sock })
99 res.setEncoding('utf8')
100 res.on('data', d => process.stdout.write(d))
101 res.on('end', () => resolve(true))
102 res.on('error', () => resolve(true))
103 })
104 req.on('error', () => resolve(connected))
105 })
106}
107
108function canConnect(sock) {
109 return new Promise(resolve => {
110 const c = net.connect(sock)
111 c.on('connect', () => { c.destroy(); resolve(true) })
112 c.on('error', () => resolve(false))
113 })
114}
115
116function listen(server, sock) {
117 return new Promise((resolve, reject) => {
118 server.once('error', reject)
119 server.listen(sock, () => { server.removeListener('error', reject); resolve() })
120 })
121}
122
123// Resolves false when another helper is already the hub; otherwise runs the
124// hub until the editor goes away, then resolves true.
125async function serve(sock, lock) {
126 const subscribers = new Set()
127 const last = {}
128 let ws = null, ready = false, nextId = 100, current = null, lastDirty = null, stopped = false
129 const pending = new Map()
130
131 const broadcast = o => {
132 last[o.type] = o
133 const line = JSON.stringify(o) + '\n'
134 process.stdout.write(line)
135 for (const res of subscribers) res.write(line)
136 }
137
138 const call = (name, args) => new Promise((resolve, reject) => {
139 if (!ws || !ready) return reject(new Error('editor not connected'))
140 const id = ++nextId
141 pending.set(id, { resolve, reject })
142 ws.send(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }))
143 setTimeout(() => { if (pending.delete(id)) reject(new Error('timeout')) }, 5000)
144 })
145
146 const handle = (req, res) => {
147 if (req.method === 'GET' && req.url === '/events') {
148 res.writeHead(200, { 'content-type': 'application/x-ndjson' })
149 for (const o of [last.status, last.selection, last.dirty]) if (o) res.write(JSON.stringify(o) + '\n')
150 subscribers.add(res)
151 req.on('close', () => subscribers.delete(res))
152 return
153 }
154 let body = ''
155 req.on('data', c => { body += c })
156 req.on('end', async () => {
157 try {
158 const { name, arguments: args } = JSON.parse(body || '{}')
159 const r = await call(name, args || {})
160 res.writeHead(200, { 'content-type': 'application/json' })
161 res.end(JSON.stringify(parse(r)))
162 } catch (err) {
163 res.writeHead(502, { 'content-type': 'application/json' })
164 res.end(JSON.stringify({ error: String(err && err.message || err) }))
165 }
166 })
167 }
168
169 let server = http.createServer(handle)
170 try {
171 await listen(server, sock)
172 } catch (err) {
173 if (err.code !== 'EADDRINUSE' || await canConnect(sock)) return false
174 try { fs.unlinkSync(sock) } catch {}
175 server = http.createServer(handle)
176 try { await listen(server, sock) } catch { return false }
177 }
178 hubSocket = sock
179 out({ type: 'ready', socket: sock })
180
181 const checkDirty = () => {
182 if (!current || !ready) return
183 const file = current
184 call('checkDocumentDirty', { filePath: file }).then(r => {
185 const isDirty = !!parse(r).isDirty
186 const key = file + ':' + isDirty
187 if (key !== lastDirty) { lastDirty = key; broadcast({ type: 'dirty', filePath: file, isDirty }) }
188 }).catch(() => {})
189 }
190 const dirtyTimer = setInterval(checkDirty, 1500)
191
192 const emit = s => {
193 if (!s || !s.filePath) return
194 current = s.filePath
195 broadcast({ type: 'selection', ide: lock.ideName, ...s })
196 checkDirty()
197 }
198
199 return new Promise(resolve => {
200 let delay = 3000, openedAt = 0, goneTimer = null
201
202 const stop = () => {
203 if (stopped) return
204 stopped = true
205 clearInterval(dirtyTimer)
206 clearTimeout(goneTimer)
207 for (const res of subscribers) res.end()
208 server.close()
209 try { fs.unlinkSync(sock) } catch {}
210 hubSocket = null
211 resolve(true)
212 }
213
214 const connect = () => {
215 if (stopped) return
216 const now = pickLock()
217 if (!now || now.port !== lock.port) return stop()
218 ws = new WebSocket('ws://127.0.0.1:' + lock.port, {
219 protocols: ['mcp'],
220 headers: { 'x-claude-code-ide-authorization': now.authToken },
221 })
222 ws.onopen = () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'editor-context', version: '1.0.1' } } }))
223 ws.onmessage = m => {
224 let d; try { d = JSON.parse(m.data) } catch { return }
225 if (d.id === 1) {
226 ready = true
227 openedAt = Date.now()
228 clearTimeout(goneTimer)
229 broadcast({ type: 'status', ide: lock.ideName })
230 ws.send(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }))
231 call('getCurrentSelection', {}).then(r => emit(parse(r))).catch(() => {})
232 } else if (d.id && pending.has(d.id)) {
233 const p = pending.get(d.id); pending.delete(d.id)
234 d.error ? p.reject(new Error(d.error.message)) : p.resolve(d.result)
235 } else if (d.method === 'selection_changed') {
236 emit(d.params)
237 }
238 }
239 ws.onclose = () => {
240 ready = false
241 for (const p of pending.values()) p.reject(new Error('editor disconnected'))
242 pending.clear()
243 // Another client (a terminal session's /ide) may have taken the
244 // editor: back off instead of fighting it, and keep the band up
245 // through a short gap.
246 delay = openedAt && Date.now() - openedAt > 10000 ? 3000 : Math.min(delay * 2, 30000)
247 openedAt = 0
248 clearTimeout(goneTimer)
249 goneTimer = setTimeout(() => broadcast({ type: 'status', ide: null, reason: 'disconnected', ideName: lock.ideName }), 8000)
250 setTimeout(connect, delay)
251 }
252 ws.onerror = () => {}
253 }
254 connect()
255 })
256}
257
258async function main() {
259 for (;;) {
260 const lock = pickLock()
261 if (!lock) { out({ type: 'status', ide: null, reason: 'no-editor' }); await sleep(5000); continue }
262 const sock = path.join(os.tmpdir(), 'editor-context-' + lock.port + '.sock')
263 if (await subscribe(sock)) { await sleep(200 + Math.random() * 800); continue }
264 await serve(sock, lock)
265 await sleep(200 + Math.random() * 500)
266 }
267}
268
269const quit = () => { if (hubSocket) { try { fs.unlinkSync(hubSocket) } catch {} } process.exit(0) }
270process.on('SIGTERM', quit)
271process.on('SIGINT', quit)
272setInterval(() => { if (process.ppid === 1) quit() }, 5000)
273main()
274`
275
276function toSelection(raw: any): EditorSelection {
277 const start = raw.selection?.start?.line ?? 0
278 let end = raw.selection?.end?.line ?? start
279 // A whole-line selection ends at column 0 of the next line.
280 if (end > start && raw.selection?.end?.character === 0) end -= 1
281 return {
282 ide: String(raw.ide ?? 'IDE'),
283 filePath: String(raw.filePath),
284 startLine: start + 1,
285 endLine: end + 1,
286 isEmpty: Boolean(raw.selection?.isEmpty ?? !raw.text),
287 text: String(raw.text ?? '').slice(0, MAX_TEXT),
288 }
289}
290
291function fileName(filePath: string) {
292 return filePath.split('/').pop() ?? filePath
293}
294
295function lineCount(s: EditorSelection) {
296 return s.endLine - s.startLine + 1
297}
298
299function rangeLabel(s: EditorSelection) {
300 return s.startLine === s.endLine ? `L${s.startLine}` : `L${s.startLine}–${s.endLine}`
301}
302
303function pinKey(p: EditorSelection) {
304 return `${p.filePath}|${p.startLine}|${p.endLine}`
305}
306
307function codeBlock(text: string) {
308 return '```\n' + text + '\n```'
309}
310
311// The bridge needs Node 22 or later, for its built-in WebSocket.
312const MIN_NODE_MAJOR = 22
313
314async function nodeMajor($: any, node: string): Promise<number> {
315 try {
316 const r = await $.process.run([node, '--version'], { timeoutMs: 5000 })
317 if (r.exitCode !== 0) return 0
318 return Number(/^v(\d+)/.exec(r.stdout.trim())?.[1] ?? 0)
319 } catch {
320 return 0
321 }
322}
323
324async function findNode($: any): Promise<string | null> {
325 // The last session's answer, kept across sessions: probing login shells is
326 // slow, and the first message should not wait on it.
327 const cached = await $.store.get('nodePath')
328 if (typeof cached === 'string' && (await nodeMajor($, cached)) >= MIN_NODE_MAJOR) return cached
329
330 const found = await probeNode($)
331 if (found) await $.store.set('nodePath', found)
332 return found
333}
334
335async function probeNode($: any): Promise<string | null> {
336 for (const candidate of ['node', '/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']) {
337 if ((await nodeMajor($, candidate)) >= MIN_NODE_MAJOR) return candidate
338 }
339 // nvm, fnm, volta and similar live behind the login shell's PATH.
340 for (const shell of ['/bin/zsh', '/bin/bash']) {
341 try {
342 const r = await $.process.run([shell, '-lic', 'command -v node'], { timeoutMs: 10000 })
343 const line = r.stdout.trim().split('\n').pop()
344 if (r.exitCode === 0 && line && line.startsWith('/') && (await nodeMajor($, line)) >= MIN_NODE_MAJOR) return line
345 } catch {}
346 }
347 return null
348}
349
350// The bridge's Unix socket, once it is listening.
351let socketPath: string | null = null
352
353async function ideCall($: any, name: string, args: Record<string, unknown>) {
354 if (!socketPath) return null
355 try {
356 const r = await $.http.fetch('http://bridge/call', {
357 method: 'POST',
358 headers: { 'content-type': 'application/json' },
359 body: JSON.stringify({ name, arguments: args }),
360 socketPath,
361 })
362 return r.ok ? JSON.parse(r.text) : null
363 } catch {
364 return null
365 }
366}
367
368// Settles once the editor's state first arrives (or there is none), so the
369// session's first message, sent while the bridge is still starting, waits
370// for it instead of going out without context.
371let isStarting = false
372let settleStart: () => void = () => {}
373let started: Promise<void> = Promise.resolve()
374const START_WAIT_MS = 4000
375
376// The mod runs in the desktop app only: the terminal CLI already gets the
377// editor's selection through /ide, and the editor accepts one client at a
378// time, so a second connection there would only compete with it.
379let isActive = false
380
381function isDesktop(surface: string | null | undefined) {
382 return surface === 'desktop'
383}
384
385async function showNotice($: any, next: { kind: string; ide?: string } | null) {
386 const current = await read($, notice)
387 if (current?.kind === next?.kind && current?.ide === next?.ide) return
388 await update($, notice, () => next)
389 await update($, isNoticeHidden, () => false)
390}
391
392async function handleLine($: any, msg: any) {
393 if (msg.type === 'ready') {
394 socketPath = msg.socket
395 } else if (msg.type === 'status') {
396 await update($, ide, () => msg.ide ?? null)
397 if (msg.ide) {
398 await showNotice($, null)
399 } else {
400 await update($, selection, () => null)
401 await showNotice($, { kind: msg.reason ?? 'no-editor', ide: msg.ideName })
402 settleStart()
403 }
404 } else if (msg.type === 'selection') {
405 const incoming = toSelection(msg)
406 const prev = await read($, selection)
407 if (prev && prev.filePath !== incoming.filePath) {
408 await update($, isDirty, () => false)
409 await update($, recent, list =>
410 [prev.filePath, ...list.filter(f => f !== prev.filePath && f !== incoming.filePath)].slice(0, MAX_RECENT))
411 }
412 await update($, selection, () => incoming)
413 settleStart()
414 } else if (msg.type === 'dirty') {
415 const s = await read($, selection)
416 if (s && s.filePath === msg.filePath) await update($, isDirty, () => Boolean(msg.isDirty))
417 }
418}
419
420async function startBridge($: any) {
421 if (isActive) return
422 isActive = true
423 isStarting = true
424 started = new Promise<void>(resolve => {
425 settleStart = () => { isStarting = false; resolve() }
426 })
427 const node = await findNode($)
428 if (!node) {
429 await showNotice($, { kind: 'no-node' })
430 settleStart()
431 return
432 }
433 let buffer = ''
434 const bridge = $.process.spawn({ argv: [node, '-e', BRIDGE, await $.session.cwd()] })
435 for await (const piece of bridge) {
436 if (piece.stream !== 'stdout') continue
437 buffer += piece.text
438 let nl
439 while ((nl = buffer.indexOf('\n')) >= 0) {
440 const line = buffer.slice(0, nl)
441 buffer = buffer.slice(nl + 1)
442 let msg: any
443 try { msg = JSON.parse(line) } catch { continue }
444 await handleLine($, msg)
445 }
446 }
447}
448
449// Explain stays busy until the turn its message started has completed.
450// `isNext`: nothing was running at the press, so the next turn is this one.
451const turnState = { isRunning: false }
452const explainTurn: { text: string; turnId: string | null; isNext: boolean } = { text: '', turnId: null, isNext: false }
453
454async function sendExplain($: any, text: string) {
455 if (await read($, isExplaining)) return
456 await update($, isExplaining, () => true)
457 explainTurn.text = text
458 explainTurn.turnId = null
459 explainTurn.isNext = !turnState.isRunning
460 const result = await $.prompt.submit({ text, asUser: true })
461 if (result.drop) await endExplain($)
462}
463
464async function endExplain($: any) {
465 explainTurn.text = ''
466 explainTurn.turnId = null
467 explainTurn.isNext = false
468 await update($, isExplaining, () => false)
469}
470
471// What each context block last sent, so an unchanged selection is not resent.
472const lastSent = new Map<string, string>()
473
474function isFresh(key: string, value: string) {
475 const isSame = lastSent.get(key) === value
476 lastSent.set(key, value)
477 return !isSame
478}
479
480// ---- Band actions ----
481// Native buttons call runAction from onPress; the desktop's custom controls post a
482// press that the `ui.message` hook turns into the same call, by the Client's key.
483
484// Presses whose action has not finished yet: a second press of the same button is ignored.
485const inFlight = new Set<string>()
486
487async function runAction($: any, key: string) {
488 if (inFlight.has(key)) return
489 inFlight.add(key)
490 try {
491 await actionOf($, key)
492 } finally {
493 inFlight.delete(key)
494 }
495}
496
497async function actionOf($: any, key: string) {
498 // The demo's rows are samples: only the band's own toggles act.
499 const demo = await read($, isDemo)
500 const s = await read($, selection)
501 switch (key) {
502 case 'editor-on':
503 case 'editor-off':
504 if ((key === 'editor-on') === (await read($, isShown))) return
505 // falls through: same as the tab
506 case 'editor-switch':
507 case 'launch-tab:editor-context': {
508 // The tab turns it on and off. Off, nothing is sent; on, it connects and sends.
509 // On and off is the one switch: Pause is folded into it.
510 lastSent.clear()
511 await update($, isPaused, () => false)
512 await update($, isShown, v => !v)
513 if (await read($, isShown)) void startBridge($)
514 return
515 }
516 case 'close-notice':
517 await update($, isNoticeHidden, () => true)
518 return
519 case 'pause':
520 await update($, isPaused, v => !v)
521 return
522 case 'show-recent':
523 await update($, isRecentHidden, () => false)
524 return
525 case 'close-recent':
526 if (!demo) await update($, isRecentHidden, () => true)
527 return
528 case 'save':
529 if (!demo && s) await ideCall($, 'saveDocument', { filePath: s.filePath })
530 return
531 case 'pin':
532 if (!demo && s && !s.isEmpty && s.text) {
533 const pin = s
534 await update($, pins, list => (list.length >= MAX_PINS || list.some(p => pinKey(p) === pinKey(pin)) ? list : [...list, pin]))
535 }
536 return
537 case 'explain': {
538 if (demo || !s) return
539 lastSent.delete('selection')
540 const text = !s.isEmpty && s.text
541 ? `Explain ${rangeLabel(s)} of \`${fileName(s.filePath)}\`.`
542 : `Explain \`${fileName(s.filePath)}\`.`
543 await sendExplain($, text)
544 return
545 }
546 }
547 if (key.startsWith('open-pin:') && !demo) {
548 const target = key.slice('open-pin:'.length)
549 const pin = (await read($, pins)).find(p => pinKey(p) === target)
550 if (pin) {
551 const lines = pin.text.split('\n').filter(l => l.trim() !== '')
552 await ideCall($, 'openFile', { filePath: pin.filePath, startText: lines[0] ?? '', endText: lines[lines.length - 1] ?? '', makeFrontmost: true })
553 }
554 return
555 }
556 if (key.startsWith('recent:') && !demo) {
557 await ideCall($, 'openFile', { filePath: key.slice('recent:'.length), startText: '', endText: '', makeFrontmost: true })
558 return
559 }
560 if (key.startsWith('unpin:') && !demo) {
561 const target = key.slice('unpin:'.length)
562 await update($, pins, list => list.filter(p => pinKey(p) !== target))
563 }
564}
565
566// ---- Launcher ----
567// On the desktop each mod draws one tab in a shared row above the prompt; its full
568// panel opens in a pane. Each mod's band nests the bands of mods loaded after it, so
569// whichever draws outermost lays the tabs out in one order, then any other bands.
570const PANE = 'editor-context'
571// The floating card's content width, in ch: the app caps the card at 360px and pads it
572// about 12px a side, which leaves 336px; one ch of the app's 13px text is about 8.2px.
573const POPOVER_WIDTH = 41
574// A launcher tab's key carries its place in the row: "launch:<rank>:<name>".
575const launchRank = (key: string) => Number(key.match(/^launch:(\d+):/)?.[1] ?? 9)
576const POWER_ICON = '<path d="M7 6a7.75 7.75 0 1 0 10 0"/><path d="M12 4l0 8"/>'
577const FILE_ICON = '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/>'
578const CODE_ICON = '<path d="M7 8l-4 4l4 4"/><path d="M17 8l4 4l-4 4"/><path d="M14 4l-4 16"/>'
579
580function nodeKey(node: any): string {
581 const key = node?.props?.key
582 return typeof key === 'string' ? key : ''
583}
584
585function stackLauncher(Box: any, mine: any, below: any, hasBelow: boolean, divider: (key: string) => any) {
586 let tabs: any[] = []
587 let rest: any[] = []
588 if (hasBelow && nodeKey(below) === 'launcher-stack') {
589 const [row, ...others] = below.children ?? []
590 tabs = row?.children ?? []
591 rest = others.filter((c: any) => !nodeKey(c).startsWith('launcher-divider'))
592 } else if (hasBelow) {
593 rest = [below]
594 }
595 const sorted = [mine, ...tabs]
596 .map((node, i) => ({ node, i, rank: launchRank(nodeKey(node)) }))
597 .sort((x, y) => x.rank - y.rank || x.i - y.i)
598 .map(x => x.node)
599 const children: any[] = [
600 <Box key="launcher-row" flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap">{sorted}</Box>,
601 ]
602 rest.forEach((node, i) => children.push(divider(`launcher-divider:${i}`), node))
603 return <Box key="launcher-stack" flexDirection="column" width="100%" rowGap={1}>{children}</Box>
604}
605
606export const register: Register = on => {
607 on('command.run', { command: 'editor-context-demo' }, async $ => {
608 await update($, isDemo, v => !v)
609 if (await read($, isDemo)) {
610 await update($, isShown, () => true)
611 }
612 return { text: (await read($, isDemo)) ? 'Editor band demo on: every row is shown with sample data. Run again to turn it off.' : 'Editor band demo off.' }
613 })
614
615 on('session.start', async ($, e, next) => {
616 const result = await next(e)
617 await endExplain($)
618 await $.command.register({
619 name: 'editor-context-demo',
620 description: 'Toggle sample data in the editor band to preview every row',
621 })
622 // The app's sessions start with no surface of their own and list it here.
623 const isOnDesktop = isDesktop(e.surface) || (await $.session.surfaces()).some(isDesktop)
624 if (isOnDesktop && (await read($, isShown))) void startBridge($)
625 return result
626 })
627
628 // A desktop session may start without a surface and attach the app after.
629 on('session.attach', async ($, e, next) => {
630 if (isDesktop(e.surface) && (await read($, isShown))) void startBridge($)
631 return next(e)
632 })
633
634 on('prompt.submit', async ($, e, next) => {
635 if (!(await read($, isShown))) return next(e)
636 if (!isActive && (await $.session.surfaces()).some(isDesktop)) void startBridge($)
637 if (!isActive) return next(e)
638 if (isStarting) await Promise.race([started, $.clock.sleep(START_WAIT_MS)])
639 const s = await read($, selection)
640 const origin = e.origin as { kind: string; name?: string }
641 const isUser = origin.kind === 'composer' || origin.kind === 'bridge' ||
642 (origin.kind === 'plugin' && origin.name === 'editor-context')
643 if (!s || !isUser || (await read($, isPaused))) return next(e)
644
645 const blocks: string[] = []
646
647 let main = `The user is working in ${s.ide}. Active file: ${s.filePath}, cursor at line ${s.startLine}.`
648 if (!s.isEmpty && s.text) {
649 main = isFresh('selection', `${s.filePath}|${s.startLine}|${s.endLine}|${s.text}`)
650 ? `The user has lines ${s.startLine}-${s.endLine} of ${s.filePath} selected in ${s.ide}. "This", "here" or "these lines" likely refers to it:\n${codeBlock(s.text)}`
651 : `The user still has lines ${s.startLine}-${s.endLine} of ${s.filePath} selected in ${s.ide} (same selection as their previous message).`
652 }
653 if (await read($, isDirty)) {
654 main += ' The file has unsaved changes in the editor, so the version on disk may differ from what the user sees.'
655 }
656 blocks.push(main)
657 const chips: Attached['chips'] = [{ icon: 'file', label: s.isEmpty ? `${fileName(s.filePath)} · L${s.startLine}` : `${fileName(s.filePath)} · ${rangeLabel(s)}` }]
658
659 // Each pin's code goes once; while unchanged, one line names them all.
660 const unchanged: string[] = []
661 for (const pin of await read($, pins)) {
662 if (isFresh(`pin:${pinKey(pin)}`, pin.text)) {
663 blocks.push(`The user pinned lines ${pin.startLine}-${pin.endLine} of ${pin.filePath} as reference for this conversation:\n${codeBlock(pin.text)}`)
664 } else {
665 unchanged.push(`lines ${pin.startLine}-${pin.endLine} of ${pin.filePath}`)
666 }
667 }
668 if (unchanged.length > 0) {
669 blocks.push(`Still pinned, unchanged since they were sent: ${unchanged.join('; ')}.`)
670 }
671 for (const pin of await read($, pins)) chips.push({ icon: 'pin', label: `${fileName(pin.filePath)} · ${rangeLabel(pin)}` })
672 await update($, attached, list => [...list, { text: e.text, chips }].slice(-MAX_ATTACHED))
673
674 // Recent files show in the popover only; they're not sent.
675
676 return next({ ...e, context: [...(e.context ?? []), ...blocks] })
677 })
678
679 // On the desktop, your message shows what went with it: chips above the bubble (chips.tsx).
680 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
681 if (e.surface !== 'desktop') return next(e)
682 const props = e.props as { text?: string; origin?: { kind: string } }
683 // The desktop marks every message row it draws as 'sdk'; the text finds what went with it.
684 const kind = props.origin?.kind
685 if (kind !== 'sdk' && kind !== 'composer' && kind !== 'bridge' && kind !== 'plugin') return next(e)
686 const sent = [...(await read($, attached))].reverse().find(a => a.text === props.text)
687 const elements = $.ui.resolve(e) as any
688 if (!sent || !elements.Svg) return next(e)
689 const mine: Chip[] = sent.chips.map((c, i) => ({ key: `0:editor-context:${i}`, icon: ICONS[c.icon], label: c.label }))
690 return sentWith(elements, await next(e), mine)
691 })
692
693 on('turn.start', async ($, e, next) => {
694 const started = await next(e)
695 turnState.isRunning = true
696 // Queued messages can enter as one turn, so the text may be anywhere in it.
697 if (explainTurn.text && explainTurn.turnId === null && (explainTurn.isNext || e.text.includes(explainTurn.text))) {
698 explainTurn.turnId = e.turnId
699 }
700 explainTurn.isNext = false
701 return started
702 })
703
704 on('turn.complete', async ($, e, next) => {
705 const done = await next(e)
706 if (e.agentId) return done
707 turnState.isRunning = false
708 if (explainTurn.turnId !== null && e.turnId === explainTurn.turnId) await endExplain($)
709 return done
710 })
711
712 // A custom control (controls.tsx) posts { press: true }; its Client's key names the button.
713 on('ui.message', async ($, e, next) => {
714 const data = e.data as { press?: unknown } | null
715 if (e.module.endsWith('controls.tsx') && data !== null && typeof data === 'object' && data.press === true) {
716 await runAction($, e.element)
717 }
718 return next(e)
719 })
720
721 // The pane closed, by its X or the tab: off, nothing more is sent.
722 on('ui.close', async ($, e, next) => {
723 if ((e as { id?: string }).id === PANE) {
724 lastSent.clear()
725 await update($, isShown, () => false)
726 }
727 return next(e)
728 })
729
730 // The launcher tab, in the row above the prompt (desktop).
731 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
732 if (!isDesktop(e.surface) || e.props.hasSurvey) return next(e)
733 const below = await next(e)
734 const hasBelow = below !== null && below !== undefined && (below as { type?: string }).type !== 'engine'
735 const elements = $.ui.resolve(e)
736 const { Box, Text } = elements
737 if (!('Client' in elements)) return below
738 const demo = await read($, isDemo)
739 const shown = demo || (await read($, isShown))
740 const s: EditorSelection | null = demo
741 ? { ide: 'Cursor', filePath: '/demo/src/components/CheckoutForm.tsx', startLine: 12, endLine: 30, isEmpty: false, text: 'demo' }
742 : await read($, selection)
743 const hasIde = demo || Boolean(await read($, ide))
744 // On, the tab is the file and its lines; off, or with no editor, it says so.
745 const name = s ? fileName(s.filePath) : ''
746 const isFileTab = shown && hasIde && s !== null
747 const tabLabel = isFileTab ? name : 'Editor'
748 const note = isFileTab ? (s!.isEmpty ? `L${s!.startLine}` : rangeLabel(s!)) : !shown ? 'off' : 'no editor'
749 // Hovering the tab floats the full panel above it: the desktop lifts a hidden Box
750 // that hover reveals into a popover card (the app's own popover look, up to 360px wide).
751 const panel = await editorPanel($, e)
752 const mine = (
753 <Box key="launch:0:editor-context" flexDirection="row">
754 <elements.Client
755 key="launch-tab:editor-context"
756 module="./controls.tsx"
757 props={{ kind: 'tab', label: tabLabel, icon: isFileTab ? FILE_ICON : CODE_ICON, isOn: isFileTab, note, ...(isFileTab ? { dot: DOT_COLOR } : {}) }}
758 />
759 <Box position="absolute" top={-1} left={0} display="none" hover={{ display: 'flex' }} flexDirection="column">
760 {panel}
761 </Box>
762 </Box>
763 )
764 const divider = (key: string) =>
765 'Svg' in elements ? (
766 <Box key={key} width="100%"><elements.Svg source={DIVIDER_SVG} alt="divider" height={0.5} /></Box>
767 ) : (
768 <Box key={key} width="100%" overflow="hidden"><Text dimColor wrap="truncate-end">{'\u2500'.repeat(400)}</Text></Box>
769 )
770 return stackLauncher(Box, mine, below, hasBelow, divider)
771 })
772
773}
774
775
776// The full panel, in the launcher tab's floating card, using its full width (POPOVER_WIDTH),
777// built like the app's own popovers: a "Share with Claude" switch on top, then the file,
778// its actions as menu rows, pins and recent files.
779async function editorPanel($: any, e: any): Promise<any> {
780 const elements = $.ui.resolve(e)
781 const { Box, Button, Text } = elements
782 const hasSvg = 'Svg' in elements
783 const muted = (text: string) => <Text dimColor>{text}</Text>
784 const header = (label: string, extra?: any) => (
785 <Box flexDirection="row" alignItems="center" width="100%">
786 {muted(label)}
787 <Box flexGrow={1} />
788 {extra ?? null}
789 </Box>
790 )
791 const menuRow = (key: string, label: string, iconPaths: string, note: string, variant?: 'accent') => (
792 <elements.Client key={key} module="./controls.tsx" width="100%" props={{ kind: 'row', label, icon: iconPaths, note, ...(variant ? { variant } : {}) }} />
793 )
794 const card = (...children: any[]) => (
795 <Box flexDirection="column" width={POPOVER_WIDTH} rowGap={1}>{children}</Box>
796 )
797
798 const demo = await read($, isDemo)
799 const shown = demo || (await read($, isShown))
800 const hasIde = demo || Boolean(await read($, ide))
801 const s: EditorSelection | null = demo
802 ? { ide: 'Cursor', filePath: '/demo/src/components/CheckoutForm.tsx', startLine: 12, endLine: 30, isEmpty: false, text: 'demo' }
803 : await read($, selection)
804 const switchRow = (
805 <elements.Client
806 key="editor-switch"
807 module="./controls.tsx"
808 width="100%"
809 props={{ kind: 'switch', label: 'Share with Claude', icon: CODE_ICON, isOn: shown }}
810 />
811 )
812 if (!shown) {
813 return card(switchRow, muted('Off. Turn on to send your Cursor or VS Code file and the lines you select with every message.'))
814 }
815 if (!isActive) void startBridge($)
816 if (!hasIde) {
817 const n = await read($, notice)
818 const message = !n ? 'Connecting to your editor\u2026'
819 : n.kind === 'no-node' ? `Editor context needs Node.js ${MIN_NODE_MAJOR} or later on this machine.`
820 : n.kind === 'disconnected' ? `Lost connection to ${n.ide ?? 'your editor'}. Reconnecting\u2026`
821 : 'Open VS Code or Cursor with the Claude Code extension installed.'
822 return card(
823 switchRow,
824 divider(elements, 'card-divider-waiting'),
825 <Box flexDirection="row" alignItems="center" columnGap={1}>
826 <Text color={WARN_COLOR}>{'\u25cb'}</Text>
827 <Text>{n?.kind === 'disconnected' ? 'Reconnecting' : 'Waiting for an editor'}</Text>
828 </Box>,
829 muted(message),
830 )
831 }
832
833 const dirty = demo || (await read($, isDirty))
834 const pinList: EditorSelection[] = demo
835 ? [
836 { ide: 'Cursor', filePath: '/demo/src/hooks/useCart.ts', startLine: 36, endLine: 48, isEmpty: false, text: 'demo' },
837 { ide: 'Cursor', filePath: '/demo/src/api/client.ts', startLine: 3, endLine: 13, isEmpty: false, text: 'demo' },
838 ]
839 : await read($, pins)
840 const files: string[] = demo ? DEMO_RECENT : await read($, recent)
841 const hasSelection = Boolean(s && !s.isEmpty && s.text)
842 const canPin = hasSelection && pinList.length < MAX_PINS && !pinList.some(p => s && pinKey(p) === pinKey(s))
843 const explaining = await read($, isExplaining)
844 const dotSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="${DOT_COLOR}"/></svg>`
845 const chip = (key: string, label: string) => (
846 <Box key={key} borderStyle="round" borderColor="#FFFFFF0F" backgroundColor="#FFFFFF0F" paddingX={0.75} paddingY={0}>
847 <Text dimColor>{label}</Text>
848 </Box>
849 )
850 const dirOf = (f: string) => f.split('/').slice(-2, -1)[0] ?? ''
851 const ext = (f: string) => (f.includes('.') ? `${f.split('.').pop()} file` : 'file')
852
853 return card(
854 switchRow,
855 muted(s ? `${s.ide} \u00b7 sent with each message` : 'Sent with each message'),
856 divider(elements, 'card-divider-file'),
857 <Box flexDirection="row" alignItems="center" columnGap={1} width="100%">
858 {hasSvg ? <elements.Svg source={dotSvg} alt="sharing" width={DOT_SIZE} height={DOT_SIZE} /> : null}
859 <Text bold wrap="truncate-middle">{s ? fileName(s.filePath) : 'No file focused'}</Text>
860 <Box flexGrow={1} />
861 {s ? chip('lines', s.isEmpty ? `L${s.startLine}` : rangeLabel(s)) : null}
862 </Box>,
863 s ? (
864 <Box flexDirection="column" width="100%">
865 <elements.Client
866 key="explain"
867 module="./controls.tsx"
868 width="100%"
869 props={{ kind: 'row', label: hasSelection ? 'Explain selection' : 'Explain file', icon: ICONS.explain, note: hasSelection ? `${lineCount(s)} lines` : ext(fileName(s.filePath)), isBusy: explaining }}
870 />
871 {canPin ? menuRow('pin', 'Pin selection', ICONS.pin, 'kept in every message') : null}
872 {dirty ? menuRow('save', 'Save file', ICONS.save, 'unsaved changes', 'accent') : null}
873 </Box>
874 ) : null,
875 pinList.length > 0 ? divider(elements, 'card-divider-pins') : null,
876 pinList.length > 0
877 ? header('Pinned', pinList.length > 1
878 ? <Button key="unpin-all" plain dimColor label="Unpin all" onPress={() => { if (!demo) void update($, pins, () => []) }} />
879 : null)
880 : null,
881 pinList.length > 0 ? (
882 <Box flexDirection="column" width="100%">
883 {pinList.map(pin => (
884 <Box key={`pin:${pinKey(pin)}`} flexDirection="row" alignItems="center" columnGap={0.5} width="100%">
885 <Box width={0} flexGrow={1}>
886 {menuRow(`open-pin:${pinKey(pin)}`, fileName(pin.filePath), ICONS.pin, rangeLabel(pin))}
887 </Box>
888 <elements.Client key={`unpin:${pinKey(pin)}`} module="./controls.tsx" props={{ kind: 'button', label: '', icon: ICONS.close, variant: 'ghost' }} />
889 </Box>
890 ))}
891 </Box>
892 ) : null,
893 files.length > 0 ? divider(elements, 'card-divider-recent') : null,
894 files.length > 0 ? header('Open a recent file') : null,
895 files.length > 0 ? (
896 <Box flexDirection="column" width="100%">
897 {files.map(f => menuRow(`recent:${f}`, fileName(f), FILE_ICON, dirOf(f)))}
898 </Box>
899 ) : null,
900 )
901}
902
903// A hairline across the card.
904function divider(elements: any, key: string) {
905 const { Box } = elements
906 return 'Svg' in elements ? <Box key={key} width="100%"><elements.Svg source={DIVIDER_SVG} alt="divider" height={0.5} /></Box> : null
907}
908hooks/chips.tsx 82 lines1// The "Sent with" row above your message on the desktop: what the mods attached to it,
2// as small chips. The same file in every mod that draws chips; keep the copies in sync.
3//
4// Each mod's UserMessage hook wraps the row beneath it. The outer one finds the inner
5// one's row (keyed "sent-with") and lays every chip out in one row, in the order the
6// chips' keys give. Text has no size setting, so the label and chips are SVGs with 11px text.
7
8export type Chip = {
9 /** "<rank>:<plugin>:<n>": the rank is the chip's place in the row, lowest first. */
10 key: string
11 /** Tabler path markup for the chip's icon. */
12 icon: string
13 label: string
14}
15
16export const CHIP_HEIGHT = 18
17const CHIP_FONT = 11
18const CHIP_TEXT = '#8A8A8A'
19const FONT = `font-family="-apple-system,BlinkMacSystemFont,system-ui,sans-serif" font-size="${CHIP_FONT}"`
20
21// About how wide the app's 11px system font draws a label, in px.
22function textWidth(text: string) {
23 let w = 0
24 for (const ch of text) w += /[il.,:·'|1 ]/.test(ch) ? 3.4 : /[A-Z0-9mw–_]/.test(ch) ? 7.2 : 5.7
25 return Math.ceil(w)
26}
27
28const escapeXml = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
29
30function labelWidth(text: string) {
31 return textWidth(text) + 2
32}
33
34function labelSvg(text: string) {
35 const w = labelWidth(text)
36 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${CHIP_HEIGHT}" viewBox="0 0 ${w} ${CHIP_HEIGHT}"><text x="0" y="${CHIP_HEIGHT / 2}" dy="0.35em" ${FONT} fill="#6E6C67">${escapeXml(text)}</text></svg>`
37}
38
39function chipWidth(label: string) {
40 return 7 + 11 + 4 + textWidth(label) + 8
41}
42
43function chipSvg(icon: string, label: string) {
44 const w = chipWidth(label)
45 const glyph = `<g transform="translate(7 3.5) scale(${11 / 24})" fill="none" stroke="${CHIP_TEXT}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">${icon}</g>`
46 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${CHIP_HEIGHT}" viewBox="0 0 ${w} ${CHIP_HEIGHT}"><rect width="${w}" height="${CHIP_HEIGHT}" rx="6" fill="#FFFFFF" fill-opacity="0.07"/>${glyph}<text x="22" y="${CHIP_HEIGHT / 2}" dy="0.35em" ${FONT} fill="${CHIP_TEXT}">${escapeXml(label)}</text></svg>`
47}
48
49const keyOf = (node: any): string => (typeof node?.props?.key === 'string' ? node.props.key : '')
50const rankOf = (key: string) => Number(key.match(/^chip:(\d+):/)?.[1] ?? 9)
51
52/** The message row with this mod's chips added to any the mods beneath it drew. */
53export function sentWith(elements: any, below: any, mine: Chip[]) {
54 const { Box, Svg } = elements
55 let chips: any[] = []
56 let row = below
57 if (keyOf(below) === 'sent-with') {
58 const [chipRow, inner] = below.children ?? []
59 chips = (chipRow?.children ?? []).filter((c: any) => keyOf(c).startsWith('chip:'))
60 row = inner
61 }
62 const drawn = mine.map(c => (
63 <Box key={`chip:${c.key}`} flexDirection="row">
64 <Svg source={chipSvg(c.icon, c.label)} alt={c.label} width={chipWidth(c.label)} height={CHIP_HEIGHT} />
65 </Box>
66 ))
67 const all = [...chips, ...drawn]
68 .map((node, i) => ({ node, i, rank: rankOf(keyOf(node)) }))
69 .sort((a, b) => a.rank - b.rank || a.i - b.i)
70 .map(x => x.node)
71 // Above the bubble: under it is the app's own hover bar (time, copy, retry).
72 return (
73 <Box key="sent-with" flexDirection="column" rowGap={0.5}>
74 <Box key="sent-with-chips" flexDirection="row" justifyContent="flex-end" alignItems="center" columnGap={0.5} rowGap={0.5} flexWrap="wrap">
75 <Svg source={labelSvg('Sent with')} alt="Sent with" width={labelWidth('Sent with')} height={CHIP_HEIGHT} />
76 {all}
77 </Box>
78 {row}
79 </Box>
80 )
81}
82hooks/controls.tsx 255 lines1// One custom control, drawn by the desktop app: a button with its icon inside,
2// a checkbox chip, a count badge (a clay circle and a label) that opens a list,
3// a launcher tab (clay while on), or a menu row for a popover: icon, label and a
4// quiet hint at the right end, filling the width, lit on hover; or a switch row: icon,
5// label, hint and an on/off switch at the right end; or the launcher pet, a picture that
6// shows its petted mood while the pointer is on it and is never pressed. Each control is its own Client, so a click anywhere in it
7// is a press of it: no hit-testing. The module posts { press: true } and the
8// hooks module (register.tsx, `ui.message`) runs the action by the Client's key.
9//
10// Inside a Client one padding row is a full line (--engine-row-unit: 1lh), not half a
11// line as elsewhere in the band: paddingY 0.2 is about 4pt a side, a ~30pt control.
12//
13// Svg is not in surface.elements, but the engine passes an `h('Svg', …)` node
14// through and the desktop draws it (width and height are required there).
15
16type Variant = 'neutral' | 'accent' | 'ghost'
17
18type ControlProps = {
19 kind: 'button' | 'check' | 'badge' | 'tab' | 'row' | 'switch' | 'pet'
20 label: string
21 /** A badge's number, drawn in its circle. */
22 count?: number
23 /** Tabler path markup for the icon; drawn in the state's color. */
24 icon?: string
25 /** A whole SVG drawn in the icon's place, for a tab whose icon shows a value (a context ring). */
26 iconSvg?: string
27 variant?: Variant
28 /** A button or checkbox that fills its region, its content centered. */
29 isFull?: boolean
30 /** A tab's status beside its label, or a row's hint at its right end, drawn quieter. */
31 note?: string
32 /** A tab's small status dot, in this color. */
33 dot?: string
34 /** A checkbox's state; for a badge, whether its list is open; for a tab, whether its pane is open. */
35 isOn?: boolean
36 /** Its message is still being worked on: drawn dim with a spinner, presses ignored. */
37 isBusy?: boolean
38 /** The pet's picture now, the one while petted, and its size in px. */
39 pet?: { svg: string; petted: string; width: number; height: number }
40}
41
42type PointerState = { isHover: boolean; isDown: boolean }
43
44// The Claude app's own dark palette, plus the clay accent.
45const TEXT = '#F0EFEC'
46const MUTED = '#8A8A8A'
47const FAINT = '#5C5C5C'
48const ACCENT = '#D97757'
49const ACCENT_INK = '#1A0F0A'
50const ACCENT_TEXT = '#F2A98E'
51
52const FILL: Record<Variant, { rest: string; hover: string; down: string; edge: string; text: string; icon: string }> = {
53 neutral: { rest: '#373737', hover: '#434343', down: '#2C2C2C', edge: '#373737', text: TEXT, icon: MUTED },
54 accent: { rest: ACCENT, hover: '#E3876A', down: '#C4684A', edge: ACCENT, text: ACCENT_INK, icon: ACCENT_INK },
55 ghost: { rest: '#212121', hover: '#2C2C2C', down: '#262626', edge: '#383838', text: MUTED, icon: MUTED },
56}
57
58const LOADER = '<path d="M12 3a9 9 0 1 0 9 9"/>'
59const ICON_PX = 15
60
61function iconSource(paths: string, color: string, strokeWidth = 2) {
62 return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
63}
64
65function boxSource(isOn: boolean) {
66 const frame = isOn
67 ? `<rect x="1" y="1" width="16" height="16" rx="4.5" fill="${ACCENT}"/>`
68 : `<rect x="1.75" y="1.75" width="14.5" height="14.5" rx="4" fill="none" stroke="#6A6A6A" stroke-width="1.5"/>`
69 const tick = isOn
70 ? `<path d="M5 9.2l2.8 2.8l5.2 -5.6" fill="none" stroke="${ACCENT_INK}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
71 : ''
72 return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">${frame}${tick}</svg>`
73}
74
75const svg = (source: string, alt: string, size = ICON_PX) => h('Svg', { source, alt, width: size, height: size })
76
77const CHEVRON_DOWN = '<path d="M6 9l6 6l6 -6"/>'
78const CHEVRON_UP = '<path d="M6 15l6 -6l6 6"/>'
79
80// A badge's count: a clay circle, a pill from two digits.
81const COUNT_PX = 20
82
83function countWidth(n: number) {
84 return COUNT_PX + Math.max(0, String(n).length - 1) * 8
85}
86
87function countSource(n: number) {
88 const w = countWidth(n)
89 const r = COUNT_PX / 2
90 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${COUNT_PX}" viewBox="0 0 ${w} ${COUNT_PX}"><rect width="${w}" height="${COUNT_PX}" rx="${r}" fill="${ACCENT}"/><text x="${w / 2}" y="${r}" dy="0.35em" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif" font-size="12" font-weight="700" fill="${ACCENT_INK}">${n}</text></svg>`
91}
92
93const Control = (props: ControlProps, surface: any) => {
94 const { Box, Text } = surface.elements
95 const state: PointerState = surface.state ?? { isHover: false, isDown: false }
96 const isBusy = props.isBusy === true
97
98 surface.onPointer((ev: { type: string; x: number; y: number; button?: string }) => {
99 const now: PointerState = surface.state ?? { isHover: false, isDown: false }
100 const isInside = ev.x >= 0 && ev.y >= 0 && ev.x < surface.columns && ev.y < surface.rows
101 if (ev.type === 'enter' || ev.type === 'move') {
102 if (now.isHover !== isInside) surface.setState({ ...now, isHover: isInside })
103 } else if (ev.type === 'leave') {
104 if (now.isHover || now.isDown) surface.setState({ isHover: false, isDown: false })
105 } else if (ev.type === 'down' && ev.button === 'left' && !isBusy) {
106 surface.setState({ isHover: true, isDown: true })
107 } else if (ev.type === 'up') {
108 if (now.isDown && isInside && !isBusy && props.kind !== 'pet') surface.post({ press: true })
109 surface.setState({ isHover: isInside, isDown: false })
110 }
111 })
112
113 if (props.kind === 'pet' && props.pet) {
114 const { svg: now, petted, width, height } = props.pet
115 return Box({ flexDirection: 'row', children: [h('Svg', { source: state.isHover ? petted : now, alt: props.label || 'pet', width, height })] })
116 }
117
118 if (props.kind === 'badge') {
119 const n = props.count ?? 0
120 const bg = state.isDown ? '#36292499' : state.isHover || props.isOn ? '#4A3630' : '#3E2F2A'
121 return Box({
122 flexDirection: 'row',
123 alignItems: 'center',
124 columnGap: 1,
125 paddingLeft: 0.6,
126 paddingRight: 1.25,
127 paddingY: 0.2,
128 borderStyle: 'round',
129 borderColor: props.isOn ? '#74483A' : bg,
130 backgroundColor: bg,
131 children: [
132 h('Svg', { source: countSource(n), alt: String(n), width: countWidth(n), height: COUNT_PX }),
133 Text({ color: ACCENT_TEXT, bold: true, children: props.label }),
134 svg(iconSource(props.isOn ? CHEVRON_UP : CHEVRON_DOWN, ACCENT_TEXT), props.isOn ? 'hide' : 'show', 13),
135 ],
136 })
137 }
138
139 if (props.kind === 'tab') {
140 const isOn = props.isOn === true
141 const bg = isOn ? (state.isDown ? '#36292499' : state.isHover ? '#4A3630' : '#3E2F2A') : state.isDown ? '#2C2C2C' : state.isHover ? '#2A2A2A' : '#212121'
142 return Box({
143 flexDirection: 'row',
144 alignItems: 'center',
145 columnGap: 1,
146 paddingX: 1.25,
147 paddingY: 0.2,
148 flexShrink: 0,
149 borderStyle: 'round',
150 borderColor: isOn ? '#74483A' : '#383838',
151 backgroundColor: bg,
152 children: [
153 ...(props.iconSvg
154 ? [h('Svg', { source: props.iconSvg, alt: props.label, width: ICON_PX, height: ICON_PX })]
155 : props.icon ? [svg(iconSource(props.icon, isOn ? ACCENT : state.isHover ? TEXT : MUTED), props.label)] : []),
156 Text({ color: isOn ? ACCENT_TEXT : TEXT, wrap: 'wrap', children: props.label }),
157 ...(props.dot ? [h('Svg', { source: `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" fill="${props.dot}"/></svg>`, alt: 'status', width: 7, height: 7 })] : []),
158 ...(props.note ? [Text({ color: isOn ? ACCENT_TEXT : MUTED, dimColor: isOn, wrap: 'wrap', children: props.note })] : []),
159 ],
160 })
161 }
162
163 if (props.kind === 'switch') {
164 const isOn = props.isOn === true
165 const bg = state.isDown ? '#262626' : state.isHover ? '#2A2A2A' : '#1F1F1F'
166 const track = `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="18" viewBox="0 0 30 18"><rect width="30" height="18" rx="9" fill="${isOn ? ACCENT : '#4A4A4A'}"/><circle cx="${isOn ? 21 : 9}" cy="9" r="7" fill="${isOn ? ACCENT_INK : '#BDBDBD'}"/></svg>`
167 return Box({
168 flexDirection: 'row',
169 alignItems: 'center',
170 columnGap: 1,
171 paddingX: 1,
172 paddingY: 0.2,
173 borderStyle: 'round',
174 borderColor: bg,
175 backgroundColor: bg,
176 children: [
177 ...(props.icon ? [svg(iconSource(props.icon, isOn ? ACCENT : state.isHover ? TEXT : MUTED), props.label)] : []),
178 Text({ color: isOn ? ACCENT_TEXT : TEXT, bold: props.variant === 'accent', children: props.label }),
179 ...(props.note ? [Text({ color: MUTED, wrap: 'truncate-end', children: props.note })] : []),
180 Box({ flexGrow: 1 }),
181 h('Svg', { source: track, alt: isOn ? 'on' : 'off', width: 30, height: 18 }),
182 ],
183 })
184 }
185
186 if (props.kind === 'row') {
187 const isAccent = props.variant === 'accent'
188 // The popover card's own fill, so a row at rest sits flat on it.
189 const bg = state.isDown ? '#262626' : state.isHover ? '#2A2A2A' : '#1F1F1F'
190 const paths = isBusy ? LOADER : props.icon
191 const iconColor = isBusy ? FAINT : isAccent ? ACCENT : state.isHover ? TEXT : MUTED
192 return Box({
193 flexDirection: 'row',
194 alignItems: 'center',
195 columnGap: 1,
196 paddingX: 1,
197 paddingY: 0.2,
198 borderStyle: 'round',
199 borderColor: bg,
200 backgroundColor: bg,
201 children: [
202 ...(paths ? [svg(iconSource(paths, iconColor), isBusy ? 'working' : props.label)] : []),
203 Text({ color: isBusy ? FAINT : isAccent ? ACCENT_TEXT : TEXT, children: isBusy ? `${props.label}…` : props.label }),
204 Box({ flexGrow: 1 }),
205 ...(props.note ? [Text({ color: MUTED, wrap: 'truncate-end', children: props.note })] : []),
206 ],
207 })
208 }
209
210 if (props.kind === 'check') {
211 const isOn = props.isOn === true
212 const bg = isOn ? (state.isDown ? '#36292499' : '#3E2F2A') : state.isHover ? '#2A2A2A' : '#212121'
213 const edge = isOn ? '#74483A' : '#383838'
214 return Box({
215 flexDirection: 'row',
216 alignItems: 'center',
217 ...(props.isFull ? { justifyContent: 'center' } : {}),
218 columnGap: 1,
219 paddingX: 1.25,
220 paddingY: 0.2,
221 borderStyle: 'round',
222 borderColor: edge,
223 backgroundColor: bg,
224 children: [
225 svg(boxSource(isOn), isOn ? 'on' : 'off', 16),
226 ...(props.icon ? [svg(iconSource(props.icon, isOn ? ACCENT : FAINT), props.label)] : []),
227 Text({ color: isOn ? ACCENT_TEXT : state.isHover ? TEXT : MUTED, children: props.label }),
228 ],
229 })
230 }
231
232 const fill = FILL[props.variant ?? 'neutral']
233 const bg = isBusy ? '#2A2A2A' : state.isDown ? fill.down : state.isHover ? fill.hover : fill.rest
234 const textColor = isBusy ? FAINT : fill.text
235 const iconColor = isBusy ? FAINT : state.isHover && props.variant !== 'accent' ? TEXT : fill.icon
236 const paths = isBusy ? LOADER : props.icon
237 return Box({
238 flexDirection: 'row',
239 alignItems: 'center',
240 ...(props.isFull ? { justifyContent: 'center' } : {}),
241 columnGap: 1,
242 paddingX: !props.label ? 0.75 : paths ? 1.25 : 1.5,
243 paddingY: 0.2,
244 borderStyle: 'round',
245 borderColor: isBusy ? '#2A2A2A' : fill.edge,
246 backgroundColor: bg,
247 children: [
248 ...(paths ? [svg(iconSource(paths, iconColor), isBusy ? 'working' : props.label || 'icon')] : []),
249 ...(props.label ? [Text({ color: textColor, bold: props.variant === 'accent', children: isBusy ? `${props.label}…` : props.label })] : []),
250 ],
251 })
252}
253
254export default Control
255types/index.d.ts 35 lines1export type EditorSelection = {
2 ide: string
3 filePath: string
4 startLine: number
5 endLine: number
6 isEmpty: boolean
7 text: string
8}
9
10/** What the mod attached to one message, drawn as chips above it on the desktop. */
11export type Attached = { text: string; chips: { icon: 'file' | 'pin'; label: string }[] }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'editor-context': {
16 selection: EditorSelection | null
17 ide: string | null
18 isPaused: boolean
19 isDirty: boolean
20 pins: EditorSelection[]
21 recent: string[]
22 isRecentHidden: boolean
23 isDemo: boolean
24 notice: { kind: string; ide?: string } | null
25 isNoticeHidden: boolean
26 /** Explain's message is still being worked on: the button ignores presses until its turn completes. */
27 isExplaining: boolean
28 /** Sharing is on (the Editor tab, or the switch in its card). Off, nothing is sent. */
29 isShown: boolean
30 /** The last messages that went out with editor context, newest last. */
31 attached: Attached[]
32 }
33 }
34}
35