# SPDX-FileCopyrightText: 2025 James Harton # # SPDX-License-Identifier: Apache-2.0 defmodule BB.Kino.Command do @moduledoc """ A Kino widget for executing robot commands. Displays available commands in a tab view with: - Dynamic form for each command's arguments - Execute button with loading state - Result/error display ## Usage BB.Kino.Command.new(MyRobot) The widget automatically: - Discovers commands from the robot's DSL definition - Generates form inputs based on argument types - Executes commands via `BB.Robot.Runtime.execute/3` - Displays results or errors """ use Kino.JS use Kino.JS.Live alias BB.Dsl.Info, as: DslInfo alias BB.Kino.Shared.RobotContext alias BB.Robot.Runtime, as: RobotRuntime alias Kino.JS.Live, as: KinoLive @doc """ Creates a new command widget for the given robot. """ @spec new(module()) :: KinoLive.t() def new(robot_module) do KinoLive.new(__MODULE__, robot_module) end @impl true def init(robot_module, ctx) do case RobotContext.validate_robot(robot_module) do {:ok, robot} -> commands = discover_commands(robot) state = RobotRuntime.state(robot) # Subscribe to state changes so we can update command availability BB.subscribe(robot, [:state_machine]) {:ok, assign(ctx, robot: robot, commands: commands, state: state, executing: nil )} {:error, reason} -> {:ok, assign(ctx, error: reason)} end end defp discover_commands(robot_module) do robot_module |> DslInfo.commands() |> Enum.map(&format_command/1) |> Enum.sort_by(& &1.name) end defp format_command(cmd) do %{ name: cmd.name, handler: cmd.handler, timeout: cmd.timeout, allowed_states: cmd.allowed_states, arguments: Enum.map(cmd.arguments, &format_argument/1) } end defp format_argument(arg) do %{ name: arg.name, type: format_type(arg.type), required: arg.required, default: arg.default, doc: arg.doc } end defp format_type(type) when is_atom(type), do: Atom.to_string(type) defp format_type({:in, values}), do: "enum:#{inspect(values)}" defp format_type(type), do: inspect(type) @impl true def handle_connect(ctx) do if ctx.assigns[:error] do {:ok, %{error: ctx.assigns.error}, ctx} else payload = %{ commands: format_commands_for_client(ctx.assigns.commands), state: Atom.to_string(ctx.assigns.state), executing: ctx.assigns.executing } {:ok, payload, ctx} end end defp format_commands_for_client(commands) do Enum.map(commands, fn cmd -> %{ name: Atom.to_string(cmd.name), allowed_states: Enum.map(cmd.allowed_states, &Atom.to_string/1), arguments: Enum.map(cmd.arguments, fn arg -> %{ name: Atom.to_string(arg.name), type: arg.type, required: arg.required, default: format_default(arg.default), doc: arg.doc } end) } end) end defp format_default(nil), do: nil defp format_default(value), do: inspect(value) @impl true def handle_event("execute", %{"command" => cmd_name, "args" => args}, ctx) do cmd_atom = String.to_existing_atom(cmd_name) parsed_args = args |> Enum.map(fn {k, v} -> {String.to_existing_atom(k), parse_value(v)} end) |> Map.new() broadcast_event(ctx, "executing", %{command: cmd_name}) {:ok, task} = RobotRuntime.execute(ctx.assigns.robot, cmd_atom, parsed_args) send(self(), {:await_task, task, cmd_name}) {:noreply, assign(ctx, executing: cmd_name)} end defp parse_value("true"), do: true defp parse_value("false"), do: false defp parse_value(":" <> rest = value) when byte_size(rest) > 0 do String.to_existing_atom(rest) rescue ArgumentError -> value end defp parse_value(value) when is_binary(value) do case Integer.parse(value) do {int, ""} -> int _ -> case Float.parse(value) do {float, ""} -> float _ -> value end end end defp parse_value(value), do: value @impl true def handle_info({:await_task, task, cmd_name}, ctx) do case Task.yield(task, 100) do {:ok, {:ok, result}} -> broadcast_event(ctx, "result", %{command: cmd_name, result: inspect(result)}) {:noreply, assign(ctx, executing: nil)} {:ok, {:error, reason}} -> broadcast_event(ctx, "error", %{command: cmd_name, error: inspect(reason)}) {:noreply, assign(ctx, executing: nil)} nil -> send(self(), {:await_task, task, cmd_name}) {:noreply, ctx} end end def handle_info({:bb, [:state_machine], %BB.Message{payload: payload}}, ctx) do broadcast_event(ctx, "state_changed", %{state: Atom.to_string(payload.to)}) {:noreply, assign(ctx, state: payload.to)} end def handle_info(_msg, ctx) do {:noreply, ctx} end @impl true def terminate(_reason, ctx) do if ctx.assigns[:robot] do BB.unsubscribe(ctx.assigns.robot, [:state_machine]) end :ok end asset "main.js" do """ export function init(ctx, payload) { ctx.importCSS("main.css"); if (payload.error) { ctx.root.innerHTML = `
No commands available
'; return; } const canExecute = cmd.allowed_states.includes(currentState); const isExecuting = executing === cmd.name; tabContent.innerHTML = `${message}`;
resultArea.style.display = 'block';
}
renderTabs();
renderContent();
ctx.handleEvent('executing', ({ command }) => {
executing = command;
renderContent();
resultArea.style.display = 'none';
});
ctx.handleEvent('result', ({ command, result }) => {
executing = null;
renderContent();
showResult('success', `Command "${command}" completed:\\n${result}`);
});
ctx.handleEvent('error', ({ command, error }) => {
executing = null;
renderContent();
showResult('error', `Command "${command}" failed:\\n${error}`);
});
ctx.handleEvent('state_changed', ({ state }) => {
currentState = state;
renderContent();
});
}
"""
end
asset "main.css" do
"""
.bb-commands {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fafafa;
overflow: hidden;
}
.tab-bar {
display: flex;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
overflow-x: auto;
}
.tab-btn {
padding: 12px 20px;
border: none;
background: transparent;
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
color: #666;
white-space: nowrap;
}
.tab-btn:hover {
background: #eee;
}
.tab-btn.active {
color: #1976d2;
border-bottom-color: #1976d2;
font-weight: 500;
}
.tab-content {
padding: 16px;
}
.command-panel {
max-width: 500px;
}
.command-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.command-name {
font-size: 18px;
font-weight: 600;
color: #333;
}
.state-badge {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.state-badge.allowed {
background: #e8f5e9;
color: #2e7d32;
}
.state-badge.blocked {
background: #fff3e0;
color: #e65100;
}
.args-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-field label {
font-size: 13px;
font-weight: 500;
color: #333;
}
.required {
color: #f44336;
}
.form-field input,
.form-field select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-field input[type="checkbox"] {
width: auto;
}
.field-doc {
font-size: 12px;
color: #888;
}
.no-args {
color: #888;
font-style: italic;
}
.execute-btn {
padding: 10px 20px;
background: #1976d2;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
align-self: flex-start;
}
.execute-btn:hover:not(:disabled) {
background: #1565c0;
}
.execute-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
margin: 16px;
padding: 12px;
border-radius: 4px;
font-size: 13px;
}
.result-area.success {
background: #e8f5e9;
border: 1px solid #4caf50;
}
.result-area.error {
background: #ffebee;
border: 1px solid #f44336;
}
.result-area pre {
margin: 0;
white-space: pre-wrap;
font-family: monospace;
}
.no-commands {
color: #888;
text-align: center;
padding: 40px;
}
.bb-commands-error {
padding: 16px;
}
.error-message {
color: #c62828;
}
"""
end
end