diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index 723973ff..c06fa224 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -58,6 +58,18 @@ local log = require('opencode.log') --- @field type "session.error" --- @field properties {sessionID: string, error: table} +--- @class EventSessionStatus +--- @field type "session.status" +--- @field properties { +--- sessionID: string, +--- status: { +--- type: string, +--- message?: string, +--- attempt?: number, +--- next?: number +--- } +--- } + --- @class OpencodePermission --- @field id string --- @field type string @@ -146,6 +158,7 @@ local log = require('opencode.log') --- | "session.updated" --- | "session.deleted" --- | "session.error" +--- | "session.status" --- | "permission.updated" --- | "permission.asked" --- | "permission.replied" @@ -208,6 +221,7 @@ end --- @overload fun(self: EventManager, event_name: "session.updated", callback: fun(data: EventSessionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.deleted", callback: fun(data: EventSessionDeleted['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.error", callback: fun(data: EventSessionError['properties']): nil) +--- @overload fun(self: EventManager, event_name: "session.status", callback: fun(data: EventSessionStatus['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.updated", callback: fun(data: EventPermissionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.replied", callback: fun(data: EventPermissionReplied['properties']): nil) --- @overload fun(self: EventManager, event_name: "file.edited", callback: fun(data: EventFileEdited['properties']): nil) @@ -249,6 +263,7 @@ end --- @overload fun(self: EventManager, event_name: "session.updated", callback: fun(data: EventSessionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.deleted", callback: fun(data: EventSessionDeleted['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.error", callback: fun(data: EventSessionError['properties']): nil) +--- @overload fun(self: EventManager, event_name: "session.status", callback: fun(data: EventSessionStatus['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.updated", callback: fun(data: EventPermissionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.replied", callback: fun(data: EventPermissionReplied['properties']): nil) --- @overload fun(self: EventManager, event_name: "file.edited", callback: fun(data: EventFileEdited['properties']): nil) diff --git a/lua/opencode/ui/loading_animation.lua b/lua/opencode/ui/loading_animation.lua index 78f7bf71..873c5e75 100644 --- a/lua/opencode/ui/loading_animation.lua +++ b/lua/opencode/ui/loading_animation.lua @@ -7,13 +7,115 @@ local M = {} M._animation = { frames = nil, text = 'Thinking... ', + status_data = nil, current_frame = 1, timer = nil, fps = 10, extmark_id = nil, ns_id = vim.api.nvim_create_namespace('opencode_loading_animation'), + status_event_manager = nil, } +---@param status table|nil +---@return string|nil +function M._format_status_text(status) + if type(status) ~= 'table' then + return nil + end + + local status_type = status.type + + if status_type == 'busy' then + return M._animation.text + end + + if status_type == 'idle' then + return nil + end + + if status_type == 'retry' then + local message = status.message or 'Retrying request' + local details = {} + + if type(status.attempt) == 'number' then + table.insert(details, 'retry ' .. status.attempt) + end + + if type(status.next) == 'number' then + local now_ms = os.time() * 1000 + local seconds = math.max(0, math.ceil((status.next - now_ms) / 1000)) + table.insert(details, 'in ' .. seconds .. 's') + end + + if #details > 0 then + return string.format('%s (%s)... ', message, table.concat(details, ', ')) + end + + return message .. '... ' + end + + if type(status.message) == 'string' and status.message ~= '' then + return status.message .. '... ' + end + + return M._animation.text +end + +local function unsubscribe_session_status_event(manager) + if manager and M._animation.status_event_manager == manager then + manager:unsubscribe('session.status', M.on_session_status) + M._animation.status_event_manager = nil + end +end + +local function subscribe_session_status_event(manager) + if not manager then + return + end + + if M._animation.status_event_manager and M._animation.status_event_manager ~= manager then + unsubscribe_session_status_event(M._animation.status_event_manager) + end + + if M._animation.status_event_manager == manager then + return + end + + manager:subscribe('session.status', M.on_session_status) + M._animation.status_event_manager = manager +end + +function M.on_session_status(properties) + if not properties or type(properties) ~= 'table' then + return + end + + local active_session = state.active_session + if active_session and active_session.id and properties.sessionID ~= active_session.id then + return + end + + M._animation.status_data = properties.status + M.render(state.windows) +end + +local function on_active_session_change(_, new_session, old_session) + local new_id = new_session and new_session.id + local old_id = old_session and old_session.id + if new_id ~= old_id then + M._animation.status_data = nil + end +end + +local function on_event_manager_change(_, new_manager, old_manager) + unsubscribe_session_status_event(old_manager) + subscribe_session_status_event(new_manager) +end + +function M._get_display_text() + return M._format_status_text(M._animation.status_data) or M._animation.text +end + function M._get_frames() if M._animation.frames then return M._animation.frames @@ -41,7 +143,7 @@ M.render = vim.schedule_wrap(function(windows) return false end - local loading_text = M._animation.text .. M._get_frames()[M._animation.current_frame] + local loading_text = M._get_display_text() .. M._get_frames()[M._animation.current_frame] M._animation.extmark_id = vim.api.nvim_buf_set_extmark(windows.footer_buf, M._animation.ns_id, 0, 0, { id = M._animation.extmark_id or nil, @@ -97,6 +199,7 @@ end function M.stop() M._clear_animation_timer() M._animation.current_frame = 1 + M._animation.status_data = nil if state.windows and state.windows.footer_buf and vim.api.nvim_buf_is_valid(state.windows.footer_buf) then pcall(vim.api.nvim_buf_clear_namespace, state.windows.footer_buf, M._animation.ns_id, 0, -1) end @@ -120,10 +223,17 @@ end function M.setup() state.subscribe('job_count', on_running_change) + state.subscribe('active_session', on_active_session_change) + state.subscribe('event_manager', on_event_manager_change) + subscribe_session_status_event(state.event_manager) end function M.teardown() state.unsubscribe('job_count', on_running_change) + state.unsubscribe('active_session', on_active_session_change) + state.unsubscribe('event_manager', on_event_manager_change) + unsubscribe_session_status_event(M._animation.status_event_manager) + M._animation.status_data = nil end return M diff --git a/tests/data/provider-overloaded-status.expected.json b/tests/data/provider-overloaded-status.expected.json new file mode 100644 index 00000000..6d5076d2 --- /dev/null +++ b/tests/data/provider-overloaded-status.expected.json @@ -0,0 +1,168 @@ +{ + "actions": [], + "extmarks": [ + [ + 1, + 1, + 0, + { + "ns_id": 3, + "priority": 10, + "right_gravity": true, + "virt_text": [ + [ + "▌󰭻 ", + "OpencodeMessageRoleUser" + ], + [ + " " + ], + [ + "USER", + "OpencodeMessageRoleUser" + ], + [ + "", + "OpencodeHint" + ], + [ + " [msg_0000000000001]", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3 + } + ], + [ + 2, + 1, + 0, + { + "ns_id": 3, + "priority": 9, + "right_gravity": true, + "virt_text": [ + [ + " 2023-11-14 22:13:20", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 3, + 2, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeMessageRoleUser" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3 + } + ], + [ + 4, + 3, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeMessageRoleUser" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3 + } + ], + [ + 5, + 6, + 0, + { + "ns_id": 3, + "priority": 10, + "right_gravity": true, + "virt_text": [ + [ + " ", + "OpencodeMessageRoleAssistant" + ], + [ + " " + ], + [ + "BUILD", + "OpencodeMessageRoleAssistant" + ], + [ + " claude-sonnet-4-5", + "OpencodeHint" + ], + [ + " [msg_0000000000002]", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3 + } + ], + [ + 6, + 6, + 0, + { + "ns_id": 3, + "priority": 9, + "right_gravity": true, + "virt_text": [ + [ + " 2023-11-14 22:13:21", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ] + ], + "lines": [ + "----", + "", + "", + "Can you help me fix this bug?", + "", + "----", + "", + "", + "Sure, I can help with that.", + "", + "" + ], + "timestamp": 1773091221 +} + diff --git a/tests/data/provider-overloaded-status.json b/tests/data/provider-overloaded-status.json new file mode 100644 index 00000000..bb7485b6 --- /dev/null +++ b/tests/data/provider-overloaded-status.json @@ -0,0 +1,156 @@ +[ + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_0000000000001", + "sessionID": "ses_0000000000001", + "role": "user", + "agent": "build", + "time": { "created": 1700000000000 }, + "model": { "providerID": "anthropic", "modelID": "claude-sonnet-4-5" } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_0000000000001", + "messageID": "msg_0000000000001", + "sessionID": "ses_0000000000001", + "type": "text", + "text": "Can you help me fix this bug?" + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_0000000000001", + "title": "Bug fix session", + "time": { "created": 1700000000000, "updated": 1700000001000 } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_0000000000002", + "sessionID": "ses_0000000000001", + "role": "assistant", + "agent": "build", + "mode": "build", + "providerID": "anthropic", + "modelID": "claude-sonnet-4-5", + "parentID": "msg_0000000000001", + "time": { "created": 1700000001000 }, + "cost": 0, + "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { + "type": "retry", + "message": "Provider is overloaded", + "attempt": 1, + "next": 1700000011000 + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { + "type": "retry", + "message": "Provider is overloaded", + "attempt": 2, + "next": 1700000021000 + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { + "type": "retry", + "message": "Provider is overloaded", + "attempt": 3, + "next": 1700000041000 + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_0000000000002", + "messageID": "msg_0000000000002", + "sessionID": "ses_0000000000001", + "type": "text", + "text": "Sure, I can help with that." + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_0000000000002", + "sessionID": "ses_0000000000001", + "role": "assistant", + "agent": "build", + "mode": "build", + "providerID": "anthropic", + "modelID": "claude-sonnet-4-5", + "parentID": "msg_0000000000001", + "time": { "created": 1700000001000, "completed": 1700000050000 }, + "cost": 0, + "tokens": { "input": 100, "output": 20, "reasoning": 0, "cache": { "read": 0, "write": 0 } } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "idle" } + } + } +] diff --git a/tests/unit/loading_animation_spec.lua b/tests/unit/loading_animation_spec.lua new file mode 100644 index 00000000..22d5db71 --- /dev/null +++ b/tests/unit/loading_animation_spec.lua @@ -0,0 +1,57 @@ +local state = require('opencode.state') +local loading_animation = require('opencode.ui.loading_animation') + +describe('loading_animation status text', function() + local original_time + + before_each(function() + original_time = os.time + loading_animation._animation.status_data = nil + state.active_session = nil + end) + + after_each(function() + os.time = original_time + loading_animation._animation.status_data = nil + state.active_session = nil + end) + + it('renders busy as thinking text', function() + local text = loading_animation._format_status_text({ type = 'busy' }) + assert.are.equal('Thinking... ', text) + end) + + it('counts down retry seconds dynamically', function() + loading_animation._animation.status_data = { + type = 'retry', + message = 'Provider is overloaded', + attempt = 2, + next = 1018000, + } + + os.time = function() + return 1000 + end + local first = loading_animation._get_display_text() + + os.time = function() + return 1005 + end + local second = loading_animation._get_display_text() + + assert.is_truthy(first:find('in 18s', 1, true)) + assert.is_truthy(second:find('in 13s', 1, true)) + end) + + it('ignores status updates for non-active sessions', function() + state.active_session = { id = 'ses_active' } + loading_animation._animation.status_data = nil + + loading_animation.on_session_status({ + sessionID = 'ses_other', + status = { type = 'retry', message = 'Provider is overloaded' }, + }) + + assert.is_nil(loading_animation._animation.status_data) + end) +end)