SLOPSHOPPER

novel-pane

A collapsible pane that reads a book in the session: one screenful at a time with the controls as its header, so the wheel cannot scroll them away, and the…

newpanecommandstatus
v0.8.1no licenseupdated 2026-10-16zhuobichen/zhaowen-claudemod/novel-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · novel-pane
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /novel ⎿ novel-pane: `/novel <文件路径>` 打开一本书 · 打开后提示框下面那行说明按钮 · `/novel n` 下一页 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

novel-pane

在当前会话里开一个可折叠的窗格读书。/novel <文件> 打开,/novel 再敲一次收起。

按钮画在窗格的第一行,钉在那儿不动;正文在它下面滚;进度也钉在提示框下面那一行,看别处也看得到:

❯ 提示框
  卷轴 7800/20700 · 滚轮往下滚             ← 提示框下面那一行(引擎给插件钉字的地方)

┌ 试验小说.txt ──────────────────┐
│ [⇄ 卷轴] [↑] [↓] [⌂] [✕] 7800/20700 │  ← 窗格第一行:滚轮滚不动它
│ 第一章 夜航                        │
│                                    │
│ 船在雾里走,灯是唯一的方向。        │
│ …                                  │
└────────────────────────────────────┘

装

/plugin install novel-pane --marketplace zhuobichen/zhaowen-claudemod

用

两条路,任选:窗格(好看、不吃对话上下文,但要焦点或鼠标)与命令(永远能用,但每页会写进对话)。

命令这条路(不需要焦点,最稳)

在提示框里直接打——按 ↑ 回车就是重复上一条,也就是"下一页键":

打什么做什么
/novel <文件>打开一本(相对路径按当前工作目录算)。打开不说话,只在失败时回一句
/novel n下一页,并把这一页打在对话里(↑⏎ 重复)
/novel p上一页
/novel 12跳到第 12 页
/novel read把当前页打出来,不动位置
/novel窗格没开就打开上次那本(也不说话);已开则收起

/novel n 在新会话里也管用:它会把上次读的那本重新打开再翻页。

窗格这条路(不吃对话上下文)

窗格第一行一排按钮,直接点就行——不需要任何键盘焦点:

[⇄ 卷轴] [↑] [↓] [⌂] [✕]  7800/20700
做什么怎么做
两种模式点 ⇄ 切换:卷轴(滚轮一行一行走)/整屏(滚轮一次一屏,靠 ↓ 也是)。按钮上的字就是当前模式,选的模式记在 store 里,下次还是它
滚动鼠标滚轮在窗格里滚,滚到书的结尾就停——没有人为的墙,也没有跳
进度怎么看按钮右边就是 7800/20700;提示框下面那一行($.ui.status 钉的)写得更全:卷轴 7800/20700 · 滚轮往下滚,整屏模式是 整屏 第 3/84 页 · 52% · 点 ↓ 翻屏
一屏一屏走点 ↓ / ↑(每次一屏 = 窗格里正文的行数,不含控件那一行)
从头开始点 ⌂
收起点 ✕,或按 Esc(提示框下面那行也跟着消失)
跳页提示框里 /novel 12 跳到第 12 页(/novel n 下一页、/novel p 上一页、/novel read 原地看看)

✓ 控件怎么才不跟着滚(0.8.0 才做对):窗格那扇窗是跟着内容滚的,画在里面的东西一律跟着走——0.5.0 就是这么丢掉控件的。真正的解法是引擎文档里那句话:"在自己画的行上面加一行表头的 hook,用 e.by 自己移行"。也就是:

  • 窗格一次只画一屏(表头一行 + 正文 行数−1 行),树的永远只有窗口那么高,所以那扇窗根本没得滚——控件不可能被滚走;
  • 滚轮事件来了,mod 不交给引擎移窗(ui.scroll 里返回 {},不是 next(e)),而是自己把读到哪加 e.by 行再重画。

顺带一个好处:一次画 20 行而不是 1500 行,滚起来更省。

(e.by 缺了的时候用 e.offset 顶——插件自己调 $.ui.scroll 触发的那种事件只带目标位置。两条都没有时原地不动:把"读到哪"写进 NaN 会让窗格画成空白加一个 NaN/60,比不动糟得多。有一条测试盯着这个。)

⚠️ 提示框上面那一行再也不要碰(0.6.0–0.7.0 的教训):那一行是所有 mod 共用的一笔预算(引擎给 maxRows 行),而且插件的树是套在一起的:后加载的插件在最内层,它的行就在最上面,而那一行的窗口留着靠提示框的几行。结果就是这个 mod 的行永远第一个被挤出去——不管它多短、跟别人排成一行也没用(别人在它的外层,它压根看不到别人)。所以 0.8.0 起这个 mod 一个字节都不往那行画,余额、月相那些行照旧。

进度为什么在提示框下面:那是引擎给插件钉字的另一处($.ui.status,每个插件一条,钉在引擎自己的通知旁边),不在上面那笔预算里,怎么变都不会挤到别人。

位置的写盘是攒着的:滚轮每动一格都写一次 store 太浪费,所以位置漂过 25 行才写一次;点按钮、跳页、收起时一定会写。

读到哪儿是精确记的:把 e.by 加到"读到哪"的那个行号就是,写回插件自己的 store——下次 /novel 从那一行接着往下。

按钮上没有数字快捷键(故意的):提示框上面那一行的按钮支持「空提示框里按一个数字就触发」,但书开着的时候你就没法用 1~5 开头写一句话了(会被吃掉)。这个 mod 只给鼠标点,键盘走 /novel n。

(Esc 之外没有键盘快捷键:键盘要先进窗格才有,而给窗格抢焦点这件事本身就不稳——所以按钮和滚轮才是主路。)

书单:在窗格里点着换书(0.8.1)

书单是一个目录里的文件,不是一个写死的书。它不进控件行——/novel shelf 进,进去以后用鼠标点书名换:

做什么怎么做
把书单摆进窗格/novel shelf(或 /novel list 只在对话里打一行)
换一本看点书名(每本一行按钮;带 • 的是你读过、有进度的)
换目录/novel shelf <目录>(记住,下次还是它);/novel list <目录> 也一样记
回正文点书名;或 /novel shelf 再敲一次(背后有书就退回那本)

