SLOPSHOPPER

context-board

Compact context card above the prompt: stacked category bar, percent, a wrapping legend, and a one-line kit strip of loaded skills, agents, CLAUDE.md, and…

newpanebandguardcommandstatus
v0.1.0no licenseupdated 2026-10-06yogeshvar/mod-claude/plugins/context-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-board
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ◆ context 97.4k of 200k 49% │ ⏺ Read(src/auth.ts) │ ┃ │ ████████████████████████████████████████ │ ⎿ Read 6 lines │ ┃ │ ██████████ │ ⏺ Update(src/auth.ts) │ ┃ │ ■ free 200k │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ r: refresh x: close │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /context-board │ ⎿ context-board: Opened the full context pane. The same breakdown │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ context 97.4k of 200k 49% │ │ ██████████████████████████████████████████████████████████████████████████████████████████████ │ │ ■ free 200k │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ context-board: ctx 49% · 97.4k of 200k

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ context 97.4k of 200k 49% │ │ ██████████████████████████████████████████████████████████████████████████████████████████████ │ │ ■ free 200k │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · Context
╭──────────────────────────────────────────────────────────╮ │ ◆ context 97.4k of 200k 49% │ │ ██████████████████████████████████████████████████ │ │ ■ free 200k │ │ │ │ r: refresh x: close │ ╰──────────────────────────────────────────────────────────╯
README

Claude Code mods

A small CLI cockpit for teams that already have agents, hooks, and instructions, and that still use Claude Code in the terminal (including Bedrock). Mods draw live UI and can hold a tool call. They do not replace those files.

  1. context-board — compact context card above the prompt, plus a one-line kit strip of what this session actually loaded
  2. agents-side — a pane of the main loop and every subagent it spawns
  3. session-map — files Claude read, edited, or wrote this session, grouped by monorepo app
  4. blast-radius — holds risky Bash until you Proceed or Cancel

Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Install

claude plugin marketplace add yogeshvar/mod-claude
claude plugin install agents-side@mod-claude
claude plugin install context-board@mod-claude
claude plugin install session-map@mod-claude
claude plugin install blast-radius@mod-claude

Or clone and load for one session:

git clone https://github.com/yogeshvar/mod-claude.git
cd mod-claude
./start-claude

Then:

  1. Stretch the terminal to at least 110 columns (144 if you want unasked panes to auto-dock). Run /tui fullscreen so panes sit beside the transcript.
  2. The context card sits above the prompt: used vs window, compact threshold, a stacked color bar, a small legend, and a kit line (skills 4 · agents 2 · CLAUDE.md · plugins …).
  3. Ask Claude to spawn a couple of subagents. If the agents pane did not auto-open, run /agents-side.
  4. Run /session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.
  5. A pnpm migrate, git reset --hard, git push --force, DROP TABLE, rm -rf, or rm .env opens Blast Radius. 1 proceeds, 2 cancels. This is a safety net, not a permission system — keep hard denies in managed settings.

Commands

PluginCommandWhat it does
agents-side/agents-sideOpen or close the pane
agents-side/agents-side clearDrop finished agents
agents-side/agents-side closeClose the pane
context-board/context-boardOpen or close the pane (the bar stays)
context-board/context-board refreshRecount every category with the token-count API
context-board/context-board paneForce-open the pane
context-board/context-board closeClose the pane
session-map/session-mapOpen or close the file pane
session-map/session-map clearDrop the file list
session-map/session-map closeClose the pane
blast-radius/blast-radiusRe-open the hold pane if a command is waiting
blast-radius/blast-radius closeCancel the held command

On the agents pane, keys 1–9 select a row (focus the pane first: click it, or ctrl+x tab). c clears finished agents, x closes it.

On the context pane: r refresh, x close.

On the session map: c clear, x close.

On Blast Radius: 1 Proceed, 2 Cancel.

openOnStart (agents pane; session-map, off by default) and openPaneOnStart (context pane, off by default) are plugin options under /config.

Bedrock and team machines

Claude Code on Amazon Bedrock / an API key does not load the built-in sec-default guard unless you push managed settings. Without that, a user-installed mod can override deny rules.

