SLOPSHOPPER

companion

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · companion
│ ┃ Board ✕ › fix the failing auth test and add an audit log call │ ┃ [Board] [ Backlog ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Usage ⎿ Read 6 lines │ ┃ 5h ██████████████████████████ 31% ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Folder [ /work/app ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Could not read the board (/Users/dev/.claude │ ┃ [ Reload ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ companion: Opened the board in a pane. │ │ ◦ Companion Done in 0s · 9 tool calls[ Board ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◦ Companion Done in 0s · 9 tool calls[ Board ] [ Hide ]
Pane · Board
[Board] [ Backlog ] Usage 5h ██████████████████████████ 31% Folder [ /work/app ] Could not read the board (/Users/dev/.claude/board.md). [ Reload ]
README

claude-code-companion-mod

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

  • your Markdown kanban board
  • a list of things Claude wants you to look at
  • rate-limit usage bars
  • a pixel-art companion whose face changes with Claude's replies

日本語版 README

The companion pane next to a Claude Code session

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.

Features

Board pane

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.

"Please look" list

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.

Usage bars

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%.

Companion and band

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.

Requirements

  • Claude Code v2.1.287 or later (mods are on by default from this version; run claude --version to check)
  • macOS, Linux or WSL (native Windows is not supported)
  • For the usage bars, a Claude subscription plan (with an API key, Claude Code receives no rate-limit windows, so the pane shows a waiting message instead)

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.

Install

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.

Update

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.

Uninstall

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

Your board file

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` |
  • Headings must match exactly, including case: ## Backlog, ## In Progress, ## Blocked, ## Review and ## Folders (Japanese headings also work: 着手前, 進行中, 保留, レビュー待ち and フォルダ).
  • Entries must start the line with - [ ] (lines that start with * [ ] or are indented are not read).
  • Tags are the [...] at the start of an entry (written together, as in [web][docs], or with spaces between them).
  • The title is the bold text right after the tags (without bold text, it is the text after the tags up to the first — , (, 、 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.

Settings

Run /plugin configure companion@claude-code-companion-mod to open the settings dialog. The same dialog opens when you install the plugin from /plugin.

SettingDefaultWhat it does
Board fileempty (reads ~/.claude/board.md)Path to the board file. ~ is expanded
Languageautoen or ja for the UI text. auto checks LC_ALL, LC_MESSAGES and LANG, in that order
Character nameCompanionName shown in the band
Sleep skillsleepThe character sleeps while this skill runs
UTC offsetthe machine's offsetTime zone for times in the pane, such as +09:00. Set it if the times are hours off
Open the pane automaticallyonOpens the pane when a session starts. /board works either way

Opening files

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:

TargetHow it is opened
URLshttp:// 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 filesIn VS Code only (not opened if code is not installed)
Folders and paths that do not existRefused

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.

Known limitations

  • The decision is made from the file extension, but on Linux 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).
  • "The folder where you started Claude Code" is the session's current working folder. If you start Claude Code in a broad folder such as ~, every web page and SVG under it opens automatically.
  • The list file is not locked while it is read and written, so if several sessions add items at almost the same moment, one item can be lost.

Add your own character

No character art comes with the mod, so the pane shows no character until you add one.

  1. Draw one PNG for each mood and put them in one folder. The names are 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.
  2. Use one image pixel for each dot, on a transparent background.
  3. The images are cropped, never scaled, because scaling down blurs the eyes and outlines of pixel art.
  4. Each image pixel takes one terminal column, and the character is not drawn when the pane is narrower than the image. A 40–60 pixel wide image is a safe size.
  5. An image larger than 200 cells in either direction is not used (height is counted in half pixels, because each terminal line shows two image rows).
  6. Convert the PNGs. You need Python 3.9 or later and Pillow. On Ubuntu and Debian, 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`.
  7. Press Reload, next to the "read at" time below the board in the pane, or wait up to a minute. The mod reads the file again when it changes, so you do not need to reinstall the plugin.

How the mood is chosen

SituationMood
The sleep skill is runningsleep
Claude is workingthink
The reply ends with a question for youthink
Apology words (sorry, failed, mistake…) are at least as many as success wordsworry
Success words (done, fixed, passed…) are moresmile
Neither kind of word appearsidle
No turn has finished yet in this session, between 5:00 and 11:00wave

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.

The band

From left to right, the band shows:

  • the character's name
  • "thinking…" while Claude works, and after a turn, how long it took and how many tools Claude used (with a short remark when the turn took more than two minutes)
  • a greeting for the time of day, if no turn has finished yet in this session
  • the number of In Progress and Review entries (counted on the same tab you have selected in the pane)
  • the Board button (opens the pane) and the Hide button

After you hide the band, run /board to show it again.

Development

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/.

License

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.

Source 19 files
hooks/register.tsx 530 lines
1import { 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}
530
hooks/ask.ts 27 lines
1import 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}
27
hooks/band.tsx 68 lines
1import 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}
68
hooks/board.ts 160 lines
1import { 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}
160
hooks/i18n.ts 261 lines
1import 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}
261
hooks/look.ts 131 lines
1import 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}
131
hooks/mood.ts 93 lines
1import 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}
93
hooks/open.ts 165 lines
1/** 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
165
hooks/paths.ts 11 lines
1/**
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}
11
hooks/pane.tsx 255 lines
1import 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}
255
hooks/scope.ts 47 lines
1import 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}
47
hooks/settings.ts 105 lines
1import 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