Files
community-plugins/opencode-companion/panel.luau
T
weinguyenandGitHub 27458632e2 feat(plugin): OpenCode Companion beta.8 upgrade + streaming/overflow (#337)
* feat(plugin): OpenCode Companion beta.8 upgrade + streaming/overflow
fixes

Upgrade to plugin API 21 and reverse the chat so the newest message sits
at the bottom with auto-scroll:

- panel: reverse message list to oldest-first; pin the scroll to the
  bottom via stickToBottom, jump there on open and session switch via
  scrollToBottomRev, and clear the stick flag in onScroll when the user
  scrolls away so reading history doesn't yank them down.
- composer: enable submitOnEnter so Enter sends and Shift+Enter inserts
  a
  newline; update en/vi placeholder and send-tooltip strings.
- panel: render assistant replies as markdown (headings, lists, tables),
  keeping user messages as plain labels.

Fix runtime errors surfaced during use:

- panel: pin markdown width to WRAP — ui.markdown has no maxWidth prop,
  so
  a bare flexGrow left it unconstrained and single-line text overflowed.
- panel: wrap fenced code blocks in monospace labels with a maxWidth —
  noctalia's MarkdownView never wraps code, so long bash/code lines
  spilled outside the panel. Text outside code fences stays markdown.
- panel: coerce onScroll(offset, maxOffset) args with tonumber — the
  reconciler passes them as strings, raising "attempt to compare number
  <= string".
- service: handle message.part.updated / message.updated streaming via
  throttled reload; acknowledge message.part.delta without reloading
  (the
  server emits one per token, and reloading on each decodes the whole
  history and blows the async callback CPU budget in json.decode).
- service: handle session.updated / session.diff with a throttled
  session
  reload so auto-generated titles stay current in the chooser.

Verified with luac syntax checks and a standalone split_markdown test
(5 cases) and JSON validation of the translation files.

* update version to 0.2.0
2026-08-12 09:49:13 -04:00

1269 lines
50 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"
-- 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
-- Chat scroll control (API 21). `chat_scroll_rev` is bumped to request a
-- one-shot jump to the bottom (deferred to the next layout pass); `chat_stick`
-- keeps the view pinned to the bottom while content grows, and is cleared when
-- the user scrolls away so reading history doesn't yank them down.
local chat_scroll_rev = 0
local chat_stick = true
local last_scrolled_session = nil
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 list of records by the given fields, so the panel re-renders
-- only when one of those fields actually changes.
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: a single collapsible toggle row (chevron + title + the
-- 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 ───────────────────────────────────────────────────────────────
-- noctalia's MarkdownView doesn't wrap fenced code blocks (the code label is
-- not width-constrained), so long lines overflow the panel. Split assistant
-- text into code / non-code segments: code renders as a wrapping monospace
-- label, everything else keeps markdown rendering.
local function split_markdown(txt)
local segs = {}
local in_code = false
local buf = ""
local function flush()
if buf ~= "" then
segs[#segs + 1] = { code = in_code, text = buf }
buf = ""
end
end
for line in (txt .. "\n"):gmatch("([^\n]*)\n") do
local stripped = line:match("^%s*(.*)$") or ""
if stripped:sub(1, 3) == "```" then
flush()
in_code = not in_code
else
buf = buf .. line .. "\n"
end
end
flush()
return segs
end
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
if role == "assistant" then
-- Render text as markdown (headings, bold, lists, tables)
-- but split out fenced code blocks, which MarkdownView
-- never wraps and would overflow the panel. Code segments
-- render as wrapping monospace labels.
local segs = split_markdown(txt)
for _, seg in ipairs(segs) do
if seg.code then
cells[#cells + 1] = ui.column({
padding = 8,
radius = 8,
fill = "surface_variant/0.45",
}, {
ui.label({
text = seg.text:gsub("\n+$", ""),
fontSize = 12,
fontFamily = "monospace",
maxWidth = WRAP - 16,
}),
})
else
cells[#cells + 1] = ui.markdown({
text = seg.text,
width = WRAP,
})
end
end
else
cells[#cells + 1] = ui.label({
text = txt,
maxWidth = WRAP,
flexGrow = 1,
})
end
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, oldest-first. Newest at the bottom; the scroll node is
-- pinned to the bottom (stickToBottom) and jumps there on open/session
-- switch (scrollToBottomRev), so the panel always shows the latest message.
-- 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)
-- Oldest-first: newest message at the bottom (API 21 supports
-- stick-to-bottom + jump-to-bottom, so the panel opens on the latest
-- message and follows the stream).
for i = 1, #messages do
local rendered = render_message(messages[i])
if rendered then
msg_items[#msg_items + 1] = rendered
end
end
-- Thinking bubble sits right BELOW the newest message (end of the
-- oldest-first list), like a spinner over the reply in progress.
if waiting then
msg_items[#msg_items + 1] = render_thinking()
end
end
rows[#rows + 1] = ui.scroll({
key = "chat_messages",
flexGrow = 1,
gap = GAP,
stickToBottom = chat_stick,
scrollToBottomRev = chat_scroll_rev,
onScroll = function(offset, maxOffset)
-- The reconciler passes both args as strings; coerce before compare.
local o = tonumber(offset) or 0
local mo = tonumber(maxOffset) or 0
chat_stick = (o >= mo - 1)
end,
}, 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,
submitOnEnter = true,
flexGrow = 1,
onChange = function(text)
draft = text
end,
-- Chat-style submit (API 21): Enter submits, Shift+Enter inserts a
-- newline. Ctrl+Enter still submits as a fallback 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)
-- Jump to the bottom when the active session changes (new session or
-- restore-on-boot), so the panel opens on the latest message.
local active_id = (type(active) == "table" and active.id) or nil
if active_id ~= last_scrolled_session then
last_scrolled_session = active_id
chat_scroll_rev = chat_scroll_rev + 1
chat_stick = true
end
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)
-- Re-entering the panel: jump to the bottom of the current session.
chat_scroll_rev = chat_scroll_rev + 1
chat_stick = 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