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

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?
An audit of 14 sessions of skimmed replies found four patterns:
/compact were lost.So the mod asks Claude for these things:
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.
| Tag | Colour | Pinned | For |
|---|---|---|---|
must-know | red | A risk, a surprise, a change you didn't ask for, or work that was skipped, unverified or failed | |
decide-now | orange | yes | A choice the current task needs |
decide-later | yellow | yes | A choice a later task needs |
suggestion | cyan | under its decision | Claude's recommendation on an open decision, drawn with the decision's id and an S, such as D5S |
proceeding-with | magenta | A choice Claude made and is acting on, with the words that overrule it | |
your-action | cyan | Something only you can do: run a command, sign in, restart something | |
optional | dim | An offer or a nice-to-know you can skip |
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.
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.
/plugin install attention-is-all-i-need --marketplace AH-Merii/claude-pluginshooks/register.tsx 302 lines1import 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}
302hooks/categories.ts 162 lines1import 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}
162hooks/parse.ts 51 lines1import { 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}
51hooks/prompt.ts 44 lines1import 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}
44hooks/shade.ts 38 lines1// 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}
38types/index.d.ts 26 lines1// 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