From 2dea9c09d725167f0b1e561e2dd480a2860a417e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 31 Aug 2026 07:16:22 -0400 Subject: [PATCH 01/12] feat(session-tabs): implement session tabs and state management --- README.md | 7 + lua/opencode/api.lua | 5 + lua/opencode/commands/handlers/session.lua | 112 +- lua/opencode/config.lua | 9 + lua/opencode/context.lua | 18 + lua/opencode/init.lua | 1 + lua/opencode/services/messaging.lua | 42 +- lua/opencode/services/session_runtime.lua | 212 ++- lua/opencode/state/init.lua | 4 + lua/opencode/state/session.lua | 28 +- lua/opencode/state/session_tabs.lua | 373 ++++ lua/opencode/state/store.lua | 2 + lua/opencode/state/ui.lua | 5 + lua/opencode/types.lua | 11 + lua/opencode/ui/autocmds.lua | 7 +- lua/opencode/ui/highlight.lua | 4 + lua/opencode/ui/input_window.lua | 12 +- lua/opencode/ui/renderer.lua | 102 + lua/opencode/ui/renderer/ctx.lua | 44 +- lua/opencode/ui/renderer/flush.lua | 4 + lua/opencode/ui/session_picker.lua | 10 + lua/opencode/ui/session_tab_picker.lua | 105 ++ lua/opencode/ui/session_tab_strip.lua | 341 ++++ lua/opencode/ui/topbar.lua | 2 + lua/opencode/ui/ui.lua | 104 +- tests/data/hello-new.json | 267 +++ tests/data/hello-old.json | 1956 ++++++++++++++++++++ tests/unit/commands_handlers_spec.lua | 69 +- tests/unit/commands_parse_spec.lua | 8 + tests/unit/persist_state_spec.lua | 5 +- tests/unit/renderer_session_tabs_spec.lua | 75 + tests/unit/session_picker_spec.lua | 31 + tests/unit/session_tab_picker_spec.lua | 87 + tests/unit/session_tab_strip_spec.lua | 75 + tests/unit/session_tabs_spec.lua | 157 ++ 35 files changed, 4238 insertions(+), 56 deletions(-) create mode 100644 lua/opencode/state/session_tabs.lua create mode 100644 lua/opencode/ui/session_tab_picker.lua create mode 100644 lua/opencode/ui/session_tab_strip.lua create mode 100644 tests/data/hello-new.json create mode 100644 tests/data/hello-old.json create mode 100644 tests/unit/renderer_session_tabs_spec.lua create mode 100644 tests/unit/session_tab_picker_spec.lua create mode 100644 tests/unit/session_tab_strip_spec.lua create mode 100644 tests/unit/session_tabs_spec.lua diff --git a/README.md b/README.md index 6d42f169..8b4354e2 100644 --- a/README.md +++ b/README.md @@ -653,13 +653,20 @@ There's 3 main ways on how to change the snacks picker layout The plugin provides the following actions that can be triggered via keymaps, commands, slash commands (typed in the input window), or the Lua API: +Panel tabs are logical tabs inside the Opencode UI. They do not create or switch Neovim tabpages. Each tab keeps its own session state, input buffer, output buffer, and model/context state while reusing the current panel layout. + | Action | Default keymap | Command | API Function | | ----------------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | | Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` | | Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` | | Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` | +| Open a new session in a panel tab | `oN` | `:Opencode tab new [name]` | `require('opencode.api').open_session_tab([name])` | +| Select a panel tab | `o?` | `:Opencode tab select` | `require('opencode.api').select_session_tab()` | +| Switch panel tabs | `o<` / `o>` | `:Opencode tab previous` / `next` | `require('opencode.api').prev_session_tab()` / `next_session_tab()` | +| Close the current panel tab | - | `:Opencode tab close` | `require('opencode.api').close_session_tab()` | | Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` | | Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) | +| Open the selected session in a new panel tab | `` (session picker) | - | - | | Rename current session | `oR` | `:Opencode session rename ` | `:Opencode session rename ` (user command) | | Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` | | Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` | diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index f446c3eb..b4c08cd8 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -50,6 +50,11 @@ local action_groups = { select_session = session.select_session, compact_session = session.compact_session, open_input_new_session_with_title = session.open_input_new_session_with_title, + open_session_tab = session.open_session_tab, + select_session_tab = session.select_session_tab, + next_session_tab = session.next_session_tab, + prev_session_tab = session.prev_session_tab, + close_session_tab = session.close_session_tab, rename_session = session.rename_session, undo = session.undo, copy_message = session.copy_message, diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index f3b15ed6..10d1b763 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -10,8 +10,22 @@ local M = { actions = {}, } -local session_subcommands = - { 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename', 'toggle_lock' } +local session_subcommands = { + 'new', + 'tab', + 'tabs', + 'next_tab', + 'prev_tab', + 'close_tab', + 'select', + 'navigate', + 'compact', + 'share', + 'unshare', + 'agents_init', + 'rename', + 'toggle_lock', +} ---@param message string local function invalid_arguments(message) @@ -102,6 +116,27 @@ function M.actions.open_input_new_session_with_title(title) end)(title) end +---@param title? string +function M.actions.open_session_tab(title) + return session_runtime.open_session_tab(title) +end + +function M.actions.select_session_tab() + return require('opencode.ui.session_tab_picker').select() +end + +function M.actions.next_session_tab() + return session_runtime.cycle_session_tab(1) +end + +function M.actions.prev_session_tab() + return session_runtime.cycle_session_tab(-1) +end + +function M.actions.close_session_tab() + return session_runtime.close_session_tab() +end + ---@param parent_id? string ---@param scope? 'project' | 'global' defaults to global when session is locked, project otherwise function M.actions.select_session(parent_id, scope) @@ -651,6 +686,21 @@ local session_subcommand_actions = { end return M.actions.open_input_new_session() end, + tab = function(args) + return M.actions.open_session_tab(parse_title(args, 2)) + end, + tabs = function() + return M.actions.select_session_tab() + end, + next_tab = function() + return M.actions.next_session_tab() + end, + prev_tab = function() + return M.actions.prev_session_tab() + end, + close_tab = function() + return M.actions.close_session_tab() + end, rename = function(args) return M.actions.rename_session(nil, parse_title(args, 2)) end, @@ -689,9 +739,43 @@ local session_subcommand_actions = { end, } +local tab_subcommands = { 'next', 'new', 'previous', 'select', 'close' } + +---@type table +local tab_subcommand_actions = { + next = function() + return M.actions.next_session_tab() + end, + new = function(args) + return M.actions.open_session_tab(parse_title(args, 2)) + end, + previous = function() + return M.actions.prev_session_tab() + end, + select = function() + return M.actions.select_session_tab() + end, + close = function() + return M.actions.close_session_tab() + end, +} + M.command_defs = { + tab = { + desc = 'Manage Opencode panel tabs', + completions = tab_subcommands, + nested_subcommand = { allow_empty = false }, + execute = function(args) + local subcommand = args[1] + local action = tab_subcommand_actions[subcommand] + if not action then + invalid_arguments('Invalid tab subcommand. Use: ' .. table.concat(tab_subcommands, ', ')) + end + return action(args) + end, + }, session = { - desc = 'Manage sessions (new/select/navigate/compact/share/unshare/rename/toggle_lock)', + desc = 'Manage sessions and Opencode panel tabs', completions = session_subcommands, nested_subcommand = { allow_empty = false }, execute = function(args) @@ -705,6 +789,28 @@ M.command_defs = { }, -- action name aliases for keymap compatibility open_input_new_session = { desc = 'Open input (new session)', execute = M.actions.open_input_new_session }, + open_session_tab = { + desc = 'Open a new session in an Opencode panel tab', + execute = function(args) + return M.actions.open_session_tab(parse_title(args, 1)) + end, + }, + select_session_tab = { + desc = 'Select an Opencode panel tab', + execute = M.actions.select_session_tab, + }, + next_session_tab = { + desc = 'Switch to the next Opencode panel tab', + execute = M.actions.next_session_tab, + }, + prev_session_tab = { + desc = 'Switch to the previous Opencode panel tab', + execute = M.actions.prev_session_tab, + }, + close_session_tab = { + desc = 'Close the current Opencode panel tab', + execute = M.actions.close_session_tab, + }, toggle_session_lock = { desc = 'Toggle session lock (preserve active session across cwd changes)', execute = function(args) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 90fb4067..777630e6 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -33,6 +33,10 @@ M.defaults = { ['og'] = { 'toggle', desc = 'Toggle Opencode window' }, ['oi'] = { 'open_input', desc = 'Open input window' }, ['oI'] = { 'open_input_new_session', desc = 'Open input (new session)' }, + ['oN'] = { 'open_session_tab', desc = 'Open new Opencode session tab' }, + ['o<'] = { 'prev_session_tab', desc = 'Previous Opencode session tab' }, + ['o>'] = { 'next_session_tab', desc = 'Next Opencode session tab' }, + ['o?'] = { 'select_session_tab', desc = 'Select Opencode session tab' }, ['oh'] = { 'select_history', desc = 'Select from history' }, ['oo'] = { 'open_output', desc = 'Open output window' }, ['ot'] = { 'toggle_focus', desc = 'Toggle focus' }, @@ -116,9 +120,14 @@ M.defaults = { rename_session = { '', desc = 'Rename selected session' }, delete_session = { '', desc = 'Delete selected sessions' }, new_session = { '', desc = 'Create a new session' }, + open_in_tab = { '', desc = 'Open selected session in a new panel tab' }, fork_session = { '', desc = 'Fork selected session' }, toggle_scope = { '', desc = 'Toggle between project/global scope' }, }, + session_tab_picker = { + new_tab = { '', desc = 'Create a new panel tab' }, + close_tab = { '', desc = 'Close selected panel tab' }, + }, timeline_picker = { undo = { '', mode = { 'i', 'n' }, desc = 'Undo to selected message' }, fork = { '', mode = { 'i', 'n' }, desc = 'Fork from selected message' }, diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 2ff19f85..153cee76 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -36,6 +36,24 @@ function M.get_context() return ChatContext.context end +---@return OpencodeContext +function M.snapshot() + return vim.deepcopy(ChatContext.context) +end + +---@param snapshot OpencodeContext|nil +function M.restore(snapshot) + ChatContext.context = vim.deepcopy(snapshot or { + mentioned_files = {}, + selections = {}, + mentioned_subagents = {}, + current_file = nil, + cursor_data = nil, + linter_errors = nil, + }) + state.context.set_context_updated_at(vim.uv.now()) +end + --- Formats context for main chat interface (new simplified API) ---@param prompt string The user's instruction/prompt ---@param context_config? OpencodeContextConfig Optional context config diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 124dd49f..9f2cfe24 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -42,6 +42,7 @@ function M.setup(opts) require('opencode.ui.highlight').setup() state = require('opencode.state') + state.session_tabs.setup() state.store.subscribe('opencode_server', on_opencode_server) state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change) state.store.subscribe('pending_permissions', session_runtime._on_current_permission_change) diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index 81fb0440..7b29dfce 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -7,6 +7,7 @@ local Promise = require('opencode.promise') local log = require('opencode.log') local agent_model = require('opencode.services.agent_model') local session_runtime = require('opencode.services.session_runtime') +local session_tabs = require('opencode.state.session_tabs') local M = {} @@ -31,6 +32,7 @@ M.send_message = Promise.async(function(prompt, opts) end opts = opts or {} + local tab_id = state.active_session_tab opts.context = vim.tbl_deep_extend('force', state.current_context_config or {}, opts.context or {}) state.context.set_current_context_config(opts.context) @@ -69,6 +71,11 @@ M.send_message = Promise.async(function(prompt, opts) context.unload_attachments() local function update_sent_message_count(num) + if tab_id then + session_tabs.update_user_message_count(tab_id, session_id, num) + return + end + local sent_message_count = vim.deepcopy(state.user_message_count) local new_value = (sent_message_count[session_id] or 0) + num sent_message_count[session_id] = new_value >= 0 and new_value or 0 @@ -84,29 +91,48 @@ M.send_message = Promise.async(function(prompt, opts) if not response or not response.info or not response.parts then log.notify('Invalid response from opencode: ' .. vim.inspect(response), vim.log.levels.ERROR) - session_runtime.cancel():await() + session_runtime.cancel(session_id, tab_id):await() return end - M.after_run(prompt, sent_context) + M.after_run(prompt, tab_id, sent_context) end) :catch(function(err) log.notify('Error sending message to session: ' .. vim.inspect(err), vim.log.levels.ERROR) update_sent_message_count(-1) - session_runtime.cancel():await() + session_runtime.cancel(session_id, tab_id):await() end) :await() end) ---@param prompt string +---@param tab_id? string ---@param sent_context? OpencodeContext -function M.after_run(prompt, sent_context) - local context_sent = vim.deepcopy(sent_context or context.get_context()) - if not sent_context then +function M.after_run(prompt, tab_id, sent_context) + if tab_id then + local runtime = session_tabs.get(tab_id) + if not runtime then + require('opencode.history').write(prompt) + vim.g.opencode_abort_count = 0 + return + end + + local runtime_context = vim.deepcopy(runtime.context_data or sent_context) + if runtime_context then + runtime_context.mentioned_files = {} + runtime_context.selections = {} + runtime.context_data = runtime_context + end + session_tabs.set_last_sent_context(tab_id, sent_context or runtime_context) + + if session_tabs.active_id() == tab_id then + context.delta_context() + end + else context.unload_attachments() + state.session.set_last_sent_context(vim.deepcopy(context.get_context())) + context.delta_context() end - state.session.set_last_sent_context(context_sent) - context.delta_context() require('opencode.history').write(prompt) vim.g.opencode_abort_count = 0 end diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 366c80c6..a36bb3d7 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -10,6 +10,7 @@ local image_handler = require('opencode.image_handler') local Promise = require('opencode.promise') local log = require('opencode.log') local agent_model = require('opencode.services.agent_model') +local session_tabs = require('opencode.state.session_tabs') local M = {} @@ -145,6 +146,9 @@ end M.open = Promise.async(function(opts) opts = opts or { focus = 'input', new_session = false } + session_tabs.ensure_current() + session_tabs.set_context(context.snapshot()) + state.ui.set_opening(true) if not require('opencode.ui.ui').is_opencode_focused() then @@ -244,6 +248,184 @@ M.create_new_session = Promise.async(function(title_or_opts) end end) +---Mount an existing session in a new logical panel tab. +---@param selected_session Session +---@return Promise +M.open_session_in_tab = Promise.async(function(selected_session) + if not selected_session or not selected_session.id then + return nil + end + + for _, runtime in ipairs(session_tabs.list()) do + if runtime.active_session and runtime.active_session.id == selected_session.id then + M.switch_session_tab(runtime.id):await() + return selected_session + end + end + + session_tabs.set_context(context.snapshot()) + if state.ui.is_visible() then + ui.prepare_session_tab_switch() + ui.hide_visible_windows(state.windows, true) + end + session_tabs.sync() + + local runtime = session_tabs.create(selected_session) + session_tabs.activate(runtime) + context.restore(session_tabs.get_context()) + state.model.clear() + + M.open({ + focus = 'input', + start_insert = true, + new_session = false, + open_action = 'create_fresh', + }):await() + + return selected_session +end) + +---Open a new session in a logical tab inside the Opencode panel. +---@param title? string +---@return Promise +M.open_session_tab = Promise.async(function(title) + local new_session = M.create_new_session(title):await() + if not new_session then + return nil + end + return M.open_session_in_tab(new_session):await() +end) + +---Switch to a logical tab inside the Opencode panel. +---@param tab_id string +---@return Promise +M.switch_session_tab = Promise.async(function(tab_id) + local runtime = session_tabs.get(tab_id) + if not runtime then + return nil + end + + if session_tabs.active_id() == tab_id then + return runtime.active_session + end + + session_tabs.set_context(context.snapshot()) + if state.ui.is_visible() then + ui.prepare_session_tab_switch() + ui.hide_visible_windows(state.windows, true) + end + session_tabs.sync() + session_tabs.activate(runtime) + context.restore(session_tabs.get_context()) + + M.open({ + focus = 'input', + new_session = false, + open_action = 'restore_hidden', + }):await() + + return runtime.active_session +end) + +---Switch to the next or previous logical panel tab. +---@param direction 1|-1 +---@return Promise +M.cycle_session_tab = Promise.async(function(direction) + local tabs = session_tabs.list() + if #tabs < 2 then + return nil + end + + local current_id = session_tabs.active_id() + local current_index = 1 + for index, runtime in ipairs(tabs) do + if runtime.id == current_id then + current_index = index + break + end + end + + local next_index = ((current_index - 1 + direction) % #tabs) + 1 + return M.switch_session_tab(tabs[next_index].id):await() +end) + +---@param runtime OpencodeSessionTabRuntime +local function delete_runtime_buffers(runtime) + local buffers = {} + local seen = {} + + local function collect(source) + for _, key in ipairs({ 'input_buf', 'output_buf', 'footer_buf', 'tab_strip_buf' }) do + local bufnr = source and source[key] + if bufnr and not seen[bufnr] then + seen[bufnr] = true + table.insert(buffers, bufnr) + end + end + end + + collect(runtime.windows) + collect(runtime._hidden_buffers) + + for _, bufnr in ipairs(buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + end + end +end + +---Close a logical panel tab. +---@param tab_id? string Close selected tab, or the active tab when omitted. +---@return boolean +function M.close_session_tab(tab_id) + local runtime = tab_id and session_tabs.get(tab_id) or session_tabs.current() + if not runtime then + return false + end + + local active_id = session_tabs.active_id() + if tab_id and active_id ~= runtime.id then + delete_runtime_buffers(runtime) + session_tabs.remove(runtime) + return true + end + + local tabs = session_tabs.list() + if #tabs == 1 then + session_tabs.set_context(context.snapshot()) + ui.teardown_visible_windows(state.windows) + session_tabs.remove(runtime) + state.session.clear_active() + return true + end + + local next_runtime + for _, candidate in ipairs(tabs) do + if candidate.id ~= runtime.id then + next_runtime = candidate + break + end + end + + session_tabs.set_context(context.snapshot()) + if state.ui.is_visible() then + ui.prepare_session_tab_switch() + ui.hide_visible_windows(state.windows, true) + end + session_tabs.sync() + delete_runtime_buffers(runtime) + session_tabs.remove(runtime) + session_tabs.activate(next_runtime) + context.restore(session_tabs.get_context()) + + M.open({ + focus = 'input', + new_session = false, + open_action = 'restore_hidden', + }) + return true +end + ---@param opts? SendMessageOpts function M.before_run(opts) local is_new_session = opts and opts.new_session or not state.active_session @@ -252,14 +434,24 @@ function M.before_run(opts) }) end ----@param opts? SendMessageOpts -M.cancel = Promise.async(function() - if state.active_session then - if state.jobs.is_running() then +---@param session_id? string +---@param tab_id? string +M.cancel = Promise.async(function(session_id, tab_id) + local target_runtime = tab_id and session_tabs.get(tab_id) or session_tabs.current() + local target_session = session_id and { id = session_id } or state.active_session + + if target_session then + local pending_count = target_runtime + and target_runtime.user_message_count + and target_runtime.user_message_count[target_session.id] + or (state.user_message_count or {})[target_session.id] + local request_running = tab_id and target_runtime and pending_count and pending_count > 0 + or (not tab_id and state.jobs.is_running()) + if request_running then vim.g.opencode_abort_count = (vim.g.opencode_abort_count or 0) + 1 end - local permissions = state.pending_permissions or {} + local permissions = target_runtime and target_runtime.pending_permissions or state.pending_permissions or {} if #permissions > 0 and state.api_client then for _, permission in ipairs(permissions) do state.api_client:reply_to_permission(permission.id, { reply = 'reject' }) @@ -267,14 +459,14 @@ M.cancel = Promise.async(function() end local ok, result = pcall(function() - return state.api_client:abort_session(state.active_session.id):wait() + return state.api_client:abort_session(target_session.id):wait() end) if not ok then vim.notify('Abort error: ' .. vim.inspect(result), vim.log.levels.ERROR) end - if vim.g.opencode_abort_count >= 3 then + if (vim.g.opencode_abort_count or 0) >= 3 then vim.notify('Re-starting Opencode server', vim.log.levels.WARN) vim.g.opencode_abort_count = 0 if state.opencode_server then @@ -286,7 +478,11 @@ M.cancel = Promise.async(function() end end - if state.ui.is_visible() then + if + target_session + and target_session.id == (state.active_session and state.active_session.id) + and state.ui.is_visible() + then require('opencode.ui.footer').clear() input_window.set_content('') require('opencode.history').index = nil diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index 970d1257..d5c864bb 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -5,6 +5,7 @@ local ui = require('opencode.state.ui') local model = require('opencode.state.model') local renderer = require('opencode.state.renderer') local context = require('opencode.state.context') +local session_tabs = require('opencode.state.session_tabs') ---@class OpencodeState : OpencodeStateData ---@field store OpencodeStateStore @@ -14,7 +15,9 @@ local context = require('opencode.state.context') ---@field model OpencodeModelStateMutations ---@field renderer OpencodeRendererStateMutations ---@field context OpencodeContextStateMutations +---@field session_tabs OpencodeSessionTabStateMutations ---@field active_session Session|nil +---@field active_session_tab string|nil ---@field current_model string|nil ---@field api_client OpencodeApiClient|nil @@ -27,6 +30,7 @@ local M = { model = model, renderer = renderer, context = context, + session_tabs = session_tabs, } return setmetatable(M, { diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index 6576b75c..a8fd7614 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -1,25 +1,30 @@ local store = require('opencode.state.store') +local session_tabs = require('opencode.state.session_tabs') ---@class OpencodeSessionStateMutations local M = {} ---@param session Session|nil function M.set_active(session) - return store.batch(function() + local result = store.batch(function() store.set('restore_points', {}) store.set('last_sent_context', nil) store.set('user_message_count', {}) return store.set('active_session', session) end) + session_tabs.sync() + return result end function M.clear_active() - return store.batch(function() + local result = store.batch(function() store.set('restore_points', {}) store.set('last_sent_context', nil) store.set('user_message_count', {}) return store.set('active_session', nil) end) + session_tabs.sync() + return result end ---@return boolean @@ -34,6 +39,7 @@ function M.set_locked(value) else store.set('session_locked', value and true or false) end + session_tabs.sync() end ---@return boolean new_value @@ -45,21 +51,29 @@ end ---@param points RestorePoint[] function M.set_restore_points(points) - return store.set('restore_points', points) + local result = store.set('restore_points', points) + session_tabs.sync() + return result end function M.reset_restore_points() - return store.set('restore_points', {}) + local result = store.set('restore_points', {}) + session_tabs.sync() + return result end ---@param context OpencodeContext|nil function M.set_last_sent_context(context) - return store.set('last_sent_context', context) + local result = store.set('last_sent_context', context) + session_tabs.sync() + return result end ---@param count table function M.set_user_message_count(count) - return store.set('user_message_count', count) + local result = store.set('user_message_count', count) + session_tabs.sync() + return result end ---Increment/decrement the message count for a session, clamped to >= 0 @@ -70,6 +84,7 @@ function M.increment_user_message_count(session_id, delta) local new_value = (counts[session_id] or 0) + delta counts[session_id] = new_value >= 0 and new_value or 0 end) + session_tabs.sync() end ---Update active_session without emitting a change event, used when a silent @@ -78,6 +93,7 @@ end ---@param session Session function M.update_silently(session) store.set_raw('active_session', session) + session_tabs.sync() end return M diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua new file mode 100644 index 00000000..0bdb1917 --- /dev/null +++ b/lua/opencode/state/session_tabs.lua @@ -0,0 +1,373 @@ +local store = require('opencode.state.store') + +---@class OpencodeSessionTabRuntime +---@field id string Logical panel-tab identifier +---@field active_session Session|nil +---@field windows OpencodeWindowState|nil Buffers and the currently mounted panel windows +---@field is_opening boolean +---@field input_content table +---@field is_opencode_focused boolean +---@field last_focused_opencode_window string|nil +---@field last_input_window_position integer[]|nil +---@field last_output_window_position integer[]|nil +---@field last_code_win_before_opencode integer|nil +---@field current_code_buf number|nil +---@field current_code_view table|nil +---@field saved_window_options table|nil +---@field display_route string|nil +---@field current_mode string|nil +---@field last_output number +---@field last_sent_context OpencodeContext|nil +---@field current_context_config OpencodeContextConfig|nil +---@field context_updated_at number|nil +---@field restore_points RestorePoint[] +---@field current_model string|nil +---@field user_mode_model_map table +---@field current_model_info table|nil +---@field current_variant string|nil +---@field messages OpencodeMessage[]|nil +---@field current_message OpencodeMessage|nil +---@field pending_permissions OpencodePermission[] +---@field cost number +---@field tokens_count number +---@field user_message_count table +---@field pre_zoom_width integer|nil +---@field last_window_width_ratio number|nil +---@field current_cwd string|nil +---@field session_locked boolean|nil +---@field _hidden_buffers OpencodeHiddenBuffers|nil +---@field context_data OpencodeContext|nil +---@field renderer_context table|nil Renderer caches associated with the preserved output buffer + +---@class OpencodeSessionTabStateMutations +local M = {} + +local RUNTIME_KEYS = { + 'active_session', + 'windows', + 'is_opening', + 'input_content', + 'is_opencode_focused', + 'last_focused_opencode_window', + 'last_input_window_position', + 'last_output_window_position', + 'last_code_win_before_opencode', + 'current_code_buf', + 'current_code_view', + 'saved_window_options', + 'display_route', + 'current_mode', + 'last_output', + 'last_sent_context', + 'current_context_config', + 'context_updated_at', + 'restore_points', + 'current_model', + 'user_mode_model_map', + 'current_model_info', + 'current_variant', + 'messages', + 'current_message', + 'pending_permissions', + 'cost', + 'tokens_count', + 'user_message_count', + 'pre_zoom_width', + 'last_window_width_ratio', + 'current_cwd', + 'session_locked', + '_hidden_buffers', +} + +local UI_KEYS = { + windows = true, + input_content = true, + is_opencode_focused = true, + last_focused_opencode_window = true, + last_input_window_position = true, + last_output_window_position = true, + last_code_win_before_opencode = true, + current_code_buf = true, + current_code_view = true, + saved_window_options = true, + display_route = true, + pre_zoom_width = true, + last_window_width_ratio = true, + _hidden_buffers = true, +} + +local runtimes = {} +local next_id = 1 +local setup_done = false + +local function new_id() + local id = 'tab-' .. next_id + next_id = next_id + 1 + return id +end + +local function default_runtime(id) + return { + id = id, + active_session = nil, + windows = nil, + is_opening = false, + input_content = {}, + is_opencode_focused = false, + last_focused_opencode_window = nil, + last_input_window_position = nil, + last_output_window_position = nil, + last_code_win_before_opencode = nil, + current_code_buf = nil, + current_code_view = nil, + saved_window_options = nil, + display_route = nil, + current_mode = nil, + last_output = 0, + last_sent_context = nil, + current_context_config = nil, + context_updated_at = nil, + restore_points = {}, + current_model = nil, + user_mode_model_map = {}, + current_model_info = nil, + current_variant = nil, + messages = nil, + current_message = nil, + pending_permissions = {}, + cost = 0, + tokens_count = 0, + user_message_count = {}, + pre_zoom_width = nil, + last_window_width_ratio = nil, + current_cwd = vim.fn.getcwd(), + session_locked = nil, + _hidden_buffers = nil, + context_data = nil, + renderer_context = nil, + } +end + +local function copy_from_store(runtime) + for _, key in ipairs(RUNTIME_KEYS) do + runtime[key] = store.get(key) + end +end + +local function copy_to_store(runtime) + for _, key in ipairs(RUNTIME_KEYS) do + store.set(key, runtime[key]) + end +end + +local function clear_ui(runtime) + local defaults = default_runtime(runtime.id) + for key, _ in pairs(UI_KEYS) do + runtime[key] = defaults[key] + end +end + +local function runtime_from_current(id, preserve_ui) + local runtime = default_runtime(id) + copy_from_store(runtime) + if not preserve_ui then + clear_ui(runtime) + end + runtime.id = id + return runtime +end + +local function capture_runtime(id) + local runtime = runtimes[id] + if not runtime then + return nil + end + + copy_from_store(runtime) + runtime.id = id + return runtime +end + +---@return OpencodeSessionTabRuntime[] +function M.list() + M.sync() + local tabs = {} + for _, runtime in pairs(runtimes) do + table.insert(tabs, runtime) + end + table.sort(tabs, function(a, b) + local a_order = tonumber(a.id:match('(%d+)$')) or 0 + local b_order = tonumber(b.id:match('(%d+)$')) or 0 + return a_order < b_order + end) + return tabs +end + +---@param id string +---@return OpencodeSessionTabRuntime|nil +function M.get(id) + return runtimes[id] +end + +---@return OpencodeSessionTabRuntime|nil +function M.current() + local runtime = runtimes[store.get('active_session_tab')] + if runtime then + capture_runtime(runtime.id) + end + return runtime +end + +---@return string|nil +function M.active_id() + return store.get('active_session_tab') +end + +---@return boolean +function M.is_current_bound() + return M.current() ~= nil +end + +---@param id? string +function M.sync(id) + id = id or store.get('active_session_tab') + if id and runtimes[id] then + capture_runtime(id) + end +end + +---@param context_data OpencodeContext|nil +function M.set_context(context_data) + local runtime = M.current() + if runtime then + runtime.context_data = vim.deepcopy(context_data) + end +end + +---@return OpencodeContext|nil +function M.get_context() + local runtime = M.current() + return runtime and vim.deepcopy(runtime.context_data) or nil +end + +---@param tab_id string +---@param session_id string +---@param delta integer +function M.update_user_message_count(tab_id, session_id, delta) + local runtime = runtimes[tab_id] + if not runtime then + return + end + + runtime.user_message_count = runtime.user_message_count or {} + local next_count = (runtime.user_message_count[session_id] or 0) + delta + runtime.user_message_count[session_id] = math.max(0, next_count) + + if store.get('active_session_tab') == tab_id then + store.set('user_message_count', runtime.user_message_count) + end +end + +---@param tab_id string +---@param context_data OpencodeContext|nil +function M.set_last_sent_context(tab_id, context_data) + local runtime = runtimes[tab_id] + if not runtime then + return + end + + runtime.last_sent_context = vim.deepcopy(context_data) + if store.get('active_session_tab') == tab_id then + store.set('last_sent_context', runtime.last_sent_context) + end +end + +---@return OpencodeSessionTabRuntime +function M.ensure_current() + local id = store.get('active_session_tab') + if id and runtimes[id] then + capture_runtime(id) + return runtimes[id] + end + + id = new_id() + local runtime = runtime_from_current(id, false) + runtimes[id] = runtime + store.set('active_session_tab', id) + return runtime +end + +---@param runtime OpencodeSessionTabRuntime +function M.activate(runtime) + if type(runtime) == 'string' then + runtime = runtimes[runtime] + end + if not runtime then + return false + end + + local previous_id = store.get('active_session_tab') + if previous_id and previous_id ~= runtime.id then + capture_runtime(previous_id) + end + + if previous_id ~= runtime.id then + store.batch(function() + copy_to_store(runtime) + store.set('active_session_tab', runtime.id) + end) + else + capture_runtime(runtime.id) + end + + return true +end + +---@param session Session|nil +---@return OpencodeSessionTabRuntime +function M.create(session) + local runtime = runtime_from_current(new_id(), false) + runtime.active_session = session + runtime.messages = nil + runtime.current_message = nil + runtime.pending_permissions = {} + runtime.restore_points = {} + runtime.last_sent_context = nil + runtime.user_message_count = {} + runtime.cost = 0 + runtime.tokens_count = 0 + runtimes[runtime.id] = runtime + return runtime +end + +---@param runtime OpencodeSessionTabRuntime +function M.remove(runtime) + if not runtime then + return + end + runtimes[runtime.id] = nil + if store.get('active_session_tab') == runtime.id then + store.set('active_session_tab', nil) + end +end + +---Reset the in-memory tab registry. Intended for teardown and tests. +function M.reset() + runtimes = {} + next_id = 1 + setup_done = false + store.set_raw('active_session_tab', nil) +end + +function M.setup() + if setup_done then + return + end + setup_done = true + + local runtime = runtime_from_current(new_id(), true) + runtimes[runtime.id] = runtime + store.set('active_session_tab', runtime.id) +end + +return M diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index 4c5f1ba6..fb8675a2 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -42,6 +42,7 @@ local M = {} ---@field current_cwd string|nil ---@field session_locked boolean|nil ---@field _hidden_buffers OpencodeHiddenBuffers|nil +---@field active_session_tab string|nil ---@type OpencodeStateData local _state = { @@ -84,6 +85,7 @@ local _state = { current_cwd = vim.fn.getcwd(), session_locked = nil, _hidden_buffers = nil, + active_session_tab = nil, } local _listeners = {} diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index 0c6d1e8a..5481a801 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -7,6 +7,7 @@ local store = require('opencode.state.store') ---@field input_buf integer ---@field output_buf integer ---@field footer_buf integer|nil +---@field tab_strip_buf integer|nil ---@field output_was_at_bottom boolean ---@field input_hidden boolean ---@field input_cursor integer[]|nil @@ -21,6 +22,8 @@ local store = require('opencode.state.store') ---@field output_win integer|nil ---@field footer_win integer|nil ---@field footer_buf integer|nil +---@field tab_strip_win integer|nil +---@field tab_strip_buf integer|nil ---@field input_buf integer|nil ---@field output_buf integer|nil ---@field output_was_at_bottom boolean|nil @@ -113,6 +116,7 @@ function M.mark_windows_hidden(output_was_at_bottom) win.input_win = nil win.output_win = nil win.footer_win = nil + win.tab_strip_win = nil win.output_was_at_bottom = output_was_at_bottom end) end @@ -340,6 +344,7 @@ local function normalize_hidden_buffers(hidden) input_buf = hidden.input_buf, output_buf = hidden.output_buf, footer_buf = valid_buf(hidden.footer_buf) and hidden.footer_buf or nil, + tab_strip_buf = valid_buf(hidden.tab_strip_buf) and hidden.tab_strip_buf or nil, output_was_at_bottom = hidden.output_was_at_bottom == true, input_hidden = hidden.input_hidden, input_cursor = normalize_cursor(hidden.input_cursor), diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 6925cec6..aba75f99 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -138,6 +138,11 @@ ---@field revert? SessionRevertInfo ---@field share? SessionShareInfo +---@class OpencodeSessionTab +---@field id string Logical panel-tab identifier +---@field active_session Session|nil Session assigned to this tab +---@field windows OpencodeWindowState|nil UI windows owned by this tab + ---@class SessionProjectInfo ---@field id string ---@field name? string @@ -161,6 +166,7 @@ ---@field input_window OpencodeKeymapInputWindow ---@field output_window OpencodeKeymapOutputWindow ---@field session_picker OpencodeSessionPickerKeymap +---@field session_tab_picker OpencodeSessionTabPickerKeymap ---@field timeline_picker OpencodeTimelinePickerKeymap ---@field history_picker OpencodeHistoryPickerKeymap ---@field quick_chat OpencodeQuickChatKeymap @@ -168,10 +174,15 @@ ---@class OpencodeSessionPickerKeymap ---@field delete_session OpencodeKeymapEntry ---@field new_session OpencodeKeymapEntry +---@field open_in_tab OpencodeKeymapEntry ---@field rename_session OpencodeKeymapEntry ---@field fork_session OpencodeKeymapEntry ---@field toggle_scope OpencodeKeymapEntry +---@class OpencodeSessionTabPickerKeymap +---@field new_tab OpencodeKeymapEntry +---@field close_tab OpencodeKeymapEntry + ---@class OpencodeTimelinePickerKeymap ---@field undo OpencodeKeymapEntry ---@field fork OpencodeKeymapEntry diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index bcc7ae35..af89bdb8 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -8,7 +8,7 @@ function M.setup_autocmds(windows) output_window.setup_autocmds(windows, group) -- Only keep shared autocmds here (e.g., WinClosed, WinLeave for all windows) - local wins = { windows.input_win, windows.output_win, windows.footer_win } + local wins = { windows.input_win, windows.output_win, windows.footer_win, windows.tab_strip_win } vim.api.nvim_create_autocmd('WinClosed', { group = group, pattern = table.concat(wins, ','), @@ -104,7 +104,7 @@ function M.setup_autocmds(windows) local current_win = vim.api.nvim_get_current_win() local current_buf = vim.api.nvim_get_current_buf() - if current_win ~= windows.output_win and current_win ~= windows.input_win then + if current_win ~= windows.output_win and current_win ~= windows.input_win and current_win ~= windows.tab_strip_win then return end @@ -112,6 +112,7 @@ function M.setup_autocmds(windows) current_buf == windows.output_buf or current_buf == windows.input_buf or (windows.footer_buf and current_buf == windows.footer_buf) + or (windows.tab_strip_buf and current_buf == windows.tab_strip_buf) ) if not is_opencode_buf then @@ -134,6 +135,7 @@ function M.setup_resize_handler(windows) require('opencode.ui.footer').update_window(windows) input_window.update_dimensions(windows) output_window.update_dimensions(windows) + require('opencode.ui.session_tab_strip').update_window(windows) end, }) vim.api.nvim_create_autocmd('WinResized', { @@ -151,6 +153,7 @@ function M.setup_resize_handler(windows) require('opencode.ui.topbar').render() require('opencode.ui.footer').update_window(windows) + require('opencode.ui.session_tab_strip').update_window(windows) end, }) end diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 2e7dba03..20291c4e 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -8,6 +8,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#9E9E9E', default = true }) vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionDescription', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabActive', { link = 'TabLineSel', bold = true, default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabInactive', { link = 'TabLine', default = true }) vim.api.nvim_set_hl(0, 'OpencodeMention', { link = 'Special', default = true }) vim.api.nvim_set_hl(0, 'OpencodeToolBorder', { fg = '#B0BEC5', nocombine = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeMessageRoleAssistant', { link = 'Special', default = true }) @@ -59,6 +61,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true }) vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionDescription', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabActive', { link = 'TabLineSel', bold = true, default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabInactive', { link = 'TabLine', default = true }) vim.api.nvim_set_hl(0, 'OpencodeMention', { link = 'Special', default = true }) vim.api.nvim_set_hl(0, 'OpencodeToolBorder', { fg = '#3b4261', nocombine = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeRevertBorder', { bg = '#FF9E3B', default = true }) diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index fc159663..cc97f831 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -203,7 +203,7 @@ M._prompt_add_to_context = function(cmd, output, exit_code) end M._append_to_input = function(text) - if M._hidden then + if M.is_hidden() then M._show() end @@ -380,7 +380,7 @@ function M.recover_input(windows) end function M.focus_input() - if M._hidden then + if M.is_hidden() then M._show() return end @@ -409,7 +409,7 @@ function M.set_content(text, windows) local lines = type(text) == 'table' and text or vim.split(tostring(text), '\n') local has_content = #lines > 1 or (lines[1] and lines[1] ~= '') - if has_content and M._hidden then + if has_content and M.is_hidden() then M._show() windows = state.windows end @@ -606,7 +606,7 @@ function M.toggle() return end - if M._hidden then + if M.is_hidden() then M._show() else M._hide() @@ -719,6 +719,10 @@ end ---Check if the input window is currently hidden ---@return boolean function M.is_hidden() + local windows = state.windows + if windows and windows.input_buf then + return not windows.input_win or not vim.api.nvim_win_is_valid(windows.input_win) + end return M._hidden end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index c28dfaa8..c64c390b 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -8,6 +8,7 @@ local events = require('opencode.ui.renderer.events') local event_scope = require('opencode.ui.event_scope') local flush = require('opencode.ui.renderer.flush') local scroll = require('opencode.ui.renderer.scroll') +local session_tabs = require('opencode.state.session_tabs') local M = {} local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__' @@ -17,6 +18,52 @@ local QUESTION_DISPLAY_MESSAGE_ID = 'question-display-message' local LAZYRENDER_EST_LINES_PER_MSG = 5 local LAZYRENDER_VIEWPORT_BUFFER = 1.5 +local rendered_session_tab = nil + +---@param tab_id string|nil +local function save_tab_context(tab_id) + if not tab_id then + return + end + + local runtime = session_tabs.get(tab_id) + if runtime then + local snapshot = ctx:snapshot() + local windows = tab_id == state.active_session_tab and state.windows or runtime.windows + snapshot.output_buf = windows and windows.output_buf or nil + runtime.renderer_context = snapshot + end +end + +---@param tab_id string|nil +---@return boolean +local function restore_tab_context(tab_id) + local runtime = tab_id and session_tabs.get(tab_id) + if not runtime or not runtime.renderer_context then + ctx:restore(nil) + reference_facts.clear() + return false + end + + local output_buf = state.windows and state.windows.output_buf + if runtime.renderer_context.output_buf and runtime.renderer_context.output_buf ~= output_buf then + ctx:restore(nil) + reference_facts.clear() + return false + end + + ctx:restore(runtime.renderer_context) + if state.active_session then + reference_facts.rebuild(state.active_session.id, state.messages or {}) + else + reference_facts.clear() + end + return true +end + +local function save_active_tab_context() + save_tab_context(state.active_session_tab) +end ---Calculate how many messages to render initially based on window height. ---@return integer @@ -311,11 +358,15 @@ function M.setup_subscriptions(subscribe) subscribe = subscribe == nil and true or subscribe if subscribe then + rendered_session_tab = state.active_session_tab state.store.subscribe('is_opencode_focused', M.on_focus_changed) state.store.subscribe('active_session', M.on_session_changed) + state.store.subscribe('active_session_tab', M.on_session_tab_changed) else + rendered_session_tab = nil state.store.unsubscribe('is_opencode_focused', M.on_focus_changed) state.store.unsubscribe('active_session', M.on_session_changed) + state.store.unsubscribe('active_session_tab', M.on_session_tab_changed) end if not state.event_manager then @@ -436,6 +487,8 @@ function M._render_full_session_data(session_data, opts) if config.hooks and config.hooks.on_session_loaded then pcall(config.hooks.on_session_loaded, state.active_session) end + + save_active_tab_context() end ---Re-render from cached session data without a server round-trip. @@ -531,6 +584,15 @@ function M.render_full_session() end) end +---Flush the active tab before its window and renderer context are detached. +function M.prepare_session_tab_switch() + if ctx.bulk_mode then + flush.end_bulk_mode() + end + flush.flush() + save_active_tab_context() +end + ---Replace the entire output buffer with the given lines ---@param lines string[] function M.render_lines(lines) @@ -588,6 +650,9 @@ end ---Re-render when the active session changes function M.on_session_changed(_, new, old) + if state.active_session_tab ~= rendered_session_tab then + return + end if (old and old.id) == (new and new.id) then return end @@ -597,6 +662,43 @@ function M.on_session_changed(_, new, old) end end +---Rebind renderer state when the selected logical panel tab changes. +function M.on_session_tab_changed(_, new, old) + if new == old then + return + end + save_tab_context(old) + rendered_session_tab = new + local restored = restore_tab_context(new) + local prompts = ctx.prompt_controllers + if prompts.question then + prompts.question.clear_question() + end + if prompts.permission then + prompts.permission.clear_all() + end + require('opencode.ui.renderer.events').render_permissions_display() + + if restored then + if ctx:has_pending_work() and output_window.mounted() then + flush.schedule() + end + if state.active_session and state.api_client then + if prompts.question and type(state.api_client.list_questions) == 'function' then + prompts.question.restore_pending_question(state.active_session.id) + end + if prompts.permission and type(state.api_client.list_permissions) == 'function' then + prompts.permission.restore_pending_permissions(state.active_session.id) + end + end + return + end + + if state.active_session then + M.render_full_session():and_then(save_active_tab_context) + end +end + M.reconcile_rendered_message_limit = reconcile_rendered_message_limit M.is_message_visible = is_message_visible diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 59f8ea83..71e3d6e6 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -56,11 +56,25 @@ local ctx = { part_folds = {}, ---@type integer|nil Number of messages to render from the end (nil = all) lazy_render_count = nil, + generation = 0, +} + +local CONTEXT_KEYS = { + 'render_state', + 'last_part_formatted', + 'formatted_parts', + 'formatted_messages', + 'pending', + 'markdown_render_scheduled', + 'global_folds', + 'part_folds', + 'lazy_render_count', } ---Reset all renderer caches and pending state. function ctx:reset() - self.render_state:reset() + self.generation = self.generation + 1 + self.render_state = RenderState.new() self.last_part_formatted = { part_id = nil, formatted_data = nil } self.formatted_parts = {} self.formatted_messages = {} @@ -82,6 +96,34 @@ function ctx:reset() self:bulk_reset() end +---@return table +function ctx:snapshot() + local snapshot = {} + for _, key in ipairs(CONTEXT_KEYS) do + snapshot[key] = self[key] + end + return snapshot +end + +---@param snapshot table|nil +---@return boolean +function ctx:restore(snapshot) + self.generation = self.generation + 1 + if not snapshot then + self:reset() + return false + end + + for _, key in ipairs(CONTEXT_KEYS) do + self[key] = snapshot[key] + end + + self.flush_scheduled = false + self.bulk_mode = false + self:bulk_reset() + return true +end + ---Reset the temporary bulk-render accumulators. function ctx:bulk_reset() self.bulk_mode = false diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index d1a93576..1b0c8f5b 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -224,7 +224,11 @@ function M.schedule() end ctx.flush_scheduled = true + local generation = ctx.generation vim.schedule(function() + if ctx.generation ~= generation then + return + end ctx.flush_scheduled = false M.flush() end) diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index f05775aa..75ce8576 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -338,6 +338,16 @@ function M.pick(sessions, callback, opts) end), reload = true, }, + open_in_tab = { + key = config.keymap.session_picker.open_in_tab, + label = 'open in tab', + fn = Promise.async(function(selected, opts) + if opts.close then + opts.close() + end + return require('opencode.services.session_runtime').open_session_in_tab(selected):await() + end), + }, fork = { key = config.keymap.session_picker.fork_session, label = 'fork', diff --git a/lua/opencode/ui/session_tab_picker.lua b/lua/opencode/ui/session_tab_picker.lua new file mode 100644 index 00000000..f699bd3a --- /dev/null +++ b/lua/opencode/ui/session_tab_picker.lua @@ -0,0 +1,105 @@ +local M = {} +local config = require('opencode.config') +local base_picker = require('opencode.ui.base_picker') +local picker = require('opencode.ui.picker') +local Promise = require('opencode.promise') +local session_tabs = require('opencode.state.session_tabs') +local session_runtime = require('opencode.services.session_runtime') + +---@param tab OpencodeSessionTabRuntime +---@param width? integer +---@return PickerItem +local function format_tab_item(tab, width) + local session = tab.active_session + local title = session and session.title + if type(title) ~= 'string' or vim.trim(title) == '' then + title = 'New session' + end + + local updated = session and session.time and session.time.updated + return base_picker.create_time_picker_item(title, updated, 'ID: ' .. tab.id, width) +end + +---@param tabs OpencodeSessionTabRuntime[] +---@param callback fun(tab: OpencodeSessionTabRuntime|nil) +---@return boolean +function M.pick(tabs, callback) + if #tabs == 0 then + vim.notify('No Opencode tabs', vim.log.levels.INFO) + return false + end + + local keymap = config.keymap.session_tab_picker + local actions = { + new = { + key = keymap.new_tab, + label = 'new', + fn = Promise.async(function(_, opts) + if opts.close then + opts.close() + end + return session_runtime.open_session_tab():await() + end), + }, + close = { + key = keymap.close_tab, + label = 'close', + fn = function(selected, opts) + if opts.close then + opts.close() + end + return session_runtime.close_session_tab(selected.id) + end, + }, + } + + return base_picker.pick({ + items = tabs, + format_fn = format_tab_item, + actions = actions, + callback = callback, + title = 'Opencode tabs', + width = config.ui.picker_width, + layout_opts = config.ui.picker, + }) +end + +---@param callback? fun(tab: OpencodeSessionTabRuntime|nil) +function M.select(callback) + local tabs = session_tabs.list() + if #tabs == 0 then + vim.notify('No Opencode tabs', vim.log.levels.INFO) + return false + end + + local on_select = callback + or function(tab) + if tab then + session_runtime.switch_session_tab(tab.id) + end + end + + local picker_type = picker.get_best_picker() + if picker_type == nil or picker_type == 'select' then + picker.select(tabs, { + prompt = 'Opencode tabs', + format_item = function(tab) + return format_tab_item(tab):to_string() + end, + }, on_select) + return true + end + + local success = M.pick(tabs, on_select) + + if not success then + picker.select(tabs, { + prompt = 'Opencode tabs', + format_item = function(tab) + return format_tab_item(tab):to_string() + end, + }, on_select) + end +end + +return M diff --git a/lua/opencode/ui/session_tab_strip.lua b/lua/opencode/ui/session_tab_strip.lua new file mode 100644 index 00000000..6bb92ff5 --- /dev/null +++ b/lua/opencode/ui/session_tab_strip.lua @@ -0,0 +1,341 @@ +local state = require('opencode.state') +local session_tabs = require('opencode.state.session_tabs') + +local M = {} + +local namespace = vim.api.nvim_create_namespace('opencode_session_tab_strip') +local ranges_by_buffer = {} +local subscribed = false + +local function display_width(text) + return vim.fn.strdisplaywidth(text) +end + +---@param text string +---@param max_width integer +---@return string +local function truncate(text, max_width) + if max_width <= 0 then + return '' + end + if display_width(text) <= max_width then + return text + end + if max_width <= 3 then + return string.rep('.', max_width) + end + + local target_width = max_width - 3 + for char_count = vim.fn.strchars(text), 0, -1 do + local prefix = vim.fn.strcharpart(text, 0, char_count) + if display_width(prefix) <= target_width then + return prefix .. '...' + end + end + return '...' +end + +---@param tab OpencodeSessionTabRuntime +---@return string +local function tab_title(tab) + local title = tab.active_session and tab.active_session.title + if type(title) ~= 'string' or vim.trim(title) == '' then + return 'New session' + end + return title +end + +---@param tabs OpencodeSessionTabRuntime[] +---@param width integer +---@return string line, table[] ranges, table[] highlights +local function build_horizontal_content(tabs, width) + if #tabs == 0 then + return '', {}, {} + end + + local separator = ' ' + local separator_width = display_width(separator) + local segment_width = math.max(3, math.floor((width - separator_width * math.max(0, #tabs - 1)) / #tabs)) + local active_id = session_tabs.active_id() + local parts = {} + local ranges = {} + local highlights = {} + local byte_col = 0 + local display_col = 0 + + for index, tab in ipairs(tabs) do + if index > 1 then + parts[#parts + 1] = separator + byte_col = byte_col + #separator + display_col = display_col + separator_width + end + + local marker = tab.id == active_id and '> ' or ' ' + local prefix = marker .. '[' .. index .. ' ' + local suffix = ']' + local title_width = segment_width - display_width(prefix) - display_width(suffix) + local label + if title_width > 0 then + label = prefix .. truncate(tab_title(tab), title_width) .. suffix + else + label = marker .. '[' .. index .. ']' + if display_width(label) > segment_width then + label = truncate(tostring(index), segment_width) + end + end + + local start_byte = byte_col + local start_display = display_col + parts[#parts + 1] = label + byte_col = byte_col + #label + display_col = display_col + display_width(label) + + ranges[#ranges + 1] = { + tab_id = tab.id, + start_byte = start_byte, + end_byte = byte_col, + start_display = start_display, + end_display = display_col, + } + highlights[#highlights + 1] = { + group = tab.id == active_id and 'OpencodeSessionTabActive' or 'OpencodeSessionTabInactive', + start_col = start_byte, + end_col = byte_col, + } + end + + return table.concat(parts), ranges, highlights +end + +---@param windows OpencodeWindowState +---@return boolean +local function valid_windows(windows) + return windows + and windows.output_win + and windows.tab_strip_win + and windows.tab_strip_buf + and vim.api.nvim_win_is_valid(windows.output_win) + and vim.api.nvim_win_is_valid(windows.tab_strip_win) + and vim.api.nvim_buf_is_valid(windows.tab_strip_buf) +end + +---@param windows OpencodeWindowState +local function setup_window_options(windows) + local win = windows.tab_strip_win + local buf = windows.tab_strip_buf + + vim.api.nvim_set_option_value('buftype', 'nofile', { buf = buf }) + vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = buf }) + vim.api.nvim_set_option_value('buflisted', false, { buf = buf }) + vim.api.nvim_set_option_value('swapfile', false, { buf = buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) + + vim.api.nvim_set_option_value('cursorline', false, { win = win }) + vim.api.nvim_set_option_value('number', false, { win = win }) + vim.api.nvim_set_option_value('relativenumber', false, { win = win }) + vim.api.nvim_set_option_value('signcolumn', 'no', { win = win }) + vim.api.nvim_set_option_value('statuscolumn', '', { win = win }) + vim.api.nvim_set_option_value('wrap', false, { win = win }) + vim.api.nvim_set_option_value('winbar', '', { win = win }) + vim.api.nvim_set_option_value( + 'winhighlight', + 'Normal:OpencodeBackground,EndOfBuffer:OpencodeBackground', + { win = win } + ) + + if vim.api.nvim_win_get_config(win).relative == '' then + vim.api.nvim_set_option_value('winfixheight', true, { win = win }) + end +end + +---@param buffer integer +---@param display_column integer +---@return string|nil +local function tab_id_at_display_column(buffer, display_column) + for _, range in ipairs(ranges_by_buffer[buffer] or {}) do + if display_column >= range.start_display and display_column < range.end_display then + return range.tab_id + end + end + return nil +end + +---@param buffer integer +---@param byte_column integer +---@return string|nil +local function tab_id_at_byte_column(buffer, byte_column) + for _, range in ipairs(ranges_by_buffer[buffer] or {}) do + if byte_column >= range.start_byte and byte_column < range.end_byte then + return range.tab_id + end + end + return nil +end + +---@param tab_id string|nil +local function select_tab(tab_id) + if not tab_id then + return + end + require('opencode.services.session_runtime').switch_session_tab(tab_id) +end + +local function click_tab() + local buffer = vim.api.nvim_get_current_buf() + local mouse = vim.fn.getmousepos() + select_tab(tab_id_at_display_column(buffer, math.max(0, mouse.column - 1))) +end + +local function select_tab_under_cursor() + local buffer = vim.api.nvim_get_current_buf() + local cursor = vim.api.nvim_win_get_cursor(0) + select_tab(tab_id_at_byte_column(buffer, cursor[2])) +end + +---@param buffer integer +local function setup_keymaps(buffer) + vim.keymap.set('n', '', click_tab, { buffer = buffer, silent = true, nowait = true }) + vim.keymap.set('n', '<2-LeftMouse>', click_tab, { buffer = buffer, silent = true, nowait = true }) + vim.keymap.set('n', '', select_tab_under_cursor, { buffer = buffer, silent = true, nowait = true }) +end + +---@param windows OpencodeWindowState +function M.render(windows) + windows = windows or state.windows + if not valid_windows(windows) then + return + end + + local buffer = windows.tab_strip_buf + local width = vim.api.nvim_win_get_width(windows.tab_strip_win) + local tabs = session_tabs.list() + local line, ranges, highlights = build_horizontal_content(tabs, math.max(1, width)) + + vim.api.nvim_set_option_value('modifiable', true, { buf = buffer }) + vim.api.nvim_buf_set_lines(buffer, 0, -1, false, { line }) + vim.api.nvim_buf_clear_namespace(buffer, namespace, 0, -1) + for _, highlight in ipairs(highlights) do + vim.api.nvim_buf_set_extmark(buffer, namespace, 0, highlight.start_col, { + end_col = highlight.end_col, + hl_group = highlight.group, + }) + end + vim.api.nvim_set_option_value('modifiable', false, { buf = buffer }) + + ranges_by_buffer[buffer] = ranges +end + +---@param output_win integer +---@return vim.api.keyset.win_config +local function build_float_config(output_win) + return { + relative = 'win', + win = output_win, + anchor = 'NW', + width = vim.api.nvim_win_get_width(output_win), + height = 1, + row = 0, + col = 0, + focusable = true, + mouse = true, + style = 'minimal', + border = 'none', + zindex = 50, + } +end + +---@param windows OpencodeWindowState +---@return integer|nil +function M.create_window(windows) + if not windows.output_win or not windows.tab_strip_buf or not vim.api.nvim_win_is_valid(windows.output_win) then + return nil + end + + local output_config = vim.api.nvim_win_get_config(windows.output_win) + if output_config.relative == '' then + windows.tab_strip_win = vim.api.nvim_open_win(windows.tab_strip_buf, false, { + split = 'above', + win = windows.output_win, + }) + vim.api.nvim_win_set_height(windows.tab_strip_win, 1) + else + windows.tab_strip_win = vim.api.nvim_open_win(windows.tab_strip_buf, false, build_float_config(windows.output_win)) + end + + setup_window_options(windows) + setup_keymaps(windows.tab_strip_buf) + return windows.tab_strip_win +end + +---@param windows? OpencodeWindowState +---@return boolean +function M.mounted(windows) + return valid_windows(windows or state.windows) +end + +---@param windows? OpencodeWindowState +function M.update_window(windows) + windows = windows or state.windows + if not valid_windows(windows) then + return + end + + if vim.api.nvim_win_get_config(windows.tab_strip_win).relative ~= '' then + pcall(vim.api.nvim_win_set_config, windows.tab_strip_win, build_float_config(windows.output_win)) + end + M.render(windows) +end + +---@return integer +function M.create_buf() + local buffer = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value('filetype', 'opencode_session_tabs', { buf = buffer }) + return buffer +end + +local function on_change() + M.render() +end + +---@param windows OpencodeWindowState +function M.setup(windows) + if not valid_windows(windows) then + return false + end + + if not subscribed then + state.store.subscribe('active_session', on_change) + state.store.subscribe('active_session_tab', on_change) + subscribed = true + end + + setup_window_options(windows) + setup_keymaps(windows.tab_strip_buf) + M.render(windows) + return true +end + +---@param preserve_buffer? boolean +---@param windows? OpencodeWindowState +function M.close(preserve_buffer, windows) + windows = windows or state.windows + if windows then + if windows.tab_strip_win and vim.api.nvim_win_is_valid(windows.tab_strip_win) then + pcall(vim.api.nvim_win_close, windows.tab_strip_win, true) + end + if not preserve_buffer and windows.tab_strip_buf and vim.api.nvim_buf_is_valid(windows.tab_strip_buf) then + pcall(vim.api.nvim_buf_delete, windows.tab_strip_buf, { force = true }) + end + if windows.tab_strip_buf then + ranges_by_buffer[windows.tab_strip_buf] = nil + end + end + + if subscribed then + state.store.unsubscribe('active_session', on_change) + state.store.unsubscribe('active_session_tab', on_change) + subscribed = false + end +end + +return M diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index ad28b3ce..043dcb09 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -96,6 +96,7 @@ function M.setup() state.store.subscribe('current_mode', on_change) state.store.subscribe('current_model', on_change) state.store.subscribe('active_session', on_change) + state.store.subscribe('active_session_tab', on_change) state.store.subscribe('is_opencode_focused', on_change) state.store.subscribe('tokens_count', on_change) state.store.subscribe('cost', on_change) @@ -107,6 +108,7 @@ function M.close() state.store.unsubscribe('current_mode', on_change) state.store.unsubscribe('current_model', on_change) state.store.unsubscribe('active_session', on_change) + state.store.unsubscribe('active_session_tab', on_change) state.store.unsubscribe('is_opencode_focused', on_change) state.store.unsubscribe('tokens_count', on_change) state.store.unsubscribe('cost', on_change) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index d1965986..e8251a46 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -5,6 +5,7 @@ local output_window = require('opencode.ui.output_window') local input_window = require('opencode.ui.input_window') local float_layout = require('opencode.ui.float_layout') local footer = require('opencode.ui.footer') +local session_tab_strip = require('opencode.ui.session_tab_strip') local topbar = require('opencode.ui.topbar') local M = {} @@ -70,6 +71,7 @@ local function capture_hidden_snapshot(windows) input_buf = windows.input_buf, output_buf = windows.output_buf, footer_buf = windows.footer_buf, + tab_strip_buf = windows.tab_strip_buf, output_was_at_bottom = output_window.is_at_bottom(windows.output_win), input_hidden = input_window.is_hidden(), input_cursor = cursor_positions.input, @@ -91,6 +93,10 @@ function M.close_windows(windows, persist) return M.teardown_visible_windows(windows) end +function M.prepare_session_tab_switch() + renderer.prepare_session_tab_switch() +end + ---Clear Opencode-specific autocmds and shared UI state before closing windows. local function prepare_window_close() if M.is_opencode_focused() then @@ -132,11 +138,12 @@ local function close_or_restore_output_window(windows) end ---@param windows OpencodeWindowState? -function M.hide_visible_windows(windows) +---@param force_preserve? boolean Preserve buffers even when persist_state is disabled +function M.hide_visible_windows(windows, force_preserve) if not windows then return end - if not config.ui.persist_state then + if not config.ui.persist_state and not force_preserve then return M.teardown_visible_windows(windows) end @@ -153,10 +160,11 @@ function M.hide_visible_windows(windows) prepare_window_close() footer.close(true) + session_tab_strip.close(true, windows) pcall(vim.api.nvim_win_close, windows.input_win, true) close_or_restore_output_window(windows) - for _, buf in ipairs({ windows.input_buf, windows.output_buf, windows.footer_buf }) do + for _, buf in ipairs({ windows.input_buf, windows.output_buf, windows.footer_buf, windows.tab_strip_buf }) do if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_set_option_value, 'bufhidden', 'hide', { buf = buf }) end @@ -180,8 +188,14 @@ function M.teardown_visible_windows(windows) end prepare_window_close() - renderer.teardown() + local session_tabs = require('opencode.state.session_tabs') + if #session_tabs.list() > 1 then + renderer.reset() + else + renderer.teardown() + end footer.close(false) + session_tab_strip.close(false, windows) pcall(vim.api.nvim_win_close, windows.input_win, true) close_or_restore_output_window(windows) @@ -196,11 +210,16 @@ end ---Drop preserved hidden buffers and clear hidden window state. function M.drop_hidden_snapshot() - renderer.teardown() + local session_tabs = require('opencode.state.session_tabs') + if #session_tabs.list() > 1 then + renderer.reset() + else + renderer.teardown() + end local hidden = state.ui.inspect_hidden_buffers() if hidden then - for _, buf in ipairs({ hidden.input_buf, hidden.output_buf, hidden.footer_buf }) do + for _, buf in ipairs({ hidden.input_buf, hidden.output_buf, hidden.footer_buf, hidden.tab_strip_buf }) do if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end @@ -209,6 +228,7 @@ function M.drop_hidden_snapshot() input_window._hidden = false state.ui.clear_hidden_window_state() + session_tabs.sync() end ---Restore windows using preserved buffers @@ -224,11 +244,16 @@ function M.restore_hidden_windows() if not footer_buf or not vim.api.nvim_buf_is_valid(footer_buf) then footer_buf = footer.create_buf() end + local tab_strip_buf = hidden.tab_strip_buf + if not tab_strip_buf or not vim.api.nvim_buf_is_valid(tab_strip_buf) then + tab_strip_buf = session_tab_strip.create_buf() + end local windows = { input_buf = hidden.input_buf, output_buf = hidden.output_buf, footer_buf = footer_buf, + tab_strip_buf = tab_strip_buf, position = config.ui.position, } local win_ids = M.create_split_windows(windows) @@ -238,6 +263,7 @@ function M.restore_hidden_windows() windows.input_win = win_ids.input_win windows.output_win = win_ids.output_win windows.footer_win = nil + windows.tab_strip_win = win_ids.tab_strip_win windows.output_was_at_bottom = hidden.output_was_at_bottom == true windows.saved_width_ratio = state.last_window_width_ratio state.ui.set_windows(windows) @@ -249,6 +275,7 @@ function M.restore_hidden_windows() output_window.setup(windows) output_window.setup_keymaps(windows, true) footer.setup(windows) + session_tab_strip.setup(windows) if state.api_client and type(state.api_client.list_providers) == 'function' then topbar.setup() end @@ -258,6 +285,8 @@ function M.restore_hidden_windows() if hidden.input_hidden then input_window._hide() + else + input_window._hidden = false end vim.schedule(function() @@ -301,12 +330,35 @@ function M.return_to_last_code_win() end end ----@return { input_buf: integer, output_buf: integer, footer_buf: integer } -function M.setup_buffers() - local input_buf = input_window.create_buf() - local output_buf = output_window.create_buf() - local footer_buf = footer.create_buf() - return { input_buf = input_buf, output_buf = output_buf, footer_buf = footer_buf } +---@param existing? OpencodeWindowState +---@return { input_buf: integer, output_buf: integer, footer_buf: integer, tab_strip_buf: integer } +function M.setup_buffers(existing) + local input_buf = existing and existing.input_buf + if not input_buf or not vim.api.nvim_buf_is_valid(input_buf) then + input_buf = input_window.create_buf() + end + + local output_buf = existing and existing.output_buf + if not output_buf or not vim.api.nvim_buf_is_valid(output_buf) then + output_buf = output_window.create_buf() + end + + local footer_buf = existing and existing.footer_buf + if not footer_buf or not vim.api.nvim_buf_is_valid(footer_buf) then + footer_buf = footer.create_buf() + end + + local tab_strip_buf = existing and existing.tab_strip_buf + if not tab_strip_buf or not vim.api.nvim_buf_is_valid(tab_strip_buf) then + tab_strip_buf = session_tab_strip.create_buf() + end + + return { + input_buf = input_buf, + output_buf = output_buf, + footer_buf = footer_buf, + tab_strip_buf = tab_strip_buf, + } end ---@param direction 'left' | 'right' | 'top' | 'bottom' @@ -321,17 +373,20 @@ local function open_split(direction, type) end ---@param windows OpencodeWindowState ----@return { input_win: integer, output_win: integer } +---@return { input_win: integer, output_win: integer, tab_strip_win: integer } local function open_float(windows) local output_config, input_config = float_layout.window_configs(windows, true) local output_win = float_layout.open_win(windows.output_buf, true, output_config) local input_win = float_layout.open_win(windows.input_buf, true, input_config) + windows.output_win = output_win + windows.input_win = input_win + local tab_strip_win = session_tab_strip.create_window(windows) - return { input_win = input_win, output_win = output_win } + return { input_win = input_win, output_win = output_win, tab_strip_win = tab_strip_win } end ---@param windows OpencodeWindowState ----@return { input_win: integer, output_win: integer } +---@return { input_win: integer, output_win: integer, tab_strip_win: integer } function M.create_split_windows(windows) if input_window.mounted() or output_window.mounted() then M.close_windows(state.windows, false) @@ -357,7 +412,10 @@ function M.create_split_windows(windows) vim.api.nvim_win_set_buf(input_win, windows.input_buf) vim.api.nvim_win_set_buf(output_win, windows.output_buf) - return { input_win = input_win, output_win = output_win } + windows.output_win = output_win + windows.input_win = input_win + local tab_strip_win = session_tab_strip.create_window(windows) + return { input_win = input_win, output_win = output_win, tab_strip_win = tab_strip_win } end ---@return OpencodeWindowState @@ -380,12 +438,18 @@ function M.create_windows() end -- Create new windows from scratch - local windows = M.setup_buffers() - windows.position = config.ui.position + local previous_windows = state.windows + if previous_windows and (input_window.mounted(previous_windows) or output_window.mounted(previous_windows)) then + previous_windows = nil + end + local windows = M.setup_buffers(previous_windows) + windows.position = previous_windows and previous_windows.position or config.ui.position + windows.output_folds = previous_windows and previous_windows.output_folds or nil local win_ids = M.create_split_windows(windows) windows.input_win = win_ids.input_win windows.output_win = win_ids.output_win + windows.tab_strip_win = win_ids.tab_strip_win local filetype = config.ui.output.filetype or 'opencode_output' vim.api.nvim_win_call(windows.output_win, function() @@ -398,6 +462,7 @@ function M.create_windows() output_window.setup(windows) output_window.setup_keymaps(windows) footer.setup(windows) + session_tab_strip.setup(windows) topbar.setup() renderer.setup_subscriptions() @@ -475,7 +540,7 @@ function M.is_opencode_window(win) if not windows then return false end - return win == windows.input_win or win == windows.output_win + return win == windows.input_win or win == windows.output_win or win == windows.tab_strip_win end ---@return boolean @@ -580,6 +645,7 @@ function M.toggle_zoom() if windows.output_win ~= nil then resize_window(windows.output_win) end + session_tab_strip.update_window(windows) end return M diff --git a/tests/data/hello-new.json b/tests/data/hello-new.json new file mode 100644 index 00000000..0571581b --- /dev/null +++ b/tests/data/hello-new.json @@ -0,0 +1,267 @@ +[ + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "open_input_new_session", + "args": [], + "source": { + "raw_args": "open_input_new_session", + "argv": ["open_input_new_session"] + } + } + }, + "intent": { + "name": "open_input_new_session", + "args": [], + "source": { + "raw_args": "open_input_new_session", + "argv": ["open_input_new_session"] + } + }, + "args": [] + }, + "type": "custom.command.before" + }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "open_input_new_session", + "args": [], + "source": { + "raw_args": "open_input_new_session", + "argv": ["open_input_new_session"] + } + } + }, + "intent": { + "name": "open_input_new_session", + "args": [], + "source": { + "raw_args": "open_input_new_session", + "argv": ["open_input_new_session"] + } + }, + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "args": [] + }, + "type": "custom.command.after" + }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "open_input_new_session", + "args": [], + "source": { + "raw_args": "open_input_new_session", + "argv": ["open_input_new_session"] + } + } + }, + "intent": { + "name": "open_input_new_session", + "args": [], + "source": { + "raw_args": "open_input_new_session", + "argv": ["open_input_new_session"] + } + }, + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "args": [] + }, + "type": "custom.command.finally" + }, + { "properties": [], "type": "custom.server_starting" }, + { + "properties": { "url": "http://127.0.0.1:4444" }, + "type": "custom.server_ready" + }, + { + "properties": { "url": "http://127.0.0.1:4444" }, + "type": "custom.server_starting" + }, + { + "properties": { "url": "http://127.0.0.1:4444" }, + "type": "custom.server_ready" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { "properties": {}, "type": "server.connected" }, + { "properties": [], "type": "custom.emit_events.finished" }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "prev_prompt_history", + "args": [], + "source": { + "raw_args": "prev_prompt_history", + "argv": ["prev_prompt_history"] + } + } + }, + "intent": { + "name": "prev_prompt_history", + "args": [], + "source": { + "raw_args": "prev_prompt_history", + "argv": ["prev_prompt_history"] + } + }, + "args": [] + }, + "type": "custom.command.before" + }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "prev_prompt_history", + "args": [], + "source": { + "raw_args": "prev_prompt_history", + "argv": ["prev_prompt_history"] + } + } + }, + "intent": { + "name": "prev_prompt_history", + "args": [], + "source": { + "raw_args": "prev_prompt_history", + "argv": ["prev_prompt_history"] + } + }, + "args": [] + }, + "type": "custom.command.after" + }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "prev_prompt_history", + "args": [], + "source": { + "raw_args": "prev_prompt_history", + "argv": ["prev_prompt_history"] + } + } + }, + "intent": { + "name": "prev_prompt_history", + "args": [], + "source": { + "raw_args": "prev_prompt_history", + "argv": ["prev_prompt_history"] + } + }, + "args": [] + }, + "type": "custom.command.finally" + }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "submit_input_prompt", + "args": ["n"], + "source": { + "raw_args": "submit_input_prompt n", + "argv": ["submit_input_prompt", "n"] + } + } + }, + "intent": { + "name": "submit_input_prompt", + "args": ["n"], + "source": { + "raw_args": "submit_input_prompt n", + "argv": ["submit_input_prompt", "n"] + } + }, + "args": ["n"] + }, + "type": "custom.command.before" + }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "submit_input_prompt", + "args": ["n"], + "source": { + "raw_args": "submit_input_prompt n", + "argv": ["submit_input_prompt", "n"] + } + } + }, + "intent": { + "name": "submit_input_prompt", + "args": ["n"], + "source": { + "raw_args": "submit_input_prompt n", + "argv": ["submit_input_prompt", "n"] + } + }, + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": true + }, + "args": ["n"] + }, + "type": "custom.command.after" + }, + { + "properties": { + "parsed": { + "ok": true, + "intent": { + "name": "submit_input_prompt", + "args": ["n"], + "source": { + "raw_args": "submit_input_prompt n", + "argv": ["submit_input_prompt", "n"] + } + } + }, + "intent": { + "name": "submit_input_prompt", + "args": ["n"], + "source": { + "raw_args": "submit_input_prompt n", + "argv": ["submit_input_prompt", "n"] + } + }, + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": true + }, + "args": ["n"] + }, + "type": "custom.command.finally" + } +] diff --git a/tests/data/hello-old.json b/tests/data/hello-old.json new file mode 100644 index 00000000..7dbce43b --- /dev/null +++ b/tests/data/hello-old.json @@ -0,0 +1,1956 @@ +[ + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "toggle", + "source": { "argv": ["toggle"], "raw_args": "toggle" } + } + }, + "intent": { + "args": [], + "name": "toggle", + "source": { "argv": ["toggle"], "raw_args": "toggle" } + } + }, + "type": "custom.command.before" + }, + { + "properties": { + "args": [], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "toggle", + "source": { "argv": ["toggle"], "raw_args": "toggle" } + } + }, + "intent": { + "args": [], + "name": "toggle", + "source": { "argv": ["toggle"], "raw_args": "toggle" } + } + }, + "type": "custom.command.after" + }, + { + "properties": { + "args": [], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "toggle", + "source": { "argv": ["toggle"], "raw_args": "toggle" } + } + }, + "intent": { + "args": [], + "name": "toggle", + "source": { "argv": ["toggle"], "raw_args": "toggle" } + } + }, + "type": "custom.command.finally" + }, + { + "properties": { "url": "http://127.0.0.1:4444" }, + "type": "custom.server_starting" + }, + { + "properties": { "url": "http://127.0.0.1:4444" }, + "type": "custom.server_ready" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { "properties": {}, "type": "server.connected" }, + { "properties": [], "type": "custom.emit_events.finished" }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "type": "custom.command.before" + }, + { + "properties": { + "args": [], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "type": "custom.command.after" + }, + { + "properties": { + "args": [], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "type": "custom.command.finally" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "slug": "quick-sailor", + "time": { "created": 1778413929849, "updated": 1778413929849 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:52:09.849Z" + } + }, + "type": "session.created" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "slug": "quick-sailor", + "time": { "created": 1778413929849, "updated": 1778413929849 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:52:09.849Z" + } + }, + "type": "session.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "configure_provider", + "source": { + "argv": ["configure_provider"], + "raw_args": "configure_provider" + } + } + }, + "intent": { + "args": [], + "name": "configure_provider", + "source": { + "argv": ["configure_provider"], + "raw_args": "configure_provider" + } + } + }, + "type": "custom.command.before" + }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "configure_provider", + "source": { + "argv": ["configure_provider"], + "raw_args": "configure_provider" + } + } + }, + "intent": { + "args": [], + "name": "configure_provider", + "source": { + "argv": ["configure_provider"], + "raw_args": "configure_provider" + } + } + }, + "type": "custom.command.after" + }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "configure_provider", + "source": { + "argv": ["configure_provider"], + "raw_args": "configure_provider" + } + } + }, + "intent": { + "args": [], + "name": "configure_provider", + "source": { + "argv": ["configure_provider"], + "raw_args": "configure_provider" + } + } + }, + "type": "custom.command.finally" + }, + { + "properties": { + "args": ["n"], + "parsed": { + "ok": true, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "type": "custom.command.before" + }, + { + "properties": { + "args": ["n"], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": true + }, + "parsed": { + "ok": true, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "type": "custom.command.after" + }, + { + "properties": { + "args": ["n"], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": true + }, + "parsed": { + "ok": true, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "type": "custom.command.finally" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "user", + "agent": "build", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "id": "msg_e11bb5562001WiWVv8mGf4g0l8", + "model": { + "variant": "high", + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + }, + "time": { "created": 1778413950306 } + } + }, + "type": "message.updated" + }, + { + "properties": { + "part": { + "synthetic": true, + "id": "prt_e11bb5584001rsoGcew6bbYllR", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "text": "Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua\"}", + "type": "text" + }, + "time": 1778413950346, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "synthetic": true, + "id": "prt_e11bb5584002e1f0O0MLmiZ4pV", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "text": "/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua\nfile\n\n1: local state = require('opencode.state')\n2: local config = require('opencode.config')\n3: local ctx = require('opencode.ui.renderer.ctx')\n4: local permission_window = require('opencode.ui.permission_window')\n5: local flush = require('opencode.ui.renderer.flush')\n6: \n7: ---@param message OpencodeMessage|nil\n8: ---@return string|nil\n9: local function get_last_part_for_message(message)\n10: if not message or not message.parts or #message.parts == 0 then\n11: return nil\n12: end\n13: for i = #message.parts, 1, -1 do\n14: local part = message.parts[i]\n15: if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then\n16: return part.id\n17: end\n18: end\n19: return nil\n20: end\n21: \n22: ---@param message OpencodeMessage|nil\n23: ---@return string|nil\n24: local function find_text_part_for_message(message)\n25: if not message or not message.parts then\n26: return nil\n27: end\n28: for _, part in ipairs(message.parts) do\n29: if part.type == 'text' and not part.synthetic then\n30: return part.id\n31: end\n32: end\n33: return nil\n34: end\n35: \n36: ---@param message_id string|nil\n37: ---@return OpencodeMessage|nil\n38: local function find_message_in_state(message_id)\n39: if not message_id then\n40: return nil\n41: end\n42: \n43: for _, message in ipairs(state.messages or {}) do\n44: if message.info and message.info.id == message_id then\n45: return message\n46: end\n47: end\n48: \n49: return nil\n50: end\n51: \n52: -- Lazy require to avoid circular dependency: renderer.lua <-> events.lua\n53: ---@param force? boolean\n54: local function scroll(force)\n55: require('opencode.ui.renderer').scroll_to_bottom(force)\n56: end\n57: \n58: local M = {}\n59: \n60: ---@param message_id string\n61: ---@param revert_index? integer\n62: local function replay_orphan_parts(message_id, revert_index)\n63: local orphan_parts = ctx.render_state:consume_orphan_parts(message_id)\n64: for _, orphan_part in ipairs(orphan_parts) do\n65: M.on_part_updated({ part = orphan_part }, revert_index)\n66: end\n67: end\n68: \n69: ---Update token/cost stats in state from a message\n70: ---@param message OpencodeMessage\n71: local function update_stats(message)\n72: if not state.current_model and message.info.providerID and message.info.providerID ~= '' then\n73: state.model.set_model(message.info.providerID .. '/' .. message.info.modelID)\n74: end\n75: \n76: local tokens = message.info.tokens\n77: if tokens and tokens.input > 0 and message.info.cost and type(message.info.cost) == 'number' then\n78: state.renderer.set_stats(tokens.input + tokens.output + tokens.cache.read + tokens.cache.write, message.info.cost)\n79: elseif tokens and tokens.input > 0 then\n80: state.renderer.set_tokens_count(tokens.input + tokens.output + tokens.cache.read + tokens.cache.write)\n81: elseif message.info.cost and type(message.info.cost) == 'number' then\n82: state.renderer.set_cost(message.info.cost)\n83: end\n84: end\n85: \n86: ---Render pending permissions as a synthetic part at the end of the buffer\n87: function M.render_permissions_display()\n88: local permissions = permission_window.get_all_permissions()\n89: if not permissions or #permissions == 0 then\n90: flush.queue_part_removal('permission-display-part')\n91: flush.queue_message_removal('permission-display-message')\n92: return\n93: end\n94: \n95: local should_scroll = ctx.render_state:get_part('permission-display-part') == nil\n96: \n97: local fake_message = {\n98: info = {\n99: id = 'permission-display-message',\n100: sessionID = state.active_session and state.active_session.id or '',\n101: role = 'system',\n102: },\n103: parts = {},\n104: }\n105: M.on_message_updated(fake_message --[[@as OpencodeMessage]])\n106: \n107: local fake_part = {\n108: id = 'permission-display-part',\n109: messageID = 'permission-display-message',\n110: sessionID = state.active_session and state.active_session.id or '',\n111: type = 'permissions-display',\n112: }\n113: M.on_part_updated({ part = fake_part })\n114: \n115: if should_scroll then\n116: scroll(true)\n117: end\n118: end\n119: \n120: ---Render the current question as a synthetic part at the end of the buffer\n121: function M.render_question_display()\n122: local use_vim_ui = config.ui.questions and config.ui.questions.use_vim_ui_select\n123: if use_vim_ui then\n124: return\n125: end\n126: \n127: local question_window = require('opencode.ui.question_window')\n128: local current_question = question_window._current_question\n129: \n130: if not question_window.has_question() or not current_question or not current_question.id then\n131: flush.queue_part_removal('question-display-part')\n132: flush.queue_message_removal('question-display-message')\n133: return\n134: end\n135: \n136: local should_scroll = ctx.render_state:get_part('question-display-part') == nil\n137: \n138: local fake_message = {\n139: info = {\n140: id = 'question-display-message',\n141: sessionID = state.active_session and state.active_session.id or '',\n142: role = 'system',\n143: },\n144: parts = {},\n145: }\n146: M.on_message_updated(fake_message --[[@as OpencodeMessage]])\n147: \n148: local fake_part = {\n149: id = 'question-display-part',\n150: messageID = 'question-display-message',\n151: sessionID = state.active_session and state.active_session.id or '',\n152: type = 'questions-display',\n153: }\n154: M.on_part_updated({ part = fake_part })\n155: if should_scroll then\n156: scroll(true)\n157: end\n158: end\n159: \n160: ---Remove the question display from the buffer\n161: function M.clear_question_display()\n162: local use_vim_ui = config.ui.questions and config.ui.questions.use_vim_ui_select\n163: local question_window = require('opencode.ui.question_window')\n164: question_window.clear_question()\n165: \n166: if not use_vim_ui then\n167: flush.queue_part_removal('question-display-part')\n168: flush.queue_message_removal('question-display-message')\n169: end\n170: end\n171: \n172: ---Handle message.updated — create the message header or update existing info\n173: ---@param message {info: MessageInfo}\n174: ---@param revert_index? integer\n175: function M.on_message_updated(message, revert_index)\n176: if not state.active_session or not state.messages then\n177: return\n178: end\n179: \n180: local msg = message --[[@as OpencodeMessage]]\n181: if not msg or not msg.info or not msg.info.id or not msg.info.sessionID then\n182: return\n183: end\n184: \n185: if state.active_session.id ~= msg.info.sessionID then\n186: return\n187: end\n188: \n189: local rendered_message = ctx.render_state:get_message(msg.info.id)\n190: local found_msg = rendered_message and rendered_message.message or find_message_in_state(msg.info.id)\n191: \n192: if revert_index then\n193: if not found_msg then\n194: table.insert(state.messages, msg)\n195: found_msg = msg\n196: end\n197: ctx.render_state:set_message(found_msg, 0, 0)\n198: replay_orphan_parts(msg.info.id, revert_index)\n199: return\n200: end\n201: \n202: if found_msg then\n203: if not rendered_message then\n204: ctx.render_state:set_message(found_msg)\n205: flush.mark_message_dirty(msg.info.id)\n206: end\n207: local error_changed = not vim.deep_equal(found_msg.info.error, msg.info.error)\n208: found_msg.info = msg.info\n209: \n210: -- Errors arrive on the message but we display them after the last part.\n211: -- Re-render the last part (or the header if there are no parts) so the\n212: -- error appears in the right place.\n213: if error_changed then\n214: local last_part_id = get_last_part_for_message(found_msg)\n215: if last_part_id then\n216: flush.mark_part_dirty(last_part_id, msg.info.id)\n217: else\n218: flush.mark_message_dirty(msg.info.id)\n219: end\n220: end\n221: else\n222: table.insert(state.messages, msg)\n223: ctx.render_state:set_message(msg)\n224: replay_orphan_parts(msg.info.id)\n225: flush.mark_message_dirty(msg.info.id)\n226: state.renderer.set_current_message(msg)\n227: end\n228: \n229: if msg.info.role == 'user' then\n230: state.renderer.set_last_user_message(msg)\n231: scroll(true)\n232: end\n233: \n234: update_stats(msg)\n235: \n236: if not revert_index and not ctx.bulk_mode and msg.info.id ~= '__opencode_hidden_messages_notice__' then\n237: require('opencode.ui.renderer').reconcile_rendered_message_limit()\n238: end\n239: end\n240: \n241: ---Handle message.removed — remove the message and all its parts from the buffer\n242: ---@param properties {sessionID: string, messageID: string}\n243: function M.on_message_removed(properties)\n244: if not properties or not state.messages then\n245: return\n246: end\n247: \n248: local message_id = properties.messageID\n249: if not message_id then\n250: return\n251: end\n252: \n253: local rendered_message = ctx.render_state:get_message(message_id)\n254: local message = rendered_message and rendered_message.message or find_message_in_state(message_id)\n255: ctx.render_state:clear_orphan_parts(message_id)\n256: if not message then\n257: return\n258: end\n259: \n260: for _, part in ipairs(message.parts or {}) do\n261: if part.id then\n262: flush.queue_part_removal(part.id)\n263: end\n264: end\n265: \n266: flush.queue_message_removal(message_id)\n267: \n268: for i, msg in ipairs(state.messages or {}) do\n269: if msg.info.id == message_id then\n270: table.remove(state.messages, i)\n271: break\n272: end\n273: end\n274: \n275: if not ctx.bulk_mode and message_id ~= '__opencode_hidden_messages_notice__' then\n276: require('opencode.ui.renderer').reconcile_rendered_message_limit()\n277: end\n278: end\n279: \n280: ---Handle message.part.updated — insert or replace a part in the buffer\n281: ---@param properties {part: OpencodeMessagePart}\n282: ---@param revert_index? integer\n283: function M.on_part_updated(properties, revert_index)\n284: if not properties or not properties.part or not state.active_session then\n285: return\n286: end\n287: \n288: local part = properties.part\n289: if not part.id or not part.messageID or not part.sessionID then\n290: return\n291: end\n292: \n293: -- Child-session parts: update the task-tool display instead\n294: if state.active_session.id ~= part.sessionID then\n295: if part.tool or part.type == 'tool' then\n296: ctx.render_state:upsert_child_session_part(part.sessionID, part)\n297: local task_part_id = ctx.render_state:get_task_part_by_child_session(part.sessionID)\n298: if task_part_id then\n299: flush.mark_part_dirty(task_part_id)\n300: end\n301: end\n302: return\n303: end\n304: \n305: local rendered_message = ctx.render_state:get_message(part.messageID)\n306: if not rendered_message then\n307: local existing_message = find_message_in_state(part.messageID)\n308: if existing_message then\n309: ctx.render_state:set_message(existing_message)\n310: rendered_message = ctx.render_state:get_message(part.messageID)\n311: end\n312: end\n313: if not rendered_message or not rendered_message.message then\n314: ctx.render_state:upsert_orphan_part(part.messageID, part)\n315: return\n316: end\n317: \n318: local message = rendered_message.message\n319: message.parts = message.parts or {}\n320: \n321: local part_data = ctx.render_state:get_part(part.id)\n322: local is_new_part = not part_data\n323: \n324: local prev_last_part_id = get_last_part_for_message(message)\n325: local existing_part_index = nil\n326: for i = #message.parts, 1, -1 do\n327: if message.parts[i].id == part.id then\n328: existing_part_index = i\n329: break\n330: end\n331: end\n332: \n333: -- Update the part reference in the message\n334: if is_new_part then\n335: if existing_part_index then\n336: message.parts[existing_part_index] = part\n337: else\n338: table.insert(message.parts, part)\n339: end\n340: else\n341: if existing_part_index then\n342: message.parts[existing_part_index] = part\n343: else\n344: for i = #message.parts, 1, -1 do\n345: if message.parts[i].id == part.id then\n346: message.parts[i] = part\n347: break\n348: end\n349: end\n350: end\n351: end\n352: \n353: -- step-start / step-finish are bookkeeping only — nothing to render\n354: if part.type == 'step-start' or part.type == 'step-finish' then\n355: return\n356: end\n357: \n358: if is_new_part then\n359: ctx.render_state:set_part(part)\n360: else\n361: local rendered_part = ctx.render_state:update_part_data(part)\n362: -- Part known but never rendered yet — treat as new\n363: if not rendered_part or (not rendered_part.line_start and not rendered_part.line_end) then\n364: is_new_part = true\n365: end\n366: end\n367: \n368: -- Update the permission window if this part has a pending permission\n369: if part.callID and state.pending_permissions then\n370: for _, permission in ipairs(state.pending_permissions) do\n371: local tool = permission.tool\n372: local perm_callID = tool and tool.callID or permission.callID\n373: local perm_messageID = tool and tool.messageID or permission.messageID\n374: if perm_callID == part.callID and perm_messageID == part.messageID then\n375: permission_window.update_permission_from_part(permission.id, part)\n376: break\n377: end\n378: end\n379: end\n380: \n381: if revert_index and is_new_part then\n382: return\n383: end\n384: \n385: if is_new_part then\n386: flush.mark_part_dirty(part.id, part.messageID)\n387: \n388: -- If there's already an error on this message, adjust adjacent parts so\n389: -- the error only appears after the last part.\n390: if message.info.error then\n391: if not prev_last_part_id then\n392: flush.mark_message_dirty(part.messageID)\n393: elseif prev_last_part_id ~= part.id then\n394: flush.mark_part_dirty(prev_last_part_id, part.messageID)\n395: end\n396: end\n397: else\n398: flush.mark_part_dirty(part.id, part.messageID)\n399: end\n400: \n401: -- File / agent mentions: re-render the text part to highlight them\n402: if (part.type == 'file' or part.type == 'agent') and part.source then\n403: local text_part_id = find_text_part_for_message(message)\n404: if text_part_id then\n405: flush.mark_part_dirty(text_part_id, part.messageID)\n406: end\n407: end\n408: end\n409: \n410: ---Handle message.part.removed\n411: ---@param properties {sessionID: string, messageID: string, partID: string}\n412: function M.on_part_removed(properties)\n413: if not properties then\n414: return\n415: end\n416: \n417: local part_id = properties.partID\n418: if not part_id then\n419: return\n420: end\n421: \n422: if properties.messageID and ctx.render_state:remove_orphan_part(properties.messageID, part_id) then\n423: return\n424: end\n425: \n426: -- Remove the part from the in-memory message too\n427: local cached = ctx.render_state:get_part(part_id)\n428: local message_id = cached and cached.message_id\n429: if message_id then\n430: local rendered_message = ctx.render_state:get_message(message_id)\n431: if rendered_message and rendered_message.message and rendered_message.message.parts then\n432: for i, part in ipairs(rendered_message.message.parts) do\n433: if part.id == part_id then\n434: table.remove(rendered_message.message.parts, i)\n435: break\n436: end\n437: end\n438: end\n439: end\n440: \n441: flush.queue_part_removal(part_id)\n442: \n443: -- Mark message dirty so header (timestamp, etc.) gets re-rendered\n444: if message_id then\n445: flush.mark_message_dirty(message_id)\n446: end\n447: end\n448: \n449: ---Handle session.updated — re-render the full session if the revert state changed\n450: ---@param properties {info: Session}\n451: function M.on_session_updated(properties)\n452: if not properties or not properties.info or not state.active_session then\n453: return\n454: end\n455: \n456: local updated_session = properties.info\n457: if not updated_session.id or updated_session.id ~= state.active_session.id then\n458: return\n459: end\n460: \n461: local current_session = state.active_session\n462: local revert_changed = not vim.deep_equal(current_session.revert, updated_session.revert)\n463: \n464: if not vim.deep_equal(current_session, updated_session) then\n465: -- Set without emitting a change event to avoid a double re-render\n466: state.store.set_raw('active_session', updated_session)\n467: end\n468: \n469: if revert_changed then\n470: local real_messages = vim.tbl_filter(function(msg)\n471: return not (msg.info and msg.info.id and msg.info.id:match('^__opencode_'))\n472: end, state.messages or {})\n473: require('opencode.ui.renderer')._render_full_session_data(real_messages)\n474: end\n475: end\n476: \n477: ---Handle session.compacted\n478: function M.on_session_compacted()\n479: vim.notify('Session has been compacted')\n480: end\n481: \n482: ---Handle session.error\n483: ---@param properties {sessionID: string, error: table}\n484: function M.on_session_error(properties)\n485: if not properties or not properties.error then\n486: return\n487: end\n488: if config.debug.enabled then\n489: vim.notify('Session error: ' .. vim.inspect(properties.error))\n490: end\n491: end\n492: \n493: ---Handle permission.updated / permission.asked\n494: ---@param permission OpencodePermission\n495: function M.on_permission_updated(permission)\n496: if not permission or not permission.id then\n497: return\n498: end\n499: \n500: local tool = permission.tool\n501: local callID = tool and tool.callID or permission.callID\n502: local messageID = tool and tool.messageID or permission.messageID\n503: \n504: if not state.pending_permissions then\n505: state.renderer.set_pending_permissions({})\n506: end\n507: \n508: local existing_index = nil\n509: for i, existing in ipairs(state.pending_permissions) do\n510: if existing.id == permission.id then\n511: existing_index = i\n512: break\n513: end\n514: end\n515: \n516: state.renderer.update_pending_permissions(function(permissions)\n517: if existing_index then\n518: permissions[existing_index] = permission\n519: else\n520: table.insert(permissions, permission)\n521: end\n522: end)\n523: \n524: permission_window.add_permission(permission)\n525: M.render_permissions_display()\n526: end\n527: \n528: ---Handle permission.replied — remove the resolved permission and update display\n529: ---@param properties {sessionID: string, permissionID?: string, requestID?: string, response: string}\n530: function M.on_permission_replied(properties)\n531: if not properties then\n532: return\n533: end\n534: \n535: local permission_id = properties.permissionID or properties.requestID\n536: if not permission_id then\n537: return\n538: end\n539: \n540: permission_window.remove_permission(permission_id)\n541: state.renderer.set_pending_permissions(vim.deepcopy(permission_window.get_all_permissions()))\n542: \n543: if #state.pending_permissions == 0 then\n544: flush.queue_part_removal('permission-display-part')\n545: flush.queue_message_removal('permission-display-message')\n546: else\n547: M.render_permissions_display()\n548: end\n549: end\n550: \n551: ---Handle question.asked — show the question picker UI\n552: ---@param properties OpencodeQuestionRequest\n553: function M.on_question_asked(properties)\n554: if not properties or not properties.id or not properties.questions then\n555: return\n556: end\n557: require('opencode.ui.question_window').show_question(properties)\n558: end\n559: \n560: ---Handle file.edited — reload buffers and fire the hook\n561: ---@param properties {file: string}\n562: function M.on_file_edited(properties)\n563: vim.cmd('checktime')\n564: if config.hooks and config.hooks.on_file_edited then\n565: pcall(config.hooks.on_file_edited, properties.file)\n566: end\n567: end\n568: \n569: ---Handle custom.restore_point.created\n570: ---@param properties RestorePointCreatedEvent\n571: function M.on_restore_points(properties)\n572: state.store.append('restore_points', properties.restore_point)\n573: if not properties or not properties.restore_point or not properties.restore_point.from_snapshot_id then\n574: return\n575: end\n576: local part = ctx.render_state:get_part_by_snapshot_id(properties.restore_point.from_snapshot_id)\n577: if part then\n578: M.on_part_updated({ part = part })\n579: end\n580: end\n581: \n582: return M\n\n(End of file - total 582 lines)\n", + "type": "text" + }, + "time": 1778413950348, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "id": "prt_e11bb5584003RjZ0boiXLOQKDW", + "filename": "lua/opencode/ui/renderer/events.lua", + "mime": "text/plain", + "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "url": "file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua", + "type": "file" + }, + "time": 1778413950352, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_e11bb5585001WEZnZ73nCfqkNu", + "text": "hi", + "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "type": "text" + }, + "time": 1778413950354, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "slug": "quick-sailor", + "time": { "created": 1778413929849, "updated": 1778413950357 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:52:09.849Z" + } + }, + "type": "session.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bb55a0001UHT3LJivzxddlU", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "variant": "high", + "agent": "build", + "time": { "created": 1778413950368 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "reasoning": 0, + "input": 0, + "output": 0, + "cache": { "write": 0, "read": 0 } + }, + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "slug": "quick-sailor", + "time": { "created": 1778413929849, "updated": 1778413952153 }, + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "summary": { "files": 0, "deletions": 0, "additions": 0 }, + "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:52:09.849Z" + } + }, + "type": "session.updated" + }, + { + "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", "diff": [] }, + "type": "session.diff" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "user", + "time": { "created": 1778413950306 }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "summary": { "diffs": [] }, + "agent": "build", + "model": { + "variant": "high", + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + }, + "id": "msg_e11bb5562001WiWVv8mGf4g0l8" + } + }, + "type": "message.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "slug": "quick-sailor", + "time": { "created": 1778413929849, "updated": 1778413952591 }, + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "summary": { "files": 0, "deletions": 0, "additions": 0 }, + "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "version": "1.14.19", + "title": "Review opencode/ui/renderer/events.lua" + } + }, + "type": "session.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", + "id": "prt_e11bb71b8001bAuUWClHlRqamJ", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", + "type": "step-start" + }, + "time": 1778413957560, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "state": { "input": {}, "raw": "", "status": "pending" }, + "id": "prt_e11bb71bb001wYYOM4TmuXVMTn", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", + "callID": "call_kk38Xdi7A08SabYxR29iupPP", + "tool": "question", + "type": "tool" + }, + "time": 1778413957563, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "questions": [ + { + "question": "What would you like me to do with this file?", + "header": "Next Step", + "options": [ + { + "label": "Review for bugs and issues (Recommended)", + "description": "Scan the file, list problems, and propose fixes or tests." + }, + { + "label": "Explain the code", + "description": "Walk through what the file does and key functions." + }, + { + "label": "Refactor for clarity", + "description": "Make minimal code improvements to simplify or organize." + }, + { + "label": "Add or update tests", + "description": "Create unit tests for the module where applicable." + } + ] + } + ], + "id": "que_e11bb753d001kDD0pjAVsoclbZ", + "tool": { + "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", + "callID": "call_kk38Xdi7A08SabYxR29iupPP" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "question.asked" + }, + { + "properties": { + "part": { + "id": "prt_e11bb71bb001wYYOM4TmuXVMTn", + "state": { + "raw": "", + "input": { + "questions": [ + { + "header": "Next Step", + "question": "What would you like me to do with this file?", + "options": [ + { + "label": "Review for bugs and issues (Recommended)", + "description": "Scan the file, list problems, and propose fixes or tests." + }, + { + "label": "Explain the code", + "description": "Walk through what the file does and key functions." + }, + { + "label": "Refactor for clarity", + "description": "Make minimal code improvements to simplify or organize." + }, + { + "label": "Add or update tests", + "description": "Create unit tests for the module where applicable." + } + ] + } + ] + }, + "time": { "start": 1778413958465 }, + "status": "running" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", + "callID": "call_kk38Xdi7A08SabYxR29iupPP", + "tool": "question", + "type": "tool" + }, + "time": 1778413958465, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "requestID": "que_e11bb753d001kDD0pjAVsoclbZ", + "answers": [["Review for bugs and issues (Recommended)"]], + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "question.replied" + }, + { + "properties": { + "part": { + "id": "prt_e11bb71bb001wYYOM4TmuXVMTn", + "state": { + "metadata": { + "answers": [["Review for bugs and issues (Recommended)"]], + "truncated": false + }, + "time": { "start": 1778413958465, "end": 1778413960806 }, + "status": "completed", + "input": { + "questions": [ + { + "header": "Next Step", + "question": "What would you like me to do with this file?", + "options": [ + { + "label": "Review for bugs and issues (Recommended)", + "description": "Scan the file, list problems, and propose fixes or tests." + }, + { + "label": "Explain the code", + "description": "Walk through what the file does and key functions." + }, + { + "label": "Refactor for clarity", + "description": "Make minimal code improvements to simplify or organize." + }, + { + "label": "Add or update tests", + "description": "Create unit tests for the module where applicable." + } + ] + } + ] + }, + "output": "User has answered your questions: \"What would you like me to do with this file?\"=\"Review for bugs and issues (Recommended)\". You can now continue with the user's answers in mind.", + "title": "Asked 1 question" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", + "callID": "call_kk38Xdi7A08SabYxR29iupPP", + "tool": "question", + "type": "tool" + }, + "time": 1778413960807, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", + "reason": "tool-calls", + "id": "prt_e11bb7e6b001W32Uq8GGpWulom", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", + "tokens": { + "input": 14040, + "cache": { "write": 0, "read": 1664 }, + "reasoning": 0, + "output": 514, + "total": 16218 + }, + "cost": 0, + "type": "step-finish" + }, + "time": 1778413960853, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bb55a0001UHT3LJivzxddlU", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "providerID": "github-copilot", + "variant": "high", + "agent": "build", + "time": { "created": 1778413950368 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "input": 14040, + "cache": { "write": 0, "read": 1664 }, + "reasoning": 0, + "output": 514, + "total": 16218 + }, + "modelID": "gpt-5-mini", + "finish": "tool-calls" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bb55a0001UHT3LJivzxddlU", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "providerID": "github-copilot", + "variant": "high", + "agent": "build", + "time": { "created": 1778413950368, "completed": 1778413960899 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "input": 14040, + "cache": { "write": 0, "read": 1664 }, + "reasoning": 0, + "output": 514, + "total": 16218 + }, + "modelID": "gpt-5-mini", + "finish": "tool-calls" + } + }, + "type": "message.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bb7ec8001avsqqdND6rr1pI", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "variant": "high", + "agent": "build", + "time": { "created": 1778413960904 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "reasoning": 0, + "input": 0, + "output": 0, + "cache": { "write": 0, "read": 0 } + }, + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "slug": "quick-sailor", + "time": { "created": 1778413929849, "updated": 1778413960985 }, + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "summary": { "files": 0, "deletions": 0, "additions": 0 }, + "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "version": "1.14.19", + "title": "Review opencode/ui/renderer/events.lua" + } + }, + "type": "session.updated" + }, + { + "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", "diff": [] }, + "type": "session.diff" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "user", + "time": { "created": 1778413950306 }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "summary": { "diffs": [] }, + "agent": "build", + "model": { + "variant": "high", + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + }, + "id": "msg_e11bb5562001WiWVv8mGf4g0l8" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", + "id": "prt_e11bbd5d60012HccDJqp0HHd0Q", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "type": "step-start" + }, + "time": 1778413983190, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_e11bbd5d80012fAWaj2OnpPwKl", + "state": { + "raw": "", + "input": { "pattern": "render_state:get_part\\(", "path": "" }, + "time": { "start": 1778413983196 }, + "status": "running" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_yT3yanzgervTiEPC1JDDAq4Q", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983197, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "id": "prt_e11bbd5d80012fAWaj2OnpPwKl", + "state": { + "metadata": { "matches": 39, "truncated": false }, + "time": { "start": 1778413983196, "end": 1778413983262 }, + "status": "completed", + "input": { "pattern": "render_state:get_part\\(", "path": "" }, + "output": "Found 39 matches\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:\n Line 145: local existing_part = ctx.render_state:get_part(HIDDEN_MESSAGES_NOTICE_PART_ID)\n\n Line 155: local part_data = ctx.render_state:get_part(HIDDEN_MESSAGES_NOTICE_PART_ID)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 95: local should_scroll = ctx.render_state:get_part('permission-display-part') == nil\n\n Line 136: local should_scroll = ctx.render_state:get_part('question-display-part') == nil\n\n Line 321: local part_data = ctx.render_state:get_part(part.id)\n\n Line 427: local cached = ctx.render_state:get_part(part_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/flush.lua:\n Line 224: local rendered_part = ctx.render_state:get_part(part_id)\n\n Line 243: local rendered_part = ctx.render_state:get_part(part_id)\n\n Line 325: local rendered_part = ctx.render_state:get_part(part_id)\n\n Line 374: local cached = ctx.render_state:get_part(part_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/buffer.lua:\n Line 386: local previous_rendered = ctx.render_state:get_part(previous.id)\n\n Line 420: local part_data = ctx.render_state:get_part(part_id)\n\n Line 429: local part_data = ctx.render_state:get_part(part_id)\n\n Line 566: local part_data = ctx.render_state:get_part(part_id)\n\n Line 575: local cached = ctx.render_state:get_part(part_id)\n\n Line 614: local part_data = ctx.render_state:get_part(part_id)\n\n Line 640: local cached_part = ctx.render_state:get_part(part_id_iter)\n\n Line 661: local cached = ctx.render_state:get_part(part_id)\n\n Line 695: local cached = ctx.render_state:get_part(part_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/render_state_spec.lua:\n Line 79: local result = render_state:get_part('part1')\n\n Line 102: local result = render_state:get_part('part1')\n\n Line 207: local result = render_state:get_part('part1')\n\n Line 221: local result = render_state:get_part('part1')\n\n Line 234: local result = render_state:get_part('part1')\n\n Line 287: local result = render_state:get_part('part1')\n\n Line 300: local result2 = render_state:get_part('part2')\n\n Line 313: local result2 = render_state:get_part('part2')\n\n Line 357: assert.is_nil(render_state:get_part('part1'))\n\n Line 359: local result2 = render_state:get_part('part2')\n\n Line 462: local result = render_state:get_part('part1')\n\n Line 475: local result1 = render_state:get_part('part1')\n\n Line 479: local result2 = render_state:get_part('part2')\n\n Line 493: local result = render_state:get_part('part1')\n\n Line 529: local result1 = render_state:get_part('part1')\n\n Line 532: local result2 = render_state:get_part('part2')\n\n Line 543: local result = render_state:get_part('part1')\n\n Line 558: local result = render_state:get_part('part1')\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-replied.json:\n Line 36: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-ask.json:\n Line 50: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...", + "title": "render_state:get_part\\(" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_yT3yanzgervTiEPC1JDDAq4Q", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983262, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_e11bbd62c00138ruVIpzIUXaYE", + "state": { + "raw": "", + "input": { "pattern": "get_part_by_snapshot_id", "path": "" }, + "time": { "start": 1778413983282 }, + "status": "running" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_fRqXmBTHj2m5HvzzXSPXOQhr", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983282, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "state": { "input": {}, "raw": "", "status": "pending" }, + "id": "prt_e11bbd63b001aOQFetAbSmXll4", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_E16UuoqooiCsCc7YS5WBHtG6", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983291, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "id": "prt_e11bbd62c00138ruVIpzIUXaYE", + "state": { + "metadata": { "matches": 4, "truncated": false }, + "time": { "start": 1778413983282, "end": 1778413983312 }, + "status": "completed", + "input": { "pattern": "get_part_by_snapshot_id", "path": "" }, + "output": "Found 4 matches\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 576: local part = ctx.render_state:get_part_by_snapshot_id(properties.restore_point.from_snapshot_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/render_state.lua:\n Line 342: function RenderState:get_part_by_snapshot_id(snapshot_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-ask.json:\n Line 50: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-replied.json:\n Line 36: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...", + "title": "get_part_by_snapshot_id" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_fRqXmBTHj2m5HvzzXSPXOQhr", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983312, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_e11bbd63b001aOQFetAbSmXll4", + "state": { + "raw": "", + "input": { "pattern": "message_id", "path": "" }, + "time": { "start": 1778413983325 }, + "status": "running" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_E16UuoqooiCsCc7YS5WBHtG6", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983325, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_e11bbd65f0010OjncQiHOoeErP", + "state": { + "raw": "", + "input": { "pattern": "messageID", "path": "" }, + "time": { "start": 1778413983330 }, + "status": "running" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_ViEopy48Z03zxmZSRiW58Bli", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983330, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "id": "prt_e11bbd63b001aOQFetAbSmXll4", + "state": { + "metadata": { "matches": 260, "truncated": true }, + "time": { "start": 1778413983325, "end": 1778413983371 }, + "status": "completed", + "input": { "pattern": "message_id", "path": "" }, + "output": "Found 260 matches (showing first 100)\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:\n Line 27: local message_id = message and message.info and message.info.id\n\n Line 28: return message_id == '__opencode_revert_message__' or message_id == HIDDEN_MESSAGES_NOTICE_MESSAGE_ID\n\n Line 50: local revert_message_id = revert and revert.messageID\n\n Line 51: if not revert_message_id then\n\n Line 57: if message.info and message.info.id == revert_message_id then\n\n Line 108: ---@param message_id string\n\n Line 110: local function find_message_in_state(message_id)\n\n Line 112: if message.info and message.info.id == message_id then\n\n Line 121: local message_id = message.info and message.info.id\n\n Line 122: if not message_id or ctx.render_state:get_message(message_id) then\n\n Line 127: flush.mark_message_dirty(message_id)\n\n Line 132: flush.mark_part_dirty(part.id, message_id)\n\n Line 171: ---@param message_id string\n\n Line 172: local function hide_rendered_message(message_id)\n\n Line 173: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 174: local message = rendered_message and rendered_message.message or find_message_in_state(message_id)\n\n Line 179: ctx.render_state:clear_orphan_parts(message_id)\n\n Line 185: flush.queue_message_removal(message_id)\n\n Line 204: local message_id = message.info and message.info.id\n\n Line 205: if message_id then\n\n Line 206: visible_ids[message_id] = true\n\n Line 212: local message_id = message.info and message.info.id\n\n Line 213: if message_id and not visible_ids[message_id] and ctx.render_state:get_message(message_id) then\n\n Line 214: hide_rendered_message(message_id)\n\n Line 225: ---@param message_id string|nil\n\n Line 227: local function is_message_visible(message_id)\n\n Line 228: if not message_id then\n\n Line 233: if message.info and message.info.id == message_id then\n\n Line 490: ---@param message_id string\n\n Line 492: function M.get_rendered_message(message_id)\n\n Line 493: return ctx.render_state:get_message(message_id) or nil\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 36: ---@param message_id string|nil\n\n Line 38: local function find_message_in_state(message_id)\n\n Line 39: if not message_id then\n\n Line 44: if message.info and message.info.id == message_id then\n\n Line 60: ---@param message_id string\n\n Line 62: local function replay_orphan_parts(message_id, revert_index)\n\n Line 63: local orphan_parts = ctx.render_state:consume_orphan_parts(message_id)\n\n Line 248: local message_id = properties.messageID\n\n Line 249: if not message_id then\n\n Line 253: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 254: local message = rendered_message and rendered_message.message or find_message_in_state(message_id)\n\n Line 255: ctx.render_state:clear_orphan_parts(message_id)\n\n Line 266: flush.queue_message_removal(message_id)\n\n Line 269: if msg.info.id == message_id then\n\n Line 275: if not ctx.bulk_mode and message_id ~= '__opencode_hidden_messages_notice__' then\n\n Line 428: local message_id = cached and cached.message_id\n\n Line 429: if message_id then\n\n Line 430: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 444: if message_id then\n\n Line 445: flush.mark_message_dirty(message_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/flush.lua:\n Line 18: local pinned_overlay_message_ids = {\n\n Line 24: ---@param message_id string|nil\n\n Line 26: local function warn_part_render_error_once(part_id, message_id, err)\n\n Line 36: tostring(message_id),\n\n Line 176: ---@param message_id string|nil\n\n Line 178: local function track_message_for_part(message_id, part_id)\n\n Line 179: if not message_id or not part_id then\n\n Line 183: local part_ids = ctx.pending.dirty_part_by_message[message_id]\n\n Line 186: ctx.pending.dirty_part_by_message[message_id] = part_ids\n\n Line 191: ---@param message_id string|nil\n\n Line 193: local function untrack_message_for_part(message_id, part_id)\n\n Line 194: local part_ids = message_id and ctx.pending.dirty_part_by_message[message_id]\n\n Line 200: ctx.pending.dirty_part_by_message[message_id] = nil\n\n Line 204: ---@param message_id string|nil\n\n Line 205: function M.mark_message_dirty(message_id)\n\n Line 206: if not message_id then\n\n Line 209: ctx.pending.removed_messages[message_id] = nil\n\n Line 210: enqueue_once(ctx.pending.dirty_message_order, ctx.pending.dirty_messages, message_id)\n\n Line 211: ctx.pending.dirty_messages[message_id] = true\n\n Line 213: ctx.formatted_messages[message_id] = nil\n\n Line 218: ---@param message_id? string\n\n Line 219: function M.mark_part_dirty(part_id, message_id)\n\n Line 225: message_id = message_id or (rendered_part and rendered_part.message_id)\n\n Line 226: if not message_id then\n\n Line 232: ctx.pending.dirty_parts[part_id] = message_id\n\n Line 233: track_message_for_part(message_id, part_id)\n\n Line 244: if rendered_part and rendered_part.message_id then\n\n Line 245: untrack_message_for_part(rendered_part.message_id, part_id)\n\n Line 255: ---@param message_id string|nil\n\n Line 256: function M.queue_message_removal(message_id)\n\n Line 257: if not message_id then\n\n Line 261: ctx.pending.dirty_messages[message_id] = nil\n\n Line 262: ctx.pending.dirty_part_by_message[message_id] = nil\n\n Line 263: enqueue_once(ctx.pending.removed_message_order, ctx.pending.removed_messages, message_id)\n\n Line 264: ctx.pending.removed_messages[message_id] = true\n\n Line 265: ctx.formatted_messages[message_id] = nil\n\n Line 299: ---@param message_id string\n\n Line 301: local function format_message(message_id)\n\n Line 302: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 308: local prev = ctx.formatted_messages[message_id]\n\n Line 309: local previous_rendered = ctx.render_state:get_previous_message(state.messages or {}, message_id)\n\n Line 317: ctx.formatted_messages[message_id] = formatted\n\n Line 323: ---@return string|nil message_id\n\n Line 330: local rendered_message = ctx.render_state:get_message(rendered_part.message_id)\n\n Line 347: warn_part_render_error_once(part_id, rendered_part.message_id, formatted_or_err)\n\n Line 348: return nil, rendered_part.message_id\n\n Line 351: return formatted_or_err, rendered_part.message_id\n\n Line 354: ---@param message_id string\n\n Line 355: local function apply_message(message_id)\n\n\n(Results truncated: showing 100 of 260 matches (160 hidden). Consider using a more specific path or pattern.)", + "title": "message_id" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_E16UuoqooiCsCc7YS5WBHtG6", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983371, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "id": "prt_e11bbd65f0010OjncQiHOoeErP", + "state": { + "metadata": { "matches": 4439, "truncated": true }, + "time": { "start": 1778413983330, "end": 1778413983490 }, + "status": "completed", + "input": { "pattern": "messageID", "path": "" }, + "output": "Found 4439 matches (showing first 100)\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:\n Line 50: local revert_message_id = revert and revert.messageID\n\n Line 97: messageID = HIDDEN_MESSAGES_NOTICE_MESSAGE_ID,\n\n Line 365: messageID = '__opencode_revert_message__',\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/permission_window_spec.lua:\n Line 366: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 395: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 424: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 453: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 482: tool = { messageID = 'msg_unknown', callID = 'call_unknown' },\n\n Line 525: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 530: tool = { messageID = 'msg_2', callID = 'call_2' },\n\n Line 558: tool = { messageID = 'msg_2', callID = 'call_2' },\n\n Line 563: it('uses root-level callID/messageID when tool field is absent', function()\n\n Line 570: messageID = 'msg_1',\n\n Line 595: it('stores messageID and callID from permission.tool', function()\n\n Line 600: messageID = 'msg_123',\n\n Line 623: it('handles permission.tool without messageID or callID', function()\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 109: messageID = 'permission-display-message',\n\n Line 150: messageID = 'question-display-message',\n\n Line 242: ---@param properties {sessionID: string, messageID: string}\n\n Line 248: local message_id = properties.messageID\n\n Line 289: if not part.id or not part.messageID or not part.sessionID then\n\n Line 305: local rendered_message = ctx.render_state:get_message(part.messageID)\n\n Line 307: local existing_message = find_message_in_state(part.messageID)\n\n Line 310: rendered_message = ctx.render_state:get_message(part.messageID)\n\n Line 314: ctx.render_state:upsert_orphan_part(part.messageID, part)\n\n Line 373: local perm_messageID = tool and tool.messageID or permission.messageID\n\n Line 374: if perm_callID == part.callID and perm_messageID == part.messageID then\n\n Line 386: flush.mark_part_dirty(part.id, part.messageID)\n\n Line 392: flush.mark_message_dirty(part.messageID)\n\n Line 394: flush.mark_part_dirty(prev_last_part_id, part.messageID)\n\n Line 398: flush.mark_part_dirty(part.id, part.messageID)\n\n Line 405: flush.mark_part_dirty(text_part_id, part.messageID)\n\n Line 411: ---@param properties {sessionID: string, messageID: string, partID: string}\n\n Line 422: if properties.messageID and ctx.render_state:remove_orphan_part(properties.messageID, part_id) then\n\n Line 502: local messageID = tool and tool.messageID or permission.messageID\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/types.lua:\n Line 115: ---@field messageID string\n\n Line 478: ---@field tool? { messageID: string, callID: string }\n\n Line 641: ---@field messageID string|nil Message identifier\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/api_client.lua:\n Line 195: --- @param init_data {messageID: string, providerID: string, modelID: string} Initialization data\n\n Line 237: --- @param fork_data {messageID?: string}|nil Fork data\n\n Line 256: --- @param message_data {messageID?: string, model?: {providerID: string, modelID: string}, agent?: string, variant?: string, system?: string, tools?: table, parts: OpencodeMessagePart[]} Message creation data\n\n Line 265: --- @param messageID string Message ID (required)\n\n Line 268: function OpencodeApiClient:get_message(id, messageID, directory)\n\n Line 269: return self:_call('/session/' .. id .. '/message/' .. messageID, 'GET', nil, { directory = directory })\n\n Line 274: --- @param command_data {messageID?: string, agent?: string, model?: string, arguments: string, command: string} Command data\n\n Line 292: --- @param revert_data {messageID: string, partID?: string} Revert data\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/permission_window.lua:\n Line 21: local message_id = (tool and tool.messageID) or permission.messageID\n\n Line 86: permission._message_id = permission.tool.messageID\n\n Line 367: local tool_message_id = tool and tool.messageID\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua:\n Line 683: M._format_assistant_message(output, vim.trim(part.text), part.messageID)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/commands/handlers/session.lua:\n Line 208: messageID = id.ascending('message'),\n\n Line 277: messageID = message_to_revert,\n\n Line 301: if message.info.id == revert.messageID then\n\n Line 327: if not active_session.revert or active_session.revert.messageID == '' then\n\n Line 344: messageID = next_message_id,\n\n Line 385: messageID = message_to_fork,\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/formatter_spec.lua:\n Line 36: messageID = 'msg_1',\n\n Line 80: messageID = 'msg_1',\n\n Line 103: messageID = 'msg_child_1',\n\n Line 149: messageID = 'msg_1',\n\n Line 172: messageID = 'msg_child_1',\n\n Line 219: messageID = 'msg_1',\n\n Line 290: messageID = 'msg_1',\n\n Line 344: messageID = 'msg_1',\n\n Line 477: messageID = 'msg_prev',\n\n Line 486: messageID = 'msg_current',\n\n Line 546: messageID = 'msg_1',\n\n Line 569: messageID = 'msg_child_1',\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua:\n Line 180: revert = { messageID = 'msg_1', snapshot = 'a', diff = '' },\n\n Line 190: revert = { messageID = 'msg_2', snapshot = 'b', diff = '' },\n\n Line 207: revert = { messageID = 'msg_1', snapshot = 'a', diff = '' },\n\n Line 244: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 250: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 256: { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 289: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 295: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 307: part = { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 339: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 345: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 351: { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 357: { id = 'part_4', messageID = 'msg_4', sessionID = 'ses_123', type = 'text', text = 'fourth' },\n\n Line 362: events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_1' })\n\n Line 389: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 395: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 401: { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 407: { id = 'part_4', messageID = 'msg_4', sessionID = 'ses_123', type = 'text', text = 'fourth' },\n\n Line 412: events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_1' })\n\n Line 418: events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_2' })\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/event_manager.lua:\n Line 21: --- @field properties {sessionID: string, messageID: string}\n\n Line 31: --- messageID: string,\n\n Line 39: --- @field properties {sessionID: string, messageID: string, partID: string}\n\n Line 78: --- @field tool? {messageID: string, callID: string}\n\n Line 79: --- @field messageID string\n\n Line 90: --- @field tool? {messageID: string, callID: string}\n\n Line 91: --- @field messageID string\n\n Line 328: local message_id = properties.messageID\n\n Line 340: messageID = message_id,\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/permission_integration_spec.lua:\n Line 38: it('correlates part with pending permission by callID and messageID', function()\n\n Line 44: messageID = 'msg_abc',\n\n Line 59: messageID = 'msg_abc',\n\n\n(Results truncated: showing 100 of 4439 matches (4339 hidden). Consider using a more specific path or pattern.)", + "title": "messageID" + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "callID": "call_ViEopy48Z03zxmZSRiW58Bli", + "tool": "grep", + "type": "tool" + }, + "time": 1778413983490, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", + "reason": "tool-calls", + "id": "prt_e11bbd705001KXYvvovAGpaHlR", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", + "tokens": { + "input": 260, + "cache": { "write": 0, "read": 15616 }, + "reasoning": 0, + "output": 2284, + "total": 18160 + }, + "cost": 0, + "type": "step-finish" + }, + "time": 1778413983564, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "message.part.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bb7ec8001avsqqdND6rr1pI", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "providerID": "github-copilot", + "variant": "high", + "agent": "build", + "time": { "created": 1778413960904 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "input": 260, + "cache": { "write": 0, "read": 15616 }, + "reasoning": 0, + "output": 2284, + "total": 18160 + }, + "modelID": "gpt-5-mini", + "finish": "tool-calls" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bb7ec8001avsqqdND6rr1pI", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "providerID": "github-copilot", + "variant": "high", + "agent": "build", + "time": { "created": 1778413960904, "completed": 1778413983618 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "input": 260, + "cache": { "write": 0, "read": 15616 }, + "reasoning": 0, + "output": 2284, + "total": 18160 + }, + "modelID": "gpt-5-mini", + "finish": "tool-calls" + } + }, + "type": "message.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bbd786001k7g5z8cTH6yNxa", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "variant": "high", + "agent": "build", + "time": { "created": 1778413983622 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "reasoning": 0, + "input": 0, + "output": 0, + "cache": { "write": 0, "read": 0 } + }, + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "slug": "quick-sailor", + "time": { "created": 1778413929849, "updated": 1778413983699 }, + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "summary": { "files": 0, "deletions": 0, "additions": 0 }, + "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "version": "1.14.19", + "title": "Review opencode/ui/renderer/events.lua" + } + }, + "type": "session.updated" + }, + { + "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", "diff": [] }, + "type": "session.diff" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "user", + "time": { "created": 1778413950306 }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "summary": { "diffs": [] }, + "agent": "build", + "model": { + "variant": "high", + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + }, + "id": "msg_e11bb5562001WiWVv8mGf4g0l8" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "cancel", + "source": { "argv": ["cancel"], "raw_args": "cancel" } + } + }, + "intent": { + "args": [], + "name": "cancel", + "source": { "argv": ["cancel"], "raw_args": "cancel" } + } + }, + "type": "custom.command.before" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "error": { + "name": "MessageAbortedError", + "data": { "message": "Aborted" } + }, + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" + }, + "type": "session.error" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "idle" } + }, + "type": "session.status" + }, + { + "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" }, + "type": "session.idle" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "cancel", + "source": { "argv": ["cancel"], "raw_args": "cancel" } + } + }, + "intent": { + "args": [], + "name": "cancel", + "source": { "argv": ["cancel"], "raw_args": "cancel" } + } + }, + "type": "custom.command.after" + }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "cancel", + "source": { "argv": ["cancel"], "raw_args": "cancel" } + } + }, + "intent": { + "args": [], + "name": "cancel", + "source": { "argv": ["cancel"], "raw_args": "cancel" } + } + }, + "type": "custom.command.finally" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "info": { + "role": "assistant", + "id": "msg_e11bbd786001k7g5z8cTH6yNxa", + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "mode": "build", + "error": { + "name": "MessageAbortedError", + "data": { "message": "Aborted" } + }, + "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", + "cost": 0, + "variant": "high", + "agent": "build", + "time": { "created": 1778413983622, "completed": 1778413988368 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "reasoning": 0, + "input": 0, + "output": 0, + "cache": { "write": 0, "read": 0 } + }, + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + } + }, + "type": "message.updated" + }, + { + "properties": { + "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", + "status": { "type": "idle" } + }, + "type": "session.status" + }, + { + "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" }, + "type": "session.idle" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { + "properties": { + "args": [], + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "type": "custom.command.before" + }, + { + "properties": { + "args": [], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "type": "custom.command.after" + }, + { + "properties": { + "args": [], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": false + }, + "parsed": { + "ok": true, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "intent": { + "args": [], + "name": "open_input_new_session", + "source": { + "argv": ["open_input_new_session"], + "raw_args": "open_input_new_session" + } + } + }, + "type": "custom.command.finally" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "slug": "cosmic-cactus", + "time": { "created": 1778413989960, "updated": 1778413989960 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:53:09.960Z" + } + }, + "type": "session.created" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "slug": "cosmic-cactus", + "time": { "created": 1778413989960, "updated": 1778413989960 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:53:09.960Z" + } + }, + "type": "session.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { + "properties": { + "args": ["n"], + "parsed": { + "ok": true, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "type": "custom.command.before" + }, + { + "properties": { + "args": ["n"], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": true + }, + "parsed": { + "ok": true, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "type": "custom.command.after" + }, + { + "properties": { + "args": ["n"], + "result": { + "_catch_callbacks": [], + "_then_callbacks": [], + "_coroutines": [], + "_resolved": true + }, + "parsed": { + "ok": true, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "intent": { + "args": ["n"], + "name": "submit_input_prompt", + "source": { + "argv": ["submit_input_prompt", "n"], + "raw_args": "submit_input_prompt n" + } + } + }, + "type": "custom.command.finally" + }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "role": "user", + "agent": "build", + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "id": "msg_e11bc493e001Q3qP1LNyp9QyD4", + "model": { + "variant": "high", + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + }, + "time": { "created": 1778414012734 } + } + }, + "type": "message.updated" + }, + { + "properties": { + "part": { + "id": "prt_e11bc493f001KxcOFSNopft1QL", + "text": "I am doing a test just answer hello there", + "messageID": "msg_e11bc493e001Q3qP1LNyp9QyD4", + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "type": "text" + }, + "time": 1778414012740, + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" + }, + "type": "message.part.updated" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "slug": "cosmic-cactus", + "time": { "created": 1778413989960, "updated": 1778414012744 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:53:09.960Z" + } + }, + "type": "session.updated" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "role": "assistant", + "id": "msg_e11bc494e001Q5WVKeHSzcB27f", + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bc493e001Q3qP1LNyp9QyD4", + "variant": "high", + "agent": "build", + "time": { "created": 1778414012750 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "reasoning": 0, + "input": 0, + "output": 0, + "cache": { "write": 0, "read": 0 } + }, + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "slug": "cosmic-cactus", + "time": { "created": 1778413989960, "updated": 1778414012807 }, + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "summary": { "files": 0, "deletions": 0, "additions": 0 }, + "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "version": "1.14.19", + "title": "New session - 2026-05-10T11:53:09.960Z" + } + }, + "type": "session.updated" + }, + { + "properties": { "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", "diff": [] }, + "type": "session.diff" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "role": "user", + "time": { "created": 1778414012734 }, + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "summary": { "diffs": [] }, + "agent": "build", + "model": { + "variant": "high", + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + }, + "id": "msg_e11bc493e001Q3qP1LNyp9QyD4" + } + }, + "type": "message.updated" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "slug": "cosmic-cactus", + "time": { "created": 1778413989960, "updated": 1778414014900 }, + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "summary": { "files": 0, "deletions": 0, "additions": 0 }, + "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "version": "1.14.19", + "title": "Greeting test — reply hello there" + } + }, + "type": "session.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "part": { + "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", + "id": "prt_e11bc595e001klMTjb2wsG5puf", + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "messageID": "msg_e11bc494e001Q5WVKeHSzcB27f", + "type": "step-start" + }, + "time": 1778414016862, + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "time": { "start": 1778414016865, "end": 1778414016867 }, + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "messageID": "msg_e11bc494e001Q5WVKeHSzcB27f", + "id": "prt_e11bc5961001s44upipLgbW5W5", + "text": "hello there", + "type": "text" + }, + "time": 1778414016867, + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", + "reason": "stop", + "id": "prt_e11bc5965001uoiWwmoHy3mPem", + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "messageID": "msg_e11bc494e001Q5WVKeHSzcB27f", + "tokens": { + "input": 141, + "cache": { "write": 0, "read": 10112 }, + "reasoning": 0, + "output": 204, + "total": 10457 + }, + "cost": 0, + "type": "step-finish" + }, + "time": 1778414016906, + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" + }, + "type": "message.part.updated" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "role": "assistant", + "id": "msg_e11bc494e001Q5WVKeHSzcB27f", + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bc493e001Q3qP1LNyp9QyD4", + "providerID": "github-copilot", + "variant": "high", + "agent": "build", + "time": { "created": 1778414012750 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "input": 141, + "cache": { "write": 0, "read": 10112 }, + "reasoning": 0, + "output": 204, + "total": 10457 + }, + "modelID": "gpt-5-mini", + "finish": "stop" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" }, + { "properties": [], "type": "custom.emit_events.started" }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "role": "assistant", + "id": "msg_e11bc494e001Q5WVKeHSzcB27f", + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "mode": "build", + "cost": 0, + "parentID": "msg_e11bc493e001Q3qP1LNyp9QyD4", + "providerID": "github-copilot", + "variant": "high", + "agent": "build", + "time": { "created": 1778414012750, "completed": 1778414016944 }, + "path": { + "root": "/home/francis/Projects/_nvim/opencode.nvim", + "cwd": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "tokens": { + "input": 141, + "cache": { "write": 0, "read": 10112 }, + "reasoning": 0, + "output": 204, + "total": 10457 + }, + "modelID": "gpt-5-mini", + "finish": "stop" + } + }, + "type": "message.updated" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "status": { "type": "busy" } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "status": { "type": "idle" } + }, + "type": "session.status" + }, + { + "properties": { "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" }, + "type": "session.idle" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "slug": "cosmic-cactus", + "time": { "created": 1778413989960, "updated": 1778414016964 }, + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "summary": { "files": 0, "deletions": 0, "additions": 0 }, + "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "version": "1.14.19", + "title": "Greeting test — reply hello there" + } + }, + "type": "session.updated" + }, + { + "properties": { "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", "diff": [] }, + "type": "session.diff" + }, + { + "properties": { + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "info": { + "role": "user", + "time": { "created": 1778414012734 }, + "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", + "summary": { "diffs": [] }, + "agent": "build", + "model": { + "variant": "high", + "modelID": "gpt-5-mini", + "providerID": "github-copilot" + }, + "id": "msg_e11bc493e001Q3qP1LNyp9QyD4" + } + }, + "type": "message.updated" + }, + { "properties": [], "type": "custom.emit_events.finished" } +] diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index 4bd6d7a0..b8ad0d48 100644 --- a/tests/unit/commands_handlers_spec.lua +++ b/tests/unit/commands_handlers_spec.lua @@ -94,12 +94,27 @@ describe('opencode.commands.handlers', function() assert.same({ 'accept', 'accept_all', 'deny' }, defs.permission.completions) assert.same({ allow_empty = false }, defs.permission.nested_subcommand) - assert.same( - { 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename', 'toggle_lock' }, - defs.session.completions - ) + assert.same({ + 'new', + 'tab', + 'tabs', + 'next_tab', + 'prev_tab', + 'close_tab', + 'select', + 'navigate', + 'compact', + 'share', + 'unshare', + 'agents_init', + 'rename', + 'toggle_lock', + }, defs.session.completions) assert.same({ allow_empty = false }, defs.session.nested_subcommand) + assert.same({ 'next', 'new', 'previous', 'select', 'close' }, defs.tab.completions) + assert.same({ allow_empty = false }, defs.tab.nested_subcommand) + assert.same({ 'input', 'output' }, defs.open.completions) assert.equal('user_commands', defs.command.completion_provider_id) end) @@ -567,6 +582,52 @@ describe('opencode.commands.handlers', function() assert.is_true(called) end) + it('tab subcommands route to panel tab actions', function() + local session_handler = require('opencode.commands.handlers.session') + local called_with = {} + local original_actions = { + next = session_handler.actions.next_session_tab, + new = session_handler.actions.open_session_tab, + previous = session_handler.actions.prev_session_tab, + select = session_handler.actions.select_session_tab, + close = session_handler.actions.close_session_tab, + } + + session_handler.actions.next_session_tab = function() + called_with.next = true + end + session_handler.actions.open_session_tab = function(title) + called_with.new = title + end + session_handler.actions.prev_session_tab = function() + called_with.previous = true + end + session_handler.actions.select_session_tab = function() + called_with.select = true + end + session_handler.actions.close_session_tab = function() + called_with.close = true + end + + session_handler.command_defs.tab.execute({ 'next' }) + session_handler.command_defs.tab.execute({ 'new', 'named', 'tab' }) + session_handler.command_defs.tab.execute({ 'previous' }) + session_handler.command_defs.tab.execute({ 'select' }) + session_handler.command_defs.tab.execute({ 'close' }) + + session_handler.actions.next_session_tab = original_actions.next + session_handler.actions.open_session_tab = original_actions.new + session_handler.actions.prev_session_tab = original_actions.previous + session_handler.actions.select_session_tab = original_actions.select + session_handler.actions.close_session_tab = original_actions.close + + assert.is_true(called_with.next) + assert.equal('named tab', called_with.new) + assert.is_true(called_with.previous) + assert.is_true(called_with.select) + assert.is_true(called_with.close) + end) + it('navigate_session_tree command_defs execute routes to action', function() local session_handler = require('opencode.commands.handlers.session') local called_with = {} diff --git a/tests/unit/commands_parse_spec.lua b/tests/unit/commands_parse_spec.lua index cb23a1f4..dec1a6ec 100644 --- a/tests/unit/commands_parse_spec.lua +++ b/tests/unit/commands_parse_spec.lua @@ -54,6 +54,14 @@ describe('opencode.commands.parse', function() assert.same({}, result.intent.args) end) + it('parses panel tab subcommands', function() + local result = command_parse.command({ args = 'tab previous', range = 0 }, commands.get_commands()) + + assert.is_true(result.ok) + assert.equal('tab', result.intent.name) + assert.same({ 'previous' }, result.intent.args) + end) + it('validates nested subcommand from command schema without hardcoded command names', function() local defs = { custom = { diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index fa589c76..72efd6fe 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -138,7 +138,7 @@ describe('persist_state', function() return end - for _, buf in ipairs({ hb.input_buf, hb.output_buf, hb.footer_buf }) do + for _, buf in ipairs({ hb.input_buf, hb.output_buf, hb.footer_buf, hb.tab_strip_buf }) do if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end @@ -332,6 +332,7 @@ describe('persist_state', function() windows = ui.create_windows() local input_buf = windows.input_buf local footer_buf = windows.footer_buf + local tab_strip_buf = windows.tab_strip_buf vim.api.nvim_buf_set_lines(input_buf, 0, -1, false, { 'preserved content' }) ui.close_windows(windows, true) @@ -341,12 +342,14 @@ describe('persist_state', function() local hidden = state.ui.inspect_hidden_buffers() assert.is_not_nil(hidden) assert.equals(footer_buf, hidden.footer_buf) + assert.equals(tab_strip_buf, hidden.tab_strip_buf) assert.is_true(vim.api.nvim_buf_is_valid(input_buf)) local restored = ui.restore_hidden_windows() assert.is_true(restored) assert.equals(input_buf, state.windows.input_buf) assert.equals(footer_buf, state.windows.footer_buf) + assert.equals(tab_strip_buf, state.windows.tab_strip_buf) assert.is_false(ui.has_hidden_buffers()) local lines = vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false) diff --git a/tests/unit/renderer_session_tabs_spec.lua b/tests/unit/renderer_session_tabs_spec.lua new file mode 100644 index 00000000..8b58a4eb --- /dev/null +++ b/tests/unit/renderer_session_tabs_spec.lua @@ -0,0 +1,75 @@ +local state = require('opencode.state') +local store = require('opencode.state.store') +local session_tabs = require('opencode.state.session_tabs') +local renderer = require('opencode.ui.renderer') +local renderer_ctx = require('opencode.ui.renderer.ctx') +local Promise = require('opencode.promise') +local stub = require('luassert.stub') + +describe('renderer session tab contexts', function() + local original_state + local output_buf + local output_win + + before_each(function() + original_state = vim.deepcopy(store.state()) + session_tabs.reset() + renderer_ctx:reset() + state.ui.set_windows(nil) + end) + + after_each(function() + if output_win and vim.api.nvim_win_is_valid(output_win) then + pcall(vim.api.nvim_win_close, output_win, true) + end + if output_buf and vim.api.nvim_buf_is_valid(output_buf) then + pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) + end + output_win = nil + output_buf = nil + state.ui.set_windows(nil) + renderer_ctx:reset() + session_tabs.reset() + for key, value in pairs(original_state) do + store.set_raw(key, value) + end + end) + + it('restores a cached renderer context without rerendering the output buffer', function() + local first = session_tabs.ensure_current() + first.active_session = { id = 'session-one', title = 'One' } + + renderer_ctx:reset() + renderer_ctx.formatted_messages = { first = true } + first.renderer_context = renderer_ctx:snapshot() + + local second = session_tabs.create({ id = 'session-two', title = 'Two' }) + renderer_ctx:reset() + renderer_ctx.formatted_messages = { second = true } + second.renderer_context = renderer_ctx:snapshot() + renderer_ctx:restore(first.renderer_context) + + output_buf = vim.api.nvim_create_buf(false, true) + output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 60, + height = 10, + row = 1, + col = 1, + }) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'preserved output' }) + state.ui.set_windows({ output_buf = output_buf, output_win = output_win }) + + store.set_raw('active_session_tab', second.id) + store.set_raw('active_session', second.active_session) + store.set_raw('messages', {}) + + local render_stub = stub(renderer, 'render_full_session').returns(Promise.new():resolve(nil)) + renderer.on_session_tab_changed(nil, second.id, first.id) + + assert.stub(render_stub).was_not_called() + assert.equals(second.renderer_context.render_state, renderer_ctx.render_state) + assert.same({ 'preserved output' }, vim.api.nvim_buf_get_lines(output_buf, 0, -1, false)) + render_stub:revert() + end) +end) diff --git a/tests/unit/session_picker_spec.lua b/tests/unit/session_picker_spec.lua index d37ca333..24caeb1a 100644 --- a/tests/unit/session_picker_spec.lua +++ b/tests/unit/session_picker_spec.lua @@ -234,6 +234,37 @@ describe('opencode.ui.session_picker', function() end) end) + it('opens the selected session in a new panel tab', function() + local base_picker = require('opencode.ui.base_picker') + local original_pick = base_picker.pick + local session_runtime = require('opencode.services.session_runtime') + local selected_session = { id = 'session-in-tab', title = 'Session in tab' } + local captured_action + + base_picker.pick = function(opts) + captured_action = opts.actions.open_in_tab + return true + end + + session_picker.pick({ selected_session }, function() end) + + local open_stub = stub(session_runtime, 'open_session_in_tab').returns(Promise.new():resolve(selected_session)) + local closed = false + captured_action + .fn(selected_session, { + close = function() + closed = true + end, + }) + :wait() + + assert.is_true(closed) + assert.stub(open_stub).was_called_with(selected_session) + + open_stub:revert() + base_picker.pick = original_pick + end) + -- ----------------------------------------------------------------------- -- Integration tests: delete action triggers switch when parent/grandparent -- of the active session is deleted diff --git a/tests/unit/session_tab_picker_spec.lua b/tests/unit/session_tab_picker_spec.lua new file mode 100644 index 00000000..13d25c4f --- /dev/null +++ b/tests/unit/session_tab_picker_spec.lua @@ -0,0 +1,87 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local Promise = require('opencode.promise') +local base_picker = require('opencode.ui.base_picker') +local session_runtime = require('opencode.services.session_runtime') +local session_tab_picker = require('opencode.ui.session_tab_picker') + +describe('opencode.ui.session_tab_picker', function() + local original_pick + + before_each(function() + original_pick = base_picker.pick + end) + + after_each(function() + base_picker.pick = original_pick + end) + + it('provides new and close actions', function() + local captured_opts + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + local tabs = { { id = 'tab-1', active_session = { title = 'One' } } } + assert.is_true(session_tab_picker.pick(tabs, function() end)) + + assert.is_table(captured_opts.actions.new) + assert.is_table(captured_opts.actions.close) + assert.same({ '', desc = 'Create a new panel tab' }, captured_opts.actions.new.key) + assert.same({ '', desc = 'Close selected panel tab' }, captured_opts.actions.close.key) + end) + + it('opens a new tab through new action and closes picker', function() + local captured_opts + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + session_tab_picker.pick({ { id = 'tab-1' } }, function() end) + + local open_stub = stub(session_runtime, 'open_session_tab').returns(Promise.new():resolve({ id = 'new' })) + local closed = false + captured_opts.actions.new + .fn({}, { + close = function() + closed = true + end, + }) + :wait() + + assert.is_true(closed) + assert.stub(open_stub).was_called() + open_stub:revert() + end) + + it('closes selected inactive tab without switching active tab', function() + local captured_opts + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + session_tab_picker.pick({ { id = 'tab-1' }, { id = 'tab-2' } }, function() end) + + local close_stub = stub(session_runtime, 'close_session_tab') + local closed + close_stub.invokes(function(tab_id) + closed = tab_id + return true + end) + + local closed_picker = false + captured_opts.actions.close.fn({ id = 'tab-2' }, { + close = function() + closed_picker = true + end, + }) + + assert.is_true(closed_picker) + assert.equal('tab-2', closed) + + close_stub:revert() + end) +end) diff --git a/tests/unit/session_tab_strip_spec.lua b/tests/unit/session_tab_strip_spec.lua new file mode 100644 index 00000000..28888b87 --- /dev/null +++ b/tests/unit/session_tab_strip_spec.lua @@ -0,0 +1,75 @@ +local state = require('opencode.state') +local store = require('opencode.state.store') +local session_tabs = require('opencode.state.session_tabs') +local session_tab_strip = require('opencode.ui.session_tab_strip') + +describe('opencode session tab strip', function() + local original_state + local windows + + before_each(function() + original_state = vim.deepcopy(store.state()) + session_tabs.reset() + end) + + after_each(function() + session_tab_strip.close(false, windows) + if windows then + if windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) then + pcall(vim.api.nvim_win_close, windows.output_win, true) + end + if windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf) then + pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true }) + end + end + state.ui.set_windows(nil) + windows = nil + session_tabs.reset() + for key, value in pairs(original_state) do + store.set(key, value) + end + end) + + it('renders truncated, selectable tabs with an active marker', function() + local first = session_tabs.ensure_current() + first.active_session = { id = 'session-one', title = 'A very long first session title' } + state.session.set_active(first.active_session) + + local second = session_tabs.create({ id = 'session-two', title = 'Second' }) + session_tabs.activate(second) + + local output_buf = vim.api.nvim_create_buf(false, true) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 40, + height = 10, + row = 1, + col = 1, + }) + windows = { + output_buf = output_buf, + output_win = output_win, + tab_strip_buf = session_tab_strip.create_buf(), + position = 'float', + } + + session_tab_strip.create_window(windows) + state.ui.set_windows(windows) + session_tab_strip.setup(windows) + + local line = vim.api.nvim_buf_get_lines(windows.tab_strip_buf, 0, 1, false)[1] + assert.matches('Second', line) + assert.matches('> %[%s*2', line) + assert.is_true(vim.fn.strdisplaywidth(line) <= vim.api.nvim_win_get_width(windows.tab_strip_win)) + + local marks = vim.api.nvim_buf_get_extmarks(windows.tab_strip_buf, -1, 0, -1, { details = true }) + local groups = {} + for _, mark in ipairs(marks) do + if mark[4] and mark[4].hl_group then + groups[mark[4].hl_group] = true + end + end + assert.is_true(groups.OpencodeSessionTabActive) + assert.is_true(groups.OpencodeSessionTabInactive) + end) +end) diff --git a/tests/unit/session_tabs_spec.lua b/tests/unit/session_tabs_spec.lua new file mode 100644 index 00000000..c3bc5e8e --- /dev/null +++ b/tests/unit/session_tabs_spec.lua @@ -0,0 +1,157 @@ +local state = require('opencode.state') +local store = require('opencode.state.store') +local session_tabs = require('opencode.state.session_tabs') +local Promise = require('opencode.promise') +local stub = require('luassert.stub') + +describe('opencode session panel tabs', function() + local original_state + + before_each(function() + original_state = vim.deepcopy(store.state()) + session_tabs.reset() + end) + + after_each(function() + vim.wait(50) + session_tabs.reset() + for key, value in pairs(original_state) do + store.set(key, value) + end + vim.wait(50) + end) + + it('keeps session state isolated when switching logical tabs', function() + local first = session_tabs.ensure_current() + state.session.set_active({ id = 'session-one', title = 'One' }) + state.renderer.set_messages({ { info = { id = 'message-one' }, parts = {} } }) + state.ui.set_input_content({ 'prompt for one' }) + + local second = session_tabs.create({ id = 'session-two', title = 'Two' }) + session_tabs.activate(second) + state.renderer.set_messages({ { info = { id = 'message-two' }, parts = {} } }) + state.ui.set_input_content({ 'prompt for two' }) + + session_tabs.activate(first) + + assert.equals('session-one', state.active_session.id) + assert.equals('message-one', state.messages[1].info.id) + assert.same({ 'prompt for one' }, state.input_content) + + session_tabs.activate(second) + + assert.equals('session-two', state.active_session.id) + assert.equals('message-two', state.messages[1].info.id) + assert.same({ 'prompt for two' }, state.input_content) + end) + + it('creates new tabs without reusing the active tab UI state', function() + session_tabs.ensure_current() + state.ui.set_windows({ input_buf = 10, output_buf = 11 }) + state.ui.set_input_content({ 'old input' }) + + local runtime = session_tabs.create({ id = 'new-session' }) + + assert.is_nil(runtime.windows) + assert.same({}, runtime.input_content) + assert.equals('old input', state.input_content[1]) + end) + + it('updates a background tab message count without changing the active tab', function() + local first = session_tabs.ensure_current() + state.session.set_active({ id = 'session-one' }) + local second = session_tabs.create({ id = 'session-two' }) + session_tabs.activate(second) + + session_tabs.update_user_message_count(first.id, 'session-one', 1) + + assert.same({}, state.user_message_count) + assert.equals(1, first.user_message_count['session-one']) + assert.equals('session-two', state.active_session.id) + end) + + it('mounts each tab with its own output and input buffers', function() + local session_runtime = require('opencode.services.session_runtime') + local server_job = require('opencode.server_job') + local agent_model = require('opencode.services.agent_model') + local renderer = require('opencode.ui.renderer') + local ui = require('opencode.ui.ui') + + local server = { + is_running = function() + return true + end, + check_health = function() + return Promise.new():resolve(true) + end, + shutdown = function() end, + } + + state.jobs.set_server(server) + state.jobs.set_api_client({}) + state.context.set_current_cwd(vim.fn.getcwd()) + + local create_session_stub = + stub(session_runtime, 'create_new_session').returns(Promise.new():resolve({ id = 'session-two', title = 'Two' })) + local ensure_server_stub = stub(server_job, 'ensure_server').returns(Promise.new():resolve(server)) + local ensure_mode_stub = stub(agent_model, 'ensure_current_mode').returns(Promise.new():resolve(true)) + local render_stub = stub(renderer, 'render_full_session').returns(Promise.new():resolve(nil)) + + state.session.set_active({ id = 'session-one', title = 'One' }) + session_runtime.open({ focus = 'output', open_action = 'create_fresh' }):await() + local first_output = state.windows.output_buf + local first_input = state.windows.input_buf + require('opencode.ui.output_window').set_lines({ 'first output' }) + require('opencode.ui.input_window').set_content({ 'first input' }) + + session_runtime.open_session_tab('Two'):await() + local second_output = state.windows.output_buf + local second_input = state.windows.input_buf + require('opencode.ui.output_window').set_lines({ 'second output' }) + require('opencode.ui.input_window').set_content({ 'second input' }) + + assert.is_not.equal(first_output, second_output) + assert.is_not.equal(first_input, second_input) + + local tabs = session_tabs.list() + local first_tab + local second_tab + for _, tab in ipairs(tabs) do + if tab.active_session and tab.active_session.id == 'session-one' then + first_tab = tab + elseif tab.active_session and tab.active_session.id == 'session-two' then + second_tab = tab + end + end + assert.is_not_nil(first_tab) + assert.is_not_nil(second_tab) + + session_runtime.switch_session_tab(first_tab.id):await() + assert.equals(first_output, state.windows.output_buf) + assert.same({ 'first input' }, vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false)) + + session_runtime.switch_session_tab(second_tab.id):await() + assert.equals(second_output, state.windows.output_buf) + assert.same({ 'second input' }, vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false)) + + if state.windows then + ui.teardown_visible_windows(state.windows) + end + for _, tab in ipairs(session_tabs.list()) do + for _, buf in ipairs({ + tab.windows and tab.windows.input_buf, + tab.windows and tab.windows.output_buf, + tab.windows and tab.windows.footer_buf, + tab.windows and tab.windows.tab_strip_buf, + }) do + if buf and vim.api.nvim_buf_is_valid(buf) then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end + end + end + create_session_stub:revert() + ensure_server_stub:revert() + ensure_mode_stub:revert() + render_stub:revert() + end) +end) From b29b337c7640a908d1b310b519959330b2ab18a3 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 9 Sep 2026 10:01:59 -0400 Subject: [PATCH 02/12] feat(ui): hide tab strip when single session tab exists --- README.md | 1 + lua/opencode/config.lua | 1 + lua/opencode/state/init.lua | 1 + lua/opencode/state/session_tabs.lua | 8 ++++ lua/opencode/state/store.lua | 2 + lua/opencode/types.lua | 1 + lua/opencode/ui/session_tab_strip.lua | 56 ++++++++++++++++++++------- tests/unit/config_spec.lua | 6 +++ tests/unit/session_tab_strip_spec.lua | 47 ++++++++++++++++++++++ 9 files changed, 108 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8b4354e2..a5dc9388 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ require('opencode').setup({ display_model = true, -- Display model name on top winbar display_context_size = true, -- Display context size in the footer display_cost = true, -- Display cost in the footer + hide_single_tab = false, -- Hide the panel tab strip when only one session tab exists window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', -- Highlight group for the opencode window persist_state = true, -- Keep buffers when toggling/closing UI so window state restores quickly icons = { diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 777630e6..5756fa6d 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -168,6 +168,7 @@ M.defaults = { display_model = true, display_context_size = true, display_cost = true, + hide_single_tab = true, window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', persist_state = true, icons = { diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index d5c864bb..222546bf 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -18,6 +18,7 @@ local session_tabs = require('opencode.state.session_tabs') ---@field session_tabs OpencodeSessionTabStateMutations ---@field active_session Session|nil ---@field active_session_tab string|nil +---@field session_tabs_changed number ---@field current_model string|nil ---@field api_client OpencodeApiClient|nil diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index 0bdb1917..3d3f1d8a 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -100,6 +100,12 @@ local runtimes = {} local next_id = 1 local setup_done = false +local function notify_change() + store.update('session_tabs_changed', function(current) + return (current or 0) + 1 + end) +end + local function new_id() local id = 'tab-' .. next_id next_id = next_id + 1 @@ -337,6 +343,7 @@ function M.create(session) runtime.cost = 0 runtime.tokens_count = 0 runtimes[runtime.id] = runtime + notify_change() return runtime end @@ -346,6 +353,7 @@ function M.remove(runtime) return end runtimes[runtime.id] = nil + notify_change() if store.get('active_session_tab') == runtime.id then store.set('active_session_tab', nil) end diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index fb8675a2..bb75b211 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -43,6 +43,7 @@ local M = {} ---@field session_locked boolean|nil ---@field _hidden_buffers OpencodeHiddenBuffers|nil ---@field active_session_tab string|nil +---@field session_tabs_changed number ---@type OpencodeStateData local _state = { @@ -86,6 +87,7 @@ local _state = { session_locked = nil, _hidden_buffers = nil, active_session_tab = nil, + session_tabs_changed = 0, } local _listeners = {} diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index aba75f99..48f60881 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -242,6 +242,7 @@ ---@field display_model boolean ---@field display_context_size boolean ---@field display_cost boolean +---@field hide_single_tab boolean ---@field window_highlight string ---@field icons { preset: 'text'|'nerdfonts', overrides: table } ---@field loading_animation OpencodeLoadingAnimationConfig diff --git a/lua/opencode/ui/session_tab_strip.lua b/lua/opencode/ui/session_tab_strip.lua index 6bb92ff5..fc9c14a5 100644 --- a/lua/opencode/ui/session_tab_strip.lua +++ b/lua/opencode/ui/session_tab_strip.lua @@ -1,5 +1,6 @@ local state = require('opencode.state') local session_tabs = require('opencode.state.session_tabs') +local config = require('opencode.config') local M = {} @@ -109,16 +110,20 @@ end ---@param windows OpencodeWindowState ---@return boolean -local function valid_windows(windows) +local function valid_output_windows(windows) return windows and windows.output_win - and windows.tab_strip_win and windows.tab_strip_buf and vim.api.nvim_win_is_valid(windows.output_win) - and vim.api.nvim_win_is_valid(windows.tab_strip_win) and vim.api.nvim_buf_is_valid(windows.tab_strip_buf) end +---@param windows OpencodeWindowState +---@return boolean +local function valid_windows(windows) + return valid_output_windows(windows) and windows.tab_strip_win and vim.api.nvim_win_is_valid(windows.tab_strip_win) +end + ---@param windows OpencodeWindowState local function setup_window_options(windows) local win = windows.tab_strip_win @@ -250,6 +255,9 @@ function M.create_window(windows) if not windows.output_win or not windows.tab_strip_buf or not vim.api.nvim_win_is_valid(windows.output_win) then return nil end + if windows.tab_strip_win and vim.api.nvim_win_is_valid(windows.tab_strip_win) then + return windows.tab_strip_win + end local output_config = vim.api.nvim_win_get_config(windows.output_win) if output_config.relative == '' then @@ -267,6 +275,17 @@ function M.create_window(windows) return windows.tab_strip_win end +---@param windows OpencodeWindowState +local function close_window(windows) + if windows.tab_strip_win and vim.api.nvim_win_is_valid(windows.tab_strip_win) then + pcall(vim.api.nvim_win_close, windows.tab_strip_win, true) + end + windows.tab_strip_win = nil + if windows.tab_strip_buf then + ranges_by_buffer[windows.tab_strip_buf] = nil + end +end + ---@param windows? OpencodeWindowState ---@return boolean function M.mounted(windows) @@ -276,10 +295,22 @@ end ---@param windows? OpencodeWindowState function M.update_window(windows) windows = windows or state.windows - if not valid_windows(windows) then + if not valid_output_windows(windows) then return end + local tabs = session_tabs.list() + if config.ui.hide_single_tab and #tabs == 1 then + close_window(windows) + return + end + + if not windows.tab_strip_win or not vim.api.nvim_win_is_valid(windows.tab_strip_win) then + if not M.create_window(windows) then + return + end + end + if vim.api.nvim_win_get_config(windows.tab_strip_win).relative ~= '' then pcall(vim.api.nvim_win_set_config, windows.tab_strip_win, build_float_config(windows.output_win)) end @@ -294,24 +325,23 @@ function M.create_buf() end local function on_change() - M.render() + M.update_window() end ---@param windows OpencodeWindowState function M.setup(windows) - if not valid_windows(windows) then + if not valid_output_windows(windows) then return false end if not subscribed then state.store.subscribe('active_session', on_change) state.store.subscribe('active_session_tab', on_change) + state.store.subscribe('session_tabs_changed', on_change) subscribed = true end - setup_window_options(windows) - setup_keymaps(windows.tab_strip_buf) - M.render(windows) + M.update_window(windows) return true end @@ -320,20 +350,16 @@ end function M.close(preserve_buffer, windows) windows = windows or state.windows if windows then - if windows.tab_strip_win and vim.api.nvim_win_is_valid(windows.tab_strip_win) then - pcall(vim.api.nvim_win_close, windows.tab_strip_win, true) - end + close_window(windows) if not preserve_buffer and windows.tab_strip_buf and vim.api.nvim_buf_is_valid(windows.tab_strip_buf) then pcall(vim.api.nvim_buf_delete, windows.tab_strip_buf, { force = true }) end - if windows.tab_strip_buf then - ranges_by_buffer[windows.tab_strip_buf] = nil - end end if subscribed then state.store.unsubscribe('active_session', on_change) state.store.unsubscribe('active_session_tab', on_change) + state.store.unsubscribe('session_tabs_changed', on_change) subscribed = false end end diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index ffb2415e..8da868b5 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -24,6 +24,12 @@ describe('opencode.config', function() assert.same(config.defaults, config.values) end) + it('supports hiding the tab strip for a single session tab', function() + config.setup({ ui = { hide_single_tab = true } }) + + assert.is_true(config.values.ui.hide_single_tab) + end) + it('merges user options with defaults', function() local custom_callback = function() return 'custom' diff --git a/tests/unit/session_tab_strip_spec.lua b/tests/unit/session_tab_strip_spec.lua index 28888b87..5f45b8f1 100644 --- a/tests/unit/session_tab_strip_spec.lua +++ b/tests/unit/session_tab_strip_spec.lua @@ -2,13 +2,16 @@ local state = require('opencode.state') local store = require('opencode.state.store') local session_tabs = require('opencode.state.session_tabs') local session_tab_strip = require('opencode.ui.session_tab_strip') +local config = require('opencode.config') describe('opencode session tab strip', function() local original_state + local original_config local windows before_each(function() original_state = vim.deepcopy(store.state()) + original_config = vim.deepcopy(config.values) session_tabs.reset() end) @@ -25,6 +28,7 @@ describe('opencode session tab strip', function() state.ui.set_windows(nil) windows = nil session_tabs.reset() + config.values = original_config for key, value in pairs(original_state) do store.set(key, value) end @@ -72,4 +76,47 @@ describe('opencode session tab strip', function() assert.is_true(groups.OpencodeSessionTabActive) assert.is_true(groups.OpencodeSessionTabInactive) end) + + it('hides the tab strip for one tab and restores it for multiple tabs', function() + config.values.ui.hide_single_tab = true + + local first = session_tabs.ensure_current() + first.active_session = { id = 'session-one', title = 'First' } + state.session.set_active(first.active_session) + + local output_buf = vim.api.nvim_create_buf(false, true) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 40, + height = 10, + row = 1, + col = 1, + }) + windows = { + output_buf = output_buf, + output_win = output_win, + tab_strip_buf = session_tab_strip.create_buf(), + position = 'float', + } + + session_tab_strip.create_window(windows) + state.ui.set_windows(windows) + session_tab_strip.setup(windows) + + assert.is_nil(windows.tab_strip_win) + + local second = session_tabs.create({ id = 'session-two', title = 'Second' }) + session_tabs.activate(second) + state.ui.set_windows(windows) + vim.wait(50) + + assert.is_not_nil(windows.tab_strip_win) + assert.is_true(vim.api.nvim_win_is_valid(windows.tab_strip_win)) + assert.matches('Second', vim.api.nvim_buf_get_lines(windows.tab_strip_buf, 0, 1, false)[1]) + + session_tabs.remove(first) + vim.wait(50) + + assert.is_nil(windows.tab_strip_win) + end) end) From 1d541423fcf8f6f2a4496957bcabb93a6a8fc63c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 9 Sep 2026 10:52:11 -0400 Subject: [PATCH 03/12] feat(session-tabs): add index selection, close keymap, overflow marker, and tab strip styling --- README.md | 3 +- lua/opencode/commands/handlers/session.lua | 18 ++- lua/opencode/config.lua | 10 ++ lua/opencode/services/session_runtime.lua | 20 ++- lua/opencode/ui/highlight.lua | 6 + lua/opencode/ui/session_tab_strip.lua | 159 ++++++++++++++++++--- tests/unit/commands_handlers_spec.lua | 8 +- tests/unit/config_spec.lua | 13 ++ tests/unit/session_tab_strip_spec.lua | 66 ++++++++- tests/unit/session_tabs_spec.lua | 15 ++ 10 files changed, 285 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index a5dc9388..6e1ff554 100644 --- a/README.md +++ b/README.md @@ -663,8 +663,9 @@ Panel tabs are logical tabs inside the Opencode UI. They do not create or switch | Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` | | Open a new session in a panel tab | `oN` | `:Opencode tab new [name]` | `require('opencode.api').open_session_tab([name])` | | Select a panel tab | `o?` | `:Opencode tab select` | `require('opencode.api').select_session_tab()` | +| Select panel tab by index | `o1` ... `o9` | `:Opencode tab select [index]` | `require('opencode.api').select_session_tab(index)` | | Switch panel tabs | `o<` / `o>` | `:Opencode tab previous` / `next` | `require('opencode.api').prev_session_tab()` / `next_session_tab()` | -| Close the current panel tab | - | `:Opencode tab close` | `require('opencode.api').close_session_tab()` | +| Close the current panel tab | `oQ` | `:Opencode tab close` | `require('opencode.api').close_session_tab()` | | Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` | | Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) | | Open the selected session in a new panel tab | `` (session picker) | - | - | diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index 10d1b763..e1f4bec4 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -121,7 +121,11 @@ function M.actions.open_session_tab(title) return session_runtime.open_session_tab(title) end -function M.actions.select_session_tab() +---@param index? string|number +function M.actions.select_session_tab(index) + if index ~= nil then + return session_runtime.switch_session_tab_by_index(index) + end return require('opencode.ui.session_tab_picker').select() end @@ -689,8 +693,8 @@ local session_subcommand_actions = { tab = function(args) return M.actions.open_session_tab(parse_title(args, 2)) end, - tabs = function() - return M.actions.select_session_tab() + tabs = function(args) + return M.actions.select_session_tab(args[2]) end, next_tab = function() return M.actions.next_session_tab() @@ -752,8 +756,8 @@ local tab_subcommand_actions = { previous = function() return M.actions.prev_session_tab() end, - select = function() - return M.actions.select_session_tab() + select = function(args) + return M.actions.select_session_tab(args[2]) end, close = function() return M.actions.close_session_tab() @@ -797,7 +801,9 @@ M.command_defs = { }, select_session_tab = { desc = 'Select an Opencode panel tab', - execute = M.actions.select_session_tab, + execute = function(args) + return M.actions.select_session_tab(args[1]) + end, }, next_session_tab = { desc = 'Switch to the next Opencode panel tab', diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 5756fa6d..32d5dc9b 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -37,11 +37,21 @@ M.defaults = { ['o<'] = { 'prev_session_tab', desc = 'Previous Opencode session tab' }, ['o>'] = { 'next_session_tab', desc = 'Next Opencode session tab' }, ['o?'] = { 'select_session_tab', desc = 'Select Opencode session tab' }, + ['o1'] = { 'select_session_tab', { 1 }, desc = 'Select Opencode session tab 1' }, + ['o2'] = { 'select_session_tab', { 2 }, desc = 'Select Opencode session tab 2' }, + ['o3'] = { 'select_session_tab', { 3 }, desc = 'Select Opencode session tab 3' }, + ['o4'] = { 'select_session_tab', { 4 }, desc = 'Select Opencode session tab 4' }, + ['o5'] = { 'select_session_tab', { 5 }, desc = 'Select Opencode session tab 5' }, + ['o6'] = { 'select_session_tab', { 6 }, desc = 'Select Opencode session tab 6' }, + ['o7'] = { 'select_session_tab', { 7 }, desc = 'Select Opencode session tab 7' }, + ['o8'] = { 'select_session_tab', { 8 }, desc = 'Select Opencode session tab 8' }, + ['o9'] = { 'select_session_tab', { 9 }, desc = 'Select Opencode session tab 9' }, ['oh'] = { 'select_history', desc = 'Select from history' }, ['oo'] = { 'open_output', desc = 'Open output window' }, ['ot'] = { 'toggle_focus', desc = 'Toggle focus' }, ['oT'] = { 'timeline', desc = 'Session timeline' }, ['oq'] = { 'close', desc = 'Close Opencode window' }, + ['oQ'] = { 'close_session_tab', desc = 'Close current Opencode session tab' }, ['os'] = { 'select_session', desc = 'Select session' }, ['oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' }, ['oP'] = { 'navigate_session_tree', { 'parent' }, desc = 'Go to parent session' }, diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index a36bb3d7..18f63b46 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -318,8 +318,9 @@ M.switch_session_tab = Promise.async(function(tab_id) session_tabs.activate(runtime) context.restore(session_tabs.get_context()) + local focus = state.last_focused_opencode_window == 'output' and 'output' or 'input' M.open({ - focus = 'input', + focus = focus, new_session = false, open_action = 'restore_hidden', }):await() @@ -327,6 +328,23 @@ M.switch_session_tab = Promise.async(function(tab_id) return runtime.active_session end) +---Switch to a logical panel tab by its displayed index. +---@param index integer|string +---@return Promise +M.switch_session_tab_by_index = Promise.async(function(index) + index = tonumber(index) + if not index or index < 1 or index % 1 ~= 0 then + return nil + end + + local runtime = session_tabs.list()[index] + if not runtime then + return nil + end + + return M.switch_session_tab(runtime.id):await() +end) + ---Switch to the next or previous logical panel tab. ---@param direction 1|-1 ---@return Promise diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 20291c4e..e912688d 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -10,6 +10,9 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeSessionDescription', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabActive', { link = 'TabLineSel', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabInactive', { link = 'TabLine', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabIndex', { link = 'Number', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabSeparator', { link = 'NonText', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabOverflow', { link = 'Special', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeMention', { link = 'Special', default = true }) vim.api.nvim_set_hl(0, 'OpencodeToolBorder', { fg = '#B0BEC5', nocombine = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeMessageRoleAssistant', { link = 'Special', default = true }) @@ -63,6 +66,9 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeSessionDescription', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabActive', { link = 'TabLineSel', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabInactive', { link = 'TabLine', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabIndex', { link = 'Number', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabSeparator', { link = 'NonText', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSessionTabOverflow', { link = 'Special', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeMention', { link = 'Special', default = true }) vim.api.nvim_set_hl(0, 'OpencodeToolBorder', { fg = '#3b4261', nocombine = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeRevertBorder', { bg = '#FF9E3B', default = true }) diff --git a/lua/opencode/ui/session_tab_strip.lua b/lua/opencode/ui/session_tab_strip.lua index fc9c14a5..cc3e3ba6 100644 --- a/lua/opencode/ui/session_tab_strip.lua +++ b/lua/opencode/ui/session_tab_strip.lua @@ -7,11 +7,24 @@ local M = {} local namespace = vim.api.nvim_create_namespace('opencode_session_tab_strip') local ranges_by_buffer = {} local subscribed = false +local minimum_tab_width = 12 local function display_width(text) return vim.fn.strdisplaywidth(text) end +---@param title string +---@return boolean +local function is_generated_title(title) + local timestamp = title:match('^New session %- (.+)$') + if not timestamp then + return false + end + + return timestamp:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d%.%d+Z$') ~= nil + or timestamp:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%dZ$') ~= nil +end + ---@param text string ---@param max_width integer ---@return string @@ -43,6 +56,9 @@ local function tab_title(tab) if type(title) ~= 'string' or vim.trim(title) == '' then return 'New session' end + if is_generated_title(title) then + return 'New session' + end return title end @@ -54,32 +70,93 @@ local function build_horizontal_content(tabs, width) return '', {}, {} end - local separator = ' ' + local separator = ' │ ' local separator_width = display_width(separator) - local segment_width = math.max(3, math.floor((width - separator_width * math.max(0, #tabs - 1)) / #tabs)) local active_id = session_tabs.active_id() + + local function fit_layout(visible_count) + local overflow_count = #tabs - visible_count + local marker = overflow_count > 0 and ('+' .. overflow_count) or '' + local separator_count = visible_count - 1 + (overflow_count > 0 and 1 or 0) + local segment_width = math.max( + minimum_tab_width, + math.floor((width - separator_width * separator_count - display_width(marker)) / visible_count) + ) + local total_width = segment_width * visible_count + separator_width * separator_count + display_width(marker) + return segment_width, total_width, overflow_count, marker + end + + local visible_count = #tabs + local segment_width, total_width, overflow_count, marker = fit_layout(visible_count) + if total_width > width then + local layout_found = false + for candidate = #tabs - 1, 1, -1 do + local candidate_width, candidate_total, candidate_overflow, candidate_marker = fit_layout(candidate) + if candidate_total <= width then + visible_count = candidate + segment_width = candidate_width + total_width = candidate_total + overflow_count = candidate_overflow + marker = candidate_marker + layout_found = true + break + end + end + if not layout_found then + visible_count = 1 + segment_width, total_width, overflow_count, marker = fit_layout(visible_count) + end + end + + local active_index + for index, tab in ipairs(tabs) do + if tab.id == active_id then + active_index = index + break + end + end + + local visible_tabs = {} + local first_count = visible_count + if active_index and active_index > visible_count then + first_count = visible_count - 1 + end + for index = 1, first_count do + visible_tabs[#visible_tabs + 1] = { index = index, tab = tabs[index] } + end + if active_index and active_index > visible_count then + visible_tabs[#visible_tabs + 1] = { index = active_index, tab = tabs[active_index] } + end + local parts = {} local ranges = {} local highlights = {} local byte_col = 0 local display_col = 0 - for index, tab in ipairs(tabs) do - if index > 1 then + for visible_index, entry in ipairs(visible_tabs) do + local index = entry.index + local tab = entry.tab + if visible_index > 1 then + local separator_start = byte_col parts[#parts + 1] = separator byte_col = byte_col + #separator display_col = display_col + separator_width + highlights[#highlights + 1] = { + group = 'OpencodeSessionTabSeparator', + start_col = separator_start, + end_col = byte_col, + } end - local marker = tab.id == active_id and '> ' or ' ' - local prefix = marker .. '[' .. index .. ' ' - local suffix = ']' - local title_width = segment_width - display_width(prefix) - display_width(suffix) + local index_text = tostring(index) + local prefix = index_text .. ' ' + local title_width = segment_width - display_width(prefix) local label if title_width > 0 then - label = prefix .. truncate(tab_title(tab), title_width) .. suffix + label = prefix .. truncate(tab_title(tab), title_width) else - label = marker .. '[' .. index .. ']' + label = index_text if display_width(label) > segment_width then label = truncate(tostring(index), segment_width) end @@ -103,6 +180,41 @@ local function build_horizontal_content(tabs, width) start_col = start_byte, end_col = byte_col, } + highlights[#highlights + 1] = { + group = 'OpencodeSessionTabIndex', + start_col = start_byte, + end_col = start_byte + #index_text, + hl_mode = 'combine', + } + end + + if overflow_count > 0 then + local separator_start = byte_col + parts[#parts + 1] = separator + byte_col = byte_col + #separator + display_col = display_col + separator_width + highlights[#highlights + 1] = { + group = 'OpencodeSessionTabSeparator', + start_col = separator_start, + end_col = byte_col, + } + + local marker_start = byte_col + parts[#parts + 1] = marker + byte_col = byte_col + #marker + display_col = display_col + display_width(marker) + ranges[#ranges + 1] = { + open_picker = true, + start_byte = marker_start, + end_byte = byte_col, + start_display = display_col - display_width(marker), + end_display = display_col, + } + highlights[#highlights + 1] = { + group = 'OpencodeSessionTabOverflow', + start_col = marker_start, + end_col = byte_col, + } end return table.concat(parts), ranges, highlights @@ -156,10 +268,10 @@ end ---@param buffer integer ---@param display_column integer ---@return string|nil -local function tab_id_at_display_column(buffer, display_column) +local function range_at_display_column(buffer, display_column) for _, range in ipairs(ranges_by_buffer[buffer] or {}) do if display_column >= range.start_display and display_column < range.end_display then - return range.tab_id + return range end end return nil @@ -167,11 +279,11 @@ end ---@param buffer integer ---@param byte_column integer ----@return string|nil -local function tab_id_at_byte_column(buffer, byte_column) +---@return table|nil +local function range_at_byte_column(buffer, byte_column) for _, range in ipairs(ranges_by_buffer[buffer] or {}) do if byte_column >= range.start_byte and byte_column < range.end_byte then - return range.tab_id + return range end end return nil @@ -185,16 +297,28 @@ local function select_tab(tab_id) require('opencode.services.session_runtime').switch_session_tab(tab_id) end +---@param range table|nil +local function select_range(range) + if not range then + return + end + if range.open_picker then + require('opencode.ui.session_tab_picker').select() + return + end + select_tab(range.tab_id) +end + local function click_tab() local buffer = vim.api.nvim_get_current_buf() local mouse = vim.fn.getmousepos() - select_tab(tab_id_at_display_column(buffer, math.max(0, mouse.column - 1))) + select_range(range_at_display_column(buffer, math.max(0, mouse.column - 1))) end local function select_tab_under_cursor() local buffer = vim.api.nvim_get_current_buf() local cursor = vim.api.nvim_win_get_cursor(0) - select_tab(tab_id_at_byte_column(buffer, cursor[2])) + select_range(range_at_byte_column(buffer, cursor[2])) end ---@param buffer integer @@ -223,6 +347,7 @@ function M.render(windows) vim.api.nvim_buf_set_extmark(buffer, namespace, 0, highlight.start_col, { end_col = highlight.end_col, hl_group = highlight.group, + hl_mode = highlight.hl_mode, }) end vim.api.nvim_set_option_value('modifiable', false, { buf = buffer }) diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index b8ad0d48..e352a830 100644 --- a/tests/unit/commands_handlers_spec.lua +++ b/tests/unit/commands_handlers_spec.lua @@ -602,8 +602,8 @@ describe('opencode.commands.handlers', function() session_handler.actions.prev_session_tab = function() called_with.previous = true end - session_handler.actions.select_session_tab = function() - called_with.select = true + session_handler.actions.select_session_tab = function(index) + called_with.select = index end session_handler.actions.close_session_tab = function() called_with.close = true @@ -612,7 +612,7 @@ describe('opencode.commands.handlers', function() session_handler.command_defs.tab.execute({ 'next' }) session_handler.command_defs.tab.execute({ 'new', 'named', 'tab' }) session_handler.command_defs.tab.execute({ 'previous' }) - session_handler.command_defs.tab.execute({ 'select' }) + session_handler.command_defs.tab.execute({ 'select', '2' }) session_handler.command_defs.tab.execute({ 'close' }) session_handler.actions.next_session_tab = original_actions.next @@ -624,7 +624,7 @@ describe('opencode.commands.handlers', function() assert.is_true(called_with.next) assert.equal('named tab', called_with.new) assert.is_true(called_with.previous) - assert.is_true(called_with.select) + assert.equal('2', called_with.select) assert.is_true(called_with.close) end) diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 8da868b5..b458edf8 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -52,6 +52,19 @@ describe('opencode.config', function() assert.equal('jump_to_target_at_cursor', output_keymap['gd'][1]) end) + it('provides direct keymaps for the first nine session tabs', function() + for index = 1, 9 do + local mapping = config.defaults.keymap.editor['o' .. index] + assert.same('select_session_tab', mapping[1]) + assert.same({ index }, mapping[2]) + end + end) + + it('maps panel-tab close separately from closing the Opencode window', function() + assert.equal('close', config.defaults.keymap.editor['oq'][1]) + assert.equal('close_session_tab', config.defaults.keymap.editor['oQ'][1]) + end) + describe('update_keymap_prefix', function() local function test_prefix_update(opts) config.values.keymap = vim.deepcopy(opts.given) diff --git a/tests/unit/session_tab_strip_spec.lua b/tests/unit/session_tab_strip_spec.lua index 5f45b8f1..b9f3ea4b 100644 --- a/tests/unit/session_tab_strip_spec.lua +++ b/tests/unit/session_tab_strip_spec.lua @@ -3,6 +3,7 @@ local store = require('opencode.state.store') local session_tabs = require('opencode.state.session_tabs') local session_tab_strip = require('opencode.ui.session_tab_strip') local config = require('opencode.config') +local stub = require('luassert.stub') describe('opencode session tab strip', function() local original_state @@ -34,12 +35,12 @@ describe('opencode session tab strip', function() end end) - it('renders truncated, selectable tabs with an active marker', function() + it('renders truncated, selectable tabs with styled tokens', function() local first = session_tabs.ensure_current() first.active_session = { id = 'session-one', title = 'A very long first session title' } state.session.set_active(first.active_session) - local second = session_tabs.create({ id = 'session-two', title = 'Second' }) + local second = session_tabs.create({ id = 'session-two', title = 'New session - 2026-02-05T22:26:08.579Z' }) session_tabs.activate(second) local output_buf = vim.api.nvim_create_buf(false, true) @@ -62,8 +63,11 @@ describe('opencode session tab strip', function() session_tab_strip.setup(windows) local line = vim.api.nvim_buf_get_lines(windows.tab_strip_buf, 0, 1, false)[1] - assert.matches('Second', line) - assert.matches('> %[%s*2', line) + assert.matches('2%s+New session', line) + assert.is_nil(line:find('2026')) + assert.is_nil(line:find('>')) + assert.is_nil(line:find('%[')) + assert.is_nil(line:find('%]')) assert.is_true(vim.fn.strdisplaywidth(line) <= vim.api.nvim_win_get_width(windows.tab_strip_win)) local marks = vim.api.nvim_buf_get_extmarks(windows.tab_strip_buf, -1, 0, -1, { details = true }) @@ -75,6 +79,60 @@ describe('opencode session tab strip', function() end assert.is_true(groups.OpencodeSessionTabActive) assert.is_true(groups.OpencodeSessionTabInactive) + assert.is_true(groups.OpencodeSessionTabIndex) + assert.is_true(groups.OpencodeSessionTabSeparator) + end) + + it('shows a clickable overflow marker when tabs do not fit', function() + session_tabs.ensure_current().active_session = { id = 'session-one', title = 'One' } + local last + for index = 2, 5 do + last = session_tabs.create({ id = 'session-' .. index, title = 'Session ' .. index }) + end + session_tabs.activate(last) + + local output_buf = vim.api.nvim_create_buf(false, true) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 50, + height = 10, + row = 1, + col = 1, + }) + windows = { + output_buf = output_buf, + output_win = output_win, + tab_strip_buf = session_tab_strip.create_buf(), + position = 'float', + } + + session_tab_strip.create_window(windows) + state.ui.set_windows(windows) + session_tab_strip.setup(windows) + + local line = vim.api.nvim_buf_get_lines(windows.tab_strip_buf, 0, 1, false)[1] + assert.matches('%+2', line) + assert.matches('5 Session 5', line) + assert.is_true(vim.fn.strdisplaywidth(line) <= vim.api.nvim_win_get_width(windows.tab_strip_win)) + + local marks = vim.api.nvim_buf_get_extmarks(windows.tab_strip_buf, -1, 0, -1, { details = true }) + local groups = {} + for _, mark in ipairs(marks) do + if mark[4] and mark[4].hl_group then + groups[mark[4].hl_group] = true + end + end + assert.is_true(groups.OpencodeSessionTabOverflow) + + local picker = require('opencode.ui.session_tab_picker') + local picker_stub = stub(picker, 'select') + local marker_column = assert(line:find('%+2')) + vim.api.nvim_set_current_win(windows.tab_strip_win) + vim.api.nvim_win_set_cursor(windows.tab_strip_win, { 1, marker_column - 1 }) + vim.api.nvim_feedkeys(vim.keycode(''), 'xt', false) + vim.wait(20) + assert.stub(picker_stub).was_called() + picker_stub:revert() end) it('hides the tab strip for one tab and restores it for multiple tabs', function() diff --git a/tests/unit/session_tabs_spec.lua b/tests/unit/session_tabs_spec.lua index c3bc5e8e..20b298c1 100644 --- a/tests/unit/session_tabs_spec.lua +++ b/tests/unit/session_tabs_spec.lua @@ -70,6 +70,19 @@ describe('opencode session panel tabs', function() assert.equals('session-two', state.active_session.id) end) + it('switches to a panel tab by displayed index', function() + local session_runtime = require('opencode.services.session_runtime') + local first = session_tabs.ensure_current() + local second = session_tabs.create({ id = 'session-two' }) + local switch_stub = stub(session_runtime, 'switch_session_tab').returns(Promise.new():resolve(nil)) + + session_runtime.switch_session_tab_by_index(2):await() + + assert.stub(switch_stub).was_called_with(second.id) + assert.equals('tab-1', first.id) + switch_stub:revert() + end) + it('mounts each tab with its own output and input buffers', function() local session_runtime = require('opencode.services.session_runtime') local server_job = require('opencode.server_job') @@ -129,10 +142,12 @@ describe('opencode session panel tabs', function() session_runtime.switch_session_tab(first_tab.id):await() assert.equals(first_output, state.windows.output_buf) assert.same({ 'first input' }, vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false)) + assert.equals(state.windows.output_win, vim.api.nvim_get_current_win()) session_runtime.switch_session_tab(second_tab.id):await() assert.equals(second_output, state.windows.output_buf) assert.same({ 'second input' }, vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false)) + assert.equals(state.windows.input_win, vim.api.nvim_get_current_win()) if state.windows then ui.teardown_visible_windows(state.windows) From c4d77af37d81544c6a335c51f750e991c913d50d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 9 Sep 2026 12:46:28 -0400 Subject: [PATCH 04/12] feat: add option to open session actions in new panel tab --- README.md | 7 ++++-- lua/opencode/commands/handlers/session.lua | 12 ++++++++-- lua/opencode/config.lua | 3 +++ lua/opencode/services/session_runtime.lua | 10 +++++++++ lua/opencode/types.lua | 4 ++++ lua/opencode/ui/base_picker.lua | 26 ++++++++++------------ lua/opencode/ui/formatter/tools/task.lua | 2 +- lua/opencode/ui/formatter/utils.lua | 10 +++++++++ lua/opencode/ui/permission_window.lua | 4 ++-- lua/opencode/ui/render_state.lua | 8 ++++--- lua/opencode/ui/session_picker.lua | 8 +++---- tests/unit/commands_handlers_spec.lua | 18 +++++++++++++++ tests/unit/config_spec.lua | 6 +++++ tests/unit/formatter_spec.lua | 23 +++++++++++++++++++ 14 files changed, 113 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 6e1ff554..8a70a533 100644 --- a/README.md +++ b/README.md @@ -239,8 +239,11 @@ require('opencode').setup({ use_vim_ui_select = false, -- If true, render questions/prompts with vim.ui.select instead of showing them inline in the output buffer. inline_other_input = true, -- If true, show an inline floating input for "Other" instead of vim.ui.input. }, - output = { - filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output') + output = { + filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output') + actions = { + open_in_new_tab = false, -- Open inline child-session and fork actions in a new panel tab + }, compact_assistant_headers = false, -- 'full' (default), 'minimal' (compact if same mode), or 'hidden' (no headers for assistant) tools = { show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true) diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index e1f4bec4..21eec287 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -255,6 +255,9 @@ function M.actions.navigate_session_tree(direction, interaction, wrap, empty_pol end return end + if interaction == 'tab' then + return session_runtime.open_session_in_tab_by_id(direction) + end if interaction == 'picker' then return session_runtime.select_session(direction, 'project') end @@ -636,7 +639,8 @@ function M.actions.timeline() end ---@param message_id? string -function M.actions.fork_session(message_id) +---@param open_in_new_tab? boolean|string +function M.actions.fork_session(message_id, open_in_new_tab) return with_active_session('No active session to fork', function(state_obj) local target = message_id and find_message_in_state(state_obj, message_id) or find_last_user_message(state_obj) if not target then @@ -657,7 +661,11 @@ function M.actions.fork_session(message_id) vim.schedule(function() if response and response.id then vim.notify('Session forked successfully. New session ID: ' .. response.id, vim.log.levels.INFO) - session_runtime.switch_session(response.id) + if open_in_new_tab == true or open_in_new_tab == 'tab' then + session_runtime.open_session_in_tab(response) + else + session_runtime.switch_session(response.id) + end else vim.notify('Session forked but no new session ID received', vim.log.levels.WARN) end diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 32d5dc9b..b0bc5739 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -192,6 +192,9 @@ M.defaults = { filetype = 'opencode_output', time_format = nil, compact_assistant_headers = false, + actions = { + open_in_new_tab = false, + }, rendering = { markdown_debounce_ms = 250, on_data_rendered = nil, diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 18f63b46..1e60fee3 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -285,6 +285,16 @@ M.open_session_in_tab = Promise.async(function(selected_session) return selected_session end) +---@param session_id string +---@return Promise +M.open_session_in_tab_by_id = Promise.async(function(session_id) + local selected_session = session.get_by_id(session_id):await() + if not selected_session then + return nil + end + return M.open_session_in_tab(selected_session):await() +end) + ---Open a new session in a logical tab inside the Opencode panel. ---@param title? string ---@return Promise diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 48f60881..1e442878 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -294,6 +294,7 @@ ---@class OpencodeUIOutputConfig ---@field time_format string|nil # Custom os.date format for timestamps, e.g. '%m/%d %H:%M'. Uses fixed default when nil. +---@field actions OpencodeUIOutputActionsConfig ---@field tools OpencodeUIOutputToolsConfig ---@field rendering OpencodeUIOutputRenderingConfig ---@field max_messages integer|nil @@ -301,6 +302,9 @@ ---@field filetype string ---@field compact_assistant_headers boolean | 'minimal' | 'hidden' | 'full' +---@class OpencodeUIOutputActionsConfig +---@field open_in_new_tab boolean # Open inline session actions in a new panel tab + ---@class OpencodeUIPickerConfig ---@field snacks_layout? snacks.picker.layout.Config --- TODO: add more picker-specific presets diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 67cb8402..07fe5193 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -131,11 +131,11 @@ local function build_title(base_title, actions, support_multi) local legend = {} for _, action in pairs(actions) do if action.key and action.key[1] then - local label = action.label .. (action.multi_selection and support_multi ~= false and ' (multi)' or '') + local label = action.label .. (action.multi_selection and support_multi ~= false and '*' or '') table.insert(legend, action.key[1] .. ' ' .. label) end end - return base_title .. (#legend > 0 and ' | ' .. table.concat(legend, ' | ') or '') + return base_title .. (#legend > 0 and '' .. table.concat(legend, '') or '') end ---Telescope UI implementation @@ -228,8 +228,8 @@ local function telescope_ui(opts) end end)(), layout_config = opts.width and { - width = opts.width + 7, -- extra space for telescope UI - } or nil, + width = opts.width + 7, -- extra space for telescope UI + } or nil, attach_mappings = function(prompt_bufnr, map) opts.close = function() selection_made = true @@ -374,8 +374,8 @@ local function fzf_ui(opts) 'start:+transform:' .. require('fzf-lua.shell').stringify_data(width_callback, opts) )) or nil, winopts = opts.width and { - width = opts.width + 8, -- extra space for fzf UI - } or nil, + width = opts.width + 8, -- extra space for fzf UI + } or nil, fzf_opts = { ['--prompt'] = opts.title .. ' > ', ['--multi'] = has_multi_action and true or nil, @@ -583,13 +583,11 @@ local function mini_pick_ui(opts) local selection_made = false mini_pick.start({ - window = opts.width - and { - config = { - width = opts.width + 2, -- extra space for mini.pick UI - }, - } - or nil, + window = opts.width and { + config = { + width = opts.width + 2, -- extra space for mini.pick UI + }, + } or nil, source = { items = items, name = opts.title, @@ -767,7 +765,7 @@ local function select_picker_ui(opts) format_item = function(item) return opts.format_fn(item, opts.width):to_string() end, - prompt = opts.title --[[@as string]] + prompt = opts.title --[[@as string]], }, opts.callback) end diff --git a/lua/opencode/ui/formatter/tools/task.lua b/lua/opencode/ui/formatter/tools/task.lua index b639b2ac..0a7e66fa 100644 --- a/lua/opencode/ui/formatter/tools/task.lua +++ b/lua/opencode/ui/formatter/tools/task.lua @@ -88,7 +88,7 @@ function M.format(output, part, context) output:add_action({ text = '[S] Open this Session', type = 'navigate_session_tree', - args = { metadata.sessionId }, + args = utils.get_session_action_args(metadata.sessionId), key = 'S', display_line = start_line, range = { from = start_line + 1, to = end_line + 1 }, diff --git a/lua/opencode/ui/formatter/utils.lua b/lua/opencode/ui/formatter/utils.lua index 7c858132..81745869 100644 --- a/lua/opencode/ui/formatter/utils.lua +++ b/lua/opencode/ui/formatter/utils.lua @@ -15,6 +15,16 @@ function M.get_duration_text(part) return util.format_duration_seconds(time.start, time['end']) end +---@param session_id string +---@return string[] +function M.get_session_action_args(session_id) + local actions = config.ui and config.ui.output and config.ui.output.actions + if actions and actions.open_in_new_tab then + return { session_id, 'tab' } + end + return { session_id } +end + ---@param icon string Icon text (result of `icons.get(key)`) or empty string ---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.) ---@param value string Value associated with the action (e.g., filename, command) diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index 1670b051..bbde2938 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -1,6 +1,7 @@ local state = require('opencode.state') local Dialog = require('opencode.ui.dialog') local session_scope = require('opencode.ui.session_scope') +local formatter_utils = require('opencode.ui.formatter.utils') local M = {} @@ -251,7 +252,6 @@ function M.format_display(output) end local icons = require('opencode.ui.icons') - local formatter_utils = require('opencode.ui.formatter.utils') local dialog_start_line = output:get_line_count() local progress = '' @@ -328,7 +328,7 @@ function M.format_display(output) output:add_action({ text = '[S] Open this Session', type = 'navigate_session_tree', - args = { child_session_id }, + args = formatter_utils.get_session_action_args(child_session_id), key = 'S', display_line = dialog_start_line, range = { from = dialog_start_line, to = math.max(dialog_start_line, output:get_line_count() - 1) }, diff --git a/lua/opencode/ui/render_state.lua b/lua/opencode/ui/render_state.lua index 67be73f6..b4dbf55c 100644 --- a/lua/opencode/ui/render_state.lua +++ b/lua/opencode/ui/render_state.lua @@ -13,6 +13,8 @@ ---@field targets RenderedTarget[] Targets associated with this part ---@field has_extmarks boolean? Whether the part currently has extmarks applied +local formatter_utils = require('opencode.ui.formatter.utils') + ---@class RenderState ---@field _messages table Message ID -> rendered message ---@field _parts table Part ID -> rendered part @@ -546,11 +548,11 @@ function RenderState:_refresh_message_actions(message_id) end local id = message_data.message.info.id - local function action(text, action_type, key) + local function action(text, action_type, key, args) return { text = text, type = action_type, - args = { id }, + args = args or { id }, key = key, display_line = line_end, range = { from = message_data.line_start, to = line_end }, @@ -560,7 +562,7 @@ function RenderState:_refresh_message_actions(message_id) message_data.actions = { action('[R]evert', 'undo', 'R'), action('[C]opy', 'copy_message', 'C'), - action('[F]ork', 'fork_session', 'F'), + action('[F]ork', 'fork_session', 'F', formatter_utils.get_session_action_args(id)), } end diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 75ce8576..b1278936 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -1,6 +1,7 @@ local M = {} local config = require('opencode.config') local base_picker = require('opencode.ui.base_picker') +local api = require('opencode.api') local util = require('opencode.util') local Promise = require('opencode.promise') @@ -262,7 +263,7 @@ function M.pick(sessions, callback, opts) }, delete = { key = config.keymap.session_picker.delete_session, - label = 'delete', + label = 'del', multi_selection = true, fn = Promise.async(function(selected, opts) local state = require('opencode.state') @@ -340,7 +341,7 @@ function M.pick(sessions, callback, opts) }, open_in_tab = { key = config.keymap.session_picker.open_in_tab, - label = 'open in tab', + label = 'tab', fn = Promise.async(function(selected, opts) if opts.close then opts.close() @@ -365,7 +366,7 @@ function M.pick(sessions, callback, opts) }, toggle = { key = config.keymap.session_picker.toggle_scope, - label = 'toggle scope', + label = 'scope', fn = Promise.async(function(_, _) local session_runtime = require('opencode.services.session_runtime') local new_scope = (opts.scope == 'global') and 'project' or 'global' @@ -439,7 +440,6 @@ end ---@param cb fun(session: Session|nil) ---@param opts? { scope?: 'project' | 'global' } function M.select(sessions, cb, opts) - local util = require('opencode.util') local picker = require('opencode.ui.picker') local success = M.pick(sessions, cb, opts) diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index e352a830..7c8b4920 100644 --- a/tests/unit/commands_handlers_spec.lua +++ b/tests/unit/commands_handlers_spec.lua @@ -645,6 +645,24 @@ describe('opencode.commands.handlers', function() assert.equal('noop', called_with.empty_policy) end) + it('navigate_session_tree opens a session in a tab when requested', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local state = require('opencode.state') + state.session.set_active({ id = 'parent-session' }) + + local opened_id + local original = session_runtime.open_session_in_tab_by_id + session_runtime.open_session_in_tab_by_id = function(session_id) + opened_id = session_id + end + + session_handler.actions.navigate_session_tree('child-session', 'tab') + + session_runtime.open_session_in_tab_by_id = original + assert.equal('child-session', opened_id) + end) + describe('copy_message', function() local state local active_session diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index b458edf8..51acbc35 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -30,6 +30,12 @@ describe('opencode.config', function() assert.is_true(config.values.ui.hide_single_tab) end) + it('supports opening inline output session actions in new tabs', function() + config.setup({ ui = { output = { actions = { open_in_new_tab = true } } } }) + + assert.is_true(config.values.ui.output.actions.open_in_new_tab) + end) + it('merges user options with defaults', function() local custom_callback = function() return 'custom' diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index ccb24fdc..5a32184f 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -1022,6 +1022,29 @@ describe('formatter', function() assert.is_truthy(table.concat(output.lines, '\n'):find('read', 1, true)) end) + it('marks task child-session actions to open in a tab when configured', function() + local original = config.values.ui.output.actions.open_in_new_tab + config.values.ui.output.actions.open_in_new_tab = true + + local output = formatter.format_part({ + id = 'prt_task_tab', + type = 'tool', + tool = 'task', + state = { + status = 'completed', + input = { description = 'inspect changes' }, + metadata = { sessionId = 'ses_child_tab' }, + }, + }, { + info = { id = 'msg_task_tab', role = 'assistant', sessionID = 'ses_parent' }, + parts = {}, + }, true, { interactive = true }) + + config.values.ui.output.actions.open_in_new_tab = original + + assert.same({ 'ses_child_tab', 'tab' }, output.actions[1].args) + end) + describe('fold_exclude', function() local function make_bash_part() return { From b224ad941fee26dae7885341bd039034d93e26a2 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 9 Sep 2026 13:38:40 -0400 Subject: [PATCH 05/12] feat: notify and mark background prompts on session tabs --- README.md | 1 + lua/opencode/config.lua | 1 + lua/opencode/init.lua | 1 + lua/opencode/state/session.lua | 17 +++ lua/opencode/state/session_tabs.lua | 139 ++++++++++++++++++ lua/opencode/types.lua | 1 + lua/opencode/ui/base_picker.lua | 3 +- lua/opencode/ui/highlight.lua | 20 +++ lua/opencode/ui/icons.lua | 2 + lua/opencode/ui/permission_window.lua | 9 ++ lua/opencode/ui/question_window.lua | 5 + lua/opencode/ui/session_tab_notifications.lua | 136 +++++++++++++++++ lua/opencode/ui/session_tab_strip.lua | 41 +++++- tests/unit/session_tab_notifications_spec.lua | 80 ++++++++++ tests/unit/session_tab_strip_spec.lua | 47 ++++++ 15 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 lua/opencode/ui/session_tab_notifications.lua create mode 100644 tests/unit/session_tab_notifications_spec.lua diff --git a/README.md b/README.md index 8a70a533..327cd47f 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ require('opencode').setup({ display_context_size = true, -- Display context size in the footer display_cost = true, -- Display cost in the footer hide_single_tab = false, -- Hide the panel tab strip when only one session tab exists + notify_on_background_prompt = true, -- Notify when an unfocused session needs a question or permission response window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', -- Highlight group for the opencode window persist_state = true, -- Keep buffers when toggling/closing UI so window state restores quickly icons = { diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index b0bc5739..dd0f1fd7 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -179,6 +179,7 @@ M.defaults = { display_context_size = true, display_cost = true, hide_single_tab = true, + notify_on_background_prompt = true, window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', persist_state = true, icons = { diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 9f2cfe24..b4faa461 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -60,6 +60,7 @@ function M.setup(opts) require('opencode.ui.completion').setup() require('opencode.keymap').setup(config.keymap) require('opencode.event_manager').setup() + require('opencode.ui.session_tab_notifications').setup() require('opencode.context').setup() require('opencode.ui.context_bar').setup() end diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index a8fd7614..cf85e0c6 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -6,6 +6,16 @@ local M = {} ---@param session Session|nil function M.set_active(session) + local previous = store.get('active_session') + local previous_id = type(previous) == 'table' and previous.id or nil + local session_id = type(session) == 'table' and session.id or nil + if previous_id ~= session_id then + local runtime = session_tabs.current() + if runtime then + session_tabs.clear_pending_prompts(runtime.id) + end + end + local result = store.batch(function() store.set('restore_points', {}) store.set('last_sent_context', nil) @@ -17,6 +27,13 @@ function M.set_active(session) end function M.clear_active() + if store.get('active_session') then + local runtime = session_tabs.current() + if runtime then + session_tabs.clear_pending_prompts(runtime.id) + end + end + local result = store.batch(function() store.set('restore_points', {}) store.set('last_sent_context', nil) diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index 3d3f1d8a..e7e1fcd4 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -28,6 +28,8 @@ local store = require('opencode.state.store') ---@field messages OpencodeMessage[]|nil ---@field current_message OpencodeMessage|nil ---@field pending_permissions OpencodePermission[] +---@field pending_prompt_permissions OpencodePermission[] +---@field pending_questions OpencodeQuestionRequest[] ---@field cost number ---@field tokens_count number ---@field user_message_count table @@ -141,6 +143,8 @@ local function default_runtime(id) messages = nil, current_message = nil, pending_permissions = {}, + pending_prompt_permissions = {}, + pending_questions = {}, cost = 0, tokens_count = 0, user_message_count = {}, @@ -215,6 +219,139 @@ function M.get(id) return runtimes[id] end +---@param session_id string|nil +---@return OpencodeSessionTabRuntime|nil +function M.find_by_session_id(session_id) + if not session_id or session_id == '' then + return nil + end + + for _, runtime in ipairs(M.list()) do + if runtime.active_session and runtime.active_session.id == session_id then + return runtime + end + + local render_state = runtime.renderer_context and runtime.renderer_context.render_state + if render_state and render_state.get_task_part_by_child_session then + local ok, task_part = pcall(render_state.get_task_part_by_child_session, render_state, session_id) + if ok and task_part then + return runtime + end + end + end + + local current = M.current() + if current and current.active_session and current.active_session.id then + local render_state = require('opencode.ui.renderer.ctx').render_state + if render_state:get_task_part_by_child_session(session_id) then + return current + end + end +end + +---@param tab_id string +---@param permission OpencodePermission +function M.add_pending_permission(tab_id, permission) + local runtime = runtimes[tab_id] + if not runtime or not permission or not permission.id then + return + end + + for index, existing in ipairs(runtime.pending_prompt_permissions) do + if existing.id == permission.id then + runtime.pending_prompt_permissions[index] = permission + notify_change() + return + end + end + + table.insert(runtime.pending_prompt_permissions, permission) + notify_change() +end + +---@param tab_id string +---@param permission_id string +function M.remove_pending_permission(tab_id, permission_id) + local runtime = runtimes[tab_id] + if not runtime or not permission_id then + return + end + + for index, permission in ipairs(runtime.pending_prompt_permissions) do + if permission.id == permission_id then + table.remove(runtime.pending_prompt_permissions, index) + notify_change() + return + end + end +end + +---@param tab_id string +---@param question OpencodeQuestionRequest +function M.add_pending_question(tab_id, question) + local runtime = runtimes[tab_id] + if not runtime or not question or not question.id then + return + end + + for index, existing in ipairs(runtime.pending_questions) do + if existing.id == question.id then + runtime.pending_questions[index] = question + notify_change() + return + end + end + + table.insert(runtime.pending_questions, question) + notify_change() +end + +---@param tab_id string +---@param question_id string +function M.remove_pending_question(tab_id, question_id) + local runtime = runtimes[tab_id] + if not runtime or not question_id then + return + end + + for index, question in ipairs(runtime.pending_questions) do + if question.id == question_id then + table.remove(runtime.pending_questions, index) + notify_change() + return + end + end +end + +---@param tab_id string +function M.clear_pending_prompts(tab_id) + local runtime = runtimes[tab_id] + if not runtime then + return + end + + local active = M.active_id() == tab_id + local has_pending = #runtime.pending_permissions > 0 + or #runtime.pending_prompt_permissions > 0 + or #runtime.pending_questions > 0 + if active then + has_pending = has_pending or #(store.get('pending_permissions') or {}) > 0 + end + if not has_pending then + return + end + + runtime.pending_permissions = {} + runtime.pending_prompt_permissions = {} + runtime.pending_questions = {} + if active then + store.batch(function() + store.set('pending_permissions', {}) + end) + end + notify_change() +end + ---@return OpencodeSessionTabRuntime|nil function M.current() local runtime = runtimes[store.get('active_session_tab')] @@ -337,6 +474,8 @@ function M.create(session) runtime.messages = nil runtime.current_message = nil runtime.pending_permissions = {} + runtime.pending_prompt_permissions = {} + runtime.pending_questions = {} runtime.restore_points = {} runtime.last_sent_context = nil runtime.user_message_count = {} diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 1e442878..407d1c18 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -243,6 +243,7 @@ ---@field display_context_size boolean ---@field display_cost boolean ---@field hide_single_tab boolean +---@field notify_on_background_prompt boolean ---@field window_highlight string ---@field icons { preset: 'text'|'nerdfonts', overrides: table } ---@field loading_animation OpencodeLoadingAnimationConfig diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 07fe5193..75db15bd 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -63,6 +63,7 @@ local Promise = require('opencode.promise') ---@class BasePicker local M = {} local picker = require('opencode.ui.picker') +local icons = require('lua.opencode.ui.icons') ---@param bufnr integer? ---@return PickerPreviewTarget @@ -135,7 +136,7 @@ local function build_title(base_title, actions, support_multi) table.insert(legend, action.key[1] .. ' ' .. label) end end - return base_title .. (#legend > 0 and '' .. table.concat(legend, '') or '') + return base_title .. (#legend > 0 and icons.get('separator') .. table.concat(legend, icons.get('separator')) or '') end ---Telescope UI implementation diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index e912688d..a67458a8 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -13,6 +13,16 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeSessionTabIndex', { link = 'Number', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabSeparator', { link = 'NonText', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabOverflow', { link = 'Special', bold = true, default = true }) + vim.api.nvim_set_hl( + 0, + 'OpencodeSessionTabPendingPermission', + { fg = '#9A3412', bg = '#FED7AA', bold = true, default = true } + ) + vim.api.nvim_set_hl( + 0, + 'OpencodeSessionTabPendingQuestion', + { fg = '#1D4ED8', bg = '#DBEAFE', bold = true, default = true } + ) vim.api.nvim_set_hl(0, 'OpencodeMention', { link = 'Special', default = true }) vim.api.nvim_set_hl(0, 'OpencodeToolBorder', { fg = '#B0BEC5', nocombine = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeMessageRoleAssistant', { link = 'Special', default = true }) @@ -69,6 +79,16 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeSessionTabIndex', { link = 'Number', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabSeparator', { link = 'NonText', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSessionTabOverflow', { link = 'Special', bold = true, default = true }) + vim.api.nvim_set_hl( + 0, + 'OpencodeSessionTabPendingPermission', + { fg = '#FFD580', bg = '#5A3A00', bold = true, default = true } + ) + vim.api.nvim_set_hl( + 0, + 'OpencodeSessionTabPendingQuestion', + { fg = '#9CDCFE', bg = '#1E3A5F', bold = true, default = true } + ) vim.api.nvim_set_hl(0, 'OpencodeMention', { link = 'Special', default = true }) vim.api.nvim_set_hl(0, 'OpencodeToolBorder', { fg = '#3b4261', nocombine = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeRevertBorder', { bg = '#FF9E3B', default = true }) diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index e812894a..91ffc497 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -51,6 +51,7 @@ local presets = { running = ' ', checkbox_checked = ' ', checkbox_unchecked = ' ', + separator = '', }, text = { -- headers @@ -98,6 +99,7 @@ local presets = { running = '> ', checkbox_checked = '[*]', checkbox_unchecked = '[ ]', + separator = '|', }, } diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index bbde2938..e6176259 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -1,4 +1,5 @@ local state = require('opencode.state') +local session_tabs = require('opencode.state.session_tabs') local Dialog = require('opencode.ui.dialog') local session_scope = require('opencode.ui.session_scope') local formatter_utils = require('opencode.ui.formatter.utils') @@ -219,6 +220,10 @@ function M.remove_permission(permission_id) for i, permission in ipairs(M._permission_queue) do if permission.id == permission_id then + local runtime = session_tabs.find_by_session_id(permission.sessionID) + if runtime then + session_tabs.remove_pending_permission(runtime.id, permission_id) + end table.remove(M._permission_queue, i) break end @@ -532,6 +537,10 @@ function M.restore_pending_permissions(session_id) for _, permission in ipairs(permissions) do if permission and permission.id then if session_scope.belongs_to_session(permission, session_id) and not is_resolved_permission(permission) then + local runtime = session_tabs.find_by_session_id(session_id) + if runtime then + session_tabs.add_pending_permission(runtime.id, permission) + end -- Check if already queued (avoid duplicate) local already_queued = false for _, existing in ipairs(M._permission_queue) do diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index 8ecec070..5b12a382 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -5,6 +5,7 @@ local Promise = require('opencode.promise') local config = require('opencode.config') local session_scope = require('opencode.ui.session_scope') +local session_tabs = require('opencode.state.session_tabs') local M = {} @@ -268,6 +269,10 @@ function M.restore_pending_question(session_id) and session_scope.belongs_to_active_session(request) and not is_resolved_question_request(request) then + local runtime = session_tabs.find_by_session_id(session_id) + if runtime then + session_tabs.add_pending_question(runtime.id, request) + end if M.matches_active_question(request) then return end diff --git a/lua/opencode/ui/session_tab_notifications.lua b/lua/opencode/ui/session_tab_notifications.lua new file mode 100644 index 00000000..e8755e6d --- /dev/null +++ b/lua/opencode/ui/session_tab_notifications.lua @@ -0,0 +1,136 @@ +local state = require('opencode.state') +local session_tabs = require('opencode.state.session_tabs') +local config = require('opencode.config') + +local M = {} + +local subscribed_manager = nil +local notified = {} + +local function request_key(kind, request_id) + return kind .. ':' .. request_id +end + +local function session_title(runtime) + local title = runtime.active_session and runtime.active_session.title + if type(title) ~= 'string' or vim.trim(title) == '' then + return 'New session' + end + return title +end + +local function track(kind, request) + if not request or not request.id or not request.sessionID then + return + end + + local runtime = session_tabs.find_by_session_id(request.sessionID) + if not runtime then + return + end + + if kind == 'permission' then + session_tabs.add_pending_permission(runtime.id, request) + else + session_tabs.add_pending_question(runtime.id, request) + end + + if runtime.id == session_tabs.active_id() then + return + end + + if config.ui.notify_on_background_prompt == false then + return + end + + local key = request_key(kind, request.id) + if notified[key] then + return + end + notified[key] = true + + local label = kind == 'permission' and 'Permission required' or 'Question waiting' + local level = kind == 'permission' and vim.log.levels.WARN or vim.log.levels.INFO + vim.notify(label .. ' in session "' .. session_title(runtime) .. '"', level) +end + +local function clear(kind, request_id) + if not request_id then + return + end + + for _, runtime in ipairs(session_tabs.list()) do + if kind == 'permission' then + session_tabs.remove_pending_permission(runtime.id, request_id) + else + session_tabs.remove_pending_question(runtime.id, request_id) + end + end + notified[request_key(kind, request_id)] = nil +end + +---@param permission OpencodePermission +function M.track_permission(permission) + track('permission', permission) +end + +---@param question OpencodeQuestionRequest +function M.track_question(question) + track('question', question) +end + +---@param request_id string +function M.clear_permission(request_id) + clear('permission', request_id) +end + +---@param request_id string +function M.clear_question(request_id) + clear('question', request_id) +end + +local function on_permission_updated(permission) + M.track_permission(permission) +end + +local function on_question_asked(question) + M.track_question(question) +end + +local function on_permission_replied(properties) + M.clear_permission(properties and (properties.permissionID or properties.requestID)) +end + +local function on_question_replied(properties) + M.clear_question(properties and properties.requestID) +end + +function M.setup() + local manager = state.event_manager + if not manager or manager == subscribed_manager then + return + end + + if subscribed_manager then + subscribed_manager:unsubscribe('permission.updated', on_permission_updated) + subscribed_manager:unsubscribe('permission.asked', on_permission_updated) + subscribed_manager:unsubscribe('permission.replied', on_permission_replied) + subscribed_manager:unsubscribe('question.asked', on_question_asked) + subscribed_manager:unsubscribe('question.replied', on_question_replied) + subscribed_manager:unsubscribe('question.rejected', on_question_replied) + end + + manager:subscribe('permission.updated', on_permission_updated) + manager:subscribe('permission.asked', on_permission_updated) + manager:subscribe('permission.replied', on_permission_replied) + manager:subscribe('question.asked', on_question_asked) + manager:subscribe('question.replied', on_question_replied) + manager:subscribe('question.rejected', on_question_replied) + subscribed_manager = manager +end + +function M.reset() + notified = {} +end + +return M diff --git a/lua/opencode/ui/session_tab_strip.lua b/lua/opencode/ui/session_tab_strip.lua index cc3e3ba6..12a78bdf 100644 --- a/lua/opencode/ui/session_tab_strip.lua +++ b/lua/opencode/ui/session_tab_strip.lua @@ -62,6 +62,29 @@ local function tab_title(tab) return title end +---@param tab OpencodeSessionTabRuntime +---@return string marker, string|nil highlight +local function pending_marker(tab) + local permission_count = #(tab.pending_prompt_permissions or {}) + local question_count = #(tab.pending_questions or {}) + local marker_parts = {} + + if permission_count > 0 then + marker_parts[#marker_parts + 1] = '[!' .. (permission_count > 1 and permission_count or '') .. ']' + end + if question_count > 0 then + marker_parts[#marker_parts + 1] = '[?' .. (question_count > 1 and question_count or '') .. ']' + end + + if #marker_parts == 0 then + return '', nil + end + if permission_count > 0 then + return table.concat(marker_parts), 'OpencodeSessionTabPendingPermission' + end + return table.concat(marker_parts), 'OpencodeSessionTabPendingQuestion' +end + ---@param tabs OpencodeSessionTabRuntime[] ---@param width integer ---@return string line, table[] ranges, table[] highlights @@ -151,14 +174,16 @@ local function build_horizontal_content(tabs, width) local index_text = tostring(index) local prefix = index_text .. ' ' - local title_width = segment_width - display_width(prefix) + local marker, marker_highlight = pending_marker(tab) + local marker_prefix = marker ~= '' and marker .. ' ' or '' + local title_width = segment_width - display_width(prefix) - display_width(marker_prefix) local label if title_width > 0 then - label = prefix .. truncate(tab_title(tab), title_width) + label = prefix .. marker_prefix .. truncate(tab_title(tab), title_width) else - label = index_text + label = prefix .. marker if display_width(label) > segment_width then - label = truncate(tostring(index), segment_width) + label = truncate(label, segment_width) end end @@ -180,6 +205,14 @@ local function build_horizontal_content(tabs, width) start_col = start_byte, end_col = byte_col, } + if marker_highlight and marker ~= '' and label:find(marker, #prefix + 1, true) then + highlights[#highlights + 1] = { + group = marker_highlight, + start_col = start_byte + #prefix, + end_col = start_byte + #prefix + #marker, + hl_mode = 'combine', + } + end highlights[#highlights + 1] = { group = 'OpencodeSessionTabIndex', start_col = start_byte, diff --git a/tests/unit/session_tab_notifications_spec.lua b/tests/unit/session_tab_notifications_spec.lua new file mode 100644 index 00000000..a2e0c294 --- /dev/null +++ b/tests/unit/session_tab_notifications_spec.lua @@ -0,0 +1,80 @@ +local state = require('opencode.state') +local store = require('opencode.state.store') +local session_tabs = require('opencode.state.session_tabs') +local notifications = require('opencode.ui.session_tab_notifications') +local config = require('opencode.config') + +describe('opencode session tab notifications', function() + local original_state + local original_config + + before_each(function() + original_state = vim.deepcopy(store.state()) + original_config = vim.deepcopy(config.values) + session_tabs.reset() + notifications.reset() + end) + + after_each(function() + session_tabs.reset() + notifications.reset() + for key, value in pairs(original_state) do + store.set_raw(key, value) + end + config.values = original_config + end) + + it('tracks background prompts and notifies once per request', function() + local first = session_tabs.ensure_current() + state.session.set_active({ id = 'session-one', title = 'One' }) + local second = session_tabs.create({ id = 'session-two', title = 'Second' }) + local original_notify = vim.notify + local notify_calls = {} + vim.notify = function(message, level) + table.insert(notify_calls, { message = message, level = level }) + end + + local permission = { id = 'permission-one', sessionID = 'session-two' } + notifications.track_permission(permission) + notifications.track_permission(permission) + + assert.same({ permission }, second.pending_prompt_permissions) + assert.equals(1, #notify_calls) + assert.same({ message = 'Permission required in session "Second"', level = vim.log.levels.WARN }, notify_calls[1]) + + local question = { id = 'question-one', sessionID = 'session-two', questions = {} } + notifications.track_question(question) + assert.same({ question }, second.pending_questions) + assert.same({ message = 'Question waiting in session "Second"', level = vim.log.levels.INFO }, notify_calls[2]) + + notifications.clear_permission(permission.id) + notifications.clear_question(question.id) + assert.same({}, second.pending_prompt_permissions) + assert.same({}, second.pending_questions) + assert.equals('session-one', first.active_session.id) + + vim.notify = original_notify + end) + + it('keeps markers without notifying when background prompt notifications are disabled', function() + local first = session_tabs.ensure_current() + state.session.set_active({ id = 'session-one', title = 'One' }) + local second = session_tabs.create({ id = 'session-two', title = 'Second' }) + config.values.ui.notify_on_background_prompt = false + + local original_notify = vim.notify + local notify_count = 0 + vim.notify = function() + notify_count = notify_count + 1 + end + + local question = { id = 'question-one', sessionID = 'session-two', questions = {} } + notifications.track_question(question) + + assert.same({ question }, second.pending_questions) + assert.equals(0, notify_count) + assert.equals('session-one', first.active_session.id) + + vim.notify = original_notify + end) +end) diff --git a/tests/unit/session_tab_strip_spec.lua b/tests/unit/session_tab_strip_spec.lua index b9f3ea4b..9bd54097 100644 --- a/tests/unit/session_tab_strip_spec.lua +++ b/tests/unit/session_tab_strip_spec.lua @@ -135,6 +135,53 @@ describe('opencode session tab strip', function() picker_stub:revert() end) + it('marks tabs with pending permissions and questions', function() + local first = session_tabs.ensure_current() + first.active_session = { id = 'session-one', title = 'One' } + state.session.set_active(first.active_session) + session_tabs.add_pending_permission(first.id, { id = 'permission-one' }) + session_tabs.add_pending_question(first.id, { id = 'question-one' }) + + local second = session_tabs.create({ id = 'session-two', title = 'Two' }) + session_tabs.add_pending_permission(second.id, { id = 'permission-two' }) + session_tabs.add_pending_permission(second.id, { id = 'permission-three' }) + session_tabs.activate(second) + + local output_buf = vim.api.nvim_create_buf(false, true) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 40, + height = 10, + row = 1, + col = 1, + }) + windows = { + output_buf = output_buf, + output_win = output_win, + tab_strip_buf = session_tab_strip.create_buf(), + position = 'float', + } + + session_tab_strip.create_window(windows) + state.ui.set_windows(windows) + session_tab_strip.setup(windows) + + local line = vim.api.nvim_buf_get_lines(windows.tab_strip_buf, 0, 1, false)[1] + assert.is_true(line:find('1 [!][?] One', 1, true) ~= nil) + assert.is_true(line:find('2 [!2] Two', 1, true) ~= nil) + + local marks = vim.api.nvim_buf_get_extmarks(windows.tab_strip_buf, -1, 0, -1, { details = true }) + local groups = {} + for _, mark in ipairs(marks) do + if mark[4] and mark[4].hl_group then + groups[mark[4].hl_group] = true + end + end + assert.is_true(groups.OpencodeSessionTabPendingPermission) + assert.is_true(groups.OpencodeSessionTabActive) + assert.is_true(groups.OpencodeSessionTabInactive) + end) + it('hides the tab strip for one tab and restores it for multiple tabs', function() config.values.ui.hide_single_tab = true From 5aa290a94b339c62c8f02d89a8674976743dc232 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 9 Sep 2026 14:34:48 -0400 Subject: [PATCH 06/12] fix: isolate message sends and renderer state per session tab --- lua/opencode/services/messaging.lua | 68 +++++++++++++++++------ lua/opencode/state/session_tabs.lua | 55 +++++++++++++++++- lua/opencode/ui/event_scope.lua | 22 ++++++++ lua/opencode/ui/renderer.lua | 21 ++++++- tests/unit/event_scope_spec.lua | 17 ++++++ tests/unit/renderer_session_tabs_spec.lua | 47 ++++++++++++++++ tests/unit/services_messaging_spec.lua | 58 +++++++++++++++++++ tests/unit/session_tabs_spec.lua | 23 ++++++++ 8 files changed, 290 insertions(+), 21 deletions(-) diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index 7b29dfce..c1e29596 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -5,7 +5,6 @@ local config = require('opencode.config') local config_file = require('opencode.config_file') local Promise = require('opencode.promise') local log = require('opencode.log') -local agent_model = require('opencode.services.agent_model') local session_runtime = require('opencode.services.session_runtime') local session_tabs = require('opencode.state.session_tabs') @@ -15,11 +14,12 @@ local M = {} --- @param prompt string The message prompt to send. --- @param opts? SendMessageOpts M.send_message = Promise.async(function(prompt, opts) - if not state.active_session or not state.active_session.id then + local target_session = vim.deepcopy(state.active_session) + if not target_session or not target_session.id then return false end - if state.active_session.parentID and config.child_readonly then + if target_session.parentID and config.child_readonly then return false end @@ -31,27 +31,41 @@ M.send_message = Promise.async(function(prompt, opts) return end - opts = opts or {} + opts = vim.deepcopy(opts or {}) local tab_id = state.active_session_tab + local session_id = target_session.id + local api_client = state.api_client + local target_model = state.current_model + local target_mode = state.current_mode + local target_variant = state.current_variant - opts.context = vim.tbl_deep_extend('force', state.current_context_config or {}, opts.context or {}) + opts.context = vim.tbl_deep_extend('force', {}, state.current_context_config or {}, opts.context or {}) state.context.set_current_context_config(opts.context) context.load() - opts.model = opts.model or agent_model.initialize_current_model():await() + local parts_promise = context.format_message(prompt, opts.context) + local sent_context = context.snapshot() + session_tabs.set_context(sent_context) + + opts.model = opts.model or target_model + if not opts.model then + local opencode_config = config_file.get_opencode_config():await() + opts.model = opencode_config and opencode_config.model ~= '' and opencode_config.model or nil + end if opts.agent == nil then - opts.agent = state.current_mode or config.default_mode + opts.agent = target_mode or config.default_mode end - opts.variant = opts.variant or state.current_variant + opts.variant = opts.variant or target_variant local params = {} + local model_update = {} if opts.model then local provider, model = opts.model:match('^(.-)/(.+)$') params.model = { providerID = provider, modelID = model } - state.model.set_model(opts.model) + model_update.model = opts.model if opts.variant then params.variant = opts.variant - state.model.set_variant(opts.variant) + model_update.variant = opts.variant end end @@ -59,16 +73,38 @@ M.send_message = Promise.async(function(prompt, opts) params.agent = opts.agent local available_agents = config_file.get_opencode_agents():await() if vim.tbl_contains(available_agents, opts.agent) then - state.model.set_mode(opts.agent) + model_update.mode = opts.agent end end - params.parts = context.format_message(prompt, opts.context):await() + if tab_id then + session_tabs.update_model_state(tab_id, model_update) + else + if model_update.model then + state.model.set_model(model_update.model) + end + if model_update.mode then + state.model.set_mode(model_update.mode) + end + if model_update.variant then + state.model.set_variant(model_update.variant) + end + end + + params.parts = parts_promise:await() params.system = opts.system or config.default_system_prompt or nil - local session_id = state.active_session.id - local sent_context = vim.deepcopy(context.get_context()) - context.unload_attachments() + if tab_id and session_tabs.active_id() ~= tab_id then + local runtime = session_tabs.get(tab_id) + if runtime then + runtime.context_data = vim.deepcopy(sent_context) + runtime.context_data.mentioned_files = {} + runtime.context_data.selections = {} + end + else + context.unload_attachments() + session_tabs.set_context(context.snapshot()) + end local function update_sent_message_count(num) if tab_id then @@ -84,7 +120,7 @@ M.send_message = Promise.async(function(prompt, opts) update_sent_message_count(1) - state.api_client + api_client :create_message(session_id, params) :and_then(function(response) update_sent_message_count(-1) diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index e7e1fcd4..d7655df5 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -40,6 +40,7 @@ local store = require('opencode.state.store') ---@field _hidden_buffers OpencodeHiddenBuffers|nil ---@field context_data OpencodeContext|nil ---@field renderer_context table|nil Renderer caches associated with the preserved output buffer +---@field renderer_dirty boolean Cached renderer missed background session events ---@class OpencodeSessionTabStateMutations local M = {} @@ -155,6 +156,7 @@ local function default_runtime(id) _hidden_buffers = nil, context_data = nil, renderer_context = nil, + renderer_dirty = false, } end @@ -249,6 +251,14 @@ function M.find_by_session_id(session_id) end end +---@param session_id string|nil +function M.mark_renderer_dirty(session_id) + local runtime = M.find_by_session_id(session_id) + if runtime and runtime.id ~= M.active_id() then + runtime.renderer_dirty = true + end +end + ---@param tab_id string ---@param permission OpencodePermission function M.add_pending_permission(tab_id, permission) @@ -402,9 +412,10 @@ function M.update_user_message_count(tab_id, session_id, delta) return end - runtime.user_message_count = runtime.user_message_count or {} - local next_count = (runtime.user_message_count[session_id] or 0) + delta - runtime.user_message_count[session_id] = math.max(0, next_count) + local counts = vim.deepcopy(runtime.user_message_count or {}) + local next_count = (counts[session_id] or 0) + delta + counts[session_id] = math.max(0, next_count) + runtime.user_message_count = counts if store.get('active_session_tab') == tab_id then store.set('user_message_count', runtime.user_message_count) @@ -425,6 +436,44 @@ function M.set_last_sent_context(tab_id, context_data) end end +---@class OpencodeSessionTabModelUpdate +---@field model? string +---@field mode? string +---@field variant? string + +---@param tab_id string +---@param update OpencodeSessionTabModelUpdate +function M.update_model_state(tab_id, update) + local runtime = runtimes[tab_id] + if not runtime then + return + end + + if update.model ~= nil then + runtime.current_model = update.model + end + if update.mode ~= nil then + runtime.current_mode = update.mode + end + if update.variant ~= nil then + runtime.current_variant = update.variant + end + + if store.get('active_session_tab') == tab_id then + store.batch(function() + if update.model ~= nil then + store.set('current_model', update.model) + end + if update.mode ~= nil then + store.set('current_mode', update.mode) + end + if update.variant ~= nil then + store.set('current_variant', update.variant) + end + end) + end +end + ---@return OpencodeSessionTabRuntime function M.ensure_current() local id = store.get('active_session_tab') diff --git a/lua/opencode/ui/event_scope.lua b/lua/opencode/ui/event_scope.lua index e3c6c8d6..53670af5 100644 --- a/lua/opencode/ui/event_scope.lua +++ b/lua/opencode/ui/event_scope.lua @@ -117,6 +117,26 @@ end local wrappers = {} +local function event_session_id(event_name, properties) + if event_name == 'session.updated' then + return properties and properties.info and properties.info.id + end + if event_name == 'message.updated' then + return properties and properties.info and properties.info.sessionID + end + if event_name == 'message.part.updated' then + return properties and properties.part and properties.part.sessionID + end + if + event_name == 'session.compacted' + or event_name == 'session.error' + or event_name == 'message.removed' + or event_name == 'message.part.removed' + then + return properties and properties.sessionID + end +end + ---@param event_name string ---@param callback function ---@return function @@ -126,6 +146,8 @@ function M.scoped_callback(event_name, callback) wrappers[event_name][callback] = function(properties) if M.should_handle(event_name, properties) then callback(properties) + else + require('opencode.state.session_tabs').mark_renderer_dirty(event_session_id(event_name, properties)) end end end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index c64c390b..49f50ba7 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -566,7 +566,16 @@ function M.render_full_session() if not output_window.mounted() or not state.api_client then return Promise.new():resolve(nil) end + local target_tab_id = state.active_session_tab + local target_session_id = state.active_session and state.active_session.id return fetch_session():and_then(function(session_data) + if + state.active_session_tab ~= target_tab_id + or not state.active_session + or state.active_session.id ~= target_session_id + then + return nil + end M._render_full_session_data(session_data, { restore_model_from_messages = true, }) @@ -669,6 +678,7 @@ function M.on_session_tab_changed(_, new, old) end save_tab_context(old) rendered_session_tab = new + local runtime = session_tabs.get(new) local restored = restore_tab_context(new) local prompts = ctx.prompt_controllers if prompts.question then @@ -679,7 +689,7 @@ function M.on_session_tab_changed(_, new, old) end require('opencode.ui.renderer.events').render_permissions_display() - if restored then + if restored and not (runtime and runtime.renderer_dirty) then if ctx:has_pending_work() and output_window.mounted() then flush.schedule() end @@ -695,7 +705,14 @@ function M.on_session_tab_changed(_, new, old) end if state.active_session then - M.render_full_session():and_then(save_active_tab_context) + M.render_full_session():and_then(function(session_data) + if session_data and state.active_session_tab == new then + if runtime then + runtime.renderer_dirty = false + end + save_active_tab_context() + end + end) end end diff --git a/tests/unit/event_scope_spec.lua b/tests/unit/event_scope_spec.lua index ee92c1c9..488e9df5 100644 --- a/tests/unit/event_scope_spec.lua +++ b/tests/unit/event_scope_spec.lua @@ -1,13 +1,18 @@ local event_scope = require('opencode.ui.event_scope') local state = require('opencode.state') +local session_tabs = require('opencode.state.session_tabs') +local stub = require('luassert.stub') describe('event_scope', function() before_each(function() + session_tabs.reset() + session_tabs.ensure_current() state.session.set_active({ id = 'session_active' }) end) after_each(function() state.session.set_active(nil) + session_tabs.reset() end) it('has a scope policy for every renderer event subscription', function() @@ -72,4 +77,16 @@ describe('event_scope', function() event_scope.scoped_callback('session.updated', callback) ) end) + + it('marks a background tab renderer dirty when its message event is rejected', function() + local background = session_tabs.create({ id = 'session_other' }) + local callback = stub.new() + + event_scope.scoped_callback('message.updated', callback)({ + info = { id = 'message_other', sessionID = 'session_other' }, + }) + + assert.is_true(background.renderer_dirty) + assert.stub(callback).was_not_called() + end) end) diff --git a/tests/unit/renderer_session_tabs_spec.lua b/tests/unit/renderer_session_tabs_spec.lua index 8b58a4eb..ba21d229 100644 --- a/tests/unit/renderer_session_tabs_spec.lua +++ b/tests/unit/renderer_session_tabs_spec.lua @@ -72,4 +72,51 @@ describe('renderer session tab contexts', function() assert.same({ 'preserved output' }, vim.api.nvim_buf_get_lines(output_buf, 0, -1, false)) render_stub:revert() end) + + it('refreshes a dirty cached renderer context on activation', function() + local first = session_tabs.ensure_current() + first.active_session = { id = 'session-one', title = 'One' } + local second = session_tabs.create({ id = 'session-two', title = 'Two' }) + second.renderer_context = renderer_ctx:snapshot() + second.renderer_dirty = true + + output_buf = vim.api.nvim_create_buf(false, true) + output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 60, + height = 10, + row = 1, + col = 1, + }) + state.ui.set_windows({ output_buf = output_buf, output_win = output_win }) + state.jobs.set_api_client({}) + store.set_raw('active_session_tab', second.id) + store.set_raw('active_session', second.active_session) + + local render_stub = stub(renderer, 'render_full_session').returns(Promise.new():resolve({})) + renderer.on_session_tab_changed(nil, second.id, first.id) + + assert.stub(render_stub).was_called(1) + vim.wait(50, function() + return not second.renderer_dirty + end) + assert.is_false(second.renderer_dirty) + render_stub:revert() + end) + + it('does not clear dirty state when refresh cannot load messages', function() + local first = session_tabs.ensure_current() + local second = session_tabs.create({ id = 'session-two', title = 'Two' }) + second.renderer_context = renderer_ctx:snapshot() + second.renderer_dirty = true + store.set_raw('active_session_tab', second.id) + store.set_raw('active_session', second.active_session) + + local render_stub = stub(renderer, 'render_full_session').returns(Promise.new():resolve(nil)) + renderer.on_session_tab_changed(nil, second.id, first.id) + vim.wait(20) + + assert.is_true(second.renderer_dirty) + render_stub:revert() + end) end) diff --git a/tests/unit/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua index 561cede8..fd4b36fa 100644 --- a/tests/unit/services_messaging_spec.lua +++ b/tests/unit/services_messaging_spec.lua @@ -10,6 +10,7 @@ local session_runtime = require('opencode.services.session_runtime') local config_file = require('opencode.config_file') local context = require('opencode.context') local state = require('opencode.state') +local session_tabs = require('opencode.state.session_tabs') local Promise = require('opencode.promise') local stub = require('luassert.stub') local assert = require('luassert') @@ -276,6 +277,63 @@ describe('opencode.services.messaging', function() state.api_client.create_message = orig end) + it('keeps an in-flight send bound to its original tab and session', function() + session_tabs.reset() + local first = session_tabs.ensure_current() + state.session.set_active({ id = 'session-one' }) + state.model.clear_model() + state.model.set_mode('mode-one') + local second = session_tabs.create({ id = 'session-two' }) + second.current_mode = 'mode-two' + + local config_promise = Promise.new() + local config_stub = stub(config_file, 'get_opencode_config').returns(config_promise) + local agents_stub = + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'mode-one', 'mode-two' })) + local sent_session + local sent_params + state.api_client.create_message = function(_, session_id, params) + sent_session = session_id + sent_params = params + return Promise.new():resolve({ info = { id = 'message-one' }, parts = {} }) + end + + local send = messaging.send_message('hello world') + session_tabs.activate(second) + config_promise:resolve({ model = 'test/model' }) + send:wait() + + assert.equals('session-one', sent_session) + assert.equals('mode-one', sent_params.agent) + assert.equals('test/model', first.current_model) + assert.equals('mode-two', state.current_mode) + assert.is_nil(state.current_model) + assert.equals(0, first.user_message_count['session-one']) + assert.is_nil(second.user_message_count['session-one']) + + config_stub:revert() + agents_stub:revert() + session_tabs.reset() + end) + + it('preserves attachments when message preparation fails', function() + state.session.set_active({ id = 'session-one' }) + state.model.clear_model() + local original_context = context.snapshot() + context.get_context().mentioned_files = { '/tmp/attached.lua' } + context.get_context().selections = {} + local config_stub = stub(config_file, 'get_opencode_config').returns(Promise.new():reject('config failed')) + + local ok = pcall(function() + messaging.send_message('hello world'):wait() + end) + + assert.is_false(ok) + assert.same({ '/tmp/attached.lua' }, context.get_context().mentioned_files) + config_stub:revert() + context.restore(original_context) + end) + it('decrements user_message_count on error', function() state.ui.set_windows({ mock = 'windows' }) state.session.set_active({ id = 'sess1' }) diff --git a/tests/unit/session_tabs_spec.lua b/tests/unit/session_tabs_spec.lua index 20b298c1..7e022c4a 100644 --- a/tests/unit/session_tabs_spec.lua +++ b/tests/unit/session_tabs_spec.lua @@ -70,6 +70,29 @@ describe('opencode session panel tabs', function() assert.equals('session-two', state.active_session.id) end) + it('notifies active-tab subscribers when message counts change', function() + local first = session_tabs.ensure_current() + state.session.set_active({ id = 'session-one' }) + local notifications = 0 + local listener = function() + notifications = notifications + 1 + end + store.subscribe('user_message_count', listener) + + session_tabs.update_user_message_count(first.id, 'session-one', 1) + vim.wait(50, function() + return notifications == 1 + end) + session_tabs.update_user_message_count(first.id, 'session-one', -1) + vim.wait(50, function() + return notifications == 2 + end) + + assert.equals(2, notifications) + assert.equals(0, state.user_message_count['session-one']) + store.unsubscribe('user_message_count', listener) + end) + it('switches to a panel tab by displayed index', function() local session_runtime = require('opencode.services.session_runtime') local first = session_tabs.ensure_current() From 54ce159ca862b4bf0da57504c62a97dca2ea088f Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 9 Sep 2026 14:43:38 -0400 Subject: [PATCH 07/12] refactor: move hook notification to per-session request completion --- lua/opencode/init.lua | 18 ---- lua/opencode/services/messaging.lua | 20 +++- lua/opencode/services/session_runtime.lua | 24 +++-- lua/opencode/state/model.lua | 21 +++- lua/opencode/state/session_tabs.lua | 4 + lua/opencode/ui/base_picker.lua | 2 +- lua/opencode/ui/session_picker.lua | 1 - tests/unit/hooks_spec.lua | 56 ++++------ tests/unit/session_tab_lifecycle_spec.lua | 118 ++++++++++++++++++++++ 9 files changed, 189 insertions(+), 75 deletions(-) create mode 100644 tests/unit/session_tab_lifecycle_spec.lua diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index b4faa461..3be9d998 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -9,23 +9,6 @@ local function on_opencode_server() require('opencode.ui.permission_window').clear_all() end -local function on_current_model_change(_key, new_val, old_val) - if new_val ~= old_val then - state.model.clear_variant() - - if new_val then - local provider, model = new_val:match('^(.-)/(.+)$') - if provider and model then - local model_state = require('opencode.model_state') - local saved_variant = model_state.get_variant(provider, model) - if saved_variant then - state.model.set_variant(saved_variant) - end - end - end - end -end - function M.setup(opts) if setup_done then return @@ -46,7 +29,6 @@ function M.setup(opts) state.store.subscribe('opencode_server', on_opencode_server) state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change) state.store.subscribe('pending_permissions', session_runtime._on_current_permission_change) - state.store.subscribe('current_model', on_current_model_change) vim.schedule(function() session_runtime.opencode_ok() diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index c1e29596..6551da04 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -107,15 +107,25 @@ M.send_message = Promise.async(function(prompt, opts) end local function update_sent_message_count(num) + local runtime = tab_id and session_tabs.get(tab_id) + if tab_id and not runtime then + return + end + + local counts = runtime and runtime.user_message_count or state.user_message_count + local old_count = counts[session_id] or 0 + local new_count = math.max(0, old_count + num) if tab_id then session_tabs.update_user_message_count(tab_id, session_id, num) - return + else + local sent_message_count = vim.deepcopy(counts) + sent_message_count[session_id] = new_count + state.session.set_user_message_count(sent_message_count) end - local sent_message_count = vim.deepcopy(state.user_message_count) - local new_value = (sent_message_count[session_id] or 0) + num - sent_message_count[session_id] = new_value >= 0 and new_value or 0 - state.session.set_user_message_count(sent_message_count) + if old_count > 0 and new_count == 0 then + session_runtime.on_session_request_completed(session_id) + end end update_sent_message_count(1) diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 1e60fee3..04275f3d 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -555,20 +555,22 @@ M.opencode_ok = Promise.async(function() return true end) -M._on_user_message_count_change = Promise.async(function(_, new, old) +M._on_user_message_count_change = Promise.async(function() require('opencode.ui.renderer.flush').flush_pending_on_data_rendered() +end) - if config.hooks and config.hooks.on_done_thinking then - local all_sessions = session.get_all_workspace_sessions():await() - local done_sessions = vim.tbl_filter(function(s) - local msg_count = new[s.id] or 0 - local old_msg_count = (old and old[s.id]) or 0 - return msg_count == 0 and old_msg_count > 0 - end, all_sessions or {}) +---Notify completion of the last outstanding local request for a session. +---@param session_id string +---@return Promise +M.on_session_request_completed = Promise.async(function(session_id) + local hook = config.hooks and config.hooks.on_done_thinking + if not hook then + return + end - for _, done_session in ipairs(done_sessions) do - pcall(config.hooks.on_done_thinking, done_session) - end + local completed_session = session.get_by_id(session_id):await() + if completed_session then + pcall(hook, completed_session) end end) diff --git a/lua/opencode/state/model.lua b/lua/opencode/state/model.lua index ae6396dd..945dac53 100644 --- a/lua/opencode/state/model.lua +++ b/lua/opencode/state/model.lua @@ -14,11 +14,28 @@ end ---@param model string|nil function M.set_model(model) - return store.set('current_model', model) + return store.batch(function() + if store.get('current_model') ~= model then + store.set('current_variant', M.saved_variant(model)) + end + return store.set('current_model', model) + end) +end + +---@param model string|nil +---@return string|nil +function M.saved_variant(model) + local provider, model_id + if model then + provider, model_id = model:match('^(.-)/(.+)$') + end + if provider and model_id then + return require('opencode.model_state').get_variant(provider, model_id) + end end function M.clear_model() - return store.set('current_model', nil) + return M.set_model(nil) end function M.clear() diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index d7655df5..132c3a9f 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -450,6 +450,9 @@ function M.update_model_state(tab_id, update) end if update.model ~= nil then + if runtime.current_model ~= update.model then + runtime.current_variant = require('opencode.state.model').saved_variant(update.model) + end runtime.current_model = update.model end if update.mode ~= nil then @@ -463,6 +466,7 @@ function M.update_model_state(tab_id, update) store.batch(function() if update.model ~= nil then store.set('current_model', update.model) + store.set('current_variant', runtime.current_variant) end if update.mode ~= nil then store.set('current_mode', update.mode) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 75db15bd..a1558f56 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -63,7 +63,6 @@ local Promise = require('opencode.promise') ---@class BasePicker local M = {} local picker = require('opencode.ui.picker') -local icons = require('lua.opencode.ui.icons') ---@param bufnr integer? ---@return PickerPreviewTarget @@ -129,6 +128,7 @@ end ---@param support_multi? boolean Whether multi-selection is supported ---@return string title The formatted title with action legend local function build_title(base_title, actions, support_multi) + local icons = require('lua.opencode.ui.icons') local legend = {} for _, action in pairs(actions) do if action.key and action.key[1] then diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index b1278936..8ba2277e 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -1,7 +1,6 @@ local M = {} local config = require('opencode.config') local base_picker = require('opencode.ui.base_picker') -local api = require('opencode.api') local util = require('opencode.util') local Promise = require('opencode.promise') diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index caca1720..614932e6 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -119,60 +119,42 @@ describe('hooks', function() end) describe('on_done_thinking', function() - it('should call hook when thinking is done', function() - local called = false - local called_session = nil + local get_session + + before_each(function() + get_session = stub(require('opencode.session'), 'get_by_id').returns( + require('opencode.promise').new():resolve({ id = 'test-session', title = 'Test' }) + ) + end) + + after_each(function() + get_session:revert() + end) + it('should call hook when thinking is done', function() + local called_session config.hooks.on_done_thinking = function(session) - called = true called_session = session end - -- Mock session.get_all_workspace_sessions to return our test session - local session_module = require('opencode.session') - local original_get_all = session_module.get_all_workspace_sessions - session_module.get_all_workspace_sessions = function() - local promise = require('opencode.promise').new() - promise:resolve({ { id = 'test-session', title = 'Test' } }) - return promise - end - - state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change) - - -- Simulate job count change from 1 to 0 (done thinking) for a specific session - state.session.set_active({ id = 'test-session', title = 'Test' }) - state.session.set_user_message_count({ ['test-session'] = 1 }) - state.session.set_user_message_count({ ['test-session'] = 0 }) - - -- Wait for async notification - vim.wait(100, function() - return called - end) - - -- Restore original function - session_module.get_all_workspace_sessions = original_get_all - state.store.unsubscribe('user_message_count', session_runtime._on_user_message_count_change) + session_runtime.on_session_request_completed('test-session'):wait() - assert.is_true(called) - assert.are.equal(called_session.id, 'test-session') + assert.equals('test-session', called_session.id) + assert.stub(get_session).was_called_with('test-session') end) it('should not error when hook is nil', function() - config.hooks.on_done_thinking = nil - state.session.set_active({ id = 'test-session', title = 'Test' }) - state.session.set_user_message_count({ ['test-session'] = 1 }) expect_nil_hook_no_error(function() - state.session.set_user_message_count({ ['test-session'] = 0 }) + session_runtime.on_session_request_completed('test-session'):wait() end) + assert.stub(get_session).was_not_called() end) it('should not crash when hook throws error', function() - state.session.set_active({ id = 'test-session', title = 'Test' }) - state.session.set_user_message_count({ ['test-session'] = 1 }) expect_throwing_hook_no_crash(function(fn) config.hooks.on_done_thinking = fn end, function() - state.session.set_user_message_count({ ['test-session'] = 0 }) + session_runtime.on_session_request_completed('test-session'):wait() end) end) end) diff --git a/tests/unit/session_tab_lifecycle_spec.lua b/tests/unit/session_tab_lifecycle_spec.lua new file mode 100644 index 00000000..6d0d1f1c --- /dev/null +++ b/tests/unit/session_tab_lifecycle_spec.lua @@ -0,0 +1,118 @@ +local state = require('opencode.state') +local tabs = state.session_tabs +local config = require('opencode.config') +local Promise = require('opencode.promise') +local stub = require('luassert.stub') +local session_runtime = require('opencode.services.session_runtime') + +describe('session tab lifecycle', function() + local original_state + local original_hooks + local stubs + + before_each(function() + vim.wait(20) + original_state = vim.deepcopy(state.store.state()) + original_hooks = config.hooks + stubs = {} + tabs.reset() + end) + + after_each(function() + state.store.unsubscribe('user_message_count', session_runtime._on_user_message_count_change) + vim.wait(20) + for _, replacement in ipairs(stubs) do + replacement:revert() + end + config.hooks = original_hooks + tabs.reset() + for key in pairs(state.store.state()) do + state.store.set_raw(key, nil) + end + for key, value in pairs(original_state) do + state.store.set_raw(key, value) + end + end) + + local function replace(module, name, value) + local replacement = stub(module, name).returns(value) + table.insert(stubs, replacement) + return replacement + end + + it('notifies only when all requests complete, including in a background tab', function() + local context = require('opencode.context') + local messaging = require('opencode.services.messaging') + local first = tabs.ensure_current() + state.session.set_active({ id = 'first' }) + state.model.set_model('provider/model') + state.model.clear_mode() + replace(context, 'load', nil) + replace(context, 'format_message', Promise.new():resolve({})) + replace(context, 'unload_attachments', nil) + replace(messaging, 'after_run', nil) + replace(require('opencode.config_file'), 'get_opencode_agents', Promise.new():resolve({})) + replace(require('opencode.session'), 'get_by_id', Promise.new():resolve({ id = 'first' })) + + local completed = {} + config.hooks = { + on_done_thinking = function(session) + table.insert(completed, session.id) + end, + } + local requests = {} + state.jobs.set_api_client({ + create_message = function() + local request = Promise.new() + table.insert(requests, request) + return request + end, + }) + state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change) + + local send_one = messaging.send_message('one') + local send_two = messaging.send_message('two') + assert.equals(2, #requests) + local second = tabs.create({ id = 'second' }) + tabs.activate(second) + vim.wait(30) + assert.same({}, completed) + + requests[1]:resolve({ info = { id = 'one' }, parts = {} }) + send_one:wait() + assert.same({}, completed) + requests[2]:resolve({ info = { id = 'two' }, parts = {} }) + send_two:wait() + vim.wait(30) + assert.same({ 'first' }, completed) + assert.equals(0, first.user_message_count.first) + assert.same({}, state.user_message_count) + tabs.activate(first) + vim.wait(30) + assert.same({ 'first' }, completed) + end) + + it('preserves tab variants while applying saved variants to actual model changes', function() + replace(require('opencode.model_state'), 'get_variant', 'medium') + local first = tabs.ensure_current() + state.model.set_model('provider/first') + assert.equals('medium', state.current_variant) + state.model.set_variant('high') + local second = tabs.create({ id = 'second' }) + tabs.activate(second) + state.model.set_model('provider/second') + assert.equals('medium', state.current_variant) + state.model.clear_variant() + + tabs.activate(first) + vim.wait(30) + assert.equals('high', state.current_variant) + tabs.activate(second) + vim.wait(30) + assert.is_nil(state.current_variant) + state.model.set_model('provider/second') + assert.is_nil(state.current_variant) + state.model.set_model('provider/third') + assert.equals('medium', state.current_variant) + end) +end) From 65d59bbb57b65b3eeecd71549bf7d404ab8b3b55 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 10 Sep 2026 08:34:21 -0400 Subject: [PATCH 08/12] feat: session idle hooks, hide single tab --- README.md | 42 +- lua/opencode/init.lua | 1 + lua/opencode/services/messaging.lua | 18 +- lua/opencode/services/session_runtime.lua | 68 +- lua/opencode/state/session.lua | 11 - lua/opencode/state/session_tabs.lua | 20 +- lua/opencode/types.lua | 2 +- lua/opencode/ui/base_picker.lua | 2 +- lua/opencode/ui/renderer.lua | 58 +- lua/opencode/ui/session_tab_notifications.lua | 20 +- lua/opencode/ui/session_tab_strip.lua | 19 +- lua/opencode/ui/ui.lua | 4 + tests/data/hello-new.json | 267 --- tests/data/hello-old.json | 1956 ----------------- tests/unit/hooks_spec.lua | 29 + tests/unit/renderer_session_tabs_spec.lua | 33 + tests/unit/services_messaging_spec.lua | 15 + tests/unit/services_session_runtime_spec.lua | 10 + tests/unit/session_tab_lifecycle_spec.lua | 7 + 19 files changed, 297 insertions(+), 2285 deletions(-) delete mode 100644 tests/data/hello-new.json delete mode 100644 tests/data/hello-old.json diff --git a/README.md b/README.md index 327cd47f..6758d8a7 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ require('opencode').setup({ display_model = true, -- Display model name on top winbar display_context_size = true, -- Display context size in the footer display_cost = true, -- Display cost in the footer - hide_single_tab = false, -- Hide the panel tab strip when only one session tab exists + hide_single_tab = true, -- Hide the panel tab strip when only one session tab exists notify_on_background_prompt = true, -- Notify when an unfocused session needs a question or permission response window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', -- Highlight group for the opencode window persist_state = true, -- Keep buffers when toggling/closing UI so window state restores quickly @@ -240,22 +240,22 @@ require('opencode').setup({ use_vim_ui_select = false, -- If true, render questions/prompts with vim.ui.select instead of showing them inline in the output buffer. inline_other_input = true, -- If true, show an inline floating input for "Other" instead of vim.ui.input. }, - output = { - filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output') - actions = { - open_in_new_tab = false, -- Open inline child-session and fork actions in a new panel tab - }, - compact_assistant_headers = false, -- 'full' (default), 'minimal' (compact if same mode), or 'hidden' (no headers for assistant) - tools = { - show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true) - show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true) - use_folds = true, -- Use folds for tool output (default: true) - folding_threshold = 25, -- Number of lines to show before folding when show_output is true (default: 25) - fold_exclude = { -- Tools that should never be folded (default: sequential-thinking) - 'bash', -- built-in tool name (exact match) - { server = 'sequential-thinking', tool = 'sequentialthinking' }, -- MCP tool (server + tool match) - }, - }, + output = { + filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output') + actions = { + open_in_new_tab = false, -- Open inline child-session and fork actions in a new panel tab + }, + compact_assistant_headers = false, -- 'full' (default), 'minimal' (compact if same mode), or 'hidden' (no headers for assistant) + tools = { + show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true) + show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true) + use_folds = true, -- Use folds for tool output (default: true) + folding_threshold = 25, -- Number of lines to show before folding when show_output is true (default: 25) + fold_exclude = { -- Tools that should never be folded (default: sequential-thinking) + 'bash', -- built-in tool name (exact match) + { server = 'sequential-thinking', tool = 'sequentialthinking' }, -- MCP tool (server + tool match) + }, + }, rendering = { markdown_debounce_ms = 250, -- Debounce time for markdown rendering on new data (default: 250ms) on_data_rendered = nil, -- Called when new data is rendered; set to false to disable default RenderMarkdown/Markview behavior @@ -360,7 +360,7 @@ require('opencode').setup({ hooks = { on_file_edited = nil, -- Called after a file is edited by opencode. on_session_loaded = nil, -- Called after a session is loaded. - on_done_thinking = nil, -- Called when opencode finishes thinking (all jobs complete). + on_done_thinking = nil, -- Called when a session becomes idle, including sessions started outside Neovim. on_permission_requested = nil, -- Called when a permission request is issued. }, quick_chat = { @@ -629,8 +629,8 @@ There's 3 main ways on how to change the snacks picker layout require("opencode").setup({ ui = { picker = { - ---@module "snacks" - ---@type snacks.picker.layout.Config | nil + ---@module "snacks" + ---@type snacks.picker.layout.Config | nil snacks_layout = { preset = "custom_layout" -- or builtin snacks, like "select", "default", etc }, @@ -1219,7 +1219,7 @@ You can define custom functions to be called at specific events in Opencode: - `on_file_edited`: Called after a file is edited by Opencode. - `on_session_loaded`: Called after a session is loaded. -- `on_done_thinking`: Called when Opencode finishes thinking (all user jobs complete). +- `on_done_thinking`: Called when a session becomes idle, including sessions started outside Neovim. - `on_permission_requested`: Called when a permission request is issued. ```lua diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 3be9d998..98bac87b 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -42,6 +42,7 @@ function M.setup(opts) require('opencode.ui.completion').setup() require('opencode.keymap').setup(config.keymap) require('opencode.event_manager').setup() + session_runtime.setup() require('opencode.ui.session_tab_notifications').setup() require('opencode.context').setup() require('opencode.ui.context_bar').setup() diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index 6551da04..1b8aeaf5 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -137,7 +137,7 @@ M.send_message = Promise.async(function(prompt, opts) if not response or not response.info or not response.parts then log.notify('Invalid response from opencode: ' .. vim.inspect(response), vim.log.levels.ERROR) - session_runtime.cancel(session_id, tab_id):await() + session_runtime.cancel(session_id, tab_id, { count_abort = true }):await() return end @@ -146,15 +146,20 @@ M.send_message = Promise.async(function(prompt, opts) :catch(function(err) log.notify('Error sending message to session: ' .. vim.inspect(err), vim.log.levels.ERROR) update_sent_message_count(-1) - session_runtime.cancel(session_id, tab_id):await() + session_runtime.cancel(session_id, tab_id, { count_abort = true }):await() end) :await() end) ---@param prompt string ----@param tab_id? string +---@param tab_id? string|OpencodeContext ---@param sent_context? OpencodeContext function M.after_run(prompt, tab_id, sent_context) + if type(tab_id) == 'table' and sent_context == nil then + sent_context = tab_id + tab_id = nil + end + if tab_id then local runtime = session_tabs.get(tab_id) if not runtime then @@ -175,8 +180,11 @@ function M.after_run(prompt, tab_id, sent_context) context.delta_context() end else - context.unload_attachments() - state.session.set_last_sent_context(vim.deepcopy(context.get_context())) + local context_sent = vim.deepcopy(sent_context or context.get_context()) + if not sent_context then + context.unload_attachments() + end + state.session.set_last_sent_context(context_sent) context.delta_context() end require('opencode.history').write(prompt) diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 04275f3d..85aaf41d 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -13,6 +13,8 @@ local agent_model = require('opencode.services.agent_model') local session_tabs = require('opencode.state.session_tabs') local M = {} +local subscribed_event_manager +local idle_events_enabled = false ---@return boolean function M.is_session_locked() @@ -396,6 +398,7 @@ local function delete_runtime_buffers(runtime) collect(runtime._hidden_buffers) for _, bufnr in ipairs(buffers) do + require('opencode.ui.session_tab_strip').clear_buffer(bufnr) if vim.api.nvim_buf_is_valid(bufnr) then pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) end @@ -406,7 +409,15 @@ end ---@param tab_id? string Close selected tab, or the active tab when omitted. ---@return boolean function M.close_session_tab(tab_id) - local runtime = tab_id and session_tabs.get(tab_id) or session_tabs.current() + local runtime + if tab_id then + runtime = session_tabs.get(tab_id) + if not runtime then + return false + end + else + runtime = session_tabs.current() + end if not runtime then return false end @@ -464,7 +475,8 @@ end ---@param session_id? string ---@param tab_id? string -M.cancel = Promise.async(function(session_id, tab_id) +---@param opts? { count_abort?: boolean } +M.cancel = Promise.async(function(session_id, tab_id, opts) local target_runtime = tab_id and session_tabs.get(tab_id) or session_tabs.current() local target_session = session_id and { id = session_id } or state.active_session @@ -475,7 +487,7 @@ M.cancel = Promise.async(function(session_id, tab_id) or (state.user_message_count or {})[target_session.id] local request_running = tab_id and target_runtime and pending_count and pending_count > 0 or (not tab_id and state.jobs.is_running()) - if request_running then + if request_running or (opts and opts.count_abort) then vim.g.opencode_abort_count = (vim.g.opencode_abort_count or 0) + 1 end @@ -555,6 +567,15 @@ M.opencode_ok = Promise.async(function() return true end) +---@param completed_session Session +local function notify_done_thinking(completed_session) + local hook = config.hooks and config.hooks.on_done_thinking + if not hook or not completed_session or not completed_session.id then + return + end + pcall(hook, completed_session) +end + M._on_user_message_count_change = Promise.async(function() require('opencode.ui.renderer.flush').flush_pending_on_data_rendered() end) @@ -563,17 +584,35 @@ end) ---@param session_id string ---@return Promise M.on_session_request_completed = Promise.async(function(session_id) - local hook = config.hooks and config.hooks.on_done_thinking - if not hook then + if idle_events_enabled or not session_id or not (config.hooks and config.hooks.on_done_thinking) then return end local completed_session = session.get_by_id(session_id):await() if completed_session then - pcall(hook, completed_session) + notify_done_thinking(completed_session) + end +end) + +---@param session_id string +M.on_session_idle = Promise.async(function(session_id) + if not idle_events_enabled or not session_id or not (config.hooks and config.hooks.on_done_thinking) then + return + end + local completed_session = session.get_by_id(session_id):await() + if completed_session then + notify_done_thinking(completed_session) end end) +---@param properties table|nil +local function on_session_idle(properties) + local session_id = properties and properties.sessionID + if session_id then + M.on_session_idle(session_id) + end +end + M._on_current_permission_change = Promise.async(function(_, new, old) local permission_requested = #old < #new if config.hooks and config.hooks.on_permission_requested and permission_requested then @@ -611,6 +650,23 @@ function M.paste_image_from_clipboard() end function M.setup() + local manager = state.event_manager + if manager == subscribed_event_manager then + return true + end + + if subscribed_event_manager then + subscribed_event_manager:unsubscribe('session.idle', on_session_idle) + subscribed_event_manager = nil + end + idle_events_enabled = false + if not manager then + return true + end + + manager:subscribe('session.idle', on_session_idle) + subscribed_event_manager = manager + idle_events_enabled = true return true end diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index cf85e0c6..b06ad999 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -93,17 +93,6 @@ function M.set_user_message_count(count) return result end ----Increment/decrement the message count for a session, clamped to >= 0 ----@param session_id string ----@param delta integer -function M.increment_user_message_count(session_id, delta) - store.mutate('user_message_count', function(counts) - local new_value = (counts[session_id] or 0) + delta - counts[session_id] = new_value >= 0 and new_value or 0 - end) - session_tabs.sync() -end - ---Update active_session without emitting a change event, used when a silent ---in-place update is needed (e.g. session metadata refresh that must not ---trigger a re-render) diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index 132c3a9f..c5e91952 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -41,6 +41,7 @@ local store = require('opencode.state.store') ---@field context_data OpencodeContext|nil ---@field renderer_context table|nil Renderer caches associated with the preserved output buffer ---@field renderer_dirty boolean Cached renderer missed background session events +---@field background_notifications table Notifications emitted for pending background prompts ---@class OpencodeSessionTabStateMutations local M = {} @@ -157,6 +158,7 @@ local function default_runtime(id) context_data = nil, renderer_context = nil, renderer_dirty = false, + background_notifications = {}, } end @@ -228,7 +230,8 @@ function M.find_by_session_id(session_id) return nil end - for _, runtime in ipairs(M.list()) do + M.sync() + for _, runtime in pairs(runtimes) do if runtime.active_session and runtime.active_session.id == session_id then return runtime end @@ -253,6 +256,21 @@ end ---@param session_id string|nil function M.mark_renderer_dirty(session_id) + if not session_id then + return + end + + local runtime_count = 0 + for _ in pairs(runtimes) do + runtime_count = runtime_count + 1 + if runtime_count > 1 then + break + end + end + if runtime_count < 2 then + return + end + local runtime = M.find_by_session_id(session_id) if runtime and runtime.id ~= M.active_id() then runtime.renderer_dirty = true diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 407d1c18..6e7d8a56 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -346,7 +346,7 @@ ---@class OpencodeHooks ---@field on_file_edited? fun(file: string): nil ---@field on_session_loaded? fun(session: Session): nil ----@field on_done_thinking? fun(session: Session): nil +---@field on_done_thinking? fun(session: Session): nil Called when a session becomes idle. ---@field on_permission_requested? fun(session: Session): nil ---@field on_command_before? OpencodeCommandDispatchHook ---@field on_command_after? OpencodeCommandDispatchHook diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index a1558f56..02eec590 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -128,7 +128,7 @@ end ---@param support_multi? boolean Whether multi-selection is supported ---@return string title The formatted title with action legend local function build_title(base_title, actions, support_multi) - local icons = require('lua.opencode.ui.icons') + local icons = require('opencode.ui.icons') local legend = {} for _, action in pairs(actions) do if action.key and action.key[1] then diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 49f50ba7..4604cff0 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -671,6 +671,36 @@ function M.on_session_changed(_, new, old) end end +---@param tab_id string +---@param runtime OpencodeSessionTabRuntime|nil +local function refresh_tab(tab_id, runtime) + if not state.active_session then + return + end + if not output_window.mounted() or not state.api_client then + if runtime then + runtime.renderer_dirty = true + end + return + end + + local refresh = M.render_full_session() + if not refresh then + if runtime then + runtime.renderer_dirty = true + end + return + end + refresh:and_then(function(session_data) + if session_data and state.active_session_tab == tab_id then + if runtime then + runtime.renderer_dirty = false + end + save_active_tab_context() + end + end) +end + ---Rebind renderer state when the selected logical panel tab changes. function M.on_session_tab_changed(_, new, old) if new == old then @@ -679,6 +709,12 @@ function M.on_session_tab_changed(_, new, old) save_tab_context(old) rendered_session_tab = new local runtime = session_tabs.get(new) + if not output_window.mounted() then + if runtime then + runtime.renderer_dirty = true + end + return + end local restored = restore_tab_context(new) local prompts = ctx.prompt_controllers if prompts.question then @@ -704,15 +740,19 @@ function M.on_session_tab_changed(_, new, old) return end - if state.active_session then - M.render_full_session():and_then(function(session_data) - if session_data and state.active_session_tab == new then - if runtime then - runtime.renderer_dirty = false - end - save_active_tab_context() - end - end) + refresh_tab(new, runtime) +end + +---Refresh a tab whose windows were mounted after the tab-change event. +function M.on_windows_mounted() + local tab_id = state.active_session_tab + local runtime = tab_id and session_tabs.get(tab_id) + if not tab_id or rendered_session_tab ~= tab_id or not runtime or not state.active_session then + return + end + + if runtime.renderer_dirty then + refresh_tab(tab_id, runtime) end end diff --git a/lua/opencode/ui/session_tab_notifications.lua b/lua/opencode/ui/session_tab_notifications.lua index e8755e6d..723dee67 100644 --- a/lua/opencode/ui/session_tab_notifications.lua +++ b/lua/opencode/ui/session_tab_notifications.lua @@ -5,7 +5,6 @@ local config = require('opencode.config') local M = {} local subscribed_manager = nil -local notified = {} local function request_key(kind, request_id) return kind .. ':' .. request_id @@ -44,10 +43,11 @@ local function track(kind, request) end local key = request_key(kind, request.id) - if notified[key] then + runtime.background_notifications = runtime.background_notifications or {} + if runtime.background_notifications[key] then return end - notified[key] = true + runtime.background_notifications[key] = true local label = kind == 'permission' and 'Permission required' or 'Question waiting' local level = kind == 'permission' and vim.log.levels.WARN or vim.log.levels.INFO @@ -59,14 +59,20 @@ local function clear(kind, request_id) return end - for _, runtime in ipairs(session_tabs.list()) do + local tabs = session_tabs.list() + for _, runtime in ipairs(tabs) do if kind == 'permission' then session_tabs.remove_pending_permission(runtime.id, request_id) else session_tabs.remove_pending_question(runtime.id, request_id) end end - notified[request_key(kind, request_id)] = nil + local key = request_key(kind, request_id) + for _, runtime in ipairs(tabs) do + if runtime.background_notifications then + runtime.background_notifications[key] = nil + end + end end ---@param permission OpencodePermission @@ -130,7 +136,9 @@ function M.setup() end function M.reset() - notified = {} + for _, runtime in ipairs(session_tabs.list()) do + runtime.background_notifications = {} + end end return M diff --git a/lua/opencode/ui/session_tab_strip.lua b/lua/opencode/ui/session_tab_strip.lua index 12a78bdf..30f0cce8 100644 --- a/lua/opencode/ui/session_tab_strip.lua +++ b/lua/opencode/ui/session_tab_strip.lua @@ -9,6 +9,14 @@ local ranges_by_buffer = {} local subscribed = false local minimum_tab_width = 12 +local function prune_ranges() + for buffer in pairs(ranges_by_buffer) do + if not vim.api.nvim_buf_is_valid(buffer) then + ranges_by_buffer[buffer] = nil + end + end +end + local function display_width(text) return vim.fn.strdisplaywidth(text) end @@ -363,6 +371,7 @@ end ---@param windows OpencodeWindowState function M.render(windows) + prune_ranges() windows = windows or state.windows if not valid_windows(windows) then return @@ -413,6 +422,9 @@ function M.create_window(windows) if not windows.output_win or not windows.tab_strip_buf or not vim.api.nvim_win_is_valid(windows.output_win) then return nil end + if config.ui.hide_single_tab and #session_tabs.list() == 1 then + return nil + end if windows.tab_strip_win and vim.api.nvim_win_is_valid(windows.tab_strip_win) then return windows.tab_strip_win end @@ -433,6 +445,11 @@ function M.create_window(windows) return windows.tab_strip_win end +---@param buffer integer +function M.clear_buffer(buffer) + ranges_by_buffer[buffer] = nil +end + ---@param windows OpencodeWindowState local function close_window(windows) if windows.tab_strip_win and vim.api.nvim_win_is_valid(windows.tab_strip_win) then @@ -440,7 +457,7 @@ local function close_window(windows) end windows.tab_strip_win = nil if windows.tab_strip_buf then - ranges_by_buffer[windows.tab_strip_buf] = nil + M.clear_buffer(windows.tab_strip_buf) end end diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index e8251a46..ea96e60d 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -221,6 +221,9 @@ function M.drop_hidden_snapshot() if hidden then for _, buf in ipairs({ hidden.input_buf, hidden.output_buf, hidden.footer_buf, hidden.tab_strip_buf }) do if buf and vim.api.nvim_buf_is_valid(buf) then + if buf == hidden.tab_strip_buf then + session_tab_strip.clear_buffer(buf) + end pcall(vim.api.nvim_buf_delete, buf, { force = true }) end end @@ -312,6 +315,7 @@ function M.restore_hidden_windows() end) require('opencode.ui.contextual_actions').setup_contextual_actions(windows) + renderer.on_windows_mounted() return true end diff --git a/tests/data/hello-new.json b/tests/data/hello-new.json deleted file mode 100644 index 0571581b..00000000 --- a/tests/data/hello-new.json +++ /dev/null @@ -1,267 +0,0 @@ -[ - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "open_input_new_session", - "args": [], - "source": { - "raw_args": "open_input_new_session", - "argv": ["open_input_new_session"] - } - } - }, - "intent": { - "name": "open_input_new_session", - "args": [], - "source": { - "raw_args": "open_input_new_session", - "argv": ["open_input_new_session"] - } - }, - "args": [] - }, - "type": "custom.command.before" - }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "open_input_new_session", - "args": [], - "source": { - "raw_args": "open_input_new_session", - "argv": ["open_input_new_session"] - } - } - }, - "intent": { - "name": "open_input_new_session", - "args": [], - "source": { - "raw_args": "open_input_new_session", - "argv": ["open_input_new_session"] - } - }, - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "args": [] - }, - "type": "custom.command.after" - }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "open_input_new_session", - "args": [], - "source": { - "raw_args": "open_input_new_session", - "argv": ["open_input_new_session"] - } - } - }, - "intent": { - "name": "open_input_new_session", - "args": [], - "source": { - "raw_args": "open_input_new_session", - "argv": ["open_input_new_session"] - } - }, - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "args": [] - }, - "type": "custom.command.finally" - }, - { "properties": [], "type": "custom.server_starting" }, - { - "properties": { "url": "http://127.0.0.1:4444" }, - "type": "custom.server_ready" - }, - { - "properties": { "url": "http://127.0.0.1:4444" }, - "type": "custom.server_starting" - }, - { - "properties": { "url": "http://127.0.0.1:4444" }, - "type": "custom.server_ready" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { "properties": {}, "type": "server.connected" }, - { "properties": [], "type": "custom.emit_events.finished" }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "prev_prompt_history", - "args": [], - "source": { - "raw_args": "prev_prompt_history", - "argv": ["prev_prompt_history"] - } - } - }, - "intent": { - "name": "prev_prompt_history", - "args": [], - "source": { - "raw_args": "prev_prompt_history", - "argv": ["prev_prompt_history"] - } - }, - "args": [] - }, - "type": "custom.command.before" - }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "prev_prompt_history", - "args": [], - "source": { - "raw_args": "prev_prompt_history", - "argv": ["prev_prompt_history"] - } - } - }, - "intent": { - "name": "prev_prompt_history", - "args": [], - "source": { - "raw_args": "prev_prompt_history", - "argv": ["prev_prompt_history"] - } - }, - "args": [] - }, - "type": "custom.command.after" - }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "prev_prompt_history", - "args": [], - "source": { - "raw_args": "prev_prompt_history", - "argv": ["prev_prompt_history"] - } - } - }, - "intent": { - "name": "prev_prompt_history", - "args": [], - "source": { - "raw_args": "prev_prompt_history", - "argv": ["prev_prompt_history"] - } - }, - "args": [] - }, - "type": "custom.command.finally" - }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "submit_input_prompt", - "args": ["n"], - "source": { - "raw_args": "submit_input_prompt n", - "argv": ["submit_input_prompt", "n"] - } - } - }, - "intent": { - "name": "submit_input_prompt", - "args": ["n"], - "source": { - "raw_args": "submit_input_prompt n", - "argv": ["submit_input_prompt", "n"] - } - }, - "args": ["n"] - }, - "type": "custom.command.before" - }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "submit_input_prompt", - "args": ["n"], - "source": { - "raw_args": "submit_input_prompt n", - "argv": ["submit_input_prompt", "n"] - } - } - }, - "intent": { - "name": "submit_input_prompt", - "args": ["n"], - "source": { - "raw_args": "submit_input_prompt n", - "argv": ["submit_input_prompt", "n"] - } - }, - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": true - }, - "args": ["n"] - }, - "type": "custom.command.after" - }, - { - "properties": { - "parsed": { - "ok": true, - "intent": { - "name": "submit_input_prompt", - "args": ["n"], - "source": { - "raw_args": "submit_input_prompt n", - "argv": ["submit_input_prompt", "n"] - } - } - }, - "intent": { - "name": "submit_input_prompt", - "args": ["n"], - "source": { - "raw_args": "submit_input_prompt n", - "argv": ["submit_input_prompt", "n"] - } - }, - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": true - }, - "args": ["n"] - }, - "type": "custom.command.finally" - } -] diff --git a/tests/data/hello-old.json b/tests/data/hello-old.json deleted file mode 100644 index 7dbce43b..00000000 --- a/tests/data/hello-old.json +++ /dev/null @@ -1,1956 +0,0 @@ -[ - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "toggle", - "source": { "argv": ["toggle"], "raw_args": "toggle" } - } - }, - "intent": { - "args": [], - "name": "toggle", - "source": { "argv": ["toggle"], "raw_args": "toggle" } - } - }, - "type": "custom.command.before" - }, - { - "properties": { - "args": [], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "toggle", - "source": { "argv": ["toggle"], "raw_args": "toggle" } - } - }, - "intent": { - "args": [], - "name": "toggle", - "source": { "argv": ["toggle"], "raw_args": "toggle" } - } - }, - "type": "custom.command.after" - }, - { - "properties": { - "args": [], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "toggle", - "source": { "argv": ["toggle"], "raw_args": "toggle" } - } - }, - "intent": { - "args": [], - "name": "toggle", - "source": { "argv": ["toggle"], "raw_args": "toggle" } - } - }, - "type": "custom.command.finally" - }, - { - "properties": { "url": "http://127.0.0.1:4444" }, - "type": "custom.server_starting" - }, - { - "properties": { "url": "http://127.0.0.1:4444" }, - "type": "custom.server_ready" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { "properties": {}, "type": "server.connected" }, - { "properties": [], "type": "custom.emit_events.finished" }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "type": "custom.command.before" - }, - { - "properties": { - "args": [], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "type": "custom.command.after" - }, - { - "properties": { - "args": [], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "type": "custom.command.finally" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "slug": "quick-sailor", - "time": { "created": 1778413929849, "updated": 1778413929849 }, - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:52:09.849Z" - } - }, - "type": "session.created" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "slug": "quick-sailor", - "time": { "created": 1778413929849, "updated": 1778413929849 }, - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:52:09.849Z" - } - }, - "type": "session.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "configure_provider", - "source": { - "argv": ["configure_provider"], - "raw_args": "configure_provider" - } - } - }, - "intent": { - "args": [], - "name": "configure_provider", - "source": { - "argv": ["configure_provider"], - "raw_args": "configure_provider" - } - } - }, - "type": "custom.command.before" - }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "configure_provider", - "source": { - "argv": ["configure_provider"], - "raw_args": "configure_provider" - } - } - }, - "intent": { - "args": [], - "name": "configure_provider", - "source": { - "argv": ["configure_provider"], - "raw_args": "configure_provider" - } - } - }, - "type": "custom.command.after" - }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "configure_provider", - "source": { - "argv": ["configure_provider"], - "raw_args": "configure_provider" - } - } - }, - "intent": { - "args": [], - "name": "configure_provider", - "source": { - "argv": ["configure_provider"], - "raw_args": "configure_provider" - } - } - }, - "type": "custom.command.finally" - }, - { - "properties": { - "args": ["n"], - "parsed": { - "ok": true, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "type": "custom.command.before" - }, - { - "properties": { - "args": ["n"], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": true - }, - "parsed": { - "ok": true, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "type": "custom.command.after" - }, - { - "properties": { - "args": ["n"], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": true - }, - "parsed": { - "ok": true, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "type": "custom.command.finally" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "user", - "agent": "build", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "id": "msg_e11bb5562001WiWVv8mGf4g0l8", - "model": { - "variant": "high", - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - }, - "time": { "created": 1778413950306 } - } - }, - "type": "message.updated" - }, - { - "properties": { - "part": { - "synthetic": true, - "id": "prt_e11bb5584001rsoGcew6bbYllR", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "text": "Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua\"}", - "type": "text" - }, - "time": 1778413950346, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "synthetic": true, - "id": "prt_e11bb5584002e1f0O0MLmiZ4pV", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "text": "/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua\nfile\n\n1: local state = require('opencode.state')\n2: local config = require('opencode.config')\n3: local ctx = require('opencode.ui.renderer.ctx')\n4: local permission_window = require('opencode.ui.permission_window')\n5: local flush = require('opencode.ui.renderer.flush')\n6: \n7: ---@param message OpencodeMessage|nil\n8: ---@return string|nil\n9: local function get_last_part_for_message(message)\n10: if not message or not message.parts or #message.parts == 0 then\n11: return nil\n12: end\n13: for i = #message.parts, 1, -1 do\n14: local part = message.parts[i]\n15: if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then\n16: return part.id\n17: end\n18: end\n19: return nil\n20: end\n21: \n22: ---@param message OpencodeMessage|nil\n23: ---@return string|nil\n24: local function find_text_part_for_message(message)\n25: if not message or not message.parts then\n26: return nil\n27: end\n28: for _, part in ipairs(message.parts) do\n29: if part.type == 'text' and not part.synthetic then\n30: return part.id\n31: end\n32: end\n33: return nil\n34: end\n35: \n36: ---@param message_id string|nil\n37: ---@return OpencodeMessage|nil\n38: local function find_message_in_state(message_id)\n39: if not message_id then\n40: return nil\n41: end\n42: \n43: for _, message in ipairs(state.messages or {}) do\n44: if message.info and message.info.id == message_id then\n45: return message\n46: end\n47: end\n48: \n49: return nil\n50: end\n51: \n52: -- Lazy require to avoid circular dependency: renderer.lua <-> events.lua\n53: ---@param force? boolean\n54: local function scroll(force)\n55: require('opencode.ui.renderer').scroll_to_bottom(force)\n56: end\n57: \n58: local M = {}\n59: \n60: ---@param message_id string\n61: ---@param revert_index? integer\n62: local function replay_orphan_parts(message_id, revert_index)\n63: local orphan_parts = ctx.render_state:consume_orphan_parts(message_id)\n64: for _, orphan_part in ipairs(orphan_parts) do\n65: M.on_part_updated({ part = orphan_part }, revert_index)\n66: end\n67: end\n68: \n69: ---Update token/cost stats in state from a message\n70: ---@param message OpencodeMessage\n71: local function update_stats(message)\n72: if not state.current_model and message.info.providerID and message.info.providerID ~= '' then\n73: state.model.set_model(message.info.providerID .. '/' .. message.info.modelID)\n74: end\n75: \n76: local tokens = message.info.tokens\n77: if tokens and tokens.input > 0 and message.info.cost and type(message.info.cost) == 'number' then\n78: state.renderer.set_stats(tokens.input + tokens.output + tokens.cache.read + tokens.cache.write, message.info.cost)\n79: elseif tokens and tokens.input > 0 then\n80: state.renderer.set_tokens_count(tokens.input + tokens.output + tokens.cache.read + tokens.cache.write)\n81: elseif message.info.cost and type(message.info.cost) == 'number' then\n82: state.renderer.set_cost(message.info.cost)\n83: end\n84: end\n85: \n86: ---Render pending permissions as a synthetic part at the end of the buffer\n87: function M.render_permissions_display()\n88: local permissions = permission_window.get_all_permissions()\n89: if not permissions or #permissions == 0 then\n90: flush.queue_part_removal('permission-display-part')\n91: flush.queue_message_removal('permission-display-message')\n92: return\n93: end\n94: \n95: local should_scroll = ctx.render_state:get_part('permission-display-part') == nil\n96: \n97: local fake_message = {\n98: info = {\n99: id = 'permission-display-message',\n100: sessionID = state.active_session and state.active_session.id or '',\n101: role = 'system',\n102: },\n103: parts = {},\n104: }\n105: M.on_message_updated(fake_message --[[@as OpencodeMessage]])\n106: \n107: local fake_part = {\n108: id = 'permission-display-part',\n109: messageID = 'permission-display-message',\n110: sessionID = state.active_session and state.active_session.id or '',\n111: type = 'permissions-display',\n112: }\n113: M.on_part_updated({ part = fake_part })\n114: \n115: if should_scroll then\n116: scroll(true)\n117: end\n118: end\n119: \n120: ---Render the current question as a synthetic part at the end of the buffer\n121: function M.render_question_display()\n122: local use_vim_ui = config.ui.questions and config.ui.questions.use_vim_ui_select\n123: if use_vim_ui then\n124: return\n125: end\n126: \n127: local question_window = require('opencode.ui.question_window')\n128: local current_question = question_window._current_question\n129: \n130: if not question_window.has_question() or not current_question or not current_question.id then\n131: flush.queue_part_removal('question-display-part')\n132: flush.queue_message_removal('question-display-message')\n133: return\n134: end\n135: \n136: local should_scroll = ctx.render_state:get_part('question-display-part') == nil\n137: \n138: local fake_message = {\n139: info = {\n140: id = 'question-display-message',\n141: sessionID = state.active_session and state.active_session.id or '',\n142: role = 'system',\n143: },\n144: parts = {},\n145: }\n146: M.on_message_updated(fake_message --[[@as OpencodeMessage]])\n147: \n148: local fake_part = {\n149: id = 'question-display-part',\n150: messageID = 'question-display-message',\n151: sessionID = state.active_session and state.active_session.id or '',\n152: type = 'questions-display',\n153: }\n154: M.on_part_updated({ part = fake_part })\n155: if should_scroll then\n156: scroll(true)\n157: end\n158: end\n159: \n160: ---Remove the question display from the buffer\n161: function M.clear_question_display()\n162: local use_vim_ui = config.ui.questions and config.ui.questions.use_vim_ui_select\n163: local question_window = require('opencode.ui.question_window')\n164: question_window.clear_question()\n165: \n166: if not use_vim_ui then\n167: flush.queue_part_removal('question-display-part')\n168: flush.queue_message_removal('question-display-message')\n169: end\n170: end\n171: \n172: ---Handle message.updated — create the message header or update existing info\n173: ---@param message {info: MessageInfo}\n174: ---@param revert_index? integer\n175: function M.on_message_updated(message, revert_index)\n176: if not state.active_session or not state.messages then\n177: return\n178: end\n179: \n180: local msg = message --[[@as OpencodeMessage]]\n181: if not msg or not msg.info or not msg.info.id or not msg.info.sessionID then\n182: return\n183: end\n184: \n185: if state.active_session.id ~= msg.info.sessionID then\n186: return\n187: end\n188: \n189: local rendered_message = ctx.render_state:get_message(msg.info.id)\n190: local found_msg = rendered_message and rendered_message.message or find_message_in_state(msg.info.id)\n191: \n192: if revert_index then\n193: if not found_msg then\n194: table.insert(state.messages, msg)\n195: found_msg = msg\n196: end\n197: ctx.render_state:set_message(found_msg, 0, 0)\n198: replay_orphan_parts(msg.info.id, revert_index)\n199: return\n200: end\n201: \n202: if found_msg then\n203: if not rendered_message then\n204: ctx.render_state:set_message(found_msg)\n205: flush.mark_message_dirty(msg.info.id)\n206: end\n207: local error_changed = not vim.deep_equal(found_msg.info.error, msg.info.error)\n208: found_msg.info = msg.info\n209: \n210: -- Errors arrive on the message but we display them after the last part.\n211: -- Re-render the last part (or the header if there are no parts) so the\n212: -- error appears in the right place.\n213: if error_changed then\n214: local last_part_id = get_last_part_for_message(found_msg)\n215: if last_part_id then\n216: flush.mark_part_dirty(last_part_id, msg.info.id)\n217: else\n218: flush.mark_message_dirty(msg.info.id)\n219: end\n220: end\n221: else\n222: table.insert(state.messages, msg)\n223: ctx.render_state:set_message(msg)\n224: replay_orphan_parts(msg.info.id)\n225: flush.mark_message_dirty(msg.info.id)\n226: state.renderer.set_current_message(msg)\n227: end\n228: \n229: if msg.info.role == 'user' then\n230: state.renderer.set_last_user_message(msg)\n231: scroll(true)\n232: end\n233: \n234: update_stats(msg)\n235: \n236: if not revert_index and not ctx.bulk_mode and msg.info.id ~= '__opencode_hidden_messages_notice__' then\n237: require('opencode.ui.renderer').reconcile_rendered_message_limit()\n238: end\n239: end\n240: \n241: ---Handle message.removed — remove the message and all its parts from the buffer\n242: ---@param properties {sessionID: string, messageID: string}\n243: function M.on_message_removed(properties)\n244: if not properties or not state.messages then\n245: return\n246: end\n247: \n248: local message_id = properties.messageID\n249: if not message_id then\n250: return\n251: end\n252: \n253: local rendered_message = ctx.render_state:get_message(message_id)\n254: local message = rendered_message and rendered_message.message or find_message_in_state(message_id)\n255: ctx.render_state:clear_orphan_parts(message_id)\n256: if not message then\n257: return\n258: end\n259: \n260: for _, part in ipairs(message.parts or {}) do\n261: if part.id then\n262: flush.queue_part_removal(part.id)\n263: end\n264: end\n265: \n266: flush.queue_message_removal(message_id)\n267: \n268: for i, msg in ipairs(state.messages or {}) do\n269: if msg.info.id == message_id then\n270: table.remove(state.messages, i)\n271: break\n272: end\n273: end\n274: \n275: if not ctx.bulk_mode and message_id ~= '__opencode_hidden_messages_notice__' then\n276: require('opencode.ui.renderer').reconcile_rendered_message_limit()\n277: end\n278: end\n279: \n280: ---Handle message.part.updated — insert or replace a part in the buffer\n281: ---@param properties {part: OpencodeMessagePart}\n282: ---@param revert_index? integer\n283: function M.on_part_updated(properties, revert_index)\n284: if not properties or not properties.part or not state.active_session then\n285: return\n286: end\n287: \n288: local part = properties.part\n289: if not part.id or not part.messageID or not part.sessionID then\n290: return\n291: end\n292: \n293: -- Child-session parts: update the task-tool display instead\n294: if state.active_session.id ~= part.sessionID then\n295: if part.tool or part.type == 'tool' then\n296: ctx.render_state:upsert_child_session_part(part.sessionID, part)\n297: local task_part_id = ctx.render_state:get_task_part_by_child_session(part.sessionID)\n298: if task_part_id then\n299: flush.mark_part_dirty(task_part_id)\n300: end\n301: end\n302: return\n303: end\n304: \n305: local rendered_message = ctx.render_state:get_message(part.messageID)\n306: if not rendered_message then\n307: local existing_message = find_message_in_state(part.messageID)\n308: if existing_message then\n309: ctx.render_state:set_message(existing_message)\n310: rendered_message = ctx.render_state:get_message(part.messageID)\n311: end\n312: end\n313: if not rendered_message or not rendered_message.message then\n314: ctx.render_state:upsert_orphan_part(part.messageID, part)\n315: return\n316: end\n317: \n318: local message = rendered_message.message\n319: message.parts = message.parts or {}\n320: \n321: local part_data = ctx.render_state:get_part(part.id)\n322: local is_new_part = not part_data\n323: \n324: local prev_last_part_id = get_last_part_for_message(message)\n325: local existing_part_index = nil\n326: for i = #message.parts, 1, -1 do\n327: if message.parts[i].id == part.id then\n328: existing_part_index = i\n329: break\n330: end\n331: end\n332: \n333: -- Update the part reference in the message\n334: if is_new_part then\n335: if existing_part_index then\n336: message.parts[existing_part_index] = part\n337: else\n338: table.insert(message.parts, part)\n339: end\n340: else\n341: if existing_part_index then\n342: message.parts[existing_part_index] = part\n343: else\n344: for i = #message.parts, 1, -1 do\n345: if message.parts[i].id == part.id then\n346: message.parts[i] = part\n347: break\n348: end\n349: end\n350: end\n351: end\n352: \n353: -- step-start / step-finish are bookkeeping only — nothing to render\n354: if part.type == 'step-start' or part.type == 'step-finish' then\n355: return\n356: end\n357: \n358: if is_new_part then\n359: ctx.render_state:set_part(part)\n360: else\n361: local rendered_part = ctx.render_state:update_part_data(part)\n362: -- Part known but never rendered yet — treat as new\n363: if not rendered_part or (not rendered_part.line_start and not rendered_part.line_end) then\n364: is_new_part = true\n365: end\n366: end\n367: \n368: -- Update the permission window if this part has a pending permission\n369: if part.callID and state.pending_permissions then\n370: for _, permission in ipairs(state.pending_permissions) do\n371: local tool = permission.tool\n372: local perm_callID = tool and tool.callID or permission.callID\n373: local perm_messageID = tool and tool.messageID or permission.messageID\n374: if perm_callID == part.callID and perm_messageID == part.messageID then\n375: permission_window.update_permission_from_part(permission.id, part)\n376: break\n377: end\n378: end\n379: end\n380: \n381: if revert_index and is_new_part then\n382: return\n383: end\n384: \n385: if is_new_part then\n386: flush.mark_part_dirty(part.id, part.messageID)\n387: \n388: -- If there's already an error on this message, adjust adjacent parts so\n389: -- the error only appears after the last part.\n390: if message.info.error then\n391: if not prev_last_part_id then\n392: flush.mark_message_dirty(part.messageID)\n393: elseif prev_last_part_id ~= part.id then\n394: flush.mark_part_dirty(prev_last_part_id, part.messageID)\n395: end\n396: end\n397: else\n398: flush.mark_part_dirty(part.id, part.messageID)\n399: end\n400: \n401: -- File / agent mentions: re-render the text part to highlight them\n402: if (part.type == 'file' or part.type == 'agent') and part.source then\n403: local text_part_id = find_text_part_for_message(message)\n404: if text_part_id then\n405: flush.mark_part_dirty(text_part_id, part.messageID)\n406: end\n407: end\n408: end\n409: \n410: ---Handle message.part.removed\n411: ---@param properties {sessionID: string, messageID: string, partID: string}\n412: function M.on_part_removed(properties)\n413: if not properties then\n414: return\n415: end\n416: \n417: local part_id = properties.partID\n418: if not part_id then\n419: return\n420: end\n421: \n422: if properties.messageID and ctx.render_state:remove_orphan_part(properties.messageID, part_id) then\n423: return\n424: end\n425: \n426: -- Remove the part from the in-memory message too\n427: local cached = ctx.render_state:get_part(part_id)\n428: local message_id = cached and cached.message_id\n429: if message_id then\n430: local rendered_message = ctx.render_state:get_message(message_id)\n431: if rendered_message and rendered_message.message and rendered_message.message.parts then\n432: for i, part in ipairs(rendered_message.message.parts) do\n433: if part.id == part_id then\n434: table.remove(rendered_message.message.parts, i)\n435: break\n436: end\n437: end\n438: end\n439: end\n440: \n441: flush.queue_part_removal(part_id)\n442: \n443: -- Mark message dirty so header (timestamp, etc.) gets re-rendered\n444: if message_id then\n445: flush.mark_message_dirty(message_id)\n446: end\n447: end\n448: \n449: ---Handle session.updated — re-render the full session if the revert state changed\n450: ---@param properties {info: Session}\n451: function M.on_session_updated(properties)\n452: if not properties or not properties.info or not state.active_session then\n453: return\n454: end\n455: \n456: local updated_session = properties.info\n457: if not updated_session.id or updated_session.id ~= state.active_session.id then\n458: return\n459: end\n460: \n461: local current_session = state.active_session\n462: local revert_changed = not vim.deep_equal(current_session.revert, updated_session.revert)\n463: \n464: if not vim.deep_equal(current_session, updated_session) then\n465: -- Set without emitting a change event to avoid a double re-render\n466: state.store.set_raw('active_session', updated_session)\n467: end\n468: \n469: if revert_changed then\n470: local real_messages = vim.tbl_filter(function(msg)\n471: return not (msg.info and msg.info.id and msg.info.id:match('^__opencode_'))\n472: end, state.messages or {})\n473: require('opencode.ui.renderer')._render_full_session_data(real_messages)\n474: end\n475: end\n476: \n477: ---Handle session.compacted\n478: function M.on_session_compacted()\n479: vim.notify('Session has been compacted')\n480: end\n481: \n482: ---Handle session.error\n483: ---@param properties {sessionID: string, error: table}\n484: function M.on_session_error(properties)\n485: if not properties or not properties.error then\n486: return\n487: end\n488: if config.debug.enabled then\n489: vim.notify('Session error: ' .. vim.inspect(properties.error))\n490: end\n491: end\n492: \n493: ---Handle permission.updated / permission.asked\n494: ---@param permission OpencodePermission\n495: function M.on_permission_updated(permission)\n496: if not permission or not permission.id then\n497: return\n498: end\n499: \n500: local tool = permission.tool\n501: local callID = tool and tool.callID or permission.callID\n502: local messageID = tool and tool.messageID or permission.messageID\n503: \n504: if not state.pending_permissions then\n505: state.renderer.set_pending_permissions({})\n506: end\n507: \n508: local existing_index = nil\n509: for i, existing in ipairs(state.pending_permissions) do\n510: if existing.id == permission.id then\n511: existing_index = i\n512: break\n513: end\n514: end\n515: \n516: state.renderer.update_pending_permissions(function(permissions)\n517: if existing_index then\n518: permissions[existing_index] = permission\n519: else\n520: table.insert(permissions, permission)\n521: end\n522: end)\n523: \n524: permission_window.add_permission(permission)\n525: M.render_permissions_display()\n526: end\n527: \n528: ---Handle permission.replied — remove the resolved permission and update display\n529: ---@param properties {sessionID: string, permissionID?: string, requestID?: string, response: string}\n530: function M.on_permission_replied(properties)\n531: if not properties then\n532: return\n533: end\n534: \n535: local permission_id = properties.permissionID or properties.requestID\n536: if not permission_id then\n537: return\n538: end\n539: \n540: permission_window.remove_permission(permission_id)\n541: state.renderer.set_pending_permissions(vim.deepcopy(permission_window.get_all_permissions()))\n542: \n543: if #state.pending_permissions == 0 then\n544: flush.queue_part_removal('permission-display-part')\n545: flush.queue_message_removal('permission-display-message')\n546: else\n547: M.render_permissions_display()\n548: end\n549: end\n550: \n551: ---Handle question.asked — show the question picker UI\n552: ---@param properties OpencodeQuestionRequest\n553: function M.on_question_asked(properties)\n554: if not properties or not properties.id or not properties.questions then\n555: return\n556: end\n557: require('opencode.ui.question_window').show_question(properties)\n558: end\n559: \n560: ---Handle file.edited — reload buffers and fire the hook\n561: ---@param properties {file: string}\n562: function M.on_file_edited(properties)\n563: vim.cmd('checktime')\n564: if config.hooks and config.hooks.on_file_edited then\n565: pcall(config.hooks.on_file_edited, properties.file)\n566: end\n567: end\n568: \n569: ---Handle custom.restore_point.created\n570: ---@param properties RestorePointCreatedEvent\n571: function M.on_restore_points(properties)\n572: state.store.append('restore_points', properties.restore_point)\n573: if not properties or not properties.restore_point or not properties.restore_point.from_snapshot_id then\n574: return\n575: end\n576: local part = ctx.render_state:get_part_by_snapshot_id(properties.restore_point.from_snapshot_id)\n577: if part then\n578: M.on_part_updated({ part = part })\n579: end\n580: end\n581: \n582: return M\n\n(End of file - total 582 lines)\n", - "type": "text" - }, - "time": 1778413950348, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "id": "prt_e11bb5584003RjZ0boiXLOQKDW", - "filename": "lua/opencode/ui/renderer/events.lua", - "mime": "text/plain", - "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "url": "file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua", - "type": "file" - }, - "time": 1778413950352, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "id": "prt_e11bb5585001WEZnZ73nCfqkNu", - "text": "hi", - "messageID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "type": "text" - }, - "time": 1778413950354, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "slug": "quick-sailor", - "time": { "created": 1778413929849, "updated": 1778413950357 }, - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:52:09.849Z" - } - }, - "type": "session.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bb55a0001UHT3LJivzxddlU", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "variant": "high", - "agent": "build", - "time": { "created": 1778413950368 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "reasoning": 0, - "input": 0, - "output": 0, - "cache": { "write": 0, "read": 0 } - }, - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "slug": "quick-sailor", - "time": { "created": 1778413929849, "updated": 1778413952153 }, - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "summary": { "files": 0, "deletions": 0, "additions": 0 }, - "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:52:09.849Z" - } - }, - "type": "session.updated" - }, - { - "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", "diff": [] }, - "type": "session.diff" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "user", - "time": { "created": 1778413950306 }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "summary": { "diffs": [] }, - "agent": "build", - "model": { - "variant": "high", - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - }, - "id": "msg_e11bb5562001WiWVv8mGf4g0l8" - } - }, - "type": "message.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "slug": "quick-sailor", - "time": { "created": 1778413929849, "updated": 1778413952591 }, - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "summary": { "files": 0, "deletions": 0, "additions": 0 }, - "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "version": "1.14.19", - "title": "Review opencode/ui/renderer/events.lua" - } - }, - "type": "session.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", - "id": "prt_e11bb71b8001bAuUWClHlRqamJ", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", - "type": "step-start" - }, - "time": 1778413957560, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "state": { "input": {}, "raw": "", "status": "pending" }, - "id": "prt_e11bb71bb001wYYOM4TmuXVMTn", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", - "callID": "call_kk38Xdi7A08SabYxR29iupPP", - "tool": "question", - "type": "tool" - }, - "time": 1778413957563, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "questions": [ - { - "question": "What would you like me to do with this file?", - "header": "Next Step", - "options": [ - { - "label": "Review for bugs and issues (Recommended)", - "description": "Scan the file, list problems, and propose fixes or tests." - }, - { - "label": "Explain the code", - "description": "Walk through what the file does and key functions." - }, - { - "label": "Refactor for clarity", - "description": "Make minimal code improvements to simplify or organize." - }, - { - "label": "Add or update tests", - "description": "Create unit tests for the module where applicable." - } - ] - } - ], - "id": "que_e11bb753d001kDD0pjAVsoclbZ", - "tool": { - "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", - "callID": "call_kk38Xdi7A08SabYxR29iupPP" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "question.asked" - }, - { - "properties": { - "part": { - "id": "prt_e11bb71bb001wYYOM4TmuXVMTn", - "state": { - "raw": "", - "input": { - "questions": [ - { - "header": "Next Step", - "question": "What would you like me to do with this file?", - "options": [ - { - "label": "Review for bugs and issues (Recommended)", - "description": "Scan the file, list problems, and propose fixes or tests." - }, - { - "label": "Explain the code", - "description": "Walk through what the file does and key functions." - }, - { - "label": "Refactor for clarity", - "description": "Make minimal code improvements to simplify or organize." - }, - { - "label": "Add or update tests", - "description": "Create unit tests for the module where applicable." - } - ] - } - ] - }, - "time": { "start": 1778413958465 }, - "status": "running" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", - "callID": "call_kk38Xdi7A08SabYxR29iupPP", - "tool": "question", - "type": "tool" - }, - "time": 1778413958465, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "requestID": "que_e11bb753d001kDD0pjAVsoclbZ", - "answers": [["Review for bugs and issues (Recommended)"]], - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "question.replied" - }, - { - "properties": { - "part": { - "id": "prt_e11bb71bb001wYYOM4TmuXVMTn", - "state": { - "metadata": { - "answers": [["Review for bugs and issues (Recommended)"]], - "truncated": false - }, - "time": { "start": 1778413958465, "end": 1778413960806 }, - "status": "completed", - "input": { - "questions": [ - { - "header": "Next Step", - "question": "What would you like me to do with this file?", - "options": [ - { - "label": "Review for bugs and issues (Recommended)", - "description": "Scan the file, list problems, and propose fixes or tests." - }, - { - "label": "Explain the code", - "description": "Walk through what the file does and key functions." - }, - { - "label": "Refactor for clarity", - "description": "Make minimal code improvements to simplify or organize." - }, - { - "label": "Add or update tests", - "description": "Create unit tests for the module where applicable." - } - ] - } - ] - }, - "output": "User has answered your questions: \"What would you like me to do with this file?\"=\"Review for bugs and issues (Recommended)\". You can now continue with the user's answers in mind.", - "title": "Asked 1 question" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", - "callID": "call_kk38Xdi7A08SabYxR29iupPP", - "tool": "question", - "type": "tool" - }, - "time": 1778413960807, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", - "reason": "tool-calls", - "id": "prt_e11bb7e6b001W32Uq8GGpWulom", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb55a0001UHT3LJivzxddlU", - "tokens": { - "input": 14040, - "cache": { "write": 0, "read": 1664 }, - "reasoning": 0, - "output": 514, - "total": 16218 - }, - "cost": 0, - "type": "step-finish" - }, - "time": 1778413960853, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bb55a0001UHT3LJivzxddlU", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "providerID": "github-copilot", - "variant": "high", - "agent": "build", - "time": { "created": 1778413950368 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "input": 14040, - "cache": { "write": 0, "read": 1664 }, - "reasoning": 0, - "output": 514, - "total": 16218 - }, - "modelID": "gpt-5-mini", - "finish": "tool-calls" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bb55a0001UHT3LJivzxddlU", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "providerID": "github-copilot", - "variant": "high", - "agent": "build", - "time": { "created": 1778413950368, "completed": 1778413960899 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "input": 14040, - "cache": { "write": 0, "read": 1664 }, - "reasoning": 0, - "output": 514, - "total": 16218 - }, - "modelID": "gpt-5-mini", - "finish": "tool-calls" - } - }, - "type": "message.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bb7ec8001avsqqdND6rr1pI", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "variant": "high", - "agent": "build", - "time": { "created": 1778413960904 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "reasoning": 0, - "input": 0, - "output": 0, - "cache": { "write": 0, "read": 0 } - }, - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "slug": "quick-sailor", - "time": { "created": 1778413929849, "updated": 1778413960985 }, - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "summary": { "files": 0, "deletions": 0, "additions": 0 }, - "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "version": "1.14.19", - "title": "Review opencode/ui/renderer/events.lua" - } - }, - "type": "session.updated" - }, - { - "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", "diff": [] }, - "type": "session.diff" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "user", - "time": { "created": 1778413950306 }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "summary": { "diffs": [] }, - "agent": "build", - "model": { - "variant": "high", - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - }, - "id": "msg_e11bb5562001WiWVv8mGf4g0l8" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", - "id": "prt_e11bbd5d60012HccDJqp0HHd0Q", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "type": "step-start" - }, - "time": 1778413983190, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "id": "prt_e11bbd5d80012fAWaj2OnpPwKl", - "state": { - "raw": "", - "input": { "pattern": "render_state:get_part\\(", "path": "" }, - "time": { "start": 1778413983196 }, - "status": "running" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_yT3yanzgervTiEPC1JDDAq4Q", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983197, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "id": "prt_e11bbd5d80012fAWaj2OnpPwKl", - "state": { - "metadata": { "matches": 39, "truncated": false }, - "time": { "start": 1778413983196, "end": 1778413983262 }, - "status": "completed", - "input": { "pattern": "render_state:get_part\\(", "path": "" }, - "output": "Found 39 matches\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:\n Line 145: local existing_part = ctx.render_state:get_part(HIDDEN_MESSAGES_NOTICE_PART_ID)\n\n Line 155: local part_data = ctx.render_state:get_part(HIDDEN_MESSAGES_NOTICE_PART_ID)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 95: local should_scroll = ctx.render_state:get_part('permission-display-part') == nil\n\n Line 136: local should_scroll = ctx.render_state:get_part('question-display-part') == nil\n\n Line 321: local part_data = ctx.render_state:get_part(part.id)\n\n Line 427: local cached = ctx.render_state:get_part(part_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/flush.lua:\n Line 224: local rendered_part = ctx.render_state:get_part(part_id)\n\n Line 243: local rendered_part = ctx.render_state:get_part(part_id)\n\n Line 325: local rendered_part = ctx.render_state:get_part(part_id)\n\n Line 374: local cached = ctx.render_state:get_part(part_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/buffer.lua:\n Line 386: local previous_rendered = ctx.render_state:get_part(previous.id)\n\n Line 420: local part_data = ctx.render_state:get_part(part_id)\n\n Line 429: local part_data = ctx.render_state:get_part(part_id)\n\n Line 566: local part_data = ctx.render_state:get_part(part_id)\n\n Line 575: local cached = ctx.render_state:get_part(part_id)\n\n Line 614: local part_data = ctx.render_state:get_part(part_id)\n\n Line 640: local cached_part = ctx.render_state:get_part(part_id_iter)\n\n Line 661: local cached = ctx.render_state:get_part(part_id)\n\n Line 695: local cached = ctx.render_state:get_part(part_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/render_state_spec.lua:\n Line 79: local result = render_state:get_part('part1')\n\n Line 102: local result = render_state:get_part('part1')\n\n Line 207: local result = render_state:get_part('part1')\n\n Line 221: local result = render_state:get_part('part1')\n\n Line 234: local result = render_state:get_part('part1')\n\n Line 287: local result = render_state:get_part('part1')\n\n Line 300: local result2 = render_state:get_part('part2')\n\n Line 313: local result2 = render_state:get_part('part2')\n\n Line 357: assert.is_nil(render_state:get_part('part1'))\n\n Line 359: local result2 = render_state:get_part('part2')\n\n Line 462: local result = render_state:get_part('part1')\n\n Line 475: local result1 = render_state:get_part('part1')\n\n Line 479: local result2 = render_state:get_part('part2')\n\n Line 493: local result = render_state:get_part('part1')\n\n Line 529: local result1 = render_state:get_part('part1')\n\n Line 532: local result2 = render_state:get_part('part2')\n\n Line 543: local result = render_state:get_part('part1')\n\n Line 558: local result = render_state:get_part('part1')\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-replied.json:\n Line 36: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-ask.json:\n Line 50: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...", - "title": "render_state:get_part\\(" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_yT3yanzgervTiEPC1JDDAq4Q", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983262, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "id": "prt_e11bbd62c00138ruVIpzIUXaYE", - "state": { - "raw": "", - "input": { "pattern": "get_part_by_snapshot_id", "path": "" }, - "time": { "start": 1778413983282 }, - "status": "running" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_fRqXmBTHj2m5HvzzXSPXOQhr", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983282, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "state": { "input": {}, "raw": "", "status": "pending" }, - "id": "prt_e11bbd63b001aOQFetAbSmXll4", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_E16UuoqooiCsCc7YS5WBHtG6", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983291, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "id": "prt_e11bbd62c00138ruVIpzIUXaYE", - "state": { - "metadata": { "matches": 4, "truncated": false }, - "time": { "start": 1778413983282, "end": 1778413983312 }, - "status": "completed", - "input": { "pattern": "get_part_by_snapshot_id", "path": "" }, - "output": "Found 4 matches\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 576: local part = ctx.render_state:get_part_by_snapshot_id(properties.restore_point.from_snapshot_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/render_state.lua:\n Line 342: function RenderState:get_part_by_snapshot_id(snapshot_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-ask.json:\n Line 50: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/data/question-replied.json:\n Line 36: \"text\": \"\\n00001| local state = require('opencode.state')\\n00002| local config = require('opencode.config')\\n00003| local formatter = require('opencode.ui.formatter')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local permission_window = require('opencode.ui.permission_window')\\n00006| local Promise = require('opencode.promise')\\n00007| local RenderState = require('opencode.ui.render_state')\\n00008| \\n00009| local M = {\\n00010| _prev_line_count = 0,\\n00011| _render_state = RenderState.new(),\\n00012| _last_part_formatted = {\\n00013| part_id = nil,\\n00014| formatted_data = nil --[[@as Output|nil]],\\n00015| },\\n00016| }\\n00017| \\n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\\n00020| \\n00021| if cb_type == 'boolean' then\\n00022| return\\n00023| end\\n00024| \\n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\\n00026| return\\n00027| end\\n00028| \\n00029| if cb_type == 'function' then\\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\\n00032| vim.cmd(':RenderMarkdown')\\n00033| elseif vim.fn.exists(':Markview') > 0 then\\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\\n00035| end\\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\\n00037| \\n00038| ---Reset renderer state\\n00039| function M.reset()\\n00040| M._prev_line_count = 0\\n00041| M._render_state:reset()\\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\\n00043| \\n00044| output_window.clear()\\n00045| \\n00046| state.messages = {}\\n00047| state.last_user_message = nil\\n00048| state.tokens_count = 0\\n00049| \\n00050| local permissions = state.pending_permissions or {}\\n00051| if #permis...", - "title": "get_part_by_snapshot_id" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_fRqXmBTHj2m5HvzzXSPXOQhr", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983312, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "id": "prt_e11bbd63b001aOQFetAbSmXll4", - "state": { - "raw": "", - "input": { "pattern": "message_id", "path": "" }, - "time": { "start": 1778413983325 }, - "status": "running" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_E16UuoqooiCsCc7YS5WBHtG6", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983325, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "id": "prt_e11bbd65f0010OjncQiHOoeErP", - "state": { - "raw": "", - "input": { "pattern": "messageID", "path": "" }, - "time": { "start": 1778413983330 }, - "status": "running" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_ViEopy48Z03zxmZSRiW58Bli", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983330, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "id": "prt_e11bbd63b001aOQFetAbSmXll4", - "state": { - "metadata": { "matches": 260, "truncated": true }, - "time": { "start": 1778413983325, "end": 1778413983371 }, - "status": "completed", - "input": { "pattern": "message_id", "path": "" }, - "output": "Found 260 matches (showing first 100)\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:\n Line 27: local message_id = message and message.info and message.info.id\n\n Line 28: return message_id == '__opencode_revert_message__' or message_id == HIDDEN_MESSAGES_NOTICE_MESSAGE_ID\n\n Line 50: local revert_message_id = revert and revert.messageID\n\n Line 51: if not revert_message_id then\n\n Line 57: if message.info and message.info.id == revert_message_id then\n\n Line 108: ---@param message_id string\n\n Line 110: local function find_message_in_state(message_id)\n\n Line 112: if message.info and message.info.id == message_id then\n\n Line 121: local message_id = message.info and message.info.id\n\n Line 122: if not message_id or ctx.render_state:get_message(message_id) then\n\n Line 127: flush.mark_message_dirty(message_id)\n\n Line 132: flush.mark_part_dirty(part.id, message_id)\n\n Line 171: ---@param message_id string\n\n Line 172: local function hide_rendered_message(message_id)\n\n Line 173: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 174: local message = rendered_message and rendered_message.message or find_message_in_state(message_id)\n\n Line 179: ctx.render_state:clear_orphan_parts(message_id)\n\n Line 185: flush.queue_message_removal(message_id)\n\n Line 204: local message_id = message.info and message.info.id\n\n Line 205: if message_id then\n\n Line 206: visible_ids[message_id] = true\n\n Line 212: local message_id = message.info and message.info.id\n\n Line 213: if message_id and not visible_ids[message_id] and ctx.render_state:get_message(message_id) then\n\n Line 214: hide_rendered_message(message_id)\n\n Line 225: ---@param message_id string|nil\n\n Line 227: local function is_message_visible(message_id)\n\n Line 228: if not message_id then\n\n Line 233: if message.info and message.info.id == message_id then\n\n Line 490: ---@param message_id string\n\n Line 492: function M.get_rendered_message(message_id)\n\n Line 493: return ctx.render_state:get_message(message_id) or nil\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 36: ---@param message_id string|nil\n\n Line 38: local function find_message_in_state(message_id)\n\n Line 39: if not message_id then\n\n Line 44: if message.info and message.info.id == message_id then\n\n Line 60: ---@param message_id string\n\n Line 62: local function replay_orphan_parts(message_id, revert_index)\n\n Line 63: local orphan_parts = ctx.render_state:consume_orphan_parts(message_id)\n\n Line 248: local message_id = properties.messageID\n\n Line 249: if not message_id then\n\n Line 253: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 254: local message = rendered_message and rendered_message.message or find_message_in_state(message_id)\n\n Line 255: ctx.render_state:clear_orphan_parts(message_id)\n\n Line 266: flush.queue_message_removal(message_id)\n\n Line 269: if msg.info.id == message_id then\n\n Line 275: if not ctx.bulk_mode and message_id ~= '__opencode_hidden_messages_notice__' then\n\n Line 428: local message_id = cached and cached.message_id\n\n Line 429: if message_id then\n\n Line 430: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 444: if message_id then\n\n Line 445: flush.mark_message_dirty(message_id)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/flush.lua:\n Line 18: local pinned_overlay_message_ids = {\n\n Line 24: ---@param message_id string|nil\n\n Line 26: local function warn_part_render_error_once(part_id, message_id, err)\n\n Line 36: tostring(message_id),\n\n Line 176: ---@param message_id string|nil\n\n Line 178: local function track_message_for_part(message_id, part_id)\n\n Line 179: if not message_id or not part_id then\n\n Line 183: local part_ids = ctx.pending.dirty_part_by_message[message_id]\n\n Line 186: ctx.pending.dirty_part_by_message[message_id] = part_ids\n\n Line 191: ---@param message_id string|nil\n\n Line 193: local function untrack_message_for_part(message_id, part_id)\n\n Line 194: local part_ids = message_id and ctx.pending.dirty_part_by_message[message_id]\n\n Line 200: ctx.pending.dirty_part_by_message[message_id] = nil\n\n Line 204: ---@param message_id string|nil\n\n Line 205: function M.mark_message_dirty(message_id)\n\n Line 206: if not message_id then\n\n Line 209: ctx.pending.removed_messages[message_id] = nil\n\n Line 210: enqueue_once(ctx.pending.dirty_message_order, ctx.pending.dirty_messages, message_id)\n\n Line 211: ctx.pending.dirty_messages[message_id] = true\n\n Line 213: ctx.formatted_messages[message_id] = nil\n\n Line 218: ---@param message_id? string\n\n Line 219: function M.mark_part_dirty(part_id, message_id)\n\n Line 225: message_id = message_id or (rendered_part and rendered_part.message_id)\n\n Line 226: if not message_id then\n\n Line 232: ctx.pending.dirty_parts[part_id] = message_id\n\n Line 233: track_message_for_part(message_id, part_id)\n\n Line 244: if rendered_part and rendered_part.message_id then\n\n Line 245: untrack_message_for_part(rendered_part.message_id, part_id)\n\n Line 255: ---@param message_id string|nil\n\n Line 256: function M.queue_message_removal(message_id)\n\n Line 257: if not message_id then\n\n Line 261: ctx.pending.dirty_messages[message_id] = nil\n\n Line 262: ctx.pending.dirty_part_by_message[message_id] = nil\n\n Line 263: enqueue_once(ctx.pending.removed_message_order, ctx.pending.removed_messages, message_id)\n\n Line 264: ctx.pending.removed_messages[message_id] = true\n\n Line 265: ctx.formatted_messages[message_id] = nil\n\n Line 299: ---@param message_id string\n\n Line 301: local function format_message(message_id)\n\n Line 302: local rendered_message = ctx.render_state:get_message(message_id)\n\n Line 308: local prev = ctx.formatted_messages[message_id]\n\n Line 309: local previous_rendered = ctx.render_state:get_previous_message(state.messages or {}, message_id)\n\n Line 317: ctx.formatted_messages[message_id] = formatted\n\n Line 323: ---@return string|nil message_id\n\n Line 330: local rendered_message = ctx.render_state:get_message(rendered_part.message_id)\n\n Line 347: warn_part_render_error_once(part_id, rendered_part.message_id, formatted_or_err)\n\n Line 348: return nil, rendered_part.message_id\n\n Line 351: return formatted_or_err, rendered_part.message_id\n\n Line 354: ---@param message_id string\n\n Line 355: local function apply_message(message_id)\n\n\n(Results truncated: showing 100 of 260 matches (160 hidden). Consider using a more specific path or pattern.)", - "title": "message_id" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_E16UuoqooiCsCc7YS5WBHtG6", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983371, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "id": "prt_e11bbd65f0010OjncQiHOoeErP", - "state": { - "metadata": { "matches": 4439, "truncated": true }, - "time": { "start": 1778413983330, "end": 1778413983490 }, - "status": "completed", - "input": { "pattern": "messageID", "path": "" }, - "output": "Found 4439 matches (showing first 100)\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua:\n Line 50: local revert_message_id = revert and revert.messageID\n\n Line 97: messageID = HIDDEN_MESSAGES_NOTICE_MESSAGE_ID,\n\n Line 365: messageID = '__opencode_revert_message__',\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/permission_window_spec.lua:\n Line 366: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 395: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 424: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 453: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 482: tool = { messageID = 'msg_unknown', callID = 'call_unknown' },\n\n Line 525: tool = { messageID = 'msg_1', callID = 'call_1' },\n\n Line 530: tool = { messageID = 'msg_2', callID = 'call_2' },\n\n Line 558: tool = { messageID = 'msg_2', callID = 'call_2' },\n\n Line 563: it('uses root-level callID/messageID when tool field is absent', function()\n\n Line 570: messageID = 'msg_1',\n\n Line 595: it('stores messageID and callID from permission.tool', function()\n\n Line 600: messageID = 'msg_123',\n\n Line 623: it('handles permission.tool without messageID or callID', function()\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer/events.lua:\n Line 109: messageID = 'permission-display-message',\n\n Line 150: messageID = 'question-display-message',\n\n Line 242: ---@param properties {sessionID: string, messageID: string}\n\n Line 248: local message_id = properties.messageID\n\n Line 289: if not part.id or not part.messageID or not part.sessionID then\n\n Line 305: local rendered_message = ctx.render_state:get_message(part.messageID)\n\n Line 307: local existing_message = find_message_in_state(part.messageID)\n\n Line 310: rendered_message = ctx.render_state:get_message(part.messageID)\n\n Line 314: ctx.render_state:upsert_orphan_part(part.messageID, part)\n\n Line 373: local perm_messageID = tool and tool.messageID or permission.messageID\n\n Line 374: if perm_callID == part.callID and perm_messageID == part.messageID then\n\n Line 386: flush.mark_part_dirty(part.id, part.messageID)\n\n Line 392: flush.mark_message_dirty(part.messageID)\n\n Line 394: flush.mark_part_dirty(prev_last_part_id, part.messageID)\n\n Line 398: flush.mark_part_dirty(part.id, part.messageID)\n\n Line 405: flush.mark_part_dirty(text_part_id, part.messageID)\n\n Line 411: ---@param properties {sessionID: string, messageID: string, partID: string}\n\n Line 422: if properties.messageID and ctx.render_state:remove_orphan_part(properties.messageID, part_id) then\n\n Line 502: local messageID = tool and tool.messageID or permission.messageID\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/types.lua:\n Line 115: ---@field messageID string\n\n Line 478: ---@field tool? { messageID: string, callID: string }\n\n Line 641: ---@field messageID string|nil Message identifier\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/api_client.lua:\n Line 195: --- @param init_data {messageID: string, providerID: string, modelID: string} Initialization data\n\n Line 237: --- @param fork_data {messageID?: string}|nil Fork data\n\n Line 256: --- @param message_data {messageID?: string, model?: {providerID: string, modelID: string}, agent?: string, variant?: string, system?: string, tools?: table, parts: OpencodeMessagePart[]} Message creation data\n\n Line 265: --- @param messageID string Message ID (required)\n\n Line 268: function OpencodeApiClient:get_message(id, messageID, directory)\n\n Line 269: return self:_call('/session/' .. id .. '/message/' .. messageID, 'GET', nil, { directory = directory })\n\n Line 274: --- @param command_data {messageID?: string, agent?: string, model?: string, arguments: string, command: string} Command data\n\n Line 292: --- @param revert_data {messageID: string, partID?: string} Revert data\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/permission_window.lua:\n Line 21: local message_id = (tool and tool.messageID) or permission.messageID\n\n Line 86: permission._message_id = permission.tool.messageID\n\n Line 367: local tool_message_id = tool and tool.messageID\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua:\n Line 683: M._format_assistant_message(output, vim.trim(part.text), part.messageID)\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/commands/handlers/session.lua:\n Line 208: messageID = id.ascending('message'),\n\n Line 277: messageID = message_to_revert,\n\n Line 301: if message.info.id == revert.messageID then\n\n Line 327: if not active_session.revert or active_session.revert.messageID == '' then\n\n Line 344: messageID = next_message_id,\n\n Line 385: messageID = message_to_fork,\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/formatter_spec.lua:\n Line 36: messageID = 'msg_1',\n\n Line 80: messageID = 'msg_1',\n\n Line 103: messageID = 'msg_child_1',\n\n Line 149: messageID = 'msg_1',\n\n Line 172: messageID = 'msg_child_1',\n\n Line 219: messageID = 'msg_1',\n\n Line 290: messageID = 'msg_1',\n\n Line 344: messageID = 'msg_1',\n\n Line 477: messageID = 'msg_prev',\n\n Line 486: messageID = 'msg_current',\n\n Line 546: messageID = 'msg_1',\n\n Line 569: messageID = 'msg_child_1',\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua:\n Line 180: revert = { messageID = 'msg_1', snapshot = 'a', diff = '' },\n\n Line 190: revert = { messageID = 'msg_2', snapshot = 'b', diff = '' },\n\n Line 207: revert = { messageID = 'msg_1', snapshot = 'a', diff = '' },\n\n Line 244: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 250: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 256: { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 289: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 295: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 307: part = { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 339: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 345: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 351: { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 357: { id = 'part_4', messageID = 'msg_4', sessionID = 'ses_123', type = 'text', text = 'fourth' },\n\n Line 362: events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_1' })\n\n Line 389: { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' },\n\n Line 395: { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' },\n\n Line 401: { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' },\n\n Line 407: { id = 'part_4', messageID = 'msg_4', sessionID = 'ses_123', type = 'text', text = 'fourth' },\n\n Line 412: events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_1' })\n\n Line 418: events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_2' })\n\n\n/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/event_manager.lua:\n Line 21: --- @field properties {sessionID: string, messageID: string}\n\n Line 31: --- messageID: string,\n\n Line 39: --- @field properties {sessionID: string, messageID: string, partID: string}\n\n Line 78: --- @field tool? {messageID: string, callID: string}\n\n Line 79: --- @field messageID string\n\n Line 90: --- @field tool? {messageID: string, callID: string}\n\n Line 91: --- @field messageID string\n\n Line 328: local message_id = properties.messageID\n\n Line 340: messageID = message_id,\n\n\n/home/francis/Projects/_nvim/opencode.nvim/tests/unit/permission_integration_spec.lua:\n Line 38: it('correlates part with pending permission by callID and messageID', function()\n\n Line 44: messageID = 'msg_abc',\n\n Line 59: messageID = 'msg_abc',\n\n\n(Results truncated: showing 100 of 4439 matches (4339 hidden). Consider using a more specific path or pattern.)", - "title": "messageID" - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "callID": "call_ViEopy48Z03zxmZSRiW58Bli", - "tool": "grep", - "type": "tool" - }, - "time": 1778413983490, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", - "reason": "tool-calls", - "id": "prt_e11bbd705001KXYvvovAGpaHlR", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "messageID": "msg_e11bb7ec8001avsqqdND6rr1pI", - "tokens": { - "input": 260, - "cache": { "write": 0, "read": 15616 }, - "reasoning": 0, - "output": 2284, - "total": 18160 - }, - "cost": 0, - "type": "step-finish" - }, - "time": 1778413983564, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "message.part.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bb7ec8001avsqqdND6rr1pI", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "providerID": "github-copilot", - "variant": "high", - "agent": "build", - "time": { "created": 1778413960904 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "input": 260, - "cache": { "write": 0, "read": 15616 }, - "reasoning": 0, - "output": 2284, - "total": 18160 - }, - "modelID": "gpt-5-mini", - "finish": "tool-calls" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bb7ec8001avsqqdND6rr1pI", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "providerID": "github-copilot", - "variant": "high", - "agent": "build", - "time": { "created": 1778413960904, "completed": 1778413983618 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "input": 260, - "cache": { "write": 0, "read": 15616 }, - "reasoning": 0, - "output": 2284, - "total": 18160 - }, - "modelID": "gpt-5-mini", - "finish": "tool-calls" - } - }, - "type": "message.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bbd786001k7g5z8cTH6yNxa", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "variant": "high", - "agent": "build", - "time": { "created": 1778413983622 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "reasoning": 0, - "input": 0, - "output": 0, - "cache": { "write": 0, "read": 0 } - }, - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "slug": "quick-sailor", - "time": { "created": 1778413929849, "updated": 1778413983699 }, - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "summary": { "files": 0, "deletions": 0, "additions": 0 }, - "id": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "version": "1.14.19", - "title": "Review opencode/ui/renderer/events.lua" - } - }, - "type": "session.updated" - }, - { - "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", "diff": [] }, - "type": "session.diff" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "user", - "time": { "created": 1778413950306 }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "summary": { "diffs": [] }, - "agent": "build", - "model": { - "variant": "high", - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - }, - "id": "msg_e11bb5562001WiWVv8mGf4g0l8" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "cancel", - "source": { "argv": ["cancel"], "raw_args": "cancel" } - } - }, - "intent": { - "args": [], - "name": "cancel", - "source": { "argv": ["cancel"], "raw_args": "cancel" } - } - }, - "type": "custom.command.before" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "error": { - "name": "MessageAbortedError", - "data": { "message": "Aborted" } - }, - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" - }, - "type": "session.error" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "idle" } - }, - "type": "session.status" - }, - { - "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" }, - "type": "session.idle" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "cancel", - "source": { "argv": ["cancel"], "raw_args": "cancel" } - } - }, - "intent": { - "args": [], - "name": "cancel", - "source": { "argv": ["cancel"], "raw_args": "cancel" } - } - }, - "type": "custom.command.after" - }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "cancel", - "source": { "argv": ["cancel"], "raw_args": "cancel" } - } - }, - "intent": { - "args": [], - "name": "cancel", - "source": { "argv": ["cancel"], "raw_args": "cancel" } - } - }, - "type": "custom.command.finally" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "info": { - "role": "assistant", - "id": "msg_e11bbd786001k7g5z8cTH6yNxa", - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "mode": "build", - "error": { - "name": "MessageAbortedError", - "data": { "message": "Aborted" } - }, - "parentID": "msg_e11bb5562001WiWVv8mGf4g0l8", - "cost": 0, - "variant": "high", - "agent": "build", - "time": { "created": 1778413983622, "completed": 1778413988368 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "reasoning": 0, - "input": 0, - "output": 0, - "cache": { "write": 0, "read": 0 } - }, - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - } - }, - "type": "message.updated" - }, - { - "properties": { - "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I", - "status": { "type": "idle" } - }, - "type": "session.status" - }, - { - "properties": { "sessionID": "ses_1ee44fa86ffe1y87hoy2vvLE5I" }, - "type": "session.idle" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { - "properties": { - "args": [], - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "type": "custom.command.before" - }, - { - "properties": { - "args": [], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "type": "custom.command.after" - }, - { - "properties": { - "args": [], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": false - }, - "parsed": { - "ok": true, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "intent": { - "args": [], - "name": "open_input_new_session", - "source": { - "argv": ["open_input_new_session"], - "raw_args": "open_input_new_session" - } - } - }, - "type": "custom.command.finally" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "slug": "cosmic-cactus", - "time": { "created": 1778413989960, "updated": 1778413989960 }, - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:53:09.960Z" - } - }, - "type": "session.created" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "slug": "cosmic-cactus", - "time": { "created": 1778413989960, "updated": 1778413989960 }, - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:53:09.960Z" - } - }, - "type": "session.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { - "properties": { - "args": ["n"], - "parsed": { - "ok": true, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "type": "custom.command.before" - }, - { - "properties": { - "args": ["n"], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": true - }, - "parsed": { - "ok": true, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "type": "custom.command.after" - }, - { - "properties": { - "args": ["n"], - "result": { - "_catch_callbacks": [], - "_then_callbacks": [], - "_coroutines": [], - "_resolved": true - }, - "parsed": { - "ok": true, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "intent": { - "args": ["n"], - "name": "submit_input_prompt", - "source": { - "argv": ["submit_input_prompt", "n"], - "raw_args": "submit_input_prompt n" - } - } - }, - "type": "custom.command.finally" - }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "role": "user", - "agent": "build", - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "id": "msg_e11bc493e001Q3qP1LNyp9QyD4", - "model": { - "variant": "high", - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - }, - "time": { "created": 1778414012734 } - } - }, - "type": "message.updated" - }, - { - "properties": { - "part": { - "id": "prt_e11bc493f001KxcOFSNopft1QL", - "text": "I am doing a test just answer hello there", - "messageID": "msg_e11bc493e001Q3qP1LNyp9QyD4", - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "type": "text" - }, - "time": 1778414012740, - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" - }, - "type": "message.part.updated" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "slug": "cosmic-cactus", - "time": { "created": 1778413989960, "updated": 1778414012744 }, - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:53:09.960Z" - } - }, - "type": "session.updated" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "role": "assistant", - "id": "msg_e11bc494e001Q5WVKeHSzcB27f", - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bc493e001Q3qP1LNyp9QyD4", - "variant": "high", - "agent": "build", - "time": { "created": 1778414012750 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "reasoning": 0, - "input": 0, - "output": 0, - "cache": { "write": 0, "read": 0 } - }, - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "slug": "cosmic-cactus", - "time": { "created": 1778413989960, "updated": 1778414012807 }, - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "summary": { "files": 0, "deletions": 0, "additions": 0 }, - "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "version": "1.14.19", - "title": "New session - 2026-05-10T11:53:09.960Z" - } - }, - "type": "session.updated" - }, - { - "properties": { "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", "diff": [] }, - "type": "session.diff" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "role": "user", - "time": { "created": 1778414012734 }, - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "summary": { "diffs": [] }, - "agent": "build", - "model": { - "variant": "high", - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - }, - "id": "msg_e11bc493e001Q3qP1LNyp9QyD4" - } - }, - "type": "message.updated" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "slug": "cosmic-cactus", - "time": { "created": 1778413989960, "updated": 1778414014900 }, - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "summary": { "files": 0, "deletions": 0, "additions": 0 }, - "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "version": "1.14.19", - "title": "Greeting test — reply hello there" - } - }, - "type": "session.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "part": { - "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", - "id": "prt_e11bc595e001klMTjb2wsG5puf", - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "messageID": "msg_e11bc494e001Q5WVKeHSzcB27f", - "type": "step-start" - }, - "time": 1778414016862, - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "time": { "start": 1778414016865, "end": 1778414016867 }, - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "messageID": "msg_e11bc494e001Q5WVKeHSzcB27f", - "id": "prt_e11bc5961001s44upipLgbW5W5", - "text": "hello there", - "type": "text" - }, - "time": 1778414016867, - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" - }, - "type": "message.part.updated" - }, - { - "properties": { - "part": { - "snapshot": "5d58740ff1dd1e88f500a8adadb178306ea53f23", - "reason": "stop", - "id": "prt_e11bc5965001uoiWwmoHy3mPem", - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "messageID": "msg_e11bc494e001Q5WVKeHSzcB27f", - "tokens": { - "input": 141, - "cache": { "write": 0, "read": 10112 }, - "reasoning": 0, - "output": 204, - "total": 10457 - }, - "cost": 0, - "type": "step-finish" - }, - "time": 1778414016906, - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" - }, - "type": "message.part.updated" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "role": "assistant", - "id": "msg_e11bc494e001Q5WVKeHSzcB27f", - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bc493e001Q3qP1LNyp9QyD4", - "providerID": "github-copilot", - "variant": "high", - "agent": "build", - "time": { "created": 1778414012750 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "input": 141, - "cache": { "write": 0, "read": 10112 }, - "reasoning": 0, - "output": 204, - "total": 10457 - }, - "modelID": "gpt-5-mini", - "finish": "stop" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" }, - { "properties": [], "type": "custom.emit_events.started" }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "role": "assistant", - "id": "msg_e11bc494e001Q5WVKeHSzcB27f", - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "mode": "build", - "cost": 0, - "parentID": "msg_e11bc493e001Q3qP1LNyp9QyD4", - "providerID": "github-copilot", - "variant": "high", - "agent": "build", - "time": { "created": 1778414012750, "completed": 1778414016944 }, - "path": { - "root": "/home/francis/Projects/_nvim/opencode.nvim", - "cwd": "/home/francis/Projects/_nvim/opencode.nvim" - }, - "tokens": { - "input": 141, - "cache": { "write": 0, "read": 10112 }, - "reasoning": 0, - "output": 204, - "total": 10457 - }, - "modelID": "gpt-5-mini", - "finish": "stop" - } - }, - "type": "message.updated" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "status": { "type": "busy" } - }, - "type": "session.status" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "status": { "type": "idle" } - }, - "type": "session.status" - }, - { - "properties": { "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw" }, - "type": "session.idle" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "slug": "cosmic-cactus", - "time": { "created": 1778413989960, "updated": 1778414016964 }, - "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", - "directory": "/home/francis/Projects/_nvim/opencode.nvim", - "summary": { "files": 0, "deletions": 0, "additions": 0 }, - "id": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "version": "1.14.19", - "title": "Greeting test — reply hello there" - } - }, - "type": "session.updated" - }, - { - "properties": { "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", "diff": [] }, - "type": "session.diff" - }, - { - "properties": { - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "info": { - "role": "user", - "time": { "created": 1778414012734 }, - "sessionID": "ses_1ee440fb7ffeu3AYzcLtrz4Ukw", - "summary": { "diffs": [] }, - "agent": "build", - "model": { - "variant": "high", - "modelID": "gpt-5-mini", - "providerID": "github-copilot" - }, - "id": "msg_e11bc493e001Q3qP1LNyp9QyD4" - } - }, - "type": "message.updated" - }, - { "properties": [], "type": "custom.emit_events.finished" } -] diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 614932e6..69c06510 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -157,6 +157,35 @@ describe('hooks', function() session_runtime.on_session_request_completed('test-session'):wait() end) end) + + it('should call hook for idle child or externally-created sessions', function() + local original_manager = state.event_manager + local idle_callback + local manager = { + subscribe = function(_, event_name, callback) + if event_name == 'session.idle' then + idle_callback = callback + end + end, + unsubscribe = function() end, + } + local called_session + config.hooks.on_done_thinking = function(session) + called_session = session + end + + state.jobs.set_event_manager(manager) + session_runtime.setup() + idle_callback({ sessionID = 'test-session' }) + + vim.wait(50, function() + return called_session ~= nil + end) + + assert.equals('test-session', called_session.id) + state.jobs.set_event_manager(original_manager) + session_runtime.setup() + end) end) describe('on_permission_requested', function() diff --git a/tests/unit/renderer_session_tabs_spec.lua b/tests/unit/renderer_session_tabs_spec.lua index ba21d229..1ff107a8 100644 --- a/tests/unit/renderer_session_tabs_spec.lua +++ b/tests/unit/renderer_session_tabs_spec.lua @@ -119,4 +119,37 @@ describe('renderer session tab contexts', function() assert.is_true(second.renderer_dirty) render_stub:revert() end) + + it('refreshes a dirty tab after its windows are mounted', function() + local first = session_tabs.ensure_current() + first.active_session = { id = 'session-one', title = 'One' } + local second = session_tabs.create({ id = 'session-two', title = 'Two' }) + second.renderer_dirty = false + + store.set_raw('active_session_tab', second.id) + store.set_raw('active_session', second.active_session) + renderer.on_session_tab_changed(nil, second.id, first.id) + assert.is_true(second.renderer_dirty) + + output_buf = vim.api.nvim_create_buf(false, true) + output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 60, + height = 10, + row = 1, + col = 1, + }) + state.ui.set_windows({ output_buf = output_buf, output_win = output_win }) + state.jobs.set_api_client({}) + + local render_stub = stub(renderer, 'render_full_session').returns(Promise.new():resolve({})) + renderer.on_windows_mounted() + + vim.wait(20, function() + return not second.renderer_dirty + end) + assert.stub(render_stub).was_called(1) + assert.is_false(second.renderer_dirty) + render_stub:revert() + end) end) diff --git a/tests/unit/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua index fd4b36fa..d5c7ce37 100644 --- a/tests/unit/services_messaging_spec.lua +++ b/tests/unit/services_messaging_spec.lua @@ -443,4 +443,19 @@ describe('opencode.services.messaging', function() context.get_context()[key] = value end end) + + it('preserves a two-argument sent context passed to after_run', function() + state.session.set_active({ id = 'sess1' }) + local sent_context = { + mentioned_files = { '/tmp/attached.lua' }, + selections = { { content = 'selected' } }, + } + local original_delta_context = context.delta_context + context.delta_context = function() end + + messaging.after_run('hello', sent_context) + + assert.same(sent_context, state.last_sent_context) + context.delta_context = original_delta_context + end) end) diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index ad33e339..e80f636c 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -804,6 +804,16 @@ describe('opencode.services.session_runtime', function() session_runtime.cancel():wait() assert.is_equal(1, vim.g.opencode_abort_count) end) + + it('counts automatic cancellation even after the pending request count is cleared', function() + state.session.set_active({ id = 'sess1' }) + store.set('job_count', 0) + vim.g.opencode_abort_count = 0 + + session_runtime.cancel('sess1', nil, { count_abort = true }):wait() + + assert.is_equal(1, vim.g.opencode_abort_count) + end) end) describe('opencode_ok (version checks)', function() diff --git a/tests/unit/session_tab_lifecycle_spec.lua b/tests/unit/session_tab_lifecycle_spec.lua index 6d0d1f1c..b8d051d1 100644 --- a/tests/unit/session_tab_lifecycle_spec.lua +++ b/tests/unit/session_tab_lifecycle_spec.lua @@ -115,4 +115,11 @@ describe('session tab lifecycle', function() state.model.set_model('provider/third') assert.equals('medium', state.current_variant) end) + + it('does not close the active tab when given an unknown tab id', function() + local current = tabs.ensure_current() + + assert.is_false(session_runtime.close_session_tab('missing-tab')) + assert.equals(current, tabs.current()) + end) end) From 62b318d771ca28599d43fa461309d1cbd4be23e4 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 10 Sep 2026 08:55:24 -0400 Subject: [PATCH 09/12] fix(ui): remove unused mouse option from session tab strip float config --- lua/opencode/ui/session_picker.lua | 1 + lua/opencode/ui/session_tab_strip.lua | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 8ba2277e..e671e901 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -228,6 +228,7 @@ end ---@param callback fun(session: Session|nil) ---@param opts? { scope?: 'project' | 'global' } function M.pick(sessions, callback, opts) + local api = require('opencode.api') local actions = { rename = { key = config.keymap.session_picker.rename_session, diff --git a/lua/opencode/ui/session_tab_strip.lua b/lua/opencode/ui/session_tab_strip.lua index 30f0cce8..2204e81e 100644 --- a/lua/opencode/ui/session_tab_strip.lua +++ b/lua/opencode/ui/session_tab_strip.lua @@ -409,7 +409,6 @@ local function build_float_config(output_win) row = 0, col = 0, focusable = true, - mouse = true, style = 'minimal', border = 'none', zindex = 50, From 0672332b1b4eaf202c6d68f5c7d40768ad4c2c60 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 10 Sep 2026 10:02:21 -0400 Subject: [PATCH 10/12] fix(renderer): mark dirty on tab switch; picker: multi-session tab open --- lua/opencode/ui/renderer.lua | 4 ++ lua/opencode/ui/session_picker.lua | 11 ++++- tests/unit/renderer_session_tabs_spec.lua | 34 ++++++++++++++ tests/unit/session_picker_spec.lua | 56 +++++++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 4604cff0..e25c4396 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -574,6 +574,10 @@ function M.render_full_session() or not state.active_session or state.active_session.id ~= target_session_id then + local runtime = session_tabs.get(target_tab_id) + if runtime then + runtime.renderer_dirty = true + end return nil end M._render_full_session_data(session_data, { diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index e671e901..ce8cbc53 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -342,11 +342,20 @@ function M.pick(sessions, callback, opts) open_in_tab = { key = config.keymap.session_picker.open_in_tab, label = 'tab', + multi_selection = true, fn = Promise.async(function(selected, opts) + local session_runtime = require('opencode.services.session_runtime') + local sessions = type(selected) == 'table' and selected.id == nil and selected or { selected } + if opts.close then opts.close() + Promise.delay(0):await() + end + + for _, session in ipairs(sessions) do + session_runtime.open_session_in_tab(session):await() + Promise.delay(0):await() end - return require('opencode.services.session_runtime').open_session_in_tab(selected):await() end), }, fork = { diff --git a/tests/unit/renderer_session_tabs_spec.lua b/tests/unit/renderer_session_tabs_spec.lua index 1ff107a8..886021e8 100644 --- a/tests/unit/renderer_session_tabs_spec.lua +++ b/tests/unit/renderer_session_tabs_spec.lua @@ -3,6 +3,7 @@ local store = require('opencode.state.store') local session_tabs = require('opencode.state.session_tabs') local renderer = require('opencode.ui.renderer') local renderer_ctx = require('opencode.ui.renderer.ctx') +local session = require('opencode.session') local Promise = require('opencode.promise') local stub = require('luassert.stub') @@ -152,4 +153,37 @@ describe('renderer session tab contexts', function() assert.is_false(second.renderer_dirty) render_stub:revert() end) + + it('marks an in-flight render dirty when its tab becomes inactive', function() + local first = session_tabs.ensure_current() + first.active_session = { id = 'session-one', title = 'One' } + local second = session_tabs.create({ id = 'session-two', title = 'Two' }) + + output_buf = vim.api.nvim_create_buf(false, true) + output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 60, + height = 10, + row = 1, + col = 1, + }) + state.ui.set_windows({ output_buf = output_buf, output_win = output_win }) + state.jobs.set_api_client({}) + store.set_raw('active_session', first.active_session) + store.set_raw('active_session_tab', first.id) + + local messages = Promise.new() + local messages_stub = stub(session, 'get_messages').returns(messages) + renderer.render_full_session() + + store.set_raw('active_session', second.active_session) + store.set_raw('active_session_tab', second.id) + messages:resolve({}) + vim.wait(50, function() + return first.renderer_dirty + end) + + assert.is_true(first.renderer_dirty) + messages_stub:revert() + end) end) diff --git a/tests/unit/session_picker_spec.lua b/tests/unit/session_picker_spec.lua index 24caeb1a..d56f5aaf 100644 --- a/tests/unit/session_picker_spec.lua +++ b/tests/unit/session_picker_spec.lua @@ -250,6 +250,7 @@ describe('opencode.ui.session_picker', function() local open_stub = stub(session_runtime, 'open_session_in_tab').returns(Promise.new():resolve(selected_session)) local closed = false + assert.is_true(captured_action.multi_selection) captured_action .fn(selected_session, { close = function() @@ -265,6 +266,61 @@ describe('opencode.ui.session_picker', function() base_picker.pick = original_pick end) + it('opens multiple selected sessions in panel tabs', function() + local base_picker = require('opencode.ui.base_picker') + local original_pick = base_picker.pick + local sessions = { + { id = 'session-1', title = 'First session' }, + { id = 'session-2', title = 'Second session' }, + } + local captured_action + + base_picker.pick = function(opts) + captured_action = opts.actions.open_in_tab + return true + end + + session_picker.pick(sessions, function() end) + + local opened = {} + local open_stub = stub(session_runtime, 'open_session_in_tab').invokes(function(session) + opened[#opened + 1] = session + return Promise.new():resolve(session) + end) + local closed = false + local original_delay = Promise.delay + local close_delay = Promise.new() + local between_opens_delay = Promise.new() + local delays = { close_delay, between_opens_delay, Promise.new():resolve(true) } + Promise.delay = function() + return table.remove(delays, 1) + end + + local action_promise = captured_action.fn(sessions, { + close = function() + closed = true + end, + }) + assert.is_true(closed) + assert.same({}, opened) + + close_delay:resolve(true) + vim.wait(50, function() + return #opened == 1 + end) + assert.same({ sessions[1] }, opened) + + between_opens_delay:resolve(true) + action_promise:wait() + Promise.delay = original_delay + + assert.same(sessions, opened) + assert.stub(open_stub).was_called(2) + + open_stub:revert() + base_picker.pick = original_pick + end) + -- ----------------------------------------------------------------------- -- Integration tests: delete action triggers switch when parent/grandparent -- of the active session is deleted From 77f57cec93188539be5464ded7a1954c45b1940b Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 10 Sep 2026 10:12:10 -0400 Subject: [PATCH 11/12] feat(ui): support multi-select confirmation in pickers --- lua/opencode/ui/base_picker.lua | 31 ++++++++++++++++++++++++++++++ lua/opencode/ui/session_picker.lua | 1 + tests/unit/base_picker_spec.lua | 31 ++++++++++++++++++++++++++++++ tests/unit/session_picker_spec.lua | 5 ++++- 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 02eec590..ae9905d9 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -14,6 +14,7 @@ local Promise = require('opencode.promise') ---@field format_fn fun(item: any, width?: number): PickerItem Function to format items for display ---@field actions table Available actions for the picker ---@field callback fun(selected: any?) Callback when item is selected +---@field multi_select_fn? fun(selected: any[], opts: PickerOptions): any|Promise Action for multiple items confirmed together ---@field title string|fun(): string The picker title ---@field width? number Optional width for the picker (defaults to config or current window width) ---@field multi_selection? table Actions that support multi-selection @@ -239,6 +240,16 @@ local function telescope_ui(opts) actions.select_default:replace(function() selection_made = true + local multi_selection = {} + action_utils.map_selections(prompt_bufnr, function(entry) + table.insert(multi_selection, entry.value) + end) + if #multi_selection > 1 and opts.multi_select_fn then + actions.close(prompt_bufnr) + opts.multi_select_fn(multi_selection, opts) + return + end + local selection = action_state.get_selected_entry() actions.close(prompt_bufnr) if selection and opts.callback then @@ -465,6 +476,17 @@ local function fzf_ui(opts) end return end + if #selected > 1 and opts.multi_select_fn then + local multi_selection = {} + for _, sel in ipairs(selected) do + local idx = fzf_opts.fn_fzf_index(sel --[[@as string]]) + if idx and opts.items[idx] then + table.insert(multi_selection, opts.items[idx]) + end + end + opts.multi_select_fn(multi_selection, opts) + return + end local idx = fzf_opts.fn_fzf_index(selected[1] --[[@as string]]) if idx and opts.items[idx] and opts.callback then opts.callback(opts.items[idx]) @@ -682,6 +704,15 @@ local function snacks_picker_ui(opts) actions = { confirm = function(_picker, item) selection_made = true + local multi_selection = _picker:selected({ fallback = true }) + if #multi_selection > 1 and opts.multi_select_fn then + _picker:close() + vim.schedule(function() + opts.multi_select_fn(multi_selection, opts) + end) + return + end + _picker:close() if item and opts.callback then vim.schedule(function() diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index ce8cbc53..ca945eed 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -395,6 +395,7 @@ function M.pick(sessions, callback, opts) items = sessions, format_fn = format_session_item, actions = actions, + multi_select_fn = actions.open_in_tab.fn, callback = callback, title = (opts and opts.scope == 'global') and 'Select A Session (all projects)' or 'Select A Session', width = config.ui.picker_width, diff --git a/tests/unit/base_picker_spec.lua b/tests/unit/base_picker_spec.lua index 61fdebfa..44a460cc 100644 --- a/tests/unit/base_picker_spec.lua +++ b/tests/unit/base_picker_spec.lua @@ -135,6 +135,37 @@ describe('opencode.ui.base_picker', function() assert.equal(998000, item.score_add) end) + it('routes multiple default selections to the multi-select action', function() + local selected = { { name = 'first' }, { name = 'second' } } + local selected_by_action + local closed = false + + base_picker.pick({ + title = 'Select model', + items = selected, + format_fn = function(item) + return base_picker.create_picker_item({ { text = item.name } }) + end, + actions = {}, + callback = function() end, + multi_select_fn = function(items) + selected_by_action = items + end, + }) + + captured_opts.actions.confirm({ + close = function() + closed = true + end, + selected = function() + return selected + end, + }, selected[1]) + + assert.is_true(closed) + assert.same(selected, selected_by_action) + end) + describe('snacks preview', function() local function pick_with(preview, preview_fn) base_picker.pick({ diff --git a/tests/unit/session_picker_spec.lua b/tests/unit/session_picker_spec.lua index d56f5aaf..57730498 100644 --- a/tests/unit/session_picker_spec.lua +++ b/tests/unit/session_picker_spec.lua @@ -274,9 +274,11 @@ describe('opencode.ui.session_picker', function() { id = 'session-2', title = 'Second session' }, } local captured_action + local captured_multi_select base_picker.pick = function(opts) captured_action = opts.actions.open_in_tab + captured_multi_select = opts.multi_select_fn return true end @@ -296,7 +298,8 @@ describe('opencode.ui.session_picker', function() return table.remove(delays, 1) end - local action_promise = captured_action.fn(sessions, { + assert.equal(captured_action.fn, captured_multi_select) + local action_promise = captured_multi_select(sessions, { close = function() closed = true end, From 3e82c2211d4ac7d5b77ca11a3b075bf33edb535e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 10 Sep 2026 13:53:14 -0400 Subject: [PATCH 12/12] fix: use here-string in has_failures and increase inline_input test timeout --- run_tests.sh | 2 +- tests/unit/inline_input_spec.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/run_tests.sh b/run_tests.sh index f4f8887b..14aebc46 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -82,7 +82,7 @@ strip_ansi() { has_failures() { local plain_output plain_output=$(strip_ansi "$1") - echo "$plain_output" | grep -Eq "Fail.*\|\||Failed[[:space:]]*:[[:space:]]*[1-9][0-9]*" + grep -Eq "Fail.*\|\||Failed[[:space:]]*:[[:space:]]*[1-9][0-9]*" <<<"$plain_output" } # Run tests based on type diff --git a/tests/unit/inline_input_spec.lua b/tests/unit/inline_input_spec.lua index 76f5a797..f5860f25 100644 --- a/tests/unit/inline_input_spec.lua +++ b/tests/unit/inline_input_spec.lua @@ -145,7 +145,7 @@ describe('inline_input', function() on_submit = function() end, on_cancel = function() end, }) - assert.is_true(vim.wait(50, function() + assert.is_true(vim.wait(1000, function() return vim.api.nvim_get_current_win() == input.win and vim.deep_equal(vim.api.nvim_win_get_cursor(input.win), { 2, #'second line' }) end))