diff --git a/README.md b/README.md index 03fff0ec..3f8853ea 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ require('opencode').setup({ ['oI'] = { 'open_input_new_session' }, -- Opens and focuses on input window on insert mode. Creates a new session ['oo'] = { 'open_output' }, -- Opens and focuses on output window ['ot'] = { 'toggle_focus' }, -- Toggle focus between opencode and last window + ['oT'] = { 'timeline' }, -- Display timeline picker to navigate/undo/redo/fork messages ['oq'] = { 'close' }, -- Close UI windows ['os'] = { 'select_session' }, -- Select and load a opencode session ['op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list @@ -159,6 +160,10 @@ require('opencode').setup({ session_picker = { delete_session = { '' }, -- Delete selected session in the session picker }, + timeline_picker = { + undo = { '', mode = { 'i', 'n' } }, -- Undo to selected message in timeline picker + fork = { '', mode = { 'i', 'n' } }, -- Fork from selected message in timeline picker + }, }, ui = { position = 'right', -- 'right' (default) or 'left'. Position of the UI split @@ -315,55 +320,56 @@ The plugin provides the following actions that can be triggered via keymaps, com > **Note:** Commands have been restructured into a single `:Opencode` command with subcommands. Legacy `Opencode*` commands (e.g., `:OpencodeOpenInput`) are still available by default but will be removed in a future version. Update your scripts and workflows to use the new nested syntax. -| Action | Default keymap | Command | API Function | -| --------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | -| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` | -| Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` | -| Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` | -| Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` | -| Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) | -| Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` | -| Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` | -| Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` | -| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` | -| Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | -| Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` | -| Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` | -| Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` | -| Close diff view tab | `oc` | `:Opencode diff close` | `require('opencode.api').diff_close()` | -| Revert all file changes since last prompt | `ora` | `:Opencode revert all prompt` | `require('opencode.api').diff_revert_all_last_prompt()` | -| Revert current file changes last prompt | `ort` | `:Opencode revert this prompt` | `require('opencode.api').diff_revert_this_last_prompt()` | -| Revert all file changes since last session | `orA` | `:Opencode revert all session` | `require('opencode.api').diff_revert_all_session()` | -| Revert current file changes last session | `orT` | `:Opencode revert this session` | `require('opencode.api').diff_revert_this_session()` | -| Revert all files to a specific snapshot | - | `:Opencode revert all_to_snapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` | -| Revert current file to a specific snapshot | - | `:Opencode revert this_to_snapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` | -| Restore a file to a restore point | - | `:Opencode restore snapshot_file` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` | -| Restore all files to a restore point | - | `:Opencode restore snapshot_all` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` | -| Initialize/update AGENTS.md file | - | `:Opencode session agents_init` | `require('opencode.api').initialize()` | -| Run prompt (continue session) [Run opts](#run-opts) | - | `:Opencode run ` | `require('opencode.api').run("prompt", opts)` | -| Run prompt (new session) [Run opts](#run-opts) | - | `:Opencode run new_session ` | `require('opencode.api').run_new_session("prompt", opts)` | -| Cancel opencode while it is running | `` | `:Opencode cancel` | `require('opencode.api').cancel()` | -| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` | -| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` | -| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` | -| Display list of availale mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` | -| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` | -| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` | -| Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` | -| Compact current session (summarize) | - | `:Opencode session compact` / `/compact` | `require('opencode.api').compact_session()` | -| Undo last opencode action | - | `:Opencode undo` / `/undo` | `require('opencode.api').undo()` | -| Redo last opencode action | - | `:Opencode redo` / `/redo` | `require('opencode.api').redo()` | -| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:Opencode permission accept` | `require('opencode.api').permission_accept()` | -| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:Opencode permission accept_all` | `require('opencode.api').permission_accept_all()` | -| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:Opencode permission deny` | `require('opencode.api').permission_deny()` | -| Insert mention (file/ agent) | `@` | - | - | -| [Pick a file and add to context](#file-mentions) | `~` | - | - | -| Navigate to next message | `]]` | - | - | -| Navigate to previous message | `[[` | - | - | -| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` | -| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` | -| Toggle input/output panes | `` | - | - | -| Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` | +| Action | Default keymap | Command | API Function | +| --------------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | +| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` | +| Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` | +| Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` | +| Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` | +| Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) | +| Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` | +| Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` | +| Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` | +| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` | +| Open timeline picker (navigate/undo/redo/fork to message) | - | `:Opencode timeline` | `require('opencode.api').timeline()` | +| Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | +| Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` | +| Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` | +| Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` | +| Close diff view tab | `oc` | `:Opencode diff close` | `require('opencode.api').diff_close()` | +| Revert all file changes since last prompt | `ora` | `:Opencode revert all prompt` | `require('opencode.api').diff_revert_all_last_prompt()` | +| Revert current file changes last prompt | `ort` | `:Opencode revert this prompt` | `require('opencode.api').diff_revert_this_last_prompt()` | +| Revert all file changes since last session | `orA` | `:Opencode revert all session` | `require('opencode.api').diff_revert_all_session()` | +| Revert current file changes last session | `orT` | `:Opencode revert this session` | `require('opencode.api').diff_revert_this_session()` | +| Revert all files to a specific snapshot | - | `:Opencode revert all_to_snapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` | +| Revert current file to a specific snapshot | - | `:Opencode revert this_to_snapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` | +| Restore a file to a restore point | - | `:Opencode restore snapshot_file` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` | +| Restore all files to a restore point | - | `:Opencode restore snapshot_all` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` | +| Initialize/update AGENTS.md file | - | `:Opencode session agents_init` | `require('opencode.api').initialize()` | +| Run prompt (continue session) [Run opts](#run-opts) | - | `:Opencode run ` | `require('opencode.api').run("prompt", opts)` | +| Run prompt (new session) [Run opts](#run-opts) | - | `:Opencode run new_session ` | `require('opencode.api').run_new_session("prompt", opts)` | +| Cancel opencode while it is running | `` | `:Opencode cancel` | `require('opencode.api').cancel()` | +| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` | +| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` | +| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` | +| Display list of availale mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` | +| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` | +| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` | +| Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` | +| Compact current session (summarize) | - | `:Opencode session compact` / `/compact` | `require('opencode.api').compact_session()` | +| Undo last opencode action | - | `:Opencode undo` / `/undo` | `require('opencode.api').undo()` | +| Redo last opencode action | - | `:Opencode redo` / `/redo` | `require('opencode.api').redo()` | +| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:Opencode permission accept` | `require('opencode.api').permission_accept()` | +| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:Opencode permission accept_all` | `require('opencode.api').permission_accept_all()` | +| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:Opencode permission deny` | `require('opencode.api').permission_deny()` | +| Insert mention (file/ agent) | `@` | - | - | +| [Pick a file and add to context](#file-mentions) | `~` | - | - | +| Navigate to next message | `]]` | - | - | +| Navigate to previous message | `[[` | - | - | +| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` | +| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` | +| Toggle input/output panes | `` | - | - | +| Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` | --- diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 723c7a41..8268edb3 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -680,6 +680,63 @@ function M.undo(messageId) end) end +function M.timeline() + local user_messages = {} + for _, msg in ipairs(state.messages or {}) do + local parts = msg.parts or {} + local is_summary = #parts == 1 and parts[1].synthetic == true + if msg.info.role == 'user' and not is_summary then + table.insert(user_messages, msg) + end + end + if #user_messages == 0 then + vim.notify('No user messages in the current session', vim.log.levels.WARN) + return + end + + local timeline_picker = require('opencode.ui.timeline_picker') + timeline_picker.pick(user_messages, function(selected_msg) + if selected_msg then + require('opencode.ui.navigation').goto_message_by_id(selected_msg.info.id) + end + end) +end + +--- Forks the current session from a specific user message. +---@param message_id? string The ID of the user message to fork from. If not provided, uses the last user message. +function M.fork_session(message_id) + if not state.active_session then + vim.notify('No active session to fork', vim.log.levels.WARN) + return + end + + local message_to_fork = message_id or state.last_user_message and state.last_user_message.info.id + if not message_to_fork then + vim.notify('No user message to fork from', vim.log.levels.WARN) + return + end + + state.api_client + :fork_session(state.active_session.id, { + messageID = message_to_fork, + }) + :and_then(function(response) + vim.schedule(function() + if response and response.id then + vim.notify('Session forked successfully. New session ID: ' .. response.id, vim.log.levels.INFO) + core.switch_session(response.id) + else + vim.notify('Session forked but no new session ID received', vim.log.levels.WARN) + end + end) + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to fork session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + -- Returns the ID of the next user message after the current undo point -- This is a port of the opencode tui logic -- https://github.com/sst/opencode/blob/dev/packages/tui/internal/components/chat/messages.go#L1199 @@ -1074,6 +1131,11 @@ M.commands = { end end, }, + + timeline = { + desc = 'Open timeline picker to navigate/undo/redo/fork to message', + fn = M.timeline, + }, } M.slash_commands_map = { @@ -1089,6 +1151,7 @@ M.slash_commands_map = { ['/redo'] = { fn = M.redo, desc = 'Redo last action' }, ['/sessions'] = { fn = M.select_session, desc = 'Select session' }, ['/share'] = { fn = M.share, desc = 'Share current session' }, + ['/timeline'] = { fn = M.timeline, desc = 'Open timeline picker' }, ['/undo'] = { fn = M.undo, desc = 'Undo last action' }, ['/unshare'] = { fn = M.unshare, desc = 'Unshare current session' }, } diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 7901aeed..e5466886 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -213,6 +213,15 @@ function OpencodeApiClient:summarize_session(id, summary_data, directory) return self:_call('/session/' .. id .. '/summarize', 'POST', summary_data, { directory = directory }) end +--- Fork an existing session at a specific message +--- @param id string Session ID (required) +--- @param fork_data {messageID?: string}|nil Fork data +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:fork_session(id, fork_data, directory) + return self:_call('/session/' .. id .. '/fork', 'POST', fork_data, { directory = directory }) +end + -- Message endpoints --- List messages for a session diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index a13d7c4b..97c57ff1 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -19,6 +19,7 @@ M.defaults = { ['oI'] = { 'open_input_new_session', desc = 'Open input (new session)' }, ['oo'] = { 'open_output', desc = 'Open output window' }, ['ot'] = { 'toggle_focus', desc = 'Toggle focus' }, + ['oT'] = { 'timeline', desc = 'Session timeline' }, ['oq'] = { 'close', desc = 'Close Opencode window' }, ['os'] = { 'select_session', desc = 'Select session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, @@ -75,12 +76,17 @@ M.defaults = { delete_session = { '' }, new_session = { '' }, }, + timeline_picker = { + undo = { '', mode = { 'i', 'n' } }, + fork = { '', mode = { 'i', 'n' } }, + }, }, ui = { position = 'right', input_position = 'bottom', window_width = 0.40, input_height = 0.15, + picker_width = 100, display_model = true, display_context_size = true, display_cost = true, @@ -172,6 +178,7 @@ M.defaults = { debug = { enabled = false, capture_streamed_events = false, + show_ids = false, }, prompt_guard = nil, } diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index d7b70ed1..72aa8dca 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -25,21 +25,24 @@ function M.select_session(parent_id) end return end - -- clear the model so it can be set by the session. If it doesn't get set - -- then core.get_model() will reset it to the default - state.current_model = nil - state.active_session = selected_session - if state.windows then - state.restore_points = {} - -- Don't need to update either renderer because they subscribe to - -- session changes - ui.focus_input() - else - M.open() - end + M.switch_session(selected_session.id) end) end +function M.switch_session(session_id) + local selected_session = session.get_by_id(session_id) + -- clear the model so it can be set by the session. If it doesn't get set + -- then core.get_model() will reset it to the default + state.current_model = nil + state.active_session = selected_session + if state.windows then + state.restore_points = {} + ui.focus_input() + else + M.open() + end +end + ---@param opts? OpenOpts function M.open(opts) opts = opts or { focus = 'input', new_session = false } diff --git a/lua/opencode/git_review.lua b/lua/opencode/git_review.lua index b2d4e458..4886f197 100644 --- a/lua/opencode/git_review.lua +++ b/lua/opencode/git_review.lua @@ -365,7 +365,7 @@ function M.with_restore_point(restore_point_id, fn) item.files and #item.files or 0, item.deleted_files and #item.deleted_files or 0, item.id:sub(1, 8), - utils.time_ago(item.created_at) or 'unknown', + utils.format_time(item.created_at) or 'unknown', item.from_snapshot_id and item.from_snapshot_id:sub(1, 8) or 'none' ) end, diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 13c51b02..f8061334 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -8,6 +8,7 @@ function M.setup(opts) local config = require('opencode.config') config.setup(opts) + require('opencode.ui.highlight').setup() require('opencode.core').setup() require('opencode.api').setup() require('opencode.keymap').setup(config.keymap) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7e8f864d..83aeb37a 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -72,11 +72,16 @@ ---@field output_window OpencodeKeymapOutputWindow ---@field permission OpencodeKeymapPermission ---@field session_picker OpencodeSessionPickerKeymap +---@field timeline_picker OpencodeTimelinePickerKeymap ---@class OpencodeSessionPickerKeymap ---@field delete_session OpencodeKeymapEntry ---@field new_session OpencodeKeymapEntry +---@class OpencodeTimelinePickerKeymap +---@field undo OpencodeKeymapEntry +---@field fork OpencodeKeymapEntry + ---@class OpencodeCompletionFileSourcesConfig ---@field enabled boolean ---@field preferred_cli_tool 'server'|'fd'|'fdfind'|'rg'|'git' @@ -95,6 +100,7 @@ ---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom') ---@field window_width number ---@field input_height number +---@field picker_width number|nil # Default width for all pickers (nil uses current window width) ---@field display_model boolean ---@field display_context_size boolean ---@field display_cost boolean @@ -126,6 +132,7 @@ ---@class OpencodeDebugConfig ---@field enabled boolean ---@field capture_streamed_events boolean +---@field show_ids boolean --- @class OpencodeProviders --- @field [string] string[] diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua new file mode 100644 index 00000000..d23ada01 --- /dev/null +++ b/lua/opencode/ui/base_picker.lua @@ -0,0 +1,427 @@ +local config = require('opencode.config') +local util = require('opencode.util') + +---@class PickerAction +---@field key? OpencodeKeymapEntry|string The key binding for this action +---@field label string The display label for this action +---@field fn fun(selected: any, opts: PickerOptions): any[]? The action function +---@field reload? boolean Whether to reload the picker after action + +---@class PickerOptions +---@field items any[] The list of items to pick from +---@field format_fn fun(item: any, width?: number): PickerItem Function to format items for display +---@field actions table Available actions for the picker +---@field callback fun(selected: any?) Callback when item is selected +---@field title string|fun(): string The picker title +---@field width? number Optional width for the picker (defaults to config or current window width) + +---@class TelescopeEntry +---@field value any +---@field display fun(entry: TelescopeEntry): string[] +---@field ordinal string + +---@class FzfLuaOptions +---@field fn_fzf_index fun(line: string): integer? + +---@class FzfAction +---@field fn fun(selected: string[], fzf_opts: FzfLuaOptions): nil +---@field header string +---@field reload boolean + +---@class FzfLuaActions +---@field [string] FzfAction|fun(selected: string[], fzf_opts: FzfLuaOptions): nil + +---@class MiniPickItem +---@field text string +---@field item any + +---@class MiniPickSelected +---@field current MiniPickItem? + +---@class PickerItem +---@field content string Main content text +---@field time_text? string Optional time text +---@field debug_text? string Optional debug text +---@field to_string fun(self: PickerItem): string +---@field to_formatted_text fun(self: PickerItem): table + +---@class BasePicker +local M = {} +local picker = require('opencode.ui.picker') + +---Build title with action legend +---@param base_title string The base title +---@param actions table The available actions +---@return string title The formatted title with action legend +local function build_title(base_title, actions) + local legend = {} + for _, action in pairs(actions) do + if action.key and action.key[1] then + table.insert(legend, action.key[1] .. ' ' .. action.label) + end + end + return base_title .. (#legend > 0 and ' | ' .. table.concat(legend, ' | ') or '') +end + +---Telescope UI implementation +---@param opts PickerOptions The picker options +local function telescope_ui(opts) + local pickers = require('telescope.pickers') + local finders = require('telescope.finders') + local conf = require('telescope.config').values + local actions = require('telescope.actions') + local action_state = require('telescope.actions.state') + local entry_display = require('telescope.pickers.entry_display') + local displayer = entry_display.create({ + separator = ' ', + items = { {}, {}, config.debug.show_ids and {} or nil }, + }) + + local current_picker + + ---Creates entry maker function for telescope + ---@param item any + ---@return TelescopeEntry + local function make_entry(item) + return { + value = item, + display = function(entry) + return displayer(opts.format_fn(entry.value):to_formatted_text()) + end, + ordinal = opts.format_fn(item):to_string(), + } + end + + local function refresh_picker() + return current_picker + and current_picker:refresh( + finders.new_table({ results = opts.items, entry_maker = make_entry }), + { reset_prompt = false } + ) + end + + current_picker = pickers.new({}, { + prompt_title = opts.title, + finder = finders.new_table({ results = opts.items, entry_maker = make_entry }), + sorter = conf.generic_sorter({}), + layout_config = opts.width and { + width = opts.width + 4, -- extra space for telescope UI + } or nil, + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + local selection = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if selection and opts.callback then + opts.callback(selection.value) + end + end) + + for _, action in pairs(opts.actions) do + if action.key and action.key[1] then + local modes = action.key.mode or { 'i', 'n' } + if type(modes) == 'string' then + modes = { modes } + end + + local action_fn = function() + local selection = action_state.get_selected_entry() + if selection then + local new_items = action.fn(selection.value, opts) + if action.reload and new_items then + opts.items = new_items + refresh_picker() + end + end + end + + for _, mode in ipairs(modes) do + map(mode, action.key[1], action_fn) + end + end + end + + return true + end, + }) + + current_picker:find() +end + +---FZF-Lua UI implementation +---@param opts PickerOptions The picker options +local function fzf_ui(opts) + local fzf_lua = require('fzf-lua') + + ---@type FzfLuaActions + local actions_config = { + ['default'] = function(selected, fzf_opts) + if not selected or #selected == 0 then + return + end + local idx = fzf_opts.fn_fzf_index(selected[1] --[[@as string]]) + if idx and opts.items[idx] and opts.callback then + opts.callback(opts.items[idx]) + end + end, + } + + for _, action in pairs(opts.actions) do + if action.key and action.key[1] then + local key = require('fzf-lua.utils').neovim_bind_to_fzf(action.key[1]) + actions_config[key] = { + fn = function(selected, fzf_opts) + if not selected or #selected == 0 then + return + end + local idx = fzf_opts.fn_fzf_index(selected[1] --[[@as string]]) + if idx and opts.items[idx] then + local new_items = action.fn(opts.items[idx], opts) + if action.reload and new_items then + opts.items = new_items + end + end + end, + header = action.label, + reload = action.reload or false, + } + end + end + + fzf_lua.fzf_exec(function(fzf_cb) + for _, item in ipairs(opts.items) do + fzf_cb(opts.format_fn(item):to_string()) + end + fzf_cb() + end, { + winopts = opts.width and { + width = opts.width + 8, -- extra space for fzf UI + } or nil, + fzf_opts = { ['--prompt'] = opts.title .. ' > ' }, + _headers = { 'actions' }, + actions = actions_config, + fn_fzf_index = function(line) + for i, item in ipairs(opts.items) do + if opts.format_fn(item):to_string() == line then + return i + end + end + return nil + end, + }) +end + +---Mini.pick UI implementation +---@param opts PickerOptions The picker options +local function mini_pick_ui(opts) + local mini_pick = require('mini.pick') + + ---@type MiniPickItem[] + local items = vim.tbl_map(function(item) + return { text = opts.format_fn(item):to_string(), item = item } + end, opts.items) + + local mappings = {} + + for action_name, action in pairs(opts.actions) do + if action.key and action.key[1] then + mappings[action_name] = { + char = action.key[1], + func = function() + local selected = mini_pick.get_picker_matches().current + if selected and selected.item then + local new_items = action.fn(selected.item, opts) + if action.reload and new_items then + opts.items = new_items + ---@type MiniPickItem[] + items = vim.tbl_map(function(it) + return { text = opts.format_fn(it):to_string(), item = it } + end, opts.items) + mini_pick.set_picker_items(items) + end + end + end, + } + end + end + + mini_pick.start({ + window = opts.width + and { + config = { + width = opts.width + 2, -- extra space for mini.pick UI + }, + } + or nil, + source = { + items = items, + name = opts.title, + choose = function(selected) + if selected and selected.item and opts.callback then + opts.callback(selected.item) + end + return false + end, + }, + mappings = mappings, + }) +end + +---Snacks picker UI implementation +---@param opts PickerOptions The picker options +local function snacks_picker_ui(opts) + local Snacks = require('snacks') + + local snack_opts = { + title = opts.title, + layout = { + preset = 'select', + config = function(layout) + local width = opts.width and (opts.width + 3) or nil -- extra space for snacks UI + layout.layout.width = width + layout.layout.max_width = width + layout.layout.min_width = width + return layout + end, + }, + finder = function() + return opts.items + end, + transform = function(item, ctx) + -- Snacks requires item.text to be set to do matching + if not item.text then + local picker_item = opts.format_fn(item) + item.text = picker_item:to_string() + end + end, + format = function(item) + return opts.format_fn(item):to_formatted_text() + end, + actions = { + confirm = function(_picker, item) + _picker:close() + if item and opts.callback then + vim.schedule(function() + opts.callback(item) + end) + end + end, + }, + } + + for action_name, action in pairs(opts.actions) do + if action.key and action.key[1] then + snack_opts.win = snack_opts.win or {} + snack_opts.win.input = snack_opts.win.input or { keys = {} } + snack_opts.win.input.keys[action.key[1]] = { action_name, mode = action.key.mode or 'i' } + + snack_opts.actions[action_name] = function(_picker, item) + if item then + vim.schedule(function() + local new_items = action.fn(item, opts) + if action.reload and new_items then + opts.items = new_items + _picker:find() + end + end) + end + end + end + end + + ---@generic T + Snacks.picker.pick(snack_opts) +end + +---@param text? string +---@param width integer +---@param opts? {align?: "left" | "right" | "center", truncate?: boolean} +function M.align(text, width, opts) + text = text or '' + opts = opts or {} + opts.align = opts.align or 'left' + local tw = vim.api.nvim_strwidth(text) + if tw > width then + return opts.truncate and (vim.fn.strcharpart(text, 0, width - 1) .. '…') or text + end + local left = math.floor((width - tw) / 2) + local right = width - tw - left + if opts.align == 'left' then + left, right = 0, width - tw + elseif opts.align == 'right' then + left, right = width - tw, 0 + end + return (' '):rep(left) .. text .. (' '):rep(right) +end + +---Creates a generic picker item that can format itself for different pickers +---@param text string Array of text parts to join +---@param time? number Optional time text to highlight +---@param debug_text? string Optional debug text to append +---@param width? number Optional width override +---@return PickerItem +function M.create_picker_item(text, time, debug_text, width) + local time_width = time and #util.format_time(time) + 1 or 0 + local debug_width = config.debug.show_ids and debug_text and #debug_text + 1 or 0 + local item_width = width or vim.api.nvim_win_get_width(0) + local text_width = item_width - (debug_width + time_width) + local item = { + content = M.align(text, text_width --[[@as integer]], { truncate = true }), + time_text = time and M.align(util.format_time(time), time_width, { align = 'right' }), + debug_text = config.debug.show_ids and debug_text or nil, + } + + function item:to_string() + return table.concat({ self.content, self.time_text or '', self.debug_text or '' }, ' ') + end + + function item:to_formatted_text() + return { + { self.content }, + self.time_text and { ' ' .. self.time_text, 'OpencodePickerTime' } or nil, + self.debug_text and { ' ' .. self.debug_text, 'OpencodeDebugText' } or nil, + } + end + + return item +end + +---Generic picker that abstracts common logic for different picker UIs +---@param opts PickerOptions The picker options +---@return boolean success Whether the picker was successfully launched +function M.pick(opts) + local picker_type = picker.get_best_picker() + + if not picker_type then + return false + end + + if not opts.width then + opts.width = config.ui.picker_width + end + + local original_format_fn = opts.format_fn + opts.format_fn = function(item) + return original_format_fn(item, opts.width) + end + + local title_str = type(opts.title) == 'function' and opts.title() or opts.title --[[@as string]] + opts.title = build_title(title_str, opts.actions) + + vim.schedule(function() + if picker_type == 'telescope' then + telescope_ui(opts) + elseif picker_type == 'fzf' then + fzf_ui(opts) + elseif picker_type == 'mini.pick' then + mini_pick_ui(opts) + elseif picker_type == 'snacks' then + snacks_picker_ui(opts) + else + opts.callback(nil) + end + end) + + return true +end + +return M diff --git a/lua/opencode/ui/completion/commands.lua b/lua/opencode/ui/completion/commands.lua index 33fabbc5..bf951b72 100644 --- a/lua/opencode/ui/completion/commands.lua +++ b/lua/opencode/ui/completion/commands.lua @@ -76,7 +76,9 @@ local command_source = { vim.api.nvim_win_set_cursor(0, { 1, #item.insert_text + 1 }) return end - item.data.fn() + vim.defer_fn(function() + item.data.fn() + end, 10) -- slight delay to allow completion menu to close, this prevent a weird bug with mini.pick where it displays an empty window with `BlinkDonotRepeatHack` text inserted require('opencode.ui.input_window').set_content('') else vim.notify('Command not found: ' .. item.label, vim.log.levels.ERROR) diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index 5ebd82de..77335fa8 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -86,7 +86,7 @@ function M.setup(windows) end windows.footer_win = vim.api.nvim_open_win(windows.footer_buf, false, M._build_footer_win_config(windows.output_win)) - vim.api.nvim_set_option_value('winhl', 'Normal:OpenCodeHint', { win = windows.footer_win }) + vim.api.nvim_set_option_value('winhl', 'Normal:OpencodeHint', { win = windows.footer_win }) -- for stats changes state.subscribe('current_model', on_change) diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 84903073..bc788cfe 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -32,6 +32,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextWarning', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextInfo', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) + vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) else vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true }) vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true }) @@ -60,6 +62,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextError', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextWarning', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) + vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) end end diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 4f06d009..502f655e 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -149,15 +149,15 @@ function M.refresh_placeholder(windows, input_lines) vim.api.nvim_buf_set_extmark(windows.input_buf, ns_id, 0, 0, { virt_text = { - { 'Type your prompt here... ', 'OpenCodeHint' }, + { 'Type your prompt here... ', 'OpencodeHint' }, { slash_key or '/', 'OpencodeInputLegend' }, - { ' commands ', 'OpenCodeHint' }, + { ' commands ', 'OpencodeHint' }, { mention_key or '@', 'OpencodeInputLegend' }, - { ' mentions ', 'OpenCodeHint' }, + { ' mentions ', 'OpencodeHint' }, { mention_file_key or '~', 'OpencodeInputLegend' }, - { ' files ', 'OpenCodeHint' }, + { ' files ', 'OpencodeHint' }, { context_key or '#', 'OpencodeInputLegend' }, - { ' context' .. padding, 'OpenCodeHint' }, + { ' context' .. padding, 'OpencodeHint' }, }, virt_text_pos = 'overlay', diff --git a/lua/opencode/ui/loading_animation.lua b/lua/opencode/ui/loading_animation.lua index f37daaf9..d1cf92a9 100644 --- a/lua/opencode/ui/loading_animation.lua +++ b/lua/opencode/ui/loading_animation.lua @@ -45,7 +45,7 @@ M.render = vim.schedule_wrap(function(windows) 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, - virt_text = { { loading_text, 'OpenCodeHint' } }, + virt_text = { { loading_text, 'OpencodeHint' } }, virt_text_pos = 'overlay', hl_mode = 'replace', }) diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index 349f3d29..9b0acd27 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -20,6 +20,24 @@ local function is_message_header(details) return first_virt_text[1] == header_user_icon or first_virt_text[1] == header_assistant_icon end +function M.goto_message_by_id(message_id) + 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 + return + end + + local rendered_msg = require('opencode.ui.renderer').get_rendered_message(message_id) + if not rendered_msg or not rendered_msg.line_start then + return + end + local sep_offset = #require('opencode.ui.formatter').separator + vim.api.nvim_win_set_cursor(win, { rendered_msg.line_start + sep_offset, 0 }) +end + function M.goto_next_message() require('opencode.ui.ui').focus_output() local windows = state.windows or {} diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 2b1762cf..d66a3ad8 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -966,4 +966,15 @@ function M._update_stats_from_message(message) end end +---Get rendered message by ID +---@param message_id string Message ID +---@return RenderedMessage|nil Rendered message or nil if not found +function M.get_rendered_message(message_id) + local rendered_msg = M._render_state:get_message(message_id) + if rendered_msg then + return rendered_msg + end + return nil +end + return M diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 75a2a53e..580003a3 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -1,441 +1,76 @@ local M = {} -local picker = require('opencode.ui.picker') - -local picker_title = function() - local config = require('opencode.config') --[[@as OpencodeConfig]] - local keymap_config = config.keymap.session_picker - - local legend = {} - local actions = { - { key = keymap_config.delete_session, label = 'delete' }, - { key = keymap_config.new_session, label = 'new' }, - } - - for _, action in ipairs(actions) do - if action.key and action.key[1] then - table.insert(legend, action.key[1] .. ' ' .. action.label) - end - end - - return 'Select A Session' .. (#legend > 0 and ' | ' .. table.concat(legend, ' | ') or '') -end - -local function format_session(session) - local util = require('opencode.util') - local parts = {} - - if session.description then - table.insert(parts, session.description) - end - - if session.message_count then - table.insert(parts, session.message_count .. ' messages') - end - - local modified = util.time_ago(session.modified) - if modified then - table.insert(parts, modified) - end - - table.insert(parts, 'ID: ' .. (session.id or 'N/A')) - return table.concat(parts, ' ~ ') +local config = require('opencode.config') +local base_picker = require('opencode.ui.base_picker') + +---Format session parts for session picker +---@param session Session object +---@return PickerItem +function format_session_item(session, width) + local debug_text = 'ID: ' .. (session.id or 'N/A') + return base_picker.create_picker_item(session.description, session.modified, debug_text, width) end -local function telescope_ui(sessions, callback, on_delete, on_new) - local pickers = require('telescope.pickers') - local finders = require('telescope.finders') - local conf = require('telescope.config').values - local actions = require('telescope.actions') - local action_state = require('telescope.actions.state') - - local current_picker - - local function refresh_picker() - local new_sessions = vim.tbl_filter(function(s) - return sessions[vim.fn.index(sessions, s) + 1] ~= nil - end, sessions) - sessions = new_sessions - - current_picker:refresh( - finders.new_table({ - results = sessions, - entry_maker = function(session) - return { - value = session, - display = format_session(session), - ordinal = format_session(session), - } - end, - }), - { reset_prompt = false } - ) - end - - current_picker = pickers.new({}, { - prompt_title = picker_title(), - finder = finders.new_table({ - results = sessions, - entry_maker = function(session) - return { - value = session, - display = format_session(session), - ordinal = format_session(session), - } - end, - }), - sorter = conf.generic_sorter({}), - attach_mappings = function(prompt_bufnr, map) - actions.select_default:replace(function() - local selection = action_state.get_selected_entry() - actions.close(prompt_bufnr) - if selection and callback then - callback(selection.value) - end - end) - - local config = require('opencode.config') - local delete_config = config.keymap.session_picker.delete_session - if delete_config and delete_config[1] then - local key = delete_config[1] - local modes = delete_config.mode or { 'i', 'n' } - if type(modes) == 'string' then - modes = { modes } - end - - local delete_fn = function() - local selection = action_state.get_selected_entry() - if selection and on_delete then - local idx = vim.fn.index(sessions, selection.value) + 1 - if idx > 0 then - table.remove(sessions, idx) - on_delete(selection.value) - refresh_picker() - end - end - end +function M.pick(sessions, callback) + local actions = { + delete = { + key = config.keymap.session_picker.delete_session, + label = 'delete', + fn = function(selected, opts) + local state = require('opencode.state') - for _, mode in ipairs(modes) do - map(mode, key, delete_fn) - end - end + local session_id_to_delete = selected.id - -- Add new session mapping using shared callback - local new_config = require('opencode.config').keymap.session_picker.new_session - if new_config and new_config[1] then - local key = new_config[1] - local modes = new_config.mode or { 'i', 'n' } - if type(modes) == 'string' then - modes = { modes } - end - local new_fn = function() - if on_new then - local new_session = on_new() - if new_session then - actions.close(prompt_bufnr) - if callback then - callback(new_session) - end - end - end - end - for _, mode in ipairs(modes) do - map(mode, key, new_fn) + if state.active_session and state.active_session.id == selected.id then + vim.notify('deleting current session, creating new session') + state.active_session = require('opencode.core').create_new_session() end - end - return true - end, - }) - - current_picker:find() -end - -local function fzf_ui(sessions, callback, on_delete, on_new) - local fzf_lua = require('fzf-lua') - local config = require('opencode.config') - - local actions_config = { - ['default'] = function(selected, opts) - if not selected or #selected == 0 then - return - end - local idx = opts.fn_fzf_index(selected[1]) - if idx and sessions[idx] and callback then - callback(sessions[idx]) - end - end, - } - - local delete_config = config.keymap.session_picker.delete_session - if delete_config and delete_config[1] then - local key = delete_config[1] - key = require('fzf-lua.utils').neovim_bind_to_fzf(key) - actions_config[key] = { - fn = function(selected, opts) - if not selected or #selected == 0 then - return - end - local idx = opts.fn_fzf_index(selected[1]) - if idx and sessions[idx] and on_delete then - local session = sessions[idx] - table.remove(sessions, idx) - on_delete(session) - end - end, - header = 'delete', - reload = true, - } - end + state.api_client:delete_session(session_id_to_delete):catch(function(err) + vim.schedule(function() + vim.notify('Failed to delete session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) - -- New session action (shared on_new) - local new_config = config.keymap.session_picker.new_session - if new_config and new_config[1] then - local key = require('fzf-lua.utils').neovim_bind_to_fzf(new_config[1]) - actions_config[key] = { - fn = function() - if on_new then - local new_session = on_new() - if new_session then - table.insert(sessions, 1, new_session) - end + local idx = vim.fn.index(opts.items, selected) + 1 + if idx > 0 then + table.remove(opts.items, idx) end + return opts.items end, - header = 'new', reload = true, - } - end - - fzf_lua.fzf_exec(function(fzf_cb) - for _, session in ipairs(sessions) do - fzf_cb(format_session(session)) - end - fzf_cb() - end, { - fzf_opts = { - ['--prompt'] = picker_title() .. ' > ', }, - _headers = { 'actions' }, - actions = actions_config, - fn_fzf_index = function(line) - for i, session in ipairs(sessions) do - if format_session(session) == line then - return i - end - end - return nil - end, - }) -end - -local function mini_pick_ui(sessions, callback, on_delete, on_new) - local mini_pick = require('mini.pick') - local config = require('opencode.config') - - local items = vim.tbl_map(function(session) - return { - text = format_session(session), - session = session, - } - end, sessions) - - local delete_config = config.keymap.session_picker.delete_session - local mappings = {} - - if delete_config and delete_config[1] then - mappings.delete_session = { - char = delete_config[1], - func = function() - local selected = mini_pick.get_picker_matches().current - if selected and selected.session and on_delete then - local idx = vim.fn.index(sessions, selected.session) + 1 - if idx > 0 then - table.remove(sessions, idx) - on_delete(selected.session) - items = vim.tbl_map(function(session) - return { - text = format_session(session), - session = session, - } - end, sessions) - mini_pick.set_picker_items(items) - end - end - end, - } - end - - -- New session mapping using shared on_new - local new_config = config.keymap.session_picker.new_session - if new_config and new_config[1] then - mappings.new_session = { - char = new_config[1], - func = function() - if on_new then - local new_session = on_new() - if new_session then - table.insert(sessions, 1, new_session) - items = vim.tbl_map(function(session) - return { - text = format_session(session), - session = session, - } - end, sessions) - mini_pick.set_picker_items(items) + new = { + key = config.keymap.session_picker.new_session, + label = 'new', + fn = function(selected, opts) + local parent_id + for _, s in ipairs(opts.items or {}) do + if s.parentID ~= nil then + parent_id = s.parentID + break end end - end, - } - end - - mini_pick.start({ - source = { - items = items, - name = picker_title(), - choose = function(selected) - if selected and selected.session and callback then - callback(selected.session) - end - return false - end, - }, - mappings = mappings, - }) -end - -local function snacks_picker_ui(sessions, callback, on_delete, on_new) - local Snacks = require('snacks') - local config = require('opencode.config') - - local delete_config = config.keymap.session_picker.delete_session - - local opts = { - title = picker_title(), - layout = { preset = 'select' }, - finder = function() - return sessions - end, - format = 'text', - transform = function(item) - item.text = format_session(item) - end, - actions = { - confirm = function(picker, item) - picker:close() - if item and callback then - vim.schedule(function() - callback(item) - end) + local state = require('opencode.state') + local created = state.api_client:create_session(parent_id and { parentID = parent_id } or false):wait() + if created and created.id then + local new_session = require('opencode.session').get_by_id(created.id) + table.insert(opts.items, 1, new_session) + return opts.items end + return nil end, + reload = true, }, } - if delete_config and delete_config[1] then - local key = delete_config[1] - local mode = delete_config.mode or 'i' - - opts.win = opts.win or {} - opts.win.input = opts.win.input or { keys = {} } - opts.win.input.keys[key] = { 'session_delete', mode = mode } - - opts.actions.session_delete = function(picker, item) - if item and on_delete then - vim.schedule(function() - local idx = vim.fn.index(sessions, item) + 1 - if idx > 0 then - table.remove(sessions, idx) - on_delete(item) - picker:find() - end - end) - end - end - end - - -- New session key using shared on_new - local new_config = config.keymap.session_picker.new_session - if new_config and new_config[1] then - local key = new_config[1] - local mode = new_config.mode or 'i' - - opts.win = opts.win or {} - opts.win.input = opts.win.input or { keys = {} } - opts.win.input.keys[key] = { 'session_new', mode = mode } - - opts.actions.session_new = function(picker) - vim.schedule(function() - if on_new then - local new_session = on_new() - if new_session then - table.insert(sessions, 1, new_session) - picker:close() - if callback then - callback(new_session) - end - end - end - end) - end - end - - Snacks.picker.pick(opts) -end - -function M.pick(sessions, callback) - local picker_type = picker.get_best_picker() - - if not picker_type then - return false - end - - local function on_delete(session) - local state = require('opencode.state') - - local session_id_to_delete = session.id - - if state.active_session and state.active_session.id == session.id then - vim.notify('deleting current session, creating new session') - state.active_session = require('opencode.core').create_new_session() - end - - state.api_client:delete_session(session_id_to_delete):catch(function(err) - vim.schedule(function() - vim.notify('Failed to delete session: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end) - end - - local function on_new() - local parent_id - for _, s in ipairs(sessions or {}) do - if s.parentID ~= nil then - parent_id = s.parentID - break - end - end - local state = require('opencode.state') - local created = state.api_client:create_session(parent_id and { parentID = parent_id } or false):wait() - if created and created.id then - return require('opencode.session').get_by_id(created.id) - end - return nil - end - - vim.schedule(function() - if picker_type == 'telescope' then - telescope_ui(sessions, callback, on_delete, on_new) - elseif picker_type == 'fzf' then - fzf_ui(sessions, callback, on_delete, on_new) - elseif picker_type == 'mini.pick' then - mini_pick_ui(sessions, callback, on_delete, on_new) - elseif picker_type == 'snacks' then - snacks_picker_ui(sessions, callback, on_delete, on_new) - else - callback(nil) - end - end) - - return true + return base_picker.pick({ + items = sessions, + format_fn = format_session_item, + actions = actions, + callback = callback, + title = 'Select A Session', + width = config.ui.picker_width or 100, + }) end return M diff --git a/lua/opencode/ui/timeline_picker.lua b/lua/opencode/ui/timeline_picker.lua new file mode 100644 index 00000000..31b72fdd --- /dev/null +++ b/lua/opencode/ui/timeline_picker.lua @@ -0,0 +1,48 @@ +local M = {} +local config = require('opencode.config') +local api = require('opencode.api') +local base_picker = require('opencode.ui.base_picker') + +---Format message parts for timeline picker +---@param msg OpencodeMessage Message object +---@return PickerItem +function format_message_item(msg, width) + local preview = msg.parts and msg.parts[1] and msg.parts[1].text or '' + + local debug_text = 'ID: ' .. (msg.info.id or 'N/A') + + return base_picker.create_picker_item(vim.trim(preview), msg.info.time.created, debug_text, width) +end + +function M.pick(messages, callback) + local keymap = config.keymap.timeline_picker + local actions = { + undo = { + key = keymap.undo, + label = 'undo', + fn = function(selected, opts) + api.undo(selected.info.id) + end, + reload = false, + }, + fork = { + key = keymap.fork, + label = 'fork', + fn = function(selected, opts) + api.fork_session(selected.info.id) + end, + reload = false, + }, + } + + return base_picker.pick({ + items = messages, + format_fn = format_message_item, + actions = actions, + callback = callback, + title = 'Timeline', + width = config.ui.picker_width or 100, + }) +end + +return M diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index ae76267b..331ff130 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -78,7 +78,6 @@ function M.create_split_windows(input_buf, output_buf) end function M.create_windows() - require('opencode.ui.highlight').setup() vim.treesitter.language.register('markdown', 'opencode_output') local autocmds = require('opencode.ui.autocmds') @@ -211,7 +210,7 @@ function M.select_session(sessions, cb) table.insert(parts, session.message_count .. ' messages') end - local modified = util.time_ago(session.modified) + local modified = util.format_time(session.modified) if modified then table.insert(parts, modified) end diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index 37d76056..b586e5dc 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -90,54 +90,22 @@ function M.sanitize_lines(lines) return stripped_lines end ---- Convert a datetime to a human-readable "time ago" format ---- @param timestamp number ---- @return string: Human-readable time ago string (e.g., "2 hours ago") -function M.time_ago(timestamp) - if timestamp > 1e12 then - timestamp = math.floor(timestamp / 1000) - end - - local now = os.time() - local diff = now - timestamp - if diff < 0 then - return 'in the future' - elseif diff < 60 then - return 'just now' - elseif diff < 3600 then - local mins = math.floor(diff / 60) - return mins == 1 and '1 minute ago' or mins .. ' minutes ago' - elseif diff < 86400 then - local hours = math.floor(diff / 3600) - return hours == 1 and '1 hour ago' or hours .. ' hours ago' - elseif diff < 604800 then - local days = math.floor(diff / 86400) - return days == 1 and '1 day ago' or days .. ' days ago' - elseif diff < 2592000 then - local weeks = math.floor(diff / 604800) - return weeks == 1 and '1 week ago' or weeks .. ' weeks ago' - elseif diff < 31536000 then - local months = math.floor(diff / 2592000) - return months == 1 and '1 month ago' or months .. ' months ago' - else - local years = math.floor(diff / 31536000) - return years == 1 and '1 year ago' or years .. ' years ago' - end -end - ---- Format a timestamp as time (e.g., "10:23 AM" or "13 Oct 2025 03:32 PM") +--- Format a timestamp as time (e.g., "10:23 AM", "13 Oct 03:32 PM" "13 Oct 2025 03:32 PM") --- @param timestamp number --- @return string: Formatted time string function M.format_time(timestamp) + local formats = { day = '%I:%M %p', year = '%d %b %I:%M %p', full = '%d %b %Y %I:%M %p' } + if timestamp > 1e12 then timestamp = math.floor(timestamp / 1000) end - if os.date('%Y-%m-%d', timestamp) == os.date('%Y-%m-%d') then - return os.date('%I:%M %p', timestamp) --[[@as string]] - end + local same_day = os.date('%Y-%m-%d') == os.date('%Y-%m-%d', timestamp) + local same_year = os.date('%Y') == os.date('%Y', timestamp) + + local format_str = same_day and formats.day or (same_year and formats.year or formats.full) - return os.date('%d %b %Y %I:%M %p', timestamp) --[[@as string]] + return os.date(format_str, timestamp) --[[@as string]] end function M.index_of(tbl, value) diff --git a/tests/helpers.lua b/tests/helpers.lua index b75d0d3a..39869a9d 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -133,20 +133,17 @@ end function M.mock_time_utils() local util = require('opencode.util') - local original_time_ago = util.time_ago local original_format_time = util.format_time ---@diagnostic disable-next-line: duplicate-set-field - util.time_ago = function(timestamp) + util.format_time = function(timestamp) if timestamp > 1e12 then timestamp = math.floor(timestamp / 1000) end return os.date('!%Y-%m-%d %H:%M:%S', timestamp) end - util.format_time = util.time_ago return function() - util.time_ago = original_time_ago util.format_time = original_format_time end end diff --git a/tests/unit/renderer_spec.lua b/tests/unit/renderer_spec.lua index b1bee192..c6281e76 100644 --- a/tests/unit/renderer_spec.lua +++ b/tests/unit/renderer_spec.lua @@ -92,8 +92,6 @@ local function assert_output_matches(expected, actual, name) end describe('renderer', function() - local restore_time_ago - before_each(function() helpers.replay_setup() end) diff --git a/tests/unit/util_spec.lua b/tests/unit/util_spec.lua index c31a421f..61a9e488 100644 --- a/tests/unit/util_spec.lua +++ b/tests/unit/util_spec.lua @@ -110,6 +110,7 @@ describe('util.format_time', function() local yesterday = os.time() - 86400 -- 24 hours ago local last_week = os.time() - (7 * 86400) -- 7 days ago + local last_month = os.time() - (30 * 86400) -- 30 days ago local next_year = make_timestamp(today.year + 1, 6, 15, 12, 0, 0) describe('today timestamps', function() @@ -140,16 +141,14 @@ describe('util.format_time', function() end) describe('other day timestamps', function() - it('formats yesterday with full date', function() + it('formats yesterday with same month date', function() local result = util.format_time(yesterday) - assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result) - assert.matches('%d%d%d%d', result) + assert.matches('^%d%d? %a%a%a %d%d?:%d%d [AP]M$', result) end) - it('formats last week with full date', function() + it('formats last week with same month date', function() local result = util.format_time(last_week) - assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result) - assert.matches('%d%d%d%d', result) + assert.matches('^%d%d? %a%a%a %d%d?:%d%d [AP]M$', result) end) it('formats future date with full date', function() @@ -178,7 +177,7 @@ describe('util.format_time', function() assert.is_string(result) local is_time_only = result:match('^%d%d?:%d%d [AP]M$') - local is_full_date = result:match('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$') + local is_full_date = result:match('^%d%d? %a%a%a %d%d?:%d%d [AP]M$') assert.is_true(is_time_only ~= nil or is_full_date ~= nil) end) @@ -229,7 +228,7 @@ describe('util.format_time', function() assert.matches('^%d%d?:%d%d [AP]M$', early_result) else -- Actually tomorrow - assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', early_result) + assert.matches('^%d%d? %a%a%a %d%d?:%d%d [AP]M$', early_result) end end) end)