Skip to content

feat: finalize Bijou v3.0.0 for release#40

Merged
flyingrobots merged 25 commits intomainfrom
feat/v3-complete-evolution
Mar 12, 2026
Merged

feat: finalize Bijou v3.0.0 for release#40
flyingrobots merged 25 commits intomainfrom
feat/v3-complete-evolution

Conversation

@flyingrobots
Copy link
Copy Markdown
Owner

@flyingrobots flyingrobots commented Mar 10, 2026

Summary

This PR finishes Bijou v3.0.0 and prepares it for general release.

It includes the V3 runtime work, shell compatibility fixes, native demo recording, canonical V3 demos, example smoke coverage, CI/release gate wiring, and the final docs truth pass.

What changed

  • make the V3 runtime contract honest with ViewOutput across App.view and framed shell panes
  • normalize the runtime and scripted driver around the shared surface/layout render path
  • tighten framed shell compatibility, including redraw behavior on resize
  • finish and document the V3 BCSS scope for supported surface primitives and frame shell regions
  • ship the nested-app lifecycle story with initSubApp(), updateSubApp(), mount(), and mapCmds()
  • harden motion, worker runtime, and the internal Surface-to-GIF demo recorder
  • add canonical V3 demos for runtime, BCSS, motion, sub-apps, workers, and pipeline extension
  • add full-repo example smoke coverage, including PTY-driven interactive form flows
  • wire typecheck:test and smoke:examples:all into CI and release verification
  • rewrite the release-facing docs and add a dedicated v3 migration guide

Release-facing docs

  • root README updated with a truthful What's New in v3.0.0 section
  • docs/ARCHITECTURE.md rewritten to reflect the actual five-package monorepo and package split
  • docs/CHANGELOG.md updated with the 3.0.0 release entry
  • docs/MIGRATING_TO_V3.md added for existing apps
  • package READMEs updated for bijou, bijou-node, bijou-tui, bijou-tui-app, and create-bijou-tui-app
  • examples docs updated to reflect the native V3 recorder path

Verification

  • npm run build
  • npm run lint
  • npm test (120 files, 2120 passing, 12 skipped)
  • npm run typecheck:test
  • npm run smoke:examples:all
  • npm pack --dry-run --workspaces

Downstream canary

  • warp-lens manually checked and working well on this branch

Summary by CodeRabbit

Release Notes

  • New Features

    • Surface-native rendering pipeline with composable middleware stages
    • Bijou CSS (BCSS) stylesheet support for component styling
    • Declarative motion API with spring and tween interpolation
    • Sub-app composition for fractal TEA architecture
    • Unified event heartbeat for synchronized animations
    • Background worker runtime for CPU-intensive operations
    • Native GIF recorder for demo generation
    • Reactive token graph for adaptive theming
    • Flex-based layout engine
  • Breaking Changes

    • App.view now returns ViewOutput (Surface | LayoutNode | string)
    • Command callbacks receive capabilities parameter
    • Terminal dimensions accessible as properties, not methods
    • Component options now inherit from BijouNodeOptions

- Implement Buffered Surface engine with 2D cell grids, masking, and affine transforms
- Implement High-Performance Differential Renderer (renderDiff) for flicker-free TTY updates
- Implement Programmable Rendering Pipeline with multi-stage middleware support
- Implement Unified Animation Heartbeat (PulseMsg) synchronized to hardware refresh rate
- Implement Reactive & Semantic Token Graph for adaptive, reference-based theming
- Implement Fractal TEA (Sub-App Composition) via mount() and mapCmds()
- Implement Render Throttling and Batching in the TEA runtime
- Establish technical specifications for 17 upcoming features in docs/specs/
- Maintain backward compatibility for existing string-based components via V3 migration bridge
- Implement BCSS Engine with zero-dependency CSS parser
- Implement BCSS Resolver supporting Specificity (ID > Class > Type) and Media Queries
- Implement TokenGraph bridge in CSS via `var()` syntax for semantic color mapping
- Integrate BCSS into the Rendering Pipeline via `bcssMiddleware`
- Implement `runInWorker` to run TEA apps in Node.js background threads, keeping main thread responsive
- Standardize `BijouNodeOptions` across core components (id, class, ctx)
- Update `badge` to resolve global BCSS overrides
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
- Upgrade canvas() to support high-resolution Braille (2x4) and Quad (2x2) plotting
- Support rich cell output (char, color, bg) from fragment shaders
- Implement normalized UV mapping (0.0 to 1.0) for resolution-independent shaders
- Add uniforms registry for passing external state into the shader pipeline
- Fix infinite loop bug in quad resolution renderer
- Update canvas tests to verify high-res modes and UV normalization
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a surface-first rendering core, programmable render pipeline, unified heartbeat (pulse), motion reconciliation, BCSS/token-graph theming, worker-thread IPC and recorder, shader/canvas v2, sub-app composition, many component API shifts to Surface/ViewOutput, extensive tests, examples, and docs.

Changes

