A Mods pane that finds every mod on this Mac by itself, also the ones installed from a marketplace, turns each one on or off in every chat, and opens the…

Current publication inventory — 2026-10-24: 135 bilingual posts across10 series (7 Tutorials /96 articles,3 Thoughts /39 essays),147 HTML files (146 enumerated), 466 images,135 feed items and146 sitemap URLs. AI-Core Functional Workflows adds twelve lessons paired with Anirach/ai-core-functional-tutorial,108 offline tests and twelve exercises. Earlier dated measurements below retain their historical context.
The personal site of Dr. Anirach Mingkhwan — Associate Professor at King Mongkut's University of Technology North Bangkok (KMUTNB), PhD (Liverpool John Moores University, UK), researcher in Agentic AI, computer networks, and big data analytics.
🌐 Live: anirach.com
A hand-written static site — seven top-level pages, four per-book detail pages, and 135 self-contained blog posts catalogued on two pages: Tutorials (/blog/, seven technical series) and Thoughts (/thoughts/, thirty-nine essays on living well and working well). No build system, no package manager, no dependencies, no JavaScript framework. Push to main and GitHub Pages publishes it.
| Pages | Home · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates |
| Posts | 135, in 10 series across two catalogs, every one bilingual Thai/English |
| Files | 147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded) |
| Stack | Plain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency |
| Hosting | GitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare |
| Build step | None |
| Tests | Four scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite |
| Page | What it holds |
|---|---|
index.html | Single-scroll consultant profile: hero, at-a-glance stats, six services, engagement approach, selected work, about and career, research, free resources, latest news with the featured book, contact |
blog/ | The Tutorials catalog — 96 cards in seven technical series, plus all 135 post files |
thoughts/ | The Thoughts catalog — 39 essay cards in three series, each linking ../blog/<slug>.html |
publications/ | The academic record: the Springer book Libraries in Transformation, 8 book chapters, and a selected-publications table |
books/ | Books & writing: four works — One Day of Light (the free last-lecture event book, EN/TH editions with free PDF downloads served from books/), the published novel Three Old Men: The Last Conversation, and two complete bilingual manuscripts — each with its own detail page |
projects/ | Live apps (each verified working before it ships) and research-code repositories |
news/ | Reverse-chronological timeline of publications, talks and appointments, plus a career timeline |
The posts never move. Every post is blog/<slug>.html, including the 39 essays that /thoughts/ catalogues — the 2026-09-08 split separated the two catalogs, not the files.
books/ was one "Books & Writing" page until 2026-08-23, when it split: academic content moved to the new publications/, and books/ became the fiction section with one detail page per book. On 2026-08-24 it gained a fourth work, the Last Lecture companion book One Day of Light, whose EN/TH PDFs are downloadable for free from books/.
script.js is gone — deleted 2026-08-26. The site loads no executable JavaScript at all, and check_site.py INV-38 fails the build on any <script> that is not application/ld+json (schema.org metadata, which the browser parses as data and never executes — 128 pages carry one). Each of the script's four jobs has a CSS replacement in style.css: the scroll-reveal and the nav scroll state are scroll-driven CSS animations (animation-timeline: view() / scroll(root)), the landing page's mobile menu is .nav__links:target, and anchor scrolling is native scroll-behavior: smooth. The other pages' mobile menu is a pure-CSS checkbox toggle — a second mechanism, also without JavaScript.
Both are static, zero-JavaScript and generated by scripts/reindex_blog.py — re-run it rather than hand-ordering cards. Each is a hero, a sticky chip strip, a "Start here" feature, a row of series tiles, then one numbered-row-card section per series. There is no filtering, sorting or search UI.
Tutorials — /blog/, hero reads 7 Series · 96 Articles
| Series | Posts | Navigation |
|---|---|---|
| ⧉ AI-Core Functional Workflows | 12 | generated12-chip strip · GitHub code, tests and exercises |
| 🛡️ Engineering AI-Core Systems | 10 | chip strip · generated |
| 💻 Hermes Desktop Hands-On | 7 | chip strip · generated |
| 🧭 AI Transformation for Organizations | 20 | grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated |
| ⚡ Hermes Agent in Practice | 10 | chip strip |
| 🤖 OpenClaw for Organizations | 13 | seven are a numbered chip-strip series; six stand alone with no nav |
| 🔧 DevOps & Vibe Coding | 24 | a single prev/next chain |
Thoughts — /thoughts/, hero reads 3 Series · 39 Essays
| Series | Essays | Navigation |
|---|---|---|
| 🌱 Wisdom for a Good Life | 20 | grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated |
| 🧭 Working Philosophies | 10 | chip strip, one working week Monday to Friday · generated |
| 🌅 Life Thought & Philosophy | 9 | chip strip, one day in three parts (Morning · Noon · Twilight) |
Five of the nine series are generated from a manifest plus per-post content sheets: python3 scripts/build_series.py --series <name>, with manifests in scripts/series/ (ai-core, ai-transformation, good-life, hermes-desktop, working). For those, the manifest and the sheets are the edit surface — never the emitted post.
Every post is bilingual. All 123 carry a pure-CSS Thai ⇄ English switch: a ไทย · English pill in the hero swaps between two complete tracks of the same article. Thai is the default, there is no JavaScript, and the page-level <html lang="th"> never changes — the English track is lang="en" inside it. The seven top-level pages and the four book detail pages are lang="en" chrome and wrap their Thai passages in <span lang="th">.
The Thoughts essays are written in a different register from the tutorials — literary Thai in the author's own voice, not code-switched technical prose. The brief, with ten rules and worked before/after pairs, is .claude/skills/blog-post/references/thai-essay-voice.md.
.
├── index.html # Landing page — the ONLY file that loads style.css
├── style.css # Landing-page styles; carries the canonical :root token block
├── _config.yml # Jekyll: keeps internal working docs out of the published site
├── CNAME # anirach.com
├── sitemap.xml # 134 URLs, generated · feed.xml — 123 items, generated · llms.txt · robots.txt
├── blog/ # index.html (the Tutorials catalog, 96 cards) + all 123 self-contained posts
├── thoughts/ # index.html (the Thoughts catalog — 39 essay cards linking ../blog/)
├── books/ # index.html + 4 per-book detail pages + 3 free PDFs, all self-contained
├── publications/ projects/ news/ # one self-contained index.html each
├── images/ # 431 files — 124 covers (800×800) + 8 book faces, 123 card thumbs (144×144),
│ # 130 share cards (1200×630), diagrams, series figures, posters, profile photo
├── scripts/ # 13 generators and sweeps (build_series, reindex_blog, gen_feed, gen_sitemap,
│ # make_cover, make_figure, bilingualize, check_visibility, …)
├── docs/ # design spec, implementation plan, OpenClaw runbook (not published)
└── .claude/skills/ # the four maintenance skills (not published)
The single most important fact before editing:
index.html ──▶ style.css (the ONLY consumer of it)
blog/index.html ──▶ its own <style> block, no JS
blog/<post>.html ──▶ its own <style> block, no JS × 123
thoughts/ books/ (index + 4 detail) publications/ projects/ news/ 404.html ──▶ same × 10
Every page embeds its complete stylesheet. There is no shared partial, template engine or token file, so a "global" change means editing N files by hand — and nothing tells you when file 23 of 135 got missed. That is what the linter is for. The compensating upside: blast radius is exactly one file.
No install, no build. Serve the repository root:
python3 -m http.server 8000 # → http://localhost:8000
Serve the root, not
blog/. Every blog page references images as../images/….
Known local-dev trap. The eight chip-strip series — 93 posts in all — link to each other with root-absolute, extensionless URLs (
/blog/openclaw-101). GitHub Pages resolves those;http.serverdoes not and returns 404. That is not a regression.
Run all four from the repo root before pushing. All must pass:
python3 .claude/skills/site-check/scripts/check_site.py # 62 cross-file integrity checks
python3 scripts/check_visibility.py --strict # search/AI-visibility, JSON-LD, metadata
python3 docs/openclaw/check-news-sync.py # news↔homepage sync, provenance, 4 counters
python3 .claude/skills/blog-post/assets/verify-wiring.py # blog post wiring
check_site.py runs 62 checks — 43 fail-severity, 17 warn, 2 info — and exits 0 when no fail-severity violation appears. Its baseline of pre-existing debt is empty since 2026-08-26 — the tree is clean (0 new, 0 known), so any violation it prints is new and any fail-severity one blocks the push. INV-25 fails the build if a baseline entry is ever added and goes stale, so the safety net cannot silently rot. INV-26 (added with the books split) ties every section-directory detail page to its own index.html: an orphan detail page, or an index card linking a file that does not exist, fails the build.
check-news-sync.py verifies the homepage strip mirrors the three newest news items, that every news item carries a <!-- source: … --> provenance comment, and that all four hand-typed counters match reality: news/index.html "7 updates", publications/index.html "8 chapters", and books/index.html "1 novel" and "2 complete" (it strips HTML comments first, so a commented-out card cannot satisfy a counter).
feed.xml and sitemap.xml are generated, not hand-written — scripts/gen_feed.py and scripts/gen_sitemap.py. INV-31 and INV-32 fail the build when either drifts from the catalogs. Run the sitemap in the commit after a content change, so the git lastmod dates exist.
git push origin main
That is the whole pipeline; GitHub Pages rebuilds in 1–3 minutes. _config.yml excludes docs/ and CLAUDE.md from the published output — they stay in the repository but are not served. Anything beginning with . or _ (including .claude/) is omitted by Jekyll automatically.
Cloudflare caches HTML for 10 minutes and assets for 4 hours. After a deploy, append
?cb=$RANDOMto check the real state before diagnosing a problem that is not there — and remember your own browser holds its copy for the same 10 minutes, so a stale tab needs a hard reload rather than a bug report.
Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:
Inter:wght@300..900 gets one variable file; the semicolon form Inter:wght@300;400;…;900 makes the API serve one static file per weight — 7 files and 331 KB for the latin subset against 1 file and 47 KB. Switching saved 189 KB per page, 56% of the font bytes. Sarabun keeps the semicolon form**: it has no variable version, a range request returns HTTP 400, and all five of its static weights are used.srcset. The row card renders its cover in a 72px box, so an 800×800 source was ~11× oversampled. /blog/ went from 3.63 MB to 0.42 MB, /thoughts/ from 2.11 MB to 0.38 MB.Both are documented with their measurements in the a11y-perf skill (R8b and R1b).
A post is not one file — it is a file plus a cover plus a share card plus a card plus counters plus its neighbours' nav links plus a feed item, a sitemap entry and an llms.txt line, in two language tracks, with no build step to catch a miss. The full recipe, including which of the four navigation patterns applies, is in .claude/skills/blog-post/SKILL.md. Start from .claude/skills/page-design/assets/post-template.html — the repo's single canonical template. For one of the five generated series, add a manifest row and run build_series.py instead; never hand-edit those posts.
Four skills in .claude/skills/ carry the verified detail:
| Skill | Covers |
|---|---|
| page-design | The house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by |
| blog-post | Adding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief |
| a11y-perf | Accessibility and performance rules with this site's real measured numbers |
| site-check | The linter, what every check means, and how to repair each failure |
Every number in those skills is load-bearing: a change that invalidates one must update it in the same commit. A confidently wrong measurement is worse than none, because it gets acted on.
News updates have their own runbook at docs/openclaw/latest-updates-runbook.md, written for an autonomous agent. Its gate enforces that every news item carries a verified source — no structural check can tell a true item from an invented one, so instead an item cannot exist without a stated source.
Open to research collaborations, speaking engagements, and academic partnerships.
No LICENSE file is present. Content and code are © Anirach Mingkhwan; all rights reserved by default.
Twelve bilingual lessons and the matching code are maintained together: Anirach/ai-core-functional-tutorial. The offline lab ZIP is downloads/ai-core-functional-lab.zip. The series manifest is scripts/series/ai-core-functional.json; article source links pin the reviewed companion commit. Default fixtures require no SDK install, API key or paid calls. Run the companion tests/advertised commands and the four website checks when revising. No additional reuse license is granted by the companion's public availability.
hooks/register.tsx 482 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The Mods pane: one row per knowledge-work mod, with an On/Off switch that works
4// in every chat (switches.json). Mods that draw something also get Shown/Hidden,
5// kept as "<name>:hidden": true in the same file. Showing a side pane, or the
6// Refresh panel, also leaves an open request for this chat.
7// The list is found on disk, not typed in: every folder under ~/.claude/mods with a
8// plugin.json and a hooks.json that has "modules", every CLAUDE_CODE_PLUGIN_DIRS
9// folder, and this chat's own ~/.claude/dev-mods/<session id>/ mods.
10
11const PANE = 'mods'
12const TITLE = 'Mods'
13const NOTE = { plugin: 'mods', key: 'note' } as const
14const POLL_MS = 3000
15const SCAN_MS = 10_000
16const BUTTONS_COLS = 26
17
18const GOOD = '#16a34a'
19
20// hide: has a Shown/Hidden switch. ask: Show also leaves an open request. tip: the slash command.
21type Mod = { name: string; title: string; what: string; pane: boolean; hide: boolean; ask: boolean; tip: string }
22// A row in the pane: a known mod or one found on disk. switchable: its source reads switches.json.
23type Row = Mod & { switchable: boolean; chatOnly: boolean }
24
25const MODS: readonly Mod[] = [
26 { name: 'cache-clock', title: 'Cache Clock', what: 'Counts down until the cache goes cold', pane: false, hide: true, ask: false, tip: '/cacheclock to show or hide' },
27 { name: 'handoff', title: 'Handoff (Refresh)', what: 'Moves a long chat to a fresh one', pane: false, hide: true, ask: true, tip: '/refresh to show it' },
28 { name: 'screen-share', title: 'Screen-Share Mode', what: 'Hides emails, phones and amounts on screen', pane: false, hide: false, ask: false, tip: '/screen-share to hide private data' },
29 { name: 'brain-dump', title: 'Brain-Dump Shaper', what: 'Turns long rambling prompts into clear briefs', pane: false, hide: false, ask: false, tip: '' },
30 { name: 'reflect', title: 'Reflect', what: 'Saves your corrections as lasting rules', pane: false, hide: false, ask: false, tip: '' },
31 { name: 'helper-desk', title: 'Helper Desk', what: 'Task progress bar above the prompt, plus a card per subagent', pane: true, hide: true, ask: true, tip: '/helpers to open' },
32 { name: 'chat-inventory', title: 'Chat Inventory', what: 'Connectors, files and skills this chat used', pane: true, hide: true, ask: true, tip: '/inventory to open' },
33]
34
35// Module variables reset on a reload; the switches themselves live in the file.
36let sid = ''
37let lastRaw = ''
38let cache: { at: number; rows: Row[] } | null = null
39
40const NOT_SWITCHABLE = 'Not switchable yet. Ask Claude: add the /mods switch to this mod.'
41
42const homeOf = ($: EngineInterface) => $.plugin.root.split('/.claude/')[0] ?? ''
43const switchesPath = ($: EngineInterface) => `${homeOf($)}/.claude/mods/switches.json`
44
45function cut(s: string, n: number) {
46 if (n <= 0) return ''
47 return s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}…`
48}
49
50async function log($: EngineInterface, where: string, err: unknown) {
51 try {
52 const dir = `${homeOf($)}/.claude/mods-logs`
53 const file = `${dir}/mods.log`
54 await $.process.run(['mkdir', '-p', dir])
55 let old = ''
56 try {
57 old = String(await $.fs.read(file))
58 } catch {
59 old = ''
60 }
61 const when = new Date(await $.clock.now()).toISOString()
62 const msg = err instanceof Error ? err.message : String(err)
63 await $.fs.write(file, `${old}${when} ${where}: ${msg}\n`.slice(-50_000))
64 } catch {
65 // Nowhere left to report it.
66 }
67}
68
69async function readRaw($: EngineInterface) {
70 try {
71 const path = switchesPath($)
72 if (!(await $.fs.exists(path))) return ''
73 return String(await $.fs.read(path))
74 } catch {
75 return ''
76 }
77}
78
79function parse(raw: string): Record<string, unknown> {
80 try {
81 const json = JSON.parse(raw) as unknown
82 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
83 } catch {
84 return {}
85 }
86}
87
88async function readSwitches($: EngineInterface) {
89 return parse(await readRaw($))
90}
91
92const isOff = (json: Record<string, unknown>, name: string) => json[name] === false
93const isHidden = (json: Record<string, unknown>, name: string) => json[`${name}:hidden`] === true
94
95async function save($: EngineInterface, json: Record<string, unknown>) {
96 await $.process.run(['mkdir', '-p', `${homeOf($)}/.claude/mods`])
97 const text = `${JSON.stringify(json, null, 2)}\n`
98 await $.fs.write(switchesPath($), text)
99 lastRaw = text
100}
101
102// Read, change, write the whole file. Off is `false`; on deletes the key.
103async function flip($: EngineInterface, name: string) {
104 const json = await readSwitches($)
105 const turnOff = !isOff(json, name)
106 if (turnOff) json[name] = false
107 else delete json[name]
108 await save($, json)
109 return turnOff
110}
111
112// Hidden is `"<name>:hidden": true`; shown deletes the key.
113async function flipHidden($: EngineInterface, name: string) {
114 const json = await readSwitches($)
115 const key = `${name}:hidden`
116 const hide = !isHidden(json, name)
117 if (hide) json[key] = true
118 else delete json[key]
119 await save($, json)
120 return hide
121}
122
123async function askOpen($: EngineInterface, name: string) {
124 if (!sid) throw new Error('no session id')
125 await $.fs.write(`${homeOf($)}/.claude/mods/.open-${name}-${sid}`, '')
126}
127
128async function readText($: EngineInterface, path: string) {
129 try {
130 if (!(await $.fs.exists(path))) return ''
131 return String(await $.fs.read(path))
132 } catch {
133 return ''
134 }
135}
136
137// Folders (or links to folders) directly inside dir. A missing dir is an empty list.
138async function listDirs($: EngineInterface, dir: string) {
139 try {
140 const entries = await $.fs.list(dir)
141 return entries.filter(x => !x.name.startsWith('.') && (x.kind === 'dir' || x.isLink)).map(x => `${dir}/${x.name}`)
142 } catch {
143 return []
144 }
145}
146
147// Plugins installed from a marketplace: ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>, newest version name last.
148async function cacheDirs($: EngineInterface, home: string) {
149 const out: string[] = []
150 for (const market of await listDirs($, `${home}/.claude/plugins/cache`)) {
151 for (const plugin of await listDirs($, market)) {
152 const versions = (await listDirs($, plugin)).sort()
153 const last = versions[versions.length - 1]
154 if (last) out.push(last)
155 }
156 }
157 return out
158}
159
160// CLAUDE_CODE_PLUGIN_DIRS: folders split by ':', '~' meaning the home folder.
161async function envDirs($: EngineInterface) {
162 let raw: string | undefined
163 try {
164 raw = await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')
165 } catch {
166 return []
167 }
168 if (!raw) return []
169 const home = homeOf($)
170 return raw
171 .split(':')
172 .map(x => x.trim())
173 .filter(Boolean)
174 .map(x => (x === '~' ? home : x.startsWith('~/') ? `${home}${x.slice(1)}` : x))
175 .map(x => x.replace(/\/+$/, ''))
176}
177
178function titleCase(name: string) {
179 return name
180 .split(/[-_\s]+/)
181 .filter(Boolean)
182 .map(w => w.charAt(0).toUpperCase() + w.slice(1))
183 .join(' ')
184}
185
186// Every hooks source file, tests and backups left out.
187async function hooksSource($: EngineInterface, dir: string) {
188 let text = ''
189 try {
190 const entries = await $.fs.list(`${dir}/hooks`)
191 for (const x of entries) {
192 if (x.kind !== 'file' || !/\.(tsx?|jsx?|mjs|cjs)$/.test(x.name) || /\.test\./.test(x.name)) continue
193 if (x.size > 2_000_000) continue
194 text += `${await readText($, `${dir}/hooks/${x.name}`)}\n`
195 }
196 } catch {
197 // An unreadable hooks folder means nothing is found in it.
198 }
199 return text
200}
201
202function commandsIn(source: string) {
203 const out: string[] = []
204 for (const m of source.matchAll(/command\.register\(\s*\{\s*name:\s*['"`]([^'"`]+)['"`]/g)) {
205 const name = m[1] ?? ''
206 if (name && !out.includes(name)) out.push(name)
207 }
208 return out
209}
210
211// A mod is a plugin.json plus a hooks.json with "modules". Anything else is not one.
212async function inspect($: EngineInterface, dir: string, chatOnly: boolean): Promise<Row | null> {
213 const pluginRaw = await readText($, `${dir}/.claude-plugin/plugin.json`)
214 if (!pluginRaw) return null
215 const hooksJson = parse(await readText($, `${dir}/hooks/hooks.json`))
216 if (!('modules' in hooksJson)) return null
217 const plugin = parse(pluginRaw)
218 const folder = dir.split('/').pop() ?? ''
219 const name = typeof plugin.name === 'string' && plugin.name ? plugin.name : folder
220 if (!name || name === 'mods') return null
221 const source = await hooksSource($, dir)
222 const known = MODS.find(m => m.name === name)
223 const commands = commandsIn(source).map(c => `/${c}`)
224 return {
225 name,
226 title: known?.title ?? titleCase(name),
227 what: known?.what ?? (typeof plugin.description === 'string' ? plugin.description : ''),
228 tip: known?.tip ?? commands.join(', '),
229 pane: known?.pane ?? false,
230 hide: known?.hide ?? false,
231 ask: known?.ask ?? false,
232 switchable: source.includes('switches.json'),
233 chatOnly,
234 }
235}
236
237// Known mods first in their old order, then new ones A to Z, then this chat's own.
238async function scan($: EngineInterface): Promise<Row[]> {
239 const home = homeOf($)
240 const found = new Map<string, Row>()
241 const dirs: [string, boolean][] = []
242 for (const d of await listDirs($, `${home}/.claude/mods`)) dirs.push([d, false])
243 for (const d of await envDirs($)) dirs.push([d, false])
244 for (const d of await cacheDirs($, home)) dirs.push([d, false])
245 if (sid) for (const d of await listDirs($, `${home}/.claude/dev-mods/${sid}`)) dirs.push([d, true])
246 const seen = new Set<string>()
247 for (const [dir, chatOnly] of dirs) {
248 if (seen.has(dir)) continue
249 seen.add(dir)
250 const row = await inspect($, dir, chatOnly)
251 if (row && !found.has(row.name)) found.set(row.name, row)
252 }
253 const rows: Row[] = MODS.map(m => {
254 const hit = found.get(m.name)
255 found.delete(m.name)
256 return { ...m, switchable: hit ? hit.switchable : true, chatOnly: hit ? hit.chatOnly : false }
257 })
258 const rest = [...found.values()].sort((a, b) => a.title.localeCompare(b.title))
259 return [...rows, ...rest.filter(r => !r.chatOnly), ...rest.filter(r => r.chatOnly)]
260}
261
262// The scan is kept for 10 seconds, so drawing stays fast.
263async function getRows($: EngineInterface, force = false) {
264 const now = await $.clock.now()
265 if (!force && cache && now - cache.at < SCAN_MS) return cache.rows
266 const rows = await scan($)
267 cache = { at: now, rows }
268 return rows
269}
270
271const sigOf = (rows: readonly Row[]) => rows.map(r => `${r.name}|${r.title}|${r.what}|${r.tip}|${r.switchable}|${r.chatOnly}`).join('\n')
272
273// While the pane is open, look again every 10 seconds and redraw when the list changed.
274async function scanPoll($: EngineInterface) {
275 let open = false
276 try {
277 open = (await $.ui.panes()).some(p => p.id === PANE)
278 } catch {
279 open = false
280 }
281 if (!open) return
282 const before = sigOf(cache?.rows ?? [])
283 const rows = await getRows($, true)
284 if (sigOf(rows) !== before) $.ui.invalidate('ui.render')
285}
286
287// Another chat may flip a switch: redraw when the file changes.
288async function poll($: EngineInterface) {
289 const raw = await readRaw($)
290 if (raw === lastRaw) return
291 lastRaw = raw
292 $.ui.invalidate('ui.render')
293}
294
295export const register: Register = on => {
296 on('session.start', async ($, e, next) => {
297 try {
298 sid = await $.session.id()
299 cache = null
300 await $.command.register({ name: 'mods', description: 'Open the Mods pane: turn each mod on or off, or show or hide it, in every chat', immediate: true })
301 lastRaw = await readRaw($)
302 $.clock.every(POLL_MS, () => {
303 void poll($).catch(err => log($, 'poll', err))
304 })
305 $.clock.every(SCAN_MS, () => {
306 void scanPoll($).catch(err => log($, 'scan', err))
307 })
308 } catch (err) {
309 await log($, 'session start', err)
310 }
311 return next(e)
312 })
313
314 on('command.run', { command: 'mods' }, async $ => {
315 try {
316 await getRows($)
317 } catch (err) {
318 await log($, 'scan on open', err)
319 }
320 await $.ui.open({ id: PANE, title: TITLE })
321 return { text: 'Opened the Mods pane.' }
322 })
323
324 // Presses come here, not to onPress: a closure lives for one drawing only.
325 on('ui.press', { plugin: 'mods', element: /^toggle-.+$/ }, async ($, e) => {
326 const name = e.element.slice('toggle-'.length)
327 const mod = (await getRows($)).find(m => m.name === name && m.switchable)
328 if (!mod) return { element: e.element }
329 try {
330 const nowOff = await flip($, name)
331 await $.state.set(NOTE, `${mod.title} is ${nowOff ? 'off' : 'on'} in every chat.`)
332 } catch (err) {
333 await log($, `toggle ${name}`, err)
334 await $.state.set(NOTE, `Could not change ${mod.title}. See ~/.claude/mods-logs/mods.log`)
335 }
336 return { element: e.element }
337 })
338
339 on('ui.press', { plugin: 'mods', element: /^show-.+$/ }, async ($, e) => {
340 const name = e.element.slice('show-'.length)
341 const mod = (await getRows($)).find(m => m.name === name && m.hide && m.switchable)
342 if (!mod) return { element: e.element }
343 try {
344 if (isOff(await readSwitches($), name)) {
345 await $.state.set(NOTE, `${mod.title} is off. Turn it on first.`)
346 return { element: e.element }
347 }
348 const nowHidden = await flipHidden($, name)
349 if (!nowHidden && mod.ask) await askOpen($, name)
350 await $.state.set(NOTE, `${mod.title} is ${nowHidden ? 'hidden' : 'shown'} in every chat.`)
351 } catch (err) {
352 await log($, `show ${name}`, err)
353 await $.state.set(NOTE, `Could not change ${mod.title}. See ~/.claude/mods-logs/mods.log`)
354 }
355 return { element: e.element }
356 })
357
358 // Open: for a side pane that is On and Shown, asks this chat to open it again.
359 on('ui.press', { plugin: 'mods', element: /^open-.+$/ }, async ($, e) => {
360 const name = e.element.slice('open-'.length)
361 const mod = (await getRows($)).find(m => m.name === name && m.pane && m.switchable)
362 if (!mod) return { element: e.element }
363 try {
364 const json = await readSwitches($)
365 if (isOff(json, name) || isHidden(json, name)) {
366 await $.state.set(NOTE, `${mod.title} is ${isOff(json, name) ? 'off' : 'hidden'}. Turn it on and show it first.`)
367 return { element: e.element }
368 }
369 await askOpen($, name)
370 await $.state.set(NOTE, `${mod.title} opens in this chat within 3 seconds.`)
371 } catch (err) {
372 await log($, `open ${name}`, err)
373 await $.state.set(NOTE, `Could not open ${mod.title}. See ~/.claude/mods-logs/mods.log`)
374 }
375 return { element: e.element }
376 })
377
378 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
379 const { Box, Text, Button } = $.ui.resolve(e)
380 const noop = () => undefined
381 const note = (await $.state.get(NOTE)).value ?? ''
382 const json = await readSwitches($)
383 const cols = Math.max(24, e.props.bodyColumns)
384 const left = Math.max(10, cols - BUTTONS_COLS)
385
386 let list: Row[] = []
387 try {
388 list = await getRows($)
389 } catch (err) {
390 await log($, 'scan', err)
391 list = MODS.map(m => ({ ...m, switchable: true, chatOnly: false }))
392 }
393
394 const rows = list.map(m => {
395 const off = m.switchable && isOff(json, m.name)
396 const hidden = isHidden(json, m.name)
397 const width = m.switchable ? left : cols
398 const about = (
399 <Box flexDirection="column" width={width}>
400 <Text bold dimColor={off} wrap="truncate-end">
401 {cut(m.title, width)}
402 </Text>
403 {m.what ? (
404 <Text dimColor wrap="truncate-end">
405 {cut(m.what, width)}
406 </Text>
407 ) : null}
408 {m.tip ? (
409 <Text dimColor italic wrap="truncate-end">
410 {cut(m.tip, width)}
411 </Text>
412 ) : null}
413 {m.chatOnly ? (
414 <Text dimColor italic wrap="truncate-end">
415 This chat only
416 </Text>
417 ) : null}
418 {m.switchable ? null : (
419 <Text dimColor wrap="wrap">
420 {NOT_SWITCHABLE}
421 </Text>
422 )}
423 </Box>
424 )
425 if (!m.switchable) {
426 return (
427 <Box key={`row-${m.name}`} flexDirection="row" marginTop={1}>
428 {about}
429 </Box>
430 )
431 }
432 return (
433 <Box key={`row-${m.name}`} flexDirection="row" justifyContent="space-between" gap={1} marginTop={1}>
434 {about}
435 <Box flexDirection="row" gap={1}>
436 {m.pane ? (
437 off || hidden ? (
438 <Text dimColor>Open</Text>
439 ) : (
440 <Button key={`open-${m.name}`} onPress={noop}>
441 Open
442 </Button>
443 )
444 ) : null}
445 {m.hide ? (
446 off ? (
447 <Text dimColor>{hidden ? 'Hidden' : 'Shown'}</Text>
448 ) : (
449 <Button key={`show-${m.name}`} onPress={noop}>
450 {hidden ? <Text dimColor>Hidden</Text> : <Text color={GOOD}>Shown</Text>}
451 </Button>
452 )
453 ) : null}
454 <Button key={`toggle-${m.name}`} onPress={noop}>
455 {off ? <Text dimColor>Off</Text> : <Text color={GOOD}>On</Text>}
456 </Button>
457 </Box>
458 </Box>
459 )
460 })
461
462 return (
463 <Box flexDirection="column" width={cols}>
464 {rows}
465 <Box marginTop={1} flexDirection="column">
466 {note ? (
467 <Text color={GOOD} wrap="truncate-end">
468 {cut(note, cols)}
469 </Text>
470 ) : null}
471 <Text dimColor wrap="wrap">
472 Off means off in every chat. Hidden keeps it on but out of sight. Changes apply within 3 seconds.
473 </Text>
474 <Text dimColor wrap="wrap">
475 New mods appear here by themselves.
476 </Text>
477 </Box>
478 </Box>
479 )
480 })
481}
482types/index.d.ts 9 lines1/** A short line under the list after a press: "Cache Clock is off in every chat." */
2export type Note = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 mods: { note: Note }
7 }
8}
9