sidebar
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.
What it does
/sidebaropens the pane and/sidebar offcloses 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.- While it is open, any mod writes a section:
$.sidebar.set({ consumer, key, title, lines, buttons, until, order })answerstrue. While it is closed nothing is kept and the call answersfalse, so the mod keeps showing its own transcript line or status line instead. - A section is drawn as a bold heading (
<consumer>: <title>, plus the time for a stream entry), its lines (okgreen,warnyellow,errorred,dimfaint) and its buttons. The pane has two parts: the standing sections at the top (until: 'session', thenuntil: 'turn', each group byorder, then consumer, then key), and the stream under them. - The stream is what
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 anoin 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: newer entries push it down, and once it is past the pane's last row you scroll to it (item 5). A taller terminal shows 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. - The stream scrolls on its own; the standing sections never move. The wheel over the pane, and the arrow and page keys while the pane has the focus, move the stream by whole entries. While older entries are in view, the divider counts the newer ones above them (
---- 3 newer ^ -----), and it draws also when no standing section is up. An entry written while you scroll keeps your view where it is and adds one to that count. Back at the top, the stream shows its newest entries again, with the rows shared as above; a scrolled view draws every entry in order, also the ones the shared rows held back. - A button runs a slash command: pressing
[ stop ]of{ label: 'stop', command: 'bg-tasks', args: 'stop b1' }runs/bg-tasks stop b1as 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, withdid not runin 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. - The three lifetimes:
sessionstands at the top until the mod replaces or clears it,streamjoins the log under it,turngoes 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: everyenabledPluginskey of its name is false, and no command of the session comes from it. A plugin loaded with--plugin-dirkeeps 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. - Every stream entry is also written to this project's own log file,
~/.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. Aclearthat 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 logprints the file's path and its newest 10 entries, the cleared ones among them. - The model reads the pane through the tool
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 snapshotgives 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.
The API other mods use
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.
Command
/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
Install
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.
After installing
- Restart Claude Code.
- Run
/sidebaronce. 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.
What it can reach
Validated with claude plugin validate on Claude Code 2.1.322:
❯ types ./types/index.d.ts declares on $: $.sidebar
❯ ./register.tsx hooks: engine.create, session.start, tool.describe{tool=/"^mcp__sidebar__read$"/}, tool.call{tool=/"^mcp__sidebar__read$"/}, command.run{command=sidebar}, ui.render{component=Pane}, ui.scroll{component=Pane, requestId=sidebar}, ui.close, turn.start, turn.complete
❯ ./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.
1. Reads: the sections other mods hand over, the clock for a stream entry's own time, HOME, the session's directory, and this project's own log files under ~/.claude/sidebar
2. Runs: the slash command a button names, through $.command.run, on the person's press only
3. Sends: nothing off the machine; the pane's text goes into the model's context when the model calls mcp__sidebar__read or you run /sidebar snapshot
4. Persists: in $.store, whether the sidebar is open; in ~/.claude/sidebar, one log file per project and day, holding the stream entries other mods wrote and one line per clear that took entries down
5. Hostile input: a section comes from another plugin and is read as data: the consumer, key and title are checked, every line and button of another shape is dropped, the text is folded to one line and wrapped to the width, and the counts are capped. A log line is read the same way, so a hand-edited or truncated file loses that line and nothing else.
Limits
The log directory was
~/.claude/streamup to 0.7.0 and is~/.claude/sidebarfrom 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/.The pane is placed beside the transcript only under the fullscreen layout; otherwise it opens above the prompt.
A session that opens the sidebar from the stored choice opens it as the plugin, not as the person: the engine leaves such a pane undrawn below 144 terminal columns, 110 once the person opened that pane themselves.
/sidebarin that session places it at any width.Closing the sidebar drops every section and the whole stream. Neither comes back when it is opened again; each mod writes its own at the next update.
The stream in the pane holds the session; the log on disk is what survives a restart, and only its newest 10 entries come back.
The log is per project and per day. The project is the last part of the directory the session started in, so two checkouts of one repository share a log file.
One day's file keeps its newest 500 lines. Nothing removes an old day's file; that is yours to clean.
The whole file is rewritten at each entry, because the engine's
$.fshas no append. A write that fails is passed over and the pane keeps working.A stream entry's time is the moment the mod wrote it, read from
$.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 line longer than the pane's width is wrapped, at the last space that fits, over at most 4 rows, each row after the first indented by two spaces. A line longer than those 4 rows has its last row cut with
…. A heading is cut, not wrapped.The stream keeps 20 entries per consumer and 100 in all. Over its own count a mod drops its own oldest entry, never another mod's.
A button can only run a slash command. A mod that wants a button must serve a command for it.
The stream scrolls by whole entries, not by rows. It reaches the entries the session holds in memory (see the stream limit above), not the log on disk;
/sidebar logreads the log.The pane draws only as many rows as its body shows, so the engine's own scroll window never moves; the mod answers every scroll of the pane itself.
Development
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