Files
community-plugins/opencode-companion/panel.luau
T
weinguyenandGitHub e77d7ef7fb Add OpenCode Companion plugin (#286)
* Add OpenCode Panel plugin
Introduce initial implementation of the OpenCode Panel plugin.
This commit includes:
- Bar widget for status and quick actions.
- Chat panel with session selection, message history, and input.
- Background service for OpenCode server management, SSE event
  handling, and state persistence.
- Plugin configuration, documentation, and internationalization.

* Refine session chooser UI layout

Remove an unnecessary spacer and apply top-justification to improve
vertical alignment. Add a subtle background fill to the session list.

* Introduce session search in chooser

Enable live filtering of sessions in the chooser. Matches against
title, slug, ID, and directory. Search is case-insensitive.

* Support multi-question agent requests and clear composer

Implement multi-question agent replies by accumulating choices locally.
A "Submit" button becomes enabled only after all questions are answered.
The chat composer clears its text after sending by updating its key,
forcing the UI to re-render an empty input field.
Updated translations for send hints and question submit button.

* Add UI mode setting for compact layout

Introduce a `ui_mode` setting (Full/Compact) that dynamically adjusts
panel
layout. Compact mode reduces padding, font sizes, and gaps, hiding some
secondary details to minimize the panel footprint. Layout metrics
recompute on every render based on this setting.

* Delete opencode-panel

* Update thumbnail.webp

* Render chat messages newest-first

Newest message shows at top. Scrolling down reveals older messages.
This avoids scroll offset resets on re-mount in older shell APIs.
Thinking bubble now appears above the newest message.

* FEAT: Add panel layout setting

Provide option for panel to fill right side or appear compact near
click.
Introduces a new panel entry for the "fill right" mode.

* Rename plugin to OpenCode Companion

Update plugin ID, panel entries, IPC commands, and state paths.

* Only send model override when known

Stale default_model now falls back to no override instead of failing
every turn. Warn when configured default unavailable.

* Update service.luau

* fix(opencode-companion): secure terminal open, publish MCP status,
respect auto_start

- Shell-quote host and session id in the "open in terminal" command so a
  crafted server_host cannot inject extra shell commands
- Publish opencode.mcp_status and render it as a collapsible footer
  (previously declared but never observable)
- Honor auto_start: skip managed-server start on load when set to false
- Fix broken thumbnail link in README (assets/thumbnail.webp ->
  thumbnail.webp)

* fix Terminal command injection,MCP status publication and UI
2026-08-09 13:43:51 -04:00

1193 lines
47 KiB
Luau

-- OpenCode Companion — the chat panel surface.
--
-- Two views:
-- • Session chooser — when no active session is selected
-- — Session chat — when a session is active
--
-- Pure subscriber of opencode.* state. User actions are dispatched as IPC
-- events to the service.
local STATE = {
connection = "opencode.connection",
active = "opencode.active_session",
sessions = "opencode.sessions",
messages = "opencode.messages",
permissions = "opencode.pending_permissions",
questions = "opencode.pending_questions",
mcp = "opencode.mcp_status",
providers = "opencode.providers",
agents = "opencode.agents",
model_options = "opencode.model_options",
agent_options = "opencode.agent_options",
selected_model = "opencode.selected_model",
selected_agent = "opencode.selected_agent",
unread = "opencode.unread_count",
last_error = "opencode.last_error",
}
-- Confirmation state for destructive actions (delete session).
local pending_delete = nil
-- The session the user most recently opened/selected. Kept so the chooser can
-- highlight it (single accent-colored row) even after the user backs out to the
-- session list. Synced from the active session on every render.
local last_opened_id = nil
-- Live filter text for the chooser's session search box (in-memory only).
local session_query = ""
-- Pending answer selections for multi-question agent requests. Keyed by
-- requestID -> { [question_index] = chosen_label }. A request with N questions
-- is only replied once every question has a selection, so a single-question
-- click never fires the reply before the others are answered.
local question_choices = {}
local SVC = "weinguyen/opencode-companion:service"
local PANEL_ID = "weinguyen/opencode-companion:panel"
-- Layout constants (recomputed from the ui_mode setting on every render).
local compact = false
local PAD = 14
local WRAP = 432 -- 560 - 2*PAD - scrollbar room
local GAP = 8
local GAP_SM = 4
local FONT_TITLE = 18
local FONT_BODY = 13
local FONT_SUB = 12
local CARD_PAD = 10
local CARD_RADIUS = 10
local BUBBLE_GAP = 4
-- Recompute layout metrics from the ui_mode setting. Called at the top of
-- render() so a settings change reflows the whole panel on the next frame.
local function apply_layout()
compact = (noctalia.getConfig and noctalia.getConfig("ui_mode")) == "compact"
if compact then
PAD = 8
GAP = 4
GAP_SM = 2
FONT_TITLE = 15
FONT_BODY = 12
FONT_SUB = 11
CARD_PAD = 6
CARD_RADIUS = 8
BUBBLE_GAP = 2
else
PAD = 14
GAP = 8
GAP_SM = 4
FONT_TITLE = 18
FONT_BODY = 13
FONT_SUB = 12
CARD_PAD = 10
CARD_RADIUS = 10
BUBBLE_GAP = 4
end
WRAP = 560 - 2 * PAD - 100
end
-- Draft persistence key (in-memory only; survives panel close/open within same boot)
local draft = ""
-- Bumped after each successful send. The composer input is uncontrolled and only
-- seeds its text once per node instance, so clearing `draft` alone won't wipe the
-- on-screen text. Changing the input's key forces the reconciler to build a fresh
-- (empty) input, which is how we clear the field after sending.
local composer_seq = 0
local view = "session_chooser"
local snap_cache = {} -- last rendered fingerprint
-- Forward declaration so chooser/chat closures can call render() to refresh
-- local-only UI state (e.g. delete confirmation) not held in shared state.
local render
-- Thinking indicator animation: cycle loader glyphs on each second tick while
-- the agent is processing (beta.7 has no animated spinner, so we rotate icons).
local thinking_frame = 0
local THINKING_GLYPHS = { "loader", "loader-2", "loader-3", "loader-quarter" }
-- Translation with an optional per-plugin language override.
--
-- noctalia.tr() always follows the global shell locale, so the plugin's
-- `language` setting cannot re-point it. When the user picks a specific
-- language we load the plugin's own bundled translations/<lang>.json (merged
-- over en.json) via noctalia.readFile — relative paths resolve against the
-- plugin dir — and resolve keys ourselves. "auto" delegates to noctalia.tr.
local i18n_cache = { lang = nil, table = nil }
local function load_lang_table(lang)
if i18n_cache.lang == lang and i18n_cache.table then
return i18n_cache.table
end
local merged = {}
local function merge_file(path)
local ok, content = pcall(noctalia.readFile, path)
if not ok or type(content) ~= "string" or content == "" then return end
local ok2, data = pcall(noctalia.json.decode, content)
if not ok2 or type(data) ~= "table" then return end
-- Deep-merge nested tables so <lang> overrides en per leaf key.
local function deep(dst, src)
for k, v in pairs(src) do
if type(v) == "table" and type(dst[k]) == "table" then
deep(dst[k], v)
else
dst[k] = v
end
end
end
deep(merged, data)
end
merge_file("translations/en.json")
if lang ~= "en" then
merge_file("translations/" .. lang .. ".json")
end
i18n_cache.lang = lang
i18n_cache.table = merged
return merged
end
local function lookup_path(tbl, key)
local node = tbl
for part in string.gmatch(key, "[^%.]+") do
if type(node) ~= "table" then return nil end
node = node[part]
end
if type(node) == "string" then return node end
return nil
end
local function tr(key, args)
local lang = noctalia.getConfig and noctalia.getConfig("language")
if type(lang) ~= "string" or lang == "" or lang == "auto" then
return noctalia.tr(key, args)
end
local tbl = load_lang_table(lang)
local str = lookup_path(tbl, key)
if not str then
-- Missing override key: fall back to the shell translator.
return noctalia.tr(key, args)
end
if type(args) == "table" then
str = string.gsub(str, "{(%w+)}", function(name)
local v = args[name]
if v == nil then return "{" .. name .. "}" end
return tostring(v)
end)
end
return str
end
-- Fingerprint a value to detect real changes (avoid re-render storms)
local function fp(v)
if type(v) ~= "table" then return tostring(v) end
local parts = {}
for k, val in pairs(v) do
parts[#parts + 1] = tostring(k) .. "=" .. tostring(val)
end
table.sort(parts)
return table.concat(parts, "\1")
end
local function fp_list(list, fields)
if type(list) ~= "table" then return "" end
local parts = {}
for i, item in ipairs(list) do
if type(item) == "table" then
local item_parts = {}
for _, f in ipairs(fields) do
item_parts[#item_parts + 1] = tostring(item[f] or "")
end
parts[i] = table.concat(item_parts, "\2")
end
end
return table.concat(parts, "\1")
end
-- Fingerprint the MCP server map (name-keyed) by sorted name + status, so the
-- footer re-renders when a server's status changes.
local function fp_mcp(servers)
if type(servers) ~= "table" then return "" end
local parts = {}
for name, srv in pairs(servers) do
local st = (type(srv) == "table" and srv.status) or "disabled"
parts[#parts + 1] = tostring(name) .. "=" .. tostring(st)
end
table.sort(parts)
return table.concat(parts, "\1")
end
-- Fingerprint a message list by message id + concatenated part text, so the
-- panel re-renders when message content actually changes (ids alone are stable
-- while a response streams in).
local function fp_messages(list)
if type(list) ~= "table" then return "" end
local parts = {}
for i, msg in ipairs(list) do
if type(msg) == "table" then
local info = msg.info
local id = (type(info) == "table" and info.id) or msg.id or ""
local text = ""
if type(msg.parts) == "table" then
local tparts = {}
for _, p in ipairs(msg.parts) do
if type(p) == "table" and type(p.text) == "string" then
tparts[#tparts + 1] = p.text
end
end
text = table.concat(tparts, "\2")
end
parts[i] = id .. "\2" .. text
end
end
return table.concat(parts, "\1")
end
-- ── helpers ─────────────────────────────────────────────────────────────────
local function is_connected()
local conn = noctalia.state.get(STATE.connection)
return type(conn) == "table" and conn.status ~= "offline"
end
local function is_processing()
local conn = noctalia.state.get(STATE.connection)
return type(conn) == "table" and conn.status == "busy"
end
local function is_waiting_permission()
local conn = noctalia.state.get(STATE.connection)
return type(conn) == "table" and conn.status == "waiting_permission"
end
local function shq(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
local function dispatch_ipc(event, payload)
local cmd = "noctalia msg plugin '" .. SVC .. "' all " .. event
if payload then
-- Shell-escape the payload
payload = payload:gsub("'", "'\\''")
cmd = cmd .. " '" .. payload .. "'"
end
noctalia.runAsync(cmd)
end
-- MCP status footer: colored server names, one per line. Returns nil when
-- there's nothing meaningful to show. Kept intentionally minimal — the
-- MCP status footer: a single collapsible toggle row (chevron + title +
-- connected/total count). Clicking expands it to show one pill per server.
-- Collapsed by default so it never crowds the message list.
local MCP_META = {
connected = { fill = "primary/0.12", color = "primary", dot = "primary", key = "connected" },
failed = { fill = "error/0.10", color = "error", dot = "error", key = "failed" },
disabled = { fill = "surface_variant/0.45", color = "on_surface_variant", dot = "on_surface/0.35", key = "disabled" },
}
local mcp_expanded = false
local function render_mcp_status()
local servers = noctalia.state.get(STATE.mcp)
if type(servers) ~= "table" then return nil end
local names = {}
for name in pairs(servers) do names[#names + 1] = name end
if #names == 0 then return nil end
table.sort(names)
local connected, failed = 0, 0
for _, name in ipairs(names) do
local st = (type(servers[name]) == "table" and servers[name].status) or "disabled"
if st == "connected" then connected = connected + 1
elseif st == "failed" then failed = failed + 1 end
end
local header = ui.button({
text = tr("mcp.title") .. " (" .. tostring(connected) .. "/" .. tostring(#names) .. ")",
glyph = mcp_expanded and "chevron-up" or "chevron-right",
variant = "ghost",
tooltip = tr("mcp.title"),
onClick = function()
mcp_expanded = not mcp_expanded
render()
end,
})
if not mcp_expanded then
return ui.column({ gap = GAP_SM, key = "mcp_status" }, { header })
end
local rows = {}
for _, name in ipairs(names) do
local st = (type(servers[name]) == "table" and servers[name].status) or "disabled"
local meta = MCP_META[st] or MCP_META.disabled
rows[#rows + 1] = ui.row({ gap = GAP, align = "center", fill = meta.fill, radius = 8, paddingH = 9, paddingV = 6 }, {
ui.box({ width = 8, height = 8, radius = 4, fill = meta.dot }),
ui.label({ text = name, color = "on_surface", fontSize = FONT_SUB, flexGrow = 1, maxWidth = WRAP - 140, maxLines = 1 }),
ui.label({ text = tr("mcp." .. meta.key), color = meta.color, fontSize = 10, maxLines = 1 }),
})
end
return ui.column({ gap = GAP_SM, key = "mcp_status", padding = 10, radius = 10, fill = "surface/0.45" }, {
header,
unpack(rows),
})
end
-- ── session chooser view ────────────────────────────────────────────────────
local function render_session_chooser(sessions, conn)
local rows = {}
-- Header
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "code-circle", color = "primary", size = compact and 18 or 24 }),
ui.label({ text = tr("chooser.title"), fontSize = FONT_TITLE, fontWeight = "bold", color = "on_surface" }),
}),
ui.row({ gap = 8 }, {
ui.button({ glyph = "refresh", onClick = function()
dispatch_ipc("refresh")
end, tooltip = tr("chooser.refresh") }),
}),
})
rows[#rows + 1] = ui.separator({})
-- New session button
rows[#rows + 1] = ui.button({
text = tr("chooser.new_session"),
glyph = "plus",
onClick = function()
dispatch_ipc("create_session")
end,
})
-- Search box (live filter over the session list). A stable `key` keeps the
-- input node alive across render() calls so typed text/cursor survive the
-- re-render triggered by onChange.
rows[#rows + 1] = ui.input({
key = "session_search",
value = session_query,
placeholder = tr("chooser.search"),
onChange = function(text)
session_query = text or ""
render()
end,
})
-- Filter sessions by the search query (case-insensitive substring over
-- title/slug/id/directory). Empty query shows everything.
local visible = sessions
if type(sessions) == "table" and session_query ~= "" then
local needle = string.lower(session_query)
visible = {}
for _, s in ipairs(sessions) do
if type(s) == "table" then
local hay = string.lower(
tostring(s.title or "") .. " " ..
tostring(s.slug or "") .. " " ..
tostring(s.id or "") .. " " ..
tostring(s.directory or "")
)
if string.find(hay, needle, 1, true) then
visible[#visible + 1] = s
end
end
end
end
-- Session list
local list_items = {}
if type(visible) ~= "table" or #visible == 0 then
list_items[#list_items + 1] = ui.label({
text = session_query ~= "" and tr("chooser.no_match") or tr("chooser.empty"),
maxWidth = WRAP,
opacity = 0.7,
})
else
for _, s in ipairs(visible) do
if type(s) == "table" and s.id then
local title = s.title or s.slug or s.id
local model_str = ""
if type(s.model) == "table" and s.model.id then
model_str = " · " .. (s.model.providerID or "?") .. "/" .. s.model.id
end
local time_str = ""
if type(s.time) == "table" and s.time.updated then
-- Simple relative time display
local now = os.time() * 1000
local diff = now - s.time.updated
if diff < 60000 then
time_str = " · " .. tr("time.just_now")
elseif diff < 3600000 then
time_str = " · " .. tr("time.minutes_ago", { n = math.floor(diff / 60000) })
elseif diff < 86400000 then
time_str = " · " .. tr("time.hours_ago", { n = math.floor(diff / 3600000) })
else
time_str = " · " .. tr("time.days_ago", { n = math.floor(diff / 86400000) })
end
end
local row_id = s.id -- capture for closure
local is_active = (row_id == last_opened_id)
-- Record the opened session so the chooser can keep the row
-- highlighted after the user backs out to the list.
local function open_this()
last_opened_id = row_id
dispatch_ipc("select_session", row_id)
end
local actions
if pending_delete == row_id then
-- Inline confirmation for the destructive delete.
actions = ui.row({ gap = 6 }, {
ui.button({
glyph = "check",
variant = "primary",
tooltip = tr("chooser.delete_confirm"),
onClick = function()
dispatch_ipc("delete_session", row_id)
pending_delete = nil
render()
end,
}),
ui.button({
glyph = "x",
variant = "secondary",
tooltip = tr("chooser.delete_cancel"),
onClick = function()
pending_delete = nil
render()
end,
}),
})
else
actions = ui.row({ gap = 6 }, {
ui.button({
glyph = "trash",
variant = "secondary",
tooltip = tr("chooser.delete"),
onClick = function()
pending_delete = row_id
render()
end,
}),
ui.button({
glyph = "arrow-right",
variant = is_active and "primary" or "secondary",
tooltip = tr("chooser.open"),
onClick = open_this,
}),
})
end
-- Each session is a card. The most recently opened one is
-- highlighted with the accent (only ever one at a time). The
-- whole label area is clickable to open; action buttons on the
-- right stay separately clickable.
list_items[#list_items + 1] = ui.row({
key = row_id,
justify = "space_between",
align = "center",
padding = CARD_PAD,
radius = CARD_RADIUS,
fill = is_active and "primary/0.15" or "surface",
border = is_active and "primary" or "surface",
borderWidth = is_active and 2 or 1,
}, {
ui.column({
gap = GAP_SM, flexGrow = 1,
onClick = open_this,
}, {
ui.label({
text = title,
maxWidth = WRAP - 140,
fontWeight = is_active and "bold" or "medium",
color = is_active and "primary" or "on_surface",
fontSize = compact and 12 or nil,
}),
(not compact)
and ui.label({
text = (s.directory or "") .. model_str .. time_str,
maxWidth = WRAP - 140,
opacity = 0.6,
fontSize = FONT_SUB,
})
or nil,
}),
actions,
})
end
end
end
rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = GAP, align = "stretch", justify = "start" }, list_items)
panel.render(ui.column({ padding = PAD, gap = GAP, flexGrow = 1, align = "stretch", justify = "start" }, rows))
end
-- ── chat view ───────────────────────────────────────────────────────────────
local function render_message(msg_data)
if type(msg_data) ~= "table" then return nil end
local info = msg_data.info
local parts = msg_data.parts
if type(info) ~= "table" then return nil end
local role = info.role or "unknown"
local cells = {}
-- Render each part
if type(parts) == "table" then
for _, part in ipairs(parts) do
if type(part) == "table" then
local ptype = part.type
if ptype == "text" and type(part.text) == "string" and part.text ~= "" then
-- The server prepends an injected context block (e.g. a
-- <memory_context>…</memory_context> section) to user messages.
-- Hide those synthetic parts so the bubble shows only what the
-- user actually typed.
local txt = part.text
local is_injected = (role == "user") and (
txt:sub(1, 16) == "<memory_context>"
or txt:sub(1, 1) == "<" and txt:find("_context>", 1, true) ~= nil
)
if not is_injected then
cells[#cells + 1] = ui.label({
text = txt,
maxWidth = WRAP,
flexGrow = 1,
})
end
elseif ptype == "reasoning" and type(part.text) == "string" and part.text ~= "" then
-- Only show reasoning if enabled (checked at render time via config)
local show_reasoning = noctalia.getConfig and noctalia.getConfig("show_reasoning")
if show_reasoning then
cells[#cells + 1] = ui.label({
text = "> " .. part.text:gsub("\n", "\n> "),
maxWidth = WRAP,
opacity = 0.6,
fontSize = 12,
})
end
elseif ptype == "tool" then
local show_tools = noctalia.getConfig and noctalia.getConfig("show_tool_calls")
if show_tools then
local tool_name = part.tool or "?"
local status = "unknown"
local detail = ""
if type(part.state) == "table" then
status = part.state.status or "?"
if type(part.state.title) == "string" then
detail = part.state.title
end
end
local status_icon = "circle"
if status == "completed" then status_icon = "circle-check"
elseif status == "error" then status_icon = "alert-circle"
elseif status == "pending" then status_icon = "loader"
end
cells[#cells + 1] = ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = status_icon, size = 14, color = status == "error" and "error" or "secondary" }),
ui.label({ text = tool_name .. (detail ~= "" and (" → " .. detail) or ""), fontSize = 12, opacity = 0.8 }),
})
end
elseif ptype == "step-start" or ptype == "step-finish" then
-- Skip structural parts
end
end
end
end
if #cells == 0 then return nil end
local is_user = (role == "user")
local header_text = is_user and tr("chat.you") or tr("chat.assistant")
local header_color = is_user and "secondary" or "primary"
local all_cells = {
ui.label({ text = header_text, fontWeight = "bold", color = header_color, fontSize = compact and 11 or 12 }),
}
for _, c in ipairs(cells) do
all_cells[#all_cells + 1] = c
end
-- User messages hug the right edge, agent messages the left.
-- `align` on the column right/left-aligns the bubble content (maxWidth on
-- column is unsupported in beta.7, so we rely on align + label maxWidth).
-- A stable `key` (the message id) keeps each bubble's node identity across
-- re-renders so the scroll offset isn't reset while a reply streams in.
local msg_id = (type(info.id) == "string" and info.id) or ""
return ui.row({ key = "msg_" .. msg_id, justify = is_user and "end" or "start", align = "start" }, {
ui.column({ gap = BUBBLE_GAP, align = is_user and "end" or "start" }, all_cells),
})
end
-- Animated "thinking" bubble shown on the left while the agent is processing.
local function render_thinking()
local glyph_name = THINKING_GLYPHS[(thinking_frame % #THINKING_GLYPHS) + 1]
return ui.row({ justify = "start", align = "start" }, {
ui.column({ gap = BUBBLE_GAP, align = "start" }, {
ui.label({ text = tr("chat.assistant"), fontWeight = "bold", color = "primary", fontSize = compact and 11 or 12 }),
ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = glyph_name, color = "primary" }),
ui.label({ text = tr("chat.thinking"), opacity = 0.7, fontSize = compact and 12 or nil }),
}),
}),
})
end
local function render_permission_card(perm)
if type(perm) ~= "table" then return nil end
local perm_id = perm.permissionID or "?"
local session_id = perm.sessionID or "?"
-- Compose a readable description from the v1 (`permission`/`patterns`) or
-- v2 (`action`/`resources`) fields the SSE event carries.
local message = ""
if type(perm.action) == "string" and perm.action ~= "" then
message = perm.action
elseif type(perm.permission) == "string" and perm.permission ~= "" then
message = perm.permission
end
if message == "" then
message = tr("permission.default_message")
end
local src = perm.resources
if type(src) ~= "table" then src = perm.patterns end
local detail_items = {}
if type(src) == "table" then
for _, s in ipairs(src) do
if type(s) == "string" and s ~= "" then
detail_items[#detail_items + 1] = s
end
end
end
local details = table.concat(detail_items, ", ")
local cards = {}
cards[#cards + 1] = ui.column({ gap = 6 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "shield-exclamation", color = "error", size = 20 }),
ui.label({ text = tr("permission.title"), fontWeight = "bold", color = "error" }),
}),
ui.label({ text = message, maxWidth = WRAP }),
})
if details ~= "" then
cards[#cards + 1] = ui.label({ text = details, fontSize = 12, fontFamily = "monospace", maxWidth = WRAP })
end
-- Action buttons
cards[#cards + 1] = ui.row({ gap = 8 }, {
ui.button({
text = tr("permission.allow"),
glyph = "check",
variant = "primary",
onClick = function()
dispatch_ipc("permission_response", perm_id .. ":allow")
end,
}),
ui.button({
text = tr("permission.allow_remember"),
glyph = "checks",
variant = "secondary",
onClick = function()
dispatch_ipc("permission_response", perm_id .. ":allow:true")
end,
}),
ui.button({
text = tr("permission.deny"),
glyph = "x",
variant = "secondary",
onClick = function()
dispatch_ipc("permission_response", perm_id .. ":deny")
end,
}),
})
return ui.column({ gap = 8, padding = 8 }, cards)
end
local function render_question_card(q)
if type(q) ~= "table" then return nil end
local rid = q.requestID or q.id
local questions = q.questions
if not rid or type(questions) ~= "table" or #questions == 0 then return nil end
-- Per-request selection store: choices[i] = chosen label for question i.
local choices = question_choices[rid]
if type(choices) ~= "table" then
choices = {}
question_choices[rid] = choices
end
local cards = {}
cards[#cards + 1] = ui.column({ gap = 6 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "message-circle-question", color = "primary", size = 20 }),
ui.label({ text = tr("question.title"), fontWeight = "bold", color = "primary" }),
}),
})
for qi, qinfo in ipairs(questions) do
if type(qinfo) == "table" then
cards[#cards + 1] = ui.label({ text = qinfo.question or "", maxWidth = WRAP })
local opts = qinfo.options
if type(opts) == "table" then
local opt_row = {}
for _, o in ipairs(opts) do
if type(o) == "table" and type(o.label) == "string" and o.label ~= "" then
local label = o.label
opt_row[#opt_row + 1] = ui.button({
text = label,
-- Highlight the chosen option; only record the
-- selection locally, never reply until every
-- question has an answer.
variant = (choices[qi] == label) and "primary" or "secondary",
selected = (choices[qi] == label),
onClick = function()
choices[qi] = label
render()
end,
})
end
end
if #opt_row > 0 then
-- Stack option buttons vertically: avoids horizontal overflow
-- (no wrap support in beta.7) and reads like choice buttons.
cards[#cards + 1] = ui.column({ gap = 6 }, opt_row)
end
end
if qinfo.custom then
cards[#cards + 1] = ui.button({
text = tr("question.custom"),
variant = (choices[qi] == "") and "primary" or "secondary",
selected = (choices[qi] == ""),
onClick = function()
-- Custom answer is encoded as an empty selection for
-- this question; still requires the others to be set.
choices[qi] = ""
render()
end,
})
end
end
end
-- Every question must have a selection before the reply can be sent.
local all_answered = true
for qi = 1, #questions do
if choices[qi] == nil then
all_answered = false
break
end
end
cards[#cards + 1] = ui.row({ gap = 6 }, {
ui.button({
text = tr("question.submit"),
glyph = "check",
variant = "primary",
enabled = all_answered,
flexGrow = 1,
onClick = function()
if not all_answered then return end
-- Build the outer array: one inner array per question, in order.
local inner = {}
for qi = 1, #questions do
local ok, enc = pcall(noctalia.json.encode, { choices[qi] })
inner[#inner + 1] = ok and enc or "[\"\"]"
end
local payload = "[" .. table.concat(inner, ",") .. "]"
question_choices[rid] = nil
dispatch_ipc("question_reply", rid .. "\2" .. payload)
end,
}),
ui.button({
text = tr("question.cancel"),
variant = "secondary",
glyph = "x",
onClick = function()
question_choices[rid] = nil
dispatch_ipc("question_reject", rid)
end,
}),
})
return ui.column({ gap = 8, padding = 8, fill = "surface" }, cards)
end
local function render_chat(active, messages, permissions, questions, conn)
local rows = {}
-- Header
local title = (type(active) == "table" and active.title) or tr("chat.title")
local model_str = ""
if type(active) == "table" and type(active.model) == "table" then
model_str = (active.model.providerID or "?") .. "/" .. (active.model.id or "?")
end
local agent_str = (type(active) == "table" and active.agent) or "?"
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.button({
glyph = "arrow-left",
variant = "secondary",
tooltip = tr("chat.back"),
onClick = function()
dispatch_ipc("deselect_session")
end,
}),
ui.glyph({ name = "code-circle", color = "primary", size = compact and 18 or 20 }),
ui.column({ gap = 0 }, {
ui.label({ text = title, fontWeight = "bold", maxWidth = WRAP - 200, fontSize = compact and 13 or 14 }),
(not compact)
and ui.label({
text = agent_str .. (model_str ~= "" and (" · " .. model_str) or ""),
fontSize = FONT_SUB,
opacity = 0.6,
})
or nil,
}),
}),
ui.row({ gap = 6 }, {
ui.button({
glyph = "plus",
variant = "secondary",
tooltip = tr("chooser.new_session"),
onClick = function()
dispatch_ipc("create_session")
end,
}),
ui.button({
glyph = "terminal",
tooltip = tr("chat.open_terminal"),
onClick = function()
local host = noctalia.getConfig and noctalia.getConfig("server_host") or "127.0.0.1"
local port = noctalia.getConfig and noctalia.getConfig("server_port") or 4096
local url = "http://" .. host .. ":" .. tostring(port)
local sess = (type(active) == "table" and type(active.id) == "string") and active.id or nil
local cmd = "opencode attach " .. shq(url)
if sess and sess ~= "" then
cmd = cmd .. " --session " .. shq(sess)
end
noctalia.runInTerminal(cmd)
end,
}),
ui.button({
glyph = "refresh",
variant = "secondary",
tooltip = tr("chat.refresh"),
onClick = function()
dispatch_ipc("refresh")
end,
}),
}),
})
-- Model + agent selectors. The service publishes option lists as arrays of
-- { value, label }; ui.select takes a flat label array + selectedIndex, and
-- its onChange fires with (indexString, labelText). We map the chosen index
-- back to the option's `value` and dispatch it to the service.
local model_options = noctalia.state.get(STATE.model_options) or {}
local agent_options = noctalia.state.get(STATE.agent_options) or {}
local sel_model = noctalia.state.get(STATE.selected_model)
local sel_agent = noctalia.state.get(STATE.selected_agent)
local function build_select(key, opts, selected_value, placeholder_key, event)
local labels = {}
local values = {}
local sel_index = nil
for i, o in ipairs(opts) do
labels[i] = (type(o) == "table" and o.label) or tostring(o)
values[i] = (type(o) == "table" and o.value) or tostring(o)
if selected_value and values[i] == selected_value then
sel_index = i - 1 -- host uses 0-based indices
end
end
local props = {
key = key,
options = labels,
placeholder = tr(placeholder_key),
flexGrow = 1,
onChange = function(idx)
-- idx arrives as a 0-based index (string or number).
local n = tonumber(idx)
if n ~= nil then
local chosen = values[math.floor(n) + 1]
if type(chosen) == "string" and chosen ~= "" then
dispatch_ipc(event, chosen)
end
end
end,
}
if sel_index ~= nil then props.selectedIndex = sel_index end
return ui.select(props)
end
if (type(model_options) == "table" and #model_options > 0)
or (type(agent_options) == "table" and #agent_options > 0) then
local selectors = {}
if type(model_options) == "table" and #model_options > 0 then
selectors[#selectors + 1] = build_select("model_select", model_options, sel_model, "chat.select_model", "set_model")
end
if type(agent_options) == "table" and #agent_options > 0 then
selectors[#selectors + 1] = build_select("agent_select", agent_options, sel_agent, "chat.select_agent", "set_agent")
end
rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, selectors)
end
rows[#rows + 1] = ui.separator({})
-- Error banner: surface the last error (e.g. session.error from the server)
-- so failures are visible instead of a silently-stuck composer.
local err = noctalia.state.get(STATE.last_error)
if type(err) == "table" and type(err.message) == "string" and err.message ~= "" then
local banner = {
ui.glyph({ name = "alert-circle", color = "error", size = 16 }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }),
(type(err.detail) == "string" and err.detail ~= "")
and ui.label({ text = err.detail, fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 })
or nil,
}),
ui.button({
glyph = "x",
variant = "secondary",
tooltip = tr("chat.dismiss_error"),
onClick = function()
dispatch_ipc("clear_error")
end,
}),
}
rows[#rows + 1] = ui.row({
gap = 8, align = "start", padding = 8, radius = 8,
fill = "surface", border = "error", borderWidth = 1,
}, banner)
end
-- Permission cards (if any)
if type(permissions) == "table" and #permissions > 0 then
for _, perm in ipairs(permissions) do
local card = render_permission_card(perm)
if card then
rows[#rows + 1] = card
rows[#rows + 1] = ui.separator({})
end
end
end
-- Question/choice cards (if any) — the agent asked the user to pick.
if type(questions) == "table" and #questions > 0 then
for _, q in ipairs(questions) do
local card = render_question_card(q)
if card then
rows[#rows + 1] = card
rows[#rows + 1] = ui.separator({})
end
end
end
-- Message list, newest-first. Newest at the top so the panel always opens on
-- the latest message without needing a scroll API (shell < API 21 resets a
-- re-mounted scroll to offset 0 = top). Scrolling down reveals older
-- messages. The scroll node carries a stable `key` so its offset survives
-- re-renders even when siblings above it appear/disappear. Children are
-- passed directly to the scroll (not wrapped in a column).
local msg_items = {}
if type(messages) ~= "table" or #messages == 0 then
msg_items[#msg_items + 1] = ui.label({ text = tr("chat.empty"), maxWidth = WRAP, opacity = 0.7 })
else
-- "Waiting" (show thinking bubble) whenever the agent is processing and
-- the newest message is the user's optimistic bubble, or an assistant
-- bubble that has no text yet. The server creates the assistant message
-- (role="assistant") as soon as streaming starts, before any text part
-- exists, so we key off "assistant + has no text" not just the role.
local newest = messages[#messages]
local newest_is_assistant = type(newest) == "table"
and type(newest.info) == "table" and newest.info.role == "assistant"
local newest_has_text = false
if type(newest) == "table" and type(newest.parts) == "table" then
for _, p in ipairs(newest.parts) do
if type(p) == "table" and p.type == "text"
and type(p.text) == "string" and p.text ~= "" then
newest_has_text = true
break
end
end
end
-- Thinking shows after the user sends (newest = user bubble) and while
-- the agent is reasoning (newest = assistant bubble, no text yet). It
-- disappears once the assistant's first text part streams in.
local waiting = is_processing()
and (not newest_is_assistant or not newest_has_text)
-- Newest-first: build the list from the end so bubble order is latest
-- first, oldest last.
for i = #messages, 1, -1 do
local rendered = render_message(messages[i])
if rendered then
msg_items[#msg_items + 1] = rendered
end
end
-- Thinking bubble sits right ABOVE the newest message (top of the
-- newest-first list), like a spinner over the reply in progress.
if waiting then
table.insert(msg_items, 1, render_thinking())
end
end
rows[#rows + 1] = ui.scroll({ key = "chat_messages", flexGrow = 1, gap = GAP }, msg_items)
-- MCP status footer
local mcp_footer = render_mcp_status()
if mcp_footer then
rows[#rows + 1] = mcp_footer
end
-- Composer: input always visible; sending is disabled while processing and a
-- stop button appears. The thinking indicator lives in the message list.
local processing = is_processing()
rows[#rows + 1] = ui.separator({})
-- Send the current draft, then clear the composer. Clearing requires bumping
-- the input's key so the reconciler rebuilds a fresh, empty input.
local function send_draft(text)
if processing then return end
text = text or draft
if type(text) ~= "string" or text == "" then return end
dispatch_ipc("send_prompt", text)
draft = ""
composer_seq = composer_seq + 1
render()
end
local composer_elems = {
ui.input({
-- Key changes on each send to force a fresh (cleared) input.
key = "composer_" .. composer_seq,
value = draft,
placeholder = tr("chat.placeholder"),
multiline = true,
flexGrow = 1,
onChange = function(text)
draft = text
end,
-- Multiline: Enter inserts a newline; Ctrl+Enter submits (send). This
-- is the input control's built-in mapping and cannot distinguish
-- Shift+Enter, so Ctrl+Enter is the send chord.
onSubmit = function(text)
send_draft(text)
end,
}),
ui.button({
glyph = "send",
variant = "primary",
tooltip = tr("chat.send"),
onClick = function()
send_draft(draft)
end,
}),
}
if processing then
composer_elems[#composer_elems + 1] = ui.button({
glyph = "player-stop",
variant = "secondary",
tooltip = tr("chat.stop"),
onClick = function()
dispatch_ipc("abort_session")
end,
})
end
rows[#rows + 1] = ui.row({ gap = 8, align = "end" }, composer_elems)
panel.render(ui.column({ padding = PAD, gap = GAP, flexGrow = 1 }, rows))
end
-- ── main render ─────────────────────────────────────────────────────────────
render = function()
apply_layout()
local active = noctalia.state.get(STATE.active)
local sessions = noctalia.state.get(STATE.sessions) or {}
local messages = noctalia.state.get(STATE.messages) or {}
local permissions = noctalia.state.get(STATE.permissions) or {}
local questions = noctalia.state.get(STATE.questions) or {}
local conn = noctalia.state.get(STATE.connection)
-- Keep the chooser highlight in sync with whatever session is actually
-- open (covers restore-on-boot and IPC-driven selection).
if type(active) == "table" and active.id then
last_opened_id = active.id
end
-- Determine view
if type(active) ~= "table" or not active.id then
view = "session_chooser"
render_session_chooser(sessions, conn)
else
view = "chat"
render_chat(active, messages, permissions, questions, conn)
end
end
local function fingerprint_state()
local active = noctalia.state.get(STATE.active)
local sessions = noctalia.state.get(STATE.sessions)
local messages = noctalia.state.get(STATE.messages)
local permissions = noctalia.state.get(STATE.permissions)
local questions = noctalia.state.get(STATE.questions)
local conn = noctalia.state.get(STATE.connection)
local err = noctalia.state.get(STATE.last_error)
local sel_model = noctalia.state.get(STATE.selected_model)
local sel_agent = noctalia.state.get(STATE.selected_agent)
local mcp = noctalia.state.get(STATE.mcp)
return table.concat({
(type(active) == "table" and active.id or "none"),
fp_list(sessions or {}, { "id", "title" }),
fp_messages(messages or {}),
fp_list(permissions or {}, { "permissionID", "sessionID" }),
fp_list(questions or {}, { "requestID", "sessionID" }),
(type(conn) == "table" and conn.status or "offline"),
(type(err) == "table" and tostring(err.message) or "none"),
-- Selection changes must re-render so the selectors reflect the pick.
tostring(sel_model or "") .. "\1" .. tostring(sel_agent or ""),
fp_mcp((type(mcp) == "table" and mcp or {})),
-- Animation frame drives the thinking spinner re-render while busy.
tostring(thinking_frame),
}, "\1")
end
-- ── lifecycle ───────────────────────────────────────────────────────────────
function onOpen(_context)
panel.setWantsSecondTicks(true)
-- Subscribe to all relevant state changes
for _, key in pairs(STATE) do
noctalia.state.watch(key, function()
-- Debounce: only re-render if fingerprint changed
local fp = fingerprint_state()
if fp ~= snap_cache.fingerprint then
snap_cache.fingerprint = fp
render()
end
end)
end
render()
end
function onClose()
-- Keep draft in memory — it persists for this panel instance
end
function update()
-- Second tick: advance the thinking animation while processing, then
-- re-render only if state changed.
if is_processing() then
thinking_frame = thinking_frame + 1
end
local fp = fingerprint_state()
if fp ~= snap_cache.fingerprint then
snap_cache.fingerprint = fp
render()
end
end