Opens one shared pane beside the transcript and draws the sections, lines and buttons every other mod writes into it through $.sidebar; each stream entry is…

When a dozen mods each write their findings as transcript lines and status lines, the transcript fills with lines that are not the conversation, and a finding scrolls away before you read it. This mod opens one shared pane beside the transcript and draws what every other mod writes into it. There is one sidebar, not one pane per mod: a mod calls $.sidebar.set(...) with a section of lines and buttons, and this mod draws it.
/sidebar opens the pane and /sidebar off closes it. The choice is kept in $.store, so a session started later opens the sidebar again by itself. While the choice is on, the session also asks the engine for the fullscreen layout at its start (/tui fullscreen, run by the mod itself), because only that layout seats panes beside the transcript; the ask is a no-op when the session is already fullscreen, and a session whose launch flags forbid the switch ignores it. The pane is a sidebar or nothing: where the surface seats panes beside the transcript (the fullscreen layout, from 110 columns) it opens there; where it does not, the pane stands down at its first render, every mod keeps its own transcript or status line, and the choice is untouched. Closing it, through the pane's own close or /sidebar off, ends it for that session only: a closed sidebar comes back with the next session until you run /sidebar off.$.sidebar.set({ consumer, key, title, lines, buttons, until, order }) answers true. While it is closed nothing is kept and the call answers false, so the mod keeps showing its own transcript line or status line instead.<consumer>: <title>, plus the time for a stream entry), its lines (ok green, warn yellow, error red, dim faint) and its buttons. The pane has two parts: the standing sections at the top (until: 'session', then until: 'turn', each group by order, then consumer, then key), and the stream under them.until: 'stream' writes: a log of findings, newest first, right under the standing sections. While both are drawn, a faint divider row separates them: dashes across the pane's width with an o in the middle (---------o---------). An entry never replaces another, so the same mod and key twice reads as two entries. A stream entry's heading also carries the day and time it was written, in the machine's own time zone (edit-loop: edit loop (21.09 14:32)), so the person reads the log after the fact; a standing section carries none, because it is rewritten at every measure. Nothing drops an entry at the turn's end: an entry leaves only when newer ones push it past the pane's last row. A taller terminal holds more of the stream, a shorter one less. The stream's rows are shared: while several mods write into it, each one draws at most its own share of the rows, so a talkative mod cannot push another mod's finding off the pane. The rows a share leaves over go to the entries it held back, and a mod writing alone takes the whole area.[ stop ] of { label: 'stop', command: 'bg-tasks', args: 'stop b1' } runs /bg-tasks stop b1 as the person would, and the command's first answer line shows at the foot of the pane, faint. A command that did not run shows /<command> did not run: <error> there, with did not run in red. The mod that offers the button serves that command itself. The label turns red under the pointer, so what a press would run is plain before the press.session stands at the top until the mod replaces or clears it, stream joins the log under it, turn goes when the turn ends. A plugin turned off and then reloaded (/reload-plugins) cannot clear its own section any more, so at each turn's start the sidebar drops the standing sections of a mod whose plugin is off: every enabledPlugins key of its name is false, and no command of the session comes from it. A plugin loaded with --plugin-dir keeps its sections, because its own command is listed, and so does a consumer no key names. The stream keeps the entries such a plugin wrote.~/.claude/sidebar/<project>-<YYYY-MM-DD>.log, one JSON object per line. When the pane opens, the newest 10 entries of that project's logs come back into the stream, each with the day and time it was first written, so a session started tomorrow still shows what yesterday found. A restored entry is not written to the log again. The day is the day of the write, so a session that runs past midnight writes the new day's file. Every write reads the file again first, so two sessions of one project on one day keep each other's lines; a file that is there and cannot be read is not written over. A clear that takes stream entries down writes one line of its own to the log ({"at", "cleared": {consumer, key}}): the entries stay in the file as history, and the restore leaves out every entry of that key written before the line, also when the line sits in a newer day's file. A closed finding therefore does not come back beside its own closing line. /sidebar log prints the file's path and its newest 10 entries, the cleared ones among them.mcp__sidebar__read, listed from the session's start without ToolSearch. Its description tells the model to call it when you refer to what the sidebar shows, so you do not paste the pane into the prompt. It answers the pane's content as plain text: each section's heading, its rows indented, the line between the standing sections and the stream as ---, a button as [ label ]; colours do not survive. /sidebar snapshot gives the same text as the command's answer, which the model also reads. A closed sidebar answers that it holds nothing. Measured on Claude Code 2.1.288 in a live session: asked what the sidebar showed, the model called the tool at once and quoted the ctx line word for word.Do not declare "dependencies": ["sidebar"] in plugin.json. A declared dependency is a hard one: the engine does not load your mod at all when the person has no sidebar installed (measured on 2.1.278). Call the API behind a guard instead, and your mod works with or without this one:
/** The finding the person reads: the sidebar while it is open, else the mod's own transcript line. */
async function toPerson($: EngineInterface, findings: readonly string[], line: string): Promise<void> {
try {
const taken = await $.sidebar.set({
consumer: 'my-mod', // your mod's name, drawn in the section heading
key: 'src-users.ts', // names the section inside your mod; [A-Za-z0-9._:-]
title: 'SQL built from strings', // the heading beside the consumer
lines: findings.map(text => ({ text, kind: 'error' })), // kind: 'ok' | 'warn' | 'error' | 'dim' | 'info' (blue), or absent
buttons: [{ label: 'fix', command: 'my-mod', args: 'fix src/users.ts' }], // optional
until: 'stream', // 'stream' logs it, 'session' keeps it standing, 'turn' drops it at the turn's end
order: 50, // smaller is higher inside your group; 100 when absent
})
if (taken) return
} catch {
// The sidebar mod is not installed, so $.sidebar is missing and the call throws.
}
$.ui.log(line)
}
set answers true when the section was kept and drawn, and false when the sidebar is closed, so one if (taken) return covers both the closed and the missing case. clear({ consumer, key }) removes your standing section of that key and every stream entry of it, and keeps a later session from taking those entries back from the log; isOpen() answers whether the pane is up.
A line colours one word when it carries parts: { text: 'model opus-5-5', parts: [{ text: 'model ' }, { text: 'opus-5-5', kind: 'error' }] }. Each part takes its own kind, a part without one takes the line's, and the parts' texts joined are the line the pane draws and wraps; a part keeps its colour across a wrapped row. Keep the whole line in text as well, because a sidebar older than 0.11.0 draws text alone.
types/index.d.ts is the contract: SidebarSection, SidebarLine, SidebarPart, SidebarKind, SidebarButton, SidebarUntil and Sidebar. /plugin-types copies it into .claude/types/claude-code-plugins/ for every enabled plugin, so $.sidebar is typed in your mod with nothing copied by hand. Develop against it with claude --plugin-dir <your mod> --plugin-dir <path to sidebar>.
In a claude plugin test file the test engine runs no engine.create, so stub the noun with an inline plugin and answer its calls in the world:
const SIDEBAR: Plugin = {
name: 'sidebar',
register(on) {
const stub = async (): Promise<never> => { throw new Error('answered by the test world') }
on('engine.create', async (_, e, next) => ({ ...(await next(e)), sidebar: { set: stub, clear: stub, isOpen: stub } }))
},
}
// then in the test: on('sidebar.set', (_, e) => ({ value: true }))
Limits per section: 50 lines and 5 buttons; the lines left out are counted in the pane. The pane draws as many rows as the surface gave its body, and at most 200. The stream holds its newest 20 entries per consumer and 100 in all, however few of them the rows show, and the rows it draws are shared between the consumers writing into it. A line longer than the pane's width is wrapped over at most 4 rows (see Limits). A line takes at most 16 parts. A section whose consumer, key or title is of another shape is refused with an error the calling mod reads.
/sidebar opens the pane, or closes it while it is open /sidebar on | off the same, named /sidebar status on or off, how many sections are up and how many entries the stream holds /sidebar log the path of this project's log of today, and its newest 10 entries /sidebar snapshot the pane's content as plain text, for you to copy and for the model to read
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install sidebar@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
/sidebar once. From then on every session opens it until you run /sidebar off. The pane's own close ends it for the session only; the choice stays.Validated with claude plugin validate on Claude Code 2.1.288:
❯ types ./types/index.d.ts declares on $: $.sidebar ❯ ./register.tsx hooks: /Users/kerem/Desktop/GIT-KilimcininKorOglu/claude-code-mods/plugins/sidebar/hooks/hooks.json ❯ ./register.tsx calls: $.clock.after (via setOpen), $.clock.now, $.command.list (via dropOff), $.command.register, $.command.run (via pressButton, switchToFullscreen), $.env.get (via openLog), $.fs.exists, $.fs.list (via logFiles), $.fs.read, $.fs.write, $.session.cwd (via openLog), $.settings.read (via dropOff), $.store.get, $.store.set (via setOpen), $.tool.register, $.ui.close (via closePane), $.ui.invalidate, $.ui.log (via switchToFullscreen), $.ui.open (via showPane), $.ui.panes (via closePane), $.ui.resolve ❯ ./register.tsx env writes: nothing ❯ ./register.tsx env reads: HOME
Reach L2, it writes a file.
~/.claude/stream up to 0.7.0 and is ~/.claude/sidebar from 0.8.0, so every mod's own directory carries the mod's name. Nothing is migrated: the old files stay on disk unread. Move them yourself to keep the restore of older days: mv ~/.claude/stream/* ~/.claude/sidebar/./sidebar in that session places it at any width.$.fs has no append. A write that fails is passed over and the pane keeps working.$.clock.now() and drawn in the machine's own time zone. It is not the moment the finding happened, and it does not change afterwards.…. A heading is cut, not wrapped.make install # eslint, typescript, typescript-eslint make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.tsx 419 lines1import type { EngineInterface, Register } from 'claude-code'
2import type { Sidebar, SidebarSection } from '../types/index.d.ts'
3import { clearLineOf, drawn, snapshotText, SNAPSHOT_COLUMNS, dropConsumers, dropTurn, offConsumers, appendLog, isLogOf, logFileAt, logLineOf, projectOf, pushed, readLive, readLog, readSection, sectionId, tailText, type Board, type Drawn, type Kept, type Logged, type Row, EMPTY_TEXT, LOG_RESTORE, MAX_BOARD_LINES } from './board.ts'
4
5type Elements = ReturnType<EngineInterface['ui']['resolve']>
6
7const PANE_ID = 'sidebar'
8
9const PANE_TITLE = 'Sidebar'
10
11const OPEN_KEY = 'open'
12
13const USAGE = 'expects nothing (open or close), on, off, status, log or snapshot'
14
15/** The tool the model reads the pane through, listed from the session's start. */
16const READ_TOOL = 'read'
17
18const READ_DESCRIPTION = [
19 "Read the sidebar: the pane the person sees beside the transcript, where the installed mods show the session's state (context, tokens, cost, effort, limits, cache), their open findings and the stream of their entries.",
20 'You get its whole content as plain text, as it stands now.',
21 'Call it when the person refers to what the sidebar or the pane shows, instead of asking them to paste it.',
22].join(' ')
23
24const READ_SCHEMA = { type: 'object', properties: {} }
25
26/** Where the logs of every project live, under the person's own Claude directory, named after the mod. */
27const LOG_DIR = '.claude/sidebar'
28
29/**
30 * The answer of the last button pressed: the command, and its first line of text, or the error of a
31 * command that did not run.
32 */
33type Answer = { command: string; text: string; failed: boolean }
34
35/**
36 * The standing sections other mods wrote, the stream under them (newest first), the number that keeps
37 * each stream entry's id its own, whether the pane is open, whether the surface seats it beside the
38 * transcript (learned at the pane's first render), the last button's answer, and the log: the
39 * directory of every project's logs and this project's name, read once at the session's start. The
40 * file is picked by the day of each write, so a session that runs past midnight writes the new day's
41 * file, and its lines are not held here: every write reads the file again, because another session
42 * writes it too.
43 */
44export type State = {
45 board: Board
46 stream: Kept[]
47 written: number
48 open: boolean
49 docked?: boolean
50 message?: Answer
51 dir: string
52 project: string
53}
54
55function emptyState(): State {
56 return { board: new Map(), stream: [], written: 0, open: false, dir: '', project: '' }
57}
58
59/** Takes down every section and stream entry, because a closed sidebar keeps nothing. */
60function forget(state: State): void {
61 state.board.clear()
62 state.stream = []
63 state.message = undefined
64}
65
66function errorText(err: unknown): string {
67 return err instanceof Error ? err.message : String(err)
68}
69
70/** Runs the slash command a button names, as the person would. */
71async function pressButton($: EngineInterface, state: State, command: string, args: string | undefined): Promise<void> {
72 try {
73 const r = await $.command.run({ command, ...(args === undefined ? {} : { args }) })
74 state.message = { command, text: (r.text ?? 'ran').split('\n')[0] ?? 'ran', failed: false }
75 } catch (err) {
76 state.message = { command, text: errorText(err), failed: true }
77 }
78 $.ui.invalidate('ui.render')
79}
80
81async function openPane($: EngineInterface, state: State): Promise<void> {
82 await takeSections($, state)
83 await showPane($, state)
84}
85
86/**
87 * Takes sections from now on, starting with what this project's log last held, because a closed
88 * sidebar keeps nothing.
89 */
90async function takeSections($: EngineInterface, state: State): Promise<void> {
91 state.open = true
92 await restoreLog($, state)
93}
94
95/**
96 * Runs the engine's `/tui fullscreen`, because only the fullscreen layout seats panes beside the
97 * transcript and the pane is a sidebar or nothing. A session already fullscreen answers "already
98 * using", and one whose launch flags forbid the switch refuses; either way the pane's own stand-down
99 * keeps the rule, so a refusal here must not stop the pane from opening.
100 */
101async function switchToFullscreen($: EngineInterface): Promise<void> {
102 try {
103 await $.command.run({ command: 'tui', args: 'fullscreen' })
104 } catch (err) {
105 // The switch is an aid, not a requirement; the pane's own stand-down holds the rule. The
106 // failure still shows, because a swallowed error reads as the switch having worked.
107 await $.ui.log(`fullscreen switch failed: ${err instanceof Error ? err.message : String(err)}`)
108 }
109}
110
111/** Draws the pane; one that does not open takes nothing, so every mod goes back to its own line. */
112async function showPane($: EngineInterface, state: State): Promise<void> {
113 try {
114 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
115 } catch (err) {
116 state.open = false
117 forget(state)
118 throw err
119 }
120}
121
122async function closePane($: EngineInterface, state: State): Promise<void> {
123 if ((await $.ui.panes()).some(p => p.id === PANE_ID)) await $.ui.close({ id: PANE_ID })
124 state.open = false
125 forget(state)
126}
127
128/** Turns the sidebar on or off and keeps the choice for the next session. */
129async function setOpen($: EngineInterface, state: State, open: boolean): Promise<string> {
130 await $.store.set(OPEN_KEY, open)
131 if (open) {
132 // The switch runs on its own timer: a command.run issued from inside a command.run dispatch
133 // does not reach the engine, and the switch may restart the session, which must not happen
134 // while this hook is still running.
135 $.clock.after(0, () => void switchToFullscreen($))
136 await openPane($, state)
137 return 'on: the sidebar is open and every mod may write into it'
138 }
139 await closePane($, state)
140 return 'off: the sidebar is closed and each mod shows its own lines again'
141}
142
143/** Where this project's logs live, and the project's name, read before a Bash `cd` can move the directory. */
144async function openLog($: EngineInterface, state: State): Promise<void> {
145 const home = (await $.env.get('HOME')) ?? ''
146 if (home === '') return
147 state.dir = `${home}/${LOG_DIR}`
148 state.project = projectOf(await $.session.cwd())
149}
150
151/** A file's text, or an empty string when it is missing or unreadable. */
152async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
153 try {
154 return String(await $.fs.read(path))
155 } catch {
156 // The file is not written yet, or the person removed it.
157 return ''
158 }
159}
160
161/** This project's log files, newest day first. */
162async function logFiles($: EngineInterface, state: State, project: string): Promise<string[]> {
163 try {
164 const names = (await $.fs.list(state.dir)).filter(e => e.kind === 'file' && isLogOf(project, e.name)).map(e => e.name)
165 return names.sort().reverse()
166 } catch {
167 // No log directory yet.
168 return []
169 }
170}
171
172/**
173 * Takes the newest entries of this project's log back into the stream, oldest first, each with the day
174 * and time it was first written. An entry a later clear took down stays out, even when the clear sits in
175 * a newer day's file. A restored entry is never written to the log again, because it is put into the
176 * stream directly rather than through `$.sidebar.set`.
177 */
178async function restoreLog($: EngineInterface, state: State): Promise<void> {
179 if (state.dir === '') return
180 const project = state.project
181 let text = ''
182 let found: Logged[] = []
183 for (const name of await logFiles($, state, project)) {
184 text = `${await readOrEmpty($, `${state.dir}/${name}`)}\n${text}`
185 found = readLive(text)
186 if (found.length >= LOG_RESTORE) break
187 }
188 for (const one of found.slice(-LOG_RESTORE)) {
189 const kept = readSection(one.section)
190 if (typeof kept === 'string') continue
191 state.stream = pushed(state.stream, { ...kept, id: `${kept.id}#${++state.written}`, at: one.at })
192 }
193 if (found.length > 0) $.ui.invalidate('ui.render')
194}
195
196/**
197 * Drops the standing sections of the plugins turned off since they wrote them. `/reload-plugins` keeps
198 * this module and its board when the module did not change, and a plugin that is no longer loaded never
199 * clears its own section. The stream keeps their entries as history.
200 */
201async function dropOff($: EngineInterface, state: State): Promise<void> {
202 if (state.board.size === 0) return
203 const { enabledPlugins } = await $.settings.read()
204 const plugins = (await $.command.list()).flatMap(c => (c.plugin === undefined ? [] : [c.plugin]))
205 if (dropConsumers(state.board, offConsumers(state.board, enabledPlugins, plugins))) $.ui.invalidate('ui.render')
206}
207
208/** What the pane holds now, as plain text; a closed sidebar holds nothing. */
209function snapshotOf(state: State): string {
210 if (!state.open) return 'the sidebar is closed, so it holds nothing; /sidebar on opens it'
211 return snapshotText(drawn(state.board, state.stream, SNAPSHOT_COLUMNS, MAX_BOARD_LINES))
212}
213
214/** The newest entries of this project's log of today, or why there is no log. */
215async function logText($: EngineInterface, state: State): Promise<string> {
216 const file = logFileAt(state.dir, state.project, await $.clock.now())
217 return file === '' ? 'no log file: HOME was not read' : tailText(file, readLog(await readOrEmpty($, file)))
218}
219
220async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
221 const word = args.trim()
222 if (word === 'on' || word === 'off') return setOpen($, state, word === 'on')
223 if (word === 'log') return logText($, state)
224 if (word === 'snapshot') return snapshotOf(state)
225 if (word === 'status') return state.open ? `on, ${state.board.size} section(s), ${state.stream.length} in the stream` : 'off'
226 return word === '' ? setOpen($, state, !state.open) : USAGE
227}
228
229/**
230 * The `$.sidebar` noun: what every other mod calls. A closed sidebar keeps nothing. `redraw` is the
231 * engine call the `engine.create` hook closes over, because the validator refuses that engine as an
232 * argument.
233 */
234export function createSidebar(redraw: () => void, now: () => Promise<number>, log: (line: string) => Promise<void>, state: State): Sidebar {
235 return {
236 set: async (section: SidebarSection) => {
237 if (!state.open) return false
238 const kept = readSection(section)
239 if (typeof kept === 'string') throw new Error(kept)
240 // A stream entry never replaces another, so the same key twice reads as two entries of a log.
241 // It also carries the time it was written, which its heading draws; a standing section does not,
242 // because that one is rewritten at every measure and its time would say nothing.
243 if (kept.until === 'stream') {
244 const entry = { ...kept, id: `${kept.id}#${++state.written}`, at: await now() }
245 state.stream = pushed(state.stream, entry)
246 await log(logLineOf(entry))
247 } else state.board.set(kept.id, kept)
248 redraw()
249 return true
250 },
251 clear: async (input: { consumer: string; key: string }) => {
252 const id = sectionId(input.consumer, input.key)
253 const kept = state.stream.filter(s => !s.id.startsWith(`${id}#`))
254 const dropped = kept.length < state.stream.length
255 state.stream = kept
256 // The log keeps the entries as history; this line keeps the next session from taking them back.
257 if (dropped) await log(clearLineOf(input.consumer, input.key, await now()))
258 if (state.board.delete(id) || dropped) redraw()
259 },
260 isOpen: async () => state.open,
261 }
262}
263
264/** The colour of a line's tone; `dim` has none of its own and is drawn faint instead. */
265function toneColor(tone: Row['tone']): string | undefined {
266 return { ok: 'green', warn: 'yellow', error: 'red', info: 'blue' }[tone as 'ok' | 'warn' | 'error' | 'info']
267}
268
269function sectionTree(els: Elements, one: Drawn, press: (command: string, args?: string) => void, first: number) {
270 const { Box, Button, Text } = els
271 return (
272 <Box key={one.id} flexDirection="column" marginBottom={1}>
273 <Text bold>{one.head}</Text>
274 {one.rows.map((row, i) => (
275 <Text key={`${one.id}:${i}`} color={toneColor(row.tone)} dimColor={row.tone === 'dim'}>
276 {row.parts === undefined
277 ? row.text
278 : row.parts.map((part, j) => (
279 <Text key={`${one.id}:${i}:${j}`} color={toneColor(part.tone)} dimColor={part.tone === 'dim'}>
280 {part.text}
281 </Text>
282 ))}
283 </Text>
284 ))}
285 {one.buttons.map((b, i) => (
286 // The label turns red under the pointer, because a button's own colour cannot be set.
287 <Button key={`${one.id}:b${i}`} plain hover={{ color: 'red' }} {...(first + i < 9 ? { hotkey: String(first + i + 1) } : {})} label={`[ ${b.label} ]`} onPress={() => press(b.command, b.args)} />
288 ))}
289 </Box>
290 )
291}
292
293/** The last button's answer, faint; `did not run` is red, so a failed press stands out from a run one. */
294function answerTree(els: Elements, answer: Answer) {
295 const { Text } = els
296 if (!answer.failed) return <Text dimColor>{`/${answer.command}: ${answer.text}`}</Text>
297 return (
298 <Text>
299 <Text dimColor>{`/${answer.command} `}</Text>
300 <Text color="red">did not run</Text>
301 <Text dimColor>{`: ${answer.text}`}</Text>
302 </Text>
303 )
304}
305
306function paneTree(els: Elements, state: State, columns: number, rows: number, press: (command: string, args?: string) => void) {
307 const { Box, Text } = els
308 const sections = drawn(state.board, state.stream, columns, rows)
309 let buttons = 0
310 return (
311 <Box flexDirection="column">
312 {sections.length === 0 ? <Text dimColor>{EMPTY_TEXT}</Text> : null}
313 {sections.map(one => {
314 if (one.divider === true) {
315 return (
316 <Box key={one.id} marginBottom={1}>
317 <Text dimColor>{one.head}</Text>
318 </Box>
319 )
320 }
321 const first = buttons
322 buttons += one.buttons.length
323 return sectionTree(els, one, press, first)
324 })}
325 {state.message === undefined ? null : answerTree(els, state.message)}
326 </Box>
327 )
328}
329
330export const register: Register = on => {
331 const state: State = emptyState()
332
333 on('engine.create', async (_, e, next) => {
334 const below = await next(e)
335 /**
336 * Writes one line to this project's log of today: a stream entry a later session takes back, or a
337 * clear. The file is read again first, so the lines another session of the project wrote stay; a
338 * file that is there and cannot be read is not written over.
339 */
340 const log = async (line: string): Promise<void> => {
341 const file = logFileAt(state.dir, state.project, await below.clock.now())
342 if (file === '') return
343 try {
344 // Each call is spelled at its own site, because the engine refuses a noun of $ passed as a value.
345 const disk = {
346 exists: (path: string) => below.fs.exists(path),
347 read: async (path: string) => String(await below.fs.read(path)),
348 write: (path: string, text: string) => below.fs.write(path, text),
349 }
350 await appendLog(disk, file, line)
351 } catch {
352 // The log is a convenience; a write that fails must not break the pane.
353 }
354 }
355 return { ...below, sidebar: createSidebar(() => below.ui.invalidate('ui.render'), () => below.clock.now(), log, state) }
356 })
357
358 /*
359 * A plugin loaded after this one finishes its own session.start first, because the hooks nest. The
360 * stored choice and the log are read before `next`, so a section such a plugin writes there is taken
361 * and sits above the restored entries; the pane itself opens once the session is ready.
362 */
363 on('session.start', async ($, e, next) => {
364 await openLog($, state)
365 const open = (await $.store.get(OPEN_KEY)) === true
366 if (open) await takeSections($, state)
367 const r = await next(e)
368 await $.command.register({ name: 'sidebar', description: 'The shared sidebar pane every mod writes into: open or close it, on, off, status, log, snapshot (sidebar)', argumentHint: '[on | off | status | log | snapshot]' })
369 // Declared once at the start, so the tool list the prompt cache holds does not change mid-session.
370 await $.tool.register({ name: READ_TOOL, description: READ_DESCRIPTION, inputSchema: READ_SCHEMA })
371 if (open) {
372 await switchToFullscreen($)
373 await showPane($, state)
374 }
375 return r
376 })
377
378 // A plugin's tool waits behind ToolSearch by default; this one is listed, so the model reads the pane at once.
379 on('tool.describe', { tool: /^mcp__sidebar__read$/ }, async (_, e, next) => ({ ...(await next(e)), isDeferred: false }))
380
381 on('tool.call', { tool: /^mcp__sidebar__read$/ }, async () => ({ result: snapshotOf(state) }))
382
383 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
384 on('command.run', { command: 'sidebar' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
385
386 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
387 if (e.requestId !== PANE_ID) return next(e)
388 // A surface that seats no pane beside the transcript seats it above the prompt instead; the
389 // sidebar is a sidebar or nothing, so the pane stands down and every mod keeps its own line.
390 if (e.props.placement === 'inline') return closePane($, state).then(() => next(e))
391 state.docked = true
392 // The body's own rows are the stream's room; the button's answer takes the last one.
393 const rows = (e.props.scroll.bodyRows || MAX_BOARD_LINES) - (state.message === undefined ? 0 : 1)
394 return paneTree($.ui.resolve(e), state, e.props.bodyColumns, rows, (command, args) => void pressButton($, state, command, args))
395 })
396
397 // A close, by the pane's own button, ends the session's sections only: the stored choice belongs
398 // to /sidebar alone, so the pane comes back with the session that follows.
399 on('ui.close', async ($, e, next) => {
400 if (e.id !== PANE_ID) return next(e)
401 const r = await next(e)
402 state.open = false
403 state.docked = undefined
404 forget(state)
405 return r
406 })
407
408 on('turn.start', async ($, e, next) => {
409 await dropOff($, state)
410 return next(e)
411 })
412
413 on('turn.complete', async ($, e, next) => {
414 const r = await next(e)
415 if (e.agentId === undefined && dropTurn(state.board)) $.ui.invalidate('ui.render')
416 return r
417 })
418}
419types/index.d.ts 62 lines1/**
2 * `$.sidebar`, added by the sidebar plugin: one shared pane every other mod writes into.
3 *
4 * `set` answers whether the section was taken: `false` means the sidebar is closed (or the plugin is
5 * not installed, where the call throws), so the caller keeps its own way of showing the same finding.
6 */
7
8/** How a line or a part of one is coloured: `ok` green, `warn` yellow, `error` red, `dim` faint, `info` blue. */
9export type SidebarKind = 'ok' | 'warn' | 'error' | 'dim' | 'info'
10
11/** A piece of a line in its own colour; a part without `kind` takes the line's. */
12export type SidebarPart = { text: string; kind?: SidebarKind }
13
14/**
15 * One line of a section; `kind` colours it. `parts`, when given, colour pieces of the line instead, and
16 * their texts joined are the line the pane draws. `text` still holds the whole line, because a sidebar
17 * older than 0.11.0 draws `text` alone.
18 */
19export type SidebarLine = { text: string; kind?: SidebarKind; parts?: readonly SidebarPart[] }
20
21/** A button under a section: pressing it runs the slash command `/<command> <args>`. */
22export type SidebarButton = { label: string; command: string; args?: string }
23
24/**
25 * How long a section stays.
26 *
27 * - `session`: it stands at the top of the pane until the mod replaces or clears it.
28 * - `stream`: it joins the stream under the standing sections, newest first, and stays there until
29 * newer entries push it off the pane's last row. A second `set` with the same key adds an entry
30 * rather than replacing one, so the stream reads as a log.
31 * - `turn`: it goes when the turn ends.
32 */
33export type SidebarUntil = 'turn' | 'session' | 'stream'
34
35export type SidebarSection = {
36 /** The mod writing it, drawn in front of the title. */
37 consumer: string
38 /** Names the section inside that mod; a second `set` with the same key replaces it, except in the stream. */
39 key: string
40 title: string
41 lines: readonly SidebarLine[]
42 buttons?: readonly SidebarButton[]
43 until: SidebarUntil
44 /** Lower comes first; 100 when absent. */
45 order?: number
46}
47
48export type Sidebar = {
49 /** Writes the section and draws it. `false`: the sidebar is closed and nothing was kept. */
50 set(section: SidebarSection): Promise<boolean>
51 /** Drops one section, and every stream entry of that key; an unknown key is left alone. */
52 clear(input: { consumer: string; key: string }): Promise<void>
53 /** Whether the pane is open now. */
54 isOpen(): Promise<boolean>
55}
56
57declare module 'claude-code' {
58 interface EngineInterface {
59 sidebar: Sidebar
60 }
61}
62hooks/board.ts 571 lines1/**
2 * The pure part of sidebar: the board of sections other mods wrote, their order, and the limits that
3 * keep one mod from filling the pane.
4 */
5
6import type { SidebarButton, SidebarKind, SidebarLine, SidebarPart, SidebarSection, SidebarUntil } from '../types/index.d.ts'
7
8/** Lines one section may draw; the rest are counted. */
9export const MAX_SECTION_LINES = 50
10
11/** Lines the whole pane may draw. */
12export const MAX_BOARD_LINES = 200
13
14/** Entries the stream keeps in memory; the pane draws only as many as its rows take. */
15export const MAX_STREAM = 100
16
17/** Entries one consumer keeps in the stream; its own oldest drops first, never another mod's. */
18export const MAX_STREAM_PER_CONSUMER = 20
19
20/** Rows one consumer draws of the stream at least, while other consumers write too. */
21export const MIN_STREAM_SHARE = 2
22
23/** Buttons one section may draw. */
24export const MAX_BUTTONS = 5
25
26/** The order of a section that names none. */
27export const DEFAULT_ORDER = 100
28
29const NAME = /^[A-Za-z0-9._:-]+$/
30
31/** A section as the board keeps it: read, cut to the limits, with its own id. */
32export type Kept = {
33 id: string
34 consumer: string
35 key: string
36 title: string
37 lines: SidebarLine[]
38 buttons: SidebarButton[]
39 until: SidebarUntil
40 order: number
41 /** Lines over `MAX_SECTION_LINES`, counted instead of drawn. */
42 more: number
43 /** When the entry was written, in milliseconds since the epoch; a stream entry alone carries it. */
44 at?: number
45}
46
47export type Board = Map<string, Kept>
48
49function oneLine(text: string): string {
50 return text.replace(/[\n\r\t]+/g, ' ').trimEnd()
51}
52
53function isName(value: unknown): value is string {
54 return typeof value === 'string' && value.length > 0 && value.length <= 64 && NAME.test(value)
55}
56
57const KINDS: readonly unknown[] = ['ok', 'warn', 'error', 'dim', 'info']
58const isKind = (kind: unknown): kind is SidebarKind => KINDS.includes(kind)
59
60/** Parts of a line this long at most; the rest joins the last one. */
61const MAX_PARTS = 16
62
63function partOf(value: unknown): SidebarPart | undefined {
64 if (typeof value !== 'object' || value === null) return undefined
65 const { text, kind } = value as { text?: unknown; kind?: unknown }
66 if (typeof text !== 'string' || text === '') return undefined
67 const one = text.replace(/[\n\r\t]+/g, ' ')
68 return isKind(kind) ? { text: one, kind } : { text: one }
69}
70
71/**
72 * The parts of a line, or undefined when it has none worth drawing. Their texts joined become the line's
73 * text, so the rows the pane wraps and the parts it colours are cut from one string.
74 */
75function partsOf(value: unknown): SidebarPart[] | undefined {
76 const parts = listOf(value, partOf, MAX_PARTS)
77 return parts.length === 0 ? undefined : parts
78}
79
80function lineOf(value: unknown): SidebarLine | undefined {
81 if (typeof value === 'string') return { text: oneLine(value) }
82 if (typeof value !== 'object' || value === null) return undefined
83 const { text, kind, parts: raw } = value as { text?: unknown; kind?: unknown; parts?: unknown }
84 if (typeof text !== 'string') return undefined
85 const parts = partsOf(raw)
86 const line: SidebarLine = { text: parts === undefined ? oneLine(text) : oneLine(parts.map(p => p.text).join('')) }
87 return { ...line, ...(isKind(kind) ? { kind } : {}), ...(parts === undefined ? {} : { parts }) }
88}
89
90function buttonOf(value: unknown): SidebarButton | undefined {
91 if (typeof value !== 'object' || value === null) return undefined
92 const { label, command, args } = value as { label?: unknown; command?: unknown; args?: unknown }
93 if (typeof label !== 'string' || label.trim() === '' || !isName(command)) return undefined
94 return { label: oneLine(label), command, ...(typeof args === 'string' ? { args } : {}) }
95}
96
97function listOf<T>(value: unknown, read: (item: unknown) => T | undefined, max: number): T[] {
98 if (!Array.isArray(value)) return []
99 const out: T[] = []
100 for (const item of value.slice(0, max)) {
101 const read1 = read(item)
102 if (read1 !== undefined) out.push(read1)
103 }
104 return out
105}
106
107export function sectionId(consumer: string, key: string): string {
108 return `${consumer}:${key}`
109}
110
111function untilOf(value: unknown): SidebarUntil {
112 if (value === 'turn') return 'turn'
113 return value === 'stream' ? 'stream' : 'session'
114}
115
116/**
117 * Reads a section another mod handed over. The input is untrusted: a field of another shape is
118 * dropped, and the lines and buttons are cut to the limits.
119 */
120export function readSection(input: SidebarSection): Kept | string {
121 if (!isName(input.consumer)) return 'consumer must be 1-64 characters of letters, digits, . _ : or -'
122 if (!isName(input.key)) return 'key must be 1-64 characters of letters, digits, . _ : or -'
123 if (typeof input.title !== 'string' || input.title.trim() === '') return 'title must be a non-empty string'
124 const all = Array.isArray(input.lines) ? input.lines.length : 0
125 const lines = listOf(input.lines, lineOf, MAX_SECTION_LINES)
126 return {
127 id: sectionId(input.consumer, input.key),
128 consumer: input.consumer,
129 key: input.key,
130 title: oneLine(input.title),
131 lines,
132 buttons: listOf(input.buttons, buttonOf, MAX_BUTTONS),
133 until: untilOf(input.until),
134 order: typeof input.order === 'number' && Number.isFinite(input.order) ? input.order : DEFAULT_ORDER,
135 more: Math.max(0, all - lines.length),
136 }
137}
138
139/** A section that stays for the session sorts before one that goes at the turn's end. */
140const rank = (s: Kept): number => (s.until === 'session' ? 0 : 1)
141
142/**
143 * The sections in drawing order: the session's own first, then by `order`, then by consumer and key.
144 * A section that comes and goes with the turn never moves a standing one, so the pane does not jump.
145 */
146export function ordered(board: Board): Kept[] {
147 return [...board.values()].sort((a, b) => rank(a) - rank(b) || a.order - b.order || a.consumer.localeCompare(b.consumer) || a.key.localeCompare(b.key))
148}
149
150/** Drops every section `goes` picks; answers whether the board changed. */
151function dropWhere(board: Board, goes: (section: Kept) => boolean): boolean {
152 let dropped = false
153 for (const [id, section] of board) {
154 if (goes(section) && board.delete(id)) dropped = true
155 }
156 return dropped
157}
158
159/** Drops every section of a turn; answers whether the board changed. */
160export function dropTurn(board: Board): boolean {
161 return dropWhere(board, section => section.until === 'turn')
162}
163
164/** A plugin's name from an `enabledPlugins` key or a command's plugin: `<name>@<marketplace>`, or the name alone. */
165const pluginName = (id: string): string => id.split('@')[0] ?? id
166
167/**
168 * The consumers whose plugin is off: every `enabledPlugins` key of their name is false, and no command
169 * of the session comes from them. A consumer no key names, such as a plugin loaded with `--plugin-dir`
170 * or a name that is not a plugin's, is never off, and neither is a folder copy of a disabled plugin,
171 * because its own command is listed.
172 */
173export function offConsumers(board: Board, enabledPlugins: unknown, commandPlugins: readonly string[]): Set<string> {
174 const keys = typeof enabledPlugins === 'object' && enabledPlugins !== null ? Object.entries(enabledPlugins) : []
175 const live = new Set(commandPlugins.map(pluginName))
176 const off = new Set<string>()
177 for (const { consumer } of board.values()) {
178 const own = keys.filter(([key]) => pluginName(key) === consumer)
179 if (own.length > 0 && own.every(([, on]) => on === false) && !live.has(consumer)) off.add(consumer)
180 }
181 return off
182}
183
184/** Drops every section of the consumers named; answers whether the board changed. */
185export function dropConsumers(board: Board, consumers: ReadonlySet<string>): boolean {
186 return dropWhere(board, section => consumers.has(section.consumer))
187}
188
189/** `[ stop ] sleep 600` cut to the body's width, so a long line does not wrap the pane. */
190export function cut(text: string, columns: number): string {
191 const width = Math.max(8, columns)
192 return text.length <= width ? text : `${text.slice(0, width - 1)}…`
193}
194
195/** What a wrapped line's second and further rows are written under, so a list reads as one finding. */
196const INDENT = ' '
197
198/** Rows one line may take; a line longer than that is cut, so one finding cannot fill the pane. */
199export const MAX_WRAP_ROWS = 4
200
201/**
202 * Where a row of at most `room` characters ends: after the last space that fits, so a word is not
203 * broken, and at `room` itself when the space sits too far left to be a break worth taking.
204 */
205function breakAt(text: string, room: number): number {
206 const space = text.lastIndexOf(' ', room)
207 return space > Math.floor(room / 2) ? space : room
208}
209
210/**
211 * One line as the rows the pane draws: the text is wrapped at the body's width instead of cut, so the
212 * person reads all of it. Every row after the first is indented. A line that asks for more than
213 * `MAX_WRAP_ROWS` rows has its last row cut, because one line must not take the whole pane.
214 */
215export function wrapped(text: string, columns: number): string[] {
216 const width = Math.max(8, columns)
217 const out: string[] = []
218 let rest = text.trimEnd()
219 while (out.length + 1 < MAX_WRAP_ROWS) {
220 const room = width - INDENT.length
221 if (rest.length <= (out.length === 0 ? width : room)) break
222 const at = breakAt(rest, out.length === 0 ? width : room)
223 out.push(out.length === 0 ? rest.slice(0, at).trimEnd() : INDENT + rest.slice(0, at).trimEnd())
224 rest = rest.slice(at).trimStart()
225 }
226 out.push(out.length === 0 ? cut(rest, width) : INDENT + cut(rest, width - INDENT.length))
227 return out
228}
229
230/** A piece of a drawn row in its own tone. */
231export type RowPart = { text: string; tone?: SidebarKind }
232
233/** One drawn line of a section, with the tone it is drawn in, and its pieces when the line has parts. */
234export type Row = { text: string; tone?: SidebarKind; parts?: RowPart[] }
235
236type Tone = SidebarKind | undefined
237
238const toneOf = (tone: Tone): { tone?: SidebarKind } => (tone === undefined ? {} : { tone })
239
240/** The tone of each character of a line, from its parts. */
241function tonesOf(line: SidebarLine): Tone[] {
242 return (line.parts ?? []).flatMap(p => Array.from({ length: p.text.length }, () => p.kind ?? line.kind))
243}
244
245/**
246 * A row's UTF-16 units grouped into runs of one tone. Both halves of a surrogate pair come from one
247 * part, so they carry one tone and stay in one run.
248 */
249function runsOf(text: string, tones: readonly Tone[]): RowPart[] {
250 const out: RowPart[] = []
251 for (let i = 0; i < text.length; i++) {
252 const last = out[out.length - 1]
253 if (last !== undefined && last.tone === tones[i]) last.text += text[i]
254 else out.push({ text: text[i] ?? '', ...toneOf(tones[i]) })
255 }
256 return out
257}
258
259/**
260 * The rows of a line with parts, each split into runs of one tone. Every row is found again in the
261 * line's text, after the indent a wrapped row carries, so a part keeps its colour across a break; the
262 * `…` of a cut row takes the colour of the character before it.
263 */
264function partRows(line: SidebarLine, rows: readonly string[]): Row[] {
265 const tones = tonesOf(line)
266 let from = 0
267 return rows.map((row, i) => {
268 const lead = i === 0 ? '' : INDENT
269 const body = row.slice(lead.length)
270 const isCut = body.endsWith('…') && !line.text.includes(body, from)
271 const core = isCut ? body.slice(0, -1) : body
272 const at = Math.max(from, line.text.indexOf(core, from))
273 from = at + core.length
274 const own = [...Array.from({ length: lead.length }, () => line.kind), ...tones.slice(at, from), ...(isCut ? [tones[from - 1]] : [])]
275 return { text: row, ...toneOf(line.kind), parts: runsOf(row, own) }
276 })
277}
278
279/** The rows one line draws. */
280function lineRows(line: SidebarLine, columns: number): Row[] {
281 const wrap = wrapped(line.text, columns)
282 return line.parts === undefined ? wrap.map(text => ({ text, ...toneOf(line.kind) })) : partRows(line, wrap)
283}
284
285/**
286 * One section as the pane draws it: its heading, its lines cut to the width, and its buttons. The line
287 * between the standing sections and the stream is one too, marked `divider`, its text in `head`.
288 */
289export type Drawn = { id: string; head: string; rows: Row[]; buttons: SidebarButton[]; divider?: true }
290
291/** The divider's id; a section's id always holds a `:`, so the two never meet. */
292export const DIVIDER_ID = 'sidebar-divider'
293
294/** The line between the standing sections and the stream: dashes across the body, `o` in the middle. */
295export function dividerText(columns: number): string {
296 const width = Math.max(8, columns)
297 const left = Math.floor((width - 1) / 2)
298 return `${'-'.repeat(left)}o${'-'.repeat(width - 1 - left)}`
299}
300
301const pad = (n: number): string => String(n).padStart(2, '0')
302
303/** When a stream entry was written, in the machine's own time zone: `21.09 14:32`. */
304export function stamp(at: number): string {
305 const d = new Date(at)
306 return `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
307}
308
309/**
310 * The heading of one section: the consumer and the title, and for a stream entry the day and time it
311 * was written, because that log keeps every entry and the person reads it after the fact.
312 */
313export function headText(section: Kept): string {
314 const head = `${section.consumer}: ${section.title}`
315 return section.at === undefined ? head : `${head} (${stamp(section.at)})`
316}
317
318function drawSection(section: Kept, columns: number, left: number): Drawn {
319 const rows: Row[] = []
320 let drawn = 0
321 for (const line of section.lines) {
322 const wrap = lineRows(line, columns)
323 if (rows.length + wrap.length > Math.max(0, left)) break
324 rows.push(...wrap)
325 drawn += 1
326 }
327 const hidden = section.more + (section.lines.length - drawn)
328 if (hidden > 0 && rows.length < left) rows.push({ text: cut(`+${hidden} more line(s)`, columns), tone: 'dim' })
329 return { id: section.id, head: cut(headText(section), columns), rows, buttons: section.buttons }
330}
331
332/** Draws one section into `out` with `left` rows to spend; answers the rows it took, 0 for none. */
333function addSection(out: Drawn[], section: Kept, columns: number, left: number): number {
334 if (left <= 1) return 0
335 const one = drawSection(section, columns, left - 1)
336 out.push(one)
337 return 1 + one.rows.length
338}
339
340/** The rows a stream entry asks for: its heading and its lines. */
341const cost = (s: Kept): number => 1 + s.lines.length
342
343/**
344 * The stream entries the pane draws, in the stream's own order. Each consumer takes at most its share
345 * of the rows while another consumer writes too, so one talkative mod cannot push every other mod's
346 * entry off the pane. A second pass hands the rows the shares left over to the entries they held
347 * back, so a consumer writing alone still fills the whole area.
348 */
349export function picked(stream: readonly Kept[], rows: number): Kept[] {
350 const names = new Set(stream.map(s => s.consumer))
351 if (names.size < 2) return [...stream]
352 const share = Math.max(MIN_STREAM_SHARE, Math.floor(rows / names.size))
353 const used = new Map<string, number>()
354 const keep = new Set<Kept>()
355 const later: Kept[] = []
356 let left = rows
357 for (const entry of stream) {
358 const before = used.get(entry.consumer) ?? 0
359 if (before + cost(entry) > share) {
360 later.push(entry)
361 continue
362 }
363 used.set(entry.consumer, before + cost(entry))
364 left -= cost(entry)
365 keep.add(entry)
366 }
367 for (const entry of later) {
368 if (left <= 1) break
369 left -= cost(entry)
370 keep.add(entry)
371 }
372 return stream.filter(e => keep.has(e))
373}
374
375/** The stream entries that fit in `rows`, in the stream's own order. */
376function drawStream(stream: readonly Kept[], columns: number, rows: number): Drawn[] {
377 const out: Drawn[] = []
378 let left = rows
379 for (const entry of picked(stream, left)) {
380 const spent = addSection(out, entry, columns, left)
381 if (spent === 0) break
382 left -= spent
383 }
384 return out
385}
386
387/**
388 * The pane's content: the standing sections first, then the stream newest first, cut to `columns` and
389 * to `rows`, the pane's own height. The stream's oldest entries are the ones the rows run out on, so
390 * a new entry pushes the oldest off the pane. A section the room left over is too small for is left
391 * out, and what it left out is counted in its own last row. While both are drawn, a divider row sits
392 * between the standing sections and the stream.
393 */
394export function drawn(board: Board, stream: readonly Kept[], columns: number, rows: number): Drawn[] {
395 const out: Drawn[] = []
396 let left = Math.min(Math.max(0, rows), MAX_BOARD_LINES)
397 for (const section of ordered(board)) {
398 const spent = addSection(out, section, columns, left)
399 if (spent === 0) return out
400 left -= spent
401 }
402 const divided = out.length > 0
403 const entries = drawStream(stream, columns, divided ? left - 1 : left)
404 if (!divided || entries.length === 0) return [...out, ...entries]
405 return [...out, { id: DIVIDER_ID, head: dividerText(columns), rows: [], buttons: [], divider: true }, ...entries]
406}
407
408/** The width a snapshot is drawn at: wide enough that a line is seldom wrapped, as no pane is this wide. */
409export const SNAPSHOT_COLUMNS = 200
410
411/**
412 * The pane's content as plain text, for the model and for the person to copy: each section's heading,
413 * then its rows indented, the divider as a rule, and a button as `[ label ]`. Colours do not survive.
414 */
415export function snapshotText(sections: readonly Drawn[]): string {
416 if (sections.length === 0) return EMPTY_TEXT
417 return sections
418 .map(one => (one.divider === true ? '---' : [one.head, ...one.rows.map(r => ` ${r.text}`), ...one.buttons.map(b => ` [ ${b.label} ]`)].join('\n')))
419 .join('\n\n')
420}
421
422/**
423 * The stream with the new entry at its head, cut to what it keeps in memory. A consumer over its own
424 * count drops its own oldest entry, so a talkative mod never evicts another mod's finding.
425 */
426export function pushed(stream: readonly Kept[], entry: Kept): Kept[] {
427 const mine = stream.filter(s => s.consumer === entry.consumer)
428 const drop = new Set(mine.slice(MAX_STREAM_PER_CONSUMER - 1))
429 const kept = drop.size === 0 ? stream : stream.filter(s => !drop.has(s))
430 return [entry, ...kept].slice(0, MAX_STREAM)
431}
432
433/** The line a pane with no section draws. */
434export const EMPTY_TEXT = 'No mod wrote anything yet. A mod writes here while the sidebar is open.'
435
436/** Lines one day's log file keeps; the oldest go when the file passes it. */
437export const LOG_MAX = 500
438
439/** Stream entries a new session takes back from the log. */
440export const LOG_RESTORE = 10
441
442/** One logged entry: what a stream entry was, and when it was written. */
443export type Logged = { at: number; section: SidebarSection }
444
445/** The project a directory names, as the log file's own name spells it. */
446export function projectOf(cwd: string): string {
447 const last = cwd.split('/').filter(p => p !== '').pop() ?? 'project'
448 return last.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 64) || 'project'
449}
450
451/** The day a time falls on, as the log file's own name spells it: `2026-09-21`. */
452export function dayOf(at: number): string {
453 const d = new Date(at)
454 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
455}
456
457/** The log file of one project and one day. */
458export function logName(project: string, at: number): string {
459 return `${project}-${dayOf(at)}.log`
460}
461
462/** Whether a file name is the log of that project, so another project's log is left alone. */
463/** The log file of a project for the day of `at`, or empty when there is no log directory. */
464export function logFileAt(dir: string, project: string, at: number): string {
465 return dir === '' ? '' : `${dir}/${logName(project, at)}`
466}
467
468export function isLogOf(project: string, name: string): boolean {
469 return name.startsWith(`${project}-`) && name.endsWith('.log')
470}
471
472/** One line of the log: the entry as data, on one line, so a log is read back line by line. */
473export function logLineOf(entry: Kept): string {
474 const { consumer, key, title, lines } = entry
475 return JSON.stringify({ at: entry.at ?? 0, consumer, key, title, lines })
476}
477
478/**
479 * The log line of a `clear` that took stream entries down. The entries stay in the log as history, and
480 * this line keeps a later session from taking them back, so a closed finding does not return beside its
481 * own closing line.
482 */
483export function clearLineOf(consumer: string, key: string, at: number): string {
484 return JSON.stringify({ at, cleared: { consumer, key } })
485}
486
487/** The section id a clear line took down, or undefined for a line of another shape. */
488function clearedOf(line: string): string | undefined {
489 try {
490 const read = JSON.parse(line) as { cleared?: { consumer?: unknown; key?: unknown } }
491 const { consumer, key } = read.cleared ?? {}
492 return isName(consumer) && isName(key) ? sectionId(consumer, key) : undefined
493 } catch {
494 return undefined
495 }
496}
497
498/** The entries a log text still holds up, oldest first: an entry a later clear line took down is left out. */
499export function readLive(text: string): Logged[] {
500 let out: Logged[] = []
501 for (const line of text.split('\n')) {
502 const gone = clearedOf(line)
503 if (gone !== undefined) {
504 out = out.filter(one => sectionId(one.section.consumer, one.section.key) !== gone)
505 continue
506 }
507 const one = line.trim() === '' ? undefined : loggedOf(line)
508 if (one !== undefined) out.push(one)
509 }
510 return out
511}
512
513/** The log's own lines, newest last, cut to what one file keeps. */
514/** The part of the engine's file system a log write needs. */
515export type LogFs = {
516 exists(path: string): Promise<boolean>
517 read(path: string): Promise<string>
518 write(path: string, text: string): Promise<void>
519}
520
521/**
522 * Adds one line to a log file and keeps its newest `LOG_MAX`. The file is read again right before the
523 * write, because another session of the same project writes the same file, and a copy read earlier would
524 * write its lines away. A file that is there and cannot be read throws, and is not written over.
525 */
526export async function appendLog(fs: LogFs, file: string, line: string): Promise<void> {
527 const text = (await fs.exists(file)) ? String(await fs.read(file)) : ''
528 await fs.write(file, `${logKept(logLines(text), line).join('\n')}\n`)
529}
530
531/** The lines of a log file's text, without the empty ones. */
532export function logLines(text: string): string[] {
533 return text.split('\n').filter(line => line.trim() !== '')
534}
535
536export function logKept(lines: readonly string[], line: string): string[] {
537 return [...lines, line].slice(-LOG_MAX)
538}
539
540/**
541 * The entries one log file holds, oldest first. A line of another shape is dropped without a word,
542 * because a log file is read as untrusted data: a hand-edited or truncated line must not stop a session.
543 */
544export function readLog(text: string): Logged[] {
545 const out: Logged[] = []
546 for (const line of text.split('\n')) {
547 if (line.trim() === '') continue
548 const one = loggedOf(line)
549 if (one !== undefined) out.push(one)
550 }
551 return out
552}
553
554function loggedOf(line: string): Logged | undefined {
555 try {
556 const read = JSON.parse(line) as { at?: unknown; consumer?: unknown; key?: unknown; title?: unknown; lines?: unknown }
557 if (typeof read.at !== 'number' || !isName(read.consumer) || !isName(read.key) || typeof read.title !== 'string') return undefined
558 const lines = listOf(read.lines, lineOf, MAX_SECTION_LINES)
559 return { at: read.at, section: { consumer: read.consumer, key: read.key, title: read.title, lines, until: 'stream' } }
560 } catch {
561 return undefined
562 }
563}
564
565/** The `/sidebar log` answer: where the log is, and its newest entries with their own times. */
566export function tailText(path: string, entries: readonly Logged[]): string {
567 if (entries.length === 0) return `${path}: no entry yet`
568 const rows = entries.slice(-LOG_RESTORE).reverse()
569 return [path, ...rows.map(one => `${stamp(one.at)} ${one.section.consumer}: ${one.section.title}`)].join('\n')
570}
571