The input bar, smarter: your Linear tickets in the typeahead as you type an id, live highlighting (tickets, paths, @mentions, secrets in red), and ;;snippets…

The input bar, smarter.

ENG-, ENG-34) and your Linear issues show in the typeahead with their titles and status; take one with Tab or Enter. Your open issues, kept fresh every ten minutes.@mentions in purple, ;;snippets in amber, and anything that looks like a secret (API keys, tokens, private keys, password=…) in red, with a warning, before you send it.[Image #N] number; delete the tag and it goes, send and the row clears. Claude Code keeps pasted images to itself, so the mod saves a small copy from the clipboard the moment the tag appears, with a tiny helper compiled once from scripts/clipthumb.swift (about 0.15 s a paste; AppleScript fallback without Swift) into ~/Library/Caches/snippets/pasted. Pictures need a terminal that shows them (WezTerm, kitty, Ghostty, iTerm2); elsewhere it shows the tag as text.;;name and a space, and it expands. ;; alone lists them in the typeahead. Sent without the space, it expands too.| Snippet | Expands to |
|---|---|
;;mmi | Merge origin main into this branch and resolve conflicts. |
;;plan | A short plan first: files, approach, risks; wait for the go. |
;;review | Review this branch for bugs, edge cases and missing tests, by severity. |
;;tests | Run the tests and fix failures the changes caused. |
;;pr | Open a PR with a clear title and a summary of what and why. |
;;explain | Explain how this works, step by step, naming files and functions. |
;;tldr | Summarize the last answer in three bullets. |
;;cont | Continue where you left off. |
/snippets lists them; /snippets add <name> <text> adds or changes one; /snippets remove <name> drops one.
hooks/register.ts 205 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEFAULT_SNIPPETS, expandAll, expandAtCursor, paint, secretsIn, suggestions } from './draft'
5import type { Issue } from './draft'
6import type { Thumb } from '../types'
7
8const SNIPPETS_KEY = 'snippets'
9const ISSUES_EVERY_MS = 10 * 60_000
10/** The names a Linear MCP server goes by: the claude.ai connector, or one added with `claude mcp add`. */
11const LINEAR_SERVERS = ['claude.ai Linear', 'linear', 'Linear']
12const OPEN_STATES = ['started', 'unstarted', 'triage', 'backlog']
13
14let issues: Issue[] = []
15let known = new Set<string>()
16let snippets: Record<string, string> = { ...DEFAULT_SNIPPETS }
17let linearServer: string | null = null
18let warnedSecret = ''
19
20// Pasted images: Claude Code puts "[Image #N]" in the draft and keeps the picture to itself, so each one is
21// saved from the clipboard the moment its tag shows up, and drawn small above the input.
22const thumbs = atom({ plugin: 'snippets', key: 'thumbs' } as const, [] as Thumb[])
23const IMAGE_TAG = /\[Image #(\d+)\]/g
24const THUMB_ROWS = 6
25const THUMB_PX = 360
26/** A terminal cell's width over its height. */
27const CELL_ASPECT = 0.46
28const captured = new Map<number, Thumb | null>()
29const capturing = new Set<number>()
30
31function clipScript($: EngineInterface): string {
32 return `${$.plugin.root.replace(/\/\.claude-plugin\/?$/, '')}/scripts/clip-image.sh`
33}
34
35/** Saves the pasted image for each new tag in the draft, and shows the thumbnails of the tags still in it. */
36async function syncThumbs($: EngineInterface, text: string) {
37 const numbers = [...new Set([...text.matchAll(IMAGE_TAG)].map(found => Number(found[1])))]
38 for (const number of numbers) {
39 if (captured.has(number) || capturing.has(number)) continue
40 capturing.add(number)
41 const session = (await $.session.id()).replace(/[^\w-]/g, '')
42 const folder = `${await $.env.get('HOME')}/Library/Caches/snippets/pasted`
43 const ran = await $.process
44 .run(['sh', clipScript($), `${folder}/${session}-${number}.png`, String(THUMB_PX)], { timeoutMs: 30_000 })
45 .catch(() => null)
46 const size = ran?.exitCode === 0 ? ran.stdout.trim().split(/\s+/).map(Number) : []
47 captured.set(number, size.length === 2 && size[0]! > 0 ? { number, file: `${folder}/${session}-${number}.png`, width: size[0]!, height: size[1]! } : null)
48 capturing.delete(number)
49 }
50 const shown = numbers.map(number => captured.get(number)).filter((thumb): thumb is Thumb => Boolean(thumb))
51 const before = await read($, thumbs)
52 if (shown.map(thumb => thumb.number).join() !== before.map(thumb => thumb.number).join()) await update($, thumbs, () => shown)
53}
54
55/** Your Linear issues, open ones first, for the ticket typeahead and highlighting; kept fresh in the background. */
56async function loadIssues($: EngineInterface) {
57 const found: Issue[] = []
58 for (const state of OPEN_STATES) {
59 const result = await linearCall($, { assignee: 'me', state, limit: 50, orderBy: 'updatedAt', fields: ['id', 'title', 'status'] })
60 if (result === null) return
61 const text = result.content.map(block => block.text ?? '').join('')
62 if (result.isError) return
63 const data = JSON.parse(text) as { issues?: { id?: string; identifier?: string; title?: string; status?: string }[] }
64 for (const raw of data.issues ?? []) {
65 const id = String(raw.identifier ?? raw.id ?? '')
66 if (id) found.push({ id, title: String(raw.title ?? ''), status: String(raw.status ?? '') })
67 }
68 }
69 issues = found
70 known = new Set(found.map(issue => issue.id))
71}
72
73async function linearCall($: EngineInterface, args: Record<string, unknown>) {
74 const names = [...new Set([linearServer, ...LINEAR_SERVERS].filter((name): name is string => Boolean(name)))]
75 for (const server of names) {
76 try {
77 const result = await $.mcp.call(server, 'list_issues', args)
78 linearServer = server
79 return result
80 } catch {
81 // Not connected under this name: try the next.
82 }
83 }
84 return null
85}
86
87/** Whether this terminal shows pictures (kitty graphics). */
88async function showsPictures($: EngineInterface): Promise<boolean> {
89 if ((await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) === '1') return true
90 if (await $.env.get('KITTY_WINDOW_ID')) return true
91 return ['WezTerm', 'ghostty', 'kitty', 'iTerm.app'].includes((await $.env.get('TERM_PROGRAM')) ?? '')
92}
93
94async function saveSnippets($: EngineInterface) {
95 const own = Object.fromEntries(Object.entries(snippets).filter(([name, text]) => DEFAULT_SNIPPETS[name] !== text))
96 const removed = Object.keys(DEFAULT_SNIPPETS).filter(name => snippets[name] === undefined)
97 await $.store.set(SNIPPETS_KEY, { own, removed })
98}
99
100function snippetList(): string {
101 const rows = Object.entries(snippets)
102 .sort(([a], [b]) => a.localeCompare(b))
103 .map(([name, text]) => `;;${name.padEnd(10)}${text}`)
104 return [...rows, '', 'Type ;;name then space (or Tab from the list) to expand it.', '/snippets add <name> <text> · /snippets remove <name>'].join('\n')
105}
106
107export const register: Register = on => {
108 on('session.start', async ($, e, next) => {
109 // Not awaited: registering makes Claude Code re-describe every command, which would hold startup.
110 // Typed as /snippets in the terminal; the command file is what the desktop app lists in its menu.
111 void $.command.register({ name: 'snippets', description: 'Your ;;snippets: list, add, remove', argumentHint: '[add <name> <text>|remove <name>]' }).catch(() => undefined)
112 const stored = (await $.store.get(SNIPPETS_KEY)) as { own?: Record<string, string>; removed?: string[] } | undefined
113 snippets = { ...DEFAULT_SNIPPETS, ...(stored?.own ?? {}) }
114 for (const name of stored?.removed ?? []) delete snippets[name]
115 void loadIssues($).catch(() => undefined)
116 $.clock.every(ISSUES_EVERY_MS, () => void loadIssues($).catch(() => undefined))
117 // The clipboard helper is compiled once (a few seconds): do it now, so the first paste is quick too.
118 void $.process.run(['sh', clipScript($), '--build'], { timeoutMs: 120_000 }).catch(() => undefined)
119 // A paste may not pass through an edit the mod sees: check the draft for new image tags four times a second.
120 $.clock.every(250, () => {
121 void (async () => {
122 const box = await $.prompt.read()
123 if (box.text.includes('[Image #') || (await read($, thumbs)).length > 0) await syncThumbs($, box.text)
124 })().catch(() => undefined)
125 })
126 return next(e)
127 })
128
129 // Each edit: a ;;snippet expands when you type a space after it, and the draft is painted.
130 on('prompt.edit', async ($, e, next) => {
131 let box = await next(e)
132 if (e.inputText === ' ' || e.inputText === '\t') {
133 const expanded = expandAtCursor(box.text, box.cursor, snippets)
134 if (expanded) box = { ...box, ...expanded }
135 }
136 const secrets = secretsIn(box.text)
137 if (secrets.length > 0) {
138 const first = box.text.slice(secrets[0]!.start, secrets[0]!.end)
139 if (first !== warnedSecret) {
140 warnedSecret = first
141 void $.ui.toast('That looks like a secret (a key or token). Take it out before you send.')
142 }
143 }
144 void syncThumbs($, box.text).catch(() => undefined)
145 return { ...box, decorations: [...(box.decorations ?? []), ...paint(box.text, known, snippets)] }
146 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
147
148 // The typeahead: your Linear tickets as you type an id, your snippets after ;;.
149 on('prompt.autocomplete', async ($, e, next) => {
150 const result = await next(e)
151 const mine = suggestions(e.token, issues, snippets)
152 return mine.length === 0 ? result : { suggestions: [...mine, ...result.suggestions] }
153 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
154
155 // A ;;snippet sent with no space after it still expands. Sending clears the image previews.
156 on('prompt.submit', async ($, e, next) => {
157 void update($, thumbs, () => [])
158 const text = expandAll(e.text, snippets)
159 return next(text === e.text ? e : { ...e, text })
160 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
161
162 // Pasted images, small, in a row above the input, each with its tag's number.
163 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164 const shown = await read($, thumbs)
165 if (shown.length === 0 || e.props.hasSurvey) return next(e)
166 const { Box, Text, Image } = $.ui.resolve(e)
167 const above = await next(e)
168 const pictures = e.surface === 'terminal' && Image !== undefined && (await showsPictures($))
169 const row = h(
170 Box,
171 { flexDirection: 'row', columnGap: 2, flexWrap: 'wrap' },
172 ...shown.map(thumb => {
173 const columns = Math.max(4, Math.min(48, Math.round((THUMB_ROWS * thumb.width) / thumb.height / CELL_ASPECT)))
174 return h(
175 Box,
176 { key: `thumb-${thumb.number}`, flexDirection: 'column' },
177 pictures
178 ? h(Image, { key: `image-${thumb.number}`, source: { file: thumb.file, format: 'png' }, columns, rows: THUMB_ROWS, alt: `Image #${thumb.number}` })
179 : null,
180 h(Text, { dimColor: true }, pictures ? `#${thumb.number}` : `🖼 Image #${thumb.number}`),
181 )
182 }),
183 )
184 return h(Box, { flexDirection: 'column' }, above, row)
185 })
186
187 on('command.run', { command: ['snippets', 'snippets:snippets'] }, async ($, e) => {
188 const [verb = '', name = '', ...rest] = e.args.trim().split(/\s+/)
189 const key = name.replace(/^;;/, '').toLowerCase()
190 if (verb === 'add') {
191 if (!/^[a-z0-9-]+$/.test(key) || rest.length === 0) return { text: 'usage: /snippets add <name> <text>' }
192 snippets[key] = rest.join(' ')
193 await saveSnippets($)
194 return { text: `;;${key} now expands to: ${snippets[key]}` }
195 }
196 if (verb === 'remove') {
197 if (snippets[key] === undefined) return { text: `No snippet ;;${key}.` }
198 delete snippets[key]
199 await saveSnippets($)
200 return { text: `;;${key} removed.` }
201 }
202 return { text: snippetList() }
203 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
204}
205hooks/draft.ts 113 lines1/**
2 * What the snippets mod reads in a draft: ticket ids, paths, @mentions, ;;snippets and things that look like secrets,
3 * as runs to paint; and the snippet expansions. Pure functions, so the hooks stay small and the rules testable.
4 */
5
6export type Issue = { id: string; title: string; status: string }
7
8export type Paint = { start: number; end: number; color: string; bold?: boolean; underline?: boolean }
9
10export const DEFAULT_SNIPPETS: Record<string, string> = {
11 mmi: 'Merge origin main into this branch and resolve conflicts.',
12 plan: 'Before writing any code, write a short plan: the files you will touch, the approach, and the risks. Wait for my go.',
13 review: 'Review the changes on this branch for bugs, edge cases and missing tests. List the findings by severity.',
14 tests: 'Run the tests and fix any failures your changes caused.',
15 pr: 'Open a pull request for this branch: a clear title, and a summary of what changed and why.',
16 explain: 'Explain how this works, step by step, naming the files and functions involved.',
17 tldr: 'Summarize your last answer in three bullet points.',
18 cont: 'Continue where you left off.',
19}
20
21const TICKET = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g
22const MENTION = /(^|\s)(@[\w./~-]+)/g
23const PATH = /(^|[\s(])((?:~|\.{1,2})?\/?(?:[\w.-]+\/)+[\w.-]+)/g
24const SNIPPET = /;;[a-z0-9-]+/gi
25
26/** Shapes of keys and tokens that should not go to a model. */
27const SECRETS: RegExp[] = [
28 /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/g,
29 /\bgh[pousr]_[A-Za-z0-9]{30,}/g,
30 /\bgithub_pat_[A-Za-z0-9_]{40,}/g,
31 /\bAKIA[0-9A-Z]{16}\b/g,
32 /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
33 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
34 /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
35 /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{6,}/gi,
36]
37
38function runs(text: string, pattern: RegExp, group = 0): { start: number; end: number; match: string }[] {
39 const out: { start: number; end: number; match: string }[] = []
40 for (const found of text.matchAll(new RegExp(pattern.source, pattern.flags))) {
41 const match = found[group]!
42 const start = found.index! + found[0].indexOf(match)
43 out.push({ start, end: start + match.length, match })
44 }
45 return out
46}
47
48/** Where a draft holds something that looks like a secret. */
49export function secretsIn(text: string): { start: number; end: number }[] {
50 return SECRETS.flatMap(pattern => runs(text, pattern))
51}
52
53/**
54 * The runs to paint over a draft: known tickets bold in blue, other ticket-shaped ids in cyan, paths, @mentions,
55 * ;;snippets waiting to expand, and secrets in red. Later runs win, so secrets are painted last.
56 */
57export function paint(text: string, known: ReadonlySet<string>, snippets: Record<string, string>): Paint[] {
58 const out: Paint[] = []
59 for (const run of runs(text, PATH, 2)) if (!/^\d/.test(run.match)) out.push({ start: run.start, end: run.end, color: '#7ec699' })
60 for (const run of runs(text, MENTION, 2)) out.push({ start: run.start, end: run.end, color: '#c792ea' })
61 for (const run of runs(text, TICKET)) {
62 out.push(known.has(run.match) ? { start: run.start, end: run.end, color: '#5fb3ff', bold: true } : { start: run.start, end: run.end, color: '#89ddff' })
63 }
64 for (const run of runs(text, SNIPPET)) {
65 const isKnown = snippets[run.match.slice(2).toLowerCase()] !== undefined
66 out.push({ start: run.start, end: run.end, color: isKnown ? '#ffcb6b' : '#8a8f98', bold: isKnown })
67 }
68 for (const run of secretsIn(text)) out.push({ start: run.start, end: run.end, color: '#ff5370', bold: true, underline: true })
69 return out
70}
71
72/**
73 * Expands the ;;snippet that ends right before the cursor, once a space or Tab has been typed after it.
74 * Returns the new draft and cursor, or null when there is nothing to expand.
75 */
76export function expandAtCursor(text: string, cursor: number, snippets: Record<string, string>): { text: string; cursor: number } | null {
77 const before = text.slice(0, cursor)
78 const found = /;;([a-z0-9-]+)(\s)$/i.exec(before)
79 if (!found) return null
80 const expansion = snippets[found[1]!.toLowerCase()]
81 if (expansion === undefined) return null
82 const start = before.length - found[0].length
83 const head = text.slice(0, start) + expansion + found[2]!
84 return { text: head + text.slice(cursor), cursor: head.length }
85}
86
87/** Every known ;;snippet left in a prompt, expanded: for one sent right after the snippet, with no space. */
88export function expandAll(text: string, snippets: Record<string, string>): string {
89 return text.replace(/;;([a-z0-9-]+)/gi, (whole, name: string) => snippets[name.toLowerCase()] ?? whole)
90}
91
92/** The typeahead rows for a token: tickets for an id being typed, snippets after ;;. */
93export function suggestions(
94 token: string,
95 issues: readonly Issue[],
96 snippets: Record<string, string>,
97): { text: string; label: string; description: string }[] {
98 if (token.startsWith(';;')) {
99 const typed = token.slice(2).toLowerCase()
100 return Object.entries(snippets)
101 .filter(([name]) => name.startsWith(typed))
102 .slice(0, 8)
103 .map(([name, expansion]) => ({ text: expansion, label: `;;${name}`, description: expansion }))
104 }
105 const ticket = /^([A-Za-z][A-Za-z0-9]{1,9})-(\d*)$/.exec(token)
106 if (!ticket) return []
107 const prefix = token.toUpperCase()
108 return issues
109 .filter(issue => issue.id.startsWith(prefix))
110 .slice(0, 8)
111 .map(issue => ({ text: issue.id, label: `${issue.id} ${issue.title}`, description: issue.status }))
112}
113types/index.d.ts 11 lines1/** A pasted image's preview: its tag's number and the small copy saved from the clipboard. */
2export type Thumb = { number: number; file: string; width: number; height: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'snippets': {
7 thumbs: Thumb[]
8 }
9 }
10}
11