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..7097bb80 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 +version = "0.2.0" +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",