Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/recipes/three-state-layout/demo.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion lua/opencode/commands/handlers/workflow.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
['<CR>'] = { 'jump_to_target_at_cursor', desc = 'Jump to target at cursor' },
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
['<M-r>'] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' },
['<leader>oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' },
Expand Down
185 changes: 138 additions & 47 deletions lua/opencode/ui/navigation.lua
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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 }
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
43 changes: 43 additions & 0 deletions tests/data/output-target-navigation.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
]
Loading
Loading