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 lua/opencode/protocols/v2/observation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function M.new(connection, ref)
---@param sync table
---@return boolean
refresh_after_event = function(resource, sync)
return resource ~= 'files' and sync.state == 'error'
return resource ~= 'files' and (sync.state == 'error' or sync.state == 'stale')
end,
request_resource = function(current, resource)
---@cast current OpencodeV2Observation
Expand Down
112 changes: 87 additions & 25 deletions lua/opencode/protocols/v2/observation/messages.lua
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ function M.ingest_snapshot(observation, messages, merge)
end)
else
local state = observation:read()
-- Only a wholesale replacement moves entries, order and cursor together,
-- and only that makes the pages cut from the previous one stale.
observation._v2_snapshot_generation = observation._v2_snapshot_generation + 1
local entries_by_id, order = {}, {}
for _, entry in ipairs(mapped) do
entries_by_id[entry.id] = entries.replace(state.entries_by_id[entry.id], entry)
Expand Down Expand Up @@ -247,26 +250,30 @@ end
---@param data table
handlers['session.synthetic'] = function(observation, event, data)
local metadata = data.metadata
-- Background completions carry no message id, so the snapshot is the only
-- source of the entry that records their outcome.
if normalize.is_background_notification(metadata) then
return invalidate_messages()
end
-- Only server-authored notices carry a description; plain synthetic parts are
-- already in the message snapshot, so projecting them again would duplicate.
if type(data.description) ~= 'string' or data.description == '' then
return false
local background = normalize.is_background_notification(metadata)
-- Pulling a snapshot for these would replace every loaded page with the
-- newest one, and the entry is already reachable from the event's id.
if not background then
-- Plain synthetic parts are already in the snapshot; only notices carry a
-- description meant for the transcript.
if type(data.description) ~= 'string' or data.description == '' then
return false
end
end
if type(data.text) ~= 'string' then
return invalid(observation, event.type, 'is missing text')
end
return put_notice(observation, event, {
local info = {
id = notice_message_id(event),
type = 'synthetic',
text = data.text,
description = data.description,
time = { created = event.created },
})
}
if background then
info.metadata = metadata
end
return put_notice(observation, event, info)
end

---@param observation OpencodeV2Observation
Expand Down Expand Up @@ -473,7 +480,37 @@ end
handlers['session.tool.success'] = finish_tool
handlers['session.tool.failed'] = finish_tool

handlers['session.revert.committed'] = invalidate_messages
---A committed revert deletes every message from its boundary onwards, so a
---loaded tail can be dropped without asking for the page again.
---@param observation OpencodeV2Observation
---@param event OpencodeV2Event
---@param data table
handlers['session.revert.committed'] = function(observation, event, data)
local order = observation:read().entry_order
local boundary
if type(data.to) == 'string' then
for position, id in ipairs(order) do
if id == data.to then
boundary = position
break
end
end
end
if not boundary then
-- The boundary predates the loaded window, so any loaded message may have
-- been deleted; only a snapshot can say which.
return invalidate_messages()
end
local state = observation:read()
for position = #order, boundary, -1 do
local id = order[position]
state.entries_by_id[id] = nil
observation._v2_content_by_message[id] = nil
order[position] = nil
end
return true
end

handlers['session.compaction.ended'] = invalidate_messages

---@param observation OpencodeV2Observation
Expand All @@ -489,8 +526,9 @@ function M.ingest_event(observation, event)
end
local result = handler(observation, event, event.data)
if result == 'refetch' then
-- Let `_event_changed` start the refetch: it bumps the revision first, so
-- the request it makes is the one that lands.
observation:read().sync.messages = { state = 'stale' }
observation:_start_resource('messages')
return true
end
if not result then
Expand Down Expand Up @@ -529,29 +567,38 @@ function M.initialize(observation)
observation._v2_content_by_message = {}
observation._v2_older_cursor = nil
observation._v2_history_complete = false
observation._v2_older_loading = false
observation._v2_older_page = nil
observation._v2_snapshot_generation = 0
end

---@param observation OpencodeV2Observation
---@param connection OpencodeV2Connection
function M.attach_history(observation, connection)
---@return Promise<nil>
function observation:load_older()
if self._v2_older_loading then
boundary.fail('load_older is already in progress')
end
if self._v2_history_complete or not self._v2_older_cursor then
return Promise.new():resolve(nil)
end

self._v2_older_loading = true
local in_flight = self._v2_older_page
if in_flight then
if in_flight.cursor == self._v2_older_cursor then
return in_flight.promise
end
-- The cursor moved while the page was in flight; queue behind it so this
-- caller reads the cursor that a rebuild left behind, not the one it saw.
return Promise.async(function()
in_flight.promise:await()
self:load_older():await()
end)()
end