Cohort / File(s) Summary
Design Specs
docs/specs/*.spec.json
Adds 20+ JSON design/spec files (BCSS, heartbeat, pipeline, motion, shader-v2, token graph, worker runtime, recorder, etc.).
Surface Core & Differ
packages/bijou/src/ports/surface.ts, packages/bijou/src/core/render/differ.ts, packages/bijou/src/core/render/differ.test.ts
Introduce Surface/Cell/Layout primitives, createSurface, parsing/serialization (parseAnsiToSurface, surfaceToString, stringToSurface), paintLayoutNode, isSameCell, renderDiff and associated tests.
ViewOutput Normalization
packages/bijou-tui/src/view-output.ts, packages/bijou-tui/src/app-frame.ts, packages/bijou-tui/src/app-frame-render.ts
App.view now accepts `Surface
Programmable Rendering Pipeline
packages/bijou-tui/src/pipeline/pipeline.ts, packages/bijou-tui/src/pipeline/middleware/*
Adds RenderPipeline, RenderState, stages (Layout,Paint,PostProcess,Diff,Output) and middleware implementations (paint, motion, grayscale, bcss) with tests.
EventBus / Pulse / Cmd Caps
packages/bijou-tui/src/eventbus.ts, packages/bijou-tui/src/types.ts, packages/bijou-tui/src/animate.ts
Adds PulseMsg, start/stop/onPulse, middleware chain, extends Cmd signature to accept capabilities (onPulse), refactors animate/timers to pulse-driven model.
Motion System
packages/bijou-tui/src/motion/*
Adds motion() wrapper, MotionOptions/TrackedMotion types, MotionReconciler (spring/tween), motionMiddleware and tests.
Canvas / Shader v2
packages/bijou-tui/src/canvas.ts, packages/bijou-tui/src/canvas.test.ts
New ShaderParams API ({u,v,time,uniforms}), ShaderFn returns Cell
BCSS Parser & Resolver
packages/bijou-tui/src/css/*, packages/bijou-tui/src/css/types.ts
Adds parseBCSS, resolver (specificity, media queries, !important, var()), installBCSSResolver, text-style helpers, and unit tests.
Token Graph Theming
packages/bijou/src/core/theme/graph*.ts, packages/bijou/src/core/theme/index.ts
Implements reactive TokenGraph with transforms, adaptive modes, cycle detection, get/getColor/set/on/import and integration into ResolvedTheme/BijouContext.
Worker Runtime & Recorder
packages/bijou-node/src/worker/*, packages/bijou-node/src/recorder.ts, packages/bijou-node/src/index.ts
Adds runInWorker/isBijouWorker/startWorkerApp with IPC message types and lifecycle, detectRefreshRate/refreshRate, native demo recorder (rasterizeSurface, writeSurfaceGif, recordDemoGif) and tests.
Runtime / Driver / Double-buffering
packages/bijou-tui/src/runtime.ts, packages/bijou-tui/src/driver.ts, packages/bijou-tui/src/screen.ts
Integrates pipeline into runtime, double-buffered surfaces, runScript now surface-aware (frames are Surfaces), RunOptions extended (generic, css, middlewares, configurePipeline), and pulse steps supported.
Components & Node Options
packages/bijou/src/core/components/*, packages/bijou/src/core/components/types.ts
Introduce BijouNodeOptions (id/class/ctx), many components (badge, box, box-v3, etc.) now return Surface and resolve BCSS; tests updated to use surfaceToString.
Layout & Flex Engine
packages/bijou/src/core/layout/flex.ts, packages/bijou/src/core/layout/flex.test.ts
Adds calculateFlex engine with flex/basis/min/max/gap handling and tests.
Sub-App Composition
packages/bijou-tui/src/subapp/*, examples/v3-subapp/*
Adds mount, initSubApp, updateSubApp, mapCmds for nested TEA sub-app composition, plus tests and example.
Examples, Scripts & CI
examples/v3-*, examples/_shared/*, scripts/*, .github/workflows/*
Many V3 examples, example recorders, smoke test scripts (smoke-v3-examples/smoke-all-examples), tsconfig.tests.json, README updates and CI steps.
Recorder & GIF Typings
packages/bijou-node/src/recorder.ts, packages/bijou-node/src/gifenc.d.ts, packages/bijou-node/src/recorder.test.ts
Implements frame rasterization, GIF encoding pipeline, writeSurfaceGif/recordDemoGif, and type declarations + tests.
Tests & API Surface Adjustments
multiple *.test.ts, packages/bijou-tui/src/*
Extensive test updates for pipeline, canvas, motion, token graph, recorder, worker, BCSS, differ; Cmd arity and event shapes updated (e.g., KeyMsg with type).

Sequence Diagram(s)

sequenceDiagram
    participant Main as Main Thread
    participant Worker as Worker Thread
    participant IO as Terminal I/O
    participant App as Worker-side App

    Main->>Worker: runInWorker(entry, options)
    Main->>IO: subscribe stdin/resize
    IO->>Main: stdin / resize events
    Main->>Worker: send {type:'io:data', data} / {type:'io:resize', columns, rows}
    Worker->>App: proxy input into update loop
    App->>App: update(msg) -> view() => Surface
    Worker->>Main: post {type:'render:frame', output: Surface}
    Main->>IO: renderSurfaceFrame(io, current, target, style)
Loading
sequenceDiagram
    participant Model as App Model
    participant Pipeline as RenderPipeline
    participant Layout as Layout Stage
    participant Motion as Motion Middleware
    participant Paint as Paint Stage
    participant Post as PostProcess
    participant Diff as Diff Stage
    participant IO as Terminal Output

    Model->>Pipeline: execute(RenderState with dt)
    Pipeline->>Layout: compute LayoutNode / layoutRoot
    Pipeline->>Motion: motionMiddleware.reconcile(layoutRoot, dt)
    Pipeline->>Paint: paintMiddleware -> produce targetSurface
    Pipeline->>Post: run PostProcess middlewares (bcss/grayscale/etc.)
    Pipeline->>Diff: renderDiff(currentSurface, targetSurface)
    Diff->>IO: minimal ANSI writes to terminal
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through pixels, tokens bright,
Pulses ticking through the night,
Surfaces stacked, pipelines hum,
Workers render, shaders drum,
Carrots, code, and v3 delight!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/v3-complete-evolution

- Add missing @flyingrobots/bijou-tui dependency to bijou-node
- Add missing tsconfig reference from bijou-node to bijou-tui
- Add explicit types to callback parameters in worker.ts and skeleton index.ts to satisfy strict check
- Fix implicit 'any' types in sub-app and skeleton logic
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2a5338dff3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/bijou-node/src/worker/worker.ts Outdated
Comment thread packages/bijou-node/src/worker/worker.ts Outdated
Comment thread packages/bijou-tui/src/runtime.ts Outdated
Comment thread packages/bijou/src/core/layout/flex.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/bijou-tui/src/runtime.ts (1)

233-255: ⚠️ Potential issue | 🟠 Major

Refresh currentSurface on every resize message, not just startup.

Only the startup resize rebuilds the backing buffer. After a later terminal resize, the next Diff stage still compares the old-sized currentSurface against a new-sized targetSurface, which can produce a bad frame before Output swaps in the clone.

🪟 Suggested fix
   bus.on((msg) => {
     if (!running) return;
@@
     const [newModel, cmds] = app.update(msg, model);
     model = newModel;
+    if (typeof msg === 'object' && msg != null && 'type' in msg && msg.type === 'resize') {
+      const resize = msg as ResizeMsg;
+      currentSurface = createSurface(
+        sanitizeDimension(resize.columns),
+        sanitizeDimension(resize.rows),
+      );
+    }
     render();
     executeCommands(cmds);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/runtime.ts` around lines 233 - 255, The currentSurface
is not updated on subsequent terminal resize messages, causing diffs against an
old-sized buffer; inside the bus.on message handler (before calling
app.update/render/executeCommands) detect resize messages (e.g. using the
existing isResizeMsg or msg.type === 'resize') and refresh/recreate the backing
buffer so currentSurface matches the new terminal dimensions (update the same
variable currentSurface to the new-sized surface) before proceeding to
update/render/executeCommands; make this change in the bus.on callback alongside
the existing isPulseMsg/isKeyMsg handling so subsequent frames diff against the
correct targetSurface.
🟠 Major comments (31)
packages/bijou/src/core/theme/accessors.ts-42-54 (1)

42-54: ⚠️ Potential issue | 🟠 Major

Don't swallow all token-graph failures in these fallbacks.

These catch blocks also hide resolver bugs, cycles, or broken fallback tokens and silently convert them into status.muted / semantic.primary. Please restrict the fallback to the specific “unknown token” path and rethrow everything else.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/theme/accessors.ts` around lines 42 - 54, The status
and ui accessor catch blocks in the accessors (status: (key) => { ... } and ui:
(key) => { ... }) currently swallow all errors from tokenGraph.get; change them
so they only perform the fallback when the thrown error indicates an
unknown/missing-token condition (check error.message or a specific error
property from tokenGraph, e.g. "Unknown token" or a recognisable error code) and
rethrow any other exceptions (resolver bugs, cycles, etc.). Locate the two
tokenGraph.get calls and replace the broad catch with logic that tests the error
identity—return the fallback token only for that specific case, otherwise throw
the error.
packages/bijou-tui/src/motion/motion.ts-30-33 (1)

30-33: ⚠️ Potential issue | 🟠 Major

Clone rect when retagging an existing layout node to prevent rect aliasing.

The spread operator creates a shallow copy, so the returned node initially shares the same rect reference as the input content. While MotionReconciler later replaces node.rect with an interpolated version, the initial aliasing means any code reading node.rect before reconcile() executes—or if the original content is reused elsewhere—will read and potentially mutate a shared rect object. Cloning rect breaks this reference chain and ensures animation state doesn't leak into reusable layout subtrees.

🔧 Proposed fix
   return {
     ...content,
-    id: options.key
+    id: options.key,
+    rect: { ...content.rect }
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/motion/motion.ts` around lines 30 - 33, The returned
object currently shallow-copies content (return { ...content, id: options.key })
which leaves content.rect aliased; update the return logic in motion.ts to
deep-clone or clone the rect property when present so the new node gets its own
rect object (e.g., create a new rect object from content.rect) while keeping id
set to options.key; this prevents shared rect references that can be mutated
before MotionReconciler runs and avoids leaking animation state from the
original content/node.
packages/bijou/src/ports/surface.ts-192-200 (1)

192-200: ⚠️ Potential issue | 🟠 Major

Deep-clone modifiers whenever a cell is copied.

These copies are all shallow, so copied cells still share the same modifiers array reference. That means get() leaks mutable internal state and clone() is not actually a deep clone. A small cloneCell() helper reused across the file would fix the aliasing.

Also applies to: 237-238, 247-248, 316-318, 348-352

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/surface.ts` around lines 192 - 200, The copies of
Cell objects (e.g., in maskCell) perform shallow copies and leak the same
modifiers array reference; create a small helper cloneCell(cell: Cell): Cell
that returns a new Cell with a shallow-copied modifiers array (modifiers:
cell.modifiers ? [...cell.modifiers] : undefined) and use it wherever cells are
copied (maskCell, get(), clone(), and the other locations mentioned) so
modifiers is deep-cloned and no shared mutable state remains.
packages/bijou/src/core/layout/flex.ts-104-113 (1)

104-113: ⚠️ Potential issue | 🟠 Major

Don't drop leftover cells during the final flex pass.

The final pass floors each share and then zeros remaining, so uneven splits leave space unassigned. With width = 100 and three { flex: 1 } children, this produces 33 / 33 / 33 and leaves one column uncovered. Please carry the remainder forward and assign the leftover cells deterministically.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/layout/flex.ts` around lines 104 - 113, In the final
flex pass inside the flex distribution logic (the block guarded by
spaceDistributed false that iterates over children, checks isConstrained and
children[i]!.flex and computes share using activeFlex and remaining), don't
simply Math.floor each share and zero out remaining; instead compute each
floored share, accumulate the totalAssigned, compute leftover = remaining -
totalAssigned, and then deterministically add +1 to the first `leftover`
eligible children (e.g., iterate in index order and increment sizes[i] for
unconstrained children with flex>0) so the final `remaining` is fully assigned
and uneven splits are resolved predictably. Ensure you update sizes and clear
remaining only after this leftover distribution.
packages/bijou/src/ports/surface.ts-263-267 (1)

263-267: ⚠️ Potential issue | 🟠 Major

Clip fill() against the original rectangle, not the clipped start.

When fx or fy is negative, xEnd = xStart + fw / yEnd = yStart + fh overpaints the region. For example, fill(cell, -2, 0, 4, 1) currently writes 4 columns instead of the 2 visible columns. Compute the end from fx + fw / fy + fh before clamping.

Proposed fix
-      const xEnd = Math.min(w, xStart + fw);
-      const yEnd = Math.min(h, yStart + fh);
+      const xEnd = Math.min(w, fx + fw);
+      const yEnd = Math.min(h, fy + fh);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/surface.ts` around lines 263 - 267, The fill()
method clips its end coordinates using the already-clipped start (xStart/yStart)
which overextends when fx or fy are negative; compute xEnd as Math.min(w, fx +
fw) and yEnd as Math.min(h, fy + fh) (i.e. derive ends from the original fx/fy
plus fw/fh before clamping) and keep xStart = Math.max(0, fx) and yStart =
Math.max(0, fy) so the written area is correctly limited to the visible
rectangle in Surface.fill.
packages/bijou-tui/src/animate.test.ts-62-68 (1)

62-68: ⚠️ Potential issue | 🟠 Major

Turn these pulse loops into deterministic failures instead of hangs.

If an animation never settles, these tests still end in await promise, so CI blocks indefinitely rather than failing. Assert completion after the pulse budget, or race the await against a timeout so regressions surface as test failures.

Suggested pattern
- await promise;
+ await Promise.race([
+   promise,
+   new Promise<never>((_, reject) =>
+     setTimeout(() => reject(new Error('animation did not settle')), 50),
+   ),
+ ]);

Also applies to: 93-99, 121-126

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/animate.test.ts` around lines 62 - 68, The pulse loops
in animate.test.ts that call caps.pulse(0.016) and then await promise can hang
CI if animations never settle (variables: settled, promise, caps.pulse,
queueMicrotask); after the for-loop that iterates up to 1000 pulses, assert that
settled is true (or that promise resolved) and fail the test if not, or replace
the final await promise with a raced timeout (e.g., Promise.race with a short
reject/timeout) so the test fails deterministically when the pulse budget is
exhausted; apply the same change to the other occurrences around lines
referencing the same pattern (the loops at 93-99 and 121-126).
packages/bijou-tui/src/animate.ts-145-149 (1)

145-149: ⚠️ Potential issue | 🟠 Major

Keep pulse animations compatible with plain cmd(emit) calls.

createSpringCmd() and createTweenCmd() now dereference caps.onPulse unconditionally. Any existing direct invocation of the returned Cmd without capabilities will now throw or never resolve, which is a behavioral break for the public animation API. Please either provide a timer fallback when caps is missing or make the requirement explicit and update callers before merging.

Also applies to: 193-197

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/animate.ts` around lines 145 - 149, The pulse handlers
in createSpringCmd and createTweenCmd dereference caps.onPulse unconditionally,
breaking callers that invoke the returned Cmd as cmd(emit) without capabilities;
modify both functions to check for caps?.onPulse and if missing use a built-in
timer fallback (e.g., requestAnimationFrame loop or setInterval) that supplies
dt to the same callback and returns a handle with a dispose/stop method,
otherwise continue using caps.onPulse; ensure the fallback clears the timer when
the animation resolves or when handle.dispose/stop is called so the Promise
resolves/cleans up identically to the caps path (refer to the local variable
handle and the caps.onPulse usage).
packages/bijou/src/core/render/differ.ts-41-52 (1)

41-52: ⚠️ Potential issue | 🟠 Major

Preserve terminal-default styling for uncolored cells.

Cell.fg is documented as undefined = terminal default, but both surfaceToString() and renderDiff() coerce that case to #ffffff. That will emit white styling for plain cells and change appearance on terminals whose default foreground is not white. Render truly unstyled cells as plain text, or build the token without forcing a foreground color.

Also applies to: 160-166

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/render/differ.ts` around lines 41 - 52,
surfaceToString currently forces an undefined Cell.fg to '#ffffff', which
overrides terminal default colors; change it so the TokenValue only includes a
hex property when cell.fg is defined (i.e., omit hex for terminal-default), and
apply the same fix in renderDiff (the block around renderDiff and the similar
code at lines ~160-166) so uncolored cells are rendered as plain text/no
explicit foreground instead of forcing white.
packages/bijou-tui/src/app-frame.ts-450-460 (1)

450-460: ⚠️ Potential issue | 🟠 Major

The model-only update path is unreachable under the current FramePage.update contract.

The FramePage.update interface (line 80) declares a return type of [PageModel, Cmd<Msg>[]], so the else branch handling bare PageModel returns (line 456) cannot execute for typed callers. The Array.isArray check is therefore unnecessary. To support non-tuple returns, explicitly broaden the FramePage.update signature to allow PageModel | [PageModel, Cmd<Msg>[]]; otherwise, remove the dead code path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/app-frame.ts` around lines 450 - 460, The updateResult
handling assumes FramePage.update may return either a tuple or a bare PageModel,
but the current FramePage.update signature only declares [PageModel,
Cmd<Msg>[]], so the else branch is unreachable; either widen the
FramePage.update return type to PageModel | [PageModel, Cmd<Msg>[]] (so callers
like targetPage.update can legally return a plain PageModel) or remove the
Array.isArray/else branch and always treat updateResult as the tuple form;
update the FramePage.update declaration (and any implementors) or simplify the
logic around targetPage.update/updateResult and PageModel/Cmd<Msg> accordingly.
packages/bijou/src/factory.ts-80-81 (1)

80-81: ⚠️ Potential issue | 🟠 Major

createBijou() has no way to configure the BCSS resolver.

resolveBCSS is hardcoded to return {} in both factory.ts and adapters/test/index.ts. CreateBijouOptions has no field to accept a resolver or stylesheet. Contexts created here cannot participate in BCSS resolution—any component calling ctx.resolveBCSS() will always receive an empty object.

The bijou-tui package works around this by mutating the context after creation ((ctx as any).resolveBCSS = ...), but this bypasses the type system and leaves the default factory unsuitable for CSS-aware components.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/factory.ts` around lines 80 - 81, createBijou currently
hardcodes resolveBCSS to return an empty object so contexts cannot perform BCSS
resolution; add a new optional field on CreateBijouOptions (e.g., bcssResolver
or stylesheetResolver) to accept a resolver function or stylesheet, use that
value when building the context instead of the hardcoded () => ({}), and fall
back to the existing no-op only if the option is not provided; also update the
test adapter's default to pass a sensible stub via the new option rather than
mutating ctx.resolveBCSS after creation.
packages/bijou/src/factory.ts-71-72 (1)

71-72: ⚠️ Potential issue | 🟠 Major

Remove the unsafe unknown cast and explicitly convert Theme to TokenDefinitions.

createTokenGraph expects a recursive token map ([key: string]: TokenInput | TokenDefinitions), but themeObj is a structured Theme interface with specific properties (status, semantic, gradient, border, ui, surface). The cast hides a real shape mismatch at a public API boundary. Either convert Theme to the expected structure explicitly, update createTokenGraph to accept Theme directly, or add a type guard with clear documentation of the conversion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/factory.ts` around lines 71 - 72, The code unsafely casts
themeObj to TokenDefinitions; remove the "as unknown as TokenDefinitions" and
either (preferred) implement an explicit converter (e.g.,
convertThemeToTokenDefinitions(themeObj)) that maps Theme's properties (status,
semantic, gradient, border, ui, surface) into the recursive TokenDefinitions
shape and call createTokenGraph(convertThemeToTokenDefinitions(themeObj)), or
change createTokenGraph's signature to accept Theme directly (or add an
overloaded signature) and implement a type guard function
isTokenDefinitions/validateThemeShape to ensure the runtime structure matches
TokenDefinitions; ensure you update references to createTokenGraph and document
the conversion/guard, and delete the unsafe cast on themeObj.
docs/specs/token-graph.spec.json-22-27 (1)

22-27: ⚠️ Potential issue | 🟠 Major

Specify the resolver behavior for invalid graphs.

The spec says cycles are detected and missing references are an edge case, but it never defines the observable result: throw, emit diagnostics and keep the previous value, or surface an explicit invalid token. BCSS hot-reload and DevTools will need that contract to behave consistently.

Also applies to: 34-42, 44-47

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/specs/token-graph.spec.json` around lines 22 - 27, Update the spec to
explicitly define the resolver contract for invalid graphs (cycles and missing
references): choose and document one observable behavior (e.g., throw an error,
emit diagnostics and retain previous resolved value, or return an explicit
"invalid token" sentinel) and describe how consumers should detect and handle
it; add this behavior to the "Token Graph resolution engine" section and to the
related items "Support for cycle detection in references" and "Light/Dark mode
bucket resolution" (and the other mentioned blocks around lines 34-42 and
44-47), include the exact runtime signals (exception types or diagnostic payload
shape and preservation semantics) and a short example of expected outcome so
hot-reload/DevTools can implement consistent handling.
docs/specs/animation-heartbeat.spec.json-13-18 (1)

13-18: ⚠️ Potential issue | 🟠 Major

Carry elapsed time in the heartbeat contract.

A frame pulse alone is not enough for springs/tweens once frames are dropped or the runtime FPS changes. Without a monotonic timestamp or deltaMs in PulseMsg, the "slow systems" case becomes frame-rate dependent instead of time-based. Add elapsed-time and step-clamping/catch-up semantics to the spec.

Also applies to: 32-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/specs/animation-heartbeat.spec.json` around lines 13 - 18, The
PulseMsg/heartbeat contract in the spec needs to carry elapsed-time so physics
updates are time-based rather than frame-based: update the EventBus/heartbeat
schema (PulseMsg) to include a monotonic timestamp (e.g., monotonicMs) and/or
deltaMs (elapsedMs) and document step-clamping/catch-up semantics (maxDeltaMs
and catchUpSteps) so spring/tween systems can accumulate and process fixed-time
sub-steps; ensure the acceptanceCriteria mentions elapsed-time in the pulse and
that the pulse frequency remains runtime-configurable (FPS), and update related
entries (lines referenced 32-37) to reflect these fields and semantics.
docs/specs/component-system.spec.json-17-19 (1)

17-19: ⚠️ Potential issue | 🟠 Major

The binder shape here is read-only.

A binder like (model) => value can read state, but it cannot write user edits back into the parent model. That conflicts with the real-time field-to-model updates in the next criterion and the golden-path test. Define a bidirectional binding contract before standardizing this API.

Also applies to: 35-35

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/specs/component-system.spec.json` around lines 17 - 19, The binder shape
"(model) => value" is read-only and cannot propagate user edits back to the
parent model; update the Field-to-Model binding contract so binders are
bidirectional by defining a concrete API (for example a Binder interface or a
function signature that includes a setter) that supports both reading and
writing — e.g., change the binder contract referenced as "Field-to-Model
binding" / "(model) => value" to either accept a second parameter for a setter
(model, set) or return an object with get/set methods (get(model): value,
set(model, newValue): void), and update any uses referenced by the golden-path
test and real-time validation to call the setter to push edits into the parent
model.
examples/v3-worker/main.ts-1-1 (1)

1-1: ⚠️ Potential issue | 🟠 Major

Use the public Bijou Node entrypoint in the example.

Importing ../../packages/bijou-node/src/worker/worker.js reaches into monorepo internals and will not reflect the published API surface. Examples should consume the same @flyingrobots/bijou-node entrypoint that end users get.

Suggested import change
-import { isBijouWorker, runInWorker, startWorkerApp } from '../../packages/bijou-node/src/worker/worker.js';
+import { isBijouWorker, runInWorker, startWorkerApp } from '@flyingrobots/bijou-node';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-worker/main.ts` at line 1, The example imports internals from the
monorepo; replace the deep import with the public package entrypoint so
consumers use the published API: update the import that currently brings in
isBijouWorker, runInWorker, and startWorkerApp from
'../../packages/bijou-node/src/worker/worker.js' to import those named exports
from the public package (e.g., '@flyingrobots/bijou-node') so the example
mirrors the published API surface.
examples/v3-worker/main.ts-50-54 (1)

50-54: ⚠️ Potential issue | 🟠 Major

Use fileURLToPath() instead of .pathname for the worker entry path.

new URL(import.meta.url).pathname produces broken paths on Windows (e.g., /C:/...), and path.resolve() cannot fix the malformed path. Import and use fileURLToPath from node:url:

import { fileURLToPath } from 'node:url';

const entryPath = fileURLToPath(import.meta.url);

runInWorker({
  entry: entryPath,
}).then(() => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-worker/main.ts` around lines 50 - 54, The entry path uses new
URL(import.meta.url).pathname which yields invalid Windows paths; replace that
with fileURLToPath(import.meta.url) (import fileURLToPath from 'node:url') and
pass the resulting entryPath into runInWorker so the worker entry resolves
correctly on all platforms (update the import and the assignment of entryPath
where entryPath and runInWorker are used).
packages/bijou-tui/src/runtime.ts-125-139 (1)

125-139: ⚠️ Potential issue | 🟠 Major

Avoid mutating the caller’s context object permanently.

resolveBCSS is attached directly to ctx and never restored. Any reused context — including a shared default context — can carry the last stylesheet into later runs that did not opt into CSS.

♻️ Suggested cleanup
 export async function run<Model, M>(
   app: App<Model, M>,
   options?: RunOptions<M>,
 ): Promise<void> {
   const ctx = options?.ctx ?? getDefaultContext();
+  const previousResolveBCSS = (ctx as any).resolveBCSS;
   const useAltScreen = options?.altScreen ?? true;
   const useHideCursor = options?.hideCursor ?? true;
   const useMouse = options?.mouse ?? false;
@@
   if (options?.css) {
@@
     (ctx as any).resolveBCSS = (identity: any) => {
       return resolveStyles(identity, sheet, {
         width: ctx.runtime.columns,
         height: ctx.runtime.rows,
       }, ctx.tokenGraph, themeMode);
     };
   }
@@
   bus.stopPulse();
   bus.dispose();
+  if (options?.css) {
+    if (previousResolveBCSS === undefined) {
+      delete (ctx as any).resolveBCSS;
+    } else {
+      (ctx as any).resolveBCSS = previousResolveBCSS;
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/runtime.ts` around lines 125 - 139, The code mutates
the caller's ctx by permanently assigning (ctx as any).resolveBCSS, which can
leak styles into later runs; change this to save and restore any existing
resolveBCSS before you overwrite it (e.g., const prevResolve = (ctx as
any).resolveBCSS), assign the new resolver (using parseBCSS/resolveStyles and
themeMode as shown), then ensure you restore (ctx as any).resolveBCSS =
prevResolve in a finally block after the pipeline/middleware run (or attach the
resolver to a local scoped context object passed to bcssMiddleware instead of
mutating ctx) so the original context is not permanently modified.
packages/bijou-tui/src/pipeline/middleware/css.ts-40-49 (1)

40-49: ⚠️ Potential issue | 🟠 Major

Keep useBCSS() on the same resolution path as runtime BCSS.

Runtime injects a token-graph/theme-aware resolver on the context, but this helper bypasses it and calls resolveStyles() with only the sheet and terminal size. Components using useBCSS() can therefore resolve different styles than components using ctx.resolveBCSS().

🔁 Suggested fix
 export function useBCSS(state: any, identity: { type: string, id?: string, classes?: string[] }) {
+  const resolveBCSS = state.ctx?.resolveBCSS as
+    | ((identity: { type: string; id?: string; classes?: string[] }) => any)
+    | undefined;
+  if (resolveBCSS) return resolveBCSS(identity);
+
   const sheet = state.data['bcss_sheet'] as BCSSSheet | undefined;
   if (!sheet) return {};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/pipeline/middleware/css.ts` around lines 40 - 49,
useBCSS currently calls resolveStyles directly and bypasses the runtime-injected
resolver on ctx; change it to prefer the context resolver: if
state.ctx.resolveBCSS exists, call state.ctx.resolveBCSS(identity, sheet,
terminal) (passing the same identity, sheet and terminal object) and return its
result, otherwise fall back to calling resolveStyles(sheet, terminal) as before;
keep the existing sheet retrieval and terminal shape and only alter the return
path in useBCSS to reference state.ctx.resolveBCSS first.
packages/bijou-tui/src/motion/reconciler.ts-16-24 (1)

16-24: ⚠️ Potential issue | 🟠 Major

Prune stale motion entries after each reconciliation pass.

Removed nodes never leave tracked, so long-lived trees will accumulate stale state and a later reuse of the same id can animate from the wrong rect/velocity.

🧹 Suggested cleanup pattern
 export class MotionReconciler {
   private tracked = new Map<string, TrackedMotion>();
+  private seen = new Set<string>();

   reconcile(node: LayoutNode, dt: number): void {
+    this.seen.clear();
+    this.walk(node, dt);
+    for (const key of this.tracked.keys()) {
+      if (!this.seen.has(key)) this.tracked.delete(key);
+    }
+  }
+
+  private walk(node: LayoutNode, dt: number): void {
     if (node.id) {
+      this.seen.add(node.id);
       this.processNode(node, dt);
     }

     for (const child of node.children) {
-      this.reconcile(child, dt);
+      this.walk(child, dt);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/motion/reconciler.ts` around lines 16 - 24,
Reconciliation never removes entries from this.tracked, causing stale motion
state to accumulate; after walking the tree in reconcile (using processNode for
each node) collect the current node ids into a Set (e.g., currentIds) and then
iterate the keys of this.tracked to delete any key not in currentIds so removed
nodes are pruned; implement this cleanup at the end of reconcile (or in a
top-level reconcileRoot) using the existing node.id values and
this.tracked.delete(id).
packages/bijou-tui/src/runtime.ts-55-72 (1)

55-72: ⚠️ Potential issue | 🟠 Major

Please route the one-shot render through the same setup path.

This branch returns before the initial resize sync, BCSS bridge, and any pipeline-based render setup run, so pipe/static/accessible mode can produce a different first frame than interactive mode for the same app.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/runtime.ts` around lines 55 - 72, The non-interactive
branch short-circuits and writes the first-frame output directly (ctx.mode !==
'interactive', app.view/initModel, createSurface, paintMiddleware,
surfaceToString, ctx.io.write) so it skips the same initial resize sync, BCSS
bridge, and pipeline-based render setup used by interactive runs; change this by
routing the one-shot render through the common initialization/render pipeline
used for interactive mode (i.e., call the same setup routine before painting) —
if such a routine doesn’t exist, factor out the setup steps (resize sync, BCSS
bridge, pipeline middleware setup) into a helper (e.g., runInitialRenderSetup or
similar) and call it from both the interactive flow and this non-interactive
branch, then perform the view() -> paintMiddleware() -> surfaceToString ->
ctx.io.write sequence so the first frame uses the exact same pipeline.
packages/bijou/src/core/theme/graph.ts-88-113 (1)

88-113: ⚠️ Potential issue | 🟠 Major

Cycle detection is reused across sibling lookups, so repeated refs can false-positive.

resolveColor() mutates one visited set and passes it into transform resolution. A token that mixes the same helper twice can trip the circular-reference guard on the second lookup even though the graph is acyclic. Clone the set when descending, or remove refs on unwind.

Suggested fix
     if ('ref' in def) {
       if (visited.has(def.ref)) {
         throw new Error(`Circular token reference detected: ${Array.from(visited).join(' -> ')} -> ${def.ref}`);
       }
-      visited.add(def.ref);
+      const nextVisited = new Set(visited);
+      nextVisited.add(def.ref);
       
       const target = definitions.get(def.ref);
       if (!target) {
         throw new Error(`Token reference not found: ${def.ref}`);
       }
 
       let color: string;
       if (typeof target === 'object' && 'fg' in target) {
-        color = resolveColor(target.fg, mode, visited);
+        color = resolveColor(target.fg, mode, nextVisited);
       } else {
-        color = resolveColor(target as ColorDefinition, mode, visited);
+        color = resolveColor(target as ColorDefinition, mode, nextVisited);
       }
 
       // Apply transforms
       if (def.transform) {
         for (const t of def.transform) {
-          color = applyTransform(color, t, mode, visited);
+          color = applyTransform(color, t, mode, new Set(nextVisited));
         }
       }
@@
       case 'mix': {
-        const otherColor = resolveColor({ ref: transform.with }, mode, visited);
+        const otherColor = resolveColor({ ref: transform.with }, mode, new Set(visited));
         result = mix(dummyToken, { hex: otherColor }, transform.ratio ?? 0.5);
         break;
       }

Also applies to: 119-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/theme/graph.ts` around lines 88 - 113, The
circular-reference guard in resolveColor reuses the same visited Set across
sibling lookups and transform descents, causing false positives; update
resolveColor (and the transform branch that calls applyTransform) to either
clone the visited set when recursing into a referenced token/transform (e.g.,
pass new Set(visited) into recursive resolveColor/applyTransform calls) or
remove def.ref from visited after resolving (unwind) so sibling lookups don't
see stale entries; ensure you apply the same fix for the second occurrence noted
(the transform-resolution path around applyTransform) and keep references to
symbols like resolveColor, applyTransform, visited, and def.ref to locate the
changes.
packages/bijou-tui/src/css/resolver.ts-39-41 (1)

39-41: ⚠️ Potential issue | 🟠 Major

Zero-specificity selectors are dropped.

getMatchSpecificity() uses 0 for a matched universal selector, but resolveStyles() treats 0 as “no match” and filters the rule out. * { ... } will never apply. Use a separate sentinel for “not matched” so specificity 0 can still participate in the cascade.

Suggested fix
-    if (specificity > 0) {
+    if (specificity >= 0) {
       matchedRules.push({ rule, specificity });
     }
@@
-        if (specificity > 0) {
+        if (specificity >= 0) {
           // Media query rules have slightly higher base priority in CSS
           matchedRules.push({ rule, specificity: specificity + 0.1 });
         }
@@
-function getMatchSpecificity(identity: ComponentIdentity, selectors: BCSSSelector[]): number {
-  let maxSpecificity = 0;
+function getMatchSpecificity(identity: ComponentIdentity, selectors: BCSSSelector[]): number {
+  let maxSpecificity = -1;

Also applies to: 49-52, 106-141

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/css/resolver.ts` around lines 39 - 41, The validator
treats a specificity of 0 as “no match”, so universal selectors (specificity 0)
are being dropped: change getMatchSpecificity/resolve logic to use a separate
sentinel for “no match” (e.g., NO_MATCH = -1) instead of reusing 0; update calls
in resolveStyles (the matchedRules push sites and any filtering/ordering logic
that currently checks specificity > 0) to check for specificity !== NO_MATCH (or
specificity > NO_MATCH) so specificity 0 is considered a valid match and still
participates in cascade/merging; adjust any sorting/merge code that assumes
positive-only specificity accordingly (references: getMatchSpecificity,
resolveStyles, the matchedRules pushes and subsequent filtering/ordering logic).
packages/bijou-tui/src/driver.ts-133-135 (1)

133-135: ⚠️ Potential issue | 🟠 Major

String-based views ignore resize steps.

Both capture paths rasterize strings with stringToSurface(viewOutput, 80, 24). After a scripted resize, the app can react to the new terminal size but the captured frame still uses the hardcoded geometry, so snapshots drift from runtime behavior.

Also applies to: 152-154

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/driver.ts` around lines 133 - 135, The string-based
capture path is hardcoding 80x24 when calling stringToSurface, causing snapshots
to ignore runtime resizes; update both occurrences that call
stringToSurface(viewOutput, 80, 24) to use the current terminal dimensions
instead (e.g., the driver/terminal width and height variables or a
getSize()/cols/rows accessor available in this module) so frame rasterization
matches actual runtime size (also fix the duplicate call at the other capture
site noted around lines 152-154); ensure you reference stringToSurface,
viewOutput, and frame when making the change.
packages/bijou-tui/src/pipeline/pipeline.ts-40-40 (1)

40-40: ⚠️ Potential issue | 🟠 Major

Async middleware is part of the API contract, but the pipeline fire-and-forgets it.

RenderMiddleware can return a Promise, yet execute() never awaits it. Async stages can run out of order, and rejected promises bypass this try/catch. Either make execute() async and await the chain, or narrow the middleware type/docs to synchronous functions only.

One possible fix
-export type RenderMiddleware = (state: RenderState, next: () => void) => void | Promise<void>;
+export type RenderMiddleware = (state: RenderState, next: () => Promise<void>) => void | Promise<void>;
@@
-  execute(state: RenderState): void;
+  execute(state: RenderState): Promise<void>;
@@
-  function execute(state: RenderState): void {
+  async function execute(state: RenderState): Promise<void> {
@@
-    const next = () => {
+    const next = async (): Promise<void> => {
       if (index < chain.length) {
         const mw = chain[index++];
         try {
-          void mw!(state, next);
+          await mw!(state, next);
         } catch (err) {
           if (state.ctx.io.writeError) {
             state.ctx.io.writeError(`[Pipeline Error] ${err instanceof Error ? err.stack : err}\n`);
           }
-          next();
+          await next();
         }
       }
     };
 
-    next();
+    await next();
   }

Also applies to: 54-55, 75-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/pipeline/pipeline.ts` at line 40, The pipeline
currently allows RenderMiddleware to return a Promise but execute() does not
await middleware, causing out-of-order execution and uncaught rejections; change
execute() to be async and await the composed middleware chain (and any inner
next() calls) so asynchronous middleware are awaited and thrown errors can be
caught, updating any middleware composition/runner helpers used by execute()
(referencing RenderMiddleware and execute) to return/await Promises and wrap the
awaited chain in the existing try/catch to propagate errors correctly;
alternatively, if you prefer synchronous-only middleware, narrow
RenderMiddleware's signature to remove Promise returns and enforce that
everywhere execute and related helpers expect sync middleware.
packages/bijou-tui/src/subapp/mount.ts-11-20 (1)

11-20: ⚠️ Potential issue | 🟠 Major

mount() currently exposes mapping hooks that never run.

mount() ignores onMsg and mapCmd, and always returns []. That makes mounted sub-app side effects impossible to forward even though the public API advertises mapped commands coming back out of this helper.

Also applies to: 50-58

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/subapp/mount.ts` around lines 11 - 20, mount currently
ignores the MountOptions.onMsg and mapCmd hooks and always returns an empty
command list, so sub-app messages and side-effects are not forwarded; update the
mount function implementation to consume the sub-app's returned tuple (model,
cmds) or update result and map each Cmd<SubMsg> into Cmd<ParentMsg> using mapCmd
when provided (otherwise map using onMsg), and return the mapped commands
alongside the mounted model; refer to the MountOptions interface, the mount
function, and the mapCmd/onMsg symbols to locate and change the code paths that
currently discard commands.
packages/bijou/src/core/theme/graph.ts-148-165 (1)

148-165: ⚠️ Potential issue | 🟠 Major

set() accepts TokenValue, but get() cannot resolve it later.

TokenInput includes TokenValue, but set() stores that object verbatim. get() only handles { fg: ... } token defs or ColorDefinition, so graph.set('x', { hex: '#fff' }) will fail with Invalid color definition.

Suggested fix
    set(path, definition) {
-      definitions.set(path, definition);
+      if (isTokenValue(definition)) {
+        definitions.set(path, {
+          fg: definition.hex,
+          bg: definition.bg,
+          modifiers: definition.modifiers,
+        });
+      } else {
+        definitions.set(path, definition);
+      }
       for (const handler of subscribers) {
         handler(path);
       }
     },

Also applies to: 171-176

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/theme/graph.ts` around lines 148 - 165, set stores
TokenValue objects verbatim but get only handles TokenDefinition ({fg, bg,
modifiers}) or ColorDefinition, so calling graph.set('x', {hex: '#fff'}) leads
resolveColor to reject it; update the logic to normalize TokenValue into a
resolvable form (either convert TokenValue {hex} into a ColorDefinition, or
detect TokenValue in get and return its hex directly) by modifying the
implementations around get and set and the definitions Map handling so that
get(path) recognizes TokenValue (hex present) and returns {hex: value.hex} or
ensures set(path, value) transforms TokenValue into a
ColorDefinition/TokenDefinition before storing; reference functions/idents: get,
set, resolveColor, definitions, TokenValue, TokenDefinition, ColorDefinition
(also apply the same normalization change to the similar code block around lines
171-176).
packages/bijou-tui/src/css/resolver.ts-62-70 (1)

62-70: ⚠️ Potential issue | 🟠 Major

Internal !important bookkeeping leaks into the returned styles.

resolveStyles() returns finalStyles, but lines 68-70 write __important_* sentinel keys into that same object. Any downstream style merge/apply that iterates properties will see fake declarations.

Suggested fix
-  const finalStyles: ResolvedStyles = {};
+  const finalStyles: ResolvedStyles = {};
+  const important = new Set<string>();
   for (const { rule } of matchedRules) {
     for (const decl of rule.declarations) {
       // !important handling
-      if (decl.important || !isImportant(finalStyles, decl.property)) {
+      if (decl.important || !isImportant(important, decl.property)) {
         finalStyles[decl.property] = resolveValue(decl.value, graph, mode);
-        if (decl.important) {
-          finalStyles[`__important_${decl.property}`] = 'true';
-        }
+        if (decl.important) important.add(decl.property);
       }
     }
   }
@@
-function isImportant(styles: ResolvedStyles, property: string): boolean {
-  return styles[`__important_${property}`] === 'true';
+function isImportant(important: Set<string>, property: string): boolean {
+  return important.has(property);
 }

Also applies to: 98-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/css/resolver.ts` around lines 62 - 70, resolveStyles()
is leaking internal !important sentinels into the returned finalStyles object by
writing keys like __important_<property> alongside real declarations; instead
track importance in a separate internal map (e.g., importantMap keyed by
property) or keep the sentinel data in a local scope and never add __important_*
keys to finalStyles, apply that map when checking isImportant/merging, and
remove the writes at the locations that set
finalStyles[`__important_${decl.property}`] (also fix the same pattern around
the other occurrence referenced in the review).
packages/bijou-tui/src/eventbus.ts-333-338 (1)

333-338: ⚠️ Potential issue | 🟠 Major

A throwing onPulse callback can take down the heartbeat loop.

Pulse listeners are invoked without a guard, so one exception escapes the timer callback instead of being reported like middleware and command failures. Wrap each handler in try/catch and route failures through safeReport().

Suggested fix
-        for (const handler of pulseHandlers) {
-          handler(dt);
-        }
+        for (const handler of pulseHandlers) {
+          try {
+            handler(dt);
+          } catch (err) {
+            safeReport('[EventBus] Pulse handler threw:', err);
+          }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/eventbus.ts` around lines 333 - 338, The pulse loop
currently calls each pulse handler directly (pulseHandlers iteration in the
heartbeat code), so an exception from an onPulse callback can escape the timer;
wrap each handler(dt) call in try/catch and forward any thrown error to
safeReport (e.g., safeReport(err, { source: 'onPulse', handler })) before
continuing to the next handler, ensuring the heartbeat continues and failures
are reported the same way as middleware/command errors; keep the subsequent
emit({ type: 'pulse', dt }) unchanged.
packages/bijou-tui/src/driver.ts-116-118 (1)

116-118: ⚠️ Potential issue | 🟠 Major

The scripted driver becomes wall-clock dependent once the real pulse starts.

runScript() already has explicit pulse steps, but starting bus.startPulse() here means any delay can inject extra pulse events based on scheduler timing. That makes frame counts and final model state nondeterministic in tests and CI.

Also applies to: 184-185

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/driver.ts` around lines 116 - 118, The driver is
starting the real heartbeat with bus.startPulse(), which makes scripted runs
using runScript() nondeterministic; stop calling bus.startPulse() during
scripted execution—remove or gate the calls at the sites referencing
bus.startPulse() (the occurrences around the runScript usage at lines shown) and
instead start the real pulse only when running in live/non-scripted mode (e.g.,
add a boolean flag or check like isScriptedRun or onlyStartPulseWhenLive and
call bus.startPulse() when that flag is false, or move startPulse() to after
runScript() completes); update the places where bus.startPulse() is invoked (the
two occurrences you noted) to use this conditional so tests remain
deterministic.
packages/bijou-node/src/worker/worker.ts-63-66 (1)

63-66: ⚠️ Potential issue | 🟠 Major

Keep altScreen and hideCursor independent.

With altScreen: false and hideCursor: true, these branches still enter and exit the alternate buffer.

🩹 Split the terminal modes
-  if (useAltScreen || useHideCursor) {
-    ctx.io.write('\x1b[?1049h'); // ENTER_ALT_SCREEN
-    if (useHideCursor) ctx.io.write('\x1b[?25l'); // HIDE_CURSOR
-  }
+  if (useAltScreen) ctx.io.write('\x1b[?1049h'); // ENTER_ALT_SCREEN
+  if (useHideCursor) ctx.io.write('\x1b[?25l'); // HIDE_CURSOR
...
-      if (useAltScreen || useHideCursor) {
-        ctx.io.write('\x1b[?1049l'); // EXIT_ALT_SCREEN
-        if (useHideCursor) ctx.io.write('\x1b[?25h'); // SHOW_CURSOR
-      }
+      if (useHideCursor) ctx.io.write('\x1b[?25h'); // SHOW_CURSOR
+      if (useAltScreen) ctx.io.write('\x1b[?1049l'); // EXIT_ALT_SCREEN

Also applies to: 117-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-node/src/worker/worker.ts` around lines 63 - 66, The code
currently gates both alternate-screen and cursor-hide sequences behind a single
condition (useAltScreen || useHideCursor) causing ENTER/EXIT_ALT_SCREEN to be
sent even when only hideCursor is true; update the startup and teardown branches
in worker.ts so each mode is handled independently: call
ctx.io.write('\x1b[?1049h') only when useAltScreen is true and call
ctx.io.write('\x1b[?25l') only when useHideCursor is true (and mirror this on
exit by sending the corresponding exit sequences only when each flag is true).
Locate the blocks referencing useAltScreen, useHideCursor and ctx.io.write and
split the combined condition into two separate guarded writes for enter and two
separate guarded writes for exit.
packages/bijou-node/src/worker/worker.ts-88-97 (1)

88-97: ⚠️ Potential issue | 🟠 Major

Do not force-terminate a graceful quit path.

The worker sends msg.type === 'quit' on successful completion. Calling worker.terminate() forces an exit code of 1, which is then rejected by line 106 (if (code !== 0) reject(...)), converting the success path into a failure. Remove the terminate() call and let the thread exit naturally with code 0, or mark the quit path as expected before evaluating the exit code at line 106.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-node/src/worker/worker.ts` around lines 88 - 97, The quit
message handler currently forces a non-graceful exit by calling
worker.terminate() when msg.type === 'quit'; remove that terminate() call in the
worker.on('message', ...) handler so the worker can exit naturally with code 0
(or alternatively set a flag to treat a quit as expected before the exit-code
check). Update the branch that checks msg.type === 'quit' (in the
worker.on('message') callback) to either do nothing (allow natural exit) or set
an explicit expected-quit marker that the exit-code evaluation (the rejection
logic that checks code !== 0) will consider a clean shutdown.
🟡 Minor comments (7)
packages/bijou-tui/src/runtime.test.ts-76-77 (1)

76-77: ⚠️ Potential issue | 🟡 Minor

This assertion is too weak to catch frame regressions.

A plain substring match still passes if the initial interactive write is missing the home/clear sequences. Keep the render contract in the assertion by matching frame('count: 0') instead of only 'count: 0'.

🔧 Proposed fix
-      const hasInitialRender = ctx.io.written.some((w) => w.includes('count: 0'));
+      const hasInitialRender = ctx.io.written.some((w) => w.includes(frame('count: 0')));
       expect(hasInitialRender).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/runtime.test.ts` around lines 76 - 77, Replace the
weak substring assertion that checks for 'count: 0' with the framed render
assertion so the test validates the full render contract; specifically update
the logic around hasInitialRender (or the expect) to search for frame('count:
0') (e.g., ctx.io.written.some(w => w.includes(frame('count: 0'))) or
expect(ctx.io.written).toContain(frame('count: 0'))), ensuring you reference the
frame helper instead of the plain string.
examples/v3-motion/main.ts-45-45 (1)

45-45: ⚠️ Potential issue | 🟡 Minor

Guard the root size for non-TTY runs.

process.stdout.columns and process.stdout.rows are undefined when stdout is not attached to a TTY (redirected or piped). This causes an invalid root rect in CI and non-interactive environments. Add the same ?? 80/24 fallback used by the runtime adapter.

Proposed fix
-      rect: { x: 0, y: 0, width: process.stdout.columns, height: process.stdout.rows },
+      rect: {
+        x: 0,
+        y: 0,
+        width: process.stdout.columns ?? 80,
+        height: process.stdout.rows ?? 24
+      },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-motion/main.ts` at line 45, The root rect uses
process.stdout.columns/process.stdout.rows which are undefined when stdout is
not a TTY; update the rect initializer (the object assigned to rect in main.ts)
to guard these values with fallbacks like (process.stdout.columns ?? 80) for
width and (process.stdout.rows ?? 24) for height so the root size is valid in
CI/non-interactive runs.
packages/bijou-node/src/runtime.ts-12-15 (1)

12-15: ⚠️ Potential issue | 🟡 Minor

Use Number() instead of parseInt() for BIJOU_FPS parsing.

parseInt() truncates decimal values (e.g., 59.94 becomes 59) and accepts junk suffixes (e.g., 120hz parses as 120). Since refresh rates like 59.94 Hz and 119.88 Hz are common, this causes silent failures. Use Number() and Number.isFinite() instead.

🔧 Proposed fix
 export function detectRefreshRate(): number {
   const envFps = process.env['BIJOU_FPS'];
   if (envFps) {
-    const parsed = parseInt(envFps, 10);
-    if (!isNaN(parsed) && parsed > 0) return parsed;
+    const parsed = Number(envFps);
+    if (Number.isFinite(parsed) && parsed > 0) return parsed;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-node/src/runtime.ts` around lines 12 - 15, The code uses
parseInt to parse BIJOU_FPS (envFps -> parsed) which truncates decimals and
accepts suffixes; replace parseInt(envFps, 10) with Number(envFps) (or
Number(envFps.trim())) and validate with Number.isFinite(parsed) && parsed > 0
instead of isNaN, keeping the same early-return behavior in the function that
reads BIJOU_FPS.
packages/bijou-tui/src/subapp/mount.test.ts-49-55 (1)

49-55: ⚠️ Potential issue | 🟡 Minor

Assert that the mapper is skipped for QUIT.

With (m) => m, this still passes even if mapCmds() incorrectly invokes the mapper on QUIT. Use a spy and assert it was not called so the passthrough guarantee is actually covered.

Suggested test tightening
-    const mapped = mapCmds([cmd], (m) => m);
+    const mapper = vi.fn((m) => m);
+    const mapped = mapCmds([cmd], mapper);

     const result = await mapped[0]!(vi.fn(), { onPulse: vi.fn() });
     expect(result).toBe(QUIT);
+    expect(mapper).not.toHaveBeenCalled();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/subapp/mount.test.ts` around lines 49 - 55, The test
currently uses a no-op mapper (m => m) so it won't detect if mapCmds wrongly
invokes the mapper for QUIT; update the test to use a spy mapper (e.g., vi.fn())
passed to mapCmds and assert that the spy was not called, while still verifying
the returned result is QUIT from the Cmd invocation (referencing mapCmds, the
Cmd async function that returns QUIT, and the QUIT/ QuitSignal expectation).
packages/bijou-tui/src/driver.test.ts-44-45 (1)

44-45: ⚠️ Potential issue | 🟡 Minor

Strengthen these frame assertions after switching to surfaceToString().

toContain() and index-only checks can still pass when stale content remains on the surface or the callback receives the wrong frame. For these one-line views, normalize the rendered string and assert the full expected payload instead.

Suggested assertion pattern
-    expect(surfaceToString(result.frames[1]!, style)).toContain('Count: 1');
+    expect(surfaceToString(result.frames[1]!, style).trim()).toBe('Count: 1');
...
     expect(captured[0]!.index).toBe(0);
     expect(captured[1]!.index).toBe(1);
+    expect(captured[0]!.frame.trim()).toBe('Count: 0');
+    expect(captured[1]!.frame.trim()).toBe('Count: 1');

Based on learnings, the test adapter's plainStyle() is a deliberate no-op, so exact text assertions here remain deterministic.

Also applies to: 57-59, 73-80, 144-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/driver.test.ts` around lines 44 - 45, The test
currently uses toContain() against surfaceToString(result.frames[0]!, style)
which can hide stale or wrong-frame content; instead normalize the rendered
string and assert the full expected payload exactly (e.g., the one-line view
text plus newline) for surfaceToString(...) of result.frames[0]!, using the test
adapter's plainStyle() behavior for determinism; update the assertions at the
shown lines (and the other occurrences mentioned: 57-59, 73-80, 144-145) to
compare the normalized surfaceToString(...) output to the exact expected string
and keep the model assertions (result.model.count) as-is.
docs/specs/snapshot-harness.spec.json-12-12 (1)

12-12: ⚠️ Potential issue | 🟡 Minor

Clarify "DIND" acronym.

The term "DIND" in "with absolute certainty (DIND)" is unclear. If this refers to a specific testing methodology or pattern, consider spelling it out for readers unfamiliar with the term.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/specs/snapshot-harness.spec.json` at line 12, The "soThat" string
contains the unexplained acronym "DIND"; update the "soThat" value to either
spell out the acronym or remove it so readers understand the intent—for example
replace "I can catch visual regressions and breaking logic changes with absolute
certainty (DIND)." with a clarified phrase like "I can catch visual regressions
and breaking logic changes with absolute certainty (Do It, No Doubt)" or simply
omit the parentheses; locate and edit the "soThat" JSON property in the spec to
make this change.
packages/bijou-tui/src/css/parser.ts-101-104 (1)

101-104: ⚠️ Potential issue | 🟡 Minor

Handle whitespace variants in !important parsing.

Per the CSS specification, whitespace is allowed between ! and important (e.g., color: blue ! important;). The current endsWith('!important') check misses these cases.

Suggested fix
-  const important = value.endsWith('!important');
-  if (important) {
-    value = value.replace('!important', '').trim();
-  }
+  const importantMatch = /!\s*important\s*$/i.exec(value);
+  const important = importantMatch !== null;
+  if (important) {
+    value = value.slice(0, importantMatch.index).trim();
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/css/parser.ts` around lines 101 - 104, The parser's
current detection of !important using value.endsWith('!important') misses valid
CSS like "! important" or other whitespace variants; update the logic around the
important/value handling (the const important and subsequent value reassignment)
to detect case-insensitive "!important" with optional whitespace between "!" and
"important" (e.g., using a regex that matches /\!\s*important$/i), strip that
matched token from value and trim the result, and preserve behavior of the
important boolean and the cleaned value variables.

Comment thread packages/bijou-node/src/worker/worker.ts
Comment thread packages/bijou-node/src/worker/worker.ts Outdated
Comment thread packages/bijou/src/ports/surface.ts
- Implement industrial-grade ANSI-to-Surface parser in core (parseAnsiToSurface)
- Upgrade App Frame to support Surface-native rendering for lenses
- Implement boxV3 component for color-pure Surface wrapping
- Enhance background worker IPC with sendToMain() and bidirectional messaging
- Move Help Line to footer and implement smart truncation with '?' indicator
- Force 24-bit Truecolor in Node adapter by default
- Upgrade canvas shader engine with smart style accumulation for high-res modes
- Make EventBus async-safe for robust command execution
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/bijou-tui/src/eventbus.ts (1)

276-282: ⚠️ Potential issue | 🟡 Minor

Formatting issue: missing newline.

Line 278 has the if statement and for loop concatenated on the same line, which appears to be an accidental merge artifact.

🔧 Proposed formatting fix
       Promise.resolve(cmd(emit, caps)).then((result) => {
         if (disposed) return;
-        if (result === QUIT) {          for (const handler of quitHandlers) {
+        if (result === QUIT) {
+          for (const handler of quitHandlers) {
             handler();
           }
           return;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/eventbus.ts` around lines 276 - 282, The code around
Promise.resolve(cmd(emit, caps)).then(...) has a formatting/merge artifact where
the `if (result === QUIT)` and the following `for (const handler of
quitHandlers) { handler(); }` are concatenated on the same line; fix by
splitting into proper lines and indentation so the `if (result === QUIT) {`
opens a new block and the `for (const handler of quitHandlers) { handler(); }`
runs inside it, preserving the early return and keeping checks for `disposed`,
`cmd`, `emit`, `caps`, and the QUIT handling logic intact.
♻️ Duplicate comments (1)
packages/bijou-node/src/worker/worker.ts (1)

99-103: ⚠️ Potential issue | 🔴 Critical

workerData includes non-cloneable fields from RunOptions.

The spread { ...options, onMessage: undefined } copies the entire options object including ctx (BijouContext with function methods) and middlewares (function array) if provided by the caller. These cannot be structured-cloned and will cause new Worker() to throw synchronously—before any error handlers can catch it—potentially leaving the TTY in alt-screen mode.

Filter to only serializable fields:

🐛 Proposed fix: pass only serializable options
  const worker = new Worker(resolvePath(options.entry), {
-   workerData: { isBijouWorker: true, options: { ...options, onMessage: undefined } },
+   workerData: {
+     isBijouWorker: true,
+     options: {
+       altScreen: options.altScreen,
+       hideCursor: options.hideCursor,
+       mouse: options.mouse,
+       css: options.css,
+       entry: options.entry,
+     },
+   },
    execArgv: options.execArgv,
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-node/src/worker/worker.ts` around lines 99 - 103, The Worker
constructor is being passed non-cloneable fields via workerData ({ ...options,
onMessage: undefined }) which will throw when options contains functions like
ctx methods or middlewares; update the worker creation in
Worker(resolvePath(options.entry), ...) to build a sanitized serializableOptions
object that explicitly picks only serializable RunOptions fields (e.g., entry,
execArgv, env, any primitive flags) and excludes ctx, middlewares, and any
functions before setting workerData: { isBijouWorker: true, options:
serializableOptions }; ensure references to options.execArgv and
resolvePath(options.entry) remain unchanged and remove onMessage from the
payload rather than spreading the whole options object.
🧹 Nitpick comments (3)
packages/bijou/src/ports/io.ts (1)

96-101: Consider using unknown instead of any for the payload type.

The payload: any type bypasses TypeScript's type checking at the call site. Using unknown would require consumers to explicitly narrow or assert the type before use, which is safer for IPC data that could have arbitrary structure.

🔧 Proposed type-safe alternative
   /**
    * Register a callback invoked when arbitrary data is received from the host.
    * Useful for IPC in background workers.
    * `@returns` A handle whose `dispose()` removes the listener.
    */
