SLOPSHOPPER

vbw

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

newpanebandspinnerrowsguard
★ 86v2.1.15MITupdated 2026-10-25swt-labs/vibe-better-with-claude-code-vbw/plugin
A shopper browsing a rack in a slop shop
README

Vibe Better With Claude Code - VBW

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

License: MIT Claude Code Models Discord

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

What Is This?

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.

For people who don't read code, and people who do

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.

The problem

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.

What VBW does about it

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.

AgentJobHow many
ArchitectWorks 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
LeadTurns 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
DevWrites 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)
QAReads 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
ScoutLooks 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
DebuggerReproduces 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
DocsWrites 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.

For engineers: the model proposes, the kernel disposes

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 ownClaude Code + the VBW kernel
Who says it's doneThe modelThe exit code of an approved check, run on a clean copy of HEAD
Who can edit the testsAnyone, including the agent being testedNot 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 livesThe chat context, until compaction eats it.vbw/record.json: one writer, locked, validated, atomic
Who runs the processThe model, improvisingDynamic workflows: deterministic JavaScript
Parallel agentsUp to the modelWaves with no shared files; guards deny writes outside each plan
Running repo commandsWhatever the model typesArgv only, no eval, after you consent to the content hash
Several sessions, one folderLast write winsA run's files are leased to its session; everything else stays open

Three design choices worth knowing:

  • No per-agent worktrees, on purpose. A worktree has no 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.
  • Proof reuses what didn't change. VBW records what each check reads while it runs; a check whose definition, last result and every file it read are unchanged keeps its pass. If your test runner can run one test file at a time, say so in the spec's ## 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.
  • One limit per computer. Every VBW run on a computer, in any project or session, shares one limit on how many checks and test files run at once (about three quarters of the processor cores), so parallel sessions take turns instead of overloading the machine.

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.


Install

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.

Updating

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.

Running VBW

Supervised (for the cautious)Auto (for the brave)
Start withclaudeSonnet, Opus or Fable, then Shift+Tab for auto mode
Claude Code asks youBefore file writes and commands, once per tool per projectOnly when it must
VBW stops forDecisions, approval, checking, shippingDecisions, approval, checking, shipping
Feels likeSome clicking in the first session, then smoothAgents keep building until a step needs you

Auto mode keeps the flow unbroken: the agents work longer between stops.

Disclaimer: --dangerously-skip-permissions turns 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.


Start

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
  1. Setup. The first run sets up the project and the VBW status line.
  2. Questions. Three about you, once. Then what you are building and for whom, then the decisions that matter, one at a time, each with a recommendation.
  3. Approve. VBW shows the plan and its checks and asks. Enter approves; Not yet or your own words change the plan. Seniors will skim, and both are short enough for that. You can change your mind any time: VBW re-plans without touching finished work.
  4. Build and prove. Agents build in parallel, then VBW runs the checks and QA reviews each phase against its goal. Failures become fixes; after three failed attempts at one, it comes to you.
  5. Judge what only you can. For each [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.
  6. Ship. VBW asks whether to ship, then recommends what to do next.

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


Features

The loop above is the core. Around it:

FeatureWhat you get
TriageEvery new idea is sorted into now, next or later, with a size. You confirm or overrule in one menu. docs/triage.md
What's nextAfter 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, recordedCost, 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 fitsA 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 failAt 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 behindWhen 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 readA 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 levelThree questions, once per project. Beginners get plain words. Seniors get terse output and the trade-offs. docs/interview.md
Picks up where you left offEverything lives in .vbw/ in your project, not in a chat window. /vbw:vibe continues exactly where things stand.
What it costEach run and each milestone is priced from the figures Claude Code gives the status line. vbw show costs. docs/costs.md
Status line and panelProgress, 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)

Commands

You need one: /vbw:vibe. The rest exist for people who want more control.

WhenCommandWhat it does
Always/vbw:vibe [what you want]The next step, every time. This is the whole product
Deciding/vbw:approveApprove the plan and its checks. Only you can; VBW also asks as a choice where Enter approves
/vbw:discuss, /vbw:researchThink a decision through; get a sourced answer to a question
/vbw:loop ITEM... --ceiling USDWork 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-todosPark an idea; see the list
Checking/vbw:statusWhere the project stands
/vbw:qa, /vbw:verifyRun 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:convertSet up VBW (vibe does it too); bring in a project from an earlier VBW
Session/vbw:pause, /vbw:resumeStop 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-soundDoes the panel work here; open Mission Control; sound on or off (docs/panel.md)
Setup/vbw:profile, /vbw:configHow VBW works in one go; one setting at a time
/vbw:skills, /vbw:rtk, /vbw:compressFind tools for your project (docs/tools.md); set up RTK to compress command output; shrink an instruction file
/vbw:help, /vbw:doctor, /vbw:reportList the commands; check the setup; prepare a bug report (you read it before anything is sent)
/vbw:update, /vbw:whats-new, /vbw:uninstallUpdate; see what changed; remove VBW (your .vbw/ folders stay)

The kernel, vbw

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


Who Works When

The team is in What VBW does about it; their specifications are in plugin/agents/.

WorkflowStarted byWho runs
Planning/vbw:vibe at the plan stepArchitect (decisions) → Architect (scope) → Lead
Building/vbw:vibe at the build stepone Dev for every ready plan (Docs for documentation), all at the same time
Fixing/vbw:vibe when a check fails or QA finds a problemone Dev for every group of related fixes, all at the same time
Verifying/vbw:vibe after the checks passone QA agent for every built phase, all at the same time

| Mapping | /vbw:map, or planning on existin

Source 20 files
hooks/panel.js 1177 lines
1// 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}
1177
hooks/panel-sessions.js 147 lines
1// 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}
147
hooks/panel-view.js 343 lines
1// 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}
343
hooks/panel-sounds.js 33 lines
1// 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}
33
hooks/panel-feed.js 368 lines
1// 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}
368
hooks/panel-stage.js 304 lines
1// 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}
304
hooks/panel-step.js 76 lines
1// 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}
76
hooks/panel-commands.js 87 lines
1// 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}
87
hooks/panel-pane.js 281 lines
1// 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}
281
hooks/panel-next.js 129 lines
1// 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}
129
hooks/panel-candy.js 282 lines
1// 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}
282
hooks/panel-settings.js 288 lines
1// 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