From e77d7ef7fbfa7d909e03966e42690a8561f1b190 Mon Sep 17 00:00:00 2001 From: weinguyen Date: Mon, 10 Aug 2026 00:43:51 +0700 Subject: [PATCH] Add OpenCode Companion plugin (#286) * Add OpenCode Panel plugin Introduce initial implementation of the OpenCode Panel plugin. This commit includes: - Bar widget for status and quick actions. - Chat panel with session selection, message history, and input. - Background service for OpenCode server management, SSE event handling, and state persistence. - Plugin configuration, documentation, and internationalization. * Refine session chooser UI layout Remove an unnecessary spacer and apply top-justification to improve vertical alignment. Add a subtle background fill to the session list. * Introduce session search in chooser Enable live filtering of sessions in the chooser. Matches against title, slug, ID, and directory. Search is case-insensitive. * Support multi-question agent requests and clear composer Implement multi-question agent replies by accumulating choices locally. A "Submit" button becomes enabled only after all questions are answered. The chat composer clears its text after sending by updating its key, forcing the UI to re-render an empty input field. Updated translations for send hints and question submit button. * Add UI mode setting for compact layout Introduce a `ui_mode` setting (Full/Compact) that dynamically adjusts panel layout. Compact mode reduces padding, font sizes, and gaps, hiding some secondary details to minimize the panel footprint. Layout metrics recompute on every render based on this setting. * Delete opencode-panel * Update thumbnail.webp * Render chat messages newest-first Newest message shows at top. Scrolling down reveals older messages. This avoids scroll offset resets on re-mount in older shell APIs. Thinking bubble now appears above the newest message. * FEAT: Add panel layout setting Provide option for panel to fill right side or appear compact near click. Introduces a new panel entry for the "fill right" mode. * Rename plugin to OpenCode Companion Update plugin ID, panel entries, IPC commands, and state paths. * Only send model override when known Stale default_model now falls back to no override instead of failing every turn. Warn when configured default unavailable. * Update service.luau * fix(opencode-companion): secure terminal open, publish MCP status, respect auto_start - Shell-quote host and session id in the "open in terminal" command so a crafted server_host cannot inject extra shell commands - Publish opencode.mcp_status and render it as a collapsible footer (previously declared but never observable) - Honor auto_start: skip managed-server start on load when set to false - Fix broken thumbnail link in README (assets/thumbnail.webp -> thumbnail.webp) * fix Terminal command injection,MCP status publication and UI --- opencode-companion/README.md | 257 ++++ opencode-companion/panel.luau | 1192 ++++++++++++++++++ opencode-companion/plugin.toml | 171 +++ opencode-companion/service.luau | 1469 +++++++++++++++++++++++ opencode-companion/thumbnail.webp | Bin 0 -> 67190 bytes opencode-companion/translations/en.json | 170 +++ opencode-companion/translations/vi.json | 170 +++ opencode-companion/widget.luau | 198 +++ 8 files changed, 3627 insertions(+) create mode 100644 opencode-companion/README.md create mode 100644 opencode-companion/panel.luau create mode 100644 opencode-companion/plugin.toml create mode 100644 opencode-companion/service.luau create mode 100644 opencode-companion/thumbnail.webp create mode 100644 opencode-companion/translations/en.json create mode 100644 opencode-companion/translations/vi.json create mode 100644 opencode-companion/widget.luau 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 0000000000000000000000000000000000000000..6807e5762002f80bdbffd8ec3b504b476a5413cc GIT binary patch literal 67190 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 literal 0 HcmV?d00001 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()