-  onData?(callback: (payload: any) => void): RawInputHandle;
+  onData?(callback: (payload: unknown) => void): RawInputHandle;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/io.ts` around lines 96 - 101, Update the onData
callback signature to use unknown instead of any: change onData?(callback:
(payload: unknown) => void): RawInputHandle; and then update any call sites of
onData (and implementations of RawInputHandle consumers) to explicitly narrow or
assert the payload before use (e.g., type-guards or instanceof checks) so
TypeScript enforces safety when handling IPC payloads; keep the RawInputHandle
return type unchanged.
packages/bijou-node/src/worker/worker.ts (1)

95-97: Asymmetric mouse mode sequences.

Enable writes modes 1000, 1002, 1006 but disable includes 1003 which was never enabled. This works (terminals ignore disabling unset modes) but is asymmetric.

♻️ Suggested fix for symmetry
      if (useMouse) {
-       ctx.io.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l'); // DISABLE_MOUSE
+       ctx.io.write('\x1b[?1000l\x1b[?1002l\x1b[?1006l'); // DISABLE_MOUSE
      }

Also applies to: 144-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-node/src/worker/worker.ts` around lines 95 - 97, The disable
sequence is asymmetric: when useMouse is true the code writes enable sequences
'\x1b[?1000h\x1b[?1002h\x1b[?1006h' but the corresponding disable uses
'\x1b[?1003l' (1003 was never enabled). Update the disable sequence(s) to mirror
the enabled modes (use '\x1b[?1000l\x1b[?1002l\x1b[?1006l') wherever
ctx.io.write is used to turn mouse modes off (the paired blocks around useMouse
in worker.ts, including the other occurrence around lines 144-146), ensuring
symmetry between enable and disable calls.
packages/bijou-tui/src/canvas.ts (1)

