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
144 changes: 69 additions & 75 deletions lua/opencode/ui/output_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ M._prev_line_count_by_win = {}
local function build_fold_state(folds)
local fold_state = {
ranges = {},
starts = {},
}

for _, range in ipairs(folds or {}) do
Expand All @@ -23,53 +22,29 @@ local function build_fold_state(folds)
from = range.from,
to = range.to,
}
fold_state.starts[#fold_state.starts + 1] = range.from
end
end

table.sort(fold_state.ranges, function(a, b)
return a.from < b.from
end)
table.sort(fold_state.starts)

return fold_state
end

---@param buf integer
---@return { ranges: table<{from: integer, to: integer}>, starts: integer[] }
---@return { ranges: table<{from: integer, to: integer}> }
local function get_fold_state(buf)
local ok, fold_state = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds')
if not ok or type(fold_state) ~= 'table' then
return { ranges = {}, starts = {} }
return { ranges = {} }
end
if type(fold_state.ranges) == 'table' and type(fold_state.starts) == 'table' then
if type(fold_state.ranges) == 'table' then
return fold_state
end
return build_fold_state(fold_state)
end

---@param ranges table<{from: integer, to: integer}>
---@param line integer
---@return boolean
local function line_in_fold(ranges, line)
local lo = 1
local hi = #ranges

while lo <= hi do
local mid = math.floor((lo + hi) / 2)
local range = ranges[mid]
if line < range.from then
hi = mid - 1
elseif line > range.to then
lo = mid + 1
else
return true
end
end

return false
end

local _update_depth = 0
local _update_buf = nil

Expand Down Expand Up @@ -243,11 +218,11 @@ function M.setup(windows)
window_options.set_buffer_option('swapfile', false, windows.output_buf)
window_options.set_buffer_option('undofile', false, windows.output_buf)
window_options.set_buffer_option('undolevels', -1, windows.output_buf)
window_options.set_window_option('foldmethod', 'expr', windows.output_win)
window_options.set_window_option('foldexpr', 'v:lua.opencode_fold_expr()', windows.output_win)
window_options.set_window_option('foldmethod', 'manual', windows.output_win)
window_options.set_window_option('foldenable', true, windows.output_win)
window_options.set_window_option('foldlevel', 0, windows.output_win)
window_options.set_window_option('foldcolumn', '1', windows.output_win)
window_options.set_window_option('fillchars', 'fold:-,foldopen:-,foldclose:+,foldsep:│', windows.output_win)
window_options.set_window_option('foldtext', 'v:lua.opencode_fold_text()', windows.output_win)

if config.ui.position ~= 'current' then
Expand Down Expand Up @@ -307,32 +282,6 @@ function M.update_dimensions(windows)
pcall(vim.api.nvim_win_set_config, windows.output_win, { width = width })
end

---Fold expression for the output buffer
---@return number
function M.fold_expr()
local output_buf = nil

local windows = state.windows
if windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf) then
output_buf = windows.output_buf
else
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_has_var(buf, 'opencode_folds') then
output_buf = buf
break
end
end
end

if not output_buf then
return 0
end

local line = vim.v.lnum
local fold_state = get_fold_state(output_buf)
return line_in_fold(fold_state.ranges, line) and 1 or 0
end

---Fold text for the output buffer
---@return string
function M.fold_text()
Expand Down Expand Up @@ -363,7 +312,6 @@ function M.fold_text()
return vim.fn.foldtext()
end

_G.opencode_fold_expr = M.fold_expr
_G.opencode_fold_text = M.fold_text

function M.get_open_fold_starts(win, buf)
Expand Down Expand Up @@ -404,28 +352,21 @@ function M.set_folds(fold_ranges)
end

local was_open = M.get_open_fold_starts(win, buf)

vim.api.nvim_buf_set_var(buf, 'opencode_folds', folds)

