diff --git a/README.md b/README.md index 6d42f169..6758d8a7 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,8 @@ 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 = 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 icons = { @@ -240,17 +242,20 @@ require('opencode').setup({ }, output = { filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output') - 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) - }, - }, + 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 @@ -355,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 = { @@ -624,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 }, @@ -653,13 +658,21 @@ 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()` | +| 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 | `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) | - | - | | 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()` | @@ -1206,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/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..21eec287 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,31 @@ 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 + +---@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 + +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) @@ -216,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 @@ -597,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 @@ -618,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 @@ -651,6 +698,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(args) + return M.actions.select_session_tab(args[2]) + 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 +751,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(args) + return M.actions.select_session_tab(args[2]) + 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 +801,30 @@ 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 = function(args) + return M.actions.select_session_tab(args[1]) + end, + }, + 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..dd0f1fd7 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -33,11 +33,25 @@ 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' }, + ['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' }, @@ -116,9 +130,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' }, @@ -159,6 +178,8 @@ M.defaults = { display_model = true, 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 = { @@ -172,6 +193,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/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..98bac87b 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 @@ -42,10 +25,10 @@ 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) - state.store.subscribe('current_model', on_current_model_change) vim.schedule(function() session_runtime.opencode_ok() @@ -59,6 +42,8 @@ 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() end diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index 81fb0440..1b8aeaf5 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -5,8 +5,8 @@ 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') local M = {} @@ -14,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 @@ -30,26 +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 @@ -57,56 +73,120 @@ 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 + + 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 = context.format_message(prompt, opts.context):await() + 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) - 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) + 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) + 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 + + if old_count > 0 and new_count == 0 then + session_runtime.on_session_request_completed(session_id) + end end update_sent_message_count(1) - state.api_client + api_client :create_message(session_id, params) :and_then(function(response) update_sent_message_count(-1) 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, { count_abort = true }):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, { count_abort = true }):await() end) :await() end) ---@param prompt string +---@param tab_id? string|OpencodeContext ---@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 - context.unload_attachments() +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 + 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 + 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 - 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..85aaf41d 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -10,8 +10,11 @@ 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 = {} +local subscribed_event_manager +local idle_events_enabled = false ---@return boolean function M.is_session_locked() @@ -145,6 +148,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 +250,221 @@ 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) + +---@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 +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()) + + local focus = state.last_focused_opencode_window == 'output' and 'output' or 'input' + M.open({ + focus = focus, + new_session = false, + open_action = 'restore_hidden', + }):await() + + 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 +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 + 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 + 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 + 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 + + 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 +473,25 @@ 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 +---@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 + + 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 or (opts and opts.count_abort) 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 +499,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 +518,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 @@ -331,23 +567,52 @@ M.opencode_ok = Promise.async(function() return true end) -M._on_user_message_count_change = Promise.async(function(_, new, old) +---@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) - 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) + if idle_events_enabled or not session_id or not (config.hooks and config.hooks.on_done_thinking) 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 + 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 @@ -385,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/init.lua b/lua/opencode/state/init.lua index 970d1257..222546bf 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,10 @@ 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 session_tabs_changed number ---@field current_model string|nil ---@field api_client OpencodeApiClient|nil @@ -27,6 +31,7 @@ local M = { model = model, renderer = renderer, context = context, + session_tabs = session_tabs, } return setmetatable(M, { 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.lua b/lua/opencode/state/session.lua index 6576b75c..b06ad999 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -1,25 +1,47 @@ 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 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) 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() + 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) store.set('user_message_count', {}) return store.set('active_session', nil) end) + session_tabs.sync() + return result end ---@return boolean @@ -34,6 +56,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,31 +68,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) -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) + local result = store.set('user_message_count', count) + session_tabs.sync() + return result end ---Update active_session without emitting a change event, used when a silent @@ -78,6 +99,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..c5e91952 --- /dev/null +++ b/lua/opencode/state/session_tabs.lua @@ -0,0 +1,591 @@ +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 pending_prompt_permissions OpencodePermission[] +---@field pending_questions OpencodeQuestionRequest[] +---@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 +---@field renderer_dirty boolean Cached renderer missed background session events +---@field background_notifications table Notifications emitted for pending background prompts + +---@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 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 + 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 = {}, + pending_prompt_permissions = {}, + pending_questions = {}, + 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, + renderer_dirty = false, + background_notifications = {}, + } +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 + +---@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 + + M.sync() + for _, runtime in pairs(runtimes) 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 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 + 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')] + 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 + + 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) + 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 + +---@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 + 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 + 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) + store.set('current_variant', runtime.current_variant) + 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') + 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.pending_prompt_permissions = {} + runtime.pending_questions = {} + runtime.restore_points = {} + runtime.last_sent_context = nil + runtime.user_message_count = {} + runtime.cost = 0 + runtime.tokens_count = 0 + runtimes[runtime.id] = runtime + notify_change() + return runtime +end + +---@param runtime OpencodeSessionTabRuntime +function M.remove(runtime) + if not runtime then + return + end + runtimes[runtime.id] = nil + notify_change() + 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..bb75b211 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -42,6 +42,8 @@ local M = {} ---@field current_cwd string|nil ---@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 = { @@ -84,6 +86,8 @@ local _state = { current_cwd = vim.fn.getcwd(), session_locked = nil, _hidden_buffers = nil, + active_session_tab = nil, + session_tabs_changed = 0, } 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..6e7d8a56 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 @@ -231,6 +242,8 @@ ---@field display_model boolean ---@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 @@ -282,6 +295,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 @@ -289,6 +303,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 @@ -329,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/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/base_picker.lua b/lua/opencode/ui/base_picker.lua index 67cb8402..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 @@ -128,14 +129,15 @@ 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('opencode.ui.icons') 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 icons.get('separator') .. table.concat(legend, icons.get('separator')) or '') end ---Telescope UI implementation @@ -228,8 +230,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 @@ -238,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 @@ -374,8 +386,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, @@ -464,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]) @@ -583,13 +606,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, @@ -683,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() @@ -767,7 +797,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/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/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/highlight.lua b/lua/opencode/ui/highlight.lua index 2e7dba03..a67458a8 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -8,6 +8,21 @@ 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, '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 }) @@ -59,6 +74,21 @@ 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, '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/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/permission_window.lua b/lua/opencode/ui/permission_window.lua index 1670b051..e6176259 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -1,6 +1,8 @@ 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') local M = {} @@ -218,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 @@ -251,7 +257,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 +333,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) }, @@ -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/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/renderer.lua b/lua/opencode/ui/renderer.lua index c28dfaa8..e25c4396 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. @@ -513,7 +566,20 @@ 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 + 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, { restore_model_from_messages = true, }) @@ -531,6 +597,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 +663,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 +675,91 @@ 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 + return + end + 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 + prompts.question.clear_question() + end + if prompts.permission then + prompts.permission.clear_all() + end + require('opencode.ui.renderer.events').render_permissions_display() + + if restored and not (runtime and runtime.renderer_dirty) 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 + + 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 + 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..ca945eed 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, @@ -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') @@ -338,6 +339,25 @@ function M.pick(sessions, callback, opts) end), reload = true, }, + 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 + end), + }, fork = { key = config.keymap.session_picker.fork_session, label = 'fork', @@ -355,7 +375,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' @@ -375,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, @@ -429,7 +450,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/lua/opencode/ui/session_tab_notifications.lua b/lua/opencode/ui/session_tab_notifications.lua new file mode 100644 index 00000000..723dee67 --- /dev/null +++ b/lua/opencode/ui/session_tab_notifications.lua @@ -0,0 +1,144 @@ +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 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) + runtime.background_notifications = runtime.background_notifications or {} + if runtime.background_notifications[key] then + return + end + 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 + vim.notify(label .. ' in session "' .. session_title(runtime) .. '"', level) +end + +local function clear(kind, request_id) + if not request_id then + return + end + + 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 + 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 +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() + for _, runtime in ipairs(session_tabs.list()) do + runtime.background_notifications = {} + end +end + +return M 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..2204e81e --- /dev/null +++ b/lua/opencode/ui/session_tab_strip.lua @@ -0,0 +1,541 @@ +local state = require('opencode.state') +local session_tabs = require('opencode.state.session_tabs') +local config = require('opencode.config') + +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 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 + +---@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 +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 + if is_generated_title(title) then + return 'New session' + end + 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 +local function build_horizontal_content(tabs, width) + if #tabs == 0 then + return '', {}, {} + end + + local separator = ' │ ' + local separator_width = display_width(separator) + 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 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 index_text = tostring(index) + local prefix = index_text .. ' ' + 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 .. marker_prefix .. truncate(tab_title(tab), title_width) + else + label = prefix .. marker + if display_width(label) > segment_width then + label = truncate(label, 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, + } + 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, + 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 +end + +---@param windows OpencodeWindowState +---@return boolean +local function valid_output_windows(windows) + return windows + and windows.output_win + and windows.tab_strip_buf + and vim.api.nvim_win_is_valid(windows.output_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 + 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 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 + end + end + return nil +end + +---@param buffer integer +---@param byte_column integer +---@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 + 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 + +---@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_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_range(range_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) + prune_ranges() + 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, + hl_mode = highlight.hl_mode, + }) + 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, + 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 + 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 + + 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 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 + pcall(vim.api.nvim_win_close, windows.tab_strip_win, true) + end + windows.tab_strip_win = nil + if windows.tab_strip_buf then + M.clear_buffer(windows.tab_strip_buf) + end +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_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 + 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.update_window() +end + +---@param windows OpencodeWindowState +function M.setup(windows) + 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 + + M.update_window(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 + 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 + 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 + +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..ea96e60d 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,12 +210,20 @@ 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 + 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 @@ -209,6 +231,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 +247,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 +266,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 +278,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 +288,8 @@ function M.restore_hidden_windows() if hidden.input_hidden then input_window._hide() + else + input_window._hidden = false end vim.schedule(function() @@ -283,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 @@ -301,12 +334,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 +377,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 +416,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 +442,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 +466,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 +544,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 +649,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/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/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/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index 4bd6d7a0..7c8b4920 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(index) + called_with.select = index + 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', '2' }) + 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.equal('2', 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 = {} @@ -584,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/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/config_spec.lua b/tests/unit/config_spec.lua index ffb2415e..51acbc35 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -24,6 +24,18 @@ 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('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' @@ -46,6 +58,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/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/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 { diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index caca1720..69c06510 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -119,62 +119,73 @@ 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) + session_runtime.on_session_request_completed('test-session'):wait() - -- 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) - - 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) + + 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/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)) 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..886021e8 --- /dev/null +++ b/tests/unit/renderer_session_tabs_spec.lua @@ -0,0 +1,189 @@ +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 session = require('opencode.session') +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) + + 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) + + 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) + + 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/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua index 561cede8..d5c7ce37 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' }) @@ -385,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_picker_spec.lua b/tests/unit/session_picker_spec.lua index d37ca333..57730498 100644 --- a/tests/unit/session_picker_spec.lua +++ b/tests/unit/session_picker_spec.lua @@ -234,6 +234,96 @@ 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 + assert.is_true(captured_action.multi_selection) + 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) + + 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 + 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 + + 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 + + assert.equal(captured_action.fn, captured_multi_select) + local action_promise = captured_multi_select(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 diff --git a/tests/unit/session_tab_lifecycle_spec.lua b/tests/unit/session_tab_lifecycle_spec.lua new file mode 100644 index 00000000..b8d051d1 --- /dev/null +++ b/tests/unit/session_tab_lifecycle_spec.lua @@ -0,0 +1,125 @@ +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) + + 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) 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_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..9bd54097 --- /dev/null +++ b/tests/unit/session_tab_strip_spec.lua @@ -0,0 +1,227 @@ +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') +local stub = require('luassert.stub') + +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) + + 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() + config.values = original_config + for key, value in pairs(original_state) do + store.set(key, value) + end + end) + + 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 = 'New session - 2026-02-05T22:26:08.579Z' }) + 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('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 }) + 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) + 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('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 + + 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) diff --git a/tests/unit/session_tabs_spec.lua b/tests/unit/session_tabs_spec.lua new file mode 100644 index 00000000..7e022c4a --- /dev/null +++ b/tests/unit/session_tabs_spec.lua @@ -0,0 +1,195 @@ +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('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() + 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') + 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)) + 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) + 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)