48-50: Unused ctx parameter in CanvasOptions.

The ctx property is declared in CanvasOptions but is never used in the canvas() function. If mode detection is no longer needed, consider removing it to avoid confusion.

♻️ Proposed fix to remove unused property
 export interface CanvasOptions {
   /** Animation time value passed to the shader. Default: 0. */
   time?: number;
   /** Plotting resolution. Default: 'cell'. */
   resolution?: CanvasResolution;
   /** Custom data passed to the shader. */
   uniforms?: Record<string, any>;
-  /** Bijou context for mode detection. */
-  ctx?: BijouContext;
 }

And update the import:

-import { createSurface, type Surface, type Cell, type BijouContext } from '@flyingrobots/bijou';
+import { createSurface, type Surface, type Cell } from '@flyingrobots/bijou';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/canvas.ts` around lines 48 - 50, The CanvasOptions
interface declares an unused ctx?: BijouContext property that is never
referenced in the canvas() function; remove this dead API surface by deleting
the ctx field from CanvasOptions and any related type imports/usages, and update
any files importing BijouContext (or the CanvasOptions type) to stop referencing
ctx so the code and types reflect that mode detection is no longer used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bijou-node/package.json`:
- Around line 27-28: Update the package manifest so internal packages are locked
to the v3 release line: change the dependency "@flyingrobots/bijou-tui" from
"2.1.0" to the v3 version (e.g., "3.0.0") and ensure the package's own "version"
field (for `@flyingrobots/bijou-node` and any listed `@flyingrobots/bijou`) is also
set to the matching v3 value; confirm all internal dependency entries
referencing `@flyingrobots/bijou`, `@flyingrobots/bijou-node`, and
`@flyingrobots/bijou-tui` use the same v3 exact version to keep lock-step versions
across the monorepo.

In `@packages/bijou-tui/README.md`:
- Around line 24-50: The Quick Start example is not self-contained because
childAppA/childAppB are undefined and init() returns {} while view() accesses
model.left/model.right; update the snippet so it compiles by either (A) inlining
minimal Sub-App definitions named childAppA and childAppB (each providing
init/update/view) and changing the parent init() to return a model with left and
right keys, or (B) simplifying the example to a single mounted child so view()
only reads the existing model shape; reference the functions init, view, mount,
childAppA, childAppB and ensure the parent init() returns the model shape that
view() expects (e.g., { left, right }) before calling mount.
- Around line 18-22: Update the Installation section so the npm install command
installs all three internal packages with exact lock-step versions (match the
monorepo convention) instead of only `@flyingrobots/bijou-tui`; replace the
single-package install line with a single npm install that includes
`@flyingrobots/bijou`, `@flyingrobots/bijou-node`, and `@flyingrobots/bijou-tui`
pinned to the same version (e.g., 3.0.0) to keep package APIs in sync and ensure
the subsequent example imports work.

In `@packages/bijou-tui/src/canvas.ts`:
- Around line 24-36: The example shader still uses the old positional signature
(x,y,cols,rows,time) so when the runtime calls it with a single ShaderParams
object it yields NaN; update the shader implementation to the new ShaderFn
signature by accepting a single params: ShaderParams argument and replace uses
of x/y with params.u and params.v (normalized 0–1 coords), access params.cols,
params.rows and params.time for dimensions/time, and map any uses of absolute
pixel math to normalized math (or compute pixel coords from params.u/params.v if
needed); alternatively, if you need backward compatibility implement a small
wrapper that accepts (params: ShaderParams) and calls the old positional
function with reconstructed x,y,cols,rows,time before returning the Cell/string.
- Around line 61-85: canvas() now returns a Surface object (not a string), so
update the example view to return that Surface directly instead of interpolating
it into a template string; locate the example's view function that calls
canvas(...) and remove the `${art}` interpolation (or any string concatenation)
so it returns the Surface value produced by canvas(...) directly, ensuring the
view's return type matches the Surface type expected by the runtime and no
manual toString conversion is used.

In `@packages/bijou-tui/src/eventbus.ts`:
- Around line 197-213: The middleware chain can double-dispatch if a middleware
calls dispatch(next) and then throws; fix by tracking whether the middleware
already invoked the next dispatch: inside dispatch (the function that iterates
middlewares and uses index and mw), create a local boolean (e.g., nextCalled)
and pass a wrapper dispatch to mw that sets nextCalled = true before delegating
to the real dispatch; in the catch block only call dispatch(currentMsg) when
nextCalled is false so subscribers/middlewares are not invoked twice; keep
references to dispatch, middlewares, mw, index and subscribers when implementing
this guard.

In `@packages/bijou/src/core/components/box-v3.ts`:
- Around line 28-30: The inner forEach callback currently uses an expression
body which implicitly returns a value; change the gs.forEach callback from
(char, x) => contentSurf.set(x, y, { char }) to a block-bodied arrow (char, x)
=> { contentSurf.set(x, y, { char }); } so the callback does not return a value;
update the code around lines.forEach, segmentGraphemes, gs.forEach and
contentSurf.set accordingly.

In `@packages/bijou/src/core/components/box.ts`:
- Around line 11-12: The BoxOptions.title field is declared but unused; update
the box rendering to honor it by having box() and drawBox() read options.title
and render it centered (or left-aligned per existing style) in the top border
(e.g., "┌─ Title ─┐"). Modify drawBox() to compute the title's visual length,
trim/pad to fit inside the computed inner width, and insert the title between
the top-left and top-right corner characters while preserving border characters
and spacing; ensure width calculation (used by box() and drawBox()) accounts for
title length so content and borders don't overflow, and remove the title
property if you prefer not to support it. Reference: BoxOptions, box(),
drawBox().

In `@packages/bijou/src/core/render/differ.ts`:
- Around line 117-122: The code in the Token construction (the object assigned
to token used by style.styled in this file) is coercing an undefined foreground
into '#ffffff'; instead preserve undefined so "inherit terminal foreground"
stays undefined (do not hard-code white). Update the token creation used around
the style.styled(token, cell.char) call to set hex to cell.fg (or cell.fg ??
undefined) rather than cell.fg ?? '#ffffff', and make the identical change in
the other occurrence noted (the block around lines 230-236) so foreground-only/
bg-only/modifier-only cells keep their original undefined fg and round-trip
correctly with parseAnsiToSurface().
- Around line 64-85: parseAnsiToSurface currently never updates currentMods so
SGR text modifiers are lost; update the SGR parsing to iterate the numeric codes
(split the incoming code string by ';' or iterate parts when handling 38/48) and
add/remove modifier tokens in currentMods for standard SGR values (e.g. add
'bold' for 1, 'dim' for 2, 'italic' for 3, 'underline' for 4, 'inverse' for 7,
'strike' for 9; remove them on their explicit resets like 22/23/24/27/29 and
clear all on 0). Implement this in parseAnsiToSurface where currentFg/currentBg
are handled (the blocks using code.startsWith and the initial code === '0'
branch) so each cell written continues to receive the correct currentMods state.
Ensure modifier tokens are consistently named and that adding is idempotent
(avoid duplicates) and removals handle missing entries gracefully.

---

Outside diff comments:
In `@packages/bijou-tui/src/eventbus.ts`:
- Around line 276-282: The code around Promise.resolve(cmd(emit,
caps)).then(...) has a formatting/merge artifact where the `if (result ===
QUIT)` and the following `for (const handler of quitHandlers) { handler(); }`
are concatenated on the same line; fix by splitting into proper lines and
indentation so the `if (result === QUIT) {` opens a new block and the `for
(const handler of quitHandlers) { handler(); }` runs inside it, preserving the
early return and keeping checks for `disposed`, `cmd`, `emit`, `caps`, and the
QUIT handling logic intact.

---

Duplicate comments:
In `@packages/bijou-node/src/worker/worker.ts`:
- Around line 99-103: The Worker constructor is being passed non-cloneable
fields via workerData ({ ...options, onMessage: undefined }) which will throw
when options contains functions like ctx methods or middlewares; update the
worker creation in Worker(resolvePath(options.entry), ...) to build a sanitized
serializableOptions object that explicitly picks only serializable RunOptions
fields (e.g., entry, execArgv, env, any primitive flags) and excludes ctx,
middlewares, and any functions before setting workerData: { isBijouWorker: true,
options: serializableOptions }; ensure references to options.execArgv and
resolvePath(options.entry) remain unchanged and remove onMessage from the
payload rather than spreading the whole options object.

---

Nitpick comments:
In `@packages/bijou-node/src/worker/worker.ts`:
- Around line 95-97: The disable sequence is asymmetric: when useMouse is true
the code writes enable sequences '\x1b[?1000h\x1b[?1002h\x1b[?1006h' but the
corresponding disable uses '\x1b[?1003l' (1003 was never enabled). Update the
disable sequence(s) to mirror the enabled modes (use
'\x1b[?1000l\x1b[?1002l\x1b[?1006l') wherever ctx.io.write is used to turn mouse
modes off (the paired blocks around useMouse in worker.ts, including the other
occurrence around lines 144-146), ensuring symmetry between enable and disable
calls.

