From d95e9789c39fcc7bd015cbdff1745fdef96a6912 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Fri, 26 Jun 2026 11:09:55 +0800 Subject: [PATCH] feat: jump from output targets at cursor --- docs/recipes/three-state-layout/demo.lua | 2 +- lua/opencode/api.lua | 2 + lua/opencode/commands/handlers/workflow.lua | 18 +- lua/opencode/config.lua | 1 + lua/opencode/ui/navigation.lua | 185 +++++++++++---- tests/data/output-target-navigation.json | 43 ++++ tests/replay/renderer_spec.lua | 47 ++++ tests/unit/config_spec.lua | 7 + tests/unit/navigation_spec.lua | 235 ++++++++++++++++++++ 9 files changed, 491 insertions(+), 49 deletions(-) create mode 100644 tests/data/output-target-navigation.json create mode 100644 tests/unit/navigation_spec.lua diff --git a/docs/recipes/three-state-layout/demo.lua b/docs/recipes/three-state-layout/demo.lua index 28f3e7a4..c31a926d 100644 --- a/docs/recipes/three-state-layout/demo.lua +++ b/docs/recipes/three-state-layout/demo.lua @@ -19,7 +19,7 @@ local get_opencode_config -- Actions to transition between modes local ACTIONS = { to_focused = function(api) - api.toggle(false) + api.hide() end, to_side_by_side = function(api) local config = get_opencode_config() diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index ed569d2d..337e0663 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -88,12 +88,14 @@ local action_groups = { slash_commands = workflow.slash_commands, references = workflow.references, jump_to_file = workflow.jump_to_file, + jump_to_target_at_cursor = workflow.jump_to_target_at_cursor, debug_output = workflow.debug_output, debug_message = workflow.debug_message, debug_session = workflow.debug_session, toggle_tool_output = workflow.toggle_tool_output, toggle_reasoning_output = workflow.toggle_reasoning_output, toggle_max_messages = workflow.toggle_max_messages, + navigate_to_location = workflow.navigate_to_location, submit_input_prompt = workflow.submit_input_prompt, run = workflow.run, run_new_session = workflow.run_new_session, diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index dace9ce1..f0961dab 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -296,7 +296,11 @@ function M.actions.clear_files() end function M.actions.jump_to_file() - require('opencode.ui.navigation').jump_to_file_at_cursor() + require('opencode.ui.navigation').jump_to_target_at_cursor() +end + +function M.actions.jump_to_target_at_cursor() + require('opencode.ui.navigation').jump_to_target_at_cursor() end function M.actions.toggle_tool_output() @@ -313,6 +317,14 @@ function M.actions.toggle_reasoning_output() ui.render_output_from_cache() end +---Navigate to a file location (file_path, optional line, optional col). +---@param file_path string +---@param line? number +---@param col? number +function M.actions.navigate_to_location(file_path, line, col) + require('opencode.ui.navigation').navigate_to_location(file_path, line, col) +end + local original_max_messages = config.ui.output.max_messages function M.actions.toggle_max_messages() local current = config.ui.output.max_messages @@ -500,6 +512,10 @@ M.command_defs = { desc = 'Jump to file at cursor in output window', execute = M.actions.jump_to_file, }, + jump_to_target_at_cursor = { + desc = 'Jump to target at cursor in output window', + execute = M.actions.jump_to_target_at_cursor, + }, debug_output = { desc = 'Open raw output debug view', execute = M.actions.debug_output, diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 10915e89..0845ed9c 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -75,6 +75,7 @@ M.defaults = { ['i'] = { 'focus_input', desc = 'Focus input window' }, ['gr'] = { 'references', desc = 'Browse code references' }, ['gf'] = { 'jump_to_file', desc = 'Jump to file at cursor' }, + [''] = { 'jump_to_target_at_cursor', desc = 'Jump to target at cursor' }, [''] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' }, [''] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' }, ['oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' }, diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index 5a24350d..fe8e6894 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -1,6 +1,7 @@ local M = {} local state = require('opencode.state') +local config = require('opencode.config') local renderer = require('opencode.ui.renderer') local output_window = require('opencode.ui.output_window') @@ -123,6 +124,97 @@ local function resolve_path(raw) end end +local function parse_path_location(raw) + if raw:match('^%a[%w+.-]*://') and not raw:match('^file://') then + return nil + end + + local path = raw:gsub('^file://', '') + local line, col + local p, l, c = path:match('^(.-):(%d+):(%d+)$') + if p then + path, line, col = p, tonumber(l), tonumber(c) + else + p, l = path:match('^(.-):(%d+)$') + if p then + path, line = p, tonumber(l) + end + end + + if path == '' then + return nil + end + + return { path = path, line = line, col = col } +end + +local function contains_col(start_pos, end_pos, col) + return col >= start_pos - 1 and col <= end_pos - 1 +end + +local function add_file_candidate(candidates, line, pattern, path_capture_index) + path_capture_index = path_capture_index or 2 + local captures = { line:match(pattern) } + while #captures > 0 do + local start_pos = captures[1] + local end_pos = captures[#captures] - 1 + local raw = captures[path_capture_index] + table.insert(candidates, { + start_pos = start_pos, + end_pos = end_pos, + target = parse_path_location(raw), + }) + local next_start = end_pos + 2 + captures = { line:match(pattern, next_start) } + end +end + +local function file_target_at_col(line, col) + local candidates = {} + + add_file_candidate(candidates, line, '()%[%`([^`]+)%`%]%([^%)]+%)()') + add_file_candidate(candidates, line, '()%`([^`\n]+%.%w+:?%d*:?%d*)%`()') + add_file_candidate(candidates, line, '()file://([%S]+%.%w+:?%d*:?%d*)()') + add_file_candidate(candidates, line, '()%*%*.-%*%*%s+%`([^`]+)%`()') + add_file_candidate(candidates, line, '()([%w_./%-]+/[%w_./%-]*%.%w+:?%d*:?%d*)()') + add_file_candidate(candidates, line, '()([%w_%-]+%.%w+:?%d*:?%d*)()') + + for _, candidate in ipairs(candidates) do + if candidate.target and contains_col(candidate.start_pos, candidate.end_pos, col) and resolve_path(candidate.target.path) then + return candidate.target + end + end +end + +local function first_file_target(line) + local max_col = math.max(#line - 1, 0) + for col = 0, max_col do + local target = file_target_at_col(line, col) + if target then + return target + end + end +end + +local function diff_line_number(buf, line_num) + local ns = output_window.namespace + local extmarks = vim.api.nvim_buf_get_extmarks(buf, ns, { line_num - 1, 0 }, { line_num - 1, -1 }, { details = true }) + for _, extmark in ipairs(extmarks) do + local details = extmark[4] + local virt_text = details and details.virt_text + if virt_text then + local gutter = virt_text[1] and virt_text[1][1] + local sign = virt_text[2] and virt_text[2][1] + if sign == '-' then + return nil + end + if sign == '+' or sign == ' ' then + return tonumber(vim.trim(gutter or '')) + end + end + end +end + ---Resolve file and line number at cursor position in the output buffer. ---@return { path: string, line: number? }? function M.resolve_file_at_cursor() @@ -136,38 +228,25 @@ function M.resolve_file_at_cursor() local cursor = vim.api.nvim_win_get_cursor(win) local line_num = cursor[1] + local col = cursor[2] local line = vim.api.nvim_buf_get_lines(buf, line_num - 1, line_num, false)[1] if not line then return nil end - -- 1. Check for markdown-style file links: [`path`](path) - local path = line:match('%[`([^`]+)%`%]%([^%)]+%)') - if path then - return { path = path } - end - - -- 2. Check for file:// style links: `file://path/to/file.lua:line` - local f_path, f_line = line:match('`file://([^:`]+):?(%d*)`') - if f_path then - return { path = f_path, line = tonumber(f_line) } + local target = file_target_at_col(line, col) + if target then + return target end - -- 3. Check for action lines: **icon tool** `path` - path = line:match('%*%*.-%*%*%s+`([^`]+)`') - if path then - return { path = path } - end - - -- 4. Check for diff hunk: look for the nearest file path upwards local file_path = nil for i = line_num, 1, -1 do local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] if l then - local p = l:match('%[`([^`]+)%`%]%([^%)]+%)') or l:match('%*%*.-%*%*%s+`([^`]+)`') - if p then - file_path = p + local found = first_file_target(l) + if found then + file_path = found.path break end end @@ -177,24 +256,9 @@ function M.resolve_file_at_cursor() return nil end - -- Check if we are on a diff line with a line number in the gutter - local ns = output_window.namespace - local extmarks = vim.api.nvim_buf_get_extmarks(buf, ns, { line_num - 1, 0 }, { line_num - 1, -1 }, { details = true }) - local ln ---@type number? - for _, extmark in ipairs(extmarks) do - local details = extmark[4] - if details and details.virt_text then - for _, vt in ipairs(details.virt_text) do - local val = tonumber(vim.trim(vt[1])) - if val then - ln = val - break - end - end - end - if ln then - break - end + local ln = diff_line_number(buf, line_num) + if not ln then + return nil end return { path = file_path, line = ln } @@ -210,7 +274,7 @@ local function open_silent(path) end end -local function open_at(win, path, line) +local function open_at(win, path, line, col) if not win or not vim.api.nvim_win_is_valid(win) then return end @@ -219,8 +283,15 @@ local function open_at(win, path, line) if line then local buf = vim.api.nvim_win_get_buf(win) local line_count = vim.api.nvim_buf_line_count(buf) - line = math.min(line, line_count) - pcall(vim.api.nvim_win_set_cursor, win, { line, 0 }) + local target_line = math.max(1, math.min(line, line_count)) + local target_col = 0 + if col then + local target_lines = vim.api.nvim_buf_get_lines(buf, target_line - 1, target_line, false) + local line_text = target_lines[1] or '' + target_col = math.max(0, math.min(col - 1, math.max(#line_text - 1, 0))) + end + pcall(vim.api.nvim_win_set_cursor, win, { target_line, target_col }) + vim.cmd('normal! zz') end end @@ -235,16 +306,36 @@ local function best_target_win() end end -function M.jump_to_file_at_cursor() - local resolved = M.resolve_file_at_cursor() - if not resolved then +---@param path string +---@param line? number +---@param col? number +function M.navigate_to_location(path, line, col) + local resolved_path = resolve_path(path) + if not resolved_path then return end - local path = resolve_path(resolved.path) - if not path then + local target_win = best_target_win() + local windows = state.windows + if config.ui.position == 'current' and windows and target_win == windows.output_win then + require('opencode.ui.ui').hide_visible_windows(windows) + end + open_at(target_win, resolved_path, line, col) +end + +function M.resolve_target_at_cursor() + return M.resolve_file_at_cursor() +end + +function M.jump_to_target_at_cursor() + local resolved = M.resolve_target_at_cursor() + if not resolved then return end - open_at(best_target_win(), path, resolved.line) + M.navigate_to_location(resolved.path, resolved.line, resolved.col) +end + +function M.jump_to_file_at_cursor() + M.jump_to_target_at_cursor() end return M diff --git a/tests/data/output-target-navigation.json b/tests/data/output-target-navigation.json new file mode 100644 index 00000000..24427296 --- /dev/null +++ b/tests/data/output-target-navigation.json @@ -0,0 +1,43 @@ +[ + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_output_target_navigation", + "directory": "/mock/project/path", + "time": { + "created": 1760000000000, + "updated": 1760000000000 + }, + "version": "0.15.0", + "title": "Output Target Navigation Test", + "projectID": "test-output-target-navigation" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_output_target_navigation", + "sessionID": "ses_output_target_navigation", + "time": { + "created": 1760000001000 + }, + "role": "assistant" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_output_target_navigation_text", + "messageID": "msg_output_target_navigation", + "sessionID": "ses_output_target_navigation", + "type": "text", + "text": "Open `lua/opencode/ui/navigation.lua:12:3` from replayed output." + } + } + } +] diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 8cc6fe51..0bdd0fa5 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -355,6 +355,53 @@ describe('renderer unit tests', function() assert.are.equal(1, #revert_messages) end) + it('supports output target navigation from a replayed assistant file reference', function() + local renderer = require('opencode.ui.renderer') + local navigation = require('opencode.ui.navigation') + + helpers.replay_setup() + + local code_buf = vim.api.nvim_create_buf(false, true) + local code_win = vim.api.nvim_open_win(code_buf, false, { + relative = 'editor', + width = 40, + height = 8, + row = 0, + col = 0, + }) + + state.ui.set_last_code_window(code_win) + local path = 'lua/opencode/ui/navigation.lua' + local events = helpers.load_test_data('tests/data/output-target-navigation.json') + state.session.set_active(helpers.get_session_from_events(events, true)) + local session_data = helpers.load_session_from_events(events) + renderer._render_full_session_data(session_data) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + local target_line, target_col + for idx, line in ipairs(lines) do + local col = line:find(path, 1, true) + if col then + target_line = idx + target_col = col - 1 + break + end + end + + assert.is_not_nil(target_line, 'replayed output did not contain file reference') + vim.api.nvim_set_current_win(state.windows.output_win) + vim.api.nvim_win_set_cursor(state.windows.output_win, { target_line, target_col }) + + navigation.jump_to_target_at_cursor() + + assert.equals(code_win, vim.api.nvim_get_current_win()) + assert.matches(path .. '$', vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(code_win))) + assert.same({ 12, 2 }, vim.api.nvim_win_get_cursor(code_win)) + + pcall(vim.api.nvim_win_close, code_win, true) + pcall(vim.api.nvim_buf_delete, code_buf, { force = true }) + end) + it('limits rendered messages and inserts a hidden-messages notice', function() local renderer = require('opencode.ui.renderer') diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 387c8003..96a2ce18 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -38,6 +38,13 @@ describe('opencode.config', function() assert.same(config.defaults.keymap, config.values.keymap) end) + it('maps output enter to target jump while keeping gf on file jump', function() + local output_keymap = config.defaults.keymap.output_window + + assert.equal('jump_to_file', output_keymap['gf'][1]) + assert.equal('jump_to_target_at_cursor', output_keymap[''][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/navigation_spec.lua b/tests/unit/navigation_spec.lua new file mode 100644 index 00000000..ec7bfd70 --- /dev/null +++ b/tests/unit/navigation_spec.lua @@ -0,0 +1,235 @@ +local assert = require('luassert') +local stub = require('luassert.stub') + +local navigation = require('opencode.ui.navigation') +local config = require('opencode.config') +local ui = require('opencode.ui.ui') +local output_window = require('opencode.ui.output_window') +local renderer = require('opencode.ui.renderer') +local state = require('opencode.state') + +local existing_path = 'lua/opencode/ui/navigation.lua' + +local function set_cursor_on(win, line_num, line, needle) + local start_pos = assert(line:find(needle, 1, true)) + vim.api.nvim_win_set_cursor(win, { line_num, start_pos - 1 }) +end + +local function add_diff_extmark(buf, line_idx, gutter, sign) + vim.api.nvim_buf_set_extmark(buf, output_window.namespace, line_idx, 0, { + virt_text = { + { gutter, 'LineNr' }, + { sign, 'DiffAdd' }, + { ' ', 'Normal' }, + }, + virt_text_pos = 'inline', + }) +end + +describe('output token navigation', 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('resolves an ordinary visible path token at the cursor', function() + local line = 'open ' .. existing_path + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + set_cursor_on(output_win, 1, line, existing_path) + + local target = navigation.resolve_target_at_cursor() + + assert.same({ path = existing_path }, target) + end) + + it('resolves path line and column from the cursor token', function() + local token = existing_path .. ':12:3' + local line = 'open ' .. token + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + set_cursor_on(output_win, 1, line, token) + + local target = navigation.resolve_target_at_cursor() + + assert.same({ path = existing_path, line = 12, col = 3 }, target) + end) + + it('resolves backtick, file uri, markdown, and tool-path forms', function() + local cases = { + { '`' .. existing_path .. ':4`', existing_path, 4 }, + { 'file://' .. existing_path .. ':5', existing_path, 5 }, + { '[`' .. existing_path .. '`](file)', existing_path, nil }, + { '**tool** `' .. existing_path .. '`', existing_path, nil }, + } + + for _, case in ipairs(cases) do + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { case[1] }) + set_cursor_on(output_win, 1, case[1], existing_path) + + local target = navigation.resolve_target_at_cursor() + + assert.same({ path = case[2], line = case[3] }, target) + end + end) + + it('uses only the path token under the cursor on multi-token lines', function() + local first = 'lua/opencode/api.lua' + local second = existing_path .. ':7' + local line = first .. ' then ' .. second + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + + set_cursor_on(output_win, 1, line, second) + assert.same({ path = existing_path, line = 7 }, navigation.resolve_target_at_cursor()) + + vim.api.nvim_win_set_cursor(output_win, { 1, #first + 2 }) + assert.is_nil(navigation.resolve_target_at_cursor()) + end) + + it('uses diff extmark new-file line for add and context rows', function() + local header = '[`' .. existing_path .. '`](file)' + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { header, '+ added', ' context' }) + + add_diff_extmark(output_buf, 1, ' 42 ', '+') + vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + assert.same({ path = existing_path, line = 42 }, navigation.resolve_target_at_cursor()) + + add_diff_extmark(output_buf, 2, ' 43 ', ' ') + vim.api.nvim_win_set_cursor(output_win, { 3, 0 }) + assert.same({ path = existing_path, line = 43 }, navigation.resolve_target_at_cursor()) + end) + + it('does not jump deleted diff rows to old-file lines', function() + local header = '[`' .. existing_path .. '`](file)' + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { header, '- deleted' }) + add_diff_extmark(output_buf, 1, ' 9 ', '-') + vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + + assert.is_nil(navigation.resolve_target_at_cursor()) + end) + + it('keeps window and cursor unchanged on missing path or plain text', function() + local notify_stub = stub(vim, 'notify') + local load_stub = stub(renderer, 'load_all_messages') + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { '`missing/not_here.lua`', 'plain text' }) + set_cursor_on(output_win, 1, '`missing/not_here.lua`', 'missing/not_here.lua') + local before_win = vim.api.nvim_get_current_win() + local before_cursor = vim.api.nvim_win_get_cursor(output_win) + + navigation.jump_to_target_at_cursor() + + assert.equals(before_win, vim.api.nvim_get_current_win()) + assert.same(before_cursor, vim.api.nvim_win_get_cursor(output_win)) + assert.stub(notify_stub).was_not_called() + assert.stub(load_stub).was_not_called() + + vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + navigation.jump_to_target_at_cursor() + assert.equals(before_win, vim.api.nvim_get_current_win()) + assert.stub(notify_stub).was_not_called() + assert.stub(load_stub).was_not_called() + + notify_stub:revert() + load_stub:revert() + end) + + it('opens explicit locations with 1-based col converted and clamped', function() + navigation.navigate_to_location(existing_path, 9999, 9999) + + assert.equals(code_win, vim.api.nvim_get_current_win()) + local cursor = vim.api.nvim_win_get_cursor(code_win) + local line_count = vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(code_win)) + assert.equals(line_count, cursor[1]) + local line = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(code_win), line_count - 1, line_count, false)[1] + assert.equals(math.max(#line - 1, 0), cursor[2]) + end) + + it('hides current-mode output before opening a target so output view can be restored', function() + config.values.ui.position = 'current' + config.values.ui.persist_state = true + + input_buf = vim.api.nvim_create_buf(false, true) + input_win = vim.api.nvim_open_win(input_buf, false, { + relative = 'editor', + width = 40, + height = 3, + row = 15, + col = 0, + }) + + vim.api.nvim_win_set_buf(code_win, output_buf) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'out 1', 'out 2', 'out 3' }) + vim.api.nvim_set_current_win(code_win) + vim.api.nvim_win_set_cursor(code_win, { 2, 3 }) + + state.ui.set_windows({ + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = code_win, + }) + state.ui.set_last_code_window(code_win) + state.ui.set_current_code_buf(code_buf) + + navigation.navigate_to_location(existing_path, 1, 1) + + assert.is_true(ui.has_hidden_buffers()) + assert.equals('hidden', state.ui.get_window_state().status) + assert.is_true(vim.api.nvim_win_is_valid(code_win)) + assert.is_not_equal(output_buf, vim.api.nvim_win_get_buf(code_win)) + + assert.is_true(ui.restore_hidden_windows()) + vim.wait(200, function() + local pos = vim.api.nvim_win_get_cursor(state.windows.output_win) + return pos[1] == 2 and pos[2] == 3 + end, 10) + + assert.equals(output_buf, vim.api.nvim_win_get_buf(state.windows.output_win)) + assert.same({ 2, 3 }, vim.api.nvim_win_get_cursor(state.windows.output_win)) + end) +end)