vim.api.nvim_win_call(win, function()
local view = vim.fn.winsaveview()
vim.cmd('silent! normal! zx')
local prev_starts = {}
for _, start_line in ipairs(prev_folds.starts) do
prev_starts[start_line] = true
end

local line_count = vim.api.nvim_buf_line_count(buf)
for _, range in ipairs(folds.ranges) do
if not prev_starts[range.from] then
vim.fn.cursor(range.from, 1)
vim.cmd('silent! normal! zc')
if range.from <= line_count and range.to <= line_count then
vim.cmd(range.from .. ',' .. range.to .. 'fold')
end
end

for _, range in ipairs(folds.ranges) do
if was_open[range.from] then
vim.fn.cursor(range.from, 1)
vim.cmd('silent! normal! zo')
vim.cmd(range.from .. ',' .. range.to .. 'foldopen!')
end
end

Expand Down Expand Up @@ -652,6 +593,14 @@ end
function M.setup_keymaps(windows)
local keymap = require('opencode.keymap')
keymap.setup_window_keymaps(config.keymap.output_window, windows.output_buf)

-- When lazy-render is active, gg only reaches the top of rendered content.
-- Load all messages first so gg reaches the true start of history.
vim.keymap.set('n', 'gg', function()
local renderer = require('opencode.ui.renderer')
renderer.load_all_messages()
vim.api.nvim_win_set_cursor(0, { 1, 0 })
end, { buffer = windows.output_buf })
end

---@param windows OpencodeWindowState
Expand Down Expand Up @@ -692,13 +641,58 @@ function M.setup_autocmds(windows, group)
end,
})

vim.api.nvim_create_autocmd('WinScrolled', {
group = group,
buffer = windows.output_buf,
callback = function()
M.sync_cursor_with_viewport(windows.output_win)
end,
})
-- Lazy-render: load more messages on scroll-to-top (debounced)
local debounced_load_more = require('opencode.util').debounce(function()
local renderer = require('opencode.ui.renderer')
local render_state = require('opencode.ui.renderer.ctx').render_state
-- Save the message at the top of the viewport before loading more
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
local anchor_msg_id = nil
local anchor_was_at_line = nil
if ok and cursor then
local top_line = cursor[1]
for _, msg in ipairs(state.messages or {}) do
local msg_id = msg.info and msg.info.id or ''
if not msg_id:match('^__opencode_') then
local rendered = render_state:get_message(msg_id)
if rendered and rendered.line_start and rendered.line_start >= top_line then
anchor_msg_id = msg_id
anchor_was_at_line = rendered.line_start
break
end
end
end
end

if renderer.load_more_messages() then
-- Restore cursor to the anchor message's new position
if anchor_msg_id then
local rendered = render_state:get_message(anchor_msg_id)
if rendered and rendered.line_start then
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { rendered.line_start, 0 })
return
end
end
-- Fallback: move to top of buffer
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { 1, 0 })
end
end, 150)
vim.api.nvim_create_autocmd('WinScrolled', {
group = group,
buffer = windows.output_buf,
callback = function()
M.sync_cursor_with_viewport(windows.output_win)
local ctx = require('opencode.ui.renderer.ctx')
local has_unrendered = ctx.lazy_render_count ~= nil
and ctx.lazy_render_count < #state.messages
if has_unrendered then
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
if ok and cursor and cursor[1] <= 3 then
debounced_load_more()
end
end
end,
})
end

---Clear the output buffer and all namespaces.
Expand Down
93 changes: 87 additions & 6 deletions lua/opencode/ui/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ local M = {}
local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__'
local HIDDEN_MESSAGES_NOTICE_PART_ID = '__opencode_hidden_messages_notice_part__'

local LAZYRENDER_EST_LINES_PER_MSG = 5
local LAZYRENDER_VIEWPORT_BUFFER = 1.5

---Calculate how many messages to render initially based on window height.
---@return integer
local function get_initial_render_count()
local win = state.windows and state.windows.output_win
if not win or not vim.api.nvim_win_is_valid(win) then
return math.huge -- no window: render all (tests, headless)
end
local ok, height = pcall(vim.api.nvim_win_get_height, win)
if not ok or not height or height <= 0 then
return math.huge
end
return math.ceil(height / LAZYRENDER_EST_LINES_PER_MSG * LAZYRENDER_VIEWPORT_BUFFER)
end

---@return integer|nil
local function get_max_rendered_messages()
local limit = config.ui and config.ui.output and config.ui.output.max_messages
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that we should combine the max_message behavior with the lazy one, otherwise it might make things confusing. And setting it to nil would essentially disable the lazy rendering ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I might be a bit busy these two days, and I actually lack some context in understanding the "max_message" feature. I can roughly grasp that the relationship between these two features might be a bit confusing, but I haven't figured it out yet.

