SLOPSHOPPER

brewmod-placeholders

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

newbandspinnerguardcommandtoast
★ 33v6.9.4MITupdated 2026-10-16kochetkov-ma/claude-brewcode/brewmod-placeholders
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brewmod-placeholders
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ brewmod-placeholders │ ⏺ Read(src/auth.ts) │ plugin files removed (updated?) · run │ ⎿ Read 6 lines │ /reload-plugins │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /placeholders ⎿ brewmod-placeholders: plugin files removed (updated?) · run /reload-plugins ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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+: run claude update first), then run /reload-plugins:

claude plugin marketplace add https://github.com/kochetkov-ma/claude-brewcode && claude plugin install brewmod-placeholders@claude-brewcode

Docs: https://doc-claude.brewcode.app/mods/placeholders/

Install

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.

Quick start

WantDo
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 typingPut 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

What Claude receives

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)
TokenNormal modePlan mode
++mManager 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)
++aArchitecture-first: delegate the design to expert agents before implementingsame 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
++rrAnti-regression review: regression, project standard and functional correctness axessame text: ++rr has no plan part
+++nothing[ADDON: ANTI-DRIFT CRON PLAN]
++rr + ++ronly ++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.

Keys on macOS

Hint on screenMac keyGlyphDoes
tabTab⇥Inserts the selected typeahead name
enterReturn⏎Sends the prompt. On ++r? alone it runs the query instead (how)
escEsc⎋Closes the typeahead, back to typing
↑ ↓ ← →arrow keys↑↓←→Move in the typeahead (↑↓) or onto a token to read its text (←→)
shift+tabShift + 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+oControl + O⌃OTranscript view: your prompt and the answer only. The injected text is NOT there; use the band or /placeholders show

Read the full text

Where you lookIt showsHow
The band above the promptUp to 15 rows of what Claude receives, cut row and plan note includedPut the caret on the token (arrows, Home, End); move it off to close
The chat, /placeholders show ++mThe whole text, printed locally; nothing is sent to ClaudeType it, or type ++m? alone and press Enter twice (below)
The chat, /placeholders listEvery placeholder with its text, normal and plan modeType 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 likeMeansIn 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 messagethe User typed ... rows
Underlined, theme text colourAn action: what to type or do/placeholders show ++m, ++m?, move the caret off ++m, ++/
Plain, theme text colourA key to press[Enter]
Grey (dim)Labels and notes; never sent. Text that is not sent now (the other mode's text) is grey tooclose:, 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.

RowText
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 tokenclose: move the caret off ++m · send as typed: ++/
Foot with the caret on an unknown tokennames: /placeholders list
Foot of the compact bandmore: put the caret on ++r · send as typed: ++/ · help: /placeholders help
Foot of an escaped messageturn back on: delete ++/
Other tokens while the caret is on onealso: ++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.

Examples

DoYou typeWhat happens
Add/placeholders add ++db: always use the staging DBClaude 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 stepsThe 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 shorterClaude starts from the current text; the change stays in its layer (a plugin default -> asks where)
Remove/placeholders remove ++dbAsks 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 ++shipAsks 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 + EnterPrints 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 listPrints 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 firstReply: saved ++lint (project): .claude/placeholders/lint.md; the project copy wins over global

Commands

CommandWhat 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, helpPrint 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)

Prompt box

StateWhat you see
Highlight, typeaheadTerminal 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 bandHeader 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 tokenThe 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 modsRows 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 ++/
UnknownAn 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

Your own files

FileMeans
<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: addReplaces 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.

  • With brewtools. brewtools 6.8.0+ ships no codeword hook, so this mod is the only provider of ++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.
  • Mod tools. Free-text /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.
  • Team and Enterprise organizations. The builtin security layer bypasses a mod's classic.* hooks, so a bundled command hook (hooks/inject.mjs) adds the blocks. Verified on Team-org and Max profiles; Enterprise was not run.
  • Limitations. The band learns plan mode only from your first prompt in it. A token inserted with 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 details

Docs | Project page | Source | All mods | Release notes | Author: Maksim Kochetkov | License: MIT

Source 13 files
hooks/register.ts 320 lines
1import { 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}
320
hooks/lib/editor-state.ts 32 lines
1// 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}
32
hooks/lib/editor.ts 91 lines
1// 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}
91
hooks/lib/match.ts 96 lines
1// 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}
96
hooks/lib/compiled.ts 16 lines
1// 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}
16
hooks/lib/defs.ts 389 lines
1// 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}
389
hooks/lib/mode.ts 19 lines
1// 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}
19
hooks/lib/band.tsx 487 lines
1// 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}
487
hooks/lib/sent.ts 193 lines
1// 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}
193
hooks/lib/command.ts 332 lines
1// /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}
332
hooks/lib/show.ts 193 lines
1// 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}
193
hooks/lib/inline.ts 79 lines
1// 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