From ed676292b6c69857b697f2c0b1f96a65c731e04f Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Thu, 5 Feb 2026 14:29:53 +0100 Subject: [PATCH 1/9] fix: enter terminal-mode when opening Codex --- lua/codex/init.lua | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/lua/codex/init.lua b/lua/codex/init.lua index 4378b46..5a53fc2 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -3,6 +3,21 @@ local installer = require 'codex.installer' local state = require 'codex.state' local M = {} +local config + +local function enter_terminal_mode() + vim.schedule(function() + if + config.auto_insert + and state.win and vim.api.nvim_win_is_valid(state.win) + and state.buf and vim.api.nvim_buf_is_valid(state.buf) + and vim.bo[state.buf].buftype == 'terminal' + then + vim.api.nvim_set_current_win(state.win) + vim.cmd('startinsert') + end + end) +end local config = { keymaps = { @@ -17,6 +32,7 @@ local config = { autoinstall = true, panel = false, -- if true, open Codex in a side-panel instead of floating window use_buffer = false, -- if true, capture Codex stdout into a normal buffer instead of a terminal + auto_insert = true, -- if true, enter terminal mode on focus/open } function M.setup(user_config) @@ -33,6 +49,26 @@ function M.setup(user_config) if config.keymaps.toggle then vim.api.nvim_set_keymap('n', config.keymaps.toggle, 'CodexToggle', { noremap = true, silent = true }) end + + if config.auto_insert then + local group = vim.api.nvim_create_augroup('CodexAutoInsert', { clear = true }) + vim.api.nvim_create_autocmd({ 'BufEnter', 'WinEnter', 'TermOpen', 'TermEnter' }, { + group = group, + pattern = '*', + callback = function(args) + local buf = args.buf + if vim.bo[buf].filetype ~= 'codex' or vim.bo[buf].buftype ~= 'terminal' then + return + end + vim.schedule(function() + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_set_current_buf(buf) + vim.cmd('startinsert') + end + end) + end, + }) + end end local function open_window() @@ -121,6 +157,7 @@ function M.open() if state.win and vim.api.nvim_win_is_valid(state.win) then vim.api.nvim_set_current_win(state.win) + enter_terminal_mode() return end @@ -218,7 +255,10 @@ function M.open() state.job = nil end, }) + enter_terminal_mode() end + else + enter_terminal_mode() end end From f33fde8c7a96b12d26ee9f2ec2f7fb03b099a347 Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Thu, 5 Feb 2026 18:27:24 +0100 Subject: [PATCH 2/9] feat: add Codex history browsing and session shortcuts --- README.md | 24 ++- lua/codex/history.lua | 254 +++++++++++++++++++++++++++ lua/codex/init.lua | 343 +++++++++++++++++++++++++++++++++++-- lua/codex/state.lua | 8 + tests/specs/codex_spec.lua | 26 +++ 5 files changed, 636 insertions(+), 19 deletions(-) create mode 100644 lua/codex/history.lua diff --git a/README.md b/README.md index 47339a4..d263718 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - ✅ Optional keymap mapping via `setup` call - ✅ Background running when window hidden - ✅ Statusline integration via `require('codex').status()` +- ✅ Browse Codex chat history with `:CodexHistory` ### Installation: @@ -31,7 +32,7 @@ export OPENAI_API_KEY=your_api_key return { 'kkrampis/codex.nvim', lazy = true, - cmd = { 'Codex', 'CodexToggle' }, -- Optional: Load only on command execution + cmd = { 'Codex', 'CodexToggle', 'CodexHistory', 'CodexHistoryToggle', 'CodexLast', 'CodexPin', 'CodexPinned', 'CodexClearSessions' }, -- Optional: Load only on command execution keys = { { 'cc', -- Change this to your preferred keybinding @@ -44,6 +45,11 @@ return { keymaps = { toggle = nil, -- Keybind to toggle Codex window (Disabled by default, watch out for conflicts) quit = '', -- Keybind to close the Codex window (default: Ctrl + q) + history = 'ch', -- Keybind to toggle Codex history + term_normal = '', -- Enter terminal-normal mode + last = 'cl', -- Resume last Codex session + pin = 'cp', -- Pin current Codex session + pinned = 'cP', -- Resume pinned Codex session }, -- Disable internal default keymap (cc -> :CodexToggle) border = 'rounded', -- Options: 'single', 'double', or 'rounded' width = 0.8, -- Width of the floating window (0.0 to 1.0) @@ -52,11 +58,27 @@ return { autoinstall = true, -- Automatically install the Codex CLI if not found panel = false, -- Open Codex in a side-panel (vertical split) instead of floating window use_buffer = false, -- Capture Codex stdout into a normal buffer instead of a terminal buffer + auto_insert = true, -- Enter terminal mode on open/focus + history = { + max_entries = 200, -- Limit entries in history list + max_files = 1000, -- Limit session files scanned for history (perf) + auto_close_active = true, -- Close active session when resuming from history + ui = 'buffer', -- 'buffer' or 'telescope' (requires telescope.nvim) + persist_pin = true, -- Persist pinned session across restarts + persist_last = true, -- Persist last session across restarts + }, }, }``` ### Usage: - Call `:Codex` (or `:CodexToggle`) to open or close the Codex popup or side-panel. +- Call `:CodexHistory` to browse past Codex sessions and resume them. +- Call `:CodexHistoryToggle` to switch between the live Codex session and history in the same window. +- Call `:CodexLast` to resume the most recent Codex session. +- Call `:CodexPin` to pin the current resumed session, and `:CodexPinned` to jump back to it. +- Call `:CodexClearSessions` to clear pinned and last sessions. +- Use `Tab` to toggle between Codex and history when the Codex window is focused. +- If `history.ui = 'telescope'`, the toggle command opens the Telescope picker instead of swapping the Codex window. - Map your own keybindings via the `keymaps.toggle` setting. - To choose floating popup vs side-panel, set `panel = false` (popup) or `panel = true` (panel) in your setup options. - To capture Codex output in an editable buffer instead of a terminal, set `use_buffer = true` (or `false` to keep terminal) in your setup options. diff --git a/lua/codex/history.lua b/lua/codex/history.lua new file mode 100644 index 0000000..f5849ba --- /dev/null +++ b/lua/codex/history.lua @@ -0,0 +1,254 @@ +local M = {} + +local function decode_json(line) + local ok, data = pcall(vim.json.decode, line) + if ok then return data end + ok, data = pcall(vim.fn.json_decode, line) + if ok then return data end + return nil +end + +local function codex_home() + return os.getenv('CODEX_HOME') or vim.fn.expand('~/.codex') +end + +local function session_files() + local dir = codex_home() .. '/sessions' + return vim.fn.globpath(dir, '**/*.jsonl', true, true) +end + +local function parse_session_meta(file) + local lines = vim.fn.readfile(file, '', 1) + if not lines or #lines == 0 then return nil end + local data = decode_json(lines[1]) + if not data or data.type ~= 'session_meta' then return nil end + + local payload = data.payload or {} + local git = payload.git or {} + + return { + id = payload.id, + timestamp = payload.timestamp, + cwd = payload.cwd, + source = payload.source, + originator = payload.originator, + model_provider = payload.model_provider, + branch = git.branch, + repository_url = git.repository_url, + file = file, + } +end + +local function short_time(iso) + if not iso or iso == '' then return '' end + local t = iso:gsub('T', ' '):gsub('Z', '') + return t:sub(1, 16) +end + +local function display_line(entry) + local time = short_time(entry.timestamp) + local id = entry.id or 'unknown' + local cwd = entry.cwd or '' + local branch = entry.branch or '' + local source = entry.source or '' + + if source ~= '' then + source = '[' .. source .. ']' + end + + return string.format('%s %s %s %s %s', time, id, cwd, branch, source) +end + +local function load_entries(max_entries) + local config = require('codex').get_config() + local max_files = (config.history and config.history.max_files) or 1000 + local files = session_files() + + if max_files and #files > max_files then + table.sort(files, function(a, b) + local sa = vim.loop.fs_stat(a) + local sb = vim.loop.fs_stat(b) + local ma = sa and sa.mtime and sa.mtime.sec or 0 + local mb = sb and sb.mtime and sb.mtime.sec or 0 + return ma > mb + end) + local trimmed = {} + for i = 1, max_files do + trimmed[i] = files[i] + end + files = trimmed + end + + local entries = {} + for _, file in ipairs(files) do + local entry = parse_session_meta(file) + if entry and entry.id and entry.timestamp then + table.insert(entries, entry) + end + end + + table.sort(entries, function(a, b) + return (a.timestamp or '') > (b.timestamp or '') + end) + + if max_entries and #entries > max_entries then + local trimmed = {} + for i = 1, max_entries do + trimmed[i] = entries[i] + end + entries = trimmed + end + + return entries +end + +function M.latest_session_id() + local config = require('codex').get_config() + local list = load_entries(config.history and config.history.max_entries or 200) + if #list == 0 then return nil end + return list[1].id +end + +local function open_telescope(entries) + local ok_telescope = pcall(require, 'telescope') + if not ok_telescope then + return false + end + local ok, pickers = pcall(require, 'telescope.pickers') + if not ok then + return false + end + local finders = require('telescope.finders') + local conf = require('telescope.config').values + local actions = require('telescope.actions') + local action_state = require('telescope.actions.state') + + pickers.new({}, { + prompt_title = 'Codex History', + finder = finders.new_table({ + results = entries, + entry_maker = function(entry) + return { + value = entry, + display = display_line(entry), + ordinal = (entry.timestamp or '') .. ' ' .. (entry.cwd or '') .. ' ' .. (entry.id or ''), + } + end, + }), + sorter = conf.generic_sorter({}), + attach_mappings = function(prompt_bufnr, map) + local function resume_selected() + local selection = action_state.get_selected_entry() + if not selection or not selection.value then + return + end + actions.close(prompt_bufnr) + require('codex').resume(selection.value.id) + end + map('i', '', resume_selected) + map('n', '', resume_selected) + return true + end, + }):find() + + return true +end + +local function close_window(win) + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end +end + +function M.build_buffer(entries) + local config = require('codex').get_config() + local list = entries or load_entries(config.history and config.history.max_entries or 200) + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe') + vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile') + vim.api.nvim_buf_set_option(buf, 'swapfile', false) + vim.api.nvim_buf_set_option(buf, 'filetype', 'codex-history') + + local header = { + 'Codex History', + 'Enter: resume q: close /: search Tab: toggle', + '', + } + + local lines = {} + for _, line in ipairs(header) do + table.insert(lines, line) + end + + for _, entry in ipairs(list) do + table.insert(lines, display_line(entry)) + end + + if #list == 0 then + table.insert(lines, 'No Codex sessions found in ' .. codex_home() .. '/sessions') + end + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.api.nvim_buf_set_option(buf, 'modifiable', false) + + vim.b[buf].codex_history_entries = list + vim.b[buf].codex_history_header_len = #header + + vim.keymap.set('n', 'q', function() + close_window(vim.api.nvim_get_current_win()) + end, { buffer = buf, silent = true }) + + local config = require('codex').get_config() + if config.keymaps and config.keymaps.quit then + vim.keymap.set('n', config.keymaps.quit, function() + require('codex').close() + end, { buffer = buf, silent = true }) + end + + if config.keymaps and config.keymaps.history then + vim.keymap.set('n', config.keymaps.history, function() + require('codex').toggle_history() + end, { buffer = buf, silent = true }) + end + + vim.keymap.set('n', '', function() + local win = vim.api.nvim_get_current_win() + local line = vim.api.nvim_win_get_cursor(win)[1] + local idx = line - (vim.b[buf].codex_history_header_len or 0) + local entry = (vim.b[buf].codex_history_entries or {})[idx] + if not entry then + return + end + require('codex').resume(entry.id) + end, { buffer = buf, silent = true }) + + vim.keymap.set('n', '', function() + require('codex').toggle_history() + end, { buffer = buf, silent = true }) + + return buf +end + +function M.open_split(entries) + local buf = M.build_buffer(entries) + vim.cmd('botright split') + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, buf) + vim.api.nvim_win_set_height(win, math.min(15, vim.o.lines - 4)) + return buf, win +end + +function M.open(entries) + local config = require('codex').get_config() + local list = entries or load_entries(config.history and config.history.max_entries or 200) + if config.history and config.history.ui == 'telescope' then + if open_telescope(list) then + return + end + vim.notify('[codex.nvim] Telescope not available; falling back to buffer history view', vim.log.levels.WARN) + end + return M.open_split(list) +end + +return M diff --git a/lua/codex/init.lua b/lua/codex/init.lua index 4378b46..277665d 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -8,6 +8,11 @@ local config = { keymaps = { toggle = nil, quit = '', -- Default: Ctrl+q to quit + history = 'ch', + term_normal = '', -- Enter terminal-normal mode + last = 'cl', + pin = 'cp', + pinned = 'cP', }, border = 'single', width = 0.8, @@ -17,11 +22,34 @@ local config = { autoinstall = true, panel = false, -- if true, open Codex in a side-panel instead of floating window use_buffer = false, -- if true, capture Codex stdout into a normal buffer instead of a terminal + auto_insert = true, -- if true, enter terminal mode on open/focus + history = { + max_entries = 200, + max_files = 1000, + auto_close_active = true, + ui = 'buffer', -- 'buffer' or 'telescope' (requires telescope.nvim) + persist_pin = true, + persist_last = true, + }, } function M.setup(user_config) config = vim.tbl_deep_extend('force', config, user_config or {}) + if config.history and config.history.persist_pin then + local ok, pinned = pcall(vim.fn.readfile, state.pinned_session_file or '') + if ok and pinned and pinned[1] and pinned[1] ~= '' then + state.pinned_session_id = pinned[1] + end + end + + if config.history and config.history.persist_last then + local ok, last = pcall(vim.fn.readfile, state.last_session_file or '') + if ok and last and last[1] and last[1] ~= '' then + state.last_session_id = last[1] + end + end + vim.api.nvim_create_user_command('Codex', function() M.toggle() end, { desc = 'Toggle Codex popup' }) @@ -30,12 +58,82 @@ function M.setup(user_config) M.toggle() end, { desc = 'Toggle Codex popup (alias)' }) + vim.api.nvim_create_user_command('CodexHistory', function() + M.open_history(false) + end, { desc = 'Browse Codex chat history' }) + + vim.api.nvim_create_user_command('CodexHistoryToggle', function() + M.toggle_history() + end, { desc = 'Toggle Codex history view' }) + + vim.api.nvim_create_user_command('CodexLast', function() + M.open_last() + end, { desc = 'Resume last Codex session' }) + + vim.api.nvim_create_user_command('CodexPin', function() + M.pin_current() + end, { desc = 'Pin current Codex session' }) + + vim.api.nvim_create_user_command('CodexPinned', function() + M.open_pinned() + end, { desc = 'Resume pinned Codex session' }) + + vim.api.nvim_create_user_command('CodexClearSessions', function() + M.clear_sessions() + end, { desc = 'Clear pinned/last Codex sessions' }) + if config.keymaps.toggle then vim.api.nvim_set_keymap('n', config.keymaps.toggle, 'CodexToggle', { noremap = true, silent = true }) end + + if config.keymaps.history then + vim.api.nvim_set_keymap('n', config.keymaps.history, 'CodexHistoryToggle', { noremap = true, silent = true }) + vim.api.nvim_set_keymap('t', config.keymaps.history, [[CodexHistoryToggle]], { noremap = true, silent = true }) + end + + if config.keymaps.last then + vim.api.nvim_set_keymap('n', config.keymaps.last, 'CodexLast', { noremap = true, silent = true }) + end + + if config.keymaps.pin then + vim.api.nvim_set_keymap('n', config.keymaps.pin, 'CodexPin', { noremap = true, silent = true }) + end + + if config.keymaps.pinned then + vim.api.nvim_set_keymap('n', config.keymaps.pinned, 'CodexPinned', { noremap = true, silent = true }) + end + + -- Toggle history from the live Codex terminal + local group = vim.api.nvim_create_augroup('CodexKeymaps', { clear = true }) + vim.api.nvim_create_autocmd('FileType', { + group = group, + pattern = 'codex', + callback = function(args) + local buf = args.buf + vim.keymap.set('n', '', function() + require('codex').toggle_history() + end, { buffer = buf, silent = true }) + vim.keymap.set('t', '', function() + require('codex').toggle_history() + end, { buffer = buf, silent = true }) + + if config.keymaps.term_normal then + vim.keymap.set('t', config.keymaps.term_normal, [[]], { buffer = buf, silent = true }) + end + + if config.auto_insert then + vim.schedule(function() + if vim.api.nvim_buf_is_valid(buf) and vim.bo[buf].buftype == 'terminal' then + vim.cmd('startinsert') + end + end) + end + end, + }) end -local function open_window() +local function open_window(buf) + local target_buf = buf or state.buf local width = math.floor(vim.o.columns * config.width) local height = math.floor(vim.o.lines * config.height) local row = math.floor((vim.o.lines - height) / 2) @@ -77,7 +175,7 @@ local function open_window() local border = type(config.border) == 'string' and styles[config.border] or config.border - state.win = vim.api.nvim_open_win(state.buf, true, { + state.win = vim.api.nvim_open_win(target_buf, true, { relative = 'editor', width = width, height = height, @@ -89,18 +187,41 @@ local function open_window() end --- Open Codex in a side-panel (vertical split) instead of floating window -local function open_panel() +local function open_panel(buf) -- Create a vertical split on the right and show the buffer vim.cmd('vertical rightbelow vsplit') local win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(win, state.buf) + vim.api.nvim_win_set_buf(win, buf or state.buf) -- Adjust width according to config (percentage of total columns) local width = math.floor(vim.o.columns * config.width) vim.api.nvim_win_set_width(win, width) state.win = win end -function M.open() +local function update_winbar(win) + if not win or not vim.api.nvim_win_is_valid(win) then return end + local buf = vim.api.nvim_win_get_buf(win) + local ft = vim.bo[buf].filetype + if ft == 'codex-history' then + vim.api.nvim_win_set_option(win, 'winbar', ' Codex History | Tab: Codex ') + elseif ft == 'codex' then + vim.api.nvim_win_set_option(win, 'winbar', ' Codex | Tab: History ') + else + vim.api.nvim_win_set_option(win, 'winbar', '') + end +end + +local function resolve_check_cmd(cmd) + if type(cmd) == 'table' then + return cmd[1] + end + if type(cmd) == 'string' then + return cmd:match('^%S+') + end + return nil +end + +function M.open(cmd_args) local function create_clean_buf() local buf = vim.api.nvim_create_buf(false, false) @@ -121,16 +242,45 @@ function M.open() if state.win and vim.api.nvim_win_is_valid(state.win) then vim.api.nvim_set_current_win(state.win) - return + local win_buf = vim.api.nvim_win_get_buf(state.win) + if win_buf == state.history_buf or vim.bo[win_buf].filetype == 'codex-history' then + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then + state.buf = create_clean_buf() + end + vim.api.nvim_win_set_buf(state.win, state.buf) + update_winbar(state.win) + else + return + end + end + + local cmd_to_run + if cmd_args then + cmd_to_run = cmd_args + else + if type(config.cmd) == 'string' then + if config.cmd:find '%s' then + cmd_to_run = config.cmd + else + cmd_to_run = { config.cmd } + end + else + cmd_to_run = vim.deepcopy(config.cmd) + end + + if type(cmd_to_run) == 'table' and config.model then + table.insert(cmd_to_run, '-m') + table.insert(cmd_to_run, config.model) + end end - local check_cmd = type(config.cmd) == 'string' and not config.cmd:find '%s' and config.cmd or (type(config.cmd) == 'table' and config.cmd[1]) or nil + local check_cmd = resolve_check_cmd(cmd_to_run) if check_cmd and vim.fn.executable(check_cmd) == 0 then if config.autoinstall then installer.prompt_autoinstall(function(success) if success then - M.open() -- Try again after installing + M.open(cmd_args) -- Try again after installing else -- Show failure message *after* buffer is created if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then @@ -173,18 +323,18 @@ function M.open() end if config.panel then open_panel() else open_window() end + update_winbar(state.win) - if not state.job then - -- assemble command - local cmd_args = type(config.cmd) == 'string' and { config.cmd } or vim.deepcopy(config.cmd) - if config.model then - table.insert(cmd_args, '-m') - table.insert(cmd_args, config.model) - end + -- Ensure terminal buffer is clean before starting job + if vim.api.nvim_buf_is_valid(state.buf) then + vim.bo[state.buf].modified = false + vim.api.nvim_set_current_buf(state.buf) + end + if not state.job then if config.use_buffer then -- capture stdout/stderr into normal buffer - state.job = vim.fn.jobstart(cmd_args, { + state.job = vim.fn.jobstart(cmd_to_run, { cwd = vim.loop.cwd(), stdout_buffered = true, on_stdout = function(_, data) @@ -212,16 +362,165 @@ function M.open() }) else -- use a terminal buffer - state.job = vim.fn.termopen(cmd_args, { + state.job = vim.fn.termopen(cmd_to_run, { cwd = vim.loop.cwd(), on_exit = function() state.job = nil end, }) + if config.auto_insert then + vim.schedule(function() + if state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_set_current_win(state.win) + vim.cmd('startinsert') + end + end) + end end end end +function M.open_history(reuse_win) + local history = require('codex.history') + if config.history and config.history.ui == 'telescope' then + history.open() + return + end + + local buf = history.build_buffer() + state.history_buf = buf + + if reuse_win and state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_set_current_win(state.win) + vim.api.nvim_win_set_buf(state.win, buf) + update_winbar(state.win) + return + end + + if config.panel then + open_panel(buf) + else + open_window(buf) + end + update_winbar(state.win) +end + +function M.toggle_history() + if config.history and config.history.ui == 'telescope' then + require('codex.history').open() + return + end + + if state.win and vim.api.nvim_win_is_valid(state.win) then + local win_buf = vim.api.nvim_win_get_buf(state.win) + if win_buf == state.history_buf or vim.bo[win_buf].filetype == 'codex-history' then + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + vim.api.nvim_win_set_buf(state.win, state.buf) + update_winbar(state.win) + if config.auto_insert and vim.bo[state.buf].buftype == 'terminal' then + vim.cmd('startinsert') + end + return + end + M.open(nil) + return + end + M.open_history(true) + return + end + + M.open_history(false) +end + +function M.resume(session_id) + if not session_id or session_id == '' then + vim.notify('[codex.nvim] Missing session id for resume', vim.log.levels.ERROR) + return + end + + if state.job then + if config.history and config.history.auto_close_active then + pcall(vim.fn.jobstop, state.job) + pcall(vim.fn.chanclose, state.job) + M.close() + state.job = nil + else + vim.notify('[codex.nvim] Close the active Codex session before resuming another', vim.log.levels.WARN) + return + end + end + + local cmd + if type(config.cmd) == 'table' then + cmd = vim.deepcopy(config.cmd) + table.insert(cmd, 'resume') + table.insert(cmd, session_id) + elseif type(config.cmd) == 'string' then + if config.cmd:find '%s' then + vim.notify('[codex.nvim] config.cmd contains spaces; using "codex" for resume', vim.log.levels.WARN) + cmd = { 'codex', 'resume', session_id } + else + cmd = { config.cmd, 'resume', session_id } + end + else + cmd = { 'codex', 'resume', session_id } + end + + state.last_session_id = session_id + if config.history and config.history.persist_last then + local dir = vim.fn.stdpath('data') .. '/codex.nvim' + vim.fn.mkdir(dir, 'p') + vim.fn.writefile({ session_id }, state.last_session_file) + end + M.open(cmd) +end + +function M.open_last() + local history = require('codex.history') + local id = state.last_session_id or history.latest_session_id() + if not id then + vim.notify('[codex.nvim] No Codex sessions found', vim.log.levels.WARN) + return + end + M.resume(id) +end + +function M.pin_current() + local id = state.last_session_id + if not id then + vim.notify('[codex.nvim] No active session to pin. Resume a session first.', vim.log.levels.WARN) + return + end + state.pinned_session_id = id + if config.history and config.history.persist_pin then + local dir = vim.fn.stdpath('data') .. '/codex.nvim' + vim.fn.mkdir(dir, 'p') + vim.fn.writefile({ id }, state.pinned_session_file) + end + vim.notify('[codex.nvim] Pinned session: ' .. id, vim.log.levels.INFO) +end + +function M.open_pinned() + local id = state.pinned_session_id + if not id or id == '' then + vim.notify('[codex.nvim] No pinned session. Use :CodexPin first.', vim.log.levels.WARN) + return + end + M.resume(id) +end + +function M.clear_sessions() + state.last_session_id = nil + state.pinned_session_id = nil + if state.last_session_file then + pcall(vim.fn.delete, state.last_session_file) + end + if state.pinned_session_file then + pcall(vim.fn.delete, state.pinned_session_file) + end + vim.notify('[codex.nvim] Cleared pinned and last sessions', vim.log.levels.INFO) +end + function M.close() if state.win and vim.api.nvim_win_is_valid(state.win) then vim.api.nvim_win_close(state.win, true) @@ -233,10 +532,18 @@ function M.toggle() if state.win and vim.api.nvim_win_is_valid(state.win) then M.close() else - M.open() + M.open(nil) end end +function M.get_config() + return config +end + +function M.get_state() + return state +end + function M.statusline() if state.job and not (state.win and vim.api.nvim_win_is_valid(state.win)) then return '[Codex]' diff --git a/lua/codex/state.lua b/lua/codex/state.lua index be06ca3..5bf1476 100644 --- a/lua/codex/state.lua +++ b/lua/codex/state.lua @@ -4,6 +4,14 @@ local M = { buf = nil, win = nil, job = nil, + history_buf = nil, + last_session_id = nil, + pinned_session_id = nil, + pinned_session_file = nil, + last_session_file = nil, } +M.pinned_session_file = vim.fn.stdpath('data') .. '/codex.nvim/pinned_session' +M.last_session_file = vim.fn.stdpath('data') .. '/codex.nvim/last_session' + return M diff --git a/tests/specs/codex_spec.lua b/tests/specs/codex_spec.lua index 3b10db9..d6d500c 100644 --- a/tests/specs/codex_spec.lua +++ b/tests/specs/codex_spec.lua @@ -110,4 +110,30 @@ describe('codex.nvim', function() -- Restore original vim.fn = original_fn end) + + it('returns most recent session id from history', function() + local original_fn = vim.fn + + vim.fn = setmetatable({ + globpath = function() + return { '/tmp/a.jsonl', '/tmp/b.jsonl' } + end, + readfile = function(path, _, __) + if path == '/tmp/a.jsonl' then + return { '{"type":"session_meta","payload":{"id":"a","timestamp":"2025-01-01T00:00:00Z"}}' } + end + return { '{"type":"session_meta","payload":{"id":"b","timestamp":"2025-02-01T00:00:00Z"}}' } + end, + expand = function() + return '/tmp' + end, + }, { __index = original_fn }) + + package.loaded['codex.history'] = nil + local history = require 'codex.history' + local id = history.latest_session_id() + eq(id, 'b') + + vim.fn = original_fn + end) end) From 6eb9dc34ba0fbce27f79b531c26d25a4cc1ac0cb Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Fri, 6 Feb 2026 11:07:18 +0100 Subject: [PATCH 3/9] Make the whole shebang more robust --- README.md | 7 +- lua/codex/history.lua | 48 +++++-- lua/codex/init.lua | 289 +++++++++++++++++++++++++++++++++++------- lua/codex/state.lua | 1 + 4 files changed, 286 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index d263718..73befbd 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ return { opts = { keymaps = { toggle = nil, -- Keybind to toggle Codex window (Disabled by default, watch out for conflicts) - quit = '', -- Keybind to close the Codex window (default: Ctrl + q) + quit = { '', '', 'ZZ' }, -- Keybinds to close the Codex window history = 'ch', -- Keybind to toggle Codex history + history_list = nil, -- Keybind to open Codex history list directly term_normal = '', -- Enter terminal-normal mode last = 'cl', -- Resume last Codex session pin = 'cp', -- Pin current Codex session @@ -54,16 +55,20 @@ return { border = 'rounded', -- Options: 'single', 'double', or 'rounded' width = 0.8, -- Width of the floating window (0.0 to 1.0) height = 0.8, -- Height of the floating window (0.0 to 1.0) + panel_width = 0.20, -- Width of the side-panel (0.0 to 1.0) model = nil, -- Optional: pass a string to use a specific model (e.g., 'o3-mini') autoinstall = true, -- Automatically install the Codex CLI if not found panel = false, -- Open Codex in a side-panel (vertical split) instead of floating window use_buffer = false, -- Capture Codex stdout into a normal buffer instead of a terminal buffer auto_insert = true, -- Enter terminal mode on open/focus + render_markdown = true, -- Render Codex output as markdown (forces use_buffer; falls back to terminal if TTY required) history = { max_entries = 200, -- Limit entries in history list max_files = 1000, -- Limit session files scanned for history (perf) auto_close_active = true, -- Close active session when resuming from history ui = 'buffer', -- 'buffer' or 'telescope' (requires telescope.nvim) + open_last_on_toggle = false, -- Toggle history key opens last session + open_session_in_panel = false, -- Resume from history opens chat in side panel persist_pin = true, -- Persist pinned session across restarts persist_last = true, -- Persist last session across restarts }, diff --git a/lua/codex/history.lua b/lua/codex/history.lua index f5849ba..de8acba 100644 --- a/lua/codex/history.lua +++ b/lua/codex/history.lua @@ -122,6 +122,16 @@ local function open_telescope(entries) local conf = require('telescope.config').values local actions = require('telescope.actions') local action_state = require('telescope.actions.state') + local state = require('codex').get_state() + local default_index = nil + if state and state.last_session_id then + for i, entry in ipairs(entries or {}) do + if entry.id == state.last_session_id then + default_index = i + break + end + end + end pickers.new({}, { prompt_title = 'Codex History', @@ -136,6 +146,7 @@ local function open_telescope(entries) end, }), sorter = conf.generic_sorter({}), + default_selection_index = default_index, attach_mappings = function(prompt_bufnr, map) local function resume_selected() local selection = action_state.get_selected_entry() @@ -143,10 +154,20 @@ local function open_telescope(entries) return end actions.close(prompt_bufnr) - require('codex').resume(selection.value.id) + local config = require('codex').get_config() + local opts = nil + if config.history and config.history.open_session_in_panel then + opts = { panel = true } + end + require('codex').resume(selection.value.id, opts) + end + local function close_picker() + actions.close(prompt_bufnr) end map('i', '', resume_selected) map('n', '', resume_selected) + map('i', '', close_picker) + map('n', '', close_picker) return true end, }):find() @@ -172,7 +193,7 @@ function M.build_buffer(entries) local header = { 'Codex History', - 'Enter: resume q: close /: search Tab: toggle', + 'Enter: resume q: close /: search', '', } @@ -201,9 +222,15 @@ function M.build_buffer(entries) local config = require('codex').get_config() if config.keymaps and config.keymaps.quit then - vim.keymap.set('n', config.keymaps.quit, function() - require('codex').close() - end, { buffer = buf, silent = true }) + local quit_maps = config.keymaps.quit + if type(quit_maps) == 'string' then + quit_maps = { quit_maps } + end + for _, lhs in ipairs(quit_maps) do + vim.keymap.set('n', lhs, function() + require('codex').close() + end, { buffer = buf, silent = true }) + end end if config.keymaps and config.keymaps.history then @@ -220,11 +247,12 @@ function M.build_buffer(entries) if not entry then return end - require('codex').resume(entry.id) - end, { buffer = buf, silent = true }) - - vim.keymap.set('n', '', function() - require('codex').toggle_history() + local config = require('codex').get_config() + local opts = nil + if config.history and config.history.open_session_in_panel then + opts = { panel = true } + end + require('codex').resume(entry.id, opts) end, { buffer = buf, silent = true }) return buf diff --git a/lua/codex/init.lua b/lua/codex/init.lua index 277665d..f0b2bd9 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -3,12 +3,14 @@ local installer = require 'codex.installer' local state = require 'codex.state' local M = {} +local apply_quit_keymaps local config = { keymaps = { toggle = nil, - quit = '', -- Default: Ctrl+q to quit + quit = { '', '', 'ZZ' }, -- Default: Ctrl+q, Ctrl+c, or ZZ to quit history = 'ch', + history_list = nil, term_normal = '', -- Enter terminal-normal mode last = 'cl', pin = 'cp', @@ -17,17 +19,21 @@ local config = { border = 'single', width = 0.8, height = 0.8, + panel_width = 0.15, -- Width for side-panel (percentage of total columns) cmd = 'codex', model = nil, -- Default to the latest model autoinstall = true, panel = false, -- if true, open Codex in a side-panel instead of floating window use_buffer = false, -- if true, capture Codex stdout into a normal buffer instead of a terminal auto_insert = true, -- if true, enter terminal mode on open/focus + render_markdown = true, -- if true, render Codex output as markdown (forces use_buffer) history = { max_entries = 200, max_files = 1000, auto_close_active = true, ui = 'buffer', -- 'buffer' or 'telescope' (requires telescope.nvim) + open_last_on_toggle = false, -- if true, toggle history key opens last session + open_session_in_panel = false, -- if true, resume from history opens chat in side panel persist_pin = true, persist_last = true, }, @@ -91,6 +97,11 @@ function M.setup(user_config) vim.api.nvim_set_keymap('t', config.keymaps.history, [[CodexHistoryToggle]], { noremap = true, silent = true }) end + if config.keymaps.history_list then + vim.api.nvim_set_keymap('n', config.keymaps.history_list, 'CodexHistory', { noremap = true, silent = true }) + vim.api.nvim_set_keymap('t', config.keymaps.history_list, [[CodexHistory]], { noremap = true, silent = true }) + end + if config.keymaps.last then vim.api.nvim_set_keymap('n', config.keymaps.last, 'CodexLast', { noremap = true, silent = true }) end @@ -110,18 +121,28 @@ function M.setup(user_config) pattern = 'codex', callback = function(args) local buf = args.buf - vim.keymap.set('n', '', function() - require('codex').toggle_history() - end, { buffer = buf, silent = true }) - vim.keymap.set('t', '', function() - require('codex').toggle_history() - end, { buffer = buf, silent = true }) - if config.keymaps.term_normal then vim.keymap.set('t', config.keymaps.term_normal, [[]], { buffer = buf, silent = true }) end + apply_quit_keymaps(buf) + if vim.bo[buf].buftype == 'terminal' then + local function send_to_term(keys) + local job_id = vim.b[buf].terminal_job_id + if job_id then + vim.api.nvim_chan_send(job_id, keys) + end + end + vim.keymap.set('n', '', function() + send_to_term('\n') + end, { buffer = buf, silent = true }) + for _, key in ipairs({ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }) do + vim.keymap.set('n', key, function() + send_to_term(key) + end, { buffer = buf, silent = true }) + end + end - if config.auto_insert then + if config.auto_insert and not vim.b[buf].codex_no_auto_insert then vim.schedule(function() if vim.api.nvim_buf_is_valid(buf) and vim.bo[buf].buftype == 'terminal' then vim.cmd('startinsert') @@ -132,6 +153,33 @@ function M.setup(user_config) }) end +local function keymap_list(value) + if type(value) == 'table' then + return value + end + if type(value) == 'string' and value ~= '' then + return { value } + end + return {} +end + +apply_quit_keymaps = function(buf) + if not config.keymaps.quit then + return + end + local function do_close() + require('codex').close() + end + for _, lhs in ipairs(keymap_list(config.keymaps.quit)) do + vim.keymap.set('n', lhs, do_close, { buffer = buf, silent = true }) + vim.keymap.set('t', lhs, [[lua require('codex').close()]], { + buffer = buf, + silent = true, + nowait = true, + }) + end +end + local function open_window(buf) local target_buf = buf or state.buf local width = math.floor(vim.o.columns * config.width) @@ -186,16 +234,72 @@ local function open_window(buf) }) end +local function apply_markdown_ui(win, buf) + if not (win and vim.api.nvim_win_is_valid(win)) then + return + end + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + vim.bo[buf].filetype = 'markdown' + vim.bo[buf].modifiable = false + vim.wo[win].wrap = true + vim.wo[win].linebreak = true + vim.wo[win].breakindent = true + vim.wo[win].conceallevel = 2 + vim.wo[win].concealcursor = 'nc' + vim.wo[win].scrolloff = 2 + vim.wo[win].sidescrolloff = 2 +end + +local function append_lines(buf, lines) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + local was_modifiable = vim.bo[buf].modifiable + if not was_modifiable then + vim.bo[buf].modifiable = true + end + vim.api.nvim_buf_set_lines(buf, -1, -1, false, lines) + vim.bo[buf].modified = false + if not was_modifiable then + vim.bo[buf].modifiable = false + end +end + +local function replace_lines(buf, lines) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + local was_modifiable = vim.bo[buf].modifiable + if not was_modifiable then + vim.bo[buf].modifiable = true + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modified = false + if not was_modifiable then + vim.bo[buf].modifiable = false + end +end + --- Open Codex in a side-panel (vertical split) instead of floating window local function open_panel(buf) + if state.panel_win and vim.api.nvim_win_is_valid(state.panel_win) then + vim.api.nvim_set_current_win(state.panel_win) + vim.api.nvim_win_set_buf(state.panel_win, buf or state.buf) + state.win = state.panel_win + return + end -- Create a vertical split on the right and show the buffer vim.cmd('vertical rightbelow vsplit') local win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(win, buf or state.buf) -- Adjust width according to config (percentage of total columns) - local width = math.floor(vim.o.columns * config.width) + local panel_width = config.panel_width or config.width + local width = math.floor(vim.o.columns * panel_width) vim.api.nvim_win_set_width(win, width) state.win = win + state.panel_win = win end local function update_winbar(win) @@ -203,14 +307,35 @@ local function update_winbar(win) local buf = vim.api.nvim_win_get_buf(win) local ft = vim.bo[buf].filetype if ft == 'codex-history' then - vim.api.nvim_win_set_option(win, 'winbar', ' Codex History | Tab: Codex ') + vim.api.nvim_win_set_option(win, 'winbar', ' Codex History ') elseif ft == 'codex' then - vim.api.nvim_win_set_option(win, 'winbar', ' Codex | Tab: History ') + vim.api.nvim_win_set_option(win, 'winbar', ' Codex ') else vim.api.nvim_win_set_option(win, 'winbar', '') end end +local function focus_last_history_entry(buf, win) + if not (win and vim.api.nvim_win_is_valid(win)) then + return + end + local last_id = state.last_session_id + if not last_id or last_id == '' then + return + end + local entries = vim.b[buf].codex_history_entries + local header_len = vim.b[buf].codex_history_header_len or 0 + if type(entries) ~= 'table' then + return + end + for i, entry in ipairs(entries) do + if entry.id == last_id then + vim.api.nvim_win_set_cursor(win, { header_len + i, 0 }) + return + end + end +end + local function resolve_check_cmd(cmd) if type(cmd) == 'table' then return cmd[1] @@ -221,36 +346,53 @@ local function resolve_check_cmd(cmd) return nil end -function M.open(cmd_args) +function M.open(cmd_args, opts) + local use_panel = (opts and opts.panel ~= nil) and opts.panel or config.panel + local force_terminal = opts and opts.force_terminal or false + local should_auto_insert = config.auto_insert and not (opts and opts.no_auto_insert) + local use_buffer = (config.use_buffer or config.render_markdown) and not force_terminal local function create_clean_buf() local buf = vim.api.nvim_create_buf(false, false) - vim.api.nvim_buf_set_option(buf, 'bufhidden', 'hide') + vim.api.nvim_buf_set_option(buf, 'bufhidden', use_buffer and 'wipe' or 'hide') vim.api.nvim_buf_set_option(buf, 'swapfile', false) vim.api.nvim_buf_set_option(buf, 'filetype', 'codex') + if use_buffer then + vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile') + else + -- ensure terminal-friendly buffer + vim.api.nvim_buf_set_option(buf, 'buftype', '') + vim.api.nvim_buf_set_option(buf, 'modifiable', true) + end + if opts and opts.no_auto_insert then + vim.b[buf].codex_no_auto_insert = true + else + vim.b[buf].codex_no_auto_insert = nil + end -- Apply configured quit keybinding - if config.keymaps.quit then - local quit_cmd = [[lua require('codex').close()]] - vim.api.nvim_buf_set_keymap(buf, 't', config.keymaps.quit, [[]] .. quit_cmd, { noremap = true, silent = true }) - vim.api.nvim_buf_set_keymap(buf, 'n', config.keymaps.quit, quit_cmd, { noremap = true, silent = true }) - end + apply_quit_keymaps(buf) return buf end - if state.win and vim.api.nvim_win_is_valid(state.win) then - vim.api.nvim_set_current_win(state.win) - local win_buf = vim.api.nvim_win_get_buf(state.win) + local target_win = nil + if use_panel and state.panel_win and vim.api.nvim_win_is_valid(state.panel_win) then + target_win = state.panel_win + elseif state.win and vim.api.nvim_win_is_valid(state.win) then + target_win = state.win + end + + if target_win then + vim.api.nvim_set_current_win(target_win) + local win_buf = vim.api.nvim_win_get_buf(target_win) if win_buf == state.history_buf or vim.bo[win_buf].filetype == 'codex-history' then if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then state.buf = create_clean_buf() end - vim.api.nvim_win_set_buf(state.win, state.buf) - update_winbar(state.win) - else - return + vim.api.nvim_win_set_buf(target_win, state.buf) + update_winbar(target_win) end end @@ -286,13 +428,13 @@ function M.open(cmd_args) if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then state.buf = create_clean_buf() end - vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, { + replace_lines(state.buf, { 'Autoinstall cancelled or failed.', '', 'You can install manually with:', ' npm install -g @openai/codex', }) - if config.panel then open_panel() else open_window() end + if use_panel then open_panel() else open_window() end end end) return @@ -301,7 +443,7 @@ function M.open(cmd_args) if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then state.buf = vim.api.nvim_create_buf(false, false) end - vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, { + replace_lines(state.buf, { 'Codex CLI not found, autoinstall disabled.', '', 'Install with:', @@ -309,21 +451,35 @@ function M.open(cmd_args) '', 'Or enable autoinstall in setup: require("codex").setup{ autoinstall = true }', }) - if config.panel then open_panel() else open_window() end + if use_panel then open_panel() else open_window() end return end end - local function is_buf_reusable(buf) - return type(buf) == 'number' and vim.api.nvim_buf_is_valid(buf) + local function is_buf_reusable(buf, need_buffer_mode) + if type(buf) ~= 'number' or not vim.api.nvim_buf_is_valid(buf) then + return false + end + local bt = vim.bo[buf].buftype + if need_buffer_mode then + return bt == 'nofile' + else + return bt ~= 'nofile' + end end - if not is_buf_reusable(state.buf) then + if not is_buf_reusable(state.buf, use_buffer) then state.buf = create_clean_buf() end + if opts and opts.no_auto_insert then + vim.b[state.buf].codex_no_auto_insert = true + end - if config.panel then open_panel() else open_window() end + if use_panel then open_panel() else open_window() end update_winbar(state.win) + if use_buffer and config.render_markdown then + apply_markdown_ui(state.win, state.buf) + end -- Ensure terminal buffer is clean before starting job if vim.api.nvim_buf_is_valid(state.buf) then @@ -332,8 +488,9 @@ function M.open(cmd_args) end if not state.job then - if config.use_buffer then - -- capture stdout/stderr into normal buffer + if use_buffer then + -- capture stdout/stderr into normal buffer; fallback to terminal if CLI needs a real TTY + local needs_tty_fallback = false state.job = vim.fn.jobstart(cmd_to_run, { cwd = vim.loop.cwd(), stdout_buffered = true, @@ -341,7 +498,10 @@ function M.open(cmd_args) if not data then return end for _, line in ipairs(data) do if line ~= '' then - vim.api.nvim_buf_set_lines(state.buf, -1, -1, false, { line }) + append_lines(state.buf, { line }) + if line:match('cursor position could not be read') then + needs_tty_fallback = true + end end end end, @@ -349,15 +509,22 @@ function M.open(cmd_args) if not data then return end for _, line in ipairs(data) do if line ~= '' then - vim.api.nvim_buf_set_lines(state.buf, -1, -1, false, { '[ERR] ' .. line }) + if line:match('stdin is not a terminal') or line:match('cursor position could not be read') then + needs_tty_fallback = true + end + append_lines(state.buf, { '[ERR] ' .. line }) end end end, on_exit = function(_, code) state.job = nil - vim.api.nvim_buf_set_lines(state.buf, -1, -1, false, { - ('[Codex exit: %d]'):format(code), - }) + if needs_tty_fallback and not force_terminal then + vim.schedule(function() + M.open(cmd_args, { panel = use_panel, force_terminal = true, no_auto_insert = true }) + end) + return + end + append_lines(state.buf, { ('[Codex exit: %d]'):format(code) }) end, }) else @@ -368,7 +535,7 @@ function M.open(cmd_args) state.job = nil end, }) - if config.auto_insert then + if should_auto_insert then vim.schedule(function() if state.win and vim.api.nvim_win_is_valid(state.win) then vim.api.nvim_set_current_win(state.win) @@ -394,6 +561,7 @@ function M.open_history(reuse_win) vim.api.nvim_set_current_win(state.win) vim.api.nvim_win_set_buf(state.win, buf) update_winbar(state.win) + focus_last_history_entry(buf, state.win) return end @@ -403,9 +571,15 @@ function M.open_history(reuse_win) open_window(buf) end update_winbar(state.win) + focus_last_history_entry(buf, state.win) end function M.toggle_history() + if config.history and config.history.open_last_on_toggle and state.last_session_id then + M.open_last() + return + end + if config.history and config.history.ui == 'telescope' then require('codex.history').open() return @@ -432,7 +606,7 @@ function M.toggle_history() M.open_history(false) end -function M.resume(session_id) +function M.resume(session_id, opts) if not session_id or session_id == '' then vim.notify('[codex.nvim] Missing session id for resume', vim.log.levels.ERROR) return @@ -472,7 +646,7 @@ function M.resume(session_id) vim.fn.mkdir(dir, 'p') vim.fn.writefile({ session_id }, state.last_session_file) end - M.open(cmd) + M.open(cmd, opts) end function M.open_last() @@ -482,7 +656,11 @@ function M.open_last() vim.notify('[codex.nvim] No Codex sessions found', vim.log.levels.WARN) return end - M.resume(id) + local opts = nil + if config.history and config.history.open_session_in_panel then + opts = { panel = true } + end + M.resume(id, opts) end function M.pin_current() @@ -522,10 +700,25 @@ function M.clear_sessions() end function M.close() - if state.win and vim.api.nvim_win_is_valid(state.win) then - vim.api.nvim_win_close(state.win, true) + local target_win = nil + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + local wins = vim.fn.win_findbuf(state.buf) + if wins and #wins > 0 then + target_win = wins[1] + end + end + if not target_win then + target_win = state.win + end + if target_win and vim.api.nvim_win_is_valid(target_win) then + vim.api.nvim_win_close(target_win, true) + end + if state.win == target_win then + state.win = nil + end + if state.panel_win == target_win then + state.panel_win = nil end - state.win = nil end function M.toggle() diff --git a/lua/codex/state.lua b/lua/codex/state.lua index 5bf1476..0b521bd 100644 --- a/lua/codex/state.lua +++ b/lua/codex/state.lua @@ -3,6 +3,7 @@ local M = { buf = nil, win = nil, + panel_win = nil, job = nil, history_buf = nil, last_session_id = nil, From 85c914f579095b61b3dae30917d5cc25ba1c7fa1 Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Fri, 6 Feb 2026 11:11:00 +0100 Subject: [PATCH 4/9] Close left over codex side panel gracefully --- lua/codex/init.lua | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lua/codex/init.lua b/lua/codex/init.lua index f0b2bd9..5659504 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -5,6 +5,26 @@ local state = require 'codex.state' local M = {} local apply_quit_keymaps +local function close_win_safe(win) + if not (win and vim.api.nvim_win_is_valid(win)) then + return + end + local cfg = vim.api.nvim_win_get_config(win) + local is_float = cfg and cfg.relative ~= '' + local normal_wins = {} + for _, w in ipairs(vim.api.nvim_list_wins()) do + local c = vim.api.nvim_win_get_config(w) + if c and c.relative == '' then + table.insert(normal_wins, w) + end + end + if not is_float and #normal_wins <= 1 then + pcall(vim.cmd, 'quit') + return + end + vim.api.nvim_win_close(win, true) +end + local config = { keymaps = { toggle = nil, @@ -710,8 +730,8 @@ function M.close() if not target_win then target_win = state.win end - if target_win and vim.api.nvim_win_is_valid(target_win) then - vim.api.nvim_win_close(target_win, true) + if target_win then + close_win_safe(target_win) end if state.win == target_win then state.win = nil From 5a5eba6aabd3bf8e4cc0a76624cee33962f8237e Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Fri, 6 Feb 2026 11:15:56 +0100 Subject: [PATCH 5/9] More improvements --- README.md | 3 ++- lua/codex/init.lua | 22 +++++++++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 73befbd..2a6df43 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ return { autoinstall = true, -- Automatically install the Codex CLI if not found panel = false, -- Open Codex in a side-panel (vertical split) instead of floating window use_buffer = false, -- Capture Codex stdout into a normal buffer instead of a terminal buffer - auto_insert = true, -- Enter terminal mode on open/focus + auto_insert = true, -- Enter terminal mode on open/focus (floating) + panel_auto_insert = false,-- Enter insert mode in side-panel (default: stay in normal mode) render_markdown = true, -- Render Codex output as markdown (forces use_buffer; falls back to terminal if TTY required) history = { max_entries = 200, -- Limit entries in history list diff --git a/lua/codex/init.lua b/lua/codex/init.lua index 5659504..2645734 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -46,6 +46,7 @@ local config = { panel = false, -- if true, open Codex in a side-panel instead of floating window use_buffer = false, -- if true, capture Codex stdout into a normal buffer instead of a terminal auto_insert = true, -- if true, enter terminal mode on open/focus + panel_auto_insert = false, -- default: side panel opens in normal mode render_markdown = true, -- if true, render Codex output as markdown (forces use_buffer) history = { max_entries = 200, @@ -369,7 +370,16 @@ end function M.open(cmd_args, opts) local use_panel = (opts and opts.panel ~= nil) and opts.panel or config.panel local force_terminal = opts and opts.force_terminal or false - local should_auto_insert = config.auto_insert and not (opts and opts.no_auto_insert) + local function resolve_auto_insert() + if opts and opts.no_auto_insert then + return false + end + if use_panel then + return config.panel_auto_insert + end + return config.auto_insert + end + local should_auto_insert = resolve_auto_insert() local use_buffer = (config.use_buffer or config.render_markdown) and not force_terminal local function create_clean_buf() local buf = vim.api.nvim_create_buf(false, false) @@ -384,11 +394,7 @@ function M.open(cmd_args, opts) vim.api.nvim_buf_set_option(buf, 'buftype', '') vim.api.nvim_buf_set_option(buf, 'modifiable', true) end - if opts and opts.no_auto_insert then - vim.b[buf].codex_no_auto_insert = true - else - vim.b[buf].codex_no_auto_insert = nil - end + vim.b[buf].codex_no_auto_insert = not should_auto_insert -- Apply configured quit keybinding @@ -491,9 +497,7 @@ function M.open(cmd_args, opts) if not is_buf_reusable(state.buf, use_buffer) then state.buf = create_clean_buf() end - if opts and opts.no_auto_insert then - vim.b[state.buf].codex_no_auto_insert = true - end + vim.b[state.buf].codex_no_auto_insert = not should_auto_insert if use_panel then open_panel() else open_window() end update_winbar(state.win) From 77d9458ee5208d5fdf6d8c5d3c044ce13216a7aa Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Fri, 6 Feb 2026 11:28:58 +0100 Subject: [PATCH 6/9] skip empty chats --- README.md | 1 + lua/codex/history.lua | 52 ++++++++++++++++++++++++++++++++++++++++--- lua/codex/init.lua | 1 + 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2a6df43..2bef549 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ return { ui = 'buffer', -- 'buffer' or 'telescope' (requires telescope.nvim) open_last_on_toggle = false, -- Toggle history key opens last session open_session_in_panel = false, -- Resume from history opens chat in side panel + skip_empty = true, -- Hide history entries with no chat content persist_pin = true, -- Persist pinned session across restarts persist_last = true, -- Persist last session across restarts }, diff --git a/lua/codex/history.lua b/lua/codex/history.lua index de8acba..7069046 100644 --- a/lua/codex/history.lua +++ b/lua/codex/history.lua @@ -29,6 +29,7 @@ local function parse_session_meta(file) return { id = payload.id, timestamp = payload.timestamp, + title = payload.title, cwd = payload.cwd, source = payload.source, originator = payload.originator, @@ -39,6 +40,27 @@ local function parse_session_meta(file) } end +local function session_has_content(file) + local lines = vim.fn.readfile(file) + if not lines or #lines <= 1 then + return false + end + for i = 2, #lines do + local data = decode_json(lines[i]) + if data then + if data.type and data.type ~= 'session_meta' then + return true + end + if data.payload and (data.payload.messages or data.payload.content) then + return true + end + elseif lines[i] ~= '' then + return true + end + end + return false +end + local function short_time(iso) if not iso or iso == '' then return '' end local t = iso:gsub('T', ' '):gsub('Z', '') @@ -48,6 +70,8 @@ end local function display_line(entry) local time = short_time(entry.timestamp) local id = entry.id or 'unknown' + local id_short = entry.id and entry.id:sub(1, 8) or '' + local title = entry.title or id local cwd = entry.cwd or '' local branch = entry.branch or '' local source = entry.source or '' @@ -56,7 +80,20 @@ local function display_line(entry) source = '[' .. source .. ']' end - return string.format('%s %s %s %s %s', time, id, cwd, branch, source) + local parts = { time, title } + if id_short ~= '' then + table.insert(parts, '[' .. id_short .. ']') + end + if cwd ~= '' then + table.insert(parts, cwd) + end + if branch ~= '' then + table.insert(parts, branch) + end + if source ~= '' then + table.insert(parts, source) + end + return table.concat(parts, ' ') end local function load_entries(max_entries) @@ -82,7 +119,11 @@ local function load_entries(max_entries) local entries = {} for _, file in ipairs(files) do local entry = parse_session_meta(file) - if entry and entry.id and entry.timestamp then + local ok_content = true + if config.history and config.history.skip_empty then + ok_content = session_has_content(file) + end + if entry and entry.id and entry.timestamp and ok_content then table.insert(entries, entry) end end @@ -141,7 +182,12 @@ local function open_telescope(entries) return { value = entry, display = display_line(entry), - ordinal = (entry.timestamp or '') .. ' ' .. (entry.cwd or '') .. ' ' .. (entry.id or ''), + ordinal = table.concat({ + entry.timestamp or '', + entry.title or '', + entry.cwd or '', + entry.id or '', + }, ' '), } end, }), diff --git a/lua/codex/init.lua b/lua/codex/init.lua index 2645734..b0bccc7 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -57,6 +57,7 @@ local config = { open_session_in_panel = false, -- if true, resume from history opens chat in side panel persist_pin = true, persist_last = true, + skip_empty = true, -- hide history entries with no chat content }, } From 8e90baa6a94001dca70ce7dde48fb61a668e5d0a Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Fri, 6 Feb 2026 14:09:46 +0100 Subject: [PATCH 7/9] Open side panel when starting a new chat --- README.md | 2 + lua/codex/init.lua | 128 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2bef549..7339b6f 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ return { model = nil, -- Optional: pass a string to use a specific model (e.g., 'o3-mini') autoinstall = true, -- Automatically install the Codex CLI if not found panel = false, -- Open Codex in a side-panel (vertical split) instead of floating window + open_new_session_in_panel = false, -- New sessions open in side panel even if panel=false + open_new_session_in_panel_on_enter = false, -- New sessions start floating and move to panel on first Enter use_buffer = false, -- Capture Codex stdout into a normal buffer instead of a terminal buffer auto_insert = true, -- Enter terminal mode on open/focus (floating) panel_auto_insert = false,-- Enter insert mode in side-panel (default: stay in normal mode) diff --git a/lua/codex/init.lua b/lua/codex/init.lua index f28382b..d8d904d 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -4,6 +4,9 @@ local state = require 'codex.state' local M = {} local apply_quit_keymaps +local apply_terminal_keymaps +local move_buf_to_panel +local update_winbar local config local function close_win_safe(win) @@ -80,6 +83,8 @@ config = { model = nil, -- Default to the latest model autoinstall = true, panel = false, -- if true, open Codex in a side-panel instead of floating window + open_new_session_in_panel = false, -- if true, new sessions open in side panel even if panel=false + open_new_session_in_panel_on_enter = false, -- if true, new sessions start floating and move to panel on first Enter use_buffer = false, -- if true, capture Codex stdout into a normal buffer instead of a terminal auto_insert = true, -- if true, enter terminal mode on open/focus panel_auto_insert = false, -- default: side panel opens in normal mode @@ -183,22 +188,7 @@ function M.setup(user_config) vim.keymap.set('t', config.keymaps.term_normal, [[]], { buffer = buf, silent = true }) end apply_quit_keymaps(buf) - if vim.bo[buf].buftype == 'terminal' then - local function send_to_term(keys) - local job_id = vim.b[buf].terminal_job_id - if job_id then - vim.api.nvim_chan_send(job_id, keys) - end - end - vim.keymap.set('n', '', function() - send_to_term('\n') - end, { buffer = buf, silent = true }) - for _, key in ipairs({ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }) do - vim.keymap.set('n', key, function() - send_to_term(key) - end, { buffer = buf, silent = true }) - end - end + apply_terminal_keymaps(buf) if config.auto_insert and not vim.b[buf].codex_no_auto_insert then vim.schedule(function() @@ -209,6 +199,17 @@ function M.setup(user_config) end end, }) + vim.api.nvim_create_autocmd('TermOpen', { + group = group, + pattern = '*', + callback = function(args) + local buf = args.buf + if vim.bo[buf].filetype ~= 'codex' then + return + end + apply_terminal_keymaps(buf) + end, + }) if config.auto_insert then local auto_group = vim.api.nvim_create_augroup('CodexAutoInsert', { clear = true }) vim.api.nvim_create_autocmd({ 'BufEnter', 'WinEnter', 'TermOpen', 'TermEnter' }, { @@ -257,6 +258,43 @@ apply_quit_keymaps = function(buf) end end +apply_terminal_keymaps = function(buf) + if vim.bo[buf].buftype ~= 'terminal' then + return + end + local function send_to_term(keys) + local job_id = vim.b[buf].terminal_job_id + if job_id then + vim.api.nvim_chan_send(job_id, keys) + return true + end + return false + end + vim.keymap.set('t', '', function() + local moved = vim.b[buf].codex_move_to_panel_on_enter + if moved then + vim.b[buf].codex_move_to_panel_on_enter = nil + end + local ok = send_to_term('\r') + if not ok then + vim.api.nvim_feedkeys('\r', 'n', false) + end + if moved then + vim.defer_fn(function() + move_buf_to_panel(buf) + end, 10) + end + end, { buffer = buf, silent = true }) + vim.keymap.set('n', '', function() + send_to_term('\n') + end, { buffer = buf, silent = true }) + for _, key in ipairs({ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }) do + vim.keymap.set('n', key, function() + send_to_term(key) + end, { buffer = buf, silent = true }) + end +end + local function open_window(buf) local target_buf = buf or state.buf local width = math.floor(vim.o.columns * config.width) @@ -379,7 +417,43 @@ local function open_panel(buf) state.panel_win = win end -local function update_winbar(win) +move_buf_to_panel = function(buf) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + local cur = vim.api.nvim_get_current_win() + local cur_cfg = vim.api.nvim_win_get_config(cur) + local was_float = cur_cfg and cur_cfg.relative ~= '' + if state.panel_win and vim.api.nvim_win_is_valid(state.panel_win) then + vim.api.nvim_set_current_win(state.panel_win) + vim.api.nvim_win_set_buf(state.panel_win, buf) + state.win = state.panel_win + update_winbar(state.win) + if was_float and cur ~= state.panel_win and vim.api.nvim_win_is_valid(cur) then + close_win_safe(cur) + end + return + end + if was_float then + for _, w in ipairs(vim.api.nvim_list_wins()) do + local cfg = vim.api.nvim_win_get_config(w) + if cfg and cfg.relative == '' then + vim.api.nvim_set_current_win(w) + break + end + end + end + open_panel(buf) + update_winbar(state.win) + if config.panel_auto_insert and vim.bo[buf].buftype == 'terminal' then + vim.cmd('startinsert') + end + if was_float and cur ~= state.panel_win and vim.api.nvim_win_is_valid(cur) then + close_win_safe(cur) + end +end + +update_winbar = function(win) if not win or not vim.api.nvim_win_is_valid(win) then return end local buf = vim.api.nvim_win_get_buf(win) local ft = vim.bo[buf].filetype @@ -424,7 +498,19 @@ local function resolve_check_cmd(cmd) end function M.open(cmd_args, opts) - local use_panel = (opts and opts.panel ~= nil) and opts.panel or config.panel + local new_session = cmd_args == nil + local use_panel + if opts and opts.panel ~= nil then + use_panel = opts.panel + elseif config.panel then + use_panel = true + elseif new_session and config.open_new_session_in_panel_on_enter then + use_panel = false + elseif new_session and config.open_new_session_in_panel then + use_panel = true + else + use_panel = false + end local force_terminal = opts and opts.force_terminal or false local function resolve_auto_insert() if opts and opts.no_auto_insert then @@ -563,6 +649,9 @@ function M.open(cmd_args, opts) state.buf = create_clean_buf() end vim.b[state.buf].codex_no_auto_insert = not should_auto_insert + if new_session and config.open_new_session_in_panel_on_enter and not use_panel and not use_buffer then + vim.b[state.buf].codex_move_to_panel_on_enter = true + end if use_panel then open_panel() else open_window() end update_winbar(state.win) @@ -602,9 +691,12 @@ function M.open(cmd_args, opts) local norm = strip_ansi(line) if norm:match('stdin is not a terminal') or norm:match('cursor position could not be read') then needs_tty_fallback = true + -- Suppress noisy non-tty errors; we will fall back to a terminal. + goto continue end append_lines(state.buf, { '[ERR] ' .. norm }) end + ::continue:: end end, on_exit = function(_, code) From 4fb27b95580fba6f66cdbb659c729560e1af789a Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Fri, 6 Feb 2026 14:26:49 +0100 Subject: [PATCH 8/9] improvements --- lua/codex/init.lua | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lua/codex/init.lua b/lua/codex/init.lua index d8d904d..a62f9f5 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -445,8 +445,19 @@ move_buf_to_panel = function(buf) end open_panel(buf) update_winbar(state.win) - if config.panel_auto_insert and vim.bo[buf].buftype == 'terminal' then - vim.cmd('startinsert') + if vim.bo[buf].buftype == 'terminal' and (config.panel_auto_insert or vim.b[buf].codex_force_insert_on_move) then + local target_win = state.win + vim.b[buf].codex_force_insert_on_move = nil + vim.schedule(function() + if not (target_win and vim.api.nvim_win_is_valid(target_win)) then + return + end + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + vim.api.nvim_set_current_win(target_win) + vim.cmd('startinsert') + end) end if was_float and cur ~= state.panel_win and vim.api.nvim_win_is_valid(cur) then close_win_safe(cur) @@ -651,6 +662,7 @@ function M.open(cmd_args, opts) vim.b[state.buf].codex_no_auto_insert = not should_auto_insert if new_session and config.open_new_session_in_panel_on_enter and not use_panel and not use_buffer then vim.b[state.buf].codex_move_to_panel_on_enter = true + vim.b[state.buf].codex_force_insert_on_move = true end if use_panel then open_panel() else open_window() end From c8e846a9f2574365f968fb72221fd26737e84818 Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Fri, 6 Feb 2026 14:28:34 +0100 Subject: [PATCH 9/9] add missing behaviour --- README.md | 2 ++ lua/codex/init.lua | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/README.md b/README.md index 7339b6f..4b94950 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ return { last = 'cl', -- Resume last Codex session pin = 'cp', -- Pin current Codex session pinned = 'cP', -- Resume pinned Codex session + panel_toggle = nil, -- Toggle Codex side panel }, -- Disable internal default keymap (cc -> :CodexToggle) border = 'rounded', -- Options: 'single', 'double', or 'rounded' width = 0.8, -- Width of the floating window (0.0 to 1.0) @@ -81,6 +82,7 @@ return { ### Usage: - Call `:Codex` (or `:CodexToggle`) to open or close the Codex popup or side-panel. +- Call `:CodexPanelToggle` to toggle the Codex side panel. - Call `:CodexHistory` to browse past Codex sessions and resume them. - Call `:CodexHistoryToggle` to switch between the live Codex session and history in the same window. - Call `:CodexLast` to resume the most recent Codex session. diff --git a/lua/codex/init.lua b/lua/codex/init.lua index a62f9f5..4726d38 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -74,6 +74,7 @@ config = { last = 'cl', pin = 'cp', pinned = 'cP', + panel_toggle = nil, -- Keybind to toggle Codex side panel }, border = 'single', width = 0.8, @@ -127,6 +128,10 @@ function M.setup(user_config) M.toggle() end, { desc = 'Toggle Codex popup (alias)' }) + vim.api.nvim_create_user_command('CodexPanelToggle', function() + M.toggle_panel() + end, { desc = 'Toggle Codex side panel' }) + vim.api.nvim_create_user_command('CodexHistory', function() M.open_history(false) end, { desc = 'Browse Codex chat history' }) @@ -177,6 +182,11 @@ function M.setup(user_config) vim.api.nvim_set_keymap('n', config.keymaps.pinned, 'CodexPinned', { noremap = true, silent = true }) end + if config.keymaps.panel_toggle then + vim.api.nvim_set_keymap('n', config.keymaps.panel_toggle, 'CodexPanelToggle', { noremap = true, silent = true }) + vim.api.nvim_set_keymap('t', config.keymaps.panel_toggle, [[CodexPanelToggle]], { noremap = true, silent = true }) + end + -- Toggle history from the live Codex terminal local group = vim.api.nvim_create_augroup('CodexKeymaps', { clear = true }) vim.api.nvim_create_autocmd('FileType', { @@ -919,6 +929,28 @@ function M.toggle() end end +function M.toggle_panel() + if state.panel_win and vim.api.nvim_win_is_valid(state.panel_win) then + close_win_safe(state.panel_win) + if state.win == state.panel_win then + state.win = nil + end + state.panel_win = nil + return + end + + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + open_panel(state.buf) + update_winbar(state.win) + if config.panel_auto_insert and vim.bo[state.buf].buftype == 'terminal' then + vim.cmd('startinsert') + end + return + end + + M.open(nil, { panel = true }) +end + function M.get_config() return config end