From b668dcc5cb8c7768bfaecc91c7672314b21e6c22 Mon Sep 17 00:00:00 2001 From: Nguyen Tien Duy Date: Tue, 11 Aug 2026 22:23:53 +0700 Subject: [PATCH 1/2] feat(plugin): OpenCode Companion beta.8 upgrade + streaming/overflow fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade to plugin API 21 and reverse the chat so the newest message sits at the bottom with auto-scroll: - panel: reverse message list to oldest-first; pin the scroll to the bottom via stickToBottom, jump there on open and session switch via scrollToBottomRev, and clear the stick flag in onScroll when the user scrolls away so reading history doesn't yank them down. - composer: enable submitOnEnter so Enter sends and Shift+Enter inserts a newline; update en/vi placeholder and send-tooltip strings. - panel: render assistant replies as markdown (headings, lists, tables), keeping user messages as plain labels. Fix runtime errors surfaced during use: - panel: pin markdown width to WRAP — ui.markdown has no maxWidth prop, so a bare flexGrow left it unconstrained and single-line text overflowed. - panel: wrap fenced code blocks in monospace labels with a maxWidth — noctalia's MarkdownView never wraps code, so long bash/code lines spilled outside the panel. Text outside code fences stays markdown. - panel: coerce onScroll(offset, maxOffset) args with tonumber — the reconciler passes them as strings, raising "attempt to compare number <= string". - service: handle message.part.updated / message.updated streaming via throttled reload; acknowledge message.part.delta without reloading (the server emits one per token, and reloading on each decodes the whole history and blows the async callback CPU budget in json.decode). - service: handle session.updated / session.diff with a throttled session reload so auto-generated titles stay current in the chooser. Verified with luac syntax checks and a standalone split_markdown test (5 cases) and JSON validation of the translation files. --- opencode-companion/README.md | 29 +++-- opencode-companion/panel.luau | 148 ++++++++++++++++++------ opencode-companion/plugin.toml | 9 +- opencode-companion/service.luau | 100 ++++++++++++---- opencode-companion/translations/en.json | 8 +- opencode-companion/translations/vi.json | 9 +- 6 files changed, 209 insertions(+), 94 deletions(-) diff --git a/opencode-companion/README.md b/opencode-companion/README.md index 3f53f93f..52ea891c 100644 --- a/opencode-companion/README.md +++ b/opencode-companion/README.md @@ -89,21 +89,20 @@ 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 | +| 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 | +| `max_messages_load` | double | `50` | Max messages to load per session | +| `debug_logging` | bool | `false` | Print debug messages | ## Session Lifecycle diff --git a/opencode-companion/panel.luau b/opencode-companion/panel.luau index ced0418d..fb8dd453 100644 --- a/opencode-companion/panel.luau +++ b/opencode-companion/panel.luau @@ -43,7 +43,6 @@ local session_query = "" local question_choices = {} local SVC = "weinguyen/opencode-companion:service" -local PANEL_ID = "weinguyen/opencode-companion:panel" -- Layout constants (recomputed from the ui_mode setting on every render). local compact = false @@ -94,6 +93,14 @@ local draft = "" -- (empty) input, which is how we clear the field after sending. local composer_seq = 0 +-- Chat scroll control (API 21). `chat_scroll_rev` is bumped to request a +-- one-shot jump to the bottom (deferred to the next layout pass); `chat_stick` +-- keeps the view pinned to the bottom while content grows, and is cleared when +-- the user scrolls away so reading history doesn't yank them down. +local chat_scroll_rev = 0 +local chat_stick = true +local last_scrolled_session = nil + local view = "session_chooser" local snap_cache = {} -- last rendered fingerprint -- Forward declaration so chooser/chat closures can call render() to refresh @@ -176,17 +183,8 @@ local function tr(key, args) return str end --- Fingerprint a value to detect real changes (avoid re-render storms) -local function fp(v) - if type(v) ~= "table" then return tostring(v) end - local parts = {} - for k, val in pairs(v) do - parts[#parts + 1] = tostring(k) .. "=" .. tostring(val) - end - table.sort(parts) - return table.concat(parts, "\1") -end - +-- Fingerprint a list of records by the given fields, so the panel re-renders +-- only when one of those fields actually changes. local function fp_list(list, fields) if type(list) ~= "table" then return "" end local parts = {} @@ -272,9 +270,7 @@ local function dispatch_ipc(event, payload) noctalia.runAsync(cmd) end --- MCP status footer: colored server names, one per line. Returns nil when --- there's nothing meaningful to show. Kept intentionally minimal — the --- MCP status footer: a single collapsible toggle row (chevron + title + +-- MCP status footer: a single collapsible toggle row (chevron + title + the -- connected/total count). Clicking expands it to show one pill per server. -- Collapsed by default so it never crowds the message list. local MCP_META = { @@ -523,6 +519,33 @@ end -- ── chat view ─────────────────────────────────────────────────────────────── +-- noctalia's MarkdownView doesn't wrap fenced code blocks (the code label is +-- not width-constrained), so long lines overflow the panel. Split assistant +-- text into code / non-code segments: code renders as a wrapping monospace +-- label, everything else keeps markdown rendering. +local function split_markdown(txt) + local segs = {} + local in_code = false + local buf = "" + local function flush() + if buf ~= "" then + segs[#segs + 1] = { code = in_code, text = buf } + buf = "" + end + end + for line in (txt .. "\n"):gmatch("([^\n]*)\n") do + local stripped = line:match("^%s*(.*)$") or "" + if stripped:sub(1, 3) == "```" then + flush() + in_code = not in_code + else + buf = buf .. line .. "\n" + end + end + flush() + return segs +end + local function render_message(msg_data) if type(msg_data) ~= "table" then return nil end local info = msg_data.info @@ -549,11 +572,40 @@ local function render_message(msg_data) 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, - }) + if role == "assistant" then + -- Render text as markdown (headings, bold, lists, tables) + -- but split out fenced code blocks, which MarkdownView + -- never wraps and would overflow the panel. Code segments + -- render as wrapping monospace labels. + local segs = split_markdown(txt) + for _, seg in ipairs(segs) do + if seg.code then + cells[#cells + 1] = ui.column({ + padding = 8, + radius = 8, + fill = "surface_variant/0.45", + }, { + ui.label({ + text = seg.text:gsub("\n+$", ""), + fontSize = 12, + fontFamily = "monospace", + maxWidth = WRAP - 16, + }), + }) + else + cells[#cells + 1] = ui.markdown({ + text = seg.text, + width = WRAP, + }) + end + end + else + cells[#cells + 1] = ui.label({ + text = txt, + maxWidth = WRAP, + flexGrow = 1, + }) + end end elseif ptype == "reasoning" and type(part.text) == "string" and part.text ~= "" then -- Only show reasoning if enabled (checked at render time via config) @@ -986,12 +1038,12 @@ local function render_chat(active, messages, permissions, questions, conn) end end - -- Message list, newest-first. Newest at the top so the panel always opens on - -- the latest message without needing a scroll API (shell < API 21 resets a - -- re-mounted scroll to offset 0 = top). Scrolling down reveals older - -- messages. The scroll node carries a stable `key` so its offset survives - -- re-renders even when siblings above it appear/disappear. Children are - -- passed directly to the scroll (not wrapped in a column). + -- Message list, oldest-first. Newest at the bottom; the scroll node is + -- pinned to the bottom (stickToBottom) and jumps there on open/session + -- switch (scrollToBottomRev), so the panel always shows the latest message. + -- The scroll node carries a stable `key` so its offset survives re-renders + -- even when siblings above it appear/disappear. Children are passed directly + -- to the scroll (not wrapped in a column). local msg_items = {} if type(messages) ~= "table" or #messages == 0 then msg_items[#msg_items + 1] = ui.label({ text = tr("chat.empty"), maxWidth = WRAP, opacity = 0.7 }) @@ -1019,22 +1071,35 @@ local function render_chat(active, messages, permissions, questions, conn) -- disappears once the assistant's first text part streams in. local waiting = is_processing() and (not newest_is_assistant or not newest_has_text) - -- Newest-first: build the list from the end so bubble order is latest - -- first, oldest last. - for i = #messages, 1, -1 do + -- Oldest-first: newest message at the bottom (API 21 supports + -- stick-to-bottom + jump-to-bottom, so the panel opens on the latest + -- message and follows the stream). + for i = 1, #messages do local rendered = render_message(messages[i]) if rendered then msg_items[#msg_items + 1] = rendered end end - -- Thinking bubble sits right ABOVE the newest message (top of the - -- newest-first list), like a spinner over the reply in progress. + -- Thinking bubble sits right BELOW the newest message (end of the + -- oldest-first list), like a spinner over the reply in progress. if waiting then - table.insert(msg_items, 1, render_thinking()) + msg_items[#msg_items + 1] = render_thinking() end end - rows[#rows + 1] = ui.scroll({ key = "chat_messages", flexGrow = 1, gap = GAP }, msg_items) + rows[#rows + 1] = ui.scroll({ + key = "chat_messages", + flexGrow = 1, + gap = GAP, + stickToBottom = chat_stick, + scrollToBottomRev = chat_scroll_rev, + onScroll = function(offset, maxOffset) + -- The reconciler passes both args as strings; coerce before compare. + local o = tonumber(offset) or 0 + local mo = tonumber(maxOffset) or 0 + chat_stick = (o >= mo - 1) + end, + }, msg_items) -- MCP status footer local mcp_footer = render_mcp_status() @@ -1066,13 +1131,13 @@ local function render_chat(active, messages, permissions, questions, conn) value = draft, placeholder = tr("chat.placeholder"), multiline = true, + submitOnEnter = true, flexGrow = 1, onChange = function(text) draft = text end, - -- Multiline: Enter inserts a newline; Ctrl+Enter submits (send). This - -- is the input control's built-in mapping and cannot distinguish - -- Shift+Enter, so Ctrl+Enter is the send chord. + -- Chat-style submit (API 21): Enter submits, Shift+Enter inserts a + -- newline. Ctrl+Enter still submits as a fallback chord. onSubmit = function(text) send_draft(text) end, @@ -1106,6 +1171,14 @@ end render = function() apply_layout() local active = noctalia.state.get(STATE.active) + -- Jump to the bottom when the active session changes (new session or + -- restore-on-boot), so the panel opens on the latest message. + local active_id = (type(active) == "table" and active.id) or nil + if active_id ~= last_scrolled_session then + last_scrolled_session = active_id + chat_scroll_rev = chat_scroll_rev + 1 + chat_stick = true + end local sessions = noctalia.state.get(STATE.sessions) or {} local messages = noctalia.state.get(STATE.messages) or {} local permissions = noctalia.state.get(STATE.permissions) or {} @@ -1160,6 +1233,9 @@ end function onOpen(_context) panel.setWantsSecondTicks(true) + -- Re-entering the panel: jump to the bottom of the current session. + chat_scroll_rev = chat_scroll_rev + 1 + chat_stick = true -- Subscribe to all relevant state changes for _, key in pairs(STATE) do noctalia.state.watch(key, function() diff --git a/opencode-companion/plugin.toml b/opencode-companion/plugin.toml index 0727d2bd..d0b79fd1 100644 --- a/opencode-companion/plugin.toml +++ b/opencode-companion/plugin.toml @@ -1,7 +1,7 @@ id = "weinguyen/opencode-companion" name = "OpenCode Companion" version = "0.1.0" -plugin_api = 3 +plugin_api = 21 author = "weinguyen" license = "MIT" icon = "code-circle" @@ -80,13 +80,6 @@ default = false label_key = "settings.show_reasoning.label" description_key = "settings.show_reasoning.description" -[[setting]] -key = "notify_on_complete" -type = "bool" -default = true -label_key = "settings.notify_on_complete.label" -description_key = "settings.notify_on_complete.description" - # Integer slider: 1..100 caps loaded history; the top notch (101) means # "unlimited" (service.luau treats >= 101 as an effectively unbounded limit). [[setting]] diff --git a/opencode-companion/service.luau b/opencode-companion/service.luau index 8129d753..38e7cf4b 100644 --- a/opencode-companion/service.luau +++ b/opencode-companion/service.luau @@ -151,9 +151,7 @@ local connection = { server_version = nil, } --- Server process info (managed mode only) -local managed_pid = nil -local managed_port = nil + -- Active session tracking local active_session = nil @@ -172,6 +170,9 @@ local last_error = nil -- Optimistic user message appended on send, replaced once the server echoes it. local optimistic_msg = nil +-- Monotonic counter so two sends within the same second still get distinct +-- optimistic ids (load_messages dedups on message id). +local optimistic_seq = 0 -- When the active session went busy (os.time seconds). Used to auto-recover a -- stale busy status if the server never emits idle/error (dropped SSE event), @@ -366,6 +367,12 @@ local function publish() if dirty.mcp_status or dirty.all then noctalia.state.set("opencode.mcp_status", mcp_status) end + if dirty.providers or dirty.all then + noctalia.state.set("opencode.providers", providers) + end + if dirty.agents or dirty.all then + noctalia.state.set("opencode.agents", agents) + end if dirty.pending_permissions or dirty.all then noctalia.state.set("opencode.pending_permissions", pending_permissions) end @@ -400,6 +407,17 @@ local function set_connection_status(status, err) publish() end +-- After answering/dismissing a permission or question, return to "online" only +-- when nothing is left waiting; otherwise keep "waiting_permission". The widget +-- glyph must not flip to online while other cards still await user input. +local function refresh_waiting_status() + if #pending_permissions > 0 or #pending_questions > 0 then + set_connection_status("waiting_permission") + else + set_connection_status("online") + end +end + local function set_last_error(message, detail) last_error = { message = message, detail = detail } mark_dirty("last_error") @@ -429,7 +447,6 @@ local function start_managed_server() local ok, data = pcall(noctalia.json.decode, resp.body) if ok and data.healthy then connection.server_version = data.version - managed_port = port set_connection_status("online") debug_log("Connected to existing server at " .. host .. ":" .. port) load_initial_data() @@ -482,8 +499,6 @@ local function start_managed_server() local ok2, data2 = pcall(noctalia.json.decode, resp2.body) if ok2 and data2.healthy then connection.server_version = data2.version - managed_port = port - managed_pid = true -- we spawned it set_connection_status("online") debug_log("Server started successfully") load_initial_data() @@ -771,10 +786,14 @@ end -- ── session management ─────────────────────────────────────────────────────── local function set_active_session(session) + -- Drop any in-flight optimistic bubble unless we're re-selecting the very + -- same session. Switching sessions (or deselecting) with a pending user + -- message would otherwise bleed that bubble into the next session's list. + local same_session = session and active_session and session.id == active_session.id active_session = session messages = {} last_messages_body = nil -- force a fresh decode for the new session - if not session then + if not same_session then optimistic_msg = nil end mark_dirty("active_session") @@ -896,12 +915,20 @@ local function send_prompt(session_id, text, model, agent) if not safe_id(session_id) then return end if type(text) ~= "string" or text == "" then return end + -- The user is actively sending in the panel: clear any unread responses + -- from a previous turn so the badge counts only this turn's replies. + if unread_count ~= 0 then + unread_count = 0 + mark_dirty("unread_count") + end + -- Optimistically append the user message so it appears immediately on the -- right. It is replaced by the server's copy once echoed (see load_messages). + optimistic_seq = optimistic_seq + 1 local now_ms = os.time() * 1000 optimistic_msg = { info = { - id = "local-" .. tostring(now_ms), + id = "local-" .. tostring(now_ms) .. "-" .. tostring(optimistic_seq), role = "user", sessionID = session_id, time = { created = now_ms }, @@ -968,7 +995,7 @@ local function respond_to_permission(session_id, permission_id, response, rememb end end mark_dirty("pending_permissions") - set_connection_status("online") + refresh_waiting_status() publish() else set_last_error(tr("error.permission_failed"), "HTTP " .. tostring(resp.status)) @@ -991,7 +1018,7 @@ local function respond_to_question(request_id, answers) end end mark_dirty("pending_questions") - set_connection_status("online") + refresh_waiting_status() publish() else set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status)) @@ -1010,7 +1037,7 @@ local function reject_question(request_id) end end mark_dirty("pending_questions") - set_connection_status("online") + refresh_waiting_status() publish() else set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status)) @@ -1026,6 +1053,11 @@ local SSE = {} last_messages_reload = 0 RELOAD_THROTTLE_S = 0.3 +-- Throttle for session-list reloads (session.updated/session.diff fire once +-- or twice per reply; the list fetch is heavier than the message one). +last_sessions_reload = 0 +SESSIONS_RELOAD_THROTTLE_S = 1 + -- Dedup set for unread counting (prevents inflating count on every part update) local counted_messages = {} local COUNTED_MAX = 100 @@ -1034,8 +1066,12 @@ local COUNTED_MAX = 100 local function parse_sse_line(line) if line == nil then return nil, nil end if line == "" then return nil, nil end - if line:sub(1, 6) == "data: " then - local json_str = line:sub(7) + if line:sub(1, 5) == "data:" then + -- SSE allows both "data:{...}" and "data: {...}"; accept either. + local json_str = line:sub(6) + if json_str:sub(1, 1) == " " then + json_str = json_str:sub(2) + end local ok, data = pcall(noctalia.json.decode, json_str) if ok and type(data) == "table" then return data.type, data @@ -1115,6 +1151,17 @@ function SSE.handle_event(event_type, event) return end + if event_type == "message.part.delta" then + -- Per-token incremental text delta while a reply streams. Do NOT reload + -- here: the server emits one of these per token, so reloading on each + -- would decode the whole history every ~RELOAD_THROTTLE_S and blow the + -- async callback CPU budget (crossed during json.decode). The + -- less-frequent message.part.updated / message.updated events carry the + -- accumulated full state and drive the (throttled) reload. This branch + -- only exists to acknowledge the event and avoid the Unknown-event log. + return + end + if event_type == "message.part.updated" or event_type == "message.updated" then local session_id = event.sessionID or (event.properties and event.properties.sessionID) local props = event.properties or event @@ -1159,8 +1206,6 @@ function SSE.handle_event(event_type, event) set_connection_status("busy") elseif st == "idle" then set_connection_status("online") - unread_count = 0 - mark_dirty("unread_count") elseif st == "error" then set_connection_status("online") end @@ -1177,8 +1222,6 @@ function SSE.handle_event(event_type, event) mark_dirty("session_status") if active_session and active_session.id == session_id then set_connection_status("online") - unread_count = 0 - mark_dirty("unread_count") counted_messages = {} load_messages(session_id) end @@ -1197,8 +1240,10 @@ function SSE.handle_event(event_type, event) local name = (type(err) == "table" and err.name) or "UnknownError" local detail = (type(err) == "table" and type(err.data) == "table" and err.data.message) or tr("error.session_error_detail") - session_status[session_id or ""] = "error" - mark_dirty("session_status") + if session_id then + session_status[session_id] = "error" + mark_dirty("session_status") + end if not active_session or not session_id or active_session.id == session_id then -- MessageAbortedError is expected when the user hits Stop; don't shout. if name ~= "MessageAbortedError" then @@ -1263,7 +1308,7 @@ function SSE.handle_event(event_type, event) end end mark_dirty("pending_permissions") - set_connection_status("online") + refresh_waiting_status() publish() end return @@ -1310,12 +1355,24 @@ function SSE.handle_event(event_type, event) end end mark_dirty("pending_questions") - set_connection_status("online") + refresh_waiting_status() publish() end return end + if event_type == "session.updated" or event_type == "session.diff" then + -- Session metadata changed (e.g. auto-generated title after the first + -- prompt). Refresh the session list (throttled) so the chooser shows the + -- current titles; these fire roughly once per reply. + local now = os.clock() + if now - last_sessions_reload > SESSIONS_RELOAD_THROTTLE_S then + last_sessions_reload = now + load_sessions() + end + return + end + -- Unknown event — log if debug debug_log("Unknown event: " .. tostring(event_type)) end @@ -1461,7 +1518,6 @@ end function onExit() SSE.stop() - managed_pid = nil sse_stream = nil end diff --git a/opencode-companion/translations/en.json b/opencode-companion/translations/en.json index a08eae92..cd69edf0 100644 --- a/opencode-companion/translations/en.json +++ b/opencode-companion/translations/en.json @@ -5,11 +5,11 @@ "dismiss_error": "Dismiss", "empty": "No messages yet. Send a prompt to begin.", "open_terminal": "Open in terminal", - "placeholder": "Ask OpenCode anything… (Enter = newline, Ctrl+Enter = send)", + "placeholder": "Ask OpenCode anything… (Enter = send, Shift+Enter = new line)", "refresh": "Refresh", "select_agent": "Agent", "select_model": "Model", - "send": "Send (Ctrl+Enter)", + "send": "Send (Enter)", "stop": "Stop", "thinking": "Thinking…", "title": "OpenCode Chat", @@ -106,10 +106,6 @@ "description": "Messages loaded per session (1–100). Set the slider to its top notch for unlimited. Default: 50.", "label": "Max Messages to Load" }, - "notify_on_complete": { - "description": "Show a desktop notification when a response completes.", - "label": "Notify on Completion" - }, "panel_mode": { "description": "Fill right opens a full-height panel pinned to the right edge; Compact shows the original floating panel near the bar click.", "label": "Panel Layout", diff --git a/opencode-companion/translations/vi.json b/opencode-companion/translations/vi.json index 6a51567d..b078524d 100644 --- a/opencode-companion/translations/vi.json +++ b/opencode-companion/translations/vi.json @@ -5,11 +5,11 @@ "dismiss_error": "Bỏ qua", "empty": "Chưa có tin nhắn. Gửi prompt để bắt đầu.", "open_terminal": "Mở trong terminal", - "placeholder": "Hỏi OpenCode bất cứ điều gì… (Enter = xuống dòng, Ctrl+Enter = gửi)", + "placeholder": "Hỏi OpenCode bất cứ điều gì… (Enter = Gửi, Shift+Enter = xuống dòng)", "refresh": "Làm mới", "select_agent": "Agent", "select_model": "Mô hình", - "send": "Gửi (Ctrl+Enter)", + "send": "Gửi (Enter)", "stop": "Dừng", "thinking": "Đang suy nghĩ…", "title": "OpenCode Chat", @@ -86,7 +86,6 @@ "label": "Agent mặc định" }, "default_model": { - "description": "Model mặc định theo định dạng 'provider/model' (ví dụ: 'anthropic/claude-3-5-sonnet-20241022'). Để trống để dùng mặc định của máy chủ.", "label": "Model mặc định" }, "default_workspace": { @@ -106,10 +105,6 @@ "description": "Số tin nhắn tải mỗi phiên (1–100). Kéo thanh trượt lên mức cao nhất để không giới hạn. Mặc định: 50.", "label": "Số tin nhắn tải tối đa" }, - "notify_on_complete": { - "description": "Hiển thị thông báo trên desktop khi phản hồi hoàn tất.", - "label": "Thông báo khi hoàn tất" - }, "panel_mode": { "description": "Full-height sát phải mở panel cao trọn màn hình ghim vào mép phải; Compact hiển thị panel nổi gần chỗ bấm trên thanh bar.", "label": "Kiểu panel", From b2924eb79d73bab7050c435ade4a703c6429f138 Mon Sep 17 00:00:00 2001 From: Nguyen Tien Duy Date: Tue, 11 Aug 2026 23:19:17 +0700 Subject: [PATCH 2/2] update version to 0.2.0 --- opencode-companion/plugin.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencode-companion/plugin.toml b/opencode-companion/plugin.toml index d0b79fd1..7097bb80 100644 --- a/opencode-companion/plugin.toml +++ b/opencode-companion/plugin.toml @@ -1,6 +1,6 @@ id = "weinguyen/opencode-companion" name = "OpenCode Companion" -version = "0.1.0" +version = "0.2.0" plugin_api = 21 author = "weinguyen" license = "MIT"