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…

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.
Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
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:
/tui fullscreen so panes sit beside the transcript.skills 4 · agents 2 · CLAUDE.md · plugins …)./agents-side./session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.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.| Plugin | Command | What it does |
|---|---|---|
| agents-side | /agents-side | Open or close the pane |
| agents-side | /agents-side clear | Drop finished agents |
| agents-side | /agents-side close | Close the pane |
| context-board | /context-board | Open or close the pane (the bar stays) |
| context-board | /context-board refresh | Recount every category with the token-count API |
| context-board | /context-board pane | Force-open the pane |
| context-board | /context-board close | Close the pane |
| session-map | /session-map | Open or close the file pane |
| session-map | /session-map clear | Drop the file list |
| session-map | /session-map close | Close the pane |
| blast-radius | /blast-radius | Re-open the hold pane if a command is waiting |
| blast-radius | /blast-radius close | Cancel 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.
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:
sec-default in prependPlugins.$(…) and scripts that call rm can still get past it).$.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.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.
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-radiushooks/register.ts 179 lines1import { 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}
179hooks/draw.ts 198 lines1import { 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}
198hooks/kit.ts 51 lines1export 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}
51hooks/model.ts 187 lines1import { 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}
187hooks/format.ts 51 lines1export 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