Compare commits

..
10 Commits
Author SHA1 Message Date
github-actions[bot] 1e08459260 chore: update plugin catalog [skip ci] 2026-08-13 18:58:22 +00:00
Spyridon SiarapisandGitHub b97a74174d Merge pull request #350 from Cleboost/feat/hotspot-control-center-shortcut
feat(hotspot): add Control Center shortcut to toggle hotspot
2026-08-13 20:58:10 +02:00
Cleboost 168861dbb3 feat(hotspot): add Control Center shortcut to toggle hotspot
Closes #349 by exposing a cc-toggle shortcut entry that mirrors service
state and sends the existing hotspot_command toggle action.
2026-08-13 11:13:20 +02:00
github-actions[bot] 3785668387 chore: update plugin catalog [skip ci] 2026-08-13 02:44:53 +00:00
Yocraft-2000andGitHub b75eb1cb33 Update Battery widget plugin (#346)
* fix: don't notify on warnings, use same default warning_threshold as noctalia

* chore: dump version
2026-08-12 22:44:40 -04:00
github-actions[bot] 335017f1e8 chore: update plugin catalog [skip ci] 2026-08-13 02:42:05 +00:00
weinguyenandGitHub 691398d9da update(procmon): fix windowed table follow-scroll & row sizing` (#340)
* update(procmon): fix windowed table follow-scroll & row sizing`

* Update plugin.toml

* Update README.md

* fix(procmon): always render list body as scroll to stop bottom clipping

0-result search switched the body from ui.scroll to a bare ui.row; when
results returned the row->scroll switch left the flexGrow viewport stuck
short, clipping bottom rows. Use ui.scroll with a shared key in both
branches so the tree keeps one stable scroll node.
2026-08-12 22:41:55 -04:00
github-actions[bot] a4ae9c8ab4 chore: update plugin catalog [skip ci] 2026-08-12 13:49:25 +00:00
weinguyenandGitHub 27458632e2 feat(plugin): OpenCode Companion beta.8 upgrade + streaming/overflow (#337)
* feat(plugin): OpenCode Companion beta.8 upgrade + streaming/overflow
fixes

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

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

Fix runtime errors surfaced during use:

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

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

* update version to 0.2.0
2026-08-12 09:49:13 -04:00
github-actions[bot] 6fcf69f738 chore: update plugin catalog [skip ci] 2026-08-12 13:37:42 +00:00
16 changed files with 387 additions and 131 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
id = "yocraft/battery-widget" id = "yocraft/battery-widget"
name = "Battery Widget" name = "Battery Widget"
version = "2.0.0" version = "2.0.1"
plugin_api = 3 plugin_api = 3
author = "yocraft" author = "yocraft"
license = "MIT" license = "MIT"
+3 -4
View File
@@ -3,7 +3,7 @@ local color
local data = { percent = 0, status = "unknown" } local data = { percent = 0, status = "unknown" }
local label_content = noctalia.getConfig("label_content") local label_content = noctalia.getConfig("label_content")
local warning_threshold = 20 local warning_threshold = 10
local path = "/sys/class/power_supply/" local path = "/sys/class/power_supply/"
local batName local batName
@@ -108,7 +108,6 @@ local function getWarningThreshold()
function(result) function(result)
if result.exitCode ~= 0 then if result.exitCode ~= 0 then
noctalia.log("battery-widget: failed to get warning_threshold: " .. result.stderr) noctalia.log("battery-widget: failed to get warning_threshold: " .. result.stderr)
notifyError()
return return
end end
@@ -116,7 +115,6 @@ local function getWarningThreshold()
if not res then if not res then
noctalia.log("battery-widget: invalid warning_threshold, keeping default") noctalia.log("battery-widget: invalid warning_threshold, keeping default")
notifyError()
return return
end end
@@ -125,7 +123,6 @@ local function getWarningThreshold()
warning_threshold = parsed warning_threshold = parsed
else else
noctalia.log("battery-widget: invalid warning_threshold, keeping default") noctalia.log("battery-widget: invalid warning_threshold, keeping default")
notifyError()
end end
end end
) )
@@ -227,11 +224,13 @@ local function initData()
function(result) function(result)
if result.exitCode ~= 0 then if result.exitCode ~= 0 then
noctalia.log("battery-widget: failed to read battery: " .. result.stderr) noctalia.log("battery-widget: failed to read battery: " .. result.stderr)
notifyError()
return return
end end
if not result.stdout then if not result.stdout then
noctalia.log("battery-widget: invalid battery output") noctalia.log("battery-widget: invalid battery output")
notifyError()
return return
end end
+18 -12
View File
@@ -755,15 +755,15 @@ tags = ["hardware", "utility", "desktop", "panel"]
[[plugin]] [[plugin]]
id = "cleboost/hotspot" id = "cleboost/hotspot"
name = "Wi-Fi Hotspot" name = "Wi-Fi Hotspot"
version = "1.0.0" version = "1.1.0"
updated_at = 1785174978 updated_at = 1786612400
added_at = 1785174978 added_at = 1785174978
author = "Cleboost" author = "Cleboost"
license = "MIT" license = "MIT"
icon = "router" 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 plugin_api = 9
tags = ["network", "bar", "panel", "service", "utility", "system", "indicator"] tags = ["network", "bar", "panel", "service", "shortcut", "utility", "system", "indicator"]
[[plugin]] [[plugin]]
id = "yuuto/arch-updater" id = "yuuto/arch-updater"
@@ -926,15 +926,15 @@ tags = ["launcher", "development", "productivity"]
[[plugin]] [[plugin]]
id = "yocraft/battery-widget" id = "yocraft/battery-widget"
name = "Battery Widget" name = "Battery Widget"
version = "1.0.1" version = "2.0.1"
updated_at = 1785684850 updated_at = 1786589080
added_at = 1785554104 added_at = 1785554104
author = "yocraft" author = "yocraft"
license = "MIT" license = "MIT"
icon = "battery" icon = "battery"
description = "Desktop/Lockscreen widget to show battery." description = "Desktop/Lockscreen widget to show battery."
deprecated = false deprecated = false
plugin_api = 19 plugin_api = 3
tags = ["desktop", "hardware", "system", "utility"] tags = ["desktop", "hardware", "system", "utility"]
[[plugin]] [[plugin]]
@@ -1210,8 +1210,8 @@ tags = ["ai", "productivity", "development", "network", "bar", "panel"]
[[plugin]] [[plugin]]
id = "weinguyen/procmon" id = "weinguyen/procmon"
name = "Process Monitor" name = "Process Monitor"
version = "0.4.0" version = "0.5.0"
updated_at = 1786298101 updated_at = 1786588915
added_at = 1786218032 added_at = 1786218032
author = "weinguyen" author = "weinguyen"
license = "MIT" license = "MIT"
@@ -1294,16 +1294,22 @@ tags = ["network", "utility", "bar", "panel", "service", "launcher"]
[[plugin]] [[plugin]]
id = "weinguyen/opencode-companion" id = "weinguyen/opencode-companion"
name = "OpenCode Companion" name = "OpenCode Companion"
version = "0.1.0" version = "0.2.0"
updated_at = 1786297431 updated_at = 1786542553
added_at = 1786297431 added_at = 1786297431
author = "weinguyen" author = "weinguyen"
license = "MIT" license = "MIT"
icon = "code-circle" icon = "code-circle"
description = "Integrate OpenCode AI agent directly into Noctalia bar with native chat panel, session management, and MCP status." 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"] tags = ["ai", "productivity", "development", "bar", "panel"]
[[plugin.release]]
plugin_api = 3
version = "0.1.0"
updated_at = 1786297431
rev = "e77d7ef7fbfa7d909e03966e42690a8561f1b190"
[[plugin]] [[plugin]]
id = "nilsonlinux/link-ip-monitor" id = "nilsonlinux/link-ip-monitor"
name = "Link/IP Monitor" name = "Link/IP Monitor"
+4 -4
View File
@@ -8,7 +8,7 @@ devices in a panel, and control the service over IPC.
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| ID | `cleboost/hotspot` | | ID | `cleboost/hotspot` |
| Entries | Bar widget: `toggle`; panel: `panel`; service: `hotspot` | | Entries | Bar widget: `toggle`; shortcut: `cc-toggle`; panel: `panel`; service: `hotspot` |
## Requirements ## Requirements
@@ -19,9 +19,9 @@ Install `nmcli` from NetworkManager, plus `iw` and `ip` from iproute2, on
## Usage ## Usage
1. Open the plugin settings and set the hotspot name and password. 1. Open the plugin settings and set the hotspot name and password.
2. Add the `toggle` widget to a bar. 2. Add the `toggle` widget to a bar and/or the `cc-toggle` shortcut in Settings → Control Center.
3. Left-click the widget to open the panel with the connected device list. 3. Left-click the bar widget to open the panel with the connected device list.
4. Right-click the widget to start or stop the hotspot. 4. Right-click the bar widget, or click the Control Center shortcut, to start or stop the hotspot.
```sh ```sh
noctalia msg panel-toggle cleboost/hotspot:panel noctalia msg panel-toggle cleboost/hotspot:panel
+7 -3
View File
@@ -1,12 +1,12 @@
id = "cleboost/hotspot" id = "cleboost/hotspot"
name = "Wi-Fi Hotspot" name = "Wi-Fi Hotspot"
version = "1.0.0" version = "1.1.0"
plugin_api = 9 plugin_api = 9
author = "Cleboost" author = "Cleboost"
license = "MIT" license = "MIT"
icon = "router" 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."
tags = ["network", "bar", "panel", "service", "utility", "system", "indicator"] tags = ["network", "bar", "panel", "service", "shortcut", "utility", "system", "indicator"]
dependencies = ["nmcli", "iw", "ip"] dependencies = ["nmcli", "iw", "ip"]
[[setting]] [[setting]]
@@ -53,6 +53,10 @@ entry = "widget.luau"
label_key = "settings.glyph.label" label_key = "settings.glyph.label"
default = "router" default = "router"
[[shortcut]]
id = "cc-toggle"
entry = "shortcut.luau"
[[panel]] [[panel]]
id = "panel" id = "panel"
entry = "panel.luau" entry = "panel.luau"
+35
View File
@@ -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
+4
View File
@@ -44,6 +44,10 @@
"stopping_title": "Stopping" "stopping_title": "Stopping"
} }
}, },
"shortcut": {
"active": "Hotspot on",
"inactive": "Hotspot off"
},
"settings": { "settings": {
"glyph": { "glyph": {
"label": "Bar glyph" "label": "Bar glyph"
+1 -2
View File
@@ -90,7 +90,7 @@ noctalia msg plugin weinguyen/opencode-companion:service all create_session
## Settings ## Settings
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
| -------------------- | ------ | ------------- | --------------------------------------------------------------- | | ------------------- | ------ | ------------- | --------------------------------------------------------------- |
| `server_mode` | string | `"auto"` | `"auto"` manages a local server; `"external"` connects to a URL | | `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_host` | string | `"127.0.0.1"` | Hostname the managed server binds to (loopback only) |
| `server_port` | double | `4096` | Port the managed server listens on | | `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 | | `auto_start` | bool | `true` | Auto-start the managed server |
| `show_tool_calls` | bool | `true` | Show tool call status cards | | `show_tool_calls` | bool | `true` | Show tool call status cards |
| `show_reasoning` | bool | `false` | Show reasoning/thinking text | | `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 | | `max_messages_load` | double | `50` | Max messages to load per session |
| `debug_logging` | bool | `false` | Print debug messages | | `debug_logging` | bool | `false` | Print debug messages |
+107 -31
View File
@@ -43,7 +43,6 @@ local session_query = ""
local question_choices = {} local question_choices = {}
local SVC = "weinguyen/opencode-companion:service" local SVC = "weinguyen/opencode-companion:service"
local PANEL_ID = "weinguyen/opencode-companion:panel"
-- Layout constants (recomputed from the ui_mode setting on every render). -- Layout constants (recomputed from the ui_mode setting on every render).
local compact = false local compact = false
@@ -94,6 +93,14 @@ local draft = ""
-- (empty) input, which is how we clear the field after sending. -- (empty) input, which is how we clear the field after sending.
local composer_seq = 0 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 view = "session_chooser"
local snap_cache = {} -- last rendered fingerprint local snap_cache = {} -- last rendered fingerprint
-- Forward declaration so chooser/chat closures can call render() to refresh -- Forward declaration so chooser/chat closures can call render() to refresh
@@ -176,17 +183,8 @@ local function tr(key, args)
return str return str
end end
-- Fingerprint a value to detect real changes (avoid re-render storms) -- Fingerprint a list of records by the given fields, so the panel re-renders
local function fp(v) -- only when one of those fields actually changes.
if type(v) ~= "table" then return tostring(v) end
local parts = {}
for k, val in pairs(v) do
parts[#parts + 1] = tostring(k) .. "=" .. tostring(val)
end
table.sort(parts)
return table.concat(parts, "\1")
end
local function fp_list(list, fields) local function fp_list(list, fields)
if type(list) ~= "table" then return "" end if type(list) ~= "table" then return "" end
local parts = {} local parts = {}
@@ -272,9 +270,7 @@ local function dispatch_ipc(event, payload)
noctalia.runAsync(cmd) noctalia.runAsync(cmd)
end end
-- MCP status footer: colored server names, one per line. Returns nil when -- MCP status footer: a single collapsible toggle row (chevron + title + the
-- there's nothing meaningful to show. Kept intentionally minimal — the
-- MCP status footer: a single collapsible toggle row (chevron + title +
-- connected/total count). Clicking expands it to show one pill per server. -- connected/total count). Clicking expands it to show one pill per server.
-- Collapsed by default so it never crowds the message list. -- Collapsed by default so it never crowds the message list.
local MCP_META = { local MCP_META = {
@@ -523,6 +519,33 @@ end
-- ── chat view ─────────────────────────────────────────────────────────────── -- ── 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) local function render_message(msg_data)
if type(msg_data) ~= "table" then return nil end if type(msg_data) ~= "table" then return nil end
local info = msg_data.info 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 or txt:sub(1, 1) == "<" and txt:find("_context>", 1, true) ~= nil
) )
if not is_injected then 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({ cells[#cells + 1] = ui.label({
text = txt, text = txt,
maxWidth = WRAP, maxWidth = WRAP,
flexGrow = 1, flexGrow = 1,
}) })
end end
end
elseif ptype == "reasoning" and type(part.text) == "string" and part.text ~= "" then elseif ptype == "reasoning" and type(part.text) == "string" and part.text ~= "" then
-- Only show reasoning if enabled (checked at render time via config) -- Only show reasoning if enabled (checked at render time via config)
local show_reasoning = noctalia.getConfig and noctalia.getConfig("show_reasoning") 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
end end
-- Message list, newest-first. Newest at the top so the panel always opens on -- Message list, oldest-first. Newest at the bottom; the scroll node is
-- the latest message without needing a scroll API (shell < API 21 resets a -- pinned to the bottom (stickToBottom) and jumps there on open/session
-- re-mounted scroll to offset 0 = top). Scrolling down reveals older -- switch (scrollToBottomRev), so the panel always shows the latest message.
-- messages. The scroll node carries a stable `key` so its offset survives -- The scroll node carries a stable `key` so its offset survives re-renders
-- re-renders even when siblings above it appear/disappear. Children are -- even when siblings above it appear/disappear. Children are passed directly
-- passed directly to the scroll (not wrapped in a column). -- to the scroll (not wrapped in a column).
local msg_items = {} local msg_items = {}
if type(messages) ~= "table" or #messages == 0 then if type(messages) ~= "table" or #messages == 0 then
msg_items[#msg_items + 1] = ui.label({ text = tr("chat.empty"), maxWidth = WRAP, opacity = 0.7 }) 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. -- disappears once the assistant's first text part streams in.
local waiting = is_processing() local waiting = is_processing()
and (not newest_is_assistant or not newest_has_text) and (not newest_is_assistant or not newest_has_text)
-- Newest-first: build the list from the end so bubble order is latest -- Oldest-first: newest message at the bottom (API 21 supports
-- first, oldest last. -- stick-to-bottom + jump-to-bottom, so the panel opens on the latest
for i = #messages, 1, -1 do -- message and follows the stream).
for i = 1, #messages do
local rendered = render_message(messages[i]) local rendered = render_message(messages[i])
if rendered then if rendered then
msg_items[#msg_items + 1] = rendered msg_items[#msg_items + 1] = rendered
end end
end end
-- Thinking bubble sits right ABOVE the newest message (top of the -- Thinking bubble sits right BELOW the newest message (end of the
-- newest-first list), like a spinner over the reply in progress. -- oldest-first list), like a spinner over the reply in progress.
if waiting then if waiting then
table.insert(msg_items, 1, render_thinking()) msg_items[#msg_items + 1] = render_thinking()
end end
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 -- MCP status footer
local mcp_footer = render_mcp_status() local mcp_footer = render_mcp_status()
@@ -1066,13 +1131,13 @@ local function render_chat(active, messages, permissions, questions, conn)
value = draft, value = draft,
placeholder = tr("chat.placeholder"), placeholder = tr("chat.placeholder"),
multiline = true, multiline = true,
submitOnEnter = true,
flexGrow = 1, flexGrow = 1,
onChange = function(text) onChange = function(text)
draft = text draft = text
end, end,
-- Multiline: Enter inserts a newline; Ctrl+Enter submits (send). This -- Chat-style submit (API 21): Enter submits, Shift+Enter inserts a
-- is the input control's built-in mapping and cannot distinguish -- newline. Ctrl+Enter still submits as a fallback chord.
-- Shift+Enter, so Ctrl+Enter is the send chord.
onSubmit = function(text) onSubmit = function(text)
send_draft(text) send_draft(text)
end, end,
@@ -1106,6 +1171,14 @@ end
render = function() render = function()
apply_layout() apply_layout()
local active = noctalia.state.get(STATE.active) 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 sessions = noctalia.state.get(STATE.sessions) or {}
local messages = noctalia.state.get(STATE.messages) or {} local messages = noctalia.state.get(STATE.messages) or {}
local permissions = noctalia.state.get(STATE.permissions) or {} local permissions = noctalia.state.get(STATE.permissions) or {}
@@ -1160,6 +1233,9 @@ end
function onOpen(_context) function onOpen(_context)
panel.setWantsSecondTicks(true) 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 -- Subscribe to all relevant state changes
for _, key in pairs(STATE) do for _, key in pairs(STATE) do
noctalia.state.watch(key, function() noctalia.state.watch(key, function()
+2 -9
View File
@@ -1,7 +1,7 @@
id = "weinguyen/opencode-companion" id = "weinguyen/opencode-companion"
name = "OpenCode Companion" name = "OpenCode Companion"
version = "0.1.0" version = "0.2.0"
plugin_api = 3 plugin_api = 21
author = "weinguyen" author = "weinguyen"
license = "MIT" license = "MIT"
icon = "code-circle" icon = "code-circle"
@@ -80,13 +80,6 @@ default = false
label_key = "settings.show_reasoning.label" label_key = "settings.show_reasoning.label"
description_key = "settings.show_reasoning.description" 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 # Integer slider: 1..100 caps loaded history; the top notch (101) means
# "unlimited" (service.luau treats >= 101 as an effectively unbounded limit). # "unlimited" (service.luau treats >= 101 as an effectively unbounded limit).
[[setting]] [[setting]]
+77 -21
View File
@@ -151,9 +151,7 @@ local connection = {
server_version = nil, server_version = nil,
} }
-- Server process info (managed mode only)
local managed_pid = nil
local managed_port = nil
-- Active session tracking -- Active session tracking
local active_session = nil local active_session = nil
@@ -172,6 +170,9 @@ local last_error = nil
-- Optimistic user message appended on send, replaced once the server echoes it. -- Optimistic user message appended on send, replaced once the server echoes it.
local optimistic_msg = nil 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 -- 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), -- 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 if dirty.mcp_status or dirty.all then
noctalia.state.set("opencode.mcp_status", mcp_status) noctalia.state.set("opencode.mcp_status", mcp_status)
end 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 if dirty.pending_permissions or dirty.all then
noctalia.state.set("opencode.pending_permissions", pending_permissions) noctalia.state.set("opencode.pending_permissions", pending_permissions)
end end
@@ -400,6 +407,17 @@ local function set_connection_status(status, err)
publish() publish()
end 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) local function set_last_error(message, detail)
last_error = { message = message, detail = detail } last_error = { message = message, detail = detail }
mark_dirty("last_error") mark_dirty("last_error")
@@ -429,7 +447,6 @@ local function start_managed_server()
local ok, data = pcall(noctalia.json.decode, resp.body) local ok, data = pcall(noctalia.json.decode, resp.body)
if ok and data.healthy then if ok and data.healthy then
connection.server_version = data.version connection.server_version = data.version
managed_port = port
set_connection_status("online") set_connection_status("online")
debug_log("Connected to existing server at " .. host .. ":" .. port) debug_log("Connected to existing server at " .. host .. ":" .. port)
load_initial_data() load_initial_data()
@@ -482,8 +499,6 @@ local function start_managed_server()
local ok2, data2 = pcall(noctalia.json.decode, resp2.body) local ok2, data2 = pcall(noctalia.json.decode, resp2.body)
if ok2 and data2.healthy then if ok2 and data2.healthy then
connection.server_version = data2.version connection.server_version = data2.version
managed_port = port
managed_pid = true -- we spawned it
set_connection_status("online") set_connection_status("online")
debug_log("Server started successfully") debug_log("Server started successfully")
load_initial_data() load_initial_data()
@@ -771,10 +786,14 @@ end
-- ── session management ─────────────────────────────────────────────────────── -- ── session management ───────────────────────────────────────────────────────
local function set_active_session(session) 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 active_session = session
messages = {} messages = {}
last_messages_body = nil -- force a fresh decode for the new session last_messages_body = nil -- force a fresh decode for the new session
if not session then if not same_session then
optimistic_msg = nil optimistic_msg = nil
end end
mark_dirty("active_session") 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 not safe_id(session_id) then return end
if type(text) ~= "string" or text == "" 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 -- 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). -- 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 local now_ms = os.time() * 1000
optimistic_msg = { optimistic_msg = {
info = { info = {
id = "local-" .. tostring(now_ms), id = "local-" .. tostring(now_ms) .. "-" .. tostring(optimistic_seq),
role = "user", role = "user",
sessionID = session_id, sessionID = session_id,
time = { created = now_ms }, time = { created = now_ms },
@@ -968,7 +995,7 @@ local function respond_to_permission(session_id, permission_id, response, rememb
end end
end end
mark_dirty("pending_permissions") mark_dirty("pending_permissions")
set_connection_status("online") refresh_waiting_status()
publish() publish()
else else
set_last_error(tr("error.permission_failed"), "HTTP " .. tostring(resp.status)) 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
end end
mark_dirty("pending_questions") mark_dirty("pending_questions")
set_connection_status("online") refresh_waiting_status()
publish() publish()
else else
set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status)) set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status))
@@ -1010,7 +1037,7 @@ local function reject_question(request_id)
end end
end end
mark_dirty("pending_questions") mark_dirty("pending_questions")
set_connection_status("online") refresh_waiting_status()
publish() publish()
else else
set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status)) set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status))
@@ -1026,6 +1053,11 @@ local SSE = {}
last_messages_reload = 0 last_messages_reload = 0
RELOAD_THROTTLE_S = 0.3 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) -- Dedup set for unread counting (prevents inflating count on every part update)
local counted_messages = {} local counted_messages = {}
local COUNTED_MAX = 100 local COUNTED_MAX = 100
@@ -1034,8 +1066,12 @@ local COUNTED_MAX = 100
local function parse_sse_line(line) local function parse_sse_line(line)
if line == nil then return nil, nil end if line == nil then return nil, nil end
if line == "" then return nil, nil end if line == "" then return nil, nil end
if line:sub(1, 6) == "data: " then if line:sub(1, 5) == "data:" then
local json_str = line:sub(7) -- 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) local ok, data = pcall(noctalia.json.decode, json_str)
if ok and type(data) == "table" then if ok and type(data) == "table" then
return data.type, data return data.type, data
@@ -1115,6 +1151,17 @@ function SSE.handle_event(event_type, event)
return return
end 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 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 session_id = event.sessionID or (event.properties and event.properties.sessionID)
local props = event.properties or event local props = event.properties or event
@@ -1159,8 +1206,6 @@ function SSE.handle_event(event_type, event)
set_connection_status("busy") set_connection_status("busy")
elseif st == "idle" then elseif st == "idle" then
set_connection_status("online") set_connection_status("online")
unread_count = 0
mark_dirty("unread_count")
elseif st == "error" then elseif st == "error" then
set_connection_status("online") set_connection_status("online")
end end
@@ -1177,8 +1222,6 @@ function SSE.handle_event(event_type, event)
mark_dirty("session_status") mark_dirty("session_status")
if active_session and active_session.id == session_id then if active_session and active_session.id == session_id then
set_connection_status("online") set_connection_status("online")
unread_count = 0
mark_dirty("unread_count")
counted_messages = {} counted_messages = {}
load_messages(session_id) load_messages(session_id)
end end
@@ -1197,8 +1240,10 @@ function SSE.handle_event(event_type, event)
local name = (type(err) == "table" and err.name) or "UnknownError" local name = (type(err) == "table" and err.name) or "UnknownError"
local detail = (type(err) == "table" and type(err.data) == "table" and err.data.message) local detail = (type(err) == "table" and type(err.data) == "table" and err.data.message)
or tr("error.session_error_detail") 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") mark_dirty("session_status")
end
if not active_session or not session_id or active_session.id == session_id then 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. -- MessageAbortedError is expected when the user hits Stop; don't shout.
if name ~= "MessageAbortedError" then if name ~= "MessageAbortedError" then
@@ -1263,7 +1308,7 @@ function SSE.handle_event(event_type, event)
end end
end end
mark_dirty("pending_permissions") mark_dirty("pending_permissions")
set_connection_status("online") refresh_waiting_status()
publish() publish()
end end
return return
@@ -1310,12 +1355,24 @@ function SSE.handle_event(event_type, event)
end end
end end
mark_dirty("pending_questions") mark_dirty("pending_questions")
set_connection_status("online") refresh_waiting_status()
publish() publish()
end end
return return
end 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 -- Unknown event — log if debug
debug_log("Unknown event: " .. tostring(event_type)) debug_log("Unknown event: " .. tostring(event_type))
end end
@@ -1461,7 +1518,6 @@ end
function onExit() function onExit()
SSE.stop() SSE.stop()
managed_pid = nil
sse_stream = nil sse_stream = nil
end end
+2 -6
View File
@@ -5,11 +5,11 @@
"dismiss_error": "Dismiss", "dismiss_error": "Dismiss",
"empty": "No messages yet. Send a prompt to begin.", "empty": "No messages yet. Send a prompt to begin.",
"open_terminal": "Open in terminal", "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", "refresh": "Refresh",
"select_agent": "Agent", "select_agent": "Agent",
"select_model": "Model", "select_model": "Model",
"send": "Send (Ctrl+Enter)", "send": "Send (Enter)",
"stop": "Stop", "stop": "Stop",
"thinking": "Thinking…", "thinking": "Thinking…",
"title": "OpenCode Chat", "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.", "description": "Messages loaded per session (1–100). Set the slider to its top notch for unlimited. Default: 50.",
"label": "Max Messages to Load" "label": "Max Messages to Load"
}, },
"notify_on_complete": {
"description": "Show a desktop notification when a response completes.",
"label": "Notify on Completion"
},
"panel_mode": { "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.", "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", "label": "Panel Layout",
+2 -7
View File
@@ -5,11 +5,11 @@
"dismiss_error": "Bỏ qua", "dismiss_error": "Bỏ qua",
"empty": "Chưa có tin nhắn. Gửi prompt để bắt đầu.", "empty": "Chưa có tin nhắn. Gửi prompt để bắt đầu.",
"open_terminal": "Mở trong terminal", "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", "refresh": "Làm mới",
"select_agent": "Agent", "select_agent": "Agent",
"select_model": "Mô hình", "select_model": "Mô hình",
"send": "Gửi (Ctrl+Enter)", "send": "Gửi (Enter)",
"stop": "Dừng", "stop": "Dừng",
"thinking": "Đang suy nghĩ…", "thinking": "Đang suy nghĩ…",
"title": "OpenCode Chat", "title": "OpenCode Chat",
@@ -86,7 +86,6 @@
"label": "Agent mặc định" "label": "Agent mặc định"
}, },
"default_model": { "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" "label": "Model mặc định"
}, },
"default_workspace": { "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.", "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" "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": { "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.", "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", "label": "Kiểu panel",
+8 -4
View File
@@ -25,6 +25,7 @@ usage (plus the process count). Click the widget to toggle the process panel:
```sh ```sh
noctalia msg panel-toggle weinguyen/procmon:panel noctalia msg panel-toggle weinguyen/procmon:panel
``` ```
The panel shows CPU, RAM and swap bars with the 1/5/15-minute load averages, 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, 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 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 - 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 commands. The panel runs the configured `kill_command` when a row's ✕ is
clicked. clicked.
- Requires `plugin_api = 13`. CPU%, RAM%, swap and the 1/5/15-minute load - Requires `plugin_api = 13`. The panel renders a windowed slice of the
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`, process table and follows the keyboard cursor with edge-follow scrolling
`/proc/loadavg`) by the service, so they work with no separate system-monitor (window slides only when the cursor pushes past an edge, so scrolling up
dependency. 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
View File
@@ -26,6 +26,23 @@ local MAX_ROWS = 80
-- selected. Ctrl+D kills the selected process directly. -- selected. Ctrl+D kills the selected process directly.
local selectedIdx = 0 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 -- 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 -- 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 -- 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", 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({ table.insert(children, ui.button({
glyph = "square-x", glyph = "square-x",
glyphSize = 14, glyphSize = 14,
variant = "ghost", variant = "ghost",
controlSize = "sm", controlSize = "sm",
height = 26,
width = KILL_W, width = KILL_W,
tooltip = tr("panel.kill_tip", { pid = p.pid }), tooltip = tr("panel.kill_tip", { pid = p.pid }),
onClick = function() onClick = function()
@@ -376,6 +395,43 @@ local function visibleList()
return list, #list return list, #list
end 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() function render()
local wantsFocus = focusFilterOnRender local wantsFocus = focusFilterOnRender
focusFilterOnRender = false focusFilterOnRender = false
@@ -393,15 +449,28 @@ function render()
local body local body
if #shown == 0 then 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" }), ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }),
}),
}) })
else else
clampWindow(#shown)
local itemRows = {} local itemRows = {}
for i, p in ipairs(shown) do for i = winStart, winEnd do
table.insert(itemRows, dataRow(p, i)) if shown[i] then
table.insert(itemRows, dataRow(shown[i], i))
end 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 end
local refreshedAt = (st("refreshedAtMs") or 0) / 1000 local refreshedAt = (st("refreshedAtMs") or 0) / 1000
@@ -554,7 +623,25 @@ function onKey(chord, pressed)
if chord == "up" or chord == "down" then if chord == "up" or chord == "down" then
local list = visibleList() local list = visibleList()
if #list > 0 then 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() render()
end end
return return
@@ -592,5 +679,7 @@ function onOpen(_context)
-- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start -- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start
-- typing a filter; the box is focused then. -- typing a filter; the box is focused then.
selectedIdx = 0 selectedIdx = 0
winStart = 1
winEnd = VIEW_CAP
render() render()
end end
+1 -1
View File
@@ -1,6 +1,6 @@
id = "weinguyen/procmon" id = "weinguyen/procmon"
name = "Process Monitor" name = "Process Monitor"
version = "0.4.0" version = "0.5.0"
plugin_api = 13 plugin_api = 13
author = "weinguyen" author = "weinguyen"
license = "MIT" license = "MIT"