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>
This commit is contained in:
+3
-3
@@ -1,4 +1,4 @@
|
||||
# Editor type definitions, fetched from official-plugins (see README).
|
||||
/noctalia.d.luau
|
||||
# Editor type definitions, fetched per-plugin from official-plugins (see README).
|
||||
noctalia.d.luau
|
||||
|
||||
|
||||
/.github/workflows/scripts/__pycache__
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Mimir
|
||||
|
||||
An AI companion for Noctalia that brings LLM-powered chat and terminal command execution directly into your desktop. Named after the Norse god of wisdom.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `alexander/mimir` |
|
||||
| Entries | Bar widget: `status`; panel: `chat`; service: `brain` |
|
||||
|
||||
## Requirements
|
||||
|
||||
- An **OpenAI-compatible API endpoint** with `/chat/completions` and `/models` endpoints.
|
||||
- An API key (for hosted providers) or leave empty for local servers (e.g. Ollama).
|
||||
- A [Noctalia](https://noctalia.app) build supporting `plugin_api >= 16`.
|
||||
|
||||
If you use [OpenCode Go](https://opencode.ai/go) with the default OpenCode endpoint, Mimir auto-detects your API key from `~/.local/share/opencode/auth.json` — no manual setup needed.
|
||||
|
||||
## Features
|
||||
|
||||
- **Chat** — Conversational AI with formatted responses, markdown rendering (code blocks in shaded boxes), and selectable text for every message.
|
||||
- **Command History** — Optionally shows executed commands in the chat, including commands run automatically in `allow` mode.
|
||||
- **Model Browser** — Fetches available models from your API endpoint. Switch models on the fly from the panel header.
|
||||
- **Command Execution** — Mimir can run terminal commands through the AI. In `ask` mode, each command must be approved before it runs; `allow` mode runs non-blocked commands automatically.
|
||||
- **Permission Modes** — `ask` (prompt before every command), `allow` (run automatically), `off` (no tools). Automatic mode still rejects blocked commands and shell composition.
|
||||
- **Command Blocklist** — Dangerous commands and shell composition are rejected before execution. This is an extra safeguard, not a replacement for reviewing commands.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────┐ state ┌──────────┐ HTTP ┌─────────────┐
|
||||
│ panel │◄───────────►│ service │◄──────────►│ API Server │
|
||||
│ (chat) │ mimir.* │ (brain) │ chat/* │ (OpenCode) │
|
||||
│ │ │ │ models │ │
|
||||
└────┬─────┘ └──────────┘ └─────────────┘
|
||||
│
|
||||
│ click
|
||||
┌────▼─────┐
|
||||
│ widget │
|
||||
│ (status) │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
**Widget** (`widget.luau`) — Bar indicator. Click to toggle the chat panel.
|
||||
|
||||
**Panel** (`panel.luau`) — Chat interface with model selector, command approval, command history, message history with markdown rendering, and per-message selectable text views.
|
||||
|
||||
**Service** (`service.luau`) — HTTP communication with the API, conversation management, command execution, model discovery, and deferred state propagation.
|
||||
|
||||
## Usage
|
||||
|
||||
### Install
|
||||
|
||||
1. Add the plugin directory as a path source in Noctalia settings.
|
||||
2. Enable `alexander/mimir` in **Settings → Plugins**.
|
||||
3. Add the bar widget `alexander/mimir:status` to your bar.
|
||||
|
||||
### Chat
|
||||
|
||||
Click the brain icon in your bar or run:
|
||||
```sh
|
||||
noctalia msg panel-toggle alexander/mimir:chat
|
||||
```
|
||||
|
||||
Type a message and press Enter. Mimir responds with formatted text — code blocks render in shaded boxes. Click the copy icon on any message to open its content in a selectable field, then copy the text manually.
|
||||
|
||||
### Command Approval
|
||||
|
||||
When Mimir wants to run a terminal command (in `ask` permission mode), the panel shows an approval dialog:
|
||||
1. Review the command shown in the dialog.
|
||||
2. Click **Approve** to run it or **Deny** to cancel.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `api_endpoint` | `string` | `https://opencode.ai/zen/go/v1` | Base URL for the API. Change to `http://localhost:11434/v1` for Ollama. |
|
||||
| `api_key` | `string` | (auto-detect) | API key. If empty and using the trusted OpenCode endpoint, reads from `~/.local/share/opencode/auth.json`. |
|
||||
| `tool_permission` | `enum` | `ask` | `ask` — prompt before commands; `allow` — run automatically; `off` — disable tools. |
|
||||
| `tool_blocklist` | `string` | `sudo,su,passwd,rm,...` | Comma-separated commands rejected before execution. |
|
||||
| `show_commands` | `bool` | `true` | Show executed commands in the chat. |
|
||||
| `max_history` | `int` | `50` | Max messages kept in context. |
|
||||
| `glyph` | `glyph` | `brain` | Bar icon (per-widget setting). |
|
||||
|
||||
## How It Works
|
||||
|
||||
### API Compatibility
|
||||
Compatible with any OpenAI-compatible chat completion API. Defaults to OpenCode Go.
|
||||
|
||||
### Tool Calling
|
||||
When the model returns `tool_calls`, the service routes them to `run_command`. The permission mode determines whether to run immediately, prompt the user, or skip. Blocklisted commands and shell composition are rejected before execution.
|
||||
|
||||
### State Flow
|
||||
Entries are isolated VMs — they communicate through Noctalia's shared state (`noctalia.state.*`). HTTP callbacks queue responses to avoid cross-context state corruption. A timer-driven `update()` processes the queue and propagates results.
|
||||
|
||||
## Notes
|
||||
|
||||
- Conversation is ephemeral (in-memory only). Restarting clears it.
|
||||
- API key auto-detection reads OpenCode Go's auth file at runtime only — never stored.
|
||||
- For best results, use a model with tool-calling support.
|
||||
@@ -0,0 +1,321 @@
|
||||
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
|
||||
@@ -0,0 +1,86 @@
|
||||
id = "alexander/mimir"
|
||||
name = "Mimir"
|
||||
version = "0.4.0"
|
||||
plugin_api = 16
|
||||
author = "Alexander"
|
||||
license = "MIT"
|
||||
icon = "brain"
|
||||
description = "An AI companion for Noctalia that brings LLM-powered chat directly into your desktop."
|
||||
dependencies = []
|
||||
tags = ["ai", "utility", "productivity", "development"]
|
||||
|
||||
[[widget]]
|
||||
id = "status"
|
||||
entry = "widget.luau"
|
||||
|
||||
[widget.actions]
|
||||
left = "panel-toggle alexander/mimir:chat"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "glyph"
|
||||
type = "glyph"
|
||||
label_key = "settings.glyph.label"
|
||||
default = "brain"
|
||||
|
||||
[[panel]]
|
||||
id = "chat"
|
||||
entry = "panel.luau"
|
||||
placement = "floating"
|
||||
position = "center_right"
|
||||
width = 450
|
||||
height = "fill"
|
||||
|
||||
[[service]]
|
||||
id = "brain"
|
||||
entry = "service.luau"
|
||||
|
||||
[[setting]]
|
||||
key = "api_endpoint"
|
||||
type = "string"
|
||||
label_key = "settings.api_endpoint.label"
|
||||
description_key = "settings.api_endpoint.description"
|
||||
default = "https://opencode.ai/zen/go/v1"
|
||||
|
||||
[[setting]]
|
||||
key = "api_key"
|
||||
type = "string"
|
||||
label_key = "settings.api_key.label"
|
||||
description_key = "settings.api_key.description"
|
||||
default = ""
|
||||
advanced = true
|
||||
|
||||
[[setting]]
|
||||
key = "tool_permission"
|
||||
type = "select"
|
||||
label_key = "settings.tool_permission.label"
|
||||
description_key = "settings.tool_permission.description"
|
||||
options = [
|
||||
{ value = "ask", label_key = "settings.tool_permission.ask" },
|
||||
{ value = "allow", label_key = "settings.tool_permission.allow" },
|
||||
{ value = "off", label_key = "settings.tool_permission.off" },
|
||||
]
|
||||
default = "ask"
|
||||
|
||||
[[setting]]
|
||||
key = "tool_blocklist"
|
||||
type = "string"
|
||||
label_key = "settings.tool_blocklist.label"
|
||||
description_key = "settings.tool_blocklist.description"
|
||||
default = "sudo,su,passwd,rm,mv,shred,truncate,tee,install,chown,chmod,mount,umount,dd,mkfs,fsck,reboot,shutdown,poweroff,init,halt,curl,wget,nc,ncat,socat,env,find,xargs,sh,bash,zsh,fish,python,python3,perl,ruby,node,lua,luau,eval,source,busybox,make,cmake,just,task"
|
||||
|
||||
[[setting]]
|
||||
key = "show_commands"
|
||||
type = "bool"
|
||||
label_key = "settings.show_commands.label"
|
||||
description_key = "settings.show_commands.description"
|
||||
default = true
|
||||
|
||||
[[setting]]
|
||||
key = "max_history"
|
||||
type = "int"
|
||||
label_key = "settings.max_history.label"
|
||||
description_key = "settings.max_history.description"
|
||||
default = 50
|
||||
min = 1
|
||||
max = 500
|
||||
advanced = true
|
||||
@@ -0,0 +1,420 @@
|
||||
local M = {}
|
||||
M.conversation = {}
|
||||
M.pendingResponses = {}
|
||||
M.pendingToolResults = {}
|
||||
M.pendingToolCalls = {}
|
||||
M.pendingApproval = nil
|
||||
M.toolBatchActive = false
|
||||
M.commandLog = {}
|
||||
|
||||
local TOOLS = {
|
||||
{
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = "run_command",
|
||||
description = "Run a shell command on the user's system. Prefer the simplest standard utility that works (ls, find, grep, cat); fall back to a small script only when no utility fits",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
command = {
|
||||
type = "string",
|
||||
description = "The shell command to run",
|
||||
},
|
||||
description = {
|
||||
type = "string",
|
||||
description = "Brief description of what this command does",
|
||||
},
|
||||
},
|
||||
required = { "command", "description" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local function isTrustedOpenCodeEndpoint(endpoint)
|
||||
local scheme, authority = endpoint:match("^(%a[%w+.-]*)://([^/%?#]+)")
|
||||
if not scheme or not authority then return false end
|
||||
local host, port = authority:match("^([^:]+):(%d+)$")
|
||||
if not host then host = authority end
|
||||
if scheme:lower() ~= "https" or host:lower() ~= "opencode.ai" then return false end
|
||||
return port == nil or port == "443"
|
||||
end
|
||||
|
||||
local function loadApiKey()
|
||||
local key = noctalia.getConfig("api_key") or ""
|
||||
if key ~= "" then return key end
|
||||
local endpoint = noctalia.getConfig("api_endpoint") or "https://opencode.ai/zen/go/v1"
|
||||
if not isTrustedOpenCodeEndpoint(endpoint) then return "" end
|
||||
local ok, data = pcall(noctalia.readFile, noctalia.expandPath("~/.local/share/opencode/auth.json"))
|
||||
if ok and data then
|
||||
local parsed = noctalia.json.decode(data)
|
||||
if parsed and parsed["opencode-go"] and parsed["opencode-go"].key then
|
||||
return parsed["opencode-go"].key
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function loadConfig()
|
||||
return {
|
||||
endpoint = noctalia.getConfig("api_endpoint") or "https://opencode.ai/zen/go/v1",
|
||||
apiKey = loadApiKey(),
|
||||
toolPermission = noctalia.getConfig("tool_permission") or "ask",
|
||||
showCommands = noctalia.getConfig("show_commands") ~= false,
|
||||
toolBlocklist = noctalia.getConfig("tool_blocklist") or "sudo,su,passwd,rm,mv,shred,truncate,tee,install,chown,chmod,mount,umount,dd,mkfs,fsck,reboot,shutdown,poweroff,init,halt,curl,wget,nc,ncat,socat,env,find,xargs,sh,bash,zsh,fish,python,python3,perl,ruby,node,lua,luau,eval,source,busybox,make,cmake,just,task",
|
||||
maxHistory = noctalia.getConfig("max_history") or 50,
|
||||
}
|
||||
end
|
||||
|
||||
local function isBlocked(command)
|
||||
local trimmed = command:match("^%s*(.-)%s*$")
|
||||
if not trimmed then return false end
|
||||
if trimmed == "" then return false end
|
||||
|
||||
-- runAsync passes this string to /bin/sh -c, so do not allow shell syntax
|
||||
-- to combine commands or invoke a second interpreter.
|
||||
if trimmed:find("[;|&><`$\\\n\r]") then return true end
|
||||
|
||||
local first = trimmed:match("^(%S+)")
|
||||
if not first then return false end
|
||||
first = first:gsub("^['\"]", ""):gsub("['\"]$", "")
|
||||
first = first:match("([^/]+)$"):lower()
|
||||
local blocklist = loadConfig().toolBlocklist
|
||||
for item in blocklist:gmatch("[^,]+") do
|
||||
local b = item:match("^%s*(.-)%s*$")
|
||||
if b and b ~= "" and b:lower():match("([^/]+)$") == first then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function setStatus(s)
|
||||
M.status = s
|
||||
noctalia.state.set("mimir.status", s)
|
||||
end
|
||||
|
||||
local function addMessage(role, content, extra)
|
||||
local msg = { role = role, content = content or "" }
|
||||
if extra then
|
||||
for k, v in pairs(extra) do msg[k] = v end
|
||||
end
|
||||
table.insert(M.conversation, msg)
|
||||
local copy = {}
|
||||
for _, v in ipairs(M.conversation) do
|
||||
table.insert(copy, v)
|
||||
end
|
||||
noctalia.state.set("mimir.messages", copy)
|
||||
end
|
||||
|
||||
local function think(input)
|
||||
setStatus("thinking")
|
||||
local config = loadConfig()
|
||||
|
||||
if #M.conversation > config.maxHistory then
|
||||
local trimmed = {}
|
||||
for i = #M.conversation - config.maxHistory + 1, #M.conversation do
|
||||
table.insert(trimmed, M.conversation[i])
|
||||
end
|
||||
M.conversation = trimmed
|
||||
local copy = {}
|
||||
for _, m in ipairs(M.conversation) do table.insert(copy, m) end
|
||||
noctalia.state.set("mimir.messages", copy)
|
||||
end
|
||||
|
||||
local model = noctalia.state.get("mimir.model") or "deepseek-v4-flash"
|
||||
|
||||
local msgs = {
|
||||
{ role = "system", content = "You are Mimir, an AI assistant running directly on the user's desktop with shell access. Your job: accomplish real tasks by running shell commands through the run_command tool, then report what actually happened. Choose the simplest, fastest command for the job. For local files and system state, standard utilities are ideal: ls, find, grep, cat, which, df, ps, head, wc. When a standard utility exists, prefer it over writing scripts — one-liners are faster, clearer, and less likely to need approvals. Reach for a script (python, etc.) only when no simple utility covers the task. Use your own knowledge to answer questions and explain things; you do not need to fetch web pages. When the task needs the user's input or a choice, ask instead of guessing. Never just give instructions — run the command and show the result. The user has already authorized command execution through the approval flow, so do not hesitate to use tools for legitimate tasks." },
|
||||
}
|
||||
for _, m in ipairs(M.conversation) do
|
||||
table.insert(msgs, m)
|
||||
end
|
||||
if input then
|
||||
table.insert(msgs, { role = "user", content = input })
|
||||
end
|
||||
|
||||
local body = { model = model, messages = msgs }
|
||||
if config.toolPermission ~= "off" then
|
||||
body.tools = TOOLS
|
||||
end
|
||||
|
||||
local encoded = noctalia.json.encode(body)
|
||||
if not encoded then
|
||||
setStatus("idle")
|
||||
if input then addMessage("assistant", "Error: failed to encode request") end
|
||||
return
|
||||
end
|
||||
|
||||
local url = config.endpoint .. "/chat/completions"
|
||||
|
||||
local headers = { "Content-Type: application/json" }
|
||||
if config.apiKey ~= "" then
|
||||
table.insert(headers, "Authorization: Bearer " .. config.apiKey)
|
||||
end
|
||||
|
||||
noctalia.http({
|
||||
url = url,
|
||||
method = "POST",
|
||||
headers = headers,
|
||||
body = encoded,
|
||||
}, function(res)
|
||||
table.insert(M.pendingResponses, res)
|
||||
end)
|
||||
end
|
||||
|
||||
local function runCommand(tool_call, command)
|
||||
setStatus("running_tool")
|
||||
|
||||
noctalia.runAsync(command, function(result)
|
||||
local output = ""
|
||||
local parts = {}
|
||||
if result.stdout and result.stdout ~= "" then table.insert(parts, result.stdout) end
|
||||
if result.stderr and result.stderr ~= "" then table.insert(parts, result.stderr) end
|
||||
output = table.concat(parts, "\n")
|
||||
if output == "" then
|
||||
output = "(exit code " .. tostring(result.status) .. ", no output)"
|
||||
end
|
||||
local config = loadConfig()
|
||||
if config.showCommands then
|
||||
table.insert(M.commandLog, { tool_call_id = tool_call.id, command = command })
|
||||
if #M.commandLog > 50 then table.remove(M.commandLog, 1) end
|
||||
local logCopy = {}
|
||||
for _, entry in ipairs(M.commandLog) do table.insert(logCopy, entry) end
|
||||
noctalia.state.set("mimir.command_log", logCopy)
|
||||
end
|
||||
table.insert(M.pendingToolResults, { tool_call_id = tool_call.id, output = output })
|
||||
end)
|
||||
end
|
||||
|
||||
local function queueToolCalls(tool_calls)
|
||||
M.pendingToolCalls = {}
|
||||
M.pendingApproval = nil
|
||||
M.toolBatchActive = true
|
||||
for _, tc in ipairs(tool_calls) do
|
||||
if tc.type == "function" and tc["function"].name == "run_command" then
|
||||
local okArgs, args = pcall(noctalia.json.decode, tc["function"].arguments)
|
||||
table.insert(M.pendingToolCalls, {
|
||||
tc = tc,
|
||||
args = (okArgs and type(args) == "table") and args or nil,
|
||||
status = "waiting",
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function allToolCallsDone()
|
||||
for _, call in ipairs(M.pendingToolCalls) do
|
||||
if call.status ~= "done" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function finishToolBatch()
|
||||
if not M.toolBatchActive then return end
|
||||
if not allToolCallsDone() then return end
|
||||
M.toolBatchActive = false
|
||||
M.pendingToolCalls = {}
|
||||
M.pendingApproval = nil
|
||||
think(nil)
|
||||
end
|
||||
|
||||
local function processToolQueue()
|
||||
if #M.pendingToolCalls == 0 then
|
||||
M.toolBatchActive = false
|
||||
return
|
||||
end
|
||||
|
||||
for _, call in ipairs(M.pendingToolCalls) do
|
||||
if call.status == "running" then return end
|
||||
end
|
||||
|
||||
local config = loadConfig()
|
||||
|
||||
for _, call in ipairs(M.pendingToolCalls) do
|
||||
if call.status == "waiting" then
|
||||
if call.args == nil or type(call.args.command) ~= "string" or call.args.command == "" then
|
||||
addMessage("tool", "Error: invalid tool arguments", { tool_call_id = call.tc.id })
|
||||
call.status = "done"
|
||||
elseif isBlocked(call.args.command) then
|
||||
addMessage("tool", "Command blocked by user settings: " .. call.args.command, { tool_call_id = call.tc.id })
|
||||
call.status = "done"
|
||||
elseif config.toolPermission == "off" then
|
||||
addMessage("tool", "Tools are disabled in settings. Enable them to run commands.", { tool_call_id = call.tc.id })
|
||||
call.status = "done"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local remaining = {}
|
||||
for _, call in ipairs(M.pendingToolCalls) do
|
||||
if call.status == "waiting" then table.insert(remaining, call) end
|
||||
end
|
||||
|
||||
if config.toolPermission == "ask" and #remaining > 0 then
|
||||
M.pendingApproval = {}
|
||||
local commands = {}
|
||||
for _, call in ipairs(remaining) do
|
||||
table.insert(M.pendingApproval, { id = call.tc.id, command = call.args.command, description = call.args.description or "" })
|
||||
table.insert(commands, { command = call.args.command, description = call.args.description or "" })
|
||||
end
|
||||
noctalia.state.set("mimir.pending_tool", { commands = commands })
|
||||
setStatus("idle")
|
||||
return
|
||||
end
|
||||
|
||||
if config.toolPermission == "allow" then
|
||||
for _, call in ipairs(remaining) do
|
||||
call.status = "running"
|
||||
runCommand(call.tc, call.args.command)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
finishToolBatch()
|
||||
end
|
||||
|
||||
function update()
|
||||
for i, res in ipairs(M.pendingResponses) do
|
||||
if M.toolBatchActive then break end
|
||||
M.pendingResponses[i] = nil
|
||||
|
||||
if not res.ok then
|
||||
local err = res.body or "no body"
|
||||
if #err > 200 then err = err:sub(1, 200) .. "..." end
|
||||
setStatus("idle")
|
||||
addMessage("assistant", "HTTP " .. tostring(res.status) .. ": " .. err)
|
||||
return
|
||||
end
|
||||
|
||||
local data = noctalia.json.decode(res.body)
|
||||
if not data or not data.choices then
|
||||
local snippet = (res.body or ""):sub(1, 200)
|
||||
setStatus("idle")
|
||||
addMessage("assistant", "Invalid API response: " .. snippet)
|
||||
return
|
||||
end
|
||||
|
||||
local choice = data.choices[1]
|
||||
local message = choice.message
|
||||
|
||||
if choice.finish_reason == "tool_calls" and message.tool_calls then
|
||||
local clean = {}
|
||||
for _, tc in ipairs(message.tool_calls) do
|
||||
local c = { id = tc.id, type = tc.type, ["function"] = tc["function"] }
|
||||
table.insert(clean, c)
|
||||
end
|
||||
addMessage("assistant", message.content, { tool_calls = clean })
|
||||
queueToolCalls(message.tool_calls)
|
||||
processToolQueue()
|
||||
else
|
||||
local content = message.content or ""
|
||||
if content ~= "" then
|
||||
addMessage("assistant", content)
|
||||
end
|
||||
setStatus("idle")
|
||||
end
|
||||
end
|
||||
|
||||
for i, result in ipairs(M.pendingToolResults) do
|
||||
M.pendingToolResults[i] = nil
|
||||
addMessage("tool", result.output, { tool_call_id = result.tool_call_id })
|
||||
for _, call in ipairs(M.pendingToolCalls) do
|
||||
if call.tc.id == result.tool_call_id then
|
||||
call.status = "done"
|
||||
end
|
||||
end
|
||||
end
|
||||
finishToolBatch()
|
||||
|
||||
local approval = M.pendingApproval
|
||||
if approval then
|
||||
local approved = noctalia.state.get("mimir.tool_approved")
|
||||
if approved then
|
||||
noctalia.state.set("mimir.tool_approved", false)
|
||||
noctalia.state.set("mimir.pending_tool", false)
|
||||
M.pendingApproval = nil
|
||||
for _, call in ipairs(M.pendingToolCalls) do
|
||||
if call.status == "waiting" then
|
||||
call.status = "running"
|
||||
runCommand(call.tc, call.args.command)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local denied = noctalia.state.get("mimir.tool_denied")
|
||||
if denied then
|
||||
noctalia.state.set("mimir.tool_denied", false)
|
||||
noctalia.state.set("mimir.pending_tool", false)
|
||||
M.pendingApproval = nil
|
||||
for _, call in ipairs(M.pendingToolCalls) do
|
||||
if call.status == "waiting" then
|
||||
addMessage("tool", "Command was cancelled by the user.", { tool_call_id = call.tc.id })
|
||||
call.status = "done"
|
||||
end
|
||||
end
|
||||
finishToolBatch()
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.state.watch("mimir.input", function(input)
|
||||
if input and input ~= "" then
|
||||
if M.pendingApproval then
|
||||
addMessage("assistant", "Please approve or deny the pending command first.")
|
||||
return
|
||||
end
|
||||
if M.toolBatchActive then
|
||||
addMessage("assistant", "Please wait for the running commands to finish.")
|
||||
return
|
||||
end
|
||||
addMessage("user", input)
|
||||
think(nil)
|
||||
end
|
||||
end)
|
||||
|
||||
noctalia.state.watch("mimir.clear", function(v)
|
||||
if v then
|
||||
M.conversation = {}
|
||||
M.pendingToolCalls = {}
|
||||
M.pendingApproval = nil
|
||||
M.toolBatchActive = false
|
||||
M.commandLog = {}
|
||||
noctalia.state.set("mimir.messages", {})
|
||||
noctalia.state.set("mimir.command_log", {})
|
||||
noctalia.state.set("mimir.pending_tool", false)
|
||||
setStatus("idle")
|
||||
end
|
||||
end)
|
||||
|
||||
local function fetchModels()
|
||||
local config = loadConfig()
|
||||
if config.endpoint == "" then return end
|
||||
local url = config.endpoint .. "/models"
|
||||
noctalia.http({ url = url }, function(res)
|
||||
if not res.ok then return end
|
||||
local data = noctalia.json.decode(res.body)
|
||||
if not data or not data.data then return end
|
||||
local models = {}
|
||||
for _, m in ipairs(data.data) do
|
||||
if m.id then table.insert(models, m.id) end
|
||||
end
|
||||
noctalia.state.set("mimir.models", models)
|
||||
end)
|
||||
end
|
||||
|
||||
noctalia.state.watch("mimir.refresh_models", function(v)
|
||||
if v then fetchModels() noctalia.state.set("mimir.refresh_models", false) end
|
||||
end)
|
||||
|
||||
noctalia.setUpdateInterval(500)
|
||||
|
||||
fetchModels()
|
||||
if noctalia.state.get("mimir.model") == nil then
|
||||
noctalia.state.set("mimir.model", "deepseek-v4-flash")
|
||||
end
|
||||
noctalia.state.set("mimir.status", "idle")
|
||||
noctalia.state.set("mimir.messages", {})
|
||||
noctalia.state.set("mimir.command_log", {})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"title": "Mimir",
|
||||
"tooltip": "Mimir AI — {status}",
|
||||
"input_placeholder": "Ask me anything...",
|
||||
"send": "Send",
|
||||
"clear": "Clear",
|
||||
"status_idle": "Idle",
|
||||
"status_thinking": "Thinking...",
|
||||
"status_tool": "Running command...",
|
||||
"status_error": "Error",
|
||||
"status_online": "Online",
|
||||
"status_offline": "Offline",
|
||||
"settings": {
|
||||
"glyph": { "label": "Bar Icon" },
|
||||
"model_name": {
|
||||
"label": "Model",
|
||||
"description": "OpenCode Go model to use",
|
||||
"deepseek_flash": "DeepSeek V4 Flash (fast, cheap)",
|
||||
"deepseek_pro": "DeepSeek V4 Pro (powerful)",
|
||||
"grok45": "Grok 4.5",
|
||||
"kimi_code": "Kimi K2.7 Code",
|
||||
"glm52": "GLM-5.2"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"label": "API Endpoint",
|
||||
"description": "Base URL for the API (default Ollama uses http://localhost:11434/v1)"
|
||||
},
|
||||
"api_key": {
|
||||
"label": "API Key",
|
||||
"description": "API key for hosted providers (stored locally and never shared)"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Thinking Mode",
|
||||
"description": "How deeply the AI thinks before responding",
|
||||
"fast": "Fast — Quick answers, minimal tools",
|
||||
"normal": "Normal — Balanced reasoning and tools",
|
||||
"deep": "Deep — Exhaustive search, multi-step reasoning"
|
||||
},
|
||||
"tool_permission": {
|
||||
"label": "Tool Permission",
|
||||
"description": "When the AI can run terminal commands",
|
||||
"ask": "Ask — Prompt before every command",
|
||||
"allow": "Allow — Run automatically",
|
||||
"off": "Off — No tools"
|
||||
},
|
||||
"tool_blocklist": {
|
||||
"label": "Blocked Commands",
|
||||
"description": "Comma-separated commands never allowed"
|
||||
},
|
||||
"show_commands": {
|
||||
"label": "Show Commands",
|
||||
"description": "Show executed commands in the chat"
|
||||
},
|
||||
"max_history": {
|
||||
"label": "Max History",
|
||||
"description": "Number of messages to keep in conversation context"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
local status = noctalia.state.get("mimir.status") or "idle"
|
||||
|
||||
local glyphs = {
|
||||
idle = "brain",
|
||||
thinking = "loader",
|
||||
tool = "terminal-2",
|
||||
error = "alert-circle",
|
||||
}
|
||||
|
||||
local colors = {
|
||||
idle = "on_surface",
|
||||
thinking = "primary",
|
||||
tool = "tertiary",
|
||||
error = "error",
|
||||
}
|
||||
|
||||
function update()
|
||||
status = noctalia.state.get("mimir.status") or "idle"
|
||||
barWidget.setGlyph(glyphs[status] or "brain")
|
||||
barWidget.setGlyphColor(colors[status] or "on_surface")
|
||||
local tooltip = status == "idle" and "Mimir — Ready" or "Mimir — " .. status
|
||||
barWidget.setTooltip(tooltip)
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel("alexander/mimir:chat")
|
||||
end
|
||||
|
||||
noctalia.state.watch("mimir.status", function(newStatus)
|
||||
status = newStatus or "idle"
|
||||
barWidget.setGlyph(glyphs[status] or "brain")
|
||||
barWidget.setGlyphColor(colors[status] or "on_surface")
|
||||
local tooltip = status == "idle" and "Mimir — Ready" or "Mimir — " .. status
|
||||
barWidget.setTooltip(tooltip)
|
||||
end)
|
||||
Reference in New Issue
Block a user