SLOPSHOPPER

prompt-templates

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

newpanecommandtoaststatusprocess
★ 1v?MITupdated 2026-10-20IT-BAER/claude-forge/mods/prompt-templates
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-templates
│ ┃ Prompts ✕ › fix the failing auth test and add an audit log call │ ┃ [ New ] [ Import/Export ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Search templates ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No templates yet. Press New, or import a ⏺ Bash(bun test) │ ┃ file. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prompts │ ⎿ prompt-templates: Prompts pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompts
[ New ] [ Import/Export ] Search templates No templates yet. Press New, or import a file.
README

prompt-templates

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.

Install

Pick one way. All three need a new Claude Code session afterwards.

1. Plugin marketplace (recommended)

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

2. Copy the folder

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

3. Try it for one session

claude --plugin-dir path/to/claude-forge/mods/prompt-templates

Use it

CommandWhat it does
/promptsOpen 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.

Requirements

  • Claude Code 2.1.x with the mod API, in the desktop app (Code tab) or the terminal. Other surfaces show a short notice.
  • Windows for Ctrl+V in the editor, which reads the clipboard with powershell.exe. Everything else works on any system.

Features

The list

  • New and Import/Export sit at the top, above the search box.
  • The search box matches names, text and tags. Tag buttons filter the list; press a tag again to clear the filter.
  • Each template has four actions:
ActionWhat it does
FillReplaces the prompt box text with the template
InsertInserts the template at the cursor and keeps what you typed
SendSends the template as a prompt at once
CopyCopies the template to the clipboard
  • Edit and Delete sit next to them. Delete asks first.

Variables

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.

Editor

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 and export

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}}" }] }

Storage

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.

Limits

  • A template body holds up to 30,000 characters; a name up to 80; up to 10 tags of 30 characters each.
  • In the store, the whole library is limited to about 3.75 MiB. A library file has no such limit.
  • Copy needs a surface with clipboard access. Where there is none, the pane says so.
  • The pane needs Claude Code's mod API, which changes between releases. After an upgrade the mod can need an update.

License

MIT, see the repository root.

Source 4 files
hooks/register.tsx 599 lines
1import { 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 === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;')
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}
599
hooks/editor.tsx 729 lines
1import 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
729
hooks/templates.ts 106 lines
1import 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}
106
types/index.d.ts 48 lines
1export 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