For my simple profile, SQLite reading doesn't seem to be the bottleneck. I'll look into why that feature was introduced in the next day or two to make sure I haven't missed any considerations

Expand Down Expand Up @@ -319,6 +336,9 @@ end
---@param opts? { restore_model_from_messages?: boolean }
function M._render_full_session_data(session_data, opts)
opts = opts or {}
-- Read before reset() clears it
local lazy_limit = ctx.lazy_render_count
local t_start = vim.uv.hrtime()
M.reset()
state.renderer.set_messages(session_data or {})

Expand All @@ -329,6 +349,18 @@ function M._render_full_session_data(session_data, opts)
local visible_messages, hidden_count = get_visible_session_messages(state.messages)
local revert_index = get_revert_index(state.messages)

if lazy_limit == nil then
local initial = get_initial_render_count()
if #visible_messages > initial then
lazy_limit = initial
end
end
ctx.lazy_render_count = lazy_limit
if lazy_limit and #visible_messages > lazy_limit then
visible_messages = vim.list_slice(visible_messages, #visible_messages - lazy_limit + 1)
end

local t_format_start = vim.uv.hrtime()
flush.begin_bulk_mode()

if hidden_count > 0 then
Expand Down Expand Up @@ -376,8 +408,10 @@ function M._render_full_session_data(session_data, opts)
events.on_part_updated({ part = revert_message.parts[1] })
end

local t_format_end = vim.uv.hrtime()
flush.flush()
flush.end_bulk_mode()
local t_flush_end = vim.uv.hrtime()

if opts.restore_model_from_messages then
require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true })
Expand All @@ -397,26 +431,73 @@ function M.render_from_cache(session_data)
if not output_window.mounted() or not state.api_client then
return
end
M._render_full_session_data(session_data, {
restore_model_from_messages = true,
})
M._render_full_session_data(session_data, {
restore_model_from_messages = true,
})
local active_session = state.active_session
if active_session and active_session.id then
require('opencode.ui.question_window').restore_pending_question(active_session.id)
permission_window.restore_pending_permissions(active_session.id)
end
end

---Load more older messages into the output buffer.
---Called when user scrolls to the top of the output window.
---@return boolean Whether more messages were loaded
function M.load_more_messages()
if not state.messages then
return false
end
-- nil means no lazy limit → all messages already rendered
if not ctx.lazy_render_count then
return false
end
local total = #get_visible_session_messages(state.messages)
if total == 0 then
return false
end
if ctx.lazy_render_count >= total then
return false
end

-- Load another viewport's worth
ctx.lazy_render_count = math.min(ctx.lazy_render_count + get_initial_render_count(), total)
M.render_from_cache(state.messages)
return true
end

---Load all remaining messages and re-render.
---Used when user explicitly navigates to the top (gg) to ensure
---the full history is available for navigation and search.
---@return boolean Whether any messages were loaded
function M.load_all_messages()
if not state.messages then
return false
end
local total = #get_visible_session_messages(state.messages)
if total == 0 then
return false
end
-- nil means no lazy limit → all messages already rendered
if not ctx.lazy_render_count or ctx.lazy_render_count >= total then
return false
end

ctx.lazy_render_count = total
M.render_from_cache(state.messages)
return true
end

---Fetch the active session from the server and render it
---@return Promise<OpencodeMessage[]>
function M.render_full_session()
if not output_window.mounted() or not state.api_client then
return Promise.new():resolve(nil)
end
return fetch_session():and_then(function(session_data)
M._render_full_session_data(session_data, {
restore_model_from_messages = true,
})
M._render_full_session_data(session_data, {
restore_model_from_messages = true,
})
local active_session = state.active_session
if active_session and active_session.id then
require('opencode.ui.question_window').restore_pending_question(active_session.id)
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode/ui/renderer/ctx.lua
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ local ctx = {
global_folds = {},
---@type table<string, {from: number, to: number}[]>
part_folds = {},
---@type integer|nil Number of messages to render from the end (nil = all)
lazy_render_count = nil,
}

---Reset all renderer caches and pending state.
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ function M.replay_setup()
end

renderer.reset()
-- Ensure replay tests render all messages (lazy-render is always active)
require('opencode.ui.renderer.ctx').lazy_render_count = math.huge
permission_window.clear_all()
question_window._clear_dialog()
question_window._current_question = nil
Expand Down
Loading
Loading