SLOPSHOPPER

focus-pane

A permanent pane: the ticket and summary of what Claude works on, its progress, and its todo list

new
v0.1.0no licenseupdated 2026-10-08Juyn/claude-focus-pane
A shopper browsing a rack in a slop shop
Preview could not run: hooks.json names ./register.tsx as the hooks module, but the repository has no such file
README

focus-pane

A Claude Code mod: a permanent side pane that keeps the session's context on screen.

  • Header — session state, branch, ticket (UNL-1234, caught from the branch or a prompt).
  • Mission / action — the first real ask, condensed once; the action in hand, rewritten each turn.
  • Cards — plan progress, context tokens and cost, turns, session time.
  • Sacred Book — the bound feature's spec and plan (status, outline, GitHub link) and its mockup.
  • Todos — the list the agent's TodoWrite wrote.
  • Agents — the session's subagents, one row each: tier (from the effort), model, context share, estimated cost and time, and a pixel crab; three cards sum cost, tokens and wall time. Running, finished and planned groups (the planned are the pending tasks, with what each waits for). r folds the rows, t hides the finished; a short pane folds them by itself, then keeps the first ones and counts the rest. Cost and window are estimates from a price table in hooks/register.tsx.
  • Mockups tab — one thumbnail per screen of the bound mockup, with an open-in-browser button.

It docks to the right of the transcript only under Claude Code's fullscreen renderer (/tui fullscreen, from 110 columns); under the classic renderer the engine seats it above the prompt, and a mod cannot change that. It opens by itself at session start. Opened unasked, the engine seats a pane only from 144 terminal columns; in a narrower terminal, run the slash command once (/mission).

Install — instructions for an agent

Run these on the machine that runs Claude Code. Do not improvise other paths: the settings below are the only mechanism that loads a mod in every session.

  1. Clone to the conventional place (any absolute path works, this one is assumed below):
   git clone git@github.com:Juyn/claude-focus-pane.git ~/.claude/mods/focus-pane
  1. Register it for every session:
   ~/.claude/mods/focus-pane/install.sh

