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)
▌ SUGGEST D5S Stop at 15 until the wizard has run live.
▌ DEFAULT 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 suggest 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 ✕.
| 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 |
suggest | cyan | under its decision | Claude's recommendation on an open decision, drawn with the decision's id and an S, such as D5S |
default | magenta | A choice Claude made and acts on unless you 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.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.
Both mods draw replies. With attention outside copy-code, code boxes and coloured lines show together. If the order is reversed, a reply that holds code shows its attention lines as plain quotes. The band works either way.
/plugin install attention --marketplace AH-Merii/claude-pluginshooks/register.tsx 251 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', key: 'categories' } as const, [...DEFAULTS])
11const openState = atom({ plugin: 'attention', key: 'open' } as const, [])
12
13const CONFIG = 'attention.json'
14// The reply's left gutter, as Claude Code draws it: the bullet on a reply's first block, else blank.
15const GUTTER = 2
16// How a settled item is drawn: a green label, its text a dimmer green, since it only confirms what the user said.
17const SETTLED: Category = {
18 id: RESOLVED,
19 label: 'SETTLED',
20 color: 'green',
21 meaning: '',
22 pin: false,
23 dim: false,
24 tint: true,
25}
26
27// The config file sits beside Claude Code's own settings.
28async function configPath($: Engine) {
29 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
30 return `${dir.replace(/\/+$/, '')}/${CONFIG}`
31}
32
33async function load($: Engine): Promise<Config & { path: string }> {
34 const path = await configPath($)
35 if (!(await $.fs.exists(path))) {
36 return { path, ...configure(undefined) }
37 }
38 let raw: unknown
39 try {
40 raw = JSON.parse(await $.fs.read(path))
41 } catch {
42 return { path, ...configure(undefined), errors: ['not valid JSON; using the defaults'] }
43 }
44 return { path, ...configure(raw) }
45}
46
47// The config's errors last reported, so a broken file is named once, not on every request.
48let reported = ''
49
50// Reads the config into the session's state and names what in it can't be used.
51async function refresh($: Engine) {
52 const { path, categories, errors } = await load($)
53 const current = await read($, categoriesState)
54 if (JSON.stringify(current) !== JSON.stringify(categories)) {
55 await update($, categoriesState, () => categories)
56 }
57 const report = errors.join('; ')
58 if (report !== '' && report !== reported) {
59 $.ui.toast(`attention: ${path}: ${report}`)
60 }
61 reported = report
62 return categories
63}
64
65// Pinned items as of a reply: new and restated ones added or updated by id, settled ones dropped, and each
66// suggestion attached to the open decision it names.
67function track(open: readonly OpenItem[], items: readonly Item[], categories: readonly Category[]): OpenItem[] {
68 const pinned = new Set(categories.filter(c => c.pin).map(c => c.id))
69 const next = [...open]
70 for (const item of items) {
71 if (item.id === undefined || item.tag === SUGGEST) continue
72 const at = next.findIndex(o => o.id === item.id)
73 if (item.tag === RESOLVED) {
74 if (at >= 0) next.splice(at, 1)
75 } else if (pinned.has(item.tag)) {
76 const entry = { id: item.id, tag: item.tag, text: item.text }
77 if (at >= 0) next[at] = { ...next[at], ...entry }
78 else next.push(entry)
79 }
80 }
81 // After the decisions, so a suggestion finds one raised in the same reply wherever it sits.
82 for (const item of items) {
83 const at = item.tag === SUGGEST ? next.findIndex(o => o.id === item.id) : -1
84 const decision = next[at]
85 if (decision) next[at] = { ...decision, suggestion: item.text }
86 }
87 return next
88}
89
90// The id an item is drawn with: a suggestion's is its decision's, marked S.
91function shownId(item: Item) {
92 return item.id && (item.tag === SUGGEST ? `${item.id}S` : item.id)
93}
94
95// An item's text as Text can draw it: code spans kept apart so they can stand out, emphasis marks dropped.
96function spans(text: string) {
97 return text
98 .replace(/\*\*([^*]*)\*\*/g, '$1')
99 .split(/(`[^`]*`)/)
100 .filter(part => part !== '')
101 .map(part =>
102 part.startsWith('`') && part.endsWith('`')
103 ? { text: part.slice(1, -1), isCode: true }
104 : { text: part, isCode: false },
105 )
106}
107
108// Markdown a one-line Text can't draw: code spans and emphasis lose their marks.
109function plain(text: string) {
110 return text.replace(/`([^`]*)`/g, '$1').replace(/\*\*([^*]*)\*\*/g, '$1')
111}
112
113export const register: Register = on => {
114 on('session.start', async ($, e, next) => {
115 await refresh($)
116 return next(e)
117 })
118
119 // Only where a terminal draws the replies: elsewhere the tags would reach the reader as raw text.
120 on('prompt.compose', async ($, e, next) => {
121 const composed = await next(e)
122 if (!e.surfaces.includes('terminal')) {
123 return composed
124 }
125 const categories = await refresh($)
126 return {
127 sections: [...composed.sections, { id: 'attention:tags', text: instructions(categories), scope: 'session' }],
128 }
129 })
130
131 on('prompt.submit', async ($, e, next) => {
132 const open = await read($, openState)
133 // Only beside what the user typed: a slash command, a notification or a peer's message settles nothing.
134 if (e.origin.kind !== 'composer' || e.text.startsWith('/') || open.length === 0) {
135 return next(e)
136 }
137 return next({ ...e, context: [...(e.context ?? []), reminder(open)] })
138 })
139
140 on('turn.complete', async ($, e, next) => {
141 if (e.agentId === undefined && e.answer !== '') {
142 const categories = await read($, categoriesState)
143 const { items } = parse(e.answer, new Set(categories.map(c => c.id)))
144 if (items.some(i => i.id !== undefined)) {
145 await update($, openState, open => track(open, items, categories))
146 }
147 }
148 return next(e)
149 })
150
151 on('ui.render', { component: 'AssistantMessage', surface: 'terminal' }, async ($, e, next) => {
152 const categories = await read($, categoriesState)
153 const { items, rest } = parse(e.props.text, new Set(categories.map(c => c.id)))
154 if (items.length === 0) {
155 return next(e)
156 }
157
158 const { Box, Markdown, Text } = $.ui.resolve(e)
159 const body = rest.trim() === '' ? undefined : await next({ ...e, props: { ...e.props, text: rest } })
160 const styled = items.map(item => ({ item, category: categories.find(c => c.id === item.tag) ?? SETTLED }))
161 const width = Math.max(...styled.map(s => s.category.label.length))
162
163 return (
164 <Box flexDirection="column">
165 {body}
166 {styled.map(({ item, category }, i) => (
167 <Box key={`${e.requestId}:attention:${i}`} flexDirection="row" marginTop={i === 0 && body ? 1 : 0}>
168 <Box width={GUTTER} flexShrink={0}>
169 {i === 0 && !body && e.props.isFirstOfReply && <Text color="text">●</Text>}
170 </Box>
171 <Box flexShrink={0}>
172 <Text color={category.color} dimColor={category.dim}>
173 ▌
174 </Text>
175 <Text color={category.color} dimColor={category.dim} bold>
176 {` ${category.label.padEnd(width)} `}
177 </Text>
178 </Box>
179 <Box flexGrow={1} width={0}>
180 {category.tint ? (
181 // Markdown takes no colour, so a tinted item is drawn as Text in a shade of its category's colour.
182 <Text {...(shade(category.color) ? { color: shade(category.color) } : { dimColor: true })}>
183 {item.id && <Text color={category.color} bold>{`${shownId(item)} `}</Text>}
184 {spans(item.text).map(s =>
185 s.isCode ? (
186 <Text color={category.color} bold>
187 {s.text}
188 </Text>
189 ) : (
190 s.text
191 ),
192 )}
193 </Text>
194 ) : (
195 <Markdown text={item.id ? `**${shownId(item)}** ${item.text}` : item.text} dimColor={category.dim} />
196 )}
197 </Box>
198 </Box>
199 ))}
200 </Box>
201 )
202 })
203
204 on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
205 const open = await read($, openState)
206 if (e.props.hasSurvey || open.length === 0) {
207 return next(e)
208 }
209 const categories = await read($, categoriesState)
210 const suggest = categories.find(c => c.id === SUGGEST)
211 const { Box, Button, Text } = $.ui.resolve(e)
212
213 return (
214 <Box flexDirection="column">
215 {open.map(o => {
216 const color = categories.find(c => c.id === o.tag)?.color ?? 'text'
217 return (
218 <Box key={`attention:open:${o.id}`} flexDirection="column">
219 <Box flexDirection="row">
220 <Text color={color}>▌</Text>
221 <Text color={color} bold>{` ${o.id} `}</Text>
222 <Box flexGrow={1} width={0}>
223 <Text wrap="truncate-end">{plain(o.text)}</Text>
224 </Box>
225 <Button
226 key={`attention:dismiss:${o.id}`}
227 label=" ✕ "
228 plain
229 dimColor
230 onPress={() => update($, openState, list => list.filter(x => x.id !== o.id))}
231 />
232 </Box>
233 {suggest && o.suggestion !== undefined && (
234 <Box flexDirection="row">
235 <Text color={suggest.color}>▌</Text>
236 <Text color={suggest.color} bold>{` ${o.id}S `}</Text>
237 <Box flexGrow={1} width={0}>
238 <Text wrap="truncate-end" {...(shade(suggest.color) ? { color: shade(suggest.color) } : {})}>
239 {plain(o.suggestion)}
240 </Text>
241 </Box>
242 </Box>
243 )}
244 </Box>
245 )
246 })}
247 </Box>
248 )
249 })
250}
251hooks/categories.ts 159 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: 'suggest',
36 label: 'SUGGEST',
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: 'default',
47 label: 'DEFAULT',
48 color: 'magenta',
49 meaning: 'a choice you made yourself and are acting on unless the user says otherwise',
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 = 'suggest'
78
79const ID = /^[a-z][a-z0-9-]*$/
80const FIELDS = ['label', 'color', 'meaning', 'pin', 'dim', 'tint', 'enabled'] as const
81
82export type Config = { categories: Category[]; errors: string[] }
83
84// Lays the user's config over the defaults. Each key of `categories` is a category id: a default's id changes
85// only the fields given, `"enabled": false` drops it, and a new id adds a category, which needs a label, a colour
86// and a meaning. What can't be read is reported and left out, so one bad entry never costs the rest.
87export function configure(raw: unknown): Config {
88 const errors: string[] = []
89 const categories = DEFAULTS.map(c => ({ ...c }))
90 if (raw === undefined) {
91 return { categories, errors }
92 }
93 const overrides = isObject(raw) ? raw.categories : undefined
94 if (!isObject(raw) || (overrides !== undefined && !isObject(overrides))) {
95 return { categories, errors: ['expected {"categories": {"<id>": {...}}}; using the defaults'] }
96 }
97
98 const dropped = new Set<string>()
99 for (const [id, entry] of Object.entries(overrides ?? {})) {
100 const error = check(id, entry, categories)
101 if (error !== undefined) {
102 errors.push(`${id}: ${error}`)
103 continue
104 }
105 const fields = entry as Partial<Category> & { enabled?: boolean }
106 if (fields.enabled === false) {
107 dropped.add(id)
108 continue
109 }
110 const known = categories.find(c => c.id === id)
111 const { enabled: _, ...rest } = fields
112 if (known) {
113 Object.assign(known, rest)
114 } else {
115 categories.push({
116 id,
117 pin: false,
118 dim: false,
119 tint: true,
120 ...(rest as Omit<Category, 'id' | 'pin' | 'dim' | 'tint'>),
121 })
122 }
123 }
124 return { categories: categories.filter(c => !dropped.has(c.id)), errors }
125}
126
127function check(id: string, entry: unknown, categories: Category[]): string | undefined {
128 if (!ID.test(id) || id === RESOLVED) {
129 return `an id is lowercase letters, digits and dashes, and "${RESOLVED}" is taken`
130 }
131 if (!isObject(entry)) {
132 return 'expected an object'
133 }
134 const unknown = Object.keys(entry).filter(k => !(FIELDS as readonly string[]).includes(k))
135 if (unknown.length > 0) {
136 return `unknown field ${unknown.map(k => `"${k}"`).join(', ')}; the fields are ${FIELDS.join(', ')}`
137 }
138 for (const key of ['label', 'color', 'meaning'] as const) {
139 if (key in entry && (typeof entry[key] !== 'string' || entry[key].trim() === '')) {
140 return `"${key}" must be a non-empty string`
141 }
142 }
143 for (const key of ['pin', 'dim', 'tint', 'enabled'] as const) {
144 if (key in entry && typeof entry[key] !== 'boolean') {
145 return `"${key}" must be true or false`
146 }
147 }
148 const isNew = !categories.some(c => c.id === id)
149 const missing = (['label', 'color', 'meaning'] as const).filter(k => !(k in entry))
150 if (isNew && entry.enabled !== false && missing.length > 0) {
151 return `a new category needs ${missing.map(k => `"${k}"`).join(', ')}`
152 }
153 return undefined
154}
155
156function isObject(value: unknown): value is Record<string, unknown> {
157 return typeof value === 'object' && value !== null && !Array.isArray(value)
158}
159hooks/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 25 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.
18export type OpenItem = { id: string; tag: string; text: string; suggestion?: string }
19
20declare module 'claude-code' {
21 interface PluginState {
22 attention: { categories: Category[]; open: OpenItem[] }
23 }
24}
25