书单看哪个目录:你指定过的那个;没指定过就是当前这本书所在的目录;连书都没开过就是上一次那本所在的目录。

只列能读的:.txt .md .markdown .text .log——目录、图片、压缩包不列(点开只会是一屏乱码)。书名按拼音排序(甲 在 乙 前),不是码点序。

⚠️ 为什么书单不做成控件行上的一个按钮(0.9.0 的教训):控件行画在窗格里面,窗格由引擎停靠在对话右边、宽度它定($.ui.open 的 columns 是停靠宽度、rows 是内联高度)——比窗格宽的行会被裁,被裁的按钮就点不着。0.9.0 往那一行加了个按钮(还带双宽 emoji),整行从 35 格涨到约 43 格,随后窗格就不再好好停在右侧了。0.8.1 把那一行原样退回,书单改用命令进、列表里点。列表里的长文件名也会按窗格宽度裁(按钮的宽度就是它标签的宽度,不裁会把整棵树撑宽)。

两条路的取舍

  • 命令式每打印一页,这一页就进了对话上下文(也会计入费用)——看两眼很顺手,长时间通读会堆。
  • 窗格里的文字是画上去的,不进对话——通读用它,配鼠标点击最省事。

几件要知道的

  • 单文件上限 4 MiB:书是用 $.fs.read 读的,超过 4 MiB 会被引擎拒绝(那时会提示"读不到这个文件",不会开一个空窗格)。
  • Markdown 原样显示:ebook-library/books 里那些 .md 技术书能读,但 #、代码块这些标记会照原样出现在窗格里,不做渲染。
  • 中文按两个格子折行:宽度是按终端单元格算的(汉字 2、拉丁字符 1),所以不会每行溢出。
  • 书不在窗格里:Client 的 props 上限 10 万字符,一本书塞不进去——文本留在 hooks 模块,每次只把当前这一页递给窗格。

文件

.claude-plugin/plugin.json  名字、版本、类型契约的位置
hooks/hooks.json            模块清单
hooks/register.tsx          /novel、读文件、记住位置、画窗格(表头+一屏正文)、滚轮自己移行、钉进度那行
hooks/paging.ts             折行、分页、一屏取多少行、按键、进度、按钮与钉的那行(纯函数,测试盯的就是它)
types/index.d.ts            $.state / Page 的类型契约
tests/paging.test.ts        31 条:宽度、折行、分页、每个键、边界、一屏几行、两种模式、钉的那行说什么
tests/pane.test.tsx         24 条:真的打开并画出来、画出来的**正好一屏高**(高一行那扇窗就有得滚了)、
                            被要求移窗时移的是正文而不是窗(控件留在一屏里)、
                            没带距离的移动不会把"读到哪"变成 NaN(空窗格 + NaN/60)、
                            点按钮翻页/回开头/收起(整条链)、打开不说话、
                            提示框上面那一行一个字节都不画、读到哪按行记住、读不到会说、不给参数会说怎么用

⚠️ 窗格里不能有任何 import——连 import type 都不行。实测:带类型导入时窗格里什么都不画(元素在树上、内容为空、哪里都不报错),把类型内联掉、去掉 import 就正常。hooks 模块(register.tsx)不受这条限制。0.3.0 起这个 mod 不再用 Client 了(按键要焦点,不稳),这条踩过的坑留着给下一次用 Client 的时候。