In `@packages/bijou-tui/src/canvas.ts`:
- Around line 48-50: The CanvasOptions interface declares an unused ctx?:
BijouContext property that is never referenced in the canvas() function; remove
this dead API surface by deleting the ctx field from CanvasOptions and any
related type imports/usages, and update any files importing BijouContext (or the
CanvasOptions type) to stop referencing ctx so the code and types reflect that
mode detection is no longer used.

In `@packages/bijou/src/ports/io.ts`:
- Around line 96-101: Update the onData callback signature to use unknown
instead of any: change onData?(callback: (payload: unknown) => void):
RawInputHandle; and then update any call sites of onData (and implementations of
RawInputHandle consumers) to explicitly narrow or assert the payload before use
(e.g., type-guards or instanceof checks) so TypeScript enforces safety when
handling IPC payloads; keep the RawInputHandle return type unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 149da3dd-72e2-4f2a-afc3-ac1d1dbd1d63

📥 Commits

Reviewing files that changed from the base of the PR and between 2a5338d and 0be4374.

📒 Files selected for processing (16)
  • packages/bijou-node/README.md
  • packages/bijou-node/package.json
  • packages/bijou-node/src/index.ts
  • packages/bijou-node/src/worker/worker.ts
  • packages/bijou-node/tsconfig.json
  • packages/bijou-tui-app/src/index.test.ts
  • packages/bijou-tui-app/src/index.ts
  • packages/bijou-tui/README.md
  • packages/bijou-tui/src/canvas.ts
  • packages/bijou-tui/src/eventbus.ts
  • packages/bijou/README.md
  • packages/bijou/src/core/components/box-v3.ts
  • packages/bijou/src/core/components/box.ts
  • packages/bijou/src/core/render/differ.ts
  • packages/bijou/src/index.ts
  • packages/bijou/src/ports/io.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/bijou/README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/bijou-tui-app/src/index.test.ts

Comment thread packages/bijou-node/package.json Outdated
Comment thread packages/bijou-tui/README.md
Comment thread packages/bijou-tui/README.md
Comment thread packages/bijou-tui/src/canvas.ts
Comment thread packages/bijou-tui/src/canvas.ts
Comment thread packages/bijou-tui/src/eventbus.ts
Comment thread packages/bijou/src/core/components/box-v3.ts Outdated
Comment thread packages/bijou/src/core/components/box.ts
Comment thread packages/bijou/src/core/render/differ.ts Outdated
Comment thread packages/bijou/src/core/render/differ.ts Outdated
@flyingrobots flyingrobots changed the title feat: Bijou v3.0.0 — Complete Engine & Framework Evolution feat: finalize Bijou v3.0.0 for release Mar 12, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/bijou-tui/src/commands.test.ts (1)

15-39: ⚠️ Potential issue | 🟡 Minor

Always restore fake timers in a finally.

If an assertion or awaited promise fails before the last line, vi.useRealTimers() never runs and later tests can inherit fake timers.

