A read-only side pane beside the transcript: it opens the file Claude last edited at the end of the turn, lists directories one page at a time, draws code…

A read-only pane beside the transcript. /sidepad opens it and closes it again, and it opens by itself on the file Claude last wrote or edited, at the end of the turn. It shows one page at a time: a file, a directory's listing, or the files Claude edited this session. Code is drawn with the engine's own highlighter, a formatted Markdown page row by row by the hooks themselves, and a passage selected with the mouse reaches Claude through a command of the bar or the next prompt typed.
Nothing in the pane edits a file: every change is Claude's, made from what the person selected.
Each feature with its rules and its limits is in docs/features.md. In one line each:
| Part | In one line | ||
|---|---|---|---|
| Opening | /sidepad opens the pane on the page it last showed, holding the keyboard, and closes it again, and `/sidepad auto [on\ | off]` governs its opening on Claude's edits | |
| Following Claude | At the end of the main loop's turn the pane shows the turn's last edited file, or marks Edited N when the person is reading something else | ||
| Frame | The top row ends on the page's name in bold; a status line names the mode of a Markdown file or a table and the lines or entries shown | ||
| Themes | `/sidepad theme auto\ | classic\ | contrast` sets the colours of the pane's own parts, kept across sessions |
| Navigation | .., the path's directories, the listing's rows and Edited N, one page at a time, by pointer or by the arrows and Enter | ||
| Viewer | Code with the engine's highlighting and line numbers, Markdown formatted or under Source, a .diff with its hunk headers marked, a .csv or .tsv as a table or under Source, a PNG as a picture | ||
| Selection | A drag takes lines, a click takes the block under it, and a second click clears it | ||
| Asking | The bar sends the selection with Explain, Find issues or Rewrite, and Ask… points at the prompt | ||
| Files changed elsewhere | After a shell call the page is checked against the disk and follows what it finds |
Claude Code 2.1.280 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Mods are early access and their API changes between releases, so the pane is verified against one Claude Code version at a time, and until mods are released it carries no code for an earlier one.
A hooks module may import only its own files and claude-code, so the one library the pane uses travels with it: markdown-it (MIT), the bundled ESM build under hooks/vendor/, with its license and its type declarations beside it. It reads a Markdown file into the blocks a click selects and into the inline tokens each drawn row is composed from; its own HTML renderer is never called.
The pane draws where the layout docks it beside the transcript, from 110 terminal columns when /sidepad asks for it. An edit opens it unasked, and Claude Code draws it from 144 columns (110 for a pane the person opened before); narrower, it waits undrawn until the terminal is widened or /sidepad asks for it. On the main screen a pane lands inline and two rows tall, so nothing opens there by itself.
| Event | What the hook does |
|---|---|
session.start | Binds the engine's calls once and registers /sidepad. It runs again on a plugin reload, which starts from an empty state; a pane the engine still holds shows the session directory's listing |
command.run of sidepad | Opens or closes the pane, or reads and sets the auto-open switch or the theme |
command.run, any | Reads the terminal's width from the command's presentation |
ui.render of PromptHint | Reads the terminal's width and layout from the line the engine draws under the prompt, on the terminal only, which is how both are known before a pane exists |
ui.render of Pane | Draws the pane: the top row, the page, and the command bar over a selection |
ui.scroll of the pane | Moves the page's own window (three lines or rows a wheel tick, a page key the lines or rows the page shows) and leaves the engine's window still |
ui.message of the pane | The presses, drags and scrolls its Client surfaces post |
ui.press of the pane | The top row's Buttons, the list's rows and the bar's commands |
ui.close of the pane | Remembers a close the person made with the pane's mark |
prompt.submit | Attaches the selection to the prompt's context and clears it |
tool.call of Edit, Write, NotebookEdit | Records an edit that landed and reads the open file again |
tool.call of Bash, PowerShell | Checks the page against the disk after a command that ran |
turn.complete | The main loop's turn end reads Claude Code's theme again and decides which file the pane shows; a subagent's turn carries agentId and waits for its parent |
session.end of reason clear, resume | Closes the pane and forgets the session's edits, selection and close |
$command.register, config.list (Claude Code's theme setting), fs.list, fs.read, fs.stat, process.run (a window of a file too large to read whole), prompt.submit, store.get, store.set, ui.close, ui.invalidate, ui.open, ui.panes, ui.resolve, ui.status.
Every feature's limits are stated with it in docs/features.md, and the code carries each one as a // LIMIT: comment at its site. docs/limits.md lists them all, split by whether Claude Code or sidepad sets them, written from those comments by bun run limits.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/sidepad
Its tests run with claude plugin test plugins/sidepad, and claude plugin validate plugins/sidepad --strict checks the manifest.
hooks/register.ts 152 lines1import type { On } from 'claude-code';
2
3import Handlers from './handlers';
4import Names from './names';
5import type Sidepad from './sidepad';
6import Tools from './tools';
7
8/**
9 * Registers the sidepad pane: `/sidepad` and the pane's drawing, following Claude's edits at the end
10 * of the main loop's turn, checking the page after shell commands, and the selection that rides a
11 * prompt. Each hook hands its event to one handler; the state lives in `sidepad`.
12 *
13 * @param on the engine's registrar
14 */
15export function register(on: On) {
16 let sidepad: Sidepad.Sidepad | null = null;
17
18 // Runs at the session's start and again on a plugin reload.
19 on('session.start', async ($, e, next) => {
20 sidepad = await Handlers.startSession(
21 {
22 stat: (path) => $.fs.stat(path),
23 read: (path) => $.fs.read(path),
24 readBytes: async (path) => (await $.fs.read(path, { as: 'bytes' })).base64,
25 list: (path) => $.fs.list(path),
26 run: (argv, init) => $.process.run(argv, init),
27 storeGet: (key) => $.store.get(key),
28 storeSet: (key, value) => $.store.set(key, value),
29 claudeTheme: async () => {
30 const row = (await $.config.list()).find((entry) => entry.key === 'theme');
31
32 return typeof row?.value === 'string' ? row.value : null;
33 },
34 invalidate: () => $.ui.invalidate('ui.render'),
35 status: (text) => $.ui.status(text),
36 openPane: (pane) => $.ui.open(pane),
37 closePane: (pane) => $.ui.close(pane),
38 panes: () => $.ui.panes(),
39 registerCommand: (spec) => $.command.register(spec),
40 },
41 e.cwd,
42 );
43
44 return next(e);
45 });
46
47 on('command.run', ($, e, next) => {
48 if (sidepad) {
49 Handlers.learnCommandWidth(sidepad, e.presentation);
50 }
51
52 return next(e);
53 });
54
55 on('command.run', { command: Names.COMMAND_SPEC.name }, ($, e, next) =>
56 sidepad ? Handlers.runSidepadCommand(sidepad, e.args) : next(e),
57 );
58
59 // The session ending, not the command typed: a `/resume` whose picker is dismissed ends nothing.
60 on('session.end', async ($, e, next) => {
61 if (sidepad && (e.reason === 'clear' || e.reason === 'resume')) {
62 await Handlers.resetSession(sidepad);
63 }
64
65 return next(e);
66 });
67
68 // The hint line under the prompt is drawn whatever the pane is doing, so its viewport is where
69 // the terminal's width and layout come from before a pane exists. Only the terminal's: the pane
70 // is drawn there, and a remote surface reports its own size and layout (the mobile app, none).
71 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
72 if (sidepad && e.surface === 'terminal') {
73 Handlers.learnViewport(sidepad, e.viewport);
74 }
75
76 return next(e);
77 });
78
79 on('ui.render', { component: 'Pane' }, ($, e, next) => {
80 const tree =
81 sidepad && e.requestId === Names.PANE_ID && e.surface === 'terminal'
82 ? Handlers.drawPane(sidepad, e, $.ui.resolve(e))
83 : null;
84
85 return tree ?? next(e);
86 });
87
88 on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
89 if (!sidepad) {
90 return next(e);
91 }
92
93 Handlers.scrollPane(sidepad, e);
94
95 return {};
96 });
97
98 on('ui.message', { requestId: Names.PANE_ID }, ($, e, next) => {
99 if (sidepad) {
100 // What a Client posted is the pane's own business: a throw here must not fail the event.
101 try {
102 Handlers.receiveMessage(sidepad, e.data);
103 } catch {
104 // The post is dropped; the pane keeps the state it had.
105 }
106 }
107
108 return next(e);
109 });
110
111 on('ui.press', { requestId: Names.PANE_ID }, async ($, e, next) => {
112 if (sidepad) {
113 await Handlers.pressInPane(sidepad, e.element, (text) => $.prompt.submit({ text })).catch(() => undefined);
114 }
115
116 return next(e);
117 });
118
119 on('prompt.submit', ($, e, next) => (sidepad ? Handlers.attachSelection(sidepad, e, next) : next(e)));
120
121 on('tool.call', { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] }, async ($, e, next) => {
122 const result = await next(e);
123
124 if (sidepad) {
125 // A failure to read the pane's page must never fail Claude's tool call.
126 await Handlers.recordToolCall(sidepad, e, result).catch(() => undefined);
127 }
128
129 return result;
130 });
131
132 on('turn.complete', async ($, e, next) => {
133 const result = await next(e);
134
135 if (sidepad) {
136 await Handlers.completeTurn(sidepad, e).catch(() => undefined);
137 }
138
139 return result;
140 });
141
142 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
143 const result = await next(e);
144
145 if (sidepad) {
146 Handlers.closePane(sidepad, e);
147 }
148
149 return result;
150 });
151}
152hooks/handlers/index.ts 23 lines1export * as default from '.';
2export * from './attach-selection';
3export * from './check-page';
4export * from './close-pane';
5export * from './complete-turn';
6export * from './draw-pane';
7export * from './ensure-window';
8export * from './learn-command-width';
9export * from './learn-viewport';
10export * from './list-directory';
11export * from './load-file';
12export * from './nearest-directory';
13export * from './press-in-pane';
14export * from './read-line-count';
15export * from './read-line-window';
16export * from './read-theme';
17export * from './receive-message';
18export * from './record-tool-call';
19export * from './reset-session';
20export * from './run-sidepad-command';
21export * from './scroll-pane';
22export * from './start-session';
23hooks/names/index.ts 10 lines1export * as default from '.';
2export * from './bar-commands';
3export * from './command-spec';
4export * from './keys';
5export * from './pane-id';
6export * from './pane-title';
7export * from './store-auto-open-key';
8export * from './store-theme-key';
9export * from './texts';
10hooks/sidepad/index.ts 3 lines1export * as default from '.';
2export type * from './sidepad';
3hooks/tools/index.ts 4 lines1export * as default from '.';
2export * from './editing-tools';
3export * from './shell-tools';
4hooks/handlers/attach-selection.ts 64 lines1import type { Args, ResultOf } from 'claude-code';
2
3import Ask from '../ask';
4import Limits from '../limits';
5import Names from '../names';
6import PaneState from '../pane-state';
7import type Sidepad from '../sidepad';
8import { readLineWindow } from './read-line-window';
9
10/**
11 * A prompt submitted while lines are selected: the selection rides it as one context entry, cut to
12 * the room it has to reach the model inline, and is cleared once the prompt was not dropped. A selection with no
13 * room at all is cleared and the status line says so.
14 *
15 * @param next the hook's `next`
16 * @returns what `next` resolved to
17 */
18export async function attachSelection(
19 sidepad: Sidepad.Sidepad,
20 e: Args<'prompt.submit'>,
21 next: (e: Args<'prompt.submit'>) => Promise<ResultOf['prompt.submit']>,
22): Promise<ResultOf['prompt.submit']> {
23 const { selection, file } = sidepad.state;
24
25 if (!selection || !file) {
26 return next(e);
27 }
28
29 const context = e.context ?? [];
30 const room = Ask.askRoomOf(context);
31 // A file too large to read whole holds one window: the lines selected are read for the prompt,
32 // since a drag past the window's edge can have left them outside it.
33 const range = selection.range;
34 const selected =
35 file.loaded.source === 'windowed'
36 ? await readLineWindow(
37 sidepad.host,
38 file.loaded.path,
39 range.start - 1,
40 Math.min(range.end - range.start + 1, Limits.ASK_MAX_LINES),
41 )
42 : null;
43 const lines = selected ?? file.loaded.lines;
44 const firstLine = selected === null ? file.loaded.from : range.start - 1;
45 const text = Ask.fittedAskTextOf(Ask.askTextOf(file.loaded.path, lines, range, firstLine), room);
46
47 if (text === undefined) {
48 sidepad.state = PaneState.withoutSelection(sidepad.state);
49 sidepad.host.status(Names.ASK_DROPPED_TEXT);
50 sidepad.host.invalidate();
51
52 return next(e);
53 }
54
55 const result = await next({ ...e, context: [...context, text] });
56
57 if (result.drop === undefined) {
58 sidepad.state = PaneState.withoutSelection(sidepad.state);
59 sidepad.host.invalidate();
60 }
61
62 return result;
63}
64hooks/handlers/check-page.ts 61 lines1import Files from '../files';
2import Names from '../names';
3import PaneState from '../pane-state';
4import Paths from '../paths';
5import type Sidepad from '../sidepad';
6import { ensureWindow } from './ensure-window';
7import { listDirectory } from './list-directory';
8import { loadFile } from './load-file';
9import { nearestDirectory } from './nearest-directory';
10
11/**
12 * The page checked against the disk, after a shell command or when the pane opens: a file that is
13 * gone gives way to its nearest existing directory with a note naming it; a file that changed is read
14 * again in place, clearing a selection; a directory is listed again, or gives way as a file does. A
15 * rename is not inferred from the command.
16 */
17export async function checkPage(sidepad: Sidepad.Sidepad): Promise<void> {
18 const { host } = sidepad;
19 const { page, file, cwd } = sidepad.state;
20
21 if (page.kind === 'file' && file) {
22 const path = file.loaded.path;
23 const stat = await host.stat(path).catch(() => null);
24 const change = Files.pageChangeOf(file.loaded.stamp, stat);
25
26 if (change === 'changed') {
27 sidepad.state = PaneState.withFileReloaded(sidepad.state, await loadFile(host, path));
28 await ensureWindow(sidepad);
29 } else if (change === 'gone') {
30 const directory = await nearestDirectory(host, Paths.parentOf(path), cwd);
31 const listing = await listDirectory(host, directory);
32
33 sidepad.state = PaneState.withDirectory(
34 sidepad.state,
35 directory,
36 listing,
37 '',
38 Names.goneNoteOf(Paths.shownPathOf(path, cwd)),
39 );
40 }
41
42 return;
43 }
44
45 if (page.kind === 'directory') {
46 const directory = await nearestDirectory(host, page.path, cwd);
47 const listing = await listDirectory(host, directory);
48
49 sidepad.state =
50 directory === page.path
51 ? PaneState.withDirectoryRelisted(sidepad.state, listing)
52 : PaneState.withDirectory(
53 sidepad.state,
54 directory,
55 listing,
56 '',
57 Names.goneNoteOf(Paths.shownPathOf(page.path, cwd)),
58 );
59 }
60}
61hooks/handlers/close-pane.ts 10 lines1import type { PaneCloseInput } from 'claude-code';
2
3import PaneState from '../pane-state';
4import type Sidepad from '../sidepad';
5
6/** The pane closed; a person's close with its mark keeps later edits from reopening it this session. */
7export function closePane(sidepad: Sidepad.Sidepad, e: PaneCloseInput): void {
8 sidepad.state = PaneState.afterClose(sidepad.state, e.origin.kind);
9}
10hooks/handlers/complete-turn.ts 51 lines1import type { TurnCompleteInput } from 'claude-code';
2
3import Names from '../names';
4import PaneState from '../pane-state';
5import type Sidepad from '../sidepad';
6import { ensureWindow } from './ensure-window';
7import { loadFile } from './load-file';
8import { readTheme } from './read-theme';
9
10/**
11 * The end of a turn. Only the main loop's counts: a subagent's turn carries `agentId`, and its edits
12 * are applied at its parent's end. The pane opens on or follows the turn's last edited file, whatever
13 * the pace of the edits (edits a person approves arrive at the person's pace, so no pause groups
14 * them), an interrupted turn included; or `Edited N` is marked. Claude Code's theme is read again
15 * first, since a change of it raises nothing the pane hears.
16 */
17export async function completeTurn(sidepad: Sidepad.Sidepad, e: TurnCompleteInput): Promise<void> {
18 if (e.agentId !== undefined) {
19 return;
20 }
21
22 await readTheme(sidepad);
23
24 if (sidepad.state.turn.edits.length === 0) {
25 return;
26 }
27
28 const isAutoOpenOn = (await sidepad.host.storeGet(Names.STORE_AUTO_OPEN_KEY)) !== false;
29 const { state, action } = PaneState.afterTurn(sidepad.state, isAutoOpenOn);
30
31 sidepad.state = state;
32
33 if (action.kind === 'open' || action.kind === 'follow') {
34 sidepad.state = PaneState.withFile(sidepad.state, await loadFile(sidepad.host, action.path), action.line);
35 }
36
37 if (action.kind === 'open') {
38 // Unasked, so no `focus`: the keys stay with the prompt, whose Up is the person's history.
39 // `isPlaced` is not read: below the floor the engine gives a pane nobody asked for, the pane is
40 // open but waits undrawn, says nothing, and is drawn on the page it holds once the terminal is
41 // widened or `/sidepad` asks for it.
42 await sidepad.host.openPane({ id: Names.PANE_ID, title: Names.PANE_TITLE });
43 sidepad.state = PaneState.afterOpened(sidepad.state);
44 }
45
46 if (sidepad.state.isOpen) {
47 sidepad.host.invalidate();
48 await ensureWindow(sidepad);
49 }
50}
51hooks/handlers/draw-pane.ts 26 lines1import type { RenderElement, RenderInput } from 'claude-code';
2import PaneState from '../pane-state';
3import Plan from '../plan';
4import type Sidepad from '../sidepad';
5import Views from '../views';
6import { ensureWindow } from './ensure-window';
7
8/**
9 * One drawing of the pane: the state laid out for this body, and drawn.
10 *
11 * @returns the tree
12 */
13export function drawPane(
14 sidepad: Sidepad.Sidepad,
15 e: RenderInput<'Pane', 'terminal'>,
16 ui: Views.TerminalUi,
17): RenderElement {
18 sidepad.state = PaneState.withColumns(sidepad.state, e.viewport?.columns);
19 sidepad.state = PaneState.laidOut(sidepad.state, { rows: e.props.scroll.bodyRows, columns: e.props.bodyColumns });
20 // The body's size is known only here, so a first drawing or a resize is where a windowed file
21 // learns how many lines to read; the page draws the window it holds until the read lands.
22 void ensureWindow(sidepad);
23
24 return Views.paneView(ui, Plan.panePlanOf(sidepad.state, e.props.scroll.offset));
25}
26hooks/handlers/ensure-window.ts 57 lines1import Files from '../files';
2import PaneState from '../pane-state';
3import type Sidepad from '../sidepad';
4import { readLineWindow } from './read-line-window';
5
6/**
7 * The window a windowed file's page wants, read and put in place, then the pane drawn again. One read
8 * at a time: a scroll while one runs is served by the read that follows it, so a spin of the wheel
9 * costs one read per landed window rather than one per tick.
10 */
11export async function ensureWindow(sidepad: Sidepad.Sidepad): Promise<void> {
12 if (sidepad.isReading) {
13 return;
14 }
15
16 sidepad.isReading = true;
17
18 // The window this pass last read. A read that leaves `isWindowHeld` unsatisfied (a line count
19 // stale because the file shrank on disk) would otherwise be repeated for the same window until
20 // the guard runs out, spawning one command per turn of the loop.
21 let read: { top: number; rows: number } | null = null;
22
23 try {
24 for (let guard = 0; guard < 100; guard += 1) {
25 const file = sidepad.state.file;
26 const rows = PaneState.windowRowsOf(sidepad.state);
27
28 if (!file || sidepad.state.page.kind !== 'file' || Files.isWindowHeld(file.loaded, file.top, rows)) {
29 return;
30 }
31
32 const top = file.top;
33
34 if (read !== null && read.top === top && read.rows === rows) {
35 return;
36 }
37
38 const lines = await readLineWindow(sidepad.host, file.loaded.path, top, rows);
39
40 read = { top, rows };
41
42 if (lines === null) {
43 return;
44 }
45
46 // The loaded file itself, not its path: an edit during the read reloads the same path into a
47 // new `loaded`, and these lines are the ones from before it.
48 if (sidepad.state.file?.loaded === file.loaded) {
49 sidepad.state = PaneState.withFileWindow(sidepad.state, top, lines);
50 sidepad.host.invalidate();
51 }
52 }
53 } finally {
54 sidepad.isReading = false;
55 }
56}
57hooks/handlers/learn-command-width.ts 13 lines1import type { CommandPresentation } from 'claude-code';
2
3import PaneState from '../pane-state';
4import type Sidepad from '../sidepad';
5
6/**
7 * Any command the person runs carries the terminal's width, read again every time, since a person
8 * resizes.
9 */
10export function learnCommandWidth(sidepad: Sidepad.Sidepad, presentation: CommandPresentation): void {
11 sidepad.state = PaneState.withColumns(sidepad.state, presentation.columns);
12}
13