A Claude Code companion: a Markdown kanban board pane (/board), a "please look" queue, a mood sprite, and rate-limit usage bars

A Claude Code mod that adds a side pane next to the transcript. The pane shows:

This screenshot is from the author's own setup, with the Japanese UI. The character art in it belongs to the author: it is not part of this repository and is not covered by the MIT license. To use your own character, see Add your own character.
This is an unofficial personal project. Anthropic did not make or endorse it.
Run /board to open a pane that shows the In Progress, Blocked and Review entries of your Markdown kanban board.
The pane has three tabs: This project, All and Backlog. This project shows only the entries whose tags are linked to the folder where you started Claude Code. See Your board file for how to link them.
In a folder that is not linked, there is no This project tab, and All is shown as Board.
The mod gives Claude a tool called ask_to_look. When Claude finishes something you should check, such as a report, a page or an image, it adds the item to the list and opens it right away. See Opening files for what it opens and what it does not.
The list appears only on the leftmost tab. On This project, it shows only the items added from the folder where you started Claude Code, or from a folder above or below it. In a folder that is not linked, the leftmost tab is Board, and it shows the items added from all sessions.
Press Open to view an item again, or Done to remove it. The list keeps the 15 most recent items and drops older ones.
For the five-hour and weekly rate-limit windows, the bars show how much you have used and when each window resets. A bar turns red above 80%.
A pixel-art character sits at the bottom of the pane. It shows a thinking face while Claude works, and when a turn ends it smiles, looks worried or stays calm, depending on the words in Claude's reply. While a skill you choose (sleep by default) is running, it sleeps, and it wakes up when you send the next message.
A one-line band appears above the prompt. See The band for what it shows.
claude --version to check)The pane appears only in a terminal session (claude) and in the Code tab of the Claude Desktop app. In the VS Code extension, claude -p and cloud sessions, the mod runs but draws nothing. Plugins do not load in WSL sessions of the Desktop app.
claude plugin marketplace add MaryCache/claude-code-companion-mod
claude plugin install companion@claude-code-companion-mod
Restart Claude Code, or run /reload-plugins in an open session. Then create your board file (see the next section) and run /board.
The pane opens by itself only when the terminal is at least 144 columns wide and in the full-screen layout. Otherwise, run /board to open it.
To see what the mod does before you install it, clone the repository and run claude plugin validate ./plugins/companion. The hooks: and calls: lines show the events it handles and what it asks Claude Code to do.
claude plugin marketplace update claude-code-companion-mod
claude plugin update companion@claude-code-companion-mod
The update takes effect when you restart Claude Code.
claude plugin uninstall companion@claude-code-companion-mod
claude plugin marketplace remove claude-code-companion-mod
Uninstalling leaves behind the files written outside the plugin. These are two files in ~/.claude/companion/: look-queue.json (the "please look" list, written by the mod) and, if you added a character, sprites.json (written by the sprite tool) (in CLAUDE_CONFIG_DIR/companion/ if you set CLAUDE_CONFIG_DIR). If you do not need them, delete the folder.
rm -r ~/.claude/companion
The board file is ~/.claude/board.md by default (or board.md in CLAUDE_CONFIG_DIR if you set it). Only unchecked - [ ] entries are shown, and other sections, such as Done, are not read.
## Backlog
- [ ] [web][docs] **Write the onboarding guide** — anything after the title is ignored
## In Progress
- [ ] [api] **Rate limiter for the public API**
## Blocked
- [ ] [api] **Payment webhook retries**
## Review
- [ ] [web] **Pricing page redesign**
## Done
- [x] [web] **Landing page**
## Folders
| Tags | Folder |
|---|---|
| `[web]` `[docs]` | `~/projects/site` |
| `[api]` | `~/projects/api` |
## Backlog, ## In Progress, ## Blocked, ## Review and ## Folders (Japanese headings also work: 着手前, 進行中, 保留, レビュー待ち and フォルダ).- [ ] (lines that start with * [ ] or are indented are not read).[...] at the start of an entry (written together, as in [web][docs], or with spaces between them). — , (, 、 or 。, cut at 40 characters).The Folders table links tags to folders. When you start Claude Code in a listed folder, or in a folder inside it, the This project tab shows only the entries with those tags. In folders that are not listed, the tabs are Board and Backlog.
The mod reads the file again every minute and at the end of each turn. If you want Claude to keep the board up to date, tell it where the file is, for example in your CLAUDE.md.
Run /plugin configure companion@claude-code-companion-mod to open the settings dialog. The same dialog opens when you install the plugin from /plugin.
| Setting | Default | What it does |
|---|---|---|
| Board file | empty (reads ~/.claude/board.md) | Path to the board file. ~ is expanded |
| Language | auto | en or ja for the UI text. auto checks LC_ALL, LC_MESSAGES and LANG, in that order |
| Character name | Companion | Name shown in the band |
| Sleep skill | sleep | The character sleeps while this skill runs |
| UTC offset | the machine's offset | Time zone for times in the pane, such as +09:00. Set it if the times are hours off |
| Open the pane automatically | on | Opens the pane when a session starts. /board works either way |
When Claude calls ask_to_look, the mod opens the item right away. The mod answers that tool call itself, so it does not go through Claude Code's permission prompt. Because of this, the mod opens only these kinds of targets:
| Target | How it is opened |
|---|---|
| URLs | http:// and https:// only, in the system's default app |
Images and PDFs (png, jpg, jpeg, gif, webp, bmp, pdf) | In the system's default app |
Web pages and SVG (html, htm, svg) | In the system's default app, only when the file is inside the folder where you started Claude Code |
Text (md, markdown, txt, csv, json) | In VS Code (code) if you have it, otherwise in the system's default app |
| Other files | In VS Code only (not opened if code is not installed) |
| Folders and paths that do not exist | Refused |
The system's default app is opened with open on macOS, xdg-open on Linux and explorer.exe on WSL.
Web pages and SVG are limited because, opened as local files, the JavaScript inside them can read other files on the same computer in some browsers. A page opened from an http(s) URL runs on its own site and cannot read your local files. When such a file is outside the folder where you started Claude Code, the mod adds it to the list but does not open it; it opens when you press Open.
A symbolic link is judged by the name and kind of the file it points to. On WSL, URLs and paths that contain a comma are refused, because explorer.exe splits its arguments at commas and one target could be opened as two.
xdg-open may choose the app from the file's contents, depending on the desktop. A file named .png that contains HTML could open in a browser (not tested by the author).~, every web page and SVG under it opens automatically.No character art comes with the mod, so the pane shows no character until you add one.
idle.png, think.png, smile.png, worry.png, wave.png and sleep.png, and a prefix such as pip-smile.png also works. If a mood is missing, idle is used instead.pip install into the system Python is refused (PEP 668), so install Pillow in a virtual environment. ``bash python3 -m venv ~/.venvs/companion-sprites ~/.venvs/companion-sprites/bin/pip install pillow git clone https://github.com/MaryCache/claude-code-companion-mod ~/.venvs/companion-sprites/bin/python claude-code-companion-mod/plugins/companion/tools/build_sprites.py ~/my-sprites --preview preview.png ` This writes ~/.claude/companion/sprites.json. To keep only the top of the images, such as the head and shoulders, add --rows N`.| Situation | Mood |
|---|---|
| The sleep skill is running | sleep |
| Claude is working | think |
| The reply ends with a question for you | think |
| Apology words (sorry, failed, mistake…) are at least as many as success words | worry |
| Success words (done, fixed, passed…) are more | smile |
| Neither kind of word appears | idle |
| No turn has finished yet in this session, between 5:00 and 11:00 | wave |
Words near the end of the reply count twice, because the conclusion usually comes last. Both Japanese and English words are counted.
The mood comes only from these words, so it is sometimes wrong.
From left to right, the band shows:
After you hide the band, run /board to show it again.
cd plugins/companion
claude plugin test . # tests that run without a session
npx -y -p typescript tsc --noEmit -p . # needs .claude-plugin/types/, which Claude Code writes
# the first time a session loads the plugin
claude plugin validate --strict .
To try your changes without installing them, start Claude Code with claude --plugin-dir ./plugins/companion.
hooks/register.tsx handles events, files and processes, and pane.tsx and band.tsx draw the pane and the band. The other logic, such as parsing the board, choosing the mood, deciding how to open files and reading the sprites, is in the other files in hooks/.
The code is under the MIT license. The character art in docs/screenshot-ja.png belongs to the author and is not covered by the license. You may not reuse it.
hooks/register.tsx 530 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, RenderInput } from 'claude-code'
3
4import type { Board, Limit, LookItem, LookQueue, SpriteSet, View } from '../types'
5import { prepareAsk } from './ask'
6import { bandView } from './band'
7import { parseBoard } from './board'
8import { messages, type Lang } from './i18n'
9import { addItem, markOpened, parseQueue, removeItem } from './look'
10import { moodFromReply, moodOf } from './mood'
11import { ACTIVE_CONTENT_EXTENSIONS, COMMAND_TIMEOUT_MS, detectPlatform, extensionOf, planOpen, type OpenDecision, type OpenPlan } from './open'
12import { isInside } from './paths'
13import { paneView } from './pane'
14import { scopeOf, type Scoped } from './scope'
15import { buildRuntime, readSettings, type Runtime } from './settings'
16import { isSleepCommand, isSleepSkill } from './sleep'
17import { MAX_SPRITE_FILE_BYTES, parseSprites } from './sprites'
18import { LOOK_TOOL, PANE } from './constants'
19import { utcOffsetMinutes } from './time'
20import { usageRows } from './usage'
21
22// The engine follows `$` and the state atoms only within this file, so the atoms below and everything that
23// touches the machine (files, commands, the pane) live here; the pure parts are in the neighbouring modules.
24//
25// Reactive state, shared by the pane and the band (the types are declared in types/index.d.ts).
26const board = atom({ plugin: 'companion', key: 'board' } as const, null)
27const last = atom({ plugin: 'companion', key: 'last' } as const, null)
28const isBandHidden = atom({ plugin: 'companion', key: 'isBandHidden' } as const, false)
29const isWorking = atom({ plugin: 'companion', key: 'isWorking' } as const, false)
30const replyMood = atom({ plugin: 'companion', key: 'replyMood' } as const, null)
31const isSleeping = atom({ plugin: 'companion', key: 'isSleeping' } as const, false)
32const limits = atom({ plugin: 'companion', key: 'limits' } as const, [] as Limit[])
33const look = atom({ plugin: 'companion', key: 'look' } as const, { items: [] })
34const sprites = atom({ plugin: 'companion', key: 'sprites' } as const, {} as SpriteSet)
35// The default is the project tab: a board shared by every project buries the rows of the one you opened.
36const view = atom({ plugin: 'companion', key: 'view' } as const, 'project' as View)
37
38/**
39 * The board file can be edited outside the conversation (by the model, by hand), so it is re-read
40 * every minute as well as at the end of each turn. The look queue and the sprite file's modification
41 * time are checked on the same beat.
42 */
43const REFRESH_MS = 60_000
44
45/** State one activation keeps between events. It is an argument rather than a variable of `register` because `$` can only be passed to functions declared at the top of this file. */
46type Cell = {
47 runtime: Promise<Runtime> | undefined
48 /** The sprite file's modification time as of the last load, shared by the timer and the Reload button. -2 until the first load. */
49 spritesMtime: number
50}
51
52/**
53 * The runtime (settings plus environment), resolved once per activation: the environment cannot change
54 * meanwhile and the pane renders often.
55 */
56function runtimeOf($: EngineInterface, options: PluginOptions, cell: Cell): Promise<Runtime> {
57 // Each `$.env.get` takes a string literal on purpose: the engine reads the names a module uses off its
58 // source, and a name passed through a variable makes the module fail to load.
59 cell.runtime ??= (async () =>
60 buildRuntime(readSettings(options), {
61 home: (await $.env.get('HOME')) || undefined,
62 claudeConfigDir: await $.env.get('CLAUDE_CONFIG_DIR'),
63 lcAll: await $.env.get('LC_ALL'),
64 lcMessages: await $.env.get('LC_MESSAGES'),
65 lang: await $.env.get('LANG'),
66 }))().catch((error: unknown) => {
67 // A rejected promise must not stay cached, or every later event would fail the same way for the rest of the session.
68 cell.runtime = undefined
69 throw error
70 })
71
72 return cell.runtime
73}
74
75/** Re-reads the board into state. If the read fails the previous value stays (a file caught mid-write must not blank the pane). */
76async function refreshBoard($: EngineInterface, rt: Runtime): Promise<void> {
77 if (rt.boardFile === undefined) return
78 try {
79 const parsed: Board = parseBoard(await $.fs.read(rt.boardFile), await $.clock.now())
80 await update($, board, () => parsed)
81 } catch {
82 // Keep showing the previous value. If the file was never readable, the pane says so.
83 }
84}
85
86/** The result of reading the queue file. */
87type QueueRead =
88 | { state: 'ok'; queue: LookQueue }
89 /** The file exists but is unreadable or not a queue. It must be left untouched. */
90 | { state: 'broken' }
91 /** There is no place to keep a queue (no config directory). */
92 | { state: 'unavailable' }
93
94/** Reads the queue file. A missing file is an empty queue; an existing file that cannot be used is `broken`. */
95async function readQueue($: EngineInterface, rt: Runtime): Promise<QueueRead> {
96 if (rt.queueFile === undefined) return { state: 'unavailable' }
97 try {
98 if (!(await $.fs.exists(rt.queueFile))) return { state: 'ok', queue: { items: [] } }
99 const queue = parseQueue(await $.fs.read(rt.queueFile))
100
101 return queue === null ? { state: 'broken' } : { state: 'ok', queue }
102 } catch {
103 return { state: 'broken' }
104 }
105}
106
107/**
108 * Writes a file through a temporary neighbour and `mv`, so a reader (another session) never sees a
109 * half-written file. The file API has no rename, hence the `mv` command; where it is unavailable the
110 * file is written in place instead, which is what a plain write would have done anyway.
111 */
112async function writeAtomic($: EngineInterface, path: string, text: string): Promise<void> {
113 const temp = `${path}.${Math.random().toString(36).slice(2, 8)}.tmp`
114 await $.fs.write(temp, text)
115 try {
116 const moved = await $.process.run(['mv', '-f', '--', temp, path], { timeoutMs: COMMAND_TIMEOUT_MS })
117 if (moved.exitCode === 0) return
118 } catch {
119 // Fall through to the in-place write.
120 }
121 await $.fs.write(path, text)
122 await $.process.run(['rm', '-f', '--', temp], { timeoutMs: COMMAND_TIMEOUT_MS }).catch(() => undefined)
123}
124
125/** What a queue change reports: done, or why nothing was written. */
126type QueueChange = 'ok' | 'broken' | 'unavailable'
127
128/**
129 * Applies a change to the queue file. It reads the file again just before writing, because another
130 * session may have added items since the last read; a broken file is never overwritten.
131 *
132 * There is no locking: two sessions changing the queue at the same instant can lose one of the updates
133 * (read-modify-write). The window is milliseconds and a lost item is only re-listed, so a lock was not worth its failure modes.
134 */
135async function changeQueue($: EngineInterface, rt: Runtime, change: (queue: LookQueue) => LookQueue): Promise<QueueChange> {
136 const current = await readQueue($, rt)
137 if (current.state !== 'ok') return current.state
138 if (rt.queueFile === undefined) return 'unavailable'
139 const next = change(current.queue)
140 await writeAtomic($, rt.queueFile, `${JSON.stringify(next, null, 2)}\n`)
141 await update($, look, () => next)
142
143 return 'ok'
144}
145
146/** Re-reads the queue on the board's interval, to pick up items other sessions added. A broken or unavailable queue shows nothing. */
147async function refreshLook($: EngineInterface, rt: Runtime): Promise<void> {
148 const current = await readQueue($, rt)
149 await update($, look, () => (current.state === 'ok' ? current.queue : { items: [] }))
150}
151
152/** Reads the sprite file into state. Missing, unreadable or invalid gives no art. Remembers the file's mtime (-1 when it cannot be stat-ed). */
153async function loadSprites($: EngineInterface, rt: Runtime, cell: Cell): Promise<void> {
154 let set: SpriteSet = {}
155 let mtimeMs = -1
156 if (rt.spritesFile !== undefined) {
157 try {
158 const stat = await $.fs.stat(rt.spritesFile)
159 mtimeMs = stat.mtimeMs
160 // Size first: reading and decoding an oversized file would stall the pane. Its mtime is still
161 // remembered, so the timer does not retry it until the file changes.
162 if (stat.size <= MAX_SPRITE_FILE_BYTES) set = parseSprites(await $.fs.read(rt.spritesFile))
163 } catch {
164 set = {}
165 }
166 }
167 cell.spritesMtime = mtimeMs
168 await update($, sprites, () => set)
169}
170
171/** Whether the sprite file changed since the last load: a cheap `stat` only, so it can run on the timer. A file that appeared or vanished counts. */
172async function hasSpritesChanged($: EngineInterface, rt: Runtime, cell: Cell): Promise<boolean> {
173 if (rt.spritesFile === undefined) return false
174 try {
175 return (await $.fs.stat(rt.spritesFile)).mtimeMs !== cell.spritesMtime
176 } catch {
177 return cell.spritesMtime !== -1
178 }
179}
180
181/** Whether a command can be started, judged by `which`. */
182async function hasCommand($: EngineInterface, command: string): Promise<boolean> {
183 try {
184 return (await $.process.run(['which', command], { timeoutMs: COMMAND_TIMEOUT_MS })).exitCode === 0
185 } catch {
186 return false
187 }
188}
189
190/**
191 * Where a path really leads: its kind and its fully resolved path (every symbolic link followed), or
192 * undefined when it does not exist, is a dangling link, or the engine withheld the resolved path.
193 * Every decision about a path is made on this resolved path, never on the name that was given: a link
194 * called `a.png` can point at a `.command` file.
195 */
196async function resolvePath($: EngineInterface, path: string): Promise<{ kind: 'file' | 'dir' | 'other'; realPath: string } | undefined> {
197 try {
198 const stat = await $.fs.stat(path, { resolve: true })
199
200 return stat.realPath === undefined ? undefined : { kind: stat.kind, realPath: stat.realPath }
201 } catch {
202 return undefined
203 }
204}
205
206/** Whether `realPath` lies in the session folder (itself resolved, so a linked project folder compares like with like). */
207async function isInSessionFolder($: EngineInterface, realPath: string): Promise<boolean> {
208 const folder = await resolvePath($, await $.session.cwd())
209
210 return folder !== undefined && folder.kind === 'dir' && isInside(realPath, folder.realPath)
211}
212
213/**
214 * Judges whether a target may be opened at all, without opening it. A path is resolved with
215 * `$.fs.stat` (it must exist; the resolved path's kind and extension decide), a URL is only parsed.
216 * Shared by `ask_to_look`, which must decide before it lists anything, and the Open button.
217 *
218 * `isAutomatic` is true for `ask_to_look`: html, htm and svg then open only inside the session folder.
219 * The Open button passes false, because pressing it is the user's own decision.
220 *
221 * The plan carries the resolved path, not the name given, so the opener gets the file that was judged.
222 */
223async function decideOpen(
224 $: EngineInterface,
225 target: string,
226 isAutomatic: boolean,
227): Promise<{ decision: OpenDecision; isMissing: boolean }> {
228 let subject = target
229 let kind: 'file' | 'dir' | 'other' = 'file'
230 let isActiveAllowed = true
231 if (target.startsWith('/')) {
232 const real = await resolvePath($, target)
233 if (real === undefined) return { decision: { ok: false, reason: 'invalid' }, isMissing: true }
234 subject = real.realPath
235 kind = real.kind
236 // Looked up only for the types it matters for.
237 if (isAutomatic && kind === 'file' && ACTIVE_CONTENT_EXTENSIONS.has(extensionOf(subject))) {
238 isActiveAllowed = await isInSessionFolder($, subject)
239 }
240 }
241 const uname = await $.process.run(['uname', '-s'], { timeoutMs: COMMAND_TIMEOUT_MS }).then(
242 ran => ran.stdout,
243 () => '',
244 )
245 const platform = detectPlatform(await $.env.get('WSL_DISTRO_NAME'), uname)
246 // `code` is only looked up when it can matter.
247 const hasCode = subject.startsWith('/') && kind === 'file' ? await hasCommand($, 'code') : false
248
249 return { decision: planOpen(platform, subject, hasCode, kind, isActiveAllowed), isMissing: false }
250}
251
252/**
253 * Runs a plan. On WSL a file path is converted with `wslpath -w` first, because `explorer.exe` does not
254 * understand Linux paths. Resolves to undefined when it ran, or to the reason when it did not.
255 */
256async function runPlan($: EngineInterface, plan: OpenPlan, lang: Lang): Promise<string | undefined> {
257 try {
258 const [command, argument] = plan.argv
259 let where = argument
260 if (plan.convertWslPath) {
261 const converted = await $.process.run(['wslpath', '-w', argument], { timeoutMs: COMMAND_TIMEOUT_MS })
262 where = converted.stdout.trim()
263 if (converted.exitCode !== 0 || where === '') {
264 return `wslpath failed (exit ${converted.exitCode}): ${converted.stderr.trim() || 'no output'}`
265 }
266 // Planning already refused commas, but the converted form is what explorer.exe parses.
267 if (where.includes(',')) return messages(lang).refusal('comma-on-wsl')
268 }
269 const ran = await $.process.run([command, where], { timeoutMs: COMMAND_TIMEOUT_MS })
270
271 return !plan.isExitCodeReliable || ran.exitCode === 0 ? undefined : `${command} exited with ${ran.exitCode}: ${ran.stderr.trim()}`
272 } catch (error) {
273 return error instanceof Error ? error.message : String(error)
274 }
275}
276
277/** Opens a queue item (the Open button). A refusal or a failure is shown as a toast and the item stays unread. */
278async function openItem($: EngineInterface, rt: Runtime, item: LookItem): Promise<void> {
279 const m = messages(rt.lang)
280 const { decision } = await decideOpen($, item.target, false)
281 const failure = decision.ok ? await runPlan($, decision.plan, rt.lang) : m.refusal(decision.reason)
282 if (failure !== undefined) {
283 $.ui.toast(m.openFailed(failure))
284 return
285 }
286 const openedAt = await $.clock.now()
287 await changeQueue($, rt, queue => markOpened(queue, item.id, openedAt))
288}
289
290/**
291 * The `ask_to_look` tool: lists a deliverable in the pane and opens it right away when the allowlist
292 * (open.ts) permits.
293 *
294 * The tool handler answers without calling `next`, so the engine's permission check never sees the
295 * call. That is why the opener is limited to files that are safe to hand to the system and why
296 * directories are refused: the model chooses `target`, and a launcher must never run what it names.
297 *
298 * Order: validate -> add to the queue -> open. A file the allowlist does not cover (and no editor to
299 * open it) is still listed so the user can find it, but is not opened, and the result says so.
300 */
301async function askToLook($: EngineInterface, rt: Runtime, input: { target?: unknown; title?: unknown; note?: unknown }) {
302 const m = messages(rt.lang)
303 const asked = prepareAsk(input, m)
304 if ('deny' in asked) return { deny: asked.deny }
305
306 const { decision, isMissing } = await decideOpen($, asked.target, true)
307 if (isMissing) return { deny: m.toolBadTarget(asked.target) }
308 if (!decision.ok && decision.reason === 'not-a-file') return { deny: m.toolNotFile(asked.target) }
309
310 const now = await $.clock.now()
311 const item: LookItem = {
312 id: `${now.toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
313 title: asked.title,
314 // A URL is listed in its normalised form, so the same page in two spellings is one item.
315 target: asked.classified.kind === 'url' ? asked.classified.href : asked.target,
316 ...(asked.note ? { note: asked.note } : {}),
317 addedAt: now,
318 cwd: await $.session.cwd(),
319 }
320 const saved = await changeQueue($, rt, queue => addItem(queue, item))
321 if (saved === 'unavailable') return { deny: m.toolNoQueue }
322 if (saved === 'broken') return { deny: m.toolQueueBroken(rt.queueFile ?? '') }
323 if (!decision.ok) return { result: m.toolAddedNotOpened(asked.title, m.refusal(decision.reason)) }
324 const failure = await runPlan($, decision.plan, rt.lang)
325
326 return { result: failure === undefined ? m.toolAdded(asked.title) : m.toolAddedButFailed(asked.title, failure) }
327}
328
329/** Gathers the state both the pane and the band show, scoped to the open folder and the selected tab. */
330async function readScoped($: EngineInterface, rt: Runtime): Promise<Scoped> {
331 return scopeOf(await read($, board), await read($, look), await $.session.cwd(), rt.home, await read($, view))
332}
333
334/** Copies an absolute path to the clipboard and reports the result in a toast, so you know it landed before pasting. */
335async function copyPath($: EngineInterface, rt: Runtime, path: string, surface: RenderInput<'Pane'>['surface']): Promise<void> {
336 const m = messages(rt.lang)
337 const copied = await $.ui.copy({ text: path, surface })
338 $.ui.toast(copied.isCopied ? m.pathCopied(path) : m.copyFailed(copied.reason))
339}
340
341/**
342 * The companion mod: keeps the board's in-progress / blocked / review sections and the "please look"
343 * queue in a side pane, and shows a one-line band above the prompt with the last turn's duration.
344 * This file wires the events and does the file and command work; drawing and the pure rules are in
345 * the neighbouring modules.
346 */
347export const register: Register = (on, options) => {
348 // Settings come from the manifest's userConfig and are fixed for this activation (a change reloads the mod).
349 const settings = readSettings(options)
350 const cell: Cell = { runtime: undefined, spritesMtime: -2 }
351 // A reload in the middle of a turn restarts these counts; the band is only a rough guide, so that is accepted.
352 let startedAt = 0
353 let tools = 0
354
355 on('session.start', async ($, e, next) => {
356 const rt = await runtimeOf($, options, cell)
357 const m = messages(rt.lang)
358 try {
359 await $.command.register({ name: 'board', description: m.commandDescription })
360 await $.tool.register({
361 name: 'ask_to_look',
362 description: m.toolDescription,
363 inputSchema: {
364 type: 'object',
365 properties: {
366 target: { type: 'string', description: m.toolTargetDescription },
367 title: { type: 'string', description: m.toolTitleDescription },
368 note: { type: 'string', description: m.toolNoteDescription },
369 },
370 required: ['target', 'title'],
371 },
372 })
373 } catch (error) {
374 // Without the command or the tool the pane and the timer are still worth starting; say what is missing.
375 $.ui.toast(m.setupFailed(error instanceof Error ? error.message : String(error)))
376 }
377 await refreshBoard($, rt)
378 await refreshLook($, rt)
379 await loadSprites($, rt, cell)
380 try {
381 // A fresh session has no earlier values, so read the engine's last report (often empty until the first response).
382 const { rateLimits } = await $.session.usage()
383 await update($, limits, () => rateLimits)
384 } catch {
385 // Usage is a nicety; the pane and the timer must still start without it.
386 }
387 $.clock.every(REFRESH_MS, async () => {
388 await refreshBoard($, rt)
389 await refreshLook($, rt)
390 if (await hasSpritesChanged($, rt, cell)) await loadSprites($, rt, cell)
391 })
392 // A pane opened unasked only docks beside the conversation at 144 columns or more; /board opens it otherwise.
393 if (settings.isAutoOpen) {
394 void $.ui.open({ id: PANE, title: m.paneTitle })
395 }
396
397 return next(e)
398 })
399
400 // The command also brings the status line back after Hide: there is no other way to un-hide it.
401 on('command.run', { command: 'board' }, async $ => {
402 const rt = await runtimeOf($, options, cell)
403 await update($, isBandHidden, () => false)
404 await refreshBoard($, rt)
405 await $.ui.open({ id: PANE, title: messages(rt.lang).paneTitle })
406
407 return { text: messages(rt.lang).commandReply }
408 })
409
410 on('prompt.submit', async ($, e, next) => {
411 startedAt = await $.clock.now()
412 tools = 0
413 await update($, isWorking, () => true)
414 // Speaking wakes the character. When the sleep command itself was typed, skill.prompt puts it back to sleep right after.
415 if (!isSleepCommand(e.text, settings.sleepSkill)) {
416 await update($, isSleeping, () => false)
417 }
418
419 return next(e)
420 })
421
422 // Fires every time a limit window moves by a point; the pane redraws from the state change.
423 on('session.measure', async ($, e, next) => {
424 if (e.changed.includes('rateLimits')) {
425 await update($, limits, () => e.rateLimits)
426 }
427
428 return next(e)
429 })
430
431 // The matcher cannot be configured at runtime, so the skill name is compared inside the handler.
432 // Both the typed slash command and the Skill tool expand the skill, so both pass through here.
433 on('skill.prompt', async ($, e, next) => {
434 if (isSleepSkill(e.skill, settings.sleepSkill)) {
435 await update($, isSleeping, () => true)
436 }
437
438 return next(e)
439 })
440
441 on('tool.call', async ($, e, next) => {
442 tools += 1
443
444 return next(e)
445 })
446
447 on('tool.call', { tool: LOOK_TOOL }, async ($, e) => askToLook($, await runtimeOf($, options, cell), e))
448
449 on('turn.complete', async ($, e, next) => {
450 // Sub-agent turns also end here. Clearing "working" for one would switch the mood back in the
451 // middle of the parent's turn.
452 if (e.agentId !== undefined) {
453 return next(e)
454 }
455 const rt = await runtimeOf($, options, cell)
456 // After a reload in mid-turn startedAt is 0, and "now minus 0" would be decades; keep the previous record instead.
457 if (startedAt !== 0) {
458 const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
459 await update($, last, () => ({ seconds, tools }))
460 }
461 await update($, isWorking, () => false)
462 // The mood comes from the reply's wording, not the turn time (time clashed with the content).
463 const rows = await $.session.messages()
464 const lastReply = [...rows].reverse().find(row => row.role === 'assistant' && row.text.trim())
465 await update($, replyMood, () => (lastReply ? moodFromReply(lastReply.text) : null))
466 // The board is often edited during a turn, so re-read it at the end.
467 await refreshBoard($, rt)
468
469 return next(e)
470 })
471
472 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
473 const rt = await runtimeOf($, options, cell)
474 const kit = $.ui.resolve(e)
475 const now = await $.clock.now()
476 const offset = rt.utcOffset ?? utcOffsetMinutes(now)
477 const mood = moodOf(await read($, isWorking), await read($, last), now, await read($, replyMood), await read($, isSleeping), offset)
478 const cwd = await $.session.cwd()
479
480 return paneView({
481 kit,
482 raster: e.surface === 'terminal' ? $.ui.resolve(e).Raster : undefined,
483 m: messages(rt.lang),
484 width: e.props.bodyColumns,
485 bodyRows: e.props.scroll.bodyRows,
486 sprite: (await read($, sprites))[mood],
487 cwd,
488 home: rt.home,
489 boardFile: rt.boardFile,
490 shown: await readScoped($, rt),
491 usage: usageRows(await read($, limits), now, messages(rt.lang), offset),
492 offset,
493 actions: {
494 selectTab: tab => update($, view, () => tab),
495 copyCwd: surface => copyPath($, rt, cwd, surface),
496 // Reload re-reads everything the pane shows from files: the board, the queue and the art.
497 reload: async () => {
498 await refreshBoard($, rt)
499 await refreshLook($, rt)
500 await loadSprites($, rt, cell)
501 },
502 open: item => openItem($, rt, item),
503 done: id => changeQueue($, rt, queue => removeItem(queue, id)),
504 },
505 })
506 })
507
508 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
509 if (e.props.hasSurvey || (await read($, isBandHidden))) {
510 return next(e)
511 }
512 const rt = await runtimeOf($, options, cell)
513 const now = await $.clock.now()
514
515 return bandView({
516 kit: $.ui.resolve(e),
517 m: messages(rt.lang),
518 name: rt.name,
519 isWorking: e.props.isWorking,
520 turn: await read($, last),
521 now,
522 offset: rt.utcOffset ?? utcOffsetMinutes(now),
523 // The band counts the same scope as the pane, so switching tabs changes its numbers.
524 board: (await readScoped($, rt)).board,
525 openPane: () => $.ui.open({ id: PANE, title: messages(rt.lang).paneTitle }),
526 hide: () => update($, isBandHidden, () => true),
527 })
528 })
529}
530hooks/ask.ts 27 lines1import type { Messages } from './i18n'
2import { MAX_NOTE, MAX_TITLE, cleanLine, isPlainTarget } from './look'
3import { classifyTarget, type TargetKind } from './open'
4
5/** The `ask_to_look` input after cleaning, or the text to refuse it with. */
6export type AskInput = { deny: string } | { title: string; note: string; target: string; classified: Extract<TargetKind, { kind: 'url' | 'path' }> }
7
8/**
9 * Cleans and checks what the model passed to `ask_to_look`, before anything touches the machine.
10 * The values are typed as strings but the model controls them, so each is checked at runtime. Title and
11 * note are cleaned (control characters become spaces, lengths are capped). The target is never
12 * rewritten, because a rewritten path names a different file: one with control characters, newlines or
13 * more than 2000 characters is refused, and so is one that is neither an absolute path nor a
14 * well-formed http(s) URL.
15 */
16export function prepareAsk(input: { target?: unknown; title?: unknown; note?: unknown }, m: Messages): AskInput {
17 const title = cleanLine(input.title, MAX_TITLE)
18 if (!title) return { deny: m.toolEmptyTitle }
19 const target = input.target
20 if (typeof target !== 'string' || target === '') return { deny: m.toolBadTarget(m.toolEmptyReceived) }
21 if (!isPlainTarget(target)) return { deny: m.toolTargetNotPlain }
22 const classified = classifyTarget(target)
23 if (classified.kind === 'invalid' || classified.kind === 'invalid-url') return { deny: m.toolBadTarget(target) }
24
25 return { title, note: cleanLine(input.note, MAX_NOTE), target, classified }
26}
27hooks/band.tsx 68 lines1import type { RenderElement } from 'claude-code'
2
3import type { Board, Turn } from '../types'
4import type { Messages } from './i18n'
5import type { Kit } from './view-types'
6import { ACCENT } from './constants'
7import { hourOf } from './time'
8
9/** A turn longer than this gets a remark in the band. Two minutes is about when waiting starts to be felt. */
10const LONG_TURN_SECONDS = 120
11
12/** Picks the greeting from the local hour. */
13function greeting(now: number, m: Messages, utcOffset: number): string {
14 const hour = hourOf(now, utcOffset)
15 if (hour >= 5 && hour < 11) return m.greetingMorning
16 if (hour >= 11 && hour < 17) return m.greetingDay
17 if (hour >= 17 && hour < 24) return m.greetingEvening
18
19 return m.greetingNight
20}
21
22/** Band text. Priority: working, the previous turn, then a greeting. */
23export function bandLine(isWorking: boolean, turn: Turn | null, now: number, m: Messages, utcOffset = 0): string {
24 if (isWorking) {
25 return m.bandWorking
26 }
27 if (turn === null) {
28 return greeting(now, m, utcOffset)
29 }
30
31 return m.bandFinished(m.duration(turn.seconds), turn.tools, turn.seconds > LONG_TURN_SECONDS)
32}
33
34/** Everything the band draws, gathered by register.tsx. */
35export type BandInput = {
36 kit: Kit
37 m: Messages
38 name: string
39 isWorking: boolean
40 turn: Turn | null
41 now: number
42 /** Offset from UTC in minutes. */
43 offset: number
44 /** The board as scoped like the pane, for the counts. */
45 board: Board | null
46 openPane: () => unknown
47 hide: () => unknown
48}
49
50/** Draws the one-line band above the prompt: name, the status text, the counts, and the Board and Hide buttons. */
51export function bandView(p: BandInput): RenderElement {
52 const { Box, Button, Text } = p.kit
53 const { m, board } = p
54
55 return (
56 <Box>
57 <Text color={ACCENT}>◦ {p.name} </Text>
58 <Text dimColor wrap="truncate-end">
59 {bandLine(p.isWorking, p.turn, p.now, m, p.offset)}
60 {board !== null && m.bandCounts(board.inProgress.length, board.review.length)}
61 </Text>
62 <Button key="board" label={m.bandBoardButton} onPress={p.openPane} />
63 <Text> </Text>
64 <Button key="hide" label={m.bandHideButton} onPress={p.hide} />
65 </Box>
66 )
67}
68hooks/board.ts 160 lines1import { isInside } from './paths'
2import type { Board, BoardItem } from '../types'
3
4/**
5 * Board file format (Markdown):
6 *
7 * ```markdown
8 * ## Backlog
9 * - [ ] [web][docs] **Write the onboarding guide** — notes after the dash are ignored
10 * ## In Progress
11 * - [ ] [api] **Rate limiter**
12 * ## Blocked
13 * ## Review
14 * ## Done
15 * - [x] ... (ignored)
16 *
17 * ## Folders
18 * | Tags | Folder |
19 * |---|---|
20 * | `[web]` `[docs]` | `~/projects/site` |
21 * | `[api]` | `~/projects/api` |
22 * ```
23 *
24 * Section headings are English (`Backlog`, `In Progress`, `Blocked`, `Review`) or Japanese
25 * (`着手前`, `進行中`, `保留`, `レビュー待ち`). Only unchecked `- [ ]` entries are read; any other
26 * section (such as Done) is ignored. The folder table is headed `## Folders` or `## フォルダ`.
27 */
28
29/** Section heading to board field. */
30const SECTIONS: Record<string, 'backlog' | 'inProgress' | 'blocked' | 'review'> = {
31 Backlog: 'backlog',
32 'In Progress': 'inProgress',
33 Blocked: 'blocked',
34 Review: 'review',
35 着手前: 'backlog',
36 進行中: 'inProgress',
37 保留: 'blocked',
38 レビュー待ち: 'review',
39}
40
41/** Headings of the folder table. Its rows are read until the next `## ` heading. */
42const FOLDER_HEADINGS = ['## Folders', '## フォルダ']
43
44/**
45 * Extracts the unfinished entries of Backlog / In Progress / Blocked / Review from the board text.
46 *
47 * Keeping the description would not fit on one pane line, so only the tags and the bold title are kept.
48 * Entries in the older format without bold use the leading text after the tags instead.
49 */
50export function parseBoard(text: string, loadedAt: number): Board {
51 const board: Board = { backlog: [], inProgress: [], blocked: [], review: [], folders: parseTagFolders(text), loadedAt }
52 let section: (typeof SECTIONS)[string] | null = null
53
54 for (const line of text.split('\n')) {
55 const heading = /^## (.+?)\s*$/.exec(line)
56 if (heading) {
57 section = SECTIONS[heading[1] ?? ''] ?? null
58 continue
59 }
60 if (section === null || !line.startsWith('- [ ] ')) {
61 continue
62 }
63 board[section].push(parseItem(line.slice('- [ ] '.length)))
64 }
65
66 return board
67}
68
69function parseItem(body: string): BoardItem {
70 const tags: string[] = []
71 let rest = body
72 // Tags may be written `[a][b]` or `[a] [b]`; the pattern eats the whitespace before each.
73 let tag = /^\s*\[([^\]]+)\]/.exec(rest)
74 while (tag) {
75 tags.push(tag[1] ?? '')
76 rest = rest.slice(tag[0].length)
77 tag = /^\s*\[([^\]]+)\]/.exec(rest)
78 }
79
80 rest = rest.trim()
81 // Bold is the title only at the start of the line. In the older format a bold span mid-sentence
82 // describes status ("M1-M4 done"), not the title.
83 const bold = /^\*\*(.+?)\*\*/.exec(rest)
84 // Title fallback (no bold at the start): the text after the tags, up to the first " — ", "(", "、" or "。",
85 // with `**` removed, cut to 40 characters (roughly one pane line).
86 const title = bold?.[1] ?? rest.split(/ — |(|、|。/)[0]?.replaceAll('**', '').slice(0, 40) ?? ''
87
88 return { tags, title }
89}
90
91/**
92 * Reads the folder-to-tags mapping from the `## Folders` table.
93 *
94 * Every `[tag]` in the first column is a tag; every backticked path in the second column is a folder
95 * (taken by the backticks rather than a separator because folder names may contain spaces). `~` stays
96 * unexpanded and is resolved against HOME when matching. Rows without a folder are skipped.
97 */
98export function parseTagFolders(text: string): Array<{ folder: string; tags: string[] }> {
99 const lines = text.split('\n')
100 const start = lines.findIndex(line => FOLDER_HEADINGS.includes(line.trim()))
101 if (start < 0) {
102 return []
103 }
104 const pairs: Array<{ folder: string; tags: string[] }> = []
105 for (const line of lines.slice(start + 1)) {
106 if (line.startsWith('## ')) {
107 break
108 }
109 const cells = line.split('|').map(cell => cell.trim())
110 // The leading and trailing | leave empty cells, so a two-column table has 4 cells.
111 if (!line.startsWith('|') || cells.length < 4) {
112 continue
113 }
114 const tags = [...(cells[1] ?? '').matchAll(/\[([^\]]+)\]/g)].map(m => m[1] ?? '')
115 for (const m of (cells[2] ?? '').matchAll(/`([^`]+)`/g)) {
116 // A trailing slash would make a prefix test look for `//`, so the folder never matched. A cell
117 // that is only slashes is the root and stays `/` (stripping it would leave an empty folder).
118 const raw = m[1] ?? ''
119 const trimmed = raw.replace(/\/+$/, '')
120 pairs.push({ folder: trimmed === '' && raw.startsWith('/') ? '/' : trimmed, tags })
121 }
122 }
123
124 return pairs
125}
126
127/** Replaces a leading `~` with HOME. A path without `~`, or an unknown HOME, is returned as is. */
128export function expandHome(path: string, home: string | undefined): string {
129 return home && (path === '~' || path.startsWith('~/')) ? `${home}${path.slice(1)}` : path
130}
131
132/**
133 * Collects the tags that apply to the open folder: a row matches when the folder is the row's folder
134 * or lies below it, so opening a subfolder of a project still picks up the project's tags.
135 */
136export function tagsForFolder(folders: Board['folders'], cwd: string, home: string | undefined): Set<string> {
137 const tags = new Set<string>()
138 for (const { folder, tags: rowTags } of folders) {
139 const path = expandHome(folder, home)
140 if (isInside(cwd, path)) {
141 for (const tag of rowTags) tags.add(tag)
142 }
143 }
144
145 return tags
146}
147
148/** Keeps only the entries carrying at least one of the given tags. */
149export function filterBoard(board: Board, tags: Set<string>): Board {
150 const keep = (items: BoardItem[]) => items.filter(item => item.tags.some(tag => tags.has(tag)))
151
152 return {
153 ...board,
154 backlog: keep(board.backlog),
155 inProgress: keep(board.inProgress),
156 blocked: keep(board.blocked),
157 review: keep(board.review),
158 }
159}
160hooks/i18n.ts 261 lines1import type { OpenRefusal } from './open'
2
3/** UI languages. Add a language by adding a table below; nothing else needs to change. */
4export type Lang = 'en' | 'ja'
5
6/** Every user-visible string. Entries that depend on a value are functions. */
7export type Messages = {
8 paneTitle: string
9 /** Description of the slash command that opens the pane. */
10 commandDescription: string
11 commandReply: string
12
13 tabProject: string
14 tabAll: string
15 /** The only non-backlog tab when the folder maps to no tags. */
16 tabBoard: string
17 tabBacklog: string
18
19 usageHeading: string
20 usageEmpty: string
21 /** Labels of the known limit windows; unknown kinds are shown as their raw name. */
22 limitFiveHour: string
23 limitSevenDay: string
24 usageUntil: (time: string) => string
25
26 folderLabel: string
27 lookHeading: (count: number) => string
28 open: string
29 done: string
30
31 sectionInProgress: string
32 sectionBlocked: string
33 sectionReview: string
34 sectionBacklog: string
35 sectionEmpty: string
36 reload: string
37 readAt: (clock: string) => string
38 boardUnreadable: (path: string) => string
39 /** Shown when there is no board path to read (HOME is not set and no board path is configured). */
40 boardNoPath: string
41
42 bandWorking: string
43 bandFinished: (duration: string, tools: number, isLong: boolean) => string
44 bandCounts: (inProgress: number, review: number) => string
45 bandBoardButton: string
46 bandHideButton: string
47 greetingMorning: string
48 greetingDay: string
49 greetingEvening: string
50 greetingNight: string
51 /** Duration of a turn, e.g. `42s` or `2m30s`. */
52 duration: (seconds: number) => string
53
54 pathCopied: (path: string) => string
55 copyFailed: (reason: string) => string
56 openFailed: (reason: string) => string
57 /** Why a target was not opened (a refusal of the allowlist, see open.ts). */
58 refusal: (reason: OpenRefusal) => string
59
60 toolDescription: string
61 toolTargetDescription: string
62 toolTitleDescription: string
63 toolNoteDescription: string
64 toolEmptyTitle: string
65 /** The target has control characters, a line break, or is too long; it is refused as it is, never rewritten. */
66 toolTargetNotPlain: string
67 /** Registering the command or the tool failed at session start; the pane and the timer still start. */
68 setupFailed: (reason: string) => string
69 toolBadTarget: (received: string) => string
70 toolAdded: (title: string) => string
71 toolAddedButFailed: (title: string, reason: string) => string
72 toolEmptyReceived: string
73 /** The target is a directory or another non-file. */
74 toolNotFile: (received: string) => string
75 /** Listed but not opened, because the allowlist does not cover it. */
76 toolAddedNotOpened: (title: string, reason: string) => string
77 /** Neither CLAUDE_CONFIG_DIR nor HOME is set, so there is nowhere to keep the queue. */
78 toolNoQueue: string
79 toolQueueBroken: (path: string) => string
80}
81
82const en: Messages = {
83 paneTitle: 'Board',
84 commandDescription: 'Open the board (in progress, blocked, review) in a pane, and show the status line again if it was hidden',
85 commandReply: 'Opened the board in a pane.',
86
87 tabProject: 'This project',
88 tabAll: 'All',
89 tabBoard: 'Board',
90 tabBacklog: 'Backlog',
91
92 usageHeading: 'Usage',
93 usageEmpty: ' not reported yet',
94 limitFiveHour: '5h',
95 limitSevenDay: 'week',
96 usageUntil: time => `until ${time}`,
97
98 folderLabel: 'Folder ',
99 lookHeading: count => `Please look ${count}`,
100 open: 'Open',
101 done: 'Done',
102
103 sectionInProgress: 'In progress',
104 sectionBlocked: 'Blocked',
105 sectionReview: 'Review',
106 sectionBacklog: 'Backlog',
107 sectionEmpty: ' none',
108 reload: 'Reload',
109 readAt: clock => `read at ${clock} `,
110 boardUnreadable: path => `Could not read the board (${path}).`,
111 boardNoPath: 'No board file: HOME is not set. Set the board path in the plugin settings.',
112
113 bandWorking: 'thinking…',
114 bandFinished: (duration, tools, isLong) =>
115 `Done in ${duration} · ${tools} ${tools === 1 ? 'tool call' : 'tool calls'}${isLong ? ' · that was a long one' : ''}`,
116 bandCounts: (inProgress, review) => ` · in progress ${inProgress} · review ${review} `,
117 bandBoardButton: 'Board',
118 bandHideButton: 'Hide',
119 greetingMorning: 'Good morning',
120 greetingDay: 'Good afternoon',
121 greetingEvening: 'Good evening',
122 greetingNight: 'Working late?',
123 duration: seconds => (seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`),
124
125 pathCopied: path => `Copied the path: ${path}`,
126 copyFailed: reason => `Could not copy (${reason})`,
127 openFailed: reason => `Could not open: ${reason}`,
128 refusal: reason =>
129 ({
130 invalid: 'not an absolute path or an http(s) URL',
131 'invalid-url': 'not a well-formed http(s) URL',
132 'not-a-file': 'not a regular file (a folder or an app bundle is never opened)',
133 'needs-editor': 'this file type is only opened in the code editor (`code`), which is not installed',
134 'comma-on-wsl': 'the target contains a comma, which Windows Explorer would split into two arguments (not opened on WSL)',
135 'outside-session': 'html, htm and svg files can run scripts, so they open automatically only inside the session folder; press Open in the pane to open it yourself',
136 })[reason],
137
138 toolDescription:
139 'Call this when a deliverable is ready for the user to read or look at. It adds the item to the ' +
140 '"please look" list in the pane and opens it right away: HTML files, images, PDFs and http(s) URLs in ' +
141 'the default app, and other files in the code editor. HTML and SVG files are opened only when they are ' +
142 'inside the session folder (elsewhere they are listed and the user opens them). A file type that only the editor may open is ' +
143 'listed but not opened when the editor is missing; folders are refused. The item stays until the user ' +
144 'presses Done, so it can be reopened later. Do not use it for drafts, config files, memory notes or the board itself. Passing the same ' +
145 'target again updates the title and moves it back to the top (when you fix something and show it again).',
146 toolTargetDescription: 'An absolute path, or an http(s) URL (for example an artifact link)',
147 toolTitleDescription: 'A short title for the list (about 40 characters)',
148 toolNoteDescription: 'What to look at (optional, one line)',
149 toolEmptyTitle: 'title is empty. Pass a short title for the list.',
150 toolTargetNotPlain: 'target must be a single line with no control characters, at most 2000 characters. It was not changed or added.',
151 setupFailed: reason => `Could not register the /board command or the ask_to_look tool: ${reason}`,
152 toolBadTarget: received => `target must be an existing absolute path or an http(s) URL (received: ${received}).`,
153 toolAdded: title => `Added to the "please look" list and opened: ${title}`,
154 toolAddedButFailed: (title, reason) => `Added to the "please look" list, but opening failed: ${title} (reason: ${reason})`,
155 toolEmptyReceived: 'empty',
156 toolNotFile: received => `target must be a regular file, not a folder or an app bundle (received: ${received}).`,
157 toolAddedNotOpened: (title, reason) => `Added to the "please look" list, but not opened: ${title} (reason: ${reason})`,
158 toolNoQueue: 'Nothing was added: the queue has no place to live (neither CLAUDE_CONFIG_DIR nor HOME is set).',
159 toolQueueBroken: path => `Nothing was added: the queue file could not be read, and it was left untouched (${path}).`,
160}
161
162const ja: Messages = {
163 paneTitle: '看板',
164 commandDescription: '看板(進行中・保留・レビュー待ち)をパネルで開く。隠した帯があれば戻す',
165 commandReply: '看板をパネルで開いたよ。',
166
167 tabProject: 'このプロジェクト',
168 tabAll: '全体',
169 tabBoard: '看板',
170 tabBacklog: '着手前',
171
172 usageHeading: '使用量',
173 usageEmpty: ' まだ届いてない',
174 limitFiveHour: '5時間',
175 limitSevenDay: '週',
176 usageUntil: time => `${time}まで`,
177
178 folderLabel: '場所 ',
179 lookHeading: count => `見てほしい ${count}`,
180 open: '開く',
181 done: '済',
182
183 sectionInProgress: '進行中',
184 sectionBlocked: '保留',
185 sectionReview: 'レビュー待ち',
186 sectionBacklog: '着手前',
187 sectionEmpty: ' なし',
188 reload: '読み直す',
189 readAt: clock => `${clock} に読んだ `,
190 boardUnreadable: path => `看板を読めなかったよ(${path})。`,
191 boardNoPath: '看板のファイルが分からないよ。HOME が未設定なので、プラグイン設定で看板のパスを指定して。',
192
193 bandWorking: '考えてるね…',
194 bandFinished: (duration, tools, isLong) =>
195 `おわったよ ${duration} · ツール ${tools} 回${isLong ? '・ちょっと長かったね' : ''}`,
196 bandCounts: (inProgress, review) => ` · 進行中 ${inProgress} · レビュー待ち ${review} `,
197 bandBoardButton: '看板',
198 bandHideButton: '隠す',
199 greetingMorning: 'おはよう',
200 greetingDay: 'こんにちは',
201 greetingEvening: 'こんばんは',
202 greetingNight: '夜ふかしだね',
203 duration: seconds => (seconds < 60 ? `${seconds}秒` : `${Math.floor(seconds / 60)}分${seconds % 60}秒`),
204
205 pathCopied: path => `パスをコピーしたよ: ${path}`,
206 copyFailed: reason => `コピーできなかった(${reason})`,
207 openFailed: reason => `開けなかった: ${reason}`,
208 refusal: reason =>
209 ({
210 invalid: '絶対パスでも http(s) の URL でもない',
211 'invalid-url': '正しい形の http(s) の URL ではない',
212 'not-a-file': '通常のファイルではない(フォルダやアプリは開かない)',
213 'needs-editor': 'この種類のファイルはコードエディタ(code)でだけ開くが、入っていない',
214 'comma-on-wsl': '対象にカンマが含まれていて、Windows のエクスプローラーが2つの引数に割ってしまう(WSL では開かない)',
215 'outside-session': 'html・htm・svg はスクリプトが動くため、自動で開くのは session のフォルダの中だけ。自分で開くならパネルの「開く」を押して',
216 })[reason],
217
218 toolDescription:
219 '読んでほしい・見てほしい成果物ができたときに呼ぶ。パネルの「見てほしい」一覧に足し、' +
220 'その場で開く(HTML・画像・PDF・http(s) の URL は既定のアプリ、それ以外のファイルはコードエディタ。' +
221 'HTML と SVG は session のフォルダの中にあるものだけ開き、外にあるものは一覧に足すだけで、ユーザーが自分で開く)。' +
222 'エディタでしか開けない種類でエディタが無いときは、一覧に足すだけで開かない。フォルダは断る。一覧は「済」を押すまで残り、' +
223 'あとから開き直せる。途中の下書き・設定ファイル・メモ・看板には使わない。' +
224 '同じ対象をもう一度渡すと、題を更新して一覧の先頭に戻す(直して見せ直すとき)。',
225 toolTargetDescription: '絶対パス、または http(s) の URL(Artifact のリンクなど)',
226 toolTitleDescription: '一覧に出す短い題(20字前後)',
227 toolNoteDescription: '何を見てほしいか(任意・1行)',
228 toolEmptyTitle: 'title が空だよ。一覧に出す短い題を渡して。',
229 toolTargetNotPlain: 'target は改行や制御文字のない1行で、2000文字までにして。書き換えもしていないし、足してもいない。',
230 setupFailed: reason => `/board コマンドか ask_to_look ツールを登録できなかった: ${reason}`,
231 toolBadTarget: received => `target は実在する絶対パスか http(s) の URL にして(受け取ったもの: ${received})。`,
232 toolAdded: title => `「見てほしい」に足して開いたよ: ${title}`,
233 toolAddedButFailed: (title, reason) => `「見てほしい」に足したけど、開くのに失敗した: ${title}(理由: ${reason})`,
234 toolEmptyReceived: '空',
235 toolNotFile: received => `target は通常のファイルにして。フォルダやアプリは受け付けない(受け取ったもの: ${received})。`,
236 toolAddedNotOpened: (title, reason) => `「見てほしい」に足したけど、開かなかった: ${title}(理由: ${reason})`,
237 toolNoQueue: '何も足していないよ。一覧を置く場所がない(CLAUDE_CONFIG_DIR も HOME も未設定)。',
238 toolQueueBroken: path => `何も足していないよ。一覧のファイルを読めなかったので、そのまま残してある(${path})。`,
239}
240
241/** The message tables, keyed by language. */
242export const MESSAGES: Record<Lang, Messages> = { en, ja }
243
244/**
245 * Chooses the UI language. An explicit setting wins when it names a supported language; otherwise the
246 * first non-empty locale variable decides, in POSIX order (`LC_ALL`, `LC_MESSAGES`, `LANG`): `ja_JP.UTF-8`
247 * gives Japanese, and anything else gives English.
248 */
249export function resolveLang(explicitLang: string | undefined, ...systemLangs: Array<string | undefined>): Lang {
250 const explicit = explicitLang?.trim().toLowerCase()
251 if (explicit === 'en' || explicit === 'ja') return explicit
252 const system = systemLangs.find(value => value !== undefined && value.trim() !== '')
253
254 return system?.toLowerCase().startsWith('ja') ? 'ja' : 'en'
255}
256
257/** The message table for a language. */
258export function messages(lang: Lang): Messages {
259 return MESSAGES[lang]
260}
261hooks/look.ts 131 lines1import type { LookItem, LookQueue } from '../types'
2import { isInside } from './paths'
3
4/**
5 * Queue size limit. Past it the oldest entries drop off, so forgetting to press Done never lets the
6 * list grow. 15 fits in the pane without pushing the board out (30 filled the whole pane).
7 */
8export const MAX_ITEMS = 15
9
10/** Longest accepted values of the `ask_to_look` fields. A model could otherwise fill the pane (or the file) with one call. */
11export const MAX_TITLE = 120
12export const MAX_NOTE = 300
13export const MAX_TARGET = 2000
14/** Longest accepted item id (ours are about 16 characters). */
15const MAX_ID = 64
16
17// The classes are written as escapes on purpose: stripping control characters is the point.
18const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/
19/**
20 * Bidirectional controls (embeddings, overrides, isolates, directional marks including the Arabic
21 * letter mark) and invisible characters (zero-width space, joiners, word joiner, BOM). They can reorder
22 * or hide characters, so a title or target could read as something other than what is stored.
23 */
24const BIDI = /[\u202a-\u202e\u2066-\u2069\u200e\u200f\u061c\u200b-\u200d\u2060\ufeff]/
25
26/**
27 * Makes text safe for one line of the pane: control characters (newlines, tabs, escape sequences)
28 * become spaces, bidirectional controls are removed, runs of spaces collapse, and the result is
29 * trimmed and cut to `max` characters.
30 */
31export function cleanLine(value: unknown, max: number): string {
32 if (typeof value !== 'string') return ''
33
34 return value
35 .replace(new RegExp(BIDI.source, 'g'), '')
36 .replace(new RegExp(`${CONTROLS.source}+`, 'g'), ' ')
37 .replace(/ {2,}/g, ' ')
38 .trim()
39 .slice(0, max)
40}
41
42/**
43 * Whether a `target` can be taken as it is: a string of at most `MAX_TARGET` characters with no control
44 * or bidirectional characters. A target is never rewritten (a rewritten path names a different file),
45 * so one that fails this is refused (`ask_to_look`) or dropped (the queue file).
46 */
47export function isPlainTarget(value: unknown): value is string {
48 return typeof value === 'string' && value.length <= MAX_TARGET && !CONTROLS.test(value) && !BIDI.test(value)
49}
50
51/**
52 * Keeps the entries that have the three required strings and rebuilds each from known fields only,
53 * with the same cleaning and caps `ask_to_look` applies (the file can be edited by hand or by another
54 * tool). An entry whose id or title is empty after cleaning, or whose target is not plain, is dropped.
55 */
56function validItem(value: unknown): LookItem | undefined {
57 if (typeof value !== 'object' || value === null) return undefined
58 const raw: Record<string, unknown> = { ...value }
59 const id = cleanLine(raw['id'], MAX_ID)
60 const title = cleanLine(raw['title'], MAX_TITLE)
61 const target = raw['target']
62 if (id === '' || title === '' || !isPlainTarget(target)) return undefined
63 const note = cleanLine(raw['note'], MAX_NOTE)
64 const cwd = cleanLine(raw['cwd'], MAX_TARGET)
65
66 return {
67 id,
68 title,
69 target,
70 ...(note ? { note } : {}),
71 addedAt: typeof raw['addedAt'] === 'number' && Number.isFinite(raw['addedAt']) ? raw['addedAt'] : 0,
72 ...(typeof raw['openedAt'] === 'number' && Number.isFinite(raw['openedAt']) ? { openedAt: raw['openedAt'] } : {}),
73 ...(cwd ? { cwd } : {}),
74 }
75}
76
77/**
78 * Reads the queue JSON. Items without a usable `id`, `title` and `target` are dropped (the pane
79 * would fail to draw them), and the rest are rebuilt from the known fields.
80 *
81 * Returns null when a non-empty text cannot be read as a queue at all (broken JSON, or no `items`
82 * array). The caller must then leave the file alone instead of writing an empty queue over what may be
83 * a half-written or hand-edited file. A blank text is an empty queue.
84 */
85export function parseQueue(text: string): LookQueue | null {
86 if (text.trim() === '') return { items: [] }
87 try {
88 const data: unknown = JSON.parse(text)
89 const items = typeof data === 'object' && data !== null && 'items' in data ? data.items : undefined
90 if (!Array.isArray(items)) return null
91 // Trim on read too, in case the limit was lowered or the file edited by hand; the file itself
92 // shrinks at the next write.
93 return { items: items.flatMap(one => validItem(one) ?? []).slice(0, MAX_ITEMS) }
94 } catch {
95 return null
96 }
97}
98
99/**
100 * Adds one item. If the same target is already listed, it moves to the top with the new title and
101 * becomes unread again (the usual case is fixing a deliverable and showing it again, so no duplicates).
102 */
103export function addItem(queue: LookQueue, item: LookItem): LookQueue {
104 const rest = queue.items.filter(one => one.target !== item.target)
105
106 return { items: [item, ...rest].slice(0, MAX_ITEMS) }
107}
108
109/** Removes an item (the Done button). */
110export function removeItem(queue: LookQueue, id: string): LookQueue {
111 return { items: queue.items.filter(one => one.id !== id) }
112}
113
114/** Records when Open was pressed; the pane dims opened items. */
115export function markOpened(queue: LookQueue, id: string, at: number): LookQueue {
116 return { items: queue.items.map(one => (one.id === id ? { ...one, openedAt: at } : one)) }
117}
118
119/**
120 * Narrows the queue to the open folder's items. An item matches when its folder equals the open one
121 * or either lies below the other (an item added at a project root stays visible from a subfolder
122 * session). Items without a recorded folder never match.
123 */
124export function itemsForFolder(queue: LookQueue, cwd: string): LookItem[] {
125 return queue.items.filter(
126 one =>
127 one.cwd !== undefined &&
128 (isInside(one.cwd, cwd) || isInside(cwd, one.cwd)),
129 )
130}
131hooks/mood.ts 93 lines1import type { Mood, ReplyMood, Turn } from '../types'
2import { hourOf } from './time'
3
4/**
5 * Every mood the pane can show, as a runtime list. The sprite generator reads this list, so it is the
6 * single source of the names; the `Mood` type is declared in types/index.d.ts (that file may not import)
7 * and the check below makes the compiler fail when the two disagree in either direction.
8 */
9export const MOODS = ['idle', 'think', 'smile', 'worry', 'wave', 'sleep'] as const satisfies readonly Mood[]
10
11type Assert<T extends true> = T
12/** Fails to compile when a `Mood` is missing from `MOODS`. */
13export type MoodsAreComplete = Assert<Mood extends (typeof MOODS)[number] ? true : false>
14
15export type { Mood, ReplyMood }
16
17// Both languages are always matched, whatever the UI language is: replies are often written in a
18// language other than the UI's.
19const WORRY = new RegExp(
20 [
21 'ごめん|すまない|失敗|できなかった|できてなかった|うまくいかなかった|うまくいってない|間違え|間違って|消えてた|壊れ|ずれてた|残念|足りなかった|見落とし',
22 "\\b(sorry|apologi[sz]e|failed|failure|mistake|my bad|i broke|broke|broken|couldn't|could not|unfortunately|missed|overlooked)\\b",
23 ].join('|'),
24 'gi',
25)
26const SMILE = new RegExp(
27 [
28 'できた|できたよ|よかった|通った|完成|完璧|そろった|うまくいった|直した|直ったよ|反映した|成功|ばっちり|ありがとう',
29 '\\b(done|fixed|passed|passing|works|working now|great|thanks|thank you|success(ful)?|completed?|all green|perfect)\\b',
30 ].join('|'),
31 'gi',
32)
33const ASK = new RegExp(
34 [
35 '[??]\\s*$|どうする|決めて|どれにする|選んで|教えて|確認させて|いい[??]|どうかな',
36 '\\b(which (one|option|do|would|should)|should i|let me know|would you like|do you want|any preference)\\b',
37 ].join('|'),
38 'i',
39)
40
41/** The last this-many characters count double: a reply's conclusion usually comes last. */
42const TAIL = 300
43
44/**
45 * Chooses a mood from the wording of a reply.
46 *
47 * It used to follow the turn duration, which clashed with the content (a smile on an apology).
48 * Wording is not always right either, since this is only keyword matching. If scenes turn up that it
49 * misses, the next step is to let a small model classify the reply.
50 *
51 * - A closing paragraph that asks something gives `think` (the decision is handed back to the user).
52 * - Otherwise apology and success words are counted, the tail counting double, and the larger wins.
53 * A tie goes to `worry`: a report that apologises and fixes should read as the apology.
54 * - No keyword at all gives `idle`.
55 */
56export function moodFromReply(text: string): ReplyMood {
57 const body = text.trim()
58 if (!body) return 'idle'
59 const lastParagraph = body.split(/\n\s*\n/).filter(p => p.trim()).at(-1) ?? body
60 if (ASK.test(lastParagraph.trim())) return 'think'
61
62 const tail = body.slice(-TAIL)
63 const count = (re: RegExp, s: string) => (s.match(re) ?? []).length
64 const worry = count(WORRY, body) + count(WORRY, tail)
65 const smile = count(SMILE, body) + count(SMILE, tail)
66 if (worry === 0 && smile === 0) return 'idle'
67
68 return worry >= smile ? 'worry' : 'smile'
69}
70
71/**
72 * The pane's mood. Sleeping wins, then working (think); after a turn the mood from the reply's
73 * wording (moodFromReply). With no reply yet it follows the time of day: wave in the morning
74 * (05:00 up to 11:00), idle otherwise.
75 */
76export function moodOf(
77 working: boolean,
78 turn: Turn | null,
79 now: number,
80 reply: ReplyMood | null = null,
81 sleeping = false,
82 utcOffset = 0,
83): Mood {
84 // The sleep routine also runs as a turn, but the sleeping face should win over the thinking face.
85 if (sleeping) return 'sleep'
86 if (working) return 'think'
87 if (reply !== null) return reply
88 if (turn !== null) return 'idle'
89 const hour = hourOf(now, utcOffset)
90
91 return hour >= 5 && hour < 11 ? 'wave' : 'idle'
92}
93hooks/open.ts 165 lines1/** Where the mod runs, which decides the opener command. */
2export type Platform = 'wsl' | 'macos' | 'linux'
3
4/**
5 * Picks the platform. WSL is told apart by its distro variable because `uname` reports plain Linux
6 * there; otherwise `uname -s` output decides (`Darwin` is macOS, anything else is treated as Linux).
7 */
8export function detectPlatform(wslDistro: string | undefined, uname: string): Platform {
9 if (wslDistro) return 'wsl'
10
11 return uname.trim() === 'Darwin' ? 'macos' : 'linux'
12}
13
14/**
15 * Things to look at, opened in the system's default app: web pages, images and PDF. Nothing else is
16 * ever handed to the system opener, because it would launch executables, scripts, shortcuts and
17 * application bundles (`.exe`, `.bat`, `.lnk`, `.command`, `.desktop`, `.app`) just as readily.
18 */
19export const SYSTEM_VIEW_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'pdf'])
20
21/**
22 * Plain-text documents. They are safe for the system opener too (and open there when the code editor
23 * is missing), but the code editor is preferred because a reader of Markdown or JSON usually wants text.
24 */
25export const SYSTEM_TEXT_EXTENSIONS: ReadonlySet<string> = new Set(['md', 'markdown', 'txt', 'csv', 'json'])
26
27/**
28 * Types that run script when a browser opens them (`.html` and `.svg` can carry JavaScript). Of the
29 * system-opened types these are the only active ones; `ask_to_look` opens them automatically only
30 * inside the session folder.
31 */
32export const ACTIVE_CONTENT_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm', 'svg'])
33
34/** The lower-case extension of a path's last component, without the dot; empty when it has none. */
35export function extensionOf(path: string): string {
36 const name = path.slice(path.lastIndexOf('/') + 1)
37 const dot = name.lastIndexOf('.')
38
39 return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
40}
41
42/**
43 * Normalises an http(s) URL. Returns its `href` (spaces, quotes and other unsafe characters are
44 * percent-encoded by the parser) or undefined for anything that does not parse or uses another scheme.
45 * The caller must pass the returned href on, never the original text.
46 */
47export function parseHttpUrl(target: string): string | undefined {
48 try {
49 const url = new URL(target)
50
51 return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined
52 } catch {
53 return undefined
54 }
55}
56
57/** Whether the target is a URL the mod accepts. Only http(s) is, so the tool is not a way to launch arbitrary schemes. */
58export function isUrl(target: string): boolean {
59 return parseHttpUrl(target) !== undefined
60}
61
62/** What kind of thing the target names, judged from its text alone (no file system access). */
63export type TargetKind =
64 | { kind: 'url'; href: string }
65 | { kind: 'path'; path: string }
66 /** Has a scheme but is not a well-formed http(s) URL (`file:`, `javascript:`, a URL with a space in the host, ...). */
67 | { kind: 'invalid-url' }
68 /** Neither an absolute path nor a URL. */
69 | { kind: 'invalid' }
70
71/**
72 * Classifies a target. An absolute path starts with `/`; anything starting with a scheme is parsed as
73 * a URL and must be http(s). A Windows drive path (`C:\x`) looks like a scheme and is rejected as a URL,
74 * which is right: the mod passes Linux paths only.
75 */
76export function classifyTarget(target: string): TargetKind {
77 if (target.startsWith('/')) return { kind: 'path', path: target }
78 if (/^[a-z][a-z0-9+.-]*:/i.test(target)) {
79 const href = parseHttpUrl(target)
80
81 return href === undefined ? { kind: 'invalid-url' } : { kind: 'url', href }
82 }
83
84 return { kind: 'invalid' }
85}
86
87/** Why a target was not opened. The target may still be listed (`needs-editor`). */
88export type OpenRefusal =
89 | 'invalid'
90 | 'invalid-url'
91 /** A directory or something else that is not a regular file (a macOS `.app` bundle is a directory). */
92 | 'not-a-file'
93 /** The extension is not on the system-opener allowlist and the `code` editor is not installed. */
94 | 'needs-editor'
95 /** On WSL: `explorer.exe` splits its command line on commas, so a target containing one could become two arguments. */
96 | 'comma-on-wsl'
97 /** An html/htm/svg file outside the session folder, which `ask_to_look` lists but does not open by itself. */
98 | 'outside-session'
99
100/** The command to run to open a target, decided without touching the machine. */
101export type OpenPlan = {
102 /** Executable followed by its single argument. For a WSL file the argument is still a Linux path. */
103 argv: [string, string]
104 /** Convert the path with `wslpath -w` before running (WSL system opener on a file). */
105 convertWslPath: boolean
106 /** Whether the exit code means anything. `explorer.exe` returns 1 even when it opened the target. */
107 isExitCodeReliable: boolean
108}
109
110/** The outcome of `planOpen`: a command, or the reason there is none. */
111export type OpenDecision = { ok: true; plan: OpenPlan } | { ok: false; reason: OpenRefusal }
112
113/**
114 * Chooses how to open a target, or refuses.
115 * - A well-formed http(s) URL goes to the system opener (`explorer.exe` on WSL, `open` on macOS,
116 * `xdg-open` on Linux) as the parsed `href`.
117 * - A regular file with a web/image/PDF extension goes to the system opener.
118 * - A regular file with a plain-text extension goes to `code` when it is installed, otherwise the system opener.
119 * - Any other regular file goes to `code` when it is installed, otherwise it is refused.
120 * - Directories and other non-files are refused whatever their name.
121 * - On WSL a target containing a comma is refused, because `explorer.exe` splits its command line there.
122 * - An html/htm/svg file is refused (`outside-session`) when `isActiveAllowed` is false: the caller
123 * passes false for automatic opening outside the session folder, true for the user's own Open press.
124 *
125 * `fileKind` is what `$.fs.stat` said about a path, and a path target must be the *resolved* path
126 * (symlinks followed), so the extension and the kind describe the same file; both are ignored for URLs.
127 */
128export function planOpen(
129 platform: Platform,
130 target: string,
131 hasCode: boolean,
132 fileKind: 'file' | 'dir' | 'other' = 'file',
133 isActiveAllowed = true,
134): OpenDecision {
135 const classified = classifyTarget(target)
136 if (classified.kind === 'invalid-url') return { ok: false, reason: 'invalid-url' }
137 if (classified.kind === 'invalid') return { ok: false, reason: 'invalid' }
138 if (classified.kind === 'url') {
139 return platform === 'wsl' && classified.href.includes(',')
140 ? { ok: false, reason: 'comma-on-wsl' }
141 : { ok: true, plan: systemPlan(platform, classified.href, false) }
142 }
143 if (fileKind !== 'file') return { ok: false, reason: 'not-a-file' }
144 if (platform === 'wsl' && classified.path.includes(',')) return { ok: false, reason: 'comma-on-wsl' }
145
146 const ext = extensionOf(classified.path)
147 if (ACTIVE_CONTENT_EXTENSIONS.has(ext) && !isActiveAllowed) return { ok: false, reason: 'outside-session' }
148 if (SYSTEM_VIEW_EXTENSIONS.has(ext)) return { ok: true, plan: systemPlan(platform, classified.path, true) }
149 if (hasCode) return { ok: true, plan: { argv: ['code', classified.path], convertWslPath: false, isExitCodeReliable: true } }
150 if (SYSTEM_TEXT_EXTENSIONS.has(ext)) return { ok: true, plan: systemPlan(platform, classified.path, true) }
151
152 return { ok: false, reason: 'needs-editor' }
153}
154
155function systemPlan(platform: Platform, argument: string, isFile: boolean): OpenPlan {
156 if (platform === 'wsl') {
157 return { argv: ['explorer.exe', argument], convertWslPath: isFile, isExitCodeReliable: false }
158 }
159
160 return { argv: [platform === 'macos' ? 'open' : 'xdg-open', argument], convertWslPath: false, isExitCodeReliable: true }
161}
162
163/** How long an opener or a helper command may run before it is killed. Openers return as soon as they hand the target to the desktop; `xdg-open` with no handler can hang for the default 30 seconds. */
164export const COMMAND_TIMEOUT_MS = 5000
165hooks/paths.ts 11 lines1/**
2 * Whether `path` is `dir` itself or lies below it. The boundary is a path separator, so `/a/bc` is not
3 * inside `/a/b`, and a root `dir` of `/` contains every absolute path (the naive `${dir}/` prefix would
4 * look for `//`). Both arguments must already be normalised absolute paths; this does not touch the disk.
5 */
6export function isInside(path: string, dir: string): boolean {
7 if (path === dir) return true
8
9 return path.startsWith(dir.endsWith('/') ? dir : `${dir}/`)
10}
11hooks/pane.tsx 255 lines1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3import type { BoardItem, LookItem, Sprite, View } from '../types'
4import type { Messages } from './i18n'
5import { isUrl } from './open'
6import { shortenHome, type Scoped } from './scope'
7import { ACCENT } from './constants'
8import { formatClock } from './time'
9import type { Kit } from './view-types'
10import { USAGE_TRACK, usageBar, usageColor, type UsageRow } from './usage'
11
12/**
13 * Estimate of how many lines the pane content (without the art) takes, used to decide whether the art
14 * can be pinned to the bottom. Every pane text is truncated to one line, so wrapping is not counted.
15 * The blank line after each section (marginBottom) is counted.
16 */
17export function paneRows(p: {
18 tabs: boolean
19 /** Number of usage windows (heading + one line each + margin; an empty state takes one line). Omit for no usage section. */
20 usage?: number
21 folder: boolean
22 look: number
23 sections: BoardItem[][] | null
24}): number {
25 let rows = (p.tabs ? 2 : 0) + (p.usage === undefined ? 0 : 2 + Math.max(p.usage, 1)) + (p.folder ? 2 : 0) + (p.look > 0 ? p.look * 2 + 2 : 0)
26 if (p.sections === null) {
27 return rows + 2
28 }
29 for (const items of p.sections) {
30 const body = items.reduce((sum, item) => sum + (item.tags.length > 0 ? 2 : 1), 0)
31 rows += 1 + Math.max(body, 1) + 1
32 }
33
34 return rows + 1
35}
36
37/** What the pane does when a button is pressed; wired to the engine by register.tsx. */
38export type PaneActions = {
39 selectTab: (tab: View) => unknown
40 /** Copies the open folder's path; `surface` is where the press happened. */
41 copyCwd: (surface: RenderSurface) => unknown
42 /** Re-reads the board, the queue and the sprite file. */
43 reload: () => unknown
44 open: (item: LookItem) => unknown
45 done: (id: string) => unknown
46}
47
48/** Everything the pane draws, gathered by register.tsx. */
49export type PaneInput = {
50 kit: Kit
51 /** Only the terminal surface has Raster; undefined elsewhere. */
52 raster: Elements['terminal']['Raster'] | undefined
53 m: Messages
54 actions: PaneActions
55 /** Pane width and visible height in cells. */
56 width: number
57 bodyRows: number
58 sprite: Sprite | undefined
59 cwd: string
60 home: string | undefined
61 /** The board file; undefined when none could be located (the pane then says so). */
62 boardFile: string | undefined
63 shown: Scoped
64 usage: UsageRow[]
65 /** Offset from UTC in minutes, for the "read at" clock. */
66 offset: number
67}
68
69/** Draws the side pane: tabs, usage bars, the open folder, the "please look" list, the board sections and the art. */
70export function paneView(p: PaneInput): RenderElement {
71 const { Box, Button, Text } = p.kit
72 const { m, home, width, actions, shown, usage, sprite, cwd } = p
73 let art = null
74 // Raster only exists in the terminal for now. In a pane narrower than the art, omit it rather than show it cut off.
75 if (sprite !== undefined && p.raster !== undefined && width >= sprite.columns) {
76 const Raster = p.raster
77 art = (
78 <Box marginTop={1} flexShrink={0}>
79 <Raster key="sprite" columns={sprite.columns} rows={sprite.rows} cells={sprite.cells} />
80 </Box>
81 )
82 }
83
84 const current = shown.board
85 // The open folder (moved here from the band, which is one line wide). Pressing it copies the absolute path.
86 const hasFolder = !shown.hasProject || shown.view === 'project'
87 const folderRow = hasFolder && (
88 <Box marginBottom={1}>
89 <Text dimColor>{m.folderLabel}</Text>
90 <Button key="copy-cwd" label={shortenHome(cwd, home)} onPress={press => actions.copyCwd(press.surface)} />
91 </Box>
92 )
93 // Usage is stacked vertically, one window per row, each with a bar. The bar length is what is left
94 // of the pane width after the label, the percentage and the reset time. Shown on every tab.
95 const barCells = Math.min(Math.max(width - 30, 6), 30)
96 const usageRow = (
97 <Box flexDirection="column" marginBottom={1}>
98 <Text bold color={ACCENT}>
99 {m.usageHeading}
100 </Text>
101 {usage.length === 0 && <Text dimColor>{m.usageEmpty}</Text>}
102 {usage.map(row => {
103 const [filled, part, rest] = usageBar(row.percent, barCells)
104 const fill = usageColor(row.percent, ACCENT)
105
106 return (
107 <Text wrap="truncate-end">
108 {row.label}{' '}
109 <Text color={fill}>{filled}</Text>
110 {/* The partial block leaves its right side transparent, so its background is the track colour to fill the gap */}
111 <Text color={fill} backgroundColor={USAGE_TRACK}>
112 {part}
113 </Text>
114 <Text color={USAGE_TRACK}>{'█'.repeat(rest)}</Text> {`${row.percent}%`.padStart(4)}
115 <Text dimColor>{row.until ? ` ${m.usageUntil(row.until)}` : ''}</Text>
116 </Text>
117 )
118 })}
119 </Box>
120 )
121 // Tabs are always shown. A folder missing from the table has an empty project tab, so it gets Board and Backlog only.
122 const tabList: Array<[View, string]> = shown.hasProject
123 ? [['project', m.tabProject], ['all', m.tabAll], ['backlog', m.tabBacklog]]
124 : [['all', m.tabBoard], ['backlog', m.tabBacklog]]
125 const tabs = (
126 <Box marginBottom={1}>
127 {tabList.map(([key, label]) => (
128 <Box marginRight={1}>
129 {shown.view === key ? (
130 <Text bold color={ACCENT}>
131 [{label}]
132 </Text>
133 ) : (
134 <Button key={`tab-${key}`} label={label} onPress={() => actions.selectTab(key)} />
135 )}
136 </Box>
137 ))}
138 </Box>
139 )
140 // With no items the section is left out, so the usual pane looks like the board alone.
141 const lookSection = shown.items.length > 0 && (
142 <Box flexDirection="column" marginBottom={1}>
143 <Text bold color={ACCENT}>
144 {m.lookHeading(shown.items.length)}
145 </Text>
146 {shown.items.map(item => (
147 <Box flexDirection="column">
148 <Box>
149 <Text wrap="truncate-end" bold={item.openedAt === undefined} dimColor={item.openedAt !== undefined}>
150 {'・'}
151 {item.title}{' '}
152 </Text>
153 <Button key={`open-${item.id}`} label={m.open} onPress={() => actions.open(item)} />
154 <Text> </Text>
155 <Button key={`done-${item.id}`} label={m.done} onPress={() => actions.done(item.id)} />
156 </Box>
157 <Text dimColor wrap="truncate-start">
158 {' '}
159 {item.note ? `${item.note} · ` : ''}
160 {isUrl(item.target) ? item.target : shortenHome(item.target, home)}
161 </Text>
162 </Box>
163 ))}
164 </Box>
165 )
166
167 const rows = paneRows({
168 tabs: true,
169 usage: usage.length,
170 folder: hasFolder,
171 look: shown.items.length,
172 sections:
173 current === null ? null : shown.view === 'backlog' ? [current.backlog] : [current.inProgress, current.blocked, current.review],
174 })
175 // The art sits at the bottom of the pane. When the content fits the window, a spacer pins it to the
176 // bottom; when it overflows, the art follows the content (the pane scrolls the whole tree as one,
177 // so there is no way to keep only the art fixed).
178 const layout = (content: RenderElement) =>
179 art !== null && sprite !== undefined && rows + sprite.rows + 1 <= p.bodyRows ? (
180 <Box flexDirection="column" width={width} height={p.bodyRows}>
181 <Box flexDirection="column" flexGrow={1}>
182 {content}
183 </Box>
184 {art}
185 </Box>
186 ) : (
187 <Box flexDirection="column" width={width}>
188 {content}
189 {art}
190 </Box>
191 )
192
193 if (current === null) {
194 return layout(
195 <Box flexDirection="column">
196 {tabs}
197 {usageRow}
198 {folderRow}
199 {lookSection}
200 <Text dimColor>{p.boardFile === undefined ? m.boardNoPath : m.boardUnreadable(p.boardFile)}</Text>
201 <Button key="reload" label={m.reload} onPress={actions.reload} />
202 </Box>,
203 )
204 }
205
206 const section = (label: string, items: BoardItem[]) => (
207 <Box flexDirection="column" marginBottom={1}>
208 <Text bold color={ACCENT}>
209 {label} {items.length}
210 </Text>
211 {items.length === 0 && <Text dimColor>{m.sectionEmpty}</Text>}
212 {items.map(item => (
213 <Box flexDirection="column">
214 <Text wrap="truncate-end">
215 {'・'}
216 {item.title}
217 </Text>
218 {item.tags.length > 0 && (
219 <Text dimColor wrap="truncate-end">
220 {' '}
221 {item.tags.join(' ')}
222 </Text>
223 )}
224 </Box>
225 ))}
226 </Box>
227 )
228
229 const footer = (
230 <Box>
231 <Text dimColor>{m.readAt(formatClock(current.loadedAt, p.offset))}</Text>
232 <Button key="reload" label={m.reload} onPress={actions.reload} />
233 </Box>
234 )
235
236 return layout(
237 <Box flexDirection="column">
238 {tabs}
239 {usageRow}
240 {folderRow}
241 {lookSection}
242 {shown.view === 'backlog' ? (
243 section(m.sectionBacklog, current.backlog)
244 ) : (
245 <>
246 {section(m.sectionInProgress, current.inProgress)}
247 {section(m.sectionBlocked, current.blocked)}
248 {section(m.sectionReview, current.review)}
249 </>
250 )}
251 {footer}
252 </Box>,
253 )
254}
255hooks/scope.ts 47 lines1import type { Board, LookItem, LookQueue, View } from '../types'
2import { filterBoard, tagsForFolder } from './board'
3import { itemsForFolder } from './look'
4
5/** Abbreviates a path under HOME with `~`. Display only, never used to resolve a path. */
6export function shortenHome(path: string, home: string | undefined): string {
7 if (home && (path === home || path.startsWith(`${home}/`))) {
8 return `~${path.slice(home.length)}`
9 }
10
11 return path
12}
13
14/**
15 * The "please look" items shown on a tab. They appear on the leftmost tab only: the other tabs are for
16 * surveying the board, and the list would crowd it out. A folder missing from the folder table has
17 * Board as its leftmost tab (whose content is everything), so it lists all items. In a project, only
18 * the items added from that folder are listed.
19 */
20export function lookItemsFor(tab: View, hasProject: boolean, queue: LookQueue, cwd: string): LookItem[] {
21 if (tab === 'backlog' || (hasProject && tab === 'all')) return []
22 if (!hasProject) return queue.items
23
24 return itemsForFolder(queue, cwd)
25}
26
27/** What the pane and the band show after the folder and the selected tab are applied. */
28export type Scoped = { board: Board | null; items: LookItem[]; hasProject: boolean; view: View }
29
30/**
31 * Decides what the pane and the band show. When the open folder is in the folder table, the selected
32 * tab applies. Otherwise the project tab would be empty, so the whole board is shown whatever was selected.
33 */
34export function scopeOf(current: Board | null, queue: LookQueue, cwd: string, home: string | undefined, picked: View): Scoped {
35 const tags = current === null ? new Set<string>() : tagsForFolder(current.folders, cwd, home)
36 const hasProject = tags.size > 0
37 // A folder outside the table has no project tab, so a stored "project" choice reads as "all".
38 const chosen: View = picked === 'project' && !hasProject ? 'all' : picked
39 const items = lookItemsFor(chosen, hasProject, queue, cwd)
40 // The Backlog tab shows the whole Backlog even inside a project (unstarted work is meant to be surveyed across projects).
41 if (chosen !== 'project' || current === null) {
42 return { board: current, items, hasProject, view: chosen }
43 }
44
45 return { board: filterBoard(current, tags), items, hasProject, view: chosen }
46}
47hooks/settings.ts 105 lines1import type { PluginOptions } from 'claude-code'
2
3import { expandHome } from './board'
4import { resolveLang, type Lang } from './i18n'
5import { parseUtcOffset } from './time'
6
7/** Defaults of the settings, mirrored in the manifest's `userConfig`. */
8export const DEFAULT_NAME = 'Companion'
9export const DEFAULT_SLEEP_SKILL = 'sleep'
10
11/** The plugin's own folder under the Claude config directory: the queue file and the sprite file live here. */
12const DATA_DIR = 'companion'
13
14/**
15 * The user's settings, from the manifest's `userConfig` (see .claude-plugin/plugin.json). The engine
16 * hands them to `register(on, options)` once per activation; changing one reloads the mod, so nothing
17 * here is re-read later.
18 */
19export type Settings = {
20 /** `auto` follows the system locale. */
21 language: 'auto' | Lang
22 name: string
23 /** Empty means the default, `<config dir>/board.md`. */
24 boardPath: string
25 sleepSkill: string
26 /** Minutes from UTC for displayed times; undefined uses the host's offset. */
27 utcOffset: number | undefined
28 /** Whether the pane opens by itself at session start. */
29 isAutoOpen: boolean
30}
31
32function text(options: PluginOptions, key: string): string {
33 const value = options[key]
34
35 return typeof value === 'string' ? value.trim() : ''
36}
37
38/**
39 * Reads the settings out of the options. A missing or wrongly typed value falls back to the default
40 * instead of failing, since the pane should still open with a half-filled configuration.
41 */
42export function readSettings(options: PluginOptions): Settings {
43 const language = text(options, 'language').toLowerCase()
44 const autoOpen = options['auto_open_pane']
45
46 return {
47 language: language === 'en' || language === 'ja' ? language : 'auto',
48 name: text(options, 'character_name') || DEFAULT_NAME,
49 boardPath: text(options, 'board_path'),
50 sleepSkill: text(options, 'sleep_skill') || DEFAULT_SLEEP_SKILL,
51 utcOffset: parseUtcOffset(text(options, 'utc_offset')),
52 isAutoOpen: typeof autoOpen === 'boolean' ? autoOpen : true,
53 }
54}
55
56/** The settings plus what the environment decides: language, home and the files the mod reads and writes. */
57export type Runtime = Settings & {
58 lang: Lang
59 home: string | undefined
60 /** Where the mod keeps its files and where the default board lives. Undefined when neither CLAUDE_CONFIG_DIR nor HOME is set. */
61 configDir: string | undefined
62 /** The board file to read. Undefined when it cannot be located (no HOME for the default or a `~` path). */
63 boardFile: string | undefined
64 /** The look-queue file. Undefined without a config dir: the queue is then neither read nor written. */
65 queueFile: string | undefined
66 /** The runtime sprite file (written by tools/build_sprites.py). */
67 spritesFile: string | undefined
68}
69
70/**
71 * Works out the config directory: `CLAUDE_CONFIG_DIR` when set, else `~/.claude`. Pure so the
72 * branches can be tested; an empty value counts as unset.
73 */
74export function configDirOf(claudeConfigDir: string | undefined, home: string | undefined): string | undefined {
75 const configured = claudeConfigDir?.trim()
76 if (configured) {
77 const expanded = expandHome(configured, home)
78 // A `~` that could not be expanded would be a relative path named "~", so treat it as unusable.
79 if (!expanded.startsWith('~')) return expanded.length > 1 ? expanded.replace(/\/+$/, '') : expanded
80 }
81
82 return home ? `${home}/.claude` : undefined
83}
84
85/** Combines the settings with the environment into the paths and language the mod works with. */
86export function buildRuntime(
87 settings: Settings,
88 env: { home?: string; claudeConfigDir?: string; lcAll?: string; lcMessages?: string; lang?: string },
89): Runtime {
90 const configDir = configDirOf(env.claudeConfigDir, env.home)
91 const custom = settings.boardPath ? expandHome(settings.boardPath, env.home) : undefined
92 const boardFile = custom !== undefined ? (custom.startsWith('~') ? undefined : custom) : configDir !== undefined ? `${configDir}/board.md` : undefined
93 const data = configDir === undefined ? undefined : `${configDir}/${DATA_DIR}`
94
95 return {
96 ...settings,
97 lang: resolveLang(settings.language === 'auto' ? undefined : settings.language, env.lcAll, env.lcMessages, env.lang),
98 home: env.home,
99 configDir,
100 boardFile,
101 queueFile: data === undefined ? undefined : `${data}/look-queue.json`,
102 spritesFile: data === undefined ? undefined : `${data}/sprites.json`,
103 }
104}
105