diff --git a/README.md b/README.md index d10a6f02..8f4f17ca 100644 --- a/README.md +++ b/README.md @@ -206,10 +206,6 @@ require('opencode').setup({ undo = { '', mode = { 'i', 'n' } }, -- Undo to selected message in timeline picker fork = { '', mode = { 'i', 'n' } }, -- Fork from selected message in timeline picker }, - history_picker = { - delete_entry = { '', mode = { 'i', 'n' } }, -- Delete selected entry in the history picker - clear_all = { '', mode = { 'i', 'n' } }, -- Clear all entries in the history picker - }, model_picker = { toggle_favorite = { '', mode = { 'i', 'n' } }, }, diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index a2150c1c..b6aed2ca 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -418,12 +418,33 @@ function M.actions.undo(messageId) return end - run_api_action_with_checktime( - state_obj.api_client:revert_message(state_obj.active_session.id, { - messageID = message_to_revert, - }), - 'Failed to undo last message: ' - ) + -- Snapshot the message now; state may drop it once the revert lands. + local refill_message = nil + for _, msg in ipairs(state_obj.messages or {}) do + if msg.info and msg.info.id == message_to_revert then + refill_message = msg + break + end + end + if not refill_message and state_obj.last_user_message and state_obj.last_user_message.info.id == message_to_revert then + refill_message = state_obj.last_user_message + end + + local revert_promise = state_obj.api_client:revert_message(state_obj.active_session.id, { + messageID = message_to_revert, + }) + + revert_promise:and_then(function() + schedule_checktime() + if refill_message then + local input_window = require('opencode.ui.input_window') + if input_window.refill_prompt_from_message(refill_message) then + input_window.focus_input() + end + end + end):catch(function(err) + notify_error('Failed to undo last message: ', err) + end) end) end @@ -515,12 +536,13 @@ function M.actions.timeline() 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) + require('opencode.ui.timeline_picker').pick(user_messages, { + callback = function(selected_msg) + if selected_msg then + require('opencode.ui.navigation').goto_message_by_id(selected_msg.info.id) + end + end, + }) end ---@param message_id? string diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index 576bdb85..b9ba6e61 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -29,15 +29,6 @@ local function get_active_session_or_warn(message) return active_session end ----@param command string ----@param args string[]|nil -local function schedule_slash_history(command, args) - local joined_args = args and table.concat(args, ' ') or '' - vim.schedule(function() - history.write('/' .. command .. ' ' .. joined_args) - end) -end - ---@param args string[]|nil ---@return string local function join_args(args) @@ -115,18 +106,66 @@ function M.actions.quick_chat(message, range) quick_chat.quick_chat(prompt, { context_config = ctx }, range) end +---Refill the input buffer from a user message returned by the timeline picker. +---Open the opencode UI first when the input window is not mounted yet. +---@param message OpencodeMessage +local function refill_input_from_message(message) + local windows = state.windows + if not input_window.mounted(windows) then + session_runtime.open({ focus = 'input' }) + windows = state.windows + end + if not input_window.mounted(windows) then + return + end + ---@cast windows { input_win: integer, input_buf: integer } + if input_window.refill_prompt_from_message(message) then + input_window.focus_input() + end +end + +---@param entries OpencodeHistoryEntry[] +local function pick_history_with(entries) + local messages = vim.tbl_map(function(entry) + return entry.message + end, entries) + require('opencode.ui.timeline_picker').pick(messages, { + title = 'Select History Entry', + callback = function(selected_msg) + if selected_msg then + refill_input_from_message(selected_msg) + end + end, + }) +end + function M.actions.select_history() - require('opencode.ui.history_picker').pick() + local entries = history.read() + if #entries == 0 then + vim.notify('No history entries found', vim.log.levels.INFO) + return false + end + return pick_history_with(entries) end ----@param prompt string|nil -local function restore_prompt_history_entry(prompt) - if not prompt then +---Refill the input buffer from a history entry reconstructed from the active +---session's user messages +---@param entry OpencodeHistoryEntry +local function restore_prompt_history_entry(entry) + local windows = state.windows + if not windows or not windows.input_buf or not input_window.mounted(windows) then return end + if input_window.refill_prompt_from_message(entry.message) then + input_window.focus_input() + end +end - input_window.set_content(prompt) - require('opencode.ui.mention').restore_mentions(state.windows.input_buf) +---@param draft string[] +local function restore_prompt_draft(draft) + if input_window.replace_input(draft) then + input_window.focus_input() + end end function M.actions.prev_history() @@ -134,8 +173,10 @@ function M.actions.prev_history() return end - local prev_prompt = history.prev() - restore_prompt_history_entry(prev_prompt) + local entry = history.prev() + if entry then + restore_prompt_history_entry(entry) + end end function M.actions.next_history() @@ -143,8 +184,12 @@ function M.actions.next_history() return end - local next_prompt = history.next() - restore_prompt_history_entry(next_prompt) + local result = history.next() + if result and result.message then + restore_prompt_history_entry(result) + elseif result then + restore_prompt_draft(result) + end end function M.actions.prev_prompt_history() @@ -256,16 +301,12 @@ M.actions.run_user_command = Promise.async(function(name, args) return end - state.api_client - :send_command(active_session.id, { - command = name, - arguments = join_args(args), - model = model, - agent = agent, - }) - :and_then(function() - schedule_slash_history(name, args) - end) + state.api_client:send_command(active_session.id, { + command = name, + arguments = join_args(args), + model = model, + agent = agent, + }) end) --[[@as Promise ]] end) @@ -355,15 +396,11 @@ M.actions.review = Promise.async(function(args) state.session.set_active(new_session) window_handler.actions.open_input():await() - state.api_client - :send_command(state.active_session.id, { - command = 'review', - arguments = join_args(args), - model = state.current_model, - }) - :and_then(function() - schedule_slash_history('review', args) - end) + state.api_client:send_command(state.active_session.id, { + command = 'review', + arguments = join_args(args), + model = state.current_model, + }) end) M.actions.add_visual_selection = Promise.async( diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index f7b181c2..2e7f42ae 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -120,10 +120,6 @@ M.defaults = { undo = { '', mode = { 'i', 'n' }, desc = 'Undo to selected message' }, fork = { '', mode = { 'i', 'n' }, desc = 'Fork from selected message' }, }, - history_picker = { - delete_entry = { '', mode = { 'i', 'n' }, desc = 'Delete selected history entries' }, - clear_all = { '', mode = { 'i', 'n' }, desc = 'Clear all history entries' }, - }, model_picker = { toggle_favorite = { '', mode = { 'i', 'n' }, desc = 'Toggle model favorite' }, }, diff --git a/lua/opencode/history.lua b/lua/opencode/history.lua index a31789aa..c348a4a1 100644 --- a/lua/opencode/history.lua +++ b/lua/opencode/history.lua @@ -1,158 +1,145 @@ +local state = require('opencode.state') +local input_window = require('opencode.ui.input_window') + +---@class OpencodeHistoryEntry +---@field id string Message ID from the server +---@field message OpencodeMessage Full message; lets callers rebuild the prompt +---@field prompt { lines: string[], mention_paths: string[] } Reconstructed prompt + local M = {} -local cached_history = nil +M.index = nil + +-- Track which session the current ring index is bound to so navigating +-- across sessions always restarts from the newest entry. +local cached_session_id = nil + +-- The user's draft at the time they first navigate backwards through history. +-- Restored when they navigate past the newest entry again. local prompt_before_history = nil -M.index = nil +local function active_session_id() + return state.active_session and state.active_session.id +end -local function get_history_file() - local data_dir = vim.fn.stdpath('data') .. '/opencode' - if vim.fn.isdirectory(data_dir) ~= 1 then - vim.fn.mkdir(data_dir, 'p') +local function maybe_reset_for_new_session() + local session_id = active_session_id() + if session_id ~= cached_session_id then + M.index = nil + prompt_before_history = nil + cached_session_id = session_id end - return data_dir .. '/history.txt' end -M.write = function(prompt) - local history = M.read() - if #history > 0 and history[1] == prompt then - return +---@param entry OpencodeHistoryEntry +local function entry_has_content(entry) + local prompt = entry.prompt + if not prompt then + return false end - - local file = io.open(get_history_file(), 'a') - if file then - -- Escape any newlines in the prompt - local escaped_prompt = prompt:gsub('\n', '\\n') - file:write(escaped_prompt .. '\n') - file:close() - -- Invalidate cache when writing new history - cached_history = nil + if prompt.mention_paths and #prompt.mention_paths > 0 then + return true + end + local lines = prompt.lines + if not lines or #lines == 0 then + return false + end + if #lines > 1 then + return true end + return lines[1] ~= nil and lines[1] ~= '' end -M.read = function() - -- Return cached result if available - if cached_history then - return cached_history +--- Read user-message prompt history for the active session, newest first. +--- Pure read against `state.messages`; if no entry has been populated yet +--- (e.g. right after switching sessions before SSE catches up) the result is +--- an empty list. Populating `state.messages` is the renderer's job. +---@return OpencodeHistoryEntry[] +function M.read() + maybe_reset_for_new_session() + local active_id = active_session_id() + if not active_id then + return {} end - - local line_by_index = {} - local file = io.open(get_history_file(), 'r') - - if file then - local lines = {} - - -- Read all non-empty lines - for line in file:lines() do - if line:gsub('%s', '') ~= '' then - -- Unescape any escaped newlines - local unescaped_line = line:gsub('\\n', '\n') - table.insert(lines, unescaped_line) + local messages = state.messages or {} + local collected = {} + for _, msg in ipairs(messages) do + if msg.info and msg.info.role == 'user' and msg.info.sessionID == active_id then + local prompt = input_window.build_prompt_from_message(msg) + if prompt then + local entry = { id = msg.info.id, message = msg, prompt = prompt } ---@type OpencodeHistoryEntry + if entry_has_content(entry) then + table.insert(collected, entry) + end end end - file:close() - - -- Reverse the array to have index 1 = most recent - for i = 1, #lines do - line_by_index[i] = lines[#lines - i + 1] - end end - -- Cache the result - cached_history = line_by_index - return line_by_index + -- Reverse so the newest user message is at index 1; prev() walks 1 -> N. + local reversed = {} + for i = 1, #collected do + reversed[i] = collected[#collected - i + 1] + end + return reversed end -M.prev = function() - local history = M.read() +--- Walk one step backwards through history. The first call captures the +--- current input draft so the next forward navigation can restore it. +---@return OpencodeHistoryEntry? +function M.prev() + local entries = M.read() + if #entries == 0 then + return nil + end if not M.index or M.index == 0 then - prompt_before_history = require('opencode.state').input_content + prompt_before_history = state.input_content end - -- Initialize or increment index M.index = (M.index or 0) + 1 - - -- Cap at the end of history - if M.index > #history then - M.index = #history + if M.index > #entries then + M.index = #entries end - return history[M.index] + return entries[M.index] end -M.next = function() - -- Return nil for invalid cases +--- Walk one step forwards through history. +--- +--- Returns one of three shapes depending on where we are in the ring: +--- * `OpencodeHistoryEntry` while still walking through user messages +--- * `string[]` once we cross past the newest entry; this is the +--- pre-history draft captured on the first `prev()` call +--- * `nil` when no history navigation is in progress +---@return (OpencodeHistoryEntry|string[])? +function M.next() if not M.index then return nil end - + local entries = M.read() if M.index <= 1 then M.index = nil return prompt_before_history end M.index = M.index - 1 - return M.read()[M.index] -end - ----Delete specific entries from history by their indices ----@param indices number[] Array of 1-based indices to delete -M.delete = function(indices) - if not indices or #indices == 0 then - return false - end - - local history = M.read() - if #history == 0 then - return false - end - - -- Sort indices in descending order to avoid index shifting issues - local sorted_indices = {} - for _, idx in ipairs(indices) do - if idx > 0 and idx <= #history then - table.insert(sorted_indices, idx) - end - end - table.sort(sorted_indices, function(a, b) - return a > b - end) - - for _, idx in ipairs(sorted_indices) do - table.remove(history, idx) - end - - return M._write_history(history) + return entries[M.index] end ----Clear all history entries -M.clear = function() - return M._write_history({}) +--- Forget the navigation cursor. Call this when the user sends a new prompt +--- or the active session changes. +function M.reset() + M.index = nil + prompt_before_history = nil end ----Internal function to write history array to file ----@param history_array table Array of history entries to write ----@return boolean success Whether the write operation succeeded -M._write_history = function(history_array) - local file = io.open(get_history_file(), 'w') - if not file then - return false +-- Reset on session changes too. Subscribers react synchronously, so the next +-- read() call sees the new active_session and clears state via +-- maybe_reset_for_new_session. +state.store.subscribe('active_session', function(_, new_session) + if new_session and new_session.id ~= cached_session_id then + M.reset() end - - for i = #history_array, 1, -1 do - local entry = history_array[i] - if entry and entry ~= '' then - local escaped_entry = entry:gsub('\n', '\\n') - file:write(escaped_entry .. '\n') - end - end - - file:close() - - cached_history = nil - - return true -end +end) return M diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index ac791ebc..f7de7c7b 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -86,7 +86,7 @@ M.send_message = Promise.async(function(prompt, opts) return end - M.after_run(prompt) + M.after_run() end) :catch(function(err) log.notify('Error sending message to session: ' .. vim.inspect(err), vim.log.levels.ERROR) @@ -96,12 +96,10 @@ M.send_message = Promise.async(function(prompt, opts) :await() end) ----@param prompt string -function M.after_run(prompt) +function M.after_run() context.unload_attachments() state.session.set_last_sent_context(vim.deepcopy(context.get_context())) context.delta_context() - require('opencode.history').write(prompt) vim.g.opencode_abort_count = 0 end diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index f5c91334..56592210 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -4,7 +4,7 @@ local M = {} ---@class OpencodeStateData ---@field windows OpencodeWindowState|nil ---@field is_opening boolean ----@field input_content table +---@field input_content string[] ---@field is_opencode_focused boolean ---@field last_focused_opencode_window string|nil ---@field last_input_window_position integer[]|nil diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index 16831d85..6f787bd4 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -102,7 +102,7 @@ function M.clear_last_window_width_ratio() return store.set('last_window_width_ratio', nil) end ----@param lines table|nil +---@param lines string[] function M.set_input_content(lines) return store.set('input_content', lines) end diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index b5824064..fde49e9b 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -162,7 +162,6 @@ ---@field output_window OpencodeKeymapOutputWindow ---@field session_picker OpencodeSessionPickerKeymap ---@field timeline_picker OpencodeTimelinePickerKeymap ----@field history_picker OpencodeHistoryPickerKeymap ---@field quick_chat OpencodeQuickChatKeymap ---@class OpencodeSessionPickerKeymap @@ -176,10 +175,6 @@ ---@field undo OpencodeKeymapEntry ---@field fork OpencodeKeymapEntry ----@class OpencodeHistoryPickerKeymap ----@field delete_entry OpencodeKeymapEntry ----@field clear_all OpencodeKeymapEntry - ---@class OpencodeQuickChatKeymap ---@field cancel OpencodeKeymapEntry diff --git a/lua/opencode/ui/history_picker.lua b/lua/opencode/ui/history_picker.lua deleted file mode 100644 index d82811c7..00000000 --- a/lua/opencode/ui/history_picker.lua +++ /dev/null @@ -1,112 +0,0 @@ -local M = {} -local config = require('opencode.config') -local base_picker = require('opencode.ui.base_picker') -local util = require('opencode.util') -local history = require('opencode.history') - ----Format history entries for history picker ----@param item table History item with text content ----@param width number Picker width ----@return PickerItem -local function format_history_item(item, width) - local entry = item.content or item.text or '' - - return base_picker.create_time_picker_item(entry:gsub('\n', '↵'), nil, 'ID: ' .. item.id, width) -end - -function M.pick(callback) - local history_entries = history.read() - - if #history_entries == 0 then - vim.notify('No history entries found', vim.log.levels.INFO) - return false - end - - local history_items = {} - for i, entry in ipairs(history_entries) do - table.insert(history_items, { id = i, text = entry, content = entry }) - end - - local actions = { - delete = { - key = config.keymap.history_picker.delete_entry, - label = 'delete', - multi_selection = true, - fn = function(selected, opts) - local entries_to_delete = type(selected) == 'table' and selected.id == nil and selected or { selected } - - local indices_to_remove = {} - for _, entry_to_delete in ipairs(entries_to_delete) do - local idx = util.find_index_of(opts.items, function(item) - return item.id == entry_to_delete.id - end) - if idx > 0 then - table.insert(indices_to_remove, idx) - end - end - - table.sort(indices_to_remove, function(a, b) - return a > b - end) - - local success = history.delete(indices_to_remove) - if success then - for _, idx in ipairs(indices_to_remove) do - table.remove(opts.items, idx) - end - vim.notify('Deleted ' .. #entries_to_delete .. ' history entry(s)', vim.log.levels.INFO) - else - vim.notify('Failed to delete history entries', vim.log.levels.ERROR) - end - - return opts.items - end, - reload = true, - }, - clear_all = { - key = config.keymap.history_picker.clear_all, - label = 'clear all', - fn = function(_, opts) - local success = history.clear() - if success then - opts.items = {} - vim.notify('Cleared all history entries', vim.log.levels.INFO) - else - vim.notify('Failed to clear history entries', vim.log.levels.ERROR) - end - - return opts.items - end, - reload = true, - }, - } - - return base_picker.pick({ - items = history_items, - format_fn = format_history_item, - actions = actions, - callback = function(selected_item) - if selected_item and callback then - callback(selected_item.content or selected_item.text) - elseif selected_item then - local input_window = require('opencode.ui.input_window') - local state = require('opencode.state') - local windows = state.windows - if not input_window.mounted(windows) then - require('opencode.services.session_runtime').open({ focus = 'input' }) - windows = state.windows - end - ---@cast windows { input_win: integer, input_buf: integer } - - input_window.set_content(selected_item.content or selected_item.text) - require('opencode.ui.mention').restore_mentions(windows.input_buf) - input_window.focus_input() - end - end, - title = 'Select History Entry', - width = config.ui.picker_width, - layout_opts = config.ui.picker, - }) -end - -return M diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index c9c63937..e53b081f 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -426,6 +426,84 @@ function M.set_content(text, windows) vim.api.nvim_buf_set_lines(windows.input_buf, 0, -1, false, lines) end +---@param message OpencodeMessage|nil +---@return { lines: string[], mention_paths: string[] }|nil +function M.build_prompt_from_message(message) + if not message or not message.parts then + return nil + end + + local lines = {} + local mention_paths = {} + + for _, part in ipairs(message.parts) do + if type(part) == 'table' then + if part.type == 'text' then + if not part.synthetic and type(part.text) == 'string' and part.text ~= '' then + for _, sub in ipairs(vim.split(part.text, '\n', { plain = true })) do + lines[#lines + 1] = sub + end + end + elseif part.type == 'file' then + local name = part.filename or (part.source and part.source.path) or part.name + if type(name) == 'string' and name ~= '' then + lines[#lines + 1] = '@' .. name .. ' ' + table.insert(mention_paths, name) + end + elseif part.type == 'agent' then + local name = part.name or (part.source and part.source.path) + if type(name) == 'string' and name ~= '' then + lines[#lines + 1] = '@' .. name .. ' ' + table.insert(mention_paths, name) + end + end + end + end + + if #lines == 0 then + return nil + end + return { lines = lines, mention_paths = mention_paths } +end + +---Replace the input buffer content with `lines`, restore mention extmarks +---and park the cursor at the end. Callers that want the user to start typing +---immediately should follow up with `focus_input`. Empty input is written +---as-is so callers restoring a previously-captured state can clear it. +---@param lines string[] +---@param windows OpencodeWindowState|nil +---@return boolean mounted True if the input window was mounted and the +---buffer was rewritten. +function M.replace_input(lines, windows) + windows = windows or state.windows + if not M.mounted(windows) then + return false + end + ---@cast windows { input_win: integer, input_buf: integer } + + M.set_content(lines, windows) + require('opencode.ui.mention').restore_mentions(windows.input_buf) + + -- nvim_win_set_cursor clamps col to the line's last byte, which is the + -- canonical end-of-line position both for 'a' (append) and 'i' (insert). + pcall(vim.api.nvim_win_set_cursor, windows.input_win, { + math.max(#lines, 1), + #(lines[#lines] or ''), + }) + + return true +end + +---@param message OpencodeMessage|nil +---@return boolean filled True when the input was populated with content. +function M.refill_prompt_from_message(message) + local prompt = M.build_prompt_from_message(message) + if not prompt then + return false + end + return M.replace_input(prompt.lines) +end + function M.set_current_line(text, windows) windows = windows or state.windows if not M.mounted(windows) then diff --git a/lua/opencode/ui/timeline_picker.lua b/lua/opencode/ui/timeline_picker.lua index 8d227984..394cbf47 100644 --- a/lua/opencode/ui/timeline_picker.lua +++ b/lua/opencode/ui/timeline_picker.lua @@ -3,8 +3,13 @@ local config = require('opencode.config') local api = require('opencode.api') local base_picker = require('opencode.ui.base_picker') +---@class OpencodeTimelinePickerOpts +---@field title? string Picker title (defaults to 'Timeline') +---@field callback fun(msg: OpencodeMessage|nil) Invoked with the selected message + ---Format message parts for timeline picker ---@param msg OpencodeMessage Message object +---@param width number ---@return PickerItem local function format_message_item(msg, width) local preview = msg.parts and msg.parts[1] and msg.parts[1].text or '' @@ -14,13 +19,18 @@ local function format_message_item(msg, width) return base_picker.create_time_picker_item(vim.trim(preview), msg.info.time.created, debug_text, width) end -function M.pick(messages, callback) +---Open a picker over the given user messages. The shared undo/fork actions +---always operate on the selected message via the server APIs; the caller +---decides what to do on plain selection by passing `opts.callback`. +---@param messages OpencodeMessage[] +---@param opts OpencodeTimelinePickerOpts +function M.pick(messages, opts) local keymap = config.keymap.timeline_picker local actions = { undo = { key = keymap.undo, label = 'undo', - fn = function(selected, opts) + fn = function(selected, _opts) api.undo(selected.info.id) end, reload = false, @@ -28,7 +38,7 @@ function M.pick(messages, callback) fork = { key = keymap.fork, label = 'fork', - fn = function(selected, opts) + fn = function(selected, _opts) api.fork_session(selected.info.id) end, reload = false, @@ -39,8 +49,8 @@ function M.pick(messages, callback) items = messages, format_fn = format_message_item, actions = actions, - callback = callback, - title = 'Timeline', + callback = opts.callback, + title = opts.title or 'Timeline', width = config.ui.picker_width, layout_opts = config.ui.picker, }) diff --git a/scripts/dependency-topology/topology.jsonc b/scripts/dependency-topology/topology.jsonc index 5819f33c..fa875af9 100644 --- a/scripts/dependency-topology/topology.jsonc +++ b/scripts/dependency-topology/topology.jsonc @@ -132,7 +132,6 @@ "opencode.ui.mention", // @mention UI "opencode.ui.file_picker", // file browser "opencode.ui.picker", // generic picker - "opencode.ui.history_picker", // history browser "opencode.ui.mcp_picker", // MCP tool browser "opencode.ui.permission.permission" // permission display ] diff --git a/tests/unit/history_spec.lua b/tests/unit/history_spec.lua new file mode 100644 index 00000000..47e0cd2d --- /dev/null +++ b/tests/unit/history_spec.lua @@ -0,0 +1,173 @@ +local history = require('opencode.history') +local state = require('opencode.state') + +local function make_message(id, parts, opts) + opts = opts or {} + return { + info = { + id = id, + sessionID = opts.sessionID or 'ses_1', + role = opts.role or 'user', + time = { created = opts.created or 0 }, + }, + parts = parts, + } +end + +local function set_messages(messages, session_id) + local sid = session_id or 'ses_1' + state.session.set_active({ id = sid }) + for _, m in ipairs(messages) do + m.info = m.info or {} + m.info.sessionID = m.info.sessionID or sid + end + state.renderer.set_messages(messages) +end + +describe('opencode.history', function() + before_each(function() + history.reset() + state.renderer.set_messages(nil) + state.session.set_active({ id = 'ses_1' }) + end) + + it('returns an empty list when there is no active session', function() + state.session.set_active(nil) + state.renderer.set_messages({ make_message('m1', { { type = 'text', text = 'hi' } }) }) + assert.same({}, history.read()) + end) + + it('returns user messages newest-first', function() + set_messages({ + make_message('m_old', { { type = 'text', text = 'first' } }, { created = 1 }), + make_message('m_new', { { type = 'text', text = 'second' } }, { created = 2 }), + }) + local entries = history.read() + assert.equals(2, #entries) + assert.equals('m_new', entries[1].id) + assert.equals('m_old', entries[2].id) + end) + + it('skips assistant messages and synthetic-only user messages', function() + set_messages({ + make_message('m_user', { { type = 'text', text = 'real' } }), + make_message('m_synth', { { type = 'text', synthetic = true, text = 'skip me' } }, { created = 2 }), + make_message('m_asst', { { type = 'text', text = 'reply' } }, { role = 'assistant', created = 3 }), + }) + local entries = history.read() + assert.equals(1, #entries) + assert.equals('m_user', entries[1].id) + end) + + it('skips user messages that have no real content', function() + set_messages({ + make_message('m_blank', { { type = 'text', synthetic = true } }), + make_message('m_real', { { type = 'text', text = 'keep' } }, { created = 2 }), + }) + local entries = history.read() + assert.equals(1, #entries) + assert.equals('m_real', entries[1].id) + end) + + it('preserves file and agent mentions in the reconstructed prompt', function() + set_messages({ + make_message('m1', { + { type = 'text', text = 'look at' }, + { type = 'file', filename = 'lua/foo.lua' }, + { type = 'agent', name = 'build' }, + }), + }) + local entries = history.read() + assert.equals(1, #entries) + assert.same({ 'look at', '@lua/foo.lua ', '@build ' }, entries[1].prompt.lines) + assert.same({ 'lua/foo.lua', 'build' }, entries[1].prompt.mention_paths) + end) + + it('prev walks newest -> oldest and captures the draft on first call', function() + set_messages({ + make_message('m_old', { { type = 'text', text = 'first' } }, { created = 1 }), + make_message('m_new', { { type = 'text', text = 'second' } }, { created = 2 }), + }) + state.ui.set_input_content({ 'my draft' }) + + local first = history.prev() + assert.is_not_nil(first) + assert.equals('m_new', first.id) + + local second = history.prev() + assert.equals('m_old', second.id) + + -- Third call caps at the oldest entry. + local third = history.prev() + assert.equals('m_old', third.id) + end) + + it('next returns the captured draft once it walks past the newest entry', function() + set_messages({ + make_message('m_new', { { type = 'text', text = 'second' } }, { created = 2 }), + }) + state.ui.set_input_content({ 'my draft' }) + + history.prev() + local back = history.next() + assert.same({ 'my draft' }, back) + end) + + it('next returns the entry table while still inside the ring and the draft lines past the newest', function() + set_messages({ + make_message('m_old', { { type = 'text', text = 'first' } }, { created = 1 }), + make_message('m_new', { { type = 'text', text = 'second' } }, { created = 2 }), + }) + state.ui.set_input_content({ 'draft' }) + + -- Walk back to the oldest first so a single forward step lands on an + -- entry (index 2 -> index 1) and a second forward step crosses past + -- the newest (index 1 -> draft). + history.prev() -- index 1, returned m_new + history.prev() -- index 2, returned m_old + + local entry_step = history.next() + assert.is_table(entry_step) + assert.is_not_nil(entry_step.message) + assert.equals('m_new', entry_step.id) + + local draft_step = history.next() + assert.is_table(draft_step) + assert.is_nil(draft_step.message) + assert.same({ 'draft' }, draft_step) + end) + + it('reset clears the ring cursor and captured draft', function() + set_messages({ + make_message('m1', { { type = 'text', text = 'hi' } }), + }) + state.ui.set_input_content({ 'draft' }) + history.prev() + history.reset() + -- After reset the next forward navigation has nothing to restore and the + -- backward navigation restarts from the newest entry, not from where we + -- left off. + assert.is_nil(history.next()) + local entry = history.prev() + assert.is_not_nil(entry) + assert.equals('m1', entry.id) + end) + + it('reset triggers when the active session changes', function() + set_messages({ + make_message('m1', { { type = 'text', text = 'a' } }, { sessionID = 'ses_1' }), + }, 'ses_1') + state.ui.set_input_content({ 'draft' }) + history.prev() + + -- Switch session: index should reset, so prev() returns the newest entry + -- of the new session (not continue from the old index). + set_messages({ + make_message('m2', { { type = 'text', text = 'b' } }, { sessionID = 'ses_2' }), + }, 'ses_2') + + local entry = history.prev() + assert.is_not_nil(entry) + assert.equals('m2', entry.id) + end) +end) diff --git a/tests/unit/input_window_spec.lua b/tests/unit/input_window_spec.lua index 7443d198..9a57bc27 100644 --- a/tests/unit/input_window_spec.lua +++ b/tests/unit/input_window_spec.lua @@ -589,4 +589,208 @@ describe('input_window', function() config.values.child_readonly = orig_readonly end) end) + + local function make_message(parts) + return { + info = { id = 'msg_1', sessionID = 'ses_1', role = 'user' }, + parts = parts, + } + end + + describe('build_prompt_from_message', function() + it('returns nil when given a nil message', function() + assert.is_nil(input_window.build_prompt_from_message(nil)) + end) + + it('returns nil when the message has no parts', function() + assert.is_nil(input_window.build_prompt_from_message(make_message({}))) + end) + + it('emits the raw text from a single non-synthetic text part', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', text = 'hello world' }, + })) + assert.same({ 'hello world' }, prompt.lines) + assert.same({}, prompt.mention_paths) + end) + + it('skips synthetic text parts', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', synthetic = true, text = 'should be dropped' }, + { type = 'text', text = 'keep me' }, + })) + assert.same({ 'keep me' }, prompt.lines) + end) + + it('emits @ tokens for file parts using filename', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', text = 'look at' }, + { type = 'file', filename = 'lua/opencode/foo.lua' }, + { type = 'text', text = 'thanks' }, + })) + assert.same({ 'look at', '@lua/opencode/foo.lua ', 'thanks' }, prompt.lines) + assert.same({ 'lua/opencode/foo.lua' }, prompt.mention_paths) + end) + + it('falls back to source.path when filename is missing', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'file', source = { path = 'src/main.lua' } }, + })) + assert.same({ '@src/main.lua ' }, prompt.lines) + assert.same({ 'src/main.lua' }, prompt.mention_paths) + end) + + it('emits @ tokens for agent parts', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', text = 'use' }, + { type = 'agent', name = 'build' }, + { type = 'text', text = 'to compile' }, + })) + assert.same({ 'use', '@build ', 'to compile' }, prompt.lines) + assert.same({ 'build' }, prompt.mention_paths) + end) + + it('skips tool, step-start, and patch parts', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', text = 'first' }, + { type = 'tool', text = 'should be dropped' }, + { type = 'step-start' }, + { type = 'patch', text = 'also dropped' }, + { type = 'text', text = 'last' }, + })) + assert.same({ 'first', 'last' }, prompt.lines) + assert.same({}, prompt.mention_paths) + end) + + it('splits text parts on embedded newlines into separate lines', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', text = 'line1\nline2' }, + })) + assert.same({ 'line1', 'line2' }, prompt.lines) + end) + + it('splits text parts on embedded newlines interleaved with mentions', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', text = 'before' }, + { type = 'file', filename = 'a.lua' }, + { type = 'text', text = 'middle\nmore' }, + { type = 'agent', name = 'build' }, + { type = 'text', text = 'after' }, + })) + assert.same({ + 'before', + '@a.lua ', + 'middle', + 'more', + '@build ', + 'after', + }, prompt.lines) + assert.same({ 'a.lua', 'build' }, prompt.mention_paths) + end) + + it('handles nil and non-string fields defensively', function() + local prompt = input_window.build_prompt_from_message(make_message({ + { type = 'text', text = nil }, + { type = 'text' }, + { type = 'text', text = 'safe' }, + { type = 'file', filename = nil }, + { type = 'agent', name = '' }, + })) + assert.same({ 'safe' }, prompt.lines) + assert.same({}, prompt.mention_paths) + end) + end) + + describe('refill_prompt_from_message', function() + local function open_input_window() + local input_buf = vim.api.nvim_create_buf(false, true) + local output_buf = vim.api.nvim_create_buf(false, true) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 80, + height = 5, + row = 0, + col = 0, + }) + local input_win = vim.api.nvim_open_win(input_buf, true, { + relative = 'editor', + width = 80, + height = 5, + row = 6, + col = 0, + }) + state.ui.set_windows({ + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + }) + vim.api.nvim_set_current_win(output_win) + input_window._hidden = false + return input_buf, input_win, output_buf, output_win + end + + local function cleanup(input_buf, input_win, output_buf, output_win) + pcall(vim.api.nvim_win_close, input_win, true) + pcall(vim.api.nvim_win_close, output_win, true) + pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) + state.ui.clear_windows() + end + + it('parks the cursor at the end of the refilled text', function() + local input_buf, input_win, output_buf, output_win = open_input_window() + local message = make_message({ + { type = 'text', text = 'refactor this' }, + }) + input_window.refill_prompt_from_message(message) + local lines = vim.api.nvim_buf_get_lines(input_buf, 0, -1, false) + local cursor = vim.api.nvim_win_get_cursor(input_win) + assert.equals(#lines, cursor[1]) + assert.equals(#lines[#lines] - 1, cursor[2]) + assert.same({ 'refactor this' }, lines) + cleanup(input_buf, input_win, output_buf, output_win) + end) + + it('parks the cursor on the last line of a multi-line refill', function() + local input_buf, input_win, output_buf, output_win = open_input_window() + local message = make_message({ + { type = 'text', text = 'line1' }, + { type = 'text', text = 'line2' }, + { type = 'text', text = 'line3' }, + }) + input_window.refill_prompt_from_message(message) + local lines = vim.api.nvim_buf_get_lines(input_buf, 0, -1, false) + local cursor = vim.api.nvim_win_get_cursor(input_win) + assert.equals(#lines, cursor[1]) + assert.equals(#lines[#lines] - 1, cursor[2]) + assert.same({ 'line1', 'line2', 'line3' }, lines) + cleanup(input_buf, input_win, output_buf, output_win) + end) + + it('parks the cursor after the mention token when a file is attached', function() + local input_buf, input_win, output_buf, output_win = open_input_window() + local message = make_message({ + { type = 'text', text = 'look at' }, + { type = 'file', filename = 'lua/opencode/foo.lua' }, + { type = 'text', text = 'thanks' }, + }) + input_window.refill_prompt_from_message(message) + local lines = vim.api.nvim_buf_get_lines(input_buf, 0, -1, false) + local cursor = vim.api.nvim_win_get_cursor(input_win) + assert.equals(#lines, cursor[1]) + assert.equals(#lines[#lines] - 1, cursor[2]) + assert.equals('thanks', lines[#lines]) + cleanup(input_buf, input_win, output_buf, output_win) + end) + + it('returns false and does not touch the buffer when there is nothing to refill', function() + local input_buf, input_win, output_buf, output_win = open_input_window() + vim.api.nvim_buf_set_lines(input_buf, 0, -1, false, { 'untouched' }) + local filled = input_window.refill_prompt_from_message(make_message({})) + assert.is_false(filled) + assert.same({ 'untouched' }, vim.api.nvim_buf_get_lines(input_buf, 0, -1, false)) + cleanup(input_buf, input_win, output_buf, output_win) + end) + end) end)