Vibe Better With Claude Code: an executable definition of done for Claude Code. Spec, contract checks, parallel build, mechanical proof, human acceptance.

Requires Claude Code 2.1.286 or newer, running Sonnet, Opus or Fable.
You're not an engineer anymore.
You're a prompt jockey with commit access.
At least do it properly.
<img src="assets/abraham.jpeg" alt="Abraham Lincoln portrait" width="300"/>
<a href="#what-is-this">What is this</a> · <a href="#install">Install</a> · <a href="#start">Start</a> · <a href="#features">Features</a> · <a href="#commands">Commands</a> · <a href="#how-it-stays-safe">Safety</a> · <a href="#configuration">Configuration</a> · <a href="#requirements">Requirements</a> · <a href="#documentation">Docs</a>
Platform: macOS and Linux. Windows is not supported natively: VBW runs on bash. On Windows, run Claude Code inside WSL.
VBW 2 is a rewrite of VBW 1, in philosophy and execution. It is built so you pay once for work that is right: every requirement is proven by a check before it ships, so you spend less over time finding, fixing and redoing what broke. How VBW thinks about cost.
You do not need to read code to use VBW. You describe what you want, answer questions in plain words and judge what only a person can judge. If you do read code, you get the contract, the checks and a git history to review.
VBW is a Claude Code plugin that works as a harness around the model: it builds what you asked for, then proves it works before anyone is allowed to say "done". A requirement counts as done only when an approved check passes on a clean copy of the code, or when you accept it.
Vibe coders: You've shipped an app you can't read, written by an AI you can't question, tested by no one. You know it works because the AI told you so.
Seasoned devs: You've spent years writing tests nobody ran for specs nobody approved, and now you have to trust 4,000 lines a model wrote in minutes.
You type /vbw:vibe and what you want. VBW runs the loop below and stops only when it needs you. And ulinke VBW 1, you type this only once, then chat with Claude Code as you'd usually do. Advanced users have granular controls.
Meet the team. Each is an AI agent with one job, and VBW runs as many of them at once as the work allows: a milestone with ten independent plans gets ten Devs building side by side, then a QA reviewer for every finished phase at the same time.
| Agent | Job | How many |
|---|---|---|
| Architect | Works out the decisions only you should make (cost, data, security, anything hard to undo), then splits the work into phases (a goal each). Writes nothing. | 1, thinking the whole milestone through |
| Lead | Turns each phase into small plans: a few tasks, their own files. Writes the tests first and makes sure they fail today (red). | 1, planning every phase of the milestone |
| Dev | Writes the code for one plan, one commit per task, until its tests pass (green). At standard and deep rigor it also breaks its own code on purpose, to confirm a test notices. Also fixes what fails later. | One for every plan, multiple plans in tandem, all at the same time: VBW sets no limit (only plans that touch the same files wait for each other) |
| QA | Reads a finished phase against its goal and reports what's wrong. Never touches the code. | One for every finished phase, all at the same time |
| Scout | Looks things up, one angle each: your code, the web, the docs. Reports with sources; changes nothing. | Up to 4 at the same time, one per angle |
| Debugger | Reproduces a bug, finds the root cause, then makes the smallest fix plus a test that proves it. | 3 at the same time to investigate, then 1 to fix |
| Docs | Writes the README, guides and changelog. Documentation files only. | One for every documentation plan, working alongside the Devs |
Who's the boss? No agent. The Lead's plans decide what gets built; the kernel decides how it runs: how many Devs work at once (as many as there are ready plans that share no files) and how much checking each phase gets (one Dev for a typo, the full team for payments code). Management by a program.
STEP PRACTICE
┌──────────────────────────────────────┐
│ /vbw:vibe │
│ First run: sets up .vbw/, asks you │
│ 3 questions. Existing code: maps it. │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 1. AGREE │ ATDD / Specification by Example
│ Spec: requirements, each marked │ Acceptance criteria written
│ [auto] (a check decides) or [human] │ before design. Decisions
│ (you decide). Decisions asked one │ recorded with their rationale.
│ at a time. │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 2. PLAN │ TDD: red
│ Lead splits each phase into small │ Every [auto] requirement gets
│ plans with disjoint files. Writes a │ an acceptance check (argv, exit
│ check per [auto] requirement and │ code), confirmed failing today
│ runs it: it must fail. │ for the right reason.
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐ ┌─────────────────────────┐
│ 3. APPROVE │ │ "Not yet": the plan │
│ You approve one contract: spec, │ ◀──│ changes, you're asked │
│ plans, checks, test files, commands. │ │ again │
│ Any change to it un-approves it. │ └─────────────────────────┘
└──────────────────────────────────────┘ Frozen contract (SHA-256).
│ Check files write-protected
│ by hooks while building.
▼
┌──────────────────────────────────────┐
│ 4. BUILD │ TDD: green
│ Devs build plans in parallel waves, │ Red verified before the first
│ each only in its own files. One │ change. Atomic commits with a
│ commit per task. │ VBW-Plan: provenance trailer.
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐ ┌─────────────────────────┐
│ 5. PROVE │ │ FIX │
│ The kernel runs every approved check │─ ─▶│ Agents fix the code; │
│ and the test command on a clean │fail│ a QA fix adds a test. │
│ copy of HEAD, checks each commit │ ◀──│ 3 failed tries: it │
│ stayed in its plan's files, and that │ │ comes to you. │
│ what each plan built is still there. │ │ │
└──────────────────────────────────────┘ └─────────────────────────┘
│ Executable definition of done.
│ Proof in a detached worktree,
│ evidence in .vbw/record.json.
▼
┌──────────────────────────────────────┐ ┌─────────────────────────┐
│ 6. REVIEW │ │ Findings become fixes │
│ QA reviews against the goal and │─ ─▶│ (back to FIX) │
│ changes nothing. You judge each │ └─────────────────────────┘
│ [human] one, or Skip for now. │ Goal-backward review.
└──────────────────────────────────────┘ Manual acceptance testing.
│
▼
┌──────────────────────────────────────┐
│ 7. SHIP │ Regression suite
│ More to build: /vbw:vibe again. │ Every later proof covers every
│ │ check: re-run if a file it
│ │ read changed, reused if not.
└──────────────────────────────────────┘ Nothing proven breaks quietly.
Every stop ends with one line, What I need from you:, and the one thing you must do now, or "nothing". You never have to guess whether VBW is waiting for you.
VBW 2 is built around a kernel: vbw, a bash + jq + git program that the model cannot argue with. Agents do the work; the kernel decides what counts. It's acceptance test-driven development, enforced by code instead of good intentions.
| Claude Code on its own | Claude Code + the VBW kernel | |
|---|---|---|
| Who says it's done | The model | The exit code of an approved check, run on a clean copy of HEAD |
| Who can edit the tests | Anyone, including the agent being tested | Not the building agents: check files are in a SHA-256 contract and write-protected by hooks. A change waits for your approval |
| Where the state lives | The chat context, until compaction eats it | .vbw/record.json: one writer, locked, validated, atomic |
| Who runs the process | The model, improvising | Dynamic workflows: deterministic JavaScript |
| Parallel agents | Up to the model | Waves with no shared files; guards deny writes outside each plan |
| Running repo commands | Whatever the model types | Argv only, no eval, after you consent to the content hash |
| Several sessions, one folder | Last write wins | A run's files are leased to its session; everything else stays open |
Three design choices worth knowing:
node_modules or virtualenv, so an agent either reinstalls everything or proves the main checkout's code instead of its own. A false proof is the one failure VBW refuses to have. File-disjoint waves plus guards give the same isolation without it.## Test files section (- run: bats {file} and - files: tests/*.bats) and your tests are reused per file too; you approve the form with the contract. vbw prove --full re-runs everything.How many agents? One Dev for every plan in a wave, all at the same time, and VBW keeps the wave as wide as the file boundaries allow: ten plans that touch different files means ten Devs at once.
Open Claude Code and run these two commands inside the session, one at a time:
Step 1: Add the marketplace
/plugin marketplace add swt-labs/vibe-better-with-claude-code-vbw
Step 2: Install the plugin
/plugin install vbw@vbw-marketplace
Two commands, two separate inputs. Pasted together, Claude Code reads them as one command and the URL breaks.
Step 3: Turn on updates
Run /plugin, then Marketplaces tab, vbw-marketplace, Enable auto-update. Claude Code then installs each new VBW by itself and tells you to run /reload-plugins. It is off by default for plugins outside Anthropic's own marketplace, and VBW never goes online to check, so without it nothing tells you a new version is out.
Restart Claude Code. VBW turns on what it needs (Dynamic workflows and its status line) the first time you use it. /vbw:doctor checks the rest.
Run /vbw:update. It runs claude plugin marketplace update vbw-marketplace and claude plugin update vbw@vbw-marketplace, then asks you to run /reload-plugins (or restart Claude Code). /vbw:whats-new then says what changed; the full history is in plugin/CHANGELOG.md. VBW makes no network call, so nothing tells you a new version is out unless you turn on Claude Code's auto-update for vbw-marketplace (/plugin, Marketplaces tab, Enable auto-update); /vbw:doctor shows whether it is on, and after an update VBW offers /vbw:whats-new once. See docs/update.md.
| Supervised (for the cautious) | Auto (for the brave) | |
|---|---|---|
| Start with | claude | Sonnet, Opus or Fable, then Shift+Tab for auto mode |
| Claude Code asks you | Before file writes and commands, once per tool per project | Only when it must |
| VBW stops for | Decisions, approval, checking, shipping | Decisions, approval, checking, shipping |
| Feels like | Some clicking in the first session, then smooth | Agents keep building until a step needs you |
Auto mode keeps the flow unbroken: the agents work longer between stops.
Disclaimer:
--dangerously-skip-permissionsturns off Claude Code's permission prompts, and VBW does not make that safe. VBW's guards are guard rails, not a sandbox; the Claude Code sandbox and permission rules remain the security boundary. Read what you approve.
You only need to remember one command. In any git repository, new or existing:
/vbw:vibe I want a command-line expense tracker in Node.js
[human] requirement (look, feel, wording) VBW shows the thing to judge and asks: Works, Something's wrong (VBW fixes it and asks again) or Skip for now. A requirement you are not ready to judge can wait: when skipped ones are all that holds up shipping, you choose to ship and judge them in the next milestone, where they keep their id, checks and history.Come back any time and type /vbw:vibe: it continues where things stand. Used an earlier VBW in this project? VBW reviews the old folder and recommends converting or starting fresh; you choose (docs/convert.md).
The loop above is the core. Around it:
| Feature | What you get |
|---|---|
| Triage | Every new idea is sorted into now, next or later, with a size. You confirm or overrule in one menu. docs/triage.md |
| What's next | After each ship, and whenever you ask, the Architect recommends a top pick and up to two runners-up, each with a reason and a size. docs/whats-next.md |
| Decisions, recorded | Cost, where data lives, security, anything hard to undo: asked one at a time with a recommendation, recorded with your reason (vbw show decisions). When someone asks "who decided this?", the record and git say it was you. |
| Rigor that fits | A typo gets one Dev and one approval. Payments or sign-in code gets the full team and deep review. VBW sizes the process from what the work touches, raises the bar when a build goes badly, and tells you why. docs/rigor.md |
| Tests that can fail | At standard and deep rigor each Dev breaks its own code on purpose (vbw mutate), and VBW records whether a check noticed. A test that stays green on broken code is strengthened before the plan can finish. docs/workflows.md |
| Fixes that leave a test behind | When QA finds a problem, the fix can add a regression test that would have caught it: the Dev writes the test file and registers it with vbw fix check FIX. It waits for one approval from you, then runs in every later proof. docs/proof.md |
| Failures you can read | A failing check or project command keeps its full output under .vbw/runtime/output/; vbw show fix names the file and the failing tests. |
| Talks at your level | Three questions, once per project. Beginners get plain words. Seniors get terse output and the trade-offs. docs/interview.md |
| Picks up where you left off | Everything lives in .vbw/ in your project, not in a chat window. /vbw:vibe continues exactly where things stand. |
| What it cost | Each run and each milestone is priced from the figures Claude Code gives the status line. vbw show costs. docs/costs.md |
| Status line and panel | Progress, what's proven and what VBW needs from you, always visible. On Claude Code 2.1.287+, the panel has Mission Control (the side pane: where the project stands, in plain words) and, from 2.1.290, Comms (the strip above the prompt: the helpers at work, or one line when VBW needs you). Each shows the running step's end time ("about 12 min, until around 14:20"); Mission Control also shows when the milestone should end, and a sound plays when a question needs you (on by default, /vbw-sound switches it). docs/panel.md |
Rigor that fits, at a glance: how much checking a phase gets, and what raises it.
The status line, under the prompt:
[VBW] expenses │ M1 First milestone │ ▓▓▓▓▓▓▓░░░ 4/6 done │ ▶ build P1.2, P2.1 · 1 of 2 · until 14:20
Context ▓▓▓░░░░░░░ 31% 62K/200K │ Tokens 2 in 195 out │ Cache 93% hit 3.8K write 55.5K read │ Cost $1.42 │ +303 −12
Limits 5h ▓▓░░░░░░░░ 23% (resets 2h13m) │ 7d ▓▓▓▓░░░░░░ 41% (resets 3d)
You need one: /vbw:vibe. The rest exist for people who want more control.
| When | Command | What it does |
|---|---|---|
| Always | /vbw:vibe [what you want] | The next step, every time. This is the whole product |
| Deciding | /vbw:approve | Approve the plan and its checks. Only you can; VBW also asks as a choice where Enter approves |
/vbw:discuss, /vbw:research | Think a decision through; get a sourced answer to a question | |
/vbw:loop ITEM... --ceiling USD | Work through named todo items while you are away, within a spend ceiling; leaves .vbw/loop-report.md (docs/loop.md) | |
/vbw:todo [idea], /vbw:list-todos | Park an idea; see the list | |
| Checking | /vbw:status | Where the project stands |
/vbw:qa, /vbw:verify | Run every check now; judge, one at a time, what only you can | |
/vbw:debug [problem], /vbw:fix [what] | Root cause from three angles at once; a quick fix with every proof re-run afterwards, for when you don't need seven agents to add a comma | |
| Existing code | /vbw:map, /vbw:teach [convention] | Map the codebase; teach VBW your conventions |
/vbw:init, /vbw:convert | Set up VBW (vibe does it too); bring in a project from an earlier VBW | |
| Session | /vbw:pause, /vbw:resume | Stop safely; pick up where you left off |
/vbw-status, /vbw-why, /vbw-todo [idea] | Instant, even mid-run, no model turn (Claude Code 2.1.287+): where things stand; who holds the run and why; park an idea | |
/vbw:panel, /vbw-panel, /vbw-sound | Does the panel work here; open Mission Control; sound on or off (docs/panel.md) | |
| Setup | /vbw:profile, /vbw:config | How VBW works in one go; one setting at a time |
/vbw:skills, /vbw:rtk, /vbw:compress | Find tools for your project (docs/tools.md); set up RTK to compress command output; shrink an instruction file | |
/vbw:help, /vbw:doctor, /vbw:report | List the commands; check the setup; prepare a bug report (you read it before anything is sent) | |
/vbw:update, /vbw:whats-new, /vbw:uninstall | Update; see what changed; remove VBW (your .vbw/ folders stay) |
vbwThe /vbw: commands call the kernel, vbw. Wherever vbw is on your PATH (/vbw:doctor says whether it is), vbw help lists its commands and vbw CMD --help (or -h) prints that command's usage. Help works right after a subcommand too (vbw fix --help lists fix done, fix retry and fix check), needs no project, works while a run is open and changes nothing. Views worth knowing: vbw status, vbw next, vbw show contract, vbw show evidence, vbw show costs.
The team is in What VBW does about it; their specifications are in plugin/agents/.
| Workflow | Started by | Who runs |
|---|---|---|
| Planning | /vbw:vibe at the plan step | Architect (decisions) → Architect (scope) → Lead |
| Building | /vbw:vibe at the build step | one Dev for every ready plan (Docs for documentation), all at the same time |
| Fixing | /vbw:vibe when a check fails or QA finds a problem | one Dev for every group of related fixes, all at the same time |
| Verifying | /vbw:vibe after the checks pass | one QA agent for every built phase, all at the same time |
| Mapping | /vbw:map, or planning on existin
hooks/panel.js 1177 lines1// The VBW panel (R51, R53, R55; mods_4_vbw.md): a Claude Code mod that shows what
2// VBW is doing. It reads the record, the last `vbw next`, the autonomous run, the
3// clone's finished steps, the session's usage and this session's workflow files,
4// all on one timer; it writes nothing itself (/vbw-todo hands its text to the
5// kernel), and does nothing at all where it cannot run: Claude Code older than
6// 2.1.287, or a folder that is not a VBW 2 project. Every hook is wrapped: an error
7// here must never reach the user's session. Every function that takes `$` lives in
8// this file (Claude Code follows `$` into this file's functions only); what is drawn
9// is worked out by the pure modules: panel-stage.js (Comms), panel-sites.js (the
10// one-line sites and rows), panel-pane.js (Mission Control), panel-view.js, and
11// panel-candy.js (motion, sprites, celebrations), whose frames this file blits.
12import { holderName, readRegistry, configDir } from './panel-sessions.js'
13import { panelView, datedRows, liveNeed, supports, COMMS_MIN_VERSION } from './panel-view.js'
14import * as sounds from './panel-sounds.js'
15import { gatherRun, findRun } from './panel-feed.js'
16import { stageModel, renderStage } from './panel-stage.js'
17import { liveStep, dayClock } from './panel-step.js'
18import { statusText, whyText, todoArgs } from './panel-commands.js'
19import { renderPane } from './panel-pane.js'
20import { freshKey } from './panel-next.js'
21import { fresh as freshLook, freshArrive, freshSee } from './panel-candy.js'
22import { readArgv, settingArgv, settingsState, settingsSig } from './panel-settings.js'
23import { stateColor } from './panel-palette.js'
24import { SITES, turnPending } from './panel-sites.js'
25import { motionOf, spriteFrame, encodeCells, confettiFrames, sweepFrames, wrappedModel, celebration } from './panel-candy.js'
26
27const PANE = 'vbw-panel'
28const TICK_MS = 2000
29// The user's choice lives in Claude Code's per-user store, never in the project.
30// A project holding .vbw/runtime/test-mode is a VBW test session (R64): it plays no
31// audio and keeps its choices under test.* keys, never the user's.
32const TEST_MARK = '/.vbw/runtime/test-mode'
33// The store keys of the panel's choices. A real-app test session (TEST_MARK) keeps them
34// under test keys of its own project, so it never touches the user's choices and one
35// scenario's choices (a pane closed on purpose) never reach the next scenario's project.
36const projectTag = (root) => {
37 let h = 5381
38 for (let i = 0; i < root.length; i++) h = ((h * 33) ^ root.charCodeAt(i)) >>> 0
39 return h.toString(36)
40}
41const keys = (testRoot) => {
42 const p = typeof testRoot === 'string' ? 'test.' + projectTag(testRoot) + '.' : ''
43 return { closed: p + 'vbw-panel.closed', nextSeen: p + 'vbw-panel.next-seen', seen: p + 'vbw-panel.seen', needSeen: p + 'vbw-panel.need-seen', needNotified: p + 'vbw-panel.need-notified', helpersSeen: p + 'vbw-panel.helpers-seen', refusalsSeen: p + 'vbw-panel.refusals-seen' }
44}
45// The sound for a request for the user: the clone setting `sound` (docs/config.md) is
46// `off`, `default` (any shipped voice) or a folder under assets/audio/; panel-sounds.js's
47// soundFor picks the clip. With none shipped, the panel is silent.
48const soundFor = sounds.soundFor
49const pick = (st) => {
50 const c = soundFor(st.soundChoice)
51 return typeof c === 'string' ? { asset: c } : isObj(c) ? c : null
52}
53// The clone setting as the kernel defines it: a missing file or key, damaged JSON or a
54// value that is not a string is `default`.
55const soundChoice = (clone) => (isObj(clone) && typeof clone.sound === 'string' && clone.sound ? clone.sound : 'default')
56const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
57const num = (x) => typeof x === 'number' && Number.isFinite(x)
58// VBW's workflows, as /vbw:vibe launches them (Workflow name 'vbw:<kind>').
59const KINDS = ['planning', 'building', 'fixing', 'verifying', 'mapping', 'researching', 'investigating', 'tooling']
60const kindOf = (name) => {
61 const k = typeof name === 'string' ? name.replace(/^vbw[:-]/, '') : ''
62 return KINDS.includes(k) ? k : null
63}
64
65// The motion level (mods_4_vbw.md §3.7, panel-candy.js): a test session is always
66// still; else the record's `settings.motion`; else the interview level. The level
67// is the one `vbw next` read (private or kept in the project) once the interview is
68// done (before that, next fills a neutral level, which says nothing about the
69// person), else the project's kept answers; unknown, calm.
70const levelOf = (st) => {
71 const p = isObj(st.next) && isObj(st.next.profile) ? st.next.profile : null
72 if (p && p.interviewed === true) return p.level
73 const pr = isObj(st.record) && isObj(st.record.project) ? st.record.project : null
74 return pr && isObj(pr.interview) ? pr.interview.level : null
75}
76const motion = (st) => motionOf({ setting: isObj(st.record) && isObj(st.record.settings) ? st.record.settings.motion : null, level: levelOf(st), testMode: st.silent })
77const FRAME_MS = 100 // 10 frames a second, while something moves
78const AFTER_MS = 5000 // a celebration never drawn in this long is let go
79const CONFETTI_ROWS = 3 // the height of the confetti burst in Comms
80const CHEER_MS = 60000
81// A sprite's cells, encoded once per role, pose and frame (every pose has 2 or 3).
82const SPRITES = new Map()
83const spriteCells = (role, pose, n) => {
84 const k = role + ':' + pose + ':' + (n % 6)
85 if (!SPRITES.has(k)) SPRITES.set(k, encodeCells(spriteFrame({ role, pose, frame: n })))
86 return SPRITES.get(k)
87}
88// A celebration: its frames are made for the width of the site that first draws
89// it, encoded once; the first is drawn, the rest are blitted.
90const effect = (key, at, make) => ({ key, at, make, frames: null, i: 0, site: null })
91const drawn = (fx, columns, site) => {
92 if (!fx.frames) {
93 const f = fx.make(Math.min(512, Math.floor(columns)))
94 if (!f.length) return null
95 Object.assign(fx, { cols: f[0].cols, rows: f[0].rows, frames: f.map(encodeCells) })
96 }
97 fx.site = site
98 return fx.frames[Math.min(fx.i, fx.frames.length - 1)]
99}
100
101// Parse a project file; null when it is not a JSON object (half written, invalid).
102function parse(text) {
103 try {
104 const v = JSON.parse(text)
105 return isObj(v) ? v : null
106 } catch {
107 return null
108 }
109}
110
111// What the panel knows: the last good state of its files, the live run, what is drawn, what is asked.
112function fresh() {
113 return {
114 live: false, registered: false, born: {}, timer: null, busy: false, question: null, sound: true, soundChoice: 'default', soundSig: undefined, settingsPath: null, keys: keys(false), silent: false, wouldPlay: 0,
115 record: null, next: null, history: null, cost: null, health: null, auto: null, stepsPath: null, steps: null, shown: '', seen: {}, sized: false,
116 root: null, sessionId: null, holder: null, registry: null, stored: null, configDir: null, sessionDir: null, runRef: null, run: null, feed: {}, finder: {}, toolRuns: {}, leases: [],
117 tab: 'now', phase: null, check: null, agent: null, suggested: null, before: null, memo: {}, notice: null,
118 lastRec: null, crews: {}, anim: null, animBusy: false, frameNo: 0, commsId: null, nextFileAt: null, questionAt: null, helpersSeen: {}, refusalsSeen: {},
119 sprites: null, burst: null, sweep: null, cheer: null, wrapped: null, nextSeen: null, nextAt: null, nextAnim: null,
120 seenNext: null, needSeen: null, needNotified: null, proving: null, stamps: {}, stepNote: null, step: null, open: {},
121 fresh: {}, settings: null,
122 }
123}
124
125// What is true now, for every place that asks: next.json comes with the time it was
126// written, so a gate is dated by the file and answered by the record that followed it.
127// A message keeps the time it began saying what it says (st.stamps): the first pass
128// reads the dated sentences bare, the second draws them all, each with its [HH:MM].
129const STAMPED = ['estimate-milestone']
130const bare = (t) => String(t).replace(/^\[\d\d:\d\d\] /, '')
131function view(st, now) {
132 const input = {
133 record: st.record, next: st.next, nextAt: st.nextFileAt, question: st.question, questionAt: st.questionAt,
134 health: st.health, now, cost: st.cost, steps: st.steps, step: st.step, proving: st.proving !== null, holder: st.holder,
135 refusals: isObj(st.run) ? st.run.refusals : null,
136 }
137 const proving = st.proving && num(st.proving.at) ? st.proving.at : undefined
138 const first = datedRows({ ...input, stamps: { proving } })
139 const stamps = { proving }
140 for (const id of STAMPED) {
141 const row = first.rows.find((r) => r.id === id)
142 const text = row ? bare(row.text) : null
143 if (text === null || /^No (?:estimate|end time)/.test(text)) delete st.stamps[id]
144 else {
145 if (!st.stamps[id] || st.stamps[id].text !== text) st.stamps[id] = { text, at: now }
146 stamps[id] = st.stamps[id].at
147 }
148 }
149 // A need without a time of its own is dated by when it was first seen.
150 const need = first.need
151 if (need && !num(need.at)) {
152 if (!st.stamps.need || st.stamps.need.text !== need.key) st.stamps.need = { text: need.key, at: now }
153 stamps.need = st.stamps.need.at
154 } else delete st.stamps.need
155 return panelView({ ...input, stamps })
156}
157
158// The session cost moved while the open lease ran; null without both figures.
159const openLease = (st) => {
160 const l = st.leases[st.leases.length - 1]
161 return l && l.costEnd === undefined ? l : null
162}
163const runCost = (st) => {
164 const l = openLease(st)
165 return l && num(l.costStart) && num(st.cost) ? st.cost - l.costStart : null
166}
167
168// Comms' model, for any width: what comms() draws and the redraw check compares.
169// What the user has not seen yet (a recommendation, a needs-you line, a failed helper)
170// is marked until it is pressed, opened, or its tab is opened.
171const needNow = (st) => liveNeed({ record: st.record, next: st.next, nextAt: st.nextFileAt, question: st.question, questionAt: st.questionAt, health: st.health })
172const needId = (n) => n.key + '@' + String(n.at)
173// The fresh marks drawn (panel-candy.js): the recommendation's while it is unseen, dated by
174// when the panel saw it arrive, and this session's finished runs and changed settings.
175const freshMarks = (st) => {
176 const key = freshKey(recOf(st))
177 return key && unseenNext(st) ? { [key]: num(st.nextAt) ? st.nextAt : 0, ...st.fresh } : st.fresh
178}
179const unseenNext = (st) => {
180 const rec = recOf(st)
181 return isObj(rec) && typeof rec.at === 'string' && rec.at !== '' && rec.at !== st.seenNext
182}
183// A short message keeps the time it began saying what it says (st.stamps.comms): the
184// same text keeps its time; new text, or text gone for longer than a few redraws, starts afresh.
185const COMMS_GAP_MS = 10000
186const commsStamp = (st, now) => (id, text) => {
187 const c = isObj(st.stamps.comms) ? st.stamps.comms : (st.stamps.comms = {})
188 const m = c[id]
189 if (!isObj(m) || m.text !== text || now - m.seen > COMMS_GAP_MS) c[id] = { text, at: now, seen: now }
190 else m.seen = now
191 return c[id].at
192}
193// The refusals the agents of the last run reported (panel-feed.js); the newest one
194// not yet seen goes on Comms until the Now tab is shown (seen(), 'refusals').
195const runRefusals = (st) => (isObj(st.run) && Array.isArray(st.run.refusals) ? st.run.refusals.filter((r) => isObj(r) && typeof r.id === 'string') : [])
196const refusalNow = (st) => runRefusals(st).filter((r) => !st.refusalsSeen[r.id]).reduce((a, r) => (!a || (num(r.at) ? r.at : 0) >= (num(a.at) ? a.at : 0) ? r : a), null)
197const stage = (st, now, props) => {
198 const need = needNow(st)
199 return stageModel({
200 refusal: refusalNow(st),
201 stamp: commsStamp(st, now),
202 run: st.run, next: st.next, step: st.step, now, health: st.health, cost: runCost(st), motion: motion(st),
203 need, fresh: need !== null && needId(need) !== st.needSeen,
204 seen: Object.keys(st.helpersSeen), born: st.born, columns: isObj(props) ? props.bodyColumns : undefined, maxRows: isObj(props) ? props.maxRows : undefined,
205 })
206}
207
208// Read a file only when its size or time changed; keep the old state when it cannot be used,
209// or drop it when `gone` and the file is gone.
210async function refresh($, st, path, key, gone, timeKey) {
211 let sig
212 let at = null
213 try {
214 const f = await $.fs.stat(path)
215 at = num(f.mtimeMs) ? f.mtimeMs : null
216 sig = f.size + ':' + f.mtimeMs
217 } catch {
218 if (gone) st[key] = null
219 if (gone && timeKey) st[timeKey] = null
220 delete st.seen[path]
221 return
222 }
223 if (st.seen[path] === sig) return
224 st.seen[path] = sig
225 let text
226 try {
227 text = await $.fs.read(path)
228 } catch {
229 return
230 }
231 const v = parse(text)
232 if (v) {
233 st[key] = v
234 if (timeKey) st[timeKey] = at // the time stays with the contents it belongs to
235 }
236}
237
238// Where this clone keeps steps.json: its git directory, also from a linked worktree
239// (whose .git file names a directory with a `commondir` pointer). Null when unknown.
240const norm = (p) => {
241 const out = []
242 for (const s of p.split('/')) {
243 if (s === '..') out.pop()
244 else if (s !== '' && s !== '.') out.push(s)
245 }
246 return '/' + out.join('/')
247}
248const place = (base, p) => norm(p.startsWith('/') ? p : base + '/' + p)
249
250async function stepsPath($, root) {
251 try {
252 const dot = root + '/.git'
253 if ((await $.fs.stat(dot)).kind === 'dir') return dot + '/vbw/steps.json'
254 const m = /^gitdir:\s*(.+?)\s*$/m.exec(await $.fs.read(dot))
255 if (!m) return null
256 const dir = place(root, m[1])
257 let common = dir
258 try {
259 const c = (await $.fs.read(dir + '/commondir')).trim()
260 if (c) common = place(dir, c)
261 } catch {
262 // not a linked worktree: this is the git directory
263 }
264 return common + '/vbw/steps.json'
265 } catch {
266 return null
267 }
268}
269
270// This session's folder, where Claude Code writes its workflow runs: the Claude
271// config folder `dir` (as tools/resolve-claude-dir.sh finds it), the project's folder
272// (its path, every other character a dash) and the session id. Null when unknown.
273// The project of a session started in DIR, found as the kernel finds it (vbw_git_root):
274// the nearest folder at or above DIR that holds .git (a folder, or the file of a linked
275// worktree or a submodule), never above /; DIR itself when none does (hooks/project.sh).
276async function projectOf($, dir) {
277 let d = dir.replace(/\/+$/, '')
278 for (;;) {
279 if (await $.fs.exists(d + '/.git')) return d || '/'
280 const i = d.lastIndexOf('/')
281 if (i < 0) return dir
282 d = d.slice(0, i)
283 }
284}
285
286function sessionDir(dir, root, id) {
287 if (typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) return null
288 return dir ? dir + '/projects/' + root.replace(/[^A-Za-z0-9]/g, '-') + '/' + id : null
289}
290
291// Who holds the open run: Claude Code's list of chats and what `vbw run start` stored
292// (.vbw/runtime/holder.json), read on the tick only while a lease is open; the list
293// alone also while the Sessions tab is shown.
294async function readHolder($, st) {
295 const lease = isObj(st.record) && isObj(st.record.lease) ? st.record.lease : null
296 if (!lease && st.tab !== 'sessions') {
297 st.holder = null
298 st.registry = null
299 return
300 }
301 const registry = await readRegistry($, st.configDir)
302 st.registry = registry
303 if (!lease) {
304 st.holder = null
305 return
306 }
307 await refresh($, st, st.root + '/.vbw/runtime/holder.json', 'stored', true)
308 st.holder = holderName({ lease, registry, stored: isObj(st.stored) ? st.stored : null, sessionId: st.sessionId })
309}
310
311// The session cost so far, the context fill and the weekly limit: numbers, or null
312// when they cannot be read (never a guess).
313async function readUsage($, st) {
314 try {
315 const u = await $.session.usage()
316 const usd = isObj(u) && isObj(u.cost) ? u.cost.usd : null
317 st.cost = num(usd) ? usd : null
318 const ctx = isObj(u) && isObj(u.context) ? u.context.percent : null
319 const week = isObj(u) && Array.isArray(u.rateLimits) ? u.rateLimits.find((r) => isObj(r) && r.kind === 'seven_day') : null
320 st.health = num(ctx) || (week && num(week.percentUsed)) ? { contextPct: num(ctx) ? ctx : null, weekPct: week && num(week.percentUsed) ? week.percentUsed : null } : null
321 } catch {
322 st.cost = null
323 st.health = null
324 }
325}
326
327// A proof is running in this session while its mark exists (the kernel removes it when
328// the proof ends); the mark's time is when it began.
329async function readProving($, st) {
330 try {
331 const f = st.sessionId ? await $.fs.stat(st.root + '/.vbw/runtime/proving.' + st.sessionId) : null
332 st.proving = f ? { at: num(f.mtimeMs) ? f.mtimeMs : null } : null
333 } catch {
334 st.proving = null
335 }
336}
337
338// Each lease (one VBW run) with the session cost when it opened and when it closed.
339function trackLeases(st) {
340 const l = isObj(st.record) && isObj(st.record.lease) ? st.record.lease : null
341 const key = l ? String(l.run ?? '') + '@' + String(l.started_at ?? '') : null
342 const open = openLease(st)
343 if (open && open.key !== key) {
344 open.costEnd = st.cost
345 st.fresh = freshArrive(st.fresh, 'timeline:run:' + open.key, 0) // drawn as the word alone: no motion, no time
346 }
347 if (key && (!open || open.key !== key)) {
348 const at = Date.parse(l.started_at)
349 st.leases.push({ key, kind: String(l.kind || 'run'), startedAt: Number.isFinite(at) ? at : null, costStart: st.cost })
350 }
351}
352
353// The reads panel-feed.js makes (list, stat, read and the clock), spelled here:
354// Claude Code follows `$` into this file's functions only.
355function reads($) {
356 return {
357 fs: { read: (p) => $.fs.read(p), list: (p) => $.fs.list(p), stat: (p) => $.fs.stat(p) },
358 clock: { now: () => $.clock.now() },
359 }
360}
361
362// The live run: the one this session launched, else (after a reload, while a lease
363// is open) the newest VBW run still going in this session's folder.
364async function feed($, st) {
365 if (!st.sessionDir) return
366 const io = reads($)
367 const ended = !st.run || st.run.status !== 'running'
368 if ((!st.runRef || ended) && isObj(st.record) && isObj(st.record.lease)) {
369 const found = await findRun(io, st.finder, st.sessionDir)
370 const kind = found && kindOf(found.kind)
371 if (kind && (!st.runRef || found.runId !== st.runRef.runId)) st.runRef = { runId: found.runId, kind }
372 }
373 if (st.runRef) st.run = await gatherRun(io, st.feed, { sessionDir: st.sessionDir, ...st.runRef })
374 // Each crew this session saw, for Wrapped's agent count.
375 if (isObj(st.run) && typeof st.run.runId === 'string' && Array.isArray(st.run.agents)) {
376 st.crews[st.run.runId] = { startedAt: st.run.startedAt, agents: st.run.agents.map((a) => (isObj(a) ? a.id : null)) }
377 }
378}
379
380// The runs this session saw, for Wrapped: each lease with what it cost (an open one
381// up to now), each workflow crew with its agents.
382const runsSeen = (st) => [
383 ...st.leases.map((l) => {
384 const end = l.costEnd === undefined ? st.cost : l.costEnd
385 return { startedAt: l.startedAt, cost: num(l.costStart) && num(end) ? end - l.costStart : null }
386 }),
387 ...Object.values(st.crews),
388]
389
390// What a change of the record earns (panel-candy.js): a phase passing QA, confetti
391// in Comms (full motion); all checks green, the sweep over Proof (full) and a
392// line in Comms (full and calm); a ship, the pane on VBW Wrapped (any motion:
393// it is information). The first snapshot is not news.
394// The proof's own time (when it was recorded, its day named when not today), else now.
395const proofTime = (st, now) => {
396 const ev = isObj(st.record) && isObj(st.record.evidence) ? st.record.evidence : null
397 const at = ev ? Date.parse(ev.at) : NaN
398 return dayClock(Number.isFinite(at) ? at : now, now)
399}
400async function celebrate($, st, now) {
401 const before = st.lastRec
402 if (before === st.record) return
403 st.lastRec = st.record
404 const c = celebration({ before, after: st.record })
405 const m = motion(st)
406 if (c === 'qa-pass' && m === 'full') st.burst = effect('vbw-confetti', now, (w) => confettiFrames({ cols: w, rows: CONFETTI_ROWS, seed: now }))
407 if (c === 'all-green' && m !== 'off') st.cheer = { text: (proofTime(st, now) ? '[' + proofTime(st, now) + '] ' : '') + '✓ All checks pass', until: now + CHEER_MS }
408 if (c === 'all-green' && m === 'full') st.sweep = effect('vbw-sweep', now, (w) => sweepFrames({ cols: w }))
409 if (c === 'shipped') {
410 st.wrapped = wrappedModel({ record: st.record, runs: runsSeen(st), steps: st.steps })
411 if (st.wrapped) {
412 st.tab = 'wrapped'
413 await open($)
414 }
415 }
416}
417
418// Repaint by blit (no redraw): the crew's sprites while the run works and Comms
419// shows them, and a celebration's frames once drawn; the loop stops when nothing moves.
420async function paint($, requestId, key, cells) {
421 try {
422 const r = await $.ui.blit({ requestId, key, cells })
423 return !(isObj(r) && r.deny)
424 } catch {
425 return false
426 }
427}
428
429function still(st) {
430 if (st.anim && st.anim.cancel) st.anim.cancel()
431 st.anim = null
432}
433
434function animate($, st) {
435 if (!st.anim) st.anim = $.clock.every(FRAME_MS, () => frame($, st))
436}
437
438async function frame($, st) {
439 if (st.animBusy) return
440 st.animBusy = true
441 try {
442 const n = ++st.frameNo
443 if (st.sprites && !(motion(st) === 'full' && isObj(st.run) && st.run.status === 'running')) st.sprites = null
444 for (const s of st.sprites || []) {
445 if (!(await paint($, st.commsId, s.key, spriteCells(s.role, s.pose, n)))) {
446 st.sprites = null
447 break
448 }
449 }
450 for (const k of ['burst', 'sweep']) {
451 const fx = st[k]
452 if (!fx || !fx.site) continue
453 fx.i++
454 if (fx.i >= fx.frames.length || !(await paint($, fx.site, fx.key, fx.frames[fx.i]))) {
455 st[k] = null
456 $.ui.invalidate('ui.render')
457 }
458 }
459 if (!st.sprites && !(st.burst && st.burst.site) && !(st.sweep && st.sweep.site)) still(st)
460 } catch {
461 still(st)
462 } finally {
463 st.animBusy = false
464 }
465}
466
467// Everything the panel shows, read afresh: each file only when it changed, never written.
468async function gather($, st) {
469 const root = st.root
470 await refresh($, st, root + '/.vbw/record.json', 'record')
471 await refresh($, st, root + '/.vbw/runtime/next.json', 'next', false, 'nextFileAt')
472 await readProving($, st)
473 await refresh($, st, root + '/.vbw/runtime/step.json', 'stepNote', true)
474 st.step = liveStep(st.stepNote, st.record, st.sessionId)
475 if (st.sessionId) await refresh($, st, root + '/.vbw/runtime/auto.' + st.sessionId + '.json', 'auto', true)
476 if (st.stepsPath) {
477 await refresh($, st, st.stepsPath, 'history')
478 st.steps = isObj(st.history) && Array.isArray(st.history.steps) ? st.history.steps : null
479 }
480 await readSound($, st)
481 await readUsage($, st)
482 await readHolder($, st)
483 trackLeases(st)
484 await feed($, st)
485}
486
487// The sound choice, from the clone's settings.json in the git directory (only the kernel
488// changes it: `vbw config set sound`); a damaged or missing file reads as `default`.
489async function readSound($, st) {
490 if (!st.settingsPath) return
491 let sig = null
492 try {
493 const f = await $.fs.stat(st.settingsPath)
494 sig = f.size + ':' + f.mtimeMs
495 } catch {
496 sig = null
497 }
498 if (sig !== st.soundSig) {
499 st.soundSig = sig
500 let v = null
501 try {
502 v = sig === null ? null : JSON.parse(await $.fs.read(st.settingsPath))
503 } catch {
504 v = null
505 }
506 st.soundChoice = soundChoice(v)
507 }
508 st.sound = st.soundChoice !== 'off'
509}
510
511// Call the user, once: the needs-you sound (`clip`) and a native notification (`text`), both
512// only while the sound is on, the notification only while motion is not off, neither in a
513// test session. Claude Code has no notification setting of VBW's own: `motion` is the
514// switch (docs/panel.md). A missing player or $.ui.notify is silent.
515async function call($, st, text, clip) {
516 if (!st.sound) return
517 if (st.silent) {
518 if (clip) st.wouldPlay++
519 return
520 }
521 if (clip) {
522 try {
523 await $.audio.play(clip)
524 } catch {
525 // no file or no player: nothing to say
526 }
527 }
528 if (!text || motion(st) === 'off') return
529 try {
530 await $.ui.notify(text, { title: 'VBW' })
531 } catch {
532 // no notifications here: nothing to show instead
533 }
534}
535
536// The need now standing, marked as notified (kept in the user's store, so a restart does
537// not raise it again): true when it had not been notified yet.
538async function firstCall($, st, need) {
539 const id = needId(need)
540 if (id === st.needNotified) return false
541 st.needNotified = id
542 await keep($, st.keys.needNotified, id)
543 return true
544}
545
546// VBW starts waiting for the person (the needs-you line appears): one notification, once
547// per need, never on a redraw. A question has its own call, with Claude Code's menu.
548async function noteNeed($, st) {
549 const need = needNow(st)
550 if (!need || need.kind === 'question') return
551 if (await firstCall($, st, need)) await call($, st, need.text, null)
552}
553
554// Claude Code's question menu just opened: one clip, and one notification naming what VBW
555// needs (the need standing besides the question, else just a question) unless that need
556// was already notified when it appeared.
557async function callForQuestion($, st) {
558 const need = liveNeed({ record: st.record, next: st.next, nextAt: st.nextFileAt, health: st.health })
559 const text = need ? ((await firstCall($, st, need)) ? need.text : null) : 'VBW has a question for you.'
560 await call($, st, text, pick(st))
561}
562
563// An approve gate puts /vbw:approve in the prompt's suggestion, once; the person sends it.
564async function suggest($, st) {
565 const need = liveNeed({ record: st.record, next: st.next, nextAt: st.nextFileAt })
566 const key = need && need.kind === 'approve' ? 'approve' : null
567 if (key === st.suggested) return
568 st.suggested = key
569 if (!key) return
570 try {
571 await $.prompt.suggest({ text: '/vbw:approve' })
572 } catch {
573 // no prompt box: nothing to suggest into
574 }
575}
576
577const cheering = (st, now) => (st.cheer && st.cheer.until > now ? st.cheer.text : null)
578const recOf = (st) => (isObj(st.record) ? st.record.recommendation : undefined)
579const signature = (st, v, now) => JSON.stringify([v, st.sound, recOf(st), unseenNext(st), st.needSeen, freshLook({ marks: freshMarks(st), key: freshKey(recOf(st)), motion: motion(st), now }), settingsSig(st.settings, now), Object.keys(st.fresh), stage(st, now), st.run, st.auto, st.leases.length, st.tab, st.phase, st.check, st.agent, st.open, st.tab === 'sessions' ? st.registry : null,
580 cheering(st, now), !!st.burst, !!st.sweep])
581
582// Remember when each agent first showed; at full motion a new row flashes for a
583// second, and one short timer redraws Comms when the flash is over.
584async function noteBorn($, st, now) {
585 const fresh = (isObj(st.run) && Array.isArray(st.run.agents) ? st.run.agents : []).filter((a) => isObj(a) && typeof a.id === 'string' && a.id && !(a.id in st.born))
586 for (const a of fresh) st.born[a.id] = now
587 if (fresh.length && motion(st) === 'full') $.clock.after(1050, () => redrawIfChanged($, st).catch(() => {}))
588}
589
590// A recommendation with a written time not seen before (kept in the user's store, so
591// a restart does not replay it) starts the What's next motion: one redraw per frame
592// until it is seen (panel-candy.js fresh), only with full motion.
593async function noteRecommendation($, st, now) {
594 const rec = recOf(st)
595 const at = isObj(rec) && typeof rec.at === 'string' && rec.at ? rec.at : null
596 if (!at || at === st.nextSeen) return
597 st.nextSeen = at
598 st.nextAt = now
599 try {
600 await $.store.set(st.keys.nextSeen, at)
601 } catch {
602 // it may move again after a restart
603 }
604 await call($, st, 'There is a new recommendation for what to build next: ' + String(rec.top && rec.top.text ? rec.top.text : 'see What\'s next') + '.', null)
605 if (motion(st) !== 'full' || st.nextAnim) return
606 st.nextAnim = $.clock.every(FRAME_MS, async () => {
607 try {
608 const t = await $.clock.now()
609 await redrawIfChanged($, st, false, t)
610 if ((!unseenNext(st) || motion(st) !== 'full') && st.nextAnim) {
611 st.nextAnim.cancel()
612 st.nextAnim = null
613 }
614 } catch {
615 // the next tick draws it
616 }
617 })
618}
619
620// Ask for a redraw when what is shown changed (`force`: in any case). `at`: the moment, when
621// the caller has just read the clock for it.
622async function redrawIfChanged($, st, force, at) {
623 const now = num(at) ? at : await $.clock.now()
624 await noteBorn($, st, now)
625 const v = view(st, now)
626 await suggest($, st)
627 const sig = signature(st, v, now)
628 if (sig === st.shown && !force) return
629 st.shown = sig
630 $.ui.invalidate('ui.render') // Claude Code drops a redraw that names nothing
631}
632
633async function tick($, st) {
634 if (st.busy) return
635 st.busy = true
636 try {
637 await gather($, st)
638 const now = await $.clock.now()
639 await celebrate($, st, now)
640 await noteRecommendation($, st, now)
641 await noteNeed($, st)
642 for (const k of ['burst', 'sweep']) if (st[k] && !st[k].site && now - st[k].at > AFTER_MS) st[k] = null
643 await redrawIfChanged($, st, false, now)
644 } catch {
645 // keep the last good state
646 } finally {
647 st.busy = false
648 }
649}
650
651// Switch the sound: the kernel keeps the choice (`vbw config set sound off|default`), the
652// panel reads it back and redraws. Null when it changed, else the kernel's refusal, and the
653// sound stays as it was.
654async function setSound($, st, on) {
655 let refusal = null
656 try {
657 const r = await $.process.run([$.plugin.root + '/bin/vbw', 'config', 'set', 'sound', on ? 'default' : 'off'], { cwd: st.root })
658 if (isObj(r) && r.exitCode === 0) {
659 st.soundChoice = on ? 'default' : 'off'
660 st.sound = on
661 st.soundSig = undefined
662 await readSound($, st)
663 } else refusal = String((isObj(r) && (r.stderr || r.stdout)) || '').trim().split('\n')[0] || 'vbw config failed'
664 } catch {
665 refusal = 'VBW could not run vbw config here'
666 }
667 try {
668 await redrawIfChanged($, st)
669 } catch {
670 // the next tick draws it
671 }
672 return refusal
673}
674
675// A quarter of the terminal, never under 40 columns (R82).
676const quarter = (terminal) => Math.max(40, Math.round(terminal / 4))
677const cols = (n) => typeof n === 'number' && Number.isFinite(n) && n > 0
678
679// Once a session, on the first drawing as a dock, ask for a quarter of the terminal
680// unless the pane already has it. The terminal is the conversation beside the pane
681// plus the pane plus 1 (Claude Code 2.1.291). A width the user dragged is kept by
682// Claude Code, which ignores this request; it is never asked again nor stored.
683async function size($, st, e) {
684 if (st.sized || !isObj(e.props) || e.props.placement !== 'dock') return
685 const beside = isObj(e.viewport) ? e.viewport.columns : null
686 const pane = e.props.bodyColumns
687 if (!cols(beside) || !cols(pane)) return
688 st.sized = true
689 const want = quarter(beside + pane + 1)
690 if (pane !== want) await open($, { columns: want })
691}
692
693async function open($, extra) {
694 try {
695 await $.ui.open({ id: PANE, title: 'VBW', ...extra })
696 } catch {
697 // where Claude Code keeps the pane closed, it stays closed
698 }
699}
700
701// The person opened it: remembered, so later sessions open it by themselves.
702async function openByPerson($, st, extra) {
703 try {
704 await $.store.set(st.keys.closed, false)
705 } catch {
706 // the panel still opens
707 }
708 await open($, extra)
709}
710
711// The user has seen these: the recommendation (kept in the user's store, so a restart
712// keeps it seen) and the needs-you line (until the gate changes).
713async function seen($, st, what) {
714 for (const tab of ['timeline', 'settings']) if (what.includes(tab)) st.fresh = freshSee(st.fresh, tab + ':')
715 if (what.includes('need')) {
716 const n = needNow(st)
717 if (n) {
718 st.needSeen = needId(n)
719 await keep($, st.keys.needSeen, st.needSeen)
720 }
721 }
722 if (what.includes('refusals')) {
723 const fresh = runRefusals(st).filter((r) => !st.refusalsSeen[r.id])
724 if (fresh.length) {
725 for (const r of fresh) st.refusalsSeen[r.id] = true
726 await keep($, st.keys.refusalsSeen, Object.keys(st.refusalsSeen).slice(-100))
727 }
728 }
729 const rec = recOf(st)
730 if (what.includes('next') && isObj(rec) && typeof rec.at === 'string' && rec.at) {
731 st.seenNext = rec.at
732 await keep($, st.keys.seen, rec.at)
733 }
734}
735
736// Keep one seen mark in the user's store; on failure it stays seen for this session.
737async function keep($, key, value) {
738 try {
739 await $.store.set(key, value)
740 } catch {
741 // it stays seen for this session
742 }
743}
744
745// A failed helper counts as seen once the user opens it: only while it is failed, so a
746// card opened while the helper worked cannot hide its later failure from Comms.
747async function seeHelper($, st, id) {
748 const rn = st.run
749 const a = isObj(rn) && Array.isArray(rn.agents) ? rn.agents.find((x) => isObj(x) && x.id === id) : null
750 if (!a || a.state !== 'failed' || st.helpersSeen[id]) return
751 st.helpersSeen[id] = true
752 await keep($, st.keys.helpersSeen, Object.keys(st.helpersSeen).slice(-100))
753}
754
755// What a press asks for (panel-stage.js and the Mission Control views): fill the
756// prompt (never send it, decision Q1), open Mission Control on a tab, or choose what
757// a tab shows. Anything else is ignored.
758async function act($, st, a) {
759 try {
760 if (!isObj(a)) return
761 if (typeof a.fill === 'string') {
762 await $.prompt.fill({ text: a.fill })
763 if (a.seen === 'next') {
764 await seen($, st, ['next'])
765 await redrawIfChanged($, st, true)
766 }
767 return
768 }
769 if (a.open === 'mission') {
770 st.tab = typeof a.tab === 'string' ? a.tab : 'now'
771 if (st.tab === 'now') await seen($, st, ['next', 'need', 'refusals'])
772 if (st.tab === 'sessions') await readHolder($, st)
773 if (st.tab === 'timeline' || st.tab === 'settings') await seen($, st, [st.tab])
774 if (st.tab === 'settings') await settings($, st, null)
775 if (typeof a.agent === 'string') {
776 st.agent = a.agent
777 await seeHelper($, st, a.agent) // opened in Mission Control: a failed helper counts as seen
778 }
779 await openByPerson($, st, { focus: true })
780 } else if (typeof a.select === 'string') {
781 st.agent = st.agent === a.select ? null : a.select
782 await seeHelper($, st, a.select) // pressing a failed helper's card opens it, selecting or not
783 }
784 else if (a.tab === 'plan' && typeof a.phase === 'string') st.phase = a.phase
785 else if (typeof a.check === 'string') st.check = st.check === a.check ? null : a.check
786 // Show or hide one thing a press opens in place (a refusal's whole text, a plan's files).
787 else if (typeof a.toggle === 'string') st.open = { ...st.open, [a.toggle]: !st.open[a.toggle] }
788 else if (isObj(a.setting)) await settings($, st, a.setting)
789 else if (typeof a.show === 'string') {
790 st.tab = a.show
791 if (a.show === 'now') await seen($, st, ['next', 'need', 'refusals'])
792 if (a.show === 'sessions') await readHolder($, st) // shown at once, not at the next tick
793 if (a.show === 'timeline' || a.show === 'settings') await seen($, st, [a.show])
794 if (a.show === 'settings') await settings($, st, null) // read at once, not at the next tick
795 }
796 else return
797 await redrawIfChanged($, st, true)
798 } catch {
799 // the press does nothing
800 }
801}
802
803// The Settings tab (panel-settings.js): `choice` ({ key, value }) runs the kernel's own
804// command for it, then the tab reads `vbw config`, `vbw statusline status`, `vbw interview
805// --json`, `vbw workflows status` and this chat's `vbw auto status` again; a change
806// that took is fresh on the tab, and its ✓ saved flash is redrawn away after about 2 s.
807async function settings($, st, choice) {
808 const run = async (argv) => {
809 try {
810 return await $.process.run([$.plugin.root + '/bin/vbw', ...argv], { cwd: st.root })
811 } catch {
812 return null
813 }
814 }
815 const argv = choice ? settingArgv(choice, st.sessionId) : null
816 if (choice && !argv) return
817 const result = argv ? await run(argv) : null
818 const reads = await Promise.all(readArgv(st.sessionId).map(run))
819 const now = await $.clock.now()
820 st.settings = settingsState({ before: st.settings, choice, result, reads, now })
821 if (result && result.exitCode === 0) {
822 st.fresh = freshArrive(st.fresh, 'settings:' + choice.key, now)
823 if (choice.key === 'sound') await readSound($, st)
824 $.clock.after(2100, () => redrawIfChanged($, st).catch(() => {}))
825 }
826}
827
828// /vbw-todo: the text (or the selection) parked by the kernel's `vbw todo add`.
829async function todo($, st, args) {
830 let selection = null
831 if (!String(args ?? '').trim()) {
832 try {
833 selection = await $.ui.selection()
834 } catch {
835 // no selection to take
836 }
837 }
838 const t = todoArgs({ args, selection })
839 if (!t.argv) return t.message
840 try {
841 const r = await $.process.run([$.plugin.root + '/bin/vbw', ...t.argv], { cwd: st.root })
842 const out = String((r.exitCode === 0 ? r.stdout : r.stderr || r.stdout) || '').trim()
843 return out || (r.exitCode === 0 ? 'Parked.' : 'vbw todo failed (exit ' + r.exitCode + ').')
844 } catch {
845 return 'VBW could not run vbw todo here.'
846 }
847}
848
849// What the instant commands read: the gathered state, never a fresh turn.
850async function facts($, st) {
851 let now = Date.now()
852 try {
853 now = await $.clock.now()
854 } catch {
855 // the machine's clock will do
856 }
857 return { record: st.record, next: st.next, nextAt: st.nextFileAt, health: st.health, now, sessionId: st.sessionId, holder: st.holder }
858}
859
860const COMMANDS = [
861 ['vbw-panel', 'Open Mission Control'],
862 ['vbw-sound', "Turn the 'needs you' sound on or off"],
863 ['vbw-status', 'VBW status, at once and free'],
864 ['vbw-why', 'Who holds the VBW run, since when, and what blocks it'],
865 ['vbw-todo', 'Park an idea for later (vbw todo)'],
866]
867
868// Answer a drawing from what a site says (panel-sites.js): null draws the engine's;
869// { props } the engine's, rewritten; { tree } that tree; { text, dim } one line in
870// the row's place; { line, below } one VBW line above (or below) the engine's own,
871// which stays in the tree exactly once.
872async function answer($, e, next, out) {
873 if (!isObj(out)) return next(e)
874 if (out.props) return next({ ...e, props: { ...e.props, ...out.props } })
875 if (out.tree) return out.tree
876 let ui
877 try {
878 ui = $.ui.resolve(e)
879 } catch {
880 return next(e)
881 }
882 if (out.text) return h(ui.Text, { dimColor: out.dim, wrap: 'truncate-end' }, out.text)
883 const line = h(ui.Text, { key: 'vbw-line', dimColor: true, wrap: 'truncate-end' }, out.line)
884 const engine = await next(e)
885 return h(ui.Box, { flexDirection: 'column' }, ...(out.below ? [engine, line] : [line, engine]))
886}
887
888// A site's drawing: nothing of VBW outside a VBW project or on any error.
889async function draw($, st, e, next) {
890 if (!st.live) return next(e)
891 let out = null
892 try {
893 if (e.component === 'AbovePrompt') out = st.comms ? await comms($, st, e) : null
894 else {
895 if (e.component === 'TurnDuration' && turnPending(st, e)) await refresh($, st, st.root + '/.vbw/record.json', 'record')
896 out = SITES[e.component](st, e, await $.clock.now())
897 }
898 } catch {
899 out = null
900 }
901 return answer($, e, next, out)
902}
903
904// Comms (panel-stage.js): the crew while a run works, the needs-you line when VBW
905// needs the person; yields to a survey.
906// Above it, a celebration: the all-green line and the confetti burst. The sprites
907// and the confetti drawn here are what the frame loop blits.
908async function comms($, st, e) {
909 const p = isObj(e.props) ? e.props : {}
910 st.sprites = null
911 if (p.hasSurvey) return null
912 const now = await $.clock.now()
913 const ui = $.ui.resolve(e)
914 // A celebration takes its rows from the room Claude Code gives (maxRows): the line
915 // needs 3 rows, the confetti 4 more, and the crew or the needs-you line keeps the rest.
916 const room = Number.isInteger(p.maxRows) && p.maxRows > 0 ? p.maxRows : 8
917 const cheer = room >= 3 ? cheering(st, now) : null
918 const fx = room - (cheer ? 1 : 0) >= 7 ? st.burst : null
919 const spare = room - (cheer ? 1 : 0) - (fx ? CONFETTI_ROWS : 0)
920 const m = stage(st, now, { ...p, maxRows: spare })
921 const tree = m ? renderStage(ui, m, (a) => act($, st, a)) : null
922 st.commsId = e.requestId
923 if (tree && m.sprites && ui.Raster) st.sprites = m.rows.map((r) => ({ key: 'vbw-sprite-' + r.id, role: r.role, pose: r.pose }))
924 const top = []
925 if (cheer) top.push(h(ui.Text, { key: 'vbw-cheer', color: stateColor('done'), wrap: 'truncate-end' }, cheer))
926 const cells = fx && ui.Raster ? drawn(fx, cols(p.bodyColumns) ? p.bodyColumns : 80, e.requestId) : null
927 if (cells) top.push(h(ui.Raster, { key: fx.key, columns: fx.cols, rows: fx.rows, cells }))
928 if (st.sprites || cells) animate($, st)
929 if (!top.length) return tree ? { tree } : null
930 return { tree: h(ui.Box, { flexDirection: 'column' }, ...top, ...(tree ? [tree] : [])) }
931}
932
933// Start the panel once the session's folder is a VBW project: at session start,
934// and again on a prompt, a question or a workflow while it is not (a first
935// session's /vbw:vibe sets the project up mid-session). No timer before that.
936async function begin($, st) {
937 if (st.live || st.unsupported === true) return
938 try {
939 await start($, st)
940 } catch (err) {
941 // A failure while starting (a store not ready yet) leaves nothing half-started: no
942 // timer, not live, so the next prompt, question or workflow starts it again.
943 st.live = false
944 if (st.timer && st.timer.cancel) st.timer.cancel()
945 st.timer = null
946 throw err
947 }
948}
949async function start($, st) {
950 if (st.unsupported !== false) {
951 const ver = await $.session.version()
952 const v = isObj(ver) ? ver.version : ver
953 st.unsupported = !supports(v)
954 if (st.unsupported) return
955 st.comms = supports(v, COMMS_MIN_VERSION)
956 }
957 const dir = await $.session.root()
958 if (typeof dir !== 'string') return
959 const root = await projectOf($, dir)
960 if (!(await $.fs.exists(root + '/.vbw/record.json'))) return
961 st.live = true
962 st.root = root
963 st.silent = await $.fs.exists(root + TEST_MARK)
964 st.keys = keys(st.silent ? root : null)
965 st.stepsPath = await stepsPath($, root)
966 st.settingsPath = st.stepsPath ? st.stepsPath.replace(/steps\.json$/, 'settings.json') : null
967 try {
968 st.sessionId = await $.session.id()
969 } catch {
970 st.sessionId = null
971 }
972 // The config folder holds this session's folder and the list of chats: found once.
973 st.configDir = await configDir($)
974 // Claude Code names the session's folder after the folder the session started in.
975 st.sessionDir = sessionDir(st.configDir, dir, st.sessionId)
976 await gather($, st)
977 st.lastRec = st.record
978 const stored = await $.store.get(st.keys.nextSeen)
979 st.nextSeen = typeof stored === 'string' ? stored : null
980 const kept = await $.store.get(st.keys.seen)
981 st.seenNext = typeof kept === 'string' ? kept : null
982 const needKept = await $.store.get(st.keys.needSeen)
983 st.needSeen = typeof needKept === 'string' ? needKept : null
984 const helpersKept = await $.store.get(st.keys.helpersSeen)
985 if (Array.isArray(helpersKept)) for (const id of helpersKept) if (typeof id === 'string') st.helpersSeen[id] = true
986 const refusalsKept = await $.store.get(st.keys.refusalsSeen)
987 if (Array.isArray(refusalsKept)) for (const id of refusalsKept) if (typeof id === 'string') st.refusalsSeen[id] = true
988 const notified = await $.store.get(st.keys.needNotified)
989 st.needNotified = typeof notified === 'string' ? notified : null
990 const now = await $.clock.now()
991 await noteRecommendation($, st, now)
992 await noteNeed($, st)
993 const v0 = view(st, now)
994 await suggest($, st)
995 st.shown = signature(st, v0, now)
996 if (st.timer && st.timer.cancel) st.timer.cancel()
997 st.timer = $.clock.every(TICK_MS, () => tick($, st))
998 if (!st.registered) for (const [name, description] of COMMANDS) $.command.register({ name, description, immediate: true })
999 st.registered = true
1000 if ((await $.store.get(st.keys.closed)) !== true) await open($)
1001}
1002async function wake($, st) {
1003 try {
1004 await begin($, st)
1005 } catch {
1006 // the panel stays out of the way
1007 }
1008}
1009
1010// A gating hook that fails is skipped by Claude Code and its `.catch` is asked. Ours let the
1011// event go on as if the panel were not there, which is also what Claude Code does without one;
1012// a command that fails says so in plain words instead of "unknown command". Claude Code names
1013// the failed hook itself, so nothing is logged here. None of them throws.
1014const passOn = ($, e, next) => next(e)
1015const failedText = (name) => '/' + name + ' did not work just now. The rest of VBW is not affected.'
1016
1017export function register(on) {
1018 const st = fresh()
1019
1020 on('session.start', async ($, e, next) => {
1021 const out = await next(e)
1022 await wake($, st)
1023 return out
1024 })
1025
1026 on('command.run', { command: 'vbw-panel' }, async ($, e, next) => {
1027 if (!st.live) return next(e)
1028 const w = isObj(e) && isObj(e.presentation) ? e.presentation.columns : null
1029 await openByPerson($, st, cols(w) ? { columns: quarter(w), focus: true } : { focus: true })
1030 return { text: 'Mission Control is open.' }
1031 }).catch(() => ({ text: failedText('vbw-panel') }))
1032
1033 on('command.run', { command: 'vbw-sound' }, async ($, e, next) => {
1034 if (!st.live) return next(e)
1035 const word = String(e.args ?? '').trim().toLowerCase()
1036 const refused = word === '' || word === 'on' || word === 'off' ? await setSound($, st, word === '' ? !st.sound : word === 'on') : null
1037 return { text: (refused ? 'VBW could not change the sound: ' + refused + '. ' : '') + "The 'needs you' sound is " + (st.sound ? 'on' : 'off') + '.' }
1038 }).catch(() => ({ text: failedText('vbw-sound') }))
1039
1040 on('command.run', { command: 'vbw-status' }, async ($, e, next) => {
1041 if (!st.live) return next(e)
1042 return { text: statusText(await facts($, st)) }
1043 }).catch(() => ({ text: failedText('vbw-status') }))
1044
1045 on('command.run', { command: 'vbw-why' }, async ($, e, next) => {
1046 if (!st.live) return next(e)
1047 return { text: whyText(await facts($, st)) }
1048 }).catch(() => ({ text: failedText('vbw-why') }))
1049
1050 on('command.run', { command: 'vbw-todo' }, async ($, e, next) => {
1051 if (!st.live) return next(e)
1052 return { text: await todo($, st, e.args) }
1053 }).catch(() => ({ text: failedText('vbw-todo') }))
1054
1055 // Remember a close made by the user only; every close is passed on.
1056 on('ui.close', async ($, e, next) => {
1057 if (st.live && isObj(e) && e.id === PANE && isObj(e.origin) && e.origin.kind === 'person') {
1058 try {
1059 await $.store.set(st.keys.closed, true)
1060 } catch {
1061 // the close still goes through
1062 }
1063 }
1064 return next(e)
1065 }).catch(passOn)
1066
1067 // A question to the user is a need while it is open.
1068 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
1069 if (!st.live) await wake($, st)
1070 if (!st.live) return next(e)
1071 st.question = { id: String(Date.now()) + Math.random() }
1072 st.questionAt = await $.clock.now()
1073 try {
1074 await callForQuestion($, st)
1075 await redrawIfChanged($, st)
1076 } catch {
1077 // the next tick draws it
1078 }
1079 try {
1080 return await next(e)
1081 } finally {
1082 st.question = null
1083 st.questionAt = null
1084 try {
1085 await redrawIfChanged($, st)
1086 } catch {
1087 // nothing to redraw
1088 }
1089 }
1090 }).catch(async ($, e, next) => {
1091 // The question went on without the panel noticing its end: it is no longer open as far as the panel knows.
1092 st.question = null
1093 st.questionAt = null
1094 try {
1095 await redrawIfChanged($, st)
1096 } catch {
1097 // the next tick draws it
1098 }
1099 return next(e)
1100 })
1101
1102 // A VBW workflow launched by this session (main loop): its run id and folder
1103 // come back in the launch's result; the timer reads the run from there.
1104 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
1105 if (!st.live) await wake($, st)
1106 const out = await next(e)
1107 try {
1108 const r = isObj(out) && isObj(out.result) ? out.result : null
1109 const kind = kindOf(e.name)
1110 if (!st.live || e.agentId || !r || !kind || typeof r.runId !== 'string' || !/^wf_[A-Za-z0-9_-]+$/.test(r.runId)) return out
1111 const m = typeof r.transcriptDir === 'string' ? /^(\/.+)\/subagents\/workflows\/[^/]+\/?$/.exec(r.transcriptDir) : null
1112 if (m) st.sessionDir = m[1]
1113 st.runRef = { runId: r.runId, kind }
1114 st.run = null
1115 if (typeof e.tool_use_id === 'string') st.toolRuns[e.tool_use_id] = r.runId
1116 } catch {
1117 // the run is found by its lease instead
1118 }
1119 return out
1120 }).catch(passOn)
1121
1122 // The record as a turn found it: the turn receipt says what moved.
1123 on('prompt.submit', async ($, e, next) => {
1124 if (!st.live) await wake($, st)
1125 if (st.live) st.before = st.record
1126 return next(e)
1127 }).catch(passOn)
1128
1129 // Mission Control: drawn as Claude Code's own pane example does, from $.ui.resolve(e)
1130 // and h(). Only this pane's drawings reach here.
1131 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1132 if (!st.live) return next(e)
1133 try {
1134 await size($, st, e)
1135 } catch {
1136 // the width stays as Claude Code placed it
1137 }
1138 try {
1139 const now = await $.clock.now()
1140 const v = view(st, now)
1141 const width = isObj(e.props) && cols(e.props.bodyColumns) ? e.props.bodyColumns : 50
1142 const ui = $.ui.resolve(e)
1143 const cells = st.sweep && st.tab === 'proof' && ui.Raster ? drawn(st.sweep, width, PANE) : null
1144 if (cells) animate($, st)
1145 const sweep = cells ? { cols: st.sweep.cols, cells } : null
1146 return renderPane(ui, { st, v, now, width, sweep, registry: st.registry, stored: st.stored, holder: st.holder, recommendation: recOf(st), fresh: freshMarks(st), motion: motion(st), wrapped: st.wrapped, scroll: isObj(e.props) ? e.props.scroll : null, portrait: $.plugin.root + '/assets/portrait.png' }, {
1147 act: (a) => act($, st, a),
1148 sound: () => setSound($, st, !st.sound),
1149 })
1150 } catch (err) {
1151 // Never an empty pane with nothing said (T160): one dim line with the reason.
1152 try {
1153 const { Text } = $.ui.resolve(e)
1154 const reason = String(err && err.message ? err.message : err).split('\n')[0].replace(/\s+/g, ' ').trim().slice(0, 120)
1155 return h(Text, { key: 'could-not-draw', dimColor: true, wrap: 'truncate' }, 'Mission Control could not draw: ' + (reason || 'unknown reason'))
1156 } catch {
1157 return next(e)
1158 }
1159 }
1160 })
1161
1162 // Every other place VBW draws, each hook matched to its site (render from memory).
1163 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => draw($, st, e, next))
1164 on('ui.render', { component: 'Spinner' }, async ($, e, next) => draw($, st, e, next))
1165 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => draw($, st, e, next))
1166 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => draw($, st, e, next))
1167 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => draw($, st, e, next))
1168 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => draw($, st, e, next))
1169 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => draw($, st, e, next))
1170 on('ui.render', { component: 'ToolUse', props: { tool: 'Workflow' } }, async ($, e, next) => draw($, st, e, next))
1171 on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e, next) => draw($, st, e, next))
1172 on('ui.render', { component: 'ToolUse', props: { tool: 'Edit' } }, async ($, e, next) => draw($, st, e, next))
1173 on('ui.render', { component: 'ToolUse', props: { tool: 'MultiEdit' } }, async ($, e, next) => draw($, st, e, next))
1174 on('ui.render', { component: 'ToolUse', props: { tool: 'Write' } }, async ($, e, next) => draw($, st, e, next))
1175 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) => draw($, st, e, next))
1176}
1177hooks/panel-sessions.js 147 lines1// The panel's one helper for chats (R192): Claude Code's list of chats
2// (<claude-config-dir>/sessions/*.json, read-only), and who holds the run in
3// words. Names are untrusted text: escape sequences and control characters go,
4// long names are cut with an ellipsis. Pure over its input except readRegistry,
5// which reads through `$` and never throws.
6
7import { ACCENT } from './panel-palette.js'
8import { dayClock } from './panel-step.js'
9
10const isObj =(x) => x !== null && typeof x === 'object' && !Array.isArray(x)
11const NAME_MAX = 40
12const ESC = String.fromCharCode(27) // the sequences are matched, never written out
13const OSC = new RegExp(ESC + '\\][^\\u0007' + ESC + ']*(?:\\u0007|' + ESC + '\\\\)?', 'g')
14const CSI = new RegExp(ESC + '\\[[0-?]*[ -/]*[@-~]', 'g')
15
16// A text with its CSI and OSC escape sequences and every control character removed.
17export function cleanName(s) {
18 if (typeof s !== 'string') return ''
19 return s
20 .replace(OSC, '')
21 .replace(CSI, '')
22 .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
23 .trim()
24}
25
26// At most n characters: a longer text becomes its first n-1 and an ellipsis.
27export function cutName(s, n) {
28 const c = [...String(s)]
29 return c.length > n ? c.slice(0, n - 1).join('') + '…' : String(s)
30}
31
32// The file texts that parse to an object with a string sessionId, newest updatedAt first.
33export function registryEntries(texts) {
34 if (!Array.isArray(texts)) return []
35 const out = []
36 for (const t of texts) {
37 try {
38 const v = JSON.parse(t)
39 if (isObj(v) && typeof v.sessionId === 'string' && v.sessionId) out.push(v)
40 } catch {
41 // a malformed file is skipped
42 }
43 }
44 const at = (e) => (Number.isFinite(e.updatedAt) ? e.updatedAt : -Infinity)
45 return out.map((e, i) => ({ e, i })).sort((a, b) => (at(b.e) - at(a.e) || a.i - b.i)).map((x) => x.e)
46}
47
48// Claude Code's config folder, as tools/resolve-claude-dir.sh finds it; null when unknown.
49export async function configDir($) {
50 try {
51 let dir = await $.env.get('CLAUDE_CONFIG_DIR')
52 if (!dir) {
53 const home = await $.env.get('HOME')
54 if (!home) return null
55 dir = (await $.fs.exists(home + '/.config/claude-code')) ? home + '/.config/claude-code' : home + '/.claude'
56 }
57 return dir.replace(/\/+$/, '')
58 } catch {
59 return null
60 }
61}
62
63// The list of chats in the config folder `dir` (configDir, found once a session), newest
64// first; [] when missing or unreadable.
65export async function readRegistry($, dir) {
66 try {
67 if (typeof dir !== 'string' || !dir) return []
68 const names = Object.values(await $.fs.list(dir + '/sessions'))
69 .map((e) => (isObj(e) ? e.name : e)).filter((n) => typeof n === 'string' && /\.json$/.test(n))
70 const texts = []
71 for (const n of names) {
72 try {
73 texts.push(await $.fs.read(dir + '/sessions/' + n))
74 } catch {
75 // an unreadable file is skipped
76 }
77 }
78 return registryEntries(texts)
79 } catch {
80 return []
81 }
82}
83
84// The holder of the run: null without a session in the lease; else its display name
85// (the list's current name, else the stored one, else 8 characters of the id), whether
86// this chat holds it and whether a name is known.
87export function holderName({ lease, registry, stored, sessionId } = {}) {
88 if (!isObj(lease) || typeof lease.session !== 'string' || !lease.session) return null
89 const self = sessionId === lease.session
90 const listed = (Array.isArray(registry) ? registry : []).find((e) => isObj(e) && e.sessionId === lease.session)
91 const fromList = listed ? cleanName(listed.name) : ''
92 const fromStore = isObj(stored) && stored.session === lease.session ? cleanName(stored.session_name) : ''
93 const name = fromList || fromStore
94 return { name: cutName(name || lease.session.slice(0, 8), NAME_MAX), self, known: Boolean(name) }
95}
96
97const EMPTY = 'No other chats are open on this project'
98const inside = (cwd, root) => typeof cwd === 'string' && typeof root === 'string' && root !== '' && (cwd === root || cwd.startsWith(root.replace(/\/+$/, '') + '/'))
99// The Sessions tab: the chats of this project (cwd is the root or a folder under it),
100// newest first, and the holder of the run, listed from the lease when no entry has it.
101export function sessionsModel({ registry, root, sessionId, lease, stored, now, width, plans } = {}) {
102 const room = Number.isInteger(width) && width > 1 ? width : 80
103 const held = isObj(lease) && typeof lease.session === 'string' && lease.session ? lease.session : null
104 const kind = held
105 ? [String(lease.kind || 'run'), ...(Array.isArray(plans) ? plans : []).filter((p) => isObj(p) && p.status === 'building' && typeof p.id === 'string').map((p) => p.id)].join(' ')
106 : ''
107 // One row per session id: the newest updatedAt wins, as the kernel reads the list.
108 const at = (e) => (Number.isFinite(e.updatedAt) ? e.updatedAt : -Infinity)
109 const list = (Array.isArray(registry) ? registry : []).filter((e) => isObj(e) && typeof e.sessionId === 'string' && inside(e.cwd, root))
110 const rows = list
111 .filter((e, i) => list.findIndex((o) => o.sessionId === e.sessionId && at(o) > at(e)) < 0 && list.findIndex((o) => o.sessionId === e.sessionId && at(o) === at(e)) === i)
112 .map((e) => ({
113 id: e.sessionId,
114 name: cutName(cleanName(e.name) || e.sessionId.slice(0, 8), room),
115 state: typeof e.status === 'string' ? cleanName(e.status) : '',
116 last: dayClock(e.updatedAt, now),
117 resume: 'claude --resume ' + e.sessionId,
118 self: e.sessionId === sessionId,
119 holder: e.sessionId === held ? { kind } : null,
120 closed: false,
121 }))
122 const empty = rows.some((r) => !r.self) ? null : EMPTY
123 if (held && !rows.some((r) => r.id === held)) {
124 const name = isObj(stored) && stored.session === held ? cleanName(stored.session_name) : ''
125 rows.push({ id: held, name: cutName(name || held.slice(0, 8), room), state: '', last: '', resume: 'claude --resume ' + held, self: held === sessionId, holder: { kind }, closed: true })
126 }
127 return { rows, empty }
128}
129
130// Text only: no button and no input; the resume line is there to copy.
131export function renderSessions(ui, model) {
132 const { Box, Text } = ui
133 const m = isObj(model) && Array.isArray(model.rows) ? model : { rows: [], empty: EMPTY }
134 const kids = m.rows.map((r) => {
135 const marks = []
136 if (r.holder) marks.push(h(Text, { key: 'holder', color: ACCENT }, '● holds the run · ' + r.holder.kind))
137 if (r.closed) marks.push(h(Text, { key: 'closed', dimColor: true }, 'closed or not seen'))
138 if (r.self) marks.push(h(Text, { key: 'self', dimColor: true }, 'this chat'))
139 return h(Box, { key: 'chat-' + r.id, flexDirection: 'column', marginBottom: 1 },
140 h(Text, { bold: true }, r.name + (r.state ? ' · ' + r.state : '') + (r.last ? ' · last ' + r.last : '')),
141 h(Text, { dimColor: true }, r.resume),
142 ...marks)
143 })
144 if (m.empty) kids.push(h(Text, { key: 'empty' }, m.empty))
145 return h(Box, { key: 'sessions', flexDirection: 'column' }, ...kids)
146}
147hooks/panel-view.js 343 lines1// What the VBW panel says (R51): turns the project record and its last `vbw next`
2// into plain sentences, each with its technical term beside it. Pure: data in,
3// rows out; it never changes its input and never throws.
4
5import { stepWords, dayClock } from './panel-step.js'
6import { REFUSED_MARK } from './panel-palette.js'
7
8export const MIN_VERSION = '2.1.287'
9
10// The dim one-word term under each of the Now tab's sentences ('milestone', 'session cost'):
11// one switch, so the terms can go in one change.
12export const SHOW_TERMS = true
13
14const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
15const num = (x) => typeof x === 'number' && Number.isFinite(x)
16const str = (x) => (typeof x === 'string' ? x : '')
17const safe = (fn) => (input) => {
18 try {
19 return fn(isObj(input) ? input : {})
20 } catch {
21 return null
22 }
23}
24const parts = (v) => {
25 const m = typeof v === 'string' ? /^(\d+)\.(\d+)\.(\d+)/.exec(v) : null
26 return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
27}
28
29// Comms redraws forever before 2.1.290 when its tree changes height (fixed in 2.1.290).
30export const COMMS_MIN_VERSION = '2.1.290'
31
32// True when this Claude Code version has the panel (or, with `min`, that feature);
33// anything unreadable counts as older.
34export function supports(version, min = MIN_VERSION) {
35 const v = parts(version)
36 const m = parts(min)
37 if (!v || !m) return false
38 for (let i = 0; i < 3; i++) if (v[i] !== m[i]) return v[i] > m[i]
39 return true
40}
41
42// '[14:20] ' before a sentence that has a start time ('[yesterday 14:20] ' on another day,
43// dayClock's rule); the sentence alone without one.
44const stamped = (ms, text, now) => (dayClock(ms, now) ? '[' + dayClock(ms, now) + '] ' + text : text)
45
46const NEUTRAL = { id: 'neutral', text: 'No VBW project is open here yet.', term: 'no project' }
47
48function doing(rec, now) {
49 const l = rec.lease
50 if (!isObj(l)) return { id: 'doing', text: 'VBW is idle: nothing is running.', term: 'idle' }
51 const ms = now - Date.parse(l.started_at)
52 const mins = Number.isFinite(ms) ? Math.floor(ms / 60000) : 0
53 const since = mins >= 1 ? mins + ' min so far' : 'just started'
54 const names = (Array.isArray(rec.plans) ? rec.plans : [])
55 .filter((p) => isObj(p) && p.status === 'building').map((p) => p.title).filter(Boolean)
56 const what = {
57 plan: ['planning the work', 'plan'],
58 build: ['building' + (names.length ? ': ' + names.join(', ') : ' the plan'), 'build'],
59 qa: ['checking the results', 'QA'],
60 fix: ['fixing what the checks found', 'fix'],
61 map: ['reading the project to map it', 'map'],
62 }[l.kind] || ['working', String(l.kind || 'run')]
63 return { id: 'doing', text: 'VBW is ' + what[0] + ' (' + since + ').', term: what[1] }
64}
65
66// The command that carries each gate on (a gate not named here goes on with /vbw:vibe).
67export const GATE_COMMANDS = { approve: '/vbw:approve', accept: '/vbw:verify', convert: '/vbw:convert' }
68
69const list = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string' && x).join(', ') : '')
70// The files of a deviations gate's detail ({deviations: [{file, ...}]}).
71const devFiles = (d) => (Array.isArray(d.deviations) ? d.deviations.filter(isObj).map((x) => x.file) : [])
72const RUN_ACTIONS = new Set(['plan', 'build', 'qa', 'fix'])
73const COMPACT_PCT = 85 // the context is this full: /compact before a run
74
75// The gate in next.json as the need it makes: key, kind, the sentence Mission Control
76// says, and the short phrase Comms puts after 'VBW needs you ·'.
77function gateNeed(nx) {
78 const d = isObj(nx.detail) ? nx.detail : {}
79 // The kernel's since (R189): when VBW reached the gate, whichever session or restart reads it.
80 const since = typeof nx.since === 'string' ? Date.parse(nx.since) : NaN
81 const need = (key, text, what) => ({ key, kind: nx.action, text, what, ...(Number.isFinite(since) ? { at: since } : {}) })
82 switch (nx.action) {
83 case 'approve': {
84 const f = list(d.files)
85 if (f) return need('approve:' + f, 'Please approve the plan before VBW builds it.', 'test files changed since your approval: ' + f)
86 if (d.changed === true) return need('approve:changed', 'Your plan changed since you approved it: please review the changes and approve it again.',
87 'the plan changed since you approved it: review the changes, then approve it')
88 return need('approve', 'Please approve the plan before VBW builds it.', 'the plan is ready: review it, then approve it to build')
89 }
90 case 'accept': { const r = list(d.requirements); return need('accept:' + r, 'Please check the result by hand: ' + r + '.', 'check by hand what only you can judge' + (r ? ': ' + r : '')) }
91 case 'ship': return need('ship', 'The milestone is ready: your go-ahead to ship it is needed.', 'the milestone is proven and accepted: ready to ship')
92 case 'spec': return need('spec', 'VBW needs you to say what to build: the requirements.', 'say what to build: the requirements')
93 case 'escalate': { const f = list(d.fixes); return need('escalate:' + f, 'A decision is needed from you on ' + f + '.', 'the fix limit was reached' + (f ? ' for ' + f : '') + ': decide how to go on') }
94 case 'unblock': { const p = list(d.plans); return need('unblock:' + p, 'Work is blocked and needs you' + (p ? ': ' + p : '') + '.', 'blocked' + (p ? ': ' + p : '') + ', VBW needs your help to go on') }
95 case 'scope': return need('scope', 'Some changes went outside their planned files: your decision is needed.', 'commits changed files outside their plans: review them')
96 case 'deviations': {
97 const f = list(devFiles(d))
98 return need('deviations:' + f, 'Files VBW built were changed outside VBW' + (f ? ': ' + f : '') + '. Keep the changes or restore them?', 'changed outside VBW' + (f ? ': ' + f : '') + ': keep the changes or restore them')
99 }
100 case 'milestone': return need('milestone', 'The milestone is shipped: say what to build next.', 'the milestone is shipped: choose what comes next')
101 case 'convert': return need('convert', 'A VBW 1 plan is here: bring it into VBW 2 or start fresh.', 'a VBW 1 plan is here: bring it into VBW 2, or start fresh')
102 default: return need('other:' + String(nx.action), 'VBW is waiting for you: run /vbw:vibe to see what for.', typeof nx.instruction === 'string' && nx.instruction ? nx.instruction : 'a decision is needed')
103 }
104}
105
106// True when the record says the gate in next.json (written at `nextAt`, ms) is done.
107// Any other record change (a parked idea) leaves it standing.
108function answered(rec, nx, nextAt) {
109 const rows = (k) => (Array.isArray(rec[k]) ? rec[k].filter(isObj) : [])
110 if (nx.action === 'approve') {
111 return Number.isFinite(nextAt) && rows('decisions').some((x) => typeof x.text === 'string' && x.text.startsWith('Contract approved') && Date.parse(x.at) > nextAt)
112 }
113 if (nx.action === 'ship') return rec.milestone.status === 'shipped'
114 if (nx.action === 'accept') {
115 const ids = isObj(nx.detail) && Array.isArray(nx.detail.requirements) ? nx.detail.requirements : []
116 const mine = ids.map((id) => rows('requirements').find((r) => r.id === id))
117 return mine.length > 0 && mine.every((r) => r && (r.status === 'accepted' || r.status === 'rejected'))
118 }
119 const ids = (key) => (isObj(nx.detail) && Array.isArray(nx.detail[key]) ? nx.detail[key] : [])
120 const gone = (key, still) => ids(key).length > 0 && ids(key).every((id) => !rows(key).some((r) => r.id === id && r.status === still))
121 const written = () => rows('requirements').some((r) => r.milestone === rec.milestone.id)
122 switch (nx.action) {
123 case 'milestone': return rec.milestone.status !== 'shipped' && rec.milestone.status !== 'parked'
124 case 'spec': return written()
125 case 'convert': return written() || Object.hasOwn(rec, 'converted') || (isObj(rec.project) && isObj(rec.project.legacy) && rec.project.legacy.choice === 'fresh')
126 case 'unblock': return gone('plans', 'blocked')
127 case 'escalate': return gone('fixes', 'escalated')
128 case 'scope': return !(isObj(rec.evidence) && Array.isArray(rec.evidence.scope) && rec.evidence.scope.length > 0)
129 // Each file is settled: a restore under way for it, or a decision after the gate that names it.
130 case 'deviations': {
131 const files = devFiles(isObj(nx.detail) ? nx.detail : {}).filter((f) => typeof f === 'string' && f)
132 const restoring = (f) => rows('fixes').some((x) => x.restore === f && (x.status === 'open' || x.status === 'fixed'))
133 const decided = (f) => Number.isFinite(nextAt) && rows('decisions').some((x) => Date.parse(x.at) > nextAt && [x.text, x.why].some((t) => typeof t === 'string' && t.includes(f)))
134 return files.length > 0 && files.every((f) => restoring(f) || decided(f))
135 }
136 default: return false
137 }
138}
139
140// The one thing VBW needs from the user now, or null: an open question, else a gate in
141// next.json that the record has not answered, else /compact before a run when the
142// context is 85% full. While a run holds the lease nothing is needed. `at` is when it
143// was reached (ms; next.json's time, or the question's), null when unknown.
144export function liveNeed(input) {
145 const { record: rec, next: nx, nextAt, question, questionAt, health } = isObj(input) ? input : {}
146 if (!isObj(rec) || !isObj(rec.milestone) || isObj(rec.lease)) return null
147 const at = (t) => (Number.isFinite(t) ? t : null)
148 if (isObj(question)) {
149 return { key: 'question:' + (question.id ?? ''), kind: 'question', text: 'VBW has a question for you and is waiting for your answer.', what: 'a question is waiting for your answer', at: at(questionAt) }
150 }
151 if (!isObj(nx)) return null
152 if (nx.gate === true) {
153 if (answered(rec, nx, nextAt)) return null
154 const g = gateNeed(nx)
155 // Dated by the kernel's since; by when next.json was written only when there is none (an older kernel).
156 return { ...g, at: Number.isFinite(g.at) ? g.at : at(nextAt) }
157 }
158 if (RUN_ACTIONS.has(nx.action) && isObj(health) && Number.isFinite(health.contextPct) && health.contextPct >= COMPACT_PCT) {
159 return { key: 'compact', kind: 'compact', text: 'The context is nearly full: run /compact first, then VBW can start.', what: 'the context is nearly full: run /compact first', at: at(nextAt) }
160 }
161 return null
162}
163
164// The session cost as one small line; no number when there is none to show.
165function costRow(cost) {
166 return Number.isFinite(cost) && cost >= 0
167 ? { id: 'cost', text: 'This session has cost $' + cost.toFixed(2) + ' so far.', term: 'session cost' }
168 : { id: 'cost', text: 'The cost so far is not available.', term: 'cost, not available' }
169}
170
171// ', held by <name>' when another chat holds the run; '' otherwise (R192).
172export const heldBy = (holder) => (isObj(holder) && holder.self === false && typeof holder.name === 'string' && holder.name ? ', held by ' + holder.name : '')
173
174// A sentence as said: its first letter upper case, a full stop at its end.
175const sentence = (t) => {
176 const s = String(t).trim()
177 return s ? s.charAt(0).toUpperCase() + s.slice(1) + (/[.!?]$/.test(s) ? '' : '.') : ''
178}
179
180// ---- The 'what's happening now' box (docs/panel.md, What's happening now) ----------------
181// VBW's state as one sentence; the current step with when it started and (from the kernel's
182// note, worded by panel-step.js) when it should end, and a done/total strip when the step has
183// parts; when VBW needs you, the need's sentence, the command that answers it and since when
184// it waits; the milestone's end with its basis (the kernel's road: the live note's, else the
185// last `vbw next`'s); the session cost. Every time names its day when not today (dayClock).
186// input: { record, need (panel-view.js liveNeed), proving, stamps ({ proving, need,
187// 'estimate-milestone' }: ms), cost, step (the live note), holder, next, now }.
188const needCommand = (need) => (need.kind === 'question' || need.kind === 'compact' ? null : GATE_COMMANDS[need.kind] || '/vbw:vibe')
189
190function stepLine(name, at, step, now) {
191 const words = step ? stepWords(step, now) : null
192 const when = dayClock(at, now)
193 const text = name ? 'Current step: ' + name + (when ? ', started at ' + when : '') + '.' : when ? 'Started at ' + when + '.' : null
194 if (!text && !words) return null
195 return { text, end: words ? (words.timed ? 'This step: ' + words.text : words.text) + '.' : null, at: num(at) ? at : null, timed: Boolean(words && words.timed) }
196}
197
198function milestoneOf(step, nx, stamp, now) {
199 const road = isObj(step) && isObj(step.road) ? step.road : isObj(step) && step.road === null ? null : isObj(nx) && isObj(nx.road) ? nx.road : null
200 if (!road) return null
201 const basis = str(road.basis).trim().replace(/\.$/, '')
202 if (typeof road.end !== 'string' || !Number.isFinite(Date.parse(road.end))) {
203 return { text: 'No estimate yet' + (basis ? ': ' + basis : '') + '.', basis: null, known: false, term: 'estimate, no basis' }
204 }
205 const say = sentence(str(road.say))
206 if (!say) return null
207 const at = dayClock(stamp, now)
208 return { text: (at ? '[' + at + '] ' : '') + say, basis: basis ? 'basis: ' + basis : null, known: true, term: 'estimate, approximate' }
209}
210
211const costOf = (cost) => (num(cost) && cost >= 0
212 ? { text: 'This session has cost $' + cost.toFixed(2) + ' so far.', known: true, term: 'session cost' }
213 : { text: 'The cost so far is not available.', known: false, term: 'cost, not available' })
214
215const partsOf = (step) => (isObj(step) && isObj(step.parts) && Number.isInteger(step.parts.total) && step.parts.total > 0 && Number.isInteger(step.parts.done)
216 ? { done: Math.max(0, Math.min(step.parts.done, step.parts.total)), total: step.parts.total } : null)
217
218export const nowBoxModel = safe(({ record: rec, need, proving, stamps, cost, step, holder, next: nx, now }) => {
219 if (!isObj(rec) || !isObj(rec.milestone)) return null
220 const t = num(now) ? now : Date.now()
221 const st = isObj(stamps) ? stamps : {}
222 const note = isObj(step) ? step : null
223 const base = { command: null, parts: null, milestone: milestoneOf(note, nx, st['estimate-milestone'], t), cost: costOf(cost) }
224 const l = isObj(rec.lease) ? rec.lease : null
225 if (l) {
226 const plans = (Array.isArray(rec.plans) ? rec.plans : []).filter((p) => isObj(p) && p.status === 'building')
227 const phases = [...new Set(plans.map((p) => p.phase).filter((x) => typeof x === 'string' && x))]
228 const titles = plans.map((p) => p.title).filter(Boolean)
229 const what = {
230 plan: 'planning the work',
231 build: 'building ' + (phases.length ? 'phase ' + phases.join(', ') : 'the plan') + (titles.length ? ' (' + titles.join(', ') + ')' : ''),
232 qa: 'checking the results',
233 fix: 'fixing what the checks found',
234 map: 'reading the project to map it',
235 }[l.kind] || 'working'
236 return { ...base, state: { text: 'VBW is ' + what + heldBy(holder) + '.', need: false }, step: stepLine(note && str(note.doing), Date.parse(l.started_at), note, t), parts: partsOf(note) }
237 }
238 if (proving === true || (note && note.kind === 'proof')) {
239 const at = num(st.proving) ? st.proving : note ? Date.parse(note.started_at) : NaN
240 return { ...base, state: { text: 'VBW is proving the work: running its checks.', need: false }, step: stepLine(note && str(note.doing), at, note, t), parts: partsOf(note) }
241 }
242 if (isObj(need) && str(need.text)) {
243 const since = num(st.need) ? st.need : need.at
244 const when = dayClock(since, t)
245 return { ...base, state: { text: need.text, need: true }, command: needCommand(need), step: when ? { text: 'Waiting since ' + when + '.', end: null, at: since, timed: false } : null }
246 }
247 return { ...base, state: { text: 'Nothing is running.', need: false }, step: null }
248})
249
250// The running step's time and the milestone's end are the kernel's, worded here; the
251// panel works none out. The step's end comes from its note (panel-step.js); the road
252// (R191) comes from the live step's note, else from the last `vbw next` (next.json).
253// No road, or none at all, gives no milestone row.
254
255function roadRow(step, nx, stamps, now) {
256 const road = isObj(step) && isObj(step.road) ? step.road : isObj(step) && step.road === null ? null : isObj(nx) && isObj(nx.road) ? nx.road : null
257 if (!road) return null
258 if (typeof road.end !== 'string' || !Number.isFinite(Date.parse(road.end))) {
259 const why = typeof road.basis === 'string' && road.basis.trim() ? road.basis.trim().replace(/\.$/, '') : ''
260 return { id: 'estimate-milestone', text: 'No estimate yet' + (why ? ': ' + why : '') + '.', term: 'estimate, no basis' }
261 }
262 const say = sentence(road.say)
263 return say ? { id: 'estimate-milestone', text: stamped(stamps['estimate-milestone'], say, now), term: 'estimate, approximate' } : null
264}
265
266function estimateRows(stamps, step, nx, now) {
267 const r = roadRow(step, nx, stamps, now)
268 return r ? [r] : []
269}
270
271// What panel.js dates before the view is drawn: the need and the rows of panelView that
272// can carry a start time (the estimates), said as panelView says them with these stamps.
273export function datedRows(input) {
274 const { record: rec, next: nx, nextAt, question, questionAt, health, now } = isObj(input) ? input : {}
275 if (!isObj(rec) || !isObj(rec.milestone) || !rec.milestone.id) return { rows: [], need: null }
276 const step = isObj(input.step) ? input.step : null
277 const stamps = isObj(input.stamps) ? input.stamps : {}
278 return { need: liveNeed({ record: rec, next: nx, nextAt, question, questionAt, health }), rows: estimateRows(stamps, step, nx, Number.isFinite(now) ? now : Date.now()) }
279}
280
281// A refusal's text, short (design rule 4): its first line, and when that line names the
282// run's files as a list ("... this run's files (a.js, b.js, ...): why"), the clause before
283// the list, with the number of files kept apart. { clause, files } or null for no text.
284const FILE_LIST = /^(.*?files) \(([^()]*)\)(?::|$)/
285export function shortRefusal(text) {
286 if (typeof text !== 'string') return null
287 const first = (text.split('\n').find((l) => l.trim()) || '').trim()
288 if (!first) return null
289 const m = FILE_LIST.exec(first)
290 const list = m ? m[2].split(',').map((f) => f.trim()).filter(Boolean) : []
291 return list.length ? { clause: m[1], files: list.length } : { clause: first, files: 0 }
292}
293export const refusedBy = (r) => (typeof r.by === 'string' && r.by ? r.by + ': ' : '')
294
295// A refusal as the Now tab says it: one short amber row, '[15:41] ⊘ VBW refused, dev P5.2:
296// <first clause>', with `more` ('· 9 files in this run') when the clause left the run's
297// file list out, and `full`, the whole text on one line, when there is more than the row
298// says (shown on a press). Null for none.
299export function refusalRow(r, now) {
300 const s = isObj(r) ? shortRefusal(r.text) : null
301 if (!s) return null
302 const at = dayClock(r.at, now)
303 const whole = r.text.split('\n').map((l) => l.trim()).filter(Boolean).join(' ')
304 const text = (at ? '[' + at + '] ' : '') + REFUSED_MARK + ' VBW refused, ' + refusedBy(r) + s.clause
305 return {
306 id: 'refused:' + String(r.id), term: 'refused', files: s.files,
307 more: s.files ? '· ' + s.files + (s.files === 1 ? ' file' : ' files') + ' in this run' : null,
308 text, full: whole === s.clause ? null : whole,
309 }
310}
311
312// The last run's refusals (panel-feed.js), newest five, each as refusalRow says it.
313const REFUSALS_SHOWN = 5
314const refusalRows = (list, now) => (Array.isArray(list) ? list : []).filter((r) => isObj(r) && typeof r.text === 'string' && r.text.trim())
315 .slice(-REFUSALS_SHOWN).map((r) => refusalRow(r, now)).filter(Boolean)
316
317export function panelView(input) {
318 const { record: rec, next: nx, nextAt, question, questionAt, health, now, cost, proving } = isObj(input) ? input : {}
319 const step = isObj(input) && isObj(input.step) ? input.step : null
320 const stamps = isObj(input) && isObj(input.stamps) ? input.stamps : {}
321 if (!isObj(rec) || !isObj(rec.milestone) || !rec.milestone.id) return { rows: [NEUTRAL], need: null, box: null }
322 // Progress as the status line counts it: the current milestone's requirements proven or accepted.
323 const rq = (Array.isArray(rec.requirements) ? rec.requirements : []).filter((r) => isObj(r) && r.milestone === rec.milestone.id)
324 const done = rq.filter((r) => r.status === 'proven' || r.status === 'accepted').length
325 const progress = rq.length === 0 ? 'No requirements are set yet.'
326 : done + ' of ' + rq.length + (rq.length === 1 ? ' requirement' : ' requirements') + ' done.'
327 const need = liveNeed({ record: rec, next: nx, nextAt, question, questionAt, health })
328 const t = Number.isFinite(now) ? now : Date.now()
329 return {
330 need,
331 box: nowBoxModel({ record: rec, need, proving: proving === true, stamps, cost, step, holder: input.holder, next: nx, now: t }),
332 rows: [
333 { id: 'milestone', text: 'Working on ' + rec.milestone.id + ': ' + (rec.milestone.title || 'untitled') + '.', term: 'milestone' },
334 { id: 'progress', text: progress, term: 'requirements' },
335 doing(rec, t),
336 ...estimateRows(stamps, step, nx, t),
337 costRow(cost),
338 ...refusalRows(input.refusals, t),
339 { id: 'need', text: need ? stamped(Number.isFinite(stamps.need) ? stamps.need : need.at, need.text, t) : 'Nothing is needed from you right now.', term: 'your turn' },
340 ],
341 }
342}
343hooks/panel-sounds.js 33 lines1// Generated by tools/sounds.sh from plugin/assets/audio/<character>/*.mp3. Do not edit.
2export const SOUNDS = [
3 'assets/audio/arnold/a1.mp3',
4 'assets/audio/arnold/a2.mp3',
5 'assets/audio/arnold/a3.mp3',
6 'assets/audio/arnold/a4.mp3',
7 'assets/audio/arnold/a5.mp3',
8 'assets/audio/arnold/a6.mp3',
9 'assets/audio/charlize/c1.mp3',
10 'assets/audio/charlize/c10.mp3',
11 'assets/audio/charlize/c11.mp3',
12 'assets/audio/charlize/c2.mp3',
13 'assets/audio/charlize/c3.mp3',
14 'assets/audio/charlize/c4.mp3',
15 'assets/audio/charlize/c5.mp3',
16 'assets/audio/charlize/c6.mp3',
17 'assets/audio/charlize/c7.mp3',
18 'assets/audio/charlize/c8.mp3',
19 'assets/audio/charlize/c9.mp3',
20 'assets/audio/darth/d1.mp3',
21 'assets/audio/darth/d2.mp3',
22 'assets/audio/darth/d3.mp3',
23]
24
25// The sound to play for record.settings.sound: 'off' is none; a folder name is a random
26// sound of that folder; unset, 'default' or a folder that is gone is a random sound of all.
27export function soundFor(choice, random = Math.random) {
28 if (choice === 'off') return null
29 const own = SOUNDS.filter((s) => s.startsWith('assets/audio/' + choice + '/'))
30 const from = own.length ? own : SOUNDS
31 return from.length ? { asset: from[Math.floor(random() * from.length)] } : null
32}
33hooks/panel-feed.js 368 lines1// The run feed: what a VBW workflow run is doing now, read from the files Claude
2// Code writes for it (mods_4_vbw.md §4). Under <sessionDir>/subagents/workflows/<runId>/:
3// journal.jsonl (started/result lines), agent-<id>.meta.json (type, description,
4// model) and agent-<id>.jsonl (each agent's transcript); and, once the run ends,
5// <sessionDir>/workflows/<runId>.json. These are undocumented Claude Code files:
6// every reader here degrades to less detail and never throws. Each file is read
7// only when its size or time changed, and a done agent's transcript at most once
8// more after it is done. The output is the run model (mods_build_plan.md).
9
10import { shellIntent } from './panel-intent.js'
11
12export const QUIET_MS = 45_000
13const ROLES = ['architect', 'lead', 'dev', 'qa', 'scout', 'debugger', 'docs']
14const MAX_READ = 4 * 1024 * 1024 // Claude Code refuses a larger read
15const SCAN = 8 // final jsons findRun opens at most, newest first
16
17const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
18const str = (x) => (typeof x === 'string' ? x : '')
19const num = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : null)
20const oneLine = (s) => str(s).replace(/\s+/g, ' ').trim()
21const cut = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s)
22const base = (p) => str(p).split(/[\\/]/).filter(Boolean).pop() || ''
23const time = (iso) => {
24 const t = Date.parse(str(iso))
25 return Number.isFinite(t) ? t : null
26}
27
28function json(text) {
29 try {
30 return JSON.parse(text)
31 } catch {
32 return undefined
33 }
34}
35
36// Lines of a JSONL text that parse as objects; a half-written last line is skipped.
37const rows = (text) => str(text).split('\n').map((l) => (l.trim() ? json(l) : undefined)).filter(isObj)
38
39// ---- parsers (pure) ----
40
41// journal.jsonl -> [{type:'started', agentId, label, phase} | {type:'result', agentId, result, error}]
42export function parseJournal(text) {
43 const out = []
44 for (const r of rows(text)) {
45 const id = str(r.agentId)
46 if (!id) continue
47 if (r.type === 'started') out.push({ type: 'started', agentId: id, label: str(r.label), phase: str(r.phase) || null })
48 else if (r.type === 'result') {
49 const err = r.error ?? (isObj(r.result) ? r.result.error : undefined)
50 out.push({ type: 'result', agentId: id, result: r.result ?? null, error: err == null || err === false ? null : err })
51 }
52 }
53 return out
54}
55
56const roleOf = (type) => {
57 const r = str(type).replace(/^vbw:/, '')
58 return str(type).startsWith('vbw:') && ROLES.includes(r) ? r : 'agent'
59}
60// "qa P55" -> "P55" when the first word is the role.
61const labelOf = (desc, role) => {
62 const d = oneLine(desc)
63 return role !== 'agent' && (d === role || d.startsWith(role + ' ')) ? d.slice(role.length).trim() : d
64}
65// 'claude-sonnet-5-5' -> 'sonnet'; a family name stays itself.
66const modelOf = (m) => {
67 const s = str(m).toLowerCase()
68 if (!s) return null
69 return ['opus', 'sonnet', 'haiku', 'fable'].find((f) => s.includes(f)) || s
70}
71
72// agent-<id>.meta.json -> {role, label, model} | null
73export function parseMeta(text) {
74 const m = typeof text === 'string' ? json(text) : undefined
75 if (!isObj(m)) return null
76 const role = roleOf(m.agentType)
77 return { role, label: labelOf(m.description, role), model: modelOf(m.model) }
78}
79
80// What a tool call means, in a few words.
81const host = (u) => (/^[a-z]+:\/\/([^/?#]+)/i.exec(str(u)) || [])[1]
82const SAY = {
83 read: ['reading', (i) => base(i.file_path)],
84 bash: ['', (i) => shellIntent(i.command)],
85 edit: ['editing', (i) => base(i.file_path)],
86 multiedit: ['editing', (i) => base(i.file_path)],
87 write: ['editing', (i) => base(i.file_path)],
88 notebookedit: ['editing', (i) => base(i.notebook_path)],
89 grep: ['searching', (i) => cut(oneLine(i.pattern), 41)],
90 glob: ['searching', (i) => cut(oneLine(i.pattern), 41)],
91 websearch: ['researching', (i) => cut(oneLine(i.query), 41)],
92 webfetch: ['researching', (i) => host(i.url)],
93 structuredoutput: ['reporting', () => ''],
94}
95// What a tool call means, in a few words.
96function intention(name, input) {
97 const say = SAY[str(name).toLowerCase()]
98 if (!say) return str(name) || 'working'
99 const what = say[1](isObj(input) ? input : {})
100 return say[0] ? (what ? say[0] + ' ' + what : say[0]) : what
101}
102
103function activityOf(blocks) {
104 const b = Array.isArray(blocks) ? blocks[blocks.length - 1] : null
105 if (!isObj(b)) return null
106 if (b.type === 'tool_use') return { kind: 'tool', text: intention(b.name, b.input) }
107 if (b.type === 'thinking' || b.type === 'redacted_thinking') return { kind: 'tool', text: 'thinking' }
108 if (b.type !== 'text') return null
109 const t = oneLine(b.text)
110 if (!t) return null
111 return { kind: 'text', text: t.length > 70 ? '…' + t.slice(-70).trim() : t }
112}
113
114const context = (u) => {
115 if (!isObj(u)) return null
116 const parts = [u.input_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens].map(num)
117 return parts.some((x) => x !== null) ? parts.reduce((a, x) => a + (x || 0), 0) : null
118}
119
120// agent-<id>.jsonl -> {activity, tokens, firstAt, lastAt}: the last assistant line's
121// last block, the context of the last usage, the first and last line times.
122export function parseTranscript(text) {
123 const out = { activity: null, tokens: null, firstAt: null, lastAt: null }
124 const lines = str(text).split('\n')
125 let seenAssistant = false
126 for (let k = lines.length - 1; k >= 0; k--) {
127 if (!lines[k].trim()) continue
128 const r = json(lines[k])
129 if (!isObj(r)) continue
130 if (out.lastAt === null) out.lastAt = time(r.timestamp)
131 if (r.type !== 'assistant' || !isObj(r.message)) continue
132 if (!seenAssistant) {
133 seenAssistant = true
134 out.activity = activityOf(r.message.content)
135 }
136 if (out.tokens === null) out.tokens = context(r.message.usage)
137 if (out.tokens !== null && out.lastAt !== null) break
138 }
139 // The first time among the first 20 lines, parsed only up to the line that has one.
140 for (let k = 0; k < Math.min(20, lines.length) && out.firstAt === null; k++) {
141 const r = lines[k].trim() ? json(lines[k]) : undefined
142 if (isObj(r)) out.firstAt = time(r.timestamp)
143 }
144 return out
145}
146
147// An agent's result as one line of at most 80 characters, or null.
148export function resultLine(r) {
149 let s = ''
150 if (typeof r === 'string') s = (r.split('\n').find((l) => l.trim()) || '').replace(/^\s*#+\s*/, '')
151 else if (isObj(r)) {
152 s = [r.summary, r.verdict, r.status, r.message].map(str).find((x) => x.trim()) || ''
153 if (!s) {
154 s = Object.entries(r)
155 .map(([k, v]) => k + ': ' + (Array.isArray(v) ? v.length : isObj(v) ? '{…}' : String(v)))
156 .join(', ')
157 }
158 } else if (typeof r === 'number' || typeof r === 'boolean') s = String(r)
159 s = oneLine(s)
160 return s ? cut(s, 80) : null
161}
162
163const STATUS = { running: 'running', completed: 'completed', failed: 'failed', error: 'failed' }
164
165// The run model (mods_build_plan.md, "Shared contract"). Inputs: journal text (or
166// parsed events), metas {id: {role,label,model,spawnedAt}}, transcripts
167// {id: parseTranscript(...)}, the final json object or null, now in ms.
168export function runModel(input) {
169 try {
170 const o = isObj(input) ? input : {}
171 const runId = str(o.runId)
172 if (!runId) return null
173 const now = num(o.now) ?? 0
174 const events = Array.isArray(o.journal) ? o.journal : parseJournal(o.journal)
175 const metas = isObj(o.metas) ? o.metas : {}
176 const txs = isObj(o.transcripts) ? o.transcripts : {}
177 const final = isObj(o.final) ? o.final : null
178 const status = !final ? 'running' : STATUS[str(final.status)] || 'stopped'
179 const progress = {}
180 for (const p of final && Array.isArray(final.workflowProgress) ? final.workflowProgress : []) {
181 if (isObj(p) && p.type === 'workflow_agent' && str(p.agentId)) progress[p.agentId] = p
182 }
183 const order = []
184 const started = {}
185 const results = {}
186 let phase = null
187 const seen = []
188 for (const e of events) {
189 if (!isObj(e) || !str(e.agentId)) continue
190 if (!order.includes(e.agentId)) order.push(e.agentId)
191 if (e.type === 'started') {
192 started[e.agentId] = e
193 if (e.phase) phase = e.phase
194 if (e.phase && !seen.includes(e.phase)) seen.push(e.phase)
195 } else if (e.type === 'result') results[e.agentId] = e
196 }
197 for (const id of Object.keys(metas)) if (!order.includes(id)) order.push(id)
198 const agents = order.map((id) => agentModel(id, metas[id], txs[id], started[id], results[id], progress[id], status, now))
199 const starts = agents.map((a) => a.startedAt).filter((t) => t !== null)
200 const startedAt = num(final && final.startTime) ?? (starts.length ? Math.min(...starts) : null)
201 const dur = num(final && final.durationMs)
202 const endedAt = !final || status === 'running' ? null : num(final.startTime) !== null && dur !== null ? final.startTime + dur : time(final.timestamp)
203 const titles = (final && Array.isArray(final.phases) ? final.phases : []).map((p) => str(isObj(p) && p.title)).filter(Boolean)
204 const phases = titles.length ? titles : seen
205 return { runId, kind: str(o.kind) || str(final && final.workflowName) || null, phase, phases, startedAt, endedAt, status, agents, refusals: refusalsOf(agents, results) }
206 } catch {
207 return null
208 }
209}
210
211// The refusals the agents reported in their results (the workflow schemas' `refused`,
212// docs/workflows.md): [{id, by, at, text}], each text verbatim, `by` the agent's role and
213// label, `at` when the agent ended; in the agents' order.
214function refusalsOf(agents, results) {
215 const out = []
216 for (const a of agents) {
217 const r = results[a.id] && isObj(results[a.id].result) ? results[a.id].result.refused : null
218 if (!Array.isArray(r)) continue
219 r.forEach((text, i) => {
220 if (typeof text === 'string' && text.trim()) out.push({ id: a.id + '#' + i, by: [a.role, a.label].filter(Boolean).join(' '), at: a.endedAt ?? a.lastSeenAt, text })
221 })
222 }
223 return out
224}
225
226function agentModel(id, meta, tx, start, res, prog, runStatus, now) {
227 const m = isObj(meta) ? meta : {}
228 const t = isObj(tx) ? tx : {}
229 const p = isObj(prog) ? prog : {}
230 let role = ROLES.includes(m.role) || m.role === 'agent' ? m.role : null
231 let label = typeof m.label === 'string' ? m.label : null
232 if (role === null || label === null) {
233 const words = oneLine(start && start.label).split(' ')
234 const guess = ROLES.includes(words[0]) ? words[0] : roleOf(p.agentType)
235 role = role ?? guess
236 label = label ?? labelOf(start ? start.label : '', role)
237 }
238 const pStart = num(p.startedAt)
239 const startedAt = pStart ?? num(t.firstAt) ?? num(m.spawnedAt)
240 const lastSeenAt = num(t.lastAt) ?? startedAt
241 const failedBy = res ? res.error != null : runStatus !== 'running' || ['failed', 'error'].includes(p.state)
242 const ended = Boolean(res) || failedBy
243 let state = failedBy ? 'failed' : res ? 'done' : 'working'
244 if (state === 'working' && lastSeenAt !== null && now - lastSeenAt > QUIET_MS) state = 'quiet'
245 const pEnd = pStart !== null && num(p.durationMs) !== null ? pStart + p.durationMs : null
246 return {
247 id,
248 role,
249 label,
250 phase: (start && start.phase) || null,
251 model: modelOf(m.model) ?? modelOf(p.model),
252 state,
253 activity: isObj(t.activity) ? t.activity : null,
254 startedAt,
255 endedAt: ended ? pEnd ?? num(t.lastAt) : null,
256 lastSeenAt,
257 tokens: num(t.tokens) ?? num(p.tokens),
258 result: res ? resultLine(res.error != null ? res.error : res.result) : null,
259 }
260}
261
262// ---- readers ($, never throw) ----
263
264// A file's parsed value, read again only when its signature (size:mtime) changed.
265// A file too big to read keeps its last value and is not tried again until it changes.
266async function load($, files, path, entry, parse) {
267 const sig = entry.size + ':' + entry.mtimeMs
268 const had = files[path]
269 if (had && had.sig === sig) return had.value
270 let value = had ? had.value : null
271 if (entry.size <= MAX_READ) {
272 try {
273 value = parse(await $.fs.read(path))
274 } catch {
275 return value // try again next time
276 }
277 }
278 files[path] = { sig, value }
279 return value
280}
281
282const statFile = ($, path) => $.fs.stat(path).then((s) => (isObj(s) && s.kind === 'file' ? s : null), () => null)
283const clock = ($) => Promise.resolve().then(() => $.clock.now()).then((t) => num(t) ?? Date.now(), () => Date.now())
284const byName = ($, dir) => $.fs.list(dir).then((es) => Object.fromEntries(es.filter(isObj).map((e) => [e.name, e])), () => ({}))
285
286// The run model of <sessionDir>'s run <runId>, or null when it cannot be read.
287export async function gatherRun($, cache, opts) {
288 try {
289 const { sessionDir, runId, kind } = opts
290 if (cache.runId !== runId) {
291 for (const k of Object.keys(cache)) delete cache[k]
292 Object.assign(cache, { runId, files: {}, sealed: {} })
293 }
294 const dir = sessionDir + '/subagents/workflows/' + runId
295 const entries = await $.fs.list(dir)
296 const at = {}
297 for (const e of entries) if (isObj(e) && e.kind === 'file') at[e.name] = e
298 const journal = at['journal.jsonl'] ? (await load($, cache.files, dir + '/journal.jsonl', at['journal.jsonl'], parseJournal)) || [] : []
299 const done = new Set(journal.filter((e) => e.type === 'result').map((e) => e.agentId))
300 const metas = {}
301 const transcripts = {}
302 for (const name of Object.keys(at)) {
303 const m = /^agent-([A-Za-z0-9_-]+)\.meta\.json$/.exec(name)
304 if (!m) continue
305 metas[m[1]] = { ...(await load($, cache.files, dir + '/' + name, at[name], parseMeta)), spawnedAt: at[name].mtimeMs || null }
306 }
307 for (const name of Object.keys(at)) {
308 const m = /^agent-([A-Za-z0-9_-]+)\.jsonl$/.exec(name)
309 if (!m) continue
310 const id = m[1]
311 const path = dir + '/' + name
312 if (cache.sealed[id]) {
313 transcripts[id] = cache.files[path] ? cache.files[path].value : null
314 continue
315 }
316 transcripts[id] = await load($, cache.files, path, at[name], parseTranscript)
317 if (done.has(id)) cache.sealed[id] = true
318 }
319 const finalPath = sessionDir + '/workflows/' + runId + '.json'
320 const fe = await statFile($, finalPath)
321 const final = fe ? await load($, cache.files, finalPath, fe, json) : null
322 return runModel({ runId, kind, journal, metas, transcripts, final, now: await clock($) })
323 } catch {
324 return null
325 }
326}
327
328// The newest run in <sessionDir> that has not ended (no final json, or one still
329// running): {runId, kind} or null. For finding a run already going after a reload.
330export async function findRun($, cache, sessionDir) {
331 try {
332 if (!isObj(cache.files)) cache.files = {}
333 if (!isObj(cache.dirAt)) cache.dirAt = {}
334 const runs = Object.values(await $.fs.list(sessionDir + '/subagents/workflows')).filter((e) => isObj(e) && e.kind === 'dir' && /^wf_/.test(e.name))
335 const finals = await byName($, sessionDir + '/workflows')
336 const scripts = {}
337 for (const e of Object.values(await byName($, sessionDir + '/workflows/scripts'))) {
338 const m = /^(.+)-(wf_[^/]+)\.js$/.exec(str(e.name))
339 if (m && !(scripts[m[2]] && scripts[m[2]].at >= e.mtimeMs)) scripts[m[2]] = { kind: m[1], at: num(e.mtimeMs) ?? 0 }
340 }
341 const when = async (id) => {
342 if (scripts[id]) return scripts[id].at
343 if (cache.dirAt[id] === undefined) {
344 const s = await $.fs.stat(sessionDir + '/subagents/workflows/' + id).catch(() => null)
345 cache.dirAt[id] = num(s && s.mtimeMs) ?? 0
346 }
347 return cache.dirAt[id]
348 }
349 const order = []
350 for (const r of runs) order.push({ id: r.name, at: await when(r.name) })
351 order.sort((a, b) => b.at - a.at)
352 let opened = 0
353 for (const { id } of order) {
354 const fe = finals[id + '.json']
355 const kind = scripts[id] ? scripts[id].kind : null
356 if (!fe) return { runId: id, kind }
357 const path = sessionDir + '/workflows/' + id + '.json'
358 const known = cache.files[path] && cache.files[path].sig === fe.size + ':' + fe.mtimeMs
359 if (!known && ++opened > SCAN) break
360 const final = await load($, cache.files, path, fe, json)
361 if (isObj(final) && final.status === 'running') return { runId: id, kind: str(final.workflowName) || kind }
362 }
363 return null
364 } catch {
365 return null
366 }
367}
368hooks/panel-stage.js 304 lines1// Comms (mods_4_vbw.md §3.1, §3.2, §3.8; R189): what the strip above the prompt
2// shows. When VBW needs the person, one amber line, 'HH:MM ⚑ VBW needs you ·
3// <what>', with no button: the `need` comes from panel-view.js (liveNeed) and
4// leaves with the gate. Under it, a refusal an agent reported, one amber line
5// 'HH:MM ⊘ VBW refused · <agent>: <first line>', until it is seen. While a VBW workflow runs, the helpers still at work: one
6// row each (a finished one leaves at once and is counted in the top line's
7// 'N done'; a failed one stays as a red ✗ row until it is seen). With full
8// motion and room, a pixel figure for at most 3 working helpers
9// (panel-candy.js; panel.js animates them). Rows beyond the room fold into
10// one '+N more · [m] Mission Control' line. Otherwise nothing: the hint line
11// already says what comes next.
12// In little room the order is: the needs-you line, then the helper line while
13// helpers work, then the step line. With one row left the helper line is the only
14// one and carries the step's time words after its own parts; with two, the helper
15// line and the step line; with the step line alone only when no helper works.
16// Pure: data in, a model out (stageModel), a tree out (renderStage). No `$`,
17// never changes its input, never throws.
18import { poseOf, spriteFrame, encodeCells } from './panel-candy.js'
19import { newMark } from './panel-next.js'
20import { stepWords, dayClock } from './panel-step.js'
21import { refusalLine } from './panel-lines.js'
22import { roleColor as colorOfRole, stateColor, STATE_MARKS, NEED, ACCENT } from './panel-palette.js'
23
24const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
25const num = (x) => typeof x === 'number' && Number.isFinite(x)
26const str = (x) => (typeof x === 'string' ? x : '')
27// The cells a text takes, one per code point: its length unless it holds a surrogate pair.
28const PAIRS = /[\uD800-\uDFFF]/
29const len = (s) => (typeof s === 'string' && !PAIRS.test(s) ? s.length : [...s].length)
30
31// Cut a line to `n` cells, marking the cut with an ellipsis.
32const cut = (s, n) => (len(s) <= n ? s : n <= 0 ? '' : [...s].slice(0, n - 1).join('') + '…')
33const pad = (s, n) => s + ' '.repeat(Math.max(0, n - len(s)))
34const padStart = (s, n) => ' '.repeat(Math.max(0, n - len(s))) + s
35
36const QUIET_MS = 45000
37const FLASH_MS = 1000 // a new row shows inverted this long (full motion only)
38const SPRITES = 3 // pixel figures at most, at full motion; the rest are text rows
39const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
40// A workflow follows these steps of `vbw next`: the moment health nudges matter.
41const RUNS = new Set(['plan', 'build', 'qa', 'fix'])
42const STARTS_RUN = new Set(['approve', 'escalate', 'unblock', ...RUNS])
43
44// 604 s -> 10m04s; an hour or more -> 1h02m.
45function clock(ms) {
46 const s = Math.max(0, Math.floor((num(ms) ? ms : 0) / 1000))
47 const two = (n) => String(n).padStart(2, '0')
48 if (s >= 3600) return Math.floor(s / 3600) + 'h' + two(Math.floor((s % 3600) / 60)) + 'm'
49 return Math.floor(s / 60) + 'm' + two(s % 60) + 's'
50}
51
52
53const tokens = (t) => {
54 if (!num(t) || t < 0) return ''
55 if (t >= 1e6) return (t / 1e6).toFixed(1) + 'M'
56 return t >= 1000 ? Math.round(t / 1000) + 'k' : String(Math.round(t))
57}
58
59// --- the needs-you line --------------------------------------------------------
60
61// `need` is { key, what, at } (panel-view.js liveNeed): the line is 'HH:MM ⚑ VBW needs you · <what>'
62// ('yesterday 21:38 ⚑ ...' on another day, dayClock). A need not yet seen (`fresh`) carries
63// the mark after its text, which keeps its columns.
64function needOf(need, columns, fresh, motion, now) {
65 if (!isObj(need) || !str(need.what)) return null
66 const time = dayClock(need.at, now)
67 const mark = fresh === true
68 return { key: str(need.key), text: cut((time ? time + ' ' : '') + '⚑ VBW needs you · ' + need.what, mark ? columns - 4 : columns), fresh: mark, motion }
69}
70
71// --- the refusal line --------------------------------------------------------------
72
73// `refusal` is the newest refusal not yet seen ({ id, by, at, text }, panel-feed.js; panel.js
74// picks it): one amber line, 'HH:MM ⊘ VBW refused · <by>: <first line>'.
75function refusalOf(r, columns, now) {
76 const words = refusalLine(r)
77 if (!words) return null
78 const time = dayClock(r.at, now)
79 return { key: str(r.id), text: cut((time ? time + ' ' : '') + words, columns) }
80}
81
82// The weekly-limit line, only when a workflow is next.
83function health(hl, action) {
84 const out = []
85 if (!isObj(hl) || !STARTS_RUN.has(action)) return out
86 if (num(hl.weekPct) && hl.weekPct >= 80) {
87 const w = Math.round(hl.weekPct)
88 const per = num(hl.weekPerRun) && hl.weekPerRun > 0 ? 'a run like this used about ' + Math.round(hl.weekPerRun) + '% last time' : 'a long run may reach it'
89 out.push({ text: 'Weekly limit ' + w + '%: ' + per + '.', color: NEED, stamp: 'limit' })
90 }
91 return out
92}
93
94// --- the step -------------------------------------------------------------------
95
96// The running step, one line: what it is doing and its end time in the panel's
97// wording ('proving 12 of 40 checks · about 8 min, until around 14:26'). '' with no
98// step (the note went, or was never live), so the line goes at the next refresh.
99// A message that reports something that happened or a time left starts with the
100// moment it began saying what it says, '[HH:MM] ': the text is cut, never the time.
101// `at(id, text)` is that moment (the caller keeps it, so the clock alone redraws nothing).
102function dated(at, id, text, columns, now) {
103 const t = dayClock(at(id, text), now)
104 return t ? '[' + t + '] ' + cut(text, Math.max(0, columns - len(t) - 3)) : cut(text, columns)
105}
106
107function stepOf(step, words, columns, at, now) {
108 if (!isObj(step) || !words) return ''
109 const doing = str(step.doing) || str(step.kind)
110 return dated(at, 'step', (doing ? doing + ' · ' : '') + words.text, columns, now)
111}
112
113// --- the crew ----------------------------------------------------------------
114
115// One helper as Comms shows it: a working or quiet one, or a failed one. A finished one has no row.
116function rowOf(a, now, motion, born) {
117 const role = str(a.role) || 'agent'
118 const roleColor = colorOfRole(role)
119 const start = num(a.startedAt) ? a.startedAt : now
120 const end = num(a.endedAt) ? a.endedAt : null
121 const base = { id: str(a.id), role, roleColor, label: str(a.label), time: dayClock(a.startedAt, now), elapsed: clock((end ?? now) - start), tokens: tokens(a.tokens), spin: '', button: null }
122 if (a.state === 'failed') {
123 return { ...base, glyph: STATE_MARKS.failed, glyphColor: stateColor('failed'), activity: str(a.result) || 'failed', activityColor: stateColor('failed'),
124 button: { label: 'details', action: { open: 'mission', tab: 'now', agent: str(a.id) } } }
125 }
126 const age = isObj(born) && num(born[base.id]) ? now - born[base.id] : -1
127 const flash = motion === 'full' && age >= 0 && age < FLASH_MS ? { flash: true } : {}
128 const seen = num(a.lastSeenAt) ? a.lastSeenAt : start
129 if (a.state === 'quiet' || now - seen >= QUIET_MS) {
130 return { ...base, ...flash, glyph: STATE_MARKS.quiet, glyphColor: stateColor('quiet'), quiet: true, activity: 'quiet ' + clock(now - seen), activityColor: stateColor('quiet') }
131 }
132 const act = isObj(a.activity) ? a.activity : null
133 const said = act && str(act.text) && act.kind === 'text'
134 const text = act && str(act.text) ? (said ? '"' + act.text + '"' : act.text) : 'working'
135 const step = motion === 'off' ? 0 : Math.floor(now / (motion === 'calm' ? 1000 : 250))
136 return { ...base, ...flash, glyph: STATE_MARKS.running, glyphColor: stateColor('running'), activity: text, activityColor: undefined, intent: !said, spin: motion === 'off' ? '·' : SPIN[step % SPIN.length] }
137}
138
139const ROLE_W = 9
140const LABEL_W = 10
141
142// Fit each row to the columns: the activity takes what the fixed cells leave;
143// when too narrow, elapsed and tokens go first, then the start time, then the label shrinks.
144function fit(rows, columns, full) {
145 const lw = Math.min(LABEL_W, Math.max(0, ...rows.map((r) => len(r.label))))
146 return rows.map((r) => {
147 // A figure takes 4 more columns than the dot, and is the row's only motion.
148 const fixed = 2 + ROLE_W + 1 + 2 + (r.pose ? 4 : 0) + (r.spin ? 2 : 0) + (r.button ? len(r.button.label) + 6 : 0)
149 const t = r.time
150 const tails = [...(full && t ? [' ' + t + ' ' + padStart(r.elapsed, 6) + ' ' + padStart(r.tokens, 4)] : []), ...(t ? [' ' + t] : [])]
151 const tail = tails.find((x) => columns - fixed - lw - len(x) >= 8) || ''
152 const w = Math.max(0, Math.min(lw, columns - fixed - 8))
153 const room = Math.max(0, columns - fixed - w - len(tail))
154 return { ...r, label: pad(cut(r.label, w), w), activity: cut(r.activity, room), elapsed: tail.includes(r.elapsed) ? r.elapsed : '', tokens: tail.includes(r.elapsed) ? r.tokens : '', tail }
155 })
156}
157
158// The crew: the helpers still at work, the failures not yet seen. Null when there is nothing to draw.
159function crewOf(rn, now, columns, maxRows, motion, cost, born, seen, stepLine, timeWords) {
160 const running = rn.status === 'running'
161 const ended = num(rn.endedAt) ? rn.endedAt : null
162 const agents = rn.agents.filter((a) => isObj(a) && typeof a.id === 'string' && a.id)
163 const unseen = (a) => a.state === 'failed' && !seen.has(a.id)
164 let rows = agents.filter((a) => (running ? a.state !== 'done' && (a.state !== 'failed' || unseen(a)) : unseen(a))).map((a) => rowOf(a, now, motion, born))
165 if (!running && !rows.length) return null
166 const doneAll = agents.filter((a) => a.state === 'done').length
167 const kind = str(rn.kind) || 'run'
168 const time = clock((ended ?? now) - (num(rn.startedAt) ? rn.startedAt : now))
169 const status = running || !str(rn.status) ? '' : rn.status
170 const button = { key: 'p', label: 'Mission Control', action: { open: 'mission' } }
171 const room = columns - len(button.label) - 6
172 if (maxRows < 4) {
173 const working = agents.filter((a) => a.state === 'working' || a.state === 'quiet').length
174 const parts = ['VBW ▸ ' + kind, status, working ? working + ' working' : '', doneAll ? doneAll + ' done' : '', time, timeWords].filter(Boolean)
175 return { kind: 'crew', mode: 'line', header: { text: cut(parts.join(' · '), room), button }, rows: [], more: 0, ...(stepLine ? { step: stepLine } : {}) }
176 }
177 let failedKey = false
178 rows = rows.map((r) => {
179 if (!r.button) return r
180 const b = { ...r.button, ...(failedKey ? {} : { key: 'd' }) }
181 failedKey = true
182 return { ...r, button: { key: b.key, label: b.label, action: b.action } }
183 })
184 const full = maxRows >= 8
185 const avail = maxRows - 1
186 // Figures: 3 rows a helper, for at most 3 working helpers, when they all fit with the other rows.
187 const live = rows.filter((r) => !r.quiet && r.glyph === STATE_MARKS.running).length
188 let figures = motion === 'full' && full && running ? Math.min(SPRITES, live) : 0
189 while (figures > 0 && rows.length + 2 * figures > avail) figures--
190 let more = 0
191 if (rows.length > avail) {
192 more = rows.length - (avail - 1)
193 rows = rows.slice(0, avail - 1)
194 }
195 if (figures > 0) {
196 const byId = new Map(agents.map((a) => [a.id, a]))
197 let n = 0
198 rows = rows.map((r) => (n < figures && !r.quiet && r.glyph === STATE_MARKS.running && ++n ? { ...r, spin: '', pose: poseOf(byId.get(r.id).activity, 'working') } : r))
199 }
200 const price = full && num(cost) && cost >= 0 ? '≈$' + cost.toFixed(2) + ' this run' : ''
201 const parts = ['VBW ▸ ' + kind, status, str(rn.phase), doneAll ? doneAll + ' done' : '', time]
202 if (price) parts.push(price)
203 const model = { kind: 'crew', mode: full ? 'full' : 'compact', header: { text: cut(parts.filter(Boolean).join(' · '), room), ...(price ? { cost: price } : {}), button }, rows: fit(rows, columns, full), more }
204 const withStep = stepLine ? { ...model, step: stepLine } : model
205 return figures > 0 ? { ...withStep, sprites: true } : withStep
206}
207
208// --- the model ---------------------------------------------------------------
209
210// What Comms shows, top down: the needs-you line (`need`), the refusal line
211// (`refusal`, the newest one not yet seen), then the crew while a
212// workflow runs (and failures not yet seen after it), else a health nudge
213// before a workflow. Null when there is nothing to say.
214export function stageModel(input) {
215 try {
216 if (!isObj(input)) return null
217 const { run: rn, next: nx, health: hl, cost, motion, born } = input
218 const now = num(input.now) ? input.now : Date.now()
219 const columns = num(input.columns) && input.columns > 0 ? Math.floor(input.columns) : 80
220 const maxRows = num(input.maxRows) && input.maxRows > 0 ? Math.floor(input.maxRows) : 8
221 const need = needOf(input.need, columns, input.fresh, motion, now)
222 // The needs-you line comes first, then the refusal line; the step line takes the next row when there is one.
223 const refusal = maxRows - (need ? 1 : 0) >= 1 ? refusalOf(input.refusal, columns, now) : null
224 const top = { ...(need ? { need } : {}), ...(refusal ? { refusal } : {}) }
225 const left = maxRows - (need ? 1 : 0) - (refusal ? 1 : 0)
226 const words = left >= 1 && isObj(input.step) ? stepWords(input.step, now) : null
227 // `stamp(id, text)`: when the caller first saw `text` as message `id`; without it, no time.
228 const at = typeof input.stamp === 'function' ? input.stamp : () => null
229 const stepText = stepOf(input.step, words, columns, at, now)
230 const seen = new Set(Array.isArray(input.seen) ? input.seen.filter((x) => typeof x === 'string') : [])
231 if (left >= 1 && isObj(rn) && Array.isArray(rn.agents) && rn.agents.some((a) => isObj(a) && typeof a.id === 'string' && a.id)) {
232 // One row left: the helper line carries the step's time words, the step line gives way.
233 const crew = left === 1
234 ? crewOf(rn, now, columns, 1, motion, cost, born, seen, '', words && words.timed ? words.text : '')
235 : crewOf(rn, now, columns, left - (stepText ? 1 : 0), motion, cost, born, seen, stepText, '')
236 if (crew) return { ...crew, ...top }
237 }
238 if (stepText) return { kind: 'step', step: stepText, ...top, lines: [] }
239 const action = isObj(nx) && typeof nx.action === 'string' ? nx.action : ''
240 const lines = health(hl, action).slice(0, Math.max(0, need || refusal ? left : maxRows - 1)).map((l) => ({ ...l, text: dated(at, l.stamp, l.text, columns, now) }))
241 if (need || refusal) return { kind: 'need', ...top, lines }
242 if (RUNS.has(action) && lines.length) return { kind: 'nudge', title: cut('VBW · before the ' + action + ' run', columns), lines }
243 return null
244 } catch {
245 return null
246 }
247}
248
249// --- the tree ----------------------------------------------------------------
250
251// Draw the model with the elements of $.ui.resolve(e). `onAction(action)` is
252// called on a press; without it, Buttons carry no onPress (the plugin's
253// ui.press hook answers them by key).
254export function renderStage(ui, model, onAction) {
255 try {
256 if (!isObj(ui) || !isObj(model)) return null
257 const { Box, Text, Button } = ui
258 const button = (b, id) => h(Button, {
259 key: 'vbw-stage-' + id, label: b.label, hotkey: b.key,
260 ...(typeof onAction === 'function' ? { onPress: () => onAction(b.action) } : {}),
261 })
262 const needText = (n) => h(Text, { key: 'need', color: NEED, bold: true, wrap: 'truncate-end' }, String(n.text))
263 const needLine = isObj(model.need)
264 ? [model.need.fresh ? h(Box, { key: 'need-row', flexDirection: 'row' }, needText(model.need), newMark(Text, model.need.motion)) : needText(model.need)]
265 : []
266 if (isObj(model.refusal)) needLine.push(h(Text, { key: 'refusal', color: NEED, wrap: 'truncate-end' }, String(model.refusal.text)))
267 const notes = (model.lines || []).map((l, i) => h(Text, { key: 'line-' + i, color: l.color, wrap: 'truncate-end' }, String(l.text)))
268 const stepRow = typeof model.step === 'string' && model.step ? [h(Text, { key: 'step', wrap: 'truncate-end' }, model.step)] : []
269 if (model.kind === 'step') return h(Box, { flexDirection: 'column' }, ...needLine, ...stepRow)
270 if (model.kind === 'need') return h(Box, { flexDirection: 'column' }, ...needLine, ...notes)
271 if (model.kind === 'nudge') return h(Box, { flexDirection: 'column' }, h(Text, { key: 'title', color: NEED, wrap: 'truncate-end' }, String(model.title)), ...notes)
272 if (model.kind !== 'crew' || !isObj(model.header) || !Array.isArray(model.rows)) return null
273 const head = String(model.header.text)
274 const price = typeof model.header.cost === 'string' && model.header.cost && head.endsWith(model.header.cost) ? model.header.cost : ''
275 const header = h(Box, { key: 'header', flexDirection: 'row', justifyContent: 'space-between' },
276 h(Box, { flexDirection: 'row' },
277 h(Text, { bold: true, wrap: 'truncate-end' }, price ? head.slice(0, head.length - price.length) : head),
278 ...(price ? [h(Text, { bold: true, color: ACCENT, wrap: 'truncate-end' }, price)] : [])),
279 button(model.header.button, 'mission'))
280 // A tool call is its verb (role colour) and its object (dim), cut as one phrase.
281 const intent = (r) => {
282 const at = r.intent ? r.activity.search(/\s/) : -1
283 if (at < 0) return [h(Text, { color: r.intent ? r.roleColor : r.activityColor, wrap: 'truncate-end' }, r.activity)]
284 return [h(Text, { color: r.roleColor }, r.activity.slice(0, at)), h(Text, { dimColor: true, wrap: 'truncate-end' }, r.activity.slice(at))]
285 }
286 const sprite = (r) => h(ui.Raster, {
287 key: 'vbw-sprite-' + r.id, columns: 5, rows: 3, cells: encodeCells(spriteFrame({ role: r.role, pose: r.pose, frame: 0 })),
288 })
289 const rows = model.rows.map((r) => h(Box, { key: 'agent-' + r.id, flexDirection: 'row' },
290 model.sprites === true && r.pose && ui.Raster ? sprite(r) : h(Text, { color: r.glyphColor }, r.glyph),
291 h(Text, { color: r.roleColor, ...(r.flash ? { inverse: true } : {}) }, ' ' + pad(r.role, ROLE_W) + ' '),
292 h(Text, { color: r.roleColor, ...(r.flash ? { inverse: true } : {}) }, r.label + ' '),
293 ...(r.spin ? [h(Text, { color: r.roleColor }, r.spin + ' ')] : []),
294 ...intent(r),
295 ...(r.tail ? [h(Text, { dimColor: true }, r.tail)] : []),
296 ...(r.button ? [h(Text, null, ' '), button(r.button, 'details-' + r.id)] : [])))
297 const fold = model.more ? [h(Box, { key: 'more', flexDirection: 'row', columnGap: 1 },
298 h(Text, { dimColor: true }, '+' + model.more + ' more ·'), button({ key: 'm', label: 'Mission Control', action: { open: 'mission' } }, 'more'))] : []
299 return h(Box, { flexDirection: 'column' }, ...needLine, header, ...stepRow, ...rows, ...fold)
300 } catch {
301 return null
302 }
303}
304hooks/panel-step.js 76 lines1// The running step as the panel says it (R190): one pure wording of the kernel's
2// note (.vbw/runtime/step.json) at a moment, so Mission Control, its Timeline and
3// Comms read alike. The panel never works a step's time out itself: the note
4// carries the end, and this file only words it.
5// about 12 min, until around 14:20
6// less than a minute, until around 14:20
7// until around 14:35, later than usual (parts; the kernel moved the end)
8// until around 14:20, 6 min late (no parts; the first end stays)
9// until around tomorrow 00:20 (an end on another day)
10// No end time yet: VBW has timed fewer than 3 steps like this here
11// Pure: data in, text out; never throws.
12
13const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
14const str = (x) => (typeof x === 'string' ? x : '')
15const MIN_MS = 60000
16const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
17
18export const NO_END = 'No end time yet: VBW has timed fewer than 3 steps like this here'
19
20const two = (n) => String(n).padStart(2, '0')
21const stamp = (d) => two(d.getHours()) + ':' + two(d.getMinutes())
22const midnight = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
23
24// Every moment the panel prints (design rule 5): the kernel's step_clock rule
25// (plugin/lib/steps.sh), 'HH:MM' in local 24-hour time today, else 'yesterday HH:MM',
26// 'tomorrow HH:MM' or the weekday's name; '' when `ms` is not a moment. `now` (ms) is
27// the moment it is said at; without one the day is not named.
28export function dayClock(ms, now) {
29 if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
30 const e = new Date(ms)
31 const days = typeof now === 'number' && Number.isFinite(now) ? Math.round((midnight(e) - midnight(new Date(now))) / 86400000) : 0
32 const day = days === 0 ? '' : days === 1 ? 'tomorrow ' : days === -1 ? 'yesterday ' : DAY_NAMES[e.getDay()] + ' '
33 return day + stamp(e)
34}
35
36// A proof's note counts only while its process lives (R190.27): a killed proof leaves
37// the note behind. EPERM means the process exists; no pid on the note is not a verdict.
38function alive(pid) {
39 if (typeof pid !== 'number') return true
40 try {
41 process.kill(pid, 0)
42 return true
43 } catch (e) {
44 return !!e && e.code === 'EPERM'
45 }
46}
47
48// The note a panel keeps only while it is live: its run is the record's open run, or it
49// is a proof of this session (a proof holds no lease). Anything else is no step here.
50export function liveStep(note, rec, sessionId) {
51 if (!isObj(note) || !str(note.kind)) return null
52 if (note.kind === 'proof') return sessionId && note.session === sessionId && alive(note.pid) ? note : null
53 const lease = isObj(rec) && isObj(rec.lease) ? rec.lease : null
54 return lease && str(note.run) && lease.run === note.run ? note : null
55}
56
57// { text, timed } for a note at `now` (ms); null when it is not a note. `timed` is
58// true when the text carries an end time.
59export function stepWords(note, now) {
60 try {
61 if (!isObj(note)) return null
62 const end = Date.parse(note.end)
63 if (!Number.isFinite(end)) return { text: NO_END, timed: false }
64 const t = Number.isFinite(now) ? now : Date.now()
65 const at = 'until around ' + dayClock(end, t)
66 const left = end - t
67 const hasParts = isObj(note.parts)
68 if (hasParts && (note.late === true || left <= 0)) return { text: at + ', later than usual', timed: true }
69 if (!hasParts && left <= -MIN_MS) return { text: at + ', ' + Math.floor(-left / MIN_MS) + ' min late', timed: true }
70 if (left < MIN_MS) return { text: left > 0 ? 'less than a minute, ' + at : at, timed: true }
71 return { text: 'about ' + Math.max(1, Math.round(left / MIN_MS)) + ' min, ' + at, timed: true }
72 } catch {
73 return null
74 }
75}
76hooks/panel-commands.js 87 lines1// What VBW's instant mod commands say (mods_4_vbw.md §3.6): /vbw-status, /vbw-why
2// and /vbw-todo run with no model turn. Pure: data in, text or argv out; it never
3// changes its input and never throws. Sentences come from panel-view.js, so the
4// commands and the panel say the same thing. The kernel writes: /vbw-todo only
5// builds the argv the mod hands to `vbw`.
6
7import { panelView, heldBy } from './panel-view.js'
8import { dayClock } from './panel-step.js'
9
10const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
11const NEUTRAL = 'No VBW project is open here yet.'
12const LEASE_HOURS = 24 // docs/workflows.md: an older run no longer holds the project
13
14// A non-gate next step in plain words.
15const STEPS = {
16 plan: 'plan the work', build: 'build the plan', fix: 'fix what the checks found',
17 prove: 'run the checks', qa: 'check the results', run: 'finish the open run', map: 'map the project',
18}
19
20const clock = (n) => (Number.isFinite(n) ? n : Date.now())
21const project = (input) => {
22 const { record, next, nextAt, health, now, sessionId, holder } = isObj(input) ? input : {}
23 const v = panelView({ record, next, nextAt, health, now: clock(now) })
24 return v.rows[0].id === 'neutral' ? null : { v, rec: record, nx: next, now: clock(now), sessionId, holder: isObj(holder) ? holder : null }
25}
26
27// /vbw-status: the milestone, its progress, what VBW is doing, then the user's turn or the next step.
28export function statusText(input) {
29 const p = project(input)
30 if (!p) return NEUTRAL
31 const row = (id) => p.v.rows.find((r) => r.id === id).text
32 const doing = isObj(p.rec.lease) ? row('doing').replace(/\.$/, heldBy(p.holder) + '.') : row('doing')
33 const out = [row('milestone'), row('progress'), doing]
34 if (!isObj(p.rec.lease)) {
35 if (p.v.need) out.push(p.v.need.text)
36 else if (!isObj(p.nx) || !p.nx.action || p.nx.gate === true) out.push('Next: run /vbw:vibe.')
37 else out.push('Next: ' + (STEPS[p.nx.action] || String(p.nx.action)) + ' (/vbw:vibe carries on).')
38 }
39 return out.join('\n')
40}
41
42function leaseLine(l, now, sessionId, holder) {
43 const start = Date.parse(l.started_at)
44 const ms = now - start
45 const mins = Number.isFinite(ms) ? Math.floor(ms / 60000) : NaN
46 const named = isObj(holder) && holder.known === true && typeof holder.name === 'string' && holder.name
47 const who = typeof l.session !== 'string' || !l.session ? 'a session VBW cannot name'
48 : named ? (l.session === sessionId ? 'this chat ("' + holder.name + '")' : '"' + holder.name + '"')
49 : (l.session === sessionId ? 'this session' : 'another session') + ' (' + l.session.slice(0, 8) + ')'
50 const since = Number.isFinite(start)
51 ? ' since ' + dayClock(start, now) + ' (' + (mins >= 1 ? mins + ' min ago' : 'just started') + ')' : ''
52 const f = l.files
53 const files = f === null || f === undefined ? 'may write any file'
54 : !Array.isArray(f) || f.length === 0 ? 'write no files'
55 : 'may write ' + f.length + (f.length === 1 ? ' file' : ' files')
56 const out = ['A ' + String(l.kind || 'VBW') + ' run is open, held by ' + who + since + '; its agents ' + files + '.']
57 if (mins >= LEASE_HOURS * 60) out.push('It is older than ' + LEASE_HOURS + ' hours, so VBW no longer holds the project to it.')
58 else if (l.session && l.session !== sessionId) out.push('Wait for it to finish in that session.')
59 return out
60}
61
62// /vbw-why: who holds the run and since when, and what blocks the next step.
63export function whyText(input) {
64 const p = project(input)
65 if (!p) return NEUTRAL
66 const blocked = (Array.isArray(p.rec.plans) ? p.rec.plans : [])
67 .filter((x) => isObj(x) && x.status === 'blocked')
68 .map((x) => x.id + (x.title ? ' (' + x.title + ')' : '') + ' is blocked: ' + (x.note || 'no reason given') + '.')
69 const open = isObj(p.rec.lease)
70 if (!open && !p.v.need && blocked.length === 0) return 'Nothing is running and nothing is blocked.'
71 const out = open ? leaseLine(p.rec.lease, p.now, p.sessionId, p.holder) : ['Nothing is running.']
72 if (p.v.need) out.push(p.v.need.text)
73 return out.concat(blocked).join('\n')
74}
75
76const words = (x) => (typeof x === 'string' ? x : Array.isArray(x) ? x.filter((w) => typeof w === 'string').join(' ') : '')
77 .replace(/\s+/g, ' ').trim()
78
79// /vbw-todo [text]: the typed text, else the selected text, parked by `vbw todo add`.
80export function todoArgs(input) {
81 const { args, selection } = isObj(input) ? input : {}
82 const text = words(args) || words(isObj(selection) ? selection.text : selection)
83 return text
84 ? { argv: ['todo', 'add', text], message: null }
85 : { argv: null, message: 'Usage: /vbw-todo TEXT parks an idea for later; with no text, select the idea first.' }
86}
87hooks/panel-pane.js 281 lines1// Mission Control (mods_4_vbw.md §3.3): the pane, six tabs as buttons across the
2// top in whole rows (a name is never split, no row is wider than the pane; the fewest rows
3// win, then the full names over the short ones, then the portrait), after the VBW portrait
4// where the terminal draws images and it costs the tabs nothing (§3.7). Now opens
5// with the panel's own sentences and its sound switch above the live workflow (the
6// crew); Plan holds the milestone's decisions, Timeline each run's time and cost, and
7// Settings every setting, changed by a press (panel-settings.js); the views are those of panel-mission-live.js,
8// panel-mission-record.js, panel-runs.js and panel-sessions.js.
9// At ship the pane shows VBW Wrapped (panel-candy.js) until a tab is chosen; the
10// all-green sweep crosses the top of Proof. Pure over the state panel.js gathered:
11// no `$` here; presses go to `on.act(action)` and the sound switch to `on.sound()`.
12import { nowModel, timelineModel, renderNow, renderTimeline, renderNowBox, renderRefusal } from './panel-mission-live.js'
13import { planModel, proofModel, renderPlan, renderProof } from './panel-mission-record.js'
14import { settingsModel, renderSettings } from './panel-settings.js'
15import { runsModel, renderRuns } from './panel-runs.js'
16import { sessionsModel, renderSessions } from './panel-sessions.js'
17import { renderNext } from './panel-next.js'
18import { renderWrapped } from './panel-candy.js'
19import { NEED, PROGRESS, ESTIMATE, ACCENT } from './panel-palette.js'
20import { SHOW_TERMS } from './panel-view.js'
21
22// The head: the portrait's PORTRAIT_DRAWN_ROWS rows or the tab rows, whichever are more, and the gap
23// under them. A short pane (fewer than PORTRAIT_ROWS) drops the portrait, and gets a gap
24// under the tabs only with room to spare.
25const PORTRAIT_ROWS = 8
26const GAP_ROWS = 6
27// Where Claude Code reports no height (inline, the main screen) the pane is at most this tall.
28const FIXED_ROWS = 30
29const PORTRAIT_COLS = 6
30const PORTRAIT_DRAWN_ROWS = 3
31// Claude Code draws a button as '[ ' + label + ' ]' (a hotkey button as '[k] ' + label): 4 cells
32// around the label. The tab buttons sit one cell apart.
33const BUTTON_CHROME = 4
34const TAB_GAP = 1
35
36const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
37export const TABS = [['now', 'Now'], ['plan', 'Plan'], ['proof', 'Proof'], ['timeline', 'Timeline'], ['sessions', 'Sessions'], ['settings', 'Settings']]
38// The tab labels where they save a row: one row from 52 columns (the full names need 65), two
39// rows below 38.
40export const SHORT = { now: 'Now', plan: 'Plan', proof: 'Proof', timeline: 'Time', sessions: 'Sess', settings: 'Set' }
41
42// The tabs packed in order into whole rows: each row holds as many whole buttons as fit (first-fit
43// in order gives the fewest rows) and a name is never split; a width narrower than one button still
44// gives one button per row. -> arrays of names.
45export function tabRows(names, width) {
46 const rows = []
47 let used = 0
48 for (const name of names) {
49 const w = [...name].length + BUTTON_CHROME
50 if (rows.length && used + TAB_GAP + w <= width) { rows[rows.length - 1].push(name); used += TAB_GAP + w } else { rows.push([name]); used = w }
51 }
52 return rows
53}
54
55// The head's layout: every candidate (the full names or SHORT, with the portrait, its columns taken
56// from the width, or without) ranked by the fewest rows, then the full names over SHORT, then the
57// portrait over none: the portrait never costs a row or a full name.
58export function layout(width, portraitWanted) {
59 const found = []
60 for (const portrait of portraitWanted ? [true, false] : [false]) {
61 for (const short of [false, true]) {
62 const rows = tabRows(TABS.map(([id, name]) => (short ? SHORT[id] : name)), width - (portrait ? PORTRAIT_COLS : 0))
63 found.push({ portrait, short, rows, rank: [rows.length, short ? 1 : 0, portrait ? 0 : 1] })
64 }
65 }
66 return found.sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.rank[2] - b.rank[2])[0]
67}
68
69// The head as drawn: the layout with each row's tabs as [id, label], worked out again only
70// when the width or the portrait's wish changes (a pane redraws at one width for long).
71let laidOut = null
72function headAt(width, portraitWanted) {
73 if (!laidOut || laidOut.width !== width || laidOut.portraitWanted !== portraitWanted) {
74 const lay = layout(width, portraitWanted)
75 const idOf = (label) => TABS.find(([i, name]) => (lay.short ? SHORT[i] : name) === label)[0]
76 laidOut = { width, portraitWanted, portrait: lay.portrait, rows: lay.rows.map((names) => names.map((label) => [idOf(label), label])) }
77 }
78 return laidOut
79}
80
81// --- how many rows a drawing takes ----------------------------------------------
82// A model of Ink's layout (a Box stacks or sets side by side, a Text wraps or is cut),
83// enough to know whether Mission Control fits the fixed height. { w, h }: the widest
84// line and the rows. Never throws; an unknown element takes no room.
85const int = (x) => (Number.isInteger(x) ? x : 0)
86const wide = (s) => [...s].length
87const textOf = (n) => (typeof n === 'string' || typeof n === 'number' ? String(n) : n && typeof n === 'object' ? (n.children || []).map(textOf).join('') : '')
88
89function measure(node, cols) {
90 if (typeof node === 'string' || typeof node === 'number') return measure({ type: 'Text', props: {}, children: [String(node)] }, cols)
91 if (Array.isArray(node)) return { w: Math.max(0, ...node.map((n) => measure(n, cols).w)), h: node.reduce((n, c) => n + measure(c, cols).h, 0) }
92 if (!node || typeof node !== 'object') return { w: 0, h: 0 }
93 const p = node.props || {}
94 if (p.display === 'none') return { w: 0, h: 0 }
95 let m
96 switch (node.type) {
97 case 'Text': {
98 const cut = /^truncate/.test(String(p.wrap || ''))
99 m = { w: 0, h: 0 }
100 for (const line of textOf(node).split('\n')) {
101 const n = wide(line)
102 if (!Number.isFinite(cols) || cut || n <= cols) m = { w: Math.max(m.w, Math.min(n, cols)), h: m.h + 1 }
103 else m = { w: cols, h: m.h + Math.ceil(n / Math.max(1, cols)) }
104 }
105 break
106 }
107 case 'Raster': case 'Image': m = { w: int(p.columns), h: int(p.rows) }; break
108 case 'Button': {
109 const label = p.label === undefined || p.label === null ? [] : [{ type: 'Text', props: {}, children: [' '.repeat(BUTTON_CHROME) + String(p.label)] }]
110 m = setBeside([...label, ...(node.children || [])], cols, { columnGap: 1 })
111 break
112 }
113 default: m = boxOf(node, cols)
114 }
115 const top = int(p.marginTop ?? p.marginY ?? p.margin)
116 const bottom = int(p.marginBottom ?? p.marginY ?? p.margin)
117 return { w: m.w, h: Math.max(0, m.h + top + Math.max(0, bottom)) }
118}
119
120const flexible = (c) => Boolean(c) && typeof c === 'object' &&
121 ((c.type === 'Box' && (c.props || {}).flexWrap === 'wrap') || (c.type === 'Text' && !/^truncate/.test(String((c.props || {}).wrap || ''))))
122
123// Children side by side: as wide as they are, the ones that can wrap sharing what the rest leave.
124function setBeside(children, cols, p) {
125 const gap = int(p.columnGap ?? p.gap)
126 const kids = children.filter((c) => measure(c, Infinity).h > 0)
127 let blocks = kids.map((c) => measure(c, Infinity))
128 if (p.flexWrap === 'wrap' && Number.isFinite(cols)) {
129 let h = 0
130 let w = 0
131 let used = 0
132 let rowH = 0
133 for (const b of blocks) {
134 if (rowH && used + gap + b.w > cols) { h += rowH; used = 0; rowH = 0 }
135 used += (rowH ? gap : 0) + b.w
136 rowH = Math.max(rowH, b.h)
137 w = Math.max(w, used)
138 }
139 return { w: Math.min(w, cols), h: h + rowH }
140 }
141 const gaps = gap * Math.max(0, blocks.length - 1)
142 const flex = kids.map(flexible)
143 if (Number.isFinite(cols) && blocks.reduce((n, b) => n + b.w, 0) + gaps > cols && flex.some(Boolean)) {
144 const fixed = blocks.reduce((n, b, i) => n + (flex[i] ? 0 : b.w), 0) + gaps
145 const share = Math.max(1, Math.floor((cols - fixed) / flex.filter(Boolean).length))
146 blocks = kids.map((c, i) => (flex[i] ? measure(c, share) : blocks[i]))
147 }
148 return { w: Math.min(blocks.reduce((n, b) => n + b.w, 0) + gaps, Number.isFinite(cols) ? cols : Infinity), h: Math.max(0, ...blocks.map((b) => b.h)) }
149}
150
151function boxOf(node, cols) {
152 const p = node.props || {}
153 const kids = node.children || []
154 const border = p.borderStyle ? 1 : 0
155 const px = int(p.paddingX ?? p.padding)
156 const py = int(p.paddingY ?? p.padding)
157 const inner = Number.isFinite(cols) ? Math.max(1, cols - 2 * border - 2 * px) : Infinity
158 let m
159 if (p.flexDirection === 'column' || p.flexDirection === 'column-reverse') {
160 const gap = int(p.rowGap ?? p.gap)
161 const parts = kids.map((c) => measure(c, inner))
162 m = { w: Math.max(0, ...parts.map((b) => b.w)), h: parts.reduce((n, b, i) => n + b.h + (i && gap ? gap : 0), 0) }
163 } else m = setBeside(kids, inner, p)
164 m = { w: m.w + 2 * px + 2 * border, h: m.h + 2 * py + 2 * border }
165 if (Number.isFinite(cols) && border) m.w = cols
166 if (Number.isInteger(p.height) && p.height >= 0) m.h = p.height
167 return m
168}
169
170// The colour of a sentence's key value; a sentence with no value in it stays plain.
171function tint(r, need) {
172 if (String(r.id).startsWith('refused:')) return NEED // a refusal: amber, beside its ⊘ mark and word
173 switch (r.id) {
174 case 'need': return need ? NEED : undefined
175 case 'progress': return /^No requirements/.test(String(r.text)) ? undefined : PROGRESS
176 case 'estimate-milestone': return /^No (?:estimate|end time)/.test(String(r.text)) ? undefined : ESTIMATE
177 case 'cost': return /not available/.test(String(r.text)) ? undefined : ACCENT
178 default: return undefined
179 }
180}
181
182// What the box already says: what VBW is doing, the milestone's end, the cost, and a need
183// (its sentence is the box's state).
184const IN_BOX = (v) => new Set(['doing', 'estimate-milestone', 'cost', ...(v.need ? ['need'] : [])])
185
186// The box (panel-view.js nowBoxModel, drawn by panel-mission-live.js renderNowBox), the crew
187// slot under it, the panel's sentences (panel-view.js), each with its term in small text
188// while SHOW_TERMS holds, a refusal as its short row (open while st.open holds its id), and
189// the sound switch.
190function summary(ui, v, st, on, crewRows, width, act) {
191 const { Box, Text, Button } = ui
192 const boxed = v.box ? IN_BOX(v) : new Set()
193 const open = isObj(st.open) ? st.open : {}
194 const box = v.box ? [renderNowBox(ui, v.box, { width, labels: SHOW_TERMS })] : []
195 const rows = [...box, ...crewRows, ...v.rows.filter((r) => !boxed.has(r.id)).map((r) =>
196 String(r.id).startsWith('refused:') ? renderRefusal(ui, r, open[r.id] === true, act)
197 : h(Box, { key: r.id, flexDirection: 'column', marginBottom: 1 },
198 h(Text, { color: tint(r, v.need) }, String(r.text)),
199 SHOW_TERMS ? h(Text, { dimColor: true }, String(r.term)) : null))]
200 // panel.js keeps st.sound from the kernel's `sound` setting and on.sound() sets it (off or default).
201 rows.push(
202 h(Box, { key: 'sound', flexDirection: 'row', marginBottom: 1 },
203 h(Text, null, 'Sound is ' + (st.sound ? 'on' : 'off') + '. '),
204 h(Button, { key: 'sound-toggle', label: st.sound ? 'Turn off' : 'Turn on', onPress: () => on.sound() })))
205 return rows
206}
207
208const marksOf = (input) => (isObj(input.fresh) ? input.fresh : {})
209
210function body(ui, input, tab, act) {
211 const { st, now, width, registry, stored } = input
212 const rec = st.record
213 const marks = marksOf(input)
214 switch (tab) {
215 // Plan: the phases, the chosen one's work, then the milestone's decisions (the record ties a decision to no phase).
216 case 'plan': return renderPlan({ ...ui, Input: undefined }, planModel({ record: rec, phase: st.phase, open: st.open, now }), act, width - 4) // Ask is not wired yet
217 case 'proof': return renderProof(ui, proofModel({ record: rec, selected: st.check, open: st.open, now }), act, width - 4)
218 // Timeline: the live run's agents on its clock, then every run of the session with its time and cost.
219 case 'timeline': return h(ui.Box, { key: 'timeline-tab', flexDirection: 'column' },
220 renderTimeline(ui, timelineModel({ run: st.run, now, steps: st.steps, step: st.step, width: Math.max(10, width - 24) })),
221 renderRuns(ui, runsModel({ runs: st.leases, steps: st.steps, sessionCost: st.cost, now, width: Math.max(20, width - 4), fresh: marks, motion: input.motion })))
222 case 'sessions': return renderSessions(ui, sessionsModel({ registry, root: st.root, sessionId: st.sessionId, lease: isObj(rec) ? rec.lease : null, stored, now, width: Math.max(10, width - 4), plans: isObj(rec) ? rec.plans : null }))
223 case 'settings': return renderSettings(ui, settingsModel({ settings: st.settings, record: rec, now, fresh: marks, motion: input.motion }), act)
224 default: return null
225 }
226}
227
228// The Now tab's crew slot, right under the box (Lane B draws the rows: panel-mission-live.js
229// renderNow). The box already says that nothing runs; "no workflow runs here" is only news
230// while a run is held.
231function crew(ui, { st, now, motion, width }, act) {
232 const rec = st.record
233 const model = nowModel({ run: st.run, now, selected: st.agent, seen: Object.keys(st.helpersSeen || {}), motion, width: Math.max(10, width - 2) })
234 return model || (isObj(rec) && isObj(rec.lease)) ? [h(ui.Box, { key: 'crew', flexDirection: 'column', marginBottom: 1 }, renderNow(ui, model, act))] : []
235}
236
237// input: { st, v, now, width, portrait (a PNG's path, or null), wrapped (the
238// Wrapped model, or null), sweep ({ cols, cells } of the sweep's frame, or null) }.
239export function renderPane(ui, input, on) {
240 const { Box, Button, Image, Raster, Text } = ui
241 const { st, v } = input
242 const act = (a) => on.act(a)
243 const wrapped = st.tab === 'wrapped' && input.wrapped ? input.wrapped : null
244 const tab = wrapped ? 'wrapped' : TABS.some(([id]) => id === st.tab) ? st.tab : 'now'
245 const sc = input.scroll
246 const rows = sc && Number.isInteger(sc.bodyRows) ? sc.bodyRows : 0
247 // The portrait goes when the room is short or costs the tabs a row or a full name.
248 const width = Number.isFinite(input.width) ? input.width : 50
249 const wanted = Boolean(Image) && typeof input.portrait === 'string' && (rows < 1 || rows >= PORTRAIT_ROWS)
250 const lay = headAt(width, wanted)
251 const withPortrait = lay.portrait
252 const tabs = h(Box, { key: 'tabs', flexDirection: 'column' },
253 ...lay.rows.map((row, r) => h(Box, { key: 'tab-row-' + r, flexDirection: 'row', columnGap: TAB_GAP, flexShrink: 0 },
254 ...row.map(([id, label]) => h(Button, { key: 'tab-' + id, label, ...(id === tab ? { variant: 'primary' } : { dimColor: true }), onPress: () => act({ show: id }) })))))
255 const portrait = withPortrait
256 ? [h(Image, { key: 'vbw-portrait', source: { file: input.portrait, format: 'png' }, columns: PORTRAIT_COLS, rows: PORTRAIT_DRAWN_ROWS, alt: 'VBW' })] : []
257 const gap = rows < 1 || withPortrait || rows >= GAP_ROWS
258 const headRows = Math.max(withPortrait ? PORTRAIT_DRAWN_ROWS : 0, lay.rows.length) + (gap ? 1 : 0)
259 const head = h(Box, { key: 'head', flexDirection: 'row', ...(gap ? { marginBottom: 1 } : {}) }, ...portrait, tabs)
260 const sweep = tab === 'proof' && Raster && input.sweep ? [h(Raster, { key: 'vbw-sweep', columns: input.sweep.cols, rows: 1, cells: input.sweep.cells })] : []
261 const view = wrapped ? renderWrapped(ui, wrapped) : body(ui, { ...input, width }, tab, act)
262 const upNext = tab === 'now' ? [renderNext(ui, { recommendation: input.recommendation, width: input.width, motion: input.motion, fresh: marksOf(input), now: input.now }, act)] : []
263 const content = [...(tab === 'now' ? summary(ui, v, st, on, crew(ui, { ...input, width }, act), width, act) : []), ...upNext, ...sweep, view]
264 if (rows < 1) {
265 // No height reported: one fixed height at most, ending with how much is left out.
266 const tall = measure(h(Box, { flexDirection: 'column' }, ...content), width).h
267 if (headRows + tall <= FIXED_ROWS) return h(Box, { flexDirection: 'column' }, head, ...content)
268 const room = FIXED_ROWS - headRows - 1
269 return h(Box, { flexDirection: 'column' }, head,
270 h(Box, { key: 'body', flexDirection: 'column', overflow: 'hidden', height: room }, h(Box, { key: 'body-rows', flexDirection: 'column', flexShrink: 0 }, ...content)),
271 h(Text, { key: 'more', dimColor: true, wrap: 'truncate-end' }, '+' + (tall - room) + ' more · open Mission Control'))
272 }
273 // The tabs are a fixed head; everything under them is one clipped body, shifted by the scroll offset.
274 const off = Number.isInteger(sc.offset) && sc.offset > 0 ? sc.offset : 0
275 const inner = h(Box, { key: 'body-rows', flexDirection: 'column', flexShrink: 0, marginTop: -off }, ...content)
276 // A pane shorter than its head clips the head to the reported rows: never more rows than Claude Code gives.
277 const top = headRows > rows ? h(Box, { key: 'head-clip', flexDirection: 'column', overflow: 'hidden', height: rows }, head) : head
278 return h(Box, { flexDirection: 'column' }, top,
279 h(Box, { key: 'body', flexDirection: 'column', overflow: 'hidden', height: Math.max(0, rows - headRows) }, inner))
280}
281hooks/panel-next.js 129 lines1// The Now tab's What's next section (R124, R125): the stored recommendation in
2// vbw status order, a Suggest next button, and the fresh highlight (panel-candy.js) that marks a
3// new recommendation. Pure: values in, trees out; no `$`, never throws.
4
5import { ACCENT, ROLE_COLORS } from './panel-palette.js'
6import { fresh } from './panel-candy.js'
7import { dayClock } from './panel-step.js'
8
9const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
10const str = (x) => typeof x === 'string' && x.trim() !== ''
11
12export const TITLE = "What's next"
13export const REQUEST = "/vbw:vibe what's next"
14
15// nextModel(recommendation): { kind: 'none' } | { kind: 'empty', at } |
16// { kind: 'pick', at, top: { text, reason, size, source }, runners: [{ text, size, source }] }.
17// Any wrong type or missing field is 'none': a half-read pick is never shown.
18export function nextModel(rec) {
19 try {
20 if (!isObj(rec) || !str(rec.at)) return { kind: 'none' }
21 if (rec.empty === true) return { kind: 'empty', at: rec.at }
22 const t = rec.top
23 if (!isObj(t) || !str(t.text) || !str(t.reason) || !str(t.size) || !Array.isArray(rec.runners)) return { kind: 'none' }
24 const runners = []
25 for (const r of rec.runners.slice(0, 2)) {
26 if (!isObj(r) || !str(r.text) || !str(r.size)) return { kind: 'none' }
27 runners.push({ text: r.text, size: r.size, source: str(r.source) ? r.source : '' })
28 }
29 return { kind: 'pick', at: rec.at, top: { text: t.text, reason: t.reason, size: t.size, source: str(t.source) ? t.source : '' }, runners }
30 } catch {
31 return { kind: 'none' }
32 }
33}
34
35// The local time of a stored moment as '[HH:MM]' ('[yesterday HH:MM]' on another day,
36// dayClock's rule), or '' when it is not a time.
37export function stamp(at, now) {
38 const t = dayClock(Date.parse(at), now)
39 return t ? '[' + t + ']' : ''
40}
41
42// What pressing a row puts in the prompt: its T or R id, else its text.
43export const commandOf = (row) => '/vbw:vibe ' + (/^[TR]\d+$/.test(row.source) ? row.source : row.text)
44
45// The section's lines before the width cut: [{ id, text, dim?, row? }]. A row
46// is a pressable item { text, size, source, reason? }, drawn as one Button.
47export function nextLines(model, now) {
48 const m = isObj(model) ? model : { kind: 'none' }
49 if (m.kind === 'empty') return [{ id: 'empty', text: 'the backlog is empty', dim: true }]
50 if (m.kind !== 'pick') return [{ id: 'none', text: 'none yet', dim: true }]
51 const out = [{ id: 'top', row: m.top, pick: true }]
52 m.runners.forEach((r, i) => out.push({ id: 'runner' + i, row: r }))
53 out.push({ id: 'at', text: (stamp(m.at, now) ? stamp(m.at, now) + ' ' : '') + 'written ' + m.at.slice(0, 10), dim: true })
54 return out
55}
56
57const cut = (s, width) => {
58 const cs = [...String(s)]
59 const w = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 40
60 return cs.length <= w ? cs.join('') : cs.slice(0, w - 1).join('') + '…'
61}
62
63const HIGHLIGHT = ROLE_COLORS.scout
64
65// The title as Text pieces: one piece when still, else before / lit / after.
66function titlePieces(Text, title) {
67 const letters = [...TITLE]
68 const whole = [h(Text, { key: 'title', bold: true, color: ACCENT }, TITLE)]
69 if (title === null) return whole
70 const from = Math.max(0, title - 1)
71 const to = Math.min(letters.length, title + 2)
72 if (from >= to) return whole
73 const piece = (key, a, b, color) => (a < b ? h(Text, { key, bold: true, color }, letters.slice(a, b).join('')) : null)
74 return [piece('t0', 0, from, ACCENT), piece('t1', from, to, HIGHLIGHT), piece('t2', to, letters.length, ACCENT)].filter(Boolean)
75}
76
77// The mark of a recommendation not yet seen: the word 'new'. Motion off draws
78// the word alone; calm and full add a steady palette colour (the shimmer and
79// pulse of fresh() come back at full motion until it is seen, and this mark stays).
80export function newMark(Text, motion) {
81 const props = { key: 'new', wrap: 'truncate' }
82 if (motion === 'full' || motion === 'calm') Object.assign(props, { bold: true, color: ACCENT })
83 return h(Text, props, ' new')
84}
85
86// The fresh key of a recommendation: 'now:next:' and when it was written.
87export const freshKey = (rec) => (isObj(rec) && str(rec.at) ? 'now:next:' + rec.at : null)
88
89// renderNext(ui, { recommendation, width, motion, fresh (the marks, panel-candy.js), now }, act) -> Box.
90// A recommendation not yet seen shimmers and pulses at full motion, and is marked new, until seen.
91export function renderNext(ui, input, act) {
92 const { Box, Text, Button } = isObj(ui) ? ui : {}
93 const { recommendation, width, motion, now } = isObj(input) ? input : {}
94 const c = fresh({ marks: isObj(input) ? input.fresh : null, key: freshKey(recommendation), motion, now })
95 const unseen = c.on
96 const model = nextModel(recommendation)
97 // A row pressed has been seen; Suggest next only fills the prompt.
98 const press = (fill, seen) => () => { if (typeof act === 'function') act(seen ? { fill, seen: 'next' } : { fill }) }
99 const lines = nextLines(model, now).map((l) => {
100 if (!l.row) {
101 const props = { key: l.id, wrap: 'truncate' }
102 if (l.dim) props.dimColor = true
103 return h(Text, props, cut(l.text, width))
104 }
105 const r = l.row
106 const w = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 40
107 // One Button, one line: '▸ item [size] reason'. Claude Code's h() lets a Button hold only
108 // strings and Text (never a Box), so every piece is a Text inside it. The item takes what the
109 // chip leaves; the reason, dim, sits on the line when it fits whole, else under it, cut.
110 const chip = ' [' + r.size + ']'
111 const item = cut(r.text, Math.max(1, w - 2 - [...chip].length))
112 const left = w - 2 - [...item].length - [...chip].length - 2
113 const beside = Boolean(r.reason) && [...r.reason].length <= left
114 const itemProps = { key: 'item', wrap: 'truncate' }
115 if (l.pick) Object.assign(itemProps, { bold: true, color: c.pulse === 2 ? HIGHLIGHT : ACCENT })
116 return h(Box, { key: l.id, flexDirection: 'column' },
117 h(Button, { key: 'item-button', plain: true, onPress: press(commandOf(r), true) },
118 h(Text, { key: 'mark', wrap: 'truncate', ...(l.pick ? { color: ACCENT } : { dimColor: true }) }, '▸ '),
119 h(Text, itemProps, item),
120 h(Text, { key: 'chip', dimColor: true, wrap: 'truncate' }, chip),
121 ...(beside ? [h(Text, { key: 'reason', dimColor: true, wrap: 'truncate' }, ' ' + r.reason)] : [])),
122 ...(r.reason && !beside ? [h(Text, { key: 'reason', dimColor: true, wrap: 'truncate' }, cut(' ' + r.reason, w))] : []))
123 })
124 return h(Box, { key: 'whats-next', flexDirection: 'column', marginBottom: 1 },
125 h(Box, { key: 'whats-next-title', flexDirection: 'row' }, ...titlePieces(Text, c.title), ...(unseen === true ? [newMark(Text, motion)] : [])),
126 ...lines,
127 h(Button, { key: 'suggest-next', label: 'Suggest next', onPress: press(REQUEST) }))
128}
129hooks/panel-candy.js 282 lines1// Delight that respects the user (mods_4_vbw.md §3.1 crew theme, §3.7): the
2// motion setting, the crew's pixel sprites, the confetti burst, the VBW
3// Wrapped card at ship, and which celebration a record change earns. Pure:
4// values in, values or trees out; no `$`, never throws.
5
6import { ROLE_COLORS, NEUTRAL, roleColor, stateColor, rgb } from './panel-palette.js'
7
8const DEFAULT = 0x01000000 // the terminal's own colour, in a Raster cell
9const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
10const arr = (x) => (Array.isArray(x) ? x : [])
11
12// motion: 'full' (sprites, celebrations), 'calm' (dots, spinners, no confetti)
13// or 'off' (static text). Test mode is always off (R64); the user's setting
14// wins; else users new to code get full and everyone else calm (R54).
15const MOTIONS = ['full', 'calm', 'off']
16const NEW_TO_CODE = ['never', 'small scripts or no-code']
17export function motionOf(input) {
18 const { setting, level, testMode } = isObj(input) ? input : {}
19 if (testMode) return 'off'
20 if (MOTIONS.includes(setting)) return setting
21 return NEW_TO_CODE.includes(level) ? 'full' : 'calm'
22}
23
24// Sprites: 5 columns by 3 rows. Columns 0-2 are the figure, in the role's
25// colour; columns 3-4 are the prop that tells the pose, in its own colour.
26// The pose is the state: no frame is decoration only.
27const PROP = { neutral: rgb(NEUTRAL), done: rgb(stateColor('done')), quiet: rgb(stateColor('quiet')), failed: rgb(stateColor('failed')) }
28const SPRITES = {
29 reading: { prop: PROP.neutral, frames: [[' ▄ ', '▐█▌▐▀', '▐ ▌ '], [' ▄ ', '▐█▌▐▄', '▐ ▌ ']] },
30 editing: { prop: PROP.neutral, frames: [[' ▄ ▐', '▐█▌▀▌', '▐ ▌ ▀'], [' ▄ ', '▐█▌▄▄', '▐ ▌▐█']] },
31 shell: { prop: PROP.neutral, frames: [[' ▄ ', '▐█▌▀ ', '▌ ▐ ▄'], [' ▄ ', '▐█▌ ▀', ' █ ▄ ']] },
32 streaming: { prop: PROP.neutral, frames: [[' ▄ ▄▀', '▐█▌ ', '▐ ▌ '], [' ▄ ▀▄', '▐█▌▄ ', '▐ ▌ '], [' ▄ ██', '▐█▌▀ ', '▐ ▌ ']] },
33 done: { prop: PROP.done, frames: [[' ▄ ✓', '▐█▌ ', '▐ ▌ '], ['▄▄▄✓ ', ' █ ', '▐ ▌ ']] },
34 quiet: { prop: PROP.quiet, frames: [[' ▄ z', '▐█▌ ', '▐ ▌ '], [' ▄ Z ', '▐█▌ z', '▐ ▌ ']] },
35 failed: { prop: PROP.failed, frames: [[' ▄ ✗', '▐█▌ ', '▐ ▌ '], [' ▄ ✗ ', '▐█▌ ', '▐ ▌ ']] },
36}
37export const POSES = Object.keys(SPRITES)
38
39// spriteFrame({ role, pose, frame }) -> { cols, rows, cells: [{ ch, fg, bg }] } | null
40export function spriteFrame(input) {
41 const { role, pose, frame } = isObj(input) ? input : {}
42 const s = SPRITES[pose]
43 if (!s) return null
44 const n = s.frames.length
45 const i = Number.isFinite(frame) ? ((Math.trunc(frame) % n) + n) % n : 0
46 const body = rgb(roleColor(role))
47 const cells = []
48 for (const line of s.frames[i]) {
49 ;[...line].forEach((ch, x) => cells.push({ ch, fg: ch === ' ' ? DEFAULT : x < 3 ? body : s.prop, bg: DEFAULT }))
50 }
51 return { cols: 5, rows: 3, cells }
52}
53
54// poseOf(activity, state): the feed's agent state and activity as a pose.
55export function poseOf(activity, state) {
56 if (state === 'done' || state === 'quiet' || state === 'failed') return state
57 if (state !== 'working') return 'quiet'
58 if (!isObj(activity) || activity.kind === 'text') return 'streaming'
59 const t = String(activity.text || '').toLowerCase()
60 if (/^(read|search|list|grep|find|look|fetch|glob)/.test(t)) return 'reading'
61 if (/^(edit|writ|creat|updat|delet|mov)/.test(t)) return 'editing'
62 return 'shell'
63}
64
65// encodeCells(grid): a Raster's `cells`: padded base64 of little-endian u32
66// triplets [codePoint, fg, bg], row-major. Written out, as the mod's runtime
67// has neither Buffer nor a guaranteed btoa.
68const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
69export function encodeCells(grid) {
70 if (!isObj(grid) || !Array.isArray(grid.cells)) return ''
71 const bytes = []
72 const u32 = (v) => bytes.push(v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255)
73 for (const c of grid.cells) {
74 u32(String(c?.ch || ' ').codePointAt(0))
75 u32(Number.isFinite(c?.fg) ? c.fg : DEFAULT)
76 u32(Number.isFinite(c?.bg) ? c.bg : DEFAULT)
77 }
78 let out = ''
79 for (let i = 0; i < bytes.length; i += 3) {
80 const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
81 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63]
82 out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
83 out += i + 2 < bytes.length ? B64[n & 63] : '='
84 }
85 return out
86}
87
88// confettiFrames({ cols, rows, n, seed }): n frames (default 15: 1.5 s at
89// 10 fps) of falling pieces in the seven role colours, the same for a seed, ending
90// clear. Sized to a Raster's limits (512 by 256).
91const PIECES = ['▀', '▄', '▌', '▐', '█', '•']
92function rng(seed) {
93 let a = seed >>> 0
94 return () => {
95 a = (a + 0x6d2b79f5) >>> 0
96 let t = Math.imul(a ^ (a >>> 15), 1 | a)
97 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
98 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
99 }
100}
101export function confettiFrames(input) {
102 const { cols, rows, n = 15, seed = 1 } = isObj(input) ? input : {}
103 if (!Number.isInteger(cols) || !Number.isInteger(rows) || !Number.isInteger(n) || cols < 1 || rows < 1 || n < 1) return []
104 const w = Math.min(cols, 512)
105 const ht = Math.min(rows, 256)
106 const rand = rng(Number.isFinite(seed) ? seed : 1)
107 const colors = Object.values(ROLE_COLORS).map(rgb)
108 const count = Math.max(3, Math.round((w * ht) / 6))
109 // Each piece starts above or in the box and falls fast enough to leave it by the last frame.
110 const last = Math.max(1, n - 1)
111 const pieces = Array.from({ length: count }, () => {
112 const y0 = -rand() * ht
113 return {
114 x: Math.floor(rand() * w), drift: rand() * 2 - 1, y0,
115 vy: (ht - y0 + 0.01) / last * (1 + rand()),
116 ch: PIECES[Math.floor(rand() * PIECES.length)], fg: colors[Math.floor(rand() * colors.length)],
117 }
118 })
119 const frames = []
120 for (let f = 0; f < n; f++) {
121 const cells = Array.from({ length: w * ht }, () => ({ ch: ' ', fg: DEFAULT, bg: DEFAULT }))
122 if (f < last || n === 1) {
123 for (const p of pieces) {
124 const y = Math.floor(p.y0 + p.vy * f)
125 const x = Math.round(p.x + p.drift * f)
126 if (y >= 0 && y < ht && x >= 0 && x < w) cells[y * w + x] = { ch: p.ch, fg: p.fg, bg: DEFAULT }
127 }
128 }
129 frames.push({ cols: w, rows: ht, cells })
130 }
131 return frames
132}
133
134// sweepFrames({ cols, n }): one green band crossing a one-row Raster left to
135// right in n frames (default 12), the last one clear: the all-green sweep.
136const GREEN = rgb(stateColor('done'))
137const TRAIL = ['█', '▓', '▒', '░']
138export function sweepFrames(input) {
139 const { cols, n = 12 } = isObj(input) ? input : {}
140 if (!Number.isInteger(cols) || !Number.isInteger(n) || cols < 1 || n < 2) return []
141 const w = Math.min(cols, 512)
142 return Array.from({ length: n }, (_, f) => {
143 const head = f === n - 1 ? -Infinity : Math.round((f * (w - 1)) / Math.max(1, n - 2))
144 const cells = Array.from({ length: w }, (_, x) => {
145 const ch = TRAIL[head - x]
146 return ch ? { ch, fg: GREEN, bg: DEFAULT } : { ch: ' ', fg: DEFAULT, bg: DEFAULT }
147 })
148 return { cols: w, rows: 1, cells }
149 })
150}
151
152// The VBW Wrapped card at ship: what the milestone took. A milestone's window
153// runs from the ship before it to its own ship; steps and runs outside it are
154// other milestones'. What VBW cannot know is null, never a guess.
155const ms = (s) => (typeof s === 'string' ? Date.parse(s) : NaN)
156export function wrappedModel(input) {
157 const { record, runs, steps, cost } = isObj(input) ? input : {}
158 if (!isObj(record)) return null
159 const ships = arr(record.shipped).filter((s) => isObj(s) && typeof s.id === 'string')
160 const ship = ships[ships.length - 1]
161 if (!ship) return null
162 const end = ms(ship.at)
163 const start = ships.length > 1 ? ms(ships[ships.length - 2].at) : -Infinity
164 const within = (t) => Number.isFinite(t) && t >= start && (!Number.isFinite(end) || t <= end)
165
166 const reqs = arr(record.requirements).filter((r) => isObj(r) && r.milestone === ship.id)
167 const ids = new Set(reqs.map((r) => r.id))
168 const checks = arr(record.checks).filter((c) => isObj(c) && ids.has(c.req))
169 const results = isObj(record.evidence) && isObj(record.evidence.checks) ? record.evidence.checks : {}
170 const fixRounds = arr(record.phases)
171 .filter((p) => isObj(p) && p.milestone === ship.id)
172 .reduce((n, p) => n + (Number.isFinite(p.outcome?.fix_rounds) ? p.outcome.fix_rounds : 0), 0)
173
174 const ourRuns = arr(runs).filter((r) => isObj(r) && within(r.startedAt))
175 const crews = ourRuns.filter((r) => Array.isArray(r.agents))
176 const runCosts = ourRuns.filter((r) => Number.isFinite(r.cost))
177 const ourSteps = arr(steps).filter((s) => isObj(s) && Number.isFinite(s.seconds) && s.seconds >= 0 && within(ms(s.started_at)) && within(ms(s.ended_at)))
178 const brief = (s) => (s ? { kind: String(s.kind), run: String(s.run), seconds: s.seconds } : null)
179 const byTime = [...ourSteps].sort((a, b) => a.seconds - b.seconds)
180
181 return {
182 milestone: { id: ship.id, title: typeof ship.title === 'string' ? ship.title : record.milestone?.id === ship.id ? String(record.milestone.title || '') : '' },
183 requirements: { proven: reqs.filter((r) => r.status === 'proven').length, total: reqs.length },
184 checks: { passing: checks.filter((c) => results[c.id]?.status === 'pass').length, total: checks.length },
185 agents: crews.length ? crews.reduce((n, r) => n + r.agents.length, 0) : null,
186 fixRounds,
187 seconds: ourSteps.length ? ourSteps.reduce((n, s) => n + s.seconds, 0) : null,
188 cost: Number.isFinite(cost) ? cost : runCosts.length ? Math.round(runCosts.reduce((n, r) => n + r.cost, 0) * 100) / 100 : null,
189 fastest: brief(byTime[0]),
190 slowest: brief(byTime[byTime.length - 1]),
191 }
192}
193
194const two = (n) => String(n).padStart(2, '0')
195const duration = (s) => {
196 const t = Math.round(s)
197 return t >= 3600 ? `${Math.floor(t / 3600)}h${two(Math.floor((t % 3600) / 60))}m` : `${Math.floor(t / 60)}m${two(t % 60)}s`
198}
199const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
200
201export function renderWrapped(ui, model) {
202 if (!isObj(ui) || !isObj(model)) return null
203 const { Box, Text } = ui
204 const m = model
205 const lines = [
206 `${m.requirements.proven} of ${m.requirements.total} requirements proven`,
207 `${m.checks.passing} of ${m.checks.total} checks pass`,
208 [m.agents === null ? null : plural(m.agents, 'agent'), plural(m.fixRounds, 'fix round')].filter(Boolean).join(' · '),
209 [m.seconds === null ? 'time unknown' : duration(m.seconds), m.cost === null ? 'cost unknown' : '$' + m.cost.toFixed(2)].join(' · '),
210 ]
211 if (m.fastest) lines.push(`fastest: ${m.fastest.kind} ${duration(m.fastest.seconds)} · slowest: ${m.slowest.kind} ${duration(m.slowest.seconds)}`)
212 return h(Box, { flexDirection: 'column', borderStyle: 'round', borderColor: stateColor('done'), paddingX: 1 },
213 h(Text, { bold: true, color: stateColor('done') }, `VBW Wrapped · ${m.milestone.id}${m.milestone.title ? ' ' + m.milestone.title : ''}`),
214 lines.map((l, i) => h(Text, { key: 'w' + i }, l)))
215}
216
217// celebration({ before, after }): what a change between two record snapshots
218// earns: a ship, then a phase passing QA, then a fresh all-green proof. The
219// first snapshot (no before) earns nothing: it is not news.
220const qaKey = (p) => (p?.qa?.result === 'pass' ? `${p.id}@${p.qa.at}` : null)
221export function celebration(input) {
222 const { before, after } = isObj(input) ? input : {}
223 if (!isObj(before) || !isObj(after)) return null
224 const shipKey = (r) => {
225 const s = arr(r.shipped)
226 return s.length ? `${s.length}:${s[s.length - 1]?.id}@${s[s.length - 1]?.at}` : ''
227 }
228 if (arr(after.shipped).length && shipKey(after) !== shipKey(before)) return 'shipped'
229 const passed = new Set(arr(before.phases).map(qaKey).filter(Boolean))
230 if (arr(after.phases).some((p) => qaKey(p) && !passed.has(qaKey(p)))) return 'qa-pass'
231 const b = isObj(before.evidence) ? before.evidence : {}
232 const a = isObj(after.evidence) ? after.evidence : {}
233 if (a.passed === true && !(b.passed === true && b.at === a.at)) return 'all-green'
234 return null
235}
236
237// ---- fresh(key): the one "new" highlight (T87) ---------------------------------
238// Anything newly arrived (a recommendation, a refusal, a finished run, a settings change)
239// carries a mark under its key until the person looks at it: its tab is opened (the key's
240// prefix, 'now:', 'timeline:', 'settings:') or it is pressed (the key itself). The marks are
241// { key: arrived at (ms) }; panel.js keeps them in the user's store. At full motion the
242// shimmer and the soft pulse come back every FRESH_CYCLE_MS until it is seen; at calm the
243// mark is steady; with motion off it is the word alone.
244export const FRESH_CYCLE_MS = 4000
245const STEP_MS = 250
246const marksOf = (m) => (isObj(m) ? m : {})
247
248export function freshArrive(marks, key, now) {
249 const m = marksOf(marks)
250 if (typeof key !== 'string' || !key || Object.prototype.hasOwnProperty.call(m, key)) return m
251 return { ...m, [key]: Number.isFinite(now) ? now : 0 }
252}
253
254// keys: a key, a prefix ending in ':' (everything under a tab), or a list of them.
255export function freshSee(marks, keys) {
256 const m = marksOf(marks)
257 const ks = (Array.isArray(keys) ? keys : [keys]).filter((k) => typeof k === 'string' && k)
258 const seen = (k) => ks.some((x) => (x.endsWith(':') ? k.startsWith(x) : k === x))
259 const out = {}
260 for (const [k, v] of Object.entries(m)) if (!seen(k)) out[k] = v
261 return out
262}
263
264export const freshKeys = (marks) => Object.keys(marksOf(marks)).sort()
265
266// fresh({ marks, key, motion, now }) -> { on, title (the sweep's centre letter, or null), pulse
267// (0 still | 1 rising | 2 peak | 3 falling) }.
268export function fresh(input) {
269 const still = { on: false, title: null, pulse: 0 }
270 try {
271 const { marks, key, motion, now } = isObj(input) ? input : {}
272 const m = marksOf(marks)
273 if (typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(m, key)) return still
274 if (motion !== 'full' || !Number.isFinite(now) || !Number.isFinite(m[key])) return { ...still, on: true }
275 const dt = (((now - m[key]) % FRESH_CYCLE_MS) + FRESH_CYCLE_MS) % FRESH_CYCLE_MS
276 const pulse = dt < 300 ? 0 : dt < 700 ? 1 : dt < 1300 ? 2 : dt < 1700 ? 3 : 0
277 return { on: true, title: Math.floor(dt / STEP_MS) - 1, pulse }
278 } catch {
279 return still
280 }
281}
282hooks/panel-settings.js 288 lines1// Mission Control's Settings tab: every setting `vbw config set` accepts (and rigor,
2// the sound and the status line), each as one line `name ● value ○ choice …` under its
3// heading, changed by a click. The kernel is the only reader and writer: the values are `vbw config`'s own
4// lines and `vbw statusline status`'s, a press runs `vbw config set KEY VALUE` (or
5// `vbw config rigor MODE`, `vbw statusline on|off`), and the tab is read again after
6// it. A change flashes `✓ saved` for about 2 s; a refusal shows the kernel's own words
7// in amber under its row and the value stays. panel.js runs the commands through `$`;
8// everything here is pure: values in, values or trees out, never throws.
9
10import { SOUNDS } from './panel-sounds.js'
11import { newMark } from './panel-next.js'
12import { ACCENT, NEED, roleColor, stateColor, STATE_MARKS } from './panel-palette.js'
13
14const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
15const str = (x) => (typeof x === 'string' && x !== '' ? x : null)
16
17export const ROLES = ['architect', 'lead', 'dev', 'qa', 'scout', 'debugger', 'docs']
18// The sound folders shipped under assets/audio/ (panel-sounds.js lists their files).
19export const SOUND_FOLDERS = [...new Set(SOUNDS.map((p) => p.split('/')[2]).filter(Boolean))]
20export const SAVED_MS = 2000
21
22// The rows, in the order drawn. `choices`: the values a press can set; `number`: the
23// step of its − and + and its bounds (the kernel's); `scope`: who shares the value;
24// `group`: the heading it sits under (GROUPS).
25const MODELS = ['fable', 'opus', 'sonnet', 'haiku', 'default']
26// The efforts Claude Code takes for an agent; default leaves the profile's per step.
27const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'default']
28// The interview's answers, as plugin/lib/interview.json lists them (a test holds the two equal).
29export const INTERVIEW = {
30 level: ['never', 'small scripts or no-code', 'professionally', 'senior engineer'],
31 depth: ['plain words', 'plain with technical terms explained', 'technical and brief'],
32 involvement: ['decide and tell me', 'options with a recommendation', 'I make the calls'],
33}
34const TALK = 'How VBW talks to you'
35export const ROWS = [
36 { key: 'profile', choices: ['quality', 'balanced', 'budget'], group: 'Models' },
37 ...ROLES.map((r) => ({ key: 'model.' + r, choices: r === 'qa' ? MODELS.filter((m) => m !== 'haiku') : MODELS, role: r, group: 'Models' })),
38 ...ROLES.map((r) => ({ key: 'effort.' + r, choices: EFFORTS, role: r, group: 'Effort' })),
39 { key: 'interview.level', label: 'your level', choices: INTERVIEW.level, group: TALK },
40 { key: 'interview.depth', label: 'wording', choices: INTERVIEW.depth, group: TALK },
41 { key: 'interview.involvement', label: 'decisions', choices: INTERVIEW.involvement, group: TALK },
42 { key: 'interview.kept', label: 'kept', choices: ['private', 'project'], group: TALK },
43 { key: 'autonomy', choices: ['guided', 'balanced', 'hands-off'], group: 'How VBW works' },
44 { key: 'autonomy_cap', number: { min: 1, max: 9999, step: 5, unset: 25 }, group: 'How VBW works' },
45 { key: 'auto', label: 'autonomous run', choices: ['on', 'off'], scope: 'this chat', group: 'How VBW works' },
46 { key: 'rigor', choices: ['auto', 'express', 'standard', 'deep'], group: 'How VBW works' },
47 { key: 'check_jobs', number: { min: 1, max: 64, step: 1 }, scope: 'this clone', group: 'Checks' },
48 { key: 'command_timeout', number: { min: 1, max: 86400, step: 60, unset: 3600 }, unit: 's', group: 'Checks' },
49 { key: 'check_budget', number: { min: 1, max: 86400, step: 10, unset: 120 }, unit: 's', group: 'Checks' },
50 { key: 'motion', choices: ['full', 'calm', 'off', 'default'], group: 'Look and sound' },
51 { key: 'sound', choices: ['off', ...SOUND_FOLDERS, 'default'], scope: 'this clone', group: 'Look and sound' },
52 { key: 'statusline', choices: ['on', 'off'], scope: 'this computer', group: 'Claude Code' },
53 { key: 'workflows', label: 'Dynamic workflows', status: true, scope: 'this computer', group: 'Claude Code' },
54]
55const rowOf = (key) => ROWS.find((r) => r.key === key) || null
56
57const SESSION = /^[A-Za-z0-9_-]+$/
58const sessionOf = (x) => (typeof x === 'string' && SESSION.test(x) ? x : null)
59
60// What panel.js runs to read the tab, in this order: `vbw config`, `vbw statusline status`,
61// `vbw interview --json`, `vbw workflows status`, and with this chat's session id
62// `vbw auto status SESSION` (no id, no autonomous run row).
63export const readArgv = (session) => [['config'], ['statusline', 'status'], ['interview', '--json'], ['workflows', 'status'],
64 ...(sessionOf(session) ? [['auto', 'status', session]] : [])]
65
66// The argv (after the vbw path) a press asks for, or null when the key or value is not one a
67// row offers: nothing is run that the tab does not draw. `session`: this chat's id, which the
68// autonomous run's on and off name.
69export function settingArgv(choice, session) {
70 try {
71 if (!isObj(choice) || typeof choice.key !== 'string') return null
72 const row = rowOf(choice.key)
73 const v = typeof choice.value === 'number' ? String(choice.value) : choice.value
74 if (!row || typeof v !== 'string') return null
75 if (row.key === 'workflows') return v === 'on' ? ['workflows', 'on'] : null
76 if (row.choices && !row.choices.includes(v)) return null
77 if (row.key === 'auto') return sessionOf(session) ? ['auto', v, session] : null
78 if (row.key === 'interview.kept') return ['interview', 'keep', v]
79 if (row.key.startsWith('interview.')) return ['interview', 'set', row.key.slice(10), v]
80 if (row.number && !(v === 'default' || (/^\d+$/.test(v) && Number(v) >= row.number.min && Number(v) <= row.number.max))) return null
81 if (row.key === 'rigor') return ['config', 'rigor', v]
82 if (row.key === 'statusline') return ['statusline', v]
83 return ['config', 'set', row.key, v]
84 } catch {
85 return null
86 }
87}
88
89// `vbw config`'s lines as { key: { value, note } }: 'profile: balanced',
90// 'check_jobs: 6 (default: ...)', 'command_timeout: 3600 seconds', 'model.dev: sonnet',
91// and the machine limit line as the note of check_jobs.
92export function parseConfig(text) {
93 const out = {}
94 for (const line of String(typeof text === 'string' ? text : '').split('\n')) {
95 const m = /^([a-z_.]+): (.*)$/.exec(line.trim())
96 if (!m) continue
97 if (m[1] === 'machine limit') continue
98 const [, key, rest] = m
99 const num = /^(\d+)\b\s*(.*)$/.exec(rest)
100 out[key] = num ? { value: num[1], note: num[2].replace(/^\((.*)\)$/, '$1') } : { value: rest.trim(), note: '' }
101 }
102 const limit = /^machine limit: (\d+)\b/m.exec(String(text || ''))
103 if (limit && out.check_jobs) out.check_jobs.limit = 'machine limit ' + limit[1]
104 return out
105}
106
107// `vbw statusline status`'s first line: on, off, or another status line.
108function parseStatusline(text) {
109 const line = String(typeof text === 'string' ? text : '').split('\n')[0].trim()
110 const m = /^(on|off):\s*(.*)$/.exec(line)
111 if (m) return { value: m[1], note: m[2] }
112 return line ? { value: 'other', note: line } : null
113}
114
115const firstLine = (r) => {
116 const t = isObj(r) ? String(r.stderr || r.stdout || '').trim() : ''
117 return t.split('\n').map((l) => l.trim()).filter(Boolean)[0] || 'VBW refused the change (exit ' + (isObj(r) ? r.exitCode : '?') + ').'
118}
119
120// `vbw interview --json`'s answers as rows' values: unanswered and not kept yet are null, no choice lit.
121function parseInterview(text) {
122 let j
123 try { j = JSON.parse(String(text || '')) } catch { return {} }
124 if (!isObj(j)) return {}
125 const out = {}
126 for (const k of ['level', 'depth', 'involvement']) out['interview.' + k] = { value: str(j[k]) || 'not answered', note: '' }
127 out['interview.kept'] = { value: str(j.kept) || 'not kept yet', note: j.kept === 'project' ? 'shared through git' : j.kept === 'private' ? 'this clone only' : '' }
128 return out
129}
130
131// `vbw workflows status`'s first word: on, default (Claude Code decides) or off (disabled on purpose).
132function parseWorkflows(text) {
133 const m = /^(on|default|off):\s*(.*)$/.exec(String(text || '').split('\n')[0].trim())
134 return m ? { workflows: { value: m[1] === 'off' ? 'disabled' : m[1], note: '' } } : {}
135}
136
137// `vbw auto status SESSION`: off, or 'armed: step N of M, since …'.
138function parseAuto(text) {
139 const line = String(text || '').split('\n')[0].trim()
140 const m = /^armed: step (\d+) of (\d+)/.exec(line)
141 if (m) return { auto: { value: 'on', note: 'step ' + m[1] + ' of ' + m[2] } }
142 return line === 'off' ? { auto: { value: 'off', note: '' } } : {}
143}
144
145// The tab's state after a read (and a press): before, the earlier state or null; choice, the
146// pressed { key, value } or null; result, what running settingArgv(choice) gave ({ exitCode,
147// stdout, stderr }, null when it could not run); reads, the results of readArgv in order.
148export function settingsState(input) {
149 try {
150 const { before, choice, result, reads, now } = isObj(input) ? input : {}
151 const prev = isObj(before) ? before : {}
152 const [cfg, line, interview, workflows, auto] = Array.isArray(reads) ? reads : []
153 const ok = (r) => isObj(r) && r.exitCode === 0
154 const values = ok(cfg)
155 ? { ...parseConfig(cfg.stdout), ...(ok(interview) ? parseInterview(interview.stdout) : {}), ...(ok(workflows) ? parseWorkflows(workflows.stdout) : {}), ...(ok(auto) ? parseAuto(auto.stdout) : {}) }
156 : isObj(prev.values) ? prev.values : null
157 const status = ok(line) || (isObj(line) && str(line.stdout)) ? parseStatusline(line.stdout) : prev.statusline || null
158 const state = {
159 values, statusline: status, at: Number.isFinite(now) ? now : null,
160 error: ok(cfg) ? null : values ? prev.error || null : 'VBW could not read the settings here' + (isObj(cfg) ? ': ' + firstLine(cfg) : '.'),
161 saved: null, refused: null,
162 }
163 if (isObj(choice) && typeof choice.key === 'string') {
164 if (ok(result)) state.saved = { key: choice.key, at: state.at }
165 else state.refused = { key: choice.key, text: isObj(result) ? firstLine(result) : 'VBW could not run the change here.' }
166 } else {
167 // A read with no press keeps a refusal on screen until the next press; the flash runs out by itself.
168 state.refused = isObj(prev.refused) ? prev.refused : null
169 state.saved = isObj(prev.saved) ? prev.saved : null
170 }
171 return state
172 } catch {
173 return { values: null, statusline: null, at: null, error: 'VBW could not read the settings here.', saved: null, refused: null }
174 }
175}
176
177const flashing = (s, now) => isObj(s) && isObj(s.saved) && Number.isFinite(s.saved.at) && Number.isFinite(now) && now - s.saved.at < SAVED_MS
178// What the redraw check compares: the state and whether the flash still shows.
179export const settingsSig = (s, now) => [isObj(s) ? s.values : null, isObj(s) ? s.statusline : null, isObj(s) ? s.refused : null, isObj(s) ? s.error : null, flashing(s, now) ? s.saved.key : null]
180
181// settingsModel({ settings, record, now }) -> { rows: [{ key, value, note, mine, choices|number,
182// saved, refused }], error, reading }. `record` only says which models the user set (record.settings.models,
183// read-only); every value is the kernel's.
184export function settingsModel(input) {
185 try {
186 const { settings, record, now, fresh, motion } = isObj(input) ? input : {}
187 const marks = isObj(fresh) ? fresh : {}
188 const s = isObj(settings) ? settings : null
189 if (!s || !isObj(s.values)) return { rows: [], error: s && s.error ? s.error : null, reading: !s || !s.error }
190 const mine = isObj(record) && isObj(record.settings) && isObj(record.settings.models) ? record.settings.models : {}
191 const rows = []
192 for (const r of ROWS) {
193 const got = r.key === 'statusline' ? s.statusline : s.values[r.key]
194 if (!isObj(got)) continue // a setting this kernel does not report has no row
195 const value = String(got.value)
196 const n = r.number && /^\d+$/.test(value) ? Number(value) : r.number && r.number.unset ? r.number.unset : null
197 rows.push({
198 key: r.key, value: value === 'null' && r.number ? 'not set' : value,
199 note: [r.key === 'check_jobs' ? got.limit : null, r.scope ? r.scope : null, r.key === 'auto' || r.key === 'interview.kept' ? str(got.note) : null].filter(Boolean).join(' · '),
200 mine: Boolean(r.role && r.key.startsWith('model.') && str(mine[r.role])), role: r.role || null, label: r.label || r.role || r.key, status: Boolean(r.status),
201 ...(r.choices ? { choices: r.choices } : {}),
202 ...(r.number ? { number: { ...r.number, at: n } } : {}),
203 unit: r.unit || '',
204 saved: flashing(s, now) && s.saved.key === r.key,
205 refused: isObj(s.refused) && s.refused.key === r.key ? s.refused.text : null,
206 fresh: Object.prototype.hasOwnProperty.call(marks, 'settings:' + r.key),
207 })
208 }
209 return { rows, error: s.error || null, reading: false, motion }
210 } catch {
211 return { rows: [], error: 'VBW could not read the settings here.', reading: false }
212 }
213}
214
215// The headings the rows sit under, in the order drawn; each row names its own.
216export const GROUPS = ['Models', 'Effort', 'How VBW talks to you', 'How VBW works', 'Checks', 'Look and sound', 'Claude Code']
217
218// One line per row: the name in its colour, then its controls, the ✓ saved flash and the new
219// mark; under it its note dim and the kernel's refusal in amber. A choice row is one button per
220// choice, `● value` lit in the accent, the others `○ choice` dim: every choice is one click. Never
221// a Select: the terminal opens one from the keyboard only. A number row is `− value +` and
222// default. The controls wrap under themselves, never under the name.
223export function renderSettings(ui, model, act) {
224 const { Box, Text, Button } = isObj(ui) ? ui : {}
225 const m = isObj(model) ? model : { rows: [] }
226 const press = (key, value) => () => { if (typeof act === 'function') act({ setting: { key, value: String(value) } }) }
227 if (!Array.isArray(m.rows) || !m.rows.length) {
228 return h(Box, { key: 'settings', flexDirection: 'column' },
229 m.error ? h(Text, { key: 'settings-error', color: NEED }, m.error) : h(Text, { key: 'settings-reading', dimColor: true }, 'Reading the settings…'))
230 }
231 // A model row is named by its role alone, under the Models heading, and an interview row by
232 // what it asks; each group pads its names to its own longest.
233 const label = (r) => r.label || r.role || r.key
234 const groupOf = (r) => (rowOf(r.key) || {}).group || ''
235 const widthOf = (g) => Math.max(...m.rows.filter((r) => groupOf(r) === g).map((r) => label(r).length)) + 2
236 const done = stateColor('done')
237 const chip = (r, c) => c === r.value
238 ? h(Button, { key: 'set-' + r.key + '-' + c, label: c, plain: true, variant: 'primary', onPress: press(r.key, c) },
239 h(Text, { key: 'chip' }, h(Text, { key: 'mark', color: ACCENT }, '● '), h(Text, { key: 'value', bold: true, color: ACCENT }, c)))
240 : h(Button, { key: 'set-' + r.key + '-' + c, label: c, plain: true, dimColor: true, onPress: press(r.key, c) },
241 h(Text, { key: 'chip', dimColor: true }, '○ ' + c))
242 const kids = []
243 let group = null
244 for (const r of m.rows) {
245 const g = groupOf(r)
246 const nameW = widthOf(g)
247 if (g && g !== group) {
248 group = g
249 kids.push(h(Text, { key: 'group:' + g, bold: true, dimColor: true }, (kids.length ? '\n' : '') + g))
250 }
251 const under = (k, text) => h(Box, { key: k + '-line', flexDirection: 'row' }, h(Text, { key: 'indent' }, ' '.repeat(nameW)), text)
252 let controls
253 if (r.status) {
254 // Dynamic workflows: on is done; Claude Code's default may be off on some plans, so it offers
255 // turn on; off on purpose (disableWorkflows) is the user's, said and never overridden.
256 const on = r.value === 'on'
257 const text = on ? STATE_MARKS.done + ' on' : r.value === 'default' ? '⚠ Claude Code decides (off on some plans)' : '⚠ off on purpose: VBW needs them to plan and build'
258 controls = [h(Text, { key: 'value', color: on ? done : NEED }, text),
259 ...(r.value === 'default' ? [h(Button, { key: 'set-' + r.key + '-on', label: 'turn on', variant: 'primary', onPress: press(r.key, 'on') })] : [])]
260 } else if (r.choices) {
261 controls = r.choices.map((c) => chip(r, c))
262 } else {
263 const n = r.number
264 const at = Number.isFinite(n.at) ? n.at : n.min
265 const value = r.value + (r.unit && /^\d+$/.test(r.value) ? ' ' + r.unit : '')
266 controls = [
267 h(Button, { key: 'set-' + r.key + '-down', label: '−', onPress: press(r.key, Math.max(n.min, at - n.step)) }),
268 h(Text, { key: 'value', bold: true, color: ACCENT }, value),
269 h(Button, { key: 'set-' + r.key + '-up', label: '+', onPress: press(r.key, Math.min(n.max, at + n.step)) }),
270 ...(r.key === 'autonomy_cap' ? [] : [h(Button, { key: 'set-' + r.key + '-default', label: 'default', plain: true, dimColor: true, onPress: press(r.key, 'default') })]),
271 ]
272 }
273 kids.push(h(Box, { key: 'setting:' + r.key, flexDirection: 'column' },
274 h(Box, { key: 'line', flexDirection: 'row' },
275 // The name's box never shrinks: wide choices wrap under themselves, the name keeps its gap.
276 h(Box, { key: 'name-cell', flexShrink: 0 }, h(Text, { key: 'name', ...(r.role ? { color: roleColor(r.role) } : {}) }, label(r).padEnd(nameW))),
277 h(Box, { key: 'controls', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, flexShrink: 1 },
278 ...controls,
279 r.mine ? h(Text, { key: 'mine', dimColor: true }, '(set by you)') : null,
280 r.saved ? h(Text, { key: 'saved', color: done }, STATE_MARKS.done + ' saved') : null,
281 r.fresh ? newMark(Text, m.motion) : null)),
282 r.note ? under('note', h(Text, { key: 'note', dimColor: true }, r.note)) : null,
283 r.refused ? under('refused', h(Text, { key: 'refused', color: NEED }, r.refused)) : null))
284 }
285 if (m.error) kids.push(h(Text, { key: 'settings-error', color: NEED }, m.error))
286 return h(Box, { key: 'settings', flexDirection: 'column' }, ...kids)
287}
288