SLOPSHOPPER

mindrian-workspace

Shows where you are in your data room and what to do next, above the prompt, with a side panel for decisions.

newpanebandcommandtoast
★ 2v0.1.0NOASSERTIONupdated 2026-10-06jsagir/mindrian-os-plugin/ui/mindrian-workspace-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mindrian-workspace
│ ┃ Mindrian workspace ✕ › fix the failing auth test and add an audit log call │ ┃ > Room [ Think ] [ Sources ] [ Review ] │ ┃ Type /workspace to use the workspace keys. ⏺ Read(src/auth.ts) │ ┃ Colors can't load. Showing plain text. ⎿ Read 6 lines │ ┃ You're not in a data room yet. Tell Larry wh ⏺ Update(src/auth.ts) │ ┃ [ Show details ] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /workspace │ ⎿ mindrian-workspace: Mindrian workspace │ │ M:OS | You're not in a data room yet | /workspace: Help h: Help ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
M:OS | You're not in a data room yet | /workspace: Help h: Help
Pane · Mindrian workspace
> Room [ Think ] [ Sources ] [ Review ] Type /workspace to use the workspace keys. Colors can't load. Showing plain text. You're not in a data room yet. Tell Larry which one to … [ Show details ]
README

Mindrian Workspace mod

Two jobs, one mod. It draws a thin band above the prompt that tells you where you are in your data room, what the folder is for, what to do next and whether anything is waiting on you. It also opens a docked pane with four tabs (Room, Think, Sources, Review) where you can look closer and, on the Review tab, save a recorded decision with one key press.

It is a Claude Code mod: function hooks written in TypeScript (.tsx, no DOM, no Node) that the terminal draws natively. The design contract is .planning/phases/369.26-mindrian-workspace-mod-an-orientation-band-and-docked-review/369.26-UI-SPEC.md. It is a walled package (Phase 369 D-17 precedent): it declares no dependency of any kind and nothing from it enters the root package.json, the root files array or the npm tarball. tests/test-369.26-wall.cjs guards that.

Status: the phase ends human_needed, not complete

