1 line
105 KiB
JSON
1 line
105 KiB
JSON
{"repo": "asx8678/spark", "files": {"lib/spark/term_ui.ex": {"f5846166112c624bb78e890fdf1427570bbdaa70": {"t": 1785745930.584221, "n": 28409, "h": "9ed4a0a66b67be77", "c": "defmodule Spark.TermUI do\n @moduledoc \"\"\"\n TermUI-based terminal UI for Spark — Component Protocol.\n Uses the simple init/view/update protocol that TermUI.App.run expects.\n \"\"\"\n\n require Logger\n\n # ── Init ──\n\n def init(_opts) do\n Spark.TUI.Actions.install_event_log_hook()\n\n # Disable mouse tracking after a brief delay to enable text selection/copy\n Process.send_after(self(), :disable_mouse_tracking, 500)\n\n {rows, cols} =\n case TermUI.Terminal.get_terminal_size() do\n {:ok, {r, c}} -> {r, c}\n _ -> {24, 80}\n end\n\n %{\n model: %Spark.TUI.Model{\n session_id: gen_id(),\n agents: Spark.TUI.Actions.load_agents(),\n agent_order: [\"planning\", \"coding\"],\n dashboard: Spark.TUI.Actions.dashboard_snapshot(),\n logs: Spark.TUI.Actions.load_logs(),\n status_message: nil,\n width: cols,\n height: rows,\n scroll_top: 0\n },\n tick_count: 0,\n shutting_down: false\n }\n end\n\n # ── Event to message conversion (called by TermUI Runtime) ──\n\n def event_to_msg(event, _state) do\n cond do\n # Quit keys\n is_map(event) && Map.get(event, :key) == :ctrl_c ->\n {:msg, :quit}\n\n # PageUp / PageDown keys\n is_map(event) && Map.get(event, :key) == :page_up ->\n {:msg, {:scroll, -5}}\n\n is_map(event) && Map.get(event, :key) == :page_down ->\n {:msg, {:scroll, 5}}\n\n # Mouse scroll actions\n is_map(event) && Map.get(event, :action) == :scroll_up ->\n {:msg, {:scroll, -3}}\n\n is_map(event) && Map.get(event, :action) == :scroll_down ->\n {:msg, {:scroll, 3}}\n\n # Ctrl-key scroll shortcuts\n is_map(event) && :ctrl in Map.get(event, :modifiers, []) ->\n case Map.get(event, :char) do\n \"p\" -> {:msg, {:scroll, -1}}\n \"n\" -> {:msg, {:scroll, 1}}\n \"k\" -> {:msg, {:scroll, -1}}\n \"j\" -> {:msg, {:scroll, 1}}\n \"u\" -> {:msg, {:scroll, -5}}\n \"d\" -> {:msg, {:scroll, 5}}\n \"c\" -> {:msg, :quit}\n _ -> :ignore\n end\n\n # Printable characters (use char for correct case)\n is_map(event) && is_binary(Map.get(event, :char, \"\")) &&\n byte_size(Map.get(event, :char, \"\")) == 1 ->\n ch = Map.get(event, :char)\n <<codepoint::utf8>> = ch\n {:msg, {:event, %{ch: codepoint}}}\n\n # Special keys\n is_map(event) && is_atom(Map.get(event, :key)) ->\n key = Map.get(event, :key)\n\n case key do\n :enter -> {:msg, {:event, %{key: 0x0D}}}\n :escape -> {:msg, {:event, %{key: 0x1B}}}\n :up -> {:msg, {:event, %{key: 0xFFFF - 18}}}\n :down -> {:msg, {:event, %{key: 0xFFFF - 19}}}\n :left -> {:msg, {:event, %{key: 0xFFFF - 20}}}\n :right -> {:msg, {:event, %{key: 0xFFFF - 21}}}\n :backspace -> {:msg, {:event, %{key: 0x08}}}\n _ -> :ignore\n end\n\n # Tick events\n is_map(event) && is_integer(Map.get(event, :interval, nil)) &&\n Map.get(event, :interval, 0) > 0 ->\n {:msg, :tick}\n\n # Resize events\n is_map(event) && Map.has_key?(event, :width) && Map.has_key?(event, :height) ->\n {:msg, {:resized, event.width, event.height}}\n\n true ->\n :ignore\n end\n end\n\n # ── Handle async results (from Task.start) ──\n\n def handle_info({:plan_result, result}, state) do\n Logger.debug(\"[SPARK] plan_result received\")\n model = Spark.TUI.Update.update(state.model, {:plan_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:approve_result, result}, state) do\n Logger.debug(\"[SPARK] approve_result received\")\n model = Spark.TUI.Update.update(state.model, {:approve_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:reject_result, result}, state) do\n Logger.debug(\"[SPARK] reject_result received\")\n model = Spark.TUI.Update.update(state.model, {:reject_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:pin_result, result}, state) do\n Logger.debug(\"[SPARK] pin_result received\")\n model = Spark.TUI.Update.update(state.model, {:pin_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info(:disable_mouse_tracking, state) do\n TermUI.Terminal.disable_mouse_tracking()\n {state, []}\n end\n\n def handle_info({:shutdown_complete, _result}, state) do\n Logger.info(\"[SPARK] Shutdown complete — exiting\")\n {state, [:quit]}\n end\n\n def handle_info({:stream_chunk, text}, state) do\n Logger.debug(\"[SPARK] stream_chunk: #{chunk_debug_size(text)} bytes\")\n model = Spark.TUI.Update.update(state.model, {:stream_chunk, text})\n {%{state | model: model}, []}\n end\n\n def handle_info({:stream_started, metadata}, state) do\n Logger.debug(\"[SPARK] stream_started: #{inspect(metadata)}\")\n model = Spark.TUI.Update.update(state.model, {:stream_started, metadata})\n {%{state | model: model}, []}\n end\n\n def handle_info({:stream_done, metadata}, state) do\n Logger.debug(\"[SPARK] stream_done: #{inspect(metadata)}\")\n model = Spark.TUI.Update.update(state.model, {:stream_done, metadata})\n {%{state | model: model}, []}\n end\n\n def handle_info({:stream_error, reason}, state) do\n Logger.warning(\"[SPARK] stream_error: #{inspect(reason)}\")\n model = Spark.TUI.Update.update(state.model, {:stream_error, reason})\n {%{state | model: model}, []}\n end\n\n def handle_info(msg, state) do\n case msg do\n :tick ->\n update(:tick, state)\n\n _ ->\n result = Spark.TUI.Update.update(state.model, msg)\n\n case result do\n {model, _cmd} -> {%{state | model: model}, []}\n model -> {%{state | model: model}, []}\n end\n end\n end\n\n # ── Update ──\n\n def update(:tick, %{tick_count: count} = state) do\n if rem(count, 60) == 0 do\n model = Spark.TUI.Update.update(state.model, :tick)\n {%{state | model: model, tick_count: count + 1}, []}\n else\n {%{state | tick_count: count + 1}, []}\n end\n end\n\n def update({:resized, width, height}, state) do\n model = Spark.TUI.Update.update(state.model, {:resized, width, height})\n {%{state | model: model}, []}\n end\n\n def update(:resized, state) do\n {rows, cols} =\n case TermUI.Terminal.get_terminal_size() do\n {:ok, {r, c}} -> {r, c}\n _ -> {24, 80}\n end\n\n model = Spark.TUI.Update.update(state.model, {:resized, cols, rows})\n {%{state | model: model}, []}\n end\n\n def update(:quit, %{shutting_down: true} = state), do: {state, [:quit]}\n\n def update(:quit, state) do\n # Begin graceful shutdown — second Ctrl+C will force-quit\n runtime_self = self()\n session_id = state.model.session_id\n\n Task.start(fn ->\n Logger.info(\"[SPARK] Graceful shutdown initiated\")\n\n # 1. Drain workers (up to 5s)\n drain_result =\n try do\n Spark.Dispatcher.drain(5000)\n rescue\n _ -> {:timeout, -1}\n catch\n :exit, _ -> {:timeout, -1}\n end\n\n Logger.info(\"[SPARK] Drain result: #{inspect(drain_result)}\")\n\n # 2. Flush Bronze log — write a shutdown event\n if session_id do\n try do\n Spark.Memory.Bronze.append(session_id, %{\n type: :shutdown,\n source: \"term_ui\",\n payload: %{drain_result: drain_result}\n })\n rescue\n _ -> :ok\n catch\n :exit, _ -> :ok\n end\n end\n\n send(runtime_self, {:shutdown_complete, drain_result})\n end)\n\n model = %{state.model | status_message: \"🛑 Shutting down...\"}\n {%{state | model: model, shutting_down: true}, []}\n end\n\n def update(msg, state) do\n result = Spark.TUI.Update.update(state.model, msg)\n\n case result do\n {:quit, _} ->\n {state, [:quit]}\n\n {model, cmd} ->\n execute_async(cmd, model, state)\n\n model ->\n {%{state | model: model}, []}\n end\n end\n\n # ── Async execution ──\n\n defp execute_async(cmd, model, state) do\n runtime_self = self()\n\n Task.start(fn ->\n try do\n result = cmd.function.(runtime_self)\n Logger.debug(\"[SPARK] async #{cmd.message} completed\")\n send(runtime_self, {cmd.message, result})\n rescue\n e ->\n Logger.debug(\"[SPARK] async #{cmd.message} error: #{Exception.message(e)}\")\n send(runtime_self, {cmd.message, {:error, Exception.message(e)}})\n catch\n :exit, reason ->\n Logger.debug(\"[SPARK] async #{cmd.message} exit: #{inspect(reason)}\")\n send(runtime_self, {cmd.message, {:error, {:exit, reason}}})\n end\n end)\n\n {%{state | model: model}, []}\n end\n\n # ── View ──\n\n def view(state) do\n model = state.model\n dims = {model.width || 80, model.height || 24}\n\n status_opts = [\n status_line: status_info_for_state(state),\n scroll_top: model.scroll_top || 0\n ]\n\n Spark.TUI.Layout.render_dashboard(\n dims,\n fn -> render_canvas_content(model) end,\n fn -> render_deck_content(model) end,\n status_opts\n )\n end\n\n defp status_info(%{error_message: err}) when is_binary(err) and err != \"\",\n do: {\" ❌ \" <> err, :red}\n\n defp status_info(%{status_message: msg}) when is_binary(msg) and msg != \"\",\n do: {\" ℹ️ \" <> msg, :green}\n\n defp status_info(_), do: nil\n\n # Override status when shutting down\n defp status_info_for_state(%{shutting_down: true}), do: {\" 🛑 Shutting down...\", :yellow}\n defp status_info_for_state(%{model: model}), do: status_info(model)\n\n # ── Canvas Content ──\n\n defp render_canvas_content(model) do\n # Planning session takes priority — combined view for both streaming and plan\n cond do\n model.view_mode == :planning_session ->\n planning_session_lines(model)\n\n model.streaming_active? ->\n streaming_lines(model)\n\n true ->\n case model.view_mode || :welcome do\n :welcome -> welcome_lines(model)\n :plan_review -> plan_review_lines(model)\n :execution -> execution_lines(model)\n :logs -> logs_lines(model)\n :help -> help_lines(model)\n :tasks -> tasks_lines(model)\n :shell_output -> shell_output_lines(model)\n _ -> welcome_lines(model)\n end\n end\n end\n\n # ── Deck Content ──\n\n defp render_deck_content(model) do\n case model.command_mode || :chat do\n :chat -> chat_deck_lines(model)\n :approve -> approve_deck_lines(model)\n :agent_picker -> agent_picker_deck_lines(model)\n _ -> chat_deck_lines(model)\n end\n end\n\n # ── Streaming Lines (canvas display during planning) ──\n\n defp streaming_lines(model) do\n content = model.streaming_content || \"\"\n\n if content == \"\" do\n # Pre-token placeholder: immediate feedback before first chunk\n spinner_frame = model.spinner_frame || 0\n spinner_chars = ~w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇]\n spinner = Enum.at(spinner_chars, rem(spinner_frame, length(spinner_chars)), \"⠋\")\n\n [\n \"\",\n \" #{spinner} 📡 Connecting to planning model...\",\n \" Waiting for first token...\",\n \"\"\n ]\n else\n lines = content |> String.split(\"\\n\") |> Enum.map(fn line -> \" #{line}\" end)\n status = if model.status_message, do: [\"\", \" #{model.status_message}...\", \"\"], else: [\"\"]\n [\"\" | status] ++ [\"\"] ++ lines\n end\n end\n\n # ── Combined Planning Session Lines ──\n\n defp planning_session_lines(model) do\n # Determine status label\n status_label =\n cond do\n model.streaming_active? -> \"📡 Streaming...\"\n model.active_plan != nil -> \"✅ Plan ready\"\n model.error_message != nil and model.error_message != \"\" -> \"❌ Error\"\n model.loading? -> \"⏳ Loading...\"\n true -> \"—\"\n end\n\n # Header\n header = [\n \"\",\n \" 📋 Planning Session\",\n \" Status: #{status_label}\",\n \"\"\n ]\n\n # Transcript section — use planning_transcript (persistent), fallback to streaming_content (live)\n transcript =\n case {model.planning_transcript || \"\", model.streaming_content || \"\"} do\n {t, _sc} when t != \"\" -> t\n {\"\", sc} when sc != \"\" -> sc\n _ -> \"\"\n end\n\n transcript_lines =\n if transcript == \"\" and model.streaming_active? do\n # Show connecting/waiting placeholder\n spinner_frame = model.spinner_frame || 0\n spinner_chars = ~w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇]\n spinner = Enum.at(spinner_chars, rem(spinner_frame, length(spinner_chars)), \"⠋\")\n\n [\n \" ─── Model Output / Visible Reasoning ───\",\n \"\",\n \" #{spinner} 📡 Connecting to planning model...\",\n \" Waiting for first token...\",\n \"\"\n ]\n else\n if transcript == \"\" do\n [\n \" ─── Model Output / Visible Reasoning ───\",\n \"\",\n \" (no transcript)\",\n \"\"\n ]\n else\n t_lines = transcript |> String.split(\"\\n\") |> Enum.map(fn line -> \" #{line}\" end)\n [\" ─── Model Output / Visible Reasoning ───\", \"\" | t_lines] ++ [\"\"]\n end\n end\n\n # Plan section\n plan_lines =\n if model.active_plan do\n planning_session_plan_lines(model)\n else\n if model.streaming_active? do\n [\" ─── Structured Plan ───\", \"\", \" Structured plan will appear below when parsing completes...\", \"\"]\n else\n []\n end\n end\n\n # Error section (if any)\n error_lines = error_prefix(model)\n\n error_lines ++ header ++ transcript_lines ++ plan_lines\n end\n\n # Renders the structured plan portion of the planning session\n defp planning_session_plan_lines(model) do\n plan = model.active_plan\n\n status_str =\n case plan.approval_status do\n :approved -> \"[APPROVED]\"\n :awaiting_approval -> \"[AWAITING APPROVAL]\"\n :rejected -> \"[REJECTED]\"\n s -> \"[#{String.upcase(to_string(s))}]\"\n end\n\n task_lines =\n if plan.tasks do\n sel = model.selected_task_index || 0\n\n Enum.with_index(plan.tasks)\n |> Enum.flat_map(fn {task, idx} ->\n marker = if idx == sel, do: \"▸\", else: \" \"\n\n risk_sym =\n case task.risk do\n :low -> \"🟢\"\n :medium -> \"🟡\"\n :high -> \"🔴\"\n _ -> \"⚪\"\n end\n\n [\n \" #{marker} #{task.id}: #{task.title}\",\n \" #{risk_sym} Risk: #{task.risk} | Deps: #{join_or_none(task.depends_on)} | R/W: #{join_or_none(task.read_paths)}/#{join_or_none(task.write_paths)}\"\n ]\n end)\n else\n [\" No tasks defined.\"]\n end\n\n plan_header =\n [\n \" ─── Structured Plan ───\",\n \"\",\n \" PLAN: #{plan.id}\",\n \" Goal: #{plan.user_goal}\",\n \" Status: #{status_str}\",\n \" Summary: #{plan.summary || \"--\"}\",\n \"\",\n \" ─── Tasks ───\"\n ]\n\n detail =\n if sel_task = Enum.at(plan.tasks || [], model.selected_task_index || 0) do\n [\n \"\",\n \" ─── Detail: #{sel_task.id} ───\",\n \" #{sel_task.title}\",\n \" #{sel_task.description || \"--\"}\",\n \" Timeout: #{sel_task.timeout_ms}ms Retries: #{sel_task.max_retries}\"\n ]\n else\n []\n end\n\n plan_header ++ task_lines ++ detail\n end\n\n # ── Welcome Lines ──\n\n defp welcome_lines(model) do\n dash = model.dashboard || %{}\n phase = dash[:orchestrator_phase] || :idle\n queue = dash[:queue_length] || 0\n active = dash[:active_count] || 0\n agents = model.agents || %{}\n planning = Map.get(agents, \"planning\", %{})\n coding = Map.get(agents, \"coding\", %{})\n\n planning_model = planning[\"model\"] || \"--\"\n planning_prov = planning[\"provider\"] || \"--\"\n coding_model = coding[\"model\"] || \"--\"\n coding_prov = coding[\"provider\"] || \"--\"\n planning_key = if secret?(:deepseek_api_key), do: \"OK\", else: \"MISSING\"\n coding_key = if secret?(:wafer_api_key), do: \"OK\", else: \"MISSING\"\n\n error_prefix = error_prefix(model)\n\n error_prefix ++\n [\n \"\",\n \" 🔮 S P A R K v 4 . 1 🐶\",\n \" Parallel Actor-Model Code Agent\",\n \"\",\n \" ─── Agent Status ───\",\n \" 📋 Planning | #{planning_model} (#{planning_prov}) key: #{planning_key}\",\n \" 🔧 Coding | #{coding_model} (#{coding_prov}) key: #{coding_key}\",\n \"\",\n \" ─── Runtime ───\",\n \" Phase: #{String.capitalize(to_string(phase))} | Queue: #{queue} | Active: #{active}\",\n \"\",\n \" ─── Shortcuts ───\",\n \" Type a goal + Enter to plan\",\n \" /welcome /plan /exec /logs /dash /agents /help /quit /clear\",\n \" /tasks /reload [prompts|tools|config|policy|guidance|all|status]\",\n \" /approve /reject (when plan is active) | !<cmd> shell\",\n \"\",\n \" Ready. Type your goal or a slash command.\"\n ]\n end\n\n # ── Plan Review Lines ──\n\n defp plan_review_lines(model) do\n plan = model.active_plan\n\n if plan == nil do\n error_prefix(model) ++ [\"\", \" No active plan.\", \"\"]\n else\n status_str =\n case plan.approval_status do\n :approved -> \"[APPROVED]\"\n :awaiting_approval -> \"[AWAITING APPROVAL]\"\n :rejected -> \"[REJECTED]\"\n s -> \"[#{String.upcase(to_string(s))}]\"\n end\n\n task_lines =\n if plan.tasks do\n sel = model.selected_task_index || 0\n\n Enum.with_index(plan.tasks)\n |> Enum.flat_map(fn {task, idx} ->\n marker = if idx == sel, do: \"▸\", else: \" \"\n\n risk_sym =\n case task.risk do\n :low -> \"🟢\"\n :medium -> \"🟡\"\n :high -> \"🔴\"\n _ -> \"⚪\"\n end\n\n [\n \" #{marker} #{task.id}: #{task.title}\",\n \" #{risk_sym} Risk: #{task.risk} | Deps: #{join_or_none(task.depends_on)} | R/W: #{join_or_none(task.read_paths)}/#{join_or_none(task.write_paths)}\"\n ]\n end)\n else\n [\" No tasks defined.\"]\n end\n\n error_prefix = error_prefix(model)\n\n header =\n error_prefix ++\n [\n \"\",\n \" PLAN: #{plan.id}\",\n \" Goal: #{plan.user_goal}\",\n \" Status: #{status_str}\",\n \" Summary: #{plan.summary || \"--\"}\",\n \"\",\n \" ─── Tasks ───\"\n ]\n\n detail =\n if sel_task = Enum.at(plan.tasks || [], model.selected_task_index || 0) do\n [\n \"\",\n \" ─── Detail: #{sel_task.id} ───\",\n \" #{sel_task.title}\",\n \" #{sel_task.description || \"--\"}\",\n \" Timeout: #{sel_task.timeout_ms}ms Retries: #{sel_task.max_retries}\"\n ]\n else\n []\n end\n\n header ++ task_lines ++ detail\n end\n end\n\n # ── Execution Lines ──\n\n defp execution_lines(model) do\n d = model.dashboard || %{}\n phase = d[:orchestrator_phase] || :idle\n queue = d[:queue_length] || 0\n active = d[:active_count] || 0\n completed = d[:completed_count] || 0\n failed = d[:failed_count] || 0\n max_conc = d[:max_concurrency] || \"--\"\n workers = d[:active_worker_tasks] || []\n total = completed + failed + active + queue\n pct = if total > 0, do: round(completed / total * 100), else: 0\n bar_filled = round(pct / 100 * 20)\n\n bar =\n \"[\" <> String.duplicate(\"█\", bar_filled) <> String.duplicate(\"░\", 20 - bar_filled) <> \"]\"\n\n worker_lines =\n if workers == [] do\n [\" Workers: none active\"]\n else\n [\" Active Workers:\"] ++ Enum.map(workers, fn name -> \" ⚡ #{name}\" end)\n end\n\n task_statuses = d[:task_statuses] || []\n\n task_status_lines =\n if task_statuses == [] do\n [\" No tasks in current plan\"]\n else\n Enum.map(task_statuses, fn entry ->\n icon =\n case entry.status do\n :running -> \"⚡\"\n :completed -> \"✅\"\n :failed -> \"❌\"\n :queued -> \"📋\"\n _ -> \"⏳\"\n end\n\n status_label = entry.status |> to_string()\n title = entry[:title] || entry.task_id\n \" #{icon} #{entry.task_id}: #{title} [#{status_label}]\"\n end)\n end\n\n log_lines =\n (model.logs || [])\n |> Enum.take(5)\n |> Enum.map(fn entry ->\n time =\n if entry.at,\n do: entry.at |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8),\n else: \"--:--:--\"\n\n type = entry.type |> to_string() |> String.slice(0, 20)\n\n # Show payload message for Code Puppy compat events\n payload = entry.payload || %{}\n msg = if m = payload[:message], do: \" #{String.slice(m, 0, 40)}\", else: \"\"\n\n \" #{time} #{type}#{msg}\"\n end)\n\n error_prefix = error_prefix(model)\n\n error_prefix ++\n [\n \"\",\n \" ⚡ EXECUTION DASHBOARD\",\n \"\",\n \" Phase: #{String.capitalize(to_string(phase))} | #{bar} #{pct}%\",\n \" Queue: #{queue} Active: #{active}/#{max_conc} Done: #{completed} Failed: #{failed}\",\n \"\"\n ] ++\n worker_lines ++\n [\n \"\",\n \" ─── Task Status ───\"\n ] ++\n task_status_lines ++\n [\n \"\",\n \" ─── Recent Events ───\"\n ] ++ log_lines\n end\n\n # ── Logs Lines ──\n\n defp logs_lines(model) do\n log_entries =\n (model.logs || [])\n |> Enum.take(30)\n |> Enum.map(fn entry ->\n time =\n if entry.at,\n do: entry.at |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8),\n else: \"--:--:--\"\n\n type = entry.type |> to_string() |> String.pad_trailing(22)\n source = entry.source |> to_string()\n\n id =\n cond do\n entry.task_id -> \" task=#{String.slice(entry.task_id, 0, 8)}\"\n entry.plan_id -> \" plan=#{String.slice(entry.plan_id, 0, 8)}\"\n true -> \"\"\n end\n\n # Show payload message/reason when available (Code Puppy compat events)\n payload_msg = extract_payload_message(entry)\n\n \" #{time} #{type} #{source}#{id}#{payload_msg}\"\n end)\n\n [\"\" | log_entries]\n end\n\n defp extract_payload_message(entry) do\n payload = entry.payload || %{}\n\n cond do\n msg = payload[:message] -> \" #{String.slice(msg, 0, 60)}\"\n reason = payload[:reason] -> \" reason=#{String.slice(inspect(reason), 0, 40)}\"\n true -> \"\"\n end\n end\n\n # ── Chat Deck ──\n\n defp chat_deck_lines(model) do\n spinner_frame = model.spinner_frame || 0\n spinner_chars = ~w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇]\n spinner = Enum.at(spinner_chars, rem(spinner_frame, length(spinner_chars)), \"⠋\")\n\n cond do\n model.loading? ->\n base = [\" #{spinner} #{model.status_message || \"Planning...\"}\"]\n streaming = model.streaming_content || \"\"\n\n if streaming != \"\" do\n lines =\n streaming\n |> String.split(\"\\n\")\n |> Enum.take(-5)\n |> Enum.map(fn line -> \" │ #{String.slice(line, 0, 70)}\" end)\n\n base ++ lines ++ [\"\"]\n else\n base ++ [\"\"]\n end\n\n true ->\n [\n \" > #{model.input_buffer}▌\",\n \" /welcome /plan /exec /logs /dash /agents /help /quit /clear\",\n \" /tasks /reload [type|all|status] | !<cmd> shell\"\n ]\n end\n end\n\n # ── Approve Deck ──\n\n defp approve_deck_lines(_model) do\n [\" [A]pprove [R]eject [↑/↓] Scroll [Esc] Back | /approve /reject\"]\n end\n\n # ── Agent Picker Deck ──\n\n defp agent_picker_deck_lines(model) do\n if model.selected_agent == nil do\n agents = ordered_agents(model)\n\n agent_str =\n agents\n |> Enum.with_index()\n |> Enum.map(fn {{key, _cfg}, idx} ->\n marker = if idx == (model.selected_index || 0), do: \"▸\", else: \" \"\n \"#{marker}#{key}\"\n end)\n |> Enum.join(\" \")\n\n [\" Agents: #{agent_str}\", \" [↑/↓] Select [Enter] Pick Model [Esc] Back\"]\n else\n agent = Map.get(model.agents || %{}, model.selected_agent)\n provider = if agent, do: agent[\"provider\"], else: \"unknown\"\n models = Spark.ModelCatalog.models_for_provider(provider)\n current_model = if agent, do: agent[\"model\"], else: \"unknown\"\n\n model_str =\n models\n |> Enum.with_index()\n |> Enum.map(fn {m, idx} ->\n marker = if idx == (model.selected_model_index || 0), do: \"▸\", else: \" \"\n current_marker = if m.id == current_model, do: \"*\", else: \"\"\n \"#{marker}#{m.name}#{current_marker}\"\n end)\n |> Enum.join(\" \")\n\n [\n \" Select model for #{model.selected_agent}: #{model_str}\",\n \" [↑/↓] Select [Enter] Pin Model [Esc] Back to Agents\"\n ]\n end\n end\n\n # ── Helpers ──\n\n defp ordered_agents(model) do\n configured = model.agents || %{}\n preferred = model.agent_order || [\"planning\", \"coding\"]\n\n pref =\n Enum.filter(preferred, &Map.has_key?(configured, &1))\n |> Enum.map(fn k -> {k, Map.fetch!(configured, k)} end)\n\n extra = Map.drop(configured, preferred) |> Enum.sort_by(fn {k, _} -> k end)\n pref ++ extra\n end\n\n defp secret?(key) do\n case Spark.Config.Secrets.get_secret(key) do\n s when is_binary(s) and s != \"\" -> true\n _ -> false\n end\n rescue\n _ -> false\n end\n\n defp join_or_none(list) when is_list(list) and length(list) > 0, do: Enum.join(list, \", \")\n defp join_or_none(_), do: \"none\"\n\n defp error_prefix(%{error_message: err}) when is_binary(err) and err != \"\",\n do: [\"\", \" ❌ #{err}\"]\n\n defp error_prefix(_), do: []\n\n # ── Help Lines ──\n\n # ── Tasks Lines ──\n\n defp tasks_lines(%{active_plan: nil} = model) do\n error_prefix(model) ++ [\"\", \" No active plan. Start one with a goal.\", \"\"]\n end\n\n defp tasks_lines(model) do\n plan = model.active_plan\n dash = model.dashboard || %{}\n queue = dash[:queue_length] || 0\n completed = dash[:completed_count] || 0\n failed = dash[:failed_count] || 0\n\n task_lines =\n Enum.map(plan.tasks, fn t ->\n status_sym =\n case t.status do\n :completed -> \"✅\"\n :failed -> \"❌\"\n :running -> \"⚡\"\n :queued -> \"📋\"\n _ -> \"⏳\"\n end\n\n \" #{status_sym} #{t.id}: #{t.title} [#{t.status}]\"\n end)\n\n error_prefix(model) ++\n [\n \"\",\n \" 📋 TASKS — #{plan.id}\",\n \" Queued: #{queue} | Completed: #{completed} | Failed: #{failed}\",\n \"\"\n ] ++ task_lines ++ [\"\"]\n end\n\n # ── Shell Output Lines ──\n\n defp shell_output_lines(model) do\n model.canvas_lines || [\"\", \" No output.\", \"\"]\n end\n\n defp help_lines(_model) do\n [\n \"\",\n \" 📖 S P A R K H E L P\",\n \"\",\n \" ─── Slash Commands ───\",\n \" /welcome Return to the welcome screen\",\n \" /plan View the active plan (if any)\",\n \" /exec Switch to execution dashboard\",\n \" /logs Show recent event logs\",\n \" /dash Alias for /exec (dashboard)\",\n \" /agents Pick or configure an agent\",\n \" /tasks Show task list for active plan\",\n \" /approve Approve the active plan\",\n \" /reject Reject the active plan\",\n \" /clear Clear error/status messages\",\n \" /help Show this help screen\",\n \" /quit Exit Spark\",\n \"\",\n \" ─── Reload Commands ───\",\n \" /reload Reload all components\",\n \" /reload prompts Reload prompts\",\n \" /reload tools Reload tools\",\n \" /reload config Reload config\",\n \" /reload policy Reload policy\",\n \" /reload guidance Reload guidance\",\n \" /reload all Reload all components\",\n \" /reload status Show reload status\",\n \"\",\n \" ─── Shell Commands ───\",\n \" !<command> Run a shell command\",\n \"\",\n \" ─── Navigation ───\",\n \" ↑/↓ Scroll / navigate tasks\",\n \" PageUp/PageDown Scroll by page\",\n \" Esc Go back / cancel\",\n \" Enter Submit goal or confirm\",\n \"\",\n \" Type a goal + Enter to start planning.\"\n ]\n end\n\n defp gen_id, do: \"tui_\" <> Base.url_encode64(:crypto.strong_rand_bytes(8), padding: false)\n\n defp chunk_debug_size(text) when is_binary(text), do: byte_size(text)\n defp chunk_debug_size(%{text: text}) when is_binary(text), do: byte_size(text)\n defp chunk_debug_size(_), do: 0\nend\n"}, "eeb24239961c25da63a56092a7fca791a25f24dd": {"t": 1785758021.5825028, "n": 28409, "h": "9ed4a0a66b67be77", "c": "defmodule Spark.TermUI do\n @moduledoc \"\"\"\n TermUI-based terminal UI for Spark — Component Protocol.\n Uses the simple init/view/update protocol that TermUI.App.run expects.\n \"\"\"\n\n require Logger\n\n # ── Init ──\n\n def init(_opts) do\n Spark.TUI.Actions.install_event_log_hook()\n\n # Disable mouse tracking after a brief delay to enable text selection/copy\n Process.send_after(self(), :disable_mouse_tracking, 500)\n\n {rows, cols} =\n case TermUI.Terminal.get_terminal_size() do\n {:ok, {r, c}} -> {r, c}\n _ -> {24, 80}\n end\n\n %{\n model: %Spark.TUI.Model{\n session_id: gen_id(),\n agents: Spark.TUI.Actions.load_agents(),\n agent_order: [\"planning\", \"coding\"],\n dashboard: Spark.TUI.Actions.dashboard_snapshot(),\n logs: Spark.TUI.Actions.load_logs(),\n status_message: nil,\n width: cols,\n height: rows,\n scroll_top: 0\n },\n tick_count: 0,\n shutting_down: false\n }\n end\n\n # ── Event to message conversion (called by TermUI Runtime) ──\n\n def event_to_msg(event, _state) do\n cond do\n # Quit keys\n is_map(event) && Map.get(event, :key) == :ctrl_c ->\n {:msg, :quit}\n\n # PageUp / PageDown keys\n is_map(event) && Map.get(event, :key) == :page_up ->\n {:msg, {:scroll, -5}}\n\n is_map(event) && Map.get(event, :key) == :page_down ->\n {:msg, {:scroll, 5}}\n\n # Mouse scroll actions\n is_map(event) && Map.get(event, :action) == :scroll_up ->\n {:msg, {:scroll, -3}}\n\n is_map(event) && Map.get(event, :action) == :scroll_down ->\n {:msg, {:scroll, 3}}\n\n # Ctrl-key scroll shortcuts\n is_map(event) && :ctrl in Map.get(event, :modifiers, []) ->\n case Map.get(event, :char) do\n \"p\" -> {:msg, {:scroll, -1}}\n \"n\" -> {:msg, {:scroll, 1}}\n \"k\" -> {:msg, {:scroll, -1}}\n \"j\" -> {:msg, {:scroll, 1}}\n \"u\" -> {:msg, {:scroll, -5}}\n \"d\" -> {:msg, {:scroll, 5}}\n \"c\" -> {:msg, :quit}\n _ -> :ignore\n end\n\n # Printable characters (use char for correct case)\n is_map(event) && is_binary(Map.get(event, :char, \"\")) &&\n byte_size(Map.get(event, :char, \"\")) == 1 ->\n ch = Map.get(event, :char)\n <<codepoint::utf8>> = ch\n {:msg, {:event, %{ch: codepoint}}}\n\n # Special keys\n is_map(event) && is_atom(Map.get(event, :key)) ->\n key = Map.get(event, :key)\n\n case key do\n :enter -> {:msg, {:event, %{key: 0x0D}}}\n :escape -> {:msg, {:event, %{key: 0x1B}}}\n :up -> {:msg, {:event, %{key: 0xFFFF - 18}}}\n :down -> {:msg, {:event, %{key: 0xFFFF - 19}}}\n :left -> {:msg, {:event, %{key: 0xFFFF - 20}}}\n :right -> {:msg, {:event, %{key: 0xFFFF - 21}}}\n :backspace -> {:msg, {:event, %{key: 0x08}}}\n _ -> :ignore\n end\n\n # Tick events\n is_map(event) && is_integer(Map.get(event, :interval, nil)) &&\n Map.get(event, :interval, 0) > 0 ->\n {:msg, :tick}\n\n # Resize events\n is_map(event) && Map.has_key?(event, :width) && Map.has_key?(event, :height) ->\n {:msg, {:resized, event.width, event.height}}\n\n true ->\n :ignore\n end\n end\n\n # ── Handle async results (from Task.start) ──\n\n def handle_info({:plan_result, result}, state) do\n Logger.debug(\"[SPARK] plan_result received\")\n model = Spark.TUI.Update.update(state.model, {:plan_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:approve_result, result}, state) do\n Logger.debug(\"[SPARK] approve_result received\")\n model = Spark.TUI.Update.update(state.model, {:approve_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:reject_result, result}, state) do\n Logger.debug(\"[SPARK] reject_result received\")\n model = Spark.TUI.Update.update(state.model, {:reject_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:pin_result, result}, state) do\n Logger.debug(\"[SPARK] pin_result received\")\n model = Spark.TUI.Update.update(state.model, {:pin_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info(:disable_mouse_tracking, state) do\n TermUI.Terminal.disable_mouse_tracking()\n {state, []}\n end\n\n def handle_info({:shutdown_complete, _result}, state) do\n Logger.info(\"[SPARK] Shutdown complete — exiting\")\n {state, [:quit]}\n end\n\n def handle_info({:stream_chunk, text}, state) do\n Logger.debug(\"[SPARK] stream_chunk: #{chunk_debug_size(text)} bytes\")\n model = Spark.TUI.Update.update(state.model, {:stream_chunk, text})\n {%{state | model: model}, []}\n end\n\n def handle_info({:stream_started, metadata}, state) do\n Logger.debug(\"[SPARK] stream_started: #{inspect(metadata)}\")\n model = Spark.TUI.Update.update(state.model, {:stream_started, metadata})\n {%{state | model: model}, []}\n end\n\n def handle_info({:stream_done, metadata}, state) do\n Logger.debug(\"[SPARK] stream_done: #{inspect(metadata)}\")\n model = Spark.TUI.Update.update(state.model, {:stream_done, metadata})\n {%{state | model: model}, []}\n end\n\n def handle_info({:stream_error, reason}, state) do\n Logger.warning(\"[SPARK] stream_error: #{inspect(reason)}\")\n model = Spark.TUI.Update.update(state.model, {:stream_error, reason})\n {%{state | model: model}, []}\n end\n\n def handle_info(msg, state) do\n case msg do\n :tick ->\n update(:tick, state)\n\n _ ->\n result = Spark.TUI.Update.update(state.model, msg)\n\n case result do\n {model, _cmd} -> {%{state | model: model}, []}\n model -> {%{state | model: model}, []}\n end\n end\n end\n\n # ── Update ──\n\n def update(:tick, %{tick_count: count} = state) do\n if rem(count, 60) == 0 do\n model = Spark.TUI.Update.update(state.model, :tick)\n {%{state | model: model, tick_count: count + 1}, []}\n else\n {%{state | tick_count: count + 1}, []}\n end\n end\n\n def update({:resized, width, height}, state) do\n model = Spark.TUI.Update.update(state.model, {:resized, width, height})\n {%{state | model: model}, []}\n end\n\n def update(:resized, state) do\n {rows, cols} =\n case TermUI.Terminal.get_terminal_size() do\n {:ok, {r, c}} -> {r, c}\n _ -> {24, 80}\n end\n\n model = Spark.TUI.Update.update(state.model, {:resized, cols, rows})\n {%{state | model: model}, []}\n end\n\n def update(:quit, %{shutting_down: true} = state), do: {state, [:quit]}\n\n def update(:quit, state) do\n # Begin graceful shutdown — second Ctrl+C will force-quit\n runtime_self = self()\n session_id = state.model.session_id\n\n Task.start(fn ->\n Logger.info(\"[SPARK] Graceful shutdown initiated\")\n\n # 1. Drain workers (up to 5s)\n drain_result =\n try do\n Spark.Dispatcher.drain(5000)\n rescue\n _ -> {:timeout, -1}\n catch\n :exit, _ -> {:timeout, -1}\n end\n\n Logger.info(\"[SPARK] Drain result: #{inspect(drain_result)}\")\n\n # 2. Flush Bronze log — write a shutdown event\n if session_id do\n try do\n Spark.Memory.Bronze.append(session_id, %{\n type: :shutdown,\n source: \"term_ui\",\n payload: %{drain_result: drain_result}\n })\n rescue\n _ -> :ok\n catch\n :exit, _ -> :ok\n end\n end\n\n send(runtime_self, {:shutdown_complete, drain_result})\n end)\n\n model = %{state.model | status_message: \"🛑 Shutting down...\"}\n {%{state | model: model, shutting_down: true}, []}\n end\n\n def update(msg, state) do\n result = Spark.TUI.Update.update(state.model, msg)\n\n case result do\n {:quit, _} ->\n {state, [:quit]}\n\n {model, cmd} ->\n execute_async(cmd, model, state)\n\n model ->\n {%{state | model: model}, []}\n end\n end\n\n # ── Async execution ──\n\n defp execute_async(cmd, model, state) do\n runtime_self = self()\n\n Task.start(fn ->\n try do\n result = cmd.function.(runtime_self)\n Logger.debug(\"[SPARK] async #{cmd.message} completed\")\n send(runtime_self, {cmd.message, result})\n rescue\n e ->\n Logger.debug(\"[SPARK] async #{cmd.message} error: #{Exception.message(e)}\")\n send(runtime_self, {cmd.message, {:error, Exception.message(e)}})\n catch\n :exit, reason ->\n Logger.debug(\"[SPARK] async #{cmd.message} exit: #{inspect(reason)}\")\n send(runtime_self, {cmd.message, {:error, {:exit, reason}}})\n end\n end)\n\n {%{state | model: model}, []}\n end\n\n # ── View ──\n\n def view(state) do\n model = state.model\n dims = {model.width || 80, model.height || 24}\n\n status_opts = [\n status_line: status_info_for_state(state),\n scroll_top: model.scroll_top || 0\n ]\n\n Spark.TUI.Layout.render_dashboard(\n dims,\n fn -> render_canvas_content(model) end,\n fn -> render_deck_content(model) end,\n status_opts\n )\n end\n\n defp status_info(%{error_message: err}) when is_binary(err) and err != \"\",\n do: {\" ❌ \" <> err, :red}\n\n defp status_info(%{status_message: msg}) when is_binary(msg) and msg != \"\",\n do: {\" ℹ️ \" <> msg, :green}\n\n defp status_info(_), do: nil\n\n # Override status when shutting down\n defp status_info_for_state(%{shutting_down: true}), do: {\" 🛑 Shutting down...\", :yellow}\n defp status_info_for_state(%{model: model}), do: status_info(model)\n\n # ── Canvas Content ──\n\n defp render_canvas_content(model) do\n # Planning session takes priority — combined view for both streaming and plan\n cond do\n model.view_mode == :planning_session ->\n planning_session_lines(model)\n\n model.streaming_active? ->\n streaming_lines(model)\n\n true ->\n case model.view_mode || :welcome do\n :welcome -> welcome_lines(model)\n :plan_review -> plan_review_lines(model)\n :execution -> execution_lines(model)\n :logs -> logs_lines(model)\n :help -> help_lines(model)\n :tasks -> tasks_lines(model)\n :shell_output -> shell_output_lines(model)\n _ -> welcome_lines(model)\n end\n end\n end\n\n # ── Deck Content ──\n\n defp render_deck_content(model) do\n case model.command_mode || :chat do\n :chat -> chat_deck_lines(model)\n :approve -> approve_deck_lines(model)\n :agent_picker -> agent_picker_deck_lines(model)\n _ -> chat_deck_lines(model)\n end\n end\n\n # ── Streaming Lines (canvas display during planning) ──\n\n defp streaming_lines(model) do\n content = model.streaming_content || \"\"\n\n if content == \"\" do\n # Pre-token placeholder: immediate feedback before first chunk\n spinner_frame = model.spinner_frame || 0\n spinner_chars = ~w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇]\n spinner = Enum.at(spinner_chars, rem(spinner_frame, length(spinner_chars)), \"⠋\")\n\n [\n \"\",\n \" #{spinner} 📡 Connecting to planning model...\",\n \" Waiting for first token...\",\n \"\"\n ]\n else\n lines = content |> String.split(\"\\n\") |> Enum.map(fn line -> \" #{line}\" end)\n status = if model.status_message, do: [\"\", \" #{model.status_message}...\", \"\"], else: [\"\"]\n [\"\" | status] ++ [\"\"] ++ lines\n end\n end\n\n # ── Combined Planning Session Lines ──\n\n defp planning_session_lines(model) do\n # Determine status label\n status_label =\n cond do\n model.streaming_active? -> \"📡 Streaming...\"\n model.active_plan != nil -> \"✅ Plan ready\"\n model.error_message != nil and model.error_message != \"\" -> \"❌ Error\"\n model.loading? -> \"⏳ Loading...\"\n true -> \"—\"\n end\n\n # Header\n header = [\n \"\",\n \" 📋 Planning Session\",\n \" Status: #{status_label}\",\n \"\"\n ]\n\n # Transcript section — use planning_transcript (persistent), fallback to streaming_content (live)\n transcript =\n case {model.planning_transcript || \"\", model.streaming_content || \"\"} do\n {t, _sc} when t != \"\" -> t\n {\"\", sc} when sc != \"\" -> sc\n _ -> \"\"\n end\n\n transcript_lines =\n if transcript == \"\" and model.streaming_active? do\n # Show connecting/waiting placeholder\n spinner_frame = model.spinner_frame || 0\n spinner_chars = ~w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇]\n spinner = Enum.at(spinner_chars, rem(spinner_frame, length(spinner_chars)), \"⠋\")\n\n [\n \" ─── Model Output / Visible Reasoning ───\",\n \"\",\n \" #{spinner} 📡 Connecting to planning model...\",\n \" Waiting for first token...\",\n \"\"\n ]\n else\n if transcript == \"\" do\n [\n \" ─── Model Output / Visible Reasoning ───\",\n \"\",\n \" (no transcript)\",\n \"\"\n ]\n else\n t_lines = transcript |> String.split(\"\\n\") |> Enum.map(fn line -> \" #{line}\" end)\n [\" ─── Model Output / Visible Reasoning ───\", \"\" | t_lines] ++ [\"\"]\n end\n end\n\n # Plan section\n plan_lines =\n if model.active_plan do\n planning_session_plan_lines(model)\n else\n if model.streaming_active? do\n [\" ─── Structured Plan ───\", \"\", \" Structured plan will appear below when parsing completes...\", \"\"]\n else\n []\n end\n end\n\n # Error section (if any)\n error_lines = error_prefix(model)\n\n error_lines ++ header ++ transcript_lines ++ plan_lines\n end\n\n # Renders the structured plan portion of the planning session\n defp planning_session_plan_lines(model) do\n plan = model.active_plan\n\n status_str =\n case plan.approval_status do\n :approved -> \"[APPROVED]\"\n :awaiting_approval -> \"[AWAITING APPROVAL]\"\n :rejected -> \"[REJECTED]\"\n s -> \"[#{String.upcase(to_string(s))}]\"\n end\n\n task_lines =\n if plan.tasks do\n sel = model.selected_task_index || 0\n\n Enum.with_index(plan.tasks)\n |> Enum.flat_map(fn {task, idx} ->\n marker = if idx == sel, do: \"▸\", else: \" \"\n\n risk_sym =\n case task.risk do\n :low -> \"🟢\"\n :medium -> \"🟡\"\n :high -> \"🔴\"\n _ -> \"⚪\"\n end\n\n [\n \" #{marker} #{task.id}: #{task.title}\",\n \" #{risk_sym} Risk: #{task.risk} | Deps: #{join_or_none(task.depends_on)} | R/W: #{join_or_none(task.read_paths)}/#{join_or_none(task.write_paths)}\"\n ]\n end)\n else\n [\" No tasks defined.\"]\n end\n\n plan_header =\n [\n \" ─── Structured Plan ───\",\n \"\",\n \" PLAN: #{plan.id}\",\n \" Goal: #{plan.user_goal}\",\n \" Status: #{status_str}\",\n \" Summary: #{plan.summary || \"--\"}\",\n \"\",\n \" ─── Tasks ───\"\n ]\n\n detail =\n if sel_task = Enum.at(plan.tasks || [], model.selected_task_index || 0) do\n [\n \"\",\n \" ─── Detail: #{sel_task.id} ───\",\n \" #{sel_task.title}\",\n \" #{sel_task.description || \"--\"}\",\n \" Timeout: #{sel_task.timeout_ms}ms Retries: #{sel_task.max_retries}\"\n ]\n else\n []\n end\n\n plan_header ++ task_lines ++ detail\n end\n\n # ── Welcome Lines ──\n\n defp welcome_lines(model) do\n dash = model.dashboard || %{}\n phase = dash[:orchestrator_phase] || :idle\n queue = dash[:queue_length] || 0\n active = dash[:active_count] || 0\n agents = model.agents || %{}\n planning = Map.get(agents, \"planning\", %{})\n coding = Map.get(agents, \"coding\", %{})\n\n planning_model = planning[\"model\"] || \"--\"\n planning_prov = planning[\"provider\"] || \"--\"\n coding_model = coding[\"model\"] || \"--\"\n coding_prov = coding[\"provider\"] || \"--\"\n planning_key = if secret?(:deepseek_api_key), do: \"OK\", else: \"MISSING\"\n coding_key = if secret?(:wafer_api_key), do: \"OK\", else: \"MISSING\"\n\n error_prefix = error_prefix(model)\n\n error_prefix ++\n [\n \"\",\n \" 🔮 S P A R K v 4 . 1 🐶\",\n \" Parallel Actor-Model Code Agent\",\n \"\",\n \" ─── Agent Status ───\",\n \" 📋 Planning | #{planning_model} (#{planning_prov}) key: #{planning_key}\",\n \" 🔧 Coding | #{coding_model} (#{coding_prov}) key: #{coding_key}\",\n \"\",\n \" ─── Runtime ───\",\n \" Phase: #{String.capitalize(to_string(phase))} | Queue: #{queue} | Active: #{active}\",\n \"\",\n \" ─── Shortcuts ───\",\n \" Type a goal + Enter to plan\",\n \" /welcome /plan /exec /logs /dash /agents /help /quit /clear\",\n \" /tasks /reload [prompts|tools|config|policy|guidance|all|status]\",\n \" /approve /reject (when plan is active) | !<cmd> shell\",\n \"\",\n \" Ready. Type your goal or a slash command.\"\n ]\n end\n\n # ── Plan Review Lines ──\n\n defp plan_review_lines(model) do\n plan = model.active_plan\n\n if plan == nil do\n error_prefix(model) ++ [\"\", \" No active plan.\", \"\"]\n else\n status_str =\n case plan.approval_status do\n :approved -> \"[APPROVED]\"\n :awaiting_approval -> \"[AWAITING APPROVAL]\"\n :rejected -> \"[REJECTED]\"\n s -> \"[#{String.upcase(to_string(s))}]\"\n end\n\n task_lines =\n if plan.tasks do\n sel = model.selected_task_index || 0\n\n Enum.with_index(plan.tasks)\n |> Enum.flat_map(fn {task, idx} ->\n marker = if idx == sel, do: \"▸\", else: \" \"\n\n risk_sym =\n case task.risk do\n :low -> \"🟢\"\n :medium -> \"🟡\"\n :high -> \"🔴\"\n _ -> \"⚪\"\n end\n\n [\n \" #{marker} #{task.id}: #{task.title}\",\n \" #{risk_sym} Risk: #{task.risk} | Deps: #{join_or_none(task.depends_on)} | R/W: #{join_or_none(task.read_paths)}/#{join_or_none(task.write_paths)}\"\n ]\n end)\n else\n [\" No tasks defined.\"]\n end\n\n error_prefix = error_prefix(model)\n\n header =\n error_prefix ++\n [\n \"\",\n \" PLAN: #{plan.id}\",\n \" Goal: #{plan.user_goal}\",\n \" Status: #{status_str}\",\n \" Summary: #{plan.summary || \"--\"}\",\n \"\",\n \" ─── Tasks ───\"\n ]\n\n detail =\n if sel_task = Enum.at(plan.tasks || [], model.selected_task_index || 0) do\n [\n \"\",\n \" ─── Detail: #{sel_task.id} ───\",\n \" #{sel_task.title}\",\n \" #{sel_task.description || \"--\"}\",\n \" Timeout: #{sel_task.timeout_ms}ms Retries: #{sel_task.max_retries}\"\n ]\n else\n []\n end\n\n header ++ task_lines ++ detail\n end\n end\n\n # ── Execution Lines ──\n\n defp execution_lines(model) do\n d = model.dashboard || %{}\n phase = d[:orchestrator_phase] || :idle\n queue = d[:queue_length] || 0\n active = d[:active_count] || 0\n completed = d[:completed_count] || 0\n failed = d[:failed_count] || 0\n max_conc = d[:max_concurrency] || \"--\"\n workers = d[:active_worker_tasks] || []\n total = completed + failed + active + queue\n pct = if total > 0, do: round(completed / total * 100), else: 0\n bar_filled = round(pct / 100 * 20)\n\n bar =\n \"[\" <> String.duplicate(\"█\", bar_filled) <> String.duplicate(\"░\", 20 - bar_filled) <> \"]\"\n\n worker_lines =\n if workers == [] do\n [\" Workers: none active\"]\n else\n [\" Active Workers:\"] ++ Enum.map(workers, fn name -> \" ⚡ #{name}\" end)\n end\n\n task_statuses = d[:task_statuses] || []\n\n task_status_lines =\n if task_statuses == [] do\n [\" No tasks in current plan\"]\n else\n Enum.map(task_statuses, fn entry ->\n icon =\n case entry.status do\n :running -> \"⚡\"\n :completed -> \"✅\"\n :failed -> \"❌\"\n :queued -> \"📋\"\n _ -> \"⏳\"\n end\n\n status_label = entry.status |> to_string()\n title = entry[:title] || entry.task_id\n \" #{icon} #{entry.task_id}: #{title} [#{status_label}]\"\n end)\n end\n\n log_lines =\n (model.logs || [])\n |> Enum.take(5)\n |> Enum.map(fn entry ->\n time =\n if entry.at,\n do: entry.at |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8),\n else: \"--:--:--\"\n\n type = entry.type |> to_string() |> String.slice(0, 20)\n\n # Show payload message for Code Puppy compat events\n payload = entry.payload || %{}\n msg = if m = payload[:message], do: \" #{String.slice(m, 0, 40)}\", else: \"\"\n\n \" #{time} #{type}#{msg}\"\n end)\n\n error_prefix = error_prefix(model)\n\n error_prefix ++\n [\n \"\",\n \" ⚡ EXECUTION DASHBOARD\",\n \"\",\n \" Phase: #{String.capitalize(to_string(phase))} | #{bar} #{pct}%\",\n \" Queue: #{queue} Active: #{active}/#{max_conc} Done: #{completed} Failed: #{failed}\",\n \"\"\n ] ++\n worker_lines ++\n [\n \"\",\n \" ─── Task Status ───\"\n ] ++\n task_status_lines ++\n [\n \"\",\n \" ─── Recent Events ───\"\n ] ++ log_lines\n end\n\n # ── Logs Lines ──\n\n defp logs_lines(model) do\n log_entries =\n (model.logs || [])\n |> Enum.take(30)\n |> Enum.map(fn entry ->\n time =\n if entry.at,\n do: entry.at |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8),\n else: \"--:--:--\"\n\n type = entry.type |> to_string() |> String.pad_trailing(22)\n source = entry.source |> to_string()\n\n id =\n cond do\n entry.task_id -> \" task=#{String.slice(entry.task_id, 0, 8)}\"\n entry.plan_id -> \" plan=#{String.slice(entry.plan_id, 0, 8)}\"\n true -> \"\"\n end\n\n # Show payload message/reason when available (Code Puppy compat events)\n payload_msg = extract_payload_message(entry)\n\n \" #{time} #{type} #{source}#{id}#{payload_msg}\"\n end)\n\n [\"\" | log_entries]\n end\n\n defp extract_payload_message(entry) do\n payload = entry.payload || %{}\n\n cond do\n msg = payload[:message] -> \" #{String.slice(msg, 0, 60)}\"\n reason = payload[:reason] -> \" reason=#{String.slice(inspect(reason), 0, 40)}\"\n true -> \"\"\n end\n end\n\n # ── Chat Deck ──\n\n defp chat_deck_lines(model) do\n spinner_frame = model.spinner_frame || 0\n spinner_chars = ~w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇]\n spinner = Enum.at(spinner_chars, rem(spinner_frame, length(spinner_chars)), \"⠋\")\n\n cond do\n model.loading? ->\n base = [\" #{spinner} #{model.status_message || \"Planning...\"}\"]\n streaming = model.streaming_content || \"\"\n\n if streaming != \"\" do\n lines =\n streaming\n |> String.split(\"\\n\")\n |> Enum.take(-5)\n |> Enum.map(fn line -> \" │ #{String.slice(line, 0, 70)}\" end)\n\n base ++ lines ++ [\"\"]\n else\n base ++ [\"\"]\n end\n\n true ->\n [\n \" > #{model.input_buffer}▌\",\n \" /welcome /plan /exec /logs /dash /agents /help /quit /clear\",\n \" /tasks /reload [type|all|status] | !<cmd> shell\"\n ]\n end\n end\n\n # ── Approve Deck ──\n\n defp approve_deck_lines(_model) do\n [\" [A]pprove [R]eject [↑/↓] Scroll [Esc] Back | /approve /reject\"]\n end\n\n # ── Agent Picker Deck ──\n\n defp agent_picker_deck_lines(model) do\n if model.selected_agent == nil do\n agents = ordered_agents(model)\n\n agent_str =\n agents\n |> Enum.with_index()\n |> Enum.map(fn {{key, _cfg}, idx} ->\n marker = if idx == (model.selected_index || 0), do: \"▸\", else: \" \"\n \"#{marker}#{key}\"\n end)\n |> Enum.join(\" \")\n\n [\" Agents: #{agent_str}\", \" [↑/↓] Select [Enter] Pick Model [Esc] Back\"]\n else\n agent = Map.get(model.agents || %{}, model.selected_agent)\n provider = if agent, do: agent[\"provider\"], else: \"unknown\"\n models = Spark.ModelCatalog.models_for_provider(provider)\n current_model = if agent, do: agent[\"model\"], else: \"unknown\"\n\n model_str =\n models\n |> Enum.with_index()\n |> Enum.map(fn {m, idx} ->\n marker = if idx == (model.selected_model_index || 0), do: \"▸\", else: \" \"\n current_marker = if m.id == current_model, do: \"*\", else: \"\"\n \"#{marker}#{m.name}#{current_marker}\"\n end)\n |> Enum.join(\" \")\n\n [\n \" Select model for #{model.selected_agent}: #{model_str}\",\n \" [↑/↓] Select [Enter] Pin Model [Esc] Back to Agents\"\n ]\n end\n end\n\n # ── Helpers ──\n\n defp ordered_agents(model) do\n configured = model.agents || %{}\n preferred = model.agent_order || [\"planning\", \"coding\"]\n\n pref =\n Enum.filter(preferred, &Map.has_key?(configured, &1))\n |> Enum.map(fn k -> {k, Map.fetch!(configured, k)} end)\n\n extra = Map.drop(configured, preferred) |> Enum.sort_by(fn {k, _} -> k end)\n pref ++ extra\n end\n\n defp secret?(key) do\n case Spark.Config.Secrets.get_secret(key) do\n s when is_binary(s) and s != \"\" -> true\n _ -> false\n end\n rescue\n _ -> false\n end\n\n defp join_or_none(list) when is_list(list) and length(list) > 0, do: Enum.join(list, \", \")\n defp join_or_none(_), do: \"none\"\n\n defp error_prefix(%{error_message: err}) when is_binary(err) and err != \"\",\n do: [\"\", \" ❌ #{err}\"]\n\n defp error_prefix(_), do: []\n\n # ── Help Lines ──\n\n # ── Tasks Lines ──\n\n defp tasks_lines(%{active_plan: nil} = model) do\n error_prefix(model) ++ [\"\", \" No active plan. Start one with a goal.\", \"\"]\n end\n\n defp tasks_lines(model) do\n plan = model.active_plan\n dash = model.dashboard || %{}\n queue = dash[:queue_length] || 0\n completed = dash[:completed_count] || 0\n failed = dash[:failed_count] || 0\n\n task_lines =\n Enum.map(plan.tasks, fn t ->\n status_sym =\n case t.status do\n :completed -> \"✅\"\n :failed -> \"❌\"\n :running -> \"⚡\"\n :queued -> \"📋\"\n _ -> \"⏳\"\n end\n\n \" #{status_sym} #{t.id}: #{t.title} [#{t.status}]\"\n end)\n\n error_prefix(model) ++\n [\n \"\",\n \" 📋 TASKS — #{plan.id}\",\n \" Queued: #{queue} | Completed: #{completed} | Failed: #{failed}\",\n \"\"\n ] ++ task_lines ++ [\"\"]\n end\n\n # ── Shell Output Lines ──\n\n defp shell_output_lines(model) do\n model.canvas_lines || [\"\", \" No output.\", \"\"]\n end\n\n defp help_lines(_model) do\n [\n \"\",\n \" 📖 S P A R K H E L P\",\n \"\",\n \" ─── Slash Commands ───\",\n \" /welcome Return to the welcome screen\",\n \" /plan View the active plan (if any)\",\n \" /exec Switch to execution dashboard\",\n \" /logs Show recent event logs\",\n \" /dash Alias for /exec (dashboard)\",\n \" /agents Pick or configure an agent\",\n \" /tasks Show task list for active plan\",\n \" /approve Approve the active plan\",\n \" /reject Reject the active plan\",\n \" /clear Clear error/status messages\",\n \" /help Show this help screen\",\n \" /quit Exit Spark\",\n \"\",\n \" ─── Reload Commands ───\",\n \" /reload Reload all components\",\n \" /reload prompts Reload prompts\",\n \" /reload tools Reload tools\",\n \" /reload config Reload config\",\n \" /reload policy Reload policy\",\n \" /reload guidance Reload guidance\",\n \" /reload all Reload all components\",\n \" /reload status Show reload status\",\n \"\",\n \" ─── Shell Commands ───\",\n \" !<command> Run a shell command\",\n \"\",\n \" ─── Navigation ───\",\n \" ↑/↓ Scroll / navigate tasks\",\n \" PageUp/PageDown Scroll by page\",\n \" Esc Go back / cancel\",\n \" Enter Submit goal or confirm\",\n \"\",\n \" Type a goal + Enter to start planning.\"\n ]\n end\n\n defp gen_id, do: \"tui_\" <> Base.url_encode64(:crypto.strong_rand_bytes(8), padding: false)\n\n defp chunk_debug_size(text) when is_binary(text), do: byte_size(text)\n defp chunk_debug_size(%{text: text}) when is_binary(text), do: byte_size(text)\n defp chunk_debug_size(_), do: 0\nend\n"}, "eab70a9b622a1acee55cdb58abdaa7669b450f68": {"t": 1785758023.9779003, "n": 22517, "h": "6197437ef5c35f15", "c": "defmodule Spark.TermUI do\n @moduledoc \"\"\"\n TermUI-based terminal UI for Spark — Component Protocol.\n Uses the simple init/view/update protocol that TermUI.App.run expects.\n \"\"\"\n\n require Logger\n\n # ── Init ──\n\n def init(_opts) do\n Spark.TUI.Actions.install_event_log_hook()\n\n # Disable mouse tracking after a brief delay to enable text selection/copy\n Process.send_after(self(), :disable_mouse_tracking, 500)\n\n {rows, cols} =\n case TermUI.Terminal.get_terminal_size() do\n {:ok, {r, c}} -> {r, c}\n _ -> {24, 80}\n end\n\n %{\n model: %Spark.TUI.Model{\n session_id: gen_id(),\n agents: Spark.TUI.Actions.load_agents(),\n agent_order: [\"planning\", \"coding\"],\n dashboard: Spark.TUI.Actions.dashboard_snapshot(),\n logs: Spark.TUI.Actions.load_logs(),\n status_message: nil,\n width: cols,\n height: rows,\n scroll_top: 0\n },\n tick_count: 0,\n shutting_down: false\n }\n end\n\n # ── Event to message conversion (called by TermUI Runtime) ──\n\n def event_to_msg(event, _state) do\n cond do\n # Quit keys\n is_map(event) && Map.get(event, :key) == :ctrl_c ->\n {:msg, :quit}\n\n # PageUp / PageDown keys\n is_map(event) && Map.get(event, :key) == :page_up ->\n {:msg, {:scroll, -5}}\n\n is_map(event) && Map.get(event, :key) == :page_down ->\n {:msg, {:scroll, 5}}\n\n # Mouse scroll actions\n is_map(event) && Map.get(event, :action) == :scroll_up ->\n {:msg, {:scroll, -3}}\n\n is_map(event) && Map.get(event, :action) == :scroll_down ->\n {:msg, {:scroll, 3}}\n\n # Ctrl-key scroll shortcuts\n is_map(event) && :ctrl in Map.get(event, :modifiers, []) ->\n case Map.get(event, :char) do\n \"p\" -> {:msg, {:scroll, -1}}\n \"n\" -> {:msg, {:scroll, 1}}\n \"k\" -> {:msg, {:scroll, -1}}\n \"j\" -> {:msg, {:scroll, 1}}\n \"u\" -> {:msg, {:scroll, -5}}\n \"d\" -> {:msg, {:scroll, 5}}\n \"c\" -> {:msg, :quit}\n _ -> :ignore\n end\n\n # Printable characters (use char for correct case)\n is_map(event) && is_binary(Map.get(event, :char, \"\")) &&\n byte_size(Map.get(event, :char, \"\")) == 1 ->\n ch = Map.get(event, :char)\n <<codepoint::utf8>> = ch\n {:msg, {:event, %{ch: codepoint}}}\n\n # Special keys\n is_map(event) && is_atom(Map.get(event, :key)) ->\n key = Map.get(event, :key)\n\n case key do\n :enter -> {:msg, {:event, %{key: 0x0D}}}\n :escape -> {:msg, {:event, %{key: 0x1B}}}\n :up -> {:msg, {:event, %{key: 0xFFFF - 18}}}\n :down -> {:msg, {:event, %{key: 0xFFFF - 19}}}\n :left -> {:msg, {:event, %{key: 0xFFFF - 20}}}\n :right -> {:msg, {:event, %{key: 0xFFFF - 21}}}\n :backspace -> {:msg, {:event, %{key: 0x08}}}\n _ -> :ignore\n end\n\n # Tick events\n is_map(event) && is_integer(Map.get(event, :interval, nil)) &&\n Map.get(event, :interval, 0) > 0 ->\n {:msg, :tick}\n\n # Resize events\n is_map(event) && Map.has_key?(event, :width) && Map.has_key?(event, :height) ->\n {:msg, {:resized, event.width, event.height}}\n\n true ->\n :ignore\n end\n end\n\n # ── Handle async results (from Task.start) ──\n\n def handle_info({:plan_result, result}, state) do\n Logger.debug(\"[SPARK] plan_result received\")\n\n model =\n state.model\n |> Map.put(:streaming_content, \"\")\n |> then(&Spark.TUI.Update.update(&1, {:plan_result, result}))\n\n {%{state | model: model}, []}\n end\n\n def handle_info({:approve_result, result}, state) do\n Logger.debug(\"[SPARK] approve_result received\")\n model = Spark.TUI.Update.update(state.model, {:approve_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:reject_result, result}, state) do\n Logger.debug(\"[SPARK] reject_result received\")\n model = Spark.TUI.Update.update(state.model, {:reject_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:pin_result, result}, state) do\n Logger.debug(\"[SPARK] pin_result received\")\n model = Spark.TUI.Update.update(state.model, {:pin_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info(:disable_mouse_tracking, state) do\n TermUI.Terminal.disable_mouse_tracking()\n {state, []}\n end\n\n def handle_info({:shutdown_complete, _result}, state) do\n Logger.info(\"[SPARK] Shutdown complete — exiting\")\n {state, [:quit]}\n end\n\n def handle_info({:stream_chunk, text}, state) do\n Logger.debug(\"[SPARK] stream_chunk: #{byte_size(text)} bytes\")\n model = Spark.TUI.Update.update(state.model, {:stream_chunk, text})\n {%{state | model: model}, []}\n end\n\n def handle_info(msg, state) do\n case msg do\n :tick ->\n update(:tick, state)\n\n _ ->\n result = Spark.TUI.Update.update(state.model, msg)\n\n case result do\n {model, _cmd} -> {%{state | model: model}, []}\n model -> {%{state | model: model}, []}\n end\n end\n end\n\n # ── Update ──\n\n def update(:tick, %{tick_count: count} = state) do\n if rem(count, 60) == 0 do\n model = Spark.TUI.Update.update(state.model, :tick)\n {%{state | model: model, tick_count: count + 1}, []}\n else\n {%{state | tick_count: count + 1}, []}\n end\n end\n\n def update({:resized, width, height}, state) do\n model = Spark.TUI.Update.update(state.model, {:resized, width, height})\n {%{state | model: model}, []}\n end\n\n def update(:resized, state) do\n {rows, cols} =\n case TermUI.Terminal.get_terminal_size() do\n {:ok, {r, c}} -> {r, c}\n _ -> {24, 80}\n end\n\n model = Spark.TUI.Update.update(state.model, {:resized, cols, rows})\n {%{state | model: model}, []}\n end\n\n def update(:quit, %{shutting_down: true} = state), do: {state, [:quit]}\n\n def update(:quit, state) do\n # Begin graceful shutdown — second Ctrl+C will force-quit\n runtime_self = self()\n session_id = state.model.session_id\n\n Task.start(fn ->\n Logger.info(\"[SPARK] Graceful shutdown initiated\")\n\n # 1. Drain workers (up to 5s)\n drain_result =\n try do\n Spark.Dispatcher.drain(5000)\n rescue\n _ -> {:timeout, -1}\n catch\n :exit, _ -> {:timeout, -1}\n end\n\n Logger.info(\"[SPARK] Drain result: #{inspect(drain_result)}\")\n\n # 2. Flush Bronze log — write a shutdown event\n if session_id do\n try do\n Spark.Memory.Bronze.append(session_id, %{\n type: :shutdown,\n source: \"term_ui\",\n payload: %{drain_result: drain_result}\n })\n rescue\n _ -> :ok\n catch\n :exit, _ -> :ok\n end\n end\n\n send(runtime_self, {:shutdown_complete, drain_result})\n end)\n\n model = %{state.model | status_message: \"🛑 Shutting down...\"}\n {%{state | model: model, shutting_down: true}, []}\n end\n\n def update(msg, state) do\n result = Spark.TUI.Update.update(state.model, msg)\n\n case result do\n {:quit, _} ->\n {state, [:quit]}\n\n {model, cmd} ->\n execute_async(cmd, model, state)\n\n model ->\n {%{state | model: model}, []}\n end\n end\n\n # ── Async execution ──\n\n defp execute_async(cmd, model, state) do\n runtime_self = self()\n\n Task.start(fn ->\n try do\n result = cmd.function.(runtime_self)\n Logger.debug(\"[SPARK] async #{cmd.message} completed\")\n send(runtime_self, {cmd.message, result})\n rescue\n e ->\n Logger.debug(\"[SPARK] async #{cmd.message} error: #{Exception.message(e)}\")\n send(runtime_self, {cmd.message, {:error, Exception.message(e)}})\n catch\n :exit, reason ->\n Logger.debug(\"[SPARK] async #{cmd.message} exit: #{inspect(reason)}\")\n send(runtime_self, {cmd.message, {:error, {:exit, reason}}})\n end\n end)\n\n {%{state | model: model}, []}\n end\n\n # ── View ──\n\n def view(state) do\n model = state.model\n dims = {model.width || 80, model.height || 24}\n\n status_opts = [\n status_line: status_info_for_state(state),\n scroll_top: model.scroll_top || 0\n ]\n\n Spark.TUI.Layout.render_dashboard(\n dims,\n fn -> render_canvas_content(model) end,\n fn -> render_deck_content(model) end,\n status_opts\n )\n end\n\n defp status_info(%{error_message: err}) when is_binary(err) and err != \"\",\n do: {\" ❌ \" <> err, :red}\n\n defp status_info(%{status_message: msg}) when is_binary(msg) and msg != \"\",\n do: {\" ℹ️ \" <> msg, :green}\n\n defp status_info(_), do: nil\n\n # Override status when shutting down\n defp status_info_for_state(%{shutting_down: true}), do: {\" 🛑 Shutting down...\", :yellow}\n defp status_info_for_state(%{model: model}), do: status_info(model)\n\n # ── Canvas Content ──\n\n defp render_canvas_content(model) do\n # During streaming planning, show the live output in the canvas\n if model.loading? && model.streaming_content != nil && model.streaming_content != \"\" do\n streaming_lines(model)\n else\n case model.view_mode || :welcome do\n :welcome -> welcome_lines(model)\n :plan_review -> plan_review_lines(model)\n :execution -> execution_lines(model)\n :logs -> logs_lines(model)\n :help -> help_lines(model)\n :tasks -> tasks_lines(model)\n :shell_output -> shell_output_lines(model)\n _ -> welcome_lines(model)\n end\n end\n end\n\n # ── Deck Content ──\n\n defp render_deck_content(model) do\n case model.command_mode || :chat do\n :chat -> chat_deck_lines(model)\n :approve -> approve_deck_lines(model)\n :agent_picker -> agent_picker_deck_lines(model)\n _ -> chat_deck_lines(model)\n end\n end\n\n # ── Streaming Lines (canvas display during planning) ──\n\n defp streaming_lines(model) do\n content = model.streaming_content || \"\"\n lines = content |> String.split(\"\\n\") |> Enum.map(fn line -> \" #{line}\" end)\n status = if model.status_message, do: [\"\", \" #{model.status_message}...\", \"\"], else: [\"\"]\n [\"\" | status] ++ [\"\"] ++ lines\n end\n\n # ── Welcome Lines ──\n\n defp welcome_lines(model) do\n dash = model.dashboard || %{}\n phase = dash[:orchestrator_phase] || :idle\n queue = dash[:queue_length] || 0\n active = dash[:active_count] || 0\n agents = model.agents || %{}\n planning = Map.get(agents, \"planning\", %{})\n coding = Map.get(agents, \"coding\", %{})\n\n planning_model = planning[\"model\"] || \"--\"\n planning_prov = planning[\"provider\"] || \"--\"\n coding_model = coding[\"model\"] || \"--\"\n coding_prov = coding[\"provider\"] || \"--\"\n planning_key = if secret?(:deepseek_api_key), do: \"OK\", else: \"MISSING\"\n coding_key = if secret?(:wafer_api_key), do: \"OK\", else: \"MISSING\"\n\n error_prefix = error_prefix(model)\n\n error_prefix ++\n [\n \"\",\n \" 🔮 S P A R K v 4 . 1 🐶\",\n \" Parallel Actor-Model Code Agent\",\n \"\",\n \" ─── Agent Status ───\",\n \" 📋 Planning | #{planning_model} (#{planning_prov}) key: #{planning_key}\",\n \" 🔧 Coding | #{coding_model} (#{coding_prov}) key: #{coding_key}\",\n \"\",\n \" ─── Runtime ───\",\n \" Phase: #{String.capitalize(to_string(phase))} | Queue: #{queue} | Active: #{active}\",\n \"\",\n \" ─── Shortcuts ───\",\n \" Type a goal + Enter to plan\",\n \" /welcome /plan /exec /logs /dash /agents /help /quit /clear\",\n \" /tasks /reload [prompts|tools|config|policy|guidance|all|status]\",\n \" /approve /reject (when plan is active) | !<cmd> shell\",\n \"\",\n \" Ready. Type your goal or a slash command.\"\n ]\n end\n\n # ── Plan Review Lines ──\n\n defp plan_review_lines(model) do\n plan = model.active_plan\n\n if plan == nil do\n error_prefix(model) ++ [\"\", \" No active plan.\", \"\"]\n else\n status_str =\n case plan.approval_status do\n :approved -> \"[APPROVED]\"\n :awaiting_approval -> \"[AWAITING APPROVAL]\"\n :rejected -> \"[REJECTED]\"\n s -> \"[#{String.upcase(to_string(s))}]\"\n end\n\n task_lines =\n if plan.tasks do\n sel = model.selected_task_index || 0\n\n Enum.with_index(plan.tasks)\n |> Enum.flat_map(fn {task, idx} ->\n marker = if idx == sel, do: \"▸\", else: \" \"\n\n risk_sym =\n case task.risk do\n :low -> \"🟢\"\n :medium -> \"🟡\"\n :high -> \"🔴\"\n _ -> \"⚪\"\n end\n\n [\n \" #{marker} #{task.id}: #{task.title}\",\n \" #{risk_sym} Risk: #{task.risk} | Deps: #{join_or_none(task.depends_on)} | R/W: #{join_or_none(task.read_paths)}/#{join_or_none(task.write_paths)}\"\n ]\n end)\n else\n [\" No tasks defined.\"]\n end\n\n error_prefix = error_prefix(model)\n\n header =\n error_prefix ++\n [\n \"\",\n \" PLAN: #{plan.id}\",\n \" Goal: #{plan.user_goal}\",\n \" Status: #{status_str}\",\n \" Summary: #{plan.summary || \"--\"}\",\n \"\",\n \" ─── Tasks ───\"\n ]\n\n detail =\n if sel_task = Enum.at(plan.tasks || [], model.selected_task_index || 0) do\n [\n \"\",\n \" ─── Detail: #{sel_task.id} ───\",\n \" #{sel_task.title}\",\n \" #{sel_task.description || \"--\"}\",\n \" Timeout: #{sel_task.timeout_ms}ms Retries: #{sel_task.max_retries}\"\n ]\n else\n []\n end\n\n header ++ task_lines ++ detail\n end\n end\n\n # ── Execution Lines ──\n\n defp execution_lines(model) do\n d = model.dashboard || %{}\n phase = d[:orchestrator_phase] || :idle\n queue = d[:queue_length] || 0\n active = d[:active_count] || 0\n completed = d[:completed_count] || 0\n failed = d[:failed_count] || 0\n max_conc = d[:max_concurrency] || \"--\"\n workers = d[:active_worker_tasks] || []\n total = completed + failed + active + queue\n pct = if total > 0, do: round(completed / total * 100), else: 0\n bar_filled = round(pct / 100 * 20)\n\n bar =\n \"[\" <> String.duplicate(\"█\", bar_filled) <> String.duplicate(\"░\", 20 - bar_filled) <> \"]\"\n\n worker_lines =\n if workers == [] do\n [\" Workers: none active\"]\n else\n [\" Active Workers:\"] ++ Enum.map(workers, fn name -> \" ⚡ #{name}\" end)\n end\n\n task_statuses = d[:task_statuses] || []\n\n task_status_lines =\n if task_statuses == [] do\n [\" No tasks in current plan\"]\n else\n Enum.map(task_statuses, fn entry ->\n icon =\n case entry.status do\n :running -> \"⚡\"\n :completed -> \"✅\"\n :failed -> \"❌\"\n :queued -> \"📋\"\n _ -> \"⏳\"\n end\n\n status_label = entry.status |> to_string()\n title = entry[:title] || entry.task_id\n \" #{icon} #{entry.task_id}: #{title} [#{status_label}]\"\n end)\n end\n\n log_lines =\n (model.logs || [])\n |> Enum.take(5)\n |> Enum.map(fn entry ->\n time =\n if entry.at,\n do: entry.at |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8),\n else: \"--:--:--\"\n\n type = entry.type |> to_string() |> String.slice(0, 20)\n \" #{time} #{type}\"\n end)\n\n error_prefix = error_prefix(model)\n\n error_prefix ++\n [\n \"\",\n \" ⚡ EXECUTION DASHBOARD\",\n \"\",\n \" Phase: #{String.capitalize(to_string(phase))} | #{bar} #{pct}%\",\n \" Queue: #{queue} Active: #{active}/#{max_conc} Done: #{completed} Failed: #{failed}\",\n \"\"\n ] ++\n worker_lines ++\n [\n \"\",\n \" ─── Task Status ───\"\n ] ++\n task_status_lines ++\n [\n \"\",\n \" ─── Recent Events ───\"\n ] ++ log_lines\n end\n\n # ── Logs Lines ──\n\n defp logs_lines(model) do\n log_entries =\n (model.logs || [])\n |> Enum.take(30)\n |> Enum.map(fn entry ->\n time =\n if entry.at,\n do: entry.at |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8),\n else: \"--:--:--\"\n\n type = entry.type |> to_string() |> String.pad_trailing(22)\n source = entry.source |> to_string()\n\n id =\n cond do\n entry.task_id -> \" task=#{String.slice(entry.task_id, 0, 8)}\"\n entry.plan_id -> \" plan=#{String.slice(entry.plan_id, 0, 8)}\"\n true -> \"\"\n end\n\n \" #{time} #{type} #{source}#{id}\"\n end)\n\n [\"\" | log_entries]\n end\n\n # ── Chat Deck ──\n\n defp chat_deck_lines(model) do\n spinner_frame = model.spinner_frame || 0\n spinner_chars = ~w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇]\n spinner = Enum.at(spinner_chars, rem(spinner_frame, length(spinner_chars)), \"⠋\")\n\n cond do\n model.loading? ->\n base = [\" #{spinner} #{model.status_message || \"Planning...\"}\"]\n streaming = model.streaming_content || \"\"\n\n if streaming != \"\" do\n lines =\n streaming\n |> String.split(\"\\n\")\n |> Enum.take(-5)\n |> Enum.map(fn line -> \" │ #{String.slice(line, 0, 70)}\" end)\n\n base ++ lines ++ [\"\"]\n else\n base ++ [\"\"]\n end\n\n true ->\n [\n \" > #{model.input_buffer}▌\",\n \" /welcome /plan /exec /logs /dash /agents /help /quit /clear\",\n \" /tasks /reload [type|all|status] | !<cmd> shell\"\n ]\n end\n end\n\n # ── Approve Deck ──\n\n defp approve_deck_lines(_model) do\n [\" [A]pprove [R]eject [↑/↓] Scroll [Esc] Back | /approve /reject\"]\n end\n\n # ── Agent Picker Deck ──\n\n defp agent_picker_deck_lines(model) do\n if model.selected_agent == nil do\n agents = ordered_agents(model)\n\n agent_str =\n agents\n |> Enum.with_index()\n |> Enum.map(fn {{key, _cfg}, idx} ->\n marker = if idx == (model.selected_index || 0), do: \"▸\", else: \" \"\n \"#{marker}#{key}\"\n end)\n |> Enum.join(\" \")\n\n [\" Agents: #{agent_str}\", \" [↑/↓] Select [Enter] Pick Model [Esc] Back\"]\n else\n agent = Map.get(model.agents || %{}, model.selected_agent)\n provider = if agent, do: agent[\"provider\"], else: \"unknown\"\n models = Spark.ModelCatalog.models_for_provider(provider)\n current_model = if agent, do: agent[\"model\"], else: \"unknown\"\n\n model_str =\n models\n |> Enum.with_index()\n |> Enum.map(fn {m, idx} ->\n marker = if idx == (model.selected_model_index || 0), do: \"▸\", else: \" \"\n current_marker = if m.id == current_model, do: \"*\", else: \"\"\n \"#{marker}#{m.name}#{current_marker}\"\n end)\n |> Enum.join(\" \")\n\n [\n \" Select model for #{model.selected_agent}: #{model_str}\",\n \" [↑/↓] Select [Enter] Pin Model [Esc] Back to Agents\"\n ]\n end\n end\n\n # ── Helpers ──\n\n defp ordered_agents(model) do\n configured = model.agents || %{}\n preferred = model.agent_order || [\"planning\", \"coding\"]\n\n pref =\n Enum.filter(preferred, &Map.has_key?(configured, &1))\n |> Enum.map(fn k -> {k, Map.fetch!(configured, k)} end)\n\n extra = Map.drop(configured, preferred) |> Enum.sort_by(fn {k, _} -> k end)\n pref ++ extra\n end\n\n defp secret?(key) do\n case Spark.Config.Secrets.get_secret(key) do\n s when is_binary(s) and s != \"\" -> true\n _ -> false\n end\n rescue\n _ -> false\n end\n\n defp join_or_none(list) when is_list(list) and length(list) > 0, do: Enum.join(list, \", \")\n defp join_or_none(_), do: \"none\"\n\n defp error_prefix(%{error_message: err}) when is_binary(err) and err != \"\",\n do: [\"\", \" ❌ #{err}\"]\n\n defp error_prefix(_), do: []\n\n # ── Help Lines ──\n\n # ── Tasks Lines ──\n\n defp tasks_lines(%{active_plan: nil} = model) do\n error_prefix(model) ++ [\"\", \" No active plan. Start one with a goal.\", \"\"]\n end\n\n defp tasks_lines(model) do\n plan = model.active_plan\n dash = model.dashboard || %{}\n queue = dash[:queue_length] || 0\n completed = dash[:completed_count] || 0\n failed = dash[:failed_count] || 0\n\n task_lines =\n Enum.map(plan.tasks, fn t ->\n status_sym =\n case t.status do\n :completed -> \"✅\"\n :failed -> \"❌\"\n :running -> \"⚡\"\n :queued -> \"📋\"\n _ -> \"⏳\"\n end\n\n \" #{status_sym} #{t.id}: #{t.title} [#{t.status}]\"\n end)\n\n error_prefix(model) ++\n [\n \"\",\n \" 📋 TASKS — #{plan.id}\",\n \" Queued: #{queue} | Completed: #{completed} | Failed: #{failed}\",\n \"\"\n ] ++ task_lines ++ [\"\"]\n end\n\n # ── Shell Output Lines ──\n\n defp shell_output_lines(model) do\n model.canvas_lines || [\"\", \" No output.\", \"\"]\n end\n\n defp help_lines(_model) do\n [\n \"\",\n \" 📖 S P A R K H E L P\",\n \"\",\n \" ─── Slash Commands ───\",\n \" /welcome Return to the welcome screen\",\n \" /plan View the active plan (if any)\",\n \" /exec Switch to execution dashboard\",\n \" /logs Show recent event logs\",\n \" /dash Alias for /exec (dashboard)\",\n \" /agents Pick or configure an agent\",\n \" /tasks Show task list for active plan\",\n \" /approve Approve the active plan\",\n \" /reject Reject the active plan\",\n \" /clear Clear error/status messages\",\n \" /help Show this help screen\",\n \" /quit Exit Spark\",\n \"\",\n \" ─── Reload Commands ───\",\n \" /reload Reload all components\",\n \" /reload prompts Reload prompts\",\n \" /reload tools Reload tools\",\n \" /reload config Reload config\",\n \" /reload policy Reload policy\",\n \" /reload guidance Reload guidance\",\n \" /reload all Reload all components\",\n \" /reload status Show reload status\",\n \"\",\n \" ─── Shell Commands ───\",\n \" !<command> Run a shell command\",\n \"\",\n \" ─── Navigation ───\",\n \" ↑/↓ Scroll / navigate tasks\",\n \" PageUp/PageDown Scroll by page\",\n \" Esc Go back / cancel\",\n \" Enter Submit goal or confirm\",\n \"\",\n \" Type a goal + Enter to start planning.\"\n ]\n end\n\n defp gen_id, do: \"tui_\" <> Base.url_encode64(:crypto.strong_rand_bytes(8), padding: false)\nend\n"}, "e78635d2c7cee1ee3639677594ed0ae6a546c46a": {"t": 1785758026.1919024, "n": 20311, "h": "50407fb46f19122a", "c": "defmodule Spark.TermUI do\n @moduledoc \"\"\"\n TermUI-based terminal UI for Spark — Component Protocol.\n Uses the simple init/view/update protocol that TermUI.App.run expects.\n \"\"\"\n\n require Logger\n import TermUI.Component.Helpers\n alias TermUI.Renderer.Style\n\n # ── Init ──\n\n def init(_opts) do\n Spark.TUI.Actions.install_event_log_hook()\n\n # Disable mouse tracking after a brief delay to enable text selection/copy\n Process.send_after(self(), :disable_mouse_tracking, 500)\n\n %{\n model: %Spark.TUI.Model{\n session_id: gen_id(),\n screen: :home,\n agents: Spark.TUI.Actions.load_agents(),\n agent_order: [\"planning\", \"coding\"],\n dashboard: Spark.TUI.Actions.dashboard_snapshot(),\n logs: Spark.TUI.Actions.load_logs(),\n status_message: nil\n },\n tick_count: 0\n }\n end\n\n # ── Event to message conversion (called by TermUI Runtime) ──\n\n def event_to_msg(event, _state) do\n cond do\n # Quit keys\n is_map(event) && Map.get(event, :char) in [\"q\", \"Q\"] ->\n {:msg, :quit}\n\n is_map(event) && Map.get(event, :key) == :ctrl_c ->\n {:msg, :quit}\n\n # Printable characters (use char for correct case)\n is_map(event) && is_binary(Map.get(event, :char, \"\")) && byte_size(Map.get(event, :char, \"\")) == 1 ->\n ch = Map.get(event, :char)\n <<codepoint::utf8>> = ch\n {:msg, {:event, %{ch: codepoint}}}\n\n # Special keys\n is_map(event) && is_atom(Map.get(event, :key)) ->\n key = Map.get(event, :key)\n case key do\n :enter -> {:msg, {:event, %{key: 0x0D}}}\n :escape -> {:msg, {:event, %{key: 0x1B}}}\n :up -> {:msg, {:event, %{key: 0xFFFF - 18}}}\n :down -> {:msg, {:event, %{key: 0xFFFF - 19}}}\n :left -> {:msg, {:event, %{key: 0xFFFF - 20}}}\n :right -> {:msg, {:event, %{key: 0xFFFF - 21}}}\n :backspace -> {:msg, {:event, %{key: 0x08}}}\n _ -> :ignore\n end\n\n # Tick events\n is_map(event) && is_integer(Map.get(event, :interval, nil)) && Map.get(event, :interval, 0) > 0 ->\n {:msg, :tick}\n\n # Resize events\n is_map(event) && Map.has_key?(event, :width) && Map.has_key?(event, :height) ->\n {:msg, :resized}\n\n true ->\n :ignore\n end\n end\n\n # ── Handle async results (from Task.start) ──\n\n def handle_info({:plan_result, result}, state) do\n Logger.debug(\"[SPARK] plan_result received\")\n model = Spark.TUI.Update.update(state.model, {:plan_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:approve_result, result}, state) do\n Logger.debug(\"[SPARK] approve_result received\")\n model = Spark.TUI.Update.update(state.model, {:approve_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info({:reject_result, result}, state) do\n Logger.debug(\"[SPARK] reject_result received\")\n model = Spark.TUI.Update.update(state.model, {:reject_result, result})\n {%{state | model: model}, []}\n end\n\n def handle_info(:disable_mouse_tracking, state) do\n TermUI.Terminal.disable_mouse_tracking()\n {state, []}\n end\n\n def handle_info(msg, state) do\n case msg do\n :tick ->\n update(:tick, state)\n _ ->\n result = Spark.TUI.Update.update(state.model, msg)\n case result do\n {model, _cmd} -> {%{state | model: model}, []}\n model -> {%{state | model: model}, []}\n end\n end\n end\n\n # ── View ──\n\n def view(state) do\n model = state.model\n\n stack(:vertical, [\n styled(text(top_bar_text(model)), top_bar_style()),\n view_content(model),\n styled(text(bottom_bar_text(model)), bottom_bar_style()),\n ])\n end\n\n # ── Update ──\n\n def update(:tick, %{tick_count: count} = state) do\n if rem(count, 60) == 0 do\n model = Spark.TUI.Update.update(state.model, :tick)\n {%{state | model: model, tick_count: count + 1}, []}\n else\n {%{state | tick_count: count + 1}, []}\n end\n end\n\n def update(:resized, state) do\n {state, []}\n end\n\n def update(:quit, state), do: {state, [:quit]}\n\n def update(msg, state) do\n result = Spark.TUI.Update.update(state.model, msg)\n\n case result do\n {model, cmd} ->\n execute_async(cmd, model, state)\n\n model ->\n {%{state | model: model}, []}\n end\n end\n\n\n\n # ── Async execution ──\n\n defp execute_async(cmd, model, state) do\n runtime_self = self()\n\n Task.start(fn ->\n try do\n result = cmd.function.()\n Logger.debug(\"[SPARK] async #{cmd.message} completed\")\n send(runtime_self, {cmd.message, result})\n rescue\n e ->\n Logger.debug(\"[SPARK] async #{cmd.message} error: #{Exception.message(e)}\")\n send(runtime_self, {cmd.message, {:error, Exception.message(e)}})\n catch\n :exit, reason ->\n Logger.debug(\"[SPARK] async #{cmd.message} exit: #{inspect(reason)}\")\n send(runtime_self, {cmd.message, {:error, {:exit, reason}}})\n end\n end)\n\n {%{state | model: model}, []}\n end\n\n # ── Top bar ──\n\n defp top_bar_text(model) do\n phase = model.dashboard[:orchestrator_phase] || \"—\"\n phase_str = to_string(phase)\n queue = model.dashboard[:queue_length] || 0\n active = model.dashboard[:active_count] || 0\n sid = if model.session_id, do: String.slice(model.session_id, 0, 8), else: \"—\"\n\n content = \" Spark v4.0 ▸ #{phase_str} ⚡ #{active}/#{queue} Session: #{sid} \"\n String.pad_trailing(content, 240)\n end\n defp top_bar_style do\n Style.new()\n |> Style.fg(:white)\n |> Style.bg(:blue)\n |> Style.bold()\n end\n\n # ── Bottom bar ──\n\n defp bottom_bar_text(model) do\n String.pad_trailing(\" #{nav_hints(model.screen)} \", 240)\n end\n\n defp nav_hints(:home), do: \"[A]gents [P]lan [D]ashboard [L]ogs [?] Help [Q]uit\"\n defp nav_hints(:help), do: \"[?] Toggle [H]ome [Q]uit\"\n defp nav_hints(:agent_manager), do: \"↑/↓ Select [P/C] Quick [Enter] Models [H]ome [Q]uit\"\n defp nav_hints(:model_picker), do: \"↑/↓ Select [Enter] Pin [Esc] Back [Q]uit\"\n defp nav_hints(:dashboard), do: \"[R]efresh [A]gents [L]ogs [H]ome [Q]uit\"\n defp nav_hints(:logs), do: \"[C]lear [R]efresh [D]ashboard [H]ome [Q]uit\"\n defp nav_hints(:plan_input), do: \"[Enter] Submit [Esc] Cancel [Q]uit\"\n defp nav_hints(:plan_review), do: \"[Enter/A]pprove [R]eject ↑/↓ Task [Esc] Home [Q]uit\"\n defp nav_hints(_), do: \"[H]ome [Q]uit\"\n\n defp bottom_bar_style do\n Style.new()\n |> Style.fg(:white)\n |> Style.bg(:blue)\n end\n\n # ── Content dispatcher ──\n\n defp view_content(model) do\n case model.screen do\n :home -> render_home(model)\n :help -> render_help()\n :agent_manager -> render_agent_manager(model)\n :model_picker -> render_model_picker(model)\n :dashboard -> render_dashboard(model)\n :logs -> render_logs(model)\n :plan_input -> render_plan_input(model)\n :plan_review -> render_plan_review(model)\n _ -> render_home(model)\n end\n end\n\n # ── Home screen ──\n\n defp render_home(model) do\n dash = model.dashboard || %{}\n phase = Map.get(dash, :orchestrator_phase, nil)\n phase_str = if phase, do: to_string(phase), else: \"—\"\n queue = Map.get(dash, :queue_length, 0)\n active = Map.get(dash, :active_count, 0)\n completed = Map.get(dash, :completed_count, 0)\n failed = Map.get(dash, :failed_count, 0)\n agents = Map.get(dash, :agents, model.agents || %{})\n planning = Map.get(agents, \"planning\", %{})\n coding = Map.get(agents, \"coding\", %{})\n\n stack(:vertical, [\n text(\"\"),\n text(\" Spark v4.0 — Parallel Actor-Model Code Agent\", Style.new() |> Style.fg(:cyan) |> Style.bold()),\n text(\"\"),\n text(\" Status\", section_style()),\n text(\" Orchestrator: #{phase_str}\", Style.new() |> Style.fg(:green)),\n text(\" Queue: #{queue} queued, #{active} active (#{completed} done, #{failed} failed)\"),\n text(\"\"),\n text(\" Agents\", section_style()),\n text(\" Planning: #{Map.get(planning, \"model\", \"—\")} (#{Map.get(planning, \"provider\", \"—\")})\"),\n text(\" Coding: #{Map.get(coding, \"model\", \"—\")} (#{Map.get(coding, \"provider\", \"—\")})\"),\n text(\"\"),\n text(\" Shortcuts\", section_style()),\n text(\" A Agent Manager P New Plan\"),\n text(\" D Dashboard L Event Logs\"),\n text(\" ? Help Q Quit\"),\n text(\"\"),\n status_or_error(model),\n text(\"\"),\n text(\" Ready — press A for agents, P for new plan, Q to quit\"),\n ])\n end\n\n # ── Help screen ──\n\n defp render_help do\n stack(:vertical, [\n text(\"\"),\n text(\" Help — Keyboard Shortcuts\", section_style()),\n text(\"\"),\n text(\" Navigation\", section_style()),\n text(\" ? Toggle help\"),\n text(\" H / Esc Home / Back\"),\n text(\" Q Quit\"),\n text(\"\"),\n text(\" Screens\", section_style()),\n text(\" A Agent Manager\"),\n text(\" P New Plan\"),\n text(\" D Dashboard\"),\n text(\" L Event Logs\"),\n text(\"\"),\n text(\" Actions\", section_style()),\n text(\" ↑/k Up ↓/j Down\"),\n text(\" Enter Select / Submit / Pin\"),\n text(\" A Approve plan R Reject plan\"),\n text(\"\"),\n text(\" Logs\", section_style()),\n text(\" C Clear R Refresh\"),\n ])\n end\n\n # ── Agent Manager ──\n\n defp render_agent_manager(model) do\n agents = ordered_agents(model)\n rows = Enum.with_index(agents) |> Enum.flat_map(fn {{key, cfg}, idx} ->\n selected = idx == model.selected_index\n marker = if selected, do: \"▸\", else: \" \"\n fg = if selected, do: :cyan, else: :white\n ks = key_status(cfg)\n [text(\" #{marker} #{pad(key, 10)} #{pad(cfg[\"actor_type\"] || \"?\", 16)} #{pad(cfg[\"model\"] || \"?\", 18)} #{pad(cfg[\"provider\"] || \"?\", 12)} #{ks}\",\n Style.new() |> Style.fg(fg) |> maybe_bold(selected))]\n end)\n\n stack(:vertical, [\n text(\"\"),\n text(\" Agent Manager\", section_style()),\n text(\"\"),\n text(\" Agent Type Model Provider Key\", section_style()),\n ] ++ rows ++ [\n text(\"\"),\n status_or_error(model),\n ])\n end\n\n # ── Model Picker ──\n\n defp render_model_picker(model) do\n agent_key = model.selected_agent\n cfg = Map.get(model.agents, agent_key, %{})\n provider = cfg[\"provider\"] || \"\"\n current = cfg[\"model\"] || \"\"\n models = Spark.ModelCatalog.models_for_provider(provider)\n\n model_rows = if models == [] do\n [text(\" No models available.\", Style.new() |> Style.fg(:yellow))]\n else\n Enum.with_index(models) |> Enum.map(fn {m, idx} ->\n selected = idx == model.selected_model_index\n is_current = m.id == current\n marker = if selected, do: \" ▸ \", else: \" \"\n tag = if is_current, do: \" [current]\", else: \"\"\n fg = if selected, do: :cyan, else: :white\n name = pad(m.name, 22)\n text(\" #{marker}#{name} (#{m.id})#{tag}\", Style.new() |> Style.fg(fg) |> maybe_bold(selected))\n end)\n end\n\n stack(:vertical, [\n text(\"\"),\n text(\" Model Picker — #{agent_key}\", section_style()),\n text(\"\"),\n text(\" Provider: #{provider} Current: #{current}\", Style.new() |> Style.fg(:green) |> Style.bold()),\n text(\"\"),\n ] ++ model_rows ++ [\n text(\"\"),\n error_only(model),\n ])\n end\n\n # ── Dashboard ──\n\n defp render_dashboard(model) do\n d = model.dashboard || %{}\n agents = Map.get(d, :agents, %{})\n tasks = Map.get(d, :active_worker_tasks, [])\n\n worker_rows = if tasks != [] do\n [text(\"\"), text(\" Active Workers\", section_style())] ++\n Enum.map(tasks, fn name -> text(\" ⚡ #{name}\", Style.new() |> Style.fg(:cyan)) end)\n else\n []\n end\n\n agent_rows = if agents == %{} do\n [text(\" none configured\", Style.new() |> Style.fg(:yellow))]\n else\n agents |> Enum.sort_by(fn {k, _} -> k end) |> Enum.map(fn {key, cfg} ->\n text(\" #{key}: #{cfg[\"model\"] || \"—\"} (#{cfg[\"provider\"] || \"—\"})\")\n end)\n end\n\n stack(:vertical, [\n text(\"\"),\n text(\" Dashboard\", section_style()),\n text(\"\"),\n text(\" Orchestrator\", section_style()),\n text(\" Phase: #{d[:orchestrator_phase]}\", phase_color_style(d[:orchestrator_phase])),\n text(\" Active Plan: #{fmt(d[:active_plan_id])} [#{fmt(d[:active_plan_status])}]\"),\n text(\"\"),\n text(\" Dispatcher\", section_style()),\n text(\" Queue: #{d[:queue_length] || 0} queued\"),\n text(\" Workers: #{d[:active_count] || 0} / #{d[:max_concurrency] || \"—\"} active\"),\n text(\" Done: #{d[:completed_count] || 0} completed #{d[:failed_count] || 0} failed\"),\n ] ++ worker_rows ++ [\n text(\"\"),\n text(\" Agents\", section_style()),\n ] ++ agent_rows ++ [\n text(\"\"),\n status_or_error(model),\n ])\n end\n\n # ── Logs ──\n\n defp render_logs(model) do\n logs = model.logs || []\n\n log_rows = if logs == [] do\n [text(\" No events captured yet.\", Style.new() |> Style.fg(:yellow))]\n else\n Enum.take(logs, 30) |> Enum.map(fn entry ->\n time = if entry.at, do: entry.at |> DateTime.to_time() |> Time.to_string() |> String.slice(0, 8), else: \"--:--:--\"\n type = Atom.to_string(entry.type) |> String.pad_trailing(24)\n source = Atom.to_string(entry.source)\n id_part = cond do\n entry.task_id -> \" task=#{String.slice(entry.task_id, 0, 8)}\"\n entry.plan_id -> \" plan=#{String.slice(entry.plan_id, 0, 8)}\"\n true -> \"\"\n end\n text(\" #{time} #{type} #{source}#{id_part}\", Style.new() |> Style.fg(log_color(entry.type)))\n end)\n end\n\n stack(:vertical, [\n text(\"\"),\n text(\" Event Logs\", section_style()),\n text(\"\"),\n ] ++ log_rows ++ [\n text(\"\"),\n status_or_error(model),\n ])\n end\n\n # ── Plan Input ──\n\n defp render_plan_input(model) do\n input_line = if model.loading? do\n [text(\" ⏳ Planning with DeepSeek...\", Style.new() |> Style.fg(:yellow))]\n else\n [text(\" > #{model.input_buffer} ▌\", Style.new() |> Style.fg(:green) |> Style.bold())]\n end\n\n stack(:vertical, [\n text(\"\"),\n text(\" New Plan\", section_style()),\n text(\"\"),\n text(\" What do you want Spark to build?\", Style.new() |> Style.fg(:cyan) |> Style.bold()),\n text(\"\"),\n ] ++ input_line ++ [\n text(\"\"),\n error_only(model),\n ])\n end\n\n # ── Plan Review ──\n\n defp render_plan_review(model) do\n plan = model.active_plan\n if plan do\n tasks = plan.tasks || []\n sel = model.selected_task_index\n sel_task = Enum.at(tasks, sel)\n task_rows = Enum.with_index(tasks) |> Enum.flat_map(fn {task, idx} ->\n selected = idx == sel\n marker = if selected, do: \" ▸ \", else: \" \"\n fg = if selected, do: :cyan, else: :white\n deps_str = join_or_none(task.depends_on)\n reads_str = join_or_none(task.read_paths)\n writes_str = join_or_none(task.write_paths)\n title_line = \"#{marker}#{task.id} #{truncate(task.title, 50)}\"\n meta_line = \"#{String.slice(\" \", 0, String.length(marker))}Risk: #{task.risk} | Deps: #{deps_str} | Reads: #{reads_str} | Writes: #{writes_str}\"\n [text(title_line, Style.new() |> Style.fg(fg) |> maybe_bold(selected)),\n text(meta_line, Style.new() |> Style.fg(fg))]\n end)\n detail_rows = if sel_task do\n [text(\"\"), text(\" Details — #{sel_task.id}\", section_style()),\n text(\" #{truncate(sel_task.title, 50)}\", Style.new() |> Style.fg(:cyan) |> Style.bold()),\n text(\" #{truncate(sel_task.description, 70)}\"),\n text(\" Risk: #{sel_task.risk}\", risk_style(sel_task.risk)),\n text(\" Read: #{join_or_none(sel_task.read_paths)}\"),\n text(\" Write: #{join_or_none(sel_task.write_paths)}\"),\n text(\" Timeout: #{sel_task.timeout_ms}ms Retries: #{sel_task.max_retries}\")]\n else; []\n end\n stack(:vertical, [\n text(\"\"), text(\" Plan Review\", section_style()), text(\"\"),\n text(\" #{plan.user_goal}\", Style.new() |> Style.fg(:cyan) |> Style.bold()),\n text(\" #{plan.summary}\"),\n text(\" Status: #{plan.approval_status}\", Style.new() |> Style.fg(plan_status_color(plan.approval_status)) |> Style.bold()),\n text(\"\"), text(\" Tasks\", section_style()),\n ] ++ task_rows ++ [\n text(\"\"),\n text(\" ─────────────────────────────────────────\", Style.new() |> Style.fg(:black) |> Style.dim()),\n ] ++ detail_rows ++ [text(\"\"), status_or_error(model)])\n else\n stack(:vertical, [\n text(\"\"), text(\" No active plan.\", Style.new() |> Style.fg(:yellow)), text(\"\"), status_or_error(model)\n ])\n end\n end\n\n defp truncate(str, max_len) when is_binary(str) and byte_size(str) > max_len do\n String.slice(str, 0, max_len) <> \"…\"\n end\n defp truncate(str, _max_len) when is_binary(str), do: str\n defp truncate(nil, _max_len), do: \"\"\n\n defp join_or_none(list) when is_list(list) and length(list) > 0, do: Enum.join(list, \", \")\n defp join_or_none(_), do: \"none\"\n\n # ── Style helpers ──\n\n defp section_style, do: Style.new() |> Style.fg(:blue) |> Style.bold()\n defp maybe_bold(style, true), do: Style.bold(style)\n defp maybe_bold(style, false), do: style\n\n defp status_or_error(model) do\n (if model.status_message, do: [text(\" #{model.status_message}\", Style.new() |> Style.fg(:green))], else: []) ++\n (if model.error_message, do: [text(\" #{model.error_message}\", Style.new() |> Style.fg(:red))], else: [])\n end\n\n defp error_only(model) do\n if model.error_message, do: [text(\" #{model.error_message}\", Style.new() |> Style.fg(:red))], else: []\n end\n\n defp phase_color_style(:awaiting_input), do: Style.new() |> Style.fg(:white)\n defp phase_color_style(:planning), do: Style.new() |> Style.fg(:cyan)\n defp phase_color_style(:awaiting_approval), do: Style.new() |> Style.fg(:yellow)\n defp phase_color_style(:executing), do: Style.new() |> Style.fg(:green)\n defp phase_color_style(:reviewing), do: Style.new() |> Style.fg(:magenta)\n defp phase_color_style(:completed), do: Style.new() |> Style.fg(:green)\n defp phase_color_style(_), do: Style.new() |> Style.fg(:white)\n\n defp risk_style(:low), do: Style.new() |> Style.fg(:green)\n defp risk_style(:medium), do: Style.new() |> Style.fg(:yellow)\n defp risk_style(:high), do: Style.new() |> Style.fg(:red)\n defp risk_style(_), do: Style.new() |> Style.fg(:white)\n\n defp plan_status_color(:awaiting_approval), do: :yellow\n defp plan_status_color(:approved), do: :green\n defp plan_status_color(:rejected), do: :red\n defp plan_status_color(_), do: :white\n\n defp log_color(type) when type in [:task_completed, :plan_approved, :llm_call_completed], do: :green\n defp log_color(type) when type in [:task_failed, :plan_rejected, :llm_call_failed], do: :red\n defp log_color(type) when type in [:task_started, :plan_awaiting_approval, :task_queued], do: :cyan\n defp log_color(_), do: :white\n\n # ── Data helpers ──\n\n defp ordered_agents(model) do\n configured = model.agents || %{}\n preferred = model.agent_order || [\"planning\", \"coding\"]\n pref = Enum.filter(preferred, &Map.has_key?(configured, &1)) |> Enum.map(fn k -> {k, Map.fetch!(configured, k)} end)\n extra = Map.drop(configured, preferred) |> Enum.sort_by(fn {k, _} -> k end)\n pref ++ extra\n end\n\n defp key_status(cfg) do\n case cfg[\"provider\"] do\n \"deepseek\" -> if secret?(:deepseek_api_key), do: \"✅\", else: \"❌\"\n \"wafer\" -> if secret?(:wafer_api_key), do: \"✅\", else: \"❌\"\n _ -> \"?\"\n end\n end\n\n defp secret?(key) do\n case Spark.Config.Secrets.get_secret(key) do\n s when is_binary(s) and s != \"\" -> true\n _ -> false\n end\n rescue _ -> false\n end\n\n defp pad(str, len) when is_binary(str), do: String.slice(str <> String.duplicate(\" \", len), 0, len)\n defp pad(_, len), do: String.duplicate(\" \", len)\n defp fmt(nil), do: \"—\"\n defp fmt(v), do: to_string(v)\n defp gen_id, do: \"tui_\" <> Base.url_encode64(:crypto.strong_rand_bytes(8), padding: false)\nend\n"}}}} |