diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index 3e0feac3..c642c770 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -4,6 +4,13 @@ local state = require('opencode.state') local config = require('opencode.config') local renderer = require('opencode.ui.renderer') +---@param win integer +local function mark_jump_position(win) + pcall(vim.api.nvim_win_call, win, function() + vim.cmd([[noau normal! m']]) + end) +end + function M.goto_message_by_id(message_id) require('opencode.ui.ui').focus_output() local windows = state.windows or {} @@ -18,6 +25,7 @@ function M.goto_message_by_id(message_id) if not rendered_msg or not rendered_msg.line_start then return end + mark_jump_position(win) vim.api.nvim_win_set_cursor(win, { rendered_msg.line_start + 1, 0 }) end @@ -34,11 +42,13 @@ function M.goto_next_message() local current_line = vim.api.nvim_win_get_cursor(win)[1] local next_message = renderer.get_next_rendered_message(current_line) if next_message and next_message.line_start then + mark_jump_position(win) vim.api.nvim_win_set_cursor(win, { next_message.line_start + 1, 0 }) return end local line_count = vim.api.nvim_buf_line_count(buf) + mark_jump_position(win) vim.api.nvim_win_set_cursor(win, { line_count, 0 }) end @@ -55,10 +65,12 @@ function M.goto_prev_message() local current_line = vim.api.nvim_win_get_cursor(win)[1] local previous_message = renderer.get_prev_rendered_message(current_line) if previous_message and previous_message.line_start then + mark_jump_position(win) vim.api.nvim_win_set_cursor(win, { previous_message.line_start + 1, 0 }) return end + mark_jump_position(win) vim.api.nvim_win_set_cursor(win, { 1, 0 }) end @@ -79,6 +91,7 @@ function M.goto_next_user_message() local current_line = vim.api.nvim_win_get_cursor(win)[1] local next_message = renderer.get_next_user_message(current_line) if next_message and next_message.line_start then + mark_jump_position(win) vim.api.nvim_win_set_cursor(win, { next_message.line_start + 1, 0 }) return end @@ -101,6 +114,7 @@ function M.goto_prev_user_message() local current_line = vim.api.nvim_win_get_cursor(win)[1] local previous_message = renderer.get_prev_user_message(current_line) if previous_message and previous_message.line_start then + mark_jump_position(win) vim.api.nvim_win_set_cursor(win, { previous_message.line_start + 1, 0 }) return end @@ -153,6 +167,7 @@ local function open_at(win, path, line, col) local line_text = target_lines[1] or '' target_col = math.max(0, math.min(col - 1, math.max(#line_text - 1, 0))) end + mark_jump_position(win) pcall(vim.api.nvim_win_set_cursor, win, { target_line, target_col }) vim.cmd('normal! zz') end diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5b1836e8..633c5164 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -677,6 +677,7 @@ function M.setup_keymaps(windows) vim.keymap.set('n', 'gg', function() local renderer = require('opencode.ui.renderer') renderer.load_all_messages() + pcall(vim.cmd, [[noau normal! m']]) vim.api.nvim_win_set_cursor(0, { 1, 0 }) end, { buffer = windows.output_buf }) end diff --git a/tests/unit/navigation_spec.lua b/tests/unit/navigation_spec.lua index ad295eed..f6d472da 100644 --- a/tests/unit/navigation_spec.lua +++ b/tests/unit/navigation_spec.lua @@ -539,3 +539,120 @@ describe('output token navigation', function() assert.same({ 2, 3 }, vim.api.nvim_win_get_cursor(state.windows.output_win)) end) end) + +describe('navigation jumplist preservation', function() + local output_buf, output_win, input_buf, input_win, code_buf, code_win + local original_windows, original_code_win, original_code_buf, original_config + + before_each(function() + original_windows = state.store.get('windows') + original_code_win = state.store.get('last_code_win_before_opencode') + original_code_buf = state.store.get('current_code_buf') + original_config = vim.deepcopy(config.values) + state.ui.clear_hidden_window_state() + + code_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(code_buf, 0, -1, false, { 'alpha', 'beta', 'gamma' }) + code_win = vim.api.nvim_open_win(code_buf, true, { + relative = 'editor', + width = 40, + height = 5, + row = 0, + col = 0, + }) + + output_buf = vim.api.nvim_create_buf(false, true) + output_win = vim.api.nvim_open_win(output_buf, true, { + relative = 'editor', + width = 80, + height = 8, + row = 6, + col = 0, + }) + + state.ui.set_windows({ output_buf = output_buf, output_win = output_win }) + state.ui.set_last_code_window(code_win) + end) + + after_each(function() + state.ui.clear_hidden_window_state() + pcall(vim.api.nvim_win_close, output_win, true) + pcall(vim.api.nvim_win_close, input_win, true) + pcall(vim.api.nvim_win_close, code_win, true) + pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, code_buf, { force = true }) + + if original_windows ~= nil then + state.ui.set_windows(original_windows) + else + state.ui.clear_windows() + end + state.ui.set_last_code_window(original_code_win) + state.ui.set_current_code_buf(original_code_buf) + config.values = original_config + end) + + it('marks the output cursor before goto_next_message moves', function() + local renderer = require('opencode.ui.renderer') + local ctx = require('opencode.ui.renderer.ctx') + state.renderer.set_messages({ + { info = { id = 'm1', role = 'user' } }, + { info = { id = 'm2', role = 'assistant' } }, + }) + ctx.render_state:set_message({ info = { id = 'm1', role = 'user' } }, 1, 1) + ctx.render_state:set_message({ info = { id = 'm2', role = 'assistant' } }, 20, 20) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.fn['repeat']({ 'line' }, 40)) + vim.api.nvim_win_set_cursor(output_win, { 5, 0 }) + vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {}) + + navigation.goto_next_message() + + local mark = vim.api.nvim_buf_get_mark(output_buf, "'") + assert.equals(5, mark[1]) + assert.equals(0, mark[2]) + end) + + it('marks the output cursor before goto_prev_message moves', function() + local renderer = require('opencode.ui.renderer') + local ctx = require('opencode.ui.renderer.ctx') + state.renderer.set_messages({ + { info = { id = 'm1', role = 'user' } }, + { info = { id = 'm2', role = 'assistant' } }, + }) + ctx.render_state:set_message({ info = { id = 'm1', role = 'user' } }, 1, 1) + ctx.render_state:set_message({ info = { id = 'm2', role = 'assistant' } }, 20, 20) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.fn['repeat']({ 'line' }, 40)) + vim.api.nvim_win_set_cursor(output_win, { 31, 0 }) + vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {}) + + navigation.goto_prev_message() + + local mark = vim.api.nvim_buf_get_mark(output_buf, "'") + assert.equals(31, mark[1]) + assert.equals(0, mark[2]) + end) + + it('marks the output cursor before jumping to a rendered file target', function() + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { existing_path }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {}) + local original_navigate = navigation.navigate_to_location + navigation.navigate_to_location = function() + return true + end + stub(renderer, 'get_target_at_position').returns({ + kind = 'file', + path = existing_path, + line = 1, + col = 1, + }) + + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate + local mark = vim.api.nvim_buf_get_mark(output_buf, "'") + assert.equals(1, mark[1]) + assert.equals(0, mark[2]) + end) +end) diff --git a/tests/unit/navigation_user_message_spec.lua b/tests/unit/navigation_user_message_spec.lua index 18787ae6..9a644e1d 100644 --- a/tests/unit/navigation_user_message_spec.lua +++ b/tests/unit/navigation_user_message_spec.lua @@ -333,4 +333,46 @@ describe('navigation user message jumps', function() assert.equals(2, cursor[1]) end) end) + + describe('jumplist preservation', function() + it('marks the previous position before jumping to the next user message', function() + seed({ + { info = { id = 'u1', role = 'user' } }, + { info = { id = 'a1', role = 'assistant' } }, + { info = { id = 'u2', role = 'user' } }, + }, { + { id = 'u1', role = 'user', line_start = 1 }, + { id = 'a1', role = 'assistant', line_start = 20 }, + { id = 'u2', role = 'user', line_start = 40 }, + }) + + vim.api.nvim_win_set_cursor(output_win, { 5, 0 }) + vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {}) + navigation.goto_next_user_message() + + local mark = vim.api.nvim_buf_get_mark(output_buf, "'") + assert.equals(5, mark[1]) + assert.equals(0, mark[2]) + end) + + it('marks the previous position before jumping to the previous user message', function() + seed({ + { info = { id = 'u1', role = 'user' } }, + { info = { id = 'a1', role = 'assistant' } }, + { info = { id = 'u2', role = 'user' } }, + }, { + { id = 'u1', role = 'user', line_start = 1 }, + { id = 'a1', role = 'assistant', line_start = 20 }, + { id = 'u2', role = 'user', line_start = 40 }, + }) + + vim.api.nvim_win_set_cursor(output_win, { 81, 0 }) + vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {}) + navigation.goto_prev_user_message() + + local mark = vim.api.nvim_buf_get_mark(output_buf, "'") + assert.equals(81, mark[1]) + assert.equals(0, mark[2]) + end) + end) end)