local finish = self:_begin_local_operation()
local cursor = self._v2_older_cursor
local revision = self._event_revisions.messages
local generation = self._v2_snapshot_generation
local ok, operation_request =
pcall(connection.operations.list_messages, connection, self._session_id, cursor, 50, nil)
if not ok then
self._v2_older_loading = false
finish()
error(operation_request, 0)
end
Expand All @@ -560,25 +607,38 @@ function M.attach_history(observation, connection)
if not self:_is_current() then
boundary.fail('older messages arrived after Observation release')
end
if self._event_revisions.messages ~= revision then
self:read().sync.messages = { state = 'stale' }
self:_start_resource('messages')
if self._v2_snapshot_generation ~= generation then
-- The snapshot this page was cut from is gone; the newer one already
-- carries the server's answer.
return
end
M.apply_page(self, page, true)
self:_notify('messages')
end)
return request:finally(function()
self._v2_older_loading = false
local page = { cursor = cursor, promise = nil }
---@cast page OpencodeV2OlderPage
page.promise = request:finally(function()
-- release/initialize drop the slot; a page landing after that must not
-- blank the one that replaced it
if self._v2_older_page == page then
self._v2_older_page = nil
end
finish()
end)
self._v2_older_page = page
return page.promise
end

---@return Promise<nil>
function observation:load_complete_history()
return Promise.async(function()
while not self._v2_history_complete and self._v2_older_cursor do
local cursor = self._v2_older_cursor
self:load_older():await()
if self._v2_older_cursor == cursor then
-- Same cursor back means another round would ask the same question.
return
end
end
end)()
end
Expand All @@ -595,6 +655,8 @@ function M.release(observation)
observation._v2_content_by_message = {}
observation._v2_older_cursor = nil
observation._v2_history_complete = false
observation._v2_older_page = nil
observation._v2_snapshot_generation = 0
end

return M
8 changes: 7 additions & 1 deletion lua/opencode/protocols/v2/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@

---@alias OpencodeV2EventHandler fun(observation: OpencodeV2Observation, event: OpencodeV2Event, data: table): false|'terminal'|nil

---An older page being fetched; callers asking for the same page share `promise`.
---@class OpencodeV2OlderPage
---@field cursor string
---@field promise Promise<nil>

---@class OpencodeV2Observation: OpencodeObservation
---@field _connection OpencodeV2Connection
---@field _v2_admissions table<string, OpencodeV2PendingAdmission|nil>
Expand All @@ -312,7 +317,8 @@
---@field _v2_content_by_message table<string, table<string, table|nil>|nil>
---@field _v2_older_cursor? string
---@field _v2_history_complete boolean
---@field _v2_older_loading boolean
---@field _v2_older_page? OpencodeV2OlderPage
---@field _v2_snapshot_generation integer Bumped when a wholesale snapshot replaces entries, order and cursor
---@field _v2_inbox_terminal table<string, table|nil>
---@field _v2_permission_terminal table<string, {answer: string}|nil>
---@field _v2_question_terminal table<string, {status: string, answers?: OpencodeV2FormAnswers}|nil>
Expand Down
78 changes: 50 additions & 28 deletions lua/opencode/ui/navigation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,27 @@ function M.goto_prev_message()
vim.api.nvim_win_set_cursor(win, { 1, 0 })
end

function M.goto_next_user_message()
---@param win integer
---@param direction 'next'|'prev'
---@param line integer Line the search starts from, 1-indexed
---@return boolean moved
local function jump_to_user_message(win, direction, line)
local target
if direction == 'next' then
target = renderer.get_next_user_message(line)
else
target = renderer.get_prev_user_message(line)
end
if not target or not target.line_start then
return false
end
mark_jump_position(win)
vim.api.nvim_win_set_cursor(win, { target.line_start + 1, 0 })
return true
end

---@param direction 'next'|'prev'
local function goto_user_message(direction)
require('opencode.ui.ui').focus_output()
local windows = state.windows or {}
local win = windows.output_win
Expand All @@ -104,42 +124,44 @@ function M.goto_next_user_message()
return
end

-- Like `gg`, under lazy render the target
-- message may not yet have a line_start, so force a full render first.
renderer.load_all_messages(nil, { scroll_to_top = false })
local missing = direction == 'next' and 'No next user message' or 'No previous user message'
-- Growing the window renumbers every line below, so hold the position by id.
local anchor = renderer.get_prev_rendered_message(vim.api.nvim_win_get_cursor(win)[1] + 1)
local anchor_id = anchor and anchor.message.id
local _, started = renderer.load_all_messages(nil, { scroll_to_top = false })

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 })
if jump_to_user_message(win, direction, vim.api.nvim_win_get_cursor(win)[1]) then
return
end

vim.notify('No next user message', vim.log.levels.INFO)
end

function M.goto_prev_user_message()
require('opencode.ui.ui').focus_output()
local windows = state.windows or {}
local win = windows.output_win
local buf = windows.output_buf

if not win or not buf then
if not started then
vim.notify(missing, vim.log.levels.INFO)
return
end

renderer.load_all_messages(nil, { scroll_to_top = false })
renderer.after_history_load(function()
if not vim.api.nvim_win_is_valid(win) then
return
end
local rendered = anchor_id and renderer.get_rendered_message(anchor_id)
local line
if rendered and rendered.line_start then
line = rendered.line_start + 2
else
line = vim.api.nvim_win_get_cursor(win)[1]
end
if not jump_to_user_message(win, direction, line) then
vim.notify(missing, vim.log.levels.INFO)
end
end)
end

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
function M.goto_next_user_message()
goto_user_message('next')
end

vim.notify('No previous user message', vim.log.levels.INFO)
function M.goto_prev_user_message()
goto_user_message('prev')
end

---@param raw string
Expand Down
Loading
Loading