From 6b6bb2b9c730e73c1f7dc13c4d41d7f31ac8d2a6 Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:57:03 +0800 Subject: [PATCH 1/4] feat(session): refill prompt input after /undo Problem: /undo and the timeline picker undo action only sent the revert request to the opencode server but left the input buffer empty, so users had to retype the prompt to tweak it. Solution: add `build_prompt_from_message` and `refill_prompt_from_message` in `input_window.lua` to reconstruct prompt lines from a message parts (text / file / agent) while skipping synthetic text and non-input parts. `M.actions.undo` captures the full message before reverting and, after the server acknowledges the revert, refills the input buffer and parks the cursor at the end of the text so the user can keep typing. --- lua/opencode/commands/handlers/session.lua | 33 +++- lua/opencode/ui/input_window.lua | 66 +++++++ tests/unit/input_window_spec.lua | 208 +++++++++++++++++++++ 3 files changed, 301 insertions(+), 6 deletions(-) diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index a2150c1c..7c5f97a9 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 diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index c9c63937..8b662217 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -426,6 +426,72 @@ 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[] } +function M.build_prompt_from_message(message) + local result = { lines = { '' }, mention_paths = {} } + if not message or not message.parts then + return result + end + + 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 + result.lines[#result.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 + result.lines[#result.lines + 1] = '@' .. name .. ' ' + table.insert(result.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 + result.lines[#result.lines + 1] = '@' .. name .. ' ' + table.insert(result.mention_paths, name) + end + end + end + end + + if #result.lines > 1 and result.lines[1] == '' then + table.remove(result.lines, 1) + end + return result +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 #prompt.lines == 1 and prompt.lines[1] == '' then + return false + end + if not M.mounted() then + return false + end + ---@cast state.windows { input_win: integer, input_buf: integer } + + M.set_content(prompt.lines) + require('opencode.ui.mention').restore_mentions(state.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, state.windows.input_win, { + math.max(#prompt.lines, 1), + #(prompt.lines[#prompt.lines] or ''), + }) + + return true +end + function M.set_current_line(text, windows) windows = windows or state.windows if not M.mounted(windows) then diff --git a/tests/unit/input_window_spec.lua b/tests/unit/input_window_spec.lua index 7443d198..ba42b6ef 100644 --- a/tests/unit/input_window_spec.lua +++ b/tests/unit/input_window_spec.lua @@ -589,4 +589,212 @@ 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 an empty placeholder when given a nil message', function() + local prompt = input_window.build_prompt_from_message(nil) + assert.same({ '' }, prompt.lines) + assert.same({}, prompt.mention_paths) + end) + + it('returns an empty placeholder when the message has no parts', function() + local prompt = input_window.build_prompt_from_message(make_message({})) + assert.same({ '' }, prompt.lines) + assert.same({}, prompt.mention_paths) + 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) From ace1b88c5c6f396fe6e5c2c09ba33557409eab2b Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:57:03 +0800 Subject: [PATCH 2/4] refactor(history): derive prompt history from active session messages Problem: `opencode.history` kept a parallel file at `stdpath("data")/opencode/history.txt` shared across every Neovim instance. This made the prev/next arrow keys and the history picker leak prompts across projects and sessions, contradicting how the opencode TUI and App build prompt history from the current session messages. Solution: rewrite `history.lua` to read user messages from `state.messages` for the active session, filter them by `sessionID`, and reuse `input_window.build_prompt_from_message` so file and agent mentions restore exactly as on the original send. The picker drops its delete/clear actions (the server already owns the messages) and falls back to a `session.get_messages` API call when the SSE cache is empty. `prev_history` / `next_history` now restore the user draft when navigating past the newest entry. --- lua/opencode/commands/handlers/workflow.lua | 49 ++-- lua/opencode/history.lua | 241 +++++++++++--------- lua/opencode/services/messaging.lua | 6 +- lua/opencode/state/store.lua | 2 +- lua/opencode/state/ui.lua | 2 +- lua/opencode/ui/history_picker.lua | 148 +++++------- lua/opencode/ui/input_window.lua | 72 +++--- tests/unit/history_spec.lua | 173 ++++++++++++++ tests/unit/input_window_spec.lua | 12 +- 9 files changed, 437 insertions(+), 268 deletions(-) create mode 100644 tests/unit/history_spec.lua diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index 576bdb85..286b0b17 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) @@ -119,14 +110,24 @@ function M.actions.select_history() require('opencode.ui.history_picker').pick() 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 +135,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 +146,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() @@ -263,9 +270,6 @@ M.actions.run_user_command = Promise.async(function(name, args) model = model, agent = agent, }) - :and_then(function() - schedule_slash_history(name, args) - end) end) --[[@as Promise ]] end) @@ -361,9 +365,6 @@ M.actions.review = Promise.async(function(args) arguments = join_args(args), model = state.current_model, }) - :and_then(function() - schedule_slash_history('review', args) - end) end) M.actions.add_visual_selection = Promise.async( diff --git a/lua/opencode/history.lua b/lua/opencode/history.lua index a31789aa..1cdc4a99 100644 --- a/lua/opencode/history.lua +++ b/lua/opencode/history.lua @@ -1,158 +1,175 @@ +local state = require('opencode.state') +local session_module = require('opencode.session') +local input_window = require('opencode.ui.input_window') +local Promise = require('opencode.promise') + +---@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() + local session = state.active_session + return session and session.id or nil +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. +--- Empty list when there is no active session or no user messages yet. +---@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 +--- Force a fresh fetch from the server for the active session. Used by +--- callers (e.g. the history picker) that want guaranteed-fresh data and +--- don't want to wait for SSE to repopulate state.messages. +---@return Promise +function M.refresh() + local active = state.active_session + if not active or not active.id then + return Promise.new():resolve({}) end + return session_module + .get_messages(active) + :and_then(function(messages) + local entries = {} + for _, msg in ipairs(messages or {}) do + if msg.info and msg.info.role == 'user' then + local prompt = input_window.build_prompt_from_message(msg) + local entry = { id = msg.info.id, message = msg, prompt = prompt } + if entry_has_content(entry) then + table.insert(entries, entry) + end + end + end + return Promise.new():resolve(entries) + end) + :catch(function() + return Promise.new():resolve({}) + end) +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 +-- 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 - - 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/ui/history_picker.lua b/lua/opencode/ui/history_picker.lua index d82811c7..83939861 100644 --- a/lua/opencode/ui/history_picker.lua +++ b/lua/opencode/ui/history_picker.lua @@ -1,105 +1,77 @@ -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') +local state = require('opencode.state') ----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 '' +local M = {} - return base_picker.create_time_picker_item(entry:gsub('\n', '↵'), nil, 'ID: ' .. item.id, width) +local function preview_text(entry) + local lines = entry.prompt and entry.prompt.lines or {} + return table.concat(lines, '\n') 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 +---@param entry OpencodeHistoryEntry +---@param width number +---@return PickerItem +local function format_history_item(entry, width) + local preview = preview_text(entry):gsub('\n', '↵') + return base_picker.create_time_picker_item(preview, nil, 'ID: ' .. entry.id, width) +end - local history_items = {} - for i, entry in ipairs(history_entries) do - table.insert(history_items, { id = i, text = entry, content = entry }) +---Open the history picker for the active session. Reads from the in-memory +---message cache when it has data; otherwise fetches the active session's +---messages from the server so the picker is never empty just because SSE has +---not yet caught up. +---@param callback? fun(prompt: string[]) +function M.pick(callback) + local entries = history.read() + if #entries > 0 then + return M._render(entries, callback) 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, - }, - } + history + .refresh() + :and_then(function(entries) + if entries and #entries > 0 then + M._render(entries, callback) + else + vim.notify('No history entries found', vim.log.levels.INFO) + end + end) + :catch(function() + vim.notify('No history entries found', vim.log.levels.INFO) + end) + return true +end +---@param entries OpencodeHistoryEntry[] +---@param callback? fun(prompt: string[]) +function M._render(entries, callback) return base_picker.pick({ - items = history_items, + items = entries, 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 } + actions = {}, + callback = function(selected_entry) + if not selected_entry then + return + end + if callback then + callback(selected_entry.prompt.lines) + return + end + + local input_window = require('opencode.ui.input_window') + local windows = state.windows + if not input_window.mounted(windows) then + require('opencode.services.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 } - input_window.set_content(selected_item.content or selected_item.text) - require('opencode.ui.mention').restore_mentions(windows.input_buf) + if input_window.refill_prompt_from_message(selected_entry.message) then input_window.focus_input() end end, diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 8b662217..e53b081f 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -427,71 +427,83 @@ function M.set_content(text, windows) end ---@param message OpencodeMessage|nil ----@return { lines: string[], mention_paths: string[] } +---@return { lines: string[], mention_paths: string[] }|nil function M.build_prompt_from_message(message) - local result = { lines = { '' }, mention_paths = {} } if not message or not message.parts then - return result + 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 - result.lines[#result.lines + 1] = sub + 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 + local name = part.filename or (part.source and part.source.path) or part.name if type(name) == 'string' and name ~= '' then - result.lines[#result.lines + 1] = '@' .. name .. ' ' - table.insert(result.mention_paths, name) + 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) + local name = part.name or (part.source and part.source.path) if type(name) == 'string' and name ~= '' then - result.lines[#result.lines + 1] = '@' .. name .. ' ' - table.insert(result.mention_paths, name) + lines[#lines + 1] = '@' .. name .. ' ' + table.insert(mention_paths, name) end end end end - if #result.lines > 1 and result.lines[1] == '' then - table.remove(result.lines, 1) + if #lines == 0 then + return nil end - return result + return { lines = lines, mention_paths = mention_paths } 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 #prompt.lines == 1 and prompt.lines[1] == '' then - return false - end - if not M.mounted() then +---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 state.windows { input_win: integer, input_buf: integer } + ---@cast windows { input_win: integer, input_buf: integer } - M.set_content(prompt.lines) - require('opencode.ui.mention').restore_mentions(state.windows.input_buf) + 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, state.windows.input_win, { - math.max(#prompt.lines, 1), - #(prompt.lines[#prompt.lines] or ''), + 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/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 ba42b6ef..9a57bc27 100644 --- a/tests/unit/input_window_spec.lua +++ b/tests/unit/input_window_spec.lua @@ -598,16 +598,12 @@ describe('input_window', function() end describe('build_prompt_from_message', function() - it('returns an empty placeholder when given a nil message', function() - local prompt = input_window.build_prompt_from_message(nil) - assert.same({ '' }, prompt.lines) - assert.same({}, prompt.mention_paths) + it('returns nil when given a nil message', function() + assert.is_nil(input_window.build_prompt_from_message(nil)) end) - it('returns an empty placeholder when the message has no parts', function() - local prompt = input_window.build_prompt_from_message(make_message({})) - assert.same({ '' }, prompt.lines) - assert.same({}, prompt.mention_paths) + 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() From f4c9c04e4fb5512a9649dad6f2d8630440d71e9b Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:30:59 +0800 Subject: [PATCH 3/4] style: format --- lua/opencode/commands/handlers/workflow.lua | 24 ++++++++++----------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index 286b0b17..615d3812 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -263,13 +263,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, - }) + state.api_client:send_command(active_session.id, { + command = name, + arguments = join_args(args), + model = model, + agent = agent, + }) end) --[[@as Promise ]] end) @@ -359,12 +358,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, - }) + 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( From aec2d87f7456fc2c3700476d7fabcd908bdd24f0 Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:37:41 +0800 Subject: [PATCH 4/4] refactor(picker): merge history_picker into timeline_picker Problem: After dropping the local history.txt, history_picker and timeline_picker rendered the same data through the same base_picker with nearly identical format functions, only differing by title, time display, and the post-selection action. Solution: Extend timeline_picker.pick(messages, opts) with a { title, callback } shape so the caller picks what happens on selection. Move workflow.select_history to feed the timeline picker and refill the input in the callback, then delete history_picker along with its keymap, type, doc snippet, and topology reference. --- README.md | 4 - lua/opencode/commands/handlers/session.lua | 13 ++-- lua/opencode/commands/handlers/workflow.lua | 40 +++++++++- lua/opencode/config.lua | 4 - lua/opencode/history.lua | 38 +--------- lua/opencode/types.lua | 5 -- lua/opencode/ui/history_picker.lua | 84 --------------------- lua/opencode/ui/timeline_picker.lua | 20 +++-- scripts/dependency-topology/topology.jsonc | 1 - 9 files changed, 65 insertions(+), 144 deletions(-) delete mode 100644 lua/opencode/ui/history_picker.lua 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 7c5f97a9..b6aed2ca 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -536,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 615d3812..b9ba6e61 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -106,8 +106,46 @@ 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 ---Refill the input buffer from a history entry reconstructed from the active 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 1cdc4a99..c348a4a1 100644 --- a/lua/opencode/history.lua +++ b/lua/opencode/history.lua @@ -1,7 +1,5 @@ local state = require('opencode.state') -local session_module = require('opencode.session') local input_window = require('opencode.ui.input_window') -local Promise = require('opencode.promise') ---@class OpencodeHistoryEntry ---@field id string Message ID from the server @@ -21,8 +19,7 @@ local cached_session_id = nil local prompt_before_history = nil local function active_session_id() - local session = state.active_session - return session and session.id or nil + return state.active_session and state.active_session.id end local function maybe_reset_for_new_session() @@ -54,7 +51,9 @@ local function entry_has_content(entry) end --- Read user-message prompt history for the active session, newest first. ---- Empty list when there is no active session or no user messages yet. +--- 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() @@ -134,35 +133,6 @@ function M.reset() prompt_before_history = nil end ---- Force a fresh fetch from the server for the active session. Used by ---- callers (e.g. the history picker) that want guaranteed-fresh data and ---- don't want to wait for SSE to repopulate state.messages. ----@return Promise -function M.refresh() - local active = state.active_session - if not active or not active.id then - return Promise.new():resolve({}) - end - return session_module - .get_messages(active) - :and_then(function(messages) - local entries = {} - for _, msg in ipairs(messages or {}) do - if msg.info and msg.info.role == 'user' then - local prompt = input_window.build_prompt_from_message(msg) - local entry = { id = msg.info.id, message = msg, prompt = prompt } - if entry_has_content(entry) then - table.insert(entries, entry) - end - end - end - return Promise.new():resolve(entries) - end) - :catch(function() - return Promise.new():resolve({}) - end) -end - -- 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. 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 83939861..00000000 --- a/lua/opencode/ui/history_picker.lua +++ /dev/null @@ -1,84 +0,0 @@ -local config = require('opencode.config') -local base_picker = require('opencode.ui.base_picker') -local history = require('opencode.history') -local state = require('opencode.state') - -local M = {} - -local function preview_text(entry) - local lines = entry.prompt and entry.prompt.lines or {} - return table.concat(lines, '\n') -end - ----@param entry OpencodeHistoryEntry ----@param width number ----@return PickerItem -local function format_history_item(entry, width) - local preview = preview_text(entry):gsub('\n', '↵') - return base_picker.create_time_picker_item(preview, nil, 'ID: ' .. entry.id, width) -end - ----Open the history picker for the active session. Reads from the in-memory ----message cache when it has data; otherwise fetches the active session's ----messages from the server so the picker is never empty just because SSE has ----not yet caught up. ----@param callback? fun(prompt: string[]) -function M.pick(callback) - local entries = history.read() - if #entries > 0 then - return M._render(entries, callback) - end - - history - .refresh() - :and_then(function(entries) - if entries and #entries > 0 then - M._render(entries, callback) - else - vim.notify('No history entries found', vim.log.levels.INFO) - end - end) - :catch(function() - vim.notify('No history entries found', vim.log.levels.INFO) - end) - return true -end - ----@param entries OpencodeHistoryEntry[] ----@param callback? fun(prompt: string[]) -function M._render(entries, callback) - return base_picker.pick({ - items = entries, - format_fn = format_history_item, - actions = {}, - callback = function(selected_entry) - if not selected_entry then - return - end - if callback then - callback(selected_entry.prompt.lines) - return - end - - local input_window = require('opencode.ui.input_window') - local windows = state.windows - if not input_window.mounted(windows) then - require('opencode.services.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(selected_entry.message) then - 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/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 ]