🛠️ Suggested cleanup guard
   describe('tick', () => {
     it('resolves with the message after the delay', async () => {
       vi.useFakeTimers();
-      const msg = { type: 'tick' as const };
-      const cmd = tick(100, msg);
-      const promise = cmd(() => {}, { onPulse: () => ({ dispose() {} }) });
-      vi.advanceTimersByTime(100);
-      const result = await promise;
-      expect(result).toBe(msg);
-      vi.useRealTimers();
+      try {
+        const msg = { type: 'tick' as const };
+        const cmd = tick(100, msg);
+        const promise = cmd(() => {}, { onPulse: () => ({ dispose() {} }) });
+        vi.advanceTimersByTime(100);
+        const result = await promise;
+        expect(result).toBe(msg);
+      } finally {
+        vi.useRealTimers();
+      }
     });
 
     it('does not resolve before the delay', async () => {
       vi.useFakeTimers();
-      const cmd = tick(500, 'done');
-      let resolved = false;
-      void cmd(() => {}, { onPulse: () => ({ dispose() {} }) }).then(() => {
-        resolved = true;
-      });
-      vi.advanceTimersByTime(499);
-      await vi.advanceTimersByTimeAsync(0);
-      expect(resolved).toBe(false);
-      vi.advanceTimersByTime(1);
-      await vi.advanceTimersByTimeAsync(0);
-      expect(resolved).toBe(true);
-      vi.useRealTimers();
+      try {
+        const cmd = tick(500, 'done');
+        let resolved = false;
+        void cmd(() => {}, { onPulse: () => ({ dispose() {} }) }).then(() => {
+          resolved = true;
+        });
+        vi.advanceTimersByTime(499);
+        await vi.advanceTimersByTimeAsync(0);
+        expect(resolved).toBe(false);
+        vi.advanceTimersByTime(1);
+        await vi.advanceTimersByTimeAsync(0);
+        expect(resolved).toBe(true);
+      } finally {
+        vi.useRealTimers();
+      }
     });
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/commands.test.ts` around lines 15 - 39, Both tests
call vi.useFakeTimers() but call vi.useRealTimers() only at the end, risking
leaked fake timers if an assertion throws; wrap the fake timer usage in a
try/finally (or add an afterEach that calls vi.useRealTimers()) so real timers
are always restored; update the tests that call vi.useFakeTimers() (the cases
invoking tick(100, msg) and tick(500, 'done') in commands.test.ts) to ensure
vi.useRealTimers() runs in finally (or add a global afterEach cleanup) so timers
are reliably restored.
🟠 Major comments (20)
scripts/pty-driver.py-28-40 (1)

28-40: ⚠️ Potential issue | 🟠 Major

Resource leak: master_fd not closed if Popen fails.

If subprocess.Popen() raises an exception, the finally block closes slave_fd, but master_fd remains open. Consider restructuring to ensure both fds are cleaned up on failure.

Proposed fix
     master_fd, slave_fd = os.openpty()
     try:
         proc = subprocess.Popen(
             argv,
             cwd=cwd,
             env=env,
             stdin=slave_fd,
             stdout=slave_fd,
             stderr=slave_fd,
             close_fds=True,
         )
-    finally:
-        os.close(slave_fd)
+    except:
+        os.close(master_fd)
+        raise
+    finally:
+        os.close(slave_fd)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/pty-driver.py` around lines 28 - 40, Popen failure currently leaves
master_fd open; change the flow to initialize proc = None, call
subprocess.Popen(...) inside a try/except, and on exception close both slave_fd
and master_fd before re-raising; on success close only slave_fd (preserving
master_fd for use). Refer to master_fd, slave_fd, proc, and subprocess.Popen in
your changes.
examples/chat/main.ts-37-42 (1)

37-42: ⚠️ Potential issue | 🟠 Major

Use the available width instead of discarding it.

Line 38 turns width into dead data, so long chat lines are rendered as a single row and get clipped in narrow terminals. Since this view only enables vertical scrolling, user-entered messages can become unreadable instead of wrapping to the viewport width.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/chat/main.ts` around lines 37 - 42, renderMessages currently ignores
the width parameter (void width) causing long lines to be clipped; update
renderMessages to wrap each Message.text to the supplied width (accounting for
the tag produced by badgeText) and join wrapped lines with '\n\n', ensuring
subsequent wrapped lines are indented to align under the message body (or start
at column after the tag) so messages display correctly in narrow terminals;
locate renderMessages and badgeText to compute tag length and apply a simple
word-wrap on Message.text using width - tagLength for the first line and width -
indent for subsequent lines.
packages/bijou/src/core/components/box.ts-68-71 (1)

68-71: ⚠️ Potential issue | 🟠 Major

Auto-width titled boxes are still sized too small.

When the title is wider than the content, this branch only reserves graphemeWidth(title) + 2, but the top-border renderer later needs room for ${title} plus one horizontal border cell on each side. contentWidth also stays at maxWidth, so content rows do not expand to the wider title-driven box. The result is a clipped title and misaligned side borders for cases like box('', { title: 'Hello' }).

Suggested fix
-    const titleWidth = title ? graphemeWidth(title) + 2 : 0;
+    const titleInnerWidth = title ? graphemeWidth(` ${title} `) + 2 : 0;
     const maxWidth = contentLines.reduce((max, line) => Math.max(max, graphemeWidth(line)), 0);
-    contentWidth = maxWidth;
-    innerWidth = Math.max(titleWidth, maxWidth + padding.left + padding.right);
+    innerWidth = Math.max(titleInnerWidth, maxWidth + padding.left + padding.right);
+    contentWidth = Math.max(0, innerWidth - padding.left - padding.right);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/components/box.ts` around lines 68 - 71, The
auto-width calculation underestimates space for titled boxes: update the
titleWidth computation (where titleWidth is set using graphemeWidth(title)) to
account for the surrounding spaces and the extra horizontal border cell on both
sides (i.e., reserve space for " ${title} " plus one border cell each side), and
then ensure contentWidth and innerWidth are derived so contentWidth expands when
title drives the width (set contentWidth to the larger of maxWidth and the
adjusted title-driven inner width minus padding.left/right), adjusting the
assignments that touch titleWidth, contentWidth, innerWidth and using
graphemeWidth and contentLines/padding to compute the final dimensions so the
title is not clipped and side borders align.
examples/v3-pipeline/main.ts-7-35 (1)

7-35: ⚠️ Potential issue | 🟠 Major

Don’t bake a single context into the exported app.

view closes over the module-scoped ctx, so any caller that runs app with a different context will still center against the wrong dimensions. That makes the exported example render incorrectly in fixed-size recording/test contexts.

♻️ Suggested refactor
-export const ctx = initDefaultContext();
-
-export const app: App<Model> = {
-  init: () => [{ scanlineOn: true }, []],
-  update: (msg, model) => {
-    if (isKeyMsg(msg)) {
-      if (msg.key === 'q' || (msg.ctrl && msg.key === 'c')) return [model, [quit()]];
-      if (msg.key === ' ') return [{ ...model, scanlineOn: !model.scanlineOn }, []];
-    }
-    return [model, []];
-  },
-  view: (model) => centerSurface(
-    ctx,
-    boxV3(
-      vstackV3(
-        badge('Pipeline Hook', { variant: 'primary' }),
-        spacer(1, 1),
-        line('This app installs a custom PostProcess middleware.'),
-        line(`Scanlines: ${model.scanlineOn ? 'enabled' : 'disabled'}`),
-        spacer(1, 1),
-        line('Press SPACE to toggle the effect. Press Q to quit.'),
-      ),
-      { title: 'Render Pipeline', padding: { top: 1, bottom: 1, left: 2, right: 2 } },
-    ),
-  ),
-};
+export function createApp(ctx: ReturnType<typeof initDefaultContext>): App<Model> {
+  return {
+    init: () => [{ scanlineOn: true }, []],
+    update: (msg, model) => {
+      if (isKeyMsg(msg)) {
+        if (msg.key === 'q' || (msg.ctrl && msg.key === 'c')) return [model, [quit()]];
+        if (msg.key === ' ') return [{ ...model, scanlineOn: !model.scanlineOn }, []];
+      }
+      return [model, []];
+    },
+    view: (model) => centerSurface(
+      ctx,
+      boxV3(
+        vstackV3(
+          badge('Pipeline Hook', { variant: 'primary' }),
+          spacer(1, 1),
+          line('This app installs a custom PostProcess middleware.'),
+          line(`Scanlines: ${model.scanlineOn ? 'enabled' : 'disabled'}`),
+          spacer(1, 1),
+          line('Press SPACE to toggle the effect. Press Q to quit.'),
+        ),
+        { title: 'Render Pipeline', padding: { top: 1, bottom: 1, left: 2, right: 2 } },
+      ),
+    ),
+  };
+}
+
+export const ctx = initDefaultContext();
+export const app = createApp(ctx);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-pipeline/main.ts` around lines 7 - 35, The view currently closes
over the module-scoped ctx (created by initDefaultContext) which makes rendering
use a fixed context; instead remove the exported ctx and put the context into
app state: add a ctx field to Model, call initDefaultContext() in app.init to
populate model.ctx, update app.view to call centerSurface(model.ctx, ...) (and
remove references to the module ctx); alternatively, if the framework passes a
rendering context into view, use that parameter (e.g., change view signature to
use the provided ctx) rather than the module-scoped ctx (references: ctx,
initDefaultContext, Model, app.init, app.view, centerSurface).
packages/bijou/src/core/components/box-v3.ts-43-44 (1)

43-44: ⚠️ Potential issue | 🟠 Major

Include the title when auto-sizing the box.

Line 43 only derives outerW from the content width. With something like boxV3('x', { title: 'Details' }), the box ends up too narrow and the later title pass truncates the title away. When width is omitted, the minimum width needs to account for title too.

♻️ Suggested fix
-  const outerW = fixedWidth ?? (innerW + 2);
+  const minTitleOuterW = title == null ? 0 : segmentGraphemes(` ${title} `).length + 4;
+  const outerW = fixedWidth ?? Math.max(innerW + 2, minTitleOuterW);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/components/box-v3.ts` around lines 43 - 44, When
auto-sizing in boxV3, outerW is calculated only from innerW so a provided title
can get truncated; update the logic that sets outerW (currently using
fixedWidth, innerW, innerH) to also consider the title length by computing a
minimum width from the title (e.g., titleLength + any needed padding/border) and
use outerW = fixedWidth ?? Math.max(innerW + 2, titleMinWidth) so the title
always fits; update references to fixedWidth/innerW/outerW in the boxV3 sizing
path accordingly.
packages/bijou-tui/src/css/install.ts-31-35 (1)

31-35: ⚠️ Potential issue | 🟠 Major

Don't force Apple Terminal into light mode.

Line 34 treats every TERM_PROGRAM=Apple_Terminal session as light, but Terminal.app supports dark profiles too. That will resolve BCSS theme rules against the wrong mode whenever COLORFGBG is absent.

♻️ Suggested fix
 function detectThemeMode(ctx: BijouContext): ThemeMode {
   const colorFgBg = ctx.runtime.env('COLORFGBG')?.split(';').pop();
   if (colorFgBg === '15') return 'light';
-  if (ctx.runtime.env('TERM_PROGRAM') === 'Apple_Terminal') return 'light';
+  if (colorFgBg === '0') return 'dark';
   return 'dark';
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/css/install.ts` around lines 31 - 35, The
detectThemeMode function currently forces TERM_PROGRAM === 'Apple_Terminal' to
'light' which is incorrect; remove that special-case check so theme detection
only uses COLORFGBG (ctx.runtime.env('COLORFGBG')) and then falls back to the
default ('dark') when COLORFGBG is absent; update detectThemeMode to stop
treating ctx.runtime.env('TERM_PROGRAM') === 'Apple_Terminal' as authoritative
for light mode.
examples/canvas/main.ts-47-49 (1)

47-49: ⚠️ Potential issue | 🟠 Major

Reserve rows for the title before drawing the shader.

Lines 47-49 render the canvas at model.rows and then stack a 3-row title above it, so the composed surface is taller than the terminal on every frame. That will clip the animation and create unnecessary bottom-row churn in the differ.

♻️ Suggested fix
-    const art = canvas(model.cols, model.rows, shader, { time: model.time });
-    const title = stringToSurface('  Plasma Shader  (q to quit)', model.cols, 3);
+    const titleHeight = 3;
+    const title = stringToSurface('  Plasma Shader  (q to quit)', model.cols, titleHeight);
+    const art = canvas(model.cols, Math.max(0, model.rows - titleHeight), shader, { time: model.time });
     return vstackV3(title, art);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/canvas/main.ts` around lines 47 - 49, The shader canvas is being
created at model.rows and then a 3-row title is stacked on top, making the
combined surface too tall; change the call that builds the shader surface
(canvas(...)) to reserve space for the title by using rows = Math.max(0,
model.rows - 3) (or a TITLE_HEIGHT constant) when calling canvas(model.cols,
..., shader, { time: model.time }), then keep stringToSurface('  Plasma Shader 
(q to quit)', model.cols, 3) and vstackV3(title, art) as before; also ensure the
reserved-rows computation handles small terminal heights so it never passes a
negative row count to canvas.
packages/bijou/src/ports/surface.ts-100-113 (1)

100-113: ⚠️ Potential issue | 🟠 Major

interpolateColors is exposed but never honored.

The public TransformOptions contract advertises blended colors, but transform() ignores the flag and always does nearest-neighbor sampling. Either implement the blended path or remove/defer the option before 3.0.0 ships.

Also applies to: 316-338

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/surface.ts` around lines 100 - 113, TransformOptions
advertises interpolateColors but transform() currently ignores it; either
implement color blending in the transform sampling path or remove the option.
Locate the transform() implementation and the pixel sampling/resampling helpers
it calls (e.g., any samplePixel/nearestNeighbor routines used during
scaling/rotation) and add a blended-color codepath when opts.interpolateColors
is true that performs proper hex/RGB interpolation (or, if you prefer to defer,
remove interpolateColors from TransformOptions and any references in callers and
docs so the flag is not exposed). Ensure the change touches TransformOptions,
transform(), and the sampling helper functions so the option is honored or fully
removed.
packages/bijou/src/ports/surface.ts-211-223 (1)

211-223: ⚠️ Potential issue | 🟠 Major

set() cannot restore transparency.

Because applyMask() returns immediately when source.empty is true, surface.set(x, y, { char: ' ', empty: true }) is a no-op. Right now there is no way to clear a single populated cell back to transparent once it has content.

Also applies to: 257-260

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/surface.ts` around lines 211 - 223, The early return
in applyMask prevents writing a transparent/empty cell because it exits when
source.empty is true; remove that immediate return and instead let the mask
logic run even if source.empty is true so fields like empty/opacity (driven by
mask.alpha) and other masked properties can be applied from the source; update
the same logic in the mirrored implementation at the other occurrence (lines
referenced in the review) so surface.set(x,y, {char:' ', empty:true}) is honored
when the mask (especially alpha) indicates the source should replace the target.
packages/bijou/src/ports/surface.ts-263-267 (1)

263-267: ⚠️ Potential issue | 🟠 Major

fill() overdraws when the origin is negative.

The clipped end is computed from the clamped start instead of the requested start. For example, fill(cell, -2, 0, 5, 1) currently paints 5 columns instead of the clipped 3.

💡 Proposed fix
     fill(cell, fx = 0, fy = 0, fw = w, fh = h, mask = FULL_MASK) {
       const xStart = Math.max(0, fx);
       const yStart = Math.max(0, fy);
-      const xEnd = Math.min(w, xStart + fw);
-      const yEnd = Math.min(h, yStart + fh);
+      const xEnd = Math.min(w, fx + fw);
+      const yEnd = Math.min(h, fy + fh);
 
       for (let y = yStart; y < yEnd; y++) {
         for (let x = xStart; x < xEnd; x++) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/surface.ts` around lines 263 - 267, The clip math in
fill() is using the clamped xStart/yStart to compute xEnd/yEnd which causes
overdraw when fx or fy are negative; change xEnd to Math.min(w, fx + fw) and
yEnd to Math.min(h, fy + fh) while keeping xStart = Math.max(0, fx) and yStart =
Math.max(0, fy) so the width/height are clipped against the original requested
origin; update the bounds in the fill(cell, fx = 0, fy = 0, fw = w, fh = h, mask
= FULL_MASK) implementation accordingly (function fill).
packages/bijou/src/ports/surface.ts-343-360 (1)

343-360: ⚠️ Potential issue | 🟠 Major

Negative row offsets are clipped against the wrong source index.

getRow() and setRow() clamp the destination x, but neither skips the clipped cells from the requested span. getRow(0, -2, 3) currently returns three cells, and setRow(..., -1) writes rowCells[0] into x=0 instead of rowCells[1].

💡 Proposed fix
     getRow(y, rx = 0, rw = w - rx, mask = FULL_MASK) {
       if (y < 0 || y >= h) return [];
       const xStart = Math.max(0, rx);
-      const count = Math.min(w - xStart, rw);
+      const sourceStart = Math.max(0, -rx);
+      const count = Math.min(w - xStart, rw - sourceStart);
       if (count <= 0) return [];
       return cells
         .slice(y * w + xStart, y * w + xStart + count)
         .map((c) => maskCell(c!, mask));
     },
 
     setRow(y, rowCells, sx = 0, mask = FULL_MASK) {
       if (y < 0 || y >= h) return;
       const xStart = Math.max(0, sx);
-      const count = Math.min(w - xStart, rowCells.length);
+      const sourceStart = Math.max(0, -sx);
+      const count = Math.min(w - xStart, rowCells.length - sourceStart);
       for (let i = 0; i < count; i++) {
         const idx = y * w + xStart + i;
-        cells[idx] = applyMask(cells[idx]!, rowCells[i]!, mask);
+        cells[idx] = applyMask(cells[idx]!, rowCells[sourceStart + i]!, mask);
       }
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/surface.ts` around lines 343 - 360, getRow and
setRow incorrectly handle negative source offsets (rx/sx): they clamp the
destination xStart but do not advance the source index or reduce count, so a
call like getRow(0, -2, 3) still returns 3 cells starting from rowCells[0] and
setRow(..., -1) writes rowCells[0] into x=0 instead of rowCells[1]. Fix both
functions by computing a sourceStart (e.g., srcOffset = Math.max(0, -rx) for
getRow and Math.max(0, -sx) for setRow), advance the slice/map or rowCells index
by that srcOffset, and reduce count accordingly so you skip clipped source
cells; keep using xStart = Math.max(0, rx/sx) and ensure maskCell/applyMask
still receive the aligned source cell values.
packages/bijou/src/ports/surface.ts-192-223 (1)

192-223: ⚠️ Potential issue | 🟠 Major

Cell copies are still shallow around modifiers.

modifiers is the only nested mutable field, and every copy path here reuses the same array reference. That means get(), clone(), or a shared fill cell can leak later push()/mutation back into other cells, which contradicts the clone() deep-copy contract.

💡 Proposed fix
+function cloneCell(cell: Cell): Cell {
+  return {
+    ...cell,
+    modifiers: cell.modifiers ? [...cell.modifiers] : undefined,
+  };
+}
+
 function maskCell(cell: Cell, mask: CellMask): Cell {
   return {
     char: mask.char ? cell.char : ' ',
     fg: mask.fg ? cell.fg : undefined,
     bg: mask.bg ? cell.bg : undefined,
-    modifiers: mask.modifiers ? cell.modifiers : undefined,
+    modifiers: mask.modifiers ? (cell.modifiers ? [...cell.modifiers] : undefined) : undefined,
     empty: mask.alpha ? (cell.empty ?? false) : false,
     opacity: mask.alpha ? (cell.opacity ?? 1) : 1,
   };
 }
@@
   return {
     char: mask.char ? source.char : target.char,
     fg: mask.fg ? source.fg : target.fg,
     bg: mask.bg ? source.bg : target.bg,
-    modifiers: mask.modifiers ? source.modifiers : target.modifiers,
+    modifiers: mask.modifiers ? (source.modifiers ? [...source.modifiers] : undefined) : target.modifiers,
     empty: mask.alpha ? (source.empty ?? false) : (target.empty ?? false),
     opacity: mask.alpha ? (source.opacity ?? 1) : (target.opacity ?? 1),
   };
 }
@@
-  const cells: Cell[] = Array.from({ length: size }, () => ({ ...defaultCell }));
+  const cells: Cell[] = Array.from({ length: size }, () => cloneCell(defaultCell));
@@
-        cells[i] = { ...defaultCell };
+        cells[i] = cloneCell(defaultCell);
@@
-        s.cells[i] = { ...cells[i]! };
+        s.cells[i] = cloneCell(cells[i]!);

Also applies to: 233-248, 363-367

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/ports/surface.ts` around lines 192 - 223, The code
currently reuses the same modifiers array reference when building Cells (in
maskCell and applyMask and the other copy/merge sites mentioned), causing
shallow-copy leaks; change every place that assigns modifiers to always create a
new array copy (e.g., when using source.modifiers or target.modifiers or
cell.modifiers, set modifiers to a new array copy of that value) and do the same
in all analogous functions (the other copy paths referenced) so no Cell ever
reuses a modifiers reference from another Cell.
examples/_shared/v3.ts-21-22 (1)

21-22: ⚠️ Potential issue | 🟠 Major

This regex literal will fail the Biome gate.

Biome's noControlCharactersInRegex rule flags the \x1b literal in the regex, so npm run lint will fail. The solution is to use a RegExp constructor to avoid the rule violation, which is the established pattern throughout the codebase (see clip.ts).

💡 Proposed fix
+const ANSI_SGR_RE = new RegExp(String.raw`\x1b\[[0-9;]*m`, 'g');
+
 function visibleWidth(text: string): number {
-  return text.replace(/\x1b\[[0-9;]*m/g, '').length;
+  return text.replace(ANSI_SGR_RE, '').length;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/_shared/v3.ts` around lines 21 - 22, The regex literal in
visibleWidth(text: string) uses the \x1b escape which trips Biome's
noControlCharactersInRegex rule; update visibleWidth to use the RegExp
constructor instead (e.g., replace the literal /\x1b\[[0-9;]*m/g with new
RegExp('\\x1b\\[[0-9;]*m','g')) so linting passes while preserving the same
replace behavior on the visibleWidth function.
examples/v3-demo/record.ts-1-10 (1)

1-10: ⚠️ Potential issue | 🟠 Major

Use fileURLToPath() instead of .pathname.

URL.pathname does not produce a valid filesystem path on Windows—it keeps percent-encoded characters intact (spaces remain as %20) and produces paths with an invalid leading / before the drive letter. This causes GIF recording to fail on non-POSIX systems. The pattern occurs across all six record.ts examples: v3-css, v3-demo, v3-motion, v3-subapp, v3-worker, and v3-pipeline.

💡 Proposed fix
+import { fileURLToPath } from 'node:url';
 import { recordDemoGif } from '@flyingrobots/bijou-node';
 import { app, ctx } from './main.ts';
@@
-    outputPath: new URL('./demo.gif', import.meta.url).pathname,
+    outputPath: fileURLToPath(new URL('./demo.gif', import.meta.url)),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-demo/record.ts` around lines 1 - 10, The outputPath is built
using new URL(...).pathname which yields invalid Windows paths; update the
record() implementation to convert the file URL to a platform path using
fileURLToPath from 'url' (replace usage of new URL('./demo.gif',
import.meta.url).pathname), so pass a correct filesystem path into recordDemoGif
(refer to the record function and the outputPath argument passed to
recordDemoGif); apply the same change across the other record.ts examples
(v3-css, v3-motion, v3-subapp, v3-worker, v3-pipeline).
packages/bijou-tui/src/motion/motion.ts-34-39 (1)

34-39: ⚠️ Potential issue | 🟠 Major

Don't overwrite an existing LayoutNode.id here.

options.key is already present inside motion, but Lines 36-38 replace any caller-provided content.id. That drops the original node identity for anything else keyed off LayoutNode.id once the node is wrapped. Preserve content.id, or normalize both branches through a dedicated wrapper node and let reconciliation use motion.key.

🧩 Minimal safe change if the reconciler reads `motion.key`
   return {
     ...content,
-    id: options.key,
     motion: options,
   } as MotionNode;

If the reconciler currently keys on id, prefer wrapping both branches in the same synthetic motion node instead of mutating the wrapped node's own id.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/motion/motion.ts` around lines 34 - 39, The current
return in create/wrap path overwrites the wrapped node's LayoutNode.id by
setting id: options.key; instead preserve content.id and instead attach
motion.key inside the MotionNode wrapper so you don't mutate the original node
identity. Update the wrapping logic around MotionNode (the object returned from
this file) to leave content.id untouched and ensure options.key is stored on the
motion property (e.g., motion.key) or by returning a synthetic wrapper node that
contains both the original content (with its id) and the motion object; make the
change in the function that builds/returns the MotionNode so reconciliation can
continue to key off LayoutNode.id while the reconciler can read motion.key when
needed.
examples/v3-subapp/main.ts-17-17 (1)

17-17: ⚠️ Potential issue | 🟠 Major

Resolve the context at render time, not import time.

Line 17 captures a ctx once during module evaluation and Line 96 reuses it for every render, while Line 125 runs the app without explicitly binding that same context. Any recorder/test/script that swaps the default context later can end up centering against stale dimensions. Use the current context inside view(), or thread one shared ctx through both rendering and run(...).

Also applies to: 80-97, 124-125

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-subapp/main.ts` at line 17, The module currently calls
initDefaultContext() once at import and exports ctx, causing a stale context to
be reused; instead resolve the context at render/run time by removing the
exported frozen ctx and either (a) call initDefaultContext() inside view() (or a
new getCtx() accessor) so each render obtains the current context, or (b) thread
a single context instance returned by initDefaultContext() through both render
and run(...) so both use the same, up-to-date context; update usages of ctx,
view(), and run(...) to obtain or accept the context at invocation rather than
importing the module-level ctx.
examples/v3-worker/main.ts-109-115 (1)

109-115: ⚠️ Potential issue | 🟠 Major

Dispose the delayed host-note timer on exit.

Lines 110-112 leave a live setTimeout behind. If the user quits before 800ms, the process stays alive waiting for that timer and the callback can call handle.send() after the worker has already exited.

🧹 Cleanup sketch
-    if (useMockContext) {
-      handle.send({ type: 'host-note', text: 'Main thread says hello via the data channel.' });
-    } else {
-      setTimeout(() => {
-        handle.send({ type: 'host-note', text: 'Main thread says hello via the data channel.' });
-      }, 800);
-    }
-
-    await handle.onExit;
+    let hostNoteTimer: ReturnType<typeof setTimeout> | undefined;
+    if (useMockContext) {
+      handle.send({ type: 'host-note', text: 'Main thread says hello via the data channel.' });
+    } else {
+      hostNoteTimer = setTimeout(() => {
+        handle.send({ type: 'host-note', text: 'Main thread says hello via the data channel.' });
+      }, 800);
+      hostNoteTimer.unref?.();
+    }
+
+    try {
+      await handle.onExit;
+    } finally {
+      if (hostNoteTimer) clearTimeout(hostNoteTimer);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-worker/main.ts` around lines 109 - 115, The deferred setTimeout
used to call handle.send({ type: 'host-note', ... }) can keep the process alive
and may run after the worker exits; capture the timer id when calling setTimeout
in the else branch and ensure you clear it (clearTimeout) when the worker
exits—e.g., register cleanup before awaiting handle.onExit or in an on-exit
handler so the timer is cancelled if handle.onExit resolves earlier; refer to
the setTimeout call, the timer id variable, handle.send, and handle.onExit when
locating and changing the code.
packages/bijou-tui/src/types.ts-135-144 (1)

135-144: ⚠️ Potential issue | 🟠 Major

Breaking change: Cmd signature now requires capabilities parameter.

Existing commands with signature (emit) => Promise<...> will fail TypeScript compilation. The migration guide at MIGRATING_TO_V3.md does not document this breaking change. Add a section with upgrade examples showing how to migrate:

// Before
const cmd: Cmd<Msg> = async (emit) => { ... };

// After
const cmd: Cmd<Msg> = async (emit, capabilities) => { ... };

Include a note that capabilities provides onPulse(handler) for animation frame subscriptions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/types.ts` around lines 135 - 144, Add a new "Commands
signature change" section to MIGRATING_TO_V3.md documenting that the Cmd generic
now requires a second parameter "capabilities" (type CmdCapabilities) so
existing commands like const cmd: Cmd<Msg> = async (emit) => { ... } must be
updated to const cmd: Cmd<Msg> = async (emit, capabilities) => { ... }; include
a short before/after example and a note that capabilities exposes
onPulse(handler: (dt:number)=>void): {dispose():void} for animation-frame
subscriptions and must be used instead of any previous global pulse APIs.
packages/bijou-tui/src/subapp/mount.ts-50-59 (1)

50-59: ⚠️ Potential issue | 🟠 Major

mount() currently ignores its command-mapping contract.

This implementation only renders app.view(model) and always returns an empty command list, so MountOptions.onMsg/mapCmd are dead API here. That makes the exported mount() surface misleading and prevents callers from receiving mapped sub-app commands through this entry point. Either make mount() strictly view-only and drop the command-related options/return value, or pass the pending sub-app cmds into this function and map them here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/subapp/mount.ts` around lines 50 - 59, mount currently
renders app.view(model) but ignores command mapping; either remove the
command-related API or wire it up: if you want mount to propagate sub-app
commands, accept the pending sub-app Cmd[] (e.g., add options.pendingCmds or use
the app init/update result), call options.mapCmd for each sub Cmd to convert
SubMsg->ParentMsg (and/or wrap with options.onMsg), and return the mapped
Cmd<ParentMsg>[] alongside the ViewOutput; alternatively, make mount strictly
view-only by removing MountOptions.onMsg and mapCmd and change the return to
just ViewOutput. Ensure you update the mount signature and use the unique
symbols mount, app.view, MountOptions.onMsg, and MountOptions.mapCmd
accordingly.
packages/bijou-tui/src/subapp/mount.ts-130-132 (1)

130-132: ⚠️ Potential issue | 🟠 Major

Import QUIT and compare directly instead of checking typeof result === 'symbol'.

mount.ts doesn't import the QUIT sentinel, so it can't distinguish between it and any other symbol. A sub-app legitimately returning a symbol-valued message will be incorrectly treated as a quit signal. Change line 131 from if (typeof result === 'symbol') to if (result === QUIT) and add QUIT to the imports from ../types.js.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/subapp/mount.ts` around lines 130 - 132, Import the
QUIT sentinel from ../types.js and replace the generic symbol check with a
direct equality against QUIT: instead of using "typeof result === 'symbol'" in
the mount handling (the block that currently returns QuitSignal), change it to
"if (result === QUIT)" so only the sentinel triggers the quit path; keep the
other branches (result === undefined returning undefined and the final "return
mapper(result as SubMsg)") unchanged and ensure the returned type remains
QuitSignal when result === QUIT.
🟡 Minor comments (11)
packages/bijou/src/core/components/bcss-style.ts-33-40 (1)

33-40: ⚠️ Potential issue | 🟡 Minor

Underline variant handling may leave stale modifiers.

When text-decoration includes 'underline', the function adds 'underline' but doesn't clear existing variant modifiers (curly-underline, dotted-underline, dashed-underline). If base already has a variant underline, the result would contain both the variant and plain 'underline'.

If the intent is for text-decoration: underline to override any existing underline style, consider clearing variants before adding:

Proposed fix
   } else if (decoration) {
     if (decoration.includes('underline')) {
+      modifiers.delete('curly-underline');
+      modifiers.delete('dotted-underline');
+      modifiers.delete('dashed-underline');
       modifiers.add('underline');
     }

If the additive behavior is intentional (allowing multiple underline styles), this can be disregarded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/components/bcss-style.ts` around lines 33 - 40, The
underline handling in the decoration branch can leave stale variant modifiers:
when decoration.includes('underline') add logic in the same block that currently
manipulates the modifiers Set (the code that checks decoration and uses
modifiers.add('underline') and
modifiers.add('strikethrough')/modifiers.delete('strike')) to first remove any
variant underline modifiers (e.g. 'curly-underline', 'dotted-underline',
'dashed-underline') from modifiers before adding the plain 'underline' so the
plain underline overrides prior variants; update the code that references
modifiers, decoration, and the underline branch accordingly.
scripts/pty-driver.py-22-26 (1)

22-26: ⚠️ Potential issue | 🟡 Minor

Inconsistent indentation: 2 spaces instead of 4.

This block uses 2-space indentation while the rest of the file uses 4-space indentation (PEP 8 standard).

Proposed fix
     env = os.environ.copy()
     for key, value in spec.get("env", {}).items():
-      if value is None:
-        env.pop(key, None)
-      else:
-        env[key] = str(value)
+        if value is None:
+            env.pop(key, None)
+        else:
+            env[key] = str(value)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/pty-driver.py` around lines 22 - 26, The for-loop over
spec.get("env", {}) uses 2-space indentation which breaks PEP8 style—update the
indentation to 4 spaces to match the rest of the file; locate the block that
iterates "for key, value in spec.get(\"env\", {}).items():" and re-indent the
body lines that reference env.pop(key, None) and env[key] = str(value) to 4
spaces so the file's indentation is consistent.
scripts/pty-driver.py-53-55 (1)

53-55: ⚠️ Potential issue | 🟡 Minor

Potential KeyError if step is missing "input" key.

Unlike delayMs which uses .get("delayMs", 0) with a default, the "input" field is accessed directly and will raise KeyError if missing. Consider using .get() with a default or adding validation.

Proposed fix (option: default to empty string)
             while next_step_index < len(steps) and now >= next_deadline:
-                payload = steps[next_step_index]["input"].encode("utf-8", "surrogatepass")
+                payload = steps[next_step_index].get("input", "").encode("utf-8", "surrogatepass")
                 os.write(master_fd, payload)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/pty-driver.py` around lines 53 - 55, The loop that builds payload
from steps[next_step_index]["input"] can raise KeyError if the "input" key is
missing; update the code in the loop that computes payload (the block
referencing steps, next_step_index, payload, and os.write(master_fd, ...)) to
safely access the field—either validate the step dict before use or replace the
direct access with steps[next_step_index].get("input", "") (and encode that
default) so missing keys become an empty payload instead of throwing, then
proceed to os.write(master_fd, payload) as before.
examples/split-editors/main.ts-142-143 (1)

142-143: ⚠️ Potential issue | 🟡 Minor

Focused header badges now make the title row spacing unstable.

badgeText() returns a styled string, but the header is still spaced with raw string padding at Line 147. That makes the escape sequences part of the length calculation, so the right-hand filename shifts when focus toggles. Keep plain-text labels for width math, or render the two header cells as separate layout items instead of padding a rendered ANSI string.

💡 Minimal fix
-    const leftLabel = model.focusLeft ? badgeText('app.ts', 'primary') : 'app.ts';
-    const rightLabel = !model.focusLeft ? badgeText('app.test.ts', 'primary') : 'app.test.ts';
+    const leftText = 'app.ts';
+    const rightText = 'app.test.ts';
+    const leftLabel = model.focusLeft ? badgeText(leftText, 'primary') : leftText;
+    const rightLabel = !model.focusLeft ? badgeText(rightText, 'primary') : rightText;
+    const headerGap = ''.padEnd(Math.max(1, paneWidth - leftText.length - 2));
-      { basis: 1, content: `  ${leftLabel}${''.padEnd(paneWidth - 10)}${rightLabel}` },
+      { basis: 1, content: `  ${leftLabel}${headerGap}${rightLabel}` },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/split-editors/main.ts` around lines 142 - 143, The issue is that
badgeText() injects ANSI styling into leftLabel/rightLabel so the header width
math uses escaped lengths and shifts when model.focusLeft toggles; change the
header width calculation to use plain-text labels (e.g., introduce
leftLabelPlain = 'app.ts' and rightLabelPlain = 'app.test.ts') for all
padding/width computations, then only apply badgeText(leftLabelPlain, 'primary')
/ badgeText(rightLabelPlain, 'primary') when actually rendering the header cell
content (or alternatively render the two header cells as separate layout items
instead of padding a single ANSI-styled string); update uses of leftLabel and
rightLabel in the header layout code to use the plain-labels for length math and
styled labels for display.
examples/v3-pipeline/main.ts-38-40 (1)

38-40: ⚠️ Potential issue | 🟡 Minor

Clarify the model contract instead of silently casting.

Line 40 assumes state.model is always a Model instance. Although the library types model: any, this helper should either document its model dependency or add a defensive check to fail gracefully if the model lacks scanlineOn. The current unchecked cast hides an implicit contract violation at runtime.

Suggested defensive guard
 export function configureScanlinePipeline(pipeline: RenderPipeline): void {
   pipeline.use('PostProcess', (state, next) => {
-    const enabled = (state.model as Model).scanlineOn;
+    const enabled =
+      typeof state.model === 'object' &&
+      state.model !== null &&
+      'scanlineOn' in state.model &&
+      state.model.scanlineOn === true;
     if (enabled) {
       for (let y = 1; y < state.targetSurface.height; y += 2) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/v3-pipeline/main.ts` around lines 38 - 40, In
configureScanlinePipeline, avoid silently casting state.model to Model; add a
defensive guard that checks state.model and the scanlineOn property (e.g., "if
(!state.model || typeof state.model.scanlineOn !== 'boolean')") and either
return early or throw a clear error describing the expected model contract, or
update the function signature/docs to require a Model; reference state.model and
the scanlineOn property so the check happens before using (state.model as
Model).scanlineOn within the pipeline.use callback.
scripts/smoke-all-examples.ts-269-281 (1)

269-281: ⚠️ Potential issue | 🟡 Minor

Python dependency required for smoke tests.

The interactive-tty mode test suite depends on an external python3 script (scripts/pty-driver.py). This dependency is not documented in the README, CI workflow, or contributing guidelines. Add a note to the development setup section or CI comments documenting that Python 3 must be available when running npm run smoke:examples:all.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/smoke-all-examples.ts` around lines 269 - 281, The test harness
spawns an external Python script ('scripts/pty-driver.py') via the spawn call in
smoke-all-examples.ts (where BIJOU_PTY_SPEC is set and python3 is invoked) but
this dependency isn't documented; update the development setup docs and CI
comments to state that Python 3 is required to run `npm run smoke:examples:all`,
and either add a short CI workflow comment/check that verifies `python3
--version` (or fails with a clear message) before running the smoke tests, and
add the same note to CONTRIBUTING/README under the development/setup section so
maintainers and contributors know the requirement.
packages/bijou/src/core/render/differ.ts-79-92 (1)

79-92: ⚠️ Potential issue | 🟡 Minor

Consider defensive handling for malformed truecolor sequences.

If the ANSI sequence is malformed (e.g., 38;2; with missing RGB values), parseInt(undefined, 10) returns NaN, and NaN.toString(16) produces "NaN", resulting in invalid hex like #NaNNaNNaN. This is an edge case but could cause downstream issues.

🛡️ Proposed defensive fix
         } else if (code === '38' && parts[i+1] === '2') {
           // Truecolor FG: 38;2;R;G;B
-          const r = parseInt(parts[i+2]!, 10).toString(16).padStart(2, '0');
-          const g = parseInt(parts[i+3]!, 10).toString(16).padStart(2, '0');
-          const b = parseInt(parts[i+4]!, 10).toString(16).padStart(2, '0');
-          currentFg = '#' + r + g + b;
+          const r = parseInt(parts[i+2] ?? '', 10);
+          const g = parseInt(parts[i+3] ?? '', 10);
+          const b = parseInt(parts[i+4] ?? '', 10);
+          if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
+            currentFg = '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
+          }
           i += 4;
         } else if (code === '48' && parts[i+1] === '2') {
           // Truecolor BG: 48;2;R;G;B
-          const r = parseInt(parts[i+2]!, 10).toString(16).padStart(2, '0');
-          const g = parseInt(parts[i+3]!, 10).toString(16).padStart(2, '0');
-          const b = parseInt(parts[i+4]!, 10).toString(16).padStart(2, '0');
-          currentBg = '#' + r + g + b;
+          const r = parseInt(parts[i+2] ?? '', 10);
+          const g = parseInt(parts[i+3] ?? '', 10);
+          const b = parseInt(parts[i+4] ?? '', 10);
+          if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
+            currentBg = '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
+          }
           i += 4;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/render/differ.ts` around lines 79 - 92, The truecolor
parsing in render/differ.ts (handling codes '38;2' and '48;2') doesn't guard
against missing or non-numeric RGB parts, which leads to "#NaNNaNNaN"; fix by
validating that parts[i+2], parts[i+3], parts[i+4] exist and parse to finite
numbers, clamp them to 0–255, and only then convert to two‑digit hex to set
currentFg/currentBg (otherwise fall back to a safe value like '#000000' or skip
updating the color); also ensure the index increment (i += 4) only occurs after
successful consumption of the RGB components.
packages/bijou/src/core/render/differ.ts-165-170 (1)

165-170: ⚠️ Potential issue | 🟡 Minor

Modifier comparison is order-sensitive.

The current implementation compares modifiers by index, so ['bold', 'dim'] and ['dim', 'bold'] would be considered different. If modifiers can be added in different orders (e.g., from different SGR sequences), this could cause unnecessary re-renders.

💡 Set-based comparison alternative
   const aMods = a.modifiers ?? [];
   const bMods = b.modifiers ?? [];
   if (aMods.length !== bMods.length) return false;
-  for (let i = 0; i < aMods.length; i++) {
-    if (aMods[i] !== bMods[i]) return false;
-  }
+  const aSet = new Set(aMods);
+  for (const mod of bMods) {
+    if (!aSet.has(mod)) return false;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/render/differ.ts` around lines 165 - 170, The
modifier comparison is order-sensitive because it compares aMods and bMods by
index; change it to an order-insensitive (set) comparison: after obtaining aMods
and bMods, if lengths differ return false, then create a Set from aMods (or a
frequency map if duplicates matter) and verify every entry in bMods exists in
that Set (and sizes match) so ['bold','dim'] and ['dim','bold'] are treated
equal; update the comparison logic where aMods and bMods are used in the differ
(the aMods/bMods block) to perform this set-based containment check.
packages/bijou-tui/src/eventbus.ts-264-267 (1)

264-267: ⚠️ Potential issue | 🟡 Minor

Unsafe cast of io.onData payload to M.

The payload from io.onData is cast directly to M without validation. If the data channel emits messages that don't conform to M, this could cause runtime issues in the application's update function.

Consider either:

  1. Adding a type guard or validation callback in connectIO options, or
  2. Documenting that io.onData payloads must conform to M.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/eventbus.ts` around lines 264 - 267, The code unsafely
casts the payload from io.onData to generic M before emitting (io.onData, emit,
M); add a runtime validation step: extend the connectIO/options to accept an
optional type guard or validator (e.g., validateData: (raw: unknown) => raw is M
or transformData: (raw: unknown) => M | undefined) and invoke it inside the
onData handler, only calling emit(payload as M) when the validator returns true
(otherwise log/ignore the message); update types for connectIO and any callers
to accept the validator so payloads are verified before being passed to update.
packages/bijou-tui/src/runtime.ts-268-273 (1)

268-273: ⚠️ Potential issue | 🟡 Minor

Pending render flush may not wait for the actual render.

The renderRequested flag is set, and a 0ms timeout is awaited. However, the render itself is scheduled via setTimeout(..., 0) in the render() function. If these timeouts race, the exit might occur before the render completes.

Consider using a more deterministic approach, such as awaiting a render completion signal or using requestAnimationFrame-style callbacks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/runtime.ts` around lines 268 - 273, The current wait
(await new Promise setTimeout 0) can race with render()'s own setTimeout;
replace this ad-hoc wait with a deterministic render completion signal: add a
module-scoped pendingRenderPromise and pendingRenderResolve that render()
assigns/creates and resolves when the actual render work completes, then here
(where renderRequested is checked) await that pendingRenderPromise (or create
one if missing) instead of the 0ms timeout so the exit only proceeds after
render() has finished.
packages/bijou-tui/src/driver.ts-114-128 (1)

114-128: ⚠️ Potential issue | 🟡 Minor

Remove startPulse() from the scripted driver—it's unnecessary overhead.

The scripted driver feeds explicit steps (key, pulse, resize, msg) sequentially and doesn't need a background animation timer. Calling bus.startPulse() starts a 60fps interval that continuously emits PulseMsg events throughout script execution, wasting resources with no benefit for deterministic testing.

If animation testing is needed, use explicit { pulse: { dt } } steps instead. The interactive driver (runtime.ts) correctly uses startPulse() with a refresh rate; the scripted driver should not.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-tui/src/driver.ts` around lines 114 - 128, Remove the
automatic animation heartbeat from the scripted driver by deleting the
bus.startPulse() call (the line invoking startPulse on the bus) so the driver
only processes explicit scripted steps (pulse, key, resize, msg); ensure no
other logic depends on a background pulse in this flow (check surrounding
initialization where ctx, installBCSSResolver, app.init(), model/currentSize are
set) and leave the interactive driver's startPulse usage untouched.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8d6237ec-321c-428d-92b7-13b18d706744

📥 Commits

Reviewing files that changed from the base of the PR and between 0be4374 and 955cebf.

⛔ Files ignored due to path filters (7)
  • examples/v3-css/demo.gif is excluded by !**/*.gif
  • examples/v3-demo/demo.gif is excluded by !**/*.gif
  • examples/v3-motion/demo.gif is excluded by !**/*.gif
  • examples/v3-pipeline/demo.gif is excluded by !**/*.gif
  • examples/v3-subapp/demo.gif is excluded by !**/*.gif
  • examples/v3-worker/demo.gif is excluded by !**/*.gif
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (127)
  • .github/workflows/ci.yml
  • .github/workflows/publish.yml
  • README.md
  • demo.ts
  • docs/ARCHITECTURE.md
  • docs/CHANGELOG.md
  • docs/EXAMPLES.md
  • docs/MIGRATING_TO_V3.md
  • examples/README.md
  • examples/_shared/canonical-app.ts
  • examples/_shared/v3.ts
  • examples/badge/main.ts
  • examples/canvas/README.md
  • examples/canvas/main.ts
  • examples/chat/main.ts
  • examples/composable/main.ts
  • examples/confirm/main.ts
  • examples/drawer/main.ts
  • examples/filter/main.ts
  • examples/flex-layout/main.ts
  • examples/focus-area/main.ts
  • examples/input/main.ts
  • examples/logo/main.ts
  • examples/modal/main.ts
  • examples/mouse/main.ts
  • examples/multiselect/main.ts
  • examples/package-manager/main.ts
  • examples/pipe/main.ts
  • examples/print-key/main.ts
  • examples/progress-animated/main.ts
  • examples/progress-download/main.ts
  • examples/select/main.ts
  • examples/showcase/app.ts
  • examples/showcase/registry.ts
  • examples/spinner/main.ts
  • examples/split-editors/main.ts
  • examples/spring/main.ts
  • examples/theme/main.ts
  • examples/timeline-anim/main.ts
  • examples/toast/main.ts
  • examples/tooltip/main.ts
  • examples/transitions/main.ts
  • examples/v3-css/README.md
  • examples/v3-css/main.ts
  • examples/v3-css/record.ts
  • examples/v3-demo/README.md
  • examples/v3-demo/main.ts
  • examples/v3-demo/record.ts
  • examples/v3-motion/README.md
  • examples/v3-motion/main.ts
  • examples/v3-motion/record.ts
  • examples/v3-pipeline/README.md
  • examples/v3-pipeline/main.ts
  • examples/v3-pipeline/record.ts
  • examples/v3-subapp/README.md
  • examples/v3-subapp/main.ts
  • examples/v3-subapp/record.ts
  • examples/v3-worker/README.md
  • examples/v3-worker/main.ts
  • examples/v3-worker/record.ts
  • package.json
  • packages/bijou-node/README.md
  • packages/bijou-node/package.json
  • packages/bijou-node/src/gifenc.d.ts
  • packages/bijou-node/src/index.ts
  • packages/bijou-node/src/recorder.test.ts
  • packages/bijou-node/src/recorder.ts
  • packages/bijou-node/src/style.ts
  • packages/bijou-node/src/worker/fixtures/echo-worker.ts
  • packages/bijou-node/src/worker/worker.test.ts
  • packages/bijou-node/src/worker/worker.ts
  • packages/bijou-tui-app/README.md
  • packages/bijou-tui-app/package.json
  • packages/bijou-tui-app/src/index.test.ts
  • packages/bijou-tui/README.md
  • packages/bijou-tui/package.json
  • packages/bijou-tui/src/animate.test.ts
  • packages/bijou-tui/src/app-frame-actions.ts
  • packages/bijou-tui/src/app-frame-render.ts
  • packages/bijou-tui/src/app-frame.test.ts
  • packages/bijou-tui/src/app-frame.ts
  • packages/bijou-tui/src/browsable-list.test.ts
  • packages/bijou-tui/src/command-palette.test.ts
  • packages/bijou-tui/src/commands.test.ts
  • packages/bijou-tui/src/css/install.ts
  • packages/bijou-tui/src/css/text-style.ts
  • packages/bijou-tui/src/driver.test.ts
  • packages/bijou-tui/src/driver.ts
  • packages/bijou-tui/src/eventbus.test.ts
  • packages/bijou-tui/src/eventbus.ts
  • packages/bijou-tui/src/focus-area.test.ts
  • packages/bijou-tui/src/focus-area.ts
  • packages/bijou-tui/src/index.ts
  • packages/bijou-tui/src/layout-preset.test.ts
  • packages/bijou-tui/src/motion/motion.ts
  • packages/bijou-tui/src/motion/reconciler.test.ts
  • packages/bijou-tui/src/motion/reconciler.ts
  • packages/bijou-tui/src/motion/types.ts
  • packages/bijou-tui/src/navigable-table.test.ts
  • packages/bijou-tui/src/panel-dock.test.ts
  • packages/bijou-tui/src/pipeline/pipeline.test.ts
  • packages/bijou-tui/src/runtime.test.ts
  • packages/bijou-tui/src/runtime.ts
  • packages/bijou-tui/src/subapp/mount.test.ts
  • packages/bijou-tui/src/subapp/mount.ts
  • packages/bijou-tui/src/types.ts
  • packages/bijou-tui/src/view-output.ts
  • packages/bijou/README.md
  • packages/bijou/package.json
  • packages/bijou/src/core/components/badge.ts
  • packages/bijou/src/core/components/bcss-style.ts
  • packages/bijou/src/core/components/box-v3.ts
  • packages/bijou/src/core/components/box.ts
  • packages/bijou/src/core/components/dag.test.ts
  • packages/bijou/src/core/render/differ.test.ts
  • packages/bijou/src/core/render/differ.ts
  • packages/bijou/src/core/theme/dtcg.fuzz.test.ts
  • packages/bijou/src/core/theme/tokens.test.ts
  • packages/bijou/src/index.ts
  • packages/bijou/src/ports/surface.ts
  • packages/create-bijou-tui-app/README.md
  • packages/create-bijou-tui-app/package.json
  • scripts/pty-driver.py
  • scripts/record-gifs.ts
  • scripts/smoke-all-examples.ts
  • scripts/smoke-v3-examples.ts
  • tsconfig.tests.json
✅ Files skipped from review due to trivial changes (5)
  • packages/bijou-tui-app/README.md
  • examples/v3-pipeline/README.md
  • examples/v3-subapp/README.md
  • docs/CHANGELOG.md
  • examples/v3-demo/README.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/bijou-tui/src/index.ts
  • packages/bijou-tui/src/subapp/mount.test.ts
  • packages/bijou/src/index.ts
  • examples/v3-css/main.ts

Comment thread packages/bijou-node/src/gifenc.d.ts
Comment thread packages/bijou-node/src/recorder.ts
@flyingrobots
Copy link
Copy Markdown
Owner Author

@coderabbitai review please 🥕

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 12, 2026

@flyingrobots: On it! 🥕 Kicking off the review for your v3.0.0 PR now!


🧠 Learnings used
Learnt from: flyingrobots
Repo: flyingrobots/bijou PR: 22
File: packages/bijou-node/package.json:30-30
Timestamp: 2026-02-28T10:17:36.090Z
Learning: In the flyingrobots/bijou monorepo, all three packages (flyingrobots/bijou, flyingrobots/bijou-node, flyingrobots/bijou-tui) are versioned in lock-step with exact peer dependency pinning (e.g., "0.8.0" not "^0.8.0"). This is intentional to prevent mismatched internal API contracts between the core and adapter packages, as documented in CHANGELOG.md.

Learnt from: flyingrobots
Repo: flyingrobots/bijou PR: 26
File: TASKS.md:97-97
Timestamp: 2026-03-03T19:46:01.640Z
Learning: In the bijou project, Phase 4 refactoring tasks are purely structural (moving code between files with no behavior change). The project enforces a strict test policy: if tests need modification during structural refactoring, it indicates a behavioral change, which is considered a bug. This discipline ensures behavior preservation during code reorganization.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
packages/bijou-node/src/worker/worker.ts (1)

47-54: ⚠️ Potential issue | 🟠 Major

Narrow RunWorkerOptions to the options worker mode actually honors.

Line 47 still exposes the full RunOptions<any> surface, but Lines 99-105 only serialize altScreen, hideCursor, mouse, and css, and Lines 214-220 rebuild proxyOptions from that subset. That means ctx, middlewares, and configurePipeline are currently accepted by the type system and then silently ignored in worker mode, so runInWorker() does not behave like run() for the same options.

Please either make RunWorkerOptions a worker-safe subset or reject unsupported fields up front.

Suggested API narrowing
-export interface RunWorkerOptions extends RunOptions<any> {
+export interface RunWorkerOptions {
+  altScreen?: boolean;
+  hideCursor?: boolean;
+  mouse?: boolean;
+  css?: string;
   /** The absolute path to the file containing the worker entry point. */
   entry: string;
   /** Optional callback for custom data messages sent from the worker via `sendToMain`. */
   onMessage?: (payload: unknown) => void;
   /** Optional arguments passed to the Node.js worker process (e.g. ['--import', 'tsx']). */
   execArgv?: string[];
 }

Also applies to: 99-105, 214-220

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-node/src/worker/worker.ts` around lines 47 - 54, The
RunWorkerOptions type currently extends RunOptions<any> but the worker only
serializes altScreen, hideCursor, mouse, and css and rebuilds proxyOptions in
runInWorker(), so unsupported fields (ctx, middlewares, configurePipeline, etc.)
are silently ignored; update RunWorkerOptions to be a worker-safe subset
containing only entry, onMessage?, execArgv?, and the four serializable UI
options (altScreen, hideCursor, mouse, css) OR add upfront validation inside
runInWorker() to throw an error when unsupported keys (ctx, middlewares,
configurePipeline and any others from RunOptions) are present before
serialization, and update references to proxyOptions reconstruction
(proxyOptions, buildProxyOptions) so they only depend on the allowed subset.
🧹 Nitpick comments (1)
packages/bijou/src/core/layout/flex.test.ts (1)

4-65: Good foundational coverage, but consider expanding edge cases.

The tests cover core scenarios well. Consider adding tests for:

  • Empty children array (returns bounds with empty children)
  • minSize constraint (only maxSize is tested)
  • Non-zero starting position (bounds.x and bounds.y > 0) to verify offset calculation
  • Unequal flex ratios (e.g., flex: 2 vs flex: 1)

These are optional additions that would strengthen the test suite.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou/src/core/layout/flex.test.ts` around lines 4 - 65, Add the
suggested edge-case tests to the calculateFlex test suite: create new it blocks
exercising calculateFlex with (1) an empty children array to assert it returns a
root with empty children and original bounds, (2) a child using minSize to
verify it respects minimum constraint (use both minSize and maxSize together),
(3) non-zero bounds (bounds.x and bounds.y > 0) to ensure child rect positions
are offset correctly, and (4) unequal flex ratios (e.g., flex: 2 vs flex: 1) to
assert proportional allocation; reference the calculateFlex function and
existing test patterns in flex.test.ts when adding these cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bijou-node/src/worker/worker.ts`:
- Around line 99-108: The worker is not receiving the host terminal size so its
initial createNodeContext()/run() use process-global dimensions; update the
Worker creation (where workerData is set for options.serializableOptions) to
include the host columns and rows (e.g., workerData: { ..., options:
serializableOptions, columns, rows }), then in the worker bootstrap where
createNodeContext() and proxyCtx are initialized, hydrate proxyCtx.runtime with
those initial columns/rows before calling run() so the first render uses the
caller's surface size; also modify the resize bridge that currently only
forwards callbacks (proxy runtime update code around the
proxyCtx.runtime/resizer handling) to set proxyCtx.runtime.columns/rows when
resize events arrive so the proxy runtime stays in sync.

In `@packages/bijou-tui/src/runtime.ts`:
- Around line 100-103: The runtime resize handling is leaving
ctx.runtime.columns and ctx.runtime.rows stale while currentSurface is updated,
causing Layout (wrapViewOutputAsLayoutRoot) and Render to use old dimensions;
either update ctx.runtime.columns/rows inside the resize handler (where
currentSurface is set) to the new width/height from the resize message, or
change the Layout (wrapViewOutputAsLayoutRoot call) and Render timeout to accept
and use the width/height from the resize event instead of reading ctx.runtime;
also apply the same fix to any other consumers (e.g., the CSS resolver in
pipeline/middleware/css.ts) so all stages read the same source of truth.
- Around line 163-183: The scheduled callback inside render() currently runs
code (createSurface, pipeline.execute which may call
app.view/renderSurfaceFrame) without error handling; wrap the body of the
setTimeout callback in a try-catch so any thrown error calls shutdown(error) (or
shutdown() then rethrow/exit) to restore terminal state, while still ensuring
renderRequested is cleared; update references inside the callback that touch
createSurface(sanitizeDimension(ctx.runtime.columns),
sanitizeDimension(ctx.runtime.rows)), pipeline.execute(renderState), and any use
of app.view/renderSurfaceFrame to occur within the try block and route
exceptions to shutdown in the catch.

In `@packages/bijou/src/core/layout/flex.ts`:
- Around line 19-26: FlexChildProps defines an align property that calculateFlex
never uses, so implement cross-axis alignment inside calculateFlex: read
child.align (from FlexChildProps) and when computing each child's cross-axis
size/position in calculateFlex, apply alignment rules (e.g., Align.Start =>
position at cross start, Align.Center => center within containerCrossSize,
Align.End => position at cross end, Align.Stretch => fill containerCrossSize)
rather than always stretching; update calculateFlex to compute child.crossSize
and child.crossOffset based on containerCrossSize and the child's min/max if
needed, using the existing Align type/enum to drive behavior.
- Line 6: The barrel export is missing the Align type so consumers can't import
it alongside FlexOptions and FlexChildProps; update the module exports to
re-export Align (the exported type from flex.ts) together with FlexOptions and
FlexChildProps so callers can import Align from the same barrel (i.e., add Align
to the list of exported symbols in the index.ts export statement referencing
FlexOptions and FlexChildProps).

---

Duplicate comments:
In `@packages/bijou-node/src/worker/worker.ts`:
- Around line 47-54: The RunWorkerOptions type currently extends RunOptions<any>
but the worker only serializes altScreen, hideCursor, mouse, and css and
rebuilds proxyOptions in runInWorker(), so unsupported fields (ctx, middlewares,
configurePipeline, etc.) are silently ignored; update RunWorkerOptions to be a
worker-safe subset containing only entry, onMessage?, execArgv?, and the four
serializable UI options (altScreen, hideCursor, mouse, css) OR add upfront
validation inside runInWorker() to throw an error when unsupported keys (ctx,
middlewares, configurePipeline and any others from RunOptions) are present
before serialization, and update references to proxyOptions reconstruction
(proxyOptions, buildProxyOptions) so they only depend on the allowed subset.

---

Nitpick comments:
In `@packages/bijou/src/core/layout/flex.test.ts`:
- Around line 4-65: Add the suggested edge-case tests to the calculateFlex test
suite: create new it blocks exercising calculateFlex with (1) an empty children
array to assert it returns a root with empty children and original bounds, (2) a
child using minSize to verify it respects minimum constraint (use both minSize
and maxSize together), (3) non-zero bounds (bounds.x and bounds.y > 0) to ensure
child rect positions are offset correctly, and (4) unequal flex ratios (e.g.,
flex: 2 vs flex: 1) to assert proportional allocation; reference the
calculateFlex function and existing test patterns in flex.test.ts when adding
these cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 631711f9-dd7f-4755-bbb9-efc3bb8ab0d2

📥 Commits

Reviewing files that changed from the base of the PR and between 955cebf and 5e4c9a3.

📒 Files selected for processing (11)
  • docs/CHANGELOG.md
  • packages/bijou-node/src/worker/fixtures/echo-worker.mjs
  • packages/bijou-node/src/worker/worker.test.ts
  • packages/bijou-node/src/worker/worker.ts
  • packages/bijou-tui/src/canvas.test.ts
  • packages/bijou-tui/src/canvas.ts
  • packages/bijou-tui/src/runtime.test.ts
  • packages/bijou-tui/src/runtime.ts
  • packages/bijou/src/core/layout/flex.test.ts
  • packages/bijou/src/core/layout/flex.ts
  • packages/bijou/src/ports/io.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/bijou-node/src/worker/worker.test.ts
  • packages/bijou-tui/src/runtime.test.ts

Comment thread packages/bijou-node/src/worker/worker.ts Outdated
Comment thread packages/bijou-tui/src/runtime.ts
Comment thread packages/bijou-tui/src/runtime.ts
Comment thread packages/bijou/src/core/layout/flex.ts Outdated
Comment thread packages/bijou/src/core/layout/flex.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/bijou-node/src/recorder.ts (1)

312-320: Consider hardening parseHex against invalid hex characters.

If a 6-character string contains non-hex characters (e.g., '#gggggg'), parseInt returns NaN, which becomes 0 when written to Uint8Array. This silently produces black instead of the intended white fallback. Given that inputs typically come from controlled sources (BCSS tokens, user config), this is low risk.

♻️ Optional: Add NaN guard for defensive robustness
 function parseHex(hex: string): Rgb {
   const normalized = hex.replace('#', '');
   if (normalized.length !== 6) return { r: 255, g: 255, b: 255 };
-  return {
-    r: parseInt(normalized.slice(0, 2), 16),
-    g: parseInt(normalized.slice(2, 4), 16),
-    b: parseInt(normalized.slice(4, 6), 16),
-  };
+  const r = parseInt(normalized.slice(0, 2), 16);
+  const g = parseInt(normalized.slice(2, 4), 16);
+  const b = parseInt(normalized.slice(4, 6), 16);
+  if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
+    return { r: 255, g: 255, b: 255 };
+  }
+  return { r, g, b };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bijou-node/src/recorder.ts` around lines 312 - 320, The parseHex
function can silently produce NaNs for non-hex characters; update parseHex to
validate the normalized string contains only 6 hex digits (e.g., a regex like
/^[0-9a-fA-F]{6}$/) before calling parseInt, and return the white fallback ({
r:255,g:255,b:255 }) when validation fails (or alternatively guard each parseInt
result for NaN and use 255 on NaN). Ensure you update the parseHex function and
its return path so invalid input never produces NaN/0 values in the resulting
Rgb.
scripts/smoke-all-examples.ts (2)

309-310: Print a short output tail for failures.

Right now the gate logs mostly stop at FAIL: <reason>. Since result.output is already available, dumping the last few sanitized lines here would make CI/publish failures debuggable without a local rerun.

Suggested improvement
 if (failures.length > 0) {
   process.stdout.write('\nFailures:\n');
   for (const failure of failures) {
     process.stdout.write(`- ${failure.path} [${failure.mode}] ${failure.reason}\n`);
+    if (failure.output) {
+      const tail = failure.output.trimEnd().split('\n').slice(-20).join('\n');
+      if (tail) process.stdout.write(`${tail}\n\n`);
+    }
   }
   process.exitCode = 1;
 }

Also applies to: 313-316

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/smoke-all-examples.ts` around lines 309 - 310, When recording a
failure (where failures.push(result) and process.stdout.write(`FAIL:
${result.reason}\n`) is called), also print a short sanitized tail of
result.output (e.g., last 5–10 non-empty lines) to aid debugging; implement a
helper to sanitize/control length (strip ANSI, trim long lines) and call it to
generate the tail string, then append it to the FAIL message (and replicate the
same change for the other process.stdout.write occurrence that logs failures) so
CI logs include a compact output snippet without dumping full logs.

14-20: The runner is still Unix-bound in discovery and PTY startup.

find, /usr/bin/script, and python3 mean smoke:examples:all can still die before the smoke logic even starts on unsupported environments. Since this script now gates .github/workflows/ci.yml:29-31 and .github/workflows/publish.yml:117-118, I’d keep target discovery inside Node and either use a platform-aware PTY wrapper or explicitly skip unsupported modes per platform.

Also applies to: 235-279

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/smoke-all-examples.ts` around lines 14 - 20, The EXAMPLES discovery
is Unix-bound because it uses execSync('find ...') and the script later spawns
platform-specific PTY helpers; replace the execSync('find ...') usage (symbol:
EXAMPLES) with a Node-native discovery using fs.readdir (or a small glob
library) to recursively find main.ts so paths work on Windows, macOS, and Linux,
and update the PTY startup block (the current PTY/spawn logic) to use a
cross-platform PTY library (e.g., node-pty) or use cross-spawn and explicit
process.platform checks to skip or choose an alternate startup on unsupported
platforms instead of invoking /usr/bin/script or python3; ensure any skipped
runs are clearly logged and that exit codes reflect skips vs failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/CHANGELOG.md`:
- Around line 9-15: Move the four stabilization bullet points currently under
the [Unreleased] heading into the 3.0.0 release notes section so they appear as
part of the v3.0.0 changelog; specifically cut the bullets mentioning
back-buffer syncing, leftover flex units, worker IPC/canvas/mouse changes, and
GIF encoder normalization and paste them under the "3.0.0" heading, then remove
them from the [Unreleased] section (which should remain as v3.0.0...HEAD) so the
3.0.0 notes are complete and Unreleased only contains post-release commits.

In `@scripts/smoke-all-examples.ts`:
- Around line 5-7: The ROOT calculation using dirname(new
URL(import.meta.url).pathname) produces percent-encoded and Windows-drive-path
issues; replace it to use fileURLToPath(import.meta.url) from 'node:url'
instead: import fileURLToPath and call dirname(fileURLToPath(import.meta.url))
when building ROOT with resolve so paths are decoded correctly across platforms
(update the import list and the ROOT constant where dirname/new URL are used).

---

Nitpick comments:
In `@packages/bijou-node/src/recorder.ts`:
- Around line 312-320: The parseHex function can silently produce NaNs for
non-hex characters; update parseHex to validate the normalized string contains
only 6 hex digits (e.g., a regex like /^[0-9a-fA-F]{6}$/) before calling
parseInt, and return the white fallback ({ r:255,g:255,b:255 }) when validation
fails (or alternatively guard each parseInt result for NaN and use 255 on NaN).
Ensure you update the parseHex function and its return path so invalid input
never produces NaN/0 values in the resulting Rgb.

In `@scripts/smoke-all-examples.ts`:
- Around line 309-310: When recording a failure (where failures.push(result) and
process.stdout.write(`FAIL: ${result.reason}\n`) is called), also print a short
sanitized tail of result.output (e.g., last 5–10 non-empty lines) to aid
debugging; implement a helper to sanitize/control length (strip ANSI, trim long
lines) and call it to generate the tail string, then append it to the FAIL
message (and replicate the same change for the other process.stdout.write
occurrence that logs failures) so CI logs include a compact output snippet
without dumping full logs.
- Around line 14-20: The EXAMPLES discovery is Unix-bound because it uses
execSync('find ...') and the script later spawns platform-specific PTY helpers;
replace the execSync('find ...') usage (symbol: EXAMPLES) with a Node-native
discovery using fs.readdir (or a small glob library) to recursively find main.ts
so paths work on Windows, macOS, and Linux, and update the PTY startup block
(the current PTY/spawn logic) to use a cross-platform PTY library (e.g.,
node-pty) or use cross-spawn and explicit process.platform checks to skip or
choose an alternate startup on unsupported platforms instead of invoking
/usr/bin/script or python3; ensure any skipped runs are clearly logged and that
exit codes reflect skips vs failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 364fd32e-2aac-4f82-9f45-03c788d0a2de

📥 Commits

Reviewing files that changed from the base of the PR and between 5e4c9a3 and 95ab776.

📒 Files selected for processing (6)
  • docs/CHANGELOG.md
  • packages/bijou-node/src/gifenc.d.ts
  • packages/bijou-node/src/gifenc.test.ts
  • packages/bijou-node/src/recorder.test.ts
  • packages/bijou-node/src/recorder.ts
  • scripts/smoke-all-examples.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/bijou-node/src/recorder.test.ts

Comment thread docs/CHANGELOG.md Outdated
Comment thread scripts/smoke-all-examples.ts Outdated
@flyingrobots flyingrobots merged commit 8e6e21b into main Mar 12, 2026
4 checks passed
@flyingrobots flyingrobots deleted the feat/v3-complete-evolution branch March 12, 2026 02:59
flyingrobots added a commit that referenced this pull request Apr 10, 2026
Phase A of the RE-017 execution plan. Migrates remaining components
to the theme cache fast path (fgRGB/bgRGB), adds three new bench
scenarios that cover realistic workloads, and captures the Phase A
baseline for measuring Part II.

Component migration (task #38):

- notification.ts: CellTextStyle extended with fgRGB/bgRGB;
  tokenToCellStyle now threads them through.
- overlay.ts: CellStyle type extended; styleFromToken and
  backgroundStyleFromToken updated.
- transition-shaders.ts: tokenCell helper updated.
- app-frame-render.ts: active header tab override path updated.
- Skipped css/text-style.ts (local StyledTextToken has no
  pre-parsed RGB; hot path already caches via parseHex once);
  box-v3.ts fallback (already has setRGB fast path); theme/graph.ts
  (not a rendering hot path).

New bench scenarios (tasks #39, #40, #41):

- diff-sparse: ~10% dirty cells per frame, realistic interactive
  updates. P50 349 µs at baseline.
- diff-static: identical surfaces, zero changes. Reference target
  for II-1 render-dirty bitmap optimization. P50 227 µs — should
  drop to near-zero post-II-1.
- dogfood-realistic: multi-region composition (header+sidebar+
  body+footer). Cross-component regression gate for Part II.
  P50 469 µs.

Phase A baseline (task #43): bench/baselines/HEAD-f966c72-phase-a.json.
30 samples each, all CoVs under 5% except paint-rgb-fixed (7.4%
due to its 145 µs floor where OS jitter dominates). Numbers are
trustworthy.

Obsolete tasks moved to docs/graveyard/:

- I-3 (custom baseline format) — bench v2 already ships one.
- I-0d (render pipeline instrumentation) — I-0e confirmed the
  hex-parse hypothesis without needing general instrumentation.

Full findings and Part II targets documented in
docs/perf/RE-017-byte-pipeline.md.

2,807 tests pass. Typecheck clean across all workspace packages.

Part II targets unlocked by this baseline:
- diff-static: 227 µs -> ~0 µs (II-1 render-dirty bitmap skip)
- diff-gradient: 2.08 ms -> <1.5 ms (II-4 byte pipeline)
- dogfood-realistic: 469 µs -> <350 µs (combined II-1 + II-4)
- diff-sparse: 349 µs -> <200 µs (II-1 + II-4)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant