diff --git a/opencode-companion/README.md b/opencode-companion/README.md new file mode 100644 index 0000000..3f53f93 --- /dev/null +++ b/opencode-companion/README.md @@ -0,0 +1,257 @@ +# OpenCode Companion + +A Noctalia v5 plugin that puts [OpenCode](https://opencode.ai/) on your bar — a glanceable status dot, a native chat panel, session management, and MCP status — all driven by the OpenCode HTTP API. No embedded terminal, no key emulation. + +![OpenCode Companion](thumbnail.webp) + +## Plugin + +| Field | Value | +| ---------- | ----------------------------------------------------------------------- | +| ID | `weinguyen/opencode-companion` | +| Entries | Bar widget: `widget`; panels: `panel-fill`, `panel`; service: `service` | +| Plugin API | 3 | + +Built and tested against: + +- **Noctalia** v5.0.0 (97917d9ca07e) +- **OpenCode** v1.18.13 + +## Requirements + +- **Noctalia v5** (beta or newer) with `plugin_api >= 3` support +- **[OpenCode](https://opencode.ai/)** installed and available on your `PATH` (`opencode --version` to verify) +- A configured OpenCode provider (run `opencode` once to set up auth) + +## Install + +```sh +# Clone the community-plugins repo (if you haven't already) +git clone https://github.com/... community-plugins + +# Symlink into Noctalia plugins directory +ln -s "$PWD/community-plugins/opencode-companion" ~/.local/share/noctalia/plugins/opencode-companion + +# Enable the plugin +noctalia msg plugins enable weinguyen/opencode-companion +``` + +## Usage + +### Adding the widget to your bar + +1. Open Noctalia Settings → Bar +2. Click **Add Widget** +3. Select **OpenCode Companion** (the code-circle icon) +4. The widget appears on your bar + +### Opening the panel + +- **Left click** the bar widget → opens/closes the panel +- **Right click** → quick-create a new session +- **Middle click** → open current session in terminal (`opencode attach`) + +### Panel workflow + +When you first open the panel (after a reboot), the **session chooser** appears. From there you can: + +- Create a new session +- Pick an existing session (sorted by most recently updated) +- Filter sessions by workspace (visible in the subtitle) + +Once a session is selected, the **chat view** shows: + +- Message history (user + assistant) +- Tool call status cards (if enabled) +- Reasoning text (if enabled) +- Streaming responses as they arrive + +Type a prompt in the composer and press Enter or click Send. + +### IPC + +```sh +# Toggle the panel (full-height, right side) +noctalia msg panel-toggle weinguyen/opencode-companion:panel-fill + +# Toggle the panel (compact, near click) +noctalia msg panel-toggle weinguyen/opencode-companion:panel + +# Force refresh +noctalia msg plugin weinguyen/opencode-companion:service all refresh + +# Reconnect to server +noctalia msg plugin weinguyen/opencode-companion:service all reconnect + +# Create a new session +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 | +| `server_url` | string | `""` | External server URL (used in `"external"` mode) | +| `default_workspace` | folder | `""` | Default working directory for new sessions | +| `default_model` | string | `""` | Default model in `provider/model` format | +| `default_agent` | string | `"build"` | Default agent for new sessions | +| `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 | + +## Session Lifecycle + +### Within the same boot + +- Selecting a session, closing the panel, and reopening it preserves the active session +- Draft text is preserved when the panel closes +- Unread responses are tracked and shown as a badge on the bar widget +- The SSE connection stays alive while the panel is closed + +### After reboot + +- The plugin detects reboot via `/proc/sys/kernel/random/boot_id` +- After reboot, the session chooser appears instead of auto-opening the last session +- Old sessions are still available to select +- The active session is persisted to `~/.local/state/noctalia/opencode-companion/opencode_state.json` + +### Boot-ID behavior + +| Condition | Behavior | +| ------------------------------ | --------------------------------------- | +| Boot ID matches saved state | Restore active session on first open | +| Boot ID differs (reboot) | Show session chooser; keep session list | +| Saved session no longer exists | Show session chooser | + +## Security Notes + +- **Loopback only**: The managed server binds to `127.0.0.1` by default +- **No credential storage**: The plugin does not store API keys or tokens +- **Shell quoting**: All paths and arguments are shell-escaped before command execution +- **No auto-approve**: Permission requests are never auto-approved +- **No secret logging**: Passwords and tokens are not written to logs + +When running in `auto` mode without authentication, any local process can reach the managed server. For multi-user systems, consider: + +- Setting `OPENCODE_SERVER_PASSWORD` before starting the server +- Using `external` mode with a password-protected server + +## MCP Status + +The plugin reads MCP status from OpenCode's `/mcp` endpoint. To check Context7 and Firecrawl: + +```sh +curl http://127.0.0.1:4096/mcp | jq +``` + +Example response: + +```json +{ + "context7": { "status": "connected" }, + "firecrawl": { "status": "connected" }, + "github": { "status": "connected" } +} +``` + +Status values: `connected`, `failed`, `disabled`. + +The plugin does **not** add or configure MCP servers — it only reports their status. Configure OpenCode MCP servers through your `opencode.json` or the TUI. + +## Troubleshooting + +### Widget shows offline + +```sh +# Verify opencode is on PATH +which opencode + +# Start a server manually to test +opencode serve --hostname 127.0.0.1 --port 4096 & + +# Check health +curl http://127.0.0.1:4096/global/health +``` + +### Server fails to start + +```sh +# Check for port conflicts +ss -tlnp | grep 4096 + +# Try a different port in plugin settings +``` + +### Panel doesn't open + +```sh +# Verify plugin is enabled +noctalia msg plugins list + +# Try toggling manually +noctalia msg panel-toggle weinguyen/opencode-companion:panel +``` + +### SSE events not arriving + +OpenCode 1.14.42+ had SSE regressions. Upgrade to 1.18.13+ if events stop flowing. The plugin handles reconnection with exponential backoff. + +### Debug logging + +Enable `debug_logging` in plugin settings, then check Noctalia logs: + +```sh +journalctl --user -u noctalia -f +``` + +## Logs + +Debug output (when enabled) is prefixed with `[opencode-companion]`. Look for: + +- Connection state changes +- SSE events received +- IPC messages handled +- HTTP request failures + +## Known Limitations + +- **No `ui.markdown`**: Responses are rendered as plain `ui.label`. Code blocks lose syntax highlighting. +- **Single-line composer fallback**: If `multiline` input is not fully supported, the composer falls back to single-line. +- **Panel layer**: Panels render at `Layer::Top` — notifications and polkit prompts may cover the panel. +- **No desktop orb**: Initial release includes only the bar widget. A desktop presence orb is planned. +- **Boot-ID edge case**: If the boot ID file is unreadable, session restoration is skipped. +- **SSE reconnection**: After server restart, SSE reconnects with backoff (up to 30s delay). +- **No model/agent switching mid-session**: Model and agent are set at session creation. + +## Uninstall + +```sh +# Disable the plugin +noctalia msg plugins disable weinguyen/opencode-companion + +# Remove the symlink +rm ~/.local/share/noctalia/plugins/opencode-companion + +# Optionally remove saved state +rm -rf ~/.local/state/noctalia/opencode-companion +``` + +## Roadmap + +- [ ] Desktop presence orb +- [ ] Model/agent switching from header +- [ ] Session rename/delete from panel +- [ ] MCP status panel +- [ ] Multi-workspace profiles +- [ ] `ui.markdown` support when available +- [ ] Attachment support (images, files) + +## License + +[MIT](LICENSE) diff --git a/opencode-companion/panel.luau b/opencode-companion/panel.luau new file mode 100644 index 0000000..ced0418 --- /dev/null +++ b/opencode-companion/panel.luau @@ -0,0 +1,1192 @@ +-- OpenCode Companion — the chat panel surface. +-- +-- Two views: +-- • Session chooser — when no active session is selected +-- — Session chat — when a session is active +-- +-- Pure subscriber of opencode.* state. User actions are dispatched as IPC +-- events to the service. + +local STATE = { + connection = "opencode.connection", + active = "opencode.active_session", + sessions = "opencode.sessions", + messages = "opencode.messages", + permissions = "opencode.pending_permissions", + questions = "opencode.pending_questions", + mcp = "opencode.mcp_status", + providers = "opencode.providers", + agents = "opencode.agents", + model_options = "opencode.model_options", + agent_options = "opencode.agent_options", + selected_model = "opencode.selected_model", + selected_agent = "opencode.selected_agent", + unread = "opencode.unread_count", + last_error = "opencode.last_error", +} + +-- Confirmation state for destructive actions (delete session). +local pending_delete = nil + +-- The session the user most recently opened/selected. Kept so the chooser can +-- highlight it (single accent-colored row) even after the user backs out to the +-- session list. Synced from the active session on every render. +local last_opened_id = nil + +-- Live filter text for the chooser's session search box (in-memory only). +local session_query = "" + +-- Pending answer selections for multi-question agent requests. Keyed by +-- requestID -> { [question_index] = chosen_label }. A request with N questions +-- is only replied once every question has a selection, so a single-question +-- click never fires the reply before the others are answered. +local question_choices = {} + +local SVC = "weinguyen/opencode-companion:service" +local PANEL_ID = "weinguyen/opencode-companion:panel" + +-- Layout constants (recomputed from the ui_mode setting on every render). +local compact = false +local PAD = 14 +local WRAP = 432 -- 560 - 2*PAD - scrollbar room +local GAP = 8 +local GAP_SM = 4 +local FONT_TITLE = 18 +local FONT_BODY = 13 +local FONT_SUB = 12 +local CARD_PAD = 10 +local CARD_RADIUS = 10 +local BUBBLE_GAP = 4 + +-- Recompute layout metrics from the ui_mode setting. Called at the top of +-- render() so a settings change reflows the whole panel on the next frame. +local function apply_layout() + compact = (noctalia.getConfig and noctalia.getConfig("ui_mode")) == "compact" + if compact then + PAD = 8 + GAP = 4 + GAP_SM = 2 + FONT_TITLE = 15 + FONT_BODY = 12 + FONT_SUB = 11 + CARD_PAD = 6 + CARD_RADIUS = 8 + BUBBLE_GAP = 2 + else + PAD = 14 + GAP = 8 + GAP_SM = 4 + FONT_TITLE = 18 + FONT_BODY = 13 + FONT_SUB = 12 + CARD_PAD = 10 + CARD_RADIUS = 10 + BUBBLE_GAP = 4 + end + WRAP = 560 - 2 * PAD - 100 +end + +-- Draft persistence key (in-memory only; survives panel close/open within same boot) +local draft = "" +-- Bumped after each successful send. The composer input is uncontrolled and only +-- seeds its text once per node instance, so clearing `draft` alone won't wipe the +-- on-screen text. Changing the input's key forces the reconciler to build a fresh +-- (empty) input, which is how we clear the field after sending. +local composer_seq = 0 + +local view = "session_chooser" +local snap_cache = {} -- last rendered fingerprint +-- Forward declaration so chooser/chat closures can call render() to refresh +-- local-only UI state (e.g. delete confirmation) not held in shared state. +local render + +-- Thinking indicator animation: cycle loader glyphs on each second tick while +-- the agent is processing (beta.7 has no animated spinner, so we rotate icons). +local thinking_frame = 0 +local THINKING_GLYPHS = { "loader", "loader-2", "loader-3", "loader-quarter" } + +-- Translation with an optional per-plugin language override. +-- +-- noctalia.tr() always follows the global shell locale, so the plugin's +-- `language` setting cannot re-point it. When the user picks a specific +-- language we load the plugin's own bundled translations/.json (merged +-- over en.json) via noctalia.readFile — relative paths resolve against the +-- plugin dir — and resolve keys ourselves. "auto" delegates to noctalia.tr. +local i18n_cache = { lang = nil, table = nil } + +local function load_lang_table(lang) + if i18n_cache.lang == lang and i18n_cache.table then + return i18n_cache.table + end + local merged = {} + local function merge_file(path) + local ok, content = pcall(noctalia.readFile, path) + if not ok or type(content) ~= "string" or content == "" then return end + local ok2, data = pcall(noctalia.json.decode, content) + if not ok2 or type(data) ~= "table" then return end + -- Deep-merge nested tables so overrides en per leaf key. + local function deep(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + deep(dst[k], v) + else + dst[k] = v + end + end + end + deep(merged, data) + end + merge_file("translations/en.json") + if lang ~= "en" then + merge_file("translations/" .. lang .. ".json") + end + i18n_cache.lang = lang + i18n_cache.table = merged + return merged +end + +local function lookup_path(tbl, key) + local node = tbl + for part in string.gmatch(key, "[^%.]+") do + if type(node) ~= "table" then return nil end + node = node[part] + end + if type(node) == "string" then return node end + return nil +end + +local function tr(key, args) + local lang = noctalia.getConfig and noctalia.getConfig("language") + if type(lang) ~= "string" or lang == "" or lang == "auto" then + return noctalia.tr(key, args) + end + local tbl = load_lang_table(lang) + local str = lookup_path(tbl, key) + if not str then + -- Missing override key: fall back to the shell translator. + return noctalia.tr(key, args) + end + if type(args) == "table" then + str = string.gsub(str, "{(%w+)}", function(name) + local v = args[name] + if v == nil then return "{" .. name .. "}" end + return tostring(v) + end) + end + return str +end + +-- Fingerprint a 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 + +local function fp_list(list, fields) + if type(list) ~= "table" then return "" end + local parts = {} + for i, item in ipairs(list) do + if type(item) == "table" then + local item_parts = {} + for _, f in ipairs(fields) do + item_parts[#item_parts + 1] = tostring(item[f] or "") + end + parts[i] = table.concat(item_parts, "\2") + end + end + return table.concat(parts, "\1") +end + +-- Fingerprint the MCP server map (name-keyed) by sorted name + status, so the +-- footer re-renders when a server's status changes. +local function fp_mcp(servers) + if type(servers) ~= "table" then return "" end + local parts = {} + for name, srv in pairs(servers) do + local st = (type(srv) == "table" and srv.status) or "disabled" + parts[#parts + 1] = tostring(name) .. "=" .. tostring(st) + end + table.sort(parts) + return table.concat(parts, "\1") +end + +-- Fingerprint a message list by message id + concatenated part text, so the +-- panel re-renders when message content actually changes (ids alone are stable +-- while a response streams in). +local function fp_messages(list) + if type(list) ~= "table" then return "" end + local parts = {} + for i, msg in ipairs(list) do + if type(msg) == "table" then + local info = msg.info + local id = (type(info) == "table" and info.id) or msg.id or "" + local text = "" + if type(msg.parts) == "table" then + local tparts = {} + for _, p in ipairs(msg.parts) do + if type(p) == "table" and type(p.text) == "string" then + tparts[#tparts + 1] = p.text + end + end + text = table.concat(tparts, "\2") + end + parts[i] = id .. "\2" .. text + end + end + return table.concat(parts, "\1") +end + +-- ── helpers ───────────────────────────────────────────────────────────────── + +local function is_connected() + local conn = noctalia.state.get(STATE.connection) + return type(conn) == "table" and conn.status ~= "offline" +end + +local function is_processing() + local conn = noctalia.state.get(STATE.connection) + return type(conn) == "table" and conn.status == "busy" +end + +local function is_waiting_permission() + local conn = noctalia.state.get(STATE.connection) + return type(conn) == "table" and conn.status == "waiting_permission" +end + +local function shq(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +local function dispatch_ipc(event, payload) + local cmd = "noctalia msg plugin '" .. SVC .. "' all " .. event + if payload then + -- Shell-escape the payload + payload = payload:gsub("'", "'\\''") + cmd = cmd .. " '" .. payload .. "'" + end + noctalia.runAsync(cmd) +end + +-- MCP status footer: 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 + +-- connected/total count). Clicking expands it to show one pill per server. +-- Collapsed by default so it never crowds the message list. +local MCP_META = { + connected = { fill = "primary/0.12", color = "primary", dot = "primary", key = "connected" }, + failed = { fill = "error/0.10", color = "error", dot = "error", key = "failed" }, + disabled = { fill = "surface_variant/0.45", color = "on_surface_variant", dot = "on_surface/0.35", key = "disabled" }, +} +local mcp_expanded = false +local function render_mcp_status() + local servers = noctalia.state.get(STATE.mcp) + if type(servers) ~= "table" then return nil end + local names = {} + for name in pairs(servers) do names[#names + 1] = name end + if #names == 0 then return nil end + table.sort(names) + + local connected, failed = 0, 0 + for _, name in ipairs(names) do + local st = (type(servers[name]) == "table" and servers[name].status) or "disabled" + if st == "connected" then connected = connected + 1 + elseif st == "failed" then failed = failed + 1 end + end + + local header = ui.button({ + text = tr("mcp.title") .. " (" .. tostring(connected) .. "/" .. tostring(#names) .. ")", + glyph = mcp_expanded and "chevron-up" or "chevron-right", + variant = "ghost", + tooltip = tr("mcp.title"), + onClick = function() + mcp_expanded = not mcp_expanded + render() + end, + }) + + if not mcp_expanded then + return ui.column({ gap = GAP_SM, key = "mcp_status" }, { header }) + end + + local rows = {} + for _, name in ipairs(names) do + local st = (type(servers[name]) == "table" and servers[name].status) or "disabled" + local meta = MCP_META[st] or MCP_META.disabled + rows[#rows + 1] = ui.row({ gap = GAP, align = "center", fill = meta.fill, radius = 8, paddingH = 9, paddingV = 6 }, { + ui.box({ width = 8, height = 8, radius = 4, fill = meta.dot }), + ui.label({ text = name, color = "on_surface", fontSize = FONT_SUB, flexGrow = 1, maxWidth = WRAP - 140, maxLines = 1 }), + ui.label({ text = tr("mcp." .. meta.key), color = meta.color, fontSize = 10, maxLines = 1 }), + }) + end + + return ui.column({ gap = GAP_SM, key = "mcp_status", padding = 10, radius = 10, fill = "surface/0.45" }, { + header, + unpack(rows), + }) +end + +-- ── session chooser view ──────────────────────────────────────────────────── + +local function render_session_chooser(sessions, conn) + local rows = {} + + -- Header + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "code-circle", color = "primary", size = compact and 18 or 24 }), + ui.label({ text = tr("chooser.title"), fontSize = FONT_TITLE, fontWeight = "bold", color = "on_surface" }), + }), + ui.row({ gap = 8 }, { + ui.button({ glyph = "refresh", onClick = function() + dispatch_ipc("refresh") + end, tooltip = tr("chooser.refresh") }), + }), + }) + + rows[#rows + 1] = ui.separator({}) + + -- New session button + rows[#rows + 1] = ui.button({ + text = tr("chooser.new_session"), + glyph = "plus", + onClick = function() + dispatch_ipc("create_session") + end, + }) + + -- Search box (live filter over the session list). A stable `key` keeps the + -- input node alive across render() calls so typed text/cursor survive the + -- re-render triggered by onChange. + rows[#rows + 1] = ui.input({ + key = "session_search", + value = session_query, + placeholder = tr("chooser.search"), + onChange = function(text) + session_query = text or "" + render() + end, + }) + + -- Filter sessions by the search query (case-insensitive substring over + -- title/slug/id/directory). Empty query shows everything. + local visible = sessions + if type(sessions) == "table" and session_query ~= "" then + local needle = string.lower(session_query) + visible = {} + for _, s in ipairs(sessions) do + if type(s) == "table" then + local hay = string.lower( + tostring(s.title or "") .. " " .. + tostring(s.slug or "") .. " " .. + tostring(s.id or "") .. " " .. + tostring(s.directory or "") + ) + if string.find(hay, needle, 1, true) then + visible[#visible + 1] = s + end + end + end + end + + -- Session list + local list_items = {} + if type(visible) ~= "table" or #visible == 0 then + list_items[#list_items + 1] = ui.label({ + text = session_query ~= "" and tr("chooser.no_match") or tr("chooser.empty"), + maxWidth = WRAP, + opacity = 0.7, + }) + else + for _, s in ipairs(visible) do + if type(s) == "table" and s.id then + local title = s.title or s.slug or s.id + local model_str = "" + if type(s.model) == "table" and s.model.id then + model_str = " · " .. (s.model.providerID or "?") .. "/" .. s.model.id + end + local time_str = "" + if type(s.time) == "table" and s.time.updated then + -- Simple relative time display + local now = os.time() * 1000 + local diff = now - s.time.updated + if diff < 60000 then + time_str = " · " .. tr("time.just_now") + elseif diff < 3600000 then + time_str = " · " .. tr("time.minutes_ago", { n = math.floor(diff / 60000) }) + elseif diff < 86400000 then + time_str = " · " .. tr("time.hours_ago", { n = math.floor(diff / 3600000) }) + else + time_str = " · " .. tr("time.days_ago", { n = math.floor(diff / 86400000) }) + end + end + + local row_id = s.id -- capture for closure + local is_active = (row_id == last_opened_id) + -- Record the opened session so the chooser can keep the row + -- highlighted after the user backs out to the list. + local function open_this() + last_opened_id = row_id + dispatch_ipc("select_session", row_id) + end + local actions + if pending_delete == row_id then + -- Inline confirmation for the destructive delete. + actions = ui.row({ gap = 6 }, { + ui.button({ + glyph = "check", + variant = "primary", + tooltip = tr("chooser.delete_confirm"), + onClick = function() + dispatch_ipc("delete_session", row_id) + pending_delete = nil + render() + end, + }), + ui.button({ + glyph = "x", + variant = "secondary", + tooltip = tr("chooser.delete_cancel"), + onClick = function() + pending_delete = nil + render() + end, + }), + }) + else + actions = ui.row({ gap = 6 }, { + ui.button({ + glyph = "trash", + variant = "secondary", + tooltip = tr("chooser.delete"), + onClick = function() + pending_delete = row_id + render() + end, + }), + ui.button({ + glyph = "arrow-right", + variant = is_active and "primary" or "secondary", + tooltip = tr("chooser.open"), + onClick = open_this, + }), + }) + end + -- Each session is a card. The most recently opened one is + -- highlighted with the accent (only ever one at a time). The + -- whole label area is clickable to open; action buttons on the + -- right stay separately clickable. + list_items[#list_items + 1] = ui.row({ + key = row_id, + justify = "space_between", + align = "center", + padding = CARD_PAD, + radius = CARD_RADIUS, + fill = is_active and "primary/0.15" or "surface", + border = is_active and "primary" or "surface", + borderWidth = is_active and 2 or 1, + }, { + ui.column({ + gap = GAP_SM, flexGrow = 1, + onClick = open_this, + }, { + ui.label({ + text = title, + maxWidth = WRAP - 140, + fontWeight = is_active and "bold" or "medium", + color = is_active and "primary" or "on_surface", + fontSize = compact and 12 or nil, + }), + (not compact) + and ui.label({ + text = (s.directory or "") .. model_str .. time_str, + maxWidth = WRAP - 140, + opacity = 0.6, + fontSize = FONT_SUB, + }) + or nil, + }), + actions, + }) + end + end + end + + rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = GAP, align = "stretch", justify = "start" }, list_items) + + panel.render(ui.column({ padding = PAD, gap = GAP, flexGrow = 1, align = "stretch", justify = "start" }, rows)) +end + +-- ── chat view ─────────────────────────────────────────────────────────────── + +local function render_message(msg_data) + if type(msg_data) ~= "table" then return nil end + local info = msg_data.info + local parts = msg_data.parts + if type(info) ~= "table" then return nil end + + local role = info.role or "unknown" + local cells = {} + + -- Render each part + if type(parts) == "table" then + for _, part in ipairs(parts) do + if type(part) == "table" then + local ptype = part.type + + if ptype == "text" and type(part.text) == "string" and part.text ~= "" then + -- The server prepends an injected context block (e.g. a + -- … section) to user messages. + -- Hide those synthetic parts so the bubble shows only what the + -- user actually typed. + local txt = part.text + local is_injected = (role == "user") and ( + txt:sub(1, 16) == "" + or txt:sub(1, 1) == "<" and txt:find("_context>", 1, true) ~= nil + ) + if not is_injected then + cells[#cells + 1] = ui.label({ + text = txt, + maxWidth = WRAP, + flexGrow = 1, + }) + end + elseif ptype == "reasoning" and type(part.text) == "string" and part.text ~= "" then + -- Only show reasoning if enabled (checked at render time via config) + local show_reasoning = noctalia.getConfig and noctalia.getConfig("show_reasoning") + if show_reasoning then + cells[#cells + 1] = ui.label({ + text = "> " .. part.text:gsub("\n", "\n> "), + maxWidth = WRAP, + opacity = 0.6, + fontSize = 12, + }) + end + elseif ptype == "tool" then + local show_tools = noctalia.getConfig and noctalia.getConfig("show_tool_calls") + if show_tools then + local tool_name = part.tool or "?" + local status = "unknown" + local detail = "" + if type(part.state) == "table" then + status = part.state.status or "?" + if type(part.state.title) == "string" then + detail = part.state.title + end + end + local status_icon = "circle" + if status == "completed" then status_icon = "circle-check" + elseif status == "error" then status_icon = "alert-circle" + elseif status == "pending" then status_icon = "loader" + end + cells[#cells + 1] = ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = status_icon, size = 14, color = status == "error" and "error" or "secondary" }), + ui.label({ text = tool_name .. (detail ~= "" and (" → " .. detail) or ""), fontSize = 12, opacity = 0.8 }), + }) + end + elseif ptype == "step-start" or ptype == "step-finish" then + -- Skip structural parts + end + end + end + end + + if #cells == 0 then return nil end + + local is_user = (role == "user") + local header_text = is_user and tr("chat.you") or tr("chat.assistant") + local header_color = is_user and "secondary" or "primary" + + local all_cells = { + ui.label({ text = header_text, fontWeight = "bold", color = header_color, fontSize = compact and 11 or 12 }), + } + for _, c in ipairs(cells) do + all_cells[#all_cells + 1] = c + end + + -- User messages hug the right edge, agent messages the left. + -- `align` on the column right/left-aligns the bubble content (maxWidth on + -- column is unsupported in beta.7, so we rely on align + label maxWidth). + -- A stable `key` (the message id) keeps each bubble's node identity across + -- re-renders so the scroll offset isn't reset while a reply streams in. + local msg_id = (type(info.id) == "string" and info.id) or "" + return ui.row({ key = "msg_" .. msg_id, justify = is_user and "end" or "start", align = "start" }, { + ui.column({ gap = BUBBLE_GAP, align = is_user and "end" or "start" }, all_cells), + }) +end + +-- Animated "thinking" bubble shown on the left while the agent is processing. +local function render_thinking() + local glyph_name = THINKING_GLYPHS[(thinking_frame % #THINKING_GLYPHS) + 1] + return ui.row({ justify = "start", align = "start" }, { + ui.column({ gap = BUBBLE_GAP, align = "start" }, { + ui.label({ text = tr("chat.assistant"), fontWeight = "bold", color = "primary", fontSize = compact and 11 or 12 }), + ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = glyph_name, color = "primary" }), + ui.label({ text = tr("chat.thinking"), opacity = 0.7, fontSize = compact and 12 or nil }), + }), + }), + }) +end + +local function render_permission_card(perm) + if type(perm) ~= "table" then return nil end + local perm_id = perm.permissionID or "?" + local session_id = perm.sessionID or "?" + + -- Compose a readable description from the v1 (`permission`/`patterns`) or + -- v2 (`action`/`resources`) fields the SSE event carries. + local message = "" + if type(perm.action) == "string" and perm.action ~= "" then + message = perm.action + elseif type(perm.permission) == "string" and perm.permission ~= "" then + message = perm.permission + end + if message == "" then + message = tr("permission.default_message") + end + local src = perm.resources + if type(src) ~= "table" then src = perm.patterns end + local detail_items = {} + if type(src) == "table" then + for _, s in ipairs(src) do + if type(s) == "string" and s ~= "" then + detail_items[#detail_items + 1] = s + end + end + end + local details = table.concat(detail_items, ", ") + + local cards = {} + + cards[#cards + 1] = ui.column({ gap = 6 }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "shield-exclamation", color = "error", size = 20 }), + ui.label({ text = tr("permission.title"), fontWeight = "bold", color = "error" }), + }), + ui.label({ text = message, maxWidth = WRAP }), + }) + + if details ~= "" then + cards[#cards + 1] = ui.label({ text = details, fontSize = 12, fontFamily = "monospace", maxWidth = WRAP }) + end + + -- Action buttons + cards[#cards + 1] = ui.row({ gap = 8 }, { + ui.button({ + text = tr("permission.allow"), + glyph = "check", + variant = "primary", + onClick = function() + dispatch_ipc("permission_response", perm_id .. ":allow") + end, + }), + ui.button({ + text = tr("permission.allow_remember"), + glyph = "checks", + variant = "secondary", + onClick = function() + dispatch_ipc("permission_response", perm_id .. ":allow:true") + end, + }), + ui.button({ + text = tr("permission.deny"), + glyph = "x", + variant = "secondary", + onClick = function() + dispatch_ipc("permission_response", perm_id .. ":deny") + end, + }), + }) + + return ui.column({ gap = 8, padding = 8 }, cards) +end + +local function render_question_card(q) + if type(q) ~= "table" then return nil end + local rid = q.requestID or q.id + local questions = q.questions + if not rid or type(questions) ~= "table" or #questions == 0 then return nil end + + -- Per-request selection store: choices[i] = chosen label for question i. + local choices = question_choices[rid] + if type(choices) ~= "table" then + choices = {} + question_choices[rid] = choices + end + + local cards = {} + cards[#cards + 1] = ui.column({ gap = 6 }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "message-circle-question", color = "primary", size = 20 }), + ui.label({ text = tr("question.title"), fontWeight = "bold", color = "primary" }), + }), + }) + + for qi, qinfo in ipairs(questions) do + if type(qinfo) == "table" then + cards[#cards + 1] = ui.label({ text = qinfo.question or "", maxWidth = WRAP }) + local opts = qinfo.options + if type(opts) == "table" then + local opt_row = {} + for _, o in ipairs(opts) do + if type(o) == "table" and type(o.label) == "string" and o.label ~= "" then + local label = o.label + opt_row[#opt_row + 1] = ui.button({ + text = label, + -- Highlight the chosen option; only record the + -- selection locally, never reply until every + -- question has an answer. + variant = (choices[qi] == label) and "primary" or "secondary", + selected = (choices[qi] == label), + onClick = function() + choices[qi] = label + render() + end, + }) + end + end + if #opt_row > 0 then + -- Stack option buttons vertically: avoids horizontal overflow + -- (no wrap support in beta.7) and reads like choice buttons. + cards[#cards + 1] = ui.column({ gap = 6 }, opt_row) + end + end + if qinfo.custom then + cards[#cards + 1] = ui.button({ + text = tr("question.custom"), + variant = (choices[qi] == "") and "primary" or "secondary", + selected = (choices[qi] == ""), + onClick = function() + -- Custom answer is encoded as an empty selection for + -- this question; still requires the others to be set. + choices[qi] = "" + render() + end, + }) + end + end + end + + -- Every question must have a selection before the reply can be sent. + local all_answered = true + for qi = 1, #questions do + if choices[qi] == nil then + all_answered = false + break + end + end + + cards[#cards + 1] = ui.row({ gap = 6 }, { + ui.button({ + text = tr("question.submit"), + glyph = "check", + variant = "primary", + enabled = all_answered, + flexGrow = 1, + onClick = function() + if not all_answered then return end + -- Build the outer array: one inner array per question, in order. + local inner = {} + for qi = 1, #questions do + local ok, enc = pcall(noctalia.json.encode, { choices[qi] }) + inner[#inner + 1] = ok and enc or "[\"\"]" + end + local payload = "[" .. table.concat(inner, ",") .. "]" + question_choices[rid] = nil + dispatch_ipc("question_reply", rid .. "\2" .. payload) + end, + }), + ui.button({ + text = tr("question.cancel"), + variant = "secondary", + glyph = "x", + onClick = function() + question_choices[rid] = nil + dispatch_ipc("question_reject", rid) + end, + }), + }) + + return ui.column({ gap = 8, padding = 8, fill = "surface" }, cards) +end + +local function render_chat(active, messages, permissions, questions, conn) + local rows = {} + + -- Header + local title = (type(active) == "table" and active.title) or tr("chat.title") + local model_str = "" + if type(active) == "table" and type(active.model) == "table" then + model_str = (active.model.providerID or "?") .. "/" .. (active.model.id or "?") + end + local agent_str = (type(active) == "table" and active.agent) or "?" + + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.row({ gap = 8, align = "center", flexGrow = 1 }, { + ui.button({ + glyph = "arrow-left", + variant = "secondary", + tooltip = tr("chat.back"), + onClick = function() + dispatch_ipc("deselect_session") + end, + }), + ui.glyph({ name = "code-circle", color = "primary", size = compact and 18 or 20 }), + ui.column({ gap = 0 }, { + ui.label({ text = title, fontWeight = "bold", maxWidth = WRAP - 200, fontSize = compact and 13 or 14 }), + (not compact) + and ui.label({ + text = agent_str .. (model_str ~= "" and (" · " .. model_str) or ""), + fontSize = FONT_SUB, + opacity = 0.6, + }) + or nil, + }), + }), + ui.row({ gap = 6 }, { + ui.button({ + glyph = "plus", + variant = "secondary", + tooltip = tr("chooser.new_session"), + onClick = function() + dispatch_ipc("create_session") + end, + }), + ui.button({ + glyph = "terminal", + tooltip = tr("chat.open_terminal"), + onClick = function() + local host = noctalia.getConfig and noctalia.getConfig("server_host") or "127.0.0.1" + local port = noctalia.getConfig and noctalia.getConfig("server_port") or 4096 + local url = "http://" .. host .. ":" .. tostring(port) + local sess = (type(active) == "table" and type(active.id) == "string") and active.id or nil + local cmd = "opencode attach " .. shq(url) + if sess and sess ~= "" then + cmd = cmd .. " --session " .. shq(sess) + end + noctalia.runInTerminal(cmd) + end, + }), + ui.button({ + glyph = "refresh", + variant = "secondary", + tooltip = tr("chat.refresh"), + onClick = function() + dispatch_ipc("refresh") + end, + }), + }), + }) + + -- Model + agent selectors. The service publishes option lists as arrays of + -- { value, label }; ui.select takes a flat label array + selectedIndex, and + -- its onChange fires with (indexString, labelText). We map the chosen index + -- back to the option's `value` and dispatch it to the service. + local model_options = noctalia.state.get(STATE.model_options) or {} + local agent_options = noctalia.state.get(STATE.agent_options) or {} + local sel_model = noctalia.state.get(STATE.selected_model) + local sel_agent = noctalia.state.get(STATE.selected_agent) + + local function build_select(key, opts, selected_value, placeholder_key, event) + local labels = {} + local values = {} + local sel_index = nil + for i, o in ipairs(opts) do + labels[i] = (type(o) == "table" and o.label) or tostring(o) + values[i] = (type(o) == "table" and o.value) or tostring(o) + if selected_value and values[i] == selected_value then + sel_index = i - 1 -- host uses 0-based indices + end + end + local props = { + key = key, + options = labels, + placeholder = tr(placeholder_key), + flexGrow = 1, + onChange = function(idx) + -- idx arrives as a 0-based index (string or number). + local n = tonumber(idx) + if n ~= nil then + local chosen = values[math.floor(n) + 1] + if type(chosen) == "string" and chosen ~= "" then + dispatch_ipc(event, chosen) + end + end + end, + } + if sel_index ~= nil then props.selectedIndex = sel_index end + return ui.select(props) + end + + if (type(model_options) == "table" and #model_options > 0) + or (type(agent_options) == "table" and #agent_options > 0) then + local selectors = {} + if type(model_options) == "table" and #model_options > 0 then + selectors[#selectors + 1] = build_select("model_select", model_options, sel_model, "chat.select_model", "set_model") + end + if type(agent_options) == "table" and #agent_options > 0 then + selectors[#selectors + 1] = build_select("agent_select", agent_options, sel_agent, "chat.select_agent", "set_agent") + end + rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, selectors) + end + + rows[#rows + 1] = ui.separator({}) + + -- Error banner: surface the last error (e.g. session.error from the server) + -- so failures are visible instead of a silently-stuck composer. + local err = noctalia.state.get(STATE.last_error) + if type(err) == "table" and type(err.message) == "string" and err.message ~= "" then + local banner = { + ui.glyph({ name = "alert-circle", color = "error", size = 16 }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }), + (type(err.detail) == "string" and err.detail ~= "") + and ui.label({ text = err.detail, fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 }) + or nil, + }), + ui.button({ + glyph = "x", + variant = "secondary", + tooltip = tr("chat.dismiss_error"), + onClick = function() + dispatch_ipc("clear_error") + end, + }), + } + rows[#rows + 1] = ui.row({ + gap = 8, align = "start", padding = 8, radius = 8, + fill = "surface", border = "error", borderWidth = 1, + }, banner) + end + + -- Permission cards (if any) + if type(permissions) == "table" and #permissions > 0 then + for _, perm in ipairs(permissions) do + local card = render_permission_card(perm) + if card then + rows[#rows + 1] = card + rows[#rows + 1] = ui.separator({}) + end + end + end + + -- Question/choice cards (if any) — the agent asked the user to pick. + if type(questions) == "table" and #questions > 0 then + for _, q in ipairs(questions) do + local card = render_question_card(q) + if card then + rows[#rows + 1] = card + rows[#rows + 1] = ui.separator({}) + end + end + end + + -- Message list, 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). + local msg_items = {} + if type(messages) ~= "table" or #messages == 0 then + msg_items[#msg_items + 1] = ui.label({ text = tr("chat.empty"), maxWidth = WRAP, opacity = 0.7 }) + else + -- "Waiting" (show thinking bubble) whenever the agent is processing and + -- the newest message is the user's optimistic bubble, or an assistant + -- bubble that has no text yet. The server creates the assistant message + -- (role="assistant") as soon as streaming starts, before any text part + -- exists, so we key off "assistant + has no text" not just the role. + local newest = messages[#messages] + local newest_is_assistant = type(newest) == "table" + and type(newest.info) == "table" and newest.info.role == "assistant" + local newest_has_text = false + if type(newest) == "table" and type(newest.parts) == "table" then + for _, p in ipairs(newest.parts) do + if type(p) == "table" and p.type == "text" + and type(p.text) == "string" and p.text ~= "" then + newest_has_text = true + break + end + end + end + -- Thinking shows after the user sends (newest = user bubble) and while + -- the agent is reasoning (newest = assistant bubble, no text yet). It + -- disappears once the assistant's first text part streams in. + local waiting = is_processing() + and (not newest_is_assistant or not newest_has_text) + -- Newest-first: build the list from the end so bubble order is latest + -- first, oldest last. + for i = #messages, 1, -1 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. + if waiting then + table.insert(msg_items, 1, render_thinking()) + end + end + + rows[#rows + 1] = ui.scroll({ key = "chat_messages", flexGrow = 1, gap = GAP }, msg_items) + + -- MCP status footer + local mcp_footer = render_mcp_status() + if mcp_footer then + rows[#rows + 1] = mcp_footer + end + + -- Composer: input always visible; sending is disabled while processing and a + -- stop button appears. The thinking indicator lives in the message list. + local processing = is_processing() + rows[#rows + 1] = ui.separator({}) + + -- Send the current draft, then clear the composer. Clearing requires bumping + -- the input's key so the reconciler rebuilds a fresh, empty input. + local function send_draft(text) + if processing then return end + text = text or draft + if type(text) ~= "string" or text == "" then return end + dispatch_ipc("send_prompt", text) + draft = "" + composer_seq = composer_seq + 1 + render() + end + + local composer_elems = { + ui.input({ + -- Key changes on each send to force a fresh (cleared) input. + key = "composer_" .. composer_seq, + value = draft, + placeholder = tr("chat.placeholder"), + multiline = true, + 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. + onSubmit = function(text) + send_draft(text) + end, + }), + ui.button({ + glyph = "send", + variant = "primary", + tooltip = tr("chat.send"), + onClick = function() + send_draft(draft) + end, + }), + } + if processing then + composer_elems[#composer_elems + 1] = ui.button({ + glyph = "player-stop", + variant = "secondary", + tooltip = tr("chat.stop"), + onClick = function() + dispatch_ipc("abort_session") + end, + }) + end + rows[#rows + 1] = ui.row({ gap = 8, align = "end" }, composer_elems) + + panel.render(ui.column({ padding = PAD, gap = GAP, flexGrow = 1 }, rows)) +end + +-- ── main render ───────────────────────────────────────────────────────────── + +render = function() + apply_layout() + local active = noctalia.state.get(STATE.active) + local sessions = noctalia.state.get(STATE.sessions) or {} + local messages = noctalia.state.get(STATE.messages) or {} + local permissions = noctalia.state.get(STATE.permissions) or {} + local questions = noctalia.state.get(STATE.questions) or {} + local conn = noctalia.state.get(STATE.connection) + + -- Keep the chooser highlight in sync with whatever session is actually + -- open (covers restore-on-boot and IPC-driven selection). + if type(active) == "table" and active.id then + last_opened_id = active.id + end + + -- Determine view + if type(active) ~= "table" or not active.id then + view = "session_chooser" + render_session_chooser(sessions, conn) + else + view = "chat" + render_chat(active, messages, permissions, questions, conn) + end +end + +local function fingerprint_state() + local active = noctalia.state.get(STATE.active) + local sessions = noctalia.state.get(STATE.sessions) + local messages = noctalia.state.get(STATE.messages) + local permissions = noctalia.state.get(STATE.permissions) + local questions = noctalia.state.get(STATE.questions) + local conn = noctalia.state.get(STATE.connection) + local err = noctalia.state.get(STATE.last_error) + local sel_model = noctalia.state.get(STATE.selected_model) + local sel_agent = noctalia.state.get(STATE.selected_agent) + local mcp = noctalia.state.get(STATE.mcp) + + return table.concat({ + (type(active) == "table" and active.id or "none"), + fp_list(sessions or {}, { "id", "title" }), + fp_messages(messages or {}), + fp_list(permissions or {}, { "permissionID", "sessionID" }), + fp_list(questions or {}, { "requestID", "sessionID" }), + (type(conn) == "table" and conn.status or "offline"), + (type(err) == "table" and tostring(err.message) or "none"), + -- Selection changes must re-render so the selectors reflect the pick. + tostring(sel_model or "") .. "\1" .. tostring(sel_agent or ""), + fp_mcp((type(mcp) == "table" and mcp or {})), + -- Animation frame drives the thinking spinner re-render while busy. + tostring(thinking_frame), + }, "\1") +end + +-- ── lifecycle ─────────────────────────────────────────────────────────────── + +function onOpen(_context) + panel.setWantsSecondTicks(true) + -- Subscribe to all relevant state changes + for _, key in pairs(STATE) do + noctalia.state.watch(key, function() + -- Debounce: only re-render if fingerprint changed + local fp = fingerprint_state() + if fp ~= snap_cache.fingerprint then + snap_cache.fingerprint = fp + render() + end + end) + end + render() +end + +function onClose() + -- Keep draft in memory — it persists for this panel instance +end + +function update() + -- Second tick: advance the thinking animation while processing, then + -- re-render only if state changed. + if is_processing() then + thinking_frame = thinking_frame + 1 + end + local fp = fingerprint_state() + if fp ~= snap_cache.fingerprint then + snap_cache.fingerprint = fp + render() + end +end diff --git a/opencode-companion/plugin.toml b/opencode-companion/plugin.toml new file mode 100644 index 0000000..0727d2b --- /dev/null +++ b/opencode-companion/plugin.toml @@ -0,0 +1,171 @@ +id = "weinguyen/opencode-companion" +name = "OpenCode Companion" +version = "0.1.0" +plugin_api = 3 +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." +tags = ["ai", "productivity", "development", "bar", "panel"] +dependencies = ["opencode"] + +# ── User settings ───────────────────────────────────────────────────────────── +[[setting]] +key = "server_mode" +type = "string" +default = "auto" +label_key = "settings.server_mode.label" +description_key = "settings.server_mode.description" + +[[setting]] +key = "server_host" +type = "string" +default = "127.0.0.1" +label_key = "settings.server_host.label" +description_key = "settings.server_host.description" + +[[setting]] +key = "server_port" +type = "double" +default = 4096 +label_key = "settings.server_port.label" +description_key = "settings.server_port.description" + +[[setting]] +key = "server_url" +type = "string" +default = "" +label_key = "settings.server_url.label" +description_key = "settings.server_url.description" + +[[setting]] +key = "default_workspace" +type = "folder" +default = "" +label_key = "settings.default_workspace.label" +description_key = "settings.default_workspace.description" + +[[setting]] +key = "default_model" +type = "string" +default = "" +label_key = "settings.default_model.label" +description_key = "settings.default_model.description" + +[[setting]] +key = "default_agent" +type = "string" +default = "build" +label_key = "settings.default_agent.label" +description_key = "settings.default_agent.description" + +[[setting]] +key = "auto_start" +type = "bool" +default = true +label_key = "settings.auto_start.label" +description_key = "settings.auto_start.description" + +[[setting]] +key = "show_tool_calls" +type = "bool" +default = true +label_key = "settings.show_tool_calls.label" +description_key = "settings.show_tool_calls.description" + +[[setting]] +key = "show_reasoning" +type = "bool" +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]] +key = "max_messages_load" +type = "int" +default = 50 +min = 1 +max = 101 +label_key = "settings.max_messages_load.label" +description_key = "settings.max_messages_load.description" + +[[setting]] +key = "language" +type = "select" +default = "auto" +label_key = "settings.language.label" +description_key = "settings.language.description" +options = [ + { value = "auto", label_key = "settings.language.options.auto" }, + { value = "en", label_key = "settings.language.options.en" }, + { value = "vi", label_key = "settings.language.options.vi" }, +] + +[[setting]] +key = "ui_mode" +type = "select" +default = "full" +label_key = "settings.ui_mode.label" +description_key = "settings.ui_mode.description" +options = [ + { value = "full", label_key = "settings.ui_mode.options.full" }, + { value = "compact", label_key = "settings.ui_mode.options.compact" }, +] + +[[setting]] +key = "debug_logging" +type = "bool" +default = false +label_key = "settings.debug_logging.label" +description_key = "settings.debug_logging.description" + +# Panel layout the widget opens. "fill_right" = full-height side bar pinned to +# the right edge; "compact" = the original floating panel near the click. Both +# are manifest-hosted panel entries, since placement/position are host-owned and +# read at load time (a plugin can't move its own panel at runtime). +[[setting]] +key = "panel_mode" +type = "select" +default = "fill_right" +label_key = "settings.panel_mode.label" +description_key = "settings.panel_mode.description" +options = [ + { value = "fill_right", label_key = "settings.panel_mode.options.fill_right" }, + { value = "compact", label_key = "settings.panel_mode.options.compact" }, +] + +# ── Entries ─────────────────────────────────────────────────────────────────── +[[widget]] +id = "widget" +entry = "widget.luau" + +# Full-height side bar, pinned to the right edge. +[[panel]] +id = "panel-fill" +entry = "panel.luau" +placement = "floating" +position = "center_right" +width = 560 +height = "fill" + +# Original floating panel near the click point. +[[panel]] +id = "panel" +entry = "panel.luau" +open_near_click = true +placement = "floating" +width = 560 +height = 720 + +[[service]] +id = "service" +entry = "service.luau" diff --git a/opencode-companion/service.luau b/opencode-companion/service.luau new file mode 100644 index 0000000..8129d75 --- /dev/null +++ b/opencode-companion/service.luau @@ -0,0 +1,1469 @@ + -- OpenCode Companion service — the headless backend and single source of truth. +-- +-- Responsibilities: +-- • Manage the OpenCode server lifecycle (auto or external mode) +-- • Maintain the SSE event stream connection +-- • Track active session, messages, and connection state +-- • Handle permission requests +-- • Publish shared state for widget and panel subscribers +-- • Persist boot-scoped session state +-- +-- The widget and panel are pure subscribers of `opencode.*` state keys. + +-- ── helpers ───────────────────────────────────────────────────────────────── + +-- Translation with an optional per-plugin language override (mirrors panel.luau). +-- noctalia.tr() follows the global shell locale, so a specific `language` choice +-- loads the plugin's own translations/.json (merged over en.json) and +-- resolves keys locally. "auto" delegates to noctalia.tr. +local i18n_cache = { lang = nil, table = nil } + +local function load_lang_table(lang) + if i18n_cache.lang == lang and i18n_cache.table then + return i18n_cache.table + end + local merged = {} + local function merge_file(path) + local ok, content = pcall(noctalia.readFile, path) + if not ok or type(content) ~= "string" or content == "" then return end + local ok2, data = pcall(noctalia.json.decode, content) + if not ok2 or type(data) ~= "table" then return end + local function deep(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + deep(dst[k], v) + else + dst[k] = v + end + end + end + deep(merged, data) + end + merge_file("translations/en.json") + if lang ~= "en" then + merge_file("translations/" .. lang .. ".json") + end + i18n_cache.lang = lang + i18n_cache.table = merged + return merged +end + +local function tr(key, args) + local lang = noctalia.getConfig and noctalia.getConfig("language") + if type(lang) ~= "string" or lang == "" or lang == "auto" then + return noctalia.tr(key, args) + end + local node = load_lang_table(lang) + for part in string.gmatch(key, "[^%.]+") do + if type(node) ~= "table" then node = nil break end + node = node[part] + end + if type(node) ~= "string" then + return noctalia.tr(key, args) + end + if type(args) == "table" then + node = string.gsub(node, "{(%w+)}", function(name) + local v = args[name] + if v == nil then return "{" .. name .. "}" end + return tostring(v) + end) + end + return node +end + +local function debug_log(msg) + if noctalia.getConfig and noctalia.getConfig("debug_logging") then + print("[opencode-companion] " .. tostring(msg)) + end +end + +-- Shell-quote for safe command composition +local function shq(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +-- Safe id validation for IPC / URL composition +local function safe_id(id) + return type(id) == "string" and id ~= "" and id:match("^[%w%._%-]+$") ~= nil +end + +-- ── config ─────────────────────────────────────────────────────────────────── + +local function get_server_mode() + local v = noctalia.getConfig and noctalia.getConfig("server_mode") + return (type(v) == "string" and v ~= "") and v or "auto" +end + +local function get_server_host() + local v = noctalia.getConfig and noctalia.getConfig("server_host") + return (type(v) == "string" and v ~= "") and v or "127.0.0.1" +end + +local function get_server_port() + local v = noctalia.getConfig and noctalia.getConfig("server_port") + return (type(v) == "number" and v > 0) and v or 4096 +end + +local function get_server_url() + local v = noctalia.getConfig and noctalia.getConfig("server_url") + return (type(v) == "string") and v or "" +end + +local function get_auto_start() + local v = noctalia.getConfig and noctalia.getConfig("auto_start") + return v ~= false +end + +local function get_default_workspace() + local v = noctalia.getConfig and noctalia.getConfig("default_workspace") + return (type(v) == "string") and v or "" +end + +local function get_default_model() + local v = noctalia.getConfig and noctalia.getConfig("default_model") + return (type(v) == "string") and v or "" +end + +local function get_default_agent() + local v = noctalia.getConfig and noctalia.getConfig("default_agent") + return (type(v) == "string" and v ~= "") and v or "build" +end + +local function get_max_messages() + local v = noctalia.getConfig and noctalia.getConfig("max_messages_load") + local n = (type(v) == "number" and v > 0) and math.floor(v) or 50 + -- The slider tops out at 101 which we treat as "unlimited": ask the server + -- for an effectively unbounded window so the whole session loads. The + -- server's /message?limit=N returns the N NEWEST messages (oldest-first + -- within that window), so a large N yields the full history. + if n >= 101 then + return 100000 + end + return n +end + +-- ── state ──────────────────────────────────────────────────────────────────── + +-- Connection state +local connection = { + status = "offline", + error = nil, + server_version = nil, +} + +-- Server process info (managed mode only) +local managed_pid = nil +local managed_port = nil + +-- Active session tracking +local active_session = nil +local sessions = {} +local messages = {} +local session_status = {} +local pending_permissions = {} +local pending_questions = {} -- [{ requestID, sessionID, questions, tool }] +local mcp_status = {} +local providers = {} +local agents = {} +local model_options = {} -- [{ value, label }] for the model picker +local agent_options = {} -- [{ value, label }] for the agent picker +local unread_count = 0 +local last_error = nil + +-- Optimistic user message appended on send, replaced once the server echoes it. +local optimistic_msg = nil + +-- 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), +-- which otherwise locks the composer ("can only send once"). +local busy_since = nil +local BUSY_STALE_S = 180 + +-- Cheap guard: skip re-decoding an identical messages payload. SSE fires many +-- part-updated events per reply; without this each one re-decodes the full +-- (often large) JSON and can blow the callback CPU budget. +local last_messages_body = nil + +-- Runtime-selected model / agent (override the configured defaults for this +-- session without editing settings). Set via IPC from the panel selectors. +-- `selected_model` is ONLY set when the user explicitly picks one; when nil we +-- send no model override so the server uses the session's own working default. +-- Auto-seeding this from /config/providers `default` was the cause of the +-- "session encountered an error" bug: `default` can list several providers and +-- pairs() picked an arbitrary (possibly unauthenticated) one, forcing every +-- turn onto a model the server couldn't run. +local selected_model = nil -- "providerID/modelID" or nil = no override +local selected_agent = nil -- agent name or nil = no override +-- Model to preselect in the picker for display only (never auto-sent). +local default_model_display = nil + +-- SSE connection state +local sse_stream = nil +local sse_reconnect_attempts = 0 +local sse_max_reconnect = 10 + +-- Boot-scoped state +local boot_id = nil +local state_file = nil + +-- Restore the previously-active session only once per boot. load_sessions() +-- runs after every delete/create/refresh, and without this guard it would +-- re-select the persisted session on every reload — making deleting a session +-- from the chooser "jump" into whatever session was last active. +local restore_done = false + +-- Forward declarations: these functions are defined later in the file but are +-- referenced by earlier callbacks (health checks, session loading). Declaring +-- them up front keeps the references in scope so the async callbacks don't hit +-- a nil global when they fire. +local load_initial_data +local restore_active_session +local select_session + +-- ── boot id persistence ────────────────────────────────────────────────────── + +local function read_boot_id() + local ok, content = pcall(noctalia.readFile, "/proc/sys/kernel/random/boot_id") + if not ok or type(content) ~= "string" or content == "" then return nil end + local id = content:match("^[^\n\r]*") + if id then id = id:gsub("^%s+", ""):gsub("%s+$", "") end + return (id and id ~= "") and id or nil +end + +local function load_boot_state() + if not state_file then return nil end + local ok, content = pcall(noctalia.readFile, state_file) + if not ok or type(content) ~= "string" or content == "" then return nil end + local ok2, data = pcall(noctalia.json.decode, content) + if not ok2 or type(data) ~= "table" then return nil end + return data +end + +local function save_boot_state(state) + if not state_file then return end + local ok, encoded = pcall(noctalia.json.encode, state) + if not ok then return end + pcall(noctalia.writeFile, state_file, encoded) +end + +local function clear_boot_state() + if not state_file then return end + pcall(noctalia.writeFile, state_file, "{}") +end + +local function persist_active_session() + if not boot_id then return end + local data = load_boot_state() or {} + data.boot_id = boot_id + data.active_session_id = active_session and active_session.id or nil + data.workspace = get_default_workspace() + data.draft = nil -- panel handles draft persistence separately + save_boot_state(data) +end + +local function restore_session_if_same_boot() + local data = load_boot_state() + if not data or type(data) ~= "table" then return false end + if data.boot_id ~= boot_id then + -- Different boot: clear stale session reference + clear_boot_state() + return false + end + if data.active_session_id and safe_id(data.active_session_id) then + -- Try to validate the session still exists + return data.active_session_id + end + return false +end + +-- ── http client ────────────────────────────────────────────────────────────── + +local API = {} + +function API.request(method, path, body, callback) + local mode = get_server_mode() + local base_url + if mode == "external" then + base_url = get_server_url() + if base_url == "" then + callback({ ok = false, status = 0, body = "external server URL not configured" }) + return + end + else + base_url = "http://" .. get_server_host() .. ":" .. tostring(get_server_port()) + end + + local url = base_url .. path + local headers = { "Accept: application/json" } + local body_str = nil + + if body then + body_str = noctalia.json.encode(body) + table.insert(headers, "Content-Type: application/json") + end + + local request = { + url = url, + method = method, + headers = headers, + body = body_str, + follow_redirects = false, + } + + noctalia.http(request, callback) +end + +function API.get(path, callback) + API.request("GET", path, nil, callback) +end + +function API.post(path, body, callback) + API.request("POST", path, body, callback) +end + +function API.patch(path, body, callback) + API.request("PATCH", path, body, callback) +end + +function API.delete(path, callback) + API.request("DELETE", path, nil, callback) +end + +function API.health(callback) + API.get("/global/health", callback) +end + +-- ── server lifecycle ───────────────────────────────────────────────────────── + +-- Dirty tracking: publish() only writes state keys that changed since the last +-- call. Publishing every key on every change is expensive (large tables like +-- sessions/messages get re-serialized each time) and blows the per-callback CPU +-- budget, which silently kills IPC/SSE handlers — the cause of "can only send +-- one message". +local dirty = {} + +local function mark_dirty(key) + dirty[key] = true +end + +local function publish() + if dirty.connection or dirty.all then + noctalia.state.set("opencode.connection", connection) + noctalia.state.set("opencode.server_version", connection.server_version) + end + if dirty.active_session or dirty.all then + noctalia.state.set("opencode.active_session", active_session) + end + if dirty.sessions or dirty.all then + noctalia.state.set("opencode.sessions", sessions) + end + if dirty.messages or dirty.all then + noctalia.state.set("opencode.messages", messages) + end + if dirty.session_status or dirty.all then + noctalia.state.set("opencode.session_status", session_status) + end + if dirty.mcp_status or dirty.all then + noctalia.state.set("opencode.mcp_status", mcp_status) + end + if dirty.pending_permissions or dirty.all then + noctalia.state.set("opencode.pending_permissions", pending_permissions) + end + if dirty.pending_questions or dirty.all then + noctalia.state.set("opencode.pending_questions", pending_questions) + end + if dirty.unread_count or dirty.all then + noctalia.state.set("opencode.unread_count", unread_count) + end + if dirty.last_error or dirty.all then + noctalia.state.set("opencode.last_error", last_error) + end + if dirty.selection or dirty.all then + -- Display value: show the server default until the user overrides it. + noctalia.state.set("opencode.selected_model", selected_model or default_model_display) + noctalia.state.set("opencode.selected_agent", selected_agent) + noctalia.state.set("opencode.model_options", model_options) + noctalia.state.set("opencode.agent_options", agent_options) + end + dirty = {} +end + +local function set_connection_status(status, err) + connection.status = status + connection.error = err + if status == "busy" then + busy_since = os.time() + else + busy_since = nil + end + mark_dirty("connection") + publish() +end + +local function set_last_error(message, detail) + last_error = { message = message, detail = detail } + mark_dirty("last_error") + publish() +end + +local function clear_last_error() + last_error = nil + mark_dirty("last_error") + publish() +end + +local function find_opencode_on_path() + if noctalia.commandExists("opencode") then + return "opencode" + end + return nil +end + +local function start_managed_server() + local host = get_server_host() + local port = get_server_port() + + -- Check health first — server may already be running + API.health(function(resp) + if resp.ok and resp.status == 200 then + 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() + return + end + end + + -- No healthy server — start one + local exe = find_opencode_on_path() + if not exe then + set_connection_status("offline", tr("error.exe_not_found")) + set_last_error(tr("error.exe_not_found_detail")) + return + end + + local workspace = get_default_workspace() + local cmd = "opencode serve --hostname " .. shq(host) .. " --port " .. tostring(port) + if workspace ~= "" then + cmd = "cd " .. shq(workspace) .. " && " .. cmd + end + + debug_log("Starting managed server: " .. cmd) + set_connection_status("starting") + + noctalia.runAsync(cmd, function(result) + -- Server should not exit immediately; if it did, there's an error + if result.exitCode ~= 0 then + local err = (result.stderr ~= "" and result.stderr or result.stdout or "unknown") + err = err:gsub("[\r\n]", " "):gsub("^%s+", ""):gsub("%s+$", "") + set_connection_status("offline", err) + set_last_error(tr("error.server_failed"), err) + end + end) + + -- Retry health check with backoff + local attempts = 0 + local function retry_health() + attempts = attempts + 1 + if attempts > 10 then + set_connection_status("offline", tr("error.server_timeout")) + set_last_error(tr("error.server_timeout_detail")) + return + end + local delay = math.min(1000 * (2 ^ attempts), 16000) + -- Use a simple timer pattern: schedule next check + -- Since we don't have real timers in service, poll via state change + -- For now, try health immediately with increasing waits + API.health(function(resp2) + if resp2.ok and resp2.status == 200 then + 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() + return + end + end + -- Schedule retry + local cmd2 = "sleep " .. tostring(delay / 1000) .. " && echo done" + noctalia.runAsync(cmd2, function(_) + retry_health() + end) + end) + end + + -- Start retry chain after a short initial delay + noctalia.runAsync("sleep 1 && echo done", function(_) + retry_health() + end) + end) +end + +local function connect_external() + local url = get_server_url() + if url == "" then + set_connection_status("offline", tr("error.external_not_configured")) + return + end + + set_connection_status("starting") + debug_log("Connecting to external server: " .. url) + + API.health(function(resp) + if resp.ok and resp.status == 200 then + local ok, data = pcall(noctalia.json.decode, resp.body) + if ok and data.healthy then + connection.server_version = data.version + set_connection_status("online") + debug_log("Connected to external server") + load_initial_data() + return + end + end + local err = tr("error.connection_failed") .. " (HTTP " .. tostring(resp.status) .. ")" + set_connection_status("offline", err) + set_last_error(tr("error.connection_failed_detail"), resp.body) + end) +end + +local function connect() + clear_last_error() + sse_reconnect_attempts = 0 + local mode = get_server_mode() + if mode == "external" then + connect_external() + else + start_managed_server() + end +end + +-- ── data loading ───────────────────────────────────────────────────────────── + +local function load_sessions() + -- Cap the session list: decoding the full history (can be 90+ sessions / + -- tens of KB) in one async callback blows the CPU budget. The chooser only + -- needs the most recent sessions. + API.get("/session?limit=40", function(resp) + if not (resp.ok and resp.status == 200) then + debug_log("Failed to load sessions: " .. tostring(resp.status)) + return + end + local ok, data = pcall(noctalia.json.decode, resp.body) + if not ok or type(data) ~= "table" then + debug_log("Failed to parse sessions response") + return + end + -- Sort by updated time descending + table.sort(data, function(a, b) + local ta = (a.time and a.time.updated) or 0 + local tb = (b.time and b.time.updated) or 0 + return ta > tb + end) + -- Defensive dedup by id (a session must never appear twice). + local seen = {} + local deduped = {} + for _, s in ipairs(data) do + if type(s) == "table" and s.id and not seen[s.id] then + seen[s.id] = true + deduped[#deduped + 1] = s + end + end + sessions = deduped + mark_dirty("sessions") + publish() + -- Try to restore active session + restore_active_session() + end) +end + +-- Restore the previously active session if it belongs to the same boot. +restore_active_session = function() + if restore_done then return end + restore_done = true + local id = restore_session_if_same_boot() + if id then + select_session(id) + end +end + +local function load_messages(session_id, limit) + if not safe_id(session_id) then return end + local lim = limit or get_max_messages() + local path = "/session/" .. session_id .. "/message?limit=" .. tostring(lim) + API.get(path, function(resp) + if not (resp.ok and resp.status == 200) then + debug_log("Failed to load messages: " .. tostring(resp.status)) + return + end + -- Nothing changed since last load: skip the decode + reconcile entirely. + if resp.body == last_messages_body and not optimistic_msg then + return + end + last_messages_body = resp.body + local ok, data = pcall(noctalia.json.decode, resp.body) + if not ok or type(data) ~= "table" then + debug_log("Failed to parse messages response") + return + end + messages = data + -- Re-append the optimistic user message if the server hasn't echoed it + -- yet, so the just-sent message doesn't flicker out before confirmation. + -- + -- The server injects extra parts (e.g. a block) into the + -- real user message, so the sent text is usually parts[2..], not parts[1]. + -- Match by scanning ALL text parts of every user message for our text. + if optimistic_msg then + local want = optimistic_msg.parts[1].text + local echoed = false + for _, m in ipairs(messages) do + if type(m) == "table" and type(m.info) == "table" and m.info.role == "user" + and type(m.parts) == "table" then + for _, p in ipairs(m.parts) do + if type(p) == "table" and type(p.text) == "string" + and (p.text == want or p.text:find(want, 1, true)) then + echoed = true + break + end + end + end + if echoed then break end + end + if not echoed then + messages[#messages + 1] = optimistic_msg + else + optimistic_msg = nil + end + end + -- Defensive dedup by message id (a message must never render twice). + local seen = {} + local deduped = {} + for _, m in ipairs(messages) do + local mid = (type(m) == "table" and type(m.info) == "table" and m.info.id) or nil + if not mid or not seen[mid] then + if mid then seen[mid] = true end + deduped[#deduped + 1] = m + end + end + messages = deduped + mark_dirty("messages") + publish() + end) +end + +local function load_mcp_status() + API.get("/mcp", function(resp) + if not (resp.ok and resp.status == 200) then + debug_log("Failed to load MCP status") + return + end + local ok, data = pcall(noctalia.json.decode, resp.body) + if not ok or type(data) ~= "table" then + debug_log("Failed to parse MCP response") + return + end + mcp_status = data + mark_dirty("mcp_status") + publish() + end) +end + +local function load_providers() + API.get("/config/providers", function(resp) + if not (resp.ok and resp.status == 200) then + debug_log("Failed to load providers") + return + end + local ok, data = pcall(noctalia.json.decode, resp.body) + if not ok or type(data) ~= "table" then + debug_log("Failed to parse providers response") + return + end + -- Flatten providers into a simple list, and build a flat model-option + -- list the panel's select can consume: { value = "prov/model", label }. + -- NOTE: /config/providers returns a LIST of provider objects, each with + -- its own .id. Iterate with ipairs and use the object's real id (not the + -- array index) — using the index produced values like "1/Code" that the + -- server can't resolve ("Model not found: Code"). + local list = {} + local opts = {} + if type(data.providers) == "table" then + for _, p in ipairs(data.providers) do + if type(p) == "table" and type(p.id) == "string" and p.id ~= "" then + local pid = p.id + table.insert(list, p) + if type(p.models) == "table" then + for model_id, _ in pairs(p.models) do + opts[#opts + 1] = { + value = pid .. "/" .. model_id, + label = model_id .. " (" .. pid .. ")", + } + end + end + end + end + end + table.sort(opts, function(a, b) return a.label < b.label end) + providers = list + model_options = opts + -- Seed a DISPLAY-ONLY default for the picker (never auto-sent). Prefer + -- the configured default_model, else the first server default entry. + if not default_model_display then + local cfg = get_default_model() + if cfg ~= "" then + default_model_display = cfg + elseif type(data.default) == "table" then + for prov_id, model_id in pairs(data.default) do + default_model_display = prov_id .. "/" .. model_id + break + end + end + end + mark_dirty("providers") + mark_dirty("selection") + publish() + end) +end + +local function load_agents() + API.get("/agent", function(resp) + if not (resp.ok and resp.status == 200) then + debug_log("Failed to load agents") + return + end + local ok, data = pcall(noctalia.json.decode, resp.body) + if not ok or type(data) ~= "table" then + debug_log("Failed to parse agents response") + return + end + agents = data + -- Build flat agent-option list; only primary agents are user-selectable. + local opts = {} + if type(data) == "table" then + for _, a in ipairs(data) do + if type(a) == "table" and type(a.name) == "string" then + if a.mode == "primary" or a.mode == nil then + opts[#opts + 1] = { value = a.name, label = a.name } + end + end + end + end + table.sort(opts, function(x, y) return x.label < y.label end) + agent_options = opts + mark_dirty("agents") + publish() + end) +end + +load_initial_data = function() + load_sessions() + load_mcp_status() + load_providers() + load_agents() + start_sse() +end + +-- ── session management ─────────────────────────────────────────────────────── + +local function set_active_session(session) + active_session = session + messages = {} + last_messages_body = nil -- force a fresh decode for the new session + if not session then + optimistic_msg = nil + end + mark_dirty("active_session") + mark_dirty("messages") + if session and session.id then + load_messages(session.id) + persist_active_session() + end + publish() +end + +select_session = function(session_id) + if not safe_id(session_id) then return end + -- Find session in list + for _, s in ipairs(sessions) do + if s.id == session_id then + set_active_session(s) + return + end + end + -- Not in list — fetch it + API.get("/session/" .. session_id, function(resp) + if resp.ok and resp.status == 200 then + local ok, data = pcall(noctalia.json.decode, resp.body) + if ok and type(data) == "table" then + set_active_session(data) + return + end + end + -- Session no longer exists + set_last_error(tr("error.session_not_found"), session_id) + active_session = nil + mark_dirty("active_session") + publish() + end) +end + +local function create_session(title) + local body = {} + if type(title) == "string" and title ~= "" then + body.title = title + end + API.post("/session", body, function(resp) + if resp.ok and resp.status == 200 then + local ok, data = pcall(noctalia.json.decode, resp.body) + if ok and type(data) == "table" then + set_active_session(data) + load_sessions() -- refresh list + clear_last_error() + return + end + end + set_last_error(tr("error.create_session_failed"), resp.body) + end) +end + +local function delete_session(session_id) + if not safe_id(session_id) then return end + API.delete("/session/" .. session_id, function(resp) + if resp.ok then + if active_session and active_session.id == session_id then + active_session = nil + messages = {} + mark_dirty("active_session") + mark_dirty("messages") + persist_active_session() + end + load_sessions() + else + set_last_error(tr("error.delete_session_failed"), "HTTP " .. tostring(resp.status)) + end + end) +end + +local function abort_session(session_id) + if not safe_id(session_id) then return end + API.post("/session/" .. session_id .. "/abort", {}, function(resp) + if not resp.ok then + set_last_error(tr("error.abort_failed"), "HTTP " .. tostring(resp.status)) + end + end) +end + +local function rename_session(session_id, title) + if not safe_id(session_id) then return end + API.patch("/session/" .. session_id, { title = title }, function(resp) + if resp.ok and resp.status == 200 then + load_sessions() + -- Update active session if it's the one renamed + if active_session and active_session.id == session_id then + local ok, data = pcall(noctalia.json.decode, resp.body) + if ok then + active_session = data + mark_dirty("active_session") + publish() + end + end + end + end) +end + +-- ── messaging ──────────────────────────────────────────────────────────────── + +-- Only send a model override when the model actually exists in the provider +-- list the server reported. A stale/unknown override (e.g. a configured +-- `default_model` pointing at a provider/model the server can't run) otherwise +-- gets sent on every turn, failing the session repeatedly — while the CLI, which +-- sends no override, works fine. When unknown, fall back to no override so the +-- server uses the session's own default. +local function model_is_known(model) + if type(model) ~= "string" or model == "" then return false end + for _, o in ipairs(model_options) do + if o.value == model then return true end + end + return false +end + +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 + + -- 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). + local now_ms = os.time() * 1000 + optimistic_msg = { + info = { + id = "local-" .. tostring(now_ms), + role = "user", + sessionID = session_id, + time = { created = now_ms }, + }, + parts = { { type = "text", text = text } }, + } + messages[#messages + 1] = optimistic_msg + mark_dirty("messages") + publish() + + -- Mark busy immediately so the thinking indicator shows right away. + set_connection_status("busy") + + -- Build parts + local parts = { { type = "text", text = text } } + local body = { parts = parts } + + if type(model) == "string" and model ~= "" then + -- Parse "provider/model" format + local provider_id, model_id = model:match("([^/]+)/(.+)") + if provider_id and model_id then + body.model = { providerID = provider_id, modelID = model_id } + end + end + if type(agent) == "string" and agent ~= "" then + body.agent = agent + end + + -- Use prompt_async to not block + API.post("/session/" .. session_id .. "/prompt_async", body, function(resp) + if resp.ok then + clear_last_error() + else + -- The turn was rejected (busy session, bad model, server error…). + -- Recover: drop the optimistic bubble and re-enable the composer. + optimistic_msg = nil + mark_dirty("messages") + set_connection_status("online") + set_last_error(tr("error.prompt_failed"), + "HTTP " .. tostring(resp.status) .. (resp.body and (": " .. tostring(resp.body)) or "")) + publish() + end + end) +end + +-- ── permissions ────────────────────────────────────────────────────────────── + +local function respond_to_permission(session_id, permission_id, response, remember) + -- The panel sends response = "allow" | "allow:true" | "deny". Map those to + -- the server's reply enum: once / always / reject. + if not safe_id(permission_id) then return end + local reply = "reject" + if response == "allow" then + reply = remember and "always" or "once" + end + local body = { reply = reply } + API.post("/permission/" .. permission_id .. "/reply", body, function(resp) + if resp.ok then + -- Remove from pending + for i, p in ipairs(pending_permissions) do + if p.permissionID == permission_id then + table.remove(pending_permissions, i) + break + end + end + mark_dirty("pending_permissions") + set_connection_status("online") + publish() + else + set_last_error(tr("error.permission_failed"), "HTTP " .. tostring(resp.status)) + end + end) +end + +local function respond_to_question(request_id, answers) + -- answers: array of arrays of selected option labels (one inner array per + -- question, in order). The request body must be a JSON array of arrays. + if not safe_id(request_id) then return end + if type(answers) ~= "table" then return end + local body = { answers = answers } + API.post("/question/" .. request_id .. "/reply", body, function(resp) + if resp.ok then + for i, q in ipairs(pending_questions) do + if q.requestID == request_id then + table.remove(pending_questions, i) + break + end + end + mark_dirty("pending_questions") + set_connection_status("online") + publish() + else + set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status)) + end + end) +end + +local function reject_question(request_id) + if not safe_id(request_id) then return end + API.post("/question/" .. request_id .. "/reject", {}, function(resp) + if resp.ok then + for i, q in ipairs(pending_questions) do + if q.requestID == request_id then + table.remove(pending_questions, i) + break + end + end + mark_dirty("pending_questions") + set_connection_status("online") + publish() + else + set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status)) + end + end) +end + +-- ── SSE event stream ──────────────────────────────────────────────────────── + +local SSE = {} + +-- Throttle for message reloads during streaming (os.clock gives fractional seconds) +last_messages_reload = 0 +RELOAD_THROTTLE_S = 0.3 + +-- Dedup set for unread counting (prevents inflating count on every part update) +local counted_messages = {} +local COUNTED_MAX = 100 + +-- Parse SSE event data +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) + local ok, data = pcall(noctalia.json.decode, json_str) + if ok and type(data) == "table" then + return data.type, data + end + end + return nil, nil +end + +function SSE.start() + if sse_stream then return end -- already connected + local mode = get_server_mode() + local base_url + if mode == "external" then + base_url = get_server_url() + if base_url == "" then return end + else + base_url = "http://" .. get_server_host() .. ":" .. tostring(get_server_port()) + end + + local url = base_url .. "/event" + local request = { + url = url, + method = "GET", + headers = { "Accept: text/event-stream" }, + } + + local buffer = "" + + sse_stream = noctalia.httpStream(request, + -- onProgress: each line + function(line) + buffer = buffer .. line .. "\n" + -- Process complete events (double newline) + while true do + local event_end = buffer:find("\n\n") + if not event_end then break end + local event_data = buffer:sub(1, event_end) + buffer = buffer:sub(event_end + 2) + + -- Extract data lines + for data_line in event_data:gmatch("([^\r\n]+)") do + local event_type, event = parse_sse_line(data_line) + if event_type then + SSE.handle_event(event_type, event) + end + end + end + end, + -- onFinish + function(result) + sse_stream = nil + sse_reconnect_attempts = sse_reconnect_attempts + 1 + if sse_reconnect_attempts <= sse_max_reconnect then + -- Reconnect with backoff + local delay = math.min(1000 * (2 ^ sse_reconnect_attempts), 30000) + -- Schedule reconnect via a sleep command + noctalia.runAsync("sleep " .. tostring(delay / 1000) .. " && echo done", function(_) + SSE.start() + end) + else + set_last_error(tr("error.sse_disconnected")) + end + end + ) +end + +function SSE.stop() + -- The stream will close when the server closes it; we just clear our ref + sse_stream = nil +end + +function SSE.handle_event(event_type, event) + debug_log("SSE event: " .. tostring(event_type)) + + if event_type == "server.connected" or event_type == "server.heartbeat" then + -- Connection lifecycle event; heartbeat is a keepalive ping — no-op. + 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 + -- Throttle message reloads during streaming + if active_session and session_id == active_session.id then + local now = os.clock() + if now - last_messages_reload > RELOAD_THROTTLE_S then + last_messages_reload = now + load_messages(active_session.id) + end + end + -- Track unread once per message (dedup by message ID) + if props and props.role == "assistant" then + local msg_id = props.messageID or props.id + if msg_id and not counted_messages[msg_id] then + counted_messages[msg_id] = true + unread_count = unread_count + 1 + mark_dirty("unread_count") + publish() + -- Prevent unbounded growth + local n = 0 + for _ in pairs(counted_messages) do n = n + 1 end + if n > COUNTED_MAX then + counted_messages = {} + end + end + end + return + end + + if event_type == "session.status" then + local session_id = event.sessionID or (event.properties and event.properties.sessionID) + local status = event.status or (event.properties and event.properties.status) + -- status arrives as a table like { type = "busy" }; normalize to the string. + local st = type(status) == "table" and status.type or status + if session_id and st then + session_status[session_id] = st + mark_dirty("session_status") + -- Update connection status based on session activity + if active_session and active_session.id == session_id then + if st == "processing" then + 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 + end + publish() + end + return + end + + if event_type == "session.idle" then + local session_id = event.sessionID or (event.properties and event.properties.sessionID) + if session_id then + session_status[session_id] = "idle" + 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 + publish() + end + return + end + + -- Session-level error: the server aborts the turn. Recover the UI so the + -- composer is usable again, surface the real error, and drop the optimistic + -- user bubble (the server did not accept the turn). + if event_type == "session.error" then + local props = event.properties or event.data or event + local session_id = props and props.sessionID + local err = props and props.error + 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" + mark_dirty("session_status") + 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 + set_last_error(tr("error.session_error"), tostring(detail)) + end + optimistic_msg = nil + set_connection_status("online") + unread_count = 0 + mark_dirty("unread_count") + if session_id then load_messages(session_id) end + end + publish() + return + end + + -- Permission events: the agent asks the user to approve a tool action. + -- Both `permission.asked` (v1) and `permission.v2.asked` (v2) carry the + -- request id in `properties.id` (^per) — NOT `permissionID`. Normalize that + -- into `permissionID` for the panel's permission card. + if event_type == "permission.asked" or event_type == "permission.v2.asked" then + local props = event.properties or event + if props and props.id and props.sessionID then + local perm = { + permissionID = props.id, + sessionID = props.sessionID, + permission = props.permission, + patterns = props.patterns, + action = props.action, + resources = props.resources, + save = props.save, + metadata = props.metadata, + tool = props.tool, + } + -- Reuse an existing pending card for the same id instead of duplicating. + local found = false + for _, p in ipairs(pending_permissions) do + if p.permissionID == props.id then + found = true + break + end + end + if not found then + table.insert(pending_permissions, perm) + end + mark_dirty("pending_permissions") + set_connection_status("waiting_permission") + publish() + noctalia.notify(tr("notify.permission_title"), tr("notify.permission_body")) + end + return + end + + -- The permission was answered (here or on another surface); drop the card. + if event_type == "permission.replied" or event_type == "permission.v2.replied" then + local props = event.properties or event + local rid = props and props.requestID + if rid then + for i, p in ipairs(pending_permissions) do + if p.permissionID == rid then + table.remove(pending_permissions, i) + break + end + end + mark_dirty("pending_permissions") + set_connection_status("online") + publish() + end + return + end + + -- Question/choice events: the agent asks the user to pick among options. + if event_type == "question.asked" then + local props = event.properties or event + if props and props.sessionID and type(props.questions) == "table" and #props.questions > 0 then + local req = { + requestID = props.id or props.requestID, + sessionID = props.sessionID, + questions = props.questions, + tool = props.tool, + } + -- Reuse an existing pending request for the same id instead of duplicating. + local found = false + for _, q in ipairs(pending_questions) do + if q.requestID == req.requestID then + q.questions = req.questions + found = true + break + end + end + if not found then + table.insert(pending_questions, req) + end + mark_dirty("pending_questions") + set_connection_status("waiting_permission") + publish() + end + return + end + + -- The agent already got an answer (from another surface); drop the prompt. + if event_type == "question.replied" or event_type == "question.rejected" then + local props = event.properties or event + local rid = props and props.requestID + if rid then + for i, q in ipairs(pending_questions) do + if q.requestID == rid then + table.remove(pending_questions, i) + break + end + end + mark_dirty("pending_questions") + set_connection_status("online") + publish() + end + return + end + + -- Unknown event — log if debug + debug_log("Unknown event: " .. tostring(event_type)) +end + +function start_sse() + SSE.start() +end + +function stop_sse() + SSE.stop() +end + +-- ── ipc handling ───────────────────────────────────────────────────────────── + +function onIpc(event, payload) + debug_log("IPC: " .. tostring(event)) + + -- Self-heal a stale busy status: if we've been "busy" longer than the + -- threshold with no idle/error event, the SSE update was likely dropped. + -- Clear it so the composer unlocks and sending works again. + if connection.status == "busy" and busy_since and (os.time() - busy_since) > BUSY_STALE_S then + optimistic_msg = nil + mark_dirty("messages") + set_connection_status("online") + if active_session then load_messages(active_session.id) end + end + + if event == "select_session" then + select_session(payload) + elseif event == "deselect_session" then + set_active_session(nil) + elseif event == "create_session" then + create_session(payload) + elseif event == "delete_session" then + if type(payload) == "string" then + delete_session(payload) + end + elseif event == "abort_session" then + if active_session then + abort_session(active_session.id) + end + elseif event == "send_prompt" then + if active_session then + local model = "" + if selected_model and selected_model ~= "" then + -- An explicit pick comes from the picker (always known); guard anyway. + if model_is_known(selected_model) then + model = selected_model + end + else + -- Config default only if the server actually reports that model. + -- Warn when the configured default is bogus so the user knows to + -- fix the setting instead of silently running a different model. + local dm = get_default_model() + if model_is_known(dm) then + model = dm + elseif dm ~= "" then + noctalia.log("opencode-companion: default_model " .. tostring(dm) .. " not available; sending without an override") + end + end + local agent = (selected_agent and selected_agent ~= "") and selected_agent or get_default_agent() + send_prompt(active_session.id, payload, model, agent) + end + elseif event == "set_model" then + if type(payload) == "string" and payload ~= "" then + selected_model = payload + mark_dirty("selection") + publish() + end + elseif event == "set_agent" then + if type(payload) == "string" and payload ~= "" then + selected_agent = payload + mark_dirty("selection") + publish() + end + elseif event == "permission_response" then + -- payload: "permission_id:response[:remember]" + if type(payload) == "string" and active_session then + local perm_id, response, remember = payload:match("^([^:]+):([^:]+):?(%w*)") + if perm_id and response then + local rem = (remember == "true") + respond_to_permission(active_session.id, perm_id, response, rem) + end + end + elseif event == "question_reply" then + -- payload: "requestID\2answers" where answers is a JSON string of the + -- outer array; the panel encodes it as JSON for safe transport. + if type(payload) == "string" and active_session then + local rid, answers_json = payload:match("^([^\2]+)\2(.+)") + if rid and answers_json then + local ok, answers = pcall(noctalia.json.decode, answers_json) + if ok and type(answers) == "table" then + respond_to_question(rid, answers) + end + end + end + elseif event == "question_reject" then + if type(payload) == "string" and active_session then + reject_question(payload) + end + elseif event == "refresh" then + load_initial_data() + elseif event == "reconnect" then + connect() + elseif event == "clear_unread" then + unread_count = 0 + mark_dirty("unread_count") + publish() + elseif event == "clear_error" then + last_error = nil + mark_dirty("last_error") + publish() + elseif event == "rename_session" then + if type(payload) == "string" then + local id, title = payload:match("^([^:]+):(.+)") + if id and title then + rename_session(id, title) + end + end + end +end + +-- ── init ───────────────────────────────────────────────────────────────────── + +function onOpen() + boot_id = read_boot_id() + -- Build state file path in plugin data dir + local plugin_dir = noctalia.pluginDataDir and noctalia.pluginDataDir() + if plugin_dir then + state_file = plugin_dir .. "/opencode_state.json" + end + + -- Initialize state + mark_dirty("all") + publish() + + -- Attempt connection (skip if the user disabled auto_start; they can + -- still reconnect manually via the panel's refresh/reconnect action). + if get_auto_start() then + connect() + end +end + +function onExit() + SSE.stop() + managed_pid = nil + sse_stream = nil +end + +-- Start on load +onOpen() diff --git a/opencode-companion/thumbnail.webp b/opencode-companion/thumbnail.webp new file mode 100644 index 0000000..6807e57 Binary files /dev/null and b/opencode-companion/thumbnail.webp differ diff --git a/opencode-companion/translations/en.json b/opencode-companion/translations/en.json new file mode 100644 index 0000000..7b563b0 --- /dev/null +++ b/opencode-companion/translations/en.json @@ -0,0 +1,170 @@ +{ + "state": { + "tip": { + "online": "OpenCode: connected", + "offline": "OpenCode: offline", + "starting": "OpenCode: starting server…", + "busy": "OpenCode: processing…", + "waiting_permission": "OpenCode: waiting for permission" + }, + "unread": "{count} unread response(s)" + }, + "chooser": { + "title": "OpenCode — Sessions", + "refresh": "Refresh", + "new_session": "New Session", + "empty": "No sessions yet. Create one to get started.", + "open": "Open session", + "delete": "Delete session", + "delete_confirm": "Confirm delete", + "delete_cancel": "Cancel", + "search": "Search sessions…", + "no_match": "No sessions match your search." + }, + "chat": { + "title": "OpenCode Chat", + "you": "You", + "assistant": "Assistant", + "empty": "No messages yet. Send a prompt to begin.", + "thinking": "Thinking…", + "stop": "Stop", + "send": "Send (Ctrl+Enter)", + "refresh": "Refresh", + "open_terminal": "Open in terminal", + "back": "Back to sessions", + "placeholder": "Ask OpenCode anything… (Enter = newline, Ctrl+Enter = send)", + "select_model": "Model", + "select_agent": "Agent", + "dismiss_error": "Dismiss" + }, + "permission": { + "title": "Permission Required", + "default_message": "OpenCode wants to perform an action that requires your approval.", + "allow": "Allow", + "allow_remember": "Always Allow", + "deny": "Deny" + }, + "mcp": { + "title": "MCP Servers", + "connected": "Connected", + "failed": "Failed", + "disabled": "Disabled" + }, + "error": { + "exe_not_found": "opencode not found on PATH", + "exe_not_found_detail": "Install opencode and ensure it is available on your PATH.", + "server_failed": "Failed to start OpenCode server", + "server_timeout": "Server did not start in time", + "server_timeout_detail": "The server did not become healthy within the timeout period.", + "external_not_configured": "External server URL not configured", + "connection_failed": "Could not connect to server", + "connection_failed_detail": "The server did not respond to a health check.", + "session_not_found": "Session not found", + "create_session_failed": "Failed to create session", + "delete_session_failed": "Failed to delete session", + "abort_failed": "Failed to abort session", + "prompt_failed": "Failed to send prompt", + "permission_failed": "Failed to respond to permission", + "question_failed": "Failed to answer question", + "session_error": "Session encountered an error", + "session_error_detail": "The agent stopped due to an error.", + "sse_disconnected": "Event stream disconnected" + }, + "notify": { + "title": "OpenCode", + "new_session": "Creating new session…", + "permission_title": "Permission Request", + "permission_body": "OpenCode is waiting for your approval." + }, + "time": { + "just_now": "just now", + "minutes_ago": "{n}m ago", + "hours_ago": "{n}h ago", + "days_ago": "{n}d ago" + }, + "settings": { + "server_mode": { + "label": "Server Mode", + "description": "How the plugin connects to OpenCode. 'auto' manages a local server; 'external' connects to a user-specified URL." + }, + "server_host": { + "label": "Server Host", + "description": "Hostname the managed server binds to. Default: 127.0.0.1 (loopback only)." + }, + "server_port": { + "label": "Server Port", + "description": "Port the managed server listens on. Default: 4096." + }, + "server_url": { + "label": "External Server URL", + "description": "Base URL of an external OpenCode server (e.g., http://127.0.0.1:4096). Used when Server Mode is 'external'." + }, + "default_workspace": { + "label": "Default Workspace", + "description": "Default working directory for new sessions. Leave empty to use the current directory." + }, + "default_model": { + "label": "Default Model", + "description": "Default model in 'provider/model' format (e.g., 'anthropic/claude-3-5-sonnet-20241022'). Leave empty to use server default." + }, + "default_agent": { + "label": "Default Agent", + "description": "Default agent to use for new sessions. Default: 'build'." + }, + "auto_start": { + "label": "Auto-start Server", + "description": "Automatically start the managed server when the plugin loads." + }, + "show_tool_calls": { + "label": "Show Tool Calls", + "description": "Display tool call status cards in the chat view." + }, + "show_reasoning": { + "label": "Show Reasoning", + "description": "Display reasoning/thinking text in the chat view." + }, + "notify_on_complete": { + "label": "Notify on Completion", + "description": "Show a desktop notification when a response completes." + }, + "max_messages_load": { + "label": "Max Messages to Load", + "description": "Messages loaded per session (1–100). Set the slider to its top notch for unlimited. Default: 50." + }, + "debug_logging": { + "label": "Debug Logging", + "description": "Print debug messages to the Noctalia log." + }, + "language": { + "label": "Language", + "description": "Language for this plugin's UI. 'Auto' follows the Noctalia shell language.", + "options": { + "auto": "Auto (follow shell)", + "en": "English", + "vi": "Tiếng Việt" + } + }, + "ui_mode": { + "label": "UI Mode", + "description": "Full shows a spacious layout; Compact tightens padding, fonts and hides secondary details to keep the panel minimal.", + "options": { + "full": "Full", + "compact": "Compact" + } + }, + "panel_mode": { + "label": "Panel Layout", + "description": "Fill right opens a full-height panel pinned to the right edge; Compact shows the original floating panel near the bar click.", + "options": { + "fill_right": "Full-height right side", + "compact": "Compact (near click)" + } + } + }, + "question": { + "title": "Question", + "custom": "Custom Answer", + "cancel": "Cancel", + "submit": "Submit" + } +} diff --git a/opencode-companion/translations/vi.json b/opencode-companion/translations/vi.json new file mode 100644 index 0000000..e29112d --- /dev/null +++ b/opencode-companion/translations/vi.json @@ -0,0 +1,170 @@ +{ + "state": { + "tip": { + "online": "OpenCode: đã kết nối", + "offline": "OpenCode: ngắt kết nối", + "starting": "OpenCode: đang khởi động…", + "busy": "OpenCode: đang xử lý…", + "waiting_permission": "OpenCode: chờ cấp quyền" + }, + "unread": "{count} phản hồi chưa đọc" + }, + "chooser": { + "title": "OpenCode — Phiên", + "refresh": "Làm mới", + "new_session": "Tạo phiên mới", + "empty": "Chưa có phiên nào. Tạo một phiên để bắt đầu.", + "open": "Mở phiên", + "delete": "Xóa phiên", + "delete_confirm": "Xác nhận xóa", + "delete_cancel": "Hủy", + "search": "Tìm phiên…", + "no_match": "Không có phiên nào khớp." + }, + "chat": { + "title": "OpenCode Chat", + "you": "Bạn", + "assistant": "Trợ lý", + "empty": "Chưa có tin nhắn. Gửi prompt để bắt đầu.", + "thinking": "Đang suy nghĩ…", + "stop": "Dừng", + "send": "Gửi (Ctrl+Enter)", + "refresh": "Làm mới", + "open_terminal": "Mở trong terminal", + "back": "Quay lại danh sách phiên", + "placeholder": "Hỏi OpenCode bất cứ điều gì… (Enter = xuống dòng, Ctrl+Enter = gửi)", + "select_model": "Mô hình", + "select_agent": "Agent", + "dismiss_error": "Bỏ qua" + }, + "permission": { + "title": "Cần cấp quyền", + "default_message": "OpenCode muốn thực hiện một hành động cần sự chấp thuận của bạn.", + "allow": "Cho phép", + "allow_remember": "Luôn cho phép", + "deny": "Từ chối" + }, + "mcp": { + "title": "Máy chủ MCP", + "connected": "Đã kết nối", + "failed": "Lỗi", + "disabled": "Đã tắt" + }, + "error": { + "exe_not_found": "Không tìm thấy opencode trên PATH", + "exe_not_found_detail": "Cài đặt opencode và đảm bảo nó có trên PATH.", + "server_failed": "Không thể khởi động máy chủ OpenCode", + "server_timeout": "Máy chủ không khởi động kịp thời gian", + "server_timeout_detail": "Máy chủ không trở nên sỏng trong khoảng thời gian chờ.", + "external_not_configured": "URL máy chủ bên ngoài chưa được cấu hình", + "connection_failed": "Không thể kết nối đến máy chụ", + "connection_failed_detail": "Máy chủ không phản hồi health check.", + "session_not_found": "Không tìm thấy phiên", + "create_session_failed": "Không thể tạo phiên", + "delete_session_failed": "Không thể xóa phiên", + "abort_failed": "Không thể hủy phiên", + "prompt_failed": "Không thể gửi prompt", + "permission_failed": "Không thể phản hồi yêu cầu quyền", + "question_failed": "Không thể trả lời câu hỏi", + "session_error": "Phiên gặp lỗi", + "session_error_detail": "Agent đã dừng do gặp lỗi.", + "sse_disconnected": "Luồng sự kiện đã ngắt kết nối" + }, + "notify": { + "title": "OpenCode", + "new_session": "Đang tạo phiên mới…", + "permission_title": "Yêu cầu quyền", + "permission_body": "OpenCode đang chờ bạn phê duyệt." + }, + "time": { + "just_now": "vừa xong", + "minutes_ago": "{n} phút trước", + "hours_ago": "{n} giờ trước", + "days_ago": "{n} ngày trước" + }, + "settings": { + "server_mode": { + "label": "Chế độ máy chủ", + "description": "Cách plugin kết nối đến OpenCode. 'auto' quản lý máy chụ cục bộ; 'external' kết nối đến URL do người dùng chỉ định." + }, + "server_host": { + "label": "Máy chủ host", + "description": "Hostname máy chủ cục bộ bind vào. Mặc định: 127.0.0.1 (chỉ loopback)." + }, + "server_port": { + "label": "Cổng máy chủ", + "description": "Cổng máy chủ cục bộ lắng nghe. Mặc định: 4096." + }, + "server_url": { + "label": "URL máy chủ bên ngoài", + "description": "URL cơ sở của máy chủ OpenCode bên ngoài (ví dụ: http://127.0.0.1:4096). Dùng khi chế độ máy chủ là 'external'." + }, + "default_workspace": { + "label": "Thư mục làm việc mặc định", + "description": "Thư mục làm việc mặc định cho phiên mới. Để trống để dùng thư mục hiện tại." + }, + "default_model": { + "label": "Model mặc định", + "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ủ." + }, + "default_agent": { + "label": "Agent mặc định", + "description": "Agent mặc định cho phiên mới. Mặc định: 'build'." + }, + "auto_start": { + "label": "Tự động khởi động", + "description": "Tự động khởi động máy chủ cục bộ khi plugin tải." + }, + "show_tool_calls": { + "label": "Hiện tool calls", + "description": "Hiển thị trạng thái tool call trong khung chat." + }, + "show_reasoning": { + "label": "Hiện reasoning", + "description": "Hiển thị nội dung reasoning/thinking trong khung chat." + }, + "notify_on_complete": { + "label": "Thông báo khi hoàn tất", + "description": "Hiển thị thông báo trên desktop khi phản hồi hoàn tất." + }, + "max_messages_load": { + "label": "Số tin nhắn tải tối đa", + "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." + }, + "debug_logging": { + "label": "Gỡ lỗi", + "description": "In thông tin gỡ lỗi vào nhật ký Noctalia." + }, + "language": { + "label": "Ngôn ngữ", + "description": "Ngôn ngữ giao diện của plugin này. 'Tự động' theo ngôn ngữ của Noctalia.", + "options": { + "auto": "Tự động (theo shell)", + "en": "English", + "vi": "Tiếng Việt" + } + }, + "ui_mode": { + "label": "Chế độ giao diện", + "description": "Full hiển thị bố cục rộng rãi; Compact thu nhỏ padding, chữ và ẩn chi tiết phụ để panel gọn gàng tối giản.", + "options": { + "full": "Full", + "compact": "Compact" + } + }, + "panel_mode": { + "label": "Kiểu panel", + "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.", + "options": { + "fill_right": "Thanh cao full màn bên phải", + "compact": "Gọn (gần điểm bấm)" + } + } + }, + "question": { + "title": "Câu hỏi", + "custom": "Trả lời tùy chỉnh", + "cancel": "Hủy", + "submit": "Gửi" + } +} diff --git a/opencode-companion/widget.luau b/opencode-companion/widget.luau new file mode 100644 index 0000000..5cb6929 --- /dev/null +++ b/opencode-companion/widget.luau @@ -0,0 +1,198 @@ +-- OpenCode Companion bar widget — a pure subscriber of opencode.connection state. +-- Shows connection status as an accent-colored glyph with a breathing glow, +-- an unread badge when responses arrive while the panel is closed, and a +-- waiting-permission bell when OpenCode needs user input. + +local STATE_KEY = "opencode.connection" +local UNREAD_KEY = "opencode.unread_count" + +local GLYPH = { + online = "code-circle", + offline = "code-off", + starting = "loader", + busy = "brain", + waiting_permission = "shield-exclamation", +} + +local COLOR = { + online = "primary", + offline = "error", + starting = "secondary", + busy = "primary", + waiting_permission = "error", +} + +local BREATH_PERIOD = 6.0 +local BREATH_FLOOR = 0.45 +local BREATH_CEIL = 1.0 +local TICK_MS = 100 + +local snap = { status = "offline" } +local unread = 0 +local phase = BREATH_PERIOD / 2 +local online_pulse = 0 -- counts ticks while online (for periodic refresh) + +-- Translation with an optional per-plugin language override (mirrors panel.luau). +local i18n_cache = { lang = nil, table = nil } + +local function load_lang_table(lang) + if i18n_cache.lang == lang and i18n_cache.table then + return i18n_cache.table + end + local merged = {} + local function merge_file(path) + local ok, content = pcall(noctalia.readFile, path) + if not ok or type(content) ~= "string" or content == "" then return end + local ok2, data = pcall(noctalia.json.decode, content) + if not ok2 or type(data) ~= "table" then return end + local function deep(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + deep(dst[k], v) + else + dst[k] = v + end + end + end + deep(merged, data) + end + merge_file("translations/en.json") + if lang ~= "en" then + merge_file("translations/" .. lang .. ".json") + end + i18n_cache.lang = lang + i18n_cache.table = merged + return merged +end + +local function tr(key, args) + local lang = noctalia.getConfig and noctalia.getConfig("language") + if type(lang) ~= "string" or lang == "" or lang == "auto" then + return noctalia.tr(key, args) + end + local node = load_lang_table(lang) + for part in string.gmatch(key, "[^%.]+") do + if type(node) ~= "table" then node = nil break end + node = node[part] + end + if type(node) ~= "string" then + return noctalia.tr(key, args) + end + if type(args) == "table" then + node = string.gsub(node, "{(%w+)}", function(name) + local v = args[name] + if v == nil then return "{" .. name .. "}" end + return tostring(v) + end) + end + return node +end + +-- Scale an RRGGBB hex toward black by factor b, returning "#RRGGBB" +local function dim(hex, b) + if type(hex) ~= "string" or #hex ~= 6 then return "#808080" end + local function ch(i) + local x = math.floor((tonumber(hex:sub(i, i + 1), 16) or 0) * b + 0.5) + if x < 0 then x = 0 elseif x > 255 then x = 255 end + return x + end + return string.format("#%02X%02X%02X", ch(1), ch(3), ch(5)) +end + +-- Resolved accent RGB (hardcoded fallback; theme follows via named roles where possible) +local ACCENT_RGB = { + primary = "B4FF00", + secondary = "EAFF00", + error = "FF4D1F", +} + +local function level_for(period) + local t = phase % period + local s = 0.5 - 0.5 * math.cos((t / period) * 2 * math.pi) + return BREATH_FLOOR + (BREATH_CEIL - BREATH_FLOOR) * s +end + +local function paint() + local g = GLYPH[snap.status] or GLYPH.offline + local c = COLOR[snap.status] or COLOR.offline + barWidget.setGlyph(g) + barWidget.setGlyphColor(dim(ACCENT_RGB[c] or ACCENT_RGB.secondary, level_for(BREATH_PERIOD))) +end + +local function render() + paint() + + local status_tip = tr("state.tip." .. tostring(snap.status)) or tr("state.tip.offline") + local tip = status_tip + if snap.error and snap.error ~= "" then + tip = tip .. "\n" .. snap.error + end + if unread > 0 then + tip = tip .. "\n" .. tr("state.unread", { count = unread }) + end + barWidget.setTooltip(tip) +end + +local function apply(s) + if type(s) ~= "table" then return end + local status = type(s.status) == "string" and s.status or "offline" + local changed = (status ~= snap.status) + snap = { status = status, error = s.error } + if changed then + phase = BREATH_PERIOD / 2 -- snap breath to peak on change + end + render() +end + +local function apply_unread(n) + unread = tonumber(n) or 0 + render() +end + +function onClick() + -- Clear unread on open + if unread > 0 then + noctalia.runAsync("noctalia msg plugin 'weinguyen/opencode-companion:service' all clear_unread") + end + -- Toggle whichever panel entry matches the panel_mode setting (host-owned + -- placement means the two modes are separate manifest entries). + local mode = noctalia.getConfig and noctalia.getConfig("panel_mode") or "fill_right" + local panel_id = (mode == "compact") and "panel" or "panel-fill" + noctalia.togglePanel("weinguyen/opencode-companion:" .. panel_id) +end + +function onRightClick() + -- Quick action: create new session + noctalia.runAsync("noctalia msg plugin 'weinguyen/opencode-companion:service' all create_session") + noctalia.notify(tr("notify.title"), tr("notify.new_session")) +end + +function onMiddleClick() + -- Open current session in terminal (opencode attach) + noctalia.runInTerminal("opencode attach") +end + +function update() + noctalia.setUpdateInterval(TICK_MS) + phase = phase + TICK_MS / 1000 + if phase > 1e6 then phase = 0 end + online_pulse = online_pulse + 1 + -- Periodic state refresh every ~10s to stay in sync + if online_pulse % 100 == 0 then + local s = noctalia.state.get(STATE_KEY) + if type(s) == "table" and s.status ~= snap.status then + apply(s) + end + end + paint() +end + +-- Subscribe to state changes +noctalia.state.watch(STATE_KEY, apply) +noctalia.state.watch(UNREAD_KEY, apply_unread) +noctalia.setUpdateInterval(TICK_MS) + +-- Seed from current state +apply(noctalia.state.get(STATE_KEY)) +apply_unread(noctalia.state.get(UNREAD_KEY)) +render()