Files
community-plugins/mimir/panel.luau
T
92f8a33a3f Mimir: introduce AI companion plugin (#164)
* game-launcher: add launcher toggles, keyboard nav, auto-refresh

* game-launcher: update README with runner toggle settings

* fix: remove duplicate translation keys after upstream merge

* fix(game-launcher): add ~/.local/share/Steam to steam roots for NixOS support

* chore(game-launcher): bump version to 1.1.1

* feat(mimir): AI companion plugin with LLM chat panel

Mimir is an AI companion for Noctalia — an LLM-powered chat interface
with model selection, conversation history, and a bar widget.

- Service-based architecture: service (brain) handles HTTP API calls,
  panel (chat) renders the UI, widget (status) shows bar indicator
- OpenAI-compatible chat completions with dynamic model discovery
- Floating side panel (center_right) with message history and
  simple markdown rendering (code blocks)
- Model selection dropdown populated from API /models endpoint
- Bar widget with brain icon to toggle chat panel
- i18n via translations/en.json
- Auto-detection of OpenCode Go API key from auth.json
- Full-height floating panel layout matching oficial notes plugin

.gitignore: add editor files, OS junk, auth secrets, compiled binary

* mimir: rename author leo->Alexander, strip scrollBottom, update README with plans

- plugin.toml: author leo -> Alexander, widget panel-toggle id -> alexander/mimir
- widget.luau: togglePanel id -> alexander/mimir:chat
- panel.luau: remove scrollBottom/dynamic key (unstable), remove setUpdateInterval
- README.md: add future plans (commands, file search, etc.)
- thumbnail.webp: removed (replaced by mimir-thumbnail.webp)

* added thumbnail

* added thumbnail.webp

* mimir: bump 0.1.0 → 0.3.0, update README with tools + copy + editable approval

* mimir: copy-to-clipboard toggle, editable command approval, unicode bold rendering

* mimir: fix review findings — conditional auth, dedupe user msg, apply max_history

* mimir: fix manifest validation — use select setting type, add README Plugin section

* mimir: multi-tool queue support, plain approve/deny, better tool-use prompt

* mimir: add full markdown rendering — headings, lists, quotes, hr, bold/italic

* mimir: bump 0.3.2 — fix Lua pattern quantifiers, multi-tool queue, markdown rendering

* mimir: fix security review findings

* mimir: clarify selectable message text

* mimir: add command history display

---------

Co-authored-by: Ahmed5Emad <ahmed5emad@users.noreply.github.com>
2026-08-01 08:59:48 -04:00

322 lines
12 KiB
Luau

local inputText = ""
local inputKey = 0
local copyTarget = nil
local modelIdx = 0
noctalia.state.watch("mimir.copy_target", function(value)
copyTarget = (value == -1) and nil or value
render()
end)
local function mapUnicode(text, lowerBase, upperBase)
local out = ""
for i = 1, #text do
local c = text:sub(i, i)
local code = c:byte()
if code >= 97 and code <= 122 then
out = out .. utf8.char(code - 97 + lowerBase)
elseif code >= 65 and code <= 90 then
out = out .. utf8.char(code - 65 + upperBase)
else
out = out .. c
end
end
return out
end
local function inlineMarkdown(text)
text = text:gsub("%*%*%*([^%*]-)%*%*%*", function(s) return mapUnicode(s, 0x1D482, 0x1D468) end)
text = text:gsub("%*%*([^%*]-)%*%*", function(s) return mapUnicode(s, 0x1D41A, 0x1D400) end)
text = text:gsub("%*([^%*]-)%*", function(s) return mapUnicode(s, 0x1D44E, 0x1D434) end)
text = text:gsub("~~([^~]-)~~", "%1")
text = text:gsub("`(.-)`", "%1")
text = text:gsub("%[([^%]]*)%]%(([^%)]+)%)", "%1 (%2)")
return text
end
local function renderText(text, color, fontSize, fontWeight)
return ui.label({ text = inlineMarkdown(text), fontSize = fontSize or 13, fontWeight = fontWeight, color = color, maxWidth = 380 })
end
local function toolCommand(toolCall)
local fn = toolCall["function"]
if not fn or type(fn.arguments) ~= "string" then return nil end
local ok, args = pcall(noctalia.json.decode, fn.arguments)
if ok and type(args) == "table" and type(args.command) == "string" then
return args.command
end
return nil
end
local function parseMessage(content)
local nodes = {}
local lines = {}
for line in (content .. "\n"):gmatch("(.-)\n") do
table.insert(lines, line)
end
local i = 1
local function trim(s)
return (s:gsub("^%s*(.-)%s*$", "%1"))
end
while i <= #lines do
local line = lines[i]
if line:find("^```") then
local buf = {}
local lang = line:sub(4)
i = i + 1
while i <= #lines and not lines[i]:find("^```") do
table.insert(buf, lines[i])
i = i + 1
end
i = i + 1
table.insert(nodes, { type = "code", language = trim(lang), content = table.concat(buf, "\n") })
elseif line:match("^#+") and line:gsub("#+", ""):match("%S") then
local level, text = line:match("^(#+)%s*(.-)%s*$")
level = #level
text = text:gsub("#+%s*$", "")
table.insert(nodes, { type = "heading", level = level, content = text })
i = i + 1
elseif line:match("^%s*[-*_]+%s*$") and #line:gsub("%s", "") >= 3 then
table.insert(nodes, { type = "hr" })
i = i + 1
elseif line:match("^%s*>") then
local buf = {}
while i <= #lines and lines[i]:match("^%s*>") do
local t = lines[i]:gsub("^%s*>%s?", "")
if t == "" then t = " " end
table.insert(buf, t)
i = i + 1
end
table.insert(nodes, { type = "quote", content = table.concat(buf, " ") })
elseif line:match("^%s*[-*+]%s+") or line:match("^%s*%d+[.)]%s+") then
local items = {}
while i <= #lines do
local l = lines[i]
local bullet = l:match("^%s*[-*+]%s+(.*)")
local numbered = l:match("^%s*%d+[.)]%s+(.*)")
if bullet then
table.insert(items, { ordered = false, content = bullet })
i = i + 1
elseif numbered then
table.insert(items, { ordered = true, content = numbered })
i = i + 1
else
break
end
end
table.insert(nodes, { type = "list", items = items })
else
local buf = {}
while i <= #lines and not lines[i]:match("^```") and not (lines[i]:match("^#+") and lines[i]:gsub("#+", ""):match("%S"))
and not (lines[i]:match("^%s*[-*_]+%s*$") and #lines[i]:gsub("%s", "") >= 3) and not lines[i]:match("^%s*>")
and not lines[i]:match("^%s*[-*+]%s+") and not lines[i]:match("^%s*%d+[.)]%s+") do
table.insert(buf, lines[i])
i = i + 1
end
local para = table.concat(buf, "\n")
if trim(para) ~= "" then
table.insert(nodes, { type = "text", content = para })
end
end
end
return nodes
end
function render()
local models = noctalia.state.get("mimir.models") or {}
local messages = noctalia.state.get("mimir.messages") or {}
local status = noctalia.state.get("mimir.status") or "idle"
local commandLog = noctalia.state.get("mimir.command_log") or {}
local showCommands = noctalia.getConfig("show_commands") ~= false
local commandsByToolCall = {}
for _, entry in ipairs(commandLog) do
commandsByToolCall[entry.tool_call_id] = entry.command
end
local statusColors = { idle = "on_surface/0.4", thinking = "primary", running_tool = "primary", error = "error" }
local statusTexts = { idle = "Ready", thinking = "Thinking...", running_tool = "Running command...", error = "Error" }
local displayMsgs = {}
local copyContent = nil
for i, msg in ipairs(messages) do
if msg.role ~= "system" and msg.role ~= "tool" and msg.content then
local isUser = msg.role == "user"
local isCopying = copyTarget == i
if isCopying then copyContent = msg.content end
local nodes = parseMessage(msg.content or "")
local contentNodes = {}
for _, node in ipairs(nodes) do
if node.type == "text" then
table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface"))
elseif node.type == "code" then
table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 6 }, {
ui.label({ text = node.content, fontSize = 12, color = "on_surface_variant" }),
}))
elseif node.type == "heading" then
local sizes = { 17, 15, 14, 13, 13, 13 }
table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface", sizes[node.level] or 13, "bold"))
elseif node.type == "quote" then
table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 4 }, {
renderText(node.content, isUser and "primary" or "on_surface_variant", 12),
}))
elseif node.type == "hr" then
table.insert(contentNodes, ui.separator({ thickness = 1, color = "surface_variant" }))
elseif node.type == "list" then
local listChildren = {}
for n, item in ipairs(node.items) do
local prefix = item.ordered and (n .. ". ") or "• "
table.insert(listChildren, renderText(prefix .. item.content, isUser and "primary" or "on_surface"))
end
table.insert(contentNodes, ui.column({ gap = 2 }, listChildren))
end
end
if msg.tool_calls then
for _, toolCall in ipairs(msg.tool_calls) do
local command = showCommands and (toolCommand(toolCall) or commandsByToolCall[toolCall.id])
if command then
table.insert(contentNodes, ui.column({ gap = 2, padding = 8, fill = "surface_variant", radius = 6 }, {
ui.label({ text = "Command", fontSize = 11, color = "on_surface/0.5" }),
ui.label({ text = "$ " .. command, fontSize = 12, color = "primary", maxWidth = 380 }),
}))
end
end
end
table.insert(displayMsgs, ui.column({ key = "m" .. i, gap = 4, paddingH = 4, align = isUser and "end" or "start" }, {
ui.row({ gap = 4, align = "center" }, {
ui.label({ text = isUser and "You" or "Mimir", fontSize = 11, color = "on_surface/0.5" }),
ui.button({ glyph = isCopying and "close" or "copy", variant = "ghost", onClick = function() noctalia.state.set("mimir.copy_target", isCopying and -1 or i) end }),
}),
table.unpack(contentNodes),
}))
end
end
local pendingTool = noctalia.state.get("mimir.pending_tool")
if pendingTool and type(pendingTool) == "table" and pendingTool.commands then
local approvalChildren = {
ui.label({ text = "Mimir wants to run:", fontSize = 12, color = "on_surface" }),
}
for _, c in ipairs(pendingTool.commands) do
table.insert(approvalChildren, ui.label({ text = c.command or "", fontSize = 12, color = "primary", maxWidth = 380 }))
end
table.insert(approvalChildren, ui.row({ gap = 8, align = "center" }, {
ui.button({ text = "Approve", variant = "primary", onClick = "onApproveTool" }),
ui.button({ text = "Deny", variant = "ghost", onClick = "onDenyTool" }),
}))
table.insert(displayMsgs, ui.column({ key = "tool-approval", gap = 8, padding = 12, fill = "surface_variant", radius = 8 }, approvalChildren))
end
local layout = {
ui.row({ gap = 6, padding = 12, align = "center" }, {
ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefreshModels" }),
ui.select({ options = models, selectedIndex = modelIdx, flexGrow = 1, placeholder = "No models", onChange = "onModelChange" }),
}),
ui.separator({ thickness = 1, color = "surface_variant" }),
ui.scroll({ flexGrow = 1, gap = 4, padding = 12 }, displayMsgs),
ui.separator({ thickness = 1, color = "surface_variant" }),
}
if copyContent then
table.insert(layout, ui.column({ flexGrow = 1, padding = 8, fill = "surface_variant" }, {
ui.input({ key = "copy-area", multiline = true, value = copyContent, focus = true, flexGrow = 1, onChange = function() end }),
}))
end
table.insert(layout, ui.row({ gap = 8, padding = { left = 12, right = 12, top = 8, bottom = 4 }, align = "center" }, {
ui.input({ key = "msg-input-" .. inputKey, value = inputText, placeholder = "Ask me anything...", flexGrow = 1, focus = true, onChange = "onInputChange", onSubmit = "onSubmit" }),
ui.button({ glyph = "send", variant = "primary", onClick = "onSubmit" }),
}))
table.insert(layout, ui.row({ gap = 12, padding = { left = 12, right = 12, top = 4, bottom = 12 }, align = "center" }, {
ui.label({ text = statusTexts[status] or "Ready", fontSize = 11, color = statusColors[status] or "on_surface/0.4" }),
ui.button({ glyph = "eraser", variant = "ghost", onClick = "onClear" }),
}))
panel.render(ui.column({ flexGrow = 1, gap = 0, align = "stretch" }, layout))
end
function onModelChange(index, text)
local models = noctalia.state.get("mimir.models") or {}
local idx = tonumber(index)
if idx == nil and text then
for i, m in ipairs(models) do
if m == text then
idx = i - 1
break
end
end
end
if idx ~= nil and models[idx + 1] then
modelIdx = idx
noctalia.state.set("mimir.model", models[idx + 1])
render()
end
end
function onClear()
noctalia.state.set("mimir.clear", true)
end
function onRefreshModels()
noctalia.state.set("mimir.refresh_models", true)
end
function onInputChange(v)
inputText = type(v) == "table" and (v.value or "") or v or ""
end
function onSubmit(v)
local text = v
if type(text) == "table" then text = text.value end
if type(text) ~= "string" or text == "" then text = inputText end
inputText = ""
if text and text ~= "" then
inputKey += 1
noctalia.state.set("mimir.copy_target", -1)
noctalia.state.set("mimir.status", "thinking")
noctalia.state.set("mimir.input", text)
render()
end
end
noctalia.state.watch("mimir.messages", function()
render()
end)
noctalia.state.watch("mimir.status", function()
render()
end)
noctalia.state.watch("mimir.models", function()
local models = noctalia.state.get("mimir.models") or {}
local currentModel = noctalia.state.get("mimir.model") or ""
modelIdx = 0
for i, m in ipairs(models) do
if m == currentModel then
modelIdx = i - 1
break
end
end
render()
end)
noctalia.state.watch("mimir.pending_tool", function()
if noctalia.state.get("mimir.pending_tool") then
noctalia.state.set("mimir.copy_target", -1)
end
render()
end)
noctalia.state.watch("mimir.command_log", function()
render()
end)
function onApproveTool()
noctalia.state.set("mimir.tool_approved", true)
end
function onDenyTool()
noctalia.state.set("mimir.tool_denied", true)
end
function onOpen()
noctalia.state.set("mimir.refresh_models", true)
render()
end