diff --git a/README.md b/README.md index 9ce7e20..158d4ce 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ local DEFAULT_CONFIG = { win = { win = 0, split = "below", - height = math.max(1, math.floor(vim.o.lines * 0.3)), }, keymap = { jump = "", @@ -82,6 +81,9 @@ local DEFAULT_CONFIG = { keymap = { toggle = "", refresh = "", + prev = "", + next = "", + detail = "", }, }, } @@ -112,7 +114,7 @@ The window label is automatically `Codex [session-id]`. Codex's built-in Vim mode is too limited for full Neovim editing workflows, so mini.codex offers an optional input pane backed by a real normal buffer. It is not loaded or opened by default. Enable it with an `input` table; mini.codex divides the Codex window into an upper terminal and lower input buffer. -By default, the input opens below the Codex window with a height of 30% of the screen. The `win` table is passed directly to `nvim_open_win()`, so split and floating layouts, dimensions, position, and appearance are controlled by native Neovim window configuration. For a floating Codex window, the default input follows the main window's position and width; use `relative = "win"` for the same behavior with a custom floating configuration. An explicitly positioned `relative = "editor"` configuration is kept unchanged. Resizing one split follows Neovim's native behavior. +By default, the input opens below the Codex window with a height of 30% of the main window. The `win` table is passed directly to `nvim_open_win()`, so split and floating layouts, dimensions, position, and appearance are controlled by native Neovim window configuration. For a floating Codex window, the default input follows the main window's position, width, and 30% height; use `relative = "win"` for the same behavior with a custom floating configuration. An explicitly positioned `relative = "editor"` configuration is kept unchanged. Resizing one split follows Neovim's native behavior. The buffer uses the `markdown.codex` filetype, allowing the [codex-prompt-lsp](https://github.com/ishi-o/codex-prompt-lsp) Neovim adapter to attach automatically and provide completions. The adapter is optional; mini.codex does not depend on it. @@ -179,11 +181,11 @@ local opts = { require("mini.codex").setup(opts) ``` -`mini.codex.Config`, `mini.codex.InputConfig`, and `mini.codex.OutputConfig` are exported LuaLS annotations. `win` uses Neovim's `vim.api.keyset.win_config`, so native window fields also receive completion and diagnostics. +`mini.codex.Config`, `mini.codex.InputConfig`, `mini.codex.OutputConfig`, `mini.codex.OutputChatTurnKind`, and `mini.codex.OutputChatTurnLabel` are exported LuaLS annotations. `win` uses Neovim's `vim.api.keyset.win_config`, so native window fields also receive completion and diagnostics. ## Output preview (optional) -The optional output preview displays the response history of the active Codex session in a dedicated read-only window. It is disabled by default. Each turn includes its question and response, and the window title identifies the latest turn. +The optional output preview displays one complete chat from the active Codex session in a dedicated read-only window. It is disabled by default. User and assistant messages are shown in full; every other internal turn is compacted to its type, state, and a short description for fast rendering. Each turn uses a `#` heading and no horizontal separator. Enable it with an `output` table: @@ -204,6 +206,9 @@ local opts = { keymap = { toggle = "", refresh = "", + prev = "", -- show the previous chat + next = "", -- show the next chat + detail = "", -- show or dismiss the turn under the cursor }, }, } @@ -211,7 +216,7 @@ local opts = { require("mini.codex").setup(opts) ``` -The `output.win` table uses Neovim's native window configuration. By default, the preview is a split to the right of the Codex window, with a width of 50% of the Codex window's width and the same height. An explicit `width` or `height` overrides the default. For a floating preview, use `relative = "editor"` with the desired position and dimensions. The toggle key opens or hides the preview, and the refresh key (default ``) updates the response history. +The `output.win` table uses Neovim's native window configuration. By default, the preview is a split to the right of the Codex window, with a width of 50% of the Codex window's width and the same height. When both input and output are enabled, the preview occupies the full right column, while the Codex window stays above the input pane on the left. The combined region keeps the bounds that the Codex/input stack had before the preview opened. An explicit `split`, `win`, `width`, or `height` is respected, including left-side and horizontal splits. For a floating preview, use `relative = "editor"` with the desired position and dimensions. Toggling preserves the surrounding layout, the main window view, and preview sizes changed manually after opening. The refresh key (default ``) reloads the session while preserving the selected chat when possible. Put the cursor on a turn and press the detail key (default ``) to load only that turn's full database record; press it again to return to the compact chat. ## Commands @@ -226,4 +231,4 @@ The `output.win` table uses Neovim's native window configuration. By default, th :Codex stop # close current session ``` -Session navigation reads Codex's local `$CODEX_HOME/state_5.sqlite` database, falling back to `~/.codex/state_5.sqlite`. +Session and output navigation read Codex's local `$CODEX_HOME/state_5.sqlite` and `$CODEX_HOME/thread_history_1.sqlite` databases, falling back to `~/.codex/`. diff --git a/doc/mini-codex.txt b/doc/mini-codex.txt index 8b3f67d..b2bbb0d 100644 --- a/doc/mini-codex.txt +++ b/doc/mini-codex.txt @@ -88,6 +88,9 @@ The complete default configuration is: keymap = { toggle = "", refresh = "", + prev = "", + next = "", + detail = "", }, }, } @@ -125,13 +128,13 @@ is not loaded or opened by default. Enable it with an `input` table; mini.codex divides the Codex window into an upper terminal and lower input buffer. By default, the input opens below the Codex window with a height of 30% of the -screen. The `win` table is passed directly to `nvim_open_win()`, so split and -floating layouts, dimensions, position, and appearance are controlled by native -Neovim window configuration. For a floating Codex window, the default input -follows the main window’s position and width; use `relative = "win"` for the -same behavior with a custom floating configuration. An explicitly positioned -`relative = "editor"` configuration is kept unchanged. Resizing one split -follows Neovim’s native behavior. +main window. The `win` table is passed directly to `nvim_open_win()`, so split +and floating layouts, dimensions, position, and appearance are controlled by +native Neovim window configuration. For a floating Codex window, the default +input follows the main window’s position, width, and 30% height; use +`relative = "win"` for the same behavior with a custom floating configuration. +An explicitly positioned `relative = "editor"` configuration is kept +unchanged. Resizing one split follows Neovim’s native behavior. The buffer uses the `markdown.codex` filetype, allowing the codex-prompt-lsp Neovim adapter to attach @@ -189,7 +192,6 @@ disable it again: win = { win = 0, split = "below", - height = math.max(1, math.floor(vim.o.lines * 0.3)), }, keymap = { jump = "", -- key that synchronizes and switches inputs @@ -204,18 +206,20 @@ disable it again: require("mini.codex").setup(opts) < -`mini.codex.Config`, `mini.codex.InputConfig`, and `mini.codex.OutputConfig` -are exported LuaLS annotations. `win` uses Neovim’s +`mini.codex.Config`, `mini.codex.InputConfig`, `mini.codex.OutputConfig`, +`mini.codex.OutputChatTurnKind`, and `mini.codex.OutputChatTurnLabel` are +exported LuaLS annotations. `win` uses Neovim’s `vim.api.keyset.win_config`, so native window fields also receive completion and diagnostics. OUTPUT PREVIEW (OPTIONAL) *mini-codex-mini.codex-output-preview-(optional)* -The optional output preview displays the response history of the active Codex -session in a dedicated read-only window. It is disabled by default. Each turn -includes its question and response, and the window title identifies the latest -turn. +The optional output preview displays one complete chat from the active Codex +session in a dedicated read-only window. It is disabled by default. User and +assistant messages are shown in full; every other internal turn is compacted to +its type, state, and a short description for fast rendering. Each turn uses a +`#` heading and no horizontal separator. Enable it with an `output` table: @@ -236,6 +240,9 @@ Enable it with an `output` table: keymap = { toggle = "", refresh = "", + prev = "", -- show the previous chat + next = "", -- show the next chat + detail = "", -- show or dismiss the turn under the cursor }, }, } @@ -245,10 +252,19 @@ Enable it with an `output` table: The `output.win` table uses Neovim’s native window configuration. By default, the preview is a split to the right of the Codex window, with a width of 50% of -the Codex window’s width and the same height. An explicit `width` or `height` -overrides the default. For a floating preview, use `relative = "editor"` with -the desired position and dimensions. The toggle key opens or hides the preview, -and the refresh key (default ``) updates the response history. +the Codex window’s width and the same height. When both input and output are +enabled, the preview remains on the right and occupies the full column, while +the Codex window stays above the input pane on the left. The combined region +keeps the bounds that the Codex/input stack had before the preview opened. An +explicit `split`, `win`, `width`, or `height` is respected, including left-side +and horizontal splits. Toggling preserves the surrounding layout, the main +window view, and preview sizes changed manually after opening. +For a floating preview, use `relative = "editor"` with the desired position and +dimensions. The toggle key opens or hides the preview, and the refresh key +(default ``) reloads the session while preserving the selected chat when +possible. Put the cursor on a turn and press the detail key (default ``) to +load only that turn’s full database record; press it again to return to the +compact chat. COMMANDS *mini-codex-mini.codex-commands* @@ -264,8 +280,8 @@ COMMANDS *mini-codex-mini.codex-commands* :Codex stop # close current session < -Session navigation reads Codex’s local `$CODEX_HOME/state_5.sqlite` database, -falling back to `~/.codex/state_5.sqlite`. +Session and output navigation read Codex’s local `$CODEX_HOME/state_5.sqlite` +and `$CODEX_HOME/thread_history_1.sqlite` databases, falling back to `~/.codex/`. Generated by panvimdoc diff --git a/lua/mini/codex.lua b/lua/mini/codex.lua index 958c2e6..3894cae 100644 --- a/lua/mini/codex.lua +++ b/lua/mini/codex.lua @@ -21,7 +21,6 @@ local DEFAULT_CONFIG = { win = { win = 0, split = "below", - height = math.max(1, math.floor(vim.o.lines * 0.3)), }, keymap = { jump = "", @@ -41,6 +40,9 @@ local DEFAULT_CONFIG = { keymap = { toggle = "", refresh = "", + prev = "", + next = "", + detail = "", }, }, } @@ -58,6 +60,14 @@ local function output_call(method, ...) return output and output[method](...) end +local function merge_component_config(current, opts) + local merged = vim.tbl_deep_extend("force", vim.deepcopy(current), opts) + if opts.win then + merged.win = vim.deepcopy(opts.win) + end + return merged +end + local function codex_executable() local path = vim.fn.exepath("codex") if path == "" then @@ -82,7 +92,7 @@ local function configure_window() end local function open_input() - input_call("open", T.winid, function() + return input_call("open", T.winid, function() return T.jobid end) end @@ -99,8 +109,8 @@ local function open_terminal(win_config) end local function close_terminal() - input_call("close") output_call("close") + input_call("close") if T.winid then pcall(vim.api.nvim_win_close, T.winid, true) end @@ -178,8 +188,8 @@ local function start_job(executable, args, token, fallback) T.jobid = id vim.b[T.bufnr].terminal_job_id = id configure_window() - output_call("attach", T.winid, T.session_id, token) - open_input() + local input_win = open_input() + output_call("attach", T.winid, T.session_id, token, input_win) return id end @@ -206,15 +216,15 @@ local function start_codex(mode) local active = T.bufnr and vim.api.nvim_buf_is_valid(T.bufnr) if active and step == nil and (mode == "" or current_mode == mode) then if T.winid and vim.api.nvim_win_is_valid(T.winid) then - input_call("hide") output_call("hide") + input_call("hide") T.win_config = vim.api.nvim_win_get_config(T.winid) vim.api.nvim_win_hide(T.winid) T.winid = nil else open_terminal(T.win_config) - open_input() - output_call("attach", T.winid, T.session_id, T.token) + local input_win = open_input() + output_call("attach", T.winid, T.session_id, T.token, input_win) end return elseif active then @@ -277,13 +287,9 @@ end ---@param opts? mini.codex.Config function M.setup(opts) opts = opts or {} - config.win = opts.win or config.win + config.win = vim.deepcopy(opts.win or config.win) if opts.input then - local input_config = vim.tbl_deep_extend("force", config.input, opts.input) - if opts.input.win then - input_config.win = vim.deepcopy(opts.input.win) - end - config.input = input_config + config.input = merge_component_config(config.input, opts.input) input_call("close") input = nil if config.input.enabled then @@ -292,18 +298,14 @@ function M.setup(opts) end end if opts.output then - local output_config = vim.tbl_deep_extend("force", config.output, opts.output) - if opts.output.win then - output_config.win = vim.deepcopy(opts.output.win) - end - config.output = output_config + config.output = merge_component_config(config.output, opts.output) output_call("close") output = nil if config.output.enabled then output = require("mini.codex.output") output.setup(config.output) if T.winid then - output.attach(T.winid, T.session_id, T.token) + output.attach(T.winid, T.session_id, T.token, input_call("window")) end end end diff --git a/lua/mini/codex/input.lua b/lua/mini/codex/input.lua index abe1e15..f3921f1 100644 --- a/lua/mini/codex/input.lua +++ b/lua/mini/codex/input.lua @@ -33,7 +33,6 @@ local defaults = { win = { win = 0, split = "below", - height = math.max(1, math.floor(vim.o.lines * 0.3)), }, keymap = { jump = "", @@ -61,6 +60,18 @@ local lsp_client_id local adaptive_float = false local syncing_float = false local available = true +local default_height_ratio = 0.3 +local saved_split_height +local buffer_option = vim.api.nvim_win_resize and "buf" or "buffer" + +local function buffer_opts(buf, opts) + opts[buffer_option] = buf + return opts +end + +local function default_height(main_config) + return math.max(1, math.floor(main_config.height * default_height_ratio)) +end local function buffer_text() if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then @@ -106,6 +117,7 @@ local function adaptive_float_config(opts, main_config) opts.row = main_config.height + border_height(main_config) + (row_offset or 0) opts.col = opts.col or 0 opts.width = opts.width or main_config.width + opts.height = opts.height or default_height(main_config) opts.split, opts.vertical = nil, nil return opts end @@ -130,6 +142,7 @@ local function sync_float() row = opts.row, col = opts.col, width = opts.width, + height = opts.height, }) syncing_float = false end @@ -140,6 +153,8 @@ local function show_input() end local opts = vim.deepcopy(config.win or {}) local main_config = vim.api.nvim_win_get_config(main_winid) + local normal_split = main_config.relative == "" and (opts.relative == nil or opts.relative == "") + opts.height = (normal_split and saved_split_height) or opts.height or default_height(main_config) adaptive_float = false if opts.relative == "win" or (main_config.relative ~= "" and (opts.relative == nil or opts.relative == "")) then opts = adaptive_float_config(opts, main_config) @@ -155,12 +170,19 @@ end function M.hide() if winid and vim.api.nvim_win_is_valid(winid) then + if vim.api.nvim_win_get_config(winid).relative == "" then + saved_split_height = vim.api.nvim_win_get_height(winid) + end vim.api.nvim_win_hide(winid) end winid = nil adaptive_float = false end +function M.window() + return winid and vim.api.nvim_win_is_valid(winid) and winid or nil +end + ---@param mode mini.codex.EditorMode local function request_editor(mode, key) if mode == "edit" and editor_path then @@ -175,9 +197,9 @@ local function request_editor(mode, key) pending, replacement = mode, mode == "apply" and buffer_text() or nil vim.api.nvim_set_current_win(main_winid) if key then - vim.fn.chansend(jobid, key) + vim.api.nvim_chan_send(jobid, key) end - vim.fn.chansend(jobid, "\7") + vim.api.nvim_chan_send(jobid, "\7") end local function apply() @@ -218,6 +240,11 @@ local function schedule_history() end, 50) end +local function cancel_completion() + local completion = vim.fn.complete_info() + return (vim.fn.pumvisible() == 1 or completion.mode ~= "") and "" or "" +end + ---@param direction mini.codex.HistoryDirection local function request_history(direction) history_queue[#history_queue + 1] = direction @@ -230,9 +257,7 @@ local function request_history(direction) end local function apply_mapping() - local completion = vim.fn.complete_info() - local cancel = (vim.fn.pumvisible() == 1 or completion.mode ~= "") and "" or "" - return cancel .. "lua require('mini.codex.input')._apply()" + return cancel_completion() .. "lua require('mini.codex.input')._apply()" end M._apply = apply @@ -363,7 +388,7 @@ function M.open(main_win, jobid_fn) vim.bo[bufnr].filetype = "markdown.codex" start_lsp(bufnr) vim.api.nvim_create_autocmd("WinLeave", { - buffer = bufnr, + [buffer_option] = bufnr, callback = function() local leaving = vim.api.nvim_get_current_win() vim.schedule(function() @@ -378,10 +403,10 @@ function M.open(main_win, jobid_fn) local key = config.keymap.jump if type(key) == "string" and key ~= "" then - local opts = { buffer = bufnr, silent = true } + local opts = buffer_opts(bufnr, { silent = true }) vim.keymap.set({ "n", "t" }, key, function() request_editor("edit") - end, { buffer = vim.api.nvim_win_get_buf(main_win), silent = true }) + end, buffer_opts(vim.api.nvim_win_get_buf(main_win), { silent = true })) vim.keymap.set("n", key, apply, opts) vim.keymap.set("i", key, apply_mapping, vim.tbl_extend("force", opts, { expr = true })) end @@ -389,14 +414,12 @@ function M.open(main_win, jobid_fn) local history_keys = { prev = config.keymap.prev, next = config.keymap.next } for direction, history_key in pairs(history_keys) do if type(history_key) == "string" and history_key ~= "" then - local history_opts = { buffer = bufnr, silent = true } + local history_opts = buffer_opts(bufnr, { silent = true }) vim.keymap.set("n", history_key, function() request_history(direction) end, history_opts) vim.keymap.set("i", history_key, function() - local completion = vim.fn.complete_info() - local cancel = (vim.fn.pumvisible() == 1 or completion.mode ~= "") and "" or "" - return cancel .. "lua require('mini.codex.input')._history('" .. direction .. "')" + return cancel_completion() .. "lua require('mini.codex.input')._history('" .. direction .. "')" end, vim.tbl_extend("force", history_opts, { expr = true })) end end @@ -420,6 +443,7 @@ function M.close() nil, nil, nil, nil, nil, nil, nil history_queue, history_active, history_dispatch_scheduled = {}, false, false adaptive_float, syncing_float = false, false + saved_split_height = nil end vim.api.nvim_create_autocmd({ "VimResized", "WinResized" }, { callback = sync_float }) diff --git a/lua/mini/codex/output.lua b/lua/mini/codex/output.lua index 9b9fb2d..2cac62d 100644 --- a/lua/mini/codex/output.lua +++ b/lua/mini/codex/output.lua @@ -3,6 +3,9 @@ local M = {} ---@class mini.codex.OutputKeymap ---@field toggle string ---@field refresh string +---@field prev string +---@field next string +---@field detail string ---@class mini.codex.OutputConfig ---@field enabled? boolean @@ -22,6 +25,9 @@ local defaults = { keymap = { toggle = "", refresh = "", + prev = "", + next = "", + detail = "", }, } local config = vim.deepcopy(defaults) @@ -34,11 +40,73 @@ local output_bound_keys = {} local adaptive_window = false local syncing_window = false local output_title = "Codex output" +local output_chats = {} +local selected_chat +local turn_ranges = {} +local detail_turn +local detail_cursor +local input_winid +local saved_output_width +local saved_output_height +local saved_split_minimums = {} +local hiding_window = false +local split_state +local buffer_option = vim.api.nvim_win_resize and "buf" or "buffer" local function is_valid_window(win) return win and vim.api.nvim_win_is_valid(win) end +local function buffer_opts(buf, opts) + opts[buffer_option] = buf + return opts +end + +local function resize_window(win, width, height) + if vim.api.nvim_win_resize then + return vim.api.nvim_win_resize(win, width or -1, height or -1, {}) + end + return vim.api.nvim_win_call(win, function() + if width then + vim.cmd("vertical resize " .. width) + end + if height then + vim.cmd("resize " .. height) + end + end) +end + +local function relax_split_minimum(vertical) + local option = vertical and "winwidth" or "winheight" + if saved_split_minimums[option] == nil and vim.o[option] > 1 then + saved_split_minimums[option] = vim.o[option] + vim.o[option] = 1 + end +end + +local function restore_split_minimum() + for option, value in pairs(saved_split_minimums) do + vim.o[option] = value + end + saved_split_minimums = {} +end + +local function save_view(win) + if not is_valid_window(win) then + return + end + local ok, view = pcall(vim.api.nvim_win_call, win, vim.fn.winsaveview) + return ok and view or nil +end + +local function restore_view(win, view) + if view and is_valid_window(win) then + pcall(vim.api.nvim_win_call, win, function() + vim.fn.winrestview(view) + end) + end +end + local function set_window_options(win) vim.wo[win].wrap = true vim.wo[win].linebreak = true @@ -55,7 +123,7 @@ local function clear_keymaps(buf, bound_keys, modes) for _, key in pairs(bound_keys) do if type(key) == "string" and key ~= "" then for _, mode in ipairs(modes) do - pcall(vim.keymap.del, mode, key, { buffer = buf }) + pcall(vim.keymap.del, mode, key, buffer_opts(buf, {})) end end end @@ -66,31 +134,35 @@ end local function map_key(buf, mode, key, callback) if type(key) == "string" and key ~= "" then - vim.keymap.set(mode, key, callback, { buffer = buf, silent = true, nowait = true }) + vim.keymap.set(mode, key, callback, buffer_opts(buf, { silent = true, nowait = true })) + end +end + +local function bind_keymaps(buf, modes, bound_keys, names) + local keymap = config.keymap + clear_keymaps(buf, bound_keys, type(modes) == "table" and modes or { modes }) + for _, name in ipairs(names) do + map_key(buf, modes, keymap[name], M[name]) + bound_keys[name] = keymap[name] end end local function set_main_keymaps() - if not main_winid or not vim.api.nvim_win_is_valid(main_winid) then - return + if is_valid_window(main_winid) then + bind_keymaps(vim.api.nvim_win_get_buf(main_winid), { "n", "t" }, main_bound_keys, { + "toggle", + "refresh", + "prev", + "next", + }) end - local buf = vim.api.nvim_win_get_buf(main_winid) - clear_keymaps(buf, main_bound_keys, { "n", "t" }) - local keymap = config.keymap - map_key(buf, { "n", "t" }, keymap.toggle, M.toggle) - map_key(buf, { "n", "t" }, keymap.refresh, M.refresh) - main_bound_keys.toggle, main_bound_keys.refresh = keymap.toggle, keymap.refresh end local function set_output_keymaps() if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end - clear_keymaps(bufnr, output_bound_keys, { "n" }) - local keymap = config.keymap - map_key(bufnr, "n", keymap.toggle, M.toggle) - map_key(bufnr, "n", keymap.refresh, M.refresh) - output_bound_keys.toggle, output_bound_keys.refresh = keymap.toggle, keymap.refresh + bind_keymaps(bufnr, "n", output_bound_keys, { "toggle", "refresh", "prev", "next", "detail" }) end local function set_buffer_name() @@ -108,37 +180,50 @@ local function compact_text(text, max_chars) return compact end -local function set_output_title(turns) - local latest = turns[#turns] - if not latest then +local function set_output_title(chat) + if not chat then output_title = "Codex output" else - local question = compact_text(latest.question, 60) + local question = compact_text(chat.question, 60) question = question:gsub("%%", "%%%%") - output_title = question == "" and string.format("Codex output · turn %d/%d", latest.turn, #turns) - or string.format("Codex output · turn %d/%d · for %s", latest.turn, #turns, question) + output_title = question == "" and string.format("Codex output · chat %d/%d", chat.chat, #output_chats) + or string.format("Codex output · chat %d/%d · for %s", chat.chat, #output_chats, question) end if is_valid_window(winid) then vim.wo[winid].winbar = output_title end end -local function render_history(turns) - if #turns == 0 then +local function render_chat(chat) + if not chat then return { "No Codex output available yet." } end - local lines = {} - for index, turn in ipairs(turns) do + local question = compact_text(chat.question, 80) + local heading = string.format("## Chat %d", chat.chat) + if question ~= "" then + heading = heading .. " · for " .. question + end + local lines = { heading, "" } + for index in pairs(turn_ranges) do + turn_ranges[index] = nil + end + for index, turn in ipairs(chat.turns) do if index > 1 then - vim.list_extend(lines, { "", "---", "" }) + lines[#lines + 1] = "" end - local question = compact_text(turn.question, 80) - local heading = string.format("## Turn %d", turn.turn) - if question ~= "" then - heading = heading .. " · for " .. question + local start_line = #lines + 1 + local heading = string.format("# Turn %d · %s", index, turn.label) + if turn.status then + heading = heading .. " · " .. turn.status end vim.list_extend(lines, { heading, "" }) - vim.list_extend(lines, vim.split(turn.response, "\n", { plain = true })) + lines[#lines + 1] = turn.kind == "command" and "````sh" or "````markdown" + vim.list_extend(lines, vim.split(turn.text, "\n", { plain = true })) + lines[#lines + 1] = "````" + turn_ranges[index] = { start_line = start_line, end_line = #lines } + end + if #lines == 0 then + return { "No Codex output available yet." } end return lines end @@ -158,18 +243,32 @@ local function ensure_buffer() return bufnr end ----@param turns mini.codex.OutputTurn[] -local function set_text(turns) +local function set_buffer_lines(lines) ensure_buffer() - set_buffer_name() - local lines = render_history(turns) - set_output_title(turns) vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false vim.bo[bufnr].modifiable = false end +---@param chats mini.codex.OutputChat[] +local function set_text(chats) + ensure_buffer() + set_buffer_name() + local chat = chats[selected_chat] + local lines = render_chat(chat) + detail_turn = nil + detail_cursor = nil + set_output_title(chat) + set_buffer_lines(lines) +end + +local function move_cursor_to_start() + if is_valid_window(winid) then + pcall(vim.api.nvim_win_set_cursor, winid, { 1, 0 }) + end +end + local function border_width(win_config) return win_config.border and win_config.border ~= "none" and 2 or 0 end @@ -247,6 +346,13 @@ local function open_window() ensure_buffer() local opts = vim.deepcopy(config.win or {}) local main_config = vim.api.nvim_win_get_config(main_winid) + local input_target = is_valid_window(input_winid) and input_winid or nil + if saved_output_width then + opts.width = saved_output_width + end + if saved_output_height then + opts.height = saved_output_height + end local normal_split = opts.relative == nil or opts.relative == "" adaptive_window = should_adapt(opts, main_config) if adaptive_window then @@ -254,24 +360,111 @@ local function open_window() elseif normal_split then opts = normal_split_config(opts, main_config) end - winid = vim.api.nvim_open_win(bufnr, true, opts) - if not adaptive_window and normal_split and is_vertical_split(opts) then - pcall(vim.api.nvim_win_set_width, winid, opts.width) + local vertical = normal_split and is_vertical_split(opts) + local target = normal_split and opts.win or nil + local stacked = input_target + and vim.api.nvim_win_get_config(input_target).relative == "" + and main_config.relative == "" + and target == main_winid + and vertical + and (opts.split == "left" or opts.split == "right") + local stack_requested = stacked + local extent = target and (vertical and vim.api.nvim_win_get_width(target) or vim.api.nvim_win_get_height(target)) + local input_height = stacked and vim.api.nvim_win_get_height(input_target) or nil + local main_view = normal_split and save_view(main_winid) or nil + local main_width = normal_split and vim.api.nvim_win_get_width(main_winid) or nil + local main_height = normal_split and vim.api.nvim_win_get_height(main_winid) or nil + if stacked then + opts.win = input_target + end + if normal_split then + relax_split_minimum(vertical) + end + winid = vim.api.nvim_open_win(bufnr, not stacked, opts) + if stacked then + local moved, result = pcall(vim.fn.win_splitmove, main_winid, input_target, { + vertical = false, + rightbelow = false, + }) + stacked = moved and result == 0 + end + if stacked then + pcall(resize_window, input_target, nil, input_height) + target = main_winid + elseif stack_requested then + target = input_target + end + if normal_split then + if vertical then + pcall(resize_window, winid, opts.width) + pcall(resize_window, target, math.max(1, extent - vim.api.nvim_win_get_width(winid) - 1)) + else + pcall(resize_window, winid, nil, opts.height) + pcall(resize_window, target, nil, math.max(1, extent - vim.api.nvim_win_get_height(winid) - 1)) + end + local restore_width = target ~= main_winid or not vertical + local restore_height = target ~= main_winid or vertical + pcall(resize_window, main_winid, restore_width and main_width or nil, restore_height and main_height or nil) + split_state = { target = target, vertical = vertical, stacked = stacked } + restore_view(main_winid, main_view) + elseif not adaptive_window then + restore_split_minimum() end set_window_options(winid) set_output_keymaps() + if stacked and is_valid_window(winid) then + vim.api.nvim_set_current_win(winid) + end end function M.hide() if is_valid_window(winid) then + local state = split_state + local split_target = state and is_valid_window(state.target) and state.target or nil + local vertical = state and state.vertical or false + local main_view = save_view(main_winid) + local target_view = split_target ~= main_winid and save_view(split_target) or nil + local main_width = is_valid_window(main_winid) and vim.api.nvim_win_get_width(main_winid) or nil + local main_height = is_valid_window(main_winid) and vim.api.nvim_win_get_height(main_winid) or nil + local input_height = state + and state.stacked + and is_valid_window(input_winid) + and vim.api.nvim_win_get_height(input_winid) + or nil + local split_extent = split_target + and (vertical and vim.api.nvim_win_get_width(split_target) + vim.api.nvim_win_get_width(winid) + 1 or vim.api.nvim_win_get_height( + split_target + ) + vim.api.nvim_win_get_height(winid) + 1) + or nil + saved_output_width = vim.api.nvim_win_get_width(winid) + saved_output_height = vim.api.nvim_win_get_height(winid) + local equalalways = vim.o.equalalways + vim.o.equalalways = false + hiding_window = true vim.api.nvim_win_hide(winid) + hiding_window = false + vim.o.equalalways = equalalways + if split_extent then + pcall(resize_window, split_target, vertical and split_extent or nil, vertical and nil or split_extent) + end + if is_valid_window(main_winid) then + local restore_width = split_target ~= main_winid or not vertical + local restore_height = split_target ~= main_winid or vertical + pcall(resize_window, main_winid, restore_width and main_width or nil, restore_height and main_height or nil) + end + if input_height and is_valid_window(input_winid) then + pcall(resize_window, input_winid, nil, input_height) + end + restore_view(main_winid, main_view) + restore_view(split_target, target_view) end winid = nil - adaptive_window = false + adaptive_window, split_state = false, nil + restore_split_minimum() end ---@param token integer? ----@return mini.codex.OutputTurn[]? +---@return mini.codex.OutputChat[]? function M.refresh(token) if not config.enabled then return @@ -279,9 +472,82 @@ function M.refresh(token) if token and session_token and token ~= session_token then return end - local turns = storage.output_history(session_id) - set_text(turns) - return turns + local previous = output_chats[selected_chat] + local chats = storage.output_history(session_id) + selected_chat = #chats + if previous then + for index, chat in ipairs(chats) do + local same_chat_id = chat.turn_id ~= nil and chat.turn_id == previous.turn_id + if same_chat_id or chat.chat == previous.chat then + selected_chat = index + break + end + end + end + output_chats = chats + set_text(chats) + return chats +end + +function M.prev() + if not config.enabled or selected_chat == nil or selected_chat <= 1 then + return + end + selected_chat = selected_chat - 1 + set_text(output_chats) + move_cursor_to_start() +end + +function M.next() + if not config.enabled or selected_chat == nil or selected_chat >= #output_chats then + return + end + selected_chat = selected_chat + 1 + set_text(output_chats) + move_cursor_to_start() +end + +function M.detail() + if not config.enabled or not is_valid_window(winid) then + return + end + if detail_turn then + local cursor = detail_cursor + set_text(output_chats) + if cursor then + pcall(vim.api.nvim_win_set_cursor, winid, cursor) + end + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(winid)[1] + local chat = output_chats[selected_chat] + local turn_index + for index, range in pairs(turn_ranges) do + if cursor_line >= range.start_line and cursor_line <= range.end_line then + turn_index = index + break + end + end + local turn = chat and chat.turns[turn_index] + if not turn then + return + end + + local text = storage.output_turn_detail(session_id, turn, chat.turn_id) + if not text or text == "" then + return + end + detail_turn = turn + detail_cursor = vim.api.nvim_win_get_cursor(winid) + local lines = { + string.format("# Detail · Turn %d · %s", turn_index, turn.label), + "", + } + vim.list_extend(lines, vim.split(text, "\n", { plain = true })) + set_buffer_lines(lines) + vim.wo[winid].winbar = string.format("Codex detail · %s · press %s to return", turn.label, config.keymap.detail) + vim.api.nvim_win_set_cursor(winid, { 1, 0 }) end function M.show() @@ -308,11 +574,14 @@ end ---@param main_win integer ---@param id string? ---@param token integer? -function M.attach(main_win, id, token) +---@param input_win integer? +function M.attach(main_win, id, token, input_win) if not config.enabled or not is_valid_window(main_win) then return end main_winid, main_bufnr, session_id, session_token = main_win, vim.api.nvim_win_get_buf(main_win), id, token + input_winid = is_valid_window(input_win) and input_win or nil + output_chats, selected_chat = {}, nil set_buffer_name() set_main_keymaps() if is_valid_window(winid) then @@ -327,7 +596,11 @@ function M.set_session(id, token) if token and session_token and token ~= session_token then return end + local session_changed = id ~= session_id session_id, session_token = id, token or session_token + if session_changed then + output_chats, selected_chat = {}, nil + end set_buffer_name() if is_valid_window(winid) then M.refresh(token) @@ -357,15 +630,26 @@ function M.close() end bufnr, main_winid, main_bufnr, session_id, session_token = nil, nil, nil, nil, nil main_bound_keys, output_bound_keys = {}, {} - adaptive_window, syncing_window = false, false + adaptive_window, syncing_window, split_state = false, false, nil output_title = "Codex output" + output_chats, selected_chat = {}, nil + turn_ranges, detail_turn, detail_cursor = {}, nil, nil + input_winid, saved_output_width, saved_output_height = nil, nil, nil + restore_split_minimum() end vim.api.nvim_create_autocmd({ "VimResized", "WinResized" }, { callback = sync_window }) vim.api.nvim_create_autocmd("WinClosed", { callback = function(args) - if tonumber(args.match) == main_winid then + local closed = tonumber(args.match) + if closed == main_winid then M.close() + elseif closed == winid then + winid = nil + adaptive_window, split_state = false, nil + if not hiding_window then + vim.schedule(restore_split_minimum) + end end end, }) diff --git a/lua/mini/codex/storage.lua b/lua/mini/codex/storage.lua index 8473378..6c585da 100644 --- a/lua/mini/codex/storage.lua +++ b/lua/mini/codex/storage.lua @@ -4,10 +4,40 @@ local M = {} ---@field id string ---@field title string ----@class mini.codex.OutputTurn ----@field turn integer +---@alias mini.codex.OutputChatTurnKind +---| "user" +---| "assistant" +---| "reasoning" +---| "command" +---| "mcp" +---| "file" +---| "subagent" +---| "compact" +---| "unknown" + +---@alias mini.codex.OutputChatTurnLabel +---| "User" +---| "Assistant" +---| "Reasoning" +---| "Command" +---| "MCP tool" +---| "File changes" +---| "Subagent" +---| "Compact" +---| "Unknown" + +---@class mini.codex.OutputChatTurn +---@field kind mini.codex.OutputChatTurnKind +---@field label mini.codex.OutputChatTurnLabel +---@field text string +---@field status string? +---@field item_id string? +---@field detail_thread_id string? + +---@class mini.codex.OutputChat +---@field chat integer ---@field question string ----@field response string +---@field turns mini.codex.OutputChatTurn[] ---@field turn_id string? local joinpath = vim.fs and vim.fs.joinpath or function(base, name) @@ -23,21 +53,76 @@ local function database_path() return joinpath(codex_home(), "state_5.sqlite") end +local function history_database_path() + return joinpath(codex_home(), "thread_history_1.sqlite") +end + local function quote(value) return "'" .. value:gsub("'", "''") .. "'" end -local function query(sql) - local db = database_path() +local function string_value(value, fallback) + if type(value) == "string" then + return value + end + return fallback +end + +local function short_text(value, max_chars) + if vim.fn.strchars(value) > max_chars then + return vim.fn.strcharpart(value, 0, max_chars - 1) .. "…" + end + return value +end + +---@type table +local item_kinds = { + userMessage = "user", + UserMessage = "user", + agentMessage = "assistant", + AgentMessage = "assistant", + reasoning = "reasoning", + Reasoning = "reasoning", + commandExecution = "command", + CommandExecution = "command", + mcpToolCall = "mcp", + McpToolCall = "mcp", + fileChange = "file", + FileChange = "file", + contextCompaction = "compact", + ContextCompaction = "compact", +} + +---@type table +local turn_labels = { + user = "User", + assistant = "Assistant", + reasoning = "Reasoning", + command = "Command", + mcp = "MCP tool", + file = "File changes", + subagent = "Subagent", + compact = "Compact", + unknown = "Unknown", +} + +local function item_kind(item_type) + return item_kinds[string_value(item_type)] or "unknown" +end + +local function query(sql, db) + db = db or database_path() if vim.fn.filereadable(db) ~= 1 then return {} end - local output = vim.fn.system({ "sqlite3", "-json", db, sql }) - if vim.v.shell_error ~= 0 or output == "" then + local started, process = pcall(vim.system, { "sqlite3", "-json", db, sql }, { text = true }) + if not started then return {} end + local result = process:wait() + local output = result.stdout or "" local ok, rows = pcall(vim.json.decode, output) - if not ok or type(rows) ~= "table" then + if result.code ~= 0 or output == "" or not ok or type(rows) ~= "table" then return {} end return rows @@ -47,7 +132,13 @@ end ---@return mini.codex.Session[] function M.session_list(cwd) local sql = string.format( - "SELECT id, title FROM threads WHERE cwd = %s AND archived = 0 ORDER BY created_at DESC LIMIT 20", + [[ + SELECT id, title + FROM threads + WHERE cwd = %s AND archived = 0 + ORDER BY created_at DESC + LIMIT 20 + ]], quote(cwd) ) local sessions = {} @@ -63,7 +154,15 @@ function M.session_list(cwd) end local function rollout_path(thread_id) - local sql = string.format("SELECT rollout_path FROM threads WHERE id = %s LIMIT 1", quote(thread_id)) + local sql = string.format( + [[ + SELECT rollout_path + FROM threads + WHERE id = %s + LIMIT 1 + ]], + quote(thread_id) + ) local row = query(sql)[1] if not row or type(row.rollout_path) ~= "string" or row.rollout_path == "" then return @@ -84,9 +183,10 @@ local function content_text(content) if type(content) ~= "table" then return end - local direct = content.text or content.output_text or content.value - if type(direct) == "string" then - return direct + for _, field in ipairs({ "text", "output_text", "value", "summary_text", "reasoning_text" }) do + if type(content[field]) == "string" then + return content[field] + end end if content.content ~= nil then local nested = content_text(content.content) @@ -96,44 +196,32 @@ local function content_text(content) end local parts = {} for _, item in ipairs(content) do - if type(item) == "string" then - parts[#parts + 1] = item - elseif type(item) == "table" then - local text = item.text or item.output_text or item.value - if type(text) == "string" then - parts[#parts + 1] = text - elseif item.content ~= nil then - local nested = content_text(item.content) - if nested then - parts[#parts + 1] = nested - end - end + local text = content_text(item) + if text then + parts[#parts + 1] = text end end return #parts > 0 and table.concat(parts, "\n") or nil end +local message_roles = { + user_message = "user", + userMessage = "user", + agent_message = "assistant", + agentMessage = "assistant", + assistant_message = "assistant", + assistantMessage = "assistant", +} + local function message_from_table(value, turn_id) if type(value) ~= "table" then return end - local role = value.role - local kind = value.type - local text - if role == "user" or role == "assistant" then - text = content_text(value.content or value.text or value.message) - elseif kind == "user_message" or kind == "userMessage" then - role = "user" - text = content_text(value.message or value.text or value.content) - elseif - kind == "agent_message" - or kind == "agentMessage" - or kind == "assistant_message" - or kind == "assistantMessage" - then - role = "assistant" - text = content_text(value.message or value.text or value.content) - end + local explicit_role = value.role == "user" or value.role == "assistant" + local role = explicit_role and value.role or message_roles[value.type] + local content = explicit_role and (value.content or value.text or value.message) + or (value.message or value.text or value.content) + local text = content_text(content) if not role or not text or text == "" then return end @@ -163,16 +251,16 @@ local function add_message(messages, role, text, turn_id) messages[#messages + 1] = { role = role, text = text, turn_id = turn_id } end -local function make_turns(messages) - local turns = {} +local function make_chats(messages) + local chats = {} local current local function finish() - if current and current.response ~= "" then - turns[#turns + 1] = { - turn = #turns + 1, + if current and #current.turns > 0 then + chats[#chats + 1] = { + chat = #chats + 1, question = current.question, - response = current.response, + turns = current.turns, turn_id = current.turn_id, } end @@ -182,24 +270,27 @@ local function make_turns(messages) for _, message in ipairs(messages) do if message.role == "user" then finish() - current = { question = message.text, response = "", turn_id = message.turn_id } + current = { + question = message.text, + turns = { { kind = "user", label = "User", text = message.text } }, + turn_id = message.turn_id, + } elseif message.role == "assistant" then if not current then - current = { question = "", response = "", turn_id = message.turn_id } - elseif message.turn_id and current.turn_id and message.turn_id ~= current.turn_id then - finish() - current = { question = "", response = "", turn_id = message.turn_id } - elseif message.turn_id and not current.turn_id then - current.turn_id = message.turn_id + current = { question = "", turns = {}, turn_id = message.turn_id } end - if current.response ~= "" then - current.response = current.response .. "\n\n" + if message.turn_id and not current.turn_id then + current.turn_id = message.turn_id end - current.response = current.response .. message.text + current.turns[#current.turns + 1] = { + kind = "assistant", + label = "Assistant", + text = message.text, + } end end finish() - return turns + return chats end local function read_rollout(path) @@ -217,15 +308,406 @@ local function read_rollout(path) end end end - return make_turns(messages) + return make_chats(messages) +end + +local function decode_json(value) + local ok, decoded = pcall(vim.json.decode, value) + if ok and type(decoded) == "table" then + return decoded + end +end + +local function encode_json(value) + local ok, encoded = pcall(vim.json.encode, value) + return ok and encoded or "" +end + +local function shell_arguments(command) + local arguments = {} + local argument = {} + local quote + local escaped = false + local index = 1 + + while index <= #command do + local character = command:sub(index, index) + if escaped then + argument[#argument + 1] = character + escaped = false + elseif character == "\\" and quote ~= "'" then + escaped = true + elseif quote then + if character == quote then + quote = nil + else + argument[#argument + 1] = character + end + elseif character == "'" or character == '"' then + quote = character + elseif character:find("%s") then + if #argument > 0 then + arguments[#arguments + 1] = table.concat(argument) + argument = {} + end + else + argument[#argument + 1] = character + end + index = index + 1 + end + + if #argument > 0 then + arguments[#arguments + 1] = table.concat(argument) + end + return arguments +end + +local function without_shell_wrapper(command) + local arguments = shell_arguments(command) + local shell = arguments[1] and arguments[1]:match("[^/\\]+$") + local option = arguments[2] + if shell and shell ~= "ssh" and (shell == "sh" or shell:match("sh$")) and option and option:sub(1, 1) == "-" then + return arguments[3] or command + end + return command +end + +local function display_command(item) + if type(item.commandActions) == "table" then + local commands = {} + for _, action in ipairs(item.commandActions) do + if type(action) == "table" and type(action.command) == "string" and action.command ~= "" then + commands[#commands + 1] = without_shell_wrapper(action.command) + end + end + if #commands > 0 then + return table.concat(commands, "\n") + end + end + return without_shell_wrapper(string_value(item.command, "")) +end + +local function command_turn(item) + local lines = { "```sh", display_command(item), "```" } + local output = string_value(item.aggregatedOutput) + or string_value(item.output) + or string_value(item.stderr) + or string_value(item.stdout) + if output ~= "" then + vim.list_extend(lines, { "", "```text", output, "```" }) + end + local status = string_value(item.status) + if item.exitCode ~= nil and item.exitCode ~= vim.NIL then + status = status and status .. " · exit " .. tostring(item.exitCode) or "exit " .. tostring(item.exitCode) + end + return { + kind = "command", + label = turn_labels.command, + text = table.concat(lines, "\n"), + status = status, + } +end + +local function mcp_turn(item) + local lines = { + "MCP: " .. string_value(item.server, "unknown") .. " / " .. string_value(item.tool, "unknown"), + } + local arguments = type(item.arguments) == "table" and encode_json(item.arguments) or "" + if arguments ~= "" and arguments ~= "{}" then + vim.list_extend(lines, { "", "```json", arguments, "```" }) + end + local result = item.result + local text = type(result) == "table" and content_text(type(result.content) == "table" and result.content or result) + or nil + if type(text) == "string" and text ~= "" then + vim.list_extend(lines, { "", "```text", text, "```" }) + elseif item.error ~= nil and item.error ~= vim.NIL then + local error_text = type(item.error) == "table" and content_text(item.error) or nil + vim.list_extend(lines, { "", "```text", error_text or encode_json(item.error), "```" }) + end + return { + kind = "mcp", + label = turn_labels.mcp, + text = table.concat(lines, "\n"), + status = string_value(item.status), + } +end + +local function file_change_turn(item) + local lines = {} + for _, change in ipairs(type(item.changes) == "table" and item.changes or {}) do + if change.path then + vim.list_extend(lines, { "### " .. change.path, "", "```diff", string_value(change.diff, ""), "```" }) + end + end + return { + kind = "file", + label = turn_labels.file, + text = table.concat(lines, "\n"), + status = string_value(item.status), + } +end + +local function item_turn(item) + local kind = item_kind(item.type) + if kind == "user" then + return { + kind = "user", + label = turn_labels.user, + text = content_text(type(item.content) == "table" and item.content or item) or "", + } + elseif kind == "assistant" then + return { + kind = "assistant", + label = turn_labels.assistant, + text = string_value(item.text) or content_text(item.content) or "", + } + elseif kind == "reasoning" then + local reasoning = content_text(item.summary) + or content_text(item.content) + or content_text(item.summary_text) + or content_text(item.raw_content) + or string_value(item.reasoning_text) + or string_value(item.text) + return { + kind = "reasoning", + label = turn_labels.reasoning, + text = reasoning or "", + } + elseif kind == "command" then + return command_turn(item) + elseif kind == "mcp" then + return mcp_turn(item) + elseif kind == "file" then + return file_change_turn(item) + end + + local text = content_text(type(item.content) == "table" and item.content or item) + return { + kind = kind, + label = turn_labels[kind], + text = text or string_value(item.text) or string_value(item.output) or encode_json(item), + status = string_value(item.status), + } +end + +local function preview_turn(row) + local kind = item_kind(row.item_type) + local summary = string_value(row.summary, "") + local status = string_value(row.status, "") + local exit_code = tonumber(row.exit_code) + if exit_code then + status = status ~= "" and status .. " · exit " .. exit_code or "exit " .. exit_code + end + + if kind == "user" or kind == "assistant" then + local item = decode_json(row.item_json) + if item then + return item_turn(item) + end + elseif kind == "command" then + summary = without_shell_wrapper(summary) + summary = short_text(summary, 160) + end + + return { + kind = kind, + label = turn_labels[kind], + text = summary ~= "" and summary or kind, + status = status ~= "" and status or nil, + item_id = string_value(row.item_id), + } +end + +local function subagent_turns(thread_id, chats) + local rows = query(string.format( + [[ + SELECT e.child_thread_id AS id, e.status, t.title, t.created_at + FROM thread_spawn_edges e LEFT JOIN threads t ON t.id = e.child_thread_id + WHERE e.parent_thread_id = %s + ORDER BY t.created_at + ]], + quote(thread_id) + )) + for _, row in ipairs(rows) do + if type(row.id) == "string" and row.id ~= "" then + local created_at = tonumber(row.created_at) + local target = chats[#chats] + for index = #chats, 1, -1 do + local chat = chats[index] + local started_at = tonumber(chat.started_at) + local completed_at = tonumber(chat.completed_at) + if created_at and started_at and created_at >= started_at then + if not completed_at or created_at <= completed_at then + target = chat + end + break + end + end + if target then + target.turns[#target.turns + 1] = { + kind = "subagent", + label = "Subagent", + text = string_value(row.title, row.id), + status = string_value(row.status), + detail_thread_id = row.id, + } + end + end + end +end + +local function thread_history(thread_id) + local turn_rows = query( + string.format( + [[ + SELECT turn_id, status, started_at, completed_at + FROM thread_turns + WHERE thread_id = %s + ORDER BY rollout_ordinal + ]], + quote(thread_id) + ), + history_database_path() + ) + if #turn_rows == 0 then + return + end + + local chats = {} + local chats_by_id = {} + for _, row in ipairs(turn_rows) do + if type(row.turn_id) == "string" and row.turn_id ~= "" then + local chat = { + chat = #chats + 1, + question = "", + turns = {}, + turn_id = row.turn_id, + status = string_value(row.status), + started_at = row.started_at, + completed_at = row.completed_at, + } + chats[#chats + 1] = chat + chats_by_id[chat.turn_id] = chat + end + end + if #chats == 0 then + return + end + + local item_rows = query( + string.format( + [[ + SELECT turn_id, item_id, item_type, + json_extract(item_json, '$.status') AS status, + json_extract(item_json, '$.exitCode') AS exit_code, + CASE + WHEN lower(item_type) = 'commandexecution' THEN substr(coalesce( + json_extract(item_json, '$.commandActions[0].command'), json_extract(item_json, '$.command') + ), 1, 512) + WHEN lower(item_type) = 'mcptoolcall' THEN substr( + json_extract(item_json, '$.server') || ' / ' || json_extract(item_json, '$.tool'), 1, 160 + ) + WHEN lower(item_type) = 'filechange' THEN coalesce(json_extract(item_json, '$.changes[0].path'), '') + WHEN lower(item_type) = 'contextcompaction' THEN 'context compacted' + ELSE substr(item_json, 1, 160) + END AS summary, + CASE WHEN lower(item_type) IN ('usermessage', 'agentmessage') THEN item_json END AS item_json + FROM thread_items + WHERE thread_id = %s AND lower(item_type) <> 'reasoning' + ORDER BY rollout_ordinal + ]], + quote(thread_id) + ), + history_database_path() + ) + for _, row in ipairs(item_rows) do + local chat = type(row.turn_id) == "string" and chats_by_id[row.turn_id] + if chat then + local turn = preview_turn(row) + if turn.text ~= "" then + chat.turns[#chat.turns + 1] = turn + if turn.kind == "user" and chat.question == "" then + chat.question = turn.text + end + end + end + end + + subagent_turns(thread_id, chats) + for index, chat in ipairs(chats) do + chat.chat = index + chat.started_at, chat.completed_at = nil, nil + end + return chats +end + +---@param thread_id string? +---@param turn mini.codex.OutputChatTurn? +---@param chat_turn_id string? +---@return string? +function M.output_turn_detail(thread_id, turn, chat_turn_id) + if type(thread_id) ~= "string" or type(turn) ~= "table" then + return + end + if turn.kind == "user" or turn.kind == "assistant" then + return turn.text + end + + local item_json + if turn.detail_thread_id then + local row = query( + string.format( + [[ + SELECT item_json + FROM thread_items + WHERE thread_id = %s AND item_type = 'agentMessage' + ORDER BY rollout_ordinal DESC + LIMIT 1 + ]], + quote(turn.detail_thread_id) + ), + history_database_path() + )[1] + item_json = row and row.item_json + elseif turn.item_id and type(chat_turn_id) == "string" then + local row = query( + string.format( + [[ + SELECT item_json + FROM thread_items + WHERE thread_id = %s AND turn_id = %s AND item_id = %s + LIMIT 1 + ]], + quote(thread_id), + quote(chat_turn_id), + quote(turn.item_id) + ), + history_database_path() + )[1] + item_json = row and row.item_json + end + + local item = decode_json(item_json) + if not item then + return turn.text + end + if turn.detail_thread_id then + return string.format("### %s\n\n%s", turn.text, string_value(item.text, "No result available yet.")) + end + return item_turn(item).text end ---@param thread_id string? ----@return mini.codex.OutputTurn[] +---@return mini.codex.OutputChat[] function M.output_history(thread_id) if type(thread_id) ~= "string" or thread_id == "" then return {} end + local history = thread_history(thread_id) + if history then + return history + end local path = rollout_path(thread_id) return path and read_rollout(path) or {} end @@ -234,8 +716,13 @@ end ---@return string? function M.latest_output(thread_id) local history = M.output_history(thread_id) - local latest = history[#history] - return latest and latest.response or nil + local chat = history[#history] + for index = chat and #chat.turns or 0, 1, -1 do + local turn = chat.turns[index] + if turn.kind ~= "user" then + return turn.text + end + end end return M diff --git a/tests/codex_spec.lua b/tests/codex_spec.lua index 5da7ac8..830c54a 100644 --- a/tests/codex_spec.lua +++ b/tests/codex_spec.lua @@ -6,6 +6,31 @@ local function eq(expected, actual) assert(expected == actual, string.format("expected %s, got %s", expected, actual)) end +local function resize_window(win, width, height) + if vim.api.nvim_win_resize then + return vim.api.nvim_win_resize(win, width or -1, height or -1, {}) + end + return vim.api.nvim_win_call(win, function() + if width then + vim.cmd("vertical resize " .. width) + end + if height then + vim.cmd("resize " .. height) + end + end) +end + +local function mock_system(callback) + return function(...) + local stdout = callback(...) + return { + wait = function() + return { code = 0, signal = 0, stdout = stdout, stderr = "" } + end, + } + end +end + busted.describe("mini.codex", function() local jobs, stops, notices, temp_files, job_opts, lsp_config local original @@ -41,12 +66,12 @@ busted.describe("mini.codex", function() filereadable = vim.fn.filereadable, jobstart = vim.fn.jobstart, jobstop = vim.fn.jobstop, - chansend = vim.fn.chansend, + nvim_chan_send = vim.api.nvim_chan_send, complete_info = vim.fn.complete_info, notify = vim.notify, pumvisible = vim.fn.pumvisible, serverstart = vim.fn.serverstart, - system = vim.fn.system, + system = vim.system, lsp_start = vim.lsp.start, lsp_get_clients = vim.lsp.get_clients, lsp_get_configs = vim.lsp.get_configs, @@ -69,15 +94,16 @@ busted.describe("mini.codex", function() vim.fn.jobstop = function() stops = stops + 1 end + vim.api.nvim_chan_send = function() end vim.notify = function(message) notices[#notices + 1] = message end vim.fn.serverstart = function() return "/tmp/mini-codex-test.sock" end - vim.fn.system = function() + vim.system = mock_system(function() return '[{"id":"only","title":"Only | session"}]' - end + end) end) busted.after_each(function() @@ -91,7 +117,6 @@ busted.describe("mini.codex", function() win = { win = 0, split = "below", - height = math.max(1, math.floor(vim.o.lines * 0.3)), }, keymap = { jump = "", @@ -109,6 +134,9 @@ busted.describe("mini.codex", function() keymap = { toggle = "", refresh = "", + prev = "", + next = "", + detail = "", }, }, }) @@ -117,12 +145,12 @@ busted.describe("mini.codex", function() vim.fn.filereadable = original.filereadable vim.fn.jobstart = original.jobstart vim.fn.jobstop = original.jobstop - vim.fn.chansend = original.chansend + vim.api.nvim_chan_send = original.nvim_chan_send vim.fn.complete_info = original.complete_info vim.notify = original.notify vim.fn.pumvisible = original.pumvisible vim.fn.serverstart = original.serverstart - vim.fn.system = original.system + vim.system = original.system vim.lsp.start = original.lsp_start vim.lsp.get_clients = original.lsp_get_clients vim.lsp.get_configs = original.lsp_get_configs @@ -162,10 +190,10 @@ busted.describe("mini.codex", function() busted.it("uses the session id in the window name", function() local queries = 0 - vim.fn.system = function() + vim.system = mock_system(function() queries = queries + 1 return queries == 1 and '[{"id":"old","title":"Old session"}]' or '[{"id":"new","title":"New session"}]' - end + end) require("mini.codex").setup({ input = { enabled = true, prompt = "Input: " }, @@ -221,7 +249,7 @@ busted.describe("mini.codex", function() eq(vim.api.nvim_win_get_width(main_win), vim.api.nvim_win_get_width(input_win)) eq(vim.api.nvim_win_get_position(main_win)[1] + main_height + 1, vim.api.nvim_win_get_position(input_win)[1]) - vim.api.nvim_win_set_height(input_win, input_height + 1) + resize_window(input_win, nil, input_height + 1) eq(total_height, vim.api.nvim_win_get_height(main_win) + vim.api.nvim_win_get_height(input_win) + 1) end) @@ -340,16 +368,16 @@ busted.describe("mini.codex", function() eq(main_win, input_config.win) eq(vim.api.nvim_win_get_height(main_win) + 2, input_config.row) eq(vim.api.nvim_win_get_width(main_win), input_config.width) - eq(math.max(1, math.floor(vim.o.lines * 0.3)), input_config.height) + eq(math.max(1, math.floor(vim.api.nvim_win_get_height(main_win) * 0.3)), input_config.height) - vim.api.nvim_win_set_height(main_win, 10) + resize_window(main_win, nil, 10) vim.cmd("doautocmd WinResized") eq(12, vim.api.nvim_win_get_config(input_win).row) end) busted.it("fully replaces the terminal input from the mapping input", function() local sent = {} - vim.fn.chansend = function(job, data) + vim.api.nvim_chan_send = function(job, data) sent[#sent + 1] = { job, data } end @@ -385,18 +413,21 @@ busted.describe("mini.codex", function() vim.cmd("Codex") local input_win = vim.api.nvim_get_current_win() assert(vim.api.nvim_win_is_valid(input_win), "input window not opened") + resize_window(input_win, nil, math.max(1, vim.api.nvim_win_get_height(input_win) - 1)) + local input_height = vim.api.nvim_win_get_height(input_win) vim.cmd("Codex toggle") assert(not vim.api.nvim_win_is_valid(input_win), "input window not hidden") vim.cmd("Codex toggle") - local restored = false + local restored for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "markdown.codex" then - restored = true + restored = win end end assert(restored, "input window not restored") + eq(input_height, vim.api.nvim_win_get_height(restored)) end) busted.it("does not leave an unsaved input buffer", function() @@ -457,7 +488,7 @@ busted.describe("mini.codex", function() busted.it("only shows an unpinned input while it is focused", function() local sent = {} - vim.fn.chansend = function(job, data) + vim.api.nvim_chan_send = function(job, data) sent[#sent + 1] = { job, data } end @@ -499,7 +530,7 @@ busted.describe("mini.codex", function() busted.it("copies the terminal input into the mapping input", function() local sent = {} - vim.fn.chansend = function(job, data) + vim.api.nvim_chan_send = function(job, data) sent[#sent + 1] = { job, data } end @@ -534,7 +565,7 @@ busted.describe("mini.codex", function() busted.it("copies Codex history into the mapping input", function() local sent = {} - vim.fn.chansend = function(job, data) + vim.api.nvim_chan_send = function(job, data) sent[#sent + 1] = { job, data } end @@ -625,7 +656,7 @@ busted.describe("mini.codex", function() busted.it("uses Enter only to edit the mapping input", function() local sent - vim.fn.chansend = function(job, data) + vim.api.nvim_chan_send = function(job, data) sent = { job, data } end @@ -643,7 +674,7 @@ busted.describe("mini.codex", function() busted.it("keeps synchronization working while completion is active", function() local sent = {} - vim.fn.chansend = function(job, data) + vim.api.nvim_chan_send = function(job, data) sent[#sent + 1] = { job, data } end @@ -678,7 +709,7 @@ busted.describe("mini.codex", function() eq("\7", sent[2][2]) end) - busted.it("previews the Codex response history", function() + busted.it("previews and navigates the Codex response history", function() local rollout = vim.fn.tempname() .. ".jsonl" temp_files[#temp_files + 1] = rollout vim.fn.writefile({ @@ -721,12 +752,12 @@ busted.describe("mini.codex", function() }, }), }, rollout, "b") - vim.fn.system = function(args) + vim.system = mock_system(function(args) if args[#args]:find("rollout_path", 1, true) then return vim.json.encode({ { rollout_path = rollout } }) end return vim.json.encode({ { id = "only", title = "Only session" } }) - end + end) require("mini.codex").setup({ output = { @@ -746,29 +777,218 @@ busted.describe("mini.codex", function() vim.cmd("Codex prev") local main_win = codex_win() + local main_width = vim.api.nvim_win_get_width(main_win) + local main_height = vim.api.nvim_win_get_height(main_win) vim.api.nvim_set_current_win(main_win) vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) - local output_buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) - local output_config = vim.api.nvim_win_get_config(vim.api.nvim_get_current_win()) + local output_win = vim.api.nvim_get_current_win() + local output_buf = vim.api.nvim_win_get_buf(output_win) + local output_config = vim.api.nvim_win_get_config(output_win) eq("editor", output_config.relative) eq(1, output_config.row) eq(2, output_config.col) eq(40, output_config.width) eq(8, output_config.height) eq("minimal", output_config.style) - eq("Codex output · turn 2/2 · for latest question", vim.wo[vim.api.nvim_get_current_win()].winbar) + eq("Codex output · chat 2/2 · for latest question", vim.wo[output_win].winbar) eq("markdown", vim.bo[output_buf].filetype) eq( - "## Turn 1 · for old question\n\nold answer\n\n---\n\n## Turn 2 · for latest question\n\n# latest\n\nvalue | preserved", + "## Chat 2 · for latest question\n\n# Turn 1 · User\n\n````markdown\nlatest question\n````\n\n" + .. "# Turn 2 · Assistant\n\n````markdown\n# latest\n\nvalue | preserved\n````", + table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), "\n") + ) + + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + eq("Codex output · chat 1/2 · for old question", vim.wo[output_win].winbar) + eq( + "## Chat 1 · for old question\n\n# Turn 1 · User\n\n````markdown\nold question\n````\n\n" + .. "# Turn 2 · Assistant\n\n````markdown\nold answer\n````", table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), "\n") ) vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) eq( - "## Turn 1 · for old question\n\nold answer\n\n---\n\n## Turn 2 · for latest question\n\n# latest\n\nvalue | preserved", + "## Chat 1 · for old question\n\n# Turn 1 · User\n\n````markdown\nold question\n````\n\n" + .. "# Turn 2 · Assistant\n\n````markdown\nold answer\n````", + table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), "\n") + ) + + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + eq( + "## Chat 2 · for latest question\n\n# Turn 1 · User\n\n````markdown\nlatest question\n````\n\n" + .. "# Turn 2 · Assistant\n\n````markdown\n# latest\n\nvalue | preserved\n````", table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), "\n") ) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + eq(main_width, vim.api.nvim_win_get_width(main_win)) + eq(main_height, vim.api.nvim_win_get_height(main_win)) + end) + + busted.it("previews command and subagent results from the database", function() + vim.system = mock_system(function(args) + local sql = args[#args] + if sql:find("FROM thread_turns", 1, true) then + return vim.json.encode({ + { turn_id = "chat-1", status = "completed", started_at = 10, completed_at = 20 }, + }) + end + if sql:find("item_type = 'agentMessage'", 1, true) then + return vim.json.encode({ + { item_json = vim.json.encode({ type = "agentMessage", text = "helper result" }) }, + }) + end + if sql:find("item_id = 'command-1'", 1, true) then + return vim.json.encode({ + { + item_json = vim.json.encode({ + type = "commandExecution", + command = "/xx/xsh -xx 'printf ran'", + aggregatedOutput = "ran result", + status = "completed", + exitCode = 0, + }), + }, + }) + end + if sql:find("FROM thread_items", 1, true) then + return vim.json.encode({ + { + turn_id = "chat-1", + item_id = "user-1", + item_type = "userMessage", + item_json = vim.json.encode({ + type = "userMessage", + content = { { type = "text", text = "run question" } }, + }), + }, + { + turn_id = "chat-1", + item_id = "command-1", + item_type = "commandExecution", + summary = "/xx/xsh -xx 'printf ran'", + status = "completed", + exit_code = 0, + }, + { + turn_id = "chat-1", + item_id = "agent-1", + item_type = "agentMessage", + item_json = vim.json.encode({ type = "agentMessage", text = "agent done" }), + }, + }) + end + if sql:find("FROM thread_spawn_edges", 1, true) then + return vim.json.encode({ { id = "child-1", status = "completed", title = "Helper", created_at = 15 } }) + end + return vim.json.encode({ { id = "only", title = "Only session" } }) + end) + + require("mini.codex").setup({ + output = { enabled = true }, + win = split_win(), + }) + + vim.cmd("Codex prev") + vim.api.nvim_set_current_win(codex_win()) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + + local output_win = vim.api.nvim_get_current_win() + local output_buf = vim.api.nvim_win_get_buf(output_win) + local output = table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), "\n") + assert(output:find("run question", 1, true), "user message missing") + assert(output:find("agent done", 1, true), "assistant message missing") + assert(output:find("# Turn 2 · Command · completed · exit 0", 1, true), "command turn missing") + assert(output:find("printf ran", 1, true), "command summary missing") + assert(not output:find("/xx/xsh -xx", 1, true), "shell wrapper should be hidden in the preview") + assert(not output:find("ran result", 1, true), "command detail should load lazily") + assert(output:find("# Turn 4 · Subagent · completed", 1, true), "subagent turn missing") + assert(output:find("Helper", 1, true), "subagent summary missing") + assert(not output:find("\n---\n", 1, true), "horizontal turn separator should not be present") + + local lines = vim.api.nvim_buf_get_lines(output_buf, 0, -1, false) + local command_line + for index, line in ipairs(lines) do + if line:find("# Turn 2 · Command", 1, true) then + command_line = index + break + end + end + assert(command_line, "command heading missing") + vim.api.nvim_win_set_cursor(output_win, { command_line, 0 }) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + output = table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), "\n") + assert(output:find("```sh\nprintf ran\n```", 1, true), "detail command invocation missing") + assert(output:find("```text\nran result\n```", 1, true), "detail command result missing") + assert(not output:find("/xx/xsh -xx", 1, true), "shell wrapper should be hidden in the detail") + eq("Codex detail · Command · press to return", vim.wo[output_win].winbar) + + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + output = table.concat(vim.api.nvim_buf_get_lines(output_buf, 0, -1, false), "\n") + assert(not output:find("ran result", 1, true), "detail should close with the same key") + eq(command_line, vim.api.nvim_win_get_cursor(output_win)[1]) + eq(0, vim.api.nvim_win_get_cursor(output_win)[2]) + end) + + busted.it("filters reasoning turns and renders compact turns", function() + local history_sql + vim.system = mock_system(function(args) + local sql = args[#args] + if sql:find("item_id = 'command-1'", 1, true) then + return vim.json.encode({ + { + item_json = vim.json.encode({ + type = "commandExecution", + command = "/xx/xsh -xx 'printf ran'", + aggregatedOutput = "", + }), + }, + }) + elseif sql:find("FROM thread_turns", 1, true) then + return vim.json.encode({ { turn_id = "turn-1", status = "completed" } }) + elseif sql:find("FROM thread_items", 1, true) then + history_sql = sql + return vim.json.encode({ + { + turn_id = "turn-1", + item_id = "user-1", + item_type = "userMessage", + item_json = vim.json.encode({ + type = "userMessage", + content = { { type = "text", text = "run question" } }, + }), + }, + { + turn_id = "turn-1", + item_id = "command-1", + item_type = "commandExecution", + summary = "/xx/xsh -xx 'printf ran'", + }, + { + turn_id = "turn-1", + item_id = "compact-1", + item_type = "contextCompaction", + summary = "context compacted", + }, + }) + end + return vim.json.encode({}) + end) + + local storage = require("mini.codex.storage") + eq( + "```sh\nprintf ran\n```", + storage.output_turn_detail("thread-1", { kind = "command", item_id = "command-1" }, "turn-1") + ) + local history = storage.output_history("thread-1") + assert(history_sql:find("AND lower(item_type) <> 'reasoning'", 1, true), "reasoning filter missing") + eq(3, #history[1].turns) + eq("user", history[1].turns[1].kind) + eq("command", history[1].turns[2].kind) + eq("printf ran", history[1].turns[2].text) + eq("compact", history[1].turns[3].kind) + eq("Compact", history[1].turns[3].label) + eq("context compacted", history[1].turns[3].text) end) busted.it("opens the default output beside the Codex window", function() @@ -791,9 +1011,197 @@ busted.describe("mini.codex", function() eq(main_height, output_config.height) end) + busted.it("places history beside the main/input stack and preserves manual sizes", function() + local main_height = vim.api.nvim_win_get_height(vim.api.nvim_get_current_win()) + require("mini.codex").setup({ + input = { enabled = true }, + output = { enabled = true }, + win = split_win(), + }) + + vim.cmd("Codex") + local input_win = vim.api.nvim_get_current_win() + eq(math.max(1, math.floor(main_height * 0.3)), vim.api.nvim_win_get_height(input_win)) + + local main_win = codex_win() + local initial_main_width = vim.api.nvim_win_get_width(main_win) + local initial_main_height = vim.api.nvim_win_get_height(main_win) + local initial_input_height = vim.api.nvim_win_get_height(input_win) + local initial_left = vim.api.nvim_win_get_position(main_win)[2] + vim.api.nvim_set_current_win(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + + local history_win = vim.api.nvim_get_current_win() + eq(initial_main_height, vim.api.nvim_win_get_height(main_win)) + eq(initial_input_height, vim.api.nvim_win_get_height(input_win)) + eq(initial_main_width, vim.api.nvim_win_get_width(main_win) + vim.api.nvim_win_get_width(history_win) + 1) + eq(vim.api.nvim_win_get_width(main_win), vim.api.nvim_win_get_width(input_win)) + eq(initial_left, vim.api.nvim_win_get_position(main_win)[2]) + eq("right", vim.api.nvim_win_get_config(history_win).split) + assert( + vim.api.nvim_win_get_position(main_win)[1] < vim.api.nvim_win_get_position(input_win)[1], + "main should be above input" + ) + assert( + vim.api.nvim_win_get_position(main_win)[2] < vim.api.nvim_win_get_position(history_win)[2], + "history should be right of main" + ) + eq(vim.api.nvim_win_get_position(main_win)[1], vim.api.nvim_win_get_position(history_win)[1]) + eq(vim.api.nvim_win_get_position(main_win)[2], vim.api.nvim_win_get_position(input_win)[2]) + + local history_width_before_focus = vim.api.nvim_win_get_width(history_win) + local main_width_before_focus = vim.api.nvim_win_get_width(main_win) + for _, target in ipairs({ main_win, input_win, history_win }) do + vim.api.nvim_set_current_win(target) + eq(main_width_before_focus, vim.api.nvim_win_get_width(main_win)) + eq(main_width_before_focus, vim.api.nvim_win_get_width(input_win)) + eq(history_width_before_focus, vim.api.nvim_win_get_width(history_win)) + end + + resize_window(history_win, math.max(1, vim.api.nvim_win_get_width(history_win) - 1)) + resize_window(input_win, nil, math.max(1, vim.api.nvim_win_get_height(input_win) - 1)) + local history_width = vim.api.nvim_win_get_width(history_win) + local input_height = vim.api.nvim_win_get_height(input_win) + vim.api.nvim_set_current_win(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + eq(initial_main_width, vim.api.nvim_win_get_width(main_win)) + eq(initial_main_width, vim.api.nvim_win_get_width(input_win)) + eq(input_height, vim.api.nvim_win_get_height(input_win)) + resize_window(main_win, math.max(1, vim.api.nvim_win_get_width(main_win) - 1)) + local hidden_width = vim.api.nvim_win_get_width(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + history_win = vim.api.nvim_get_current_win() + eq(history_width, vim.api.nvim_win_get_width(history_win)) + eq(input_height, vim.api.nvim_win_get_height(input_win)) + eq(hidden_width, vim.api.nvim_win_get_width(main_win) + vim.api.nvim_win_get_width(history_win) + 1) + eq(vim.api.nvim_win_get_width(main_win), vim.api.nvim_win_get_width(input_win)) + eq(vim.api.nvim_win_get_position(main_win)[2], vim.api.nvim_win_get_position(input_win)[2]) + assert( + vim.api.nvim_win_get_position(main_win)[2] < vim.api.nvim_win_get_position(history_win)[2], + "history should remain right of main after toggling" + ) + end) + + busted.it("honors a configured history width inside the existing stack", function() + require("mini.codex").setup({ + input = { enabled = true }, + output = { enabled = true, win = { split = "right", width = 6 } }, + win = split_win(), + }) + + vim.cmd("Codex") + local main_win = codex_win() + local stack_width = vim.api.nvim_win_get_width(main_win) + vim.api.nvim_set_current_win(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + + local history_win = vim.api.nvim_get_current_win() + eq(6, vim.api.nvim_win_get_width(history_win)) + eq(stack_width, vim.api.nvim_win_get_width(main_win) + vim.api.nvim_win_get_width(history_win) + 1) + end) + + busted.it("supports a configured left-side history with input", function() + require("mini.codex").setup({ + input = { enabled = true }, + output = { enabled = true, win = { split = "left", width = 6 } }, + win = split_win(), + }) + + vim.cmd("Codex") + local input_win = vim.api.nvim_get_current_win() + local main_win = codex_win() + local stack_width = vim.api.nvim_win_get_width(main_win) + vim.api.nvim_set_current_win(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + + local history_win = vim.api.nvim_get_current_win() + eq(6, vim.api.nvim_win_get_width(history_win)) + eq(stack_width, vim.api.nvim_win_get_width(main_win) + vim.api.nvim_win_get_width(history_win) + 1) + eq(vim.api.nvim_win_get_width(main_win), vim.api.nvim_win_get_width(input_win)) + assert( + vim.api.nvim_win_get_position(history_win)[2] < vim.api.nvim_win_get_position(main_win)[2], + "configured history should remain left of main" + ) + end) + + busted.it("supports a resized horizontal history inside the existing region", function() + require("mini.codex").setup({ + output = { enabled = true, win = { split = "below", height = 4 } }, + win = split_win(), + }) + + vim.cmd("Codex") + local main_win = codex_win() + local region_height = vim.api.nvim_win_get_height(main_win) + local main_width = vim.api.nvim_win_get_width(main_win) + vim.api.nvim_set_current_win(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + + local history_win = vim.api.nvim_get_current_win() + eq(4, vim.api.nvim_win_get_height(history_win)) + eq(region_height, vim.api.nvim_win_get_height(main_win) + vim.api.nvim_win_get_height(history_win) + 1) + eq(main_width, vim.api.nvim_win_get_width(main_win)) + resize_window(history_win, nil, 3) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + eq(region_height, vim.api.nvim_win_get_height(main_win)) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + eq(3, vim.api.nvim_win_get_height(vim.api.nvim_get_current_win())) + end) + + busted.it("restores the main window cursor after closing history", function() + require("mini.codex").setup({ + input = { enabled = true }, + output = { enabled = true }, + win = split_win(), + }) + + vim.cmd("Codex") + local main_win = codex_win() + local main_buf = vim.api.nvim_win_get_buf(main_win) + vim.api.nvim_buf_set_lines(main_buf, 0, -1, false, vim.tbl_map(tostring, vim.fn.range(1, 30))) + vim.api.nvim_win_set_cursor(main_win, { 20, 0 }) + vim.api.nvim_set_current_win(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + + eq(main_win, vim.api.nvim_get_current_win()) + eq(20, vim.api.nvim_win_get_cursor(main_win)[1]) + end) + + busted.it("preserves the current stack bounds across a full toggle", function() + require("mini.codex").setup({ + input = { enabled = true }, + output = { enabled = true }, + win = split_win(), + }) + + vim.cmd("Codex") + local input_win = vim.api.nvim_get_current_win() + local main_win = codex_win() + vim.api.nvim_set_current_win(main_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "mx", false) + + local history_win = vim.api.nvim_get_current_win() + resize_window(history_win, math.max(1, vim.api.nvim_win_get_width(history_win) - 1)) + resize_window(input_win, nil, math.max(1, vim.api.nvim_win_get_height(input_win) - 1)) + local stack_width = vim.api.nvim_win_get_width(main_win) + vim.api.nvim_win_get_width(history_win) + 1 + local main_height = vim.api.nvim_win_get_height(main_win) + local input_height = vim.api.nvim_win_get_height(input_win) + + vim.cmd("Codex toggle") + vim.cmd("Codex toggle") + + main_win = codex_win() + input_win = vim.api.nvim_get_current_win() + eq(stack_width, vim.api.nvim_win_get_width(main_win)) + eq(main_height, vim.api.nvim_win_get_height(main_win)) + eq(stack_width, vim.api.nvim_win_get_width(input_win)) + eq(input_height, vim.api.nvim_win_get_height(input_win)) + end) + busted.it("does not block repeated editor requests", function() local sent = {} - vim.fn.chansend = function(job, data) + vim.api.nvim_chan_send = function(job, data) sent[#sent + 1] = { job, data } end @@ -842,9 +1250,9 @@ busted.describe("mini.codex", function() end) busted.it("navigates repeatedly through sessions", function() - vim.fn.system = function() + vim.system = mock_system(function() return '[{"id":"one","title":"One session"},{"id":"two","title":"Two session"},{"id":"three","title":"Three session"}]' - end + end) require("mini.codex").setup({ win = split_win(),