SLOPSHOPPER

headspace

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…

newpanebandcommandtoastprompt
★ 1v0.1.3MITupdated 2026-10-15Xabilimon1/emcc-claude-code-mods/mods/headspace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · headspace
│ ┃ headspace ✕ › fix the failing auth test and add an audit log call │ ┃ █ system rules █ conversation █ large │ ┃ ⏺ Read(src/auth.ts) │ ┃ rules 0 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No instruction files read yet. They show ⎿ Added 2 lines, removed 1 line │ ┃ after the next request. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ session rules 0 │ ┃ add a session rule ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ apply ] adds ~0 to the next message ✻ Worked for 42s · done 4:20 PM │ │ › /headspace │ ⎿ headspace: opened on rules │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · headspace
█ system rules █ conversation █ large ▁ free ░ reserv rules 0 No instruction files read yet. They show after the next request. session rules 0 add a session rule [ apply ] adds ~0 to the next message
README

headspace

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.

What it shows

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.

KindWhat it holdsColourCan be taken out
systemsystem prompt, system tools, MCP server instructions, MCP tools, custom agents, skillsstone #b0aea5no, view only
rulesmemory files (CLAUDE.md, rules files, auto-memory) and your session rulesclay #d97757yes
conversationmessages, minus the large resultssky #6a9bccno, view only
largetool results of threshold tokens or more (default 2000)olive #788c5dyes
freefree spacean empty rail ▁, stone, dimmedno
reservethe autocompact bufferstone, 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.

The hover and the pane

  • Hover a segment: it lights up, and in place of the labels a card says the kind, its tokens, how many pieces it holds, and what you can do (click to view and remove, or view only).
  • Click a segment, or a label, to open the pane on that kind. From the keyboard: 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.

Rules

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:

  • off / on: for this session only. Off is never deletion: turn it back on and apply.
  • edit the selected line, or add line below it.
  • Auto-memory entries (the index lines of your 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.

  • Save works on the file as it is on disk. A change whose rule reads differently there (Claude reads your files with comments and frontmatter stripped) is not saved, and the pane says how many.
  • If the file changes on disk while you are being asked, nothing is written: changed on disk, nothing written.
  • A session rule's save asks which CLAUDE.md, the project's or yours (~/.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.
  • A change you also made by hand in the file (the rule deleted, or the line edited the same way) leaves quietly when Claude Code reads the file again; it is not counted as a change that no longer applies.

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.

Large results

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]
  • It works only between turns. While Claude works, the button is dimmed and says so.
  • Next to the button: the tokens it frees, and the cache it costs, which is everything from the first removed result to the end, sent again without the prompt cache. There is no dollar figure: the price per token depends on the model and your plan, and headspace does not know them.
  • There is no undo. A removed result is gone from what Claude reads; Claude can run the tool again.
  • Only tool results can be removed. Your prompts and Claude's answers never are, and neither is the turn in progress.
  • Your transcript shows a compaction boundary, as /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.

The fallback: /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.

What it never does

  • No model calls. Everything is worked out from what Claude Code already has.
  • Nothing is sent anywhere. No network, no processes.
  • Your prompts and Claude's answers are never removed or changed.
  • It writes a file only when you press save or undo save and confirm, and only that file.
  • If one of its hooks fails, Claude Code's own context passes unchanged: nobody loses their rules to a bug. The pane then says headspace lost track at <event>.
  • Without a context breakdown (a -p run), it draws nothing.

Install

/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

Commands

  • /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.

Options

Set them in /config, or at install with --config key=value.

OptionDefaultWhat it does
threshold2000Tool results of this many tokens or more show as large

What it hooks and calls

  • Hooks: 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)
  • Reads headspace-cut's state: ready and its heartbeat seenAt
  • Calls: $.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.register
  • Kept after the session: only the save backups. No network, no processes, no model calls.

Tests

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

Source 6 files
hooks/register.tsx 1538 lines
1import { 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 lines
1import 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}
150
hooks/large.ts 260 lines
1/**
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}
260
hooks/memory.ts 67 lines
1/**
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}
67
hooks/rules.ts 342 lines
1import 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}
342
types/index.d.ts 64 lines
1/**
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