Compare commits
10
Commits
e9121c114c
...
1e08459260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e08459260 | ||
|
|
b97a74174d | ||
|
|
168861dbb3 | ||
|
|
3785668387 | ||
|
|
b75eb1cb33 | ||
|
|
335017f1e8 | ||
|
|
691398d9da | ||
|
|
a4ae9c8ab4 | ||
|
|
27458632e2 | ||
|
|
6fcf69f738 |
@@ -1,6 +1,6 @@
|
||||
id = "yocraft/battery-widget"
|
||||
name = "Battery Widget"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
plugin_api = 3
|
||||
author = "yocraft"
|
||||
license = "MIT"
|
||||
|
||||
@@ -3,7 +3,7 @@ local color
|
||||
local data = { percent = 0, status = "unknown" }
|
||||
|
||||
local label_content = noctalia.getConfig("label_content")
|
||||
local warning_threshold = 20
|
||||
local warning_threshold = 10
|
||||
|
||||
local path = "/sys/class/power_supply/"
|
||||
local batName
|
||||
@@ -108,7 +108,6 @@ local function getWarningThreshold()
|
||||
function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
noctalia.log("battery-widget: failed to get warning_threshold: " .. result.stderr)
|
||||
notifyError()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -116,7 +115,6 @@ local function getWarningThreshold()
|
||||
|
||||
if not res then
|
||||
noctalia.log("battery-widget: invalid warning_threshold, keeping default")
|
||||
notifyError()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -125,7 +123,6 @@ local function getWarningThreshold()
|
||||
warning_threshold = parsed
|
||||
else
|
||||
noctalia.log("battery-widget: invalid warning_threshold, keeping default")
|
||||
notifyError()
|
||||
end
|
||||
end
|
||||
)
|
||||
@@ -227,11 +224,13 @@ local function initData()
|
||||
function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
noctalia.log("battery-widget: failed to read battery: " .. result.stderr)
|
||||
notifyError()
|
||||
return
|
||||
end
|
||||
|
||||
if not result.stdout then
|
||||
noctalia.log("battery-widget: invalid battery output")
|
||||
notifyError()
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
+18
-12
@@ -755,15 +755,15 @@ tags = ["hardware", "utility", "desktop", "panel"]
|
||||
[[plugin]]
|
||||
id = "cleboost/hotspot"
|
||||
name = "Wi-Fi Hotspot"
|
||||
version = "1.0.0"
|
||||
updated_at = 1785174978
|
||||
version = "1.1.0"
|
||||
updated_at = 1786612400
|
||||
added_at = 1785174978
|
||||
author = "Cleboost"
|
||||
license = "MIT"
|
||||
icon = "router"
|
||||
description = "Start and stop a Wi-Fi hotspot from the bar, with connected device list and IPC control."
|
||||
description = "Start and stop a Wi-Fi hotspot from the bar or Control Center, with connected device list and IPC control."
|
||||
plugin_api = 9
|
||||
tags = ["network", "bar", "panel", "service", "utility", "system", "indicator"]
|
||||
tags = ["network", "bar", "panel", "service", "shortcut", "utility", "system", "indicator"]
|
||||
|
||||
[[plugin]]
|
||||
id = "yuuto/arch-updater"
|
||||
@@ -926,15 +926,15 @@ tags = ["launcher", "development", "productivity"]
|
||||
[[plugin]]
|
||||
id = "yocraft/battery-widget"
|
||||
name = "Battery Widget"
|
||||
version = "1.0.1"
|
||||
updated_at = 1785684850
|
||||
version = "2.0.1"
|
||||
updated_at = 1786589080
|
||||
added_at = 1785554104
|
||||
author = "yocraft"
|
||||
license = "MIT"
|
||||
icon = "battery"
|
||||
description = "Desktop/Lockscreen widget to show battery."
|
||||
deprecated = false
|
||||
plugin_api = 19
|
||||
plugin_api = 3
|
||||
tags = ["desktop", "hardware", "system", "utility"]
|
||||
|
||||
[[plugin]]
|
||||
@@ -1210,8 +1210,8 @@ tags = ["ai", "productivity", "development", "network", "bar", "panel"]
|
||||
[[plugin]]
|
||||
id = "weinguyen/procmon"
|
||||
name = "Process Monitor"
|
||||
version = "0.4.0"
|
||||
updated_at = 1786298101
|
||||
version = "0.5.0"
|
||||
updated_at = 1786588915
|
||||
added_at = 1786218032
|
||||
author = "weinguyen"
|
||||
license = "MIT"
|
||||
@@ -1294,16 +1294,22 @@ tags = ["network", "utility", "bar", "panel", "service", "launcher"]
|
||||
[[plugin]]
|
||||
id = "weinguyen/opencode-companion"
|
||||
name = "OpenCode Companion"
|
||||
version = "0.1.0"
|
||||
updated_at = 1786297431
|
||||
version = "0.2.0"
|
||||
updated_at = 1786542553
|
||||
added_at = 1786297431
|
||||
author = "weinguyen"
|
||||
license = "MIT"
|
||||
icon = "code-circle"
|
||||
description = "Integrate OpenCode AI agent directly into Noctalia bar with native chat panel, session management, and MCP status."
|
||||
plugin_api = 3
|
||||
plugin_api = 21
|
||||
tags = ["ai", "productivity", "development", "bar", "panel"]
|
||||
|
||||
[[plugin.release]]
|
||||
plugin_api = 3
|
||||
version = "0.1.0"
|
||||
updated_at = 1786297431
|
||||
rev = "e77d7ef7fbfa7d909e03966e42690a8561f1b190"
|
||||
|
||||
[[plugin]]
|
||||
id = "nilsonlinux/link-ip-monitor"
|
||||
name = "Link/IP Monitor"
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@ devices in a panel, and control the service over IPC.
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `cleboost/hotspot` |
|
||||
| Entries | Bar widget: `toggle`; panel: `panel`; service: `hotspot` |
|
||||
| Entries | Bar widget: `toggle`; shortcut: `cc-toggle`; panel: `panel`; service: `hotspot` |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -19,9 +19,9 @@ Install `nmcli` from NetworkManager, plus `iw` and `ip` from iproute2, on
|
||||
## Usage
|
||||
|
||||
1. Open the plugin settings and set the hotspot name and password.
|
||||
2. Add the `toggle` widget to a bar.
|
||||
3. Left-click the widget to open the panel with the connected device list.
|
||||
4. Right-click the widget to start or stop the hotspot.
|
||||
2. Add the `toggle` widget to a bar and/or the `cc-toggle` shortcut in Settings → Control Center.
|
||||
3. Left-click the bar widget to open the panel with the connected device list.
|
||||
4. Right-click the bar widget, or click the Control Center shortcut, to start or stop the hotspot.
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle cleboost/hotspot:panel
|
||||
|
||||
+7
-3
@@ -1,12 +1,12 @@
|
||||
id = "cleboost/hotspot"
|
||||
name = "Wi-Fi Hotspot"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
plugin_api = 9
|
||||
author = "Cleboost"
|
||||
license = "MIT"
|
||||
icon = "router"
|
||||
description = "Start and stop a Wi-Fi hotspot from the bar, with connected device list and IPC control."
|
||||
tags = ["network", "bar", "panel", "service", "utility", "system", "indicator"]
|
||||
description = "Start and stop a Wi-Fi hotspot from the bar or Control Center, with connected device list and IPC control."
|
||||
tags = ["network", "bar", "panel", "service", "shortcut", "utility", "system", "indicator"]
|
||||
dependencies = ["nmcli", "iw", "ip"]
|
||||
|
||||
[[setting]]
|
||||
@@ -53,6 +53,10 @@ entry = "widget.luau"
|
||||
label_key = "settings.glyph.label"
|
||||
default = "router"
|
||||
|
||||
[[shortcut]]
|
||||
id = "cc-toggle"
|
||||
entry = "shortcut.luau"
|
||||
|
||||
[[panel]]
|
||||
id = "panel"
|
||||
entry = "panel.luau"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
--!nonstrict
|
||||
|
||||
local COMMAND_KEY = "hotspot_command"
|
||||
|
||||
local status = noctalia.state.get("hotspot_status") or {
|
||||
active = false,
|
||||
busy = false,
|
||||
available = true,
|
||||
}
|
||||
|
||||
local function sendCommand(action)
|
||||
noctalia.state.set(COMMAND_KEY, { action = action })
|
||||
end
|
||||
|
||||
local function render()
|
||||
local active = status.active == true
|
||||
shortcut.setLabel(if active then noctalia.tr("shortcut.active") else noctalia.tr("shortcut.inactive"))
|
||||
shortcut.setIcon("router", "wifi-off")
|
||||
shortcut.setActive(active)
|
||||
shortcut.setEnabled(status.available ~= false and status.busy ~= true)
|
||||
end
|
||||
|
||||
noctalia.state.watch("hotspot_status", function(value)
|
||||
if type(value) == "table" then
|
||||
status = value
|
||||
render()
|
||||
end
|
||||
end)
|
||||
|
||||
render()
|
||||
|
||||
function onClick()
|
||||
if status.available == false or status.busy == true then return end
|
||||
sendCommand("toggle")
|
||||
end
|
||||
@@ -44,6 +44,10 @@
|
||||
"stopping_title": "Stopping"
|
||||
}
|
||||
},
|
||||
"shortcut": {
|
||||
"active": "Hotspot on",
|
||||
"inactive": "Hotspot off"
|
||||
},
|
||||
"settings": {
|
||||
"glyph": {
|
||||
"label": "Bar glyph"
|
||||
|
||||
@@ -90,7 +90,7 @@ noctalia msg plugin weinguyen/opencode-companion:service all create_session
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| -------------------- | ------ | ------------- | --------------------------------------------------------------- |
|
||||
| ------------------- | ------ | ------------- | --------------------------------------------------------------- |
|
||||
| `server_mode` | string | `"auto"` | `"auto"` manages a local server; `"external"` connects to a URL |
|
||||
| `server_host` | string | `"127.0.0.1"` | Hostname the managed server binds to (loopback only) |
|
||||
| `server_port` | double | `4096` | Port the managed server listens on |
|
||||
@@ -101,7 +101,6 @@ noctalia msg plugin weinguyen/opencode-companion:service all create_session
|
||||
| `auto_start` | bool | `true` | Auto-start the managed server |
|
||||
| `show_tool_calls` | bool | `true` | Show tool call status cards |
|
||||
| `show_reasoning` | bool | `false` | Show reasoning/thinking text |
|
||||
| `notify_on_complete` | bool | `true` | Notify when a response completes |
|
||||
| `max_messages_load` | double | `50` | Max messages to load per session |
|
||||
| `debug_logging` | bool | `false` | Print debug messages |
|
||||
|
||||
|
||||
+107
-31
@@ -43,7 +43,6 @@ local session_query = ""
|
||||
local question_choices = {}
|
||||
|
||||
local SVC = "weinguyen/opencode-companion:service"
|
||||
local PANEL_ID = "weinguyen/opencode-companion:panel"
|
||||
|
||||
-- Layout constants (recomputed from the ui_mode setting on every render).
|
||||
local compact = false
|
||||
@@ -94,6 +93,14 @@ local draft = ""
|
||||
-- (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
|
||||
@@ -176,17 +183,8 @@ local function tr(key, args)
|
||||
return str
|
||||
end
|
||||
|
||||
-- Fingerprint a value to detect real changes (avoid re-render storms)
|
||||
local function fp(v)
|
||||
if type(v) ~= "table" then return tostring(v) end
|
||||
local parts = {}
|
||||
for k, val in pairs(v) do
|
||||
parts[#parts + 1] = tostring(k) .. "=" .. tostring(val)
|
||||
end
|
||||
table.sort(parts)
|
||||
return table.concat(parts, "\1")
|
||||
end
|
||||
|
||||
-- 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 = {}
|
||||
@@ -272,9 +270,7 @@ local function dispatch_ipc(event, payload)
|
||||
noctalia.runAsync(cmd)
|
||||
end
|
||||
|
||||
-- MCP status footer: colored server names, one per line. Returns nil when
|
||||
-- there's nothing meaningful to show. Kept intentionally minimal — the
|
||||
-- MCP status footer: a single collapsible toggle row (chevron + title +
|
||||
-- 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 = {
|
||||
@@ -523,6 +519,33 @@ 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
|
||||
@@ -549,12 +572,41 @@ local function render_message(msg_data)
|
||||
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")
|
||||
@@ -986,12 +1038,12 @@ local function render_chat(active, messages, permissions, questions, conn)
|
||||
end
|
||||
end
|
||||
|
||||
-- Message list, newest-first. Newest at the top so the panel always opens on
|
||||
-- the latest message without needing a scroll API (shell < API 21 resets a
|
||||
-- re-mounted scroll to offset 0 = top). Scrolling down reveals older
|
||||
-- messages. The scroll node carries a stable `key` so its offset survives
|
||||
-- re-renders even when siblings above it appear/disappear. Children are
|
||||
-- passed directly to the scroll (not wrapped in a column).
|
||||
-- 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 })
|
||||
@@ -1019,22 +1071,35 @@ local function render_chat(active, messages, permissions, questions, conn)
|
||||
-- disappears once the assistant's first text part streams in.
|
||||
local waiting = is_processing()
|
||||
and (not newest_is_assistant or not newest_has_text)
|
||||
-- Newest-first: build the list from the end so bubble order is latest
|
||||
-- first, oldest last.
|
||||
for i = #messages, 1, -1 do
|
||||
-- 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 ABOVE the newest message (top of the
|
||||
-- newest-first list), like a spinner over the reply in progress.
|
||||
-- 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
|
||||
table.insert(msg_items, 1, render_thinking())
|
||||
msg_items[#msg_items + 1] = render_thinking()
|
||||
end
|
||||
end
|
||||
|
||||
rows[#rows + 1] = ui.scroll({ key = "chat_messages", flexGrow = 1, gap = GAP }, msg_items)
|
||||
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()
|
||||
@@ -1066,13 +1131,13 @@ local function render_chat(active, messages, permissions, questions, conn)
|
||||
value = draft,
|
||||
placeholder = tr("chat.placeholder"),
|
||||
multiline = true,
|
||||
submitOnEnter = true,
|
||||
flexGrow = 1,
|
||||
onChange = function(text)
|
||||
draft = text
|
||||
end,
|
||||
-- Multiline: Enter inserts a newline; Ctrl+Enter submits (send). This
|
||||
-- is the input control's built-in mapping and cannot distinguish
|
||||
-- Shift+Enter, so Ctrl+Enter is the send chord.
|
||||
-- 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,
|
||||
@@ -1106,6 +1171,14 @@ end
|
||||
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 {}
|
||||
@@ -1160,6 +1233,9 @@ end
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "weinguyen/opencode-companion"
|
||||
name = "OpenCode Companion"
|
||||
version = "0.1.0"
|
||||
plugin_api = 3
|
||||
version = "0.2.0"
|
||||
plugin_api = 21
|
||||
author = "weinguyen"
|
||||
license = "MIT"
|
||||
icon = "code-circle"
|
||||
@@ -80,13 +80,6 @@ default = false
|
||||
label_key = "settings.show_reasoning.label"
|
||||
description_key = "settings.show_reasoning.description"
|
||||
|
||||
[[setting]]
|
||||
key = "notify_on_complete"
|
||||
type = "bool"
|
||||
default = true
|
||||
label_key = "settings.notify_on_complete.label"
|
||||
description_key = "settings.notify_on_complete.description"
|
||||
|
||||
# Integer slider: 1..100 caps loaded history; the top notch (101) means
|
||||
# "unlimited" (service.luau treats >= 101 as an effectively unbounded limit).
|
||||
[[setting]]
|
||||
|
||||
@@ -151,9 +151,7 @@ local connection = {
|
||||
server_version = nil,
|
||||
}
|
||||
|
||||
-- Server process info (managed mode only)
|
||||
local managed_pid = nil
|
||||
local managed_port = nil
|
||||
|
||||
|
||||
-- Active session tracking
|
||||
local active_session = nil
|
||||
@@ -172,6 +170,9 @@ local last_error = nil
|
||||
|
||||
-- Optimistic user message appended on send, replaced once the server echoes it.
|
||||
local optimistic_msg = nil
|
||||
-- Monotonic counter so two sends within the same second still get distinct
|
||||
-- optimistic ids (load_messages dedups on message id).
|
||||
local optimistic_seq = 0
|
||||
|
||||
-- When the active session went busy (os.time seconds). Used to auto-recover a
|
||||
-- stale busy status if the server never emits idle/error (dropped SSE event),
|
||||
@@ -366,6 +367,12 @@ local function publish()
|
||||
if dirty.mcp_status or dirty.all then
|
||||
noctalia.state.set("opencode.mcp_status", mcp_status)
|
||||
end
|
||||
if dirty.providers or dirty.all then
|
||||
noctalia.state.set("opencode.providers", providers)
|
||||
end
|
||||
if dirty.agents or dirty.all then
|
||||
noctalia.state.set("opencode.agents", agents)
|
||||
end
|
||||
if dirty.pending_permissions or dirty.all then
|
||||
noctalia.state.set("opencode.pending_permissions", pending_permissions)
|
||||
end
|
||||
@@ -400,6 +407,17 @@ local function set_connection_status(status, err)
|
||||
publish()
|
||||
end
|
||||
|
||||
-- After answering/dismissing a permission or question, return to "online" only
|
||||
-- when nothing is left waiting; otherwise keep "waiting_permission". The widget
|
||||
-- glyph must not flip to online while other cards still await user input.
|
||||
local function refresh_waiting_status()
|
||||
if #pending_permissions > 0 or #pending_questions > 0 then
|
||||
set_connection_status("waiting_permission")
|
||||
else
|
||||
set_connection_status("online")
|
||||
end
|
||||
end
|
||||
|
||||
local function set_last_error(message, detail)
|
||||
last_error = { message = message, detail = detail }
|
||||
mark_dirty("last_error")
|
||||
@@ -429,7 +447,6 @@ local function start_managed_server()
|
||||
local ok, data = pcall(noctalia.json.decode, resp.body)
|
||||
if ok and data.healthy then
|
||||
connection.server_version = data.version
|
||||
managed_port = port
|
||||
set_connection_status("online")
|
||||
debug_log("Connected to existing server at " .. host .. ":" .. port)
|
||||
load_initial_data()
|
||||
@@ -482,8 +499,6 @@ local function start_managed_server()
|
||||
local ok2, data2 = pcall(noctalia.json.decode, resp2.body)
|
||||
if ok2 and data2.healthy then
|
||||
connection.server_version = data2.version
|
||||
managed_port = port
|
||||
managed_pid = true -- we spawned it
|
||||
set_connection_status("online")
|
||||
debug_log("Server started successfully")
|
||||
load_initial_data()
|
||||
@@ -771,10 +786,14 @@ end
|
||||
-- ── session management ───────────────────────────────────────────────────────
|
||||
|
||||
local function set_active_session(session)
|
||||
-- Drop any in-flight optimistic bubble unless we're re-selecting the very
|
||||
-- same session. Switching sessions (or deselecting) with a pending user
|
||||
-- message would otherwise bleed that bubble into the next session's list.
|
||||
local same_session = session and active_session and session.id == active_session.id
|
||||
active_session = session
|
||||
messages = {}
|
||||
last_messages_body = nil -- force a fresh decode for the new session
|
||||
if not session then
|
||||
if not same_session then
|
||||
optimistic_msg = nil
|
||||
end
|
||||
mark_dirty("active_session")
|
||||
@@ -896,12 +915,20 @@ local function send_prompt(session_id, text, model, agent)
|
||||
if not safe_id(session_id) then return end
|
||||
if type(text) ~= "string" or text == "" then return end
|
||||
|
||||
-- The user is actively sending in the panel: clear any unread responses
|
||||
-- from a previous turn so the badge counts only this turn's replies.
|
||||
if unread_count ~= 0 then
|
||||
unread_count = 0
|
||||
mark_dirty("unread_count")
|
||||
end
|
||||
|
||||
-- Optimistically append the user message so it appears immediately on the
|
||||
-- right. It is replaced by the server's copy once echoed (see load_messages).
|
||||
optimistic_seq = optimistic_seq + 1
|
||||
local now_ms = os.time() * 1000
|
||||
optimistic_msg = {
|
||||
info = {
|
||||
id = "local-" .. tostring(now_ms),
|
||||
id = "local-" .. tostring(now_ms) .. "-" .. tostring(optimistic_seq),
|
||||
role = "user",
|
||||
sessionID = session_id,
|
||||
time = { created = now_ms },
|
||||
@@ -968,7 +995,7 @@ local function respond_to_permission(session_id, permission_id, response, rememb
|
||||
end
|
||||
end
|
||||
mark_dirty("pending_permissions")
|
||||
set_connection_status("online")
|
||||
refresh_waiting_status()
|
||||
publish()
|
||||
else
|
||||
set_last_error(tr("error.permission_failed"), "HTTP " .. tostring(resp.status))
|
||||
@@ -991,7 +1018,7 @@ local function respond_to_question(request_id, answers)
|
||||
end
|
||||
end
|
||||
mark_dirty("pending_questions")
|
||||
set_connection_status("online")
|
||||
refresh_waiting_status()
|
||||
publish()
|
||||
else
|
||||
set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status))
|
||||
@@ -1010,7 +1037,7 @@ local function reject_question(request_id)
|
||||
end
|
||||
end
|
||||
mark_dirty("pending_questions")
|
||||
set_connection_status("online")
|
||||
refresh_waiting_status()
|
||||
publish()
|
||||
else
|
||||
set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status))
|
||||
@@ -1026,6 +1053,11 @@ local SSE = {}
|
||||
last_messages_reload = 0
|
||||
RELOAD_THROTTLE_S = 0.3
|
||||
|
||||
-- Throttle for session-list reloads (session.updated/session.diff fire once
|
||||
-- or twice per reply; the list fetch is heavier than the message one).
|
||||
last_sessions_reload = 0
|
||||
SESSIONS_RELOAD_THROTTLE_S = 1
|
||||
|
||||
-- Dedup set for unread counting (prevents inflating count on every part update)
|
||||
local counted_messages = {}
|
||||
local COUNTED_MAX = 100
|
||||
@@ -1034,8 +1066,12 @@ local COUNTED_MAX = 100
|
||||
local function parse_sse_line(line)
|
||||
if line == nil then return nil, nil end
|
||||
if line == "" then return nil, nil end
|
||||
if line:sub(1, 6) == "data: " then
|
||||
local json_str = line:sub(7)
|
||||
if line:sub(1, 5) == "data:" then
|
||||
-- SSE allows both "data:{...}" and "data: {...}"; accept either.
|
||||
local json_str = line:sub(6)
|
||||
if json_str:sub(1, 1) == " " then
|
||||
json_str = json_str:sub(2)
|
||||
end
|
||||
local ok, data = pcall(noctalia.json.decode, json_str)
|
||||
if ok and type(data) == "table" then
|
||||
return data.type, data
|
||||
@@ -1115,6 +1151,17 @@ function SSE.handle_event(event_type, event)
|
||||
return
|
||||
end
|
||||
|
||||
if event_type == "message.part.delta" then
|
||||
-- Per-token incremental text delta while a reply streams. Do NOT reload
|
||||
-- here: the server emits one of these per token, so reloading on each
|
||||
-- would decode the whole history every ~RELOAD_THROTTLE_S and blow the
|
||||
-- async callback CPU budget (crossed during json.decode). The
|
||||
-- less-frequent message.part.updated / message.updated events carry the
|
||||
-- accumulated full state and drive the (throttled) reload. This branch
|
||||
-- only exists to acknowledge the event and avoid the Unknown-event log.
|
||||
return
|
||||
end
|
||||
|
||||
if event_type == "message.part.updated" or event_type == "message.updated" then
|
||||
local session_id = event.sessionID or (event.properties and event.properties.sessionID)
|
||||
local props = event.properties or event
|
||||
@@ -1159,8 +1206,6 @@ function SSE.handle_event(event_type, event)
|
||||
set_connection_status("busy")
|
||||
elseif st == "idle" then
|
||||
set_connection_status("online")
|
||||
unread_count = 0
|
||||
mark_dirty("unread_count")
|
||||
elseif st == "error" then
|
||||
set_connection_status("online")
|
||||
end
|
||||
@@ -1177,8 +1222,6 @@ function SSE.handle_event(event_type, event)
|
||||
mark_dirty("session_status")
|
||||
if active_session and active_session.id == session_id then
|
||||
set_connection_status("online")
|
||||
unread_count = 0
|
||||
mark_dirty("unread_count")
|
||||
counted_messages = {}
|
||||
load_messages(session_id)
|
||||
end
|
||||
@@ -1197,8 +1240,10 @@ function SSE.handle_event(event_type, event)
|
||||
local name = (type(err) == "table" and err.name) or "UnknownError"
|
||||
local detail = (type(err) == "table" and type(err.data) == "table" and err.data.message)
|
||||
or tr("error.session_error_detail")
|
||||
session_status[session_id or ""] = "error"
|
||||
if session_id then
|
||||
session_status[session_id] = "error"
|
||||
mark_dirty("session_status")
|
||||
end
|
||||
if not active_session or not session_id or active_session.id == session_id then
|
||||
-- MessageAbortedError is expected when the user hits Stop; don't shout.
|
||||
if name ~= "MessageAbortedError" then
|
||||
@@ -1263,7 +1308,7 @@ function SSE.handle_event(event_type, event)
|
||||
end
|
||||
end
|
||||
mark_dirty("pending_permissions")
|
||||
set_connection_status("online")
|
||||
refresh_waiting_status()
|
||||
publish()
|
||||
end
|
||||
return
|
||||
@@ -1310,12 +1355,24 @@ function SSE.handle_event(event_type, event)
|
||||
end
|
||||
end
|
||||
mark_dirty("pending_questions")
|
||||
set_connection_status("online")
|
||||
refresh_waiting_status()
|
||||
publish()
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if event_type == "session.updated" or event_type == "session.diff" then
|
||||
-- Session metadata changed (e.g. auto-generated title after the first
|
||||
-- prompt). Refresh the session list (throttled) so the chooser shows the
|
||||
-- current titles; these fire roughly once per reply.
|
||||
local now = os.clock()
|
||||
if now - last_sessions_reload > SESSIONS_RELOAD_THROTTLE_S then
|
||||
last_sessions_reload = now
|
||||
load_sessions()
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Unknown event — log if debug
|
||||
debug_log("Unknown event: " .. tostring(event_type))
|
||||
end
|
||||
@@ -1461,7 +1518,6 @@ end
|
||||
|
||||
function onExit()
|
||||
SSE.stop()
|
||||
managed_pid = nil
|
||||
sse_stream = nil
|
||||
end
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"dismiss_error": "Dismiss",
|
||||
"empty": "No messages yet. Send a prompt to begin.",
|
||||
"open_terminal": "Open in terminal",
|
||||
"placeholder": "Ask OpenCode anything… (Enter = newline, Ctrl+Enter = send)",
|
||||
"placeholder": "Ask OpenCode anything… (Enter = send, Shift+Enter = new line)",
|
||||
"refresh": "Refresh",
|
||||
"select_agent": "Agent",
|
||||
"select_model": "Model",
|
||||
"send": "Send (Ctrl+Enter)",
|
||||
"send": "Send (Enter)",
|
||||
"stop": "Stop",
|
||||
"thinking": "Thinking…",
|
||||
"title": "OpenCode Chat",
|
||||
@@ -106,10 +106,6 @@
|
||||
"description": "Messages loaded per session (1–100). Set the slider to its top notch for unlimited. Default: 50.",
|
||||
"label": "Max Messages to Load"
|
||||
},
|
||||
"notify_on_complete": {
|
||||
"description": "Show a desktop notification when a response completes.",
|
||||
"label": "Notify on Completion"
|
||||
},
|
||||
"panel_mode": {
|
||||
"description": "Fill right opens a full-height panel pinned to the right edge; Compact shows the original floating panel near the bar click.",
|
||||
"label": "Panel Layout",
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"dismiss_error": "Bỏ qua",
|
||||
"empty": "Chưa có tin nhắn. Gửi prompt để bắt đầu.",
|
||||
"open_terminal": "Mở trong terminal",
|
||||
"placeholder": "Hỏi OpenCode bất cứ điều gì… (Enter = xuống dòng, Ctrl+Enter = gửi)",
|
||||
"placeholder": "Hỏi OpenCode bất cứ điều gì… (Enter = Gửi, Shift+Enter = xuống dòng)",
|
||||
"refresh": "Làm mới",
|
||||
"select_agent": "Agent",
|
||||
"select_model": "Mô hình",
|
||||
"send": "Gửi (Ctrl+Enter)",
|
||||
"send": "Gửi (Enter)",
|
||||
"stop": "Dừng",
|
||||
"thinking": "Đang suy nghĩ…",
|
||||
"title": "OpenCode Chat",
|
||||
@@ -86,7 +86,6 @@
|
||||
"label": "Agent mặc định"
|
||||
},
|
||||
"default_model": {
|
||||
"description": "Model mặc định theo định dạng 'provider/model' (ví dụ: 'anthropic/claude-3-5-sonnet-20241022'). Để trống để dùng mặc định của máy chủ.",
|
||||
"label": "Model mặc định"
|
||||
},
|
||||
"default_workspace": {
|
||||
@@ -106,10 +105,6 @@
|
||||
"description": "Số tin nhắn tải mỗi phiên (1–100). Kéo thanh trượt lên mức cao nhất để không giới hạn. Mặc định: 50.",
|
||||
"label": "Số tin nhắn tải tối đa"
|
||||
},
|
||||
"notify_on_complete": {
|
||||
"description": "Hiển thị thông báo trên desktop khi phản hồi hoàn tất.",
|
||||
"label": "Thông báo khi hoàn tất"
|
||||
},
|
||||
"panel_mode": {
|
||||
"description": "Full-height sát phải mở panel cao trọn màn hình ghim vào mép phải; Compact hiển thị panel nổi gần chỗ bấm trên thanh bar.",
|
||||
"label": "Kiểu panel",
|
||||
|
||||
+8
-4
@@ -25,6 +25,7 @@ usage (plus the process count). Click the widget to toggle the process panel:
|
||||
```sh
|
||||
noctalia msg panel-toggle weinguyen/procmon:panel
|
||||
```
|
||||
|
||||
The panel shows CPU, RAM and swap bars with the 1/5/15-minute load averages,
|
||||
then a process table. Sort by the column dropdown (PID, CPU%, MEM%, RSS,
|
||||
COMMAND) and flip asc/desc with the arrow button. Type in the filter box to
|
||||
@@ -54,7 +55,10 @@ while it is open.
|
||||
- The bar widget only renders data the service publishes; it never runs
|
||||
commands. The panel runs the configured `kill_command` when a row's ✕ is
|
||||
clicked.
|
||||
- Requires `plugin_api = 13`. CPU%, RAM%, swap and the 1/5/15-minute load
|
||||
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`,
|
||||
`/proc/loadavg`) by the service, so they work with no separate system-monitor
|
||||
dependency.
|
||||
- Requires `plugin_api = 13`. The panel renders a windowed slice of the
|
||||
process table and follows the keyboard cursor with edge-follow scrolling
|
||||
(window slides only when the cursor pushes past an edge, so scrolling up
|
||||
doesn't collapse the page until the top edge is reached). CPU%, RAM%, swap
|
||||
and the 1/5/15-minute load averages are sampled from `/proc` (`/proc/stat`,
|
||||
`/proc/meminfo`, `/proc/loadavg`) by the service, so they work with no
|
||||
separate system-monitor dependency.
|
||||
|
||||
+95
-6
@@ -26,6 +26,23 @@ local MAX_ROWS = 80
|
||||
-- selected. Ctrl+D kills the selected process directly.
|
||||
local selectedIdx = 0
|
||||
|
||||
-- Windowed table rendering, edge-follow (htop/btop style). The panel renders
|
||||
-- only a VIEW_CAP-row slice [winStart..winEnd] that fits the table viewport.
|
||||
-- Rows ~30px (kill button height=26 + paddingV 2), so 10 fill the 660px panel
|
||||
-- without clipping the last one (tuned so no dead gap sits below the list).
|
||||
-- The cursor moves freely INSIDE the window; the window slides only when the
|
||||
-- cursor pushes past an edge — down at the bottom edge, up at the top edge. So
|
||||
-- scrolling back up from the bottom does not collapse the page: the rows above
|
||||
-- (6,7,8,9) stay in view while the highlight climbs, and only reaching the
|
||||
-- window's top edge starts following up again. There is no host "scroll to row
|
||||
-- N" (API 21 only jumps to absolute bottom), so the slice is what the panel
|
||||
-- draws and the window IS the visible page.
|
||||
-- ponytail: VIEW_CAP is tuned to the panel's table height; could derive it from
|
||||
-- a scroll fill-measure if the API ever exposes available height.
|
||||
local VIEW_CAP = 10
|
||||
local winStart = 1
|
||||
local winEnd = VIEW_CAP
|
||||
|
||||
-- Cheap fingerprint of everything the table shows, so the 1s tick only rebuilds
|
||||
-- the heavy UI tree when the data actually changed. Rebuilding 200 rows every
|
||||
-- second exceeded the panel update CPU budget; sampling the first SIG_SAMPLE
|
||||
@@ -253,12 +270,14 @@ local function dataRow(p, idx)
|
||||
color = selected and "primary" or "on_surface",
|
||||
}))
|
||||
|
||||
-- Kill button (fixed trailing width)
|
||||
-- Kill button (fixed trailing width). Explicit height keeps the row short
|
||||
-- enough that VIEW_CAP rows fit without clipping the last one.
|
||||
table.insert(children, ui.button({
|
||||
glyph = "square-x",
|
||||
glyphSize = 14,
|
||||
variant = "ghost",
|
||||
controlSize = "sm",
|
||||
height = 26,
|
||||
width = KILL_W,
|
||||
tooltip = tr("panel.kill_tip", { pid = p.pid }),
|
||||
onClick = function()
|
||||
@@ -376,6 +395,43 @@ local function visibleList()
|
||||
return list, #list
|
||||
end
|
||||
|
||||
-- Keep the window [winStart..winEnd] covering the cursor and inside the list.
|
||||
-- Called from render() after the cursor is clamped, so it also heals the window
|
||||
-- when the list shrank (filter/sort/data) and left the cursor or window out of
|
||||
-- bounds. The lazy up-follow during navigation lives in onKey, not here (this
|
||||
-- only heals shrink/regrow).
|
||||
local function clampWindow(n)
|
||||
if n == 0 then
|
||||
winStart, winEnd = 1, 0
|
||||
return
|
||||
end
|
||||
-- Cursor below the window (list regrew or follow-down persisted): extend.
|
||||
if selectedIdx > winEnd then
|
||||
winEnd = selectedIdx
|
||||
winStart = math.max(1, winEnd - VIEW_CAP + 1)
|
||||
-- Cursor above the window: the list shrank (a filter) or re-sorted and left the
|
||||
-- cursor outside. Flatten back to the top page — cleared search shows row 1.
|
||||
elseif selectedIdx < winStart then
|
||||
winStart = 1
|
||||
winEnd = math.min(VIEW_CAP, n)
|
||||
end
|
||||
-- Never let the window extend past the list nor below row 1.
|
||||
if winEnd > n then
|
||||
winEnd = n
|
||||
winStart = math.max(1, winEnd - VIEW_CAP + 1)
|
||||
end
|
||||
if winStart < 1 then
|
||||
winStart = 1
|
||||
end
|
||||
-- Re-expand a below-capacity window to a full page. Only fires when the list
|
||||
-- shrank (filter) then regrew: otherwise the window would stay a few rows wide
|
||||
-- and grow one row per Down press. Filling back to VIEW_CAP makes a cleared
|
||||
-- search show the whole page again. No-op during normal navigation.
|
||||
if n >= VIEW_CAP and winEnd - winStart + 1 < VIEW_CAP then
|
||||
winEnd = math.min(winStart + VIEW_CAP - 1, n)
|
||||
end
|
||||
end
|
||||
|
||||
function render()
|
||||
local wantsFocus = focusFilterOnRender
|
||||
focusFilterOnRender = false
|
||||
@@ -393,15 +449,28 @@ function render()
|
||||
|
||||
local body
|
||||
if #shown == 0 then
|
||||
body = ui.row({ align = "center", justify = "center", flexGrow = 1 }, {
|
||||
-- Keep the body a ui.scroll in BOTH states so the flexGrow container keeps
|
||||
-- a stable height. Rendering a bare ui.row here (and switching back to a
|
||||
-- scroll once results return) left the scroll container stuck short, which
|
||||
-- clipped the bottom of the list after a 0-result search.
|
||||
winStart, winEnd = 1, 0
|
||||
-- Same stable key as the results scroll so the UI tree reconciles this as
|
||||
-- one persistent scroll node across the 0-result <-> results transition,
|
||||
-- keeping the flexGrow viewport height stable (avoids bottom clipping).
|
||||
body = ui.scroll({ key = "proc-list", flexGrow = 1 }, {
|
||||
ui.row({ align = "center", justify = "center", flexGrow = 1 }, {
|
||||
ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }),
|
||||
}),
|
||||
})
|
||||
else
|
||||
clampWindow(#shown)
|
||||
local itemRows = {}
|
||||
for i, p in ipairs(shown) do
|
||||
table.insert(itemRows, dataRow(p, i))
|
||||
for i = winStart, winEnd do
|
||||
if shown[i] then
|
||||
table.insert(itemRows, dataRow(shown[i], i))
|
||||
end
|
||||
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
|
||||
end
|
||||
body = ui.scroll({ key = "proc-list", flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
|
||||
end
|
||||
|
||||
local refreshedAt = (st("refreshedAtMs") or 0) / 1000
|
||||
@@ -554,7 +623,25 @@ function onKey(chord, pressed)
|
||||
if chord == "up" or chord == "down" then
|
||||
local list = visibleList()
|
||||
if #list > 0 then
|
||||
selectedIdx = math.max(1, math.min(#list, selectedIdx + (chord == "down" and 1 or -1)))
|
||||
if chord == "down" then
|
||||
selectedIdx = math.min(#list, selectedIdx + 1)
|
||||
-- Cursor pushed past the bottom edge: extend the window one row and
|
||||
-- slide its top so the new row is revealed below the cursor.
|
||||
if selectedIdx > winEnd then
|
||||
winEnd = selectedIdx
|
||||
winStart = math.max(1, winEnd - VIEW_CAP + 1)
|
||||
end
|
||||
else
|
||||
selectedIdx = math.max(1, selectedIdx - 1)
|
||||
-- Cursor rose past the top edge: only now follow up — slide the window's
|
||||
-- top to the cursor and refill down. Rows inside the window (6,7,8,9
|
||||
-- while stepping down from 10) never shift the window; reaching below the
|
||||
-- top edge (5) is what starts scrolling up again.
|
||||
if selectedIdx < winStart then
|
||||
winStart = selectedIdx
|
||||
winEnd = math.min(winStart + VIEW_CAP - 1, #list)
|
||||
end
|
||||
end
|
||||
render()
|
||||
end
|
||||
return
|
||||
@@ -592,5 +679,7 @@ function onOpen(_context)
|
||||
-- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start
|
||||
-- typing a filter; the box is focused then.
|
||||
selectedIdx = 0
|
||||
winStart = 1
|
||||
winEnd = VIEW_CAP
|
||||
render()
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
id = "weinguyen/procmon"
|
||||
name = "Process Monitor"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
plugin_api = 13
|
||||
author = "weinguyen"
|
||||
license = "MIT"
|
||||
|
||||
Reference in New Issue
Block a user