Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/asx8678_spark.json
T

1 line
105 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}