local agent_id = nil local agents = {} local messages = {} local folder_path = noctalia.getenv("HOME") or "" local composer = "" local extension_input = "" local pending_extension = nil local status = "Loading Pi agents…" local request_in_flight = false local stream_started = false local view = "output" local command_suggestions = {} local models = {} local model_options = {} local thinking_options = { "off", "minimal", "low", "medium", "high", "xhigh", "max" } local thinking_level = "medium" local session_summary = "Session loading…" local function socket_path() local runtime = noctalia.getenv("XDG_RUNTIME_DIR") if runtime == nil or runtime == "" then return nil end return runtime .. "/pi-status-bridge/bridge.sock" end local function shell_quote(value) return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'" end local function request(op, agent, payload, callback) local socket = socket_path() if socket == nil then status = "Bridge socket is unavailable." render_panel() return end local body = { op = op } if agent ~= nil then body.agentId = agent end if payload ~= nil then body.payload = payload end local encoded, err = noctalia.json.encode(body) if encoded == nil then status = "Could not encode bridge request: " .. tostring(err) render_panel() return end local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " request " .. shell_quote(encoded) noctalia.runAsync(command, function(result) if result.exitCode ~= 0 then status = "Bridge request failed: " .. noctalia.string.trim(result.stderr) render_panel() return end local decoded, decode_error = noctalia.json.decode(result.stdout) if decoded == nil then status = "Invalid bridge response: " .. tostring(decode_error) render_panel() return end callback(decoded) end) end local function message_text(message) if message == nil then return "" end if type(message.content) == "string" then return message.content end if type(message.content) ~= "table" then return "" end local parts = {} for _, block in ipairs(message.content) do if block.type == "text" and block.text ~= nil then table.insert(parts, block.text) end if block.type == "thinking" and block.thinking ~= nil then table.insert(parts, "Thinking: " .. block.thinking) end if block.type == "toolCall" and block.name ~= nil then table.insert(parts, "Tool: " .. block.name) end end return table.concat(parts, "\n") end local function choose_home_agent() local home = noctalia.getenv("HOME") for _, agent in ipairs(agents) do if agent.worktreePath == home then return agent.id end end return agents[1] and agents[1].id or nil end local function known_agent(id) for _, agent in ipairs(agents) do if agent.id == id then return true end end return false end local function refresh_agents() request("list_agents", nil, nil, function(result) agents = result.agents or {} local remembered_id = agent_id or noctalia.state.get("agent_id") agent_id = known_agent(remembered_id) and remembered_id or choose_home_agent() if agent_id ~= nil then noctalia.state.set("agent_id", agent_id) end status = agent_id and "Pi ready" or "Add a folder to start Pi" render_panel() if agent_id ~= nil then refresh_transcript() refresh_commands() refresh_controls() end end) end function refresh_controls() if agent_id == nil then return end request("get_state", agent_id, nil, function(result) local state = result.data or {} thinking_level = state.thinkingLevel or thinking_level session_summary = (state.sessionName or state.sessionId or "Session") .. " • " .. tostring(state.messageCount or 0) .. " messages" .. " • " .. tostring(state.pendingMessageCount or 0) .. " queued" status = state.isStreaming and "Pi working" or "Pi ready" request("get_available_models", agent_id, nil, function(models_result) models = (models_result.data and models_result.data.models) or {} model_options = {} for _, model in ipairs(models) do table.insert(model_options, (model.provider or "") .. "/" .. (model.id or model.name or "model")) end render_panel() end) end) end function refresh_commands() if agent_id == nil then return end request("get_commands", agent_id, nil, function(result) command_suggestions = {} for _, command in ipairs((result.data and result.data.commands) or {}) do if command.name ~= nil then table.insert(command_suggestions, "/" .. command.name) end end render_panel() end) end function refresh_transcript() if agent_id == nil or request_in_flight then return end request_in_flight = true request("get_transcript", agent_id, nil, function(result) request_in_flight = false messages = result.data and result.data.messages or {} render_panel() end) end local function start_stream() if stream_started or agent_id == nil then return end local socket = socket_path() if socket == nil then return end stream_started = true local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " subscribe --agent " .. shell_quote(agent_id) noctalia.runStream(command, function(line) local event = noctalia.json.decode(line) if event == nil then return end if event.type == "extension_ui_request" then pending_extension = event.data and event.data.event end if event.type == "agent_state" then status = (event.data and event.data.state) or "Pi state changed" end if event.type == "queue" then status = "Pi queue updated" end if event.type == "stream" and event.data and event.data.event and event.data.event.type == "tool_execution_start" then status = "Tool: " .. tostring(event.data.event.toolName or "running") end if event.type == "message_end" or event.type == "agent_state" then refresh_transcript() end render_panel() end) end local function agent_label(agent) return agent.worktreePath .. " — " .. agent.state end function render_panel() local selected_path = noctalia.state.get("project_path") or "Home" local children = { ui.row({ gap = 8, justify = "space_between" }, { ui.column({ gap = 2 }, { ui.label({ text = "Pi", fontSize = 22, fontWeight = "bold" }), ui.label({ text = selected_path .. " — " .. status }) }), ui.button({ text = view == "output" and "Settings" or "Output", glyph = "settings", variant = "ghost", onClick = "onToggleSettings" }) }) } if view == "settings" then table.insert(children, ui.label({ text = "Folders and agents", fontWeight = "bold" })) local choices = {} for _, agent in ipairs(agents) do table.insert(choices, agent_label(agent)) end if #choices > 0 then table.insert(children, ui.select({ options = choices, onChange = "onSettingsAgentChange" })) end table.insert(children, ui.input({ value = folder_path, placeholder = "/absolute/path/to/project", onChange = "onFolderChanged", onSubmit = "onAddFolder" })) table.insert(children, ui.button({ text = "Add folder", glyph = "folder-plus", variant = "primary", onClick = "onAddFolder" })) table.insert(children, ui.label({ text = "Adding a folder explicitly starts or resumes its own Pi instance." })) table.insert(children, ui.label({ text = session_summary, maxLines = 2 })) table.insert(children, ui.label({ text = "Pi controls", fontWeight = "bold" })) if #model_options > 0 then table.insert(children, ui.select({ options = model_options, onChange = "onModelChange" })) end table.insert(children, ui.select({ options = thinking_options, onChange = "onThinkingChange" })) table.insert(children, ui.row({ gap = 8 }, { ui.button({ text = "Retry", onClick = "onRetry" }), ui.button({ text = "Restart Pi", variant = "destructive", onClick = "onRestart" }) })) else table.insert(children, ui.label({ text = "Output", fontWeight = "bold" })) local transcript = {} for _, message in ipairs(messages) do local text = message_text(message) if text ~= "" then table.insert(transcript, ui.label({ text = (message.role or "message") .. ": " .. text, maxLines = 12 })) end end if #transcript == 0 then table.insert(transcript, ui.label({ text = "No messages yet." })) end table.insert(children, ui.scroll({ minHeight = 260, maxHeight = 520 }, transcript)) table.insert(children, ui.input({ value = composer, placeholder = "Message Pi", onChange = "onComposerChanged", onSubmit = "onPrompt" })) if string.sub(composer, 1, 1) == "/" and #command_suggestions > 0 then table.insert(children, ui.select({ options = command_suggestions, onChange = "onCommandSuggestion" })) end table.insert(children, ui.row({ gap = 8 }, { ui.button({ text = "Send", variant = "primary", onClick = "onPrompt" }), ui.button({ text = "Abort", variant = "destructive", onClick = "onAbort" }) })) end if pending_extension ~= nil then local method = pending_extension.method or "input" table.insert(children, ui.label({ text = "Pi extension request: " .. (pending_extension.title or method), fontWeight = "bold" })) table.insert(children, ui.label({ text = pending_extension.message or "Choose a response." })) if method == "select" and pending_extension.options ~= nil then table.insert(children, ui.select({ options = pending_extension.options, onChange = "onExtensionSelect" })) elseif method == "confirm" then table.insert(children, ui.row({ gap = 8 }, { ui.button({ text = "Confirm", variant = "primary", onClick = "onExtensionConfirm" }), ui.button({ text = "Decline", onClick = "onExtensionDecline" }) })) elseif method == "input" or method == "editor" then table.insert(children, ui.input({ value = extension_input, placeholder = method == "editor" and "Extension editor content" or "Extension response", onChange = "onExtensionInputChanged", onSubmit = "onExtensionSubmit" })) table.insert(children, ui.button({ text = "Submit", variant = "primary", onClick = "onExtensionSubmit" })) else table.insert(children, ui.label({ text = "This extension UI is unsupported in the panel. No response has been approved.", maxLines = 3 })) end table.insert(children, ui.button({ text = "Cancel request", variant = "ghost", onClick = "onExtensionCancel" })) end panel.render(ui.scroll({ padding = 20, gap = 12 }, children)) end function onOpen() panel.setWantsSecondTicks(true) refresh_agents() render_panel() end function update() refresh_transcript() end function onFolderChanged(value) folder_path = value end function onComposerChanged(value) composer = value if string.sub(composer, 1, 1) == "/" then render_panel() end end function onCommandSuggestion(index) local suggestion = command_suggestions[tonumber(index) + 1] if suggestion ~= nil then composer = suggestion .. " " render_panel() end end function onExtensionInputChanged(value) extension_input = value end function onSettingsAgentChange(index) local agent = agents[tonumber(index) + 1] if agent == nil then return end agent_id = agent.id noctalia.state.set("agent_id", agent_id) noctalia.state.set("project_path", agent.worktreePath) stream_started = false start_stream() refresh_transcript() end function onModelChange(index) local model = models[tonumber(index) + 1] if model == nil or agent_id == nil then return end request("set_model", agent_id, { provider = model.provider, modelId = model.id }, function(_) refresh_controls() end) end function onThinkingChange(index) local level = thinking_options[tonumber(index) + 1] if level == nil or agent_id == nil then return end request("set_thinking_level", agent_id, { level = level }, function(_) refresh_controls() end) end function onRetry() if agent_id ~= nil then request("retry", agent_id, nil, function(_) refresh_agents() end) end end function onRestart() if agent_id ~= nil then request("restart", agent_id, nil, function(_) refresh_agents() end) end end function onAddFolder() if string.sub(folder_path, 1, 1) ~= "/" then status = "Enter an absolute folder path."; render_panel(); return end request("select_agent", nil, { worktreePath = folder_path }, function(result) agent_id = result.agent and result.agent.id or nil if agent_id == nil then status = "Bridge did not return an agent."; render_panel(); return end noctalia.state.set("agent_id", agent_id) noctalia.state.set("project_path", folder_path) status = "Pi ready for " .. folder_path stream_started = false start_stream() refresh_agents() end) end local function submit_composer() if agent_id == nil then status = "Choose a folder first."; render_panel(); return end if composer == "" then return end request("submit_prompt", agent_id, { message = composer }, function(_) composer = "" status = "Prompt queued by Pi" refresh_transcript() end) end function onPrompt() submit_composer() end function onAbort() if agent_id ~= nil then request("abort", agent_id, nil, function(_) status = "Aborted"; render_panel() end) end end local function send_extension_response(response) if pending_extension == nil or agent_id == nil then return end request("extension_response", agent_id, { requestId = pending_extension.id, response = response }, function(_) pending_extension = nil extension_input = "" render_panel() end) end function onExtensionSubmit() send_extension_response({ value = extension_input }) end function onExtensionSelect(index) if pending_extension == nil or pending_extension.options == nil then return end local option = pending_extension.options[tonumber(index) + 1] if option ~= nil then send_extension_response({ value = option }) end end function onExtensionConfirm() send_extension_response({ confirmed = true }) end function onExtensionDecline() send_extension_response({ confirmed = false }) end function onExtensionCancel() if pending_extension == nil or agent_id == nil then return end request("extension_response", agent_id, { requestId = pending_extension.id, response = { cancelled = true } }, function(_) pending_extension = nil extension_input = "" render_panel() end) end function onClosePicker() panel.close() end