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
This commit is contained in:
weinguyen
2026-08-09 13:43:51 -04:00
committed by GitHub
parent 66226fc7e1
commit e77d7ef7fb
8 changed files with 3627 additions and 0 deletions
+257
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+171
View File
@@ -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"
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+170
View File
@@ -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"
}
}
+170
View File
@@ -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"
}
}
+198
View File
@@ -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()