b7fea83ed6
Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
361 lines
14 KiB
Lua
361 lines
14 KiB
Lua
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
|