It adds the folder to CLAUDE_CODE_PLUGIN_DIRS and sets CLAUDE_CODE_PLUGIN_DIR_WATCH=1 in the env block of ~/.claude/settings.json (backup written beside it; other plugin folders are kept; running it twice changes nothing). To do it by hand instead:

   { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/home/<user>/.claude/mods/focus-pane", "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1" } }

Several folders are separated by : (; on Windows).

  1. Verify:
   claude plugin validate ~/.claude/mods/focus-pane   # must end with "Validation passed"
   claude plugin test ~/.claude/mods/focus-pane       # the mod's own tests

If claude plugin test answers hooks modules are turned off in this process, mods are not enabled for this account or build: nothing will load, and that is not fixable from this repo. Report it instead of retrying.

  1. Start a new session. Plugin folders are read when the Claude Code process starts; a running session never picks them up. The pane footer names the slash command actually granted (/mission, else /focus-pane, /unlocker, /pane).

Claude Code in a terminal (TUI), any host

Steps 1–4 as written, on that host, as the user who runs claude. Requires Claude Code ≥ 2.1.287.

Claude Desktop (Code tab)

The desktop app starts its local Code sessions with the same ~/.claude/settings.json, so steps 1–4 on the desktop machine are the whole install: the env block is how a folder is named where no --plugin-dir flag can be given. Quit and reopen the app (or start a new Code session) afterwards. For a session the desktop app runs on a remote host, install on that host, not on the desktop. On the desktop surface the cat and its meadow are one self-playing SVG, drawn from the 64×36 sheet and the decor sheet in real pixels: sky, ground, grass, the ladybird and the butterfly, what grew, the lasagne and its wave. The mockup thumbnails are terminal cells and are not drawn there (captions and the open button are).

One-off, without touching settings

claude --plugin-dir ~/.claude/mods/focus-pane

Requirements

Needed forWhat
The paneClaude Code ≥ 2.1.287 with mods enabled
Sacred Book cardsa checkout of unlocker-io/sacred-book at ~/Sites/sacred-book, or SACRED_BOOK_DIR
Open a mockupxdg-open; API_DEV_UNLKR (a dev.unlkr.io token with dev.designs.read) in the environment for the signed URL — without it the checkout's local file is opened
Mockup thumbnailspython3, ImageMagick (magick), a Chromium browser (brave, chromium, google-chrome)

Everything but the first row is optional: the pane draws without it. Never write the token into a file of this repo or into settings.json; export it from the shell profile.

Use

CommandDoes
/missionreopens the pane
/mission spec <feature>binds a Sacred Book feature by folder name or ticket (console-comptes, UNL-4844); spec off unbinds
/mission mission <text>sets the mission by hand
/mission <text> / autopins the action line / hands it back to the model
(the scene)the cat lives in a small meadow drawn from assets/decor/decor-sheet.png: sun or moon by the hour, a cloud adrift, grass that sways, a ladybird and a butterfly. It tells the session too: a bloom grows for each finished todo, a mushroom comes up for each failed call, dust rises at each tool call, and a speech bubble holds canned lines and one quip a turn start and a turn end, worded by haiku
/mission cat roux / noir / garfield / pandawhich animal walks the pane; remembered
/mission bambou (or b in the pane)with the panda: plants a bamboo sprout, which it goes and eats
/mission lasagneserves a steaming dish in the scene: for a minute the cat tears from end to end and leaps, under a wave of lit squares rolling across like an RGB keyboard's; lasagne off clears the table
/mission pet sprite / bigits size: small (32 columns, 11 to 14 rows) or big (64 columns by 20 rows, three times the pixels)
/mission pet pngthe cat as a real image (64×36 frames from assets/frames/, 9 rows), where the terminal draws pictures (kitty graphics protocol); elsewhere it says so and falls back to sprite. python3 scripts/build-frames.py cuts the frames
/mission pet sprite / 3d / line / pixel / offthe cat at the bottom of the pane: the sprite sheet (12 rows, default), ray-marched 3D (10 rows), line art (9 rows), flat pixels (3 rows), or sent in
/mission demofills the whole pane with demonstration data — mission, feature (the bound one, else a made-up one), todos, activity — to see it full
/mission sessions (or s in the pane)the Sessions tab: what is running or waiting, on this machine and on the one the sync reflects, including subagents
/inbox (ou d dans le pane)l'onglet Drops : les fichiers reçus dans ~/inbox, à insérer ou copier

With the keyboard in the pane (click it, or ctrl+x tab): m mockup thumbnails, o open the mockup, esc back to the prompt.

Sessions on two machines

The Sessions tab reads this machine live (scripts/live_snapshot.py) and the other one from ~/.cache/focus-pane/hosts/, which scripts/live-sync.sh fills every 3 s over SSH. Run the sync on the machine that can reach the other (here, the PC reaching the server's alias factory):

~/.claude/mods/focus-pane/install.sh --sync factory

It installs and (re)starts the user service focus-pane-sync.service; run it again after adding an alias or pulling a fix of live-sync.sh.

The other machine must load the mod too: this repo at ~/.claude/mods/focus-pane (kept up to date with git pull), its own install.sh run there without --sync, and python3. Without it, its sessions publish no heartbeats and it shows no Sessions tab.

The service runs with BatchMode, so it never asks for a passphrase: it needs an SSH key the systemd user service can use, either a key without passphrase, or an agent whose SSH_AUTH_SOCK the user manager sees (for example systemctl --user import-environment SSH_AUTH_SOCK, then restart the service).

Diagnose with:

journalctl --user -u focus-pane-sync -f

Uninstall with systemctl --user disable --now focus-pane-sync.service, then remove ~/.config/systemd/user/focus-pane-sync.service.

Dropping files to the server

Drop any file in ~/inbox on the PC (bookmarked in Nautilus as "Vers VPS"): the sync sends new files to ~/inbox on the server every few seconds — never deleting there, and a file already there is never sent again (rename it to send a new version). In every session, a band above the prompt offers the newest file: i inserts its path in the prompt, x sets it aside — both act once the band has the keyboard (click it, or ctrl+x tab). /inbox (or d in the pane) opens the Drops tab: the 20 latest files, the 5 newest set off, each with "insérer" and "copier le chemin". The "Inbox VPS" bookmark browses the server's inbox over sftp.

Only files that held still for 10 s leave the PC: never one still being written, never *.part / *.crdownload / *.tmp, never an empty file. The upload runs in the background, so a large file never stalls the sync. An inbox sync that fails says why in journalctl --user -u focus-pane-sync.

A branch that carries a ticket known to a Sacred Book README binds its feature at session start. A binding made with spec is remembered per checkout and restored at the next session there; outside a git checkout (a workspace root) it lasts the session only.

Develop

claude plugin validate .
bunx --package typescript tsc -p .
claude plugin test .

The cats live under assets/cats/<name>/: cat-sheet.png is the 64×36 sheet drawn big, and cat-sheet-small.png a 32×24 one where the cat has it (else the big sheet is sampled down by half); each has its JSON manifest beside it. The decor is assets/decor/decor-sheet.png, 16×16 cases, its elements named in decor-sheet.json. python3 scripts/build-sprites.py bakes them all into the module: run it again after changing or adding a sheet (a new cat is a folder, a name in the script's ORDER, and a value of PetCoat). On a terminal the panda does not walk the meadow: it has a world of its own, after the design « Univers · Bao le panda » — a bamboo grove by the water at night, fireflies, a panda that rolls to a sprout as a ball, eats it, and is rewarded with confetti and hearts; a sprout comes up for each finished todo, at rest it sleeps. On the desktop the panda walks the meadow as the cats do, from a sheet that is not a drawing but a program: python3 scripts/draw-panda.py builds every frame from a few shapes and writes the sheet. Both sizes are drawn in half blocks, one pixel a half cell: a Raster cell takes no character beyond the Basic Multilingual Plane, which rules sextants out, and a quadrant's pixel is twice as tall as wide, which squeezes a square sheet.

A Raster paints 1024 distinct pairs of colors and rounds everything past that to a coarse palette, the whole picture with it: an animation must draw from a bounded set of colors, so fades go in a few steps, never a new shade a frame.

hooks/register.tsx is the module, types/index.d.ts its state contract, scripts/thumbs.py the thumbnail shooter (cached in ~/.cache/focus-pane). With CLAUDE_CODE_PLUGIN_DIR_WATCH=1 an edit reloads the mod when the turn that made it ends. .claude-plugin/types/ is written by the engine and ignored by git.

Source 1 files
types/index.d.ts 217 lines
1export type Focus = {
2  /** The ticket key the work hangs on, from a prompt, the branch, or /focus. */
3  ticket: string | null
4  /** The session's standing mission: the first real ask, phrased once by haiku. */
5  mission: string | null
6  /** True once haiku condensed the mission: the raw first prompt is shown until then. */
7  isMissionPhrased: boolean
8  /** One line on the action now: rewritten by haiku each turn, or pinned by /focus. */
9  summary: string | null
10  /** The branch of the session's cwd, read once at session.start. */
11  branch: string | null
12  /** The prompt of the turn now running, kept so the lines can be rewritten. */
13  ask: string
14  /** True while /focus pinned the action by hand: no model rewrites it. */
15  isPinned: boolean
16  /** True once the person closed the pane: it stops coming back on its own. */
17  isDismissed: boolean
18}
19
20export type Todo = {
21  /** The task's id when a task tool made it; a TodoWrite row has none. */
22  id?: string
23  content: string
24  status: 'pending' | 'in_progress' | 'completed'
25  activeForm: string
26  /** Ids of the tasks that must finish first, as the task tools reported them. */
27  blockedBy?: string[]
28}
29
30export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | number
31
32/** One subagent of the session, as its spawn and its steps reported it. */
33export type AgentRow = {
34  /** The agentId its loop's events carry. */
35  id: string
36  /** The spawn's description, or the listed agent's. */
37  title: string
38  /** The subagent type it runs as. */
39  type: string
40  /** The resolved model id: the spawn's, then each step's. */
41  model: string | null
42  /** The `effort` of its last step. */
43  effort: Effort | null
44  status: 'running' | 'completed' | 'failed'
45  startedAt: number
46  endedAt: number | null
47  /** The size of its last step: input, cache read, cache write and output. */
48  context: number
49  /** The four counters, summed over every step. */
50  tokens: number
51  /** What those steps cost, estimated from the price table. */
52  usd: number
53}
54
55/** The session's own (main-loop) agent: the model and effort its last request reported. */
56export type MainLoop = { model: string | null; effort: Effort | null }
57
58/** One running subagent, as a session's heartbeat publishes it. */
59export type BeatAgent = { id: string; title: string; model: string | null; effort: Effort | null; startedAt: number }
60
61/** What a session publishes about itself in ~/.cache/focus-pane/live/<sessionId>.json. */
62export type Heartbeat = {
63  v: 1
64  sessionId: string
65  updatedAt: number
66  main: { model: string | null; effort: Effort | null; isRunning: boolean }
67  agents: BeatAgent[]
68}
69
70/** One session that works or waits, as a machine's snapshot lists it. */
71export type LiveSession = {
72  sessionId: string
73  pid: number
74  name: string
75  cwd: string
76  origin: 'desktop' | 'cli' | 'worker'
77  status: 'busy' | 'waiting' | 'idle'
78  statusUpdatedAt: number
79  main: Heartbeat['main'] | null
80  agents: BeatAgent[]
81}
82
83/** A machine's live snapshot, as scripts/live_snapshot.py prints it. */
84export type Snapshot = { v: 1; host: string; label: string; takenAt: number; sessions: LiveSession[] }
85
86/** What the Sessions tab draws from: the snapshots last read, raw; ages are worked out when drawn. */
87export type SessionsView = {
88  /** This machine's snapshot; null when the script gave none. */
89  own: Snapshot | null
90  /** The other machines' snapshots, as the sync left them in ~/.cache/focus-pane/hosts. */
91  others: Snapshot[]
92  /** This session's id, to mark its row (ici). */
93  here: string
94  /** When they were read; null before the first read. */
95  readAt: number | null
96}
97
98/** One file of the inbox folder, as a session saw it stable. */
99export type Drop = { name: string; size: number; mtimeMs: number }
100
101/** Who took a file of the inbox: the session that inserted it in its prompt. */
102export type Taken = { sessionId: string; name: string; at: number }
103
104/** What the band above the prompt and the Drops tab draw from. */
105export type InboxView = {
106  /** The inbox folder's absolute path; '' while HOME is unknown. */
107  dir: string
108  /** The stable files, newest first, at most INBOX_KEPT. */
109  drops: Drop[]
110  /** inbox-taken.json as last read. */
111  taken: Record<string, Taken>
112  /** The files this session set aside with x (in this session only). */
113  dismissed: string[]
114  /** When what the band or the tab draws last changed; null before the first read. */
115  readAt: number | null
116}
117
118/** How the AGENTS section is shown: rows folded to a line, the finished hidden. */
119export type AgentsView = {
120  isFolded: boolean
121  isDoneHidden: boolean
122}
123
124/** One tool call of the live feed, newest first; `ms` stays null while it runs. */
125export type FeedRow = {
126  id: string
127  tool: string
128  detail: string
129  at: number
130  ms: number | null
131  isError: boolean
132}
133
134/** One active document of a Sacred Book feature, as its README lists them. */
135export type Doc = {
136  kind: 'spec' | 'plan' | 'design'
137  /** The file's own name, as the README shows it. */
138  name: string
139  /** Its path in the Sacred Book repository: what a GitHub URL or the dev API takes. */
140  path: string
141  status: string | null
142  title: string | null
143  /** Its `##` headings, and its `###` ones indented by two spaces. */
144  outline: string[]
145}
146
147/** The Sacred Book feature the pane is bound to, read off the local checkout. */
148export type Feature = {
149  /** `v2/banking/console-comptes`: the feature's folder in the repository. */
150  path: string
151  title: string
152  ticket: string | null
153  docs: Doc[]
154}
155
156/** How the cat at the bottom of the pane is drawn: the sprite sheet, ray-marched in 3D, line art, flat pixels, or not at all. */
157/** Which cat walks the pane: a folder of sheets under assets/cats/. */
158export type PetCoat = 'roux' | 'noir' | 'garfield' | 'panda'
159
160export type PetStyle = 'png' | 'big' | 'sprite' | '3d' | 'line' | 'pixel' | 'off'
161
162/** The mockups tab: the screens of the bound mockup, shot once by thumbs.py. */
163export type Gallery = {
164  status: 'idle' | 'loading' | 'ready' | 'failed'
165  /** The mockup the shots are of, so another feature's are shot anew. */
166  path: string | null
167  /** `cells`: base64 Raster cells, `columns` by `rows`. */
168  shots: { id: string; title: string; columns: number; rows: number; cells: string }[]
169}
170
171/** The session's own counters, as `$.session.usage()` last reported them. */
172export type Usage = {
173  tokens: number | null
174  window: number
175  percent: number | null
176  usd: number | null
177  startedAt: number | null
178}
179
180export type TurnState = {
181  count: number
182  isRunning: boolean
183  startedAt: number | null
184  lastMs: number | null
185}
186
187/**
188 * Which palette the pane paints with, from the `theme` row of /config: the two
189 * brand palettes, or `ansi` for a theme that asks for the terminal's own colors
190 * — there the pane sets no color at all.
191 */
192export type Skin = 'dark' | 'light' | 'ansi'
193
194declare module 'claude-code' {
195  interface PluginState {
196    'focus-pane': {
197      focus: Focus
198      todos: Todo[]
199      feed: FeedRow[]
200      agents: AgentRow[]
201      agentsView: AgentsView
202      mainLoop: MainLoop
203      sessionsView: SessionsView
204      inboxView: InboxView
205      usage: Usage
206      feature: Feature | null
207      gallery: Gallery
208      petStyle: PetStyle
209      petCoat: PetCoat
210      turn: TurnState
211      skin: Skin
212      /** The slash command the engine actually granted, or null while it has none. */
213      command: string | null
214    }
215  }
216}
217