For the team:

  1. Allow this marketplace (and block unknown ones) in managed settings.
  2. Put sec-default in prependPlugins.
  3. Keep production denies in permission rules. Blast Radius only holds commands whose text looks risky ($(…) and scripts that call rm can still get past it).
  4. On Bedrock, $.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.

What each one shows

context-board calls $.session.usage({ breakdown: "summary" }) after each main-loop turn (local estimates, no extra API). /context-board refresh uses breakdown: "full". The kit strip reads enabled plugins from settings, CLAUDE.md / AGENTS.md from the instruction walk, skill and agent counts from usage, and the last Skill tool name.

agents-side watches agent.spawn, tool.call, turn.start, turn.complete, and session.measure. It never blocks those events.

session-map observes Read, Edit, Write, and Glob after they run. It never blocks.

blast-radius classifies Bash, dry-runs git status / git clean -n when it can, and holds the call until Proceed or Cancel.

Check the plugins

claude plugin validate .
claude plugin validate ./plugins/agents-side
claude plugin validate ./plugins/context-board
claude plugin validate ./plugins/session-map
claude plugin validate ./plugins/blast-radius
claude plugin test ./plugins/agents-side
claude plugin test ./plugins/context-board
claude plugin test ./plugins/session-map
claude plugin test ./plugins/blast-radius
Source 5 files
hooks/register.ts 179 lines
1import { drawBoard } from './draw.ts'
2import { instructionNames, pluginsFromSettings } from './kit.ts'
3import { PANE, afterCompact, applyHeadline, applyUsage, emptyBoard, type Board } from './model.ts'
4
5let board: Board = emptyBoard()
6
7function pin($: { ui: { status: (text: string | undefined) => void; invalidate: (event: 'ui.render') => void } }) {
8  if (board.waiting && board.percent == null) {
9    $.ui.status('ctx waiting for the first answer')
10  } else {
11    const percent = board.percent ?? 0
12    const tokens = board.tokens ?? 0
13    const window = board.window ?? 0
14    $.ui.status(
15      window
16        ? `ctx ${percent}% · ${Math.round(tokens / 100) / 10}k of ${Math.round(window / 1000)}k`
17        : `ctx ${percent}%`,
18    )
19  }
20  $.ui.invalidate('ui.render')
21}
22
23async function takeReading(
24  $: { session: { usage: (args?: { breakdown?: 'summary' | 'full'; columns?: number }) => Promise<unknown> } },
25  detail: 'summary' | 'full',
26) {
27  const usage = await $.session.usage({ breakdown: detail, columns: 80 })
28  board = applyUsage(board, usage as Parameters<typeof applyUsage>[1], detail)
29  pin($)
30}
31
32async function loadKit($: {
33  settings: { read: () => Promise<unknown> }
34  fs: { ancestors: (request: { names: string[] }) => Promise<Array<{ dir: string; name: string; content: string }>> }
35  agent: { list: () => Promise<Array<{ id: string }>> }
36}) {
37  const settings = (await $.settings.read().catch(() => ({}))) as Record<string, unknown>
38  const found = await $.fs.ancestors({ names: ['CLAUDE.md', 'AGENTS.md'] }).catch(() => [])
39  const listed = await $.agent.list().catch(() => [])
40  const spawned = listed.filter(row => row.id && row.id !== 'main').length
41  board = {
42    ...board,
43    kit: {
44      ...board.kit,
45      plugins: pluginsFromSettings(settings),
46      instructions: instructionNames([...board.kit.instructions.map(name => ({ name })), ...found]),
47      agents: Math.max(board.kit.agents, spawned),
48    },
49  }
50  pin($)
51}
52
53async function isOpen($: { ui: { panes: () => Promise<Array<{ id: string; isPlaced: boolean }>> } }) {
54  const panes = await $.ui.panes()
55  return panes.some(pane => pane.id === PANE && pane.isPlaced)
56}
57
58function paneArgs() {
59  return { id: PANE, title: 'Context', columns: 56, rows: 10 }
60}
61
62export function register(
63  on: (
64    event: string,
65    matcher?: unknown,
66    hook?: (...args: never[]) => unknown,
67  ) => void,
68  options: Readonly<Record<string, string | number | boolean | readonly string[]>>,
69) {
70  const openPaneOnStart = options.openPaneOnStart === true
71
72  on('session.start', async ($, e, next) => {
73    const result = await next(e)
74    board = emptyBoard()
75    await $.command.register({
76      name: 'context-board',
77      description: 'Show the full context breakdown, or refresh the counts',
78      argumentHint: '[refresh|pane|close]',
79      immediate: true,
80    })
81    if (e.isInteractive) {
82      await loadKit($)
83      await takeReading($, 'summary')
84      if (openPaneOnStart) await $.ui.open(paneArgs())
85    }
86    return result
87  })
88
89  on('session.measure', async ($, e, next) => {
90    const result = await next(e)
91    board = applyHeadline(board, e.context, e.rateLimits, e.cost?.usd)
92    pin($)
93    return result
94  })
95
96  on('turn.complete', async ($, e, next) => {
97    const result = await next(e)
98    if (!e.agentId) await takeReading($, 'summary')
99    return result
100  })
101
102  on('session.compact', async ($, e, next) => {
103    const result = await next(e)
104    if (!result.skip) {
105      board = afterCompact(board)
106      pin($)
107    }
108    return result
109  }).catch(($, e, next) => next(e))
110
111  on('command.run', { command: 'clear' }, async ($, e, next) => {
112    const result = await next(e)
113    board = afterCompact(board)
114    pin($)
115    return result
116  }).catch(($, e, next) => next(e))
117
118  on('command.run', { command: 'context-board' }, async ($, e) => {
119    const arg = e.args.trim()
120    if (arg === 'refresh') {
121      await takeReading($, 'full')
122      return { text: 'Counted every context category with the token-count API.' }
123    }
124    if (arg === 'close') {
125      await $.ui.close({ id: PANE })
126      return { text: 'Closed the context pane. The bar above the prompt stays.' }
127    }
128    if (arg === 'pane' || arg === '') {
129      if (arg === '' && (await isOpen($))) {
130        await $.ui.close({ id: PANE })
131        return { text: 'Closed the context pane. The bar above the prompt stays.' }
132      }
133      await takeReading($, 'summary')
134      await $.ui.open(paneArgs())
135      return { text: 'Opened the full context pane. The same breakdown stays in the bar above the prompt.' }
136    }
137    return { text: 'Usage: /context-board, /context-board refresh, /context-board pane, /context-board close' }
138  })
139
140  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
141    const result = await next(e)
142    const skill = typeof e.skill === 'string' ? e.skill : ''
143    if (skill) {
144      board = { ...board, kit: { ...board.kit, lastSkill: skill } }
145      pin($)
146    }
147    return result
148  }).catch(($, e, next) => next(e))
149
150  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
151    if (e.props.hasSurvey) return next(e)
152    const { Box, Text, Button } = $.ui.resolve(e)
153    return drawBoard(Box, Text, Button, board, { bodyColumns: e.props.bodyColumns, showClose: false })
154  })
155
156  on('ui.render', { component: 'Pane' }, ($, e, next) => {
157    if (e.requestId !== PANE) return next(e)
158    const { Box, Text, Button } = $.ui.resolve(e)
159    return drawBoard(Box, Text, Button, board, { bodyColumns: e.props.bodyColumns, showClose: true })
160  })
161
162  on('ui.press', async ($, e, next) => {
163    if (e.plugin !== 'context-board') return next(e)
164    if (e.element === 'refresh') {
165      await takeReading($, 'full')
166      return {}
167    }
168    if (e.element === 'pane') {
169      await $.ui.open(paneArgs())
170      return {}
171    }
172    if (e.element === 'close' && e.requestId === PANE) {
173      await $.ui.close({ id: PANE })
174      return {}
175    }
176    return next(e)
177  })
178}
179
hooks/draw.ts 198 lines
1import { FREE_COLOR, SLICE_STYLES, clip, short, styleOf, TICK_COLOR, fillTone } from './format.ts'
2import { kitLine } from './kit.ts'
3import { type Board, type Category } from './model.ts'
4
5export type Node = unknown
6export type El = (props: Record<string, unknown>) => Node
7
8export type Slice = {
9  label: string
10  color: string
11  tokens: number
12  kind: 'used' | 'free'
13}
14
15function sum(items: Array<{ tokens: number }>): number {
16  return items.reduce((n, item) => n + item.tokens, 0)
17}
18
19function fromCategory(row: Category): Slice | null {
20  if (row.kind === 'buffer' || row.kind === 'deferred') return null
21  if (row.kind === 'free') {
22    return { label: 'free', color: FREE_COLOR, tokens: row.tokens, kind: 'free' }
23  }
24  const style = styleOf(row.name)
25  return { label: style.label, color: style.color, tokens: row.tokens, kind: 'used' }
26}
27
28function extra(label: string, tokens: number): Slice {
29  const style = SLICE_STYLES.find(s => s.label === label) ?? styleOf(label)
30  return { label: style.label, color: style.color, tokens, kind: 'used' }
31}
32
33export function slicesOf(board: Board): Slice[] {
34  const fromCats = board.categories.map(fromCategory).filter((row): row is Slice => row !== null)
35  const labels = new Set(fromCats.map(row => row.label))
36  const extras: Slice[] = []
37  const mcp = sum(board.mcpTools)
38  if (mcp > 0 && !labels.has('mcp tools')) extras.push(extra('mcp tools', mcp))
39  if (board.agents.length > 0 && !labels.has('agents')) extras.push(extra('agents', sum(board.agents)))
40  if (board.skills && board.skills.tokens > 0 && !labels.has('skills')) {
41    extras.push(extra('skills', board.skills.tokens))
42  }
43  if (board.slashCommands && board.slashCommands.tokens > 0 && !labels.has('slash commands')) {
44    extras.push(extra('slash commands', board.slashCommands.tokens))
45  }
46
47  const used = [...fromCats.filter(row => row.kind === 'used'), ...extras]
48  const free = fromCats.find(row => row.kind === 'free')
49  const window = board.maxTokens ?? board.window ?? sum(used)
50  const usedTokens = sum(used)
51  const freeTokens = free?.tokens ?? Math.max(0, window - usedTokens)
52  const order = SLICE_STYLES.map(style => style.label)
53  used.sort((a, b) => {
54    const ia = order.indexOf(a.label)
55    const ib = order.indexOf(b.label)
56    return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib)
57  })
58  return [...used, { label: 'free', color: FREE_COLOR, tokens: freeTokens, kind: 'free' }]
59}
60
61function stack(
62  slices: Slice[],
63  width: number,
64  compactAt: number | undefined,
65  window: number,
66): Array<{ color: string; text: string }> {
67  const total = window > 0 ? window : Math.max(1, sum(slices))
68  const cells: string[] = Array.from({ length: width }, () => FREE_COLOR)
69  let x = 0
70  for (const slice of slices) {
71    if (slice.kind === 'free') break
72    let cols = Math.round((slice.tokens / total) * width)
73    if (slice.tokens > 0) cols = Math.max(1, cols)
74    for (let i = 0; i < cols && x < width; i++) {
75      cells[x] = slice.color
76      x += 1
77    }
78  }
79  if (compactAt != null && window > 0) {
80    const at = Math.max(0, Math.min(width - 1, Math.round((compactAt / window) * width)))
81    cells[at] = TICK_COLOR
82  }
83  const parts: Array<{ color: string; text: string }> = []
84  for (const color of cells) {
85    const last = parts[parts.length - 1]
86    if (last && last.color === color) last.text += '█'
87    else parts.push({ color, text: '█' })
88  }
89  return parts
90}
91
92function legendItem(Box: El, Text: El, slice: Slice, window: number): Node {
93  const pct = window > 0 ? Math.round((slice.tokens / window) * 100) : 0
94  const nums = slice.kind === 'free' ? short(slice.tokens) : `${short(slice.tokens)} ${pct}%`
95  return Box({
96    flexDirection: 'row',
97    marginRight: 2,
98    children: [
99      Text({ color: slice.color, children: '■ ' }),
100      Text({ children: `${slice.label} ` }),
101      Text({ bold: true, children: nums }),
102    ],
103  })
104}
105
106export function drawBoard(
107  Box: El,
108  Text: El,
109  Button: El,
110  board: Board,
111  args: { bodyColumns: number; showClose: boolean },
112): Node {
113  const width = Math.max(24, args.bodyColumns - 4)
114  const slices = slicesOf(board)
115  const window = board.maxTokens ?? board.window ?? 0
116  const used = board.totalTokens ?? board.tokens ?? sum(slices.filter(s => s.kind === 'used'))
117  const percent = board.percent ?? (window > 0 ? Math.round((used / window) * 100) : 0)
118  const compactAt = board.isAutoCompactEnabled === false ? undefined : board.autoCompactThreshold
119  const badge = fillTone(percent)
120
121  const headerRight = board.waiting && board.percent == null
122    ? [Text({ dimColor: true, children: 'waiting for the first answer' })]
123    : [
124        Text({
125          dimColor: true,
126          children: window
127            ? `${short(used)} of ${short(window)}${compactAt != null ? ` · compacts at ${short(compactAt)}` : ''}`
128            : short(used),
129        }),
130        Text({ backgroundColor: badge, color: '#1a202c', bold: true, children: ` ${percent}% ` }),
131      ]
132
133  const barWidth = Math.max(16, width - 2)
134  const parts = stack(slices, barWidth, compactAt, window || 1)
135  const bar = Box({
136    flexDirection: 'row',
137    children: parts.map(part => Text({ color: part.color, children: part.text })),
138  })
139
140  const kids: Node[] = [
141    Box({
142      flexDirection: 'row',
143      justifyContent: 'space-between',
144      children: [
145        Box({
146          flexDirection: 'row',
147          children: [
148            Text({ color: '#ed8936', children: '◆ ' }),
149            Text({ bold: true, children: 'context' }),
150          ],
151        }),
152        Box({ flexDirection: 'row', columnGap: 1, children: headerRight }),
153      ],
154    }),
155  ]
156
157  if (!(board.waiting && board.percent == null && slices.every(s => s.tokens === 0))) {
158    kids.push(bar)
159    kids.push(
160      Box({
161        flexDirection: 'row',
162        flexWrap: 'wrap',
163        children: slices.filter(s => s.kind === 'free' || s.tokens > 0 || s.label === 'messages').map(slice =>
164          legendItem(Box, Text, slice, window || 1),
165        ),
166      }),
167    )
168  }
169
170  const kit = kitLine(board.kit)
171  if (kit) {
172    kids.push(Text({ dimColor: true, children: clip(kit, width) }))
173  }
174
175  if (args.showClose) {
176    kids.push(
177      Box({
178        flexDirection: 'row',
179        columnGap: 2,
180        marginTop: 1,
181        children: [
182          Button({ key: 'refresh', label: 'refresh', hotkey: 'r', plain: true, onPress: () => undefined }),
183          Button({ key: 'close', label: 'close', hotkey: 'x', plain: true, onPress: () => undefined }),
184        ],
185      }),
186    )
187  }
188
189  return Box({
190    flexDirection: 'column',
191    borderStyle: 'round',
192    borderColor: 'gray',
193    paddingX: 1,
194    paddingY: 0,
195    children: kids,
196  })
197}
198
hooks/kit.ts 51 lines
1export type Kit = {
2  skills: number
3  agents: number
4  plugins: string[]
5  instructions: string[]
6  lastSkill?: string
7}
8
9export function emptyKit(): Kit {
10  return { skills: 0, agents: 0, plugins: [], instructions: [] }
11}
12
13export function pluginsFromSettings(settings: Record<string, unknown>): string[] {
14  const enabled = settings.enabledPlugins
15  if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return []
16  const names: string[] = []
17  for (const [id, on] of Object.entries(enabled as Record<string, unknown>)) {
18    if (on !== true) continue
19    const name = id.split('@')[0]
20    if (name) names.push(name)
21  }
22  return names
23}
24
25export function instructionNames(files: Array<{ path?: string; name?: string }>): string[] {
26  const names = new Set<string>()
27  for (const file of files) {
28    const base = (file.name ?? file.path ?? '').split('/').pop() ?? ''
29    if (base === 'CLAUDE.md' || base === 'AGENTS.md') names.add(base)
30  }
31  return [...names].sort()
32}
33
34export function mergeNames(left: string[], right: string[]): string[] {
35  return [...new Set([...left, ...right])].sort()
36}
37
38export function kitLine(kit: Kit): string {
39  const bits: string[] = []
40  if (kit.skills > 0) bits.push(`skills ${kit.skills}`)
41  if (kit.agents > 0) bits.push(`agents ${kit.agents}`)
42  if (kit.instructions.length > 0) bits.push(kit.instructions.join(', '))
43  if (kit.plugins.length > 0) {
44    const shown = kit.plugins.slice(0, 4)
45    const extra = kit.plugins.length > 4 ? '…' : ''
46    bits.push(`plugins ${shown.join(', ')}${extra}`)
47  }
48  if (kit.lastSkill) bits.push(`last ${kit.lastSkill}`)
49  return bits.join(' · ')
50}
51
hooks/model.ts 187 lines
1import { emptyKit, instructionNames, mergeNames, type Kit } from './kit.ts'
2
3export const PANE = 'context-board'
4export const HISTORY = 24
5
6export type Category = {
7  name: string
8  tokens: number
9  color: string
10  isDeferred: boolean
11  kind: string
12}
13
14export type GridSquare = {
15  color: string
16  isFilled: boolean
17  categoryName: string
18  tokens: number
19  percentage: number
20  squareFullness: number
21}
22
23export type MemoryFile = { path: string; type: string; tokens: number }
24export type McpTool = { name: string; serverName: string; tokens: number; isLoaded: boolean }
25export type ContextAgent = { agentType: string; source: string; tokens: number }
26export type Skill = { name: string; source: string; tokens: number; pluginName?: string }
27export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
28export type ApiUsage = {
29  input_tokens: number
30  output_tokens: number
31  cache_creation_input_tokens: number
32  cache_read_input_tokens: number
33}
34
35export type Board = {
36  tokens?: number
37  window?: number
38  percent?: number
39  costUsd?: number
40  rateLimits: RateLimit[]
41  detail: 'none' | 'summary' | 'full'
42  waiting: boolean
43  lastDelta?: number
44  history: number[]
45  categories: Category[]
46  gridRows: GridSquare[][]
47  memoryFiles: MemoryFile[]
48  mcpTools: McpTool[]
49  agents: ContextAgent[]
50  slashCommands?: { totalCommands: number; includedCommands: number; tokens: number }
51  skills?: { totalSkills: number; includedSkills: number; tokens: number; skillFrontmatter: Skill[] }
52  autoCompactThreshold?: number
53  isAutoCompactEnabled?: boolean
54  autocompactSource?: string
55  model?: string
56  maxTokens?: number
57  totalTokens?: number
58  apiUsage?: ApiUsage | null
59  kit: Kit
60}
61
62export function emptyBoard(): Board {
63  return {
64    rateLimits: [],
65    detail: 'none',
66    waiting: true,
67    history: [],
68    categories: [],
69    gridRows: [],
70    memoryFiles: [],
71    mcpTools: [],
72    agents: [],
73    kit: emptyKit(),
74  }
75}
76
77export function withKitFromBoard(board: Board): Board {
78  const fromMemory = instructionNames(board.memoryFiles)
79  return {
80    ...board,
81    kit: {
82      ...board.kit,
83      skills: board.skills?.totalSkills ?? board.kit.skills,
84      agents: Math.max(board.kit.agents, board.agents.length),
85      instructions: mergeNames(board.kit.instructions, fromMemory),
86    },
87  }
88}
89
90export function headroomLimit(board: Board): number | undefined {
91  if (board.autoCompactThreshold != null) return board.autoCompactThreshold
92  if (board.maxTokens != null) return board.maxTokens
93  return board.window
94}
95
96export function applyHeadline(
97  board: Board,
98  context: { tokens?: number; window: number; percent?: number },
99  rateLimits: RateLimit[],
100  costUsd?: number,
101): Board {
102  const next = { ...board }
103  if (context.tokens != null) {
104    if (!next.waiting && next.tokens != null && context.tokens !== next.tokens) {
105      next.lastDelta = context.tokens - next.tokens
106    }
107    next.tokens = context.tokens
108    next.waiting = false
109    if (next.history.length === 0 || next.history[next.history.length - 1] !== context.tokens) {
110      next.history = [...next.history, context.tokens].slice(-HISTORY)
111    }
112  }
113  next.window = context.window
114  next.percent = context.percent
115  next.rateLimits = rateLimits
116  if (costUsd != null) next.costUsd = costUsd
117  return next
118}
119
120export function applyUsage(
121  board: Board,
122  usage: {
123    context: {
124      tokens?: number
125      window: number
126      percent?: number
127      breakdown?: {
128        categories?: Category[]
129        gridRows?: GridSquare[][]
130        memoryFiles?: MemoryFile[]
131        mcpTools?: McpTool[]
132        agents?: ContextAgent[]
133        slashCommands?: Board['slashCommands']
134        skills?: Board['skills']
135        autoCompactThreshold?: number
136        isAutoCompactEnabled?: boolean
137        autocompactSource?: string
138        model?: string
139        maxTokens?: number
140        totalTokens?: number
141        apiUsage?: ApiUsage | null
142        rawMaxTokens?: number
143      }
144    }
145    rateLimits?: RateLimit[]
146    cost?: { usd: number }
147  },
148  detail: Board['detail'],
149): Board {
150  let next = applyHeadline(
151    board,
152    usage.context,
153    usage.rateLimits ?? board.rateLimits,
154    usage.cost?.usd,
155  )
156  next.detail = detail
157  const br = usage.context.breakdown
158  if (!br) return next
159  next = {
160    ...next,
161    categories: br.categories ?? [],
162    gridRows: br.gridRows ?? [],
163    memoryFiles: br.memoryFiles ?? [],
164    mcpTools: br.mcpTools ?? [],
165    agents: br.agents ?? [],
166    slashCommands: br.slashCommands,
167    skills: br.skills,
168    autoCompactThreshold: br.autoCompactThreshold,
169    isAutoCompactEnabled: br.isAutoCompactEnabled,
170    autocompactSource: br.autocompactSource,
171    model: br.model,
172    maxTokens: br.rawMaxTokens ?? br.maxTokens,
173    totalTokens: br.totalTokens,
174    apiUsage: br.apiUsage,
175  }
176  return withKitFromBoard(next)
177}
178
179export function afterCompact(board: Board): Board {
180  return {
181    ...emptyBoard(),
182    window: board.window,
183    waiting: true,
184    kit: { ...board.kit, lastSkill: undefined },
185  }
186}
187
hooks/format.ts 51 lines
1export function clip(text: string, max: number): string {
2  const t = text.replace(/\s+/g, ' ').trim()
3  if (t.length <= max) return t
4  if (max <= 1) return '…'
5  return `${t.slice(0, max - 1)}…`
6}
7
8export function short(n: number): string {
9  if (n >= 1_000_000) {
10    const m = n / 1_000_000
11    return `${m >= 10 ? Math.round(m) : +m.toFixed(1)}M`
12  }
13  if (n >= 1_000) {
14    const k = n / 1_000
15    return `${k >= 100 ? Math.round(k) : +k.toFixed(1)}k`
16  }
17  return String(Math.round(n))
18}
19
20export function fillTone(percent: number | undefined): string {
21  if (percent == null) return '#68d391'
22  if (percent >= 90) return '#fc8181'
23  if (percent >= 75) return '#ed8936'
24  if (percent >= 50) return '#ecc94b'
25  return '#68d391'
26}
27
28export type SliceStyle = { label: string; color: string; match: RegExp }
29
30export const SLICE_STYLES: SliceStyle[] = [
31  { label: 'system prompt', color: '#6ea8ff', match: /system prompt|^prompt$/i },
32  { label: 'tools', color: '#5ad0e8', match: /system tools|^tools$/i },
33  { label: 'mcp tools', color: '#b794f6', match: /mcp/i },
34  { label: 'agents', color: '#68d391', match: /agent/i },
35  { label: 'memory files', color: '#ecc94b', match: /memory/i },
36  { label: 'skills', color: '#f687b3', match: /skill/i },
37  { label: 'messages', color: '#ed8936', match: /message/i },
38  { label: 'slash commands', color: '#63b3ed', match: /slash|command/i },
39]
40
41export const FREE_COLOR = '#4a5568'
42export const TICK_COLOR = '#ecc94b'
43
44export function styleOf(name: string): SliceStyle {
45  return SLICE_STYLES.find(style => style.match.test(name)) ?? {
46    label: name.toLowerCase(),
47    color: '#a0aec0',
48    match: /$^/,
49  }
50}
51