Everything that can be proven without a logged-in terminal is built, tested and green. One requirement is not closed and is not claimed closed:

  • WS-16 (the spike 008 render check) is PENDING-HUMAN. The agent that built the check had no Claude login, so nothing was rendered in a real session and no live result exists. Every live row in .planning/spikes/008-mods-types-and-surfaces/render-check/RESULTS.md says PENDING-HUMAN and the spike 008 verdict says PENDING (human). The way to close it is .planning/spikes/008-mods-types-and-surfaces/render-check/RUNBOOK.md: one command, node ui/mindrian-workspace-mod/scripts/render-check.cjs --final, about 30 to 40 minutes, no model prompt, a throwaway room. Until the navigator runs it and a follow-up session records the answer, treat every "looks right" claim about paint (colors, the 60/40 split, hotkeys with the prompt focused, dim text, the glyphs) as unverified. RESULTS.md is the template that run fills in.
  • C-32 (2026-10-06): usability first, brand second. The five-rectangle logo is gone: every tier draws the plain text mark M:OS. No yellow is drawn anywhere; red appears only for the list of points with no evidence (a scoped use of the canon red role); no blue is drawn in the pane; no button is primary; a selected option is marked with >; refusals are plain bold words; the choices on a decision card are equal and a line says the suggestion is only a suggestion. Deferred brand requirements (doorway edge, dashed unknown frames, the ink-plane card, blue evidence planes) are listed in UI-SPEC 17.1 under C-32 and are not built. Source guard G12 holds the role scope.
  • C-31a to C-31d (layout fixes from the navigator's screenshots, 2026-10-06): a docked or narrow band keeps the place and the waiting count and drops the hint first; the Think tab's two blocks are top-aligned from 90 columns and stacked below it, with the red list capped at 3 rows; a pick row is a mark plus a one-line label; the guidance chip is short, on its own row, and Set aside for later heads a list that has no open card. Tests pin the structure; before and after screenshots are still owed (implementation complete; visual verification pending).

Load it

claude --plugin-dir ui/mindrian-workspace-mod

Check it without a session:

claude plugin validate ui/mindrian-workspace-mod
claude plugin test ui/mindrian-workspace-mod
node ui/mindrian-workspace-mod/scripts/typecheck.cjs
bash tests/run-all-369.26.sh

The command

workspace is the guaranteed way to open the pane, and the only thing the band tells you to type. The first real render (2026-10-06) answered R-03: a letter hotkey does NOT fire while the prompt box has focus, it is typed into the prompt box and sent as a message (C-28). It only opens the pane and flips two switches; it never sends a prompt and never runs work.

You typeWhat happens
/workspaceopens the pane on Review when a decision is waiting, else on Room
/workspace think (or room, sources, review)opens that tab
/workspace plainturns plain mode on (words and outlines, no colors), or off if it is on
/workspace sample <name>draws a named sample data room (clearly marked as sample) and opens its tab. Names: wide, narrow, missing, empty, noroom, limit, drift, broken, several, nofile, unreadable
/workspace livestops drawing the sample and goes back to the real room

The dev switch MOS_WORKSPACE_SAMPLE=<name> starts a session already showing a sample (the render check uses it).

Keys

Letters and digits only (an engine rule: one digit or one lowercase letter). Enter submits the prompt, so it is never a key here.

When each key fires (C-28). A hotkey fires only while the site that drew it holds the keys: the band after ctrl+x then tab or a click, the pane after /workspace, a click, or the pane opening with focus. While the prompt box holds the keys (the normal case, you are typing) a letter is TYPED, not pressed, and a digit in an empty prompt box answers a band survey only. So the band never names a bare letter: its one hint is /workspace: Open workspace (/workspace: Help at narrower widths), black on the cream row. o and h stay armed behind it and are drawn as nothing. When the pane is open without the keys the pane says so (N06, "Type /workspace to use the workspace keys.").

WhereKeyFires whenDoes
Prompt box/workspacealways (a command, not a key)Opens the workspace and gives it the keys
Band (armed, not drawn)othe band or the pane holds the keysOpen workspace (gives the pane focus if it is already open)
Band (armed, not drawn)hthe band or the pane holds the keysOpens or shuts the all-keys list
Band (button, no letter shown)rclick, Tab then Enter, or the band holds the keysRun a checkup (only shown when the room needs one; fills the prompt, does not send it)
Band (button, no letter shown)kclick, Tab then Enter, or the band holds the keysSave my thinking (only shown at 80 percent context or more; fills the prompt, does not send it)
Pane, everywhereh, e, s, Escthe pane holds the keysHelp (all keys), Explain this, Show details (where a tab has details), Esc closes
Roomn, v, mthe pane holds the keysDo this step (fills the prompt), Look at the decision (jumps to Review), More things to do
Thinkg, c, a, w, xthe pane holds the keysDig in, Connect, Another way, Why, Example
Thinkl, t, vthe pane holds the keysLook up guidance and Talk it through (only after a help kind is picked), Look at the evidence (only when there is evidence)
Sourcesbthe pane holds the keysBack to the list (only while a source is open)
Review1 to 3the pane holds the keysChoose the recorded answer of that rank
Reviewd, ithe pane holds the keysDecide later (writes nothing), Ask it here (draws a card another window raised)

The band's hint words are Text, not a button: a Button's label color is the host's (it rendered pale on the cream row), only Text can be black. o and h are therefore kept as buttons inside a Box with no width. Unverified without a live session: whether a zero-width Button still takes a Tab stop (an invisible focus) and whether the pane's all-keys panel o label ("Open workspace", B84) reads right. The all-keys panel also lists the fix keys at T1.

There are no letter or digit hotkeys for the tabs (digits stay free for the decision card). The tab strip is four buttons: Tab moves to the next button, Enter presses it, Esc closes the workspace (copy line H22). Under 30 columns it becomes a Select.

What is real today, and what is recorded as missing

The model reads only what a canonical source holds. A source that is not there yet is shown as its own plain "not recorded yet" words, never as a made-up value.

Real now (read live, refreshed at session start and after every turn):

  • where you are (the bound data room and the folder), through status_read
  • what the folder is for (the folder's own description file)
  • this install's last health check (a global cache, so it says "this install", not "this room")
  • how much of the context window is used
  • how many decisions wait, and the recorded decision cards themselves (gate_list)
  • the Think tab's points with no evidence yet (whitespace_scan) and the Sources tab's readable files (room_artifact)

Recorded as missing until Phase 369.25 lands its readers (shown as "not recorded yet", or not drawn):

  • the next step, its reason and its method (so the band says "Next: not recorded yet", and the Think tab's lookup button is never drawn on a real room)
  • what a folder reads and writes
  • the installed Mindrian version (not drawn at all)
  • the Think tab's "best supported conclusion" beyond a recorded governing thought, and the "one gap that could change your decision" line (P79, held back on purpose, see below)
  • "searching" states (sample data only)

Tri-polar stance

  • CLI: the build target. Built and tested through the engine's own mount and press harness. The real terminal render is the pending WS-16 check.
  • Desktop: covered only by the kit's mount loops over the terminal and desktop surfaces. A real Desktop render is deferred (phase CONTEXT, Deferred). A stated deferral, not an oversight.
  • Cowork: a mod has no Cowork surface. A stated skip.

Canon positions

  • Part 8, graph boundary. The mod reads the room locally. The one outbound call is the Think tab's "Look up general guidance" button, and it sends exactly { framework: <a method name from the framework-name canon> }, never a word from your room. tests/test-369.26-part8.cjs proves that three ways, each with a mutation arm.
  • Part 9, memory locality. The only write the mod makes is gate_answer for a recorded decision card, through src/pane/review/gate-client.ts. It is recorded as relayed, it is never optimistic (the screen says "saved" only after the runtime says so), and nothing the mod shows confirms a truth-claim.
  • Part 11, invocation. The workspace command opens a pane and fills the prompt box, nothing more. It adds no command, skill or agent that reaches a decision fork on its own.
  • Part 12, pedagogy. No grades, no praise, no scores in any string. Larry's color marks appear only on the Think help results.
  • Part 3, decision gate. The review card draws the recorded card (at most three choices, ranked) and invents none.

The band's reading order, measured

A screen reader reads the band in this order (UI-SPEC 12.4 sets the target). Measured 2026-10-06 by mounting the band at four sizes in the engine's test harness (text and button labels in tree order):

SizeMeasured order
Wide (100 columns)place, waiting, context (then four empty bar dots), purpose, next, then Open and Help
Wide, room needs a checkup or is brokenplace, waiting, context, purpose, the health words and the Run a checkup key, next, Open, Help
Wide, context 80 percent or morecontext and Save my thinking first, then place, waiting, purpose, next, Open, Help
80 columnsas wide, with no bar and Help only
55 columnsthe folder name alone, the one alert, Help

Differences from UI-SPEC 12.4, stated plainly:

  1. Health sits between purpose and next, not after next. Nothing is lost; the order just differs from the table.
  2. At the context limit the promoted block leads, on purpose (the spec's cliff rule promotes it to the front of row 1). 12.4 has no row for this.
  3. The four empty-cell dots of the context bar are real text in the tree (·), so a screen reader would read them. 12.4 says the bar cells are hidden. No hide switch was used; the bar is decoration and can be dropped without losing meaning (R-14).
  4. The pane's reading order was not measured here.

Known limits

  • It does not replace the old status line. A mod cannot (spike 008). Both draw until the navigator removes statusLine from settings after the band is proven (OQ-11).
  • Dev-only load. It loads with --plugin-dir. Shipping it inside the install is deferred (OQ-12).
  • Seen once on a real screen (2026-10-06, claude 2.1.290 and 291, truecolor, sample wide): blocks, logo and bar paint correctly; a band key does NOT fire from the prompt box (fixed in the words, C-28); the Button-label hints were unreadable (fixed, now Text). Still not eyeballed after C-28: the ▶ and · glyphs in the WSL terminal, the 60/40 split, the pane title chrome.
  • Second real render (2026-10-06, claude 2.1.290 and 291, Windows Terminal, truecolor, docked pane, sample wide, Review tab) and what it changed (C-29). The pane page is cream, and a Button has no color prop, so its label is the host's light color: three of the four tab buttons, choices [2] and [3], Decide later and Show details were invisible, and dimColor text on the cream page was faint grey. Fixed in one change: every pane Button and Select sits on a black chip (the tab strip and the hint line are black bars; the recommended or selected one is a blue block; a pick inside the red list sits on the red), through ground() in src/pane/ink.ts; quiet text on cream is normal-weight black (soft() dims only in plain mode); the band's checkup and save buttons moved from a cream block to the black frame. Plain and NO_COLOR mode are unchanged. Not seen on a real screen after this change: whether the black chips, the black tab and hint bars, the blue primary choice and the black outline paint as the tests say, and whether the [ label ] of a primary Button is readable on the blue block for the non-tab buttons (it was for the tab). Markdown text (the Sources reading view, the Think lookup result, a multi-line purpose) is also the host's color on the cream page and was not in the screenshot: look at it on the next render.
  • The Think tab's lookup does not work through the installed Brain shim yet. The shim exposes six tools and framework_techniques is not one of them, so a lookup would show "Couldn't look that up right now. Nothing was sent from your data room." (Q-17-1). On a real room the button is not drawn anyway until next.method is recorded.
  • A card the model raises without flagging an option shows no "Mindrian suggests" line (Q-17-2).
  • The Think tab is the heaviest view: 8 buttons in 4 groups on the default view, exactly the budget with no spare.

Decisions the navigator still owes

Each has a default the mod already follows, so nothing is blocked. Answer in a word.

IDQuestionThe mod does nowRecommendation
WS-16Run the render check (RUNBOOK.md), then accept or change the spike 008 verdict and its fallbacksevery live result PENDING-HUMAN, verdict PENDING (human)run it; this is what closes the phase
OQ-11Remove the old statusLine once the band is proven?both drawyes, after WS-16
OQ-12Where does the mod ship (inside the install, a built asset)?dev-only loaddecide in the roadmap phase that ships it
OQ-13The workspace command's description and its result lines have no copy-deck wordsthe description and the "opened" line use the pane title (P00), plain-on uses N01, sample uses N04; no words inventedadd one short description entry to the deck
OQ-14After Decide later or Not now the runtime ends the card, so "This decision stays open" (D13) is almost never true, and the line shown before the press (D11, "leaves this open") has the same problem. The deck has no sentence for "your wait was saved and the card goes away"says D13 only while the card is still listed, else "Saved to your data room: {label}" (D24)write one sentence for the wait case and retire D13 and D11 for wait-type choices
OQ-15An approve on a card that runs work (research gates, canon release, ambient, never-do) would run the step on the server side. D10 to D12 say "saves your decision", not "runs the step". Cards with options the mod cannot classify have no honest "answer in the conversation" sentence either (D30 is about pick-many cards)saves none of those approvals; reject and defer on them are savable; D30 stands inrule either that D12 may say it runs the work, or that those cards stay in the conversation, and add the sentence
Q-17-1Should the guarded Brain shim proxy framework_techniques?the lookup refuses or fails honestlyyes: one small quick, a seventh shim tool with strict input { framework } that calls the Brain client so the Part 8 guard stays in the shim. Never point the mod at the raw Theo server. Low urgency; or retire the lookup button
Q-17-2Keep "no flag, no suggestion line" for cards the model does not flag?keeps itkeep, and have the raiser pass recommended: true
Q-17-3The nested test session reads as bound only through a binding file plus a session id, not through CLAUDE_ACTIVE_ROOMthe harness does it itselfno change (informational; --repo-plugin is the fallback)
Q-17-4Is E01 ("This decision card is not one this window drew. Ask for it again.") the right words for a card another window raised?E01, and the Ask it here key followskeep

Smaller notes, no ruling needed unless you want one:

  • Held-back copy. Five deck entries are not drawn on purpose (B83 is retired, C-32) and are allow-listed in the source guard with their UI-SPEC reason: B02, B67, L04, L05, and P79 ("This is the one gap that could change your decision", held back until the planned reasoning brief can support the claim).
  • A card that changed under you can read E03 and then D32 ("Still current") because the check cannot see the changed subject. Plan 10 fixed this on purpose; it is a candidate for a ruling.

What keeps it honest (the permanent guards)

bash tests/run-all-369.26.sh runs them all. The two that matter most for later edits:

  • tests/test-369.26-source-guards.cjs: no long dash in any mod file, no hex color in src/, no require, process, Node built-in or fetch, every visible string a copy-deck id (checked on the syntax tree), every deck id used or allow-listed with a reason, only the audited MCP tools, the only write gate_answer in the gate client, no italic, underline, strikethrough, timer or gray_meta, logoGreen and contradiction only in the theme (G7), no primary variant and no round border (G7), role lock G12 (evidence, contradiction and assumption drawn only in their role files), all four tab bodies defined, every hotkey one character, and (G11, C-29) every pane Button and Select inside a Box that spreads ground(), no dimColor attribute on the cream page, no Button in a cream band block. Each guard has a mutation arm that plants a violation in a scratch copy and must see it.
  • tests/test-369.26-part8.cjs: the Brain boundary (referenced here, not repeated).
  • tests/ground.test.tsx (run by claude plugin test): draws every tab and sub-view on terminal, desktop, vscode and mobile at four widths and fails when a Button or Select is not on a black, blue or red ground, and (C-30, C-32) when yellow or blue is drawn in the pane, red is drawn outside the no-evidence list and its marks, a Button has the primary variant, or a border is round, when text on the cream page is dim, or when it is not the theme's black.

Engine facts

The full rules are in .planning/phases/369.26-mindrian-workspace-mod-an-orientation-band-and-docked-review/369.26-ENGINE-RULES.md: read it before writing any hook file. In short:

  • $ never crosses an import: a helper that takes $ lives in the same file as the hook, or the hook is registered by a registrar that receives on.
  • read($, x), update($, x, fn) and atom(x, initial) need x as a literal { plugin, key } as const or an atom declared in the same file; atoms imported from src/state/atoms.ts are refused (the file holds references and starting values only).
  • plugin.json carries "types": "./types/state.d.ts" and that d.ts has no import.
  • A render hook cannot write state; the engine's test $ has no env, fs, state, store or plugin noun.
  • tests/test-369.26-engine-rules.cjs proves a state-reading hook validates and that each mistake is still refused.

Measured in plan 01

Measured on Claude Code 2.1.290 (declaration file written by 2.1.289), 2026-10-06. Every later plan reads this section.

  1. Import form. A relative import between plugin files loads with NO extension (import { x } from './ids'), with a directory index (from './sub'), and also with .js or .ts spelled out; tsc under our tsconfig accepts the extensionless and the .js spelling and rejects the .ts spelling (TS5097, allowImportingTsExtensions is off). Use the extensionless form everywhere. A missing module fails loudly in the engine ("cannot import ... no such file"). Proved by a throwaway sibling module drawn from a ui.render hook and found by a mounted test, under claude plugin validate ui/mindrian-workspace-mod, claude plugin test ui/mindrian-workspace-mod and node ui/mindrian-workspace-mod/scripts/typecheck.cjs, with a deliberate missing import failing all three.
  2. hooks.json shape. modules is an ARRAY holding exactly one path, resolved relative to hooks/hooks.json itself: { "modules": ["../src/register.tsx"] }. A bare string is refused ("expected array, received string"), and a second entry is refused ("one hooks module per plugin"). Proved by claude plugin validate ui/mindrian-workspace-mod failing on the string and passing on the array.
  3. Test discovery. claude plugin test <dir> runs every *.test.ts and *.test.tsx anywhere under the folder (it found a test in src/, in tests/ and in tests/deep/), each file in its own child. A test imports from claude-code/testing, and may import plugin source with a relative path (../src/...). The plan puts tests in tests/. Proved by claude plugin test ui/mindrian-workspace-mod counting all four throwaway tests in its own output ("Ran 4 tests across 4 files").
  4. Types layout. The engine lays .claude-plugin/types/ (claude-code/, claude-code-tools/, claude-code-mcp/, a tsconfig.json) when a session loads the mod from a folder (claude --plugin-dir); claude plugin validate and claude plugin test do NOT lay it. scripts/lay-types.cjs is the fallback that copies the plugin-authoring skill's declaration file when the folder has none, and scripts/typecheck.cjs runs it first. The folder is gitignored and is never committed. Proved by deleting .claude-plugin/types, running validate and test (still absent), then claude --plugin-dir ui/mindrian-workspace-mod -p ... (laid, even though the session then stopped at authentication).

Other facts worth knowing:

  • The manifest wants an author; without one claude plugin validate passes with a warning, so plugin.json carries one.
  • hooks: nothing in the validate output is the honest report of an empty register; once a hook exists the output lists it (ui.render{component=AbovePrompt}).
  • Props the engine hands AbovePrompt (read from the declaration file): hasSurvey, isWorking, maxRows, bodyColumns, scroll ({ offset, bodyRows }) and view ({ agentId? }). A test mount must pass all six. Pane carries title, isFocused, bodyColumns, placement, scroll and view.
  • TypeScript is the compiler of tools/ts-check (or ui/shell as a fallback); this package installs nothing.

Layout

  • src/register.tsx the hooks module; calls the three registrars in order.
  • src/registrars/ model (live refresh), band, pane (the body kit and the one Brain-facing closure). Each owns its file because $ cannot cross an import.
  • src/band/ the tiers, tiles, logo and one-row band. src/pane/ the shell, the four bodi
Source 80 files
src/register.tsx 12 lines
1import type { Register } from 'claude-code'
2
3import { registerBand } from './registrars/band'
4import { registerModel } from './registrars/model'
5import { registerPane } from './registrars/pane'
6
7export const register: Register = (on, options) => {
8  registerModel(on, options)
9  registerBand(on, options)
10  registerPane(on, options)
11}
12
src/registrars/band.tsx 120 lines
1// Plan 05 (replaces the plan 01 seam wholesale), extended by plan 08: the orientation band above
2// the prompt, event `ui.render` on `AbovePrompt`.
3//
4// The hook draws every tier and case: three rows at T3-wide and T3-compact, one row at T1 and T0,
5// one row at any width for a room that is not bound (src/band/band.tsx `drawBand` routes them). It
6// yields to the engine's own drawing (`next(e)`) only for a survey, a window under four rows, and
7// when the mod has no view model to draw. It never writes state while drawing, never starts a
8// timer, and never reads the surface size: the tier comes from `maxRows` and `bodyColumns`.
9//
10// Plan 08 keys: `o` opens the workspace, `h` opens the all-keys panel, `r` and `k` add a sentence
11// to the prompt box. None runs work and none submits (src/runtime/prefill.ts has no submit).
12//
13// ENGINE RULES (369.26-ENGINE-RULES.md): `$` does not cross an import and a state read needs a
14// literal reference at the call site. So every read is spelled here, in this file, the press
15// closures are built here over this file's `$` (`makeBandAct`), and only plain values and those
16// closures go to the pure functions (chooseViewModel, decideMode, drawBand).
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Register } from 'claude-code'
19
20import { fixFlags } from '../band/alerts'
21import { drawBand } from '../band/band'
22import type { BandActions } from '../band/hint-row'
23import { pickTier } from '../band/tier'
24import { text } from '../copy/text'
25import { chooseViewModel } from '../model/read'
26import type { TabId } from '../runtime/ids'
27import { prefillPrompt } from '../runtime/prefill'
28import type { PrefillIo } from '../runtime/prefill'
29import { INITIAL } from '../state/atoms'
30import { PALETTE_ASSET } from '../theme/theme'
31import { decideMode } from '../theme/plain'
32
33// The pane id as a literal of this file (the engine reads a literal or a same-file const).
34const PANE = 'mindrian-workspace'
35
36const tabAtom = atom({ plugin: 'mindrian-workspace', key: 'tab' } as const, INITIAL.tab)
37const keysOpenAtom = atom({ plugin: 'mindrian-workspace', key: 'keysOpen' } as const, INITIAL.keysOpen)
38
39// What a band key does, closures over this hook's `$`. `tab` is where `o` lands when the pane is
40// not open yet: Review when a decision waits, else Room (C-11).
41function makeBandAct($: EngineInterface, tab: TabId): BandActions {
42  const io: PrefillIo = {
43    fill: async (words) => (await $.prompt.fill({ text: words, mode: 'replace' })).isFilled,
44    toast: (message) => {
45      $.ui.toast(message)
46    },
47  }
48  // The engine's own record of this plugin's open panes; unreadable counts as closed.
49  const paneIsOpen = async (): Promise<boolean> => {
50    try {
51      return (await $.ui.panes()).some((pane) => pane.id === PANE)
52    } catch {
53      return false
54    }
55  }
56  return {
57    open: async () => {
58      // With the pane already open the key only gives it focus: its tab stays where the person left it.
59      if (!(await paneIsOpen())) await update($, tabAtom, () => tab)
60      await $.ui.open({ id: PANE, title: text('P00'), focus: true, closeOnEscape: true })
61    },
62    help: async () => {
63      // Open: the panel opens or shuts. Closed: Room opens with the panel already open (UI-SPEC 8.5).
64      if (await paneIsOpen()) {
65        await update($, keysOpenAtom, (open) => !open)
66        return
67      }
68      await update($, tabAtom, () => 'room')
69      await update($, keysOpenAtom, () => true)
70      await $.ui.open({ id: PANE, title: text('P00'), focus: true, closeOnEscape: true })
71    },
72    checkup: async () => {
73      await prefillPrompt(io, 'Q06')
74    },
75    save: async () => {
76      await prefillPrompt(io, 'Q07')
77    },
78  }
79}
80
81export const registerBand: Register = (on) => {
82  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83    const { hasSurvey, isWorking, maxRows, bodyColumns } = e.props
84
85    // Cheap yields first, before anything is read.
86    if (pickTier(bodyColumns, maxRows, hasSurvey) === 'yield') return next(e)
87
88    // Which view model to draw: the sample switch, then the live value (plan 04's rule).
89    const fromAtom = await read($, { plugin: 'mindrian-workspace', key: 'sample' } as const)
90    const fromEnv = await $.env.get('MOS_WORKSPACE_SAMPLE')
91    const live = await read($, { plugin: 'mindrian-workspace', key: 'viewModel' } as const)
92    const vm = chooseViewModel(fromAtom, fromEnv, live)
93    if (vm === null) return next(e)
94
95    // How to paint: the person's switch (state or the kept store value), the palette text, and the
96    // two environment names (plan 03's rule).
97    const statePlain = await read($, { plugin: 'mindrian-workspace', key: 'plain' } as const)
98    const storePlain = await $.store.get('plain')
99    let paletteText: string | null
100    try {
101      paletteText = await $.fs.read(`${$.plugin.root}/${PALETTE_ASSET}`)
102    } catch {
103      paletteText = null
104    }
105    const noColor = await $.env.get('NO_COLOR')
106    const term = await $.env.get('TERM')
107    const mode = decideMode({
108      switchOn: statePlain === true || storePlain === true,
109      paletteText,
110      noColor,
111      term,
112    })
113
114    const el = $.ui.resolve(e)
115    const act = makeBandAct($, fixFlags(vm).openTab)
116    const band = drawBand(el, vm, mode.theme, mode, { bodyColumns, maxRows, hasSurvey, isWorking }, act)
117    return band === null ? next(e) : band
118  })
119}
120
src/registrars/model.ts 52 lines
1// Plan 06 (replaces the plan 01 seam): the live model refreshes at session start and after every
2// turn. A refresh that fails never blocks the session or the turn: the hook always passes the event
3// on, and a failed source is its own Seen state inside the model.
4//
5// ENGINE RULE (369.26-ENGINE-RULES.md): `$` does not cross an import and a state write needs a
6// literal reference in the file that holds `$`, so this file owns both. makeIo builds the narrow
7// set of reads the fetchers may make, with `$` and every env name spelled right here; the fetchers
8// in src/model/live never see `$`. The only MCP calls the fetchers make are status_read and
9// gate_list on the Mindrian OS server (read only, nothing sent to the Brain: Canon Part 8).
10import { update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import { refreshViewModel } from '../model/live/refresh'
14import type { LiveIo } from '../model/live/io'
15
16function makeIo($: EngineInterface): LiveIo {
17  return {
18    mcpCall: (server, tool, args) => $.mcp.call(server, tool, args),
19    // Each name is a string literal at its own call site (the engine lists what a module reads).
20    envGet: (name) => {
21      if (name === 'MINDRIAN_ROOMS_HOME') return $.env.get('MINDRIAN_ROOMS_HOME')
22      if (name === 'HOME') return $.env.get('HOME')
23      return $.env.get('USERPROFILE')
24    },
25    cwd: () => $.session.cwd(),
26    fsExists: (path) => $.fs.exists(path),
27    fsRead: (path) => $.fs.read(path),
28    usage: () => $.session.usage(),
29    now: () => $.clock.now(),
30  }
31}
32
33async function refreshNow($: EngineInterface): Promise<void> {
34  try {
35    const vm = await refreshViewModel(makeIo($))
36    await update($, { plugin: 'mindrian-workspace', key: 'viewModel' } as const, () => vm)
37  } catch (_error) {
38    // The event still goes on: a dead server or a refused write must not stall the session.
39  }
40}
41
42export const registerModel: Register = (on) => {
43  on('session.start', async ($, e, next) => {
44    await refreshNow($)
45    return next(e)
46  })
47  on('turn.complete', async ($, e, next) => {
48    await refreshNow($)
49    return next(e)
50  })
51}
52
src/registrars/pane.tsx 293 lines
1// Plan 07 (replaces the plan 01 seam): the pane's hooks. The pane draws only when a person opens
2// it (the workspace command now, the band's o key in plan 08), so it seats at any width, and it never
3// asks the surface to hold other plugins' toasts (a pane that stays open must not).
4//
5// ENGINE RULES (369.26-ENGINE-RULES.md) shape this file:
6//  - `$` never crosses an import and never sits in an object, so every read of `$` and every action
7//    closure is written HERE, and the shell view (src/pane/pane.tsx) is a pure function that gets
8//    plain values and the closures (rule 1);
9//  - each atom is declared in this file with a literal reference (rule 2), starting values come
10//    from INITIAL (plain data);
11//  - the render hook only reads; every write is in a press, select, open, close, turn or command
12//    handler (rule 4).
13// The plan's renderPane($, e, deps) therefore became buildPane(el, input, deps) plus this hook, and
14// the tab bodies get closures (`act`) instead of `$`.
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register } from 'claude-code'
17
18import { registerWorkspaceCommand } from '../command/workspace'
19import type { LiveIo } from '../model/live/io'
20import { refreshViewModel } from '../model/live/refresh'
21import { chooseViewModel } from '../model/read'
22import { allowedServer, asBody, isAssetName, mergeBody, replaceBody } from '../pane/kit'
23import { buildPane } from '../pane/pane'
24import { isCanonicalHandle } from '../pane/think/lookup'
25import { paneLayout } from '../pane/layout'
26import { closeDecision, closedFor, flipped } from '../pane/state'
27import { tabFocusKey } from '../pane/tab-strip'
28import { tabBodies } from '../pane/tab-bodies'
29import type { ShellActions } from '../pane/types'
30import { BRAIN_SERVER } from '../runtime/ids'
31import type { TabId } from '../runtime/ids'
32import { INITIAL } from '../state/atoms'
33import { decideMode } from '../theme/plain'
34import { PALETTE_ASSET } from '../theme/theme'
35
36// The pane id as a literal of this file, because the engine's listing (and its scan of a matcher)
37// reads a literal or a const of the same file, not an imported one; tests/pane.test.tsx mounts the real
38// pane on PANE_ID in src/runtime/ids.ts, so a drift makes every registrar arm fail.
39const PANE = 'mindrian-workspace'
40
41const tabAtom = atom({ plugin: 'mindrian-workspace', key: 'tab' } as const, INITIAL.tab)
42const keysOpenAtom = atom({ plugin: 'mindrian-workspace', key: 'keysOpen' } as const, INITIAL.keysOpen)
43const explainOpenAtom = atom({ plugin: 'mindrian-workspace', key: 'explainOpen' } as const, INITIAL.explainOpen)
44const detailsOpenAtom = atom({ plugin: 'mindrian-workspace', key: 'detailsOpen' } as const, INITIAL.detailsOpen)
45const plainAtom = atom({ plugin: 'mindrian-workspace', key: 'plain' } as const, INITIAL.plain)
46const sampleAtom = atom({ plugin: 'mindrian-workspace', key: 'sample' } as const, INITIAL.sample)
47const viewModelAtom = atom({ plugin: 'mindrian-workspace', key: 'viewModel' } as const, INITIAL.viewModel)
48// Plan 11: the one generic body state (a slice of JSON per tab). Declared here once, read by the
49// render hook and written only by act.patch and act.update below.
50const bodyAtom = atom({ plugin: 'mindrian-workspace', key: 'body' } as const, INITIAL.body)
51
52// The narrow set of reads a body's loader may make (plan 06's recipe, ENGINE-RULES rule 14), built
53// over this file's `$` with every name spelled as a literal. Differences from the model
54// registrar's copy: `mcpCall` refuses every server except the Mindrian OS server, so a body can
55// never reach the Brain (Canon Part 8); there is no write here. Exported for the kit's tests.
56export function makeIo($: EngineInterface): LiveIo {
57  return {
58    mcpCall: (server, tool, args) =>
59      allowedServer(server) ? $.mcp.call(server, tool, args) : Promise.reject(new Error('server_not_allowed')),
60    envGet: (name) => {
61      if (name === 'MINDRIAN_ROOMS_HOME') return $.env.get('MINDRIAN_ROOMS_HOME')
62      if (name === 'HOME') return $.env.get('HOME')
63      return $.env.get('USERPROFILE')
64    },
65    cwd: () => $.session.cwd(),
66    fsExists: (path) => $.fs.exists(path),
67    fsRead: (path) => $.fs.read(path),
68    usage: () => $.session.usage(),
69    now: () => $.clock.now(),
70  }
71}
72
73// The closures a body and the shell's buttons run, over this hook's `$`. `focusKey` says which
74// keyed control to focus after a tab press (null: none), because only the render hook knows the
75// width and the mode that decide what the strip drew.
76//
77// Plan 11 added the body kit (369.26-ENGINE-RULES.md, "Pane body recipe"): `io`, `patch`, `update`,
78// `refresh`, `readAsset`, `sampleName` and `focus`. A body gets these closures and never `$`.
79// Exported for the kit's tests (a test hook builds a real act over its own `$`).
80export function makeAct($: EngineInterface, focusKey: (tab: TabId) => string | null): ShellActions {
81  const io = makeIo($)
82  const act: ShellActions = {
83    setTab: async (tab) => {
84      await update($, tabAtom, () => tab)
85      const key = focusKey(tab)
86      if (key !== null) {
87        // The first control of the new view (UI-SPEC 8.3). Best effort: a refused move is ignored.
88        await act.focus(key)
89      }
90      try {
91        await tabBodies[tab]?.onOpen?.(act)
92      } catch {
93        // A body that fails to load costs nothing: it draws its own unavailable state.
94      }
95    },
96    fill: async (promptText) => {
97      const filled = await $.prompt.fill({ text: promptText, mode: 'replace' })
98      return filled.isFilled
99    },
100    toast: (message) => {
101      $.ui.toast(message)
102    },
103    toggleKeys: async () => {
104      await update($, keysOpenAtom, (open) => !open)
105    },
106    toggleExplain: async () => {
107      await update($, explainOpenAtom, (open) => !open)
108    },
109    toggleDetails: async (tab) => {
110      await update($, detailsOpenAtom, (rec) => flipped(rec, tab))
111    },
112    io,
113    patch: async (tab, partial) => {
114      await update($, bodyAtom, (rec) => mergeBody(asBody(rec), tab, partial))
115    },
116    update: async (tab, fn) => {
117      await update($, bodyAtom, (rec) => {
118        const body = asBody(rec)
119        return replaceBody(body, tab, fn(body[tab]))
120      })
121    },
122    refresh: async () => {
123      try {
124        const vm = await refreshViewModel(io)
125        await update($, viewModelAtom, () => vm)
126      } catch {
127        // Never rejects: a dead server or a refused write leaves the model as it was.
128      }
129    },
130    readAsset: async (name) => {
131      if (!isAssetName(name)) throw new Error('bad_asset_name')
132      return await $.fs.read(`${$.plugin.root}/assets/${name}`)
133    },
134    sampleName: async () => {
135      const fromAtom = await read($, sampleAtom)
136      const fromEnv = await $.env.get('MOS_WORKSPACE_SAMPLE')
137      const sample = chooseViewModel(fromAtom, fromEnv, null)
138      return sample === null ? null : sample.sampleName
139    },
140    focus: async (key) => {
141      try {
142        await $.ui.focus({ requestId: PANE, key })
143      } catch {
144        // A refused move is ignored on purpose.
145      }
146    },
147    // Plan 16 (Canon Part 8, R-24): the ONE Brain-facing call in the mod. The framework-name canon
148    // is read here, a handle that is not an exact member of it is refused with NO call, and what is
149    // sent is exactly `{ framework: handle }`: no room text, title, point or purpose is in scope of
150    // this closure's call, and tests/test-369.26-part8.cjs holds that shape.
151    guidance: async (handle) => {
152      let canonText: string
153      try {
154        canonText = await $.fs.read(`${$.plugin.root}/assets/framework-names.json`)
155      } catch {
156        return { kind: 'refused' }
157      }
158      if (!isCanonicalHandle(handle, canonText)) return { kind: 'refused' }
159      try {
160        const reply = await $.mcp.call(BRAIN_SERVER, 'framework_techniques', { framework: handle })
161        return { kind: 'reply', reply }
162      } catch {
163        return { kind: 'failed' }
164      }
165    },
166  }
167  return act
168}
169
170export const registerPane: Register = (on) => {
171  registerWorkspaceCommand(on)
172
173  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
174    const el = $.ui.resolve(e)
175    const tab = await read($, tabAtom)
176    const keysOpen = await read($, keysOpenAtom)
177    const explainOpen = await read($, explainOpenAtom)
178    const detailsOpen = await read($, detailsOpenAtom)
179    const body = asBody(await read($, bodyAtom))
180    // The Pane props carry no working flag. The store holds the id of the session whose turn is
181    // running (written by the turn hooks below), so a flag a crashed session left behind is another
182    // session's id and reads as idle here.
183    const workingFor = await $.store.get('working')
184    let working = false
185    try {
186      working = typeof workingFor === 'string' && workingFor !== '' && workingFor === (await $.session.id())
187    } catch {
188      working = false
189    }
190
191    // Which model to draw: the session's sample, the dev env switch, then the live value.
192    const fromAtom = await read($, sampleAtom)
193    const fromEnv = await $.env.get('MOS_WORKSPACE_SAMPLE')
194    const live = await read($, viewModelAtom)
195    const vm = chooseViewModel(fromAtom, fromEnv, live)
196
197    // The mode, decided from plain values read here (the person's switch, the palette, the env).
198    // The state value is what redraws the pane when `workspace plain` flips it; the store is what
199    // survives a restart. Either one says plain.
200    const plainState = await read($, plainAtom)
201    const switchOn = plainState === true || (await $.store.get('plain')) === true
202    let paletteText: string | null
203    try {
204      paletteText = await $.fs.read(`${$.plugin.root}/${PALETTE_ASSET}`)
205    } catch {
206      paletteText = null
207    }
208    const noColor = await $.env.get('NO_COLOR')
209    const term = await $.env.get('TERM')
210    const mode = decideMode({ switchOn, paletteText, noColor, term })
211
212    const surface = e.surface
213    const layout = paneLayout(e.props.bodyColumns)
214    const act = makeAct($, (id) => tabFocusKey(id, mode.plain, layout.tabsAsSelect, surface))
215
216    return buildPane(
217      el,
218      {
219        surface,
220        tab,
221        vm,
222        mode,
223        theme: mode.theme,
224        bodyColumns: e.props.bodyColumns,
225        isFocused: e.props.isFocused,
226        working,
227        keysOpen,
228        explainOpen,
229        detailsOpen,
230        body,
231        act,
232      },
233      { bodies: tabBodies },
234    )
235  })
236
237  // A person's Esc closes the open sub-panel first (explain, then all keys, then details), and the
238  // pane only when none is open. Answering without next keeps the pane open.
239  on('ui.close', { id: PANE }, async ($, e, next) => {
240    const explain = await read($, explainOpenAtom)
241    const keys = await read($, keysOpenAtom)
242    const details = (await read($, detailsOpenAtom))[await read($, tabAtom)]
243    const which = closeDecision(e.origin.kind, { explain, keys, details })
244    if (which === 'explain') {
245      await update($, explainOpenAtom, () => false)
246      return { value: undefined }
247    }
248    if (which === 'keys') {
249      await update($, keysOpenAtom, () => false)
250      return { value: undefined }
251    }
252    if (which === 'details') {
253      const tab = await read($, tabAtom)
254      await update($, detailsOpenAtom, (rec) => closedFor(rec, tab))
255      return { value: undefined }
256    }
257    // The pane really closes: start the next opening from a clean frame.
258    const closed = await next(e)
259    await update($, explainOpenAtom, () => false)
260    await update($, keysOpenAtom, () => false)
261    return closed
262  }).catch(($, e, next) => next(e))
263
264  // The engine raised ui.open for the pane: the active tab's body loads its data.
265  on('ui.open', { id: PANE }, async ($, e, next) => {
266    const result = await next(e)
267    const tab = await read($, tabAtom)
268    try {
269      await tabBodies[tab]?.onOpen?.(makeAct($, () => null))
270    } catch {
271      // ignored on purpose
272    }
273    return result
274  }).catch(($, e, next) => next(e))
275
276  // P05 "Larry is working": a turn starting and completing say it. These write the store, not a
277  // state key, on purpose: plan 06's turn.complete refresh is tested to write the viewModel key and
278  // nothing else. The drawing does not subscribe to the store, so the pane is told to draw again.
279  on('turn.start', async ($, e, next) => {
280    await $.store.set('working', await $.session.id())
281    $.ui.invalidate('ui.render')
282    return next(e)
283  })
284  // plan 06's model registrar already hooks turn.complete with no matcher, and the engine refuses
285  // two unmatched hooks on one event in one module, so this one names every way a turn can end
286  // (TurnCompleteReason) as a one-of matcher.
287  on('turn.complete', { reason: ['answer', 'aborted', 'error', 'refusal'] }, async ($, e, next) => {
288    await $.store.set('working', '')
289    $.ui.invalidate('ui.render')
290    return next(e)
291  })
292}
293
src/band/alerts.ts 63 lines
1// Plan 08: which alerts the one-row band draws, and which fix keys are armed (UI-SPEC 10.5, 7.1,
2// OQ-10). Pure: a view model and a slot count in, plain data out. No `$`, no words (the deck ids
3// are chosen where the alert is drawn).
4//
5// Priority (UI-SPEC 10.5): 1 the context limit (80 percent or more), 2 a room problem (needs a
6// checkup, or broken), 3 a waiting decision. One slot below 68 columns, two from 68. A fact that
7// cannot be read is never an alert: an unreadable room or an unreadable count is not a problem the
8// person can act on, and drawing one would be a false alarm (INV-SL-2).
9import type { TabId } from '../runtime/ids'
10import type { ViewModel } from '../model/view-model'
11
12export type Alert =
13  | { kind: 'context'; percent: number }
14  | { kind: 'health'; status: 'drift' | 'broken' }
15  | { kind: 'waiting'; n: number }
16
17// The context cliff, from docs/STATUSLINE-CONTRACT.md (the navigator-set value).
18export const CONTEXT_LIMIT = 80
19
20// Every alert that is active, in priority order.
21function active(vm: ViewModel): Alert[] {
22  const out: Alert[] = []
23  if (vm.context.state === 'ok' && vm.context.value >= CONTEXT_LIMIT) {
24    out.push({ kind: 'context', percent: vm.context.value })
25  }
26  if (vm.health.state === 'ok' && (vm.health.value === 'drift' || vm.health.value === 'broken')) {
27    out.push({ kind: 'health', status: vm.health.value })
28  }
29  if (vm.waiting.state === 'ok' && vm.waiting.value >= 1) {
30    out.push({ kind: 'waiting', n: vm.waiting.value })
31  }
32  return out
33}
34
35// The alerts that fit in `slots` (a negative or zero count draws none).
36export function chooseAlerts(vm: ViewModel, slots: number): Alert[] {
37  return active(vm).slice(0, Math.max(0, slots))
38}
39
40// How many alert slots a one-row band of this width has (OQ-10 default: one below 68, two from 68).
41export function alertSlots(bodyColumns: number): number {
42  return bodyColumns >= 68 ? 2 : 1
43}
44
45// The keys that are armed because of a state, and where `o` lands. INV-SL-4: a problem is never
46// drawn without its one-tap fix, so `r` and `k` exist exactly when their problem does.
47export type FixFlags = {
48  // `r` (B44): the room needs a checkup or is broken.
49  checkup: boolean
50  // `k` (B55): the context is at the limit.
51  save: boolean
52  // `o`: the pane opens at Review when a decision waits, else at Room (C-11).
53  openTab: Extract<TabId, 'review' | 'room'>
54}
55
56export function fixFlags(vm: ViewModel | null): FixFlags {
57  if (vm === null) return { checkup: false, save: false, openTab: 'room' }
58  const health = vm.health.state === 'ok' && (vm.health.value === 'drift' || vm.health.value === 'broken')
59  const save = vm.context.state === 'ok' && vm.context.value >= CONTEXT_LIMIT
60  const waits = vm.waiting.state === 'ok' && vm.waiting.value >= 1
61  return { checkup: health, save, openTab: waits ? 'review' : 'room' }
62}
63
src/band/band.tsx 126 lines
1// Plan 05: the orientation band at the two three-row tiers (UI-SPEC 6.1, 10.1, 10.3). A pure view
2// function: the element table, the view model, the theme, the mode and the band's own props in; a
3// tree out. It reads `maxRows` and `bodyColumns` from props only (never the window size from anywhere else), draws no
4// timer and no animation, and never writes state.
5//
6// Layout: a root row `bodyColumns` wide. Left, the M:OS text mark (6 columns, 3 rows, C-32). Right, three rows of
7// one line each, blocks separated by one black frame column (a ' | ' in plain mode):
8//   row 1  place, waiting, context (the context block jumps to the front at 80 percent or more)
9//   row 2  purpose, then the health block only when the room needs a checkup or is broken
10//   row 3  next step, then the right slot (plan 08: the Open and Help keys)
11// The version is never drawn (C-10, OQ-04: there is no verified source). Healthy rooms draw no
12// health block (C-15).
13//
14// Plan 08: `renderBand` still draws only the two three-row tiers (and returns null for the rest);
15// `drawBand` is the one entry the registrar calls. It routes every tier and case: the three-row
16// band with its key slots (hint-row.tsx), the one-row band for T1 and T0 and for any room that is
17// not bound at any width (one-row.tsx), and null (the registrar yields to the engine's own
18// drawing) for a survey or a window under four rows.
19import type { RenderElement, RenderNode } from 'claude-code'
20
21import type { ViewModel } from '../model/view-model'
22import type { Mode } from '../theme/plain'
23import type { Theme } from '../theme/theme'
24import { FrameCell } from './blocks'
25import type { El } from './blocks'
26import { bandSlots } from './hint-row'
27import type { BandActions } from './hint-row'
28import { LogoCell } from './logo'
29import { renderOneRow } from './one-row'
30import { pickTier } from './tier'
31import { ContextTile, HealthTile, NextTile, PlaceTile, PurposeTile, WaitingTile, WorkingNote } from './tiles'
32
33// What the engine hands the band (AbovePromptProps), reduced to what the band reads.
34export type BandProps = {
35  bodyColumns: number
36  maxRows: number
37  hasSurvey: boolean
38  isWorking: boolean
39}
40
41// The slots hint-row.tsx fills with the band's keys (Save my thinking, the checkup, the hints).
42export type BandSlots = {
43  row1Fix?: RenderNode
44  row2Fix?: RenderNode
45  row3Right?: RenderNode
46}
47
48// The items of one row with a frame cell between each pair; absent items (null) leave no gap.
49function joined(el: El, theme: Theme | null, mode: Mode, items: Array<RenderNode | null | undefined>): RenderNode[] {
50  const present = items.filter((i): i is RenderNode => i !== null && i !== undefined)
51  const out: RenderNode[] = []
52  present.forEach((item, at) => {
53    if (at > 0) out.push(FrameCell(el, theme, mode))
54    out.push(item)
55  })
56  return out
57}
58
59export function renderBand(
60  el: El,
61  vm: ViewModel,
62  theme: Theme | null,
63  mode: Mode,
64  props: BandProps,
65  slots: BandSlots,
66): RenderElement | null {
67  const tier = pickTier(props.bodyColumns, props.maxRows, props.hasSurvey)
68  if (tier !== 'T3-wide' && tier !== 'T3-compact') return null
69  const { Box } = el
70
71  const showBar = tier === 'T3-wide'
72  const place = PlaceTile(el, vm.place, theme, mode)
73  const waiting = WaitingTile(el, vm.waiting, theme, mode)
74  const context = ContextTile(el, vm.context, theme, mode, showBar)
75  const working = WorkingNote(el, props.isWorking, theme, mode)
76
77  // At the limit the context block (with its Save fix) comes to the front of row 1.
78  const atLimit = vm.context.state === 'ok' && vm.context.value >= 80
79  const row1 = atLimit
80    ? joined(el, theme, mode, [context, slots.row1Fix, place, waiting, working])
81    : joined(el, theme, mode, [place, waiting, context, slots.row1Fix, working])
82
83  const row2 = joined(el, theme, mode, [
84    PurposeTile(el, vm.purpose, theme, mode),
85    HealthTile(el, vm.health, theme, mode),
86    slots.row2Fix,
87  ])
88  const row3 = joined(el, theme, mode, [NextTile(el, vm.next, theme, mode), slots.row3Right])
89
90  return (
91    <Box flexDirection="row" width={props.bodyColumns} height={3}>
92      {LogoCell(el, 'tall', theme, mode)}
93      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
94        <Box flexDirection="row" height={1}>
95          {row1}
96        </Box>
97        <Box flexDirection="row" height={1}>
98          {row2}
99        </Box>
100        <Box flexDirection="row" height={1}>
101          {row3}
102        </Box>
103      </Box>
104    </Box>
105  )
106}
107
108// Every tier and case (plan 08). The tier comes from `maxRows` and `bodyColumns` only. A room that
109// is not bound draws one row at every width (UI-SPEC 10.3), so only a bound room reaches the
110// three-row band, and only at the two three-row tiers.
111export function drawBand(
112  el: El,
113  vm: ViewModel,
114  theme: Theme | null,
115  mode: Mode,
116  props: BandProps,
117  act: BandActions,
118): RenderElement | null {
119  const tier = pickTier(props.bodyColumns, props.maxRows, props.hasSurvey)
120  if (tier === 'yield') return null
121  if (tier === 'T0' || tier === 'T1' || !vm.place.isBound) {
122    return renderOneRow(el, vm, theme, mode, props, tier === 'T0' ? 'T0' : 'T1', { onHelp: () => void act.help() })
123  }
124  return renderBand(el, vm, theme, mode, props, bandSlots(el, { vm, tier, theme, mode }, act))
125}
126
src/band/hint-row.tsx 106 lines
1// Plan 08, reworked by C-28 after the first real render: the band's keys, placed in the slots plan 05
2// left empty (UI-SPEC 7.1 HintLine, 8.2, 8.4, 10.2, 10.5). Pure: the element table, the view model and
3// the press closures in; nodes out.
4//
5//   hint  /workspace: Open workspace (B80) at T3-wide, /workspace: Help (B82) at T3-compact, row 3,
6//         right end. Plain Text in black on the cream row. It names the slash command because a
7//         bare letter is TYPED into the prompt box while the prompt box has focus (real render,
8//         2026-10-06); the command is the one thing that works from there. At T1 and T0 the same
9//         hint is the Help block of the one-row band (drawn by one-row.tsx).
10//   o     armed, drawn as nothing, T3-wide only (HiddenKey): opens the pane (B84)
11//   h     armed, drawn as nothing: opens or shuts the all-keys list (H05)
12//   r     Run a checkup (B44), right end of row 2, only on a room problem (INV-SL-4)
13//   k     Save my thinking (B55), front of row 1, only at 80 percent or more (INV-SL-4)
14//
15// None of the four fires while the prompt box has the keys; each fires only after the person gives
16// the band the keys (ctrl+x then tab, or a click). So no band hint draws a bare letter: `r` and `k`
17// are drawn as bracketed buttons that are pressed by a click or by Tab then Enter, and `o` and `h`
18// are never drawn. At T1 the fix keys are not in the band (no room, and the alert block names the
19// problem): they are armed in the pane's all-keys panel, which `h` opens (src/pane/keys-panel.tsx).
20//
21// The press closures come from the hook file (`$` never crosses an import). `r` and `k` only
22// prefill the prompt box (src/runtime/prefill.ts); `o` and `h` open the pane or its key panel.
23// A band hotkey is one lowercase letter (ButtonProps.hotkey) and each is unique in the band; the
24// pane draws its own `h` and `e`, but a hotkey fires only while its own site holds the keys, so the
25// two sites never contend.
26import type { RenderElement } from 'claude-code'
27
28import { text } from '../copy/text'
29import type { ViewModel } from '../model/view-model'
30import type { Mode } from '../theme/plain'
31import type { Theme } from '../theme/theme'
32import { fixFlags } from './alerts'
33import type { BandSlots } from './band'
34import { Block, HiddenHelpKey, HiddenOpenKey, HintText } from './blocks'
35import type { El } from './blocks'
36import type { Tier } from './tier'
37
38// What a press does, closures over the hook file's `$`.
39export type BandActions = {
40  // `o`: open the pane at Review when a decision waits, else Room; with it open, give it focus.
41  open: () => void | Promise<void>
42  // `h`: open or close the all-keys panel; with the pane closed, open Room with it open.
43  help: () => void | Promise<void>
44  // `r`: add the checkup request to the prompt box.
45  checkup: () => void | Promise<void>
46  // `k`: add the save-my-thinking request to the prompt box.
47  save: () => void | Promise<void>
48}
49
50export type BandKeysInput = {
51  vm: ViewModel
52  tier: Tier
53  theme: Theme | null
54  mode: Mode
55}
56
57export function bandSlots(el: El, input: BandKeysInput, act: BandActions): BandSlots {
58  const { Box, Button } = el
59  const { tier, theme, mode } = input
60  // The three-row tiers only; the one-row band draws its own Help block.
61  if (tier !== 'T3-wide' && tier !== 'T3-compact') return {}
62
63  const flags = fixFlags(input.vm)
64  const slots: BandSlots = {}
65
66  // Row 3, right end: ONE readable hint, black on the cream row (C-28). It names the slash command,
67  // the one way that works from the prompt box (a bare letter would be typed into it). Wide says
68  // Open workspace (B80); compact says Help (B82). `o` and `h` stay armed behind it, drawn as
69  // nothing, and fire only while the band holds the keys.
70  slots.row3Right = Block(el, {
71    job: 'paper',
72    theme,
73    mode,
74    bordered: false,
75    children: [
76      HintText(el, tier === 'T3-wide' ? 'B80' : 'B82', theme, mode),
77      ...(tier === 'T3-wide' ? [HiddenOpenKey(el, text('B84'), () => void act.open())] : []),
78      HiddenHelpKey(el, text('H05'), () => void act.help()),
79    ],
80  })
81
82  // Right end of row 2: the checkup, only when the room needs one or is broken. C-29: a Button label is
83  // the host's light color, so its block is the black frame, never cream (on cream it is invisible).
84  if (flags.checkup) {
85    slots.row2Fix = Block(el, {
86      job: 'structure',
87      theme,
88      mode,
89      bordered: false,
90      children: [<Button key="band:checkup" label={text('B44')} hotkey="r" onPress={() => void act.checkup()} />],
91    })
92  }
93
94  // Front of row 1: save my thinking, only at the context limit.
95  if (flags.save) {
96    slots.row1Fix = Block(el, {
97      job: 'structure',
98      theme,
99      mode,
100      bordered: false,
101      children: [<Button key="band:save" label={text('B55')} hotkey="k" onPress={() => void act.save()} />],
102    })
103  }
104  return slots
105}
106
src/band/tier.ts 21 lines
1// Plan 05: which band the window can hold (UI-SPEC 10.1). A pure function of the three facts the
2// engine hands the band as props; it never looks at the terminal size by any other road (R-02:
3// the thresholds are written against `maxRows` and `bodyColumns` as given, whatever they count).
4//
5//   yield       a survey holds the band, or maxRows is under 4: draw nothing
6//   T3-wide     84 or more columns and 6 or more rows: the concept WIDE band
7//   T3-compact  72 to 83 columns and 6 or more rows: the same rows, no bar
8//   T1          30 to 71 columns, or 4 or 5 rows: one row (plan 08 draws it)
9//   T0          under 30 columns and 4 or more rows: one row, the name and Help only (plan 08)
10export type Tier = 'yield' | 'T3-wide' | 'T3-compact' | 'T1' | 'T0'
11
12export function pickTier(bodyColumns: number, maxRows: number, hasSurvey: boolean): Tier {
13  if (hasSurvey || maxRows < 4) return 'yield'
14  if (bodyColumns < 30) return 'T0'
15  if (maxRows >= 6) {
16    if (bodyColumns >= 84) return 'T3-wide'
17    if (bodyColumns >= 72) return 'T3-compact'
18  }
19  return 'T1'
20}
21
src/copy/text.ts 34 lines
1// Plan 02: text(id, data) is the only way a string reaches the screen.
2// A placeholder with no data is a type error at the call site and an Error at run time; so is a key the
3// string does not use. A typo in a call site must surface in a test, never as a visible brace.
4import { COPY } from './deck'
5import type { CopyId } from './deck'
6
7// The names inside {braces} of a string literal type, as a union (never when there are none).
8type Placeholders<S extends string> = S extends `${string}{${infer Name}}${infer Rest}`
9  ? Name | Placeholders<Rest>
10  : never
11
12// undefined for an id with no placeholder; otherwise one key per placeholder (a count is a number).
13export type CopyData<Id extends CopyId> = [Placeholders<(typeof COPY)[Id]>] extends [never]
14  ? undefined
15  : { [K in Placeholders<(typeof COPY)[Id]>]: K extends 'n' ? number : string }
16
17type TextArgs<Id extends CopyId> = [CopyData<Id>] extends [undefined] ? [data?: undefined] : [data: CopyData<Id>]
18
19const PLACEHOLDER = /\{([^}]*)\}/g
20
21export function text<Id extends CopyId>(id: Id, ...args: TextArgs<Id>): string {
22  const template: string = COPY[id]
23  const data = (args[0] ?? {}) as Record<string, string | number>
24  const wanted = new Set<string>()
25  for (const match of template.matchAll(PLACEHOLDER)) wanted.add(match[1] ?? '')
26  for (const name of wanted) {
27    if (!(name in data)) throw new Error(`missing_copy_data:${name}`)
28  }
29  for (const name of Object.keys(data)) {
30    if (!wanted.has(name)) throw new Error(`unexpected_copy_data:${name}`)
31  }
32  return template.replace(PLACEHOLDER, (_whole, name: string) => String(data[name]))
33}
34
src/model/read.ts 43 lines
1// Plan 04: which view model a component draws. A sample never persists across sessions (no
2// $.store), so a forgotten fixture cannot outlive the session; the pane always shows N04 while
3// `source` is 'sample'.
4//
5// Order: the session atom `sample` (the `workspace sample` command, plan 07), then the
6// environment variable MOS_WORKSPACE_SAMPLE (a dev switch: the render check starts the session
7// with it set), then the live atom `viewModel`.
8//
9// ENGINE FACT (measured in plan 04, see the plan 04 SUMMARY): the host's scan of a hooks module
10// follows `$` only into functions declared in the SAME file, never across an import, and it only
11// accepts a state read whose source is a literal { plugin, key } reference or an atom const
12// declared in that same file. So this module holds no `$` and no atom read (importing it would
13// make `claude plugin validate` fail): every hook spells the three reads itself, then hands the
14// plain values to chooseViewModel:
15//
16//   const fromAtom = await read($, { plugin: 'mindrian-workspace', key: 'sample' } as const)
17//   const fromEnv = await $.env.get('MOS_WORKSPACE_SAMPLE')   // the name must be a literal here
18//   const live = await read($, { plugin: 'mindrian-workspace', key: 'viewModel' } as const)
19//   const vm = chooseViewModel(fromAtom, fromEnv, live)
20import { SAMPLES } from './fixtures'
21import { isViewModel, SAMPLE_NAME_LIST } from './view-model'
22import type { SampleName, ViewModel } from './view-model'
23
24export const SAMPLE_ENV = 'MOS_WORKSPACE_SAMPLE'
25
26function asSampleName(x: unknown): SampleName | null {
27  if (typeof x !== 'string') return null
28  const found = SAMPLE_NAME_LIST.find((name) => name === x)
29  return found ?? null
30}
31
32// The selection rule, pure: the session atom, then the env switch, then the live value. An
33// invalid sample name is ignored; a live value that is not a view model is null.
34export function chooseViewModel(fromAtom: unknown, fromEnv: unknown, live: unknown): ViewModel | null {
35  const atomName = asSampleName(fromAtom)
36  if (atomName !== null) return SAMPLES[atomName]
37
38  const envName = asSampleName(fromEnv)
39  if (envName !== null) return SAMPLES[envName]
40
41  return isViewModel(live) ? live : null
42}
43
src/runtime/ids.ts 12 lines
1// Plan 01: the names every later plan shares. Changing one here changes it everywhere.
2
3export const PLUGIN_NAME = 'mindrian-workspace'
4export const PANE_ID = 'mindrian-workspace'
5
6// The MCP server names as /mcp lists them for the installed plugin (plan 17 verifies both).
7export const MINDRIAN_SERVER = 'plugin:mos:mindrian-os'
8export const BRAIN_SERVER = 'plugin:mos:mindrian-brain'
9
10export const TAB_IDS = ['room', 'think', 'sources', 'review'] as const
11export type TabId = (typeof TAB_IDS)[number]
12
src/runtime/prefill.ts 79 lines
1// Plan 08: the prefill helper. Every button that "does" something for the person (the checkup, save
2// my thinking, the next step, a talk-it-through) only ADDS a sentence to the prompt box; the person
3// reads it and presses Enter. Nothing here submits, runs a command or writes to a room (CONTEXT
4// Contract: the mod "only prefills the prompt box"; threat T-369.26-08-01).
5//
6// ENGINE RULE (369.26-ENGINE-RULES.md rule 1): `$` never crosses an import, so the plan's
7// `prefillPrompt($, id, data)` is `prefillPrompt(io, id, data)`: the hook file builds the two
8// closures over its own `$` (`fill` is `$.prompt.fill({ text, mode: 'replace' })` answering
9// `isFilled`, `toast` is `$.ui.toast`) and hands them in. There is no `submit` closure, so this
10// function has no way to submit.
11import type { CopyId } from '../copy/deck'
12import { text } from '../copy/text'
13import type { CopyData } from '../copy/text'
14
15// The prompt sentences of the copy deck (UI-SPEC 9.5, Q01 to Q07).
16export type QuestionId = Extract<CopyId, `Q${string}`>
17
18export type PrefillIo = {
19  // Put the sentence in the prompt box (replace); resolves whether the box took it.
20  fill: (words: string) => Promise<boolean>
21  // Show a short deck line to the person.
22  toast: (message: string) => void
23}
24
25type DataArg<Id extends QuestionId> = [CopyData<Id>] extends [undefined] ? [data?: undefined] : [data: CopyData<Id>]
26
27// Put `words` in the prompt box and tell the person in P34 (the box took it) or P35 (it did not: a
28// dialog is up, or there is no box). Never throws: a box that refuses is the same as a box that is
29// not there. Resolves whether the words were added.
30async function fillAndTell(io: PrefillIo, words: string): Promise<boolean> {
31  let filled = false
32  try {
33    filled = await io.fill(words)
34  } catch {
35    filled = false
36  }
37  tell(io, filled)
38  return filled
39}
40
41function tell(io: PrefillIo, filled: boolean): void {
42  try {
43    io.toast(filled ? text('P34') : text('P35'))
44  } catch {
45    // A toast that cannot be shown costs nothing.
46  }
47}
48
49// Add the sentence `id` (with its data, when it has placeholders) to the prompt box. The person is
50// told in P34 when the box took it and in P35 when it did not (a dialog is up, or there is no box).
51// Never throws: a box that refuses is the same as a box that is not there. Resolves whether the
52// sentence was added.
53export async function prefillPrompt<Id extends QuestionId>(io: PrefillIo, id: Id, ...args: DataArg<Id>): Promise<boolean> {
54  let words: string
55  try {
56    words = (text as unknown as (id: CopyId, data?: unknown) => string)(id, args[0])
57  } catch {
58    tell(io, false)
59    return false
60  }
61  return await fillAndTell(io, words)
62}
63
64// The longest recorded command the pane will add to the prompt box (T-369.26-11-01: a recorded
65// command is length-capped, then only ever filled, never run).
66export const RECORDED_MAX = 2000
67
68// Plan 11: add a recorded command (data the runtime wrote, not deck copy) to the prompt box,
69// unchanged. Only a non-blank string of at most RECORDED_MAX characters is added; anything else
70// adds nothing and the person is told P35. Fill only: there is no submit closure, so this cannot
71// run it. Resolves whether the text was added. Both take `io` (the shell's `act` fits), never `$`.
72export async function prefillRecorded(io: PrefillIo, words: string): Promise<boolean> {
73  if (typeof words !== 'string' || words.trim().length === 0 || words.length > RECORDED_MAX) {
74    tell(io, false)
75    return false
76  }
77  return await fillAndTell(io, words)
78}
79