Shows Claude's finished replies in a side pane as fixed pages with short lines

Reusable Claude Code skills, and one mod.
Copy (or symlink) a skill folder into a project's .claude/skills/ to use it.
implementskeleton-uibook-pagerShows Claude's finished replies in a side pane as fixed pages with short lines, so they are easier to read in a wide terminal. Each session keeps its own book, and a restart or /resume brings it back.
To load it in every session, add this to ~/.claude/settings.json (inside the "env" block if there already is one), then start a new session:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/code/claude-skills/book-pager" }
/pager shows or hides the pane.p / n turn the page when the pane has focus. With the prompt empty, , and . turn it from the prompt.width setting, in terminal columns of text (default 76, 20 to 200). Change "Page width" in /config, or set it in ~/.claude/settings.json and restart: "pluginConfigs": { "book-pager": { "options": { "width": 90 } } }
The mod API is early access, and the mod was built on Claude Code 2.1.289. Setup steps, the design and what was tested are in the design note.
hooks/register.tsx 309 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Book, BookEntry, BookPosition } from "../types";
5import { entryPages } from "./pages";
6import type { Line, Run } from "./pages";
7
8const PANE = "book-pager";
9const DEFAULT_COLUMNS = 76;
10// The pane's border and padding around the text.
11const DOCK_MARGIN = 4;
12const MAX_ENTRIES = 200;
13// The button row and the rule under it.
14const HEADER_ROWS = 2;
15// Typed into an empty prompt, these turn the page instead of typing. The Japanese ones are what the IME types.
16const PREV_KEYS = [",", "、"];
17const NEXT_KEYS = [".", "。"];
18
19const EMPTY_BOOK: Book = {
20 sessionId: "",
21 entries: [],
22 position: { entry: 0, page: 0 },
23};
24const heldBook = atom({ plugin: "book-pager", key: "book" } as const, EMPTY_BOOK);
25const pendingPrompt = atom(
26 { plugin: "book-pager", key: "pendingPrompt" } as const,
27 "",
28);
29
30// Each session's book is also saved in $.store, so a restart or /resume brings it back.
31// $.store holds 4 MiB in all, so only the newest books are kept.
32const MAX_SAVED_BOOKS = 30;
33const SAVED_SESSIONS_KEY = "sessions";
34const entriesKey = (sessionId: string) => `entries:${sessionId}`;
35const positionKey = (sessionId: string) => `position:${sessionId}`;
36
37// The running session's book: the copy in $.state, or the saved one when that copy is another session's.
38async function loadBook($: EngineInterface): Promise<Book> {
39 const sessionId = await $.session.id();
40 const held = await read($, heldBook);
41 if (held.sessionId === sessionId) {
42 return held;
43 }
44 const entries = (await $.store.get(entriesKey(sessionId))) as
45 | BookEntry[]
46 | undefined;
47 const position = (await $.store.get(positionKey(sessionId))) as
48 | BookPosition
49 | undefined;
50
51 return {
52 sessionId,
53 entries: entries ?? [],
54 position: position ?? EMPTY_BOOK.position,
55 };
56}
57
58async function forgetBook($: EngineInterface, sessionId: string) {
59 await $.store.delete(entriesKey(sessionId));
60 await $.store.delete(positionKey(sessionId));
61}
62
63async function saveEntries($: EngineInterface, saved: Book) {
64 const listed =
65 ((await $.store.get(SAVED_SESSIONS_KEY)) as string[] | undefined) ?? [];
66 const sessions = [
67 ...listed.filter((id) => id !== saved.sessionId),
68 saved.sessionId,
69 ];
70 while (sessions.length > MAX_SAVED_BOOKS) {
71 await forgetBook($, sessions.shift() ?? "");
72 }
73 // When the store is full, the oldest books make room.
74 for (;;) {
75 try {
76 await $.store.set(entriesKey(saved.sessionId), saved.entries);
77 break;
78 } catch (error) {
79 const oldest = sessions[0];
80 if (oldest === undefined || oldest === saved.sessionId) {
81 throw error;
82 }
83 sessions.shift();
84 await forgetBook($, oldest);
85 }
86 }
87 await $.store.set(SAVED_SESSIONS_KEY, sessions);
88}
89
90// The text width the pane opens with, from the plugin's settings. A width the person drags the pane to wins.
91let openColumns = DEFAULT_COLUMNS;
92
93// The pane's text size from its last draw, so a key typed in the prompt pages the same way.
94let layout = { width: DEFAULT_COLUMNS, rows: 20 };
95
96type Spot = {
97 book: Line[][][];
98 entry: number;
99 page: number;
100 before: number;
101 total: number;
102 isFirst: boolean;
103 isLast: boolean;
104};
105
106async function findSpot($: EngineInterface): Promise<Spot> {
107 const current = await loadBook($);
108 const book = current.entries.map((entry) =>
109 entryPages(entry, layout.width, layout.rows),
110 );
111 const saved = current.position;
112 const lastEntry = book.length - 1;
113 const entry = Math.max(0, Math.min(saved.entry, lastEntry));
114 const pagesInEntry = book[entry]?.length ?? 1;
115 const page = Math.min(saved.page, pagesInEntry - 1);
116 const before = book
117 .slice(0, entry)
118 .reduce((sum, pages) => sum + pages.length, 0);
119 const total = Math.max(
120 1,
121 book.reduce((sum, pages) => sum + pages.length, 0),
122 );
123
124 return {
125 book,
126 entry,
127 page,
128 before,
129 total,
130 isFirst: entry === 0 && page === 0,
131 isLast: entry >= lastEntry && page === pagesInEntry - 1,
132 };
133}
134
135async function turnPage($: EngineInterface, direction: 1 | -1) {
136 const spot = await findSpot($);
137 const { book, entry, page } = spot;
138 if (direction === 1 && spot.isLast) {
139 $.ui.toast("This is the last page.");
140 return;
141 }
142 if (direction === -1 && spot.isFirst) {
143 $.ui.toast("This is the first page.");
144 return;
145 }
146 const pagesInEntry = book[entry]?.length ?? 1;
147 const nextSpot =
148 direction === 1
149 ? page < pagesInEntry - 1
150 ? { entry, page: page + 1 }
151 : { entry: entry + 1, page: 0 }
152 : page > 0
153 ? { entry, page: page - 1 }
154 : { entry: entry - 1, page: (book[entry - 1]?.length ?? 1) - 1 };
155 const current = await loadBook($);
156 await update($, heldBook, () => ({ ...current, position: nextSpot }));
157 await $.store.set(positionKey(current.sessionId), nextSpot);
158}
159
160function openPane($: EngineInterface, isFocused: boolean) {
161 return $.ui.open({
162 id: PANE,
163 title: "Pager",
164 columns: openColumns + DOCK_MARGIN,
165 ...(isFocused ? { focus: true } : {}),
166 });
167}
168
169export const register: Register = (on, options) => {
170 openColumns =
171 typeof options.width === "number" ? options.width : DEFAULT_COLUMNS;
172 layout = { ...layout, width: openColumns };
173
174 on("session.start", async ($, e, next) => {
175 await $.command.register({
176 name: "pager",
177 description: "Show or hide Claude's replies as fixed pages in a side pane",
178 });
179 void openPane($, false);
180
181 return next(e);
182 });
183
184 // /clear goes on under a new session id with no session.start, so this redraws the pane with that session's book.
185 on("classic.SessionStart", async ($, e, next) => {
186 if (e.source === "clear") {
187 await update($, heldBook, () => EMPTY_BOOK);
188 }
189
190 return next(e);
191 });
192
193 // A toggle, because the close key and Escape do not close the pane everywhere.
194 on("command.run", { command: "pager" }, async ($) => {
195 const panes = await $.ui.panes();
196 if (panes.some((pane) => pane.id === PANE && pane.isShown && pane.isPlaced)) {
197 await $.ui.close({ id: PANE });
198
199 return { text: "Pager pane closed." };
200 }
201 await openPane($, true);
202
203 return { text: "Pager pane opened." };
204 });
205
206 // A prompt typed while Claude works joins the prompt of the running turn.
207 on("prompt.submit", async ($, e, next) => {
208 if (e.text.trim() !== "/pager") {
209 await update($, pendingPrompt, (pending) =>
210 e.turnId !== undefined && pending !== ""
211 ? `${pending}\n${e.text}`
212 : e.text,
213 );
214 }
215
216 return next(e);
217 });
218
219 on("prompt.edit", async ($, e, next) => {
220 const direction = NEXT_KEYS.includes(e.inputText)
221 ? 1
222 : PREV_KEYS.includes(e.inputText)
223 ? -1
224 : 0;
225 if (e.text !== "" || direction === 0) {
226 return next(e);
227 }
228 const panes = await $.ui.panes();
229 if (!panes.some((pane) => pane.id === PANE && pane.isShown)) {
230 return next(e);
231 }
232 await turnPage($, direction);
233
234 return { text: e.text, cursor: e.cursor };
235 });
236
237 // Adds a reply only when it is finished, so the page on screen stays still while Claude writes.
238 on("turn.complete", async ($, e, next) => {
239 if (e.agentId === undefined && e.answer.trim() !== "") {
240 const prompt = await read($, pendingPrompt);
241 const current = await loadBook($);
242 const entries = [...current.entries, { prompt, reply: e.answer }].slice(
243 -MAX_ENTRIES,
244 );
245 const position = { entry: entries.length - 1, page: 0 };
246 const grown = { ...current, entries, position };
247 await update($, heldBook, () => grown);
248 await update($, pendingPrompt, () => "");
249 await saveEntries($, grown);
250 await $.store.set(positionKey(current.sessionId), position);
251 }
252
253 return next(e);
254 });
255
256 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
257 const { Box, Text, Button } = $.ui.resolve(e);
258 layout = {
259 width: Math.max(10, e.props.bodyColumns),
260 rows: Math.max(1, e.props.scroll.bodyRows - HEADER_ROWS),
261 };
262 const spot = await findSpot($);
263 const lines = spot.book[spot.entry]?.[spot.page] ?? [];
264
265 const drawRun = (run: Run) => (
266 <Text
267 bold={run.style === "bold"}
268 dimColor={run.style === "dim"}
269 color={run.style === "code" ? "cyan" : undefined}
270 >
271 {run.text}
272 </Text>
273 );
274
275 return (
276 <Box flexDirection="column">
277 <Box>
278 <Button
279 key="prev"
280 hotkey="p"
281 plain
282 dimColor={spot.isFirst}
283 label="Prev"
284 onPress={() => turnPage($, -1)}
285 />
286 <Text> </Text>
287 <Button
288 key="next"
289 hotkey="n"
290 plain
291 dimColor={spot.isLast}
292 label="Next"
293 onPress={() => turnPage($, 1)}
294 />
295 <Text
296 dimColor
297 wrap="truncate"
298 >{` Page ${spot.before + spot.page + 1} / ${spot.total} (empty prompt: , and .)`}</Text>
299 </Box>
300 <Text dimColor>{"─".repeat(layout.width)}</Text>
301 {spot.book.length === 0 && <Text dimColor>No finished reply yet.</Text>}
302 {lines.map((line) => (
303 <Text>{line.length === 0 ? " " : line.map(drawRun)}</Text>
304 ))}
305 </Box>
306 );
307 });
308};
309hooks/pages.ts 383 lines1import type { BookEntry } from "../types";
2
3export type Style = "plain" | "bold" | "code" | "dim";
4export type Run = { text: string; style: Style };
5export type Line = Run[];
6
7type Word = Run[];
8
9// Japanese, Chinese, Korean and emoji take two terminal cells.
10function charWidth(char: string): number {
11 const code = char.codePointAt(0) ?? 0;
12 const isWide =
13 (code >= 0x1100 && code <= 0x115f) ||
14 (code >= 0x2e80 && code <= 0xa4cf) ||
15 (code >= 0xac00 && code <= 0xd7a3) ||
16 (code >= 0xf900 && code <= 0xfaff) ||
17 (code >= 0xfe30 && code <= 0xfe4f) ||
18 (code >= 0xff00 && code <= 0xff60) ||
19 (code >= 0xffe0 && code <= 0xffe6) ||
20 (code >= 0x1f300 && code <= 0x1faff);
21
22 return isWide ? 2 : 1;
23}
24
25export function textWidth(text: string): number {
26 let width = 0;
27 for (const char of text) {
28 width += charWidth(char);
29 }
30
31 return width;
32}
33
34function lineWidth(line: Line): number {
35 return line.reduce((sum, run) => sum + textWidth(run.text), 0);
36}
37
38// A link shows only its text, because the pane cannot open the URL.
39const LINK = /\[([^\]]+)\]\([^)\s]+\)/g;
40
41function parseInline(text: string, base: Style): Run[] {
42 const runs: Run[] = [];
43 for (const part of text
44 .replace(LINK, "$1")
45 .split(/(\*\*[^*]+\*\*|`[^`]+`)/)) {
46 if (part === "") {
47 continue;
48 }
49 if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
50 runs.push({
51 text: part.slice(2, -2),
52 style: base === "plain" ? "bold" : base,
53 });
54 } else if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
55 runs.push({ text: part.slice(1, -1), style: "code" });
56 } else {
57 runs.push({ text: part, style: base });
58 }
59 }
60
61 return runs;
62}
63
64function toWords(runs: Run[]): Word[] {
65 const words: Word[] = [];
66 let current: Word = [];
67 for (const run of runs) {
68 const pieces = run.text.split(/( +)/);
69 for (const piece of pieces) {
70 if (piece === "") {
71 continue;
72 }
73 if (piece.startsWith(" ")) {
74 if (current.length > 0) {
75 words.push(current);
76 current = [];
77 }
78 } else {
79 current.push({ text: piece, style: run.style });
80 }
81 }
82 }
83 if (current.length > 0) {
84 words.push(current);
85 }
86
87 return words;
88}
89
90function splitLongWord(word: Word, width: number): Word[] {
91 const parts: Word[] = [];
92 let part: Word = [];
93 let used = 0;
94 for (const run of word) {
95 for (const char of run.text) {
96 const size = charWidth(char);
97 if (used + size > width && used > 0) {
98 parts.push(part);
99 part = [];
100 used = 0;
101 }
102 const last = part[part.length - 1];
103 if (last && last.style === run.style) {
104 last.text += char;
105 } else {
106 part.push({ text: char, style: run.style });
107 }
108 used += size;
109 }
110 }
111 if (part.length > 0) {
112 parts.push(part);
113 }
114
115 return parts;
116}
117
118// Wraps at word boundaries. Lines after the first start with `indent`.
119function wrap(
120 runs: Run[],
121 width: number,
122 firstPrefix: string,
123 indent: string,
124): Line[] {
125 const room = Math.max(1, width - textWidth(indent));
126 const words = toWords(runs).flatMap((word) =>
127 lineWidth(word) > room ? splitLongWord(word, room) : [word],
128 );
129 const lines: Line[] = [];
130 let line: Line =
131 firstPrefix === "" ? [] : [{ text: firstPrefix, style: "plain" }];
132 let used = textWidth(firstPrefix);
133 let hasWord = false;
134 for (const word of words) {
135 const size = lineWidth(word);
136 const gap = hasWord ? 1 : 0;
137 if (hasWord && used + gap + size > width) {
138 lines.push(line);
139 line = indent === "" ? [] : [{ text: indent, style: "plain" }];
140 used = textWidth(indent);
141 hasWord = false;
142 }
143 if (hasWord) {
144 line.push({ text: " ", style: "plain" });
145 used += 1;
146 }
147 line.push(...word);
148 used += size;
149 hasWord = true;
150 }
151 lines.push(line);
152
153 return lines;
154}
155
156// Cuts at the width with no word wrapping, for code.
157function hardWrap(
158 text: string,
159 width: number,
160 style: Style,
161 indent: string,
162): Line[] {
163 if (text === "") {
164 return [[{ text: indent, style }]];
165 }
166 const room = Math.max(1, width - textWidth(indent));
167
168 return splitLongWord([{ text, style }], room).map((part) => [
169 { text: indent, style: "plain" },
170 ...part,
171 ]);
172}
173
174const TABLE_ROW = /^\s*\|/;
175
176// "\|" is a pipe inside a cell, not a column border.
177function splitCells(row: string): string[] {
178 return row
179 .trim()
180 .replace(/^\|/, "")
181 .replace(/(?<!\\)\|$/, "")
182 .split(/(?<!\\)\|/)
183 .map((cell) => cell.replace(/\\\|/g, "|").trim());
184}
185
186function isDividerRow(cells: string[]): boolean {
187 return cells.every((cell) => /^:?-+:?$/.test(cell));
188}
189
190// A table with borders, or undefined when it is wider than the width.
191function formatGrid(
192 header: string[],
193 body: string[][],
194 width: number,
195): Line[] | undefined {
196 const rows = [
197 ...(header.length > 0
198 ? [header.map((cell) => parseInline(cell, "bold"))]
199 : []),
200 ...body.map((row) => row.map((cell) => parseInline(cell, "plain"))),
201 ];
202 const columns = Math.max(0, ...rows.map((row) => row.length));
203 const widths = Array.from({ length: columns }, (_, column) =>
204 Math.max(0, ...rows.map((row) => lineWidth(row[column] ?? []))),
205 );
206 // Each cell has "│ " before it and " " after it, and the last one a closing "│".
207 const total = widths.reduce((sum, size) => sum + size + 3, 1);
208 if (total > width) {
209 return undefined;
210 }
211 const border = (left: string, middle: string, right: string): Line => [
212 {
213 text: `${left}${widths.map((size) => "─".repeat(size + 2)).join(middle)}${right}`,
214 style: "dim",
215 },
216 ];
217 const bar: Run = { text: "│", style: "dim" };
218 const lines: Line[] = rows.map((row) => [
219 ...widths.flatMap((size, column) => {
220 const cell = row[column] ?? [];
221 const padding = size - lineWidth(cell) + 1;
222
223 return [
224 bar,
225 { text: " ", style: "plain" as const },
226 ...cell,
227 { text: " ".repeat(padding), style: "plain" as const },
228 ];
229 }),
230 bar,
231 ]);
232 if (header.length > 0) {
233 lines.splice(1, 0, border("├", "┼", "┤"));
234 }
235
236 return [border("┌", "┬", "┐"), ...lines, border("└", "┴", "┘")];
237}
238
239// One block per row: the first cell is the title, then each header over its value.
240function formatCards(
241 header: string[],
242 body: string[][],
243 width: number,
244): Line[] {
245 const lines: Line[] = [];
246 body.forEach((row, index) => {
247 if (index > 0) {
248 lines.push([{ text: "─".repeat(width), style: "dim" }]);
249 }
250 lines.push(...wrap(parseInline(row[0] ?? "", "bold"), width, "", ""));
251 row.slice(1).forEach((cell, column) => {
252 if (cell === "") {
253 return;
254 }
255 lines.push([]);
256 const label = header[column + 1] ?? "";
257 if (label !== "") {
258 lines.push(...wrap(parseInline(label, "bold"), width, " ", " "));
259 }
260 lines.push(...wrap(parseInline(cell, "plain"), width, " ", " "));
261 });
262 });
263
264 return lines;
265}
266
267function formatTable(rows: string[], width: number): Line[] {
268 const cells = rows.map(splitCells);
269 const hasHeader = cells.length > 1 && isDividerRow(cells[1] ?? []);
270 const header = hasHeader ? (cells[0] ?? []) : [];
271 const body = hasHeader ? cells.slice(2) : cells;
272
273 return formatGrid(header, body, width) ?? formatCards(header, body, width);
274}
275
276export function formatMarkdown(markdown: string, width: number): Line[] {
277 const lines: Line[] = [];
278 let isInCode = false;
279 let tableRows: string[] = [];
280 for (const source of markdown.replace(/\t/g, " ").split("\n")) {
281 if (!isInCode && TABLE_ROW.test(source)) {
282 tableRows.push(source);
283 continue;
284 }
285 if (tableRows.length > 0) {
286 lines.push(...formatTable(tableRows, width));
287 tableRows = [];
288 }
289 if (/^\s*```/.test(source)) {
290 isInCode = !isInCode;
291 continue;
292 }
293 if (isInCode) {
294 lines.push(...hardWrap(source, width, "code", " "));
295 continue;
296 }
297 if (source.trim() === "") {
298 lines.push([]);
299 continue;
300 }
301 const heading = /^#{1,6}\s+(.*)$/.exec(source);
302 if (heading) {
303 lines.push(...wrap(parseInline(heading[1] ?? "", "bold"), width, "", ""));
304 continue;
305 }
306 const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(source);
307 if (item) {
308 const prefix = `${item[1] ?? ""}${item[2] ?? ""} `;
309 const indent = " ".repeat(textWidth(prefix));
310 lines.push(
311 ...wrap(parseInline(item[3] ?? "", "plain"), width, prefix, indent),
312 );
313 continue;
314 }
315 const leading = /^\s*/.exec(source)?.[0] ?? "";
316 lines.push(
317 ...wrap(parseInline(source.trim(), "plain"), width, leading, leading),
318 );
319 }
320 if (tableRows.length > 0) {
321 lines.push(...formatTable(tableRows, width));
322 }
323
324 return lines;
325}
326
327// Fixed-size pages. A page never starts with a blank line.
328export function paginate(lines: Line[], rows: number): Line[][] {
329 const pages: Line[][] = [];
330 let page: Line[] = [];
331 for (const line of lines) {
332 if (page.length === 0 && line.length === 0) {
333 continue;
334 }
335 page.push(line);
336 if (page.length === rows) {
337 pages.push(page);
338 page = [];
339 }
340 }
341 if (page.length > 0 || pages.length === 0) {
342 pages.push(page);
343 }
344
345 return pages;
346}
347
348const PROMPT_MAX_LINES = 4;
349
350// The prompt as a few dim lines above the reply, cut with "…" when long.
351export function formatPrompt(prompt: string, width: number): Line[] {
352 const text = prompt.replace(/\s+/g, " ").trim();
353 if (text === "") {
354 return [];
355 }
356 // Two columns stay free for the " …".
357 const lines = wrap([{ text, style: "dim" }], width - 2, "› ", " ").map(
358 (line) => line.map((run) => ({ ...run, style: "dim" as const })),
359 );
360 if (lines.length <= PROMPT_MAX_LINES) {
361 return lines;
362 }
363 const kept = lines.slice(0, PROMPT_MAX_LINES);
364 kept[PROMPT_MAX_LINES - 1]?.push({ text: " …", style: "dim" });
365
366 return kept;
367}
368
369// Each entry starts on a new page, so a reply always begins at the top.
370export function entryPages(
371 entry: BookEntry,
372 width: number,
373 rows: number,
374): Line[][] {
375 const prompt = formatPrompt(entry.prompt, width);
376 const reply = formatMarkdown(entry.reply, width);
377
378 return paginate(
379 prompt.length === 0 ? reply : [...prompt, [], ...reply],
380 rows,
381 );
382}
383types/index.d.ts 18 lines1export type BookEntry = { prompt: string; reply: string };
2export type BookPosition = { entry: number; page: number };
3// One session's book. `sessionId` says which session the copy in $.state belongs to.
4export type Book = {
5 sessionId: string;
6 entries: BookEntry[];
7 position: BookPosition;
8};
9
10declare module "claude-code" {
11 interface PluginState {
12 "book-pager": {
13 book: Book;
14 pendingPrompt: string;
15 };
16 }
17}
18