From c17b58eb879a9feda1f5f9ffcd08f09c848d419d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 5 Nov 2025 08:38:57 -0500 Subject: [PATCH 01/13] feat(timeline-picker): Implement basic timeline picker This is the first commit, the fork action is not yet implemented --- README.md | 103 ++++---- lua/opencode/api.lua | 26 ++ lua/opencode/config.lua | 4 + lua/opencode/types.lua | 4 + lua/opencode/ui/navigation.lua | 18 ++ lua/opencode/ui/renderer.lua | 11 + lua/opencode/ui/session_picker.lua | 5 +- lua/opencode/ui/timeline_picker.lua | 353 ++++++++++++++++++++++++++++ 8 files changed, 474 insertions(+), 50 deletions(-) create mode 100644 lua/opencode/ui/timeline_picker.lua diff --git a/README.md b/README.md index 03fff0ec..6f9762d4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,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 +319,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..4d4fb8c1 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -680,6 +680,26 @@ function M.undo(messageId) end) end +function M.timeline() + local user_messages = {} + for _, msg in ipairs(state.messages or {}) do + if msg.info.role == 'user' 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 + -- 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 +1094,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 +1114,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/config.lua b/lua/opencode/config.lua index a13d7c4b..6266ab0e 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -75,6 +75,10 @@ M.defaults = { delete_session = { '' }, new_session = { '' }, }, + timeline_picker = { + undo = { '', mode = { 'i', 'n' } }, + fork = { '', mode = { 'i', 'n' } }, + }, }, ui = { position = 'right', diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7e8f864d..d652bd4b 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -77,6 +77,10 @@ ---@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' diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index 349f3d29..41595c70 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 = 2 + 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..fe1605a7 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -1,5 +1,6 @@ local M = {} local picker = require('opencode.ui.picker') +local config = require('lua.opencode.config') local picker_title = function() local config = require('opencode.config') --[[@as OpencodeConfig]] @@ -37,7 +38,9 @@ local function format_session(session) table.insert(parts, modified) end - table.insert(parts, 'ID: ' .. (session.id or 'N/A')) + if config.debug then + table.insert(parts, 'ID: ' .. (session.id or 'N/A')) + end return table.concat(parts, ' ~ ') end diff --git a/lua/opencode/ui/timeline_picker.lua b/lua/opencode/ui/timeline_picker.lua new file mode 100644 index 00000000..8081deb8 --- /dev/null +++ b/lua/opencode/ui/timeline_picker.lua @@ -0,0 +1,353 @@ +local M = {} +local picker = require('opencode.ui.picker') +local config = require('lua.opencode.config') + +local picker_title = function() + local config = require('opencode.config') --[[@as OpencodeConfig]] + local keymap_config = config.keymap.timeline_picker or {} + + local legend = {} + local actions = { + { key = keymap_config.undo, label = 'undo' }, + { key = keymap_config.fork, label = 'fork' }, + } + + 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 'Timeline' .. (#legend > 0 and ' | ' .. table.concat(legend, ' | ') or '') +end + +local function format_message(msg) + local util = require('opencode.util') + local parts = {} + local length_limit = config.debug and 50 or 70 + + local preview = msg.parts and msg.parts[1] and msg.parts[1].text or '' + if #preview > length_limit then + preview = preview:sub(1, length_limit - 3) .. '...' + end + + if preview and preview ~= '' then + table.insert(parts, preview) + end + + local time_str = util.format_time(msg.info.time.created) + if time_str then + table.insert(parts, time_str) + end + + if config.debug then + table.insert(parts, 'ID: ' .. (msg.info.id or 'N/A')) + end + + return table.concat(parts, ' ~ ') +end + +local function telescope_ui(messages, callback, on_undo, on_fork) + 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 = pickers.new({}, { + prompt_title = picker_title(), + finder = finders.new_table({ + results = messages, + entry_maker = function(msg) + return { + value = msg, + display = format_message(msg), + ordinal = format_message(msg), + } + 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 timeline_config = config.keymap.timeline_picker or {} + + if timeline_config.undo and timeline_config.undo[1] then + local key = timeline_config.undo[1] + local modes = timeline_config.undo.mode or { 'i', 'n' } + if type(modes) == 'string' then + modes = { modes } + end + + local undo_fn = function() + local selection = action_state.get_selected_entry() + if selection and on_undo then + actions.close(prompt_bufnr) + on_undo(selection.value) + end + end + + for _, mode in ipairs(modes) do + map(mode, key, undo_fn) + end + end + + if timeline_config.fork and timeline_config.fork[1] then + local key = timeline_config.fork[1] + local modes = timeline_config.fork.mode or { 'i', 'n' } + if type(modes) == 'string' then + modes = { modes } + end + + local fork_fn = function() + local selection = action_state.get_selected_entry() + if selection and on_fork then + actions.close(prompt_bufnr) + on_fork(selection.value) + end + end + + for _, mode in ipairs(modes) do + map(mode, key, fork_fn) + end + end + + return true + end, + }) + + current_picker:find() +end + +local function fzf_ui(messages, callback, on_undo, on_fork) + 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 messages[idx] and callback then + callback(messages[idx]) + end + end, + } + + local timeline_config = config.keymap.timeline_picker or {} + + if timeline_config.undo and timeline_config.undo[1] then + local key = require('fzf-lua.utils').neovim_bind_to_fzf(timeline_config.undo[1]) + 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 messages[idx] and on_undo then + on_undo(messages[idx]) + end + end, + header = 'undo', + } + end + + if timeline_config.fork and timeline_config.fork[1] then + local key = require('fzf-lua.utils').neovim_bind_to_fzf(timeline_config.fork[1]) + 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 messages[idx] and on_fork then + on_fork(messages[idx]) + end + end, + header = 'fork', + } + end + + fzf_lua.fzf_exec(function(fzf_cb) + for _, msg in ipairs(messages) do + fzf_cb(format_message(msg)) + end + fzf_cb() + end, { + fzf_opts = { + ['--prompt'] = picker_title() .. ' > ', + }, + _headers = { 'actions' }, + actions = actions_config, + fn_fzf_index = function(line) + for i, msg in ipairs(messages) do + if format_message(msg) == line then + return i + end + end + return nil + end, + }) +end + +local function mini_pick_ui(messages, callback, on_undo, on_fork) + local mini_pick = require('mini.pick') + local config = require('opencode.config') + + local items = vim.tbl_map(function(msg) + return { + text = format_message(msg), + message = msg, + } + end, messages) + + local timeline_config = config.keymap.timeline_picker or {} + local mappings = {} + + if timeline_config.undo and timeline_config.undo[1] then + mappings.undo = { + char = timeline_config.undo[1], + func = function() + local selected = mini_pick.get_picker_matches().current + if selected and selected.message and on_undo then + on_undo(selected.message) + end + end, + } + end + + if timeline_config.fork and timeline_config.fork[1] then + mappings.fork = { + char = timeline_config.fork[1], + func = function() + local selected = mini_pick.get_picker_matches().current + if selected and selected.message and on_fork then + on_fork(selected.message) + end + end, + } + end + + mini_pick.start({ + source = { + items = items, + name = picker_title(), + choose = function(selected) + if selected and selected.message and callback then + callback(selected.message) + end + return false + end, + }, + mappings = mappings, + }) +end + +local function snacks_picker_ui(messages, callback, on_undo, on_fork) + local Snacks = require('snacks') + local config = require('opencode.config') + + local timeline_config = config.keymap.timeline_picker or {} + + local opts = { + title = picker_title(), + layout = { preset = 'select' }, + finder = function() + return messages + end, + format = 'text', + transform = function(item) + item.text = format_message(item) + end, + actions = { + confirm = function(picker, item) + picker:close() + if item and callback then + vim.schedule(function() + callback(item) + end) + end + end, + }, + } + + if timeline_config.undo and timeline_config.undo[1] then + local key = timeline_config.undo[1] + local mode = timeline_config.undo.mode or 'i' + + opts.win = opts.win or {} + opts.win.input = opts.win.input or { keys = {} } + opts.win.input.keys[key] = { 'timeline_undo', mode = mode } + + opts.actions.timeline_undo = function(picker, item) + if item and on_undo then + vim.schedule(function() + picker:close() + on_undo(item) + end) + end + end + end + + if timeline_config.fork and timeline_config.fork[1] then + local key = timeline_config.fork[1] + local mode = timeline_config.fork.mode or 'i' + + opts.win = opts.win or {} + opts.win.input = opts.win.input or { keys = {} } + opts.win.input.keys[key] = { 'timeline_fork', mode = mode } + + opts.actions.timeline_fork = function(picker, item) + if item and on_fork then + vim.schedule(function() + picker:close() + on_fork(item) + end) + end + end + end + + Snacks.picker.pick(opts) +end + +function M.pick(messages, callback) + local picker_type = picker.get_best_picker() + + if not picker_type then + return false + end + + local function on_undo(msg) + require('opencode.api').undo(msg.info.id) + end + + local function on_fork(msg) + -- TODO: Implement fork functionality + vim.notify('Fork functionality not yet implemented', vim.log.levels.WARN) + end + + vim.schedule(function() + if picker_type == 'telescope' then + telescope_ui(messages, callback, on_undo, on_fork) + elseif picker_type == 'fzf' then + fzf_ui(messages, callback, on_undo, on_fork) + elseif picker_type == 'mini.pick' then + mini_pick_ui(messages, callback, on_undo, on_fork) + elseif picker_type == 'snacks' then + snacks_picker_ui(messages, callback, on_undo, on_fork) + else + callback(nil) + end + end) + + return true +end + +return M From 03c1bef7485c3518fa3fa2a53a525c2b7a549b55 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 5 Nov 2025 09:57:02 -0500 Subject: [PATCH 02/13] feat(timeline-picker): add fork session action --- lua/opencode/api.lua | 37 ++++++++++++++++++++++++++++- lua/opencode/api_client.lua | 9 +++++++ lua/opencode/core.lua | 29 ++++++++++++---------- lua/opencode/ui/timeline_picker.lua | 4 ++-- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 4d4fb8c1..91498ca7 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -683,7 +683,9 @@ end function M.timeline() local user_messages = {} for _, msg in ipairs(state.messages or {}) do - if msg.info.role == 'user' then + 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 @@ -700,6 +702,39 @@ function M.timeline() end) end +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 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/core.lua b/lua/opencode/core.lua index d7b70ed1..08f299da 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -25,21 +25,26 @@ 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 = {} + -- Don't need to update either renderer because they subscribe to + -- session changes + 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/ui/timeline_picker.lua b/lua/opencode/ui/timeline_picker.lua index 8081deb8..f5666a5e 100644 --- a/lua/opencode/ui/timeline_picker.lua +++ b/lua/opencode/ui/timeline_picker.lua @@ -1,6 +1,7 @@ local M = {} local picker = require('opencode.ui.picker') local config = require('lua.opencode.config') +local api = require('opencode.api') local picker_title = function() local config = require('opencode.config') --[[@as OpencodeConfig]] @@ -329,8 +330,7 @@ function M.pick(messages, callback) end local function on_fork(msg) - -- TODO: Implement fork functionality - vim.notify('Fork functionality not yet implemented', vim.log.levels.WARN) + api.fork_session(msg.info.id) end vim.schedule(function() From b83ba301193dac9c9815ae12845958d56b158c7a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 6 Nov 2025 09:12:14 -0500 Subject: [PATCH 03/13] feat(pickers): add better highlight for supported pickers Format the time as and highlight it as well as align it to the right --- lua/opencode/config.lua | 1 + lua/opencode/git_review.lua | 2 +- lua/opencode/init.lua | 1 + lua/opencode/types.lua | 1 + lua/opencode/ui/completion/commands.lua | 4 +- lua/opencode/ui/footer.lua | 2 +- lua/opencode/ui/highlight.lua | 4 ++ lua/opencode/ui/input_window.lua | 10 ++-- lua/opencode/ui/loading_animation.lua | 2 +- lua/opencode/ui/picker_utils.lua | 77 +++++++++++++++++++++++++ lua/opencode/ui/session_picker.lua | 63 ++++++++++---------- lua/opencode/ui/timeline_picker.lua | 56 +++++++++--------- lua/opencode/ui/ui.lua | 3 +- lua/opencode/util.lua | 35 ----------- tests/helpers.lua | 5 +- tests/unit/renderer_spec.lua | 2 - 16 files changed, 153 insertions(+), 115 deletions(-) create mode 100644 lua/opencode/ui/picker_utils.lua diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 6266ab0e..e2f97b06 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -176,6 +176,7 @@ M.defaults = { debug = { enabled = false, capture_streamed_events = false, + show_ids = false, }, prompt_guard = nil, } 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 d652bd4b..5f36b611 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -130,6 +130,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/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/picker_utils.lua b/lua/opencode/ui/picker_utils.lua new file mode 100644 index 00000000..79cefe95 --- /dev/null +++ b/lua/opencode/ui/picker_utils.lua @@ -0,0 +1,77 @@ +local config = require('opencode.config') +local util = require('opencode.util') +local M = {} + +---@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): string, table + +---@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 +---@return PickerItem +function M.create_picker_item(text, time, debug_text) + local debug_offset = config.debug.show_ids and #debug_text or 0 + local item = { + content = M.align(text, 70 - debug_offset + 1, { truncate = true }), + time_text = time and M.align(util.format_time(time), 20, { align = 'right' }), + debug_text = config.debug.show_ids and debug_text or nil, + } + + function item:to_string() + local segments = { self.content } + + if self.time_text then + table.insert(segments, self.time_text) + end + + if self.debug_text then + table.insert(segments, self.debug_text) + end + + return table.concat(segments, ' ') + end + + function item:to_formatted_text() + local segments = { { self.content } } + + if self.time_text then + table.insert(segments, { ' ' .. self.time_text, 'OpencodePickerTime' }) + end + + if self.debug_text then + table.insert(segments, { ' ' .. self.debug_text, 'OpencodeDebugText' }) + end + + return segments + end + + return item +end + +return M diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index fe1605a7..6794071d 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -1,9 +1,9 @@ local M = {} local picker = require('opencode.ui.picker') -local config = require('lua.opencode.config') +local picker_utils = require('opencode.ui.picker_utils') +local config = require('opencode.config') local picker_title = function() - local config = require('opencode.config') --[[@as OpencodeConfig]] local keymap_config = config.keymap.session_picker local legend = {} @@ -21,27 +21,13 @@ local picker_title = function() return 'Select A Session' .. (#legend > 0 and ' | ' .. table.concat(legend, ' | ') or '') end -local function format_session(session) - local util = require('opencode.util') - local parts = {} +---Format session parts for session picker +---@param session Session object +---@return PickerItem +function format_session_item(session) + local debug_text = 'ID: ' .. (session.id or 'N/A') - 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 - - if config.debug then - table.insert(parts, 'ID: ' .. (session.id or 'N/A')) - end - return table.concat(parts, ' ~ ') + return picker_utils.create_picker_item(session.description, session.modified, debug_text) end local function telescope_ui(sessions, callback, on_delete, on_new) @@ -50,6 +36,15 @@ local function telescope_ui(sessions, callback, on_delete, on_new) 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 @@ -65,8 +60,10 @@ local function telescope_ui(sessions, callback, on_delete, on_new) entry_maker = function(session) return { value = session, - display = format_session(session), - ordinal = format_session(session), + display = function(entry) + return displayer(format_session_item(entry.value):to_formatted_text()) + end, + ordinal = format_session_item(session):to_string(), } end, }), @@ -81,8 +78,10 @@ local function telescope_ui(sessions, callback, on_delete, on_new) entry_maker = function(session) return { value = session, - display = format_session(session), - ordinal = format_session(session), + display = function(entry) + return displayer(format_session_item(entry.value):to_formatted_text()) + end, + ordinal = format_session_item(session):to_string(), } end, }), @@ -210,7 +209,7 @@ local function fzf_ui(sessions, callback, on_delete, on_new) fzf_lua.fzf_exec(function(fzf_cb) for _, session in ipairs(sessions) do - fzf_cb(format_session(session)) + fzf_cb(format_session_item(session):to_string()) end fzf_cb() end, { @@ -221,7 +220,7 @@ local function fzf_ui(sessions, callback, on_delete, on_new) actions = actions_config, fn_fzf_index = function(line) for i, session in ipairs(sessions) do - if format_session(session) == line then + if format_session_item(session):to_string() == line then return i end end @@ -236,7 +235,7 @@ local function mini_pick_ui(sessions, callback, on_delete, on_new) local items = vim.tbl_map(function(session) return { - text = format_session(session), + text = format_session_item(session):to_string(), session = session, } end, sessions) @@ -307,7 +306,6 @@ 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 @@ -317,9 +315,8 @@ local function snacks_picker_ui(sessions, callback, on_delete, on_new) finder = function() return sessions end, - format = 'text', - transform = function(item) - item.text = format_session(item) + format = function(item) + return format_session_item(item, config.debug.show_ids):to_formatted_text() end, actions = { confirm = function(picker, item) diff --git a/lua/opencode/ui/timeline_picker.lua b/lua/opencode/ui/timeline_picker.lua index f5666a5e..a691bef8 100644 --- a/lua/opencode/ui/timeline_picker.lua +++ b/lua/opencode/ui/timeline_picker.lua @@ -1,5 +1,6 @@ local M = {} local picker = require('opencode.ui.picker') +local picker_utils = require('opencode.ui.picker_utils') local config = require('lua.opencode.config') local api = require('opencode.api') @@ -22,30 +23,15 @@ local picker_title = function() return 'Timeline' .. (#legend > 0 and ' | ' .. table.concat(legend, ' | ') or '') end -local function format_message(msg) - local util = require('opencode.util') - local parts = {} - local length_limit = config.debug and 50 or 70 - +---Format message parts for timeline picker +---@param msg OpencodeMessage Message object +---@return PickerItem +function format_message_item(msg) local preview = msg.parts and msg.parts[1] and msg.parts[1].text or '' - if #preview > length_limit then - preview = preview:sub(1, length_limit - 3) .. '...' - end - - if preview and preview ~= '' then - table.insert(parts, preview) - end - local time_str = util.format_time(msg.info.time.created) - if time_str then - table.insert(parts, time_str) - end + local debug_text = 'ID: ' .. (msg.info.id or 'N/A') - if config.debug then - table.insert(parts, 'ID: ' .. (msg.info.id or 'N/A')) - end - - return table.concat(parts, ' ~ ') + return picker_utils.create_picker_item(preview, msg.info.time.created, debug_text) end local function telescope_ui(messages, callback, on_undo, on_fork) @@ -54,6 +40,15 @@ local function telescope_ui(messages, callback, on_undo, on_fork) 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 = pickers.new({}, { prompt_title = picker_title(), @@ -62,8 +57,10 @@ local function telescope_ui(messages, callback, on_undo, on_fork) entry_maker = function(msg) return { value = msg, - display = format_message(msg), - ordinal = format_message(msg), + display = function(entry) + return displayer(format_message_item(entry):to_formatted_text()) + end, + ordinal = format_message_item(msg):to_string(), } end, }), @@ -178,7 +175,7 @@ local function fzf_ui(messages, callback, on_undo, on_fork) fzf_lua.fzf_exec(function(fzf_cb) for _, msg in ipairs(messages) do - fzf_cb(format_message(msg)) + fzf_cb(format_message_item(msg):to_string()) end fzf_cb() end, { @@ -189,7 +186,7 @@ local function fzf_ui(messages, callback, on_undo, on_fork) actions = actions_config, fn_fzf_index = function(line) for i, msg in ipairs(messages) do - if format_message(msg) == line then + if format_message_item(msg):to_string() == line then return i end end @@ -200,11 +197,10 @@ end local function mini_pick_ui(messages, callback, on_undo, on_fork) local mini_pick = require('mini.pick') - local config = require('opencode.config') local items = vim.tbl_map(function(msg) return { - text = format_message(msg), + text = format_message_item(msg):to_string(), message = msg, } end, messages) @@ -263,10 +259,10 @@ local function snacks_picker_ui(messages, callback, on_undo, on_fork) finder = function() return messages end, - format = 'text', - transform = function(item) - item.text = format_message(item) + format = function(item, picker) + return format_message_item(item):to_formatted_text() end, + actions = { confirm = function(picker, item) picker:close() 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..dc4c2280 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -90,41 +90,6 @@ 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") --- @param timestamp number --- @return string: Formatted time string 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) From f0d5416ed6115b82a48bcb73d60f955bff2d3480 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 6 Nov 2025 12:42:26 -0500 Subject: [PATCH 04/13] feat(keymaps): add keymap `oT` to open the timeline picker --- README.md | 1 + lua/opencode/config.lua | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 6f9762d4..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 diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index e2f97b06..38ffa999 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' }, From 095f47cbf818f83105618fe979dce56daa737e70 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 6 Nov 2025 12:43:21 -0500 Subject: [PATCH 05/13] chore(api): add typings to fork_session --- lua/opencode/api.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 91498ca7..8268edb3 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -702,6 +702,8 @@ function M.timeline() 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) From 511952f5845c7dcbcbc6c04f30f8cb2630317c46 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 6 Nov 2025 15:48:03 -0500 Subject: [PATCH 06/13] fix(session_picker): mini_pick_ui was calling old format method --- lua/opencode/ui/session_picker.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 6794071d..89f181c5 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -231,7 +231,6 @@ 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 { @@ -255,7 +254,7 @@ local function mini_pick_ui(sessions, callback, on_delete, on_new) on_delete(selected.session) items = vim.tbl_map(function(session) return { - text = format_session(session), + text = format_session_item(session):to_string(), session = session, } end, sessions) @@ -278,7 +277,7 @@ local function mini_pick_ui(sessions, callback, on_delete, on_new) table.insert(sessions, 1, new_session) items = vim.tbl_map(function(session) return { - text = format_session(session), + text = format_session_item(session):to_string(), session = session, } end, sessions) From e36ed422250119b0c445ecdadc1544ec228e4ca3 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 7 Nov 2025 10:58:49 -0500 Subject: [PATCH 07/13] refactor(picker_utils): create a base picker This creates a base picker module to make it as easy as possible to create new ones with the same pattern It also aligns the date to the right properly for every picker --- lua/opencode/config.lua | 1 + lua/opencode/core.lua | 2 - lua/opencode/types.lua | 2 + lua/opencode/ui/base_picker.lua | 405 ++++++++++++++++++++++++ lua/opencode/ui/picker_utils.lua | 77 ----- lua/opencode/ui/session_picker.lua | 460 +++------------------------- lua/opencode/ui/timeline_picker.lua | 353 ++------------------- lua/opencode/util.lua | 13 +- 8 files changed, 490 insertions(+), 823 deletions(-) create mode 100644 lua/opencode/ui/base_picker.lua delete mode 100644 lua/opencode/ui/picker_utils.lua diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 38ffa999..97c57ff1 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -86,6 +86,7 @@ M.defaults = { input_position = 'bottom', window_width = 0.40, input_height = 0.15, + picker_width = 100, display_model = true, display_context_size = true, display_cost = true, diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 08f299da..72aa8dca 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -37,8 +37,6 @@ function M.switch_session(session_id) 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() diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 5f36b611..83aeb37a 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -72,6 +72,7 @@ ---@field output_window OpencodeKeymapOutputWindow ---@field permission OpencodeKeymapPermission ---@field session_picker OpencodeSessionPickerKeymap +---@field timeline_picker OpencodeTimelinePickerKeymap ---@class OpencodeSessionPickerKeymap ---@field delete_session OpencodeKeymapEntry @@ -99,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 diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua new file mode 100644 index 00000000..cf4a0092 --- /dev/null +++ b/lua/opencode/ui/base_picker.lua @@ -0,0 +1,405 @@ +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 } } 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', width = opts.width or nil }, + finder = function() + return opts.items + 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(0) + 1 or 0 -- longest time format by using 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/picker_utils.lua b/lua/opencode/ui/picker_utils.lua deleted file mode 100644 index 79cefe95..00000000 --- a/lua/opencode/ui/picker_utils.lua +++ /dev/null @@ -1,77 +0,0 @@ -local config = require('opencode.config') -local util = require('opencode.util') -local M = {} - ----@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): string, table - ----@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 ----@return PickerItem -function M.create_picker_item(text, time, debug_text) - local debug_offset = config.debug.show_ids and #debug_text or 0 - local item = { - content = M.align(text, 70 - debug_offset + 1, { truncate = true }), - time_text = time and M.align(util.format_time(time), 20, { align = 'right' }), - debug_text = config.debug.show_ids and debug_text or nil, - } - - function item:to_string() - local segments = { self.content } - - if self.time_text then - table.insert(segments, self.time_text) - end - - if self.debug_text then - table.insert(segments, self.debug_text) - end - - return table.concat(segments, ' ') - end - - function item:to_formatted_text() - local segments = { { self.content } } - - if self.time_text then - table.insert(segments, { ' ' .. self.time_text, 'OpencodePickerTime' }) - end - - if self.debug_text then - table.insert(segments, { ' ' .. self.debug_text, 'OpencodeDebugText' }) - end - - return segments - end - - return item -end - -return M diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 89f181c5..580003a3 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -1,440 +1,76 @@ local M = {} -local picker = require('opencode.ui.picker') -local picker_utils = require('opencode.ui.picker_utils') local config = require('opencode.config') - -local picker_title = function() - 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 base_picker = require('opencode.ui.base_picker') ---Format session parts for session picker ---@param session Session object ---@return PickerItem -function format_session_item(session) +function format_session_item(session, width) local debug_text = 'ID: ' .. (session.id or 'N/A') - - return picker_utils.create_picker_item(session.description, session.modified, debug_text) + 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 entry_display = require('telescope.pickers.entry_display') - local displayer = entry_display.create({ - separator = ' ', - items = { - {}, - {}, - config.debug.show_ids and {} or nil, - }, - }) - - 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 = function(entry) - return displayer(format_session_item(entry.value):to_formatted_text()) - end, - ordinal = format_session_item(session):to_string(), - } - 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 = function(entry) - return displayer(format_session_item(entry.value):to_formatted_text()) - end, - ordinal = format_session_item(session):to_string(), - } - 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_item(session):to_string()) - 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_item(session):to_string() == 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 items = vim.tbl_map(function(session) - return { - text = format_session_item(session):to_string(), - 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_item(session):to_string(), - 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_item(session):to_string(), - 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 delete_config = config.keymap.session_picker.delete_session - - local opts = { - title = picker_title(), - layout = { preset = 'select' }, - finder = function() - return sessions - end, - format = function(item) - return format_session_item(item, config.debug.show_ids):to_formatted_text() - 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 index a691bef8..31b72fdd 100644 --- a/lua/opencode/ui/timeline_picker.lua +++ b/lua/opencode/ui/timeline_picker.lua @@ -1,349 +1,48 @@ local M = {} -local picker = require('opencode.ui.picker') -local picker_utils = require('opencode.ui.picker_utils') -local config = require('lua.opencode.config') +local config = require('opencode.config') local api = require('opencode.api') - -local picker_title = function() - local config = require('opencode.config') --[[@as OpencodeConfig]] - local keymap_config = config.keymap.timeline_picker or {} - - local legend = {} - local actions = { - { key = keymap_config.undo, label = 'undo' }, - { key = keymap_config.fork, label = 'fork' }, - } - - 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 'Timeline' .. (#legend > 0 and ' | ' .. table.concat(legend, ' | ') or '') -end +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) +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 picker_utils.create_picker_item(preview, msg.info.time.created, debug_text) -end - -local function telescope_ui(messages, callback, on_undo, on_fork) - 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 = pickers.new({}, { - prompt_title = picker_title(), - finder = finders.new_table({ - results = messages, - entry_maker = function(msg) - return { - value = msg, - display = function(entry) - return displayer(format_message_item(entry):to_formatted_text()) - end, - ordinal = format_message_item(msg):to_string(), - } - 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 timeline_config = config.keymap.timeline_picker or {} - - if timeline_config.undo and timeline_config.undo[1] then - local key = timeline_config.undo[1] - local modes = timeline_config.undo.mode or { 'i', 'n' } - if type(modes) == 'string' then - modes = { modes } - end - - local undo_fn = function() - local selection = action_state.get_selected_entry() - if selection and on_undo then - actions.close(prompt_bufnr) - on_undo(selection.value) - end - end - - for _, mode in ipairs(modes) do - map(mode, key, undo_fn) - end - end - - if timeline_config.fork and timeline_config.fork[1] then - local key = timeline_config.fork[1] - local modes = timeline_config.fork.mode or { 'i', 'n' } - if type(modes) == 'string' then - modes = { modes } - end - - local fork_fn = function() - local selection = action_state.get_selected_entry() - if selection and on_fork then - actions.close(prompt_bufnr) - on_fork(selection.value) - end - end - - for _, mode in ipairs(modes) do - map(mode, key, fork_fn) - end - end - - return true - end, - }) - - current_picker:find() + return base_picker.create_picker_item(vim.trim(preview), msg.info.time.created, debug_text, width) end -local function fzf_ui(messages, callback, on_undo, on_fork) - 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 messages[idx] and callback then - callback(messages[idx]) - end - end, - } - - local timeline_config = config.keymap.timeline_picker or {} - - if timeline_config.undo and timeline_config.undo[1] then - local key = require('fzf-lua.utils').neovim_bind_to_fzf(timeline_config.undo[1]) - 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 messages[idx] and on_undo then - on_undo(messages[idx]) - end - end, - header = 'undo', - } - end - - if timeline_config.fork and timeline_config.fork[1] then - local key = require('fzf-lua.utils').neovim_bind_to_fzf(timeline_config.fork[1]) - actions_config[key] = { +function M.pick(messages, callback) + local keymap = config.keymap.timeline_picker + local actions = { + undo = { + key = keymap.undo, + label = 'undo', fn = function(selected, opts) - if not selected or #selected == 0 then - return - end - local idx = opts.fn_fzf_index(selected[1]) - if idx and messages[idx] and on_fork then - on_fork(messages[idx]) - end + api.undo(selected.info.id) end, - header = 'fork', - } - end - - fzf_lua.fzf_exec(function(fzf_cb) - for _, msg in ipairs(messages) do - fzf_cb(format_message_item(msg):to_string()) - end - fzf_cb() - end, { - fzf_opts = { - ['--prompt'] = picker_title() .. ' > ', + reload = false, }, - _headers = { 'actions' }, - actions = actions_config, - fn_fzf_index = function(line) - for i, msg in ipairs(messages) do - if format_message_item(msg):to_string() == line then - return i - end - end - return nil - end, - }) -end - -local function mini_pick_ui(messages, callback, on_undo, on_fork) - local mini_pick = require('mini.pick') - - local items = vim.tbl_map(function(msg) - return { - text = format_message_item(msg):to_string(), - message = msg, - } - end, messages) - - local timeline_config = config.keymap.timeline_picker or {} - local mappings = {} - - if timeline_config.undo and timeline_config.undo[1] then - mappings.undo = { - char = timeline_config.undo[1], - func = function() - local selected = mini_pick.get_picker_matches().current - if selected and selected.message and on_undo then - on_undo(selected.message) - end - end, - } - end - - if timeline_config.fork and timeline_config.fork[1] then - mappings.fork = { - char = timeline_config.fork[1], - func = function() - local selected = mini_pick.get_picker_matches().current - if selected and selected.message and on_fork then - on_fork(selected.message) - end - end, - } - end - - mini_pick.start({ - source = { - items = items, - name = picker_title(), - choose = function(selected) - if selected and selected.message and callback then - callback(selected.message) - end - return false - end, - }, - mappings = mappings, - }) -end - -local function snacks_picker_ui(messages, callback, on_undo, on_fork) - local Snacks = require('snacks') - local config = require('opencode.config') - - local timeline_config = config.keymap.timeline_picker or {} - - local opts = { - title = picker_title(), - layout = { preset = 'select' }, - finder = function() - return messages - end, - format = function(item, picker) - return format_message_item(item):to_formatted_text() - end, - - actions = { - confirm = function(picker, item) - picker:close() - if item and callback then - vim.schedule(function() - callback(item) - end) - end + fork = { + key = keymap.fork, + label = 'fork', + fn = function(selected, opts) + api.fork_session(selected.info.id) end, + reload = false, }, } - if timeline_config.undo and timeline_config.undo[1] then - local key = timeline_config.undo[1] - local mode = timeline_config.undo.mode or 'i' - - opts.win = opts.win or {} - opts.win.input = opts.win.input or { keys = {} } - opts.win.input.keys[key] = { 'timeline_undo', mode = mode } - - opts.actions.timeline_undo = function(picker, item) - if item and on_undo then - vim.schedule(function() - picker:close() - on_undo(item) - end) - end - end - end - - if timeline_config.fork and timeline_config.fork[1] then - local key = timeline_config.fork[1] - local mode = timeline_config.fork.mode or 'i' - - opts.win = opts.win or {} - opts.win.input = opts.win.input or { keys = {} } - opts.win.input.keys[key] = { 'timeline_fork', mode = mode } - - opts.actions.timeline_fork = function(picker, item) - if item and on_fork then - vim.schedule(function() - picker:close() - on_fork(item) - end) - end - end - end - - Snacks.picker.pick(opts) -end - -function M.pick(messages, callback) - local picker_type = picker.get_best_picker() - - if not picker_type then - return false - end - - local function on_undo(msg) - require('opencode.api').undo(msg.info.id) - end - - local function on_fork(msg) - api.fork_session(msg.info.id) - end - - vim.schedule(function() - if picker_type == 'telescope' then - telescope_ui(messages, callback, on_undo, on_fork) - elseif picker_type == 'fzf' then - fzf_ui(messages, callback, on_undo, on_fork) - elseif picker_type == 'mini.pick' then - mini_pick_ui(messages, callback, on_undo, on_fork) - elseif picker_type == 'snacks' then - snacks_picker_ui(messages, callback, on_undo, on_fork) - else - callback(nil) - end - end) - - return true + 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/util.lua b/lua/opencode/util.lua index dc4c2280..b586e5dc 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -90,19 +90,22 @@ function M.sanitize_lines(lines) return stripped_lines 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) From 269bad05dc62d7dedeb9a10f4baa792de6c57d24 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 7 Nov 2025 11:04:17 -0500 Subject: [PATCH 08/13] chore: use real separator to calc offset --- lua/opencode/ui/navigation.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index 41595c70..9b0acd27 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -34,7 +34,7 @@ function M.goto_message_by_id(message_id) if not rendered_msg or not rendered_msg.line_start then return end - local sep_offset = 2 + local sep_offset = #require('opencode.ui.formatter').separator vim.api.nvim_win_set_cursor(win, { rendered_msg.line_start + sep_offset, 0 }) end From 6f68fdeafcd72172b2cead8fb60617328e70d572 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 7 Nov 2025 11:39:19 -0500 Subject: [PATCH 09/13] test(format_time): adjust tests for shorter date without year --- tests/unit/util_spec.lua | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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) From b7fd9ef38a8cc44d2120a758af87d01302785af4 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 7 Nov 2025 11:47:32 -0500 Subject: [PATCH 10/13] fix(mini_pick): added padding to mini.pick --- lua/opencode/ui/base_picker.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index cf4a0092..e9217ed2 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -245,7 +245,11 @@ local function mini_pick_ui(opts) end mini_pick.start({ - window = opts.width and { config = { width = opts.width } } or nil, + window = opts.width and { + config = { + width = opts.width + 2, -- extra space for mini.pick UI + }, + } or nil, source = { items = items, name = opts.title, From 80d2467326cd328ea720de1a9e5c2912725e48f0 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 7 Nov 2025 14:01:14 -0500 Subject: [PATCH 11/13] fix(snacks): width not respected by picker config --- lua/opencode/ui/base_picker.lua | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index e9217ed2..2900777b 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -245,11 +245,13 @@ local function mini_pick_ui(opts) end mini_pick.start({ - window = opts.width and { - config = { - width = opts.width + 2, -- extra space for mini.pick UI - }, - } or nil, + window = opts.width + and { + config = { + width = opts.width + 2, -- extra space for mini.pick UI + }, + } + or nil, source = { items = items, name = opts.title, @@ -271,7 +273,16 @@ local function snacks_picker_ui(opts) local snack_opts = { title = opts.title, - layout = { preset = 'select', width = opts.width or nil }, + 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, From d4a006c0aa3bc3fac22b16299ba289467170bb00 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Fri, 7 Nov 2025 12:25:55 -0800 Subject: [PATCH 12/13] fix(base_picker): snacks, set item.text for matching --- lua/opencode/ui/base_picker.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 2900777b..05c0117b 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -286,6 +286,13 @@ local function snacks_picker_ui(opts) 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, From 615d0d35509a2095e403164973b42e62c8575266 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Fri, 7 Nov 2025 12:26:17 -0800 Subject: [PATCH 13/13] fix(base_picker): use actual time for width --- lua/opencode/ui/base_picker.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 05c0117b..d23ada01 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -360,7 +360,7 @@ end ---@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(0) + 1 or 0 -- longest time format by using 0 + 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)