The moon's phase and a line of poetry above the prompt: a little decoration for the window.

提示行上面一行月光:月相 + 月龄 + 一句古诗,整行压暗。纯装饰,/moon 可以收起来。
🌒 娥眉月 4.6天 · 「今人不见古时月,今月曾经照古人。」李白《把酒问月》
/plugin install moon-band --marketplace zhuobichen/zhaowen-claudemod
月相名:不是用"平均朔望月"糊出来的。实测:经典的平均公式拿 2026 年 10 月的四个相时刻去对,误差最大到 −0.72 天(近 17 小时);就算把锚点换成 10-10 那次新月、再扫周期参数,最坏误差仍有 0.75 天——因为月亮走得本来就不匀,两参数模型不可能更准。而背错 Meeus 的系数表会得到一个"看着挺准其实全错"的月亮,那更糟。
所以用的是一张相时刻表 + 相邻两时刻线性插值:
≈,告诉你这是个估计,不装成事实月龄:是距上一次新月的真实天数,不是"相位占比 × 平均月长"。后者会把月龄算小——这个朔望月偏长(新月→上弦 8.0 天),占比算法今天会报 4.2 天,而农历是 4.6 天。农历日期是你能自己核的,所以这个数必须对得上它。
要延长覆盖,就往 hooks/moon.ts 的 INSTANTS 里整段追加相时刻(必须保持四相轮转、不可乱序,新月靠"从第一项起每第四项"定位——有一条测试盯着这个)。表是给 2026-10-03 起的连续四相用的,中间不能跳。
每分钟换一句,从一个 46167 句的库(随 mod 一起分发)里挑,一轮不重复。
库在 data/poems.txt:唐诗 3 万句 + 宋词 1.6 万句,每行 诗句<TAB>作者《篇名》,3.18 MiB($.fs.read 的硬上限是 4 MiB)。来源与生成方式见 data/NOTICE.md,复现脚本在仓库根的 tools/build_poems.py。
怎么做到"轮完一遍才回头":用一条满周期线性同余——index = (step * slot + offset) mod N,其中 step 与 N 互质。这是个双射,所以连续 N 个格子会把每一句都取到一次、且只取一次,走完 N 格才重复;step/offset 每个周期重新取,所以下一轮是另一种顺序,不是同一轮重播。
读数不靠状态:$.store 里只存"要不要显示"这一个开关。
data/poems.txt 每行 诗句<TAB>来源 即可,缺一半的行、重复的句子会被丢掉。注意 4 MiB 上限(超了 $.fs.read 直接拒绝,mod 会退回内置 8 句并在屏幕上弹一句提示——不会假装在用你的库)。
内置那 8 句只在库读不到时兜底:李白《把酒问月》/《月下独酌》/《静夜思》、张九龄《望月怀远》、张若虚《春江花月夜》、王维《竹里馆》、苏轼《水调歌头》、李商隐《嫦娥》。
用 emoji 月相(🌑🌒🌓🌔🌕🌖🌗🌘,八相一一对应)。渲染不好就换单色字形一族(◐◑◒◓ 那套),改 hooks/moon.ts 的 GLYPHS 一行。
它挂在 ui.render{component:'AbovePrompt'},和 Context View、deepseek-balance 是同一个位置。所以它 await next(e) 把下层画的树包住再画自己那行——直接返回自己的树会把别人那行吞掉。
/moon 收起/放回,记在 $.store 里,跨会话保留。
.claude-plugin/plugin.json 名字、版本、类型契约的位置
hooks/hooks.json 模块清单
hooks/register.tsx 挂载、/moon、读语料、每 20 秒刷新一次(诗一分钟一换,刷新是为了它不迟到)
hooks/moon.ts 相时刻表、插值、八相命名、月龄、语料解析与选句(纯函数,测试盯的就是它)
data/poems.txt 语料本体(46167 行)
data/NOTICE.md 语料来源、许可、转简说明、4 MiB 上限
types/index.d.ts $.state 里那个开关的类型契约
tests/moon.test.tsx 23 条:表是不是严格四相轮转、插值是否落在发布时刻上、月龄是否等于距新月的真实天数、
表外是否承认自己在估、一个周期是否正好走遍且不重、下一周期换顺序、
语料解析(缺一半/重复行要丢)、真的从语料里取句画出来、
不吞下面那行、/moon 能收起放回hooks/register.tsx 123 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { lineOf, poemsOf, POEMS } from './moon'
5import type { Poem } from './moon'
6
7// moon-band: the moon's phase and a line of poetry as one dim row above the
8// prompt. Nothing here is load-bearing — it is decoration, and `/moon` takes it
9// away for a while when a screen of prose is wanted instead.
10//
11// It wraps what `next(e)` resolved to rather than drawing the row itself, so
12// whatever else draws at this spot (the context meter, the balance meter) is
13// kept underneath it.
14
15/**
16 * Twenty seconds against a one-minute slot: the line turns over within twenty
17 * seconds of the minute rather than up to a minute late. A redraw is cheap and
18 * the engine folds sooner ones.
19 */
20const REFRESH_MS = 20 * 1000
21const HIDDEN_KEY = 'isHidden'
22const CORPUS_PATH = 'data/poems.txt'
23
24const isHidden = atom({ plugin: 'moon-band', key: 'isHidden' } as const, false)
25
26/** Runs `work`, and answers null when it throws. */
27const quietly = async <T,>(work: () => Promise<T>): Promise<T | null> => {
28 try {
29 return await work()
30 } catch {
31 return null
32 }
33}
34
35let corpus: Poem[] | null = null
36
37/**
38 * The corpus the mod ships, read once. It is the whole point of the thing, so a
39 * read that fails — or comes back with less than the built-ins would have given —
40 * says so out loud and falls back: a quietly smaller corpus would look exactly
41 * like a working one.
42 */
43const loadCorpus = async ($: EngineInterface): Promise<Poem[]> => {
44 if (corpus !== null) return corpus
45
46 const text = await quietly(() => $.fs.read(`${$.plugin.root}/${CORPUS_PATH}`))
47 const read = text === null ? [] : poemsOf(text)
48
49 if (read.length > POEMS.length) {
50 corpus = read
51
52 return corpus
53 }
54
55 try {
56 $.ui.toast(`moon-band: ${CORPUS_PATH} 没读到,先用内置 8 句`)
57 } catch {
58 // the toast is a courtesy; the fallback still holds
59 }
60
61 return POEMS
62}
63
64export const register: Register = on => {
65 let tick: Timer | null = null
66
67 on('session.start', async ($, e, next) => {
68 const result = await next(e)
69
70 await quietly(() => $.command.register({ name: 'moon', description: 'Hide or show the moon line (moon-band)' }))
71 await loadCorpus($)
72
73 const wasHidden = await quietly(() => $.store.get(HIDDEN_KEY))
74
75 if (wasHidden === true) await update($, isHidden, () => true)
76
77 if (tick === null) {
78 try {
79 // keeps a long session's moon and line from going stale
80 tick = $.clock.every(REFRESH_MS, () => {
81 try {
82 $.ui.invalidate('ui.render')
83 } catch {
84 // the next redraw takes it
85 }
86 })
87 } catch {
88 // without the tick the line still redraws on every turn
89 }
90 }
91
92 return result
93 })
94
95 on('command.run', { command: 'moon' }, async ($, e, next) => {
96 const wasHidden = await read($, isHidden)
97
98 await update($, isHidden, () => !wasHidden)
99 await quietly(() => $.store.set(HIDDEN_KEY, !wasHidden))
100
101 return { text: wasHidden ? 'moon line shown' : 'moon line hidden' }
102 })
103
104 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
105 if (e.props.hasSurvey) return next(e)
106 if (await read($, isHidden)) return next(e)
107
108 const { Box, Text } = $.ui.resolve(e)
109 const nowMs = (await quietly(() => $.clock.now())) ?? Date.now()
110 const lines = await loadCorpus($)
111 const inner = await next(e)
112
113 return (
114 <Box flexDirection="column">
115 {inner}
116 <Text dimColor wrap="truncate-end">
117 {lineOf(nowMs, lines)}
118 </Text>
119 </Box>
120 )
121 })
122}
123hooks/moon.ts 249 lines1// The moon and the poem, with no engine calls in them, so a test can hold them
2// to account without a session.
3//
4// The phase name is read off a table of published phase instants rather than
5// from a mean-synodic-month formula, because that formula is not good enough to
6// say a phase name: checked against October 2026's four instants it is out by up
7// to 0.72 days, and no two-parameter fit does better than 0.75 — the Moon's
8// motion is genuinely uneven.
9//
10// The age is not `fraction × mean month` either: it is the days since the last
11// new moon the table knows, which is the number anyone can check against a
12// lunar-calendar date.
13
14/**
15 * The phases, Beijing time (UTC+8), from StarWalk's phase calendar and the Hong
16 * Kong Astronomical Society's 2026 table, which agree to a minute.
17 *
18 * **A strict quarter cycle**: consecutive entries are one quarter apart and the
19 * gaps are 6.7–8.2 days (measured, and a test holds the table to that). Never
20 * add an instant without its neighbours, and never reorder: the interpolation
21 * reads these as evenly spaced quarters, not as dates, and the new moons are
22 * found by counting every fourth entry from the first.
23 */
24const INSTANTS = [
25 1791033900000, // 2026-10-03 21:25 下弦
26 1791647400000, // 2026-10-10 23:50 新月(农历九月初一)
27 1792339920000, // 2026-10-19 00:12 上弦
28 1792987860000, // 2026-10-26 12:11 满月(猎人月)
29 1793564880000, // 2026-11-02 04:28 下弦
30 1794207720000, // 2026-11-09 15:02 新月
31 1794916080000, // 2026-11-17 19:48 上弦
32 1795531980000, // 2026-11-24 22:53 满月(超级海狸月)
33 1796134080000, // 2026-12-01 22:08 下弦
34 1796777520000, // 2026-12-09 08:52 新月
35 1797486180000, // 2026-12-17 13:43 上弦
36 1798075680000, // 2026-12-24 09:28 满月(2026 最后一次超级月亮)
37 1798657140000, // 2026-12-31 02:59 下弦
38]
39
40/** The phase the table starts on: a last quarter, which is 0.75 of the cycle. */
41const FIRST_PHASE = 0.75
42
43/** How long a quarter of the cycle takes on average: the fallback's step. */
44const QUARTER_MS = (29.530588853 / 4) * 86_400_000
45
46const MEAN_MONTH_DAYS = 29.530588853
47const DAY_MS = 86_400_000
48
49/** How long the line stays: a minute, as asked. */
50export const POEM_SLOT_MS = 60_000
51
52export type Moon = {
53 /** Where in the cycle: 0 the new moon, 0.5 the full moon, 1 the next new moon. */
54 fraction: number
55 /** Days since the last new moon — the number a lunar calendar would give. */
56 ageDays: number
57 /** The phase in Chinese. */
58 name: string
59 /** One emoji for the phase; the eight are the whole cycle in order. */
60 glyph: string
61 /** True when the phase is a mean-month estimate: outside the table. */
62 isEstimated: boolean
63}
64
65const NAMES = ['新月', '娥眉月', '上弦月', '盈凸月', '满月', '亏凸月', '下弦月', '残月']
66const GLYPHS = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']
67
68/** The last new moon the table knows at or before this instant, or null. */
69const newMoonBefore = (nowMs: number): number | null => {
70 let found: number | null = null
71
72 // new moons are every fourth entry, the table opening on a last quarter
73 for (let i = 1; i < INSTANTS.length; i += 4) {
74 if (INSTANTS[i] <= nowMs) found = INSTANTS[i]
75 }
76
77 return found
78}
79
80/** The phase at an instant inside the table, or an estimate outside it. */
81export const moonAt = (nowMs: number): Moon => {
82 const first = INSTANTS[0]
83 const last = INSTANTS[INSTANTS.length - 1]
84
85 let fraction: number
86 let isEstimated = false
87
88 if (nowMs < first) {
89 fraction = FIRST_PHASE - (first - nowMs) / QUARTER_MS / 4
90 isEstimated = true
91 } else if (nowMs > last) {
92 fraction = FIRST_PHASE + (INSTANTS.length - 1) / 4 + (nowMs - last) / QUARTER_MS / 4
93 isEstimated = true
94 } else {
95 let i = 0
96
97 // stop one short: the instant *after* the last pair is the pair's own end,
98 // and asking for it would read past the table
99 while (i < INSTANTS.length - 2 && INSTANTS[i + 1] <= nowMs) i += 1
100
101 const span = INSTANTS[i + 1] - INSTANTS[i]
102 const through = (nowMs - INSTANTS[i]) / span
103
104 fraction = FIRST_PHASE + (i + through) / 4
105 }
106
107 const wrapped = ((fraction % 1) + 1) % 1
108 // the nearest of the eight names; `min` keeps the last band from rounding up
109 // past `残月` into the new moon
110 const octant = Math.min(7, Math.floor(wrapped * 8 + 0.5))
111
112 // Days since the last new moon. The age is the elapsed time, not a share of a
113 // mean month: this lunation runs long, and sharing a mean month understates it
114 // by a third of a day. Past the last new moon the table holds the next one
115 // arrives within a month, so an age older than that is counting on a new moon
116 // nobody told this table about.
117 const newMoon = newMoonBefore(nowMs)
118 const sinceNewMoon = newMoon === null ? null : (nowMs - newMoon) / DAY_MS
119 const isAgeTrue = sinceNewMoon !== null && sinceNewMoon < 30
120
121 return {
122 fraction: wrapped,
123 ageDays: isAgeTrue ? sinceNewMoon : wrapped * MEAN_MONTH_DAYS,
124 name: NAMES[octant],
125 glyph: GLYPHS[octant],
126 isEstimated,
127 }
128}
129
130/** One line and where it comes from. */
131export type Poem = { line: string; from: string }
132
133/**
134 * What the meter draws from until the corpus is read — and if it never is. Eight
135 * famous lines, each carrying its own source; several are one line of a longer
136 * poem, so the title is the poem's, not the line's.
137 */
138export const POEMS: Poem[] = [
139 { line: '今人不见古时月,今月曾经照古人。', from: '李白《把酒问月》' },
140 { line: '举杯邀明月,对影成三人。', from: '李白《月下独酌》' },
141 { line: '海上生明月,天涯共此时。', from: '张九龄《望月怀远》' },
142 { line: '江畔何人初见月?江月何年初照人?', from: '张若虚《春江花月夜》' },
143 { line: '举头望明月,低头思故乡。', from: '李白《静夜思》' },
144 { line: '深林人不知,明月来相照。', from: '王维《竹里馆》' },
145 { line: '人有悲欢离合,月有阴晴圆缺,此事古难全。', from: '苏轼《水调歌头》' },
146 { line: '嫦娥应悔偷灵药,碧海青天夜夜心。', from: '李商隐《嫦娥》' },
147]
148
149/**
150 * The corpus that ships with the mod: one `line<TAB>source` per line. A blank
151 * line, or one without both halves, is dropped rather than drawn as a puzzle.
152 * Duplicates go too — the same line twice would read as a repeat inside a cycle
153 * that promises none.
154 */
155export const poemsOf = (text: string): Poem[] => {
156 const seen = new Set<string>()
157 const poems: Poem[] = []
158
159 for (const raw of text.split('\n')) {
160 const [line, from] = raw.trim().split('\t')
161
162 if (!line || !from) continue
163 if (seen.has(line)) continue
164
165 seen.add(line)
166 poems.push({ line, from })
167 }
168
169 return poems
170}
171
172/** A small non-negative hash of an integer, for seeding a cycle. */
173const hashOf = (value: number): number => {
174 let x = Math.imul(value, 2654435761) >>> 0
175
176 x ^= x >>> 15
177 x = Math.imul(x, 2246822519) >>> 0
178 x ^= x >>> 13
179
180 // back to unsigned: `^` answers a signed 32-bit, and a negative here would
181 // index off the front of the list
182 return x >>> 0
183}
184
185const greatestCommonDivisor = (a: number, b: number): number => {
186 let x = a
187 let y = b
188
189 while (y !== 0) [x, y] = [y, x % y]
190
191 return x
192}
193
194/**
195 * A step that shares no factor with the corpus size, so that walking by it
196 * visits every index exactly once before coming back round. `1` is stepped over:
197 * a step of one is the corpus in file order, which is not a shuffle.
198 */
199const stepFor = (seed: number, count: number): number => {
200 let step = 1 + 2 * (hashOf(seed) % Math.floor(count / 2))
201
202 while (greatestCommonDivisor(step, count) !== 1 || step % count === 1) step += 2
203
204 return step
205}
206
207/**
208 * The index for a slot: **a full cycle of the whole corpus**, no line twice
209 * until every line has been drawn — a linear walk whose step and start are the
210 * only state it keeps.
211 *
212 * `step` is coprime with `count`, so `(step * i + offset) mod count` is a
213 * permutation: the first `count` slots cover every line once, in an order that
214 * looks shuffled. Each cycle re-seeds both, so the next round is a different
215 * order of the same corpus rather than the same round again.
216 */
217export const poemIndexAt = (slot: number, count: number): number => {
218 if (count <= 1) return 0
219
220 const cycle = Math.floor(slot / count)
221 const step = stepFor(cycle + 1, count)
222 const offset = hashOf(cycle) % count
223
224 return (step * ((slot % count) + offset)) % count
225}
226
227/**
228 * The line for this minute: the same one for the whole slot (a line that changed
229 * between frames would be unreadable), never the same as the slot before it
230 * inside a cycle, and one of the corpus's own orderings after that.
231 */
232export const poemAt = (nowMs: number, corpus: Poem[] = POEMS): Poem => {
233 const poems = corpus.length > 0 ? corpus : POEMS
234 const slot = Math.floor(nowMs / POEM_SLOT_MS)
235
236 return poems[poemIndexAt(slot, poems.length)]
237}
238
239/** The one line above the prompt. */
240export const lineOf = (nowMs: number, corpus: Poem[] = POEMS): string => {
241 const moon = moonAt(nowMs)
242 const poem = poemAt(nowMs, corpus)
243
244 return `${moon.isEstimated ? '≈' : ''}${moon.glyph} ${moon.name} ${moon.ageDays.toFixed(1)}天 · 「${poem.line}」${poem.from}`
245}
246
247/** The table, for the test that holds it to being a quarter cycle. */
248export const instantsOf = (): readonly number[] => INSTANTS
249types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 'moon-band': {
4 /** Whether `/moon` has taken the line away. */
5 isHidden: boolean
6 }
7 }
8}
9