SLOPSHOPPER

attention

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

newbandrowstoastprompt
A shopper browsing a rack in a slop shop
README

attention

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?

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 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 ✕.

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
suggestcyanunder its decisionClaude's recommendation on an open decision, drawn with the decision's id and an S, such as D5S
defaultmagentaA choice Claude made and acts on unless you 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.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.

With copy-code

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.

Install

/plugin install attention --marketplace AH-Merii/claude-plugins
Source 6 files
hooks/register.tsx 251 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', 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}
251
hooks/categories.ts 159 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: '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}
159
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 25 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.
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