Skip to content

Refactor to Declarative Component System & Color Spruce-up#14

Open
dylanwatsonsoftware wants to merge 3 commits into
mainfrom
refactor-declarative-components-colors-17122179855656149949
Open

Refactor to Declarative Component System & Color Spruce-up#14
dylanwatsonsoftware wants to merge 3 commits into
mainfrom
refactor-declarative-components-colors-17122179855656149949

Conversation

@dylanwatsonsoftware

@dylanwatsonsoftware dylanwatsonsoftware commented Mar 21, 2026

Copy link
Copy Markdown
Owner

This major refactor transforms BobaTea into a modern, declarative TUI framework inspired by Go's Bubble Tea.

Key changes:

  • Reactive Lifecycle: Components now implement onEvent(BobaEvent): Boolean for state updates and tick(deltaMs: Long) for animations, replacing imperative interaction loops.
  • Unified Run Loop: A cross-platform Boba.run loop in commonMain manages the rendering, event dispatching, and timing, simplifying application development.
  • Enhanced Coordinate System: Components now track their rendered position (x, y) and size (widthPx, heightPx), allowing layout components like Stack and Inline to support reliable mouse hit detection for nested elements.
  • Modern Color Palette: Added a vibrant new color palette in ConsoleColors using RGB support, bringing the visual style closer to the original Bubble Tea framework.
  • Non-Blocking IO: Updated Terminal implementations to support timed event reading, ensuring smooth frame rates and responsive UIs.

The sample applications and WASM demo have been completely rewritten to showcase these new patterns.


PR created automatically by Jules for task 17122179855656149949 started by @dylanwatsonsoftware

Summary by CodeRabbit

  • New Features

    • Six new vibrant console colors (pink, cyan, green, yellow, purple, gray)
    • New event-driven runtime loop for continuous rendering and ticking
    • Components expose callbacks (select, complete, back) for callback-driven interaction
  • Improvements

    • Components track layout (position/size) and propagate events/ticks to children
    • Loading indicator now animates with configurable styles
    • Terminal event reads support timeouts (non-blocking behavior)
    • UI moved from suspend/loop flows to component state machine
  • Style

    • Web badge images constrained to a fixed height for consistent alignment

- Introduce central `Boba.run` loop for unified rendering and event handling
- Refactor `BobaComponent` with `onEvent` and `tick` lifecycle methods
- Update layout components to propagate coordinates and events to children
- Port interactive components to the new declarative model
- Spruce up `ConsoleColors` with Bubble Tea-inspired RGB palette
- Migrate sample and WASM apps to the new API

Co-authored-by: dylanwatsonsoftware <2309644+dylanwatsonsoftware@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@vercel

vercel Bot commented Mar 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bobatea Ready Ready Preview, Comment Mar 21, 2026 9:05am

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR converts Bobatea from suspend-loop-driven components to a component/event-driven architecture: adds per-component layout state and lifecycle hooks, introduces a central Boba.run render–event–tick loop, makes Terminal.readEvent timeout/nullable, and updates many components to use onEvent/tick and report measured width/height.

Changes

Cohort / File(s) Summary
Core Foundation
bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt, bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt
Added layout state (x,y,widthPx,heightPx), lifecycle hooks (onEvent, tick), and Boba.run(terminal, root) with expect currentTimeMillis() for timing.
Platform time impls
bobatea/src/jvmMain/kotlin/.../Boba.jvm.kt, bobatea/src/wasmJsMain/kotlin/.../Boba.wasm.kt
Added actual implementations of currentTimeMillis() for JVM and WASM/JS.
Terminal API & impls
bobatea/src/commonMain/kotlin/.../Terminal.kt, bobatea/src/jvmMain/kotlin/.../JvmTerminal.kt, bobatea/src/wasmJsMain/kotlin/.../WasmTerminal.kt
Changed readEvent()readEvent(timeoutMs: Long = 100): BobaEvent?; JVM/WASM terminals now support timeout and may return null.
Layout containers
bobatea/src/commonMain/kotlin/.../Inline.kt, bobatea/src/commonMain/kotlin/.../Stack.kt
Child render now measures and assigns child x,y,widthPx,heightPx; containers update their own widthPx/heightPx after wrapping; added event/tick propagation to children.
Interactive containers/components
bobatea/src/commonMain/kotlin/.../ExpandableComponent.kt, bobatea/src/commonMain/kotlin/.../BackButton.kt, bobatea/src/commonMain/kotlin/.../LoadingIndicator.kt
Removed suspend-based interact loops; added onEvent handlers and tick where appropriate; render now updates component dimensions and invokes callbacks (onClicked, etc.).
Selection components
bobatea/src/commonMain/kotlin/.../SelectionList.kt, bobatea/src/commonMain/kotlin/.../MultiSelectionList.kt
Replaced interact(terminal) with onEvent; added onSelect/onComplete callbacks; mouse/key handling via onEvent; render updates measured size.
Console theme
bobatea/src/commonMain/kotlin/.../ConsoleColors.kt
Added six RGB color constants (BOBA_PINK, BOBA_CYAN, BOBA_GREEN, BOBA_YELLOW, BOBA_PURPLE, BOBA_GRAY).
Sample & Web demos
sample/src/main/kotlin/.../App.kt, web/src/wasmJsMain/kotlin/.../Main.kt, web/src/wasmJsMain/resources/index.html
Refactored sample and web demos to use component roots (AppRoot/WasmAppRoot), screen enums, builders and onEvent/tick-based components; updated web CSS badges sizing.
Removed legacy helpers/tests
bobatea/src/jvmMain/kotlin/.../Boba.kt (deleted), bobatea/src/jvmTest/kotlin/.../BobaTest.kt
Removed legacy synchronous Boba companion helpers and adjusted tests that referenced them.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant BobaRun as Boba.run()
    participant Terminal
    participant Root as RootComponent
    participant Child as ChildComponent

    loop Render-Tick Loop
        BobaRun->>Terminal: size()
        BobaRun->>Root: render(width,height)
        Root->>Child: render(availableWidth)
        Child-->>Root: rendered string + layout (x,y,widthPx,heightPx)
        Root-->>BobaRun: composed output
        BobaRun->>Terminal: clear & write output

        BobaRun->>Terminal: readEvent(50)
        alt Event received
            Terminal-->>BobaRun: BobaEvent
            BobaRun->>Root: onEvent(event)
            Root->>Child: onEvent(event)
            alt Child handles event
                Child-->>Root: true
            else
                Child-->>Root: false
            end
        else Timeout (null)
            Terminal-->>BobaRun: null
        end

        BobaRun->>Root: tick(deltaMs)
        Root->>Child: tick(deltaMs)
        BobaRun->>BobaRun: delay(10)
    end

    User->>Terminal: keyboard / mouse input
    Terminal-->>User: visual update
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped into the event loop bright,

Buttons listen, ticks keep time at night,
Children measure, borders snug and neat,
Keys and clicks now dance to one heartbeat,
Hooray — a rabbit's render, swift and light!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main refactoring work: migrating to a declarative component system and updating the color palette.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor-declarative-components-colors-17122179855656149949

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 15

