SLOPSHOPPER

attention-is-all-i-need

Colours what needs you at the end of Claude's replies (must-know, decide now, decide later, defaults, your actions) and pins open decisions above the prompt

bandrowstoastprompt
A shopper browsing a rack in a slop shop
README

attention-is-all-i-need: what needs you in a reply, highlighted

attention-is-all-i-need

A Claude Code mod for people who skim replies. Claude ends each reply with one tagged line for each thing that needs you, and the mod draws those lines in colour. Decisions stay pinned above the prompt until they're settled.

● Merged #72; CI is green. Next I start on slice 16.

  ▌ MUST KNOW       The Outlook wizard never ran against a real account.
  ▌ DECIDE NOW      D4 File both issues as drafted?
  ▌ DECIDE LATER    D5 Go past slice 15? (after D4)
  ▌ SUGGESTION      D5S Stop at 15 until the wizard has run live.
  ▌ PROCEEDING WITH D6 Classifying runs only when asked. Say so to turn it on in the background.
  ▌ YOUR ACTION     Run ! rm -rf ~/.azure
  ▌ OPTIONAL        Want me to close the stale issues?

Why

An audit of 14 sessions of skimmed replies found four patterns:

  • Asks were missed when they sat in the middle of a long reply, or when several were stacked. Asks phrased as statements ("unless you say otherwise", "tell me if…") were also missed.
  • A default Claude acted on "unless you say otherwise" shipped, and needed a PR to undo.
  • Questions asked just before a task notification or a /compact were lost.
  • Users asked about things the reply had already said, in a middle bullet.

So the mod asks Claude for these things:

  • Put everything that needs you at the end, one short line per item.
  • Ask each choice as a question.
  • Give every item you may answer an id, such as D4. You can then reply "D4: why not X?" in prose and ask about an item before you answer it.

When Claude's reply weighs in on an open decision, the recommendation goes in a suggestion line as well as in the prose, so it isn't lost in a paragraph. The band shows the latest one under its decision until the decision is settled.

Pinned items carry over to Claude with each message you type, so a /compact doesn't lose them. Each one stays above the prompt until Claude marks it settled, or until you dismiss it with its ✕. The 󰍉 beside it scrolls back to the reply that first raised it. If that reply is no longer in the transcript, a toast says so and the item stays pinned. Without a Nerd Font the button is drawn as 🔍; the nerd-font mod, which attention-is-all-i-need depends on, decides which.

Categories

TagColourPinnedFor
must-knowredA risk, a surprise, a change you didn't ask for, or work that was skipped, unverified or failed
decide-noworangeyesA choice the current task needs
decide-lateryellowyesA choice a later task needs
suggestioncyanunder its decisionClaude's recommendation on an open decision, drawn with the decision's id and an S, such as D5S
proceeding-withmagentaA choice Claude made and is acting on, with the words that overrule it
your-actioncyanSomething only you can do: run a command, sign in, restart something
optionaldimAn offer or a nice-to-know you can skip

Configuring

Ask Claude to change it, e.g. "make decide-now red" or "add a category for failed CI checks". The attention skill edits attention-is-all-i-need.json in Claude Code's config directory ($CLAUDE_CONFIG_DIR, or ~/.claude). The file holds only what differs from the defaults:

{
  "categories": {
    "decide-now": { "color": "#ff5f00" },
    "optional": { "enabled": false },
    "ci-red": { "label": "CI RED", "color": "red", "meaning": "a check that failed and blocks the merge", "pin": true }
  }
}

The fields are label, color, meaning, pin, dim, tint (the item's text in a dimmer shade of the label's colour, on by default) and enabled. A colour is a name, a hex value, or a Claude Code theme key. Changes apply from your next message. If an entry can't be used, a toast names it and the rest still apply.

Terminal only

The colours and the band are drawn in the terminal. In print mode, the Desktop app or VS Code, Claude isn't asked for the tags, so replies read as before.

Install

