在 Claude Code 面板裡滑 PTT,可自訂看板,附老闆鍵

一個 Claude Code mod:在 Claude Code 裡面開一個 PTT 面板,邊跟 Claude 寫 code 邊滑 PTT。
🌐 網站:<https://chenyao0910.github.io/claude-ptt-mod/>(可以在網頁上試玩老闆鍵)
Claude 在跑測試、改檔案的時候,你不用切到瀏覽器:面板就在對話旁邊,看看盤後閒聊、刷一下八卦。老闆經過?按 b,整個面板瞬間變成一堆編譯 log。

<sub>實際使用畫面:左邊照常跟 Claude 對話,右邊的 PTT 面板正在看八卦板文章。截圖中的使用者資訊和推文 IP 已遮蔽。</sub>
Claude Code 的 plugin 除了能提供 skill 和指令,還能用 function hooks 直接改 Claude Code 自己的介面:畫面板(pane)、在輸入框上方加一列、顯示 toast、改狀態列、攔截工具呼叫等等。這類「改 Claude Code 本身」的 plugin 就叫 mod。
這個 mod 用到的功能:
| Claude Code 功能 | 在這個 mod 裡的用途 |
|---|---|
面板(ui.render 的 Pane) | PTT 文章列表、文章內頁、老闆鍵畫面 |
斜線指令(command.register) | /ptt、/ptt add、/ptt remove |
面板快捷鍵(Button hotkey) | b 老闆鍵、r 重新整理…… |
跨 session 儲存($.store) | 記住你自訂的看板清單 |
網路請求($.http.fetch) | 讀取 PTT 網頁版 |
程式全部在 plugins/ptt/hooks/,大約 300 行 TypeScript,想學怎麼寫 mod 可以從這裡看起。
claude --version
需要 2.1.289 以上,舊版不支援 mod 的面板功能。太舊的話先執行 claude update。
在終端機執行(不是在 Claude Code 對話裡):
claude plugin marketplace add chenyao0910/claude-ptt-mod
claude plugin install ptt@claude-ptt-mod
第一行把這個 repo 加成 plugin 來源,第二行安裝裡面的 ptt mod。
照平常一樣啟動 claude。如果 Claude Code 本來就開著,先在對話裡執行 /reload-plugins。
在輸入框打:
/ptt
面板會出現在 Claude Code 裡,顯示 Stock 板的最新文章。想直接看別的板:
/ptt Gossiping
💡 終端機寬一點比較好看,建議 144 欄以上,面板才放得下又不擠到對話。
ctrl+x 再按 tab,讓面板取得焦點,接著就能用快捷鍵| 鍵 | 作用 |
|---|---|
b | 老闆鍵(再按一次切回來) |
r | 重新整理 |
n | 載入更舊的文章 |
h | 從文章回到列表 |
e | 管理看板 |
面板有焦點時,你打的字會給面板而不是對話。想回去跟 Claude 講話,點一下輸入框,或再按一次 ctrl+x tab 切換。
預設看板是 Stock、Gossiping、Tech_Job、Soft_Job、DigiCurrency、Lifeismoney。改成你自己的:
方法 A:在面板裡改
e)NBA),按 Enter方法 B:用指令
/ptt add NBA
/ptt remove Soft_Job
板名不分大小寫,打 nba 會自動修正成 PTT 上的正式名稱 NBA;打錯的板名會直接告訴你找不到。改過的清單會存下來,下次開 Claude Code 還在。
按 b(或面板上的「老闆鍵」),面板立刻變成這樣:
再按一次 b,或點最下面那個灰色的 _,就會回到剛剛看的地方。
claude plugin update ptt@claude-ptt-mod # 更新到最新版
claude plugin uninstall ptt@claude-ptt-mod # 移除
| 文章內頁 | 管理看板 |
|---|---|
面板的圖是照實際排版畫的,列表和文章用的是 PTT Stock 板的實際資料。實際顏色會依你的終端機配色而不同。
www.ptt.cc,只在你打開看板或文章時才抓。PTT 改版的話解析可能會壞,歡迎開 issueclaude plugin validate plugins/ptt # 檢查 manifest 和 hooks
claude plugin test plugins/ptt # 跑測試
claude --plugin-dir ./plugins/ptt # 不安裝,直接載入本機版本試跑
用 --plugin-dir 載入時,改完存檔 mod 會自動重新載入,不用重開 Claude Code。
重新產生 README 的圖片(用 PTT 現在的資料):
npx tsx scripts/dump-live.mts Stock > /tmp/live.json
python3 scripts/make-screenshots.py /tmp/live.json
MIT
hooks/register.tsx 355 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Article, Mode, Post } from '../types'
5import { parseArticle, parseIndex } from './parse'
6
7const PANE = 'ptt'
8const TITLE = 'PTT'
9const HOST = 'https://www.ptt.cc'
10// 面板一次最多畫約 10 萬字元,長文和爆文要截斷
11const MAX_BODY = 6000
12const MAX_PUSHES = 150
13const MAX_POSTS = 200
14const DEFAULT_BOARDS = ['Stock', 'Gossiping', 'Tech_Job', 'Soft_Job', 'DigiCurrency', 'Lifeismoney']
15// 看板清單存在 $.store,關掉 Claude Code 再開還在
16const STORE_BOARDS = 'boards'
17
18const board = atom({ plugin: 'ptt', key: 'board' } as const, 'Stock')
19const boards = atom({ plugin: 'ptt', key: 'boards' } as const, DEFAULT_BOARDS)
20const editing = atom({ plugin: 'ptt', key: 'editing' } as const, false)
21const posts = atom({ plugin: 'ptt', key: 'posts' } as const, [] as Post[])
22const prevPage = atom({ plugin: 'ptt', key: 'prevPage' } as const, '')
23const article = atom({ plugin: 'ptt', key: 'article' } as const, null as Article | null)
24const mode = atom({ plugin: 'ptt', key: 'mode' } as const, 'list' as Mode)
25const lastMode = atom({ plugin: 'ptt', key: 'lastMode' } as const, 'list' as Mode)
26const status = atom({ plugin: 'ptt', key: 'status' } as const, '')
27
28async function get($: Engine, path: string): Promise<string | undefined> {
29 const res = await $.http.fetch(HOST + path, { headers: { Cookie: 'over18=1' } })
30 if (!res.ok) {
31 await update($, status, () => `讀取失敗 (HTTP ${res.status})`)
32 return undefined
33 }
34 return res.text
35}
36
37async function loadBoard($: Engine, name: string, page?: string) {
38 await update($, status, () => '讀取中…')
39 const html = await get($, page ?? `/bbs/${name}/index.html`)
40 if (html === undefined) return
41 const parsed = parseIndex(html)
42 await update($, board, () => parsed.boardName || name)
43 // 翻舊頁時接在後面,方便一路往下滑
44 await update($, posts, list => (page ? [...list, ...parsed.posts] : parsed.posts).slice(0, MAX_POSTS))
45 await update($, prevPage, () => parsed.prevPage)
46 await update($, mode, () => 'list')
47 await update($, status, () => '')
48}
49
50async function openArticle($: Engine, url: string) {
51 await update($, status, () => '讀取中…')
52 const html = await get($, url)
53 if (html === undefined) return
54 await update($, article, () => parseArticle(url, html))
55 await update($, mode, () => 'article')
56 await update($, status, () => '')
57}
58
59async function toggleBoss($: Engine) {
60 const now = await read($, mode)
61 if (now === 'boss') {
62 const back = await read($, lastMode)
63 await update($, mode, () => back)
64 } else {
65 await update($, lastMode, () => now)
66 await update($, mode, () => 'boss')
67 }
68}
69
70const sameBoard = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
71
72async function saveBoards($: Engine, list: string[]) {
73 await update($, boards, () => list)
74 await $.store.set(STORE_BOARDS, list)
75}
76
77// 回傳給使用者看的結果訊息
78async function addBoard($: Engine, raw: string): Promise<string> {
79 const name = raw.trim()
80 const list = await read($, boards)
81 let msg: string
82 if (!/^[A-Za-z0-9_-]+$/.test(name)) {
83 msg = '看板名稱只能有英文、數字、底線和減號,例如 NBA、C_Chat'
84 } else if (list.some(b => sameBoard(b, name))) {
85 msg = `已經有 ${list.find(b => sameBoard(b, name))} 了`
86 } else {
87 const res = await $.http.fetch(`${HOST}/bbs/${name}/index.html`, { headers: { Cookie: 'over18=1' } })
88 if (res.status === 404) {
89 msg = `PTT 上找不到 ${name} 這個看板`
90 } else if (!res.ok) {
91 msg = `確認看板時連線失敗 (HTTP ${res.status}),稍後再試`
92 } else {
93 const canonical = parseIndex(res.text).boardName || name
94 await saveBoards($, [...list, canonical])
95 msg = `已新增 ${canonical}`
96 }
97 }
98 await update($, status, () => msg)
99 return msg
100}
101
102async function removeBoard($: Engine, raw: string): Promise<string> {
103 const list = await read($, boards)
104 const target = list.find(b => sameBoard(b, raw.trim()))
105 const msg = target ? `已移除 ${target}` : `清單裡沒有 ${raw.trim()}`
106 if (target) await saveBoards($, list.filter(b => b !== target))
107 await update($, status, () => msg)
108 return msg
109}
110
111async function resetBoards($: Engine) {
112 await saveBoards($, DEFAULT_BOARDS)
113 await update($, status, () => '已恢復預設看板')
114}
115
116function nrecColor(n: string): string | undefined {
117 if (n === '爆') return 'red'
118 if (n.startsWith('X')) return 'gray'
119 const v = Number(n)
120 if (v >= 10) return 'yellow'
121 if (v > 0) return 'green'
122 return undefined
123}
124
125// 標題拆成「Re: / Fw:」、分類標籤、其餘文字,標籤依分類上色
126function splitTitle(title: string): { prefix: string; tag: string; rest: string } {
127 const m = title.match(/^((?:Re|Fw|R|轉):\s*)?\[([^\]]{1,6})\]\s*(.*)$/i)
128 if (!m) return { prefix: '', tag: '', rest: title }
129 return { prefix: (m[1] ?? '').trim(), tag: m[2] ?? '', rest: m[3] || title }
130}
131
132const TAG_COLORS: Record<string, string> = {
133 新聞: 'cyan',
134 標的: 'magenta',
135 請益: 'green',
136 問卦: 'green',
137 情報: 'yellow',
138 心得: 'blue',
139 閒聊: 'blueBright',
140 討論: 'blueBright',
141 爆卦: 'red',
142 公告: 'red',
143 分享: 'yellowBright',
144}
145const tagColor = (tag: string) => TAG_COLORS[tag.trim()] ?? 'white'
146
147function statusColor(msg: string): string {
148 if (msg.startsWith('讀取中')) return 'yellow'
149 if (msg.startsWith('已')) return 'green'
150 return 'red'
151}
152
153const pushColor = (tag: string) => (tag.startsWith('推') ? 'green' : tag.startsWith('噓') ? 'red' : undefined)
154
155// 老闆鍵畫面:看起來很忙的假 log
156const FAKE_LOG = [
157 '[info] webpack 5.98.0 compiled successfully in 2384 ms',
158 '[info] ✓ 412 modules transformed.',
159 '[debug] resolving dependency graph for @internal/payment-gateway',
160 '[info] running migration 20261006_add_index_on_orders ... ok',
161 '[warn] deprecated: Buffer() is deprecated, use Buffer.from()',
162 '[info] PASS src/services/__tests__/ledger.test.ts (3.214 s)',
163 '[info] PASS src/services/__tests__/settlement.test.ts (2.871 s)',
164 '[debug] cache hit ratio 0.93 (hits=18231, misses=1374)',
165 '[info] Tests: 128 passed, 128 total',
166 '[info] watching for file changes...',
167]
168
169export const register: Register = on => {
170 on('session.start', async ($, e, next) => {
171 await $.command.register({
172 name: 'ptt',
173 description: '在面板裡滑 PTT。/ptt 看板名 直接開;/ptt add 看板名、/ptt remove 看板名 管理看板',
174 })
175 const saved = await $.store.get(STORE_BOARDS)
176 if (Array.isArray(saved) && saved.every(b => typeof b === 'string')) await update($, boards, () => saved as string[])
177 return next(e)
178 })
179
180 on('command.run', { command: 'ptt' }, async ($, e) => {
181 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
182 const arg = rest.join(' ')
183 if (verb === 'add' || verb === 'remove' || verb === 'rm') {
184 if (arg === '') return { text: `用法:/ptt ${verb} 看板名稱` }
185 return { text: verb === 'add' ? await addBoard($, arg) : await removeBoard($, arg) }
186 }
187 const name = verb || (await read($, board))
188 await $.ui.open({ id: PANE, title: TITLE })
189 await loadBoard($, name)
190 const shown = await read($, board)
191 return { text: `PTT 面板已開啟:${shown} 板。面板取得焦點後(ctrl+x tab):b 老闆鍵、r 重新整理、n 載入更舊、h 回列表、e 管理看板。` }
192 })
193
194 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
195 const ui = $.ui.resolve(e)
196 const { Box, Text, Button, Markdown } = ui
197 const m = await read($, mode)
198 const msg = await read($, status)
199
200 if (m === 'boss') {
201 return (
202 <Box flexDirection="column">
203 {FAKE_LOG.map(line => (
204 <Text dimColor={line.startsWith('[debug]')} color={line.startsWith('[warn]') ? 'yellow' : undefined}>
205 {line}
206 </Text>
207 ))}
208 <Button key="boss" plain dimColor hotkey="b" label="_" onPress={() => void toggleBoss($)} />
209 </Box>
210 )
211 }
212
213 const name = await read($, board)
214 const list = await read($, boards)
215 const isEditing = await read($, editing)
216 const width = Math.max(20, Math.min(e.props.bodyColumns, 100))
217 const divider = <Text color="blue" dimColor>{'─'.repeat(width)}</Text>
218
219 const header = (
220 <Box key="header" flexDirection="row" columnGap={1}>
221 <Text backgroundColor="blue" color="white" bold>{' PTT '}</Text>
222 <Text dimColor>批踢踢實業坊 ›</Text>
223 <Text color="yellow" bold>{m === 'article' ? `${name} › 文章` : name}</Text>
224 </Box>
225 )
226
227 const statusLine = msg !== '' && <Text color={statusColor(msg)}>{msg}</Text>
228
229 if (m === 'article') {
230 const a = await read($, article)
231 if (a) {
232 const good = a.pushes.filter(p => p.tag.startsWith('推')).length
233 const bad = a.pushes.filter(p => p.tag.startsWith('噓')).length
234 const body = a.body.length > MAX_BODY ? `${a.body.slice(0, MAX_BODY)}\n…(內文太長,後面省略)` : a.body
235 const shown = a.pushes.slice(-MAX_PUSHES)
236 const t = splitTitle(a.title)
237 return (
238 <Box flexDirection="column" gap={1}>
239 {header}
240 <Box key="nav" flexDirection="row" columnGap={1}>
241 <Button key="back" hotkey="h" variant="primary" label="← 回列表" onPress={() => void update($, mode, () => 'list')} />
242 <Button key="boss" hotkey="b" variant="secondary" label="老闆鍵" onPress={() => void toggleBoss($)} />
243 </Box>
244 <Box key="head" flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1}>
245 <Box key="title-row" flexDirection="row" columnGap={1}>
246 {t.prefix !== '' && <Text dimColor>{t.prefix}</Text>}
247 {t.tag !== '' && <Text color={tagColor(t.tag)} bold>{`[${t.tag}]`}</Text>}
248 <Text bold>{t.rest}</Text>
249 </Box>
250 <Box key="meta-row" flexDirection="row" columnGap={1}>
251 <Text dimColor>作者</Text>
252 <Text color="cyan">{a.author}</Text>
253 <Text dimColor>{`· ${a.time}`}</Text>
254 </Box>
255 </Box>
256 <Text wrap="wrap">{body}</Text>
257 {divider}
258 <Box key="score" flexDirection="row" columnGap={1}>
259 <Text color="green" bold>{`推 ${good}`}</Text>
260 <Text color="red" bold>{`噓 ${bad}`}</Text>
261 <Text dimColor>{`共 ${a.pushes.length} 則${shown.length < a.pushes.length ? `(只顯示最後 ${shown.length} 則)` : ''}`}</Text>
262 </Box>
263 <Box key="pushes" flexDirection="column">
264 {shown.map((p, i) => (
265 <Box key={`push${i}`} flexDirection="row" columnGap={1}>
266 <Text color={pushColor(p.tag)} bold>{p.tag}</Text>
267 <Text color="yellow">{p.user}</Text>
268 <Text wrap="wrap">{p.text}</Text>
269 <Text dimColor>{p.time}</Text>
270 </Box>
271 ))}
272 </Box>
273 </Box>
274 )
275 }
276 }
277
278 // 看板一列、操作一列,中間用線分開
279 const boardRow = (
280 <Box key="boards" flexDirection="row" flexWrap="wrap" columnGap={1}>
281 <Text color="cyan" bold>看板</Text>
282 {list.flatMap((b, i) => [
283 ...(i > 0 ? [<Text dimColor>|</Text>] : []),
284 <Button
285 key={`board-${b}`}
286 variant={b === name ? 'primary' : undefined}
287 plain={b === name ? undefined : true}
288 label={b}
289 onPress={() => void loadBoard($, b)}
290 />,
291 ])}
292 </Box>
293 )
294 const actionRow = (
295 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
296 <Text color="magenta" bold>操作</Text>
297 <Button key="refresh" hotkey="r" variant="secondary" label="重新整理" onPress={() => void loadBoard($, name)} />
298 <Button key="edit" hotkey="e" variant={isEditing ? 'primary' : 'secondary'} label={isEditing ? '完成' : '管理看板'} onPress={() => void update($, editing, v => !v)} />
299 <Button key="boss" hotkey="b" variant="secondary" label="老闆鍵" onPress={() => void toggleBoss($)} />
300 </Box>
301 )
302
303 const manage = isEditing && (
304 <Box key="manage" flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
305 <Text color="cyan" bold>管理看板</Text>
306 {list.length === 0 && <Text dimColor>清單是空的,新增一個看板吧</Text>}
307 {list.map(b => (
308 <Box key={`row-${b}`} flexDirection="row" columnGap={1}>
309 <Text color="yellow">{b}</Text>
310 <Button key={`remove-${b}`} plain label="✕ 移除" hover={{ color: 'red', bold: true }} onPress={() => void removeBoard($, b)} />
311 </Box>
312 ))}
313 {'Input' in ui ? (
314 <ui.Input key="add-board" label="新增看板" placeholder="輸入看板英文名稱,例如 NBA,按 Enter" onSubmit={(v: string) => void addBoard($, v)} />
315 ) : (
316 <Text dimColor>新增看板:輸入 /ptt add 看板名稱</Text>
317 )}
318 <Button key="reset" plain dimColor label="恢復預設看板" onPress={() => void resetBoards($)} />
319 </Box>
320 )
321
322 const items = await read($, posts)
323 const older = await read($, prevPage)
324 return (
325 <Box flexDirection="column" gap={1}>
326 {header}
327 <Box key="controls" flexDirection="column">
328 {boardRow}
329 {divider}
330 {actionRow}
331 </Box>
332 {manage}
333 {statusLine}
334 <Box key="list" flexDirection="column">
335 {items.length === 0 && msg === '' && <Markdown key="empty" text="_還沒載入,按上面的看板或「重新整理」_" />}
336 {items.map(p => {
337 const t = splitTitle(p.title)
338 return (
339 <Box key={p.url} flexDirection="row" columnGap={1}>
340 <Text color={nrecColor(p.nrec)} bold>{p.nrec.padStart(2, ' ') || ' '}</Text>
341 {t.prefix !== '' && <Text dimColor>{t.prefix}</Text>}
342 {t.tag !== '' && <Text color={tagColor(t.tag)}>{`[${t.tag}]`}</Text>}
343 <Button key={`open-${p.url}`} plain label={t.rest} hover={{ color: 'cyan', bold: true }} onPress={() => void openArticle($, p.url)} />
344 <Text color="cyan" dimColor wrap="truncate">{p.author}</Text>
345 <Text dimColor>{p.date}</Text>
346 </Box>
347 )
348 })}
349 </Box>
350 {older !== '' && <Button key="older" hotkey="n" variant="secondary" label="載入更舊的文章" onPress={() => void loadBoard($, name, older)} />}
351 </Box>
352 )
353 })
354}
355hooks/parse.ts 63 lines1import type { Article, Post, Push } from '../types'
2
3const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }
4
5export function decode(s: string): string {
6 return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, code: string) => {
7 if (code[0] === '#') return String.fromCodePoint(code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10))
8 return ENTITIES[code] ?? m
9 })
10}
11
12const stripTags = (s: string) => decode(s.replace(/<[^>]*>/g, ''))
13
14export function parseIndex(html: string): { posts: Post[]; prevPage: string; boardName: string } {
15 // 置底公告在 r-list-sep 之後,略過
16 const listPart = html.split('<div class="r-list-sep"></div>')[0] ?? html
17 const posts: Post[] = []
18 for (const chunk of listPart.split('<div class="r-ent">').slice(1)) {
19 const link = chunk.match(/<a href="(\/bbs\/[^"]+\.html)">([\s\S]*?)<\/a>/)
20 if (!link) continue // 已刪除的文章沒有連結
21 posts.push({
22 url: link[1] ?? '',
23 title: stripTags(link[2] ?? '').trim(),
24 nrec: stripTags(chunk.match(/<div class="nrec">([\s\S]*?)<\/div>/)?.[1] ?? '').trim(),
25 author: (chunk.match(/<div class="author">([^<]*)/)?.[1] ?? '').trim(),
26 date: (chunk.match(/<div class="date">([^<]*)/)?.[1] ?? '').trim(),
27 })
28 }
29 const prevPage = html.match(/href="([^"]+)">‹ 上頁/)?.[1] ?? ''
30 // 看板的正式名稱(大小寫以 PTT 為準)
31 const boardName = html.match(/<a class="board" href="\/bbs\/([^/]+)\/index\.html">/)?.[1] ?? ''
32 return { posts: posts.reverse(), prevPage, boardName }
33}
34
35// 有些看板(例如八卦板)推文會附 IP,面板只顯示日期時間
36export function pushTime(raw: string): string {
37 return raw.match(/\d{1,2}\/\d{1,2}(?:\s+\d{1,2}:\d{2})?\s*$/)?.[0].trim() ?? raw
38}
39
40export function parseArticle(url: string, html: string): Article {
41 const start = html.indexOf('<div id="main-content"')
42 const main = start >= 0 ? html.slice(start) : html
43 const meta = [...main.matchAll(/<span class="article-meta-value">([^<]*)<\/span>/g)].map(m => decode(m[1] ?? ''))
44 const firstPush = main.indexOf('<div class="push">')
45 const bodyHtml = (firstPush >= 0 ? main.slice(0, firstPush) : main)
46 .replace(/<div class="article-metaline(?:-right)?">[\s\S]*?<\/div>/g, '')
47 .replace(/<div id="main-content"[^>]*>/, '')
48 const body = stripTags(bodyHtml).replace(/\n{3,}/g, '\n\n').trim()
49 const pushes: Push[] = [...main.matchAll(/<div class="push">([\s\S]*?)<\/div>/g)].map(m => {
50 const p = m[1] ?? ''
51 const span = (cls: string) => stripTags(p.match(new RegExp(`<span[^>]*${cls}[^>]*>([\\s\\S]*?)</span>`))?.[1] ?? '').trim()
52 return { tag: span('push-tag'), user: span('push-userid'), text: span('push-content').replace(/^:\s*/, ''), time: pushTime(span('push-ipdatetime')) }
53 })
54 return {
55 url,
56 author: meta[0] ?? '',
57 title: meta[2] ?? meta[1] ?? '',
58 time: meta[3] ?? '',
59 body,
60 pushes,
61 }
62}
63types/index.d.ts 21 lines1export type Post = { url: string; title: string; nrec: string; author: string; date: string }
2export type Push = { tag: string; user: string; text: string; time: string }
3export type Article = { url: string; title: string; author: string; time: string; body: string; pushes: Push[] }
4export type Mode = 'list' | 'article' | 'boss'
5
6declare module 'claude-code' {
7 interface PluginState {
8 ptt: {
9 board: string
10 boards: string[]
11 editing: boolean
12 posts: Post[]
13 prevPage: string
14 article: Article | null
15 mode: Mode
16 lastMode: Mode
17 status: string
18 }
19 }
20}
21