Prompt template library in a pane: search, tags, {{variables}}, fill, insert, send or copy; /prompts

A prompt template library for Claude Code. It opens a pane where you keep, search and tag your prompt templates, fill in their {{variables}}, and put the result into the prompt box, send it, or copy it.
Pick one way. All three need a new Claude Code session afterwards.
Run this in Claude Code:
/plugin marketplace add IT-BAER/claude-forge
/plugin install prompt-templates@claude-forge
Or from a normal terminal:
claude plugin marketplace add IT-BAER/claude-forge
claude plugin install prompt-templates@claude-forge
git clone https://github.com/IT-BAER/claude-forge.git
mkdir -p ~/.claude/skills
rm -rf ~/.claude/skills/prompt-templates
cp -r claude-forge/mods/prompt-templates ~/.claude/skills/prompt-templates
PowerShell:
git clone https://github.com/IT-BAER/claude-forge.git
Remove-Item "$HOME\.claude\skills\prompt-templates" -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item claude-forge\mods\prompt-templates "$HOME\.claude\skills\prompt-templates" -Recurse
claude --plugin-dir path/to/claude-forge/mods/prompt-templates
| Command | What it does |
|---|---|
/prompts | Open or close the pane |
/prompts <name> | Fill the prompt box with that template. A unique start of the name is enough. A template with variables opens its form in the pane. |
powershell.exe. Everything else works on any system.| Action | What it does |
|---|---|
| Fill | Replaces the prompt box text with the template |
| Insert | Inserts the template at the cursor and keeps what you typed |
| Send | Sends the template as a prompt at once |
| Copy | Copies the template to the clipboard |
Write {{name}} in a template for a value you fill in each time, for example Review {{file}} for {{topic}}. A variable name has letters, digits, - and _. Write \{{ for literal braces.
When a template has variables, its action opens a form with one field per variable and a preview of the result. Enter in the last field runs the action you chose; the other actions sit below the preview. The form remembers the last value of each variable.
New and Edit open the editor: a name field, a tags field (comma separated) and the body editor. The body editor is the one from file-explorer: syntax colours for Markdown, soft wrap (the Wrap button switches it), undo and redo, find and replace with Ctrl+F and Ctrl+H, and clipboard keys.
Changes stay a draft until you press Save (back to the list) or Ctrl+S (stay in the editor). Cancel, closing the pane and /prompts ask before they discard a draft. The editor lists the variables it finds in the body.
Import/Export writes all templates to a JSON file (default prompt-templates.json in your user folder) or reads one back. On import, a template with the same name (case does not matter) is replaced or skipped, as you choose. A file with one invalid entry is refused whole.
The file format:
{ "version": 1, "templates": [{ "name": "Review", "tags": ["dev"], "body": "Review {{file}} for {{topic}}" }] }
By default, templates live in the mod's own store, a JSON file in your Claude Code configuration folder. They are the same in every session on this computer, but they do not sync to other computers.
To sync them, set the libraryFile option to a JSON file in a folder you sync, for example with git:
"pluginConfigs": {
"prompt-templates@claude-forge": { "options": { "libraryFile": "~/.claude/prompt-templates.json" } }
}
Put this in ~/.claude/settings.json, or set it in /config. On first use, a missing file is created from the templates in the store; the store keeps its copy as a backup. After that the pane reads and writes only the file. It is sorted by name, so a git diff shows only the templates that changed. If the file is not valid JSON, the pane shows the error and does not overwrite the file. The last variable values stay in the store.
Export and Import still work in both modes, for example for backups.
MIT, see the repository root.
hooks/register.tsx 599 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Action, Editor, Template, View } from '../types'
5import { COLORS, HEAD, highlight, layout } from './editor'
6import type { EditorProps } from './editor'
7import { exportJson, filterTemplates, findByName, libraryJson, mergeImport, parseImport, parseLibrary, parseTags, parseVars, renderTemplate, tagsOf, validateTemplate } from './templates'
8
9const PANE = 'prompt-templates'
10const TITLE = 'Prompts'
11const KEY = 'templates'
12const VARS_KEY = 'lastVars'
13// $.store rejects past 4 MiB in all; the margin leaves room for lastVars.
14const STORE_LIMIT = 4 * 1024 * 1024 - 256 * 1024
15const ACTIONS: Action[] = ['fill', 'insert', 'send', 'copy']
16const LABEL: Record<Action, string> = { fill: 'Fill', insert: 'Insert', send: 'Send', copy: 'Copy' }
17const DONE: Record<Action, string> = { fill: 'Filled', insert: 'Inserted', send: 'Sent', copy: 'Copied' }
18const CONTROL = /[\x00-\x1f\x7f-\x9f]/
19const EMPTY: View = { mode: 'list', query: '', tag: '', selected: '', vars: {}, ioPath: '', notice: '', error: '', tick: 0 }
20const view = atom({ plugin: 'prompt-templates', key: 'view' } as const, EMPTY)
21let paneOpen = false
22let libraryFile = ''
23
24const clean = (error: unknown) => String((error as Error)?.message ?? error).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').slice(0, 300)
25const current = async ($: any): Promise<View> => ({ ...EMPTY, ...(await read($, view)) })
26const setView = ($: any, patch: Partial<View> | ((v: View) => Partial<View>)) =>
27 update($, view, (cur: View) => { const v = { ...EMPTY, ...cur }; return { ...v, ...(typeof patch === 'function' ? patch(v) : patch) } })
28const isTemplate = (t: any): t is Template =>
29 !!t && typeof t.id === 'string' && typeof t.name === 'string' && typeof t.body === 'string' && Array.isArray(t.tags)
30const firstLine = (body: string) => body.split('\n').find(l => l.trim())?.trim().slice(0, 200) ?? ''
31
32async function loadStore($: any): Promise<Template[]> {
33 const value = await $.store.get(KEY)
34 return Array.isArray(value) ? value.filter(isTemplate) : []
35}
36
37async function libraryPath($: any) {
38 if (!libraryFile) return ''
39 try { return await resolvePath($, libraryFile) } catch (error) { throw new Error(`libraryFile option: ${clean(error)}`) }
40}
41
42// A missing library file is seeded once from the store, which keeps its copy; an invalid one throws, so nothing overwrites it.
43async function loadAll($: any): Promise<Template[]> {
44 const path = await libraryPath($)
45 if (!path) return loadStore($)
46 if (!(await $.fs.exists(path))) {
47 const seed = await loadStore($)
48 if (seed.length) await $.fs.write(path, libraryJson(seed))
49 return seed
50 }
51 const parsed = parseLibrary(await $.fs.read(path))
52 if ('error' in parsed) throw new Error(`The library file ${path} is not valid: ${parsed.error}`)
53 return parsed.templates
54}
55
56// Read-modify-write on the library, so a save from another session is not overwritten by a stale copy.
57async function mutate($: any, fn: (list: Template[]) => Template[]) {
58 const path = await libraryPath($)
59 const next = fn(await loadAll($))
60 if (path) await $.fs.write(path, libraryJson(next))
61 else {
62 if (JSON.stringify(next).length > STORE_LIMIT) throw new Error('The template library would exceed the 4 MiB store limit.')
63 await $.store.set(KEY, next)
64 }
65 await setView($, v => ({ tick: v.tick + 1 }))
66}
67
68async function openPane($: any) {
69 const opened = await $.ui.open({ id: PANE, title: TITLE })
70 paneOpen = true
71 if (!opened.isPlaced) $.ui.toast(`Prompts pane not drawn: ${opened.reason}`)
72}
73
74async function closePane($: any) {
75 if (!(await leaveEditor($))) return false
76 await $.ui.close({ id: PANE })
77 paneOpen = false
78 return true
79}
80
81async function runAction($: any, t: Template, action: Action, values: Record<string, string>, surface?: string) {
82 const text = renderTemplate(t.body, values)
83 try {
84 if (action === 'copy') {
85 const copied = await $.ui.copy(surface ? { text, surface } : { text })
86 if (!copied.isCopied) throw new Error(`Copy failed (${copied.reason}).`)
87 } else if (action === 'send') {
88 await $.prompt.submit({ text })
89 } else {
90 const filled = await $.prompt.fill({ text, mode: action === 'insert' ? 'insert' : 'replace' })
91 if (!filled.isFilled) throw new Error(`The prompt box is not available (${filled.refusal ?? 'unknown'}).`)
92 }
93 await setView($, { mode: 'list', selected: '', action: undefined, notice: `${DONE[action]} "${t.name}".`, error: '' })
94 return true
95 } catch (error) {
96 await setView($, { notice: '', error: clean(error) })
97 return false
98 }
99}
100
101async function start($: any, id: string, action: Action, surface?: string) {
102 const t = (await loadAll($)).find(x => x.id === id)
103 if (!t) return setView($, { mode: 'list', selected: '', error: 'This template no longer exists.' })
104 const names = parseVars(t.body)
105 if (!names.length) return runAction($, t, action, {}, surface)
106 const last = await $.store.get(VARS_KEY) as Record<string, unknown> | undefined
107 const vars = Object.fromEntries(names.map(n => [n, typeof last?.[n] === 'string' ? last[n] as string : '']))
108 await setView($, { mode: 'vars', selected: t.id, action, vars, notice: '', error: '' })
109}
110
111async function runVars($: any, action: Action, surface?: string) {
112 const v = await current($)
113 const t = (await loadAll($)).find(x => x.id === v.selected)
114 if (!t) return setView($, { mode: 'list', selected: '', error: 'This template no longer exists.' })
115 const last = await $.store.get(VARS_KEY)
116 const merged = { ...(last && typeof last === 'object' && !Array.isArray(last) ? last : {}), ...v.vars }
117 const kept = Object.entries(merged).filter(([, x]) => typeof x === 'string' && x.length <= 2000).slice(-200)
118 await $.store.set(VARS_KEY, Object.fromEntries(kept))
119 await runAction($, t, action, v.vars, surface)
120}
121
122async function home($: any) {
123 const dir = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
124 return dir.replace(/\\/g, '/').replace(/\/+$/, '')
125}
126
127async function resolvePath($: any, raw: string) {
128 let path = raw.trim().replace(/^["']|["']$/g, '')
129 if (/^~[\\/]/.test(path)) path = (await home($)) + path.slice(1)
130 if (!path || CONTROL.test(path) || !/\.json$/i.test(path)) throw new Error('The path must name a .json file.')
131 return path
132}
133
134async function exportAll($: any) {
135 try {
136 const path = await resolvePath($, (await current($)).ioPath)
137 const list = await loadAll($)
138 await $.fs.write(path, exportJson(list))
139 await setView($, { notice: `Exported ${list.length} templates to ${path}.`, error: '' })
140 } catch (error) {
141 await setView($, { notice: '', error: `Export failed: ${clean(error)}` })
142 }
143}
144
145async function importFile($: any, mode: 'replace' | 'skip') {
146 try {
147 const path = await resolvePath($, (await current($)).ioPath)
148 let text: string
149 try { text = await $.fs.read(path) } catch (error) { throw new Error(`The file could not be read: ${clean(error)}`) }
150 const parsed = parseImport(text)
151 if ('error' in parsed) throw new Error(parsed.error)
152 let counts = { added: 0, replaced: 0, skipped: 0 }
153 await mutate($, list => {
154 const merged = mergeImport(list, parsed.templates, mode, Date.now(), () => crypto.randomUUID())
155 counts = merged
156 return merged.list
157 })
158 await setView($, { notice: `Imported: ${counts.added} added, ${counts.replaced} replaced, ${counts.skipped} skipped.`, error: '' })
159 } catch (error) {
160 await setView($, { notice: '', error: `Import failed: ${clean(error)}` })
161 }
162}
163
164async function openIo($: any) {
165 const v = await current($)
166 const ioPath = v.ioPath || `${await home($)}/prompt-templates.json`
167 await setView($, { mode: 'io', ioPath, notice: '', error: '' })
168}
169
170async function deleteTemplate($: any, id: string) {
171 try {
172 await mutate($, list => list.filter(t => t.id !== id))
173 await setView($, { mode: 'list', selected: '', notice: 'Template deleted.', error: '' })
174 } catch (error) {
175 await setView($, { error: clean(error) })
176 }
177}
178
179// The body editor is file-explorer's Client editor (editor.tsx, kept identical); the code below is its pane wiring.
180const EDITOR_KEY = 'body-editor'
181const EDITOR_MODULE = 'hooks/editor.tsx'
182const EDITOR_WINDOW = 120
183// Desktop geometry measured for file-explorer: CSS px per pointer cell and per row, code font size, baseline, characters per cell.
184const G = { cellW: 7.92, cellPx: 19.2, codeFont: 13, codeBase: 0.74, textFit: 4 / 3, mono: true }
185const editorGeo = new Map<number, { columns: number; left: number; limit: number }>()
186const paneView = { offset: 0, body: 35, content: 0, fit: 1, mono: false }
187let editRequest = Date.now()
188let findShown = false
189
190const isDirty = (p: Editor) => p.draft !== p.saved || p.name !== p.savedName || p.tags !== p.savedTags
191
192function editorFor(p: Editor, rows: number): EditorProps {
193 const total = p.editorTotal ?? p.draft.split('\n').length
194 const top = Math.max(0, paneView.offset - Math.max(0, paneView.content - total - 2))
195 return { request: p.request, text: p.draft, instance: p.editorInstance ?? '', seq: p.editorSeq ?? 0, refresh: p.editorRefresh ?? 0,
196 cursor: p.editorCursor ?? 0, busy: !!p.busy, rows, fit: paneView.fit, top, span: Math.max(8, paneView.body), win: p.editorWin ?? 0,
197 wrap: p.editorWrap ?? true, ext: 'md', mono: paneView.mono }
198}
199
200// Applies fn to the open editor when it is still the one for `request`; resolves whether it did.
201async function updateEdit($: any, request: number, fn: (p: Editor) => Editor | undefined, extra: Partial<View> = {}) {
202 let applied = false
203 await update($, view, (cur: View) => {
204 applied = false
205 const v = { ...EMPTY, ...cur }
206 const next = v.edit && v.edit.request === request ? fn(v.edit) : undefined
207 if (!next) return v
208 applied = true
209 return { ...v, ...extra, edit: next }
210 })
211 return applied
212}
213
214async function openEditor($: any, id?: string) {
215 const t = id ? (await loadAll($)).find(x => x.id === id) : undefined
216 if (id && !t) return setView($, { error: 'This template no longer exists.' })
217 const tags = t ? t.tags.join(', ') : ''
218 const edit: Editor = { ...(t ? { id: t.id } : {}), name: t?.name ?? '', tags, savedName: t?.name ?? '', savedTags: tags,
219 draft: t?.body ?? '', saved: t?.body ?? '', request: ++editRequest, editorWrap: true }
220 await setView($, { mode: 'edit', edit, selected: '', notice: '', error: '' })
221}
222
223// Leaves the editor unless it holds unsaved changes; then it asks first. Resolves whether it left.
224async function leaveEditor($: any, discard = false) {
225 let left = false
226 await update($, view, (cur: View) => {
227 const v = { ...EMPTY, ...cur }
228 left = false
229 const p = v.edit
230 if (v.mode !== 'edit' || !p) { left = true; return v }
231 if (p.busy) return v
232 if (isDirty(p) && !discard) return { ...v, edit: { ...p, confirmBack: true } }
233 left = true
234 const { edit: _, ...rest } = v
235 return { ...rest, mode: 'list' as const, notice: '', error: '' }
236 })
237 if (left && findShown) { findShown = false; $.ui.status(undefined) }
238 return left
239}
240
241async function saveEdit($: any, request: number, close: boolean) {
242 const p = (await current($)).edit
243 if (!p || p.request !== request || p.busy) return false
244 const draft = { name: p.name.trim(), tags: parseTags(p.tags), body: p.draft }
245 const invalid = validateTemplate(draft)
246 if (invalid) { await setView($, { error: invalid, notice: '' }); return false }
247 const id = p.id ?? crypto.randomUUID()
248 await updateEdit($, request, cur => ({ ...cur, busy: true }))
249 try {
250 await mutate($, list => {
251 if (list.some(t => t.id !== id && t.name.toLowerCase() === draft.name.toLowerCase())) throw new Error(`A template named "${draft.name}" already exists.`)
252 const t: Template = { id, ...draft, updatedAt: Date.now() }
253 return list.some(x => x.id === id) ? list.map(x => (x.id === id ? t : x)) : [...list, t]
254 })
255 } catch (error) {
256 await updateEdit($, request, cur => ({ ...cur, busy: false }), { error: clean(error), notice: '' })
257 return false
258 }
259 const tags = draft.tags.join(', ')
260 await updateEdit($, request, cur => ({ ...cur, id, busy: false, confirmBack: false, saved: p.draft, savedName: p.name, savedTags: p.tags,
261 ...(cur.tags === p.tags ? { tags, savedTags: tags } : {}) }), { notice: `Saved "${draft.name}".`, error: '' })
262 if (close && (await leaveEditor($))) await setView($, { notice: `Saved "${draft.name}".` })
263 return true
264}
265
266async function editorMessage($: any, e: any) {
267 const data = e.data
268 if (data?.geo) {
269 const g = data.geo
270 if ([g.request, g.columns, g.left, g.limit].every(Number.isSafeInteger) && g.columns >= 8 && g.columns <= 1000 && g.left >= 0 && g.limit >= 0) {
271 const prior = editorGeo.get(g.request)
272 if (!prior || prior.columns !== g.columns || prior.left !== g.left || prior.limit !== g.limit) { editorGeo.set(g.request, g); $.ui.invalidate('ui.render') }
273 }
274 return {}
275 }
276 if (data?.view) {
277 const w = data.view
278 if (![w.request, w.row, w.line, w.col, w.selected, w.total].every(Number.isSafeInteger) || w.row < 0 || w.line < 0 || w.col < 0 || w.selected < 0 || w.total < 1) return {}
279 await updateEdit($, w.request, p => ({ ...p, editorRow: w.row, editorLine: w.line, editorCol: w.col, editorSelected: w.selected, editorTotal: w.total,
280 ...(Number.isSafeInteger(w.cursor) && Number.isSafeInteger(w.anchor) && w.cursor >= 0 && w.anchor >= 0 ? { editorCaret: w.cursor, editorAnchor: w.anchor } : {}) }))
281 $.clock.after(80, () => $.ui.scroll({ to: { key: 'editor-caret' }, in: PANE }).catch(() => {}))
282 return {}
283 }
284 if (data?.find) {
285 const f = data.find
286 if (!Number.isSafeInteger(f.request) || typeof f.text !== 'string' || f.text.length > 400) return {}
287 if (f.request !== (await current($)).edit?.request) return {}
288 findShown = !!f.text
289 $.ui.status(f.text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ') || undefined)
290 return {}
291 }
292 if (!data || typeof data.instance !== 'string' || !data.instance || data.instance.length > 100 || !Number.isSafeInteger(data.seq) || data.seq <= 0 || !Number.isSafeInteger(data.request)) return {}
293 if (!Number.isSafeInteger(data.rows) || data.rows < 8 || data.rows > 40) return {}
294 if (typeof data.text !== 'string' || data.text.length > 30000 || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(data.text)) return {}
295 if (!['change', 'save', 'copy', 'cut', 'paste'].includes(data.action)) return {}
296 if (!Number.isSafeInteger(data.from) || !Number.isSafeInteger(data.to) || data.from < 0 || data.to < data.from || data.to > data.text.length) return {}
297 const accepted = await updateEdit($, data.request, p => (p.editorInstance === data.instance && data.seq <= (p.editorSeq ?? 0)) ? undefined
298 : { ...p, draft: data.text, editorInstance: data.instance, editorSeq: data.seq, editorCursor: data.to, confirmBack: false })
299 if (!accepted) return {}
300 if (data.action === 'save') await saveEdit($, data.request, false)
301 else if (data.action !== 'change') {
302 await updateEdit($, data.request, p => ({ ...p, busy: true }))
303 try {
304 let inserted = ''
305 if (data.action === 'paste') {
306 const result = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $text=Get-Clipboard -Raw; [Console]::Out.Write((@{text=$text}|ConvertTo-Json -Compress))'], { timeoutMs: 5000 })
307 if (result.exitCode !== 0) throw new Error('Clipboard could not be read.')
308 const clipboard = JSON.parse(result.stdout)
309 if (clipboard.text !== null && typeof clipboard.text !== 'string') throw new Error('Clipboard does not contain text.')
310 inserted = (clipboard.text ?? '').replace(/\r\n?/g, '\n')
311 } else {
312 const copied = await $.ui.copy({ text: data.text.slice(data.from, data.to), surface: e.surface })
313 if (!copied.isCopied) throw new Error('Clipboard could not be updated.')
314 }
315 const text = data.action === 'copy' ? data.text : data.text.slice(0, data.from) + inserted + data.text.slice(data.to)
316 if (text.length > 30000 || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(text)) throw new Error('Pasted text exceeds the editor limit or contains unsupported control characters.')
317 let changed = true
318 await updateEdit($, data.request, p => {
319 changed = p.editorSeq === data.seq && p.editorInstance === data.instance
320 return changed ? { ...p, draft: text, busy: false, editorRefresh: (p.editorRefresh ?? 0) + (data.action === 'copy' ? 0 : 1), editorCursor: data.from + inserted.length }
321 : { ...p, busy: false }
322 }, {})
323 if (!changed) await setView($, { error: 'The text changed during the clipboard operation. Try again.' })
324 } catch (error) {
325 await updateEdit($, data.request, p => ({ ...p, busy: false }), { error: clean(error) })
326 }
327 }
328 const p = (await current($)).edit
329 return p && p.request === data.request ? { props: editorFor(p, data.rows) } : {}
330}
331
332const xmlText = (value: string) => value.replace(/[&<>]/g, c => c === '&' ? '&' : c === '<' ? '<' : '>')
333// One editor row as an SVG: every character sits at its own cell, so pointer cell N is character N whatever the font.
334function rowSvg(text: string, kinds: Uint8Array | undefined, from: number, left: number, cells: number, sel?: [number, number], caret?: number): string {
335 const w = cells * G.cellW
336 const h = G.cellPx
337 const runs: string[] = []
338 if (sel && sel[1] > from + left && sel[0] < text.length + 1) {
339 const a = Math.max(0, sel[0] - from - left), b = Math.min(cells, sel[1] - from - left)
340 if (b > a) runs.push(`<rect x="${+(a * G.cellW).toFixed(2)}" y="0" width="${+((b - a) * G.cellW).toFixed(2)}" height="${h}" fill="#264f78"/>`)
341 }
342 let x: number[] = []
343 let chars = ''
344 let color = ''
345 let bold = false
346 const flush = () => {
347 if (chars) runs.push(`<text x="${x.map(v => +v.toFixed(2)).join(' ')}" fill="${color}"${bold ? ' font-weight="700"' : ''}>${xmlText(chars)}</text>`)
348 x = []; chars = ''
349 }
350 const line = text.slice(from + left, from + left + cells)
351 for (let i = 0; i < line.length; i++) {
352 const c = line[i] === '\t' ? ' ' : line[i]!
353 if (c === ' ') continue
354 const kind = kinds?.[from + left + i] ?? 0
355 const nextColor = COLORS[kind] ?? '#d4d4d4'
356 const nextBold = kind === HEAD
357 if (nextColor !== color || nextBold !== bold) { flush(); color = nextColor; bold = nextBold }
358 x.push(i * G.cellW)
359 chars += c
360 }
361 flush()
362 if (caret !== undefined && caret >= from + left && caret - from - left <= cells) runs.push(`<rect x="${+((caret - from - left) * G.cellW).toFixed(2)}" y="1" width="1.6" height="${h - 2}" fill="#aeafad"/>`)
363 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><g font-family="Cascadia Mono,Consolas,ui-monospace,monospace" font-size="${G.codeFont}">${runs.join('').replace(/<text /g, `<text y="${(h * G.codeBase).toFixed(2)}" `)}</g></svg>`
364}
365
366export const register: Register = (on, options) => {
367 libraryFile = typeof options.libraryFile === 'string' ? options.libraryFile.trim() : ''
368 on('session.start', async ($, e, next) => {
369 await $.command.register({ name: 'prompts', description: 'Toggle the prompt templates pane; /prompts <name> fills that template', argumentHint: '[name]' })
370 return next(e)
371 })
372
373 on('ui.close', async ($, e, next) => {
374 if ((e as any).id === PANE || (e as any).requestId === PANE) {
375 const p = (await current($)).edit
376 if (e.origin.kind !== 'unload' && p && (isDirty(p) || p.busy)) { await leaveEditor($); return { value: undefined } }
377 paneOpen = false
378 if (findShown) { findShown = false; $.ui.status(undefined) }
379 }
380 return next(e)
381 })
382
383 on('command.run', { command: 'prompts' }, async ($, e) => {
384 const arg = (e.args ?? '').trim().replace(/^["']|["']$/g, '')
385 const busy = 'Save or discard the template draft in the Prompts pane first.'
386 if (!arg) {
387 if (paneOpen) return { text: (await closePane($)) ? 'Prompts pane closed.' : busy }
388 await openPane($)
389 return { text: 'Prompts pane opened.' }
390 }
391 if (!(await leaveEditor($))) { await openPane($); return { text: busy } }
392 const t = findByName(await loadAll($), arg)
393 if (!t) {
394 await setView($, { mode: 'list', query: arg, tag: '', notice: '', error: '' })
395 await openPane($)
396 return { text: `No template matches "${arg}". The Prompts pane shows the search.` }
397 }
398 if (parseVars(t.body).length) {
399 await start($, t.id, 'fill')
400 await openPane($)
401 return { text: `"${t.name}" has variables. Fill them in the Prompts pane.` }
402 }
403 return { text: (await runAction($, t, 'fill', {})) ? `Filled "${t.name}" into the prompt box.` : `Could not fill "${t.name}".` }
404 })
405
406 // The pane scrolls the editor natively; the editor draws only a window of lines, centred on the scroll position.
407 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
408 paneView.offset = e.offset; paneView.body = e.bodyRows; paneView.content = e.contentRows
409 const p = (await current($)).edit
410 if (p) {
411 const total = p.editorTotal ?? p.draft.split('\n').length
412 const first = Math.max(0, e.offset - Math.max(0, e.contentRows - total - 2))
413 const win = Math.max(0, Math.min(total - EDITOR_WINDOW, first - Math.floor((EDITOR_WINDOW - e.bodyRows) / 2)))
414 if (Math.abs(win - (p.editorWin ?? 0)) > 15) updateEdit($, p.request, cur => ({ ...cur, editorWin: win })).catch(() => {})
415 }
416 return next(e)
417 })
418
419 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
420 if (e.element === EDITOR_KEY && e.module === EDITOR_MODULE) return editorMessage($, e)
421 return {}
422 })
423
424 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
425 if (e.surface !== 'desktop' && e.surface !== 'terminal') return $.ui.resolve(e).Text({ children: 'The Prompts pane is available in Claude Desktop and the terminal.' })
426 const elements = $.ui.resolve(e)
427 const { Box, Text, Button, Input, Client } = elements
428 const Svg = 'Svg' in elements ? elements.Svg : undefined
429 try {
430 const v = await current($)
431 const all = await loadAll($)
432 paneView.offset = e.props.scroll.offset; paneView.body = e.props.scroll.bodyRows
433 paneView.mono = e.surface === 'desktop' && G.mono; paneView.fit = e.surface === 'desktop' && !paneView.mono ? G.textFit : 1
434 const messages = <Box flexDirection="column">
435 {v.error ? <Text color="red" wrap="wrap">{v.error}</Text> : null}
436 {v.notice ? <Text dimColor wrap="wrap">{v.notice}</Text> : null}
437 </Box>
438
439 if (v.mode === 'edit' && v.edit) {
440 const p = v.edit
441 const request = p.request
442 const props = editorFor(p, Math.max(8, Math.min(40, e.props.scroll.bodyRows - 12)))
443 const names = parseVars(p.draft)
444 const client = <Client key={EDITOR_KEY} module="./editor.tsx" width="100%" props={props} />
445 const geo = props.mono && Svg ? editorGeo.get(request) : undefined
446 let body
447 if (!geo) {
448 body = <Box flexDirection="column" position="relative">
449 <Box key="editor-caret" position="absolute" top={p.editorRow ?? 0} height={1} width={1} />
450 {client}
451 </Box>
452 } else {
453 // Monospace mode: the text is SVG rows here, the Client lies on top for keys, pointer, caret and highlights.
454 const text = props.text
455 const gutter = Math.max(4, String(text.split('\n').length).length + 2)
456 const cells = Math.max(8, geo.columns - gutter)
457 const vrows = layout(text, geo.limit)
458 const from = Math.max(0, props.top - 20)
459 const to = Math.min(vrows.length, props.top + props.span + 20)
460 const caretLine = vrows[Math.min(vrows.length - 1, p.editorRow ?? 0)]?.line
461 const kinds = highlight(text, props.ext)
462 const sel: [number, number] | undefined = p.editorCaret !== undefined && p.editorAnchor !== undefined && p.editorCaret !== p.editorAnchor
463 ? [Math.min(p.editorCaret, p.editorAnchor), Math.max(p.editorCaret, p.editorAnchor)] : undefined
464 body = <Box flexDirection="column" position="relative" height={vrows.length}>
465 {from > 0 && <Box key="code-top" height={from} flexShrink={0} />}
466 {vrows.slice(from, to).map((r, i) => {
467 const end = text[r.end - 1] === '\n' ? r.end - 1 : r.end
468 return <Box key={`code-row:${from + i}`} height={1} flexShrink={0} overflow="hidden" flexDirection="row" backgroundColor={r.line === caretLine ? '#151b23' : undefined}>
469 <Box width={gutter} flexShrink={0} />
470 <Box flexGrow={1} height={1} overflow="hidden">{Svg && (text.slice(r.start, end).trim() || from + i === p.editorRow || sel) ? <Svg source={rowSvg(text.slice(0, end), kinds, r.start, geo.left, cells, sel, from + i === p.editorRow ? p.editorCaret : undefined)} alt="editor row" width={Math.round(cells * G.cellW)} height={G.cellPx} /> : null}</Box>
471 </Box>
472 })}
473 <Box key="editor-caret" position="absolute" top={p.editorRow ?? 0} height={1} width={1} />
474 <Box position="absolute" top={0} left={0} width="100%" height={vrows.length}>{client}</Box>
475 </Box>
476 }
477 const field = (patch: Partial<Editor>) => updateEdit($, request, cur => ({ ...cur, ...patch, confirmBack: false }))
478 return (
479 <Box flexDirection="column" paddingX={1} gap={1}>
480 <Box flexDirection="row" justifyContent="space-between">
481 <Text bold>{`${p.id ? 'Edit template' : 'New template'}${isDirty(p) ? ' • Unsaved' : ''}`}</Text>
482 <Box flexDirection="row" gap={1}>
483 <Button key="edit-wrap" label={props.wrap ? 'Wrap: on' : 'Wrap: off'} onPress={() => updateEdit($, request, cur => ({ ...cur, editorWrap: !(cur.editorWrap ?? true) }))} />
484 <Button key="edit-cancel" label="Cancel" onPress={() => leaveEditor($)} />
485 <Button key="edit-save" label={p.busy ? 'Saving…' : 'Save'} variant="primary" onPress={() => saveEdit($, request, true)} />
486 </Box>
487 </Box>
488 <Input key="edit-name" label="Name" placeholder="Template name" value={p.name} onInput={name => field({ name })} onSubmit={name => field({ name })} />
489 <Input key="edit-tags" label="Tags" placeholder="comma separated" value={p.tags} onInput={tags => field({ tags })} onSubmit={tags => field({ tags })} />
490 {p.confirmBack && (
491 <Box flexDirection="column" gap={1}>
492 <Text>Save changes before returning to the list?</Text>
493 <Box flexDirection="row" gap={1}>
494 <Button key="edit-save-back" label="Save and return" variant="primary" onPress={() => saveEdit($, request, true)} />
495 <Button key="edit-discard" label="Discard changes" onPress={() => leaveEditor($, true)} />
496 <Button key="edit-keep" label="Keep editing" onPress={() => updateEdit($, request, cur => ({ ...cur, confirmBack: false }))} />
497 </Box>
498 </Box>
499 )}
500 {messages}
501 <Text dimColor>{`Ln ${(p.editorLine ?? 0) + 1}, Col ${(p.editorCol ?? 0) + 1}${p.editorSelected ? ` (${p.editorSelected} selected)` : ''} · Ctrl+S Save · Ctrl+Z Undo · Ctrl+Y Redo · Ctrl+V Paste`}</Text>
502 {body}
503 <Text dimColor wrap="wrap">{names.length ? `Variables: ${names.join(', ')}` : 'Write {{name}} for a variable. \\{{ keeps literal braces.'}</Text>
504 </Box>
505 )
506 }
507
508 const selected = all.find(t => t.id === v.selected)
509 if (v.mode === 'vars' && selected) {
510 const names = parseVars(selected.body)
511 const setVar = (name: string, value: string) => setView($, cur => ({ vars: { ...cur.vars, [name]: value } }))
512 return (
513 <Box flexDirection="column" paddingX={1} gap={1}>
514 <Text bold>{selected.name}</Text>
515 <Text dimColor>Fill in the variables, then choose an action. Enter in the last field runs {LABEL[v.action ?? 'fill']}.</Text>
516 {names.map((name, i) => (
517 <Input key={`var:${name}`} label={name} value={v.vars[name] ?? ''} {...(i === 0 ? { autoFocus: true as const } : {})}
518 onInput={value => setVar(name, value)}
519 onSubmit={async (value, ev) => { await setVar(name, value); if (i === names.length - 1) await runVars($, v.action ?? 'fill', (ev as any)?.surface) }} />
520 ))}
521 <Box borderStyle="round" borderDimColor paddingX={1}>
522 <Text dimColor wrap="wrap">{renderTemplate(selected.body, v.vars).slice(0, 2000)}</Text>
523 </Box>
524 <Box flexDirection="row" gap={1} flexWrap="wrap">
525 {ACTIONS.map(a => <Button key={`vars-${a}`} label={LABEL[a]} variant={a === (v.action ?? 'fill') ? 'primary' : 'secondary'} onPress={press => runVars($, a, press.surface)} />)}
526 <Button key="vars-cancel" label="Cancel" onPress={() => setView($, { mode: 'list', selected: '', notice: '', error: '' })} />
527 </Box>
528 {messages}
529 </Box>
530 )
531 }
532
533 if (v.mode === 'io') {
534 const setPath = (ioPath: string) => setView($, { ioPath })
535 return (
536 <Box flexDirection="column" paddingX={1} gap={1}>
537 <Text bold>Import / Export</Text>
538 <Text dimColor wrap="wrap">Export writes all {all.length} templates to a JSON file. Import adds the templates of such a file; a template with the same name is replaced or skipped.</Text>
539 <Input key="io-path" label="File" value={v.ioPath} onInput={setPath} onSubmit={setPath} />
540 <Box flexDirection="row" gap={1} flexWrap="wrap">
541 <Button key="io-export" label="Export all" variant="primary" onPress={() => exportAll($)} />
542 <Button key="io-import-replace" label="Import, replace same names" onPress={() => importFile($, 'replace')} />
543 <Button key="io-import-skip" label="Import, skip same names" onPress={() => importFile($, 'skip')} />
544 <Button key="io-back" label="Back" onPress={() => setView($, { mode: 'list', notice: '', error: '' })} />
545 </Box>
546 {messages}
547 </Box>
548 )
549 }
550
551 const tags = tagsOf(all)
552 const shown = filterTemplates(all, v.query, tags.includes(v.tag) ? v.tag : '')
553 return (
554 <Box flexDirection="column" paddingX={1} gap={1}>
555 {/* The desktop draws an Input full width, so the buttons get a row of their own. */}
556 <Box flexDirection="row" gap={1}>
557 <Button key="new" label="New" variant="primary" onPress={() => openEditor($)} />
558 <Button key="io" label="Import/Export" onPress={() => openIo($)} />
559 </Box>
560 <Input key="search" placeholder="Search templates" value={v.query} onInput={query => setView($, { query })} onSubmit={query => setView($, { query })} />
561 {tags.length > 0 && (
562 <Box flexDirection="row" gap={1} flexWrap="wrap">
563 {tags.map(tag => <Button key={`tag:${tag}`} label={`#${tag}`} variant={v.tag === tag ? 'primary' : 'secondary'}
564 onPress={() => setView($, cur => ({ tag: cur.tag === tag ? '' : tag }))} />)}
565 </Box>
566 )}
567 {messages}
568 {all.length === 0 ? <Text dimColor wrap="wrap">No templates yet. Press New, or import a file.</Text>
569 : shown.length === 0 ? <Text dimColor>No template matches.</Text>
570 : shown.map(t => (
571 <Box key={`row:${t.id}`} flexDirection="column">
572 <Box flexDirection="row" gap={1}>
573 <Text bold>{t.name}</Text>
574 {t.tags.length > 0 ? <Text dimColor>{t.tags.map(x => `#${x}`).join(' ')}</Text> : null}
575 </Box>
576 <Text dimColor wrap="truncate-end">{firstLine(t.body) || '(empty)'}</Text>
577 {v.mode === 'delete' && v.selected === t.id ? (
578 <Box flexDirection="row" gap={1} flexWrap="wrap">
579 <Text color="red">{`Delete "${t.name}"?`}</Text>
580 <Button key="delete-confirm" label="Delete" variant="primary" onPress={() => deleteTemplate($, t.id)} />
581 <Button key="delete-cancel" label="Cancel" onPress={() => setView($, { mode: 'list', selected: '' })} />
582 </Box>
583 ) : (
584 <Box flexDirection="row" gap={1} flexWrap="wrap">
585 {ACTIONS.map(a => <Button key={`${a}:${t.id}`} label={LABEL[a]} variant={a === 'fill' ? 'primary' : 'secondary'} onPress={press => start($, t.id, a, press.surface)} />)}
586 <Button key={`edit:${t.id}`} label="Edit" onPress={() => openEditor($, t.id)} />
587 <Button key={`delete:${t.id}`} label="Delete" onPress={() => setView($, { mode: 'delete', selected: t.id, notice: '', error: '' })} />
588 </Box>
589 )}
590 </Box>
591 ))}
592 </Box>
593 )
594 } catch (error) {
595 return <Text color="red" wrap="wrap">{`Prompts pane failed: ${clean(error)}`}</Text>
596 }
597 })
598}
599hooks/editor.tsx 729 lines1import type { ClientModule } from 'claude-code'
2
3export type EditorProps = {
4 request: number; text: string; instance: string; seq: number; refresh: number; cursor: number;
5 busy: boolean; rows: number; ext: string; fit: number; win: number; wrap: boolean; top: number; span: number
6 mono?: boolean
7}
8type Snapshot = { text: string; cursor: number; anchor: number }
9type Find = { mode: 0 | 1 | 2; q: string; r: string; field: 0 | 1 }
10type State = Snapshot & {
11 request: number; instance: string; seq: number; refresh: number; limit: number; find: Find; findView: string;
12 left: number; py: number; view?: string; geo?: string; win: number; winProp: number; seen: number; undo: Snapshot[]; redo: Snapshot[]; drag: boolean; locked: number
13 clicks: { t: number; x: number; y: number; n: number } | null; unit: 'char' | 'word' | 'line'; base: [number, number]
14 edit: { kind: string; t: number; at: number } | null
15}
16
17const SLASHES = ['c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'java', 'js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'go', 'rs', 'swift', 'kt', 'php', 'scala', 'dart']
18const HASHES = ['py', 'sh', 'bash', 'zsh', 'ps1', 'psm1', 'psd1', 'yml', 'yaml', 'toml', 'rb', 'r', 'pl', 'conf', 'env', 'gitignore', 'dockerfile']
19function commentMarks(ext: string): [string, string] {
20 if (SLASHES.includes(ext)) return ['//', '']
21 if (HASHES.includes(ext)) return ['#', '']
22 if (['sql', 'lua', 'hs'].includes(ext)) return ['--', '']
23 if (['ini', 'cfg'].includes(ext)) return [';', '']
24 if (['css', 'scss'].includes(ext)) return ['/*', '*/']
25 if (['html', 'htm', 'xml', 'md', 'svg', 'vue'].includes(ext)) return ['<!--', '-->']
26 return ['#', '']
27}
28
29const WINDOW = 120, BUDGET = 70000
30export const PLAIN = 0, KW = 1, CTL = 2, STR = 3, COM = 4, NUM = 5, TYPE = 6, FN = 7, PROP = 8, HEAD = 9, CODE = 10
31const FAINT = '#4b5260', MATCH = '#3a3f4b', FOUND = '#515c6a'
32export const COLORS: (string | undefined)[] =[undefined, '#569cd6', '#c586c0', '#ce9178', '#6a9955', '#b5cea8', '#4ec9b0', '#dcdcaa', '#9cdcfe', '#569cd6', '#ce9178']
33
34type Lang = {
35 line?: string[]; block?: [string, string][]; quotes?: string; triple?: boolean
36 kw?: Set<string>; ctl?: Set<string>; types?: Set<string>
37 ci?: boolean; vars?: boolean; fn?: boolean; dash?: boolean; verbNoun?: boolean; hex?: boolean; at?: boolean; keyed?: ':' | '='
38}
39const words = (list: string) => new Set(list.split(' '))
40const CLIKE: Lang = { line: ['//'], block: [['/*', '*/']], quotes: '"\'`', fn: true,
41 kw: words('const let var function class interface enum extends implements new this super typeof instanceof in of as import from export default async await static public private protected readonly void null undefined true false delete keyof namespace declare abstract int long short byte char float double bool string struct fn mut impl trait use mod pub func package go defer chan using internal sealed override virtual nil self'),
42 ctl: words('if else for while do switch case break continue return throw try catch finally yield match goto select foreach'),
43 types: words('number boolean any unknown never symbol bigint Array Promise Record Map Set Date Error String Number Boolean Object') }
44const JSONL: Lang = { quotes: '"', keyed: ':', kw: words('true false null') }
45const PY: Lang = { line: ['#'], quotes: '"\'', triple: true, fn: true,
46 kw: words('def class import from as lambda None True False self cls in is not and or with pass global nonlocal async await del'),
47 ctl: words('if elif else for while break continue return raise try except finally yield assert match case') }
48const PS1: Lang = { line: ['#'], block: [['<#', '#>']], quotes: '"\'', vars: true, ci: true, dash: true, verbNoun: true,
49 kw: words('function param begin process end class using filter workflow in switch'),
50 ctl: words('if elseif else foreach for while do until break continue return throw try catch finally exit trap') }
51const SH: Lang = { line: ['#'], quotes: '"\'', vars: true,
52 kw: words('function export local readonly alias source in do done then fi esac declare'),
53 ctl: words('if elif else for while until case return exit break continue') }
54const YML: Lang = { line: ['#'], quotes: '"\'', keyed: ':', dash: true, kw: words('true false null yes no on off True False') }
55const TOML: Lang = { line: ['#', ';'], quotes: '"\'', keyed: '=', dash: true, kw: words('true false') }
56const SQL: Lang = { line: ['--'], block: [['/*', '*/']], quotes: '\'"', ci: true, fn: true,
57 kw: words('select from where and or not null insert into values update set delete create table alter drop index join left right inner outer on as group by order having limit distinct union all case when then else end is in like between exists primary key foreign references default int varchar text begin commit rollback') }
58const CSS: Lang = { block: [['/*', '*/']], quotes: '"\'', keyed: ':', dash: true, hex: true, at: true, kw: words('important') }
59const LANGS = new Map<string, Lang | 'md' | 'html'>()
60for (const e of 'js jsx mjs cjs ts tsx cs java go rs c cc cpp h hpp kt swift dart php scala'.split(' ')) LANGS.set(e, CLIKE)
61for (const [list, lang] of [['json jsonc', JSONL], ['py', PY], ['ps1 psm1 psd1', PS1], ['sh bash zsh', SH], ['yml yaml', YML], ['toml ini cfg conf env', TOML], ['sql', SQL], ['css scss', CSS],
62 ['md markdown', 'md'], ['html htm xml svg vue', 'html']] as const) for (const e of list.split(' ')) LANGS.set(e, lang)
63const FENCE_EXT: Record<string, string> = { typescript: 'ts', javascript: 'js', powershell: 'ps1', bash: 'sh', shell: 'sh', python: 'py', yaml: 'yml', csharp: 'cs', rust: 'rs', golang: 'go', 'c++': 'cpp', xml: 'html' }
64
65const IDENT = /[A-Za-z_$][\w$]*/y
66const IDENT_DASH = /[A-Za-z_$][\w$-]*/y
67const NUMBER = /0[xX][\da-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?[A-Za-z%]*/y
68const VAR = /\$\{?[\w:?]+\}?/y
69const HEX = /#[\da-fA-F]{3,8}\b/y
70
71function sticky(re: RegExp, text: string, at: number) {
72 re.lastIndex = at
73 return re.exec(text)
74}
75
76function scan(text: string, cfg: Lang, kinds: Uint8Array, from: number, to: number) {
77 const keyRe = cfg.keyed === '=' ? /^[ \t]*=(?!=)/ : /^[ \t]*:(?=\s|$)/
78 const strKeyRe = cfg.keyed === '=' ? /^\s*=/ : /^\s*:/
79 const ident = cfg.dash ? IDENT_DASH : IDENT
80 let i = from
81 outer: while (i < to) {
82 const c = text[i]!
83 for (const [open, close] of cfg.block ?? []) {
84 if (!text.startsWith(open, i)) continue
85 const end = text.indexOf(close, i + open.length)
86 const stop = end < 0 || end + close.length > to ? to : end + close.length
87 kinds.fill(COM, i, stop)
88 i = stop
89 continue outer
90 }
91 for (const marker of cfg.line ?? []) {
92 if (!text.startsWith(marker, i)) continue
93 const nl = text.indexOf('\n', i)
94 const stop = nl < 0 || nl > to ? to : nl
95 kinds.fill(COM, i, stop)
96 i = stop
97 continue outer
98 }
99 if (cfg.quotes?.includes(c)) {
100 const triple = !!cfg.triple && text.startsWith(c.repeat(3), i)
101 const quote = triple ? c.repeat(3) : c
102 let j = i + quote.length
103 while (j < to) {
104 if (text[j] === '\\') { j += 2; continue }
105 if (text.startsWith(quote, j)) { j += quote.length; break }
106 if (!triple && c !== '`' && text[j] === '\n') break
107 j++
108 }
109 j = Math.min(j, to)
110 kinds.fill(cfg.keyed && strKeyRe.test(text.slice(j, j + 40)) ? PROP : STR, i, j)
111 i = j
112 continue
113 }
114 const special = c === '$' && cfg.vars ? sticky(VAR, text, i) : c === '#' && cfg.hex ? sticky(HEX, text, i) : null
115 if (special) {
116 const stop = Math.min(i + special[0].length, to)
117 kinds.fill(c === '$' ? PROP : NUM, i, stop)
118 i = stop
119 continue
120 }
121 if (c === '@' && cfg.at) {
122 const m = sticky(IDENT_DASH, text, i + 1)
123 if (m) { const stop = Math.min(i + 1 + m[0].length, to); kinds.fill(KW, i, stop); i = stop; continue }
124 }
125 if (c >= '0' && c <= '9') {
126 const stop = Math.min(i + sticky(NUMBER, text, i)![0].length, to)
127 kinds.fill(NUM, i, stop)
128 i = stop
129 continue
130 }
131 const m = sticky(ident, text, i)
132 if (m) {
133 const stop = Math.min(i + m[0].length, to)
134 const word = cfg.ci ? m[0].toLowerCase() : m[0]
135 const rest = text.slice(stop, stop + 20)
136 const kind = cfg.ctl?.has(word) ? CTL : cfg.kw?.has(word) ? KW : cfg.types?.has(word) ? TYPE
137 : cfg.keyed && keyRe.test(rest) ? PROP
138 : cfg.verbNoun && /^[A-Za-z]+-[A-Za-z]+$/.test(m[0]) ? FN
139 : cfg.fn && rest[0] === '(' ? FN
140 : cfg.fn && /^[A-Z][a-z]/.test(m[0]) ? TYPE : PLAIN
141 if (kind) kinds.fill(kind, i, stop)
142 i = stop
143 continue
144 }
145 i++
146 }
147}
148
149function htmlScan(text: string, kinds: Uint8Array, base: number) {
150 let i = 0
151 while (i < text.length) {
152 const lt = text.indexOf('<', i)
153 if (lt < 0) break
154 if (text.startsWith('<!--', lt)) {
155 const end = text.indexOf('-->', lt + 4)
156 const stop = end < 0 ? text.length : end + 3
157 kinds.fill(COM, base + lt, base + stop)
158 i = stop
159 continue
160 }
161 const tag = /^<\/?[A-Za-z][\w:.-]*/.exec(text.slice(lt, lt + 80))
162 if (!tag) { i = lt + 1; continue }
163 kinds.fill(TYPE, base + lt + (tag[0][1] === '/' ? 2 : 1), base + lt + tag[0].length)
164 let j = lt + tag[0].length
165 while (j < text.length && text[j] !== '>') {
166 const c = text[j]!
167 if (c === '"' || c === '\'') {
168 const end = text.indexOf(c, j + 1)
169 const stop = end < 0 ? text.length : end + 1
170 kinds.fill(STR, base + j, base + stop)
171 j = stop
172 continue
173 }
174 const attr = /^[A-Za-z_:@][\w:.@-]*/.exec(text.slice(j, j + 60))
175 if (attr) { kinds.fill(PROP, base + j, base + j + attr[0].length); j += attr[0].length; continue }
176 j++
177 }
178 i = j + 1
179 }
180}
181
182function highlightRange(text: string, ext: string, kinds: Uint8Array, from: number, to: number) {
183 const lang = LANGS.get(FENCE_EXT[ext.toLowerCase()] ?? ext.toLowerCase())
184 if (lang === 'html') htmlScan(text.slice(from, to), kinds, from)
185 else if (lang && lang !== 'md') scan(text, lang, kinds, from, to)
186}
187
188function markdownScan(text: string, kinds: Uint8Array) {
189 let pos = 0
190 let fence: { marker: string; lang: string; body: number } | null = null
191 while (pos <= text.length) {
192 const nl = text.indexOf('\n', pos)
193 const end = nl < 0 ? text.length : nl
194 const line = text.slice(pos, end)
195 if (fence) {
196 if (line.trim().startsWith(fence.marker)) {
197 highlightRange(text, fence.lang, kinds, fence.body, pos)
198 kinds.fill(COM, pos, end)
199 fence = null
200 }
201 } else {
202 const open = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/.exec(line)
203 if (open) {
204 fence = { marker: open[1]!, lang: open[2] ?? '', body: Math.min(end + 1, text.length) }
205 kinds.fill(COM, pos, end)
206 } else if (/^#{1,6}\s/.test(line)) kinds.fill(HEAD, pos, end)
207 else if (/^\s*>/.test(line)) kinds.fill(COM, pos, end)
208 else {
209 const list = /^\s*(?:[-*+]|\d+[.)])\s/.exec(line)
210 if (list) kinds.fill(KW, pos, pos + list[0].length - 1)
211 for (const m of line.matchAll(/\[[^\]\n]*\]\([^)\n]*\)/g)) kinds.fill(FN, pos + m.index!, pos + m.index! + m[0].length)
212 for (const m of line.matchAll(/`[^`\n]+`/g)) kinds.fill(CODE, pos + m.index!, pos + m.index! + m[0].length)
213 }
214 }
215 if (nl < 0) break
216 pos = nl + 1
217 }
218 if (fence) highlightRange(text, fence.lang, kinds, fence.body, text.length)
219}
220
221let memo: { text: string; ext: string; kinds: Uint8Array } | undefined
222export function highlight(text: string, ext: string): Uint8Array | undefined {
223 const lang = LANGS.get(ext)
224 if (!lang) return undefined
225 if (memo && memo.text === text && memo.ext === ext) return memo.kinds
226 const kinds = new Uint8Array(text.length)
227 try {
228 if (lang === 'md') markdownScan(text, kinds)
229 else highlightRange(text, ext, kinds, 0, text.length)
230 } catch { kinds.fill(PLAIN) }
231 memo = { text, ext, kinds }
232 return kinds
233}
234
235// The bracket under or just before `at` and its partner; brackets inside strings and comments do not count.
236function matchBracket(text: string, at: number, kinds?: Uint8Array): [number, number] | undefined {
237 const skip = (i: number) => kinds !== undefined && (kinds[i] === STR || kinds[i] === COM)
238 for (const i of [at, at - 1]) {
239 const c = text[i]
240 if (i < 0 || !c || skip(i)) continue
241 const open = '([{'.indexOf(c)
242 const close = ')]}'.indexOf(c)
243 if (open < 0 && close < 0) continue
244 const dir = open >= 0 ? 1 : -1
245 const partner = open >= 0 ? ')]}'[open]! : '([{'[close]!
246 let depth = 0
247 for (let j = i; j >= 0 && j < text.length; j += dir) {
248 if (skip(j)) continue
249 if (text[j] === c) depth++
250 else if (text[j] === partner && --depth === 0) return [i, j]
251 }
252 }
253 return undefined
254}
255
256// Start offsets of every case-insensitive, non-overlapping occurrence of q.
257function findAll(text: string, q: string): number[] {
258 if (!q) return []
259 const hay = text.toLowerCase()
260 const needle = q.toLowerCase()
261 const folded = hay.length === text.length && needle.length === q.length
262 const h = folded ? hay : text
263 const n = folded ? needle : q
264 const out: number[] = []
265 for (let i = h.indexOf(n); i >= 0; i = h.indexOf(n, i + n.length)) out.push(i)
266 return out
267}
268
269const kindOf = (c: string) => c === '\n' ? 'n' : /[ \t]/.test(c) ? 's' : /[\p{L}\p{N}_\ud800-\udfff]/u.test(c) ? 'w' : 'p'
270
271function wordRange(text: string, at: number): [number, number] {
272 let i = at
273 if ((i >= text.length || text[i] === '\n') && i > 0 && text[i - 1] !== '\n') i--
274 if (i >= text.length || text[i] === '\n') return [at, at]
275 const kind = kindOf(text[i]!)
276 let from = i
277 let to = i + 1
278 while (from > 0 && kindOf(text[from - 1]!) === kind) from--
279 while (to < text.length && kindOf(text[to]!) === kind) to++
280 return [from, to]
281}
282
283const lineStart = (text: string, at: number) => at <= 0 ? 0 : text.lastIndexOf('\n', at - 1) + 1
284
285// One entry per drawn row. `limit` 0 means one row per line; otherwise rows break after the last space, else hard-cut.
286type VRow = { start: number; end: number; line: number; first: boolean; last: boolean }
287let layoutMemo: { text: string; limit: number; rows: VRow[] } | undefined
288export function layout(text: string, limit: number): VRow[] {
289 if (layoutMemo && layoutMemo.text === text && layoutMemo.limit === limit) return layoutMemo.rows
290 const rows: VRow[] = []
291 let start = 0
292 for (let line = 0; ; line++) {
293 const nl = text.indexOf('\n', start)
294 const stop = nl < 0 ? text.length : nl
295 for (let at = start; ;) {
296 let end = stop
297 if (limit && stop - at > limit) {
298 const space = text.lastIndexOf(' ', at + limit - 1)
299 end = space > at ? space + 1 : at + limit
300 if (space <= at && /[\ud800-\udbff]/.test(text[end - 1]!)) end--
301 }
302 rows.push({ start: at, end, line, first: at === start, last: end === stop })
303 if (end === stop) break
304 at = end
305 }
306 if (nl < 0) break
307 start = nl + 1
308 }
309 layoutMemo = { text, limit, rows }
310 return rows
311}
312
313function lineRange(text: string, at: number): [number, number] {
314 const from = lineStart(text, at)
315 const newline = text.indexOf('\n', at)
316 return [from, newline < 0 ? text.length : newline + 1]
317}
318
319// The frame timer outlives each render's closures, so it calls whichever tick the latest render set.
320const ticks = new WeakMap<object, () => void>()
321
322const Editor: ClientModule<EditorProps, State> = (props, surface) => {
323 const { Box, Text } = surface.elements
324 if (!surface.state) surface.every(60, () => ticks.get(surface)?.())
325 let s: State = surface.state?.request === props.request ? surface.state : { text: props.text, cursor: 0, anchor: 0, request: props.request,
326 instance: crypto.randomUUID(), seq: 0, refresh: props.refresh, limit: -1, find: { mode: 0, q: '', r: '', field: 0 }, findView: '',
327 left: 0, py: 0, win: props.win, winProp: props.win, seen: 0, undo: [], redo: [], drag: false, locked: 0, clicks: null, unit: 'char', base: [0, 0], edit: null }
328 if (surface.state !== s) surface.setState(s)
329 if (props.instance === s.instance && props.seq >= s.locked && !props.busy) s.locked = 0
330 if (props.refresh !== s.refresh) {
331 s.undo.push({ text: s.text, cursor: s.cursor, anchor: s.anchor })
332 s.text = props.text
333 s.cursor = Math.min(props.cursor, s.text.length)
334 s.anchor = s.cursor
335 s.refresh = props.refresh
336 s.edit = null
337 }
338 const height = props.rows
339 const gutter = Math.max(4, String(s.text.split('\n').length).length + 2)
340 const cells = Math.max(8, (surface.columns || 70) - gutter)
341 const width = Math.max(8, Math.floor(cells * props.fit))
342 const limit = props.wrap ? width - 1 : 0
343 const V = () => layout(s.text, limit)
344 // Rows are drawn rows, not lines: with wrap off the two are the same.
345 const point = (position: number) => {
346 const all = V()
347 let lo = 0
348 let hi = all.length - 1
349 while (lo < hi) {
350 const mid = (lo + hi + 1) >> 1
351 if (all[mid]!.start <= position) lo = mid; else hi = mid - 1
352 }
353 return { row: lo, col: position - all[lo]!.start }
354 }
355 const offset = (row: number, col: number) => {
356 const all = V()
357 const r = all[Math.max(0, Math.min(all.length - 1, row))]!
358 return r.start + Math.max(0, Math.min(r.end - r.start - (r.last ? 0 : 1), col))
359 }
360 const redraw = (reveal = true) => {
361 if (reveal) {
362 const p = point(s.cursor)
363 if (limit) s.left = 0
364 else if (p.col < s.left) s.left = p.col
365 else if (p.col >= s.left + width) s.left = p.col - width + 1
366 }
367 surface.setState({ ...s })
368 }
369 const send = (action = 'change') => {
370 const seq = ++s.seq
371 if (action !== 'change') s.locked = seq
372 surface.post({ request: s.request, instance: s.instance, seq, text: s.text, action, rows: props.rows,
373 from: Math.min(s.cursor, s.anchor), to: Math.max(s.cursor, s.anchor) })
374 redraw()
375 }
376 const snapshot = (): Snapshot => ({ text: s.text, cursor: s.cursor, anchor: s.anchor })
377 // A run of typing, or of deletes, at one caret is a single undo step.
378 const replace = (text: string, from = Math.min(s.cursor, s.anchor), to = Math.max(s.cursor, s.anchor), kind = 'other') => {
379 text = text.replace(/\r\n?/g, '\n')
380 if (s.text.length - (to - from) + text.length > 30000 || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(text)) return false
381 const now = Date.now()
382 const last = s.edit
383 const join = kind !== 'other' && last && last.kind === kind && now - last.t < 800 && last.at === s.cursor && s.cursor === s.anchor
384 if (!join) {
385 s.undo.push(snapshot())
386 if (s.undo.length > 100) s.undo.shift()
387 }
388 s.redo = []
389 s.text = s.text.slice(0, from) + text + s.text.slice(to)
390 s.cursor = from + text.length
391 s.anchor = s.cursor
392 s.edit = { kind, t: now, at: s.cursor }
393 send()
394 return true
395 }
396 const mapLines = (fn: (line: string) => string) => {
397 const from = Math.min(s.cursor, s.anchor)
398 const to = Math.max(s.cursor, s.anchor)
399 const bs = lineStart(s.text, from)
400 const newline = s.text.indexOf('\n', to > from && s.text[to - 1] === '\n' ? to - 1 : to)
401 const be = newline < 0 ? s.text.length : newline
402 const block = s.text.slice(bs, be)
403 const text = block.split('\n').map(fn).join('\n')
404 const ranged = s.cursor !== s.anchor
405 const caret = s.cursor
406 if (text === block || !replace(text, bs, be)) return
407 if (ranged) { s.anchor = bs; s.cursor = bs + text.length } else { s.cursor = s.anchor = Math.max(bs, caret + text.length - block.length) }
408 redraw()
409 }
410 const toggleComment = () => {
411 const [open, close] = commentMarks(props.ext)
412 const from = Math.min(s.cursor, s.anchor)
413 const to = Math.max(s.cursor, s.anchor)
414 const first = lineStart(s.text, from)
415 const last = s.text.indexOf('\n', to)
416 const filled = s.text.slice(first, last < 0 ? s.text.length : last).split('\n').filter(l => l.trim())
417 const commented = filled.length > 0 && filled.every(l => l.trimStart().startsWith(open))
418 mapLines(line => {
419 if (!line.trim()) return line
420 const indent = /^[ \t]*/.exec(line)![0]
421 const body = line.slice(indent.length)
422 if (commented) return indent + body.slice(open.length).replace(/^ /, '').replace(close ? new RegExp(` ?${close.replace(/[*]/g, '\\$&')}$`) : /$^/, '')
423 return indent + open + ' ' + body + (close ? ' ' + close : '')
424 })
425 }
426 const move = (next: number, select: boolean) => {
427 s.edit = null
428 s.cursor = Math.max(0, Math.min(s.text.length, next))
429 if (!select) s.anchor = s.cursor
430 redraw()
431 }
432 const previous = (at: number) => at > 1 && /[\udc00-\udfff]/.test(s.text[at - 1]!) ? at - 2 : at - 1
433 const following = (at: number) => /[\ud800-\udbff]/.test(s.text[at] ?? '') ? at + 2 : at + 1
434 const loose = (c: string | undefined) => c !== undefined && (kindOf(c) === 's' || kindOf(c) === 'p')
435 const wordRight = (at: number) => {
436 const t = s.text
437 if (at >= t.length) return at
438 if (t[at] === '\n') return at + 1
439 let i = at
440 while (loose(t[i])) i++
441 if (i >= t.length || t[i] === '\n') return i
442 while (i < t.length && kindOf(t[i]!) === 'w') i++
443 return i
444 }
445 const wordLeft = (at: number) => {
446 const t = s.text
447 if (at <= 0) return 0
448 if (t[at - 1] === '\n') return at - 1
449 let i = at
450 while (loose(t[i - 1])) i--
451 if (i === 0 || t[i - 1] === '\n') return i
452 while (i > 0 && kindOf(t[i - 1]!) === 'w') i--
453 return i
454 }
455 const step = (dir: 1 | -1, inclusive: boolean) => {
456 const all = findAll(s.text, s.find.q)
457 if (!all.length) return
458 const lo = Math.min(s.cursor, s.anchor)
459 const hi = Math.max(s.cursor, s.anchor)
460 const at = dir === 1 ? all.find(h => h >= (inclusive ? lo : hi)) ?? all[0]! : [...all].reverse().find(h => h < lo) ?? all[all.length - 1]!
461 s.anchor = at
462 s.cursor = at + s.find.q.length
463 s.edit = null
464 redraw()
465 }
466 const replaceCurrent = () => {
467 const lo = Math.min(s.cursor, s.anchor)
468 const hi = Math.max(s.cursor, s.anchor)
469 if (s.find.q && s.text.slice(lo, hi).toLowerCase() === s.find.q.toLowerCase() && !replace(s.find.r, lo, hi)) return
470 step(1, false)
471 }
472 const replaceAll = () => {
473 const all = findAll(s.text, s.find.q)
474 if (!all.length) return
475 let out = ''
476 let last = 0
477 for (const h of all) { out += s.text.slice(last, h) + s.find.r; last = h + s.find.q.length }
478 if (replace(out + s.text.slice(last), 0, s.text.length)) { s.cursor = s.anchor = all[0]!; redraw() }
479 }
480 // Escape returns the focus before it reaches a Client, so the bar closes with Ctrl+F or Ctrl+H again.
481 const toggleFind = (mode: 1 | 2) => {
482 const f = s.find
483 const picked = s.text.slice(Math.min(s.cursor, s.anchor), Math.max(s.cursor, s.anchor))
484 s.find = f.mode === mode ? { ...f, mode: 0 } : { ...f, mode, field: 0, q: picked && !picked.includes('\n') ? picked : f.q }
485 redraw(false)
486 }
487 const findKey = (e: { key: string; ctrl?: true; shift?: true; meta?: true }, key: string): boolean => {
488 const f = s.find
489 if (e.ctrl || e.meta) {
490 if (key === 'return') { if (!props.busy && !s.locked) replaceAll(); return true }
491 return false
492 }
493 const edited = (patch: Partial<Find>) => { s.find = { ...f, ...patch }; if (patch.q !== undefined) step(1, true); else redraw(false) }
494 if (key === 'return') {
495 if (e.shift) step(-1, false)
496 else if (f.mode === 2 && f.field === 1) { if (!props.busy && !s.locked) replaceCurrent() }
497 else step(1, false)
498 } else if (key === 'tab') { if (f.mode === 2) edited({ field: f.field ? 0 : 1 }) }
499 else if (key === 'backspace') edited(f.field ? { r: f.r.slice(0, -1) } : { q: f.q.slice(0, -1) })
500 else if ([...e.key].length === 1) edited(f.field ? { r: f.r + e.key } : { q: f.q + e.key })
501 else return false
502 return true
503 }
504 surface.onKey(e => {
505 const key = e.key.toLowerCase()
506 const p = point(s.cursor)
507 const selected = s.cursor !== s.anchor
508 if ((e.ctrl || e.meta) && (key === 'f' || key === 'h')) { toggleFind(key === 'f' ? 1 : 2); return }
509 if (s.find.mode && findKey(e, key)) return
510 if (e.ctrl || e.meta) {
511 if (key === 'a') { s.anchor = 0; s.cursor = s.text.length; redraw(); return }
512 if (key === 'home') { move(0, !!e.shift); return }
513 if (key === 'end') { move(s.text.length, !!e.shift); return }
514 if (key === 'left') { move(wordLeft(s.cursor), !!e.shift); return }
515 if (key === 'right') { move(wordRight(s.cursor), !!e.shift); return }
516 if (key === 'l') { [s.anchor, s.cursor] = lineRange(s.text, s.cursor); s.edit = null; redraw(); return }
517 if (props.busy || s.locked) return
518 if (key === 'backspace' || key === 'delete') {
519 if (selected) replace('')
520 else if (key === 'backspace') replace('', wordLeft(s.cursor), s.cursor)
521 else replace('', s.cursor, wordRight(s.cursor))
522 return
523 }
524 if (key === 'd') {
525 const from = Math.min(s.cursor, s.anchor)
526 const to = Math.max(s.cursor, s.anchor)
527 const start = lineStart(s.text, from)
528 const newline = s.text.indexOf('\n', to > from && s.text[to - 1] === '\n' ? to - 1 : to)
529 const stop = newline < 0 ? s.text.length : newline
530 const block = s.text.slice(start, stop)
531 const caret = s.cursor + block.length + 1
532 if (replace('\n' + block, stop, stop)) { s.cursor = s.anchor = caret; redraw() }
533 return
534 }
535 if (key === 'k') {
536 const [start] = lineRange(s.text, Math.min(s.cursor, s.anchor))
537 const [, end] = lineRange(s.text, Math.max(s.cursor, s.anchor))
538 const last = end >= s.text.length && s.text[end - 1] !== '\n'
539 replace('', last && start > 0 ? start - 1 : start, end)
540 return
541 }
542 if (key === '/' || key === '_') { toggleComment(); return }
543 if (key === 's') { send('save'); return }
544 if (key === 'c' || key === 'x') { if (selected) send(key === 'c' ? 'copy' : 'cut'); return }
545 if (key === 'v') { send('paste'); return }
546 if (key === 'z' || key === 'y') {
547 const redo = key === 'y' || !!e.shift
548 const next = (redo ? s.redo : s.undo).pop()
549 if (next) {
550 (redo ? s.undo : s.redo).push(snapshot())
551 Object.assign(s, next)
552 s.edit = null
553 send()
554 }
555 return
556 }
557 return
558 }
559 if (key === 'left') { move(selected && !e.shift ? Math.min(s.cursor, s.anchor) : previous(s.cursor), !!e.shift); return }
560 if (key === 'right') { move(selected && !e.shift ? Math.max(s.cursor, s.anchor) : following(s.cursor), !!e.shift); return }
561 if (key === 'up' || key === 'down' || key === 'pageup' || key === 'pagedown') {
562 const delta = key === 'up' ? -1 : key === 'down' ? 1 : key === 'pageup' ? -height : height
563 move(offset(p.row + delta, p.col), !!e.shift); return
564 }
565 if (key === 'home' || key === 'end') {
566 const r = V()[p.row]!
567 const indent = r.first ? /^[ \t]*/.exec(s.text.slice(r.start, r.end))![0].length : 0
568 move(offset(p.row, key === 'end' ? Infinity : p.col === indent ? 0 : indent), !!e.shift); return
569 }
570 if (props.busy || s.locked) return
571 if (key === 'return') {
572 const from = Math.min(s.cursor, s.anchor)
573 const before = s.text.slice(lineStart(s.text, from), from)
574 replace('\n' + /^[ \t]*/.exec(before)![0] + (/[{(\[]\s*$/.test(before) ? ' ' : ''))
575 }
576 else if (key === 'tab') {
577 if (e.shift) mapLines(line => line.replace(/^(?: {1,2}|\t)/, ''))
578 else if (selected) mapLines(line => line ? ' ' + line : line)
579 else replace(' ', undefined, undefined, 'type')
580 }
581 else if (key === 'backspace') {
582 if (selected) replace('')
583 else if (s.cursor > 0) replace('', previous(s.cursor), s.cursor, 'delete')
584 } else if (key === 'delete') {
585 if (selected) replace('')
586 else if (s.cursor < s.text.length) replace('', s.cursor, following(s.cursor), 'delete')
587 } else if ([...e.key].length === 1 || e.key.includes('\n')) replace(e.key, undefined, undefined, e.key === '\n' ? 'other' : 'type')
588 })
589 // Past the visible rows the caret keeps stepping toward the pointer, faster the further out it is.
590 ticks.set(surface, () => {
591 const bottom = props.top + props.span - 1
592 const gap = s.py > bottom ? s.py - bottom : s.py < props.top ? s.py - props.top : 0
593 if (!s.drag || !gap) return
594 const p = point(s.cursor)
595 move(offset(p.row + Math.sign(gap) * Math.min(8, 1 + (Math.abs(gap) >> 2)), p.col), true)
596 })
597 const span = (at: number): [number, number] => s.unit === 'word' ? wordRange(s.text, at) : s.unit === 'line' ? lineRange(s.text, at) : [at, at]
598 // The pointer column counts cells; the desktop draws props.fit characters per cell. The caret goes before the character under the cell centre.
599 const place = (e: { x: number; y: number }) => offset(e.y, s.left + Math.max(0, Math.round((e.x - gutter + 0.5) * props.fit - 0.5)))
600 surface.onPointer(e => {
601 if (e.type === 'down' && e.button === 'left') {
602 const at = place(e)
603 const now = Date.now()
604 const last = s.clicks
605 const n = last && now - last.t < 400 && last.y === e.y && Math.abs(last.x - e.x) <= 1 ? Math.min(3, last.n + 1) : 1
606 s.clicks = { t: now, x: e.x, y: e.y, n }
607 s.edit = null
608 s.unit = n === 3 ? 'line' : n === 2 ? 'word' : 'char'
609 if (s.unit === 'char') {
610 s.cursor = at
611 if (!e.shift) s.anchor = s.cursor
612 } else {
613 s.base = span(at)
614 s.anchor = s.base[0]
615 s.cursor = s.base[1]
616 }
617 s.drag = true
618 s.py = e.y
619 redraw()
620 } else if (e.type === 'move' && s.drag && e.button === 'left') {
621 s.py = e.y
622 const at = place({ x: e.x, y: Math.max(props.top, Math.min(props.top + props.span - 1, e.y)) })
623 if (s.unit === 'char') s.cursor = at
624 else {
625 const [from, to] = span(at)
626 if (at >= s.base[0]) { s.anchor = s.base[0]; s.cursor = to } else { s.anchor = s.base[1]; s.cursor = from }
627 }
628 redraw()
629 } else if (e.type === 'up' || e.type === 'leave') s.drag = false
630 })
631 const vrows = V()
632 const start = Math.min(s.cursor, s.anchor)
633 const end = Math.max(s.cursor, s.anchor)
634 const caret = point(s.cursor)
635 const caretLine = vrows[caret.row]!.line
636 const logicalCol = s.cursor - lineStart(s.text, s.cursor)
637 const kinds = highlight(s.text, props.ext)
638 const total = vrows.length
639 const view = `${caret.row}:${caretLine}:${logicalCol}:${s.cursor}:${s.anchor}:${total}`
640 if (s.view !== view) {
641 s.view = view
642 surface.post({ view: { request: s.request, row: caret.row, line: caretLine, col: logicalCol, selected: end - start, total, cursor: s.cursor, anchor: s.anchor } })
643 }
644 if (s.limit !== limit) { s.limit = limit; s.seen = -1 }
645 if (props.win !== s.winProp) { s.winProp = props.win; s.win = props.win }
646 if (s.cursor !== s.seen && (caret.row < s.win + 2 || caret.row >= s.win + WINDOW - 2)) s.win = caret.row - WINDOW / 2
647 s.seen = s.cursor
648 s.win = Math.max(0, Math.min(total - WINDOW, Math.floor(s.win)))
649 const pair = start === end ? matchBracket(s.text, s.cursor, kinds) : undefined
650 const hits = s.find.mode ? findAll(s.text, s.find.q) : []
651 const mask = hits.length ? new Uint8Array(s.text.length) : undefined
652 for (const h of hits) mask!.fill(1, h, h + s.find.q.length)
653 const shown = s.find.q.slice(0, 40)
654 const index = end - start === s.find.q.length ? hits.indexOf(start) : -1
655 const count = !s.find.q ? 'type to search' : hits.length ? `${index >= 0 ? index + 1 : '–'}/${hits.length}` : 'no results'
656 const status = !s.find.mode ? ''
657 : s.find.mode === 1 ? `Find: ${shown}▏ · ${count} · Enter next · Shift+Enter prev · Ctrl+F close`
658 : `Find: ${shown}${s.find.field ? '' : '▏'} → Replace: ${s.find.r.slice(0, 40)}${s.find.field ? '▏' : ''} · ${count} · Tab field · Enter replace · Ctrl+Enter all · Ctrl+H close`
659 if (s.findView !== status) {
660 s.findView = status
661 surface.post({ find: { request: s.request, text: status } })
662 }
663 let size = 0
664 const draw = (row: number) => {
665 const r = vrows[row]!
666 const line = s.text.slice(r.start, r.end)
667 const at = r.start
668 const visible = [...line.slice(s.left, s.left + cells * 2)]
669 let column = s.left
670 const lead = r.first ? / */.exec(line)![0].length : 0
671 const trail = r.last ? / *$/.exec(line)![0].length : 0
672 const spans: { text: string; cursor: boolean; selected: boolean; kind: number; tone: number; match: boolean; found: boolean }[] = []
673 for (const char of visible) {
674 const offset = at + column
675 const cursor = s.cursor === offset && row === caret.row
676 const selected = offset >= start && offset < end
677 const kind = kinds?.[offset] ?? PLAIN
678 const match = !!pair && (offset === pair[0] || offset === pair[1])
679 const found = mask?.[offset] === 1
680 const faint = char === '\t' || (char === ' ' && trail > 0 && column >= line.length - trail)
681 const guide = char === ' ' && lead < line.length && column % 2 === 0 && column + 2 <= lead
682 const tone = faint ? 2 : guide ? 1 : 0
683 column += char.length
684 const text = char === '\t' ? '→' : faint ? '·' : guide ? '│' : char
685 const last = spans[spans.length - 1]
686 if (last && last.cursor === cursor && last.selected === selected && last.kind === kind && last.tone === tone && last.match === match && last.found === found) last.text += text
687 else spans.push({ text, cursor, selected, kind, tone, match, found })
688 }
689 if (props.mono) {
690 // The pane draws this row's text, caret and selection as SVG under the Client; here only find and bracket marks.
691 const marks: { left: number; width: number; color: string }[] = []
692 let x = 0
693 for (const span of spans) {
694 const n = [...span.text].length
695 const color = span.found ? '#515c6aaa' : span.match ? '#3a3f4baa' : ''
696 const last = marks[marks.length - 1]
697 if (color && last?.color === color && last.left + last.width === x) last.width += n
698 else if (color) marks.push({ left: x, width: n, color })
699 x += n
700 }
701 size += 200 + marks.length * 120
702 return <Box key={`line:${row}`} flexShrink={0} height={1} overflow="hidden" flexDirection="row">
703 <Box width={gutter} flexShrink={0}><Text dimColor>{r.first ? `${String(r.line + 1).padStart(gutter - 1)} ` : ' '.repeat(gutter)}</Text></Box>
704 <Box key={`code:${row}`} flexGrow={1} height={1} overflow="hidden" position="relative">
705 {marks.map((m, n) => <Box key={`mark:${n}`} position="absolute" top={0} left={m.left} width={m.width} height={1} backgroundColor={m.color} />)}
706 </Box>
707 </Box>
708 }
709 size += 260 + spans.reduce((n, span) => n + span.text.length + 80, 0)
710 return <Box key={`line:${row}`} flexShrink={0} height={1} overflow="hidden" flexDirection="row" backgroundColor={r.line === caretLine ? '#151b23' : undefined}>
711 <Box width={gutter}><Text dimColor>{r.first ? `${String(r.line + 1).padStart(gutter - 1)} ` : ' '.repeat(gutter)}</Text></Box>
712 <Text wrap="truncate-end">{spans.map((span, n) => <Text key={`span:${n}`} inverse={span.cursor} color={span.tone ? FAINT : COLORS[span.kind]} bold={span.kind === HEAD || span.match} backgroundColor={span.selected ? '#264f78' : span.found ? FOUND : span.match ? MATCH : undefined}>{span.text}</Text>)}{caret.row === row && caret.col === line.length ? <Text inverse> </Text> : ''}</Text>
713 </Box>
714 }
715 const rows: unknown[] = []
716 let to = s.win
717 while (to < Math.min(total, s.win + WINDOW) && size < BUDGET) rows.push(draw(to++))
718 const geo = `${surface.columns}:${s.left}:${limit}`
719 if (props.mono && s.geo !== geo) { s.geo = geo; surface.post({ geo: { request: s.request, columns: surface.columns || 70, left: s.left, limit } }) }
720 // A see-through root still takes the pointer only with a background.
721 return <Box flexDirection="column" position="relative" backgroundColor={props.mono ? '#00000001' : undefined}>
722 {s.win > 0 ? <Box key="pad-top" height={s.win} flexShrink={0} /> : ''}
723 {rows}
724 {to < total ? <Box key="pad-bottom" height={total - to} flexShrink={0} /> : ''}
725 </Box>
726}
727
728export default Editor
729hooks/templates.ts 106 lines1import type { Template, TemplateDraft } from '../types'
2
3export const MAX_NAME = 80
4export const MAX_BODY = 30000
5export const MAX_TAGS = 10
6export const MAX_TAG = 30
7
8// `\{{` escapes a literal `{{`; the lookbehind keeps it out of the variable set.
9const VAR = /(?<!\\)\{\{([A-Za-z0-9_-]{1,40})\}\}/g
10const CONTROL = /[\x00-\x1f\x7f-\x9f]/
11
12export function parseVars(body: string): string[] {
13 return [...new Set([...body.matchAll(VAR)].map(m => m[1]!))]
14}
15
16export function renderTemplate(body: string, values: Record<string, string>): string {
17 return body.replace(VAR, (_, name: string) => values[name] ?? '').replace(/\\\{\{/g, '{{')
18}
19
20export function parseTags(text: string): string[] {
21 return [...new Set(text.split(',').map(t => t.trim().toLowerCase()).filter(Boolean))]
22}
23
24export function tagsOf(list: readonly Template[]): string[] {
25 return [...new Set(list.flatMap(t => t.tags))].sort()
26}
27
28const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true })
29
30export function filterTemplates(list: readonly Template[], query: string, tag = ''): Template[] {
31 const q = query.trim().toLowerCase()
32 return list
33 .filter(t => !tag || t.tags.includes(tag))
34 .filter(t => !q || t.name.toLowerCase().includes(q) || t.body.toLowerCase().includes(q) || t.tags.some(x => x.includes(q)))
35 .sort(byName)
36}
37
38export function validateTemplate(t: TemplateDraft): string | undefined {
39 const name = t.name.trim()
40 if (!name || name.length > MAX_NAME || CONTROL.test(name)) return `Name must be 1-${MAX_NAME} characters without control characters.`
41 if (t.body.length > MAX_BODY) return `Body exceeds ${MAX_BODY} characters.`
42 if (/[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(t.body)) return 'Body contains unsupported control characters.'
43 if (t.tags.length > MAX_TAGS) return `At most ${MAX_TAGS} tags.`
44 if (t.tags.some(x => !x || x.length > MAX_TAG || CONTROL.test(x) || x.includes(','))) return `Each tag must be 1-${MAX_TAG} characters without commas.`
45 return undefined
46}
47
48export function findByName(list: readonly Template[], name: string): Template | undefined {
49 const n = name.trim().toLowerCase()
50 if (!n) return undefined
51 const exact = list.find(t => t.name.toLowerCase() === n)
52 if (exact) return exact
53 const prefixed = list.filter(t => t.name.toLowerCase().startsWith(n))
54 return prefixed.length === 1 ? prefixed[0] : undefined
55}
56
57export function exportJson(list: readonly Template[]): string {
58 return JSON.stringify({ version: 1, templates: [...list].sort(byName).map(t => ({ name: t.name, tags: t.tags, body: t.body })) }, null, 2)
59}
60
61export function parseImport(text: string): { templates: TemplateDraft[] } | { error: string } {
62 let data: unknown
63 try { data = JSON.parse(text) } catch { return { error: 'File is not valid JSON.' } }
64 const items = Array.isArray(data) ? data : (data as { templates?: unknown } | null)?.templates
65 if (!Array.isArray(items)) return { error: 'File has no templates list.' }
66 const templates: TemplateDraft[] = []
67 for (const [i, item] of items.entries()) {
68 const it = item as { name?: unknown; tags?: unknown; body?: unknown } | null
69 if (!it || typeof it.name !== 'string' || typeof it.body !== 'string') return { error: `Entry ${i + 1}: name and body must be text.` }
70 if (it.tags !== undefined && (!Array.isArray(it.tags) || !it.tags.every(x => typeof x === 'string'))) return { error: `Entry ${i + 1}: tags must be a list of text.` }
71 const draft = { name: it.name.trim(), tags: parseTags(((it.tags as string[] | undefined) ?? []).join(',')), body: it.body.replace(/\r\n?/g, '\n') }
72 const error = validateTemplate(draft)
73 if (error) return { error: `Entry ${i + 1}: ${error}` }
74 templates.push(draft)
75 }
76 return { templates }
77}
78
79// The synced library file: the export format plus id and updatedAt, sorted by name so diffs stay small.
80export function libraryJson(list: readonly Template[]): string {
81 return JSON.stringify({ version: 1, templates: [...list].sort(byName).map(t => ({ id: t.id, name: t.name, tags: t.tags, body: t.body, updatedAt: t.updatedAt })) }, null, 2) + '\n'
82}
83
84export function parseLibrary(text: string): { templates: Template[] } | { error: string } {
85 const parsed = parseImport(text)
86 if ('error' in parsed) return parsed
87 const data = JSON.parse(text)
88 const items: any[] = Array.isArray(data) ? data : data.templates
89 return { templates: parsed.templates.map((d, i) => {
90 const it = items[i]
91 return { id: typeof it.id === 'string' && it.id ? it.id : `name:${d.name.toLowerCase()}`, ...d, updatedAt: Number.isFinite(it.updatedAt) ? it.updatedAt : 0 }
92 }) }
93}
94
95export function mergeImport(existing: readonly Template[], incoming: readonly TemplateDraft[], mode: 'replace' | 'skip', now: number, newId: () => string) {
96 const list = [...existing]
97 let added = 0, replaced = 0, skipped = 0
98 for (const d of incoming) {
99 const at = list.findIndex(t => t.name.toLowerCase() === d.name.toLowerCase())
100 if (at < 0) { list.push({ id: newId(), ...d, updatedAt: now }); added++ }
101 else if (mode === 'replace') { list[at] = { ...list[at]!, ...d, updatedAt: now }; replaced++ }
102 else skipped++
103 }
104 return { list, added, replaced, skipped }
105}
106types/index.d.ts 48 lines1export type Template = { id: string; name: string; tags: string[]; body: string; updatedAt: number }
2export type TemplateDraft = { name: string; tags: string[]; body: string }
3export type Action = 'fill' | 'insert' | 'send' | 'copy'
4export type Editor = {
5 id?: string
6 name: string
7 tags: string
8 savedName: string
9 savedTags: string
10 draft: string
11 saved: string
12 busy?: boolean
13 confirmBack?: boolean
14 request: number
15 editorInstance?: string
16 editorSeq?: number
17 editorRefresh?: number
18 editorCursor?: number
19 editorRow?: number
20 editorCol?: number
21 editorSelected?: number
22 editorWin?: number
23 editorLine?: number
24 editorTotal?: number
25 editorCaret?: number
26 editorAnchor?: number
27 editorWrap?: boolean
28}
29export type View = {
30 mode: 'list' | 'vars' | 'edit' | 'delete' | 'io'
31 query: string
32 tag: string
33 selected: string
34 action?: Action
35 vars: Record<string, string>
36 edit?: Editor
37 ioPath: string
38 notice: string
39 error: string
40 tick: number
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'prompt-templates': { view: View }
46 }
47}
48