Skip to main content

lib/bb/tui/app.ex

defmodule BB.TUI.App do
  @moduledoc """
  Main TUI application using the `ExRatatui.App` **reducer runtime**.

  Renders the dashboard layout and handles every keyboard event,
  PubSub message, and side effect through a single `update/2` arrow.
  Pure state transitions live in `BB.TUI.State`; this module's job is
  to wire input and async results to those transitions and return
  declarative `ExRatatui.Command` values for IO.

  ## Layout

      ┌ Safety ────────┬─ Joint Control ──────────────────────────────┐
      │ ● ARMED        │ Joint       Type  Position  Range           │
      │ Runtime: Idle  │ elbow       rev   -63.8°    ████████░░░░░░  │
      │ [a] Arm        │ gripper SIM pri    30.6mm   ███░░░░░░░░░░░  │
      │ [d] Disarm     │ ...                                         │
      ├ Commands ──────┤                                             │
      │ ▶ home   Ready │                                             │
      │   calibrate    │                                             │
      ├ Events (47) ───┴── Parameters ───────────────────────────────┤
      │ 18:23:12 sensor.sim  │ speed              100               │
      │ 18:23:11 state_m...  │ controller.kp      0.5               │
      └──────────────────────┴───────────────────────────────────────┘
       Robot | ● Armed | idle | [q]Quit [Tab]Panel [?]Help

  ## Reducer callbacks

    * `init/1` — validates the robot module, subscribes to PubSub
      (`{:bb, _, _}` messages flow into `update/2` as `{:info, _}`
      automatically), and snapshots ETS state. No `Task.Supervisor`
      is required: long-running command execution is owned by the
      runtime via `ExRatatui.Command.async/2`.
    * `render/2` — composes panel functions into a flat
      `[{widget, rect}]` list (the events panel contributes two panes:
      the list and an overlay `ExRatatui.Widgets.Scrollbar`).
    * `update/2` — the single dispatch arrow. Receives `{:event, ev}`
      for terminal input and `{:info, msg}` for everything else
      (PubSub, async results, subscription ticks, `send_after`
      messages). Returns `{:noreply, state}` for pure transitions or
      `{:noreply, state, commands: [cmd]}` when an effect should fire.
    * `subscriptions/1` — declares the throbber tick interval whenever
      the dashboard has something animating (a `:disarming` safety
      state or a command currently executing), and a one-shot
      `:sensor_flush` tick whenever a sensor render is pending (see
      *High-rate sensor handling*). The runtime diffs the result
      against the previous one, so timers only run when needed. This
      replaces the previously-dormant `Process.send_after`-style
      throbber tick.

  ## High-rate sensor handling

  Robot sensor readings arrive on the `[:sensor | _]` path and can be
  very fast. Two mechanisms keep the UI responsive without dropping
  meaningful information:

    * `BB.TUI.State.append_event/3` debounces the event log — a repeat
      of the same `{path, payload-type}` within `throttle.debounce_ms`
      (default 1s) is dropped, so one fast sensor cannot evict every
      other event from the 100-entry log.
    * Sensor messages update state but suppress their immediate render
      (`render?: false`); a one-shot `:sensor_flush` tick
      (`throttle.flush_ms`, default ~33ms / 30fps) armed by
      `subscriptions/1` performs a single coalesced redraw. Every other
      message — key presses, command results, safety/param/state
      events — still renders immediately.

  Both intervals live in the `BB.TUI.State.Throttle` substruct, so tests
  can shrink or disable them (a debounce window of `0` disables it).

  ## Async commands

  Pressing Enter on a Ready command executes it when the command has
  no arguments, or enters an inline argument-edit mode when the
  command declares arguments. From edit mode, Tab/Shift+Tab cycle
  fields, typing edits the focused field, Enter executes with the
  parsed values, and Esc exits without executing.

  Execution returns a `Command.async/2` that calls
  `BB.Command.await/2`, which waits on the spawned command via
  `GenServer.call`, falls back to bb's `ResultCache` if the handler
  finishes before we can await, and enforces the timeout internally.
  The result arrives as a single `{:command_result, _}` info message
  (success, error, or `{:error, :timeout}`).

  ## Side-effect convention

  Fast, fire-and-forget calls (`Robot.arm/2`, `Robot.disarm/2`,
  `Robot.set_actuator/4`, `Robot.set_parameter/4`,
  `Robot.publish/4`, `Robot.force_disarm/2`) are invoked inline from
  `update/2` rather than wrapped in a `Command.async/2`. They are
  effectively constant-time PubSub publishes; the boilerplate of
  routing through a no-op result mapper would dwarf the call. Only
  `Robot.execute_command/4`, which monitors a spawned command process
  and waits for its `:DOWN`, goes through `Command.async/2`.

  ## Configuration

  The wait window for `BB.Command.await/2` is compile-time configurable
  via `Application.compile_env/3`:

      # config/config.exs
      config :bb_tui, command_timeout: 30_000

  Default is `30_000` ms. The test suite overrides this to `100` ms in
  `config/test.exs` to keep timeout assertions snappy. Because the
  value is read with `compile_env`, downstream apps need to recompile
  `:bb_tui` after changing the config (`mix deps.compile bb_tui
  --force`).
  """

  use ExRatatui.App, runtime: :reducer

  alias BB.Robot.Joint
  alias BB.TUI.Panels
  alias BB.TUI.Robot
  alias BB.TUI.State
  alias ExRatatui.Command
  alias ExRatatui.Event
  alias ExRatatui.Layout
  alias ExRatatui.Layout.Rect
  alias ExRatatui.Subscription

  @command_timeout Application.compile_env(:bb_tui, :command_timeout, 30_000)

  # Visualization-tab camera step sizes (radians / world units per keypress).
  @viz_orbit 0.15
  @viz_zoom 0.1

  # ── Init ──────────────────────────────────────────────────────

  @impl true
  def init(opts) do
    robot = Keyword.fetch!(opts, :robot)
    node = Keyword.get(opts, :node)

    unless Code.ensure_loaded?(robot) and
             function_exported?(robot, :robot, 0) and
             function_exported?(robot, :spark_dsl_config, 0) do
      raise ArgumentError, "#{inspect(robot)} is not a valid BB robot module"
    end

    paths =
      Keyword.get(opts, :subscribe_paths, [
        [:state_machine],
        [:sensor],
        [:param],
        [:actuator],
        [:command],
        [:safety],
        [:estimator]
      ])

    Robot.subscribe(robot, paths, node)

    renderers = Keyword.get(opts, :renderers, %{})

    robot_struct = Robot.get_robot(robot, node)
    positions = Robot.positions(robot, node)

    joints =
      robot_struct
      |> BB.Robot.joints_in_order()
      |> Enum.filter(&Joint.movable?/1)
      |> Map.new(&{&1.name, %{joint: &1, position: positions[&1.name] || 0.0, target: nil}})

    commands = Robot.discover_commands(robot, node)
    bridges = Robot.list_bridges(robot, node)

    state =
      %State{
        robot: robot,
        robot_struct: robot_struct,
        node: node,
        safety: %State.Safety{
          state: Robot.safety_state(robot, node),
          runtime: Robot.runtime_state(robot, node)
        },
        joints: %State.Joints{entries: joints},
        commands: %State.Commands{available: commands},
        renderers: renderers
      }
      |> State.update_parameters(Robot.list_parameters(robot, [], node))
      |> State.set_parameter_tabs(bridges)

    # Probe the terminal once on mount so the Visualization tab's pixel
    # render modes (Kitty / Sixel / iTerm2) and `render_mode: :auto` pick up
    # the real protocol and cell pixel size. Without this the font size
    # defaults to 8x16, sizing the rendered image far too small and anchoring
    # it in the pane's corner. Soft-fails (no TTY) and is skipped under
    # test_mode, so it has no effect on the suite.
    {:ok, state, probe_image_protocol: true}
  end

  # ── Render ────────────────────────────────────────────────────

  @impl true
  def render(state, frame) do
    full = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}

    # Title bar + main area + status bar
    [title_bar_area, main, status_bar_area] =
      Layout.split(full, :vertical, [
        {:length, 1},
        {:min, 0},
        {:length, 1}
      ])

    # Title bar: brand on the left, top-level tabs on the right
    [brand_area, tabs_area] =
      Layout.split(title_bar_area, :horizontal, [
        {:min, 0},
        {:length, 32}
      ])

    chrome = [
      {Panels.TitleBar.render(state), brand_area},
      {Panels.TabBar.render(state.ui.active_tab), tabs_area},
      {Panels.StatusBar.render(state), status_bar_area}
    ]

    maybe_add_popup(chrome ++ render_body(state, main), state, full)
  end

  defp render_body(%{ui: %{active_tab: :visualization}} = state, main) do
    Panels.Visualization.render_panes(state, main)
  end

  defp render_body(state, main) do
    # Top section (60%) + bottom section (40%)
    [top, bottom] =
      Layout.split(main, :vertical, [
        {:percentage, 60},
        {:percentage, 40}
      ])

    # Top: left sidebar (25%) + joints (75%)
    [left_col, joints_area] =
      Layout.split(top, :horizontal, [
        {:percentage, 25},
        {:percentage, 75}
      ])

    # Left sidebar: safety (55%) + commands (45%)
    [safety_area, commands_area] =
      Layout.split(left_col, :vertical, [
        {:percentage, 55},
        {:percentage, 45}
      ])

    # Bottom: events (55%) + parameters (45%)
    [events_area, params_area] =
      Layout.split(bottom, :horizontal, [
        {:percentage, 55},
        {:percentage, 45}
      ])

    [
      {Panels.Safety.render(state, state.ui.active_panel == :safety), safety_area},
      {Panels.Commands.render(state, state.ui.active_panel == :commands), commands_area},
      {Panels.Joints.render(state, state.ui.active_panel == :joints), joints_area}
    ] ++
      Panels.Events.render_panes(state, state.ui.active_panel == :events, events_area) ++
      [{Panels.Parameters.render(state, state.ui.active_panel == :parameters), params_area}]
  end

  # ── Update — popup intercepts ────────────────────────────────

  @impl true
  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{show_help?: true}} = state
      )
      when code in ["j", "down"] do
    {:noreply, State.scroll_help_down(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{show_help?: true}} = state
      )
      when code in ["k", "up"] do
    {:noreply, State.scroll_help_up(state)}
  end

  def update({:event, %Event.Key{kind: "press"}}, %{ui: %{show_help?: true}} = state) do
    {:noreply, State.toggle_help(state)}
  end

  def update(
        {:event, %Event.Key{code: "y", kind: "press"}},
        %{safety: %{confirm_force_disarm?: true}} = state
      ) do
    Robot.force_disarm(state.robot, state.node)
    {:noreply, State.dismiss_force_disarm(state)}
  end

  def update(
        {:event, %Event.Key{code: "n", kind: "press"}},
        %{safety: %{confirm_force_disarm?: true}} = state
      ) do
    {:noreply, State.dismiss_force_disarm(state)}
  end

  def update(
        {:event, %Event.Key{kind: "press"}},
        %{safety: %{confirm_force_disarm?: true}} = state
      ) do
    {:noreply, state}
  end

  def update({:event, %Event.Key{kind: "press"}}, %{events: %{show_detail?: true}} = state) do
    {:noreply, State.dismiss_event_detail(state)}
  end

  # ── Update — global keys ─────────────────────────────────────

  def update({:event, %Event.Key{code: "q", kind: "press"}}, state) do
    {:stop, state}
  end

  def update({:event, %Event.Key{code: "]", kind: "press"}}, state) do
    {:noreply, State.next_tab(state)}
  end

  def update({:event, %Event.Key{code: "[", kind: "press"}}, state) do
    {:noreply, State.prev_tab(state)}
  end

  # Visualization-tab camera controls.
  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      )
      when code in ["left", "h"] do
    {:noreply, State.orbit_camera(state, -@viz_orbit, 0.0)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      )
      when code in ["right", "l"] do
    {:noreply, State.orbit_camera(state, @viz_orbit, 0.0)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      )
      when code in ["up", "k"] do
    {:noreply, State.orbit_camera(state, 0.0, @viz_orbit)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      )
      when code in ["down", "j"] do
    {:noreply, State.orbit_camera(state, 0.0, -@viz_orbit)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      )
      when code in ["+", "="] do
    {:noreply, State.zoom_camera(state, -@viz_zoom)}
  end

  def update(
        {:event, %Event.Key{code: "-", kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      ) do
    {:noreply, State.zoom_camera(state, @viz_zoom)}
  end

  def update(
        {:event, %Event.Key{code: "r", kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      ) do
    {:noreply, State.reset_camera(state)}
  end

  def update(
        {:event, %Event.Key{code: "m", kind: "press"}},
        %{ui: %{active_tab: :visualization}} = state
      ) do
    {:noreply, State.cycle_render_mode(state)}
  end

  def update(
        {:event, %Event.Key{code: "tab", kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      ) do
    {:noreply, State.focus_next_arg(state)}
  end

  def update(
        {:event, %Event.Key{code: "back_tab", kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      ) do
    {:noreply, State.focus_prev_arg(state)}
  end

  def update(
        {:event, %Event.Key{code: "tab", kind: "press"}},
        %{ui: %{active_tab: :control}} = state
      ) do
    {:noreply, State.cycle_panel(state)}
  end

  def update(
        {:event, %Event.Key{code: "back_tab", kind: "press"}},
        %{ui: %{active_tab: :control}} = state
      ) do
    {:noreply, State.cycle_panel_back(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_tab: :control}, commands: %{edit_mode?: false}} = state
      )
      when code in ["1", "2", "3", "4", "5"] do
    panel = State.panel_at(String.to_integer(code))
    {:noreply, State.jump_to_panel(state, panel)}
  end

  def update({:event, %Event.Key{code: "?", kind: "press"}}, state) do
    {:noreply, State.toggle_help(state)}
  end

  def update({:event, %Event.Key{code: "a", kind: "press"}}, state) do
    Robot.arm(state.robot, state.node)
    {:noreply, state}
  end

  def update({:event, %Event.Key{code: "d", kind: "press"}}, state) do
    Robot.disarm(state.robot, state.node)
    {:noreply, state}
  end

  def update({:event, %Event.Key{code: "f", kind: "press"}}, state) do
    if state.safety.state == :error do
      {:noreply, State.show_force_disarm(state)}
    else
      {:noreply, state}
    end
  end

  # ── Update — events panel keys ───────────────────────────────

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :events}} = state
      )
      when code in ["j", "down"] do
    {:noreply, State.scroll_down(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :events}} = state
      )
      when code in ["k", "up"] do
    {:noreply, State.scroll_up(state)}
  end

  def update(
        {:event, %Event.Key{code: "p", kind: "press"}},
        %{ui: %{active_panel: :events}} = state
      ) do
    {:noreply, State.toggle_events_pause(state)}
  end

  def update(
        {:event, %Event.Key{code: "c", kind: "press"}},
        %{ui: %{active_panel: :events}} = state
      ) do
    {:noreply, State.clear_events(state)}
  end

  def update(
        {:event, %Event.Key{code: "enter", kind: "press"}},
        %{ui: %{active_panel: :events}} = state
      ) do
    if State.selected_event(state) do
      {:noreply, State.toggle_event_detail(state)}
    else
      {:noreply, state}
    end
  end

  # ── Update — commands panel: argument-edit mode ──────────────
  # These clauses run only when the user has entered edit mode on a
  # command with arguments (Enter from the list view enters edit mode
  # when the selected command has args). Keep them above the list-view
  # clauses so they take precedence.

  def update(
        {:event, %Event.Key{code: "esc", kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      ) do
    {:noreply, State.exit_command_edit_mode(state)}
  end

  def update(
        {:event, %Event.Key{code: "enter", kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      ) do
    execute_selected_command(state)
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      )
      when code in ["tab", "down"] do
    {:noreply, State.focus_next_arg(state)}
  end

  def update(
        {:event, %Event.Key{code: "up", kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      ) do
    {:noreply, State.focus_prev_arg(state)}
  end

  def update(
        {:event, %Event.Key{code: "backspace", kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      ) do
    {:noreply, State.backspace_focused_arg(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      )
      when code in ["left", "right", "h", "l"] do
    handle_arg_horizontal_key(state, code)
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :commands}, commands: %{edit_mode?: true}} = state
      )
      when byte_size(code) == 1 do
    {:noreply, State.append_to_focused_arg(state, code)}
  end

  # ── Update — commands panel: list view ───────────────────────

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :commands}} = state
      )
      when code in ["j", "down"] do
    {:noreply, State.select_next_command(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :commands}} = state
      )
      when code in ["k", "up"] do
    {:noreply, State.select_prev_command(state)}
  end

  def update(
        {:event, %Event.Key{code: "enter", kind: "press"}},
        %{ui: %{active_panel: :commands}} = state
      ) do
    case State.selected_command(state) do
      %{arguments: [_ | _]} -> {:noreply, State.enter_command_edit_mode(state)}
      _ -> execute_selected_command(state)
    end
  end

  # ── Update — joints panel keys ───────────────────────────────

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :joints}} = state
      )
      when code in ["j", "down"] do
    {:noreply, State.select_next_joint(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :joints}} = state
      )
      when code in ["k", "up"] do
    {:noreply, State.select_prev_joint(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :joints}} = state
      )
      when code in ["l", "right"] do
    adjust_selected_joint(state, :increase, 1)
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :joints}} = state
      )
      when code in ["h", "left"] do
    adjust_selected_joint(state, :decrease, 1)
  end

  def update(
        {:event, %Event.Key{code: "L", kind: "press"}},
        %{ui: %{active_panel: :joints}} = state
      ) do
    adjust_selected_joint(state, :increase, 10)
  end

  def update(
        {:event, %Event.Key{code: "H", kind: "press"}},
        %{ui: %{active_panel: :joints}} = state
      ) do
    adjust_selected_joint(state, :decrease, 10)
  end

  # ── Update — parameters panel keys ───────────────────────────

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      )
      when code in ["j", "down"] do
    {:noreply, State.select_next_param(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      )
      when code in ["k", "up"] do
    {:noreply, State.select_prev_param(state)}
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      )
      when code in ["l", "right"] do
    adjust_selected_param(state, :increase, 1)
  end

  def update(
        {:event, %Event.Key{code: code, kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      )
      when code in ["h", "left"] do
    adjust_selected_param(state, :decrease, 1)
  end

  def update(
        {:event, %Event.Key{code: "L", kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      ) do
    adjust_selected_param(state, :increase, 10)
  end

  def update(
        {:event, %Event.Key{code: "H", kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      ) do
    adjust_selected_param(state, :decrease, 10)
  end

  def update(
        {:event, %Event.Key{code: "enter", kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      ) do
    toggle_selected_param(state)
  end

  def update(
        {:event, %Event.Key{code: "t", kind: "press"}},
        %{ui: %{active_panel: :parameters}} = state
      ) do
    state = State.cycle_parameter_tab(state)
    {:noreply, refresh_selected_tab(state)}
  end

  # ── Update — PubSub + async info messages ────────────────────

  def update({:info, {:bb, [:state_machine | _] = path, msg}}, state) do
    safety_state = Robot.safety_state(state.robot, state.node)
    runtime_state = Robot.runtime_state(state.robot, state.node)

    state =
      state
      |> State.update_safety(safety_state, runtime_state)
      |> State.append_event(path, msg)

    {:noreply, state}
  end

  # Sensor messages are the high-rate path. Update state but suppress the
  # immediate render (render?: false) and flag a pending render; the
  # :sensor_flush tick armed by subscriptions/1 performs one coalesced frame.
  def update({:info, {:bb, [:sensor | _] = path, %{payload: payload} = msg}}, state) do
    positions =
      case payload do
        %{names: names, positions: pos} -> Enum.zip(names, pos) |> Map.new()
        _ -> %{}
      end

    state =
      state
      |> State.update_positions(positions)
      |> State.update_power(payload)
      |> State.append_event(path, msg)
      |> State.mark_render_pending()

    {:noreply, state, render?: false}
  end

  def update({:info, {:bb, [:param | _] = path, msg}}, state) do
    parameters = Robot.list_parameters(state.robot, [], state.node)

    state =
      state
      |> State.update_parameters(parameters)
      |> State.append_event(path, msg)

    {:noreply, state}
  end

  # Consumer-renderer seam: a path matching a registered renderer prefix
  # (longest-prefix match, like a routing table) is handed off to the consumer's
  # module rather than pattern-matched here — bb_tui never inspects the payload's
  # struct. The renderer's `observed/2` (if exported) feeds the at-a-glance
  # status-bar readout; the event row is rendered later from the same renderer
  # (see `BB.TUI.Panels.Events`). The render is coalesced like sensors
  # (render?: false + the :sensor_flush tick) so a fast consumer stays smooth.
  # Falls through to the catch-all below when no renderer matches.
  def update({:info, {:bb, path, %{payload: payload} = msg}}, state) do
    case State.renderer_for(state, path) do
      nil ->
        {:noreply, State.append_event(state, path, msg)}

      renderer ->
        state =
          state
          |> maybe_put_observed(renderer, path, payload)
          |> State.append_event(path, msg)
          |> State.mark_render_pending()

        {:noreply, state, render?: false}
    end
  end

  # Everything else we subscribe to but don't model in dedicated state —
  # notably `[:safety, :error]` hardware-error reports and `[:estimator | _]`
  # odometry/pose — lands here and is surfaced in the event log. Safety *state*
  # transitions arrive separately on `[:state_machine]` (see above), so the
  # badge already reflects an error before its detail shows up here.
  def update({:info, {:bb, path, msg}}, state) do
    {:noreply, State.append_event(state, path, msg)}
  end

  def update({:info, {:command_result, result}}, state) do
    {:noreply, State.set_command_result(state, result)}
  end

  def update({:info, :throbber_tick}, state) do
    {:noreply, State.tick_throbber(state)}
  end

  # Coalesced sensor render: clear the pending flag and let the default
  # render?: true draw the single freshest frame. The next subscriptions/1
  # reconcile then drops :sensor_flush until the next sensor message.
  def update({:info, :sensor_flush}, state) do
    {:noreply, State.clear_render_pending(state)}
  end

  # ── Update — catch-all ───────────────────────────────────────

  def update(_msg, state), do: {:noreply, state}

  # ── Subscriptions ────────────────────────────────────────────

  @impl true
  def subscriptions(state) do
    throbber_subscriptions(state) ++ sensor_flush_subscriptions(state)
  end

  defp throbber_subscriptions(state) do
    if needs_throbber?(state) do
      [Subscription.interval(:throbber, 100, :throbber_tick)]
    else
      []
    end
  end

  # A one-shot armed only while a sensor render is pending. Repeated sensor
  # messages don't reset the armed timer (the reducer reconciles an equal
  # :once subscription as a no-op); once it fires and clears the flag, the
  # next reconcile removes it — so an idle TUI carries no flush timer.
  defp sensor_flush_subscriptions(%State{throttle: %{render_pending?: true, flush_ms: ms}}) do
    [Subscription.once(:sensor_flush, ms, :sensor_flush)]
  end

  defp sensor_flush_subscriptions(_state), do: []

  # ── Helpers ──────────────────────────────────────────────────

  # Feed the status-bar readout from the consumer's optional `observed/2`. The
  # callback is optional (`@optional_callbacks observed: 2`), so skip the call
  # entirely when the renderer doesn't export it. A `nil` return (or a
  # non-tuple) means "no slot for this payload" — leave `state.observed` as is.
  defp maybe_put_observed(state, renderer, path, payload) do
    # `observed/2` is optional. Force the module loaded before checking — under
    # Elixir 1.19+ module loading is lazier, so `function_exported?/3` can return
    # false for a not-yet-loaded module and the optional callback would be silently
    # skipped (it passed on 1.18 only because the module happened to be loaded).
    if Code.ensure_loaded?(renderer) and function_exported?(renderer, :observed, 2) do
      case renderer.observed(path, payload) do
        {slot_key, display, meta} ->
          State.put_observed(state, slot_key, %{display: display, meta: meta})

        _ ->
          state
      end
    else
      state
    end
  end

  defp needs_throbber?(%State{safety: %{state: :disarming}}), do: true
  defp needs_throbber?(%State{commands: %{executing: marker}}) when marker != nil, do: true
  defp needs_throbber?(_), do: false

  defp maybe_add_popup(panels, %{ui: %{show_help?: true, help_scroll_offset: offset}}, full) do
    panels ++ [{Panels.Help.render(offset), full}]
  end

  defp maybe_add_popup(panels, %{safety: %{confirm_force_disarm?: true}}, full) do
    panels ++ [{Panels.ForceDisarm.render(), full}]
  end

  defp maybe_add_popup(panels, %{events: %{show_detail?: true}} = state, full) do
    case State.selected_event(state) do
      nil -> panels
      event -> panels ++ [{Panels.EventDetail.render(event), full}]
    end
  end

  defp maybe_add_popup(panels, %{commands: %{edit_mode?: true}} = state, full) do
    case Panels.CommandEdit.render(state) do
      nil -> panels
      popup -> panels ++ [{popup, full}]
    end
  end

  defp maybe_add_popup(panels, _state, _full), do: panels

  defp adjust_selected_joint(state, _direction, _multiplier)
       when state.safety.state not in [:armed, :disarming] do
    {:noreply, state}
  end

  defp adjust_selected_joint(state, direction, multiplier) do
    name = State.selected_joint_name(state)

    case name && Map.get(state.joints.entries, name) do
      nil ->
        {:noreply, state}

      %{position: nil} ->
        {:noreply, state}

      %{position: pos, joint: joint} ->
        step = State.joint_step(joint) * multiplier

        new_pos =
          case direction do
            :increase -> pos + step
            :decrease -> pos - step
          end
          |> State.clamp_position(joint)

        actuator = find_actuator_for_joint(state.robot_struct, name)
        state = State.set_joint_target(state, name, new_pos)

        if actuator do
          Robot.set_actuator(state.robot, actuator, new_pos, state.node)
          {:noreply, state}
        else
          publish_simulated_position(state.robot, name, new_pos, state.node)
          {:noreply, State.set_joint_position(state, name, new_pos)}
        end
    end
  end

  defp publish_simulated_position(robot, joint_name, position, node) do
    {:ok, msg} =
      BB.Message.new(BB.Message.Sensor.JointState, :simulated,
        names: [joint_name],
        positions: [position * 1.0],
        velocities: [0.0],
        efforts: [0.0]
      )

    Robot.publish(robot, [:sensor, :simulated], msg, node)
  end

  defp find_actuator_for_joint(%{actuators: actuators}, joint_name) do
    actuators
    |> Enum.find(fn {_name, info} -> info.joint == joint_name end)
    |> case do
      {actuator_name, _info} -> actuator_name
      nil -> nil
    end
  end

  defp find_actuator_for_joint(_, _joint_name), do: nil

  defp refresh_selected_tab(state) do
    case State.selected_parameter_tab(state) do
      :local ->
        state

      {:bridge, name} ->
        payload =
          case Robot.list_remote_parameters(state.robot, name, state.node) do
            {:ok, params} -> params
            {:error, _} = error -> error
          end

        State.put_remote_parameters(state, name, payload)
    end
  end

  defp adjust_selected_param(state, direction, multiplier) do
    case State.selected_parameter_tab(state) do
      :local -> adjust_local_param(state, direction, multiplier)
      {:bridge, name} -> adjust_remote_param(state, name, direction, multiplier)
    end
  end

  defp adjust_local_param(state, direction, multiplier) do
    case State.selected_param(state) do
      {path, value} when is_integer(value) ->
        bounds = State.parameter_bounds(state, path)
        step = integer_step(bounds) * multiplier
        new_value = State.clamp_to_bounds(apply_step(value, direction, step), bounds)
        Robot.set_parameter(state.robot, path, new_value, state.node)
        {:noreply, state}

      {path, value} when is_float(value) ->
        bounds = State.parameter_bounds(state, path)
        step = float_step(bounds) * multiplier
        new_value = State.clamp_to_bounds(apply_step(value, direction, step), bounds)
        Robot.set_parameter(state.robot, path, new_value, state.node)
        {:noreply, state}

      _ ->
        {:noreply, state}
    end
  end

  defp adjust_remote_param(state, bridge_name, direction, multiplier) do
    case State.selected_remote_param(state) do
      %{value: value} = param when is_integer(value) ->
        bounds = State.remote_param_bounds(param)
        step = integer_step(bounds) * multiplier
        new_value = State.clamp_to_bounds(apply_step(value, direction, step), bounds)
        dispatch_remote_set(state, bridge_name, param, new_value)

      %{value: value} = param when is_float(value) ->
        bounds = State.remote_param_bounds(param)
        step = float_step(bounds) * multiplier
        new_value = State.clamp_to_bounds(apply_step(value, direction, step), bounds)
        dispatch_remote_set(state, bridge_name, param, new_value)

      _ ->
        {:noreply, state}
    end
  end

  defp dispatch_remote_set(state, bridge_name, param, new_value) do
    id = State.remote_param_id(param)

    case Robot.set_remote_parameter(state.robot, bridge_name, id, new_value, state.node) do
      :ok -> {:noreply, refresh_selected_tab(state)}
      {:error, _reason} -> {:noreply, state}
    end
  end

  defp integer_step({min, max}) when is_integer(min) and is_integer(max),
    do: max(div(max - min, 100), 1)

  defp integer_step(_), do: 1

  defp float_step({min, max}) when is_number(min) and is_number(max), do: (max - min) / 100
  defp float_step(_), do: 0.1

  defp apply_step(value, :increase, step), do: value + step
  defp apply_step(value, :decrease, step), do: value - step

  defp toggle_selected_param(state) do
    case State.selected_parameter_tab(state) do
      :local -> toggle_local_param(state)
      {:bridge, name} -> toggle_remote_param(state, name)
    end
  end

  defp toggle_local_param(state) do
    case State.selected_param(state) do
      {path, value} when is_boolean(value) ->
        Robot.set_parameter(state.robot, path, !value, state.node)
        {:noreply, state}

      _ ->
        {:noreply, state}
    end
  end

  defp toggle_remote_param(state, bridge_name) do
    case State.selected_remote_param(state) do
      %{value: value} = param when is_boolean(value) ->
        dispatch_remote_set(state, bridge_name, param, !value)

      _ ->
        {:noreply, state}
    end
  end

  defp handle_arg_horizontal_key(state, code) do
    case State.focused_arg_enum_values(state) do
      [_ | _] ->
        direction = if code in ["right", "l"], do: :next, else: :prev
        {:noreply, State.cycle_focused_enum(state, direction)}

      _ when byte_size(code) == 1 ->
        {:noreply, State.append_to_focused_arg(state, code)}

      _ ->
        {:noreply, state}
    end
  end

  defp execute_selected_command(%State{commands: %{available: commands, selected: idx}} = state) do
    case Enum.at(commands, idx) do
      nil ->
        {:noreply, state}

      cmd ->
        if Panels.Commands.command_ready?(cmd, state.safety.runtime) and
             state.commands.executing == nil do
          args = State.parsed_args_for_selected(state)

          {:noreply, State.start_command(State.exit_command_edit_mode(state), :running),
           commands: [execute_command_command(state, cmd, args)]}
        else
          {:noreply, state}
        end
    end
  end

  defp execute_command_command(%State{robot: robot, node: node}, cmd, args) do
    name = cmd.name

    Command.async(
      fn -> wait_for_command_result(robot, name, args, node) end,
      fn result -> {:command_result, result} end
    )
  end

  defp wait_for_command_result(robot, name, args, node) do
    case Robot.execute_command(robot, name, args, node) do
      {:ok, cmd_pid} -> await_command(cmd_pid)
      {:error, reason} -> {:error, reason}
    end
  end

  # BB.Command.await/2 traps `:exit, {:noproc, _}` and falls back to the
  # command's ResultCache, so it survives the race where a fast handler
  # (e.g. a synchronous {:stop, :normal, _}) terminates between
  # `Robot.execute_command/4` returning the pid and us awaiting on it.
  # The earlier Process.monitor approach surfaced that race as a
  # spurious {:error, :noproc}; await also enforces a timeout, so we no
  # longer need a separate Command.send_after backstop.
  #
  # Unwrap {:command_failed, reason} at the boundary so the panel shows
  # the bare reason (`:timeout`, `:noproc`, etc.) consistently.
  defp await_command(cmd_pid) do
    case BB.Command.await(cmd_pid, @command_timeout) do
      {:ok, result} -> {:ok, result}
      {:ok, result, _opts} -> {:ok, result}
      {:error, {:command_failed, reason}} -> {:error, reason}
      {:error, _reason} = error -> error
    end
  end
end