Caution

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

⚠️ Outside diff range comments (1)
bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt (1)

28-40: ⚠️ Potential issue | 🟠 Major

runLoading no longer shows a loader.

This public API now just delegates to callback() and ignores terminal, styling, and layout arguments, so existing callers never render a spinner. bobatea/src/jvmTest/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaTest.kt still exercises this path.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt`
around lines 28 - 40, The runLoading function currently bypasses rendering and
simply returns callback(); restore its original behavior by implementing a
suspendable loader lifecycle around the callback: use the runLoading(<message>,
style: LoaderStyle, terminal: Terminal, padding: Int, margin: Int, borderStyle:
BorderStyle, color: String?, callback: suspend () -> R) signature to start a
spinner/loader on the provided Terminal (respecting LoaderStyle, padding,
margin, borderStyle and color), run the callback, and ensure the loader is
stopped/cleared afterwards even on exceptions, then return the callback result;
locate runLoading in LoadingIndicator.kt and wrap callback execution with
start/stop logic so tests in BobaTest.kt that exercise this path will see the
spinner.
🧹 Nitpick comments (2)
bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt (1)

57-61: Keep the leftover elapsed time in tick().

Resetting elapsedMs to 0 drops any extra delay, so long frames make the animation run slower than intended.

Suggested fix
     override fun tick(deltaMs: Long) {
         elapsedMs += deltaMs
-        if (elapsedMs >= 200) {
+        while (elapsedMs >= 200) {
             frameIndex++
-            elapsedMs = 0
+            elapsedMs -= 200
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt`
around lines 57 - 61, In tick(deltaMs: Long) the code currently sets elapsedMs =
0 which discards any leftover time; change the logic in LoadingIndicator.tick to
subtract the frame duration (200 ms) from elapsedMs (and loop/subtract
repeatedly if elapsedMs >= 200) when advancing frameIndex so leftover time
carries over instead of being lost; update references to elapsedMs, frameIndex
and the 200 ms frame duration in that method accordingly.
bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt (1)

33-37: Use the rendered bounds for the back button hitbox.

The current textLength = 16 check already accepts one extra column because of <=, and it will drift if the label or wrapping changes. The button already measures itself in render(), so it would be safer to derive the clickable area from that state instead of a magic width.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt`
around lines 33 - 37, The hitbox uses a hardcoded textLength (16) which will
drift; instead use the actual rendered width computed in render() (e.g., a
measuredWidth / labelWidth field set by BackButton.render) to determine the
clickable X range and replace the magic constant with that field in the click
check (use the same border/padding offsets as already used). Update the
comparison to use the rendered width (event.x < x + ... + renderedWidth) so the
hitbox always matches the drawn button; reference BackButton.render and the
BackButton click handling logic where textLength is currently 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
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt`:
- Around line 52-53: The hit-test for the title uses hard-coded x bounds and
title length which ignores the same inset used vertically; update the
hit-testing logic in ExpandableComponent.kt to compute the title X origin the
same way as titleLine (use borderStyle and padding to compute an xInset/left
offset) and compute the hitbox width from the rendered title string (e.g.,
measure title.length or the rendered width) instead of "title.length + 4"; then
compare event.x against that derived xOrigin and xOrigin + titleWidth so
hover/click align with the displayed title. Ensure you update both the
mouse-check branch(s) that reference x and title.length (including the
comparisons around event and title) to use the new xInset and measured title
width.
- Around line 54-58: The handler for BobaEvent.Key currently toggles expanded on
any SPACE/ENTER and so the first ExpandableComponent steals those keys; change
the condition in the BobaEvent.Key branch so you only toggle expanded when this
component is the intended target (e.g., the component has focus or the event is
routed to it) instead of unconditionally. Concretely, wrap the
SPACE.key/ENTER.key check with a focus/route guard (check your component's focus
state method or event target/routing flag on the incoming BobaEvent.Key) before
flipping expanded so later siblings aren't preempted (symbols to modify:
BobaEvent.Key handling, SPACE.key, ENTER.key, expanded; behavior referenced in
Stack.kt children propagation).

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt`:
- Around line 42-52: In the Inline rendering loop (the children.forEach block in
Inline.kt) set the child's position (child.x and child.y) before invoking
child.render(...) so nested containers compute correct coordinates for their
descendants; move the assignment of child.x = currentX and child.y =
this@Inline.y + padding + border offset to precede the call to
child.render(childAvailableWidth, resolvedHeight), then compute
child.widthPx/heightPx from the rendered output and advance currentX and emit
the cell(Text(renderedChild)) as before.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`:
- Around line 60-77: The onEvent handler in MultiSelectionList uses modulo
arithmetic and toggle/currentIndex access without guarding empty options, which
crashes when options.isEmpty(); fix by short-circuiting when options.isEmpty()
(e.g., at the top of onEvent return false or handle Enter/Q accordingly) or
enforce non-empty lists in the MultiSelectionList constructor (validate options
and throw/require). Specifically, modify MultiSelectionList.onEvent to check
options.isEmpty() before any use of currentIndex, options.size,
toggle(currentIndex) or reading selected, or add a constructor check for
options.isEmpty() and reject/throw to ensure callers cannot pass an empty list.
- Around line 82-89: The mouse handler in MultiSelectionList (inside the
BobaEvent.Mouse branch) assumes one row per option and ignores event.x, causing
incorrect toggles for wrapped options or clicks outside the component; update
the handler to compute the component's top Y (use the same borderStyle/padding
logic already used for rendering), compute each option's rendered height
(accounting for wrapping/line breaks and any per-option rowHeight), build a
mapping from screen Y ranges to option indices, and only toggle when event.x
falls within the component's left/right bounds and event.y maps to a specific
option; modify the BobaEvent.Mouse logic that sets
startLine/clickedIndex/currentIndex and calls toggle(currentIndex) to use this
bounds-checked, wrap-aware mapping instead.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt`:
- Around line 46-60: SelectionList.onEvent currently performs modulo by
options.size and indexes options[currentIndex] which will crash when options is
empty; either validate/reject empty lists when constructing SelectionList (throw
IllegalArgumentException in constructor) or add an early guard at the top of
onEvent (if options.isEmpty() return false or handle Up/Down/Confirm as no-ops)
so navigation and confirm branches never compute % options.size or access
options[currentIndex]; update references to currentIndex, options, and onSelect
accordingly to ensure they are only used when options.isNotEmpty().
- Around line 65-76: The mouse hit-test assumes one-row-per-item and ignores
horizontal bounds; replace the simple clickedIndex = event.y - startLine logic
in the BobaEvent.Mouse branch with a proper per-option bounds check: compute the
actual top and bottom Y for each option by accounting for the prompt height,
borderStyle, padding, and the measured/wrapped height of each option (use
whatever layout/measure function or stored line counts your rendering path
provides), and compute the left/right X bounds from component X, padding and any
scroll/wrap offsets; then iterate options to find the one where event.y is
between its top and bottom and event.x is within its X bounds, only then update
currentIndex or call onSelect(selected); ignore clicks outside those bounds so
clicks outside the component do not select items.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt`:
- Around line 36-46: The child coordinates are being set after calling
child.render, causing nested children to use stale x/y; move the coordinate
assignments so child.x and child.y (computed using this@Stack.x,
this@Stack.padding, borderStyle check, and currentY) are set before invoking
child.render(innerAvailableWidth, resolvedHeight), then compute renderedChild,
lines, widthPx and heightPx, update currentY, and finally call
row(Text(renderedChild)) so hit detection and descendant layout use the correct
positions.

