Skip to content
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 = {
{
'<leader>cc', -- Change this to your preferred keybinding
Expand All @@ -43,20 +44,52 @@ return {
opts = {
keymaps = {
toggle = nil, -- Keybind to toggle Codex window (Disabled by default, watch out for conflicts)
quit = '<C-q>', -- Keybind to close the Codex window (default: Ctrl + q)
quit = { '<C-q>', '<C-c>', 'ZZ' }, -- Keybinds to close the Codex window
history = '<leader>ch', -- Keybind to toggle Codex history
history_list = nil, -- Keybind to open Codex history list directly
term_normal = '<Esc><Esc>', -- Enter terminal-normal mode
last = '<leader>cl', -- Resume last Codex session
pin = '<leader>cp', -- Pin current Codex session
pinned = '<leader>cP', -- Resume pinned Codex session
panel_toggle = nil, -- Toggle Codex side panel
}, -- Disable internal default keymap (<leader>cc -> :CodexToggle)
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
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)
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
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
},
},
}```

### 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.
- 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.
Expand Down
328 changes: 328 additions & 0 deletions lua/codex/history.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
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,
title = payload.title,
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 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', '')
return t:sub(1, 16)
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 ''

if source ~= '' then
source = '[' .. source .. ']'
end

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)
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)
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

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')
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',
finder = finders.new_table({
results = entries,
entry_maker = function(entry)
return {
value = entry,
display = display_line(entry),
ordinal = table.concat({
entry.timestamp or '',
entry.title or '',
entry.cwd or '',
entry.id or '',
}, ' '),
}
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()
if not selection or not selection.value then
return
end
actions.close(prompt_bufnr)
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', '<CR>', resume_selected)
map('n', '<CR>', resume_selected)
map('i', '<C-c>', close_picker)
map('n', '<C-c>', close_picker)
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',
'',
}

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
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
vim.keymap.set('n', config.keymaps.history, function()
require('codex').toggle_history()
end, { buffer = buf, silent = true })
end

vim.keymap.set('n', '<CR>', 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
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
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
Loading