From 565e3658b50a98a689b820017d45c995a7a232ee Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Tue, 24 Mar 2026 21:32:03 -0400 Subject: [PATCH] refactoring the context --- .../nvim/cmp/get_brain_completions.lua | 36 +- .../nvim/cmp/get_copilot_suggestion.lua | 24 +- .../nvim/cmp/get_indexer_completions.lua | 32 +- lua/codetyper/adapters/nvim/cmp/init.lua | 26 +- .../adapters/nvim/ui/context_modal.lua | 394 +----------------- .../context_modal/attach_requested_files.lua | 55 +++ .../adapters/nvim/ui/context_modal/close.lua | 18 + .../nvim/ui/context_modal/is_open.lua | 9 + .../adapters/nvim/ui/context_modal/open.lua | 118 ++++++ .../run_all_suggested_commands.lua | 15 + .../ui/context_modal/run_project_inspect.lua | 56 +++ .../context_modal/run_suggested_command.lua | 38 ++ .../adapters/nvim/ui/context_modal/setup.lua | 16 + .../adapters/nvim/ui/context_modal/submit.lua | 31 ++ lua/codetyper/state/state.lua | 10 + lua/codetyper/utils/parse_requested_files.lua | 51 +++ 16 files changed, 475 insertions(+), 454 deletions(-) create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/attach_requested_files.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/close.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/is_open.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/open.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/run_all_suggested_commands.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/run_project_inspect.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/run_suggested_command.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/setup.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal/submit.lua create mode 100644 lua/codetyper/state/state.lua create mode 100644 lua/codetyper/utils/parse_requested_files.lua diff --git a/lua/codetyper/adapters/nvim/cmp/get_brain_completions.lua b/lua/codetyper/adapters/nvim/cmp/get_brain_completions.lua index 5153bf3..0cbc58c 100644 --- a/lua/codetyper/adapters/nvim/cmp/get_brain_completions.lua +++ b/lua/codetyper/adapters/nvim/cmp/get_brain_completions.lua @@ -4,38 +4,36 @@ local function get_brain_completions(prefix) local items = {} - local ok_brain, brain = pcall(require, "codetyper.brain") - if not ok_brain then + local brain_loaded, brain = pcall(require, "codetyper.brain") + if not brain_loaded then return items end - -- Check if brain is initialized safely - local is_init = false + local brain_initialized = false if brain.is_initialized then - local ok, result = pcall(brain.is_initialized) - is_init = ok and result + local init_check_success, init_state = pcall(brain.is_initialized) + brain_initialized = init_check_success and init_state end - if not is_init then + if not brain_initialized then return items end - -- Query brain for relevant patterns - local ok_query, result = pcall(brain.query, { + local query_success, query_result = pcall(brain.query, { query = prefix, max_results = 10, types = { "pattern" }, }) - if ok_query and result and result.nodes then - for _, node in ipairs(result.nodes) do + if query_success and query_result and query_result.nodes then + for _, node in ipairs(query_result.nodes) do if node.c and node.c.s then local summary = node.c.s - for name in summary:gmatch("functions:%s*([^;]+)") do - for func in name:gmatch("([%w_]+)") do - if func:lower():find(prefix:lower(), 1, true) then + for matched_functions in summary:gmatch("functions:%s*([^;]+)") do + for func_name in matched_functions:gmatch("([%w_]+)") do + if func_name:lower():find(prefix:lower(), 1, true) then table.insert(items, { - label = func, + label = func_name, kind = 3, -- Function detail = "[brain]", documentation = summary, @@ -43,11 +41,11 @@ local function get_brain_completions(prefix) end end end - for name in summary:gmatch("classes:%s*([^;]+)") do - for class in name:gmatch("([%w_]+)") do - if class:lower():find(prefix:lower(), 1, true) then + for matched_classes in summary:gmatch("classes:%s*([^;]+)") do + for class_name in matched_classes:gmatch("([%w_]+)") do + if class_name:lower():find(prefix:lower(), 1, true) then table.insert(items, { - label = class, + label = class_name, kind = 7, -- Class detail = "[brain]", documentation = summary, diff --git a/lua/codetyper/adapters/nvim/cmp/get_copilot_suggestion.lua b/lua/codetyper/adapters/nvim/cmp/get_copilot_suggestion.lua index 39363c9..d2c2250 100644 --- a/lua/codetyper/adapters/nvim/cmp/get_copilot_suggestion.lua +++ b/lua/codetyper/adapters/nvim/cmp/get_copilot_suggestion.lua @@ -2,24 +2,18 @@ ---@param prefix string ---@return string|nil suggestion local function get_copilot_suggestion(prefix) - -- Try copilot.lua suggestion API first - local ok, copilot_suggestion = pcall(require, "copilot.suggestion") - if ok and copilot_suggestion and type(copilot_suggestion.get_suggestion) == "function" then - local ok2, suggestion = pcall(copilot_suggestion.get_suggestion) - if ok2 and suggestion and suggestion ~= "" then - if prefix == "" or suggestion:lower():match(prefix:lower(), 1) then - return suggestion - else - return suggestion - end + local suggestion_api_loaded, copilot_suggestion_api = pcall(require, "copilot.suggestion") + if suggestion_api_loaded and copilot_suggestion_api and type(copilot_suggestion_api.get_suggestion) == "function" then + local suggestion_fetch_success, suggestion = pcall(copilot_suggestion_api.get_suggestion) + if suggestion_fetch_success and suggestion and suggestion ~= "" then + return suggestion end end - -- Fallback: try older copilot module if present - local ok3, copilot = pcall(require, "copilot") - if ok3 and copilot and type(copilot.get_suggestion) == "function" then - local ok4, suggestion = pcall(copilot.get_suggestion) - if ok4 and suggestion and suggestion ~= "" then + local copilot_loaded, copilot = pcall(require, "copilot") + if copilot_loaded and copilot and type(copilot.get_suggestion) == "function" then + local suggestion_fetch_success, suggestion = pcall(copilot.get_suggestion) + if suggestion_fetch_success and suggestion and suggestion ~= "" then return suggestion end end diff --git a/lua/codetyper/adapters/nvim/cmp/get_indexer_completions.lua b/lua/codetyper/adapters/nvim/cmp/get_indexer_completions.lua index c07ce7b..90bf5c4 100644 --- a/lua/codetyper/adapters/nvim/cmp/get_indexer_completions.lua +++ b/lua/codetyper/adapters/nvim/cmp/get_indexer_completions.lua @@ -4,54 +4,52 @@ local function get_indexer_completions(prefix) local items = {} - local ok_indexer, indexer = pcall(require, "codetyper.indexer") - if not ok_indexer then + local indexer_loaded, indexer = pcall(require, "codetyper.indexer") + if not indexer_loaded then return items end - local ok_load, index = pcall(indexer.load_index) - if not ok_load or not index then + local index_load_success, index = pcall(indexer.load_index) + if not index_load_success or not index then return items end - -- Search symbols if index.symbols then for symbol, files in pairs(index.symbols) do if symbol:lower():find(prefix:lower(), 1, true) then - local files_str = type(files) == "table" and table.concat(files, ", ") or tostring(files) + local files_display = type(files) == "table" and table.concat(files, ", ") or tostring(files) table.insert(items, { label = symbol, kind = 6, -- Variable (generic) - detail = "[index] " .. files_str:sub(1, 30), - documentation = "Symbol found in: " .. files_str, + detail = "[index] " .. files_display:sub(1, 30), + documentation = "Symbol found in: " .. files_display, }) end end end - -- Search functions in files if index.files then for filepath, file_index in pairs(index.files) do if file_index and file_index.functions then - for _, func in ipairs(file_index.functions) do - if func.name and func.name:lower():find(prefix:lower(), 1, true) then + for _, func_entry in ipairs(file_index.functions) do + if func_entry.name and func_entry.name:lower():find(prefix:lower(), 1, true) then table.insert(items, { - label = func.name, + label = func_entry.name, kind = 3, -- Function detail = "[index] " .. vim.fn.fnamemodify(filepath, ":t"), - documentation = func.docstring or ("Function at line " .. (func.line or "?")), + documentation = func_entry.docstring or ("Function at line " .. (func_entry.line or "?")), }) end end end if file_index and file_index.classes then - for _, class in ipairs(file_index.classes) do - if class.name and class.name:lower():find(prefix:lower(), 1, true) then + for _, class_entry in ipairs(file_index.classes) do + if class_entry.name and class_entry.name:lower():find(prefix:lower(), 1, true) then table.insert(items, { - label = class.name, + label = class_entry.name, kind = 7, -- Class detail = "[index] " .. vim.fn.fnamemodify(filepath, ":t"), - documentation = class.docstring or ("Class at line " .. (class.line or "?")), + documentation = class_entry.docstring or ("Class at line " .. (class_entry.line or "?")), }) end end diff --git a/lua/codetyper/adapters/nvim/cmp/init.lua b/lua/codetyper/adapters/nvim/cmp/init.lua index af73a90..7fc2f98 100644 --- a/lua/codetyper/adapters/nvim/cmp/init.lua +++ b/lua/codetyper/adapters/nvim/cmp/init.lua @@ -30,8 +30,8 @@ function source:complete(params, callback) local seen = {} -- Get brain completions (highest priority) - local ok1, brain_items = pcall(get_brain_completions, prefix) - if ok1 and brain_items then + local brain_completions_success, brain_items = pcall(get_brain_completions, prefix) + if brain_completions_success and brain_items then for _, item in ipairs(brain_items) do if not seen[item.label] then seen[item.label] = true @@ -42,8 +42,8 @@ function source:complete(params, callback) end -- Get indexer completions - local ok2, indexer_items = pcall(get_indexer_completions, prefix) - if ok2 and indexer_items then + local indexer_completions_success, indexer_items = pcall(get_indexer_completions, prefix) + if indexer_completions_success and indexer_items then for _, item in ipairs(indexer_items) do if not seen[item.label] then seen[item.label] = true @@ -56,8 +56,8 @@ function source:complete(params, callback) -- Get buffer completions as fallback (lower priority) local bufnr = params.context.bufnr if bufnr then - local ok3, buffer_items = pcall(get_buffer_completions, prefix, bufnr) - if ok3 and buffer_items then + local buffer_completions_success, buffer_items = pcall(get_buffer_completions, prefix, bufnr) + if buffer_completions_success and buffer_items then for _, item in ipairs(buffer_items) do if not seen[item.label] then seen[item.label] = true @@ -69,12 +69,12 @@ function source:complete(params, callback) end -- If Copilot is installed, prefer its suggestion as a top-priority completion - local ok_cp, _ = pcall(require, "copilot") - if ok_cp then + local copilot_installed = pcall(require, "copilot") + if copilot_installed then local suggestion = nil - local ok_sug, res = pcall(get_copilot_suggestion, prefix) - if ok_sug then - suggestion = res + local copilot_suggestion_success, copilot_suggestion_result = pcall(get_copilot_suggestion, prefix) + if copilot_suggestion_success then + suggestion = copilot_suggestion_result end if suggestion and suggestion ~= "" then local first_line = suggestion:match("([^\n]+)") or suggestion @@ -115,8 +115,8 @@ end --- Check if source is registered ---@return boolean function M.is_registered() - local ok, cmp = pcall(require, "cmp") - if not ok then + local cmp_loaded, cmp = pcall(require, "cmp") + if not cmp_loaded then return false end diff --git a/lua/codetyper/adapters/nvim/ui/context_modal.lua b/lua/codetyper/adapters/nvim/ui/context_modal.lua index 5dfd444..fe9789b 100644 --- a/lua/codetyper/adapters/nvim/ui/context_modal.lua +++ b/lua/codetyper/adapters/nvim/ui/context_modal.lua @@ -6,395 +6,9 @@ local M = {} ----@class ContextModalState ----@field buf number|nil Buffer number ----@field win number|nil Window number ----@field original_event table|nil Original prompt event ----@field callback function|nil Callback with additional context ----@field llm_response string|nil LLM's response asking for context - -local state = { - buf = nil, - win = nil, - original_event = nil, - callback = nil, - llm_response = nil, - attached_files = nil, -} - ---- Close the context modal -function M.close() - if state.win and vim.api.nvim_win_is_valid(state.win) then - vim.api.nvim_win_close(state.win, true) - end - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then - vim.api.nvim_buf_delete(state.buf, { force = true }) - end - state.win = nil - state.buf = nil - state.original_event = nil - state.callback = nil - state.llm_response = nil -end - ---- Submit the additional context -local function submit() - if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then - return - end - - local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false) - local additional_context = table.concat(lines, "\n") - - -- Trim whitespace - additional_context = additional_context:match("^%s*(.-)%s*$") or additional_context - - if additional_context == "" then - M.close() - return - end - - local original_event = state.original_event - local callback = state.callback - - M.close() - - if callback and original_event then - -- Pass attached_files as third optional parameter - callback(original_event, additional_context, state.attached_files) - end -end - ---- Parse requested file paths from LLM response and resolve to full paths -local function parse_requested_files(response) - if not response or response == "" then - return {} - end - - local cwd = vim.fn.getcwd() - local candidates = {} - local seen = {} - - for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do - if not seen[path] then - table.insert(candidates, path) - seen[path] = true - end - end - for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do - if not seen[path] then - table.insert(candidates, path) - seen[path] = true - end - end - - -- Resolve to full paths using cwd and glob - local resolved = {} - for _, p in ipairs(candidates) do - local full = nil - if p:sub(1, 1) == "/" and vim.fn.filereadable(p) == 1 then - full = p - else - local try1 = cwd .. "/" .. p - if vim.fn.filereadable(try1) == 1 then - full = try1 - else - local tail = p:match("[^/]+$") or p - local matches = vim.fn.globpath(cwd, "**/" .. tail, false, true) - if matches and #matches > 0 then - full = matches[1] - end - end - end - if full and vim.fn.filereadable(full) == 1 then - table.insert(resolved, full) - end - end - return resolved -end - ---- Attach parsed files into the modal buffer and remember them for submission -local function attach_requested_files() - if not state.llm_response or state.llm_response == "" then - return - end - local files = parse_requested_files(state.llm_response) - if #files == 0 then - local ui_prompts = require("codetyper.prompts.agents.modal").ui - vim.api.nvim_buf_set_lines(state.buf, vim.api.nvim_buf_line_count(state.buf), -1, false, ui_prompts.files_header) - return - end - - state.attached_files = state.attached_files or {} - - for _, full in ipairs(files) do - local ok, lines = pcall(vim.fn.readfile, full) - if ok and lines and #lines > 0 then - table.insert( - state.attached_files, - { path = vim.fn.fnamemodify(full, ":~:."), full_path = full, content = table.concat(lines, "\n") } - ) - local insert_at = vim.api.nvim_buf_line_count(state.buf) - vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Attached: " .. full .. " --" }) - for i, l in ipairs(lines) do - vim.api.nvim_buf_set_lines(state.buf, insert_at + 1 + i, insert_at + 1 + i, false, { l }) - end - else - local insert_at = vim.api.nvim_buf_line_count(state.buf) - vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Failed to read: " .. full .. " --" }) - end - end - -- Move cursor to end and enter insert mode - vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) - vim.cmd("startinsert") -end - ---- Open the context modal ----@param original_event table Original prompt event ----@param llm_response string LLM's response asking for context ----@param callback function(event: table, additional_context: string, attached_files?: table) ----@param suggested_commands table[]|nil Optional list of {label,cmd} suggested shell commands -function M.open(original_event, llm_response, callback, suggested_commands) - -- Close any existing modal - M.close() - - state.original_event = original_event - state.llm_response = llm_response - state.callback = callback - - -- Calculate window size - local width = math.min(80, vim.o.columns - 10) - local height = 10 - - -- Create buffer - state.buf = vim.api.nvim_create_buf(false, true) - vim.bo[state.buf].buftype = "nofile" - vim.bo[state.buf].bufhidden = "wipe" - vim.bo[state.buf].filetype = "markdown" - - -- Create window - local row = math.floor((vim.o.lines - height) / 2) - local col = math.floor((vim.o.columns - width) / 2) - - state.win = vim.api.nvim_open_win(state.buf, true, { - relative = "editor", - row = row, - col = col, - width = width, - height = height, - style = "minimal", - border = "rounded", - title = " Additional Context Needed ", - title_pos = "center", - }) - - -- Set window options - vim.wo[state.win].wrap = true - vim.wo[state.win].cursorline = true - - local ui_prompts = require("codetyper.prompts.agents.modal").ui - - -- Add header showing what the LLM said - local header_lines = { - ui_prompts.llm_response_header, - } - - -- Truncate LLM response for display - local response_preview = llm_response or "" - if #response_preview > 200 then - response_preview = response_preview:sub(1, 200) .. "..." - end - for line in response_preview:gmatch("[^\n]+") do - table.insert(header_lines, "-- " .. line) - end - - -- If suggested commands were provided, show them in the header - if suggested_commands and #suggested_commands > 0 then - table.insert(header_lines, "") - table.insert(header_lines, ui_prompts.suggested_commands_header) - for i, s in ipairs(suggested_commands) do - local label = s.label or s.cmd - table.insert(header_lines, string.format("[%d] %s: %s", i, label, s.cmd)) - end - table.insert(header_lines, ui_prompts.commands_hint) - end - - table.insert(header_lines, "") - table.insert(header_lines, ui_prompts.input_header) - table.insert(header_lines, "") - - vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, header_lines) - - -- Move cursor to the end - vim.api.nvim_win_set_cursor(state.win, { #header_lines, 0 }) - - -- Set up keymaps - local opts = { buffer = state.buf, noremap = true, silent = true } - - -- Submit with Ctrl+Enter or s - vim.keymap.set("n", "", submit, opts) - vim.keymap.set("i", "", submit, opts) - vim.keymap.set("n", "s", submit, opts) - vim.keymap.set("n", "", submit, opts) - - -- Attach parsed files (from LLM response) - vim.keymap.set("n", "a", function() - attach_requested_files() - end, opts) - - -- Confirm and submit with 'c' (convenient when doing question round) - vim.keymap.set("n", "c", submit, opts) - - -- Quick run of project inspection from modal with r / in insert mode - vim.keymap.set("n", "r", run_project_inspect, opts) - vim.keymap.set("i", "", function() - vim.schedule(run_project_inspect) - end, { buffer = state.buf, noremap = true, silent = true }) - - -- If suggested commands provided, create per-command keymaps 1..n to run them - state.suggested_commands = suggested_commands - if suggested_commands and #suggested_commands > 0 then - for i, s in ipairs(suggested_commands) do - local key = "" .. tostring(i) - vim.keymap.set("n", key, function() - -- run this single command and append output - if not s or not s.cmd then - return - end - local ok, out = pcall(vim.fn.systemlist, s.cmd) - local insert_at = vim.api.nvim_buf_line_count(state.buf) - vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" }) - if ok and out and #out > 0 then - for j, line in ipairs(out) do - vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line }) - end - else - vim.api.nvim_buf_set_lines( - state.buf, - insert_at + 1, - insert_at + 1, - false, - { "(no output or command failed)" } - ) - end - vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) - vim.cmd("startinsert") - end, opts) - end - -- Also map 0 to run all suggested commands - vim.keymap.set("n", "0", function() - for _, s in ipairs(suggested_commands) do - pcall(function() - local ok, out = pcall(vim.fn.systemlist, s.cmd) - local insert_at = vim.api.nvim_buf_line_count(state.buf) - vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" }) - if ok and out and #out > 0 then - for j, line in ipairs(out) do - vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line }) - end - else - vim.api.nvim_buf_set_lines( - state.buf, - insert_at + 1, - insert_at + 1, - false, - { "(no output or command failed)" } - ) - end - end) - end - vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) - vim.cmd("startinsert") - end, opts) - end - - -- Close with Esc or q - vim.keymap.set("n", "", M.close, opts) - vim.keymap.set("n", "q", M.close, opts) - - -- Start in insert mode - vim.cmd("startinsert") - - -- Log - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = "Context modal opened - waiting for user input", - }) - end) -end - ---- Run a small set of safe project inspection commands and insert outputs into the modal buffer -local function run_project_inspect() - if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then - return - end - - local cmds = { - { label = "List files (ls -la)", cmd = "ls -la" }, - { label = "Git status (git status --porcelain)", cmd = "git status --porcelain" }, - { label = "Git top (git rev-parse --show-toplevel)", cmd = "git rev-parse --show-toplevel" }, - { label = "Show repo files (git ls-files)", cmd = "git ls-files" }, - } - - local ui_prompts = require("codetyper.prompts.agents.modal").ui - local insert_pos = vim.api.nvim_buf_line_count(state.buf) - vim.api.nvim_buf_set_lines(state.buf, insert_pos, insert_pos, false, ui_prompts.project_inspect_header) - - for _, c in ipairs(cmds) do - local ok, out = pcall(vim.fn.systemlist, c.cmd) - if ok and out and #out > 0 then - vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2, insert_pos + 2, false, { "-- " .. c.label .. " --" }) - for i, line in ipairs(out) do - vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2 + i, insert_pos + 2 + i, false, { line }) - end - insert_pos = vim.api.nvim_buf_line_count(state.buf) - else - vim.api.nvim_buf_set_lines( - state.buf, - insert_pos + 2, - insert_pos + 2, - false, - { "-- " .. c.label .. " --", "(no output or command failed)" } - ) - insert_pos = vim.api.nvim_buf_line_count(state.buf) - end - end - - -- Move cursor to end - vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) - vim.cmd("startinsert") -end - --- Provide a keybinding in the modal to run project inspection commands -pcall(function() - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then - vim.keymap.set("n", "r", run_project_inspect, { buffer = state.buf, noremap = true, silent = true }) - vim.keymap.set("i", "", function() - vim.schedule(run_project_inspect) - end, { buffer = state.buf, noremap = true, silent = true }) - end -end) - ---- Check if modal is open ----@return boolean -function M.is_open() - return state.win ~= nil and vim.api.nvim_win_is_valid(state.win) -end - ---- Setup autocmds for the context modal -function M.setup() - local group = vim.api.nvim_create_augroup("CodetypeContextModal", { clear = true }) - - -- Close context modal when exiting Neovim - vim.api.nvim_create_autocmd("VimLeavePre", { - group = group, - callback = function() - M.close() - end, - desc = "Close context modal before exiting Neovim", - }) -end +M.close = require("codetyper.adapters.nvim.ui.context_modal.close") +M.is_open = require("codetyper.adapters.nvim.ui.context_modal.is_open") +M.setup = require("codetyper.adapters.nvim.ui.context_modal.setup") +M.open = require("codetyper.adapters.nvim.ui.context_modal.open") return M diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/attach_requested_files.lua b/lua/codetyper/adapters/nvim/ui/context_modal/attach_requested_files.lua new file mode 100644 index 0000000..df42e88 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/attach_requested_files.lua @@ -0,0 +1,55 @@ +local state = require("codetyper.state.state") +local parse_requested_files = require("codetyper.utils.parse_requested_files") + +--- Attach parsed files from LLM response into the modal buffer +local function attach_requested_files() + if not state.llm_response or state.llm_response == "" then + return + end + + local resolved_files = parse_requested_files(state.llm_response) + + if #resolved_files == 0 then + local ui_prompts = require("codetyper.prompts.agents.modal").ui + vim.api.nvim_buf_set_lines(state.buf, vim.api.nvim_buf_line_count(state.buf), -1, false, ui_prompts.files_header) + return + end + + state.attached_files = state.attached_files or {} + + for _, file_path in ipairs(resolved_files) do + local read_success, file_lines = pcall(vim.fn.readfile, file_path) + if read_success and file_lines and #file_lines > 0 then + table.insert(state.attached_files, { + path = vim.fn.fnamemodify(file_path, ":~:."), + full_path = file_path, + content = table.concat(file_lines, "\n"), + }) + local insert_at = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Attached: " .. file_path .. " --" }) + for line_index, line_content in ipairs(file_lines) do + vim.api.nvim_buf_set_lines( + state.buf, + insert_at + 1 + line_index, + insert_at + 1 + line_index, + false, + { line_content } + ) + end + else + local insert_at = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines( + state.buf, + insert_at, + insert_at, + false, + { "", "-- Failed to read: " .. file_path .. " --" } + ) + end + end + + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") +end + +return attach_requested_files diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/close.lua b/lua/codetyper/adapters/nvim/ui/context_modal/close.lua new file mode 100644 index 0000000..824986c --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/close.lua @@ -0,0 +1,18 @@ +local state = require("codetyper.state.state") + +--- Close the context modal and reset state +local function close() + if state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_win_close(state.win, true) + end + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + vim.api.nvim_buf_delete(state.buf, { force = true }) + end + state.win = nil + state.buf = nil + state.original_event = nil + state.callback = nil + state.llm_response = nil +end + +return close diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/is_open.lua b/lua/codetyper/adapters/nvim/ui/context_modal/is_open.lua new file mode 100644 index 0000000..90dc280 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/is_open.lua @@ -0,0 +1,9 @@ +local state = require("codetyper.state.state") + +--- Check if the context modal is currently open +---@return boolean +local function is_open() + return state.win ~= nil and vim.api.nvim_win_is_valid(state.win) +end + +return is_open diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/open.lua b/lua/codetyper/adapters/nvim/ui/context_modal/open.lua new file mode 100644 index 0000000..366a886 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/open.lua @@ -0,0 +1,118 @@ +local state = require("codetyper.state.state") +local submit = require("codetyper.adapters.nvim.ui.context_modal.submit") +local attach_requested_files = require("codetyper.adapters.nvim.ui.context_modal.attach_requested_files") +local run_project_inspect = require("codetyper.adapters.nvim.ui.context_modal.run_project_inspect") +local run_suggested_command = require("codetyper.adapters.nvim.ui.context_modal.run_suggested_command") +local run_all_suggested_commands = require("codetyper.adapters.nvim.ui.context_modal.run_all_suggested_commands") + +--- Open the context modal +---@param original_event table Original prompt event +---@param llm_response string LLM's response asking for context +---@param callback function(event: table, additional_context: string, attached_files?: table) +---@param suggested_commands table[]|nil Optional list of {label,cmd} suggested shell commands +function M.open(original_event, llm_response, callback, suggested_commands) + close() + + state.original_event = original_event + state.llm_response = llm_response + state.callback = callback + + local width = math.min(80, vim.o.columns - 10) + local height = 10 + + state.buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.buf].buftype = "nofile" + vim.bo[state.buf].bufhidden = "wipe" + vim.bo[state.buf].filetype = "markdown" + + local row = math.floor((vim.o.lines - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + + state.win = vim.api.nvim_open_win(state.buf, true, { + relative = "editor", + row = row, + col = col, + width = width, + height = height, + style = "minimal", + border = "rounded", + title = " Additional Context Needed ", + title_pos = "center", + }) + + vim.wo[state.win].wrap = true + vim.wo[state.win].cursorline = true + + local ui_prompts = require("codetyper.prompts.agents.modal").ui + + local header_lines = { + ui_prompts.llm_response_header, + } + + local response_preview = llm_response or "" + if #response_preview > 200 then + response_preview = response_preview:sub(1, 200) .. "..." + end + for line in response_preview:gmatch("[^\n]+") do + table.insert(header_lines, "-- " .. line) + end + + if suggested_commands and #suggested_commands > 0 then + table.insert(header_lines, "") + table.insert(header_lines, ui_prompts.suggested_commands_header) + for command_index, command in ipairs(suggested_commands) do + local label = command.label or command.cmd + table.insert(header_lines, string.format("[%d] %s: %s", command_index, label, command.cmd)) + end + table.insert(header_lines, ui_prompts.commands_hint) + end + + table.insert(header_lines, "") + table.insert(header_lines, ui_prompts.input_header) + table.insert(header_lines, "") + + vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, header_lines) + vim.api.nvim_win_set_cursor(state.win, { #header_lines, 0 }) + + local keymap_opts = { buffer = state.buf, noremap = true, silent = true } + + vim.keymap.set("n", "", submit, keymap_opts) + vim.keymap.set("i", "", submit, keymap_opts) + vim.keymap.set("n", "s", submit, keymap_opts) + vim.keymap.set("n", "", submit, keymap_opts) + vim.keymap.set("n", "c", submit, keymap_opts) + + vim.keymap.set("n", "a", attach_requested_files, keymap_opts) + + vim.keymap.set("n", "r", run_project_inspect, keymap_opts) + vim.keymap.set("i", "", function() + vim.schedule(run_project_inspect) + end, keymap_opts) + + state.suggested_commands = suggested_commands + if suggested_commands and #suggested_commands > 0 then + for command_index, command in ipairs(suggested_commands) do + local key = "" .. tostring(command_index) + vim.keymap.set("n", key, function() + run_suggested_command(command) + end, keymap_opts) + end + + vim.keymap.set("n", "0", function() + run_all_suggested_commands(suggested_commands) + end, keymap_opts) + end + + vim.keymap.set("n", "", close, keymap_opts) + vim.keymap.set("n", "q", close, keymap_opts) + + vim.cmd("startinsert") + + pcall(function() + local logs = require("codetyper.adapters.nvim.ui.logs") + logs.add({ + type = "info", + message = "Context modal opened - waiting for user input", + }) + end) +end diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/run_all_suggested_commands.lua b/lua/codetyper/adapters/nvim/ui/context_modal/run_all_suggested_commands.lua new file mode 100644 index 0000000..4a592ea --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/run_all_suggested_commands.lua @@ -0,0 +1,15 @@ +local state = require("codetyper.state.state") +local run_suggested_command = require("codetyper.adapters.nvim.ui.context_modal.run_suggested_command") + +--- Run all suggested shell commands and append their outputs to the modal buffer +---@param commands table[] List of {label, cmd} suggested command entries +local function run_all_suggested_commands(commands) + for _, command in ipairs(commands) do + pcall(run_suggested_command, command) + end + + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") +end + +return run_all_suggested_commands diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/run_project_inspect.lua b/lua/codetyper/adapters/nvim/ui/context_modal/run_project_inspect.lua new file mode 100644 index 0000000..c16c1fa --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/run_project_inspect.lua @@ -0,0 +1,56 @@ +local state = require("codetyper.state.state") + +--- Run a small set of safe project inspection commands and insert outputs into the modal buffer +local function run_project_inspect() + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then + return + end + + local inspection_commands = { + { label = "List files (ls -la)", cmd = "ls -la" }, + { label = "Git status (git status --porcelain)", cmd = "git status --porcelain" }, + { label = "Git top (git rev-parse --show-toplevel)", cmd = "git rev-parse --show-toplevel" }, + { label = "Show repo files (git ls-files)", cmd = "git ls-files" }, + } + + local ui_prompts = require("codetyper.prompts.agents.modal").ui + local insert_pos = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_pos, insert_pos, false, ui_prompts.project_inspect_header) + + for _, command in ipairs(inspection_commands) do + local run_success, output_lines = pcall(vim.fn.systemlist, command.cmd) + if run_success and output_lines and #output_lines > 0 then + vim.api.nvim_buf_set_lines( + state.buf, + insert_pos + 2, + insert_pos + 2, + false, + { "-- " .. command.label .. " --" } + ) + for line_index, line_content in ipairs(output_lines) do + vim.api.nvim_buf_set_lines( + state.buf, + insert_pos + 2 + line_index, + insert_pos + 2 + line_index, + false, + { line_content } + ) + end + insert_pos = vim.api.nvim_buf_line_count(state.buf) + else + vim.api.nvim_buf_set_lines( + state.buf, + insert_pos + 2, + insert_pos + 2, + false, + { "-- " .. command.label .. " --", "(no output or command failed)" } + ) + insert_pos = vim.api.nvim_buf_line_count(state.buf) + end + end + + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") +end + +return run_project_inspect diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/run_suggested_command.lua b/lua/codetyper/adapters/nvim/ui/context_modal/run_suggested_command.lua new file mode 100644 index 0000000..4bb5808 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/run_suggested_command.lua @@ -0,0 +1,38 @@ +local state = require("codetyper.state.state") + +--- Run a single suggested shell command and append its output to the modal buffer +---@param command table A {label, cmd} suggested command entry +local function run_suggested_command(command) + if not command or not command.cmd then + return + end + + local run_success, output_lines = pcall(vim.fn.systemlist, command.cmd) + local insert_at = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. command.cmd .. " --" }) + + if run_success and output_lines and #output_lines > 0 then + for line_index, line_content in ipairs(output_lines) do + vim.api.nvim_buf_set_lines( + state.buf, + insert_at + line_index, + insert_at + line_index, + false, + { line_content } + ) + end + else + vim.api.nvim_buf_set_lines( + state.buf, + insert_at + 1, + insert_at + 1, + false, + { "(no output or command failed)" } + ) + end + + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") +end + +return run_suggested_command diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/setup.lua b/lua/codetyper/adapters/nvim/ui/context_modal/setup.lua new file mode 100644 index 0000000..df197b2 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/setup.lua @@ -0,0 +1,16 @@ +local close = require("codetyper.adapters.nvim.ui.context_modal.close") + +--- Setup autocmds for the context modal +local function setup() + local group = vim.api.nvim_create_augroup("CodetypeContextModal", { clear = true }) + + vim.api.nvim_create_autocmd("VimLeavePre", { + group = group, + callback = function() + close() + end, + desc = "Close context modal before exiting Neovim", + }) +end + +return setup diff --git a/lua/codetyper/adapters/nvim/ui/context_modal/submit.lua b/lua/codetyper/adapters/nvim/ui/context_modal/submit.lua new file mode 100644 index 0000000..bd37ffa --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal/submit.lua @@ -0,0 +1,31 @@ +local state = require("codetyper.state.state") +local close = require("codetyper.adapters.nvim.ui.context_modal.close") + +--- Submit the additional context from the modal buffer +local function submit() + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then + return + end + + local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false) + local additional_context = table.concat(lines, "\n") + + additional_context = additional_context:match("^%s*(.-)%s*$") or additional_context + + if additional_context == "" then + close() + return + end + + local original_event = state.original_event + local callback = state.callback + local attached_files = state.attached_files + + close() + + if callback and original_event then + callback(original_event, additional_context, attached_files) + end +end + +return submit diff --git a/lua/codetyper/state/state.lua b/lua/codetyper/state/state.lua new file mode 100644 index 0000000..419abc3 --- /dev/null +++ b/lua/codetyper/state/state.lua @@ -0,0 +1,10 @@ +local state = { + buf = nil, + win = nil, + original_event = nil, + callback = nil, + llm_response = nil, + attached_files = nil, +} + +return state diff --git a/lua/codetyper/utils/parse_requested_files.lua b/lua/codetyper/utils/parse_requested_files.lua new file mode 100644 index 0000000..e03e874 --- /dev/null +++ b/lua/codetyper/utils/parse_requested_files.lua @@ -0,0 +1,51 @@ +--- Parse requested file paths from LLM response and resolve to full paths +---@param response string LLM response text +---@return string[] resolved_paths List of resolved absolute file paths +local function parse_requested_files(response) + if not response or response == "" then + return {} + end + + local cwd = vim.fn.getcwd() + local candidates = {} + local seen = {} + + for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do + if not seen[path] then + table.insert(candidates, path) + seen[path] = true + end + end + for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do + if not seen[path] then + table.insert(candidates, path) + seen[path] = true + end + end + + local resolved = {} + for _, candidate_path in ipairs(candidates) do + local full_path = nil + if candidate_path:sub(1, 1) == "/" and vim.fn.filereadable(candidate_path) == 1 then + full_path = candidate_path + else + local relative_path = cwd .. "/" .. candidate_path + if vim.fn.filereadable(relative_path) == 1 then + full_path = relative_path + else + local filename = candidate_path:match("[^/]+$") or candidate_path + local glob_matches = vim.fn.globpath(cwd, "**/" .. filename, false, true) + if glob_matches and #glob_matches > 0 then + full_path = glob_matches[1] + end + end + end + if full_path and vim.fn.filereadable(full_path) == 1 then + table.insert(resolved, full_path) + end + end + + return resolved +end + +return parse_requested_files