In
`@bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/JvmTerminal.kt`:
- Around line 82-90: readEventWithTimeout currently returns readEventBlocking as
soon as one byte is available which allows the escape-sequence parser
(readEventBlocking / whatever internal parseEscapeSequence function handles
CSI/ESC sequences) to block indefinitely if only a partial sequence is present;
change readEventWithTimeout to propagate the deadline (compute deadline =
startTime + timeoutMs) into the parser by calling a new/overloaded parse
function that accepts the deadline, or modify readEventBlocking to accept a
deadline parameter and perform non-blocking reads until the deadline, and if the
deadline elapses while parsing an escape sequence return a standalone ESC event
(or the best-effort partial event) instead of blocking. Ensure the unique
symbols affected are readEventWithTimeout, readEventBlocking and the
escape-sequence parsing routine so the timeout budget is honored end-to-end.

In `@sample/src/main/kotlin/com/example/App.kt`:
- Around line 79-88: The SelectionList and MultiSelectionList are recreated
every render inside render(), and events are only forwarded to backButton so the
lists never receive input and their cursor state resets; fix by making the
SelectionList and MultiSelectionList persistent (create and store instances
outside render or keep them in App state) instead of rebuilding them each frame
and update render() to forward input/events to those stored instances as well as
to backButton (ensure Screen.SINGLE_SELECT and Screen.MULTI_SELECT branches
reference the persisted SelectionList/MultiSelectionList objects and not freshly
constructed ones).
- Line 105: CoordinatesDemo currently consumes every key event: change the
CoordinatesDemo.onEvent(event) implementation so it returns true only when it
actually handles an input (e.g., an arrow key moved the cursor) and returns
false for unhandled keys; this ensures the Screen.COORDINATES branch expression
(coordinatesDemo.onEvent(event) || backButton.onEvent(event)) allows
backButton.onEvent(event) to run for keys like 'q'. Update the onEvent method in
CoordinatesDemo (and the same logic in the block covering lines ~183–191) to
detect handled keys explicitly and return false otherwise.
- Around line 68-69: The render() method currently calls
kotlin.system.exitProcess(0) when App.currentScreen == Screen.EXIT which
bypasses main()'s finally block and prevents terminal.teardown() from running;
remove that hard exit and instead signal the run loop to stop—either change
render() to return an exit flag or set a shared state that Boba.run() observes
(use App.currentScreen == Screen.EXIT) and have Boba.run() break its event loop
so main()'s finally executes; ensure no direct calls to
kotlin.system.exitProcess(0) remain in render() and that Boba.run() performs the
clean shutdown.

In `@web/src/wasmJsMain/kotlin/Main.kt`:
- Line 66: WasmScreen.EXPANDABLE currently constructs a new ExpandableComponent
each render by calling buildWasmExpandable() twice; create and reuse a single
ExpandableComponent instance instead: instantiate buildWasmExpandable() once
(e.g., val expandable = buildWasmExpandable()) and pass that same expandable
into the Stack and any event handlers rather than calling buildWasmExpandable()
again so the ExpandableComponent instance retains its geometry/state across
renders; update references in the WasmScreen.EXPANDABLE branch (and the
corresponding similar branch at lines ~99) to use the shared expandable variable
and remove duplicate calls.
- Around line 68-75: The SelectionList and MultiSelectionList are being
recreated every render in the WasmScreen.SELECTION and WasmScreen.MULTI branches
which resets their internal state and prevents navigation; instead create and
retain stable instances (or initialize them only when the screen is entered) and
reuse them across frames: move creation of SelectionList and MultiSelectionList
out of the per-frame render branches into persistent properties (or lazily
initialize when currentWasmScreen transitions to WasmScreen.SELECTION /
WasmScreen.MULTI), keep the existing onSelect/onComplete callbacks to set
currentWasmScreen, and continue forwarding events to backButton as before.
- Around line 150-158: In onEvent(event: BobaEvent): Boolean (Main.kt), only
consume the key event when it actually moves the cursor: compute a local flag
(e.g., moved = false) and set it true inside the
KeyCodes.isDown/isUp/isLeft/isRight branches only when xPos or yPos is changed
(for example, when yPos < 7 then yPos++; moved = true). After the when block
return moved instead of unconditionally returning true so non-movement keys
(like 'q') propagate to backButton.onEvent(event).

---

Outside diff comments:
In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt`:
- Around line 28-40: The runLoading function currently bypasses rendering and
simply returns callback(); restore its original behavior by implementing a
suspendable loader lifecycle around the callback: use the runLoading(<message>,
style: LoaderStyle, terminal: Terminal, padding: Int, margin: Int, borderStyle:
BorderStyle, color: String?, callback: suspend () -> R) signature to start a
spinner/loader on the provided Terminal (respecting LoaderStyle, padding,
margin, borderStyle and color), run the callback, and ensure the loader is
stopped/cleared afterwards even on exceptions, then return the callback result;
locate runLoading in LoadingIndicator.kt and wrap callback execution with
start/stop logic so tests in BobaTest.kt that exercise this path will see the
spinner.

---

Nitpick comments:
In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt`:
- Around line 33-37: The hitbox uses a hardcoded textLength (16) which will
drift; instead use the actual rendered width computed in render() (e.g., a
measuredWidth / labelWidth field set by BackButton.render) to determine the
clickable X range and replace the magic constant with that field in the click
check (use the same border/padding offsets as already used). Update the
comparison to use the rendered width (event.x < x + ... + renderedWidth) so the
hitbox always matches the drawn button; reference BackButton.render and the
BackButton click handling logic where textLength is currently used.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt`:
- Around line 57-61: In tick(deltaMs: Long) the code currently sets elapsedMs =
0 which discards any leftover time; change the logic in LoadingIndicator.tick to
subtract the frame duration (200 ms) from elapsedMs (and loop/subtract
repeatedly if elapsedMs >= 200) when advancing frameIndex so leftover time
carries over instead of being lost; update references to elapsedMs, frameIndex
and the 200 ms frame duration in that method accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 004dc7ba-fe3b-49b6-9fea-00a18dcd485d

📥 Commits

Reviewing files that changed from the base of the PR and between 2a9f269 and 01d32e7.

📒 Files selected for processing (19)
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ConsoleColors.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Terminal.kt
  • bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.jvm.kt
  • bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt
  • bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/JvmTerminal.kt
  • bobatea/src/jvmTest/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaTest.kt
  • bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.wasm.kt
  • bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/WasmTerminal.kt
  • sample/src/main/kotlin/com/example/App.kt
  • web/src/wasmJsMain/kotlin/Main.kt
💤 Files with no reviewable changes (2)
  • bobatea/src/jvmTest/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaTest.kt
  • bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt

Comment on lines +54 to +58
is BobaEvent.Key -> {
when (event.code) {
SPACE.key, ENTER.key -> {
expanded = !expanded
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Gate SPACE/ENTER behind focus or parent routing.

Line 56 toggles on every SPACE/ENTER event. In bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt:59-63, children are tried in order and propagation stops on the first true, so the first ExpandableComponent in a container will steal those keys from later interactive siblings.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt`
around lines 54 - 58, The handler for BobaEvent.Key currently toggles expanded
on any SPACE/ENTER and so the first ExpandableComponent steals those keys;
change the condition in the BobaEvent.Key branch so you only toggle expanded
when this component is the intended target (e.g., the component has focus or the
event is routed to it) instead of unconditionally. Concretely, wrap the
SPACE.key/ENTER.key check with a focus/route guard (check your component's focus
state method or event target/routing flag on the incoming BobaEvent.Key) before
flipping expanded so later siblings aren't preempted (symbols to modify:
BobaEvent.Key handling, SPACE.key, ENTER.key, expanded; behavior referenced in
Stack.kt children propagation).

