SLOPSHOPPER

editor-context

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…

newbandrowscommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · editor-context
› 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 › /editor-context-demo ⎿ editor-context: Editor band demo on: every row is shown with sample data. Run again to turn it off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

editor-context

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.

Version License: MIT Claude desktop app

Why

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".

Features

🗂️ 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.

Quick start

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.

Requirements

  • The Claude desktop app, with Claude Code v2.1.287 or later
  • VS Code or Cursor, open, with the Claude Code extension
  • Node.js 22 or later
  • macOS or Linux

Good to know

  • Off at the start of every session. Click the Editor tab to turn it on. The new-session screen comes before the session exists, so no mod can draw there; the tab appears once the session starts.
  • The card opens on hover. Mods can only float a card on hover, not on click; the click on the tab is the on/off switch.
  • Desktop app only. In terminal sessions the mod stays off, because /ide already does this there.
  • One editor connection. Your editor accepts one connection at a time. All your desktop sessions share it, but a terminal session using /ide on the same editor will take turns with them.
  • Long selections are cut at 20,000 characters.

What Claude sees

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.

🔒 Privacy

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.

Troubleshooting

  • Click the Editor tab. It's off until you do.
  • Check that the editor is open and the extension is installed: ls ~/.claude/ide should list a .lock file.
  • Run /plugin and check that editor-context is listed as active.
  • Make sure you're in the desktop app, not the terminal.

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.

How it works

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

Update or remove

claude plugin marketplace update talbarina          # update
claude plugin uninstall editor-context@talbarina    # remove

Credits

Icons by Tabler Icons (MIT).

License

MIT © 2026 Tal Barina

Source 4 files
hooks/register.tsx 908 lines
1import { 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}
908
hooks/chips.tsx 82 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
82
hooks/controls.tsx 255 lines
1// 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
255
types/index.d.ts 35 lines
1export 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