Source 3 files
hooks/register.tsx 668 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  bookFilesOf,
6  BUTTONS,
7  clipTo,
8  isStepOf,
9  labelOf,
10  modeOf,
11  positionOf,
12  printedOf,
13  shelfLabelOf,
14  shelfPrintOf,
15  shelfStatusOf,
16  shelfTopOf,
17  shelfWindowOf,
18  textRowsOf,
19  statusOf,
20  steppedBy,
21  windowOf,
22  wrapText,
23} from './paging'
24import type { Mode } from './paging'
25
26// novel-pane: a pane that reads a book in the session, and a command that turns
27// its pages without one.
28//
29// The pane draws the text and its buttons straight into its own tree. Nothing
30// here needs keyboard focus: a click on a button raises `ui.press`, and the
31// pane's own window — which the engine moves with the wheel — is the scroll. The
32// place is kept by line, so `offset` into the drawn chunk is where the reader
33// actually is, and closing or quitting resumes there.
34
35const PANE_ID = 'novel'
36const PLACES_KEY = 'places'
37const LAST_KEY = 'lastPath'
38const MODE_KEY = 'mode'
39const SHELF_KEY = 'shelfDir'
40
41const DEFAULT_ROWS = 20
42const DEFAULT_COLUMNS = 80
43
44const isOpen = atom({ plugin: 'novel-pane', key: 'isOpen' } as const, false)
45
46/** Runs `work`, and answers null when it throws. */
47const quietly = async <T,>(work: () => Promise<T>): Promise<T | null> => {
48  try {
49    return await work()
50  } catch {
51    return null
52  }
53}
54
55/**
56 * The open book: the text as read, the wrapped lines, where the drawn chunk
57 * starts, and the line the reader has actually scrolled to — the last of these
58 * is what gets remembered.
59 */
60let book: { path: string; text: string; columns: number; lines: string[]; line: number; seen: number } | null = null
61
62/** The rows the pane last reported, for the buttons and the command path. */
63let lastRows = DEFAULT_ROWS
64
65/** How the pane reads: the long scroll, or a screen at a time. Kept in the store. */
66let mode: Mode = 'scroll'
67
68/**
69 * The list of books, when it is up instead of a book: the directory it comes
70 * from, the names worth offering, where the list is scrolled to, and which of
71 * them the reader has a place in. `null` means the pane is reading.
72 */
73let shelf: { dir: string; names: string[]; at: number; hasPlace: Set<string> } | null = null
74
75const rowsOf = (props: unknown): number => {
76  const withScroll = props as { scroll?: { bodyRows?: number }; bodyRows?: number } | undefined
77
78  return Math.max(1, Math.floor(withScroll?.scroll?.bodyRows ?? withScroll?.bodyRows ?? lastRows))
79}
80
81const columnsOf = (props: unknown): number => {
82  const columns = (props as { bodyColumns?: number } | undefined)?.bodyColumns
83
84  return Math.max(8, Math.floor(columns ?? DEFAULT_COLUMNS))
85}
86
87const titleOf = (path: string): string => path.replace(/\\/g, '/').split('/').pop() ?? path
88
89/** A path as a person types it: the quotes a copied path comes wrapped in, off. */
90const cleanPath = (raw: string): string => raw.trim().replace(/^["']|["']$/g, '').trim()
91
92/**
93 * The same path with a stray command word off the end — `/novel <path>/novel`
94 * happens when tab-completion or the history puts the command in twice. Only
95 * ever tried when the path as given could not be read, so a real file whose name
96 * ends in `novel` is never second-guessed.
97 */
98const withoutCommandTail = (path: string): string | null => {
99  const trimmed = path.replace(/[/\\]+$/, '')
100  const cut = trimmed.replace(/[/\\]novel$/i, '')
101
102  return cut === trimmed || cut === '' ? null : cut
103}
104
105/** What came of reading the path: the text, or which of the two ways it failed. */
106type ReadOutcome =
107  | { ok: true; path: string; text: string }
108  | { ok: false; why: 'unreadable' | 'empty'; path: string }
109
110/**
111 * The file's text, from the path as given or from the path with its tail off.
112 * A file that reads as nothing — empty, or whitespace — is reported as exactly
113 * that: a blank pane is a worse answer than a sentence saying so.
114 */
115const readBook = async ($: EngineInterface, path: string): Promise<ReadOutcome> => {
116  const tried = async (at: string): Promise<string | null> => {
117    const text = await quietly(() => $.fs.read(at))
118
119    return text === null || text.trim() === '' ? null : text
120  }
121
122  const text = await tried(path)
123
124  if (text !== null) return { ok: true, path, text }
125
126  const shorter = withoutCommandTail(path)
127
128  if (shorter !== null) {
129    const retried = await tried(shorter)
130
131    if (retried !== null) return { ok: true, path: shorter, text: retried }
132  }
133
134  // told apart for the person reading the answer, not for the code
135  const empty = await quietly(() => $.fs.read(path))
136
137  return { ok: false, why: empty !== null ? 'empty' : 'unreadable', path }
138}
139
140const wrapped = (text: string, columns: number): string[] => wrapText(text, columns)
141
142/** Rewraps the open book when the pane's width changed, keeping the line place. */
143const fitTo = (columns: number): void => {
144  if (book === null || book.columns === columns) return
145
146  book = { ...book, columns, lines: wrapped(book.text, columns) }
147}
148
149const placesOf = async ($: EngineInterface): Promise<Record<string, number>> => {
150  const raw = await quietly(() => $.store.get(PLACES_KEY))
151
152  return raw !== null && typeof raw === 'object' ? (raw as Record<string, number>) : {}
153}
154
155/** Writes down where the reader is — the scrolled-to line, not the chunk's top. */
156const remember = async ($: EngineInterface): Promise<void> => {
157  if (book === null) return
158
159  const places = await placesOf($)
160  const at = book
161
162  await quietly(() => $.store.set(PLACES_KEY, { ...places, [at.path]: at.seen }))
163  await quietly(() => $.store.set(LAST_KEY, at.path))
164  persisted = at.seen
165}
166
167/** How far the place may drift before it is worth a write: a wheel tick is not. */
168const PLACE_WRITE_STEP = 25
169
170let persisted = -1
171
172/** Writes the place down, unless it has barely moved since the last write. */
173const persist = async ($: EngineInterface): Promise<void> => {
174  if (book === null) return
175  if (Math.abs(book.seen - persisted) < PLACE_WRITE_STEP) return
176
177  await remember($)
178}
179
180/** Repaints the pane after the place moved. */
181const repaint = async ($: EngineInterface): Promise<void> => {
182  await quietly(() => $.ui.invalidate('ui.render'))
183}
184
185/**
186 * Where a line number lands in the book, ends taken into account. Nothing but a
187 * real line ever comes of it: a move that arrives without a distance (`e.by`
188 * absent, an event raised by a plugin's own `scroll`) would otherwise be `NaN`,
189 * and a `NaN` place draws an empty pane under a `NaN/60` readout — a silent
190 * break, where the reader's place is worth more than the move.
191 */
192const clampLine = (line: number): number => {
193  if (book === null) return 0
194
195  const last = Math.max(0, book.lines.length - 1)
196
197  return Number.isFinite(line) ? Math.min(last, Math.max(0, Math.floor(line))) : book.seen
198}
199
200/**
201 * Pins this plugin's line under the prompt: how the pane reads and where the
202 * reader is. It does not go in the band above the prompt — those rows are a
203 * shared budget, and a second row of this plugin's own pushed the other mods out
204 * of it. `undefined` takes the line away again once the pane is closed, even
205 * though the book stays where it was.
206 */
207const pin = async ($: EngineInterface): Promise<void> => {
208  const open = await read($, isOpen)
209
210  let line: string | undefined
211
212  if (!open) line = undefined
213  else if (shelf !== null) line = shelfStatusOf(shelf.dir, shelf.at, shelf.names.length, pageRowsOf())
214  else if (book !== null) line = statusOf(book.seen, pageRowsOf(), book.lines.length, mode)
215
216  await quietly(() => $.ui.status(line))
217}
218
219// ── the shelf ────────────────────────────────────────────────────────────────
220//
221// Entered by command, switched by click. Nothing is added to the pane's control
222// row: that row is drawn inside the pane, the pane is docked at a width the
223// engine chose, and a row wider than the pane is clipped — a clipped button is
224// one nobody can click.
225
226/** The directory part of a path, whichever separator it came with. */
227const dirOf = (path: string): string => {
228  const cut = path.replace(/[/\\]+$/, '')
229  const at = Math.max(cut.lastIndexOf('/'), cut.lastIndexOf('\\'))
230
231  return at <= 0 ? cut : cut.slice(0, at)
232}
233
234/** A name inside a directory, keeping the separator that directory already uses. */
235const joinPath = (dir: string, name: string): string =>
236  dir.includes('/') && !dir.includes('\\')
237    ? `${dir.replace(/\/+$/, '')}/${name}`
238    : `${dir.replace(/[/\\]+$/, '')}\\${name}`
239
240/**
241 * Where the shelf looks: the directory set for it, else the one the open book
242 * lies in, else the one the last book lay in.
243 */
244const shelfDirOf = async ($: EngineInterface): Promise<string> => {
245  const set = await quietly(() => $.store.get(SHELF_KEY))
246
247  if (typeof set === 'string' && set !== '') return set
248  if (book !== null) return dirOf(book.path)
249
250  const last = await quietly(() => $.store.get(LAST_KEY))
251
252  return typeof last === 'string' ? dirOf(last) : ''
253}
254
255/**
256 * Puts the list up in place of the reading. A directory that cannot be listed is
257 * not worth a sentence in the conversation: the list comes up empty and the
258 * pinned line says which directory it looked in.
259 */
260const openShelf = async ($: EngineInterface, asked?: string): Promise<void> => {
261  const dir = asked !== undefined && asked !== '' ? asked : await shelfDirOf($)
262  const places = await placesOf($)
263  const listed = dir === '' ? [] : await quietly(() => $.fs.list(dir))
264  const names = bookFilesOf(listed ?? [])
265
266  shelf = {
267    dir,
268    names,
269    at: 0,
270    hasPlace: new Set(names.filter(name => places[joinPath(dir, name)] !== undefined)),
271  }
272
273  if (asked !== undefined && asked !== '') await quietly(() => $.store.set(SHELF_KEY, asked))
274}
275
276/** Puts the reading back, leaving the open book where it was. */
277const closeShelf = (): void => {
278  shelf = null
279}
280
281/** Opens the pane on the list rather than on a book. */
282const openShelfInPane = async ($: EngineInterface): Promise<string | undefined> => {
283  await update($, isOpen, () => true)
284
285  const opened = await quietly(() => $.ui.open({ id: PANE_ID, title: '书单', closeOnEscape: true }))
286
287  await pin($)
288  await repaint($)
289
290  if (opened === null) return '打开了书单,但引擎没接受这次开窗(窗格不会出现)'
291
292  return undefined
293}
294
295/** Fills the list, puts it up, and answers the one sentence when it could not be. */
296const openShelfOn = async ($: EngineInterface, dir?: string): Promise<string | undefined> => {
297  await openShelf($, dir)
298
299  return openShelfInPane($)
300}
301
302/**
303 * A page of reading: the rows of text the pane shows under its controls. One
304 * fewer than the pane, because the controls take one — a `↓` that moved by the
305 * whole pane would skip a line on every page.
306 */
307const pageRowsOf = (): number => textRowsOf(lastRows)
308
309/**
310 * Opens the pane on a book, reading the file when it is not the open one, and
311 * answers what the conversation should be told: nothing at all when it worked —
312 * the pane and the pinned line already say where the reader is, and a book
313 * opened is not worth a line in the transcript — or the one sentence that says
314 * why it did not.
315 */
316const openOn = async (
317  $: EngineInterface,
318  asked: string,
319  columns: number,
320): Promise<string | undefined> => {
321  let path = asked
322
323  if (book === null || book.path !== path) {
324    const outcome = await readBook($, path)
325
326    if (!outcome.ok) {
327      return outcome.why === 'empty' ? `这个文件是空的:${asked}` : `读不到这个文件:${asked}`
328    }
329
330    path = outcome.path
331
332    const places = await placesOf($)
333    const lines = wrapped(outcome.text, columns)
334    const seen = Math.min(Math.max(0, places[path] ?? 0), Math.max(0, lines.length - 1))
335
336    mode = modeOf(await quietly(() => $.store.get(MODE_KEY)))
337    book = { path, text: outcome.text, columns, lines, line: seen, seen }
338  }
339
340  // a book opened from the list takes the pane back from the list
341  closeShelf()
342  await update($, isOpen, () => true)
343
344  const opened = await quietly(() =>
345    $.ui.open({ id: PANE_ID, title: titleOf(path), closeOnEscape: true }),
346  )
347
348  await remember($)
349  await pin($)
350  await repaint($)
351
352  if (opened === null) return `打开了 ${titleOf(path)},但引擎没接受这次开窗(窗格不会出现)`
353  if (!opened.isPlaced) return `打开了 ${titleOf(path)},但窗格没能摆出来:${opened.reason ?? '原因没说'}`
354
355  return undefined
356}
357
358const closePane = async ($: EngineInterface): Promise<void> => {
359  await remember($)
360  await update($, isOpen, () => false)
361  await quietly(() => $.ui.close({ id: PANE_ID }))
362  await pin($)
363}
364
365/** Moves the place by whole screens, the way the buttons do. */
366const moveBy = async ($: EngineInterface, lines: number): Promise<void> => {
367  if (book === null) return
368
369  const next = clampLine(book.seen + lines)
370
371  book = { ...book, line: next, seen: next }
372  await remember($)
373  await pin($)
374  await repaint($)
375}
376
377/**
378 * Runs one of the pane's buttons. Declared at the top of the file on purpose:
379 * the loader refuses a `$` handed to a function made inside a hook, so the
380 * Button's closure calls this rather than doing the work itself.
381 */
382async function actOn($: EngineInterface, key: string): Promise<void> {
383  // While the list is up the same controls work it: up, down and home walk the
384  // list, and a row's own button opens that book.
385  if (shelf !== null) {
386    if (key === 'close') {
387      await closePane($)
388
389      return
390    }
391
392    if (key === 'down') {
393      const rows = pageRowsOf()
394
395      shelf = { ...shelf, at: Math.min(shelfTopOf(shelf.names.length, rows), shelf.at + rows) }
396    } else if (key === 'up') {
397      shelf = { ...shelf, at: Math.max(0, shelf.at - pageRowsOf()) }
398    } else if (key === 'top') {
399      shelf = { ...shelf, at: 0 }
400    } else if (key.startsWith('open:')) {
401      const at = Number(key.slice('open:'.length))
402      const name = Number.isInteger(at) ? shelf.names[at] : undefined
403
404      if (name !== undefined) {
405        await openOn($, joinPath(shelf.dir, name), columnsOf(undefined))
406
407        return
408      }
409    }
410
411    await pin($)
412    await repaint($)
413
414    return
415  }
416
417  if (book === null) return
418
419  if (key === 'down') await moveBy($, pageRowsOf())
420  else if (key === 'up') await moveBy($, -pageRowsOf())
421  else if (key === 'top') await moveBy($, -book.seen)
422  else if (key === 'close') await closePane($)
423  else if (key === 'mode') {
424    mode = mode === 'scroll' ? 'page' : 'scroll'
425    await quietly(() => $.store.set(MODE_KEY, mode))
426    await pin($)
427    await repaint($)
428  }
429}
430
431export const register: Register = on => {
432  on('session.start', async ($, e, next) => {
433    const result = await next(e)
434
435    await quietly(() =>
436      $.command.register({
437        name: 'novel',
438        description:
439          'Read a book in a pane, or turn its pages by command: /novel <path>, /novel n, /novel 12 (novel-pane)',
440      }),
441    )
442
443    return result
444  })
445
446  on('command.run', { command: 'novel' }, async ($, e, next) => {
447    const asked = cleanPath(e.args ?? '')
448
449    // The shelf. It is entered by a command rather than a button on the pane's
450    // control row on purpose: that row is drawn inside the pane, the pane is
451    // docked at a width the engine chose, and a row wider than the pane is
452    // clipped — a clipped button is one nobody can click. The switching inside
453    // the list is the click.
454    if (asked === 'shelf' || asked.toLowerCase().startsWith('shelf ')) {
455      const dir = cleanPath(asked.slice('shelf'.length))
456      const problem = await openShelfOn($, dir === '' ? undefined : dir)
457
458      return problem === undefined ? {} : { text: problem }
459    }
460
461    // the same list as a line of the conversation, for when a pane is not what
462    // the person wants — or cannot be seen, as in a headless run
463    if (asked === 'list' || asked.toLowerCase().startsWith('list ')) {
464      const dir = cleanPath(asked.slice('list'.length))
465      const where = dir !== '' ? dir : await shelfDirOf($)
466
467      if (where === '') {
468        return { text: '还没有书单目录:`/novel list <目录>` 或 `/novel shelf <目录>` 先指定一个' }
469      }
470
471      const listed = await quietly(() => $.fs.list(where))
472
473      if (dir !== '') await quietly(() => $.store.set(SHELF_KEY, dir))
474
475      return { text: shelfPrintOf(where, bookFilesOf(listed ?? [])) }
476    }
477
478    // A word about the place, not a path: move there and print the page into the
479    // conversation, where no focus is needed and ↑⏎ asks for the next one. The
480    // pane, when it is open, follows.
481    if (isStepOf(asked)) {
482      if (book === null) {
483        const last = await quietly(() => $.store.get(LAST_KEY))
484        const remembered = typeof last === 'string' ? last : ''
485
486        if (remembered === '') return { text: '还没读过书:`/novel <文件路径>` 先打开一本' }
487
488        const reopened = await openOn($, remembered, columnsOf(undefined))
489
490        if (book === null) return { text: reopened ?? '读不到上次那本书' }
491      }
492
493      const at = book
494      const stepped = steppedBy(asked, at.seen, pageRowsOf(), at.lines.length)
495
496      if (stepped !== null) {
497        book = { ...at, line: stepped, seen: stepped }
498        await remember($)
499        await pin($)
500        await repaint($)
501      }
502
503      return { text: printedOf(book.lines, book.seen, pageRowsOf()) }
504    }
505
506    // opening, and closing, say nothing: the pane and the pinned line already
507    // show where the reader is, and a line per `/novel` is a line in the
508    // transcript every time. Only a failure is worth a sentence.
509    if (await read($, isOpen)) {
510      await closePane($)
511
512      return {}
513    }
514
515    const last = await quietly(() => $.store.get(LAST_KEY))
516    const path = asked !== '' ? asked : typeof last === 'string' ? last : ''
517
518    if (path === '') {
519      return { text: '`/novel <文件路径>` 打开一本书 · 打开后提示框下面那行说明按钮 · `/novel n` 下一页' }
520    }
521
522    return { text: await openOn($, path, columnsOf(undefined)) }
523  })
524
525  // A click on one of this pane's buttons, or its chord. No keyboard focus is
526  // involved, which is the whole reason the buttons exist.
527
528  // The wheel, as it turns. The engine asks before moving the site's window and
529  // this hook answers for itself — no `next`, so nothing moves — because the
530  // window must stay at the top for the controls to stay on screen. The reading
531  // moves instead: the place goes by the rows the wheel asked for, and the pane
532  // is redrawn one window further on.
533  //
534  // (The engine's own words for this: a hook drawing its own rows under a header
535  // moves them by `e.by` and invalidates.)
536  on('ui.scroll', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
537    if (book === null) return next(e)
538
539    // how far the move asks for. `by` is the engine's for the person's wheel;
540    // an event raised by a plugin's own `$.ui.scroll` carries the target offset
541    // instead, and its window stands at the top (this pane draws one windowful),
542    // so the offset is the distance
543    const step = Number.isFinite(e.by) ? e.by : Number.isFinite(e.offset) ? e.offset : 0
544
545    // the wheel over the list walks the list
546    if (shelf !== null) {
547      const rows = pageRowsOf()
548      const by = mode === 'page' ? Math.sign(step) * rows : step
549      const at = Number.isFinite(by)
550        ? Math.min(shelfTopOf(shelf.names.length, rows), Math.max(0, shelf.at + by))
551        : shelf.at
552
553      if (at !== shelf.at) {
554        shelf = { ...shelf, at }
555        await pin($)
556        await repaint($)
557      }
558
559      return {}
560    }
561
562    if (book === null) return next(e)
563
564    // in the whole-screen mode a wheel tick turns the page it is over, rather
565    // than creeping a line at a time
566    const asked = mode === 'page' ? Math.sign(step) * pageRowsOf() : step
567    const seen = clampLine(book.seen + asked)
568
569    if (seen === book.seen) return {}
570
571    book = { ...book, line: seen, seen }
572    await persist($)
573    await pin($)
574    await repaint($)
575
576    return {}
577  })
578
579  // The pane's body: the controls, then as much text as the pane has room for.
580  // Exactly the window's height, so the engine has nowhere to scroll and the
581  // header cannot be scrolled away from under the reader.
582  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
583    if (book === null && shelf === null) return next(e)
584
585    lastRows = rowsOf(e.props)
586
587    const { Box, Button, Text } = $.ui.resolve(e)
588    const rows = pageRowsOf()
589
590    // The tree is exactly one windowful, so the engine's window has nowhere to
591    // move and this is 0. Should it ever be moved from outside (the pane is
592    // focused and a key reaches it), the row it stands on is the reader's place.
593    // The list does not read that offset: its own place is `shelf.at`.
594    if (shelf === null) {
595      fitTo(columnsOf(e.props))
596
597      const offset = (e.props as { scroll?: { offset?: number } } | undefined)?.scroll?.offset ?? 0
598
599      if (Number.isFinite(offset) && offset > 0 && book !== null) {
600        book = { ...book, seen: clampLine(book.seen + offset) }
601      }
602    }
603
604    const control = (
605      <Box flexDirection="row" flexShrink={0}>
606        {BUTTONS.map(button => (
607          <Button
608            key={`novel-${button.key}`}
609            label={labelOf(button, mode)}
610            onPress={() => {
611              void actOn($, button.key)
612            }}
613          />
614        ))}
615        <Text dimColor wrap="truncate-end">
616          {' '}
617          {shelf !== null
618            ? `${shelf.names.length} 本`
619            : book === null
620              ? ''
621              : positionOf(book.seen, rows, book.lines.length, mode)}
622        </Text>
623      </Box>
624    )
625
626    if (shelf !== null) {
627      const showing = shelfWindowOf(shelf.names, shelf.at, rows)
628      // a button is as wide as its label, so a long name would widen the whole
629      // tree: clipped to the pane, it cannot
630      const room = Math.max(8, columnsOf(e.props) - 4)
631
632      return (
633        <Box flexDirection="column" width="100%">
634          {control}
635          {shelf.names.length === 0 ? (
636            <Text dimColor wrap="truncate-end">
637              (这个目录里没有能读的文件:{shelf.dir})
638            </Text>
639          ) : (
640            showing.map((name, index) => (
641              <Button
642                key={`novel-book-${shelf.at + index}`}
643                label={clipTo(shelfLabelOf(name, shelf.hasPlace.has(name)), room)}
644                onPress={() => {
645                  void actOn($, `open:${(shelf as NonNullable<typeof shelf>).at + index}`)
646                }}
647              />
648            ))
649          )}
650        </Box>
651      )
652    }
653
654    const drawn = windowOf((book as NonNullable<typeof book>).lines, (book as NonNullable<typeof book>).seen, lastRows)
655
656    return (
657      <Box flexDirection="column" width="100%">
658        {control}
659        {drawn.map((line, index) => (
660          <Text key={`line-${index}`} wrap="truncate-end">
661            {line}
662          </Text>
663        ))}
664      </Box>
665    )
666  })
667}
668
hooks/paging.ts 355 lines
1// The reading itself — wrapping, paging, keys, the place — with no engine calls
2// in them, so a test can hold them to account without a session.
3
4import type { Page } from '../types'
5
6/** How many rows a page holds, never less than one. */
7const rowsOf = (rows: number): number => Math.max(1, Math.floor(rows))
8
9/** One line of a page, and how wide it draws. */
10const WIDE = [
11  [0x1100, 0x115f],
12  [0x2e80, 0xa4cf],
13  [0xac00, 0xd7a3],
14  [0xf900, 0xfaff],
15  [0xfe30, 0xfe6f],
16  [0xff00, 0xff60],
17  [0xffe0, 0xffe6],
18  [0x1f300, 0x1f64f],
19  [0x1f900, 0x1f9ff],
20  [0x20000, 0x3fffd],
21]
22
23/**
24 * How many cells a character takes. A Chinese character is two cells in a
25 * terminal, so wrapping by code point would overflow every other line — the one
26 * thing a reader notices first.
27 */
28export const cellsOf = (character: string): number => {
29  const point = character.codePointAt(0) ?? 0
30
31  return WIDE.some(([from, to]) => point >= from && point <= to) ? 2 : 1
32}
33
34/** How many cells a string takes. */
35export const widthOf = (text: string): number =>
36  [...text].reduce((cells, character) => cells + cellsOf(character), 0)
37
38/**
39 * The text as lines that fit `columns`: paragraphs kept, blank lines kept (a
40 * novel's paragraphing is half its rhythm), nothing invented to fill a row.
41 */
42export const wrapText = (text: string, columns: number): string[] => {
43  // the width it is given, not a comfortable one: a caller that passes fewer
44  // columns than fit gets lines that overflow the pane instead of a silent fix
45  const width = Math.max(1, Math.floor(columns))
46  const lines: string[] = []
47
48  for (const paragraph of text.replace(/\r\n?/g, '\n').split('\n')) {
49    if (paragraph.trim() === '') {
50      lines.push('')
51
52      continue
53    }
54
55    let line = ''
56    let used = 0
57
58    for (const character of paragraph) {
59      const cells = cellsOf(character)
60
61      if (used + cells > width && line !== '') {
62        lines.push(line)
63        line = ''
64        used = 0
65      }
66
67      line += character
68      used += cells
69    }
70
71    lines.push(line)
72  }
73
74  return lines
75}
76
77/** The lines of one page, `rows` of them, from `line`. */
78export const pageOf = (lines: string[], line: number, rows: number): string[] =>
79  lines.slice(line, line + Math.max(1, Math.floor(rows)))
80
81/** The first line of the last page: past it the pane would scroll into blank. */
82export const lastLineOf = (total: number, rows: number): number =>
83  Math.max(0, total - Math.max(1, Math.floor(rows)))
84
85/** Where the page sits, in pages and percent. */
86export const progressOf = (line: number, rows: number, total: number): string => {
87  const perPage = Math.max(1, Math.floor(rows))
88  const pages = Math.max(1, Math.ceil(total / perPage))
89  const page = Math.min(pages, Math.floor(line / perPage) + 1)
90  const percent = total === 0 ? 100 : Math.min(100, Math.round(((line + perPage) / total) * 100))
91
92  return `第 ${page}/${pages} 页 · ${percent}%`
93}
94
95/**
96 * The row of the pane the controls take, before the text. The body is drawn as
97 * the header and then exactly as much text as the pane has room for, so the tree
98 * is no taller than the window over it: the engine's window never has anywhere
99 * to move, the wheel is answered by the plugin moving its own rows, and the
100 * controls cannot scroll out of sight the way they did when the text was drawn
101 * as one long chunk.
102 */
103export const HEADER_ROWS = 1
104
105/** The rows of text a pane of `rows` rows has room for, under the header. */
106export const textRowsOf = (rows: number): number => Math.max(1, rowsOf(rows) - HEADER_ROWS)
107
108/** The text lines a pane shows from `line`: one windowful, no more. */
109export const windowOf = (lines: string[], line: number, rows: number): string[] =>
110  pageOf(lines, line, textRowsOf(rows))
111
112/**
113 * How far into the book the reader is, in the unit a scroll thinks in. The line
114 * is the one under the top of the window, so it is where they are, not how far
115 * the chunk they were given reaches.
116 */
117export const readoutOf = (seen: number, total: number): string => {
118  if (total <= 0) return '读到 100%'
119
120  const line = Math.min(Math.max(0, seen) + 1, total)
121  const percent = Math.min(100, Math.round((line / total) * 100))
122
123  return `读到 ${percent}% 第 ${line}/${total} 行`
124}
125
126/** How the pane reads: a scroll that keeps going, or one screen at a time. */
127export type Mode = 'scroll' | 'page'
128
129export const MODES: readonly Mode[] = ['scroll', 'page']
130
131export const modeOf = (raw: unknown): Mode => (raw === 'page' ? 'page' : 'scroll')
132
133/** What the mode is called, in the button that switches it and in the pinned line. */
134export const modeNameOf = (mode: Mode): string => (mode === 'scroll' ? '卷轴' : '整屏')
135
136/**
137 * What the pane's buttons carry as keys and what each says. The labels are as
138 * short as they can be because these are drawn in the band above the prompt,
139 * whose rows are shared with every other plugin drawing there — a wide row wraps
140 * into a second one and pushes theirs out of the band's window.
141 *
142 * There are no digits on them, though the band would take them: a digit on a
143 * band Button presses from an empty composer, so while a book was open the
144 * person could not begin a line with `1` without turning a page instead.
145 */
146export const BUTTONS = [
147  { key: 'mode', label: '⇄ 卷轴' },
148  { key: 'up', label: '↑' },
149  { key: 'down', label: '↓' },
150  { key: 'top', label: '⌂' },
151  { key: 'close', label: '✕' },
152] as const
153
154/** What each button's label is, the mode button naming the mode it is in. */
155export const labelOf = (button: (typeof BUTTONS)[number], mode: Mode): string =>
156  button.key === 'mode' ? `⇄ ${modeNameOf(mode)}` : button.label
157
158/** Where the reader is, as short as it can be said. */
159export const positionOf = (seen: number, rows: number, total: number, mode: Mode): string => {
160  const lines = Math.max(1, total)
161
162  return mode === 'scroll' ? `${Math.min(Math.max(0, seen) + 1, lines)}/${lines}` : progressOf(seen, rows, total)
163}
164
165/**
166 * The line this plugin pins under the prompt (`$.ui.status`): how the pane
167 * reads, and where the reader is.
168 *
169 * It is pinned rather than drawn in the band because the band's rows are a
170 * budget (its `maxRows`, shared with every other plugin drawing there): a second
171 * row of this plugin's own is what squeezed the other mods out once already. The
172 * status line is the engine's own place for a plugin's line, beside its notices.
173 */
174export const statusOf = (seen: number, rows: number, total: number, mode: Mode): string =>
175  `${modeNameOf(mode)} ${positionOf(seen, rows, total, mode)} · ${mode === 'scroll' ? '滚轮往下滚' : '点 ↓ 翻屏'}`
176
177/** How far a screen is, for a button that moves by one. */
178export const screenOf = (rows: number): number => Math.max(1, Math.floor(rows))
179
180/** What a key does to the place. */
181export type Move = { line: number } | { close: true } | null
182
183/**
184 * The keys this reader answers. Space and the page keys turn a page, j/k and the
185 * arrows move a line, g/G go to an end, q closes.
186 */
187export const moveOf = (key: string, line: number, rows: number, total: number): Move => {
188  const perPage = Math.max(1, Math.floor(rows))
189  const last = lastLineOf(total, rows)
190  const land = (next: number): Move => ({ line: Math.min(last, Math.max(0, next)) })
191
192  switch (key) {
193    case ' ':
194    case 'pagedown':
195    case 'f':
196      return land(line + perPage)
197    case 'b':
198    case 'pageup':
199      return land(line - perPage)
200    case 'j':
201    case 'down':
202      return land(line + 1)
203    case 'k':
204    case 'up':
205      return land(line - 1)
206    case 'd':
207      return land(line + Math.floor(perPage / 2))
208    case 'u':
209      return land(line - Math.floor(perPage / 2))
210    case 'g':
211    case 'home':
212      return land(0)
213    case 'G':
214    case 'end':
215      return land(last)
216    case 'q':
217      return { close: true }
218    default:
219      return null
220  }
221}
222
223/**
224 * The key a click in the pane stands for, by the row it landed on: the lower
225 * half turns forward, the upper half back — the way tapping a phone page does.
226 * One key, so a click and a keystroke cannot drift apart.
227 */
228export const clickKeyOf = (y: number, rows: number): string => (y >= rowsOf(rows) / 2 ? ' ' : 'b')
229
230export const KEYS = '点下半屏/空格 下一页 · 点上半屏/b 上一页 · j/k 逐行 · q 收起'
231
232/** The page the pane draws. */
233export const pageFor = (lines: string[], line: number, rows: number): Page => ({
234  lines: pageOf(lines, line, rows),
235  rows: rowsOf(rows),
236  progress: progressOf(line, rows, lines.length),
237  keys: KEYS,
238})
239
240/** What `/novel n` answers: how to ask for the next one without the pane's keys. */
241export const PRINT_HINT = '↑ 回车 = 下一页 · `/novel n` 下一页 · `/novel p` 上一页 · `/novel 12` 跳到第 12 页'
242
243/** The page as lines a command can print into the conversation. */
244export const printedOf = (lines: string[], line: number, rows: number): string => {
245  const page = pageFor(lines, line, rows)
246
247  return [...page.lines, '', `${page.progress} ${PRINT_HINT}`].join('\n')
248}
249
250/**
251 * Whether the word is about the place rather than a file. Told apart before
252 * anything is opened, because `/novel n` in a session that has not opened the
253 * book yet must reopen it — not treat `n` as a path.
254 */
255export const isStepOf = (word: string): boolean => /^(n|next|p|prev|read|\d+)$/i.test(word.trim())
256
257/**
258 * Where the place goes for a word typed after `/novel`: a page forward or back,
259 * a page number, or `read` for where it already is. `null` when the word is not
260 * about the place at all — a path, say.
261 */
262export const steppedBy = (word: string, line: number, rows: number, total: number): number | null => {
263  const asked = word.trim().toLowerCase()
264  const land = (next: number): number => Math.min(lastLineOf(total, rows), Math.max(0, next))
265
266  if (asked === 'read') return line
267  if (/^\d+$/.test(asked)) return land((Number(asked) - 1) * rowsOf(rows))
268  if (asked === 'n' || asked === 'next') return land(line + rowsOf(rows))
269  if (asked === 'p' || asked === 'prev') return land(line - rowsOf(rows))
270
271  return null
272}
273
274// The shelf: the books in a directory, as a list to read from and click in.
275//
276// Nothing here touches the control row. That row is drawn inside the pane, the
277// pane is docked beside the transcript at a width the engine chose, and a row
278// wider than the pane is clipped — a clipped button is one nobody can click. The
279// shelf button that used to sit in it was taken back out for that reason: the
280// list is entered by `/novel shelf` instead, and the switching itself is the click.
281
282/** What a listed directory offers as a book: the kinds this reader can show. */
283const BOOK_SUFFIXES = ['.txt', '.md', '.markdown', '.text', '.log']
284
285export const bookFilesOf = (entries: readonly { name: string; kind: string }[]): string[] =>
286  entries
287    .filter(
288      entry =>
289        entry.kind === 'file' &&
290        BOOK_SUFFIXES.some(suffix => entry.name.toLowerCase().endsWith(suffix)),
291    )
292    .map(entry => entry.name)
293    .sort((left, right) => left.localeCompare(right))
294
295/** Where a list of books may start, so its last page stays full. */
296export const shelfTopOf = (count: number, rows: number): number =>
297  Math.max(0, count - Math.max(1, Math.floor(rows)))
298
299/** The books one page of the list shows, from `at`. */
300export const shelfWindowOf = (names: readonly string[], at: number, rows: number): string[] =>
301  names.slice(Math.max(0, at), Math.max(0, at) + Math.max(1, Math.floor(rows)))
302
303/** One book as a row: a dot when there is a place in it to come back to. */
304export const shelfLabelOf = (name: string, hasPlace: boolean): string => (hasPlace ? `• ${name}` : name)
305
306/**
307 * The text cut to a budget of cells, with an ellipsis where it had to cut.
308 *
309 * A Button does not truncate itself the way a `Text` with `truncate-end` does:
310 * its label is its width. One long name would widen the pane's whole tree, and a
311 * tree wider than the dock is clipped.
312 */
313export const clipTo = (text: string, budget: number): string => {
314  if (widthOf(text) <= budget) return text
315
316  const room = Math.max(0, Math.floor(budget) - 1)
317  let kept = ''
318  let used = 0
319
320  for (const character of [...text]) {
321    const gap = cellsOf(character)
322
323    if (used + gap > room) break
324
325    kept += character
326    used += gap
327  }
328
329  return `${kept}…`
330}
331
332/** The line under the prompt while the list is up. */
333export const shelfStatusOf = (dir: string, at: number, count: number, rows: number): string => {
334  const perPage = Math.max(1, Math.floor(rows))
335  const from = count === 0 ? 0 : Math.max(0, at) + 1
336  const to = Math.min(count, Math.max(0, at) + perPage)
337
338  return `书单 ${dir} · ${from}-${to}/${count} · 点书名打开`
339}
340
341/**
342 * The list as one line of the conversation, for `/novel list`: what the shelf
343 * holds without opening the pane, and the only way to see a real listing where a
344 * pane cannot be seen at all.
345 */
346export const shelfPrintOf = (dir: string, names: readonly string[], most = 20): string => {
347  if (names.length === 0) return `书单 ${dir}:没有能读的文件`
348
349  const shown = names.slice(0, Math.max(1, most))
350  const rest = names.length - shown.length
351  const tail = rest > 0 ? ` …(还有 ${rest} 本)` : ''
352
353  return `书单 ${dir}(${names.length} 本):${shown.join('、')}${tail}`
354}
355
types/index.d.ts 21 lines
1/** What the pane's client draws: one page, already wrapped by the hooks module. */
2export type Page = {
3  /** The lines of this page, one per row, already wrapped to the pane's width. */
4  lines: string[]
5  /** Rows to draw, so a short last page still puts the footer at the bottom. */
6  rows: number
7  /** Where the page sits: `第 12/78 页 · 43%`. */
8  progress: string
9  /** The keys, for the footer. */
10  keys: string
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'novel-pane': {
16      /** Whether the pane is open. The places and the last book live in `$.store`. */
17      isOpen: boolean
18    }
19  }
20}
21