SLOPSHOPPER

book-pager

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

newpanecommandtoastprompt
v0.1.0no licenseupdated 2026-10-09kdaisho/claude-skills/book-pager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · book-pager
│ ┃ Pager ✕ › fix the failing auth test and add an audit log call │ ┃ p: Prev n: Next Page 1 / 1 (empty prompt │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ › fix the failing auth test and add an audit ⏺ Update(src/auth.ts) │ ┃ log call ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Done. I made refresh reject expired claims, ⎿ 3 pass, 1 fail │ ┃ added an │ ┃ audit call, and created src/audit.ts. One ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ test still │ ┃ fails (revokes on logout), which looks ✻ Worked for 42s · done 4:20 PM │ ┃ unrelated to this │ ┃ change. › /pager │ ⎿ book-pager: Pager pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pager
p: Prev n: Next Page 1 / 1 (empty prompt: , and .) ──────────────────────────────────────────────────────── › fix the failing auth test and add an audit log call Done. I made refresh reject expired claims, added an audit call, and created src/audit.ts. One test still fails (revokes on logout), which looks unrelated to this change.
README

claude-skills

Reusable Claude Code skills, and one mod.

Skills

Copy (or symlink) a skill folder into a project's .claude/skills/ to use it.

  • implement
  • skeleton-ui

Mods

book-pager

Shows 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.
  • The text fills the pane, so drag the pane's edge to change the page width. A table is drawn with borders when it fits and as one card per row when it does not.
  • The pane opens at the 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.

Source 3 files
hooks/register.tsx 309 lines
1import { 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};
309
hooks/pages.ts 383 lines
1import 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}
383
types/index.d.ts 18 lines
1export 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