/plugin install attention-is-all-i-need --marketplace AH-Merii/claude-plugins
Source 6 files
hooks/register.tsx 302 lines
1import type { EngineInterface as Engine, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Category, OpenItem } from '../types'
5import { type Config, configure, DEFAULTS, RESOLVED, SUGGEST } from './categories'
6import { type Item, parse } from './parse'
7import { instructions, reminder } from './prompt'
8import { shade } from './shade'
9
10const categoriesState = atom({ plugin: 'attention-is-all-i-need', key: 'categories' } as const, [...DEFAULTS])
11const openState = atom({ plugin: 'attention-is-all-i-need', key: 'open' } as const, [])
12
13const CONFIG = 'attention-is-all-i-need.json'
14// The jump button's label: nf-md-magnify when the nerd-font mod says the terminal has a Nerd Font, else the emoji.
15const MAGNIFY = '\u{F0349}'
16const PLAIN_MAGNIFY = '🔍'
17// nerd-font's answer: true, false, or undefined until its check has run. Only true means glyphs.
18const nerdFont = { plugin: 'nerd-font', key: 'enabled' } as const
19// The reply's left gutter, as Claude Code draws it: the bullet on a reply's first block, else blank.
20const GUTTER = 2
21// How a settled item is drawn: a green label, its text a dimmer green, since it only confirms what the user said.
22const SETTLED: Category = {
23  id: RESOLVED,
24  label: 'SETTLED',
25  color: 'green',
26  meaning: '',
27  pin: false,
28  dim: false,
29  tint: true,
30}
31
32// The config file sits beside Claude Code's own settings.
33async function configPath($: Engine) {
34  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
35  return `${dir.replace(/\/+$/, '')}/${CONFIG}`
36}
37
38async function load($: Engine): Promise<Config & { path: string }> {
39  const path = await configPath($)
40  if (!(await $.fs.exists(path))) {
41    return { path, ...configure(undefined) }
42  }
43  let raw: unknown
44  try {
45    raw = JSON.parse(await $.fs.read(path))
46  } catch {
47    return { path, ...configure(undefined), errors: ['not valid JSON; using the defaults'] }
48  }
49  return { path, ...configure(raw) }
50}
51
52// The config's errors last reported, so a broken file is named once, not on every request.
53let reported = ''
54
55// Where each item id was first written this turn: the message id of the reply block that holds it. A reply's
56// blocks are stored as the turn goes, before turn.complete, which carries no message ids.
57let raisedIn = new Map<string, string>()
58
59// Reads the config into the session's state and names what in it can't be used.
60async function refresh($: Engine) {
61  const { path, categories, errors } = await load($)
62  const current = await read($, categoriesState)
63  if (JSON.stringify(current) !== JSON.stringify(categories)) {
64    await update($, categoriesState, () => categories)
65  }
66  const report = errors.join('; ')
67  if (report !== '' && report !== reported) {
68    $.ui.toast(`attention-is-all-i-need: ${path}: ${report}`)
69  }
70  reported = report
71  return categories
72}
73
74// Pinned items as of a reply: new and restated ones added or updated by id, settled ones dropped, and each
75// suggestion attached to the open decision it names.
76function track(
77  open: readonly OpenItem[],
78  items: readonly Item[],
79  categories: readonly Category[],
80  raised: ReadonlyMap<string, string>,
81): OpenItem[] {
82  const pinned = new Set(categories.filter(c => c.pin).map(c => c.id))
83  const next = [...open]
84  for (const item of items) {
85    if (item.id === undefined || item.tag === SUGGEST) continue
86    const at = next.findIndex(o => o.id === item.id)
87    if (item.tag === RESOLVED) {
88      if (at >= 0) next.splice(at, 1)
89    } else if (pinned.has(item.tag)) {
90      const entry = { id: item.id, tag: item.tag, text: item.text }
91      // A restatement keeps where the item was first raised.
92      if (at >= 0) next[at] = { ...next[at], ...entry }
93      else next.push({ ...entry, from: raised.get(item.id) })
94    }
95  }
96  // After the decisions, so a suggestion finds one raised in the same reply wherever it sits.
97  for (const item of items) {
98    const at = item.tag === SUGGEST ? next.findIndex(o => o.id === item.id) : -1
99    const decision = next[at]
100    if (decision) next[at] = { ...decision, suggestion: item.text }
101  }
102  return next
103}
104
105// The id an item is drawn with: a suggestion's is its decision's, marked S.
106function shownId(item: Item) {
107  return item.id && (item.tag === SUGGEST ? `${item.id}S` : item.id)
108}
109
110// An item's text as Text can draw it: code spans kept apart so they can stand out, emphasis marks dropped.
111function spans(text: string) {
112  return text
113    .replace(/\*\*([^*]*)\*\*/g, '$1')
114    .split(/(`[^`]*`)/)
115    .filter(part => part !== '')
116    .map(part =>
117      part.startsWith('`') && part.endsWith('`')
118        ? { text: part.slice(1, -1), isCode: true }
119        : { text: part, isCode: false },
120    )
121}
122
123// Markdown a one-line Text can't draw: code spans and emphasis lose their marks.
124function plain(text: string) {
125  return text.replace(/`([^`]*)`/g, '$1').replace(/\*\*([^*]*)\*\*/g, '$1')
126}
127
128// Scrolls the transcript to the reply that first raised an item, its top at the top of the screen; says so when
129// that reply is gone, as after a rewind summarizes it away. The item stays pinned either way.
130async function jump($: Engine, id: string) {
131  const from = (await read($, openState)).find(o => o.id === id)?.from
132  const { deny } =
133    from === undefined
134      ? { deny: 'not recorded' }
135      : await $.ui.scroll({ to: { requestId: from }, block: 'start' }).catch((err: unknown) => ({ deny: String(err) }))
136  if (deny !== undefined) {
137    $.ui.toast(`attention-is-all-i-need: the reply that raised ${id} is no longer in the transcript (${deny})`)
138  }
139}
140
141export const register: Register = on => {
142  on('session.start', async ($, e, next) => {
143    await refresh($)
144    return next(e)
145  })
146
147  // Only where a terminal draws the replies: elsewhere the tags would reach the reader as raw text.
148  on('prompt.compose', async ($, e, next) => {
149    const composed = await next(e)
150    if (!e.surfaces.includes('terminal')) {
151      return composed
152    }
153    const categories = await refresh($)
154    return {
155      sections: [...composed.sections, { id: 'attention:tags', text: instructions(categories), scope: 'session' }],
156    }
157  })
158
159  on('prompt.submit', async ($, e, next) => {
160    const open = await read($, openState)
161    // Only beside what the user typed: a slash command, a notification or a peer's message settles nothing.
162    if (e.origin.kind !== 'composer' || e.text.startsWith('/') || open.length === 0) {
163      return next(e)
164    }
165    return next({ ...e, context: [...(e.context ?? []), reminder(open)] })
166  })
167
168  on('session.append', { door: 'response' }, async ($, e, next) => {
169    if (e.agentId === undefined) {
170      const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
171      const categories = await read($, categoriesState)
172      for (const { id } of parse(text, new Set(categories.map(c => c.id))).items) {
173        if (id !== undefined && !raisedIn.has(id)) raisedIn.set(id, e.uuid)
174      }
175    }
176    return next(e)
177  })
178
179  on('turn.complete', async ($, e, next) => {
180    if (e.agentId === undefined) {
181      const raised = raisedIn
182      raisedIn = new Map()
183      const categories = await read($, categoriesState)
184      const { items } = parse(e.answer, new Set(categories.map(c => c.id)))
185      if (items.some(i => i.id !== undefined)) {
186        await update($, openState, open => track(open, items, categories, raised))
187      }
188    }
189    return next(e)
190  })
191
192  on('ui.render', { component: 'AssistantMessage', surface: 'terminal' }, async ($, e, next) => {
193    const categories = await read($, categoriesState)
194    const { items, rest } = parse(e.props.text, new Set(categories.map(c => c.id)))
195    if (items.length === 0) {
196      return next(e)
197    }
198
199    const { Box, Markdown, Text } = $.ui.resolve(e)
200    const body = rest.trim() === '' ? undefined : await next({ ...e, props: { ...e.props, text: rest } })
201    const styled = items.map(item => ({ item, category: categories.find(c => c.id === item.tag) ?? SETTLED }))
202    const width = Math.max(...styled.map(s => s.category.label.length))
203
204    return (
205      <Box flexDirection="column">
206        {body}
207        {styled.map(({ item, category }, i) => (
208          <Box key={`${e.requestId}:attention:${i}`} flexDirection="row" marginTop={i === 0 && body ? 1 : 0}>
209            <Box width={e.props.isFirstOfReply ? GUTTER : 0} flexShrink={0}>
210              {i === 0 && !body && e.props.isFirstOfReply && <Text color="text">●</Text>}
211            </Box>
212            <Box flexShrink={0}>
213              <Text color={category.color} dimColor={category.dim}>
214                ▌
215              </Text>
216              <Text color={category.color} dimColor={category.dim} bold>
217                {` ${category.label.padEnd(width)} `}
218              </Text>
219            </Box>
220            <Box flexGrow={1} width={0}>
221              {category.tint ? (
222                // Markdown takes no colour, so a tinted item is drawn as Text in a shade of its category's colour.
223                <Text {...(shade(category.color) ? { color: shade(category.color) } : { dimColor: true })}>
224                  {item.id && <Text color={category.color} bold>{`${shownId(item)} `}</Text>}
225                  {spans(item.text).map(s =>
226                    s.isCode ? (
227                      <Text color={category.color} bold>
228                        {s.text}
229                      </Text>
230                    ) : (
231                      s.text
232                    ),
233                  )}
234                </Text>
235              ) : (
236                <Markdown text={item.id ? `**${shownId(item)}** ${item.text}` : item.text} dimColor={category.dim} />
237              )}
238            </Box>
239          </Box>
240        ))}
241      </Box>
242    )
243  })
244
245  on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
246    const open = await read($, openState)
247    if (e.props.hasSurvey || open.length === 0) {
248      return next(e)
249    }
250    const categories = await read($, categoriesState)
251    const suggest = categories.find(c => c.id === SUGGEST)
252    // Read while drawing, so the band redraws when nerd-font answers or its setting changes.
253    const { value: hasNerdFont } = await $.state.get(nerdFont)
254    const magnify = hasNerdFont === true ? MAGNIFY : PLAIN_MAGNIFY
255    const { Box, Button, Text } = $.ui.resolve(e)
256
257    return (
258      <Box flexDirection="column">
259        {open.map(o => {
260          const color = categories.find(c => c.id === o.tag)?.color ?? 'text'
261          return (
262            <Box key={`attention:open:${o.id}`} flexDirection="column">
263              <Box flexDirection="row">
264                <Text color={color}>▌</Text>
265                <Text color={color} bold>{` ${o.id} `}</Text>
266                <Box flexGrow={1} width={0}>
267                  <Text wrap="truncate-end">{plain(o.text)}</Text>
268                </Box>
269                <Button
270                  key={`attention:jump:${o.id}`}
271                  label={` ${magnify} `}
272                  plain
273                  dimColor
274                  onPress={() => jump($, o.id)}
275                />
276                <Button
277                  key={`attention:dismiss:${o.id}`}
278                  label=" ✕ "
279                  plain
280                  dimColor
281                  onPress={() => update($, openState, list => list.filter(x => x.id !== o.id))}
282                />
283              </Box>
284              {suggest && o.suggestion !== undefined && (
285                <Box flexDirection="row">
286                  <Text color={suggest.color}>▌</Text>
287                  <Text color={suggest.color} bold>{` ${o.id}S `}</Text>
288                  <Box flexGrow={1} width={0}>
289                    <Text wrap="truncate-end" {...(shade(suggest.color) ? { color: shade(suggest.color) } : {})}>
290                      {plain(o.suggestion)}
291                    </Text>
292                  </Box>
293                </Box>
294              )}
295            </Box>
296          )
297        })}
298      </Box>
299    )
300  })
301}
302
hooks/categories.ts 162 lines
1import type { Category } from '../types'
2
3// The defaults come from an audit of 14 sessions of skimmed replies: what got missed, and what it cost.
4export const DEFAULTS: readonly Category[] = [
5  {
6    id: 'must-know',
7    label: 'MUST KNOW',
8    color: 'red',
9    meaning:
10      'something the user must read before going on: a risk, a surprising finding, a change you made that they ' +
11      'did not ask for, or work you skipped, could not verify or that failed',
12    pin: false,
13    dim: false,
14    tint: true,
15  },
16  {
17    id: 'decide-now',
18    label: 'DECIDE NOW',
19    color: '#ff8700',
20    meaning: 'a choice you need from the user before you can finish the current task',
21    pin: true,
22    dim: false,
23    tint: true,
24  },
25  {
26    id: 'decide-later',
27    label: 'DECIDE LATER',
28    color: 'yellow',
29    meaning: 'a choice a later task needs; you can carry on without it for now',
30    pin: true,
31    dim: false,
32    tint: true,
33  },
34  {
35    id: 'suggestion',
36    label: 'SUGGESTION',
37    color: 'cyan',
38    meaning:
39      'your recommendation on a decision still open, under the id of that decision (`D6: …`); whenever the reply ' +
40      'weighs in on an open decision, the recommendation goes here as well as in the prose',
41    pin: false,
42    dim: false,
43    tint: true,
44  },
45  {
46    id: 'proceeding-with',
47    label: 'PROCEEDING WITH',
48    color: 'magenta',
49    meaning: 'a choice you made yourself and are acting on; say what you chose and the words that overrule it',
50    pin: false,
51    dim: false,
52    tint: true,
53  },
54  {
55    id: 'your-action',
56    label: 'YOUR ACTION',
57    color: 'cyan',
58    meaning: 'something only the user can do: run a command, sign in, restart something, check something on screen',
59    pin: false,
60    dim: false,
61    tint: true,
62  },
63  {
64    id: 'optional',
65    label: 'OPTIONAL',
66    color: 'inactive',
67    meaning: 'an offer or a nice-to-know the user can safely skip',
68    pin: false,
69    dim: true,
70    tint: true,
71  },
72]
73
74// The tag that closes a pinned item; not a category of its own.
75export const RESOLVED = 'resolved'
76// The category whose items attach to a pinned decision, by its id, instead of standing alone.
77export const SUGGEST = 'suggestion'
78// Ids the categories had before they were renamed, so an older attention-is-all-i-need.json still reaches them.
79const RENAMED: Record<string, string> = { suggest: 'suggestion', default: 'proceeding-with' }
80
81const ID = /^[a-z][a-z0-9-]*$/
82const FIELDS = ['label', 'color', 'meaning', 'pin', 'dim', 'tint', 'enabled'] as const
83
84export type Config = { categories: Category[]; errors: string[] }
85
86// Lays the user's config over the defaults. Each key of `categories` is a category id: a default's id changes
87// only the fields given, `"enabled": false` drops it, and a new id adds a category, which needs a label, a colour
88// and a meaning. What can't be read is reported and left out, so one bad entry never costs the rest.
89export function configure(raw: unknown): Config {
90  const errors: string[] = []
91  const categories = DEFAULTS.map(c => ({ ...c }))
92  if (raw === undefined) {
93    return { categories, errors }
94  }
95  const overrides = isObject(raw) ? raw.categories : undefined
96  if (!isObject(raw) || (overrides !== undefined && !isObject(overrides))) {
97    return { categories, errors: ['expected {"categories": {"<id>": {...}}}; using the defaults'] }
98  }
99
100  const dropped = new Set<string>()
101  for (const [key, entry] of Object.entries(overrides ?? {})) {
102    const id = RENAMED[key] ?? key
103    const error = check(id, entry, categories)
104    if (error !== undefined) {
105      errors.push(`${key}: ${error}`)
106      continue
107    }
108    const fields = entry as Partial<Category> & { enabled?: boolean }
109    if (fields.enabled === false) {
110      dropped.add(id)
111      continue
112    }
113    const known = categories.find(c => c.id === id)
114    const { enabled: _, ...rest } = fields
115    if (known) {
116      Object.assign(known, rest)
117    } else {
118      categories.push({
119        id,
120        pin: false,
121        dim: false,
122        tint: true,
123        ...(rest as Omit<Category, 'id' | 'pin' | 'dim' | 'tint'>),
124      })
125    }
126  }
127  return { categories: categories.filter(c => !dropped.has(c.id)), errors }
128}
129
130function check(id: string, entry: unknown, categories: Category[]): string | undefined {
131  if (!ID.test(id) || id === RESOLVED) {
132    return `an id is lowercase letters, digits and dashes, and "${RESOLVED}" is taken`
133  }
134  if (!isObject(entry)) {
135    return 'expected an object'
136  }
137  const unknown = Object.keys(entry).filter(k => !(FIELDS as readonly string[]).includes(k))
138  if (unknown.length > 0) {
139    return `unknown field ${unknown.map(k => `"${k}"`).join(', ')}; the fields are ${FIELDS.join(', ')}`
140  }
141  for (const key of ['label', 'color', 'meaning'] as const) {
142    if (key in entry && (typeof entry[key] !== 'string' || entry[key].trim() === '')) {
143      return `"${key}" must be a non-empty string`
144    }
145  }
146  for (const key of ['pin', 'dim', 'tint', 'enabled'] as const) {
147    if (key in entry && typeof entry[key] !== 'boolean') {
148      return `"${key}" must be true or false`
149    }
150  }
151  const isNew = !categories.some(c => c.id === id)
152  const missing = (['label', 'color', 'meaning'] as const).filter(k => !(k in entry))
153  if (isNew && entry.enabled !== false && missing.length > 0) {
154    return `a new category needs ${missing.map(k => `"${k}"`).join(', ')}`
155  }
156  return undefined
157}
158
159function isObject(value: unknown): value is Record<string, unknown> {
160  return typeof value === 'object' && value !== null && !Array.isArray(value)
161}
162
hooks/parse.ts 51 lines
1import { RESOLVED } from './categories'
2
3export type Item = { tag: string; id?: string; text: string }
4
5// `> [!decide-now] D2: Merge as is?`: a blockquote led by a tag, with an optional id such as D2.
6const ITEM = /^ {0,3}>\s?\[!([a-z][a-z0-9-]*)\]\s*(?:([A-Z]\d+)\s*[:—–-]\s*)?(.*)$/
7// A blockquote line that carries on the item above it.
8const MORE = /^ {0,3}>\s?(?!\s*\[!)(.*\S.*)$/
9// A code fence, which may quote the syntax without meaning it.
10const FENCE = /^ {0,3}(`{3,}|~{3,})/
11
12// Takes the tagged lines out of a reply: the items, in the order written, and the text left without them.
13// Tags outside `tags` (and `resolved`) are not this mod's, so they stay in the text.
14export function parse(text: string, tags: ReadonlySet<string>): { items: Item[]; rest: string } {
15  const items: Item[] = []
16  const rest: string[] = []
17  let fence: string | undefined
18  let last: Item | undefined
19
20  for (const line of text.split('\n')) {
21    const open = FENCE.exec(line)?.[1]
22    if (fence !== undefined) {
23      if (open !== undefined && open[0] === fence[0] && open.length >= fence.length) fence = undefined
24      rest.push(line)
25      continue
26    }
27    if (open !== undefined) {
28      fence = open
29      last = undefined
30      rest.push(line)
31      continue
32    }
33
34    const item = ITEM.exec(line)
35    const tag = item?.[1]
36    if (item && tag !== undefined && (tags.has(tag) || tag === RESOLVED)) {
37      last = { tag, ...(item[2] ? { id: item[2] } : {}), text: (item[3] ?? '').trim() }
38      items.push(last)
39      continue
40    }
41    const more = last && MORE.exec(line)
42    if (last && more) {
43      last.text = `${last.text} ${(more[1] ?? '').trim()}`.trim()
44      continue
45    }
46    last = undefined
47    rest.push(line)
48  }
49  return { items: items.filter(i => i.text !== '' || i.tag === RESOLVED), rest: rest.join('\n').trimEnd() }
50}
51
hooks/prompt.ts 44 lines
1import type { Category, OpenItem } from '../types'
2import { RESOLVED } from './categories'
3
4// What Claude is told, built from the categories so a configured one is explained like a default.
5export function instructions(categories: readonly Category[]): string {
6  const pinned = categories.filter(c => c.pin).map(c => c.id)
7  const example = pinned[0] ?? categories[0]?.id ?? 'decide-now'
8  return [
9    'The user skims your replies, and misses what sits in the middle of them. The attention mod draws tagged ' +
10      'lines in colour at the end of each reply, so anything the user must read, decide or do goes there.',
11    '',
12    'End the reply with one line per item, each a blockquote led by its tag:',
13    '',
14    `> [!${example}] D3: Merge #41 as is, or clean up first?`,
15    '',
16    'The tags:',
17    ...categories.map(c => `- \`${c.id}\`: ${c.meaning}.`),
18    '',
19    'Rules:',
20    '- One short line per item. The detail belongs in the reply above; the line names the point.',
21    '- Ask each choice as a question ending in "?", one choice per item. Order items so that the ones others ' +
22      'depend on come first, and say so on the dependent one: "(after D2)".',
23    `- Give every item the user may answer an id: D1, D2, … counting up through the session, never reused. ${
24      pinned.length > 0 ? `${pinned.map(p => `\`${p}\``).join(' and ')} items stay pinned above the prompt until ` : ''
25    }you mark them settled.`,
26    `- When the user settles an item, or it no longer applies, add \`> [!${RESOLVED}] D3: <what was decided>\`. ` +
27      'The user may answer in prose or ask about an item first; an item they only asked about stays open.',
28    '- Raise each item once. Restate an earlier one only when it changed, under the same id: repeated lines ' +
29      'turn into wallpaper the user stops reading, and pinned items are already shown above the prompt.',
30    '- A default you are acting on says what you chose and the words that overrule it ("Say X to change it"); ' +
31      'never "unless you say otherwise".',
32    '- Leave the block out when there is nothing for the user. Never put the tags inside code blocks.',
33  ].join('\n')
34}
35
36// The note beside each prompt while items are pinned, so they survive a compaction and Claude can close them.
37export function reminder(open: readonly OpenItem[]): string {
38  return [
39    'attention: these items are still open, pinned above the prompt:',
40    ...open.map(o => `- ${o.id} [${o.tag}] ${o.text}${o.suggestion ? ` Your suggestion: ${o.suggestion}` : ''}`),
41    `If this message settles one, end your reply with \`> [!${RESOLVED}] <id>: <what was decided>\` for it.`,
42  ].join('\n')
43}
44
hooks/shade.ts 38 lines
1// The colour names Ink takes, as Claude Code draws them: its own RGB values, whatever the terminal's palette.
2// Measured from the pixels of each name drawn in Claude Code 2.1.299 under its dark theme, in Ghostty.
3const NAMED: Record<string, string> = {
4  black: '#000000',
5  red: '#e6464c',
6  green: '#44a857',
7  yellow: '#f5da03',
8  blue: '#3c63de',
9  magenta: '#d73ea0',
10  cyan: '#01a3c3',
11  white: '#ffffff',
12  gray: '#8c8e99',
13  grey: '#8c8e99',
14  blackBright: '#8c8e99',
15  redBright: '#ff6369',
16  greenBright: '#5bd180',
17  yellowBright: '#ffe724',
18  blueBright: '#5b8eef',
19  magentaBright: '#ef6ac3',
20  cyanBright: '#3cc3e1',
21  whiteBright: '#ffffff',
22}
23
24// How much of the colour a shade keeps.
25const KEEP = 0.95
26
27// A dimmer shade of a colour, as hex: a hex colour as given, a named one as Claude Code draws it. Undefined for a
28// theme key, whose value the mod can't read. Claude Code's dimColor draws dim text grey whatever its colour, so
29// the shade is worked out here.
30export function shade(color: string): string | undefined {
31  const hex = /^#[0-9a-f]{6}$/i.test(color) ? color : NAMED[color]
32  if (hex === undefined) {
33    return undefined
34  }
35  const channels = [1, 3, 5].map(i => Math.round(Number.parseInt(hex.slice(i, i + 2), 16) * KEEP))
36  return `#${channels.map(c => c.toString(16).padStart(2, '0')).join('')}`
37}
38
types/index.d.ts 26 lines
1// A kind of thing to pay attention to, and how its lines are drawn.
2export type Category = {
3  id: string
4  label: string
5  // A theme key (`error`, `warning`, …) or a raw colour: a name such as `red`, or hex.
6  color: string
7  // What the item is, as Claude is told it.
8  meaning: string
9  // Kept in the band above the prompt until Claude marks it resolved.
10  pin: boolean
11  // Drawn dim: an item the reader may skip.
12  dim: boolean
13  // The item's text drawn in a dimmer shade of the category's colour; on unless a category turns it off.
14  tint: boolean
15}
16
17// A pinned item still waiting on the user, with Claude's latest recommendation on it, and the message id of the
18// reply that first raised it, where the band's magnifier scrolls to.
19export type OpenItem = { id: string; tag: string; text: string; suggestion?: string; from?: string }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'attention-is-all-i-need': { categories: Category[]; open: OpenItem[] }
24  }
25}
26