Brewmod placeholders -- expands ++m, ++a, ++r, ++rr and +++ style placeholders into prompt context, with live highlighting and user-defined placeholders…

<img src="../assets/brewmods/placeholders.gif" width="800" alt="Claude Code terminal, real session: ++ opens the placeholder list, Tab inserts ++r, the caret on it shows the lines Claude receives, and /placeholders global add ++db saves your own after one question."><br><sub>Real Claude Code 2.1.301 screens, sped up where the title bar shows ⏩. <code>++</code>, <code>↑</code>, <code>⇥</code> insert <code>++r</code>; <code>⏎</code> sends; <code>/placeholders global add ++db: …</code> saves your own.</sub> <h1 align="center">Prompt placeholders</h1> <a href="https://github.com/kochetkov-ma/claude-brewcode/stargazers"><img src="https://img.shields.io/github/stars/kochetkov-ma/claude-brewcode?style=flat&logo=github" alt="GitHub stars"></a> <img src="https://img.shields.io/badge/Claude_Code-2.1.301%2B-d97706" alt="Requires Claude Code 2.1.301+"> <img src="https://img.shields.io/badge/type-mod-8b5cf6" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT">
[!IMPORTANT]
++m++a++r++rr+++do nothing? Since brewtools v6.8.0 these prompt-injection codewords live in this mod,brewmod-placeholders, not in brewtools. It is easier to use and lets you add your own placeholders. Install it (Claude Code 2.1.301+: runclaude updatefirst), then run/reload-plugins:claude plugin marketplace add https://github.com/kochetkov-ma/claude-brewcode && claude plugin install brewmod-placeholders@claude-brewcode
Requires Claude Code 2.1.301+: run claude update first.
claude plugin marketplace add https://github.com/kochetkov-ma/claude-brewcode && claude plugin install brewmod-placeholders@claude-brewcode
Then run /reload-plugins. Works on Team organizations.
Update: claude update first (Claude Code 2.1.301+), then claude plugin marketplace update claude-brewcode && claude plugin update brewmod-placeholders@claude-brewcode. Dev mode, no install: claude --plugin-dir ./brewmod-placeholders. Needs node on the PATH (the bundled command hook runs as plain Node). Mods use the hooks module API, which is early access.
| Want | Do |
|---|---|
| Add a block | ++r check the diff, then Enter. The block is sent as hidden context; your prompt stays as typed |
| Read a token's text while typing | Put the caret on it (arrows, Home, End). The band shows up to 15 rows of its text, cut row and plan note included; move the caret off to return to the compact band (details) |
| Print the whole text | /placeholders show ++r, or ++r? alone and Enter twice (how). /placeholders list prints all. Full help: /placeholders help, or /placeholders plus a question in your own words |
| Make your own | /placeholders add ++ship: …. With no scope word the one dialog also asks Where (Global recommended, or Project); start with global or project to skip that question |
you type ++r check the diff
Claude reads ++r check the diff <- your prompt, unchanged
+ hidden context (not shown in the transcript, not even with ctrl+o):
User typed `++r` — Two-phase review discipline is active for this turn:
[REVIEW DISCIPLINE: DOUBLE-CHECK]
Every significant change: multi-agent review ... (11 lines in all)
| Token | Normal mode | Plan mode |
|---|---|---|
++m | Manager mode is active: [ROLE: MANAGER], delegate, never code (29 lines) | Manager + Plan Mode is active: the same text + [ADDON: PLAN MODE]: stay read-only; the plan opens with the role and the full task graph, plus a deferred anti-drift cron (59 lines) |
++a | Architecture-first: delegate the design to expert agents before implementing | same text: ++a has no plan part |
++r | [REVIEW DISCIPLINE: DOUBLE-CHECK]: review, reproduce, fix confirmed findings only (11 lines) | same text: ++r has no plan part |
++rr | Anti-regression review: regression, project standard and functional correctness axes | same text: ++rr has no plan part |
+++ | nothing | [ADDON: ANTI-DRIFT CRON PLAN] |
++rr + ++r | only ++rr is sent (one review per message) | same |
Line counts are lines of the sent text, header line included: the numbers the band uses.
User typed `++r` — Two-phase review discipline is active for this turn:
[REVIEW DISCIPLINE: DOUBLE-CHECK]
Every significant change: multi-agent review (matching reviewer expert, else
general-purpose), simplification pass first.
1) Review - finding = defect with path:line + evidence (failing command/input).
2) Double-check - reproduce each finding.
3) Fix - confirmed findings only.
Big review -> split by area, never one long pass. Brief each: goal + area +
acceptance, what's covered (don't re-litigate), who consumes the verdict;
return verdict first, then findings.
User typed `++m` — Manager mode is active for this turn:
[ROLE: MANAGER]
Manage; never code/build/test/hand-debug: the main context is for orchestration.
Protocol: 1) TaskList; graph exists -> reuse it, else TaskCreate the full graph
before any work. 2) addBlockedBy/addBlocks only for real data-handoffs, else
parallel. 3) TaskUpdate owner, then launch. 4) Validate reports, integrate;
completed only when truly done; failure -> follow-up task + re-delegate. 5) all
code written -> final task: simplify, strip over-engineering.
No Task tools (CLAUDE_CODE_ENABLE_TODO_TOOLS=1 unset) -> same numbered graph
in the plan or .claude/features/<task>.md.
Sizing: one subagent = 1 deliverable, ~<=5 files, ~<=10 steps, ~<=20 min;
larger -> split. An hour-long agent = drift nobody can observe.
Scan all agents: best-matching expert per task, else general-purpose.
Spawn brief: six labeled fields. A subagent starts with an empty context, so a bare one-line task is never enough.
GOAL: task + why it exists - without the why it cannot rank the work.
ROLE: what it owns, must not touch - else two agents edit one file.
SCOPE: paths/commands in bounds and out - else the task widens.
CONTEXT: done, by whom, in parallel; only what this agent needs - else work is redone or overwritten.
CONSUMER: who or what uses the result next + the shape it must fit - else a correct result fails the next step.
DONE: acceptance, what counts as a finding, return (verdict first; evidence: command, result, unchecked; bounded; long output to a file it can write, path returned) - else it stops early or returns a dump.
Code/test briefs add: follow the closest well-built repo counterpart's
principles - additive to conventions/rules/docs, never instead.
Finish the whole job; decide without routine questions.
Plan mode: the first line becomes `User typed ++m — Manager + Plan Mode is active for this turn:`; the normal text stays, then a blank line and this addition:
[ADDON: PLAN MODE]
Stay read-only: no code, board/task-state writes, installation, configuration,
or cron creation. Read-only expert exploration is allowed.
Include in the plan (English, token economy):
- PREAMBLE: [ROLE: MANAGER] + the 5-step protocol restated verbatim as the
plan's opening section - this addon expires, so the plan carries the role
into implementation, no hook needed.
- STEP 0, stated literally: "Re-assume the MANAGER role. Call TaskList; if no
graph exists, create the ENTIRE TaskGraph now - TaskCreate for every node, or
the numbered checklist if task tools are absent - then delegate." On exit
this comes first: not code, not one task.
- The full TaskGraph: every task decomposed (subject, acceptance, owner
agent, bounded scope+acceptance per spawn), dependencies marked, parallel
branches explicit, critical path named.
Include the task-board execution step: announce and create one unique session
anti-drift cron per top-level task, hourly unless the user chooses another tick;
during execution record its task-specific methodology/prompt, timezone, ID,
tick count and stop rule. Scheduling unavailable -> explicit gap, never a
claimed schedule. Each tick: reread methodology/anti-drift rules, collect agent
updates, reconcile status/dependencies/goal, keep all unfinished + latest 10
completed graph entries, preserve older evidence outside the active graph.
The tick report is `HH:MM TZ · tick N · elapsed`, then only the nonempty rows:
🟢 completed, 🔵 active/remaining/next, 🔴 problems/blockers/questions,
⚪ anti-drift verdict. Completion/cancellation -> stop and verify cron removal.
Deliver the embedded role + graph + deferred cron step, not implementation code.
Your own placeholder can have a plan part too: see Examples.
| Hint on screen | Mac key | Glyph | Does |
|---|---|---|---|
tab | Tab | ⇥ | Inserts the selected typeahead name |
enter | Return | ⏎ | Sends the prompt. On ++r? alone it runs the query instead (how) |
esc | Esc | ⎋ | Closes the typeahead, back to typing |
↑ ↓ ← → | arrow keys | ↑↓←→ | Move in the typeahead (↑↓) or onto a token to read its text (←→) |
shift+tab | Shift + Tab | ⇧⇥ | Default mode (⏸ manual mode on): twice, accept edits then plan; or type /plan. The band does not change at the press, entering or leaving plan mode: it shows the mode of your last completed prompt, so plan mode adds: appears only after a prompt has run in plan mode. The next prompt is still sent with the new mode's text. The effort toast comes from the other mod, brewmod-plan-effort |
ctrl+o | Control + O | ⌃O | Transcript view: your prompt and the answer only. The injected text is NOT there; use the band or /placeholders show |
| Where you look | It shows | How |
|---|---|---|
| The band above the prompt | Up to 15 rows of what Claude receives, cut row and plan note included | Put the caret on the token (arrows, Home, End); move it off to close |
The chat, /placeholders show ++m | The whole text, printed locally; nothing is sent to Claude | Type it, or type ++m? alone and press Enter twice (below) |
The chat, /placeholders list | Every placeholder with its text, normal and plan mode | Type it |
What ? does. A ? right after a token, with nothing else in the message, is a query, not text. The first Enter fills /placeholders show ++m in the box and sends nothing to Claude. Claude Code prefixes the notice: Prompt dropped by a hook: ++m? = /placeholders show ++m · not sent to Claude · press Enter to print it. That is the expected result: nothing was sent. The second Enter prints the text in the chat. In a longer message (check ++r?), ++r? is ordinary text: the ++r block is sent and the ? stays.
While the name menu is open (typing ++r offers ++rr), it caps the band, and a ? right after the token closes it. If the whole text then fits in 15 rows, the cut row ends with · type ? after ++r to see all. ++m never shows that hint: its 29 lines do not fit in 15 rows. Otherwise the band looks the same with or without the ?: the caret already shows the text.
Band with the caret on ++m (default mode, 120x40). Colours come from your Claude Code theme.
++m · Claude receives these lines now:
User typed `++m` — Manager mode is active ...
... 12 more text rows (13 in all) ...
... 16 more lines · whole text: /placeholders show ++m or ++m? alone [Enter]
not sent now · plan mode adds 29 lines
close: move the caret off ++m · send as typed: ++/
| Looks like | Means | In the band above |
|---|---|---|
| Bold, placeholder blue (the theme's suggestion colour) | A placeholder name; an unknown one is bold yellow | ++m, ++zz |
| Bold, theme text colour (white on a dark theme, black on a light one) | Text Claude receives with this message | the User typed ... rows |
| Underlined, theme text colour | An action: what to type or do | /placeholders show ++m, ++m?, move the caret off ++m, ++/ |
| Plain, theme text colour | A key to press | [Enter] |
| Grey (dim) | Labels and notes; never sent. Text that is not sent now (the other mode's text) is grey too | close:, more:, ... 16 more lines, plan mode adds:, not sent now · plan mode adds 29 lines |
The text under the head holds up to 15 rows, the cut row and the plan note included; the head and foot rows (and an also: row) come on top. The text rows are wrapped to the terminal width, so how many fit changes with it. K more lines counts the lines of the sent text left out (its header line and blank line included): ++m is 29 lines, 13 text rows shown at 120x40 (13 + the cut row + the plan note = 15). plan mode adds N lines counts the lines of the plan part. In plan mode the band shows the plan header, the normal rows, a grey plan mode adds: and then [ADDON: PLAN MODE] with its rows. A plan-only token outside plan mode reads +++ · not sent now (plan only): with a grey text.
Hint rows, as drawn. A custom prefix replaces ++ in all of them.
| Row | Text |
|---|---|
| Cut row | ... 16 more lines · whole text: /placeholders show ++m or ++m? alone [Enter] |
| Cut row, narrow terminal or a build without the mod tools | ... 16 more lines · whole text: /placeholders show ++m |
Cut row, name menu open and ? would show it all | ... 5 more lines · type ? after ++r to see all |
| Foot with the caret on a token | close: move the caret off ++m · send as typed: ++/ |
| Foot with the caret on an unknown token | names: /placeholders list |
| Foot of the compact band | more: put the caret on ++r · send as typed: ++/ · help: /placeholders help |
| Foot of an escaped message | turn back on: delete ++/ |
| Other tokens while the caret is on one | also: ++a ++rr |
The or ++m? alone [Enter] part is the query from What ? does: [Enter] fills the show command. It appears only when the whole row fits the width. A narrower terminal shrinks the row in steps: first or ++m? alone [Enter] goes, then whole text:, then the count (... 16 more lines · ). The show command is never cut while it fits on its own. /placeholders and /placeholders help name the ++r? hand-off.
| Do | You type | What happens |
|---|---|---|
| Add | /placeholders add ++db: always use the staging DB | Claude drafts it and asks once: Save ++db as drafted? with Save as drafted (Recommended) or Change it (the question never names a layer; scope words count only outside the placeholder's own text); with no scope word, the same dialog also asks Where to save ++db? with Global (Recommended) or Project; starting with global or project skips it. Reply: saved ++db (global): ~/.claude/placeholders/db.md |
| Add with a plan part | /placeholders add ++ship: run the tests; in plan mode also list the release steps | The same dialog adds Add this plan-mode part to ++ship? (Add it (Recommended) or No plan part). Saved as ship.md (run the tests) and ship.plan.md (also list the release steps, plan: add) |
| Change | /placeholders make ++r shorter | Claude starts from the current text; the change stays in its layer (a plugin default -> asks where) |
| Remove | /placeholders remove ++db | Asks once: Remove it (Recommended) or Keep it; if the name is in both layers, it also asks where. Removing your override of a built-in brings the built-in back; plugin defaults are never removed (++r is built in and cannot be removed). Reply: removed ++db (global): ~/.claude/placeholders/db.md · ++db is no longer defined |
| Remove a plan part | /placeholders remove the plan part of ++ship | Asks Remove the plan-mode part of ++ship (global)?, then deletes ship.plan.md; ++ship keeps its normal text |
| Show | /placeholders show ++r ++m, or ++r? alone + Enter | Prints the exact text with no Claude turn: outside plan mode normal: then plan mode adds:; in plan mode one block normal + plan mode part: as sent. An inline placeholder that does not expand shows stays as typed; an empty body silences the token, in plan mode too. For ++r? see Read the full text |
| List | /placeholders list | Prints every placeholder with its text as sent, normal and plan mode, with no Claude turn |
| Escape | ++/ what does ++r do? | Sent exactly as typed: no block, ++/ stays in the text |
| Scope | /placeholders project add ++lint: run npm run lint first | Reply: saved ++lint (project): .claude/placeholders/lint.md; the project copy wins over global |
| Command | What it does |
|---|---|
/placeholders (also status) | One row per name with layer and tags (plan +, plan only), plus hints for reading texts (caret, /placeholders show), escape and scope |
/placeholders show ++<name> ..., list, help | Print the exact text sent (list = all, normal and plan), or the full help, with no Claude turn |
/placeholders <anything else> | Claude answers, or drafts, asks once and saves; any language. A first word global or project picks the layer; without it your words decide, else one question (Global first) |
| State | What you see |
|---|---|
| Highlight, typeahead | Terminal UI only. A known token is underlined; after the prefix, matching names appear, Tab inserts one. The one option, prefix (default ++, 2-8 punctuation characters), is set in /config |
| Compact band | Header placeholders 3 ( · plan mode is appended in plan mode), then one row per token: ++r <description> (name in bold blue, description grey), with · Claude receives these lines now: and the text under it when the text is drawn. Rows past the space fold into ... K more placeholders. Foot (actions underlined): more: put the caret on ++r · send as typed: ++/ · help: /placeholders help. plan + marks a name with a plan part; a skipped token says why, for example (not sent: ++rr wins) |
| Caret on a token | The band shown in Read the full text. A plan part that replaces the normal text reads plan mode sends N lines instead; other tokens sit on one also: ++m ++a row |
| Rows from other mods | Rows another mod draws above the prompt, for example the yellow plan-mode row of brewmod-plan-effort, stay visible above the band; the band fits into the rows that are left, in the default and the fullscreen view alike, whichever of the two mods loads first: head, text, cut row and foot all stay on screen |
| Escaped | ++/ in the message: header ++/ placeholders off: this message is sent exactly as typed and every token (not sent: ++/ in this message) (markers and names bold blue, reasons grey), foot turn back on: delete ++/ |
| Unknown | An unknown ++zz shows bold yellow with unknown placeholder grey; while the caret is on it the foot reads names: /placeholders list, otherwise the compact foot. On submit a toast names it and the prompt is sent unchanged |
| File | Means |
|---|---|
<name>.md in ~/.claude/placeholders/ (global) or .claude/placeholders/ (project, wins) | The text; the file name is the token name; front matter is optional |
<name>.plan.md with plan: add (front matter when: plan, plan: add) | Appended to <name>.md in plan mode only. Example: ship.md = run the tests, ship.plan.md = also list the release steps |
<name>.plan.md without plan: add | Replaces the text in plan mode. A <name>.md with when: plan alone is plan-only, like +++ |
Layers, highest wins file by file: project > its brewtools override > global > its brewtools override > plugin defaults. Keys and verification tables: definition files, verified.
++m, ++a, ++r, ++rr and +++. brewtools 6.7.x also injects them, so each block appears twice: use 6.8.0+. Override files under .claude/brewtools/manager/prompts/ and <config>/manager/prompts/ are read through the legacy key, whatever the brewtools version. To silence a token, give your override an empty body; it stays silent in plan mode too./placeholders requests hand off to Claude with list_placeholders, save_placeholder, remove_placeholder and placeholders_help. Saving refuses symlinks; removing never touches plugin defaults or legacy brewtools files. Each save or remove asks once through one dialog; no permission prompt follows.classic.* hooks, so a bundled command hook (hooks/inject.mjs) adds the blocks. Verified on Team-org and Max profiles; Enterprise was not run.Tab or a recalled history entry shows no highlight or band until the next keystroke. Drafts over 20,000 characters get no highlight and no band; expansion on submit still works. If plugin files are deleted under a running session (for example by a plugin update), the band and one toast say plugin files removed (updated?) · run /reload-plugins; tokens are not marked unknown and inline placeholders keep expanding. Context blocks return after /reload-plugins. Full technical detailsDocs | Project page | Source | All mods | Release notes | Author: Maksim Kochetkov | License: MIT
hooks/register.ts 320 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import { editorMatchers, getEditorEntries, setEditorEntries } from './lib/editor-state'
4import { caretFocus, highlight, isMenuOpen, sameFocus, sameView, suggest, type CaretFocus } from './lib/editor'
5import { escapeMarkers, isEscaped, markerOf, validatePrefix, type PlaceholderView } from './lib/match'
6import { memoCompile, type Compiled } from './lib/compiled'
7import { FILES_GONE, PluginFilesGone, configDirOf, issuesMessage, loadRegistry, newRegistryCache, toEditorEntries, type Registry } from './lib/defs'
8import { modeStatePath, planFromState } from './lib/mode'
9import { bandRows, bandState, budgets, renderBand, treeRows } from './lib/band'
10import { assembleDraft, planParts } from './lib/sent'
11import { NEEDS_NEW_CC, TOOL_PREFIX, handoffAck, handoffText, listTool, oneLayer, removeTool, saveTool, tools, type RemoveInput, type SaveInput, type ToolOut } from './lib/command'
12import { fillText, fullText, helpText, parseArgs, queryDrop, queryNames, shownDirs, statusText } from './lib/show'
13import { expandInline, isInlineSubmit, unknownMessage, unknownTokens, type SubmitLike } from './lib/inline'
14
15// Atoms are declared in this file only (the scan reads this file); the band reads the same view atom.
16const viewAtom = atom({ plugin: 'brewmod-placeholders', key: 'view' } as const, { tokens: [] })
17const focusAtom = atom({ plugin: 'brewmod-placeholders', key: 'focus' } as const, { name: null, isQuery: false, isMenuOpen: false })
18
19const REFRESH_MS = 1000
20
21// No guards around the mod's own logic: the engine skips a failing hook and the chain goes on (reference.md:78).
22// `$` is never passed on, so each registry load spells its `$.fs.*` lambdas at the call site.
23export const register: Register = (on, options) => {
24 const check = validatePrefix(options.prefix)
25 const prefix = check.prefix
26 const cache = newRegistryCache()
27 let hasTools = false // the model tools were registered at session.start
28 let isPlan = false // permission_mode of the last state file hooks/inject.mjs wrote for this session
29 let compiled: Compiled | null = null
30 let published: Registry | null = null
31 let shown: PlaceholderView | null = null // last view written to viewAtom
32 let focused: CaretFocus | null = null // last focus written to focusAtom
33 let refreshedAt = -REFRESH_MS // clock of the last typeahead registry refresh
34 let wasDraft: boolean | null = null // isDraft of the last hint-line draw
35 let isGone = false // the plugin's own placeholders/ folder vanished: the cached list stays, the band shows the reload line
36 let goneToasted = false
37
38 // A load that finds the plugin files gone resolves null (cache kept); a flip of that state redraws the band, the first sighting toasts.
39 const settle = async (load: Promise<Registry>, toast: (text: string) => void, redraw: () => void): Promise<Registry | null> => {
40 const was = isGone
41 let reg: Registry | null = null
42 try {
43 reg = await load
44 isGone = false
45 } catch (err) {
46 if (!(err instanceof PluginFilesGone)) throw err
47 isGone = true
48 }
49 if (isGone !== was) redraw()
50 if (isGone && !goneToasted) {
51 goneToasted = true
52 toast(FILES_GONE)
53 }
54 return reg
55 }
56
57 // Hands a freshly loaded registry to the editor/band names and the compiled matchers; both skip an unchanged registry.
58 const adopt = (reg: Registry): Compiled => {
59 if (published !== reg) {
60 published = reg
61 setEditorEntries(toEditorEntries(reg.defs))
62 }
63 compiled = memoCompile(compiled, reg.defs, prefix)
64 return compiled
65 }
66
67 // Command registration cannot fail the warm-up below, and the warm-up cannot block the session.
68 on('session.start', async ($, e, next) => {
69 await $.command
70 .register({ name: 'placeholders', description: 'Show, list or explain your placeholders; or say in any words what to add, change or remove', argumentHint: `[show ${prefix}<names> | list | help | any words]` })
71 .catch(() => undefined)
72 // Older builds have no $.tool (the call throws): hasTools stays false and the command answers with the status only.
73 try {
74 for (const t of tools(prefix)) await $.tool.register({ name: t.name, description: t.description, inputSchema: t.inputSchema, isDeferred: false })
75 hasTools = true
76 // A band drawn while the tools were registering lacks the `P<name>?` hand-off: draw it again.
77 $.ui.invalidate('ui.render')
78 } catch {
79 hasTools = false
80 }
81 if (check.warning !== null) $.ui.toast(check.warning)
82 const reg = await settle(
83 loadRegistry(
84 { exists: (p) => $.fs.exists(p), list: (p) => $.fs.list(p), read: (p) => $.fs.read(p) },
85 { pluginRoot: $.plugin.root, root: await $.session.root(), configDir: configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))) },
86 cache,
87 ),
88 (t) => $.ui.toast(t, { timeoutMs: 8000 }), () => $.ui.invalidate('ui.render'),
89 )
90 if (reg !== null) {
91 adopt(reg)
92 const issues = issuesMessage(reg)
93 if (issues !== null) $.ui.toast(issues)
94 }
95 const wasPlan = isPlan
96 try {
97 const file = modeStatePath($.plugin.root, await $.session.id())
98 if (file !== null) isPlan = planFromState(await $.fs.read(file)) ?? isPlan
99 } catch {
100 // no state file yet: not in plan mode
101 }
102 // The band reads a module variable, not an atom: a flipped mode redraws it now, not at the next key.
103 if (isPlan !== wasPlan) $.ui.invalidate('ui.render')
104 return next(e)
105 })
106
107 // The command hook records the mode at each prompt and tool batch; a finished turn has seen both. No I/O in prompt.edit, so the band reads the module variable.
108 on('turn.complete', async ($, e, next) => {
109 const wasPlan = isPlan
110 try {
111 const file = modeStatePath($.plugin.root, await $.session.id())
112 if (file !== null) isPlan = planFromState(await $.fs.read(file)) ?? isPlan
113 } catch {
114 // keep the last known mode
115 }
116 if (isPlan !== wasPlan) $.ui.invalidate('ui.render')
117 return next(e)
118 })
119
120 // Zero I/O except the view and focus atoms, each written only when it changes; caret moves arrive here too (inputText '').
121 on('prompt.edit', async ($, e, next) => {
122 const r = await next(e)
123 const h = highlight(editorMatchers(prefix), r.text)
124 const f = { ...caretFocus(h.view, r.text, r.cursor), isMenuOpen: isMenuOpen(prefix, r.text, r.cursor, getEditorEntries()) }
125 try {
126 if (shown === null || !sameView(h.view, shown)) {
127 await update($, viewAtom, () => h.view)
128 shown = h.view
129 }
130 if (focused === null || !sameFocus(f, focused)) {
131 await update($, focusAtom, () => f)
132 focused = f
133 }
134 } catch {
135 // state write failed: keep the highlight, retry on the next edit
136 }
137 return h.decorations.length === 0 ? r : { ...r, decorations: [...(r.decorations ?? []), ...h.decorations] }
138 })
139
140 // The hint line redraws when the box turns empty or non-empty (Esc Esc, submit, a recall into an empty box): draw the band again, it reads the box. A state write is refused while drawing.
141 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
142 const flipped = wasDraft !== null && wasDraft !== e.props.isDraft
143 wasDraft = e.props.isDraft
144 if (flipped) $.ui.invalidate('ui.render')
145 return next(e)
146 })
147
148 // A prefixed token re-reads the definitions at most once a second, so a file made by hand shows as known without a submit. A failed read keeps the rows.
149 // prompt.edit already drew this key's view with the old names: a changed list re-draws it here, so the band does not lag a key.
150 on('prompt.autocomplete', { token: /^[^\w\s]{2}/ }, async ($, e, next) => {
151 const r = await next(e)
152 try {
153 const now = await $.clock.now()
154 if (e.token.startsWith(prefix) && now - refreshedAt >= REFRESH_MS) {
155 refreshedAt = now
156 const reg = await settle(
157 loadRegistry(
158 { exists: (p) => $.fs.exists(p), list: (p) => $.fs.list(p), read: (p) => $.fs.read(p) },
159 { pluginRoot: $.plugin.root, root: await $.session.root(), configDir: configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))) },
160 cache,
161 ),
162 (t) => $.ui.toast(t, { timeoutMs: 8000 }), () => $.ui.invalidate('ui.render'),
163 )
164 const before = getEditorEntries()
165 if (reg !== null) adopt(reg)
166 const view = highlight(editorMatchers(prefix), e.text).view
167 if (getEditorEntries() !== before && (shown === null || !sameView(view, shown))) {
168 await update($, viewAtom, () => view)
169 shown = view
170 }
171 }
172 } catch {
173 // keep the entries we have: a failed refresh must not drop the typeahead rows
174 }
175 const rows = suggest(prefix, e.token, getEditorEntries(), /\s/.test(e.text.charAt(e.cursor)))
176 return rows.length === 0 ? r : { suggestions: [...r.suggestions, ...rows] }
177 })
178
179 // Inline defs are expanded in the submitted text; context defs go through hooks/inject.mjs. No mode here: plan/not-plan inline defs stay verbatim.
180 // The unknown-token toast fires here (a command hook has no UI): only for text the person typed, after the expansion.
181 on('prompt.submit', async ($, e, next) => {
182 const own: SubmitLike = e // isMeta is absent from the 2.1.296 types
183 const isTyped = own.origin.kind === 'composer' && own.isMeta === undefined
184 // The draft leaves the composer only on the person's own Enter: other origins keep the band.
185 if (isTyped) {
186 try {
187 await update($, viewAtom, () => ({ tokens: [] }))
188 shown = null
189 } catch {
190 // band stays until the next edit
191 }
192 }
193 // `P/` or `P\` anywhere: sent as typed, no expansion and no toast; inject.mjs skips it too.
194 if (isEscaped(prefix, e.text)) return next(e)
195 // Only `P<name>?` items, no attachments, a build with tools (the hand-off's gate; fill proven only there): dropped, the box gets `/placeholders show P<names> `.
196 // Not run in place: $.command.run passes "every hook but the calling one" (claude-code.d.ts); seen live: `prompt.submit skipped: re-entry` (hand-off).
197 const asked = isTyped && hasTools && e.command === undefined && (e.attachments?.length ?? 0) === 0 ? queryNames(editorMatchers(prefix), e.text) : null
198 if (asked !== null) {
199 $.clock.after(0, () => {
200 try {
201 void $.prompt.fill({ text: fillText(prefix, asked), mode: 'replace' }).catch(() => undefined)
202 } catch {
203 // a fill that throws at once: the drop notice stays, the box stays empty
204 }
205 })
206 return { drop: queryDrop(prefix, asked) }
207 }
208 if (!isInlineSubmit(e) || !e.text.includes(prefix)) return next(e)
209 const reg = await settle(
210 loadRegistry(
211 { exists: (p) => $.fs.exists(p), list: (p) => $.fs.list(p), read: (p) => $.fs.read(p) },
212 { pluginRoot: $.plugin.root, root: await $.session.root(), configDir: configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))) },
213 cache,
214 ),
215 (t) => $.ui.toast(t, { timeoutMs: 8000 }), () => $.ui.invalidate('ui.render'),
216 )
217 // Gone: inline defs expand from the cached list; no unknown toast, the list cannot be vouched for.
218 const c = reg === null ? compiled : adopt(reg)
219 if (c === null) return next(e)
220 const text = expandInline(c, e.text)
221 const bad = reg === null ? [] : unknownTokens(c.matchers, text ?? e.text)
222 if (bad.length > 0) $.ui.toast(unknownMessage(bad))
223 return next(text === null ? e : { ...e, text })
224 })
225
226 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227 // An emptied box (Esc Esc, submit) draws no band: the atoms are only written by edits. A surface without a box answers '' too, so only the terminal is asked.
228 const draft = e.surface === 'terminal' ? (await $.prompt.read()).text : null
229 const view = await read($, viewAtom)
230 const focus = await read($, focusAtom)
231 const mode = bandState({ hasSurvey: e.props.hasSurvey, draft, view, focus: focus.name })
232 if (mode === 'hidden') return next(e)
233 // The exact text per token, from the draft (terminal) or the token texts; before the first registry load the entries' bodies stand.
234 const sent = compiled === null ? undefined : assembleDraft(draft ?? view.tokens.map((t) => t.text).join(' '), compiled, prefix, isPlan).tokens
235 const defs = compiled?.defs
236 const plans = defs === undefined ? undefined : (name: string) => planParts(defs, name, prefix)
237 // Another mod's rows beneath sit above the band (kept on screen, as when that mod is outermost); the band gives up their rows.
238 const below = await next(e)
239 const ui = $.ui.resolve(e)
240 const band = renderBand(ui, bandRows(view, getEditorEntries(), sent, plans), {
241 mode,
242 focus,
243 prefix,
244 // No draft (desktop): the marker the last edit saw.
245 marker: (draft === null ? null : markerOf(compiled?.matchers ?? prefix, draft)) ?? view.escapedBy ?? escapeMarkers(prefix)[0],
246 isPlan,
247 isGone,
248 ...budgets(e.props.maxRows - treeRows(below), e.viewport?.isFullscreen === true, focus.isMenuOpen),
249 columns: e.props.bodyColumns,
250 hasQuery: hasTools,
251 })
252 return below.type === 'engine' ? band : ui.Box({ flexDirection: 'column', children: [below, band] })
253 })
254
255 on('command.run', { command: 'placeholders' }, async ($, e) => {
256 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
257 const roots = { pluginRoot: $.plugin.root, root: await $.session.root().catch(() => undefined), configDir: configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), home) }
258 const reg = await settle(loadRegistry({ exists: (p) => $.fs.exists(p), list: (p) => $.fs.list(p), read: (p) => $.fs.read(p) }, roots, cache), (t) => $.ui.toast(t, { timeoutMs: 8000 }), () => $.ui.invalidate('ui.render'))
259 if (reg === null) return { text: FILES_GONE }
260 adopt(reg)
261 // status, help, show and list answer here; the rest goes to Claude.
262 const act = parseArgs(e.args, prefix, reg.defs.map((d) => d.name))
263 const dirs = shownDirs(roots, home)
264 const one = oneLayer(reg, roots)
265 if (act.kind === 'help') return { text: helpText(prefix, dirs, hasTools) }
266 if (act.kind === 'list' || act.kind === 'show') return { text: fullText(one, act.kind === 'list' ? null : act.names, prefix, isPlan) }
267 const status = statusText(one, prefix, dirs, hasTools)
268 if (act.kind === 'status') return { text: status }
269 if (!hasTools) return { text: `${status}\n${NEEDS_NEW_CC}` }
270 // submit is refused inside command.run: hand off from a timer; fill is the fallback when submit refuses.
271 const text = handoffText(act.request, prefix, act.scope)
272 $.clock.after(250, () => {
273 void (async () => {
274 try {
275 await $.prompt.submit({ text, asUser: true })
276 } catch {
277 await $.prompt.fill({ text, mode: 'replace' }).catch(() => undefined)
278 }
279 })()
280 })
281 return { text: handoffAck(status) }
282 }).catch((_$, _e, next) => ({ text: `failed: ${next.error.message ?? next.error.kind}` }))
283
284 // The model's tools: answered here, so Claude Code asks no permission for the file write.
285 on('tool.call', { tool: /^mcp__brewmod-placeholders__/ }, async ($, e) => {
286 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
287 const roots = { pluginRoot: $.plugin.root, root: await $.session.root().catch(() => undefined), configDir: configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), home) }
288 if (e.tool === `${TOOL_PREFIX}placeholders_help`) {
289 const help = helpText(prefix, shownDirs(roots, home))
290 return { result: help, text: help }
291 }
292 const io = {
293 exists: (p: string) => $.fs.exists(p),
294 list: (p: string) => $.fs.list(p),
295 read: (p: string) => $.fs.read(p),
296 write: (p: string, t: string) => $.fs.write(p, t),
297 stat: (p: string) => $.fs.stat(p, { resolve: true }).catch(() => undefined),
298 remove: async (p: string) => {
299 const r = await $.process.run(['rm', '--', p])
300 return { exitCode: r.exitCode, stderr: r.stderr }
301 },
302 }
303 const input = e as SaveInput & RemoveInput
304 // Gone: nothing is written or removed, the model gets the reload hint.
305 const reg = await settle(loadRegistry(io, roots, cache), (t) => $.ui.toast(t, { timeoutMs: 8000 }), () => $.ui.invalidate('ui.render'))
306 if (reg === null) return { result: FILES_GONE, text: FILES_GONE, isError: true }
307 const out: ToolOut =
308 e.tool === `${TOOL_PREFIX}save_placeholder`
309 ? await saveTool(io, roots, input, prefix, home)
310 : e.tool === `${TOOL_PREFIX}remove_placeholder`
311 ? await removeTool(io, roots, input, prefix, home)
312 : await listTool(io, roots, reg, prefix)
313 if (out.registry !== null) adopt(out.registry)
314 return out.isError === true ? { result: out.text, text: out.text, isError: true } : { result: out.text, text: out.text }
315 }).catch((_$, _e, next) => {
316 const text = `failed: ${next.error.message ?? next.error.kind}`
317 return { result: text, text, isError: true }
318 })
319}
320hooks/lib/editor-state.ts 32 lines1// Module state shared by the editor hooks and the registry: names for highlight and typeahead.
2import { compileMatchers, type Matchers } from './match'
3import type { EditorEntry } from './editor'
4
5export const DEFAULT_ENTRIES: readonly EditorEntry[] = [
6 { name: 'm', description: 'Manager mode: orchestrate and delegate, never code in the main context' },
7 { name: 'a', description: 'Architecture-first: delegate the design to expert agents before implementing' },
8 { name: 'r', description: 'Two-phase review: review every significant change, then double-check each finding' },
9 { name: 'rr', description: 'Anti-regression review: regression, project standard and functional correctness axes' },
10 { name: '+', description: 'Anti-drift cron plan addon, active in Plan mode only', planOnly: true },
11]
12
13let entries: readonly EditorEntry[] = DEFAULT_ENTRIES
14let compiled: { entries: readonly EditorEntry[]; prefix: string; ms: Matchers } | null = null
15
16// Registry hook (session.start): replace the names the editor knows; pass a NEW array each time.
17export function setEditorEntries(next: readonly EditorEntry[]): void {
18 entries = next
19}
20
21export function getEditorEntries(): readonly EditorEntry[] {
22 return entries
23}
24
25// Matchers for the current entries; rebuilt only when the entries array or the prefix changes.
26export function editorMatchers(prefix: string): Matchers {
27 if (compiled === null || compiled.entries !== entries || compiled.prefix !== prefix) {
28 compiled = { entries, prefix, ms: compileMatchers(prefix, entries.map((e) => e.name)) }
29 }
30 return compiled.ms
31}
32hooks/lib/editor.ts 91 lines1// Pure editor UX: draft -> view + decorations, view diff, typeahead rows. Zero I/O, no engine state.
2import type { PromptAutocompleteSuggestion, PromptDecoration } from 'claude-code'
3import type { PlaceholderFocus as CaretFocus, PlaceholderView } from '../../types'
4import { scanDraft, type Matchers } from './match'
5
6export const MAX_DECORATED_CHARS = 20_000
7export const EMPTY_VIEW: PlaceholderView = { tokens: [] }
8
9export const KNOWN_COLOR = 'suggestion'
10export const UNKNOWN_COLOR = 'warning'
11export type EditorEntry = { name: string; description?: string; body?: string; planOnly?: boolean; isInline?: boolean }
12export type Highlight = { view: PlaceholderView; decorations: PromptDecoration[] }
13export type { CaretFocus }
14
15const GRAPHEME_TAIL = /[\p{M}\u200D\uFE00-\uFE0F]*/uy
16
17// A run ends on a grapheme edge: the engine moves an end inside a grapheme back to its start, so combining marks, ZWJ and variation selectors after the token join it.
18function graphemeEnd(text: string, end: number): number {
19 GRAPHEME_TAIL.lastIndex = end
20 const m = GRAPHEME_TAIL.exec(text)
21 return end + (m === null ? 0 : m[0].length)
22}
23
24// Known token -> KNOWN_COLOR + underline; unknown -> UNKNOWN_COLOR. Above the cap or without the prefix -> nothing.
25// Escaped draft: markers bold UNKNOWN_COLOR, every token dim and struck through (nothing in it is substituted); escapedBy = the first marker.
26export function highlight(ms: Matchers, text: string): Highlight {
27 if (text.length > MAX_DECORATED_CHARS || !text.includes(ms.prefix)) return { view: EMPTY_VIEW, decorations: [] }
28 const view = scanDraft(ms, text)
29 const marks = [...text.matchAll(ms.escape)]
30 const first = marks[0]
31 if (first !== undefined) {
32 const runs = [
33 ...view.tokens.map((t): PromptDecoration => ({ start: t.start, end: graphemeEnd(text, t.end), dimColor: true, strikethrough: true })),
34 ...marks.map((m): PromptDecoration => ({ start: m.index, end: graphemeEnd(text, m.index + m[0].length), color: UNKNOWN_COLOR, bold: true })),
35 ]
36 return { view: { ...view, isEscaped: true, escapedBy: first[0] }, decorations: runs.sort((a, b) => a.start - b.start) }
37 }
38 const decorations = view.tokens.map((t): PromptDecoration => {
39 const end = graphemeEnd(text, t.end)
40 return t.isKnown ? { start: t.start, end, color: KNOWN_COLOR, underline: true } : { start: t.start, end, color: UNKNOWN_COLOR }
41 })
42 return { view, decorations }
43}
44
45// Offsets are not compared: the band reads text/name/isKnown and the escape marker only; decorations are rebuilt on every edit.
46export function sameView(a: PlaceholderView, b: PlaceholderView): boolean {
47 if (a.tokens.length !== b.tokens.length || a.isEscaped !== b.isEscaped || a.escapedBy !== b.escapedBy) return false
48 return a.tokens.every((x, i) => {
49 const y = b.tokens[i]
50 return y !== undefined && x.text === y.text && x.name === y.name && x.isKnown === y.isKnown
51 })
52}
53
54// Token whose [start, end] holds the caret, or end+1 when `?` follows it (isQuery): the band's FOCUS trigger.
55export function caretFocus(view: PlaceholderView, text: string, cursor: number): Omit<CaretFocus, 'isMenuOpen'> {
56 for (const t of view.tokens) {
57 const isQuery = text.charAt(t.end) === '?'
58 if (cursor >= t.start && cursor <= t.end + (isQuery ? 1 : 0)) return { name: t.name, isQuery }
59 }
60 return { name: null, isQuery: false }
61}
62
63const SPACE = /\s/
64
65// Our typeahead has rows for the word before the caret; computed in prompt.edit, as prompt.autocomplete lands too late.
66// A backward walk, not /\S*$/: that regex is quadratic on a long word (a pasted blob).
67export function isMenuOpen(prefix: string, text: string, cursor: number, entries: readonly EditorEntry[]): boolean {
68 let i = cursor
69 while (i > 0 && !SPACE.test(text.charAt(i - 1))) i--
70 return suggest(prefix, text.slice(i, cursor), entries).length > 0
71}
72
73export const sameFocus = (a: CaretFocus, b: CaretFocus): boolean => a.name === b.name && a.isQuery === b.isQuery && a.isMenuOpen === b.isMenuOpen
74
75// Rows for the token at the cursor: names whose `P+name` extends it; an exact match and a repeated name give no row.
76// A space already after the caret -> rows drop their own trailing space, so taking one never leaves two.
77export function suggest(prefix: string, token: string, entries: readonly EditorEntry[], isSpaceAfter = false): PromptAutocompleteSuggestion[] {
78 if (!token.startsWith(prefix)) return []
79 const typed = token.toLowerCase()
80 const seen = new Set<string>()
81 const rows: PromptAutocompleteSuggestion[] = []
82 for (const entry of entries) {
83 const name = entry.name.toLowerCase()
84 const full = prefix + name
85 if (seen.has(name) || full === typed || !full.startsWith(typed)) continue
86 seen.add(name)
87 rows.push({ text: isSpaceAfter ? full : `${full} `, label: full, description: entry.description ?? name })
88 }
89 return rows
90}
91hooks/lib/match.ts 96 lines1// Pure placeholder matching: precompiled regexes, draft scan -> tokens. Zero I/O; bit-identical to brewtools for prefix `++`.
2
3export const DEFAULT_PREFIX = '++'
4
5export type PlaceholderToken = { text: string; name: string; isKnown: boolean; start: number; end: number }
6// Same shape as types/index.d.ts PlaceholderView: escapedBy = the first escape marker, set with isEscaped.
7export type PlaceholderView = { tokens: PlaceholderToken[]; isEscaped?: true; escapedBy?: string }
8export type PrefixCheck = { prefix: string; warning: string | null }
9export type NameKind = 'word' | 'symbol'
10
11export type Matcher = { name: string; kind: NameKind; scan: RegExp }
12export type Matchers = { prefix: string; byName: ReadonlyMap<string, Matcher>; unknown: RegExp; escape: RegExp }
13
14const PREFIX_FIX = '/config -> Placeholder prefix'
15const PREFIX_RE = /^[!-/:-@[-^`{-~]{2,8}$/
16const WORD_NAME = /^[A-Za-z0-9_]{1,32}$/
17const SYMBOL_NAME = /^[!-/:-@[-^`{-~]+$/
18
19// Invalid prefix -> `++` plus a warning; a leading `/` is allowed but warned (slash-command clash).
20export function validatePrefix(raw: unknown): PrefixCheck {
21 if (raw === undefined) return { prefix: DEFAULT_PREFIX, warning: null }
22 if (typeof raw !== 'string' || !PREFIX_RE.test(raw)) {
23 return { prefix: DEFAULT_PREFIX, warning: `prefix must be 2-8 ASCII punctuation characters (not _), using ${DEFAULT_PREFIX} · change: ${PREFIX_FIX}` }
24 }
25 return { prefix: raw, warning: raw.startsWith('/') ? `prefix starting with / clashes with slash commands · change: ${PREFIX_FIX}` : null }
26}
27
28export function nameKind(name: string): NameKind | null {
29 if (WORD_NAME.test(name)) return 'word'
30 if (SYMBOL_NAME.test(name)) return 'symbol'
31 return null
32}
33
34const escRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
35const escClass = (s: string): string => s.replace(/[\\\][^-]/g, '\\$&')
36// Class of what glues to a symbol token or a marker: letters, digits, marks, `_` and `chars`.
37const glue = (chars: string): string => `[\\p{L}\\p{N}\\p{M}_${escClass([...new Set(chars)].join(''))}]`
38
39function build(prefix: string, name: string, kind: NameKind): Matcher {
40 const token = escRe(prefix + name)
41 if (kind === 'word') {
42 const src = `(?<![\\w${escClass(prefix)}])${token}(?![\\w])`
43 return { name, kind, scan: new RegExp(src, 'gi') }
44 }
45 const cls = glue(prefix + name)
46 const src = `(?<!${cls})${token}(?!${cls})`
47 return { name, kind, scan: new RegExp(src, 'gu') }
48}
49
50// Names are keyed lowercase; a name that is neither word nor symbol is skipped.
51export function compileMatchers(prefix: string, names: Iterable<string>): Matchers {
52 const byName = new Map<string, Matcher>()
53 for (const raw of names) {
54 const name = raw.toLowerCase()
55 const kind = nameKind(name)
56 if (kind === null || byName.has(name)) continue
57 byName.set(name, build(prefix, name, kind))
58 }
59 const unknown = new RegExp(`(?<![\\w${escClass(prefix)}])${escRe(prefix)}(\\w+)`, 'g')
60 return { prefix, byName, unknown, escape: escapeScan(prefix) }
61}
62
63// `P/` and `P\`: either one anywhere in a message sends it as typed, every placeholder off.
64export const escapeMarkers = (prefix: string): [string, string] => [`${prefix}/`, `${prefix}\\`]
65
66// A marker glued on its left to a letter, digit, mark, `_` or a prefix char is none (`c++/java`, `+++/`).
67export const escapeScan = (prefix: string): RegExp => new RegExp(`(?<!${glue(prefix)})(?:${escapeMarkers(prefix).map(escRe).join('|')})`, 'gu')
68
69// First escape marker in a text, null = not escaped; compiled matchers reuse their regex (`match`, never `exec`: it is `g`).
70export function markerOf(src: Matchers | string, text: string): string | null {
71 const prefix = typeof src === 'string' ? src : src.prefix
72 if (!text.includes(prefix)) return null
73 return text.match(typeof src === 'string' ? escapeScan(prefix) : src.escape)?.[0] ?? null
74}
75
76export const isEscaped = (src: Matchers | string, text: string): boolean => markerOf(src, text) !== null
77
78// Draft -> known and unknown tokens sorted by start; unknown = standalone `P\w+` with an undefined name.
79export function scanDraft(ms: Matchers, text: string): PlaceholderView {
80 if (!text.includes(ms.prefix)) return { tokens: [] }
81 const tokens: PlaceholderToken[] = []
82 for (const m of ms.byName.values()) {
83 for (const hit of text.matchAll(m.scan)) {
84 const start = hit.index ?? 0
85 tokens.push({ text: hit[0], name: m.name, isKnown: true, start, end: start + hit[0].length })
86 }
87 }
88 for (const hit of text.matchAll(ms.unknown)) {
89 const name = (hit[1] ?? '').toLowerCase()
90 if (ms.byName.has(name)) continue
91 const start = hit.index ?? 0
92 tokens.push({ text: hit[0], name, isKnown: false, start, end: start + hit[0].length })
93 }
94 return { tokens: tokens.sort((a, b) => a.start - b.start) }
95}
96hooks/lib/compiled.ts 16 lines1// Compiled matchers for the editor, band and inline expansion; the context assembly itself lives in hooks/inject.mjs.
2
3import type { Def } from './defs'
4import { DEFAULT_PREFIX, compileMatchers, type Matchers } from './match'
5
6export type Compiled = { prefix: string; defs: readonly Def[]; matchers: Matchers }
7
8export function compileDefs(defs: readonly Def[], prefix = DEFAULT_PREFIX): Compiled {
9 return { prefix, defs, matchers: compileMatchers(prefix, defs.map((d) => d.name)) }
10}
11
12// Recompile only when the defs array (registry identity) or the prefix changed.
13export function memoCompile(prev: Compiled | null, defs: readonly Def[], prefix: string): Compiled {
14 return prev !== null && prev.defs === defs && prev.prefix === prefix ? prev : compileDefs(defs, prefix)
15}
16hooks/lib/defs.ts 389 lines1// Definition parsing (pure) + layered registry. File access only through an injected Io, never `$`.
2import type { EditorEntry } from './editor'
3import { nameKind } from './match'
4
5export type When = 'always' | 'plan' | 'not-plan'
6export type Insert = 'context' | 'inline'
7// A `when: plan` def's relation to its name's normal def; absent = replace.
8export type PlanKind = 'add' | 'replace'
9
10export type DefMeta = {
11 name?: string
12 description?: string
13 insert?: Insert
14 when?: When
15 order?: number
16 group?: string
17 priority?: number
18 header?: string
19 legacy?: string
20 plan?: PlanKind
21}
22
23// `skip` = the file cannot be read as a definition (front matter never closes): the loader drops it.
24export type Parsed = { meta: DefMeta; text: string; hasFrontMatter: boolean; issues: string[]; skip?: true }
25
26export type Def = {
27 name: string
28 body: string
29 insert: Insert
30 when: When
31 order: number
32 group?: string
33 priority: number
34 header: string
35 legacy?: string
36 description?: string
37 plan?: PlanKind
38}
39
40// Text inside all ``` or ~~~ fences, blank-line joined; no fence -> whole raw trimmed. 1:1 port of brewtools.
41export function extractFenced(raw: string): string {
42 const blocks: string[] = []
43 const re = /(?:```|~~~)[^\n]*\n([\s\S]*?)\n?(?:```|~~~)/g
44 let m: RegExpExecArray | null
45 while ((m = re.exec(raw)) !== null) {
46 blocks.push((m[1] ?? '').replace(/\n+$/, ''))
47 }
48 if (blocks.length === 0) return raw.trim()
49 return blocks.join('\n\n').trim()
50}
51
52type Split = { fm: string; body: string }
53
54const OPEN = /^(?:[ \t]*\r?\n)*---[ \t]*(?:\r?\n|$)/
55
56// Front matter = first non-blank line `---` (after an optional BOM) up to the next `---` line; no opening line -> null, no closing line -> 'unclosed'.
57function splitFrontMatter(raw: string): Split | 'unclosed' | null {
58 const src = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw
59 const open = OPEN.exec(src)
60 if (!open) return null
61 const start = open[0].length
62 let pos = start
63 while (pos <= src.length) {
64 const nl = src.indexOf('\n', pos)
65 const end = nl === -1 ? src.length : nl
66 if (/^---[ \t]*$/.test(src.slice(pos, end).replace(/\r$/, ''))) {
67 return { fm: src.slice(start, pos), body: nl === -1 ? '' : src.slice(nl + 1) }
68 }
69 if (nl === -1) break
70 pos = nl + 1
71 }
72 return 'unclosed'
73}
74
75function scalar(value: string): string {
76 const dq = /^"((?:[^"\\]|\\.)*)"/.exec(value)
77 if (dq) {
78 try {
79 return JSON.parse(dq[0]) as string
80 } catch {
81 return dq[1] ?? ''
82 }
83 }
84 const sq = /^'((?:[^']|'')*)'/.exec(value)
85 if (sq) return (sq[1] ?? '').replace(/''/g, "'")
86 return value.replace(/\s+#.*$/, '').trim()
87}
88
89const INT = /^-?\d+$/
90
91// Tolerant subset: `key: scalar` lines, "double"/'single'/bare scalars; unknown keys and bad values -> issues, never throws.
92export function parseFrontMatter(fm: string): { meta: DefMeta; issues: string[] } {
93 const meta: DefMeta = {}
94 const issues: string[] = []
95 fm.split('\n').forEach((rawLine, i) => {
96 const line = rawLine.replace(/\r$/, '')
97 if (line.trim() === '' || line.trimStart().startsWith('#')) return
98 const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*)$/.exec(line)
99 if (!kv) {
100 issues.push(`line ${i + 1}: not a key: value pair`)
101 return
102 }
103 const key = kv[1] ?? ''
104 const value = scalar(kv[2] ?? '')
105 switch (key) {
106 case 'name':
107 case 'description':
108 case 'group':
109 case 'header':
110 case 'legacy':
111 meta[key] = value
112 break
113 case 'insert':
114 if (value === 'context' || value === 'inline') meta.insert = value
115 else issues.push(`insert: '${value}' is not context|inline`)
116 break
117 case 'when':
118 if (value === 'always' || value === 'plan' || value === 'not-plan') meta.when = value
119 else issues.push(`when: '${value}' is not always|plan|not-plan`)
120 break
121 case 'plan':
122 if (value === 'add' || value === 'replace') meta.plan = value
123 else issues.push(`plan: '${value}' is not add|replace`)
124 break
125 case 'order':
126 case 'priority':
127 if (INT.test(value)) meta[key] = Number(value)
128 else issues.push(`${key}: '${value}' is not an integer`)
129 break
130 default:
131 issues.push(`unknown key '${key}'`)
132 }
133 })
134 return { meta, issues }
135}
136
137// Mod-native definition file: optional front matter, body through extractFenced (no front matter -> whole file).
138export function parseDefinition(raw: string): Parsed {
139 const split = splitFrontMatter(raw)
140 if (split === 'unclosed') return { meta: {}, text: '', hasFrontMatter: true, issues: ['front matter is not closed'], skip: true }
141 if (split === null) return { meta: {}, text: extractFenced(raw), hasFrontMatter: false, issues: [] }
142 const { meta, issues } = parseFrontMatter(split.fm)
143 return { meta, text: extractFenced(split.body), hasFrontMatter: true, issues }
144}
145
146// Brewtools override file: never front matter, the whole file is the fenced source.
147export function parseLegacy(raw: string): Parsed {
148 return { meta: {}, text: extractFenced(raw), hasFrontMatter: false, issues: [] }
149}
150
151// Per-field inheritance: parsed meta wins, then base, then built-ins. No name anywhere -> null.
152// `plan` is per file, never inherited: an old full-text override (or a legacy file) of an `add` part replaces.
153export function toDef(parsed: Parsed, base?: Partial<Def>): Def | null {
154 const m = parsed.meta
155 const name = m.name ?? base?.name
156 if (name === undefined || name === '') return null
157 const group = m.group ?? base?.group
158 const legacy = m.legacy ?? base?.legacy
159 const description = m.description ?? base?.description
160 return {
161 name,
162 body: parsed.text,
163 insert: m.insert ?? base?.insert ?? 'context',
164 when: m.when ?? base?.when ?? 'always',
165 order: m.order ?? base?.order ?? 100,
166 priority: m.priority ?? base?.priority ?? 0,
167 header: m.header ?? base?.header ?? '',
168 ...(group === undefined ? {} : { group }),
169 ...(legacy === undefined ? {} : { legacy }),
170 ...(description === undefined ? {} : { description }),
171 ...(m.plan === undefined ? {} : { plan: m.plan }),
172 }
173}
174
175// ---- Layers: lowest to highest priority; a layer file replaces the one below it, field by field.
176
177export type IoEntry = { name: string; kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number; isLink: boolean }
178export type Io = {
179 exists: (path: string) => Promise<boolean>
180 list: (path: string) => Promise<readonly IoEntry[]>
181 read: (path: string) => Promise<string>
182}
183
184export type LayerKind = 'native' | 'legacy'
185export type LayerSpec = { id: string; kind: LayerKind; dir: string }
186export type LayerFiles = { id: string; kind: LayerKind; files: ReadonlyMap<string, string> }
187export type Roots = { pluginRoot: string; root?: string; configDir?: string }
188export type Resolved = { defs: Def[]; issues: string[]; skipped: string[]; sources: Map<string, string> }
189export type Registry = { defs: readonly Def[]; issues: readonly string[]; skipped: readonly string[]; sources: ReadonlyMap<string, string> }
190
191const trimSep = (p: string): string => (p.length > 1 ? p.replace(/[\\/]+$/, '') : p)
192
193// Absolute POSIX paths get `.`/`..`/double-slash folding (path.resolve); anything else only loses trailing separators.
194export function normalizeDir(p: string): string {
195 if (!p.startsWith('/')) return trimSep(p)
196 const out: string[] = []
197 for (const seg of p.split('/')) {
198 if (seg === '' || seg === '.') continue
199 if (seg === '..') out.pop()
200 else out.push(seg)
201 }
202 return '/' + out.join('/')
203}
204
205// Port of brewtools configDir(): CLAUDE_CONFIG_DIR when non-empty, else <home>/.claude; no home -> undefined.
206export function configDirOf(configEnv: string | undefined, home: string | undefined): string | undefined {
207 if (configEnv !== undefined && configEnv !== '') return normalizeDir(configEnv)
208 if (home === undefined || home === '') return undefined
209 return `${trimSep(home)}/.claude`
210}
211
212// Plugin defaults, then per scope the brewtools legacy dir under the mod's own dir (global below project).
213export function layerSpecs(r: Roots): LayerSpec[] {
214 const specs: LayerSpec[] = [{ id: 'default', kind: 'native', dir: `${trimSep(r.pluginRoot)}/placeholders` }]
215 if (r.configDir !== undefined) {
216 const cfg = trimSep(r.configDir)
217 specs.push({ id: 'global-legacy', kind: 'legacy', dir: `${cfg}/manager/prompts` })
218 specs.push({ id: 'global', kind: 'native', dir: `${cfg}/placeholders` })
219 }
220 if (r.root !== undefined) {
221 const root = trimSep(r.root)
222 specs.push({ id: 'project-legacy', kind: 'legacy', dir: `${root}/.claude/brewtools/manager/prompts` })
223 specs.push({ id: 'project', kind: 'native', dir: `${root}/.claude/placeholders` })
224 }
225 return specs
226}
227
228// A native file without front matter and without a lower layer: `x.plan` -> name x, plan variant.
229function stemBase(stem: string): Partial<Def> {
230 return stem.endsWith('.plan') && stem.length > 5 ? { name: stem.slice(0, -5), when: 'plan' } : { name: stem }
231}
232
233// Layers low to high; a stem is a file name without `.md`, keyed lower-case. Legacy files map to the key whose def carries that `legacy`.
234// A file that cannot be a definition (unclosed front matter, no or unusable name) is skipped with an issue, never injected raw.
235export function resolveDefs(layers: readonly LayerFiles[]): Resolved {
236 const cur = new Map<string, Def>()
237 const sources = new Map<string, string>()
238 const issues: string[] = []
239 const skipped: string[] = []
240 const legacyStem = new Map<string, string>()
241 const put = (id: string, stem: string, key: string, parsed: Parsed, base: Partial<Def> | undefined): void => {
242 const where = `${id}/${stem}.md`
243 for (const i of parsed.issues) issues.push(`${where}: ${i}`)
244 const def = parsed.skip === true ? null : toDef(parsed, base)
245 if (def === null) {
246 if (parsed.skip !== true) issues.push(`${where}: no name`)
247 skipped.push(where)
248 return
249 }
250 if (nameKind(def.name.toLowerCase()) === null) {
251 issues.push(`${where}: name '${def.name}' is not a placeholder name (1-32 of A-Z a-z 0-9 _, or punctuation only)`)
252 skipped.push(where)
253 return
254 }
255 if (def.plan === 'add' && def.when !== 'plan') issues.push(`${where}: plan: add needs when: plan; sent as a normal text`)
256 cur.set(key, def)
257 sources.set(key, id)
258 if (def.legacy !== undefined) legacyStem.set(def.legacy, key)
259 }
260 for (const layer of layers) {
261 for (const [file, raw] of [...layer.files].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
262 if (layer.kind === 'native') {
263 const key = file.toLowerCase()
264 put(layer.id, file, key, parseDefinition(raw), cur.get(key) ?? stemBase(key))
265 continue
266 }
267 const stem = legacyStem.get(file)
268 if (stem !== undefined) put(layer.id, stem, stem, parseLegacy(raw), cur.get(stem))
269 }
270 }
271 return { defs: [...cur.values()], issues, skipped, sources }
272}
273
274// ---- mtime cache: a layer is re-read only when its listing (name, size, mtime) changed.
275
276type Cached = { sig: string; raw: string; error?: string }
277export type RegistryCache = {
278 files: Map<string, Map<string, Cached>>
279 layerSig: Map<string, string>
280 legacyNames: ReadonlySet<string>
281 registry: Registry | null
282}
283
284export const newRegistryCache = (): RegistryCache => ({ files: new Map(), layerSig: new Map(), legacyNames: new Set(), registry: null })
285
286const listSig = (es: readonly IoEntry[]): string =>
287 es.map((en) => `${en.name}:${en.size}:${en.mtimeMs}:${en.isLink ? 'L' : 'F'}`).sort().join('|')
288
289export const FILES_GONE = 'plugin files removed (updated?) · run /reload-plugins'
290
291// The plugin's own placeholders/ folder is missing or holds no .md file (an rm -rf mid-walk): its root was deleted or replaced.
292export class PluginFilesGone extends Error {
293 constructor() {
294 super(FILES_GONE)
295 }
296}
297
298// A listing failure, a missing or unreadable shipped default rejects and leaves the cache untouched (the caller fails open); an unreadable user file is skipped.
299export async function loadRegistry(io: Io, roots: Roots, cache: RegistryCache): Promise<Registry> {
300 const layers: LayerFiles[] = []
301 const nextFiles = new Map<string, Map<string, Cached>>()
302 const nextSig = new Map<string, string>()
303 const unreadable: string[] = []
304 let legacyNames = cache.legacyNames
305 let changed = cache.registry === null
306
307 for (const spec of layerSpecs(roots)) {
308 const listing = (await io.exists(spec.dir)) ? await io.list(spec.dir) : []
309 const entries = listing.filter((en) => {
310 if (en.kind === 'dir' || en.name.startsWith('.') || !en.name.endsWith('.md')) return false
311 return spec.kind === 'native' || legacyNames.has(en.name.slice(0, -3))
312 })
313 if (spec.id === 'default' && entries.length === 0) throw new PluginFilesGone()
314 const sig = listSig(entries)
315 const prev = cache.files.get(spec.id)
316 const kept = new Map<string, Cached>()
317 if (cache.layerSig.get(spec.id) !== sig) changed = true
318 for (const en of entries) {
319 const stem = en.name.slice(0, -3)
320 const fsig = `${en.size}:${en.mtimeMs}`
321 const hit = prev?.get(stem)
322 if (hit !== undefined && hit.sig === fsig && !en.isLink) {
323 kept.set(stem, hit)
324 continue
325 }
326 try {
327 const raw = await io.read(`${spec.dir}/${en.name}`)
328 if (hit?.error !== undefined || hit?.raw !== raw) changed = true
329 kept.set(stem, { sig: fsig, raw })
330 } catch (err) {
331 if (spec.id === 'default') throw err
332 const error = err instanceof Error ? err.message : String(err)
333 if (hit?.error !== error) changed = true
334 kept.set(stem, { sig: fsig, raw: '', error })
335 }
336 }
337 for (const [stem, c] of kept) if (c.error !== undefined) unreadable.push(`${spec.id}/${stem}.md: unreadable (${c.error})`)
338 nextFiles.set(spec.id, kept)
339 nextSig.set(spec.id, sig)
340 layers.push({ id: spec.id, kind: spec.kind, files: new Map([...kept].filter(([, v]) => v.error === undefined).map(([k, v]) => [k, v.raw])) })
341 if (spec.id === 'default' && changed) {
342 const names = new Set<string>()
343 for (const raw of kept.values()) {
344 const legacy = parseDefinition(raw.raw).meta.legacy
345 if (legacy !== undefined && legacy !== '') names.add(legacy)
346 }
347 legacyNames = names
348 }
349 }
350
351 if (!changed && cache.registry !== null) return cache.registry
352 const out = resolveDefs(layers)
353 const registry: Registry = {
354 defs: out.defs,
355 issues: [...out.issues, ...unreadable],
356 skipped: [...out.skipped, ...unreadable.map((u) => u.slice(0, u.indexOf(': ')))],
357 sources: out.sources,
358 }
359 cache.files = nextFiles
360 cache.layerSig = nextSig
361 cache.legacyNames = legacyNames
362 cache.registry = registry
363 return registry
364}
365
366const MAX_SHOWN = 5
367
368// One session toast text: issue count, the first issue and the skipped files; null = nothing to say.
369export function issuesMessage(reg: Pick<Registry, 'issues' | 'skipped'>): string | null {
370 if (reg.issues.length === 0) return null
371 const more = reg.skipped.length > MAX_SHOWN ? ` and ${reg.skipped.length - MAX_SHOWN} more` : ''
372 const skipped = reg.skipped.length === 0 ? '' : `; skipped ${reg.skipped.slice(0, MAX_SHOWN).join(', ')}${more}`
373 return `${reg.issues.length} definition issue(s), first: ${reg.issues[0]}${skipped} · all: /placeholders status`
374}
375
376// Editor/band names: one entry per name, first-seen order; the first non-plan def wins (a plan part in a lower layer never makes it plan only).
377export function toEditorEntries(defs: readonly Def[]): EditorEntry[] {
378 const pick = new Map<string, Def>()
379 for (const d of defs) {
380 const key = d.name.toLowerCase()
381 const cur = pick.get(key)
382 if (cur === undefined || (cur.when === 'plan' && d.when !== 'plan')) pick.set(key, d)
383 }
384 return [...pick.values()].map((d) => {
385 const base = { name: d.name, body: d.body, planOnly: d.when === 'plan', ...(d.insert === 'inline' ? { isInline: true } : {}) }
386 return d.description === undefined ? base : { ...base, description: d.description }
387 })
388}
389hooks/lib/mode.ts 19 lines1// Plan-mode state written by hooks/inject.mjs: <plugin root>/.state/mode-<session id>.json = {mode, ts}.
2
3const SESSION_ID = /^[A-Za-z0-9_-]+$/
4
5// Null = no valid path for this id (never read a file outside .state).
6export function modeStatePath(pluginRoot: string, sessionId: unknown): string | null {
7 return typeof sessionId === 'string' && SESSION_ID.test(sessionId) ? `${pluginRoot.replace(/[\\/]+$/, '')}/.state/mode-${sessionId}.json` : null
8}
9
10// True = plan, false = any other mode; null = unreadable or no string mode (the caller keeps its last value).
11export function planFromState(raw: string): boolean | null {
12 try {
13 const mode: unknown = (JSON.parse(raw) as { mode?: unknown } | null)?.mode
14 return typeof mode === 'string' ? mode === 'plan' : null
15 } catch {
16 return null
17 }
18}
19hooks/lib/band.tsx 487 lines1// Pure band rendering: view + registry entries + sent texts -> rows -> tree. Zero I/O; atoms and `$` stay in register.ts.
2import type { ElementTable, RenderElement } from 'claude-code'
3import { FILES_GONE } from './defs'
4import { KNOWN_COLOR, UNKNOWN_COLOR, type CaretFocus, type EditorEntry } from './editor'
5import type { PlaceholderView } from './match'
6import { PLAN_LABEL, REASON, escapedReason, type PlanParts, type Sent } from './sent'
7
8// body = exact text the token sends; reason = why it is not sent (plan-only has its own flag); plan = its plan-mode addon.
9export type BandRow = { text: string; name: string; isKnown: boolean; description: string; body: string; isPlanOnly: boolean; isInline?: boolean; reason?: string; plan?: RowPlan }
10// A plan-mode text beside the normal one: add = plan is the addition, replace = plan is the whole plan-mode text.
11export type RowPlan = { kind: 'add' | 'replace'; plan: string }
12export type BandMode = 'hidden' | 'escaped' | 'focus' | 'compact'
13// mode = bandState's pick; marker = the escape typed; budget = focus band and hover rows, autoBudget = compact rows, queryBudget = rows once `?` closes the menu; columns = bodyColumns.
14export type BandOptions = {
15 mode: Exclude<BandMode, 'hidden'>
16 focus: CaretFocus
17 prefix: string
18 marker: string
19 isPlan: boolean
20 isGone: boolean
21 budget: number
22 autoBudget: number
23 queryBudget: number
24 columns: number
25 // The `P<name>?` hand-off runs (the build has the mod tools): the cut row names it.
26 hasQuery: boolean
27}
28export type BandUi = Pick<ElementTable<'terminal'>, 'Box' | 'Text'>
29
30// Default TUI: maxRows = terminal height and the prompt box + footer take 8 rows more (45-row probe: 37 fit, 38 clip); +4 for a wrapped draft or a spinner.
31const BAND_RESERVE = 12
32const BAND_MAX = 30
33const BAND_MIN = 6
34const AUTO_MAX = 15
35const AUTO_SHARE = 0.4
36// Up to this many token rows show their bodies in the compact band; more get hover bodies.
37const AUTO_MAX_ROWS = 2
38// An open typeahead reserves rows under the box that maxRows does not subtract (80x24: 6 rows fit, 120x40: 14).
39const MENU_RESERVE = 6
40const MENU_MIN = 3
41export const FOCUS_MAX = 15
42
43// Focus band: fullscreen -> the engine's bottom-slot rows, else the terminal height less the reserve; always 6..30 (fullscreen up to maxRows).
44export const bandBudget = (maxRows: number, isFullscreen: boolean): number =>
45 isFullscreen ? Math.min(BAND_MAX, maxRows) : Math.max(BAND_MIN, Math.min(BAND_MAX, maxRows - BAND_RESERVE))
46
47// Compact band: min(15, 40% of the terminal rows), at least 6, never above the focus budget.
48export const autoBudget = (maxRows: number, isFullscreen: boolean): number =>
49 Math.min(bandBudget(maxRows, isFullscreen), isFullscreen ? AUTO_MAX : Math.max(BAND_MIN, Math.min(AUTO_MAX, Math.floor(maxRows * AUTO_SHARE))))
50
51// The budgets; outside fullscreen an open menu caps budget and autoBudget, else the band's top rows are pushed into scrollback.
52export function budgets(maxRows: number, isFullscreen: boolean, isMenuOpen: boolean): { budget: number; autoBudget: number; queryBudget: number } {
53 const cap = isMenuOpen && !isFullscreen ? Math.max(MENU_MIN, Math.floor(maxRows / 2) - MENU_RESERVE) : Number.POSITIVE_INFINITY
54 const open = bandBudget(maxRows, isFullscreen)
55 return { budget: Math.min(open, cap), autoBudget: Math.min(autoBudget(maxRows, isFullscreen), cap), queryBudget: open }
56}
57
58// Rows a tree drawn beneath takes: Text or string 1, column Box the sum, row Box its tallest child; core's own drawing 0.
59export function treeRows(node: unknown): number {
60 if (typeof node === 'string') return 1
61 if (node === null || typeof node !== 'object') return 0
62 const el = node as { type?: unknown; props?: { flexDirection?: unknown; display?: unknown }; children?: unknown[] }
63 if (el.type === 'engine' || el.props?.display === 'none') return 0
64 if (el.type !== 'Box') return 1
65 const rows = (el.children ?? []).map(treeRows)
66 return String(el.props?.flexDirection ?? '').startsWith('column') ? rows.reduce((a, b) => a + b, 0) : Math.max(0, ...rows)
67}
68
69// Precedence: hidden > escaped > focus > compact.
70export function bandState(p: { hasSurvey: boolean; draft: string | null; view: PlaceholderView; focus: string | null }): BandMode {
71 if (p.hasSurvey || p.draft === '') return 'hidden'
72 if (p.view.isEscaped === true) return 'escaped'
73 if (p.view.tokens.length === 0) return 'hidden'
74 return p.focus !== null && p.view.tokens.some((t) => t.name === p.focus) ? 'focus' : 'compact'
75}
76
77const ASCII = /^[\x20-\x7e]*$/
78// East Asian Wide/Fullwidth blocks, sorted and disjoint; escapes only, as editors NFC-fold a raw U+F900 to U+8C48.
79const WIDE = /[\p{Emoji_Presentation}\u1100-\u115F\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
80const ZERO = /[\p{M}\u200B-\u200D\uFE00-\uFE0F]/u
81const PICTO = /\p{Extended_Pictographic}/u
82const VS16 = '\uFE0F'
83
84// Terminal cells of a text: wide CJK and emoji 2, combining marks, ZWJ and VS16 0, the rest 1; VS16 turns a text-style pictograph (☺) to 2.
85export function cells(s: string): number {
86 if (ASCII.test(s)) return s.length
87 let n = 0
88 let prev = ''
89 for (const ch of s) {
90 if (ch === VS16 && PICTO.test(prev) && !WIDE.test(prev)) n += 1
91 n += ZERO.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1
92 prev = ch
93 }
94 return n
95}
96
97// One line as rows of at most `width` cells: greedy by words, a word wider than a row broken hard; each row is drawn truncate-end.
98export function wrapRows(line: string, width: number): string[] {
99 if (width < 1 || cells(line) <= width) return [line]
100 const rows: string[] = []
101 let cur = ''
102 let col = 0
103 for (const m of line.matchAll(/ +|[^ ]+/g)) {
104 const seg = m[0]
105 const w = cells(seg)
106 if (col + w <= width) {
107 cur += seg
108 col += w
109 continue
110 }
111 if (seg.startsWith(' ')) {
112 rows.push(cur.trimEnd())
113 cur = ''
114 col = 0
115 continue
116 }
117 if (col > 0) rows.push(cur.trimEnd())
118 cur = ''
119 col = 0
120 for (const ch of seg) {
121 const cw = cells(ch)
122 if (col + cw > width && col > 0) {
123 rows.push(cur)
124 cur = ''
125 col = 0
126 }
127 cur += ch
128 col += cw
129 }
130 }
131 // Trailing spaces past the width leave no blank row of their own.
132 if (cur.trimEnd() !== '' || rows.length === 0) rows.push(cur.trimEnd())
133 return rows
134}
135
136// Text cut to `room` cells with a trailing ellipsis; nothing when there is no room.
137export function clip(s: string, room: number): string {
138 if (cells(s) <= room) return s
139 if (room < 1) return ''
140 let out = ''
141 let used = 0
142 for (const ch of s) {
143 const w = cells(ch)
144 if (used + w > room - 1) break
145 out += ch
146 used += w
147 }
148 return `${out}…`
149}
150
151// Rows a body takes at `width`: its source lines as they are (the text exactly as sent), each wrapped.
152const rowsOf = (body: string, width: number): number => (body === '' ? 0 : body.split('\n').reduce((n, l) => n + wrapRows(l, width).length, 0))
153// Rows a drawn body needs: a plan note (outside plan mode) or a `sends instead` mark adds one; plan mode's `adds` mark takes the blank separator row.
154const needOf = (r: BandRow, isPlan: boolean, width: number): number => rowsOf(r.body, width) + (r.plan !== undefined && (!isPlan || r.plan.kind === 'replace') ? 1 : 0)
155
156// Rows per body, never more than `free` in all: a body needing less leaves its spare rows to the rest; each gets one while rows are left.
157export function shareRows(needs: readonly number[], free: number): number[] {
158 const out = needs.map(() => 0)
159 const order = needs.map((_, i) => i).sort((a, b) => (needs[a] ?? 0) - (needs[b] ?? 0))
160 let left = free
161 order.forEach((i, k) => {
162 const give = left <= 0 ? 0 : Math.min(needs[i] ?? 0, Math.max(1, Math.floor(left / (order.length - k))))
163 out[i] = give
164 left -= give
165 })
166 return out
167}
168
169// Body rows: sent now = bold theme text colour, else dim.
170const SENT_STYLE = { color: 'text', bold: true } as const
171const DIM_STYLE = { dimColor: true } as const
172const styleOf = (isSent: boolean) => (isSent ? SENT_STYLE : DIM_STYLE)
173const plural = (n: number, word: string): string => (n === 1 ? word : `${word}s`)
174const nMore = (n: number, word: string): string => `... ${n} more ${plural(n, word)}`
175
176// A row piece: plain = dim label, act = what to type or do, key = a key to press, tok = a placeholder name.
177type Part = string | { act: string } | { key: string } | { tok: string; isKnown: boolean }
178// Token names: placeholder colour (unknown: warning), bold; actions: theme text colour, underlined; keys: theme text colour.
179const tokStyle = (isKnown: boolean) => ({ color: isKnown ? KNOWN_COLOR : UNKNOWN_COLOR, bold: true }) as const
180const ACT_STYLE = { color: 'text', underline: true } as const
181const KEY_STYLE = { color: 'text' } as const
182type Style = typeof DIM_STYLE | typeof ACT_STYLE | typeof KEY_STYLE | ReturnType<typeof tokStyle>
183const pieceOf = (p: Part): [Style, string] =>
184 typeof p === 'string' ? [DIM_STYLE, p] : 'act' in p ? [ACT_STYLE, p.act] : 'key' in p ? [KEY_STYLE, p.key] : [tokStyle(p.isKnown), p.tok]
185const partsCells = (parts: readonly Part[]): number => cells(parts.map((p) => pieceOf(p)[1]).join(''))
186
187// One row of styled pieces, cut at the end as a whole.
188function partsRow(Text: BandUi['Text'], key: string, parts: readonly Part[]): RenderElement {
189 return (
190 <Text key={key} wrap="truncate-end">
191 {parts.map((p, i) => {
192 const [style, text] = pieceOf(p)
193 return (
194 <Text key={`${key}:${i}`} {...style}>
195 {text}
196 </Text>
197 )
198 })}
199 </Text>
200 )
201}
202
203// What a cut row offers: the show command (plus the `P<name>?` hand-off where it runs), or `?` while the menu caps the rows.
204type More = { kind: 'show'; token: string; hasQuery: boolean } | { kind: 'query'; token: string } | null
205
206// The cut row: the widest variant that fits `width`; the hand-off goes first, the show command is never cut while it fits alone.
207function cutParts(left: number, more: More, width: number): Part[] {
208 const lines = nMore(left, 'line')
209 if (more === null) return [lines]
210 if (more.kind === 'query') return [`${lines} · `, { act: `type ? after ${more.token}` }, ' to see all']
211 const show: Part = { act: `/placeholders show ${more.token}` }
212 const tiers: Part[][] = [
213 ...(more.hasQuery ? [[`${lines} · whole text: `, show, ' or ', { act: `${more.token}?` }, ' alone ', { key: '[Enter]' }]] : []),
214 [`${lines} · whole text: `, show],
215 [`${lines} · `, show],
216 ]
217 return tiers.find((t) => partsCells(t) <= width) ?? [show]
218}
219
220// Whole source lines of a body while their pre-wrapped rows fit `rows`; a cut adds one row counting the source lines left and what `more` offers.
221function bodyRows(Text: BandUi['Text'], body: string, rows: number, key: string, width: number, more: More, isSent: boolean): RenderElement[] {
222 const lines = body.split('\n').map((l) => wrapRows(l, width))
223 const total = lines.reduce((n, l) => n + l.length, 0)
224 const room = total > rows ? rows - 1 : total
225 let take = 0
226 let used = 0
227 for (const l of lines) {
228 if (used + l.length > room) break
229 used += l.length
230 take++
231 }
232 const out = lines.slice(0, take).flatMap((parts, i) =>
233 parts.map((p, j) => (
234 <Text key={`${key}:${i}:${j}`} {...styleOf(isSent)} wrap="truncate-end">
235 {p === '' ? ' ' : p}
236 </Text>
237 )),
238 )
239 if (used < total) out.push(partsRow(Text, `${key}:more`, cutParts(lines.length - take, more, width)))
240 return out
241}
242
243export const UNKNOWN_LABEL = 'unknown placeholder'
244export const PLAN_ONLY_LABEL = `(${PLAN_LABEL.only})`
245const GONE_REASON = 'not sent: plugin files removed'
246
247// One row per distinct token (case-insensitive, first occurrence); known rows take description and plan flag from the entry, the text from `sent` when given.
248export function bandRows(view: PlaceholderView, entries: readonly EditorEntry[], sent?: readonly Sent[], plans?: (name: string) => PlanParts): BandRow[] {
249 const byName = new Map<string, EditorEntry>()
250 for (const entry of entries) {
251 const name = entry.name.toLowerCase()
252 if (!byName.has(name)) byName.set(name, entry)
253 }
254 const bySent = new Map((sent ?? []).map((s) => [s.name, s]))
255 const seen = new Set<string>()
256 const rows: BandRow[] = []
257 for (const token of view.tokens) {
258 const id = token.text.toLowerCase()
259 if (seen.has(id)) continue
260 seen.add(id)
261 const entry = token.isKnown ? byName.get(token.name) : undefined
262 const s = token.isKnown ? bySent.get(token.name) : undefined
263 const reason = s?.reason ?? null
264 const pp = token.isKnown ? plans?.(token.name) : undefined
265 rows.push({
266 text: token.text,
267 name: token.name,
268 isKnown: token.isKnown,
269 description: token.isKnown ? (entry?.description ?? token.name) : UNKNOWN_LABEL,
270 body: s === undefined ? (entry?.body ?? '') : s.text,
271 isPlanOnly: entry?.planOnly === true,
272 ...(entry?.isInline === true ? { isInline: true } : {}),
273 ...(reason !== null && reason !== REASON.planOnly ? { reason } : {}),
274 ...(pp?.kind === 'add' || pp?.kind === 'replace' ? { plan: { kind: pp.kind, plan: pp.plan } } : {}),
275 })
276 }
277 return rows
278}
279
280// Every band string; P = the prefix, token = the first or focused token as typed.
281const showMore = (o: BandOptions, row: BandRow): More => ({ kind: 'show', token: o.prefix + row.name, hasQuery: o.hasQuery })
282const compactFoot = (token: string, prefix: string): Part[] => ['more: ', { act: `put the caret on ${token}` }, ' · send as typed: ', { act: `${prefix}/` }, ' · help: ', { act: '/placeholders help' }]
283const focusFoot = (token: string, prefix: string): Part[] => ['close: ', { act: `move the caret off ${token}` }, ' · send as typed: ', { act: `${prefix}/` }]
284const UNKNOWN_FOOT: Part[] = ['names: ', { act: '/placeholders list' }]
285const alsoRow = (rows: readonly BandRow[]): Part[] => ['also:', ...rows.flatMap((r): Part[] => [' ', { tok: r.text, isKnown: r.isKnown }])]
286const ESCAPED_HEAD = ' placeholders off: this message is sent exactly as typed'
287const escapedFoot = (marker: string): Part[] => ['turn back on: ', { act: `delete ${marker}` }]
288const PLAN_HEAD = ' · plan mode'
289const PLAN_PLUS = ` ${PLAN_LABEL.plus}`
290// The band knows plan mode only after a prompt (state file), so the note says when the text is sent.
291const planNote = (p: RowPlan): string => {
292 const n = p.plan.split('\n').length
293 const lines = `${n} ${plural(n, 'line')}`
294 return p.kind === 'add' ? `not sent now · plan mode adds ${lines}` : `not sent now · plan mode sends ${lines} instead`
295}
296
297// Body rows sit 2 cells in.
298const bodyWidth = (o: BandOptions): number => Math.max(o.columns - 2, 1)
299// Token rows that fit `budget` beside the band head, the foot and a `K more` row.
300const fitting = (rows: readonly BandRow[], budget: number): readonly BandRow[] => (rows.length + 2 <= budget ? rows : rows.slice(0, Math.max(budget - 3, 0)))
301// Rows left for bodies once the head, the foot, `shown` token rows and a `K more` row past them are drawn.
302const freeRows = (budget: number, shown: number, total: number): number => budget - 2 - shown - (shown < total ? 1 : 0)
303
304type Fit = { shown: readonly BandRow[]; alloc: ReadonlyMap<BandRow, number> }
305
306// Token rows that fit `budget` and the body rows each shown row gets.
307// Fewer free rows than bodies: the last token rows fold into `K more` (one stays), so the band never passes `budget`.
308function fitRows(rows: readonly BandRow[], budget: number, width: number, isPlan: boolean): Fit {
309 let shown = fitting(rows, budget)
310 while (shown.length > 1 && freeRows(budget, shown.length, rows.length) < shown.filter((r) => r.body !== '').length) shown = shown.slice(0, -1)
311 const bodies = shown.filter((r) => r.body !== '')
312 const needs = bodies.map((r) => needOf(r, isPlan, width))
313 const give = shareRows(needs, freeRows(budget, shown.length, rows.length))
314 return { shown, alloc: new Map(bodies.map((r, i) => [r, give[i] ?? 0])) }
315}
316
317// The body is what Claude receives with this message (a cut body too); else it is the other mode's text or not sent.
318export const isSentNow = (row: BandRow, isPlan: boolean): boolean => row.isKnown && row.body !== '' && !(row.isPlanOnly && !isPlan) && (row.reason === undefined || row.reason === REASON.cut)
319
320export const RECEIVES = 'Claude receives these lines now:'
321// Label over a drawn body; null = nothing drawn or not sent in either mode (the head keeps its reason tag).
322export function receivesLabel(row: BandRow, isPlan: boolean): string | null {
323 if (!row.isKnown || row.body === '') return null
324 if (row.isPlanOnly && !isPlan) return `not sent now (${PLAN_LABEL.only}):`
325 if (row.reason === REASON.notInPlan) return 'not sent in plan mode:'
326 if (row.reason === REASON.cut) return `Claude receives these lines now (${REASON.cut}):`
327 return row.reason === undefined ? RECEIVES : null
328}
329
330// A token's one line: the description gives way so the token and its labels stay whole.
331// label = receivesLabel of a drawn body: it replaces the plan-only/reason tags; isFocus also drops the description and the plan tag.
332function drawHead(ui: BandUi, row: BandRow, o: BandOptions, label: string | null = null, isFocus = false): RenderElement {
333 const { Box, Text } = ui
334 const tail = label === null ? '' : ` · ${label}`
335 const planLabel = label === null && row.isPlanOnly && !o.isPlan ? ` ${PLAN_ONLY_LABEL}` : ''
336 const reasonLabel = label !== null || row.reason === undefined ? '' : ` (${row.reason})`
337 const plus = row.plan === undefined || (isFocus && label !== null) ? '' : PLAN_PLUS
338 const description = isFocus && label !== null ? '' : clip(` ${row.description}`, o.columns - cells(row.text) - cells(plus) - cells(planLabel) - cells(reasonLabel) - cells(tail))
339 return (
340 <Box>
341 <Text {...tokStyle(row.isKnown)}>{row.text}</Text>
342 {description === '' ? null : (
343 <Text dimColor wrap="truncate-end">
344 {description}
345 </Text>
346 )}
347 {plus === '' ? null : <Text dimColor>{plus}</Text>}
348 {planLabel === '' ? null : <Text dimColor>{planLabel}</Text>}
349 {reasonLabel === '' ? null : <Text dimColor>{reasonLabel}</Text>}
350 {tail === '' ? null : (
351 <Text dimColor wrap="truncate-end">
352 {tail}
353 </Text>
354 )}
355 </Box>
356 )
357}
358
359function foot(ui: BandUi, o: BandOptions, parts: readonly Part[]): RenderElement {
360 return partsRow(ui.Text, 'hint', o.isGone ? [FILES_GONE] : parts)
361}
362
363// Gone: a context row cannot be sent (its hook is gone); an inline one still expands.
364const goneRow = (r: BandRow): BandRow => (r.isInline === true || !r.isKnown ? r : { ...r, body: '', reason: GONE_REASON })
365
366export function renderBand(ui: BandUi, all: readonly BandRow[], o: BandOptions): RenderElement {
367 if (o.mode === 'escaped') return renderEscaped(ui, all, o)
368 const rows = o.isGone ? all.map(goneRow) : all
369 const row = o.mode === 'focus' ? rows.find((r) => r.name === o.focus.name) : undefined
370 return row === undefined ? renderCompact(ui, rows, o) : renderFocus(ui, rows, row, o)
371}
372
373// The caret token's sent text (up to 15 rows), the other tokens on one row; no Button.
374// A cut names `?` only when the menu caps the rows and `?` would show the whole text; else the show command.
375function renderFocus(ui: BandUi, rows: readonly BandRow[], row: BandRow, o: BandOptions): RenderElement {
376 const { Box, Text } = ui
377 const others = rows.filter((r) => r !== row)
378 const roomOf = (budget: number): number => Math.max(0, Math.min(FOCUS_MAX, budget - 2 - (others.length > 0 ? 1 : 0)))
379 const room = roomOf(o.budget)
380 const need = needOf(row, o.isPlan, bodyWidth(o))
381 const more: More = o.focus.isMenuOpen && !o.focus.isQuery && need <= roomOf(o.queryBudget) ? { kind: 'query', token: row.text } : showMore(o, row)
382 return (
383 <Box key="band" flexDirection="column">
384 {tokenRow(ui, row, o, room, more, false, true)}
385 {others.length === 0 ? null : partsRow(Text, 'also', alsoRow(others))}
386 {foot(ui, o, row.isKnown ? focusFoot(row.text, o.prefix) : UNKNOWN_FOOT)}
387 </Box>
388 )
389}
390
391// A token's head and its body within `rows`; isHover = the body stays hidden until hovered (un-keyed Box: a keyed one is refused).
392// A drawn body gets the receives label on the head; isFocus = the label replaces the description.
393function tokenRow(ui: BandUi, row: BandRow, o: BandOptions, rows: number, more: More, isHover = false, isFocus = false): RenderElement {
394 const { Box, Text } = ui
395 const key = row.text.toLowerCase()
396 const body = row.body === '' || rows < 1 ? null : planBody(Text, row, rows, key, o, more)
397 return (
398 <Box key={`row:${key}`} flexDirection="column">
399 {drawHead(ui, row, o, body === null || isHover ? null : receivesLabel(row, o.isPlan), isFocus)}
400 {body === null ? null : isHover ? (
401 <Box marginLeft={2} flexDirection="column" display="none" hover={{ display: 'flex' }}>
402 {body}
403 </Box>
404 ) : (
405 <Box marginLeft={2} flexDirection="column">
406 {body}
407 </Box>
408 )}
409 </Box>
410 )
411}
412
413// A body within `rows`: plan mode marks where the plan-mode text starts (dim label row), outside a dim row says it exists.
414function planBody(Text: BandUi['Text'], row: BandRow, rows: number, key: string, o: BandOptions, more: More): RenderElement[] {
415 const p = row.plan
416 const width = bodyWidth(o)
417 const isSent = isSentNow(row, o.isPlan)
418 const whole = (n: number): RenderElement[] => bodyRows(Text, row.body, n, key, width, more, isSent)
419 if (p === undefined || rows < (o.isPlan ? 3 : 2)) return whole(rows)
420 if (!o.isPlan) {
421 const note = (
422 <Text key={`${key}:plan`} dimColor wrap="truncate-end">
423 {planNote(p)}
424 </Text>
425 )
426 return [...whole(rows - 1), note]
427 }
428 const mark = (label: string): RenderElement => (
429 <Text key={`${key}:plan`} dimColor wrap="truncate-end">
430 {label}
431 </Text>
432 )
433 if (p.kind === 'replace') return [mark(PLAN_LABEL.instead), ...whole(rows - 1)]
434 const sep = `\n\n${p.plan}`
435 if (!row.body.endsWith(sep)) return whole(rows)
436 const normal = row.body.slice(0, -sep.length)
437 const [a, b] = shareRows([rowsOf(normal, width), rowsOf(p.plan, width)], rows - 1)
438 return [...bodyRows(Text, normal, a ?? 1, `${key}:a`, width, null, isSent), mark(PLAN_LABEL.adds), ...bodyRows(Text, p.plan, b ?? 1, `${key}:b`, width, more, isSent)]
439}
440
441// `P/` in the message: nothing is substituted, so no bodies and no Button; one row per token says so.
442function renderEscaped(ui: BandUi, rows: readonly BandRow[], o: BandOptions): RenderElement {
443 const { Box, Text } = ui
444 const shown = fitting(rows, o.budget)
445 const reason = ` (${escapedReason(o.marker)})`
446 return (
447 <Box key="band" flexDirection="column">
448 <Box key="head">
449 <Text {...tokStyle(true)}>{o.marker}</Text>
450 <Text dimColor wrap="truncate-end">
451 {ESCAPED_HEAD}
452 </Text>
453 </Box>
454 {shown.map((r) => (
455 <Box key={`row:${r.text.toLowerCase()}`}>
456 <Text {...tokStyle(r.isKnown)}>{r.text}</Text>
457 <Text dimColor wrap="truncate-end">
458 {reason}
459 </Text>
460 </Box>
461 ))}
462 {rows.length > shown.length ? <Text dimColor>{nMore(rows.length - shown.length, 'placeholder')}</Text> : null}
463 {partsRow(Text, 'hint', escapedFoot(o.marker))}
464 </Box>
465 )
466}
467
468// Header `placeholders N`, one keyed row per token, `K more placeholders` past the budget, a foot hint.
469// 1-2 rows: bodies within the auto budget, a cut names the show command; 3+ rows: each body in a hidden hover Box.
470function renderCompact(ui: BandUi, rows: readonly BandRow[], o: BandOptions): RenderElement {
471 const { Box, Text } = ui
472 const view = rows.length <= AUTO_MAX_ROWS ? fitRows(rows, o.autoBudget, bodyWidth(o), o.isPlan) : null
473 const shown = view?.shown ?? fitting(rows, o.autoBudget)
474 const hoverRows = Math.max(freeRows(o.budget, shown.length, rows.length), 1)
475 const first = rows.find((r) => r.isKnown) ?? rows[0]
476 return (
477 <Box key="band" flexDirection="column">
478 <Text key="head" dimColor>
479 {`placeholders ${rows.length}${o.isPlan ? PLAN_HEAD : ''}`}
480 </Text>
481 {shown.map((row) => (view === null ? tokenRow(ui, row, o, hoverRows, showMore(o, row), true) : tokenRow(ui, row, o, view.alloc.get(row) ?? 0, showMore(o, row))))}
482 {rows.length > shown.length ? <Text dimColor>{nMore(rows.length - shown.length, 'placeholder')}</Text> : null}
483 {foot(ui, o, compactFoot(first?.text ?? `${o.prefix}name`, o.prefix))}
484 </Box>
485 )
486}
487hooks/lib/sent.ts 193 lines1// What a prompt sends, per token: TS port of `assemble` in hooks/inject.mjs plus a reason for every token not sent. Zero I/O.
2import type { Def } from './defs'
3import { compileDefs, type Compiled } from './compiled'
4import { expandInline, inlineBodies } from './inline'
5import { DEFAULT_PREFIX, markerOf, scanDraft } from './match'
6
7export const MAX_CHARS = 9000
8const JOINER = '\n\n---\n\n'
9const CUT_NOTE = '\n...[truncated]'
10// First `at` chars, one fewer when the last would be a lone high surrogate (inject.mjs cuts the same way).
11const cutAt = (text: string, at: number): string => {
12 const c = text.charCodeAt(at - 1)
13 return text.slice(0, c >= 0xd800 && c <= 0xdbff ? at - 1 : at)
14}
15// Subagent deliveries arrive as prompts too; only the leading tag marks them (inject.mjs skips them).
16const SYNTHETIC_PROMPT = /^\s*<(?:task-notification>|agent-message\s+from=)/
17
18export const REASON = {
19 planOnly: 'plan only',
20 notInPlan: 'not in plan mode',
21 empty: 'not sent: empty',
22 staysAsTyped: 'stays as typed',
23 overCap: `not sent: over ${MAX_CHARS} chars`,
24 cut: `cut at ${MAX_CHARS} chars`,
25 delivery: 'not sent: delivery tag',
26 unknown: 'unknown placeholder',
27} as const
28// Plan-mode part labels and tags of the band and `/placeholders show`.
29export const PLAN_LABEL = { adds: 'plan mode adds:', instead: 'plan mode sends instead:', onlyPart: 'plan mode only:', only: REASON.planOnly, plus: 'plan +' } as const
30const winsReason = (token: string): string => `not sent: ${token} wins`
31export const escapedReason = (marker: string): string => `not sent: ${marker} in this message`
32
33// text = exact text the token adds ('' = nothing; a mode mismatch carries the other mode's text).
34export type Sent = { token: string; name: string; kind: 'context' | 'inline' | 'none'; text: string; isSent: boolean; reason: string | null }
35// prompt = the text after inline expansion; context = the hook's additionalContext ('' = none); tokens = one per known draft name, draft order.
36export type Draft = { prompt: string; context: string; tokens: Sent[] }
37
38const unsent = (reason: string): Pick<Sent, 'text' | 'isSent' | 'reason'> => ({ text: '', isSent: false, reason })
39// The hook sends nothing for a delivery tag or a prompt without the prefix.
40const isSkipped = (prompt: string, prefix: string): boolean => SYNTHETIC_PROMPT.test(prompt) || !prompt.includes(prefix)
41
42const applies = (d: Def, plan: boolean): boolean => d.when === 'always' || (d.when === 'plan') === plan
43// Same name, both applicable: `plan` beats `always`/`not-plan`; equal rank -> first wins.
44const rank = (d: Def): number => (d.when === 'plan' ? 2 : 1)
45
46function blockOf(d: Def, prefix: string): { text: string } {
47 const head = d.header.split('{token}').join(prefix + d.name.toLowerCase())
48 return { text: head === '' ? d.body : `${head}\n\n${d.body}` }
49}
50
51function modeWinners(defs: readonly Def[], plan: boolean): Map<string, Def> {
52 const out = new Map<string, Def>()
53 for (const d of defs) {
54 if (d.insert !== 'context' || !applies(d, plan)) continue
55 const key = d.name.toLowerCase()
56 const cur = out.get(key)
57 if (cur === undefined || rank(d) > rank(cur)) out.set(key, d)
58 }
59 return out
60}
61
62const isAdd = (p: Def, base: Def | undefined): base is Def => p.when === 'plan' && p.plan === 'add' && base?.when === 'always'
63
64// `plan: add` part on its `always` def: plan header if any, normal body, '\n\n', addition. Empty normal body = silenced in both modes.
65const withPart = (base: Def, part: Def): Def =>
66 base.body === '' ? base : { ...base, header: part.header === '' ? base.header : part.header, body: part.body === '' ? base.body : `${base.body}\n\n${part.body}` }
67
68// What each name sends in this mode: in plan mode an `add` winner is joined to the normal def, else the mode winner as is.
69function winners(defs: readonly Def[], plan: boolean): Map<string, Def> {
70 const out = modeWinners(defs, plan)
71 if (!plan) return out
72 const normal = modeWinners(defs, false)
73 for (const [key, d] of out) {
74 const base = normal.get(key)
75 if (isAdd(d, base)) out.set(key, withPart(base, d))
76 }
77 return out
78}
79
80// The caller's compiled defs (register.ts memoises them); a bare defs array is compiled per call.
81export type DefsLike = Compiled | readonly Def[]
82const isCompiled = (src: DefsLike): src is Compiled => !Array.isArray(src)
83const compiledOf = (src: DefsLike, prefix: string): Compiled => (isCompiled(src) ? (src.prefix === prefix ? src : compileDefs(src.defs, prefix)) : compileDefs(src, prefix))
84
85// inject.mjs order: mode winners that hit the prompt, then the top priority per group, then `order`. lost = group loser name -> its winner.
86function pick(c: Compiled, prompt: string, plan: boolean): { picked: Def[]; lost: Map<string, Def> } {
87 const hit = [...winners(c.defs, plan).values()].filter((d) => {
88 const m = c.matchers.byName.get(d.name.toLowerCase())
89 return m !== undefined && prompt.search(m.scan) >= 0
90 })
91 const best = new Map<string, Def>()
92 for (const d of hit) {
93 if (d.group === undefined) continue
94 const cur = best.get(d.group)
95 if (cur === undefined || d.priority > cur.priority) best.set(d.group, d)
96 }
97 const lost = new Map<string, Def>()
98 for (const d of hit) {
99 const top = d.group === undefined ? d : best.get(d.group)
100 if (top !== undefined && top !== d) lost.set(d.name.toLowerCase(), top)
101 }
102 return { picked: hit.filter((d) => !lost.has(d.name.toLowerCase())).sort((a, b) => a.order - b.order), lost }
103}
104
105// Joined blocks capped like inject.mjs; cuts = name -> its block text past the cap ('' = dropped), whole blocks absent.
106function join(picked: readonly Def[], prefix: string): { context: string; cuts: Map<string, string> } {
107 const blocks = picked.filter((d) => d.body !== '').map((d) => ({ name: d.name.toLowerCase(), text: blockOf(d, prefix).text }))
108 const cuts = new Map<string, string>()
109 let at = 0
110 for (const b of blocks) {
111 const room = MAX_CHARS - at
112 if (room < b.text.length) cuts.set(b.name, room > 0 ? cutAt(b.text, room) + CUT_NOTE : '')
113 at += b.text.length + JOINER.length
114 }
115 const joined = blocks.map((b) => b.text).join(JOINER)
116 return { context: joined.length > MAX_CHARS ? cutAt(joined, MAX_CHARS) + CUT_NOTE : joined, cuts }
117}
118
119// The hook's whole answer, port of inject.mjs `run` + `assemble`: '' = nothing; only the mode string `plan` is plan mode.
120export function injectedContext(src: DefsLike, prefix: string, prompt: string, mode: unknown): string {
121 const c = compiledOf(src, prefix)
122 if (isSkipped(prompt, prefix) || markerOf(c.matchers, prompt) !== null) return ''
123 return join(pick(c, prompt, mode === 'plan').picked, prefix).context
124}
125
126// A name's plan-mode text: add (`plan: add` on an `always` def; plan = the addition body), replace (plan = the plan block), only (no normal def) or none.
127export type PlanParts = { normal: string; plan: string; kind: 'add' | 'replace' | 'only' | 'none' }
128export function planParts(defs: readonly Def[], name: string, prefix: string = DEFAULT_PREFIX): PlanParts {
129 const key = name.toLowerCase()
130 const ctx = defs.filter((d) => d.insert === 'context' && d.name.toLowerCase() === key)
131 const p = modeWinners(ctx, true).get(key)
132 const b = modeWinners(ctx, false).get(key)
133 const normal = b === undefined || b.body === '' ? '' : blockOf(b, prefix).text
134 if (p === undefined || p === b) return { normal, plan: '', kind: 'none' }
135 // An add part over an empty normal body sends nothing in plan mode either.
136 if (isAdd(p, b)) return b.body === '' ? { normal, plan: '', kind: 'none' } : { normal, plan: p.body, kind: 'add' }
137 return { normal, plan: blockOf(p, prefix).text, kind: b === undefined ? 'only' : 'replace' }
138}
139
140// Known names of a draft, first occurrence order.
141function knownNames(c: Compiled, text: string): string[] {
142 const names: string[] = []
143 for (const t of scanDraft(c.matchers, text).tokens) if (t.isKnown && !names.includes(t.name)) names.push(t.name)
144 return names
145}
146
147const strip = (token: string, prefix: string): string => (token.startsWith(prefix) && token.length > prefix.length ? token.slice(prefix.length) : token).toLowerCase()
148
149// One token alone in a prompt (`r` or `++r`): its context block or inline text in this mode, else why not.
150export function sentText(token: string, defs: readonly Def[], prefix: string, isPlan: boolean): Sent {
151 const name = strip(token, prefix)
152 const base = { token: prefix + name, name }
153 const ctx = defs.filter((d) => d.insert === 'context' && d.name.toLowerCase() === name)
154 if (ctx.length > 0) {
155 const now = winners(ctx, isPlan).get(name)
156 if (now !== undefined) return now.body === '' ? { ...base, kind: 'context', ...unsent(REASON.empty) } : { ...base, kind: 'context', ...blockOf(now, prefix), isSent: true, reason: null }
157 const other = winners(ctx, !isPlan).get(name)
158 const block = other === undefined || other.body === '' ? { text: '' } : blockOf(other, prefix)
159 return { ...base, kind: 'context', ...block, isSent: false, reason: isPlan ? REASON.notInPlan : REASON.planOnly }
160 }
161 const body = inlineBodies(defs).get(name)
162 if (body !== undefined) return { ...base, kind: 'inline', text: body, isSent: true, reason: null }
163 const inl = defs.filter((d) => d.insert === 'inline' && d.name.toLowerCase() === name)
164 if (inl.length === 0) return { ...base, kind: 'none', ...unsent(REASON.unknown) }
165 return { ...base, kind: 'inline', ...unsent(inl.some((d) => d.when === 'always' && d.body === '') ? REASON.empty : REASON.staysAsTyped) }
166}
167
168// A typed draft as the person sends it: inline expansion (module), then the context blocks (command hook) with group and cap reasons.
169// Escaped (`P/`): sent verbatim, nothing expanded or injected. A marker an inline body brings in: no context, inline text stays expanded.
170export function assembleDraft(text: string, src: DefsLike, prefix: string, isPlan: boolean): Draft {
171 const c = compiledOf(src, prefix)
172 const marker = markerOf(c.matchers, text)
173 if (marker !== null) return { prompt: text, context: '', tokens: knownNames(c, text).map((name) => ({ ...sentText(name, c.defs, prefix, isPlan), ...unsent(escapedReason(marker)) })) }
174 const prompt = expandInline(c, text) ?? text
175 const late = markerOf(c.matchers, prompt)
176 const skipped = isSkipped(prompt, prefix)
177 const { picked, lost } = skipped || late !== null ? { picked: [], lost: new Map<string, Def>() } : pick(c, prompt, isPlan)
178 const { context, cuts } = join(picked, prefix)
179 const tokens = knownNames(c, text).map((name): Sent => {
180 const s = sentText(name, c.defs, prefix, isPlan)
181 if (s.kind !== 'context') return s
182 if (late !== null) return { ...s, ...unsent(escapedReason(late)) }
183 if (!s.isSent) return s
184 if (skipped) return { ...s, ...unsent(REASON.delivery) }
185 const top = lost.get(name)
186 if (top !== undefined) return { ...s, ...unsent(winsReason(prefix + top.name.toLowerCase())) }
187 const cut = cuts.get(name)
188 if (cut === undefined) return s
189 return cut === '' ? { ...s, ...unsent(REASON.overCap) } : { ...s, text: cut, reason: REASON.cut }
190 })
191 return { prompt, context, tokens }
192}
193hooks/lib/command.ts 332 lines1// /placeholders model hand-off text and the model tools: pure over an injected Io, never `$`.
2import { DEFAULT_PREFIX, escapeMarkers } from './match'
3import { planParts, type PlanParts } from './sent'
4import { FOCUS_MAX } from './band'
5import { layerSpecs, loadRegistry, newRegistryCache, parseDefinition, type Def, type Io, type Registry, type Roots } from './defs'
6
7export type StatLite = { kind: 'file' | 'dir' | 'other'; isLink: boolean; realPath?: string }
8export type CmdIo = Io & {
9 write: (path: string, text: string) => Promise<void>
10 stat: (path: string) => Promise<StatLite | undefined>
11 remove: (path: string) => Promise<{ exitCode: number; stderr: string }>
12}
13export type Scope = 'project' | 'global'
14export type Part = 'all' | 'normal' | 'plan'
15export type SaveInput = { name?: unknown; description?: unknown; when?: unknown; text?: unknown; planText?: unknown; scope?: unknown }
16export type RemoveInput = { name?: unknown; scope?: unknown; part?: unknown }
17export type ToolOut = { text: string; registry: Registry | null; isError?: true }
18export type Row = { stem: string; layer: string; def: Def }
19
20export const TOOL_PREFIX = 'mcp__brewmod-placeholders__'
21export const NEEDS_NEW_CC = 'changing placeholders by words needs Claude Code 2.1.300+ · meanwhile: edit the <name>.md files by hand'
22export const SCOPE_REQUIRED = "scope is required: 'global' or 'project'"
23const BAD_SCOPE = "scope must be 'project' or 'global'"
24const BAD_FENCE = 'that text holds a ``` or ~~~ fence marker or opens with a --- line, which the definition format cannot carry'
25const NEEDS_ALWAYS = 'a plan-mode part needs a normal text with when: always'
26
27// Markdown-safe block: the first line stays plain (the engine glues "<plugin>: " in front of it, so a fence cannot open there), the rest sits in a fence longer than any backtick run inside.
28export function block(head: string, lines: readonly string[], foot: readonly string[] = []): string {
29 const run = Math.max(0, ...[...lines.join('\n').matchAll(/`+/g)].map((m) => m[0].length))
30 const fence = '`'.repeat(Math.max(3, run + 1))
31 return [head, fence, ...lines, fence, ...foot].join('\n')
32}
33
34const WORD = /^[a-z0-9_]{1,32}$/
35const SYMBOL = /^[!#$%&'()+,\-;=@[\]^`{}~]{1,32}$/
36const PLAN = '.plan'
37const PLAN_MD = '.plan.md'
38
39// File-safe stem: lowercase word or punctuation run (no / \ or .; shell characters pass, names only reach argv), optional `.plan`; else null.
40export function normalizeName(raw: string): string | null {
41 const lower = raw.toLowerCase()
42 const base = lower.endsWith(PLAN) ? lower.slice(0, -PLAN.length) : lower
43 return WORD.test(base) || SYMBOL.test(base) ? lower : null
44}
45
46const why = (e: unknown): string => (e instanceof Error ? e.message : String(e))
47export const layerLabel = (id: string): string => (id === 'default' ? 'plugin' : id)
48const dirOf = (roots: Roots, layer: string): string | undefined => layerSpecs(roots).find((s) => s.id === layer)?.dir
49
50// Launched in HOME: the project dir is the global one, so both are one layer, global.
51const isOneDir = (roots: Roots): boolean => dirOf(roots, 'project') !== undefined && dirOf(roots, 'project') === dirOf(roots, 'global')
52export const oneLayer = (reg: Registry, roots: Roots): Registry =>
53 isOneDir(roots) ? { ...reg, sources: new Map([...reg.sources].map(([k, v]) => [k, v === 'project' ? 'global' : v])) } : reg
54const scopeIn = (roots: Roots, scope: Scope): Scope => (isOneDir(roots) ? 'global' : scope)
55const load = async (io: Io, roots: Roots): Promise<Registry> => oneLayer(await loadRegistry(io, roots, newRegistryCache()), roots)
56
57// Shown layer dirs: the config dir with `~` for HOME; the project one relative to its root.
58export type Dirs = { global: string; project: string }
59export function shownDirs(roots: Roots, home: string | undefined): Dirs {
60 const cfg = roots.configDir
61 const h = home === undefined || home === '' ? undefined : home.replace(/\/+$/, '')
62 const tilde = cfg !== undefined && h !== undefined && cfg.startsWith(`${h}/`) ? `~${cfg.slice(h.length)}` : cfg
63 return { global: tilde === undefined ? '(no config dir)' : `${tilde}/placeholders`, project: '.claude/placeholders' }
64}
65const noDir = (scope: Scope): string => (scope === 'project' ? 'no project root for this session' : 'no config directory (CLAUDE_CONFIG_DIR and HOME are unset)')
66const fail = (text: string): ToolOut => ({ text, registry: null, isError: true })
67export const isScope = (v: unknown): v is Scope => v === 'project' || v === 'global'
68
69// defs[i] pairs with the i-th source key: resolveDefs inserts into both maps together.
70function rowsOf(reg: Registry): Row[] {
71 const stems = [...reg.sources.keys()]
72 return reg.defs
73 .map((def, i): Row => {
74 const stem = stems[i] ?? def.name
75 return { stem, layer: reg.sources.get(stem) ?? 'default', def }
76 })
77 .sort((a, b) => (a.stem < b.stem ? -1 : a.stem > b.stem ? 1 : 0))
78}
79
80export type Named = { name: string; lead: Row; plan: Row | undefined; own: Row[]; kind: PlanParts['kind'] }
81
82// One entry per name (any case), in stem order; lead = the normal part, else the plan-mode part.
83export function byName(reg: Registry, prefix: string): Named[] {
84 const rows = rowsOf(reg)
85 return [...new Set(rows.map((r) => r.def.name.toLowerCase()))].flatMap((name) => {
86 const own = rows.filter((r) => r.def.name.toLowerCase() === name)
87 const plan = own.find((r) => r.def.when === 'plan')
88 const lead = own.find((r) => r.def.when !== 'plan') ?? plan
89 return lead === undefined ? [] : [{ name, lead, plan, own, kind: planParts(reg.defs, name, prefix).kind }]
90 })
91}
92
93export const handoffAck = (status: string): string => `asking Claude to help with your placeholder ...\n${status}`
94
95const ASK_FIRST = 'Ask first: no save_placeholder or remove_placeholder call before I answered your one AskUserQuestion about that exact change in this turn, even if my request is explicit (only a remove of a name with files: none skips it).'
96const NO_SCOPE = 'Scope: not given; infer it from my words outside the placeholder text, else ask "Where to save?" in the same dialog, Global first; a change keeps its layer, a remove uses the layer that holds it.'
97
98// Short on purpose: it shows in the transcript. The confirm rule leads; the other rules live in the tool descriptions.
99// The escape marker keeps the tokens named in the request from being expanded or injected into this message.
100export const handoffText = (request: string, prefix: string = DEFAULT_PREFIX, scope: Scope | null = null): string =>
101 `Placeholder helper. My request: <request>${request}</request>\n${ASK_FIRST}\n${scope === null ? NO_SCOPE : `Scope: ${scope} (I said it; skip only the Where question).`}\nUse the ${TOOL_PREFIX}* tools (list_placeholders first); a placeholder is inserted into prompts as ${prefix}name (not in this message: ${escapeMarkers(prefix)[0]}).`
102
103// Tool specs for the configured prefix: every token they name is spelled with it.
104export const tools = (p: string) => [
105 {
106 name: 'list_placeholders',
107 description: `List every placeholder, one entry per name: token, layer (plugin | global | project | global-legacy | project-legacy), files (the user files per layer; both layers when it is in both), when, plan (add | replace | only | none), description, its text and its plan-mode text. Call it first before creating, changing, removing or showing one. To show placeholders, print the requested ones verbatim; /placeholders show ${p}<name> also prints them instantly.`,
108 inputSchema: { type: 'object', properties: {} },
109 },
110 {
111 name: 'save_placeholder',
112 description: `Never call this tool before the user answered your one AskUserQuestion about this exact save in this turn, even when the request is explicit. Create or change a placeholder file; never use Write. Call list_placeholders first. name: lowercase letters, digits, underscore, no prefix. text: the user's words verbatim when given, else your draft; a change starts from its text in list_placeholders. planText: a plan-mode part, added to the text only in Plan mode (file <name>.plan.md); draft one only when the user mentions plan mode (plan, plan mode, planning, план, планир, режим плана) or the name already has one. A change picks the part by words: plan part, plan mode, план, в режиме плана -> planText only; normal, обычн, основн -> text only; both, оба -> both; else text. An omitted part stays as it is. scope (required): a first word global or project wins; else infer it only from the words outside the placeholder's own text (global: global, everywhere, all projects, глобально, везде, во всех проектах; project: project, this project, here, this repo, в проекте, здесь, локально); a change keeps its layer from list_placeholders (global-legacy = global, project-legacy = project). That one AskUserQuestion call holds these questions in this order: 1. header "Placeholder", question exactly "Save ${p}<name> as drafted?" (never a layer in it) with the text, options "Save as drafted (Recommended)" / "Change it" (Other = a different name or text; apply it and save without asking again). 2. Only with a plan-mode part: header "Plan mode", question "Add this plan-mode part to ${p}<name>?", the draft as the first option's preview, options "Add it (Recommended)" / "No plan part". 3. Only when the scope is unclear (no word, or a new override of a plugin default): header "Where", question "Where to save ${p}<name>?", options "Global (Recommended)" (all your projects) then "Project" (only this project; wins over global). Only with no AskUserQuestion tool at all: save as drafted, scope global unless a word outside the text says project. Then say where it was saved.`,
113 inputSchema: {
114 type: 'object',
115 properties: {
116 name: { type: 'string', description: 'lowercase name without the prefix' },
117 description: { type: 'string', description: 'one short line shown in the list and the typeahead' },
118 text: { type: 'string', description: 'the normal text inserted into prompts; optional when only planText changes on an existing name' },
119 planText: { type: 'string', description: 'optional plan-mode part, added to the text only in Plan mode' },
120 when: { type: 'string', enum: ['always', 'plan', 'not-plan'], description: 'optional; default always (plan alone = a plan-only placeholder, no planText)' },
121 scope: { type: 'string', enum: ['global', 'project'], description: 'required: global (all projects) or project (this project only)' },
122 },
123 required: ['name', 'description', 'scope'],
124 },
125 },
126 {
127 name: 'remove_placeholder',
128 description: `Never call this tool before the user answered your one AskUserQuestion about this exact removal in this turn, even when the request is explicit; only a name with files: none in list_placeholders skips the question (a plugin default or legacy brewtools file: nothing is removed; relay the result). Remove a placeholder file from the global or project layer; never use Bash rm. The wording is free (remove, delete, drop, удали, убери, сотри). Call list_placeholders first. part: all (default; <name>.md and <name>.plan.md), plan (the plan-mode part only: plan part, plan mode, план, в режиме плана), normal (the normal text only). scope: a first word global or project wins; else omit it and the tool takes the one layer holding the file. Question 1, header "Remove": "Remove ${p}<name> (<layer>)?", options "Remove it (Recommended)" / "Keep it"; when it has a plan-mode part and no part was named: "Remove ${p}<name> (<layer>)? It has a plan-mode part.", options "Both parts (Recommended)" / "Only the plan-mode part" / "Keep it"; when the plan part was named: "Remove the plan-mode part of ${p}<name> (<layer>)?", options "Remove it (Recommended)" / "Keep it". When list_placeholders shows files in both layers and no scope was given, add question 2 in the same call: header "Where", "Remove ${p}<name> from where?", options "Global" / "Project". Then say what is served afterwards (the result says it).`,
129 inputSchema: {
130 type: 'object',
131 properties: {
132 name: { type: 'string', description: 'name without the prefix' },
133 scope: { type: 'string', enum: ['global', 'project'], description: 'omit to use the one layer holding the file' },
134 part: { type: 'string', enum: ['all', 'normal', 'plan'], description: 'default all' },
135 },
136 required: ['name'],
137 },
138 },
139 {
140 name: 'placeholders_help',
141 description: `How the user's ${p} placeholders work: syntax, full text (/placeholders show ${p}name; the caret on a token shows up to ${FOCUS_MAX} rows in the band), sending a message as typed (${escapeMarkers(p)[0]}), global vs project, file format, /placeholders commands and doc links. Call it before answering any question about placeholders or the ${p} prefix.`,
142 inputSchema: { type: 'object', properties: {} },
143 },
144] as const
145
146type Held = { scope: Scope; dir: string; files: string[] }
147
148// File names in the global and project dirs (`.md`, no dot files, no dirs); a missing dir lists nothing; one shared dir is global only.
149async function userFiles(io: Io, roots: Roots): Promise<Held[]> {
150 const out: Held[] = []
151 for (const scope of isOneDir(roots) ? (['global'] as const) : (['global', 'project'] as const)) {
152 const dir = dirOf(roots, scope)
153 if (dir === undefined) continue
154 const listing = (await io.exists(dir)) ? await io.list(dir) : []
155 out.push({ scope, dir, files: listing.filter((en) => en.kind !== 'dir' && !en.name.startsWith('.') && en.name.toLowerCase().endsWith('.md')).map((en) => en.name) })
156 }
157 return out
158}
159
160// `<stem>.md` = normal part, `<stem>.plan.md` = plan-mode part, normal first; names match case-insensitively.
161const isPlanFile = (f: string): boolean => f.toLowerCase().endsWith(PLAN_MD)
162const partFiles = (files: readonly string[], stem: string, part: Part): string[] =>
163 files
164 .filter((f) => {
165 const l = f.toLowerCase()
166 return (part !== 'plan' && l === `${stem}.md`) || (part !== 'normal' && l === `${stem}${PLAN_MD}`)
167 })
168 .sort((a, b) => Number(isPlanFile(a)) - Number(isPlanFile(b)))
169
170const bodyOf = (d: Def): string => (d.body === '' ? '(empty)' : d.body)
171
172function entry({ name, lead, plan, kind }: Named, held: readonly Held[], prefix: string): string {
173 const files = held.map((h) => ({ scope: h.scope, files: partFiles(h.files, name, 'all') })).filter((h) => h.files.length > 0)
174 const head = [
175 `## ${prefix}${name}`,
176 `layer: ${layerLabel(lead.layer)}`,
177 `files: ${files.length === 0 ? 'none' : files.map((h) => `${h.scope} (${h.files.join(', ')})`).join(', ')}`,
178 `when: ${lead.def.when}`,
179 `plan: ${kind}`,
180 ...(lead.def.insert === 'inline' ? ['insert: inline'] : []),
181 lead.def.description ?? '-',
182 ].join(' · ')
183 const extra = plan === undefined || plan === lead ? [] : [`### plan mode ${kind === 'add' ? 'adds' : 'sends instead'} · layer: ${layerLabel(plan.layer)}`, bodyOf(plan.def)]
184 return [head, bodyOf(lead.def), ...extra].join('\n')
185}
186
187// One entry per name: layer, user files per layer, plan kind, the text and the plan-mode text.
188export async function listTool(io: Io, roots: Roots, reg: Registry, prefix: string = DEFAULT_PREFIX): Promise<ToolOut> {
189 const held = await userFiles(io, roots)
190 const text = byName(oneLayer(reg, roots), prefix).map((n) => entry(n, held, prefix)).join('\n\n')
191 return { text: text === '' ? 'no placeholders defined' : text, registry: null }
192}
193
194const bare = (raw: unknown, prefix: string): string | null => {
195 if (typeof raw !== 'string') return null
196 const s = raw.trim()
197 return normalizeName(s.startsWith(prefix) && s.length > prefix.length ? s.slice(prefix.length) : s)
198}
199const filled = (v: unknown): string | null => (typeof v === 'string' && v.trim() !== '' ? v : null)
200
201// File = front matter (description, when when given, plan) + body; null when the text would not read back (fence marker, leading ---).
202export function definitionFile(description: string, when: string | undefined, text: string, plan?: 'add'): string | null {
203 const head = [`description: ${JSON.stringify(description)}`, ...(when === undefined ? [] : [`when: ${when}`]), ...(plan === undefined ? [] : [`plan: ${plan}`])]
204 const body = text.trim()
205 const content = `---\n${head.join('\n')}\n---\n${body}\n`
206 const back = parseDefinition(content)
207 return body === '' || back.issues.length > 0 || back.text !== body || back.meta.description !== description || back.meta.plan !== plan ? null : content
208}
209
210// text -> `<stem>.md`, planText -> `<stem>.plan.md` (`when: plan`, `plan: add`), both in the scope's dir; an omitted part is left untouched.
211// With planText the text file states `when` itself, so it never inherits a lower layer's `not-plan`. home: shown paths use `~`.
212export async function saveTool(io: CmdIo, roots: Roots, input: SaveInput, prefix: string = DEFAULT_PREFIX, home?: string): Promise<ToolOut> {
213 try {
214 const stem = bare(input.name, prefix)
215 if (stem === null) return fail('bad name: use lowercase letters, digits, underscore (1-32) or punctuation only, optional .plan suffix')
216 if (input.scope === undefined || input.scope === null || input.scope === '') return fail(SCOPE_REQUIRED)
217 if (!isScope(input.scope)) return fail(BAD_SCOPE)
218 const scope = scopeIn(roots, input.scope)
219 const text = filled(input.text)
220 const planText = filled(input.planText)
221 if (text === null && planText === null) return fail('text is empty')
222 const description = typeof input.description === 'string' ? input.description.trim().replace(/\s+/g, ' ') : ''
223 if (description === '') return fail('description is empty')
224 const given = input.when === undefined ? undefined : String(input.when)
225 if (given !== undefined && !['always', 'plan', 'not-plan'].includes(given)) return fail("when must be 'always', 'plan' or 'not-plan'")
226 const when = planText !== null && text !== null ? (given ?? 'always') : given
227 const token = prefix + stem
228 if (planText !== null) {
229 if (stem.endsWith(PLAN)) return fail('planText needs the name without .plan')
230 // The normal def the part joins, as served after this save: its when is this text's, else the served one's; insert is inherited.
231 const base = rowsOf(await load(io, roots)).find((r) => r.def.name.toLowerCase() === stem && r.def.when !== 'plan')?.def
232 if (text === null && base === undefined) return fail(`${token} has no text yet: send text too (a placeholder only for Plan mode: text with when: plan)`)
233 if ((text === null ? base?.when : when) !== 'always' || (base?.insert ?? 'context') !== 'context') return fail(NEEDS_ALWAYS)
234 }
235 const dir = dirOf(roots, scope)
236 if (dir === undefined) return fail(noDir(scope))
237 const files: [string, string][] = []
238 for (const [file, content] of [
239 [`${stem}.md`, text === null ? undefined : definitionFile(description, when, text)],
240 [`${stem}${PLAN_MD}`, planText === null ? undefined : definitionFile(description, 'plan', planText, 'add')],
241 ] as const) {
242 if (content === null) return fail(BAD_FENCE)
243 if (content !== undefined) files.push([file, content])
244 }
245 // Same-name entry (any case): a symlink or directory would send the write outside the layer dir.
246 const listing = (await io.exists(dir)) ? await io.list(dir) : []
247 const clash = listing.find((en) => files.some(([f]) => en.name.toLowerCase() === f) && (en.isLink || en.kind !== 'file'))
248 if (clash !== undefined) return fail(`refused, ${dir}/${clash.name} is not a plain file`)
249 for (const [file, content] of files) await io.write(`${dir}/${file}`, content)
250 const registry = await load(io, roots)
251 const won = files.map(([f]) => registry.sources.get(f.slice(0, -3))).find((l) => l !== undefined && l !== scope)
252 const note = won === undefined ? '' : `; note: the ${layerLabel(won)} layer wins over ${scope}`
253 const what = text === null ? `the plan-mode part of ${token}` : token
254 const shown = shownDirs(roots, home)[scope]
255 return { text: `saved ${what} (${scope}): ${files.map(([f]) => `${shown}/${f}`).join(', ')}${note}`, registry }
256 } catch (e) {
257 return fail(`failed: ${why(e)}`)
258 }
259}
260
261const servedLabel = (id: string): string => (id.endsWith('-legacy') ? `brewtools ${id.slice(0, -'-legacy'.length)}` : id)
262
263// What the name sends after the removal: the layer now serving the removed part, else what is left.
264function after(reg: Registry, stem: string, token: string, isPlanOnly: boolean): string {
265 const now = reg.sources.get(isPlanOnly ? `${stem}${PLAN}` : stem)
266 const what = isPlanOnly ? `plan-mode part of ${token}` : token
267 if (now !== undefined) return now === 'default' ? `the built-in ${what} is back` : `the ${servedLabel(now)} ${what} is served now`
268 if (!reg.sources.has(isPlanOnly ? stem : `${stem}${PLAN}`)) return `${token} is no longer defined`
269 return isPlanOnly ? `${token} keeps its normal text` : `${token} keeps only its plan-mode part`
270}
271
272// No user file to remove: a legacy brewtools file, a built-in, a missing plan part or an unknown name.
273async function notRemovable(io: Io, roots: Roots, stem: string, part: Part, token: string): Promise<string> {
274 const keys = part === 'plan' ? [`${stem}${PLAN}`] : part === 'normal' ? [stem] : [stem, `${stem}${PLAN}`]
275 const reg = await load(io, roots)
276 const rows = rowsOf(reg).filter((r) => keys.includes(r.stem))
277 const legacy = rows.find((r) => r.layer.endsWith('-legacy'))
278 if (legacy !== undefined) {
279 const dir = dirOf(roots, legacy.layer) ?? legacy.layer
280 return `${token} comes from a brewtools file (${dir}/${legacy.def.legacy ?? stem}.md) · delete it by hand if you no longer need it`
281 }
282 if (rows.some((r) => r.layer === 'default')) return `${token} is built in and cannot be removed · change what it sends: /placeholders change ${token} ...`
283 if (part === 'plan' && reg.sources.has(stem)) return `${token} has no plan-mode part`
284 return `unknown placeholder ${token}`
285}
286
287// Removes the part's files from one layer: the given scope, else the one layer holding them; both layers -> ask. home: shown paths use `~`.
288export async function removeTool(io: CmdIo, roots: Roots, input: RemoveInput, prefix: string = DEFAULT_PREFIX, home?: string): Promise<ToolOut> {
289 try {
290 const raw = bare(input.name, prefix)
291 if (raw === null) return fail('bad name')
292 const isPlanStem = raw.endsWith(PLAN)
293 const stem = isPlanStem ? raw.slice(0, -PLAN.length) : raw
294 const part = input.part === undefined ? (isPlanStem ? 'plan' : 'all') : input.part
295 if (part !== 'all' && part !== 'normal' && part !== 'plan') return fail("part must be 'all', 'normal' or 'plan'")
296 if (input.scope !== undefined && !isScope(input.scope)) return fail(BAD_SCOPE)
297 const scope = input.scope === undefined ? undefined : scopeIn(roots, input.scope)
298 const token = prefix + stem
299 const held = (await userFiles(io, roots)).map((h) => ({ ...h, files: partFiles(h.files, stem, part) })).filter((h) => h.files.length > 0)
300 const picked = scope === undefined ? held : held.filter((h) => h.scope === scope)
301 if (picked.length > 1) return fail(`${token} is in both layers (global and project): ask "Remove ${token} from where?" (Global / Project), then call remove_placeholder again with scope`)
302 const target = picked[0]
303 if (target === undefined) {
304 const other = held[0]
305 return fail(other !== undefined ? `${token} has no ${String(scope)} file; it is in the ${other.scope} layer` : await notRemovable(io, roots, stem, part, token))
306 }
307 // Every file must be a plain file whose real path sits inside the real layer dir; nothing goes until all pass.
308 const base = (await io.stat(target.dir))?.realPath?.replace(/\/+$/, '')
309 const reals: string[] = []
310 for (const f of target.files) {
311 const st = await io.stat(`${target.dir}/${f}`)
312 const real = st?.realPath
313 if (st === undefined || st.kind !== 'file' || st.isLink || base === undefined || base === '' || real === undefined || !real.startsWith(`${base}/`)) {
314 return fail(`refused, ${target.dir}/${f} is not a plain file inside ${target.dir}`)
315 }
316 reals.push(real)
317 }
318 for (const real of reals) {
319 const res = await io.remove(real)
320 if (res.exitCode !== 0) return fail(`rm failed (exit ${res.exitCode}): ${res.stderr.trim()}`)
321 }
322 const registry = await load(io, roots)
323 const shown = shownDirs(roots, home)[target.scope]
324 const paths = target.files.map((f) => `${shown}/${f}`).join(', ')
325 const isPlanOnly = target.files.every(isPlanFile)
326 const what = isPlanOnly ? `the plan-mode part of ${token}` : token
327 return { text: `removed ${what} (${target.scope}): ${paths} · ${after(registry, stem, token, isPlanOnly)}`, registry }
328 } catch (e) {
329 return fail(`failed: ${why(e)}`)
330 }
331}
332hooks/lib/show.ts 193 lines1// What /placeholders answers without Claude: the grammar, status, help, show and list texts. Pure, zero I/O.
2import { DEFAULT_PREFIX, compileMatchers, scanDraft, type Matchers } from './match'
3import { MAX_CHARS, PLAN_LABEL, REASON, planParts, sentText, type PlanParts } from './sent'
4import { inlineBodies } from './inline'
5import { FOCUS_MAX } from './band'
6import { block, byName, isScope, layerLabel, type Dirs, type Scope } from './command'
7import type { Registry } from './defs'
8
9export { shownDirs, type Dirs } from './command'
10
11export const DOCS_URL = 'https://doc-claude.brewcode.app/mods/placeholders/'
12export const README_URL = 'https://github.com/kochetkov-ma/claude-brewcode/tree/main/brewmod-placeholders'
13
14export type Action =
15 | { kind: 'status' }
16 | { kind: 'help' }
17 | { kind: 'list' }
18 | { kind: 'show'; names: string[] }
19 | { kind: 'claude'; request: string; scope: Scope | null }
20
21type Cue = 'status' | 'help' | 'list' | 'show' | 'mutate' | 'filler'
22
23// Per cue: exact EN/RU words, then RU stems (prefix match). The order is the lookup order.
24const CUES: readonly [Cue, readonly string[], readonly string[]][] = [
25 ['status', ['status'], ['статус']],
26 ['help', ['help', '-h', '--help', '?', 'usage', 'docs'], ['помощ', 'справк', 'хелп', 'инструкц']],
27 ['list', ['list', 'ls', 'all', 'every', 'everything', 'все', 'весь', 'всех'], ['список']],
28 ['show', ['show', 'print', 'display', 'view', 'see', 'cat', 'open', 'expand', 'what', 'whats', 'что'], ['покаж', 'показ', 'выве', 'вывод', 'отобраз', 'откр', 'раскр', 'посмотр', 'глян']],
29 [
30 'mutate',
31 ['add', 'new', 'create', 'make', 'save', 'set', 'write', 'change', 'edit', 'update', 'modify', 'fix', 'rename', 'replace', 'rewrite', 'shorten', 'extend', 'remove', 'delete', 'drop', 'rm', 'del'],
32 ['добав', 'нов', 'созда', 'сдела', 'сохран', 'запомн', 'измен', 'поменя', 'замен', 'исправ', 'обнов', 'редакт', 'переимен', 'перепиш', 'сократ', 'укорот', 'допиш', 'расшир', 'удал', 'убер', 'сотр'],
33 ],
34 [
35 'filler',
36 ['me', 'the', 'and', 'please', 'text', 'full', 'whole', 'of', 'for', 'placeholder', 'placeholders', 'is', 'are', 'does', 'do', 'it', 'its', 'mean', 'means', 'about', 'мне', 'и', 'а', 'пожалуйста', 'целиком', 'такое', 'за', 'это', 'про'],
37 ['текст', 'полн', 'плейсхолдер', 'значит', 'делает'],
38 ],
39]
40
41const cueOf = (word: string): Cue | null => CUES.find(([, words, stems]) => words.includes(word) || stems.some((st) => word.startsWith(st)))?.[0] ?? null
42
43// Prefix tokens (known or not) cut out, a `?` right after one taken with it; isQueries = every token had one, none overlapped.
44function walk(ms: Matchers, text: string): { names: string[]; isQueries: boolean; rest: string } {
45 const names: string[] = []
46 let isQueries = true
47 let rest = ''
48 let at = 0
49 for (const t of scanDraft(ms, text).tokens) {
50 const isQuery = text.charAt(t.end) === '?'
51 isQueries = isQueries && t.start >= at && isQuery
52 if (t.start < at) continue
53 rest += `${text.slice(at, t.start)} `
54 names.push(t.name)
55 at = isQuery ? t.end + 1 : t.end
56 }
57 return { names, isQueries, rest: rest + text.slice(at) }
58}
59
60// First match wins: scope word, a change word, names to show, help, list, status; any other word goes to Claude.
61// Lowercase, `ё` -> `е`; words split on spaces and `,;:!.`, a trailing `?` dropped.
62export function parseArgs(args: string, prefix: string, names: readonly string[]): Action {
63 const request = args.trim()
64 const toClaude = (scope: Scope | null = null): Action => ({ kind: 'claude', request, scope })
65 const known = new Set(names.map((n) => n.toLowerCase()))
66 const { names: tokens, rest } = walk(compileMatchers(prefix, known), request.toLowerCase().replace(/ё/g, 'е'))
67 const words = rest
68 .replace(/['’]/g, '')
69 .split(/[\s,;:!.]+/)
70 .filter((w) => w !== '')
71 .map((w) => (w.length > 1 ? w.replace(/\?+$/, '') : w))
72 const first = request.split(/\s+/)[0]?.toLowerCase() ?? ''
73 if (isScope(first)) return words.length + tokens.length > 1 ? toClaude(first) : { kind: 'status' }
74 const cues = words.map((w) => ({ w, cue: cueOf(w) }))
75 if (cues.some((c) => c.cue === 'mutate' || (c.cue === null && !known.has(c.w)))) return toClaude()
76 const items = [...new Set([...tokens, ...cues.filter((c) => c.cue === null).map((c) => c.w)])]
77 if (items.length > 0) return cues.some((c) => c.cue === 'status' || c.cue === 'help') ? toClaude() : { kind: 'show', names: items }
78 if (cues.some((c) => c.cue === 'help')) return { kind: 'help' }
79 if (cues.some((c) => c.cue === 'list')) return { kind: 'list' }
80 return { kind: 'status' }
81}
82
83// P1: a prompt made only of `P<name>?` items (spaces, commas between) -> the names; anything else -> null.
84export function queryNames(ms: Matchers, text: string): string[] | null {
85 const w = walk(ms, text)
86 return w.names.length > 0 && w.isQueries && /^[\s,;]*$/.test(w.rest) ? [...new Set(w.names)] : null
87}
88
89// Prefixed tokens: a bare name can be a cue word (`fix`, `docs`, `all`) and turn the line into another command.
90export const showCommand = (prefix: string, names: readonly string[]): string => `/placeholders show ${names.map((n) => prefix + n).join(' ')}`
91// P1 fill: a trailing space puts the caret past the last token, so no name typeahead opens under the box and Enter runs the command.
92export const fillText = (prefix: string, names: readonly string[]): string => `${showCommand(prefix, names)} `
93export const queryDrop = (prefix: string, names: readonly string[]): string =>
94 `${names.map((n) => `${prefix}${n}?`).join(' ')} = ${showCommand(prefix, names)} · not sent to Claude · press Enter to print it`
95
96const DEFAULT_DIRS: Dirs = { global: '~/.claude/placeholders', project: '.claude/placeholders' }
97
98type NameRow = { name: string; layer: string; planLayer: string | null; kind: PlanParts['kind']; inline: boolean; description: string }
99
100// One row per name, sorted by name; layer = the normal part's (plan-only: the plan file's).
101const nameRows = (reg: Registry, prefix: string): NameRow[] =>
102 byName(reg, prefix).map(({ name, lead, plan, own, kind }) => ({
103 name,
104 layer: layerLabel(lead.layer),
105 planLayer: plan === undefined ? null : layerLabel(plan.layer),
106 kind,
107 inline: own.every((r) => r.def.insert === 'inline'),
108 description: lead.def.description ?? '-',
109 }))
110
111const tagOf = (r: NameRow): string =>
112 r.kind === 'only' ? PLAN_LABEL.only : r.kind === 'add' || r.kind === 'replace' ? (r.planLayer === null || r.planLayer === r.layer ? PLAN_LABEL.plus : `${PLAN_LABEL.plus} (${r.planLayer})`) : r.inline ? 'inline' : ''
113
114// The example name in hints: `r` when defined, else the first word name, else the first name.
115const sample = (rows: readonly NameRow[]): string => rows.find((r) => r.name === 'r')?.name ?? rows.find((r) => /^\w+$/.test(r.name))?.name ?? rows[0]?.name ?? 'name'
116
117// hasQuery = the `P<name>?` hand-off runs (the build has the mod tools); else the texts name only the show command.
118export function statusText(reg: Registry, prefix: string = DEFAULT_PREFIX, dirs: Dirs = DEFAULT_DIRS, hasQuery = true): string {
119 const rows = nameRows(reg, prefix)
120 const tags = rows.map(tagOf)
121 const w = Math.max(4, ...rows.map((r) => (prefix + r.name).length))
122 const lw = Math.max(6, ...rows.map((r) => r.layer.length))
123 const tw = Math.max(0, ...tags.map((t) => t.length))
124 const lines = rows.map((r, i) => [(prefix + r.name).padEnd(w), r.layer.padEnd(lw), ...(tw === 0 ? [] : [(tags[i] ?? '').padEnd(tw)]), r.description].join(' '))
125 const issues = reg.issues.length === 0 ? [] : ['', `issues (${reg.issues.length}):`, ...reg.issues.map((i) => ` ${i}`)]
126 const s = sample(rows)
127 return block(`${rows.length} placeholders · type ${prefix}name anywhere in a prompt`, [...lines, ...issues], [
128 `Caret on a token: the band shows up to ${FOCUS_MAX} rows of what it sends · whole text: \`${showCommand(prefix, [s])}\`${hasQuery ? `, or \`${prefix}${s}?\` alone + Enter twice` : ''} · \`/placeholders list\` for all`,
129 `Send one message as typed: put \`${prefix}/\` anywhere in it`,
130 `Add, change, remove: \`/placeholders <your words>\` in any language, e.g. \`/placeholders add ${prefix}db: use the staging DB\``,
131 `Where: global = all projects (\`${dirs.global}\`), project = this one (\`${dirs.project}\`, wins over global); start with \`global\` or \`project\`, or just say it`,
132 'Commands (`list`, `show`, `help`) are only faster; plain words work too · full help: `/placeholders help`',
133 ])
134}
135
136export function helpText(prefix: string = DEFAULT_PREFIX, dirs: Dirs = DEFAULT_DIRS, hasQuery = true): string {
137 const p = prefix
138 return [
139 `placeholders help · a placeholder is a short token (${p}name) that adds saved text to your prompt`,
140 `Use: type \`${p}m\` anywhere in a prompt; Tab completes; the band above the prompt shows what each token sends.`,
141 `Caret on a token (arrows, Home, End): the band shows up to ${FOCUS_MAX} rows of what it sends; \`?\` right after the token only makes room for those rows while the name menu is open.`,
142 `Whole text, printed in the chat, not sent to Claude: \`${showCommand(p, ['r'])}\` (several: \`${showCommand(p, ['r', 'm'])}\`)${hasQuery ? ` · or \`${p}r?\` alone in a message: Enter fills that command, Enter again prints it` : ''} · all: \`/placeholders list\`.`,
143 `Send as typed: \`${p}/\` (or \`${p}\\\`) anywhere in a message turns every placeholder in it off; the marker stays in the text.`,
144 `Plan mode: \`<name>.plan.md\` with \`plan: add\` adds its text to \`<name>.md\` only in Plan mode (built-in \`${p}m\` works this way); without \`plan: add\` it replaces the text; \`when: plan\` in \`<name>.md\` alone = plan-only (\`${p}+\`). The band marks \`${PLAN_LABEL.plus}\` and shows "${PLAN_LABEL.adds}"; one review per message (\`${p}rr\` wins over \`${p}r\`).`,
145 `Add, change, remove: \`/placeholders <your words, any language>\`; Claude drafts, asks once, saves. E.g. \`/placeholders add ${p}db: use the staging DB\` · \`/placeholders global make ${p}r shorter\` · \`/placeholders удали ${p}db\` · \`/placeholders add a plan-mode part to ${p}db: plan first, then build\`.`,
146 `Remove: \`/placeholders remove ${p}db\` (both parts) · \`/placeholders удали план у ${p}db\` (plan part only) · removing your override brings the built-in back; built-ins cannot be removed.`,
147 `Where: global = all projects, \`${dirs.global}/<name>.md\`; project = this project, \`${dirs.project}/<name>.md\`, wins over global; plugin defaults sit below both. A first word \`global\` or \`project\` decides, else your words do; unclear -> one question, Global first. A change keeps its layer; a remove asks when the name is in both.`,
148 'Files: `<name>.md` = optional front matter (description, when: always|plan|not-plan, insert: context|inline, order, group, priority, header) + the text. Edit by hand any time; the next prompt reads it.',
149 `Prefix: \`/config\` -> Placeholder prefix (2-8 punctuation characters, default \`${DEFAULT_PREFIX}\`).`,
150 `Commands: \`/placeholders\` status · \`list\` · \`show ${p}<name> ...\` · \`help\` · anything else goes to Claude. Commands are shortcuts; plain words work too.`,
151 `Docs: ${DOCS_URL}`,
152 `README: ${README_URL}`,
153 ].join('\n')
154}
155
156// A part in its own fence; an empty one says so instead. sent = the length the cap applies to (an added part is sent joined).
157const part = (label: string, text: string, sent: number = text.length): string =>
158 text === '' ? `${label} (empty, sends nothing)` : block(`${label}${sent > MAX_CHARS ? ` (cut at ${MAX_CHARS} chars when sent)` : ''}`, text.split('\n'))
159
160function nameBlock(reg: Registry, r: NameRow, prefix: string, isPlan: boolean): string {
161 const tag = tagOf(r)
162 const head = `\`${prefix}${r.name}\` · ${r.layer}${tag === '' ? '' : ` · ${tag}`}`
163 if (r.inline) {
164 const body = inlineBodies(reg.defs).get(r.name)
165 if (body !== undefined) return [head, part('replaces the token with:', body)].join('\n')
166 // Only a `when: always` inline def with a body expands (the module has no mode); any other stays as typed.
167 const d = reg.defs.find((x) => x.name.toLowerCase() === r.name && x.insert === 'inline')
168 return [head, d === undefined || d.body === '' ? `${REASON.staysAsTyped} (empty)` : part(`${REASON.staysAsTyped} (when: ${d.when}); its text:`, d.body, 0)].join('\n')
169 }
170 const p = planParts(reg.defs, r.name, prefix)
171 if (p.kind === 'only') return [head, part(PLAN_LABEL.onlyPart, p.plan)].join('\n')
172 if (p.kind === 'add') {
173 // Plan mode sends one block: the plan header when it has one, the normal body, then the part.
174 const joined = sentText(r.name, reg.defs, prefix, true).text
175 if (isPlan) return [head, part('normal + plan mode part:', joined)].join('\n')
176 return [head, part('normal:', p.normal), part(PLAN_LABEL.adds, p.plan, joined.length)].join('\n')
177 }
178 const extra = p.kind === 'replace' ? [part(PLAN_LABEL.instead, p.plan)] : []
179 return [head, part('normal:', p.normal), ...extra].join('\n')
180}
181
182// show (names) or list (null): one block per name; unknown names are listed at the foot.
183export function fullText(reg: Registry, names: readonly string[] | null, prefix: string = DEFAULT_PREFIX, isPlan = false): string {
184 const rows = nameRows(reg, prefix)
185 const mode = `full text as sent · plan mode ${isPlan ? 'on' : 'off'}`
186 const blocks = (picked: readonly NameRow[]): string[] => picked.map((r) => nameBlock(reg, r, prefix, isPlan))
187 if (names === null) return [`${rows.length} placeholders · ${mode}`, ...blocks(rows), 'Add, change, remove: `/placeholders <your words>`'].join('\n')
188 const keys = names.map((n) => n.toLowerCase())
189 const unknown = keys.filter((k) => !rows.some((r) => r.name === k))
190 const foot = unknown.length === 0 ? [] : [`unknown: ${unknown.map((n) => `\`${prefix}${n}\``).join(', ')} · names: /placeholders list`]
191 return [`${keys.map((k) => prefix + k).join(', ')} · ${mode}`, ...blocks(keys.flatMap((k) => rows.filter((r) => r.name === k))), ...foot].join('\n')
192}
193hooks/lib/inline.ts 79 lines1// Pure helpers: inline expansion of a submitted prompt and the unknown-token list for the toast. Zero I/O.
2
3import type { Compiled } from './compiled'
4import type { Def } from './defs'
5import { scanDraft, type Matchers } from './match'
6
7// Origins whose text is the person's own typed prompt; every other kind (notifications, peers, schedules) is left alone.
8const INLINE_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
9
10export type SubmitLike = { origin: { kind: string }; command?: unknown; isMeta?: unknown }
11
12// prompt.submit has no mode: expansion needs a real person's prompt, no slash command, and no isMeta (a rewrite of those prompts is partial or absent).
13export function isInlineSubmit(e: SubmitLike): boolean {
14 return INLINE_ORIGINS.has(e.origin.kind) && e.command === undefined && e.isMeta === undefined
15}
16
17// Lowercase name -> body. Only `insert: inline` + `when: always` defs with a non-empty body; first wins per name.
18// A name that also has a context def is excluded entirely, so one token can never be inserted twice (inline + context).
19export function inlineBodies(defs: readonly Def[]): Map<string, string> {
20 const contextNames = new Set<string>()
21 for (const d of defs) if (d.insert === 'context') contextNames.add(d.name.toLowerCase())
22 const out = new Map<string, string>()
23 for (const d of defs) {
24 const key = d.name.toLowerCase()
25 if (d.insert !== 'inline' || d.when !== 'always' || d.body === '' || contextNames.has(key) || out.has(key)) continue
26 out.set(key, d.body)
27 }
28 return out
29}
30
31// One pass over the original text (no body re-scan here); the command hook later expands context tokens a body carries, as brewtools did. Null = nothing to change.
32// Non-always inline defs and empty bodies stay verbatim; inline text has no block header.
33export function expandInline(c: Compiled, text: string): string | null {
34 if (!text.includes(c.prefix)) return null
35 const bodies = inlineBodies(c.defs)
36 if (bodies.size === 0) return null
37 const known = scanDraft(c.matchers, text)
38 .tokens.filter((t) => t.isKnown)
39 .sort((a, b) => a.start - b.start || b.end - a.end)
40
41 let out = ''
42 let at = 0
43 let changed = false
44 for (const t of known) {
45 if (t.start < at) continue
46 const body = bodies.get(t.name)
47 if (body === undefined) continue
48 out += text.slice(at, t.start) + body
49 at = t.end
50 changed = true
51 }
52 return changed ? out + text.slice(at) : null
53}
54
55const MAX_NAMED = 5
56
57const CODE_SPAN = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g
58const CODE_NEXT = /[;)[\]]/
59
60// Unknown `prefix+word` tokens of a prompt: unique (case-insensitive), first-seen order, original spelling.
61// Code is not a typo: tokens inside fences or backticks, or closed by `;` `)` `[` `]` (`++i)`, `a[++i]`), are left out.
62export function unknownTokens(ms: Matchers, text: string): string[] {
63 const seen = new Set<string>()
64 const out: string[] = []
65 const plain = text.replace(CODE_SPAN, (m) => ' '.repeat(m.length))
66 for (const t of scanDraft(ms, plain).tokens) {
67 if (t.isKnown || seen.has(t.text.toLowerCase()) || CODE_NEXT.test(text.charAt(t.end))) continue
68 seen.add(t.text.toLowerCase())
69 out.push(t.text)
70 }
71 return out
72}
73
74export function unknownMessage(tokens: readonly string[]): string {
75 const shown = tokens.slice(0, MAX_NAMED).join(', ')
76 const more = tokens.length > MAX_NAMED ? ` and ${tokens.length - MAX_NAMED} more` : ''
77 return `unknown ${shown}${more} · sent as typed · names: /placeholders list`
78}
79