What Claude carries, in colour: a bar above the prompt with system, rules, conversation and large tool results, and a pane to turn rules off for the session…

What Claude carries, in colour, and what you can take out of it. Tested with Claude Code 2.1.287, terminal and desktop.
<img src="../../docs/media/headspace/demo.gif" width="880" alt="Claude Code with Haiku 4.5 in a repo whose CLAUDE.md says Answer in French. Claude reads build.log and answers in French; the bar shows a large result of 5.9k. In the pane the rule is turned off and applied, the Read of build.log is marked and removed after a confirmation, and the next answer is in English">
Other context mods show how full the window is. headspace shows what fills it: the system prompt and tools, your rules, the conversation, and the big tool results that pile up without you noticing. Point at a piece and see what it is. Then take it out: a CLAUDE.md rule for this session, a session rule of your own, or a 40k cat from an hour ago. No model call, nothing summarized.
One line above the prompt: a bar with one segment per kind, each as wide as its share of the window, and the percentage used at the right.
| Kind | What it holds | Colour | Can be taken out |
|---|---|---|---|
| system | system prompt, system tools, MCP server instructions, MCP tools, custom agents, skills | stone #b0aea5 | no, view only |
| rules | memory files (CLAUDE.md, rules files, auto-memory) and your session rules | clay #d97757 | yes |
| conversation | messages, minus the large results | sky #6a9bcc | no, view only |
| large | tool results of threshold tokens or more (default 2000) | olive #788c5d | yes |
| free | free space | an empty rail ▁, stone, dimmed | no |
| reserve | the autocompact buffer | stone, drawn ░ | no |
The figures start from Claude Code's own breakdown, the one /context shows, and headspace makes no token-count calls. That breakdown estimates each kind locally and gives the conversation what is left of the last response's input; with many tools installed the estimates run high and the conversation comes out too small. So headspace counts the conversation at no less than its characters over four, and makes the used kinds add up to the last response's input: the percentage is the status line's. Just after a compaction, until the next response, Claude Code has no such figure and the bar shows the estimate. Tool schemas loaded on demand (MCP tools (deferred) and the like) are not in the window, so they are not in the bar. The bar refreshes when a turn ends and when the pane opens; a subagent's turn end does not count.
After the bar comes one label per kind that holds anything, in the bar's order, with the counts after the kind they count, so a change is never forgotten: sys rules 9.2k 2 off conv large 16k 3 marked free res, and 1 change no longer applies when a rule you changed is no longer in its file.
On a narrow terminal the labels shrink, and the bar with them so they fit: under 100 columns they lose their figures (sys rules 2 off conv large 3 marked free res), under 70 they become one letter each and the counts go (s r c l f b, b for the reserve, the autocompact buffer). The colours and the percentage stay, and every kind that holds anything keeps at least one cell and one label.
With emcc-core or witness installed, their band is drawn under headspace's line, never replaced.
click to view and remove, or view only).ctrl+x tab moves into the band's labels, and Enter opens the one in focus. Every kind that holds anything has its label at every width, so the keyboard reaches each one. The percentage opens the pane on rules./headspace opens the pane on rules; /headspace large (or any other kind) on that kind.The pane has the six kinds as tabs across the top, in their colours, and the selected kind's pieces below. Esc closes it. headspace never opens the pane on its own. Claude Code seats a side pane only from about 110 columns: on a narrower terminal the pane opens but waits, and shows once the terminal is wider. headspace says so with a toast (headspace: widen the terminal to see the pane), and /headspace answers opened on rules; widen the terminal to see it.
The rules tab lists the files behind your CLAUDE.md context in the order Claude reads them (your user CLAUDE.md, the project's, rules files, @ imports), then auto-memory, then your session rules. A file under the project shows relative to it (CLAUDE.md, docs/rules.md), one under your home directory as ~/…, and a path still too long for the pane loses its start:
rules 9.2k
~/.claude/CLAUDE.md user 3.1k
save
● Commit messages follow Conventional… 410
○ Never push straight to main… 380
session rules 0.1k
● Keep the public API unchanged remove save
[ add a session rule ]
[ apply ] adds ~2.4k to the next message
A file splits into rules: each top-level list item with its continuation lines, or each paragraph. Select a rule to work on it:
MEMORY.md) can only be turned off and on.Apply. Changes wait until you press apply (or run /headspace apply), and take effect from the next request. Claude Code does not rewrite the first message of the conversation, where it put your rules: that copy stays as it was, the rule you turned off included, and your rules as they now stand arrive inside your next message. Claude reads both, and a rule that is only missing from the new copy can still be followed from the old one. So while a rule is off, headspace's context says it in words: a block names each rule turned off, by its first line, and tells Claude to ignore it even where it appears earlier. An edited line gets the same: a block gives it as it was and as it reads now. A session rule you take out is named in the same block, since the old copy holds it too. The rule still leaves the new copy as well. Only /compact or /clear reads your rules afresh and drops the old copy for good.
Next to the button is what applying costs: adds ~2.4k to the next message, the size of what is sent again (your CLAUDE.md block, headspace's blocks and the session rules, at four characters a token). Nothing earlier is sent again, so the cache before it holds. Once a change waits, a line under it says the original stays earlier in the conversation until the next /compact. Turning off an auto-memory entry costs more: that index is part of the system prompt, so everything after it is cached again, and the line adds , rewrites ~148k of cache.
Session rules are typed into the field at the bottom. They go into the context as a block of headspace's own, and they are still there after /compact, because headspace adds the block again whenever Claude Code rebuilds the context. remove takes one out; like every change, it waits for apply.
Save to the file. Changes made in the pane live only in this session until you save them. Each file has its own save: it shows the change line by line (- out, + in) and asks before writing. Before it writes, headspace keeps a copy of the file as it was, in Claude Code's storage for the plugin, never next to your CLAUDE.md, so no stray file lands in a repo. It keeps the last 10 saves per file. undo save puts the newest one back, after asking the same way, and uses it up: a second undo goes one save further back, it never redoes the first.
changed on disk, nothing written.~/.claude/CLAUDE.md, or the one under CLAUDE_CONFIG_DIR), and adds it as - <rule> under a ## Session rules heading, made at the end of the file the first time. Saved, it leaves the session rules: Claude reads it once, from headspace's block until Claude Code reads the file again (after /compact or /clear), then from the file. Undoing that save puts it back among the session rules.Read-only: files of type managed (set by an organization) show their rules, marked managed, with no buttons. headspace never turns them off, edits or saves them.
The large tab lists every tool result in what Claude currently reads that is threshold tokens or more, biggest first. A result's tokens are its share of the conversation's figure, in proportion to characters; the <system-reminder> blocks Claude Code puts at the top of a prompt (the environment, the skills, CLAUDE.md) are counted under other kinds, so they are left out of that share, while one inside a tool result is part of the result. Each row shows the tool, its target in short (the file, the command, the URL), its size, and how many of your prompts came after it.
large 16.4k
[x] Read src/data.json 9.8k 14 turns ago view
[x] Bash npm test 4.1k 3 turns ago view
[ ] WebFetch docs.anthropic.com 2.5k 1 turn ago view
[ apply ] frees 13.9k, rewrites ~112k
view shows the first 20 lines of a result. The checkbox marks it.
Apply removes the marked results, after asking, because a removal cannot be undone:
Remove 2 results from what Claude reads? This cannot be undone. Frees 13.9k, rewrites ~112k of cache.
Cancel leaves everything as it was, marks included. /headspace cut is typed on purpose, so it does not ask; if it is refused by the time it runs (Claude started working meanwhile), a toast says why. Each removed result becomes a single line in the conversation, and nothing else changes:
[headspace removed the result of Read src/data.json (9.8k tokens); run it again if you need it]
/compact does.The removal is a compaction answered by headspace-cut, which installs with headspace: Claude Code never shows a plugin the compaction it started itself, so a second plugin answers it. Without headspace-cut (headspace loaded alone with --plugin-dir, or headspace-cut removed), apply is not offered and the pane says install headspace-cut to remove results. headspace-cut also marks every turn end it sees; if it misses one (turned off during the session), apply is not offered either and the pane says headspace-cut is not running: turn it on to remove results. If headspace-cut cannot answer (a marked result is no longer there), it skips: the conversation stays as it is, the marks stay, and the pane shows why.
/compact headspace-cut:…The same removal can be typed after /compact, without the pane:
/compact headspace-cut:{"marks":[{"id":"toolu_01AbC","label":"Read src/data.json (9.8k tokens)"}]}
id is the tool call's tool_use_id, as it appears in the session's transcript file; label is what the line left in its place names. Any other /compact works exactly as it does without headspace. See headspace-cut for what it answers and what it skips.
headspace lost track at <event>.-p run), it draws nothing./plugin marketplace add Xabilimon1/emcc-claude-code-mods
/plugin install headspace@emcc
headspace-cut comes with it. headspace does not need emcc-core.
To try it without installing, load both folders:
claude --plugin-dir mods/headspace --plugin-dir mods/headspace-cut
/headspace opens the pane on rules and answers opened on rules. /headspace <kind> opens it on rules, large, system, conversation, free or reserve./headspace apply applies the rule changes waiting, as the pane's apply does./headspace cut removes the marked large results, as the pane's apply does, without asking first.Set them in /config, or at install with --config key=value.
| Option | Default | What it does |
|---|---|---|
threshold | 2000 | Tool results of this many tokens or more show as large |
session.start, turn.start, turn.complete, prompt.context (rules turned off, edits, the blocks that name them, the session rules block), prompt.section (memory, auto-memory entries turned off), command.run (headspace), ui.render (AbovePrompt, the headspace pane)ready and its heartbeat seenAt$.session.usage, $.session.messages, $.session.cwd, $.session.root, $.session.compact, $.state.get, $.state.set, $.store.get and $.store.set (the save backups), $.fs.read, $.fs.exists and $.fs.write (only the file being saved or restored), $.env.get (HOME, CLAUDE_CONFIG_DIR, to find your CLAUDE.md), $.ui.open, $.ui.ask, $.ui.toast, $.ui.invalidate, $.ui.resolve, $.clock.now, $.clock.after, $.command.registerclaude plugin test mods/headspace
202 tests: the bar's kinds, widths and narrow fallbacks (24), rules parsed and rendered back byte for byte, changes already in a file, short file paths, and the blocks that name rules turned off or changed (54), auto-memory entries (12), large results, their cost and the removal instructions (20), the hooks, apply, the rules named again, removal, save and undo in a simulated session (64), and the band and the pane on both terminal and desktop, a terminal too narrow for the pane included (28). Each one was seen failing before it passed.
hooks/register.tsx 1538 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ApiMessage,
4 ElementConstructor,
5 ElementTable,
6 EngineInterface,
7 InputProps,
8 InstructionFile,
9 PromptContextBlock,
10 Register,
11 RenderElement,
12} from 'claude-code'
13
14import type { HeadspaceFileChanges, HeadspaceKind, HeadspaceRuleChange, HeadspaceSaved, HeadspaceSnapshot } from '../types'
15import { COLOURS, ORDER, fallback, formatTokens, reconcile, toSegments, widths } from './bar'
16import type { Category } from './bar'
17import { cacheCost, conversationChars, cutInstructions, label, largeResults } from './large'
18import type { LargeResult } from './large'
19import { memoryEntries, renderMemory } from './memory'
20import { changeNotes, diffLines, hash, isReflected, noticeBlocks, parseRules, renderRules, shortPath, withoutMarker } from './rules'
21import type { ChangeNotes } from './rules'
22
23const COMMAND = 'headspace'
24const PANE = 'headspace'
25/** The source auto-memory changes are filed under. */
26const MEMORY = 'memory'
27const SESSION_RULES = 'sessionRules'
28const DEFAULT_THRESHOLD = 2000
29
30// ---------------------------------------------------------------- state
31
32const snapshot = atom({ plugin: 'headspace', key: 'snapshot' } as const, null)
33const pending = atom({ plugin: 'headspace', key: 'pending' } as const, [])
34const applied = atom({ plugin: 'headspace', key: 'applied' } as const, [])
35const sessionRules = atom({ plugin: 'headspace', key: 'sessionRules' } as const, [])
36const pendingSessionRules = atom({ plugin: 'headspace', key: 'pendingSessionRules' } as const, null)
37const droppedSessionRules = atom({ plugin: 'headspace', key: 'droppedSessionRules' } as const, [])
38const marks = atom({ plugin: 'headspace', key: 'marks' } as const, [])
39const staleCount = atom({ plugin: 'headspace', key: 'staleCount' } as const, 0)
40const selected = atom({ plugin: 'headspace', key: 'selected' } as const, 'rules')
41const cursor = atom({ plugin: 'headspace', key: 'cursor' } as const, null)
42const isCutting = atom({ plugin: 'headspace', key: 'isCutting' } as const, false)
43const lostAt = atom({ plugin: 'headspace', key: 'lostAt' } as const, null)
44const savedFrom = atom({ plugin: 'headspace', key: 'savedFrom' } as const, {})
45
46/** The large results threshold, in tokens: the `threshold` userConfig field. */
47let threshold = DEFAULT_THRESHOLD
48/** Between `turn.start` and the main loop's `turn.complete`: a cut waits for the person's move. */
49let isTurnRunning = false
50/**
51 * When the main loop's last turn started (its end less its duration), null
52 * before one ended: headspace-cut's heartbeat must be no older, or it is no
53 * longer running. The start, not the end: both plugins hook that turn end,
54 * in either order.
55 */
56let turnStartedAt: number | null = null
57/** What the last refresh found, for the pane: the large results and the live conversation they came from. */
58let lastLarge: LargeResult[] = []
59let lastApi: ApiMessage[] = []
60/** The instruction files and the memory section as the engine last gave them, for the pane's rows. */
61let lastFiles: readonly InstructionFile[] = []
62let lastMemory: string | null = null
63/** The claudeMd block's characters as the engine last built it: what an Apply sends again, with headspace's own blocks. */
64let lastClaudeMdChars = 0
65/** The breakdown's categories and its Messages figure at the last refresh, for the pane's read-only kinds and the cut's cost. */
66let lastCategories: Category[] = []
67let lastMessageTokens: number | null = null
68/** Why the last cut did not happen (a refusal or a skip): shown in the pane, never a crash. */
69let cutNotice: string | null = null
70
71async function lose($: EngineInterface, where: string): Promise<void> {
72 try {
73 await update($, lostAt, () => where)
74 } catch {
75 // Nothing left to record with.
76 }
77}
78
79// ---------------------------------------------------------------- the bar
80
81/** Session rules counted as the bar counts text: characters over four. */
82function tokensOf(rules: readonly string[]): number {
83 return Math.round(rules.reduce((sum, rule) => sum + rule.length, 0) / 4)
84}
85
86/**
87 * Reads the breakdown and the live conversation into a new snapshot, and drops
88 * the marks whose result is no longer there. No breakdown (a `-p` run, no bound
89 * session): the snapshot is left as it stands.
90 */
91async function refresh($: EngineInterface): Promise<void> {
92 const usage = await $.session.usage({ breakdown: 'summary' })
93 const breakdown = usage.context.breakdown
94 if (breakdown === undefined) {
95 return
96 }
97 const api = await $.session.messages({ as: 'api' })
98 const categories: Category[] = reconcile(
99 breakdown.categories.map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
100 conversationChars(api),
101 usage.context.tokens,
102 )
103 const messageTokens = categories.find(c => c.kind === 'used' && c.name === 'Messages')?.tokens ?? null
104 const { results, share } = largeResults(api, threshold, messageTokens, await $.session.cwd())
105 const segments = toSegments(categories, share, tokensOf(await read($, sessionRules)))
106 const at = await $.clock.now()
107 lastApi = api
108 lastLarge = results
109 lastCategories = categories
110 lastMessageTokens = messageTokens
111 await update($, snapshot, () => ({ segments, window: breakdown.rawMaxTokens, percent: usage.context.percent ?? breakdown.percentage, at }))
112 const live = new Set(results.map(r => r.id))
113 const marked = await read($, marks)
114 if (!marked.every(m => live.has(m.id))) {
115 await update($, marks, () => marked.filter(m => live.has(m.id)))
116 }
117}
118
119// ---------------------------------------------------------------- rules
120
121/** Every change of a rule in force or waiting: what pending says for it, else what applied says. */
122async function changesOf($: EngineInterface, source: string, ruleId: string): Promise<HeadspaceRuleChange[]> {
123 const waiting = (await read($, pending)).find(f => f.source === source)?.changes[ruleId]
124 if (waiting !== undefined) {
125 return waiting
126 }
127
128 return (await read($, applied)).find(f => f.source === source)?.changes[ruleId] ?? []
129}
130
131/** Writes a rule's whole list of changes into pending: it replaces that rule's list in applied at Apply. */
132async function setPending($: EngineInterface, source: string, ruleId: string, list: HeadspaceRuleChange[]): Promise<void> {
133 await update($, pending, files => {
134 const others = files.filter(f => f.source !== source)
135 const own = files.find(f => f.source === source)
136
137 return [...others, { source, changes: { ...(own?.changes ?? {}), [ruleId]: list } }]
138 })
139}
140
141/** Turns a rule off, or back on, from the next Apply. */
142async function toggleRule($: EngineInterface, source: string, ruleId: string): Promise<void> {
143 const list = await changesOf($, source, ruleId)
144 const isOff = list.some(c => c.type === 'off')
145 await setPending($, source, ruleId, isOff ? list.filter(c => c.type !== 'off') : [...list, { type: 'off' }])
146}
147
148/** Replaces line `lineIndex` of a rule with `text`; a second edit of the same line replaces the first. */
149async function editLine($: EngineInterface, source: string, ruleId: string, lineIndex: number, text: string): Promise<void> {
150 const list = await changesOf($, source, ruleId)
151 const others = list.filter(c => !(c.type === 'edit' && c.lineIndex === lineIndex))
152 await setPending($, source, ruleId, [...others, { type: 'edit', lineIndex, text }])
153}
154
155/** Adds a line after line `afterLineIndex` of a rule (-1: before its first). */
156async function addLine($: EngineInterface, source: string, ruleId: string, afterLineIndex: number, text: string): Promise<void> {
157 const list = await changesOf($, source, ruleId)
158 await setPending($, source, ruleId, [...list, { type: 'addLine', afterLineIndex, text }])
159}
160
161async function addSessionRule($: EngineInterface, text: string): Promise<void> {
162 const rule = text.trim()
163 if (rule === '') {
164 return
165 }
166 const base = (await read($, pendingSessionRules)) ?? (await read($, sessionRules))
167 await update($, pendingSessionRules, () => [...base, rule])
168}
169
170async function removeSessionRule($: EngineInterface, index: number): Promise<void> {
171 const base = (await read($, pendingSessionRules)) ?? (await read($, sessionRules))
172 await update($, pendingSessionRules, () => base.filter((_, at) => at !== index))
173}
174
175/** The session rules named as off once `after` replaces `before`: the ones taken out, less any added back. */
176function droppedAfter(dropped: readonly string[], before: readonly string[], after: readonly string[]): string[] {
177 return [...dropped, ...before.filter(rule => !dropped.includes(rule))].filter(rule => !after.includes(rule))
178}
179
180/** Pending's rule lists replace applied's, rule by rule; an empty list takes the rule's changes away. */
181function merged(inForce: readonly HeadspaceFileChanges[], waiting: readonly HeadspaceFileChanges[]): HeadspaceFileChanges[] {
182 const bySource = new Map<string, Record<string, HeadspaceRuleChange[]>>()
183 for (const file of inForce) {
184 bySource.set(file.source, { ...file.changes })
185 }
186 for (const file of waiting) {
187 const changes = bySource.get(file.source) ?? {}
188 for (const [ruleId, list] of Object.entries(file.changes)) {
189 if (list.length === 0) {
190 delete changes[ruleId]
191 } else {
192 changes[ruleId] = list
193 }
194 }
195 bySource.set(file.source, changes)
196 }
197
198 return [...bySource]
199 .filter(([, changes]) => Object.keys(changes).length > 0)
200 .map(([source, changes]) => ({ source, changes }))
201}
202
203/** Puts every waiting change in force; the context and the memory section are computed again for the next request. Answers how many rules changed. */
204async function applyRules($: EngineInterface): Promise<number> {
205 const waiting = await read($, pending)
206 const rules = await read($, pendingSessionRules)
207 const count = waiting.reduce((sum, f) => sum + Object.keys(f.changes).length, 0) + (rules === null ? 0 : 1)
208 if (waiting.length > 0) {
209 const inForce = await read($, applied)
210 await update($, applied, () => merged(inForce, waiting))
211 await update($, pending, () => [])
212 }
213 if (rules !== null) {
214 const before = await read($, sessionRules)
215 const dropped = await read($, droppedSessionRules)
216 await update($, droppedSessionRules, () => droppedAfter(dropped, before, rules))
217 await update($, sessionRules, () => rules)
218 await update($, pendingSessionRules, () => null)
219 }
220 // The person has seen the bar's stale line and chosen again: it starts over.
221 await update($, staleCount, () => 0)
222 $.ui.invalidate('prompt.context')
223 $.ui.invalidate('prompt.section')
224
225 return count
226}
227
228/** How many single changes a file's list holds for these rules. */
229function countChanges(changes: Record<string, HeadspaceRuleChange[]>, ids: readonly string[]): number {
230 return ids.reduce((sum, id) => sum + (changes[id]?.length ?? 0), 0)
231}
232
233/** Where file `path`'s frame opens in the claudeMd text, from `from` on: `Contents of <path>` then ` (` or `:`; -1 when it is not there. */
234function frameAt(text: string, path: string, from: number): number {
235 const header = `Contents of ${path}`
236 for (let at = text.indexOf(header, from); at !== -1; at = text.indexOf(header, at + 1)) {
237 const after = text[at + header.length]
238 if (after === ' ' || after === ':') {
239 return at
240 }
241 }
242
243 return -1
244}
245
246/**
247 * Where each file's text sits in the claudeMd text: looked for inside its own
248 * frame, the files taken in the order the engine renders them, so a file that
249 * quotes another's text is never taken for it. The engine frames each file's
250 * text trimmed. A file whose frame or text is not found is left out.
251 */
252function framed(text: string, files: readonly InstructionFile[]): Map<string, { at: number; length: number }> {
253 const found = new Map<string, { at: number; length: number }>()
254 let from = 0
255 for (const file of files) {
256 const body = file.content.trim()
257 const header = frameAt(text, file.path, from)
258 if (body === '' || header === -1 || found.has(file.path)) {
259 continue
260 }
261 const at = text.indexOf(body, header)
262 const next = text.indexOf('\nContents of ', header + 1)
263 if (at === -1 || (next !== -1 && at > next)) {
264 continue
265 }
266 found.set(file.path, { at, length: body.length })
267 from = at + body.length
268 }
269
270 return found
271}
272
273/**
274 * The texts a file's rule ids may come from, newest first: what the engine
275 * handed for it before, and the backups headspace's saves kept.
276 */
277async function earlierTexts($: EngineInterface, path: string, previous: readonly InstructionFile[]): Promise<string[]> {
278 const handed = previous.find(f => f.path === path)?.content
279 const backups = await backupsOf($, path).catch(() => [] as Backup[])
280
281 return [...(handed === undefined ? [] : [handed]), ...backups.map(b => b.text).reverse()]
282}
283
284/** The ids among `unknown` whose change the file's text now holds already (rules.ts isReflected): they leave quietly. */
285async function reflectedIds($: EngineInterface, file: InstructionFile, changes: Record<string, HeadspaceRuleChange[]>, unknown: readonly string[], previous: readonly InstructionFile[]): Promise<string[]> {
286 if (unknown.length === 0) {
287 return []
288 }
289 const now = parseRules(file.content)
290 const earlier = (await earlierTexts($, file.path, previous)).map(text => parseRules(text))
291
292 return unknown.filter(id => {
293 for (const before of earlier) {
294 const reflected = isReflected(before, now, id, changes[id] ?? [])
295 if (reflected !== null) {
296 return reflected
297 }
298 }
299
300 return false
301 })
302}
303
304/**
305 * The `claudeMd` block with the applied changes of each file it frames: the
306 * file's text, found in its own frame, replaced by the text rendered with the
307 * changes. Changes whose rule is gone, or whose file is not found in the
308 * block, are dropped from applied and counted stale, save the ones the file
309 * now holds already, which leave quietly. `previous` is what the engine handed
310 * before this read.
311 */
312async function rewriteClaudeMd($: EngineInterface, block: PromptContextBlock, files: readonly InstructionFile[], previous: readonly InstructionFile[]): Promise<PromptContextBlock> {
313 const inForce = (await read($, applied)).filter(f => f.source !== MEMORY)
314 const where = framed(block.text, files)
315 const edits: { at: number; length: number; text: string }[] = []
316 let stale = 0
317 let isDropped = false
318 const kept: HeadspaceFileChanges[] = []
319 for (const entry of inForce) {
320 const file = files.find(f => f.path === entry.source)
321 if (file === undefined) {
322 // Not loaded in this context (a nested file not read yet): kept for when it is.
323 kept.push(entry)
324 continue
325 }
326 const span = where.get(file.path)
327 if (span === undefined) {
328 stale += countChanges(entry.changes, Object.keys(entry.changes))
329 isDropped = true
330 continue
331 }
332 const { text: rendered, unknown } = renderRules(parseRules(file.content), entry.changes)
333 const reflected = await reflectedIds($, file, entry.changes, unknown, previous)
334 stale += countChanges(entry.changes, unknown.filter(id => !reflected.includes(id)))
335 isDropped = isDropped || unknown.length > 0
336 edits.push({ ...span, text: rendered.trim() })
337 const changes = Object.fromEntries(Object.entries(entry.changes).filter(([id]) => !unknown.includes(id)))
338 if (Object.keys(changes).length > 0) {
339 kept.push({ source: entry.source, changes })
340 }
341 }
342 if (isDropped) {
343 const memory = (await read($, applied)).filter(f => f.source === MEMORY)
344 await update($, applied, () => [...kept, ...memory])
345 }
346 if (stale > 0) {
347 await update($, staleCount, count => count + stale)
348 }
349 // From the last to the first, so each place found stays where it was.
350 let text = block.text
351 for (const edit of edits.sort((a, b) => b.at - a.at)) {
352 text = text.slice(0, edit.at) + edit.text + text.slice(edit.at + edit.length)
353 }
354
355 return text === block.text ? block : { name: block.name, text }
356}
357
358/**
359 * Settles what saves wrote against the files the engine hands now: a file it
360 * hands with other text than when the save went through was read again, so
361 * the changes that save wrote are in its text and leave applied quietly, and
362 * the session rules it took are read from the file alone. Answers the session
363 * rules still carried in the block, for the files the engine has not read again.
364 */
365async function settleSaved($: EngineInterface, files: readonly InstructionFile[] | undefined): Promise<string[]> {
366 const saved = await read($, savedFrom)
367 const reread = Object.entries(saved).filter(([path, entry]) => {
368 const file = files?.find(f => f.path === path)
369
370 return file !== undefined && hash(file.content) !== entry.loaded
371 })
372 if (reread.length > 0) {
373 const gone = new Map(reread.map(([path, entry]) => [path, entry.ids]))
374 await update($, savedFrom, () => Object.fromEntries(Object.entries(saved).filter(([path]) => !gone.has(path))))
375 const list = await read($, applied)
376 await update($, applied, () =>
377 list
378 .map(f => (gone.has(f.source) ? { source: f.source, changes: Object.fromEntries(Object.entries(f.changes).filter(([id]) => !(gone.get(f.source) ?? []).includes(id))) } : f))
379 .filter(f => Object.keys(f.changes).length > 0),
380 )
381 }
382 const left = Object.entries(saved).filter(([path]) => !reread.some(([gone]) => gone === path))
383
384 return left.flatMap(([, entry]) => entry.rules)
385}
386
387/** The session rules block's text. */
388function sessionRulesText(rules: readonly string[]): string {
389 return 'Session rules the person set for this session:\n' + rules.map(r => '- ' + r).join('\n')
390}
391
392/**
393 * The blocks that name again the rules `changes` turns off or edits: the files
394 * in the order the engine renders them, then the auto-memory entries. After an
395 * Apply the engine leaves the first message as it was, old rules included, and
396 * sends the context again in the next one: Claude reads both, and these blocks
397 * say which holds. A rule the files no longer have is not named. Session rules
398 * taken out are named as off too, after the files' rules.
399 */
400function noticesFor(files: readonly InstructionFile[] | undefined, memory: string | null, changes: readonly HeadspaceFileChanges[], dropped: readonly string[]): PromptContextBlock[] {
401 const notes: ChangeNotes = { off: [], edits: [] }
402 for (const file of files ?? []) {
403 const own = changes.find(f => f.source === file.path)
404 if (own !== undefined) {
405 const found = changeNotes(parseRules(file.content), own.changes)
406 notes.off.push(...found.off)
407 notes.edits.push(...found.edits)
408 }
409 }
410 const memoryChanges = changes.find(f => f.source === MEMORY)?.changes ?? {}
411 for (const entry of memory === null ? [] : memoryEntries(memory)) {
412 if ((memoryChanges[entry.id] ?? []).some(c => c.type === 'off')) {
413 notes.off.push(withoutMarker(entry.line))
414 }
415 }
416 notes.off.push(...dropped)
417
418 return noticeBlocks(notes)
419}
420
421/** The names of headspace's own blocks, put after core's. */
422const OWN_BLOCKS = [SESSION_RULES, 'turnedOff', 'changedRules']
423
424// ---------------------------------------------------------------- save and undo
425
426/** How many saves of one file `$.store` keeps to undo. */
427const MAX_BACKUPS = 10
428const SESSION_HEADING = '## Session rules'
429/** At most this many changed lines in a confirmation; the rest are counted. */
430const DIFF_LINES = 30
431
432type Backup = { at: number; text: string }
433
434/** Marks a save of a file the engine has not read into this context: whatever text it hands next is the saved one. */
435const NOT_LOADED = ''
436
437/**
438 * Records what a save wrote into `path`, kept in `savedFrom` (session state,
439 * so a reload of headspace keeps it): the engine may go on handing the text it
440 * read before until it reads the file again (a compaction, /clear), so those
441 * changes stay in force and those session rules ride in the block until its
442 * text differs (settleSaved).
443 */
444async function recordSave($: EngineInterface, path: string, ids: readonly string[], rules: readonly string[]): Promise<void> {
445 const loaded = lastFiles.find(f => f.path === path)?.content
446 const all = await read($, savedFrom)
447 const own: HeadspaceSaved = all[path] ?? { ids: [], rules: [], loaded: loaded === undefined ? NOT_LOADED : hash(loaded) }
448 await update($, savedFrom, () => ({ ...all, [path]: { ids: [...new Set([...own.ids, ...ids])], rules: [...new Set([...own.rules, ...rules])], loaded: own.loaded } }))
449}
450
451/** The last save's outcome per source (a path, or SESSION_RULES), shown in the pane under its group. */
452const saveNotices = new Map<string, string>()
453
454function backupKey(path: string): string {
455 return `backup:${path}`
456}
457
458async function backupsOf($: EngineInterface, path: string): Promise<Backup[]> {
459 const value = (await $.store.get(backupKey(path))) as { saves?: unknown } | undefined
460 const saves = Array.isArray(value?.saves) ? (value.saves as unknown[]) : []
461
462 return saves.filter((s): s is Backup => typeof s === 'object' && s !== null && typeof (s as Backup).text === 'string' && typeof (s as Backup).at === 'number')
463}
464
465/** Shows the outcome under the group, and as a toast; answers it. */
466function notify($: EngineInterface, source: string, text: string): string {
467 saveNotices.set(source, text)
468 $.ui.toast(text)
469 $.ui.invalidate('ui.render')
470
471 return text
472}
473
474/** The changed lines between two texts, `- ` out and `+ ` in, as a confirmation shows them. */
475function diffText(before: string, after: string): string {
476 const changed = diffLines(before, after).filter(line => line.type !== 'same')
477 const shownLines = changed.slice(0, DIFF_LINES).map(line => `${line.type === 'out' ? '-' : '+'} ${line.text}`)
478 const more = changed.length - shownLines.length
479
480 return [...shownLines, ...(more > 0 ? [`and ${count(more, 'more line')}`] : [])].join('\n')
481}
482
483/** Asks with `yes` and Cancel; a dismissed question is a Cancel. */
484async function confirm($: EngineInterface, question: string, yes: string): Promise<boolean> {
485 try {
486 return (await $.ui.ask(question, [yes, 'Cancel'])) === yes
487 } catch {
488 return false
489 }
490}
491
492/** A file's text, or '' when it is not there yet. */
493async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
494 return (await $.fs.exists(path)) ? await $.fs.read(path) : ''
495}
496
497/**
498 * Writes `text` over `path`, which held `disk` when it was read: refused when
499 * the file changed since; else `disk` is kept first as the newest backup (ten
500 * at most). Answers the refusal, or null once written.
501 */
502async function writeKeepingBackup($: EngineInterface, path: string, disk: string, text: string): Promise<string | null> {
503 if ((await readOrEmpty($, path)) !== disk) {
504 return 'changed on disk, nothing written'
505 }
506 const saves = [...(await backupsOf($, path)), { at: await $.clock.now(), text: disk }].slice(-MAX_BACKUPS)
507 await $.store.set(backupKey(path), { saves })
508 await $.fs.write(path, text)
509
510 return null
511}
512
513/**
514 * Writes a file's applied changes into the file on disk, after showing the
515 * diff and asking. Rule ids are worked out on the disk text: a change whose
516 * rule reads otherwise there is not saved, stays in force, and is counted.
517 * The saved changes leave applied once the engine reads the saved file
518 * (savedFrom); until then they keep the rule out of what Claude reads.
519 */
520async function save($: EngineInterface, source: string): Promise<string> {
521 try {
522 // An organization's file: the pane offers no save, and a drawing older than the engine's last read is refused here.
523 if (lastFiles.find(f => f.path === source)?.kind === 'managed') {
524 return notify($, source, 'managed files are read-only')
525 }
526 const entry = (await read($, applied)).find(f => f.source === source)
527 if (entry === undefined) {
528 return notify($, source, 'nothing applied to save')
529 }
530 // Changes an earlier save wrote are in the file already: only the others are saved now.
531 const written = (await read($, savedFrom))[source]?.ids ?? []
532 const changes = Object.fromEntries(Object.entries(entry.changes).filter(([id]) => !written.includes(id)))
533 const disk = await $.fs.read(source)
534 const { text, unknown } = renderRules(parseRules(disk), changes)
535 const lost = countChanges(changes, unknown)
536 const lostLine = lost > 0 ? `${count(lost, 'change')} cannot be saved: the rule differs on disk` : null
537 if (text === disk) {
538 return notify($, source, lostLine ?? 'nothing to save')
539 }
540 if (!(await confirm($, `${diffText(disk, text)}\n\nSave these changes to ${source}?`, 'Save'))) {
541 return notify($, source, 'nothing written')
542 }
543 const refusal = await writeKeepingBackup($, source, disk, text)
544 if (refusal !== null) {
545 return notify($, source, refusal)
546 }
547 const saved = Object.keys(changes).filter(id => !unknown.includes(id))
548 const loaded = lastFiles.find(f => f.path === source)?.content
549 if (loaded === undefined) {
550 // The engine has not read this file into the context: it will read the saved text, so the changes leave now.
551 await update($, applied, files =>
552 files
553 .map(f => (f.source === source ? { source, changes: Object.fromEntries(Object.entries(f.changes).filter(([id]) => !saved.includes(id))) } : f))
554 .filter(f => Object.keys(f.changes).length > 0),
555 )
556 } else {
557 // Until the engine reads the file again it hands the old text: the saved changes stay in force over it.
558 await recordSave($, source, saved, [])
559 }
560 $.ui.invalidate('prompt.context')
561
562 return notify($, source, lostLine === null ? `saved to ${source}` : `saved to ${source}; ${lostLine}`)
563 } catch (error) {
564 return notify($, source, `nothing written: ${error instanceof Error ? error.message : String(error)}`)
565 }
566}
567
568/**
569 * Puts back the file as it was before its last save, after showing the diff
570 * and asking. Refused when the file changed while the question was open; the
571 * text it writes over is kept for that check alone, never as a save, so a
572 * second undo goes one save further back instead of redoing the first. The
573 * backup it restored is used up only once the write went through.
574 */
575async function undoSave($: EngineInterface, source: string): Promise<string> {
576 try {
577 const last = (await backupsOf($, source)).at(-1)
578 if (last === undefined) {
579 return notify($, source, 'no save to undo')
580 }
581 const current = await readOrEmpty($, source)
582 if (!(await confirm($, `${diffText(current, last.text)}\n\nRestore ${source} to how it was before the last save?`, 'Restore'))) {
583 return notify($, source, 'nothing written')
584 }
585 if ((await readOrEmpty($, source)) !== current) {
586 return notify($, source, 'changed on disk, nothing written')
587 }
588 await $.fs.write(source, last.text)
589 await $.store.set(backupKey(source), { saves: (await backupsOf($, source)).slice(0, -1) })
590 // The file is as it was before the save; the changes that save wrote stay in force for this session, as before it.
591 // A session rule it held no more goes back among the session rules, so it is never lost.
592 const { [source]: entry, ...others } = await read($, savedFrom)
593 if (entry !== undefined) {
594 const lines = new Set(last.text.split(/\r?\n/).map(line => line.trim()))
595 const back = entry.rules.filter(rule => !lines.has(`- ${rule}`))
596 const still = entry.rules.filter(rule => !back.includes(rule))
597 await update($, savedFrom, () => (still.length === 0 ? others : { ...others, [source]: { ids: [], rules: still, loaded: entry.loaded } }))
598 if (back.length > 0) {
599 const list = await read($, sessionRules)
600 await update($, sessionRules, () => [...list, ...back.filter(rule => !list.includes(rule))])
601 }
602 }
603 $.ui.invalidate('prompt.context')
604
605 return notify($, source, `restored ${source}`)
606 } catch (error) {
607 return notify($, source, `nothing written: ${error instanceof Error ? error.message : String(error)}`)
608 }
609}
610
611/** `text` with `- rule` added at the end of its `## Session rules` section, the section made at the end when absent. */
612function withSessionRule(text: string, rule: string): string {
613 const eol = /\r\n/.exec(text)?.[0] ?? '\n'
614 const lines = text === '' ? [] : text.split(eol)
615 if (lines.at(-1) === '') {
616 lines.pop()
617 }
618 const item = `- ${rule}`
619 const heading = lines.findIndex(line => line.trim() === SESSION_HEADING)
620 if (heading === -1) {
621 const gap = lines.length > 0 && lines.at(-1)?.trim() !== '' ? [''] : []
622
623 return [...lines, ...gap, SESSION_HEADING, '', item].join(eol) + eol
624 }
625 const next = lines.findIndex((line, at) => at > heading && /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line))
626 let end = next === -1 ? lines.length : next
627 while (end > heading + 1 && lines[end - 1]?.trim() === '') {
628 end--
629 }
630 const gap = end === heading + 1 ? [''] : []
631
632 return [...lines.slice(0, end), ...gap, item, ...lines.slice(end)].join(eol) + eol
633}
634
635/** Saves a session rule into the project's CLAUDE.md or the person's, as they choose, after showing the diff and asking. */
636async function saveSessionRule($: EngineInterface, rule: string): Promise<string> {
637 try {
638 let where: string
639 try {
640 where = await $.ui.ask('Save this rule to which CLAUDE.md?', ['Project', 'User', 'Cancel'])
641 } catch {
642 where = 'Cancel'
643 }
644 let path: string
645 if (where === 'Project') {
646 path = `${await $.session.root()}/CLAUDE.md`
647 } else if (where === 'User') {
648 const config = await $.env.get('CLAUDE_CONFIG_DIR')
649 const home = await $.env.get('HOME')
650 if (config === undefined && home === undefined) {
651 return notify($, SESSION_RULES, 'cannot find your CLAUDE.md: HOME is not set')
652 }
653 path = `${config ?? `${home}/.claude`}/CLAUDE.md`
654 } else {
655 return notify($, SESSION_RULES, 'nothing written')
656 }
657 const disk = await readOrEmpty($, path)
658 const text = withSessionRule(disk, rule)
659 if (!(await confirm($, `${diffText(disk, text)}\n\nSave these changes to ${path}?`, 'Save'))) {
660 return notify($, SESSION_RULES, 'nothing written')
661 }
662 const refusal = await writeKeepingBackup($, path, disk, text)
663 if (refusal !== null) {
664 return notify($, SESSION_RULES, refusal)
665 }
666 // In the file now, it is no longer a session rule: Claude reads it once, from the block until the engine reads the file again.
667 const inForce = await read($, sessionRules)
668 const waiting = await read($, pendingSessionRules)
669 await update($, sessionRules, () => inForce.filter(one => one !== rule))
670 await update($, pendingSessionRules, () => (waiting === null ? null : waiting.filter(one => one !== rule)))
671 await recordSave($, path, [], [rule])
672 $.ui.invalidate('prompt.context')
673
674 return notify($, SESSION_RULES, `saved to ${path}`)
675 } catch (error) {
676 return notify($, SESSION_RULES, `nothing written: ${error instanceof Error ? error.message : String(error)}`)
677 }
678}
679
680// ---------------------------------------------------------------- large results
681
682/**
683 * Why headspace-cut cannot answer a removal now, or null when it can: it
684 * wrote `ready` at session start, and its heartbeat `seenAt` is no older than
685 * the last main-loop turn's start (before the first turn ends, ready is
686 * enough). A heartbeat older than that means it stopped running: disabled
687 * during the session, it no longer answers, and core would summarize.
688 */
689async function cutMissing($: EngineInterface): Promise<string | null> {
690 let ready: unknown
691 let seenAt: unknown
692 try {
693 ready = (await $.state.get({ plugin: 'headspace-cut', key: 'ready' })).value
694 seenAt = (await $.state.get({ plugin: 'headspace-cut', key: 'seenAt' })).value
695 } catch {
696 ready = undefined
697 }
698 if (ready !== true) {
699 return 'install headspace-cut to remove results'
700 }
701 if (turnStartedAt !== null && !(typeof seenAt === 'number' && seenAt >= turnStartedAt)) {
702 return 'headspace-cut is not running: turn it on to remove results'
703 }
704
705 return null
706}
707
708/** Why a cut cannot run now, or null when it can. */
709async function cutRefusal($: EngineInterface): Promise<string | null> {
710 if (isTurnRunning) {
711 return 'Claude is working: remove results between turns'
712 }
713 if (await read($, isCutting)) {
714 return 'a removal is already running'
715 }
716 if ((await read($, marks)).length === 0) {
717 return 'no results marked'
718 }
719
720 return cutMissing($)
721}
722
723async function toggleMark($: EngineInterface, id: string): Promise<void> {
724 const found = lastLarge.find(r => r.id === id)
725 await update($, marks, list => {
726 if (list.some(m => m.id === id)) {
727 return list.filter(m => m.id !== id)
728 }
729
730 return found === undefined ? list : [...list, { id, label: label(found) }]
731 })
732}
733
734/**
735 * Removes the marked results: a compaction headspace-cut answers. Refused (the
736 * reason answered, and kept for the pane) while a turn runs, while a cut is in
737 * flight, with nothing marked, or without headspace-cut. A skip keeps the marks.
738 */
739async function applyCut($: EngineInterface): Promise<string | null> {
740 const refusal = await cutRefusal($)
741 if (refusal !== null) {
742 // The pane works the refusal out again as it draws: nothing to keep.
743 return refusal
744 }
745 cutNotice = null
746 await update($, isCutting, () => true)
747 try {
748 const result = await $.session.compact({ instructions: cutInstructions(await read($, marks)) })
749 if (result.skip !== undefined) {
750 cutNotice = result.skip
751 $.ui.toast(result.skip)
752
753 return result.skip
754 }
755 await update($, marks, () => [])
756 } catch (error) {
757 // The engine refused the compaction (a turn started meanwhile): nothing changed.
758 cutNotice = `nothing removed: ${error instanceof Error ? error.message : String(error)}`
759 $.ui.toast(cutNotice)
760
761 return cutNotice
762 } finally {
763 await update($, isCutting, () => false)
764 }
765 await refresh($)
766
767 return null
768}
769
770/** What the marked results free, and the cache removing them rewrites (everything from the first one on). */
771async function cutFigures($: EngineInterface): Promise<{ ids: Set<string>; frees: number; cost: number }> {
772 const ids = new Set((await read($, marks)).map(m => m.id))
773 const frees = lastLarge.filter(r => ids.has(r.id)).reduce((sum, r) => sum + r.tokens, 0)
774
775 return { ids, frees, cost: cacheCost(lastApi, [...ids], lastMessageTokens) }
776}
777
778/**
779 * The pane's apply: a removal cannot be undone, so it asks first, as save
780 * does; Cancel, or a dismissed question, leaves everything as it was. A
781 * removal refused already asks nothing: the pane says why beside the button.
782 */
783async function confirmCut($: EngineInterface): Promise<void> {
784 if ((await cutRefusal($)) !== null) {
785 return
786 }
787 const { ids, frees, cost } = await cutFigures($)
788 const question = `Remove ${count(ids.size, 'result')} from what Claude reads? This cannot be undone. Frees ${formatTokens(frees)}, rewrites ~${formatTokens(cost)} of cache.`
789 if (await confirm($, question, 'Remove')) {
790 await applyCut($)
791 }
792}
793
794/**
795 * `/headspace cut`, once the command is answered: typed on purpose, so it asks
796 * nothing. A refusal by then (a turn started meanwhile) is said in a toast.
797 */
798async function typedCut($: EngineInterface): Promise<void> {
799 const refusal = await cutRefusal($)
800 if (refusal !== null) {
801 $.ui.toast('headspace: ' + refusal)
802
803 return
804 }
805 await applyCut($)
806}
807
808// ---------------------------------------------------------------- the pane
809
810/**
811 * Opens the pane on `kind`. On a terminal too narrow to seat it the engine
812 * opens it undrawn (it shows once the terminal widens), which looks like a
813 * press that did nothing: so headspace says why. Resolves whether it is on screen.
814 */
815async function openOn($: EngineInterface, kind: HeadspaceKind): Promise<boolean> {
816 await update($, selected, () => kind)
817 await update($, cursor, () => null)
818 const opened = await $.ui.open({ id: PANE, title: 'headspace', closeOnEscape: true, focus: true })
819 if (!opened.isPlaced) {
820 $.ui.toast(`headspace: widen the terminal to see the pane (${opened.reason})`)
821 }
822 await refresh($)
823
824 return opened.isPlaced
825}
826
827function isKind(word: string): word is HeadspaceKind {
828 return (ORDER as readonly string[]).includes(word)
829}
830
831// ---------------------------------------------------------------- drawing
832
833/** Each kind lit under the pointer: a lighter shade of its colour. */
834const LIGHTER: Record<HeadspaceKind, string> = {
835 system: '#d1cfc6', rules: '#e9a58a', conversation: '#9cbde0', large: '#a5b78a', free: '#d1cfc6', reserve: '#d1cfc6',
836}
837const STONE = COLOURS.system
838const CLAY = COLOURS.rules
839/**
840 * What the bar leaves beside it for the text, per fallback: room for every
841 * kind's label, the counts and the percentage (`100%`, 4 cells, and the two
842 * gaps of 2 around it: 8), at their widest.
843 * full: `sys` `rules 999k` `conv` `large 999k` `free` `res` (34) and `99 off`
844 * `99 marked` (15), gaps of 2 between the eight (14): 63, so 71.
845 * counts: the short labels (24) and the counts (15), gaps of 1 (7): 46, so 54.
846 * bare: six letters and five gaps of 1: 11, so 19.
847 * The stale line is cut to what is left.
848 */
849const BESIDE: Record<ReturnType<typeof fallback>, number> = { full: 71, counts: 54, bare: 19 }
850/** The gap between the things after the percentage, per fallback. */
851const GAP: Record<ReturnType<typeof fallback>, number> = { full: 2, counts: 1, bare: 1 }
852/** Each kind's label where the band has little room: short, and in a bare band one letter (`b` for the reserve, the autocompact buffer). */
853const SHORT: Record<HeadspaceKind, string> = { system: 'sys', rules: 'rules', conversation: 'conv', large: 'large', free: 'free', reserve: 'res' }
854const LETTER: Record<HeadspaceKind, string> = { system: 's', rules: 'r', conversation: 'c', large: 'l', free: 'f', reserve: 'b' }
855const BAR_MAX = 60
856const BAR_MIN = 6
857
858type Els = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
859type InputEl = ElementConstructor<InputProps> | undefined
860
861/** `1 rule`, `3 rules`. */
862function count(n: number, one: string, many = `${one}s`): string {
863 return `${n} ${n === 1 ? one : many}`
864}
865
866/** A one-line cut of `text` to `columns` cells, with an ellipsis when cut. */
867function cut(text: string, columns: number): string {
868 const room = Math.max(4, columns)
869
870 return text.length <= room ? text : text.slice(0, room - 1) + '…'
871}
872
873/** Characters over four, as the bar counts text it has no figure for. */
874function estimate(text: string): number {
875 return Math.round(text.length / 4)
876}
877
878/** The categories behind a read-only kind, as the breakdown named them. */
879function categoriesOf(kind: HeadspaceKind): Category[] {
880 return lastCategories.filter(c => {
881 if (c.kind === 'deferred') {
882 return false
883 }
884 if (kind === 'free') {
885 return c.kind === 'free'
886 }
887 if (kind === 'reserve') {
888 return c.kind === 'buffer'
889 }
890 if (kind === 'conversation') {
891 return c.kind === 'used' && c.name === 'Messages'
892 }
893
894 return kind === 'system' && c.kind === 'used' && c.name !== 'Messages' && c.name !== 'Memory files'
895 })
896}
897
898/** One file's rows for the pane: a file behind claudeMd, or the auto-memory index. */
899type RuleGroup = { source: string; title: string; isManaged: boolean; isMemory: boolean; rules: { id: string; lines: string[]; first: string }[] }
900
901/** The session root and the home directory, for short paths; null where the engine does not say. */
902async function pathBases($: EngineInterface): Promise<{ root: string | null; home: string | null }> {
903 const root = await $.session.root().catch(() => null)
904 const home = (await $.env.get('HOME').catch(() => undefined)) ?? null
905
906 return { root, home }
907}
908
909function ruleGroups(bases: { root: string | null; home: string | null } = { root: null, home: null }): RuleGroup[] {
910 const groups: RuleGroup[] = lastFiles.map(file => ({
911 source: file.path,
912 title: `${shortPath(file.path, bases.root, bases.home)} ${file.kind} ${formatTokens(estimate(file.content))}`,
913 isManaged: file.kind === 'managed',
914 isMemory: false,
915 rules: parseRules(file.content).rules.map(rule => ({ id: rule.id, lines: rule.lines, first: rule.lines[0] ?? '' })),
916 }))
917 if (lastMemory !== null) {
918 const entries = memoryEntries(lastMemory)
919 if (entries.length > 0) {
920 groups.push({
921 source: MEMORY,
922 title: `auto-memory memory ${formatTokens(estimate(entries.map(e => e.line).join('\n')))}`,
923 isManaged: false,
924 isMemory: true,
925 rules: entries.map(entry => ({ id: entry.id, lines: [entry.line], first: entry.title })),
926 })
927 }
928 }
929
930 return groups
931}
932
933/** A rule's changes as the pane shows them: pending's list, else applied's. */
934function effective(waiting: readonly HeadspaceFileChanges[], inForce: readonly HeadspaceFileChanges[], source: string, id: string): HeadspaceRuleChange[] {
935 return waiting.find(f => f.source === source)?.changes[id] ?? inForce.find(f => f.source === source)?.changes[id] ?? []
936}
937
938/** The rules turned off and in force, every file and the memory together. */
939function offCount(inForce: readonly HeadspaceFileChanges[]): number {
940 return inForce.reduce((sum, file) => sum + Object.values(file.changes).filter(list => list.some(c => c.type === 'off')).length, 0)
941}
942
943/** Where the pane's cursor is: a rule (its file, id and selected line) or a large result. */
944type Cursor = { type: 'rule'; source: string; id: string; line: number } | { type: 'large'; id: string }
945
946function cursorOf(text: string | null): Cursor | null {
947 if (text === null) {
948 return null
949 }
950 try {
951 const value = JSON.parse(text) as unknown[]
952 if (value[0] === 'rule' && typeof value[1] === 'string' && typeof value[2] === 'string') {
953 return { type: 'rule', source: value[1], id: value[2], line: typeof value[3] === 'number' ? value[3] : 0 }
954 }
955 if (value[0] === 'large' && typeof value[1] === 'string') {
956 return { type: 'large', id: value[1] }
957 }
958 } catch {
959 // Not a cursor of this version: nothing selected.
960 }
961
962 return null
963}
964
965/** Runs a button's work; a failure is recorded, never thrown at the surface. */
966function press($: EngineInterface, where: string, work: () => Promise<unknown>): void {
967 void work().catch(() => lose($, where))
968}
969
970/** The pieces each kind holds, for the cards: rules, results, messages, parts. Counted once per draw: the rules mean parsing every file. */
971function pieces(rules: readonly string[]): Partial<Record<HeadspaceKind, string>> {
972 return {
973 rules: count(ruleGroups().reduce((sum, g) => sum + g.rules.length, 0) + rules.length, 'rule'),
974 large: count(lastLarge.length, 'result'),
975 conversation: count(lastApi.length, 'message'),
976 system: count(categoriesOf('system').length, 'part'),
977 }
978}
979
980/**
981 * The hover group a kind's segment and its card share: the pointer on the
982 * segment shows the card in the labels' place, in the band's own row (an
983 * absolute Box is clipped to the band, so a card above a one-row band would
984 * never show).
985 */
986function scopeOf(kind: HeadspaceKind): string {
987 return `headspace-${kind}`
988}
989
990/** The cell a kind is drawn with: free is an empty rail, the reserve hatched, the rest solid blocks. */
991function glyph(kind: HeadspaceKind): string {
992 return kind === 'free' ? '▁' : kind === 'reserve' ? '░' : '█'
993}
994
995/** One segment of the bar: its cells, and the same cells as a button under the pointer. */
996function segment($: EngineInterface, { Box, Text, Button }: Els, kind: HeadspaceKind, width: number): RenderElement {
997 const run = glyph(kind).repeat(width)
998 const hover = { scope: scopeOf(kind), color: LIGHTER[kind] }
999
1000 return (
1001 <Box key={`seg-${kind}`} flexShrink={0}>
1002 <Text color={COLOURS[kind]} dimColor={kind === 'free' ? true : undefined} hover={hover}>
1003 {run}
1004 </Text>
1005 <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
1006 <Button key={`open-${kind}`} plain label={run} hover={hover} onPress={() => press($, 'ui.press', () => openOn($, kind))} />
1007 </Box>
1008 </Box>
1009 )
1010}
1011
1012/** What a kind's card says: `rules 4k 12 rules click to view and remove`. */
1013function cardText(kind: HeadspaceKind, tokens: number, held: Partial<Record<HeadspaceKind, string>>): string {
1014 const what = held[kind] ?? null
1015 const action = kind === 'rules' || kind === 'large' ? 'click to view and remove' : 'view only'
1016
1017 return [`${kind} ${formatTokens(tokens)}`, what, action].filter((part): part is string => part !== null).join(' ')
1018}
1019
1020/**
1021 * A kind's card, drawn over the labels after the bar while the pointer is on
1022 * its segment: one line, padded to the labels' width so none shows through.
1023 */
1024function card({ Box, Text }: Els, kind: HeadspaceKind, text: string, room: number): RenderElement {
1025 return (
1026 <Box key={`card-${kind}`} position="absolute" top={0} left={0} width={room} display="none" hover={{ display: 'flex', scope: scopeOf(kind) }}>
1027 <Text color={COLOURS[kind]} wrap="truncate-end">
1028 {cut(text, room).padEnd(room)}
1029 </Text>
1030 </Box>
1031 )
1032}
1033
1034/** The kinds as tabs: the selected one drawn inverse, the others pressable. */
1035function tabs($: EngineInterface, { Box, Text, Button }: Els, shown: HeadspaceKind): RenderElement {
1036 return (
1037 <Box gap={2} flexWrap="wrap">
1038 {ORDER.map(kind =>
1039 kind === shown ? (
1040 <Text key={`tab-${kind}-on`} color={COLOURS[kind]} inverse bold>
1041 {` ${kind} `}
1042 </Text>
1043 ) : (
1044 <Box key={`tab-box-${kind}`} gap={1}>
1045 <Text color={COLOURS[kind]} dimColor={kind === 'free' ? true : undefined}>
1046 {glyph(kind)}
1047 </Text>
1048 <Button key={`tab-${kind}`} plain label={kind} onPress={() => press($, 'ui.press', () => showKind($, kind))} />
1049 </Box>
1050 ),
1051 )}
1052 </Box>
1053 )
1054}
1055
1056async function showKind($: EngineInterface, kind: HeadspaceKind): Promise<void> {
1057 await update($, selected, () => kind)
1058 await update($, cursor, () => null)
1059}
1060
1061async function select($: EngineInterface, value: Cursor | null): Promise<void> {
1062 const text = value === null ? null : JSON.stringify(value.type === 'rule' ? ['rule', value.source, value.id, value.line] : ['large', value.id])
1063 await update($, cursor, () => text)
1064}
1065
1066/** The rules tab: each file's rules, the session rules, and apply. */
1067async function rulesBody($: EngineInterface, { Box, Text, Button }: Els, Input: InputEl, columns: number, at: Cursor | null): Promise<RenderElement> {
1068 const waiting = await read($, pending)
1069 const inForce = await read($, applied)
1070 const shot = await read($, snapshot)
1071 const rules = (await read($, pendingSessionRules)) ?? (await read($, sessionRules))
1072 const groups = ruleGroups(await pathBases($))
1073 const waitingCount = waiting.reduce((sum, f) => sum + Object.keys(f.changes).length, 0) + ((await read($, pendingSessionRules)) === null ? 0 : 1)
1074 // What an Apply costs: the engine leaves the first message as it was and sends the context again
1075 // in the next one, so the claudeMd block, headspace's notes and the session rules, at characters
1076 // over four. An auto-memory change rewrites the system prompt, and everything after it is cached again.
1077 const dropped = droppedAfter(await read($, droppedSessionRules), await read($, sessionRules), rules)
1078 const sent = lastClaudeMdChars + noticesFor(lastFiles, lastMemory, merged(inForce, waiting), dropped).reduce((sum, b) => sum + b.text.length, 0) + (rules.length > 0 ? sessionRulesText(rules).length : 0)
1079 const used = (shot?.segments ?? []).filter(s => s.kind !== 'free' && s.kind !== 'reserve').reduce((sum, s) => sum + s.tokens, 0)
1080 const cost = `adds ~${formatTokens(Math.round(sent / 4))} to the next message` + (waiting.some(f => f.source === MEMORY) ? `, rewrites ~${formatTokens(used)} of cache` : '')
1081 /** Whether each group's file has a save to undo: only files headspace may write. */
1082 const undoable = await Promise.all(groups.map(async g => !g.isManaged && !g.isMemory && (await backupsOf($, g.source).catch(() => [])).length > 0))
1083 const sessionNotice = saveNotices.get(SESSION_RULES)
1084
1085 return (
1086 <Box flexDirection="column" gap={1}>
1087 {groups.length === 0 && <Text color={STONE}>No instruction files read yet. They show after the next request.</Text>}
1088 {groups.map((group, g) => (
1089 <Box key={`group-${g}`} flexDirection="column">
1090 <Text bold wrap="truncate-start">
1091 {group.isManaged ? `${group.title} managed` : group.title}
1092 </Text>
1093 {!group.isManaged && !group.isMemory && (
1094 <Box gap={2}>
1095 <Button key={`save-${g}`} plain label="save" onPress={() => press($, 'ui.press', () => save($, group.source))} />
1096 {undoable[g] === true && <Button key={`undo-${g}`} plain label="undo save" onPress={() => press($, 'ui.press', () => undoSave($, group.source))} />}
1097 {saveNotices.has(group.source) && <Text color={STONE}>{saveNotices.get(group.source)}</Text>}
1098 </Box>
1099 )}
1100 {group.rules.map((rule, r) => {
1101 const list = effective(waiting, inForce, group.source, rule.id)
1102 const isOff = list.some(c => c.type === 'off')
1103 const isAt = at?.type === 'rule' && at.source === group.source && at.id === rule.id
1104 const tokens = formatTokens(estimate(rule.lines.join('\n')))
1105 const first = cut(rule.first, columns - 12)
1106
1107 return (
1108 <Box key={`rule-box-${g}-${r}`} flexDirection="column">
1109 <Box gap={1}>
1110 <Text color={isOff ? STONE : CLAY}>{isOff ? '○' : '●'}</Text>
1111 {group.isManaged ? (
1112 <Text>{first}</Text>
1113 ) : (
1114 <Button
1115 key={`rule-${g}-${r}`}
1116 plain
1117 label={first}
1118 dimColor={isOff ? true : undefined}
1119 onPress={() => press($, 'ui.press', () => select($, isAt ? null : { type: 'rule', source: group.source, id: rule.id, line: 0 }))}
1120 />
1121 )}
1122 <Text color={STONE}>{tokens}</Text>
1123 </Box>
1124 {isAt && at?.type === 'rule' && (
1125 <Box flexDirection="column" marginLeft={2}>
1126 {rule.lines.map((line, l) =>
1127 rule.lines.length === 1 || group.isMemory ? (
1128 <Text key={`line-${l}`}>{line}</Text>
1129 ) : (
1130 <Button
1131 key={`line-${l}`}
1132 plain
1133 label={l === at.line ? `> ${line}` : ` ${line}`}
1134 onPress={() => press($, 'ui.press', () => select($, { type: 'rule', source: group.source, id: rule.id, line: l }))}
1135 />
1136 ),
1137 )}
1138 <Box gap={2}>
1139 <Button key="toggle" label={isOff ? 'on' : 'off'} onPress={() => press($, 'ui.press', () => toggleRule($, group.source, rule.id))} />
1140 </Box>
1141 {Input !== undefined && !group.isMemory && (
1142 <Input
1143 key="edit"
1144 label="edit "
1145 value={list.find((c): c is Extract<HeadspaceRuleChange, { type: 'edit' }> => c.type === 'edit' && c.lineIndex === at.line)?.text ?? rule.lines[at.line] ?? ''}
1146 onSubmit={value => press($, 'ui.input', () => editLine($, group.source, rule.id, at.line, value))}
1147 />
1148 )}
1149 {Input !== undefined && !group.isMemory && (
1150 <Input
1151 key="add-line"
1152 label="add line "
1153 placeholder="a line below the selected one"
1154 value=""
1155 onSubmit={value => press($, 'ui.input', () => addLine($, group.source, rule.id, at.line, value))}
1156 />
1157 )}
1158 </Box>
1159 )}
1160 </Box>
1161 )
1162 })}
1163 </Box>
1164 ))}
1165 <Box flexDirection="column">
1166 <Text bold>{`session rules ${formatTokens(tokensOf(rules))}`}</Text>
1167 {rules.map((rule, i) => (
1168 <Box key={`session-${i}`} gap={1}>
1169 <Text color={CLAY}>●</Text>
1170 <Text>{rule}</Text>
1171 <Button key={`session-remove-${i}`} plain dimColor label="remove" onPress={() => press($, 'ui.press', () => removeSessionRule($, i))} />
1172 <Button key={`session-save-${i}`} plain dimColor label="save" onPress={() => press($, 'ui.press', () => saveSessionRule($, rule))} />
1173 </Box>
1174 ))}
1175 {sessionNotice !== undefined && <Text color={STONE}>{sessionNotice}</Text>}
1176 {Input !== undefined && (
1177 <Input key="session-rule" placeholder="add a session rule" value="" onSubmit={value => press($, 'ui.input', () => addSessionRule($, value))} />
1178 )}
1179 </Box>
1180 <Box gap={2}>
1181 <Button key="apply-rules" label="apply" variant="primary" onPress={() => press($, 'ui.press', () => applyRules($))} />
1182 <Text color={STONE}>{cost}</Text>
1183 {waitingCount > 0 && <Text color={CLAY}>{count(waitingCount, 'change') + ' waiting'}</Text>}
1184 </Box>
1185 {waitingCount > 0 && <Text color={STONE}>the original stays earlier in the conversation until the next /compact</Text>}
1186 </Box>
1187 )
1188}
1189
1190/** The large tab: the results biggest first, each marked or not, and apply. */
1191async function largeBody($: EngineInterface, { Box, Text, Button }: Els, columns: number, at: Cursor | null): Promise<RenderElement> {
1192 const refusal = await cutRefusal($)
1193 const { ids, frees, cost } = await cutFigures($)
1194
1195 return (
1196 <Box flexDirection="column" gap={1}>
1197 {lastLarge.length === 0 && <Text color={STONE}>No tool result is that large.</Text>}
1198 <Box flexDirection="column">
1199 {lastLarge.map((result, i) => {
1200 const what = result.target === '' ? result.tool : `${result.tool} ${result.target}`hooks/bar.ts 150 lines1import type { HeadspaceKind, HeadspaceSegment } from '../types'
2
3export const COLOURS: Record<HeadspaceKind, string> = {
4 system: '#b0aea5', rules: '#d97757', conversation: '#6a9bcc', large: '#788c5d', free: '#b0aea5', reserve: '#b0aea5',
5}
6export const ORDER: HeadspaceKind[] = ['system', 'rules', 'conversation', 'large', 'free', 'reserve']
7
8/** A breakdown category as the engine gives it. */
9export type Category = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
10
11const MEMORY_FILES = 'Memory files'
12const MESSAGES = 'Messages'
13
14/**
15 * The six kinds from the breakdown. Deferred categories are left out. `Memory files` -> rules
16 * (plus `sessionRuleTokens`); `Messages` -> conversation minus large; `Free space` -> free;
17 * kind `buffer` -> reserve; every other used category -> system. `largeShare` is the
18 * fraction (0..1) of Messages that large results take (from large.ts); large never exceeds Messages.
19 */
20/**
21 * `categories` made to agree with what the engine measured. A summary breakdown estimates its rows
22 * locally and makes Messages what the last response's usage leaves after them; with many tools the
23 * estimates run high, and Messages can come out smaller than one tool result in it. So Messages is
24 * raised to the conversation headspace reads, at characters / 4, and when `liveTokens` (the last
25 * response's input, the status line's figure) is known the used rows add up to it. The system rows
26 * take the difference, in proportion to their size and never below zero; Memory files stay.
27 */
28export function reconcile(categories: Category[], conversationChars: number, liveTokens?: number): Category[] {
29 const messages = categories.find(c => c.kind === 'used' && c.name === MESSAGES)
30 if (messages === undefined) return categories
31 const isSystem = (c: Category) => c.kind === 'used' && c.name !== MESSAGES && c.name !== MEMORY_FILES
32 const systemRows = categories.filter(isSystem)
33 const systemTotal = systemRows.reduce((total, c) => total + c.tokens, 0)
34 const memory = categories.filter(c => c.kind === 'used' && c.name === MEMORY_FILES).reduce((total, c) => total + c.tokens, 0)
35 const target = liveTokens ?? systemTotal + memory + messages.tokens
36 const messageTokens = Math.min(Math.max(messages.tokens, Math.round(conversationChars / 4)), Math.max(0, target - memory))
37 const systemTarget = Math.max(0, target - memory - messageTokens)
38 if (messageTokens === messages.tokens && systemTarget === systemTotal) return categories
39 let left = systemTarget
40 return categories.map(c => {
41 if (c === messages) return { ...c, tokens: messageTokens }
42 if (!isSystem(c)) return c
43 // Floors for all but the last row, which takes what is left, so the rows add up to the target.
44 const tokens = c === systemRows.at(-1) ? left : Math.floor(systemTotal > 0 ? (c.tokens / systemTotal) * systemTarget : 0)
45 left -= tokens
46 return { ...c, tokens }
47 })
48}
49
50export function toSegments(categories: Category[], largeShare: number, sessionRuleTokens: number): HeadspaceSegment[] {
51 const tokens: Record<HeadspaceKind, number> = { system: 0, rules: sessionRuleTokens, conversation: 0, large: 0, free: 0, reserve: 0 }
52 let messages = 0
53
54 for (const category of categories) {
55 if (category.kind === 'deferred') continue
56 if (category.kind === 'free') tokens.free += category.tokens
57 else if (category.kind === 'buffer') tokens.reserve += category.tokens
58 else if (category.name === MEMORY_FILES) tokens.rules += category.tokens
59 else if (category.name === MESSAGES) messages += category.tokens
60 else tokens.system += category.tokens
61 }
62
63 const share = Number.isFinite(largeShare) ? Math.min(1, Math.max(0, largeShare)) : 0
64 tokens.large = Math.round(messages * share)
65 tokens.conversation = messages - tokens.large
66
67 return ORDER.map(kind => ({ kind, tokens: tokens[kind] }))
68}
69
70/** One drawn cell run per segment. */
71export type Cell = { kind: HeadspaceKind; width: number }
72
73// A count that is not a positive finite number draws nothing.
74const counted = (tokens: number) => (Number.isFinite(tokens) && tokens > 0 ? tokens : 0)
75
76type Share = { kind: HeadspaceKind; rank: number; width: number; remainder: number; floor: number }
77
78/**
79 * Widths for `columns` cells, proportional to tokens over `window`; every segment with
80 * tokens > 0 gets at least 1 cell; the sum is exactly `columns` when tokens fill the window,
81 * never more. Largest-remainder rounding, ties to the earlier kind in ORDER.
82 */
83export function widths(segments: HeadspaceSegment[], window: number, columns: number): Cell[] {
84 const isDrawn = window > 0
85 const shares: Share[] = segments
86 .map(s => {
87 const ideal = isDrawn ? (counted(s.tokens) / window) * columns : 0
88 const whole = Math.floor(ideal)
89 // A non-empty segment keeps one cell even when its share is a sliver.
90 const floor = ideal > 0 ? 1 : 0
91 // Never wider than the bar, so every loop below ends.
92 const width = Math.min(Math.max(0, columns), Math.max(whole, floor))
93 // Measured from the cells given: a sliver raised to one cell has nothing left to claim.
94 return { kind: s.kind, rank: ORDER.indexOf(s.kind), width, remainder: ideal - width, floor }
95 })
96 .sort((a, b) => a.rank - b.rank)
97
98 const used = segments.reduce((total, s) => total + counted(s.tokens), 0)
99 // Whole cells the tokens cover; a window not quite full leaves its last cell empty.
100 const covered = isDrawn ? Math.floor(Math.min(1, used / window) * columns + 1e-9) : 0
101 const minimum = shares.reduce((total, s) => total + s.floor, 0)
102 const total = Math.min(columns, Math.max(covered, minimum))
103
104 let drawn = shares.reduce((total, s) => total + s.width, 0)
105
106 // Spare cells go to the largest remainders, ties to the earlier kind.
107 const byRemainder = [...shares].sort((a, b) => b.remainder - a.remainder || a.rank - b.rank)
108 for (const share of byRemainder) {
109 if (drawn >= total) break
110 share.width += 1
111 drawn += 1
112 }
113
114 // Cells the one-cell minimum took are given back by the smallest remainders, ties from the later kind.
115 while (drawn > total) {
116 const donor = [...shares]
117 .filter(s => s.width > s.floor)
118 .sort((a, b) => a.remainder - b.remainder || b.rank - a.rank)[0]
119 // More non-empty segments than columns: the later kinds lose their cell.
120 const victim = donor ?? [...shares].filter(s => s.width > 0).sort((a, b) => b.rank - a.rank)[0]
121 if (!victim) break
122 victim.width -= 1
123 victim.remainder += 1
124 drawn -= 1
125 }
126
127 return shares.map(s => ({ kind: s.kind, width: s.width }))
128}
129
130/** How much text fits beside the bar: 'full' (labels and counts), 'counts' (no labels), 'bare' (percentage only). */
131export function fallback(bodyColumns: number): 'full' | 'counts' | 'bare' {
132 if (bodyColumns >= 100) return 'full'
133 if (bodyColumns >= 70) return 'counts'
134 return 'bare'
135}
136
137// One decimal below 100 of a unit, none from there; `10.0` drops its `.0`.
138function scaled(value: number, unit: string): string {
139 const text = value < 100 ? value.toFixed(1) : Math.round(value).toString()
140 return text.replace(/\.0$/, '') + unit
141}
142
143/** `9.2k`, `148k`, `950`, `1.2M`. */
144export function formatTokens(n: number): string {
145 const tokens = Number.isFinite(n) ? Math.max(0, n) : 0
146 if (Math.round(tokens) < 1000) return Math.round(tokens).toString()
147 if (Math.round(tokens / 1000) < 1000) return scaled(tokens / 1000, 'k')
148 return scaled(tokens / 1_000_000, 'M')
149}
150hooks/large.ts 260 lines1/**
2 * The pure half of the large results: finding them in the live conversation
3 * (`$.session.messages({ as: 'api' })`), their estimated tokens, short target
4 * and age; the cache an apply rewrites; the instructions an apply sends.
5 */
6
7import type { ApiMessage } from 'claude-code'
8
9import { formatTokens } from './bar'
10
11/** A tool result in the live conversation. */
12export type LargeResult = { id: string; tool: string; target: string; chars: number; tokens: number; turnsAgo: number; preview: string[] }
13
14/**
15 * headspace-cut's CUT_PREFIX, spelled again: a plugin cannot import another
16 * plugin's module. The test holds the literal.
17 */
18export const CUT_PREFIX = 'headspace-cut:'
19
20/** How the text of a stub headspace-cut wrote begins. */
21const STUB_START = '[headspace removed'
22
23const PREVIEW_LINES = 20
24const COMMAND_CHARS = 40
25
26type Block = { type: string; [field: string]: unknown }
27
28/** The text of a tool_result's content: a string, or its text blocks joined by newlines. */
29function resultText(content: unknown): string {
30 if (typeof content === 'string') {
31 return content
32 }
33 if (!Array.isArray(content)) {
34 return ''
35 }
36
37 return (content as unknown[])
38 .filter((b): b is { type: 'text'; text: string } => isBlock(b) && b.type === 'text' && typeof b.text === 'string')
39 .map(b => b.text)
40 .join('\n')
41}
42
43function isBlock(value: unknown): value is Block {
44 return typeof value === 'object' && value !== null && typeof (value as { type?: unknown }).type === 'string'
45}
46
47/** Characters a block puts before the model: text, a tool call's name and input, a result's text, thinking. */
48function blockChars(block: Block): number {
49 switch (block.type) {
50 case 'text':
51 return typeof block.text === 'string' ? block.text.length : 0
52 case 'tool_use':
53 return (typeof block.name === 'string' ? block.name.length : 0) + (JSON.stringify(block.input ?? {})?.length ?? 0)
54 case 'tool_result':
55 return resultText(block.content).length
56 case 'thinking':
57 return typeof block.thinking === 'string' ? block.thinking.length : 0
58 default:
59 return 0
60 }
61}
62
63/** A text block of a user message that is a `<system-reminder>`, not something typed. */
64function isReminder(block: Block): boolean {
65 return block.type === 'text' && typeof block.text === 'string' && block.text.trimStart().startsWith('<system-reminder>')
66}
67
68/**
69 * Characters a block of `message` adds to the breakdown's Messages figure. The
70 * `<system-reminder>` text blocks at the top of a user message (the environment,
71 * the skills, MCP instructions, CLAUDE.md, the date) count under other
72 * categories, so they count 0 here. Reminder text inside a tool_result is part
73 * of the result and counts.
74 */
75function messageChars(message: ApiMessage, block: Block): number {
76 return message.role === 'user' && isReminder(block) ? 0 : blockChars(block)
77}
78
79/** The characters the breakdown's Messages counts: every block but the reminders at the top of a user message. */
80export function conversationChars(messages: readonly ApiMessage[]): number {
81 let sum = 0
82 for (const message of messages) {
83 for (const block of message.content) {
84 sum += messageChars(message, block)
85 }
86 }
87
88 return sum
89}
90
91/** Tokens for `chars`: their share of `messageTokens`, or chars / 4 without it. */
92function toTokens(chars: number, allChars: number, messageTokens: number | null): number {
93 if (messageTokens === null) {
94 return Math.round(chars / 4)
95 }
96 if (allChars <= 0) {
97 return 0
98 }
99
100 return Math.round((chars / allChars) * messageTokens)
101}
102
103/** A user message holding text the person typed (a reminder alone is not one). */
104function isPrompt(message: ApiMessage): boolean {
105 if (message.role !== 'user') {
106 return false
107 }
108
109 return message.content.some(b => b.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '' && !isReminder(b))
110}
111
112function relative(path: string, cwd: string): string {
113 const base = cwd.endsWith('/') ? cwd : cwd + '/'
114
115 return path.startsWith(base) ? path.slice(base.length) : path
116}
117
118/** `https://host/path?q#h` -> `host/path`, without a trailing slash. */
119function hostPath(url: string): string {
120 const noScheme = url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
121 const cut = noScheme.search(/[?#]/)
122 const bare = cut === -1 ? noScheme : noScheme.slice(0, cut)
123
124 return bare.replace(/\/+$/, '')
125}
126
127function targetOf(tool: string, input: unknown, cwd: string): string {
128 const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
129 const text = (key: string): string | null => (typeof fields[key] === 'string' ? (fields[key] as string) : null)
130 switch (tool) {
131 case 'Read':
132 case 'Edit':
133 case 'Write': {
134 const path = text('file_path')
135 return path === null ? '' : relative(path, cwd)
136 }
137 case 'Bash': {
138 const command = text('command')
139 return command === null ? '' : (command.split('\n')[0] ?? '').slice(0, COMMAND_CHARS).trimEnd()
140 }
141 case 'WebFetch': {
142 const url = text('url')
143 return url === null ? '' : hostPath(url)
144 }
145 case 'Grep':
146 case 'Glob':
147 return text('pattern') ?? ''
148 default:
149 return ''
150 }
151}
152
153/** Every tool_use in the messages, by id: its tool name and input. */
154function callsById(messages: readonly ApiMessage[]): Map<string, { name: string; input: unknown }> {
155 const calls = new Map<string, { name: string; input: unknown }>()
156 for (const message of messages) {
157 for (const block of message.content) {
158 if (block.type === 'tool_use' && typeof block.id === 'string') {
159 calls.set(block.id, { name: typeof block.name === 'string' ? block.name : '', input: block.input })
160 }
161 }
162 }
163
164 return calls
165}
166
167/**
168 * From `$.session.messages({ as: 'api' })`: every tool_result paired with its
169 * tool_use, measured, and those of `threshold` tokens or more, biggest first.
170 * Tokens are the result's share of `messageTokens` (the breakdown's Messages
171 * figure) in proportion to characters, or chars / 4 when it is null.
172 * `turnsAgo` counts the prompts the person typed after the result. Results
173 * headspace-cut already stubbed are left out. `share` is the large results'
174 * tokens over Messages (0..1), for bar.ts.
175 */
176export function largeResults(
177 messages: readonly ApiMessage[],
178 threshold: number,
179 messageTokens: number | null,
180 cwd: string,
181): { results: LargeResult[]; share: number } {
182 const allChars = conversationChars(messages)
183 const calls = callsById(messages)
184 // Prompts typed from each message index to the end.
185 const promptsFrom: number[] = new Array<number>(messages.length + 1).fill(0)
186 for (let i = messages.length - 1; i >= 0; i--) {
187 const message = messages[i]
188 promptsFrom[i] = (promptsFrom[i + 1] ?? 0) + (message !== undefined && isPrompt(message) ? 1 : 0)
189 }
190
191 const results: LargeResult[] = []
192 messages.forEach((message, index) => {
193 for (const block of message.content) {
194 if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') {
195 continue
196 }
197 const text = resultText(block.content)
198 if (text.startsWith(STUB_START)) {
199 continue
200 }
201 const tokens = toTokens(text.length, allChars, messageTokens)
202 if (tokens < threshold) {
203 continue
204 }
205 const use = calls.get(block.tool_use_id)
206 const tool = use?.name ?? ''
207 results.push({
208 id: block.tool_use_id,
209 tool,
210 target: targetOf(tool, use?.input, cwd),
211 chars: text.length,
212 tokens,
213 turnsAgo: promptsFrom[index + 1] ?? 0,
214 preview: text.split('\n').slice(0, PREVIEW_LINES),
215 })
216 }
217 })
218 results.sort((a, b) => b.tokens - a.tokens || b.chars - a.chars)
219
220 const whole = messageTokens ?? Math.round(allChars / 4)
221 const largeTokens = results.reduce((sum, r) => sum + r.tokens, 0)
222 const share = whole > 0 ? Math.min(1, Math.max(0, largeTokens / whole)) : 0
223
224 return { results, share }
225}
226
227/** `Read src/data.json (9.8k tokens)`: what the stub names. */
228export function label(result: LargeResult): string {
229 const what = result.target === '' ? result.tool : `${result.tool} ${result.target}`
230
231 return `${what} (${formatTokens(result.tokens)} tokens)`
232}
233
234/**
235 * Tokens re-cached by cutting `ids`: every block from the first marked result
236 * to the end, in the same estimate as largeResults. 0 when none is found.
237 */
238export function cacheCost(messages: readonly ApiMessage[], ids: readonly string[], messageTokens: number | null): number {
239 const marked = new Set(ids)
240 let isCounting = false
241 let chars = 0
242 for (const message of messages) {
243 for (const block of message.content) {
244 if (!isCounting && block.type === 'tool_result' && typeof block.tool_use_id === 'string' && marked.has(block.tool_use_id)) {
245 isCounting = true
246 }
247 if (isCounting) {
248 chars += messageChars(message, block)
249 }
250 }
251 }
252
253 return toTokens(chars, conversationChars(messages), messageTokens)
254}
255
256/** The instructions an apply sends: CUT_PREFIX and the marks as JSON, as headspace-cut's parseCut reads them. */
257export function cutInstructions(marks: { id: string; label: string }[]): string {
258 return CUT_PREFIX + JSON.stringify({ marks: marks.map(({ id, label }) => ({ id, label })) })
259}
260hooks/memory.ts 67 lines1/**
2 * Auto-memory rides in the system prompt section `memory`: the engine's
3 * instructions on how to use memory, then the index, one line per entry
4 * (`- [Title](file.md) — hook`). These functions find the entries and render
5 * the section without the ones turned off, leaving everything else alone.
6 */
7
8/** An auto-memory index entry: a line `- [Title](file.md) ...` in the memory section. */
9export type MemoryEntry = { id: string; title: string; line: string }
10
11/** A top-level list item that opens with a link: `- [title](target)`, both non-empty. */
12const ENTRY = /^- \[([^\]]+)\]\(([^)\s]+)\)/
13
14/** A code fence opener or closer: what sits inside is an example, never an entry. */
15const FENCE = /^\s*(```|~~~)/
16
17/** FNV-1a, 32 bits, as eight hex digits: a stable id with no engine call. */
18const hash = (text: string): string => {
19 let h = 0x811c9dc5
20 for (let i = 0; i < text.length; i += 1) {
21 h ^= text.charCodeAt(i)
22 h = Math.imul(h, 0x01000193)
23 }
24 return (h >>> 0).toString(16).padStart(8, '0')
25}
26
27/** Each raw line (split on `\n`, so a CRLF line keeps its `\r`) with its entry, if it is one. */
28const scan = (sectionText: string): { raw: string; entry: MemoryEntry | null }[] => {
29 let isInFence = false
30 return sectionText.split('\n').map((raw) => {
31 const line = raw.replace(/\r$/, '')
32 if (FENCE.test(line)) {
33 isInFence = !isInFence
34 return { raw, entry: null }
35 }
36 const match = isInFence ? null : ENTRY.exec(line)
37 const title = match?.[1]
38 if (title === undefined) return { raw, entry: null }
39 return { raw, entry: { id: `memory#${hash(line)}`, title, line } }
40 })
41}
42
43/**
44 * The entries of the section text, in order; id = `memory#${hash(line)}`.
45 * An identical line repeated is one entry: it has one id, and turning it off
46 * removes every copy.
47 */
48export function memoryEntries(sectionText: string): MemoryEntry[] {
49 const seen = new Set<string>()
50 const entries: MemoryEntry[] = []
51 for (const { entry } of scan(sectionText)) {
52 if (entry === null || seen.has(entry.id)) continue
53 seen.add(entry.id)
54 entries.push(entry)
55 }
56 return entries
57}
58
59/** The section text with the entries whose id is in `off` removed; everything else byte for byte. */
60export function renderMemory(sectionText: string, off: ReadonlySet<string>): string {
61 if (off.size === 0) return sectionText
62 return scan(sectionText)
63 .filter(({ entry }) => entry === null || !off.has(entry.id))
64 .map(({ raw }) => raw)
65 .join('\n')
66}
67hooks/rules.ts 342 lines1import type { HeadspaceRuleChange } from '../types'
2
3/**
4 * One rule: its heading (the nearest `#` line above, '' at the top), position
5 * under it, and lines. `start` is the index of its first line in the file,
6 * `end` the index just past its last (as `slice` takes them).
7 */
8export type Rule = { id: string; heading: string; index: number; lines: string[]; start: number; end: number }
9
10/** A parsed file: every line, and the rules found in it. Lines outside rules (headings, blanks, fences) are kept as they are. `eol` is the file's own line end, LF or CRLF. */
11export type ParsedFile = { lines: string[]; rules: Rule[]; eol: string; finalNewline: boolean }
12
13/** FNV-1a, 32 bits, as 8 hex digits: short and stable across runs. */
14export const hash = (text: string): string => {
15 let h = 0x811c9dc5
16 for (let i = 0; i < text.length; i++) {
17 h ^= text.charCodeAt(i)
18 h = Math.imul(h, 0x01000193)
19 }
20 return (h >>> 0).toString(16).padStart(8, '0')
21}
22
23const HEADING = /^ {0,3}#{1,6}(?:[ \t]|$)/
24const TOP_ITEM = /^ ?(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/
25const FENCE_OPEN = /^\s*(`{3,}|~{3,})/
26const BLANK = /^\s*$/
27
28/** A line as classified: without the carriage return a file with mixed line ends leaves on it. */
29const bare = (line: string) => line.replace(/\r$/, '')
30
31const closesFence = (line: string, fence: string) => {
32 const m = /^\s*(`{3,}|~{3,})\s*$/.exec(line)
33 return !!m && m[1]![0] === fence[0] && m[1]!.length >= fence.length
34}
35
36/** Splits text into lines the way render joins them back. */
37const splitLines = (text: string): Pick<ParsedFile, 'lines' | 'eol' | 'finalNewline'> => {
38 if (text === '') return { lines: [], eol: '\n', finalNewline: false }
39 const breaks = text.match(/\n/g)?.length ?? 0
40 const crlfs = text.match(/\r\n/g) ?? []
41 // CRLF, taken from the text, only when every line ends with it.
42 const eol = breaks > 0 && crlfs.length === breaks ? crlfs[0]! : '\n'
43 const lines = text.split(eol)
44 const finalNewline = text.endsWith(eol)
45 if (finalNewline) lines.pop()
46 return { lines, eol, finalNewline }
47}
48
49/**
50 * Splits text into rules: under each heading, each top-level list item (`- `, `* `, `+ `, `1. `)
51 * with its continuation lines (indented lines and nested items), or each paragraph (a run of
52 * non-blank lines that is not a heading). A fenced code block (``` or ~~~) is never split: it belongs
53 * whole to the rule it sits in, or is a rule of its own. Headings are not rules; neither are
54 * frontmatter at the top nor a comment block (`<!-- ... -->`) standing on its own, which the engine
55 * strips before the model reads the file.
56 * `id` = `${heading}#${index}#${hash(lines.join('\n'))}`; a second rule with the very same id gets `#2`, `#3`, ... after it.
57 */
58export function parseRules(text: string): ParsedFile {
59 const { lines, eol, finalNewline } = splitLines(text)
60 const rules: Rule[] = []
61 const seen = new Map<string, number>()
62
63 let heading = ''
64 let index = 0
65 let cur: { start: number; end: number; kind: 'item' | 'paragraph' | 'fence' } | null = null
66 let fence: string | null = null
67 let afterBlank = false
68
69 const close = () => {
70 if (!cur) return
71 const ruleLines = lines.slice(cur.start, cur.end).map(bare)
72 const base = `${heading}#${index}#${hash(ruleLines.join('\n'))}`
73 const count = (seen.get(base) ?? 0) + 1
74 seen.set(base, count)
75 rules.push({ id: count === 1 ? base : `${base}#${count}`, heading, index, lines: ruleLines, start: cur.start, end: cur.end })
76 index++
77 cur = null
78 }
79
80 let i = 0
81 if (lines.length > 0 && bare(lines[0]!) === '---') {
82 const endAt = lines.findIndex((l, j) => j > 0 && bare(l) === '---')
83 if (endAt > 0) i = endAt + 1
84 }
85
86 for (; i < lines.length; i++) {
87 const line = bare(lines[i]!)
88
89 if (fence !== null) {
90 cur!.end = i + 1
91 if (closesFence(line, fence)) {
92 fence = null
93 if (cur!.kind === 'fence') close()
94 }
95 continue
96 }
97
98 if (BLANK.test(line)) {
99 afterBlank = true
100 continue
101 }
102
103 const wasAfterBlank = afterBlank
104 afterBlank = false
105
106 if (HEADING.test(line)) {
107 close()
108 heading = line.trim()
109 index = 0
110 continue
111 }
112
113 const open = FENCE_OPEN.exec(line)
114
115 if (TOP_ITEM.test(line)) {
116 close()
117 cur = { start: i, end: i + 1, kind: 'item' }
118 continue
119 }
120
121 if (cur && (!wasAfterBlank || (cur.kind === 'item' && /^\s/.test(line)))) {
122 cur.end = i + 1
123 if (open) fence = open[1]!
124 continue
125 }
126
127 close()
128
129 if (line.trimStart().startsWith('<!--')) {
130 while (i < lines.length - 1 && !bare(lines[i]!).includes('-->')) i++
131 continue
132 }
133
134 if (open) {
135 cur = { start: i, end: i + 1, kind: 'fence' }
136 fence = open[1]!
137 continue
138 }
139
140 cur = { start: i, end: i + 1, kind: 'paragraph' }
141 }
142 close()
143
144 return { lines, rules, eol, finalNewline }
145}
146
147/** The text a change brings, split on its own line breaks so it takes the file's. */
148const linesOf = (text: string) => text.split(/\r?\n/)
149
150/**
151 * The text with changes applied: off removes the rule's lines; edit replaces line `lineIndex` of the
152 * rule; addLine inserts after `afterLineIndex` (-1 inserts before the first). Off wins over the rule's
153 * other changes; a line index outside the rule is ignored. Unknown ids are reported, not applied.
154 */
155export function renderRules(file: ParsedFile, changes: Record<string, HeadspaceRuleChange[]>): { text: string; unknown: string[] } {
156 const byId = new Map(file.rules.map(r => [r.id, r]))
157 const unknown = Object.keys(changes).filter(id => !byId.has(id))
158
159 /** For each line of the file: what to write in its place (empty to drop it), and what to add before or after it. */
160 const replaced = new Map<number, string[]>()
161 const before = new Map<number, string[]>()
162 const after = new Map<number, string[]>()
163 const push = (map: Map<number, string[]>, at: number, text: string) => map.set(at, [...(map.get(at) ?? []), ...linesOf(text)])
164
165 for (const [id, list] of Object.entries(changes)) {
166 const rule = byId.get(id)
167 if (!rule) continue
168 if (list.some(c => c.type === 'off')) {
169 for (let at = rule.start; at < rule.end; at++) replaced.set(at, [])
170 continue
171 }
172 const size = rule.end - rule.start
173 for (const change of list) {
174 if (change.type === 'edit' && change.lineIndex >= 0 && change.lineIndex < size) {
175 replaced.set(rule.start + change.lineIndex, linesOf(change.text))
176 } else if (change.type === 'addLine' && change.afterLineIndex === -1) {
177 push(before, rule.start, change.text)
178 } else if (change.type === 'addLine' && change.afterLineIndex >= 0 && change.afterLineIndex < size) {
179 push(after, rule.start + change.afterLineIndex, change.text)
180 }
181 }
182 }
183
184 const out: string[] = []
185 file.lines.forEach((line, at) => {
186 out.push(...(before.get(at) ?? []))
187 out.push(...(replaced.get(at) ?? [line]))
188 out.push(...(after.get(at) ?? []))
189 })
190
191 const text = out.join(file.eol) + (file.finalNewline && out.length > 0 ? file.eol : '')
192 return { text, unknown }
193}
194
195/** Lines for a diff: line ends dropped, a final newline not counted as an empty last line. */
196const diffSplit = (text: string) => {
197 if (text === '') return []
198 const lines = text.split(/\r?\n/)
199 if (/\r?\n$/.test(text)) lines.pop()
200 return lines
201}
202
203/** A line diff for the save confirmation: each line `{ type: 'same' | 'out' | 'in', text }`. LCS on lines. */
204export function diffLines(before: string, after: string): { type: 'same' | 'out' | 'in'; text: string }[] {
205 const a = diffSplit(before)
206 const b = diffSplit(after)
207 const n = a.length
208 const m = b.length
209
210 // lcs[i][j]: the longest common subsequence of a[i..] and b[j..].
211 const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
212 for (let i = n - 1; i >= 0; i--) {
213 for (let j = m - 1; j >= 0; j--) {
214 lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
215 }
216 }
217
218 const diff: { type: 'same' | 'out' | 'in'; text: string }[] = []
219 let i = 0
220 let j = 0
221 while (i < n && j < m) {
222 if (a[i] === b[j]) {
223 diff.push({ type: 'same', text: a[i]! })
224 i++
225 j++
226 } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
227 diff.push({ type: 'out', text: a[i]! })
228 i++
229 } else {
230 diff.push({ type: 'in', text: b[j]! })
231 j++
232 }
233 }
234 while (i < n) diff.push({ type: 'out', text: a[i++]! })
235 while (j < m) diff.push({ type: 'in', text: b[j++]! })
236 return diff
237}
238
239const sameLines = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((line, i) => line === b[i])
240
241/**
242 * Whether a change the engine's text no longer has a rule for is in that text
243 * already: `before` is a text the rule `id` was in (what the engine handed
244 * earlier, or a save's backup), `after` the engine's text now. An edit or an
245 * added line is in it when a rule of `after` reads exactly as the change makes
246 * the rule. A rule turned off is in it when its lines are gone and nothing was
247 * written in its place: the rule now at its heading and position, if any, is
248 * one that came after it in `before`. A rule rewritten by hand is not.
249 * Null when `before` has no rule `id`: nothing to tell by.
250 */
251export function isReflected(before: ParsedFile, after: ParsedFile, id: string, list: readonly HeadspaceRuleChange[]): boolean | null {
252 const rule = before.rules.find(r => r.id === id)
253 if (rule === undefined) return null
254 if (list.some(c => c.type === 'off')) {
255 if (after.rules.some(r => sameLines(r.lines, rule.lines))) return false
256 const later = before.rules.filter(r => r.heading === rule.heading && r.index > rule.index)
257 const now = after.rules.find(r => r.heading === rule.heading && r.index === rule.index)
258 return now === undefined || later.some(r => sameLines(r.lines, now.lines))
259 }
260 const alone: ParsedFile = { lines: rule.lines, rules: [{ ...rule, start: 0, end: rule.lines.length }], eol: '\n', finalNewline: false }
261 const changed = renderRules(alone, { [id]: [...list] }).text.split('\n')
262 return after.rules.some(r => sameLines(r.lines, changed))
263}
264
265/** What a context must say again about the rules in force: the first line of each rule turned off, and each edited line as it was and as it reads now. */
266export type ChangeNotes = { off: string[]; edits: { from: string; to: string }[] }
267
268/** A line without its indent and its list marker (`- `, `* `, `+ `, `1. `), as a note names it. */
269export const withoutMarker = (line: string) => line.trim().replace(/^(?:[-*+]|\d{1,9}[.)])(?:[ \t]+|$)/, '')
270
271/**
272 * The notes for a file's changes, in the file's order. Off wins over the rule's
273 * other changes; an edit that leaves the line as it was, an added line, and a
274 * rule the file no longer has name nothing.
275 */
276export function changeNotes(file: ParsedFile, changes: Record<string, HeadspaceRuleChange[]>): ChangeNotes {
277 const notes: ChangeNotes = { off: [], edits: [] }
278 for (const rule of file.rules) {
279 const list = changes[rule.id] ?? []
280 if (list.some(c => c.type === 'off')) {
281 notes.off.push(withoutMarker(rule.lines[0] ?? ''))
282 continue
283 }
284 for (const change of list) {
285 const was = change.type === 'edit' ? rule.lines[change.lineIndex] : undefined
286 if (change.type === 'edit' && was !== undefined && was.trim() !== change.text.trim()) {
287 notes.edits.push({ from: withoutMarker(was), to: withoutMarker(change.text) })
288 }
289 }
290 }
291 return notes
292}
293
294/**
295 * The blocks that say the notes again, after the context. The engine leaves the
296 * first message as it was, so the old rules are still read there; these blocks
297 * tell Claude which copy holds. None when nothing is off or edited.
298 */
299export function noticeBlocks(notes: ChangeNotes): { name: string; text: string }[] {
300 const blocks: { name: string; text: string }[] = []
301 if (notes.off.length > 0) {
302 blocks.push({
303 name: 'turnedOff',
304 text: 'The person turned these rules off for this session. Ignore them, even where they appear earlier in this conversation:\n' + notes.off.map(line => `- ${line}`).join('\n'),
305 })
306 }
307 if (notes.edits.length > 0) {
308 blocks.push({
309 name: 'changedRules',
310 text:
311 'The person changed these rules for this session; the new wording replaces the old one, even where the old one appears earlier:\n' +
312 notes.edits.map(e => `- was: ${e.from}\n now: ${e.to}`).join('\n'),
313 })
314 }
315 return blocks
316}
317
318/** `path` beneath `dir`: what follows `dir/`, or null. A dir of `/` holds nothing in particular. */
319function beneath(path: string, dir: string | null): string | null {
320 const base = dir === null ? '' : dir.replace(/\/+$/, '')
321 if (base === '') {
322 return null
323 }
324
325 return path.startsWith(base + '/') ? path.slice(base.length + 1) : null
326}
327
328/**
329 * A file's path as the pane shows it: relative under the session `root`
330 * (`CLAUDE.md`, `docs/rules.md`), `~/…` under the `home` directory, as it is
331 * anywhere else. The root wins when it sits inside the home directory.
332 */
333export function shortPath(path: string, root: string | null, home: string | null): string {
334 const inRoot = beneath(path, root)
335 if (inRoot !== null) {
336 return inRoot
337 }
338 const inHome = beneath(path, home)
339
340 return inHome === null ? path : `~/${inHome}`
341}
342types/index.d.ts 64 lines1/**
2 * headspace's session state: the bar's last reading, the rule changes
3 * (pending and in force), the session rules, the marked large results and
4 * what the pane shows. Save backups live in `$.store`, not here.
5 */
6
7export type HeadspaceKind = 'system' | 'rules' | 'conversation' | 'large' | 'free' | 'reserve'
8
9/** One segment of the bar: its kind and tokens. */
10export type HeadspaceSegment = { kind: HeadspaceKind; tokens: number }
11
12/** The bar's last reading. `window` is the breakdown's rawMaxTokens; `percent` its percentage. */
13export type HeadspaceSnapshot = { segments: HeadspaceSegment[]; window: number; percent: number; at: number }
14
15/** A change to one rule, keyed by its RuleId (see rules.ts). */
16export type HeadspaceRuleChange =
17 | { type: 'off' }
18 | { type: 'edit'; lineIndex: number; text: string }
19 | { type: 'addLine'; afterLineIndex: number; text: string }
20
21/** A file's changes; `source` is the path, or 'memory' for auto-memory entries. */
22export type HeadspaceFileChanges = { source: string; changes: Record<string, HeadspaceRuleChange[]> }
23
24/** A large result the person marked: what headspace-cut needs to stub it. */
25export type HeadspaceMark = { id: string; label: string }
26
27/**
28 * What the saves of one file wrote this session: the rule ids whose changes
29 * went into it, the session rules added to it, and a hash (rules.ts `hash`)
30 * of the text the engine held for it when the save went through. While the
31 * engine hands that text, those changes and rules stay in force over it.
32 */
33export type HeadspaceSaved = { ids: string[]; rules: string[]; loaded: string }
34
35declare module 'claude-code' {
36 interface PluginState {
37 headspace: {
38 snapshot: HeadspaceSnapshot | null
39 /** Changes waiting for Apply. */
40 pending: HeadspaceFileChanges[]
41 /** Changes in force: what prompt.context and prompt.section apply. */
42 applied: HeadspaceFileChanges[]
43 sessionRules: string[]
44 /** Session rules waiting for Apply (added or removed since the last Apply). */
45 pendingSessionRules: string[] | null
46 /** Session rules taken out by an Apply: the first message still holds them, so they are named as off. */
47 droppedSessionRules: string[]
48 marks: HeadspaceMark[]
49 /** Changes dropped because their file changed on disk. */
50 staleCount: number
51 /** The kind the pane shows. */
52 selected: HeadspaceKind
53 /** The rule or large result selected in the pane, by id. */
54 cursor: string | null
55 /** Set while a cut compaction is in flight. */
56 isCutting: boolean
57 /** The last event a hook failed at, shown in the pane. */
58 lostAt: string | null
59 /** Per path saved this session: what its saves wrote, until the engine reads the saved file. */
60 savedFrom: Record<string, HeadspaceSaved>
61 }
62 }
63}
64