Skip to content

Commit 556c8c8

Browse files
authored
fix(navigation): preserve jumplist before output buffer jumps (#456)
Problem: programmatic cursor moves via nvim_win_set_cursor in the output window (gg, ]], [[, ]u, [u, <CR>, ...) do not update the previous-position mark, so users cannot return to the prior output location with `` or <C-o> after a jump. Solution: introduce mark_jump_position() which records the current cursor as the ' mark inside the source window via `noau normal! m'`, and call it before every nvim_win_set_cursor in navigation.lua and before the gg keymap in output_window.lua.
1 parent 49da835 commit 556c8c8

4 files changed

Lines changed: 175 additions & 0 deletions

File tree

lua/opencode/ui/navigation.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ local state = require('opencode.state')
44
local config = require('opencode.config')
55
local renderer = require('opencode.ui.renderer')
66

7+
---@param win integer
8+
local function mark_jump_position(win)
9+
pcall(vim.api.nvim_win_call, win, function()
10+
vim.cmd([[noau normal! m']])
11+
end)
12+
end
13+
714
function M.goto_message_by_id(message_id)
815
require('opencode.ui.ui').focus_output()
916
local windows = state.windows or {}
@@ -18,6 +25,7 @@ function M.goto_message_by_id(message_id)
1825
if not rendered_msg or not rendered_msg.line_start then
1926
return
2027
end
28+
mark_jump_position(win)
2129
vim.api.nvim_win_set_cursor(win, { rendered_msg.line_start + 1, 0 })
2230
end
2331

@@ -34,11 +42,13 @@ function M.goto_next_message()
3442
local current_line = vim.api.nvim_win_get_cursor(win)[1]
3543
local next_message = renderer.get_next_rendered_message(current_line)
3644
if next_message and next_message.line_start then
45+
mark_jump_position(win)
3746
vim.api.nvim_win_set_cursor(win, { next_message.line_start + 1, 0 })
3847
return
3948
end
4049

4150
local line_count = vim.api.nvim_buf_line_count(buf)
51+
mark_jump_position(win)
4252
vim.api.nvim_win_set_cursor(win, { line_count, 0 })
4353
end
4454

@@ -55,10 +65,12 @@ function M.goto_prev_message()
5565
local current_line = vim.api.nvim_win_get_cursor(win)[1]
5666
local previous_message = renderer.get_prev_rendered_message(current_line)
5767
if previous_message and previous_message.line_start then
68+
mark_jump_position(win)
5869
vim.api.nvim_win_set_cursor(win, { previous_message.line_start + 1, 0 })
5970
return
6071
end
6172

73+
mark_jump_position(win)
6274
vim.api.nvim_win_set_cursor(win, { 1, 0 })
6375
end
6476

@@ -79,6 +91,7 @@ function M.goto_next_user_message()
7991
local current_line = vim.api.nvim_win_get_cursor(win)[1]
8092
local next_message = renderer.get_next_user_message(current_line)
8193
if next_message and next_message.line_start then
94+
mark_jump_position(win)
8295
vim.api.nvim_win_set_cursor(win, { next_message.line_start + 1, 0 })
8396
return
8497
end
@@ -101,6 +114,7 @@ function M.goto_prev_user_message()
101114
local current_line = vim.api.nvim_win_get_cursor(win)[1]
102115
local previous_message = renderer.get_prev_user_message(current_line)
103116
if previous_message and previous_message.line_start then
117+
mark_jump_position(win)
104118
vim.api.nvim_win_set_cursor(win, { previous_message.line_start + 1, 0 })
105119
return
106120
end
@@ -153,6 +167,7 @@ local function open_at(win, path, line, col)
153167
local line_text = target_lines[1] or ''
154168
target_col = math.max(0, math.min(col - 1, math.max(#line_text - 1, 0)))
155169
end
170+
mark_jump_position(win)
156171
pcall(vim.api.nvim_win_set_cursor, win, { target_line, target_col })
157172
vim.cmd('normal! zz')
158173
end

lua/opencode/ui/output_window.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ function M.setup_keymaps(windows)
677677
vim.keymap.set('n', 'gg', function()
678678
local renderer = require('opencode.ui.renderer')
679679
renderer.load_all_messages()
680+
pcall(vim.cmd, [[noau normal! m']])
680681
vim.api.nvim_win_set_cursor(0, { 1, 0 })
681682
end, { buffer = windows.output_buf })
682683
end

tests/unit/navigation_spec.lua

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,3 +539,120 @@ describe('output token navigation', function()
539539
assert.same({ 2, 3 }, vim.api.nvim_win_get_cursor(state.windows.output_win))
540540
end)
541541
end)
542+
543+
describe('navigation jumplist preservation', function()
544+
local output_buf, output_win, input_buf, input_win, code_buf, code_win
545+
local original_windows, original_code_win, original_code_buf, original_config
546+
547+
before_each(function()
548+
original_windows = state.store.get('windows')
549+
original_code_win = state.store.get('last_code_win_before_opencode')
550+
original_code_buf = state.store.get('current_code_buf')
551+
original_config = vim.deepcopy(config.values)
552+
state.ui.clear_hidden_window_state()
553+
554+
code_buf = vim.api.nvim_create_buf(false, true)
555+
vim.api.nvim_buf_set_lines(code_buf, 0, -1, false, { 'alpha', 'beta', 'gamma' })
556+
code_win = vim.api.nvim_open_win(code_buf, true, {
557+
relative = 'editor',
558+
width = 40,
559+
height = 5,
560+
row = 0,
561+
col = 0,
562+
})
563+
564+
output_buf = vim.api.nvim_create_buf(false, true)
565+
output_win = vim.api.nvim_open_win(output_buf, true, {
566+
relative = 'editor',
567+
width = 80,
568+
height = 8,
569+
row = 6,
570+
col = 0,
571+
})
572+
573+
state.ui.set_windows({ output_buf = output_buf, output_win = output_win })
574+
state.ui.set_last_code_window(code_win)
575+
end)
576+
577+
after_each(function()
578+
state.ui.clear_hidden_window_state()
579+
pcall(vim.api.nvim_win_close, output_win, true)
580+
pcall(vim.api.nvim_win_close, input_win, true)
581+
pcall(vim.api.nvim_win_close, code_win, true)
582+
pcall(vim.api.nvim_buf_delete, output_buf, { force = true })
583+
pcall(vim.api.nvim_buf_delete, input_buf, { force = true })
584+
pcall(vim.api.nvim_buf_delete, code_buf, { force = true })
585+
586+
if original_windows ~= nil then
587+
state.ui.set_windows(original_windows)
588+
else
589+
state.ui.clear_windows()
590+
end
591+
state.ui.set_last_code_window(original_code_win)
592+
state.ui.set_current_code_buf(original_code_buf)
593+
config.values = original_config
594+
end)
595+
596+
it('marks the output cursor before goto_next_message moves', function()
597+
local renderer = require('opencode.ui.renderer')
598+
local ctx = require('opencode.ui.renderer.ctx')
599+
state.renderer.set_messages({
600+
{ info = { id = 'm1', role = 'user' } },
601+
{ info = { id = 'm2', role = 'assistant' } },
602+
})
603+
ctx.render_state:set_message({ info = { id = 'm1', role = 'user' } }, 1, 1)
604+
ctx.render_state:set_message({ info = { id = 'm2', role = 'assistant' } }, 20, 20)
605+
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.fn['repeat']({ 'line' }, 40))
606+
vim.api.nvim_win_set_cursor(output_win, { 5, 0 })
607+
vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {})
608+
609+
navigation.goto_next_message()
610+
611+
local mark = vim.api.nvim_buf_get_mark(output_buf, "'")
612+
assert.equals(5, mark[1])
613+
assert.equals(0, mark[2])
614+
end)
615+
616+
it('marks the output cursor before goto_prev_message moves', function()
617+
local renderer = require('opencode.ui.renderer')
618+
local ctx = require('opencode.ui.renderer.ctx')
619+
state.renderer.set_messages({
620+
{ info = { id = 'm1', role = 'user' } },
621+
{ info = { id = 'm2', role = 'assistant' } },
622+
})
623+
ctx.render_state:set_message({ info = { id = 'm1', role = 'user' } }, 1, 1)
624+
ctx.render_state:set_message({ info = { id = 'm2', role = 'assistant' } }, 20, 20)
625+
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.fn['repeat']({ 'line' }, 40))
626+
vim.api.nvim_win_set_cursor(output_win, { 31, 0 })
627+
vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {})
628+
629+
navigation.goto_prev_message()
630+
631+
local mark = vim.api.nvim_buf_get_mark(output_buf, "'")
632+
assert.equals(31, mark[1])
633+
assert.equals(0, mark[2])
634+
end)
635+
636+
it('marks the output cursor before jumping to a rendered file target', function()
637+
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { existing_path })
638+
vim.api.nvim_win_set_cursor(output_win, { 1, 0 })
639+
vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {})
640+
local original_navigate = navigation.navigate_to_location
641+
navigation.navigate_to_location = function()
642+
return true
643+
end
644+
stub(renderer, 'get_target_at_position').returns({
645+
kind = 'file',
646+
path = existing_path,
647+
line = 1,
648+
col = 1,
649+
})
650+
651+
navigation.jump_to_target_at_cursor()
652+
653+
navigation.navigate_to_location = original_navigate
654+
local mark = vim.api.nvim_buf_get_mark(output_buf, "'")
655+
assert.equals(1, mark[1])
656+
assert.equals(0, mark[2])
657+
end)
658+
end)

tests/unit/navigation_user_message_spec.lua

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,46 @@ describe('navigation user message jumps', function()
333333
assert.equals(2, cursor[1])
334334
end)
335335
end)
336+
337+
describe('jumplist preservation', function()
338+
it('marks the previous position before jumping to the next user message', function()
339+
seed({
340+
{ info = { id = 'u1', role = 'user' } },
341+
{ info = { id = 'a1', role = 'assistant' } },
342+
{ info = { id = 'u2', role = 'user' } },
343+
}, {
344+
{ id = 'u1', role = 'user', line_start = 1 },
345+
{ id = 'a1', role = 'assistant', line_start = 20 },
346+
{ id = 'u2', role = 'user', line_start = 40 },
347+
})
348+
349+
vim.api.nvim_win_set_cursor(output_win, { 5, 0 })
350+
vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {})
351+
navigation.goto_next_user_message()
352+
353+
local mark = vim.api.nvim_buf_get_mark(output_buf, "'")
354+
assert.equals(5, mark[1])
355+
assert.equals(0, mark[2])
356+
end)
357+
358+
it('marks the previous position before jumping to the previous user message', function()
359+
seed({
360+
{ info = { id = 'u1', role = 'user' } },
361+
{ info = { id = 'a1', role = 'assistant' } },
362+
{ info = { id = 'u2', role = 'user' } },
363+
}, {
364+
{ id = 'u1', role = 'user', line_start = 1 },
365+
{ id = 'a1', role = 'assistant', line_start = 20 },
366+
{ id = 'u2', role = 'user', line_start = 40 },
367+
})
368+
369+
vim.api.nvim_win_set_cursor(output_win, { 81, 0 })
370+
vim.api.nvim_buf_set_mark(output_buf, "'", 1, 0, {})
371+
navigation.goto_prev_user_message()
372+
373+
local mark = vim.api.nvim_buf_get_mark(output_buf, "'")
374+
assert.equals(81, mark[1])
375+
assert.equals(0, mark[2])
376+
end)
377+
end)
336378
end)

0 commit comments

Comments
 (0)