Comment on lines 42 to +52
children.forEach { child ->
cell(Text(child.render(childAvailableWidth, resolvedHeight)))
val renderedChild = child.render(childAvailableWidth, resolvedHeight)
val lines = renderedChild.lines()

child.x = currentX
child.y = this@Inline.y + this@Inline.padding + (if (this@Inline.borderStyle != BorderStyle.NONE) 1 else 0)
child.widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0
child.heightPx = lines.size

currentX += child.widthPx
cell(Text(renderedChild))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Set the child's position before calling render().

Nested containers use their own x/y during render() to position descendants. Rendering first and assigning child.x / child.y afterwards leaves grandchildren with stale coordinates, so nested mouse hit detection drifts.

Suggested fix
                 row {
                     children.forEach { child ->
-                        val renderedChild = child.render(childAvailableWidth, resolvedHeight)
-                        val lines = renderedChild.lines()
-
                         child.x = currentX
                         child.y = this@Inline.y + this@Inline.padding + (if (this@Inline.borderStyle != BorderStyle.NONE) 1 else 0)
+                        val renderedChild = child.render(childAvailableWidth, resolvedHeight)
+                        val lines = renderedChild.lines()
                         child.widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0
                         child.heightPx = lines.size
 
                         currentX += child.widthPx
                         cell(Text(renderedChild))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt`
around lines 42 - 52, In the Inline rendering loop (the children.forEach block
in Inline.kt) set the child's position (child.x and child.y) before invoking
child.render(...) so nested containers compute correct coordinates for their
descendants; move the assignment of child.x = currentX and child.y =
this@Inline.y + padding + border offset to precede the call to
child.render(childAvailableWidth, resolvedHeight), then compute
child.widthPx/heightPx from the rendered output and advance currentX and emit
the cell(Text(renderedChild)) as before.

Comment on lines +60 to +77
override fun onEvent(event: BobaEvent): Boolean {
when (event) {
is BobaEvent.Key -> {
when {
KeyCodes.isUp(event.code) -> {
currentIndex = (currentIndex - 1 + options.size) % options.size
return true
}
KeyCodes.isDown(event.code) -> {
currentIndex = (currentIndex + 1) % options.size
return true
}
is BobaEvent.Mouse -> {
if (event.action == MouseAction.PRESS) {
val clickedIndex = event.y - startLine
if (clickedIndex in options.indices) {
currentIndex = clickedIndex
toggle(currentIndex)
printList()
}
}
event.code == SPACE.key -> {
toggle(currentIndex)
return true
}
event.code == ENTER.key || event.code == 'q'.code || event.code == 'Q'.code -> {
onComplete(selected)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard the empty-options case before modulo/toggle access.

With options = emptyList(), Up/Down evaluates % options.size and Space reaches toggle(0), so the first key press throws. Please either reject empty lists in the constructor or branch around the modulo/index access here.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`
around lines 60 - 77, The onEvent handler in MultiSelectionList uses modulo
arithmetic and toggle/currentIndex access without guarding empty options, which
crashes when options.isEmpty(); fix by short-circuiting when options.isEmpty()
(e.g., at the top of onEvent return false or handle Enter/Q accordingly) or
enforce non-empty lists in the MultiSelectionList constructor (validate options
and throw/require). Specifically, modify MultiSelectionList.onEvent to check
options.isEmpty() before any use of currentIndex, options.size,
toggle(currentIndex) or reading selected, or add a constructor check for
options.isEmpty() and reject/throw to ensure callers cannot pass an empty list.

Comment on lines +82 to +89
is BobaEvent.Mouse -> {
if (event.action == MouseAction.PRESS) {
val startLine = y + (if (borderStyle != BorderStyle.NONE) 1 else 0) + padding + 1
val clickedIndex = event.y - startLine
if (clickedIndex in options.indices) {
currentIndex = clickedIndex
toggle(currentIndex)
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Mouse selection still assumes fixed row geometry.

The click math uses a hard-coded start line and one row per option, and it never checks event.x. Wrapped prompts/options or clicks on the same Y row outside the component will toggle the wrong item.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`
around lines 82 - 89, The mouse handler in MultiSelectionList (inside the
BobaEvent.Mouse branch) assumes one row per option and ignores event.x, causing
incorrect toggles for wrapped options or clicks outside the component; update
the handler to compute the component's top Y (use the same borderStyle/padding
logic already used for rendering), compute each option's rendered height
(accounting for wrapping/line breaks and any per-option rowHeight), build a
mapping from screen Y ranges to option indices, and only toggle when event.x
falls within the component's left/right bounds and event.y maps to a specific
option; modify the BobaEvent.Mouse logic that sets
startLine/clickedIndex/currentIndex and calls toggle(currentIndex) to use this
bounds-checked, wrap-aware mapping instead.

Comment on lines +79 to +88
Screen.SINGLE_SELECT -> Stack(listOf(
SelectionList("Single Selection Demo:", listOf("Option A", "Option B", "Option C"),
onSelect = { App.lastSelection = it; App.currentScreen = Screen.MAIN }),
backButton
))
Screen.MULTI_SELECT -> Stack(listOf(
MultiSelectionList("Multi Selection Demo:", listOf("Red", "Green", "Blue", "Cyan", "Yellow"),
onComplete = { App.lastMultiSelection = it; App.currentScreen = Screen.MAIN }),
backButton
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Single- and multi-select demos never receive input.

These lists are rebuilt inside render(), then Lines 106-107 forward events only to backButton. That makes both screens non-interactive today, and even if you wire them later, recreating the components here will reset their cursor/selection state every frame.

💡 Suggested shape
+        private val singleSelectDemo = SelectionList(
+            "Single Selection Demo:",
+            listOf("Option A", "Option B", "Option C"),
+            onSelect = { App.lastSelection = it; App.currentScreen = Screen.MAIN }
+        )
+        private val multiSelectDemo = MultiSelectionList(
+            "Multi Selection Demo:",
+            listOf("Red", "Green", "Blue", "Cyan", "Yellow"),
+            onComplete = { App.lastMultiSelection = it; App.currentScreen = Screen.MAIN }
+        )
...
-                Screen.SINGLE_SELECT -> Stack(listOf(
-                    SelectionList("Single Selection Demo:", listOf("Option A", "Option B", "Option C"),
-                        onSelect = { App.lastSelection = it; App.currentScreen = Screen.MAIN }),
-                    backButton
-                ))
-                Screen.MULTI_SELECT -> Stack(listOf(
-                    MultiSelectionList("Multi Selection Demo:", listOf("Red", "Green", "Blue", "Cyan", "Yellow"),
-                        onComplete = { App.lastMultiSelection = it; App.currentScreen = Screen.MAIN }),
-                    backButton
-                ))
+                Screen.SINGLE_SELECT -> Stack(listOf(singleSelectDemo, backButton))
+                Screen.MULTI_SELECT -> Stack(listOf(multiSelectDemo, backButton))
...
-                Screen.SINGLE_SELECT -> backButton.onEvent(event)
-                Screen.MULTI_SELECT -> backButton.onEvent(event)
+                Screen.SINGLE_SELECT -> singleSelectDemo.onEvent(event) || backButton.onEvent(event)
+                Screen.MULTI_SELECT -> multiSelectDemo.onEvent(event) || backButton.onEvent(event)

Also applies to: 106-107

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

In `@sample/src/main/kotlin/com/example/App.kt` around lines 79 - 88, The
SelectionList and MultiSelectionList are recreated every render inside render(),
and events are only forwarded to backButton so the lists never receive input and
their cursor state resets; fix by making the SelectionList and
MultiSelectionList persistent (create and store instances outside render or keep
them in App state) instead of rebuilding them each frame and update render() to
forward input/events to those stored instances as well as to backButton (ensure
Screen.SINGLE_SELECT and Screen.MULTI_SELECT branches reference the persisted
SelectionList/MultiSelectionList objects and not freshly constructed ones).

Screen.NESTED -> backButton.onEvent(event)
Screen.MORDANT -> backButton.onEvent(event)
Screen.EXPANDABLE -> expandableLayout.onEvent(event) || backButton.onEvent(event)
Screen.COORDINATES -> coordinatesDemo.onEvent(event) || backButton.onEvent(event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't consume every key in CoordinatesDemo.

Line 191 returns true for all key events, even when no arrow key matched. Because Line 105 uses coordinatesDemo.onEvent(event) || backButton.onEvent(event), q never reaches the back button on this screen.

💡 Minimal fix
     override fun onEvent(event: BobaEvent): Boolean {
-        if (event is BobaEvent.Key) {
-            when {
-                KeyCodes.isDown(event.code) -> yPos++
-                KeyCodes.isUp(event.code) -> yPos--
-                KeyCodes.isLeft(event.code) -> xPos--
-                KeyCodes.isRight(event.code) -> xPos++
-            }
-            return true
-        }
-        return false
+        if (event !is BobaEvent.Key) return false
+        return when {
+            KeyCodes.isDown(event.code) -> { yPos++; true }
+            KeyCodes.isUp(event.code) -> { yPos--; true }
+            KeyCodes.isLeft(event.code) -> { xPos--; true }
+            KeyCodes.isRight(event.code) -> { xPos++; true }
+            else -> false
+        }
     }

Also applies to: 183-191

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

In `@sample/src/main/kotlin/com/example/App.kt` at line 105, CoordinatesDemo
currently consumes every key event: change the CoordinatesDemo.onEvent(event)
implementation so it returns true only when it actually handles an input (e.g.,
an arrow key moved the cursor) and returns false for unhandled keys; this
ensures the Screen.COORDINATES branch expression (coordinatesDemo.onEvent(event)
|| backButton.onEvent(event)) allows backButton.onEvent(event) to run for keys
like 'q'. Update the onEvent method in CoordinatesDemo (and the same logic in
the block covering lines ~183–191) to detect handled keys explicitly and return
false otherwise.

WasmScreen.MENU -> mainMenu
WasmScreen.LAYOUT -> Stack(listOf(buildWasmLayout(), backButton))
WasmScreen.MORDANT -> Stack(listOf(buildWasmMordant(), backButton))
WasmScreen.EXPANDABLE -> Stack(listOf(buildWasmExpandable(), backButton))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reuse one ExpandableComponent instance for the screen.

Both branches call buildWasmExpandable() anew. The event path then targets a fresh component that has never been rendered, so its geometry/state is discarded immediately and the demo cannot stay expanded.

Also applies to: 99-99

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

In `@web/src/wasmJsMain/kotlin/Main.kt` at line 66, WasmScreen.EXPANDABLE
currently constructs a new ExpandableComponent each render by calling
buildWasmExpandable() twice; create and reuse a single ExpandableComponent
instance instead: instantiate buildWasmExpandable() once (e.g., val expandable =
buildWasmExpandable()) and pass that same expandable into the Stack and any
event handlers rather than calling buildWasmExpandable() again so the
ExpandableComponent instance retains its geometry/state across renders; update
references in the WasmScreen.EXPANDABLE branch (and the corresponding similar
branch at lines ~99) to use the shared expandable variable and remove duplicate
calls.

Comment on lines +68 to +75
WasmScreen.SELECTION -> Stack(listOf(
SelectionList("What's your favourite Boba?", listOf("Milk Tea", "Matcha", "Taro", "Brown Sugar"), onSelect = { currentWasmScreen = WasmScreen.MENU }),
backButton
))
WasmScreen.MULTI -> Stack(listOf(
MultiSelectionList("Toppings:", listOf("Pearls", "Grass Jelly", "Pudding", "Aloe Vera", "Red Bean"), onComplete = { currentWasmScreen = WasmScreen.MENU }),
backButton
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

The selection demo screens never receive their own input.

Each render builds a brand-new SelectionList / MultiSelectionList, and onEvent() only forwards to backButton. That resets list state every frame and makes the screens impossible to navigate or complete. These components need a stable instance across frames, or at least recreation on screen entry rather than on every render.

Also applies to: 101-102

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

In `@web/src/wasmJsMain/kotlin/Main.kt` around lines 68 - 75, The SelectionList
and MultiSelectionList are being recreated every render in the
WasmScreen.SELECTION and WasmScreen.MULTI branches which resets their internal
state and prevents navigation; instead create and retain stable instances (or
initialize them only when the screen is entered) and reuse them across frames:
move creation of SelectionList and MultiSelectionList out of the per-frame
render branches into persistent properties (or lazily initialize when
currentWasmScreen transitions to WasmScreen.SELECTION / WasmScreen.MULTI), keep
the existing onSelect/onComplete callbacks to set currentWasmScreen, and
continue forwarding events to backButton as before.

Comment on lines +150 to +158
override fun onEvent(event: BobaEvent): Boolean {
if (event is BobaEvent.Key) {
when {
KeyCodes.isDown(event.code) -> if (y < 7) render(x, ++y)
KeyCodes.isUp(event.code) -> if (y > 0) render(x, --y)
KeyCodes.isLeft(event.code) -> if (x > 0) render(--x, y)
KeyCodes.isRight(event.code) -> if (x < 7) render(++x, y)
event.code == KeyCodes.ENTER.key || event.code == KeyCodes.SPACE.key -> {
terminal.clear()
terminal.write("${color("Step 3: Custom Navigation", YELLOW)}\n")
terminal.write("Confirmed position: $x, $y\n")
return
}
KeyCodes.isDown(event.code) -> if (yPos < 7) yPos++
KeyCodes.isUp(event.code) -> if (yPos > 0) yPos--
KeyCodes.isLeft(event.code) -> if (xPos > 0) xPos--
KeyCodes.isRight(event.code) -> if (xPos < 7) xPos++
}
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only consume keys that actually move the cursor.

The unconditional return true swallows every key, including q, so backButton.onEvent(event) never runs on the coordinates screen.

Suggested fix
     override fun onEvent(event: BobaEvent): Boolean {
         if (event is BobaEvent.Key) {
-            when {
-                KeyCodes.isDown(event.code) -> if (yPos < 7) yPos++
-                KeyCodes.isUp(event.code) -> if (yPos > 0) yPos--
-                KeyCodes.isLeft(event.code) -> if (xPos > 0) xPos--
-                KeyCodes.isRight(event.code) -> if (xPos < 7) xPos++
-            }
-            return true
+            return when {
+                KeyCodes.isDown(event.code) -> { if (yPos < 7) yPos++; true }
+                KeyCodes.isUp(event.code) -> { if (yPos > 0) yPos--; true }
+                KeyCodes.isLeft(event.code) -> { if (xPos > 0) xPos--; true }
+                KeyCodes.isRight(event.code) -> { if (xPos < 7) xPos++; true }
+                else -> false
+            }
         }
         return false
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/wasmJsMain/kotlin/Main.kt` around lines 150 - 158, In onEvent(event:
BobaEvent): Boolean (Main.kt), only consume the key event when it actually moves
the cursor: compute a local flag (e.g., moved = false) and set it true inside
the KeyCodes.isDown/isUp/isLeft/isRight branches only when xPos or yPos is
changed (for example, when yPos < 7 then yPos++; moved = true). After the when
block return moved instead of unconditionally returning true so non-movement
keys (like 'q') propagate to backButton.onEvent(event).

… fix mouse events

- Introduce central `Boba.run` loop for unified rendering and event handling
- Refactor `BobaComponent` with `onEvent` and `tick` lifecycle methods
- Update layout components to propagate coordinates and events to children
- Port interactive components to the new declarative model
- Spruce up `ConsoleColors` with Bubble Tea-inspired RGB palette
- Migrate sample and WASM apps to the new API
- Fix mouse tracking by enabling it in the core loop and correcting coordinate offsets
- Fix Java 11 badge sizing on the landing page

Co-authored-by: dylanwatsonsoftware <2309644+dylanwatsonsoftware@users.noreply.github.com>
… fix UI/UX issues

- Introduce central `Boba.run` loop for unified rendering and event handling
- Refactor `BobaComponent` with `onEvent` and `tick` lifecycle methods
- Add `getContentStartX()` and `getContentStartY()` for centralized coordinate logic
- Update layout components to propagate coordinates and events to children
- Port interactive components to the new declarative model and fix hit detection
- Spruce up `ConsoleColors` with Bubble Tea-inspired RGB palette
- Migrate sample and WASM apps to the new API
- Fix Java 11 badge sizing and mobile responsiveness on the landing page

Co-authored-by: dylanwatsonsoftware <2309644+dylanwatsonsoftware@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (6)
bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt (2)

46-60: ⚠️ Potential issue | 🟠 Major

Guard empty options before navigating or confirming.

Up/Down still use % options.size, and confirm still reads options[currentIndex], so the first key event throws when the list is empty. Reject empty lists up front or short-circuit these paths.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt`
around lines 46 - 60, The onEvent handler doesn't guard against empty options
causing modulo-by-zero or index-out-of-bounds when handling Key events; update
onEvent (in SelectionList) to first check options.isEmpty() and return false (or
no-op) for navigation/confirmation keys, or alternatively clamp currentIndex and
skip onSelect when options.isEmpty(); ensure any use of currentIndex,
options.size and options[currentIndex] (inside onEvent and where
ENTER/SPACE/'q'/'Q' are handled) is protected by that guard so no division by
zero or invalid indexing occurs.

65-76: ⚠️ Potential issue | 🟠 Major

Mouse selection still uses fixed row math.

clickedIndex = event.y - startLine assumes the question and every option occupy exactly one visual row, and it never checks event.x. Once the question/options wrap, clicks outside the component or on wrapped rows can select the wrong item.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt`
around lines 65 - 76, The mouse handling in the
BobaEvent.Mouse/MouseAction.PRESS branch incorrectly computes clickedIndex using
a fixed row subtraction (clickedIndex = event.y - startLine), which breaks when
the question or options wrap and ignores event.x; fix it by mapping the mouse y
to the actual visual line of the component using the layout/line metrics rather
than assuming one row per item: compute the number of visual lines occupied by
getContentStartY() (question) and for each option in options calculate its
wrapped line count (using the same width/wrapping logic as rendering) to
determine the cumulative start/end line ranges, then check event.x is inside the
component bounds and find which option range contains event.y to set
currentIndex or call onSelect(selected); update the logic in the block
referencing clickedIndex/currentIndex/onSelect/getContentStartY() accordingly so
wrapped rows and out-of-bounds clicks are handled correctly.
bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt (2)

55-59: ⚠️ Potential issue | 🟠 Major

Don't consume SPACE/ENTER unless this component is actually targeted.

This still flips expanded on every SPACE/ENTER, so the first ExpandableComponent that sees the event can preempt later interactive siblings. Gate the toggle behind focus/targeted routing and return false otherwise.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt`
around lines 55 - 59, The key handler in ExpandableComponent currently toggles
expanded for any BobaEvent.Key with SPACE/ENTER regardless of focus; change the
logic in the BobaEvent.Key branch to first check whether this component is the
focused/targeted receiver (use whatever focus/targeted flag or function the UI
framework provides — e.g. isFocused, isTargeted, or a hit-test/target check) and
only flip expanded = !expanded and return true when that check passes; if the
component is not targeted, do not consume the event and return false so later
siblings can handle SPACE/ENTER.

44-47: ⚠️ Potential issue | 🟠 Major

Header hit-testing still assumes a single unwrapped row.

render() wraps the component first, but currentlyHovered still uses event.y == titleLine and a flat character count. Once the header wraps inside a narrow layout, only the first visual row remains hoverable/clickable.

Also applies to: 52-65

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt`
around lines 44 - 47, Header hit-testing in currentlyHovered incorrectly assumes
the title is a single unwrapped row; compute the wrapped title box the same way
render() does (use wrapInBox(title.toString().trimEnd('\n'), availableWidth,
availableHeight) or reuse the wrapped result variable) and determine the
header's visual row range and per-line visible lengths with visibleLength so you
check event.y against the title's wrapped row indices and event.x against the
corresponding wrapped line's visible length; update the logic in
currentlyHovered (and the similar logic around lines 52-65) to use that
wrapped-title row range and per-line widths instead of titleLine and a flat
character count.
bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt (2)

64-74: ⚠️ Potential issue | 🟠 Major

Guard empty options before navigating or toggling.

Up/Down still do % options.size, and SPACE still reaches toggle(currentIndex) with no empty-list guard. options = emptyList() will throw as soon as the user navigates or toggles.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`
around lines 64 - 74, Guard against options.isEmpty() in the key handling
branches so navigation and toggling never perform modulo by zero or call toggle
on an empty list: in the KeyCodes.isUp and KeyCodes.isDown branches, check if
options.isEmpty() and if so skip updating currentIndex and return false (or
otherwise consume appropriately), otherwise compute currentIndex = (currentIndex
+/- 1 + options.size) % options.size; also in the event.code == SPACE.key
branch, check options.isEmpty() before calling toggle(currentIndex) and
skip/return false when empty. Ensure you reference and modify the existing
currentIndex, options, toggle(...) logic inside MultiSelectionList's key
handler.

82-89: ⚠️ Potential issue | 🟠 Major

Mouse toggling still uses fixed row math.

clickedIndex = event.y - startLine assumes one visual row per option and never checks event.x. Wrapped question/item text or clicks outside the component can toggle the wrong entry.

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`
around lines 82 - 89, The mouse handler in BobaEvent.Mouse (MouseAction.PRESS)
currently computes clickedIndex as event.y - getContentStartY() which assumes
one row per option and ignores event.x; change the hit detection to (1) verify
event.x is within the component/content width before accepting a click, and (2)
map the y coordinate to the correct option by iterating the rendered layout (use
the same logic that performs word-wrapping/line measurement for options—e.g.,
compute per-option rendered heights or row offsets used by getContentStartY())
to find the option whose vertical span contains event.y, then set currentIndex
and call toggle(currentIndex) only when that mapping succeeds. Ensure you update
the code around currentIndex, toggle(), getContentStartY(), and the
BobaEvent.Mouse branch to use this mapping instead of the fixed clickedIndex
calculation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt`:
- Around line 21-25: The hitbox is hard-coded to one row and 16 cells; change
onEvent to use the measured bounds produced by render/wrapInBox instead of fixed
values: use the computed widthPx and heightPx (and any visibleLength logic)
returned after calling wrapInBox(styled, availableWidth, availableHeight) to
determine the button rectangle and test the incoming click/touch coordinates
against that rectangle; update both occurrences (the onEvent handling around the
first render and the second occurrence at lines 33-37) so they check x in [x0,
x0+widthPx) and y in [y0, y0+heightPx) rather than assuming height=1 and
width=16. Ensure you reference render(), wrapInBox(...), onEvent, widthPx and
heightPx when making the change.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt`:
- Around line 23-33: The idle loop currently uses terminal.readEvent(50) plus
delay(10) which forces no-input frames to ~60ms; instead consolidate to a single
per-frame budget: introduce a targetFrameMs (e.g. 16L) and measure elapsed time
at frame start, call terminal.readEvent(remainingTimeout) where remainingTimeout
= max(0, targetFrameMs - elapsedSoFar), then call root.onEvent(event) and
root.tick(now - lastTick) and only call delay(remainingDelay) if you still have
positive remaining time after event polling and ticking; update uses of
terminal.readEvent, root.tick, lastTick and delay to implement this single-frame
budget behavior.
- Line 10: The frame-timing uses wall-clock time (lastTick =
currentTimeMillis()) which can jump; update both platform-specific
implementations in Boba.kt to use monotonic clocks: on JVM replace
System.currentTimeMillis()/currentTimeMillis() with System.nanoTime() and on
WASM replace Date.now() with performance.now(); keep lastTick and delta
calculations consistent by storing the same unit on both sides (e.g., use
nanoseconds everywhere or convert nano→ms when computing delta) and update any
delta math that uses lastTick so it no longer produces negative/large values
after clock changes.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`:
- Around line 76-77: The onComplete callback is being passed the live MutableSet
"selected" which can be mutated after completion; change the call in the
key-handling branch (the block that checks event.code == ENTER.key || event.code
== 'q'.code || event.code == 'Q'.code) to pass a snapshot instead by invoking
onComplete(selected.toSet()) so the caller receives an immutable copy rather
than the component's live MutableSet.

---

Duplicate comments:
In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt`:
- Around line 55-59: The key handler in ExpandableComponent currently toggles
expanded for any BobaEvent.Key with SPACE/ENTER regardless of focus; change the
logic in the BobaEvent.Key branch to first check whether this component is the
focused/targeted receiver (use whatever focus/targeted flag or function the UI
framework provides — e.g. isFocused, isTargeted, or a hit-test/target check) and
only flip expanded = !expanded and return true when that check passes; if the
component is not targeted, do not consume the event and return false so later
siblings can handle SPACE/ENTER.
- Around line 44-47: Header hit-testing in currentlyHovered incorrectly assumes
the title is a single unwrapped row; compute the wrapped title box the same way
render() does (use wrapInBox(title.toString().trimEnd('\n'), availableWidth,
availableHeight) or reuse the wrapped result variable) and determine the
header's visual row range and per-line visible lengths with visibleLength so you
check event.y against the title's wrapped row indices and event.x against the
corresponding wrapped line's visible length; update the logic in
currentlyHovered (and the similar logic around lines 52-65) to use that
wrapped-title row range and per-line widths instead of titleLine and a flat
character count.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`:
- Around line 64-74: Guard against options.isEmpty() in the key handling
branches so navigation and toggling never perform modulo by zero or call toggle
on an empty list: in the KeyCodes.isUp and KeyCodes.isDown branches, check if
options.isEmpty() and if so skip updating currentIndex and return false (or
otherwise consume appropriately), otherwise compute currentIndex = (currentIndex
+/- 1 + options.size) % options.size; also in the event.code == SPACE.key
branch, check options.isEmpty() before calling toggle(currentIndex) and
skip/return false when empty. Ensure you reference and modify the existing
currentIndex, options, toggle(...) logic inside MultiSelectionList's key
handler.
- Around line 82-89: The mouse handler in BobaEvent.Mouse (MouseAction.PRESS)
currently computes clickedIndex as event.y - getContentStartY() which assumes
one row per option and ignores event.x; change the hit detection to (1) verify
event.x is within the component/content width before accepting a click, and (2)
map the y coordinate to the correct option by iterating the rendered layout (use
the same logic that performs word-wrapping/line measurement for options—e.g.,
compute per-option rendered heights or row offsets used by getContentStartY())
to find the option whose vertical span contains event.y, then set currentIndex
and call toggle(currentIndex) only when that mapping succeeds. Ensure you update
the code around currentIndex, toggle(), getContentStartY(), and the
BobaEvent.Mouse branch to use this mapping instead of the fixed clickedIndex
calculation.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt`:
- Around line 46-60: The onEvent handler doesn't guard against empty options
causing modulo-by-zero or index-out-of-bounds when handling Key events; update
onEvent (in SelectionList) to first check options.isEmpty() and return false (or
no-op) for navigation/confirmation keys, or alternatively clamp currentIndex and
skip onSelect when options.isEmpty(); ensure any use of currentIndex,
options.size and options[currentIndex] (inside onEvent and where
ENTER/SPACE/'q'/'Q' are handled) is protected by that guard so no division by
zero or invalid indexing occurs.
- Around line 65-76: The mouse handling in the BobaEvent.Mouse/MouseAction.PRESS
branch incorrectly computes clickedIndex using a fixed row subtraction
(clickedIndex = event.y - startLine), which breaks when the question or options
wrap and ignores event.x; fix it by mapping the mouse y to the actual visual
line of the component using the layout/line metrics rather than assuming one row
per item: compute the number of visual lines occupied by getContentStartY()
(question) and for each option in options calculate its wrapped line count
(using the same width/wrapping logic as rendering) to determine the cumulative
start/end line ranges, then check event.x is inside the component bounds and
find which option range contains event.y to set currentIndex or call
onSelect(selected); update the logic in the block referencing
clickedIndex/currentIndex/onSelect/getContentStartY() accordingly so wrapped
rows and out-of-bounds clicks are handled correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa8145a5-c5e5-4a8e-bd04-eaf793536e60

📥 Commits

Reviewing files that changed from the base of the PR and between 01d32e7 and a7d71ef.

📒 Files selected for processing (7)
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt
  • web/src/wasmJsMain/resources/index.html
✅ Files skipped from review due to trivial changes (1)
  • web/src/wasmJsMain/resources/index.html
🚧 Files skipped from review as they are similar to previous changes (1)
  • bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt

Comment on lines +21 to +25
val result = wrapInBox(styled, availableWidth, availableHeight)
val lines = result.lines()
widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0
heightPx = lines.size
return result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

The back-button hitbox is still a fixed one-row, 16-cell box.

render() goes through wrapInBox(...), but onEvent assumes the label always occupies one row and exactly 16 cells. That can miss clicks on narrow layouts and on terminals where renders wider than one cell. Reuse the label's measured bounds instead of hard-coding them.

Also applies to: 33-37

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

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt`
around lines 21 - 25, The hitbox is hard-coded to one row and 16 cells; change
onEvent to use the measured bounds produced by render/wrapInBox instead of fixed
values: use the computed widthPx and heightPx (and any visibleLength logic)
returned after calling wrapInBox(styled, availableWidth, availableHeight) to
determine the button rectangle and test the incoming click/touch coordinates
against that rectangle; update both occurrences (the onEvent handling around the
first render and the second occurrence at lines 33-37) so they check x in [x0,
x0+widthPx) and y in [y0, y0+heightPx) rather than assuming height=1 and
width=16. Ensure you reference render(), wrapInBox(...), onEvent, widthPx and
heightPx when making the change.

suspend fun run(terminal: Terminal, root: BobaComponent) {
terminal.clear()
terminal.enableMouseTracking(true)
var lastTick = currentTimeMillis()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd 'Boba\.(jvm|wasm)\.kt$' bobatea/src | while read -r f; do
  echo "== $f =="
  sed -n '1,120p' "$f"
done
rg -n 'currentTimeMillis\(' bobatea/src

Repository: dylanwatsonsoftware/bobatea

Length of output: 1544


Use monotonic time for frame-delta calculations instead of wall-clock time.

Both actual implementations use wall-clock APIs—JVM uses System.currentTimeMillis() and WASM uses Date.now()—which can jump backward after system clock adjustments. This causes frame deltas to become negative or unexpectedly large. Use System.nanoTime() on JVM and performance.now() on WASM instead.

Affected: lines 10, 28-30, 42 in Boba.kt

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

In `@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt`
at line 10, The frame-timing uses wall-clock time (lastTick =
currentTimeMillis()) which can jump; update both platform-specific
implementations in Boba.kt to use monotonic clocks: on JVM replace
System.currentTimeMillis()/currentTimeMillis() with System.nanoTime() and on
WASM replace Date.now() with performance.now(); keep lastTick and delta
calculations consistent by storing the same unit on both sides (e.g., use
nanoseconds everywhere or convert nano→ms when computing delta) and update any
delta math that uses lastTick so it no longer produces negative/large values
after clock changes.

Comment on lines +23 to +33
val event = terminal.readEvent(50)
if (event != null) {
root.onEvent(event)
}

val now = currentTimeMillis()
root.tick(now - lastTick)
lastTick = now

// Yield to other coroutines
delay(10)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The idle loop is capped at roughly 16 FPS.

readEvent(50) and then delay(10) make no-input frames take at least ~60 ms, so tick-driven animations will only update around 16 times per second. Fold both waits into a single frame budget, or make the extra delay conditional on how long event polling already blocked.

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

In `@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt`
around lines 23 - 33, The idle loop currently uses terminal.readEvent(50) plus
delay(10) which forces no-input frames to ~60ms; instead consolidate to a single
per-frame budget: introduce a targetFrameMs (e.g. 16L) and measure elapsed time
at frame start, call terminal.readEvent(remainingTimeout) where remainingTimeout
= max(0, targetFrameMs - elapsedSoFar), then call root.onEvent(event) and
root.tick(now - lastTick) and only call delay(remainingDelay) if you still have
positive remaining time after event polling and ticking; update uses of
terminal.readEvent, root.tick, lastTick and delay to implement this single-frame
budget behavior.

Comment on lines +76 to +77
event.code == ENTER.key || event.code == 'q'.code || event.code == 'Q'.code -> {
onComplete(selected)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Pass a snapshot to onComplete.

selected is the component's live MutableSet. Handing that same instance to the callback lets later toggles mutate the caller's "completed" result. Send selected.toSet() instead.

Suggested change
-                        onComplete(selected)
+                        onComplete(selected.toSet())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
event.code == ENTER.key || event.code == 'q'.code || event.code == 'Q'.code -> {
onComplete(selected)
event.code == ENTER.key || event.code == 'q'.code || event.code == 'Q'.code -> {
onComplete(selected.toSet())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt`
around lines 76 - 77, The onComplete callback is being passed the live
MutableSet "selected" which can be mutated after completion; change the call in
the key-handling branch (the block that checks event.code == ENTER.key ||
event.code == 'q'.code || event.code == 'Q'.code) to pass a snapshot instead by
invoking onComplete(selected.toSet()) so the caller receives an immutable copy
rather than the component's live MutableSet.

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