From 2989fb5f14543851ff9b6a17cf28c3c3bbf80edc Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Tue, 13 Jan 2026 20:45:55 -0500 Subject: [PATCH] feat: add agent mode and CoderType command for mode switching - Add agent module with tool execution support (read_file, edit_file, bash) - Add agent/ui.lua with chat sidebar, input area, and real-time logs panel - Add agent/logs.lua for token counting and request/response logging - Add generate_with_tools to claude.lua and ollama.lua for tool use - Add chat_switcher.lua modal picker for Ask/Agent mode selection - Add CoderType command to show mode switcher (replaces C-Tab keymaps) - Update ask.lua and agent/ui.lua headers to reference :CoderType Co-Authored-By: Claude Opus 4.5 --- .gitignore | 47 ++- lua/codetyper/agent/diff.lua | 240 +++++++++++ lua/codetyper/agent/executor.lua | 294 ++++++++++++++ lua/codetyper/agent/init.lua | 308 ++++++++++++++ lua/codetyper/agent/logs.lua | 228 +++++++++++ lua/codetyper/agent/parser.lua | 117 ++++++ lua/codetyper/agent/tools.lua | 144 +++++++ lua/codetyper/agent/ui.lua | 674 +++++++++++++++++++++++++++++++ lua/codetyper/ask.lua | 56 +-- lua/codetyper/chat_switcher.lua | 44 ++ lua/codetyper/commands.lua | 72 +++- lua/codetyper/llm/claude.lua | 367 ++++++++++++----- lua/codetyper/llm/ollama.lua | 196 +++++++++ 13 files changed, 2658 insertions(+), 129 deletions(-) create mode 100644 lua/codetyper/agent/diff.lua create mode 100644 lua/codetyper/agent/executor.lua create mode 100644 lua/codetyper/agent/init.lua create mode 100644 lua/codetyper/agent/logs.lua create mode 100644 lua/codetyper/agent/parser.lua create mode 100644 lua/codetyper/agent/tools.lua create mode 100644 lua/codetyper/agent/ui.lua create mode 100644 lua/codetyper/chat_switcher.lua diff --git a/.gitignore b/.gitignore index 1a3cacc..f03cc40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,48 @@ # Codetyper.nvim - AI coding partner files *.coder.* .coder/ +.claude/ -/@ -add gitignore for lua files -/@ +# Created by https://www.toptal.com/developers/gitignore/api/lua + +### Lua ### +# Compiled Lua sources +luac.out + +# luarocks build files +*.src.rock +*.zip +*.tar.gz + +# Object files +*.o +*.os +*.ko +*.obj +*.elf + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo +*.def +*.exp + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex diff --git a/lua/codetyper/agent/diff.lua b/lua/codetyper/agent/diff.lua new file mode 100644 index 0000000..5717df3 --- /dev/null +++ b/lua/codetyper/agent/diff.lua @@ -0,0 +1,240 @@ +---@mod codetyper.agent.diff Diff preview UI for agent changes +--- +--- Shows diff previews for file changes and bash command approvals. + +local M = {} + +--- Show a diff preview for file changes +---@param diff_data table { path: string, original: string, modified: string, operation: string } +---@param callback fun(approved: boolean) Called with user decision +function M.show_diff(diff_data, callback) + local original_lines = vim.split(diff_data.original, "\n", { plain = true }) + local modified_lines = vim.split(diff_data.modified, "\n", { plain = true }) + + -- Calculate window dimensions + local width = math.floor(vim.o.columns * 0.8) + local height = math.floor(vim.o.lines * 0.7) + local row = math.floor((vim.o.lines - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + + -- Create left buffer (original) + local left_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, original_lines) + vim.bo[left_buf].modifiable = false + vim.bo[left_buf].bufhidden = "wipe" + + -- Create right buffer (modified) + local right_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, modified_lines) + vim.bo[right_buf].modifiable = false + vim.bo[right_buf].bufhidden = "wipe" + + -- Set filetype for syntax highlighting based on file extension + local ext = vim.fn.fnamemodify(diff_data.path, ":e") + if ext and ext ~= "" then + vim.bo[left_buf].filetype = ext + vim.bo[right_buf].filetype = ext + end + + -- Create left window (original) + local half_width = math.floor((width - 1) / 2) + local left_win = vim.api.nvim_open_win(left_buf, true, { + relative = "editor", + width = half_width, + height = height - 2, + row = row, + col = col, + style = "minimal", + border = "rounded", + title = " ORIGINAL ", + title_pos = "center", + }) + + -- Create right window (modified) + local right_win = vim.api.nvim_open_win(right_buf, false, { + relative = "editor", + width = half_width, + height = height - 2, + row = row, + col = col + half_width + 1, + style = "minimal", + border = "rounded", + title = " MODIFIED [" .. diff_data.operation .. "] ", + title_pos = "center", + }) + + -- Enable diff mode in both windows + vim.api.nvim_win_call(left_win, function() + vim.cmd("diffthis") + end) + vim.api.nvim_win_call(right_win, function() + vim.cmd("diffthis") + end) + + -- Sync scrolling + vim.wo[left_win].scrollbind = true + vim.wo[right_win].scrollbind = true + vim.wo[left_win].cursorbind = true + vim.wo[right_win].cursorbind = true + + -- Track if callback was already called + local callback_called = false + + -- Close function + local function close_and_respond(approved) + if callback_called then + return + end + callback_called = true + + -- Disable diff mode + pcall(function() + vim.api.nvim_win_call(left_win, function() + vim.cmd("diffoff") + end) + end) + pcall(function() + vim.api.nvim_win_call(right_win, function() + vim.cmd("diffoff") + end) + end) + + -- Close windows + pcall(vim.api.nvim_win_close, left_win, true) + pcall(vim.api.nvim_win_close, right_win, true) + + -- Call callback + vim.schedule(function() + callback(approved) + end) + end + + -- Set up keymaps for both buffers + local keymap_opts = { noremap = true, silent = true, nowait = true } + + for _, buf in ipairs({ left_buf, right_buf }) do + -- Approve + vim.keymap.set("n", "y", function() + close_and_respond(true) + end, vim.tbl_extend("force", keymap_opts, { buffer = buf })) + vim.keymap.set("n", "", function() + close_and_respond(true) + end, vim.tbl_extend("force", keymap_opts, { buffer = buf })) + + -- Reject + vim.keymap.set("n", "n", function() + close_and_respond(false) + end, vim.tbl_extend("force", keymap_opts, { buffer = buf })) + vim.keymap.set("n", "q", function() + close_and_respond(false) + end, vim.tbl_extend("force", keymap_opts, { buffer = buf })) + vim.keymap.set("n", "", function() + close_and_respond(false) + end, vim.tbl_extend("force", keymap_opts, { buffer = buf })) + + -- Switch between windows + vim.keymap.set("n", "", function() + local current = vim.api.nvim_get_current_win() + if current == left_win then + vim.api.nvim_set_current_win(right_win) + else + vim.api.nvim_set_current_win(left_win) + end + end, vim.tbl_extend("force", keymap_opts, { buffer = buf })) + end + + -- Show help message + vim.api.nvim_echo({ + { "Diff: ", "Normal" }, + { diff_data.path, "Directory" }, + { " | ", "Normal" }, + { "y/", "Keyword" }, + { " approve ", "Normal" }, + { "n/q/", "Keyword" }, + { " reject ", "Normal" }, + { "", "Keyword" }, + { " switch panes", "Normal" }, + }, false, {}) +end + +--- Show approval dialog for bash commands +---@param command string The bash command to approve +---@param callback fun(approved: boolean) Called with user decision +function M.show_bash_approval(command, callback) + -- Create a simple floating window for bash approval + local lines = { + "", + " BASH COMMAND APPROVAL", + " " .. string.rep("-", 50), + "", + " Command:", + " $ " .. command, + "", + " " .. string.rep("-", 50), + " Press [y] or [Enter] to execute", + " Press [n], [q], or [Esc] to cancel", + "", + } + + local width = math.max(60, #command + 10) + local height = #lines + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + vim.bo[buf].bufhidden = "wipe" + + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + title = " Approve Command? ", + title_pos = "center", + }) + + -- Apply some highlighting + vim.api.nvim_buf_add_highlight(buf, -1, "Title", 1, 0, -1) + vim.api.nvim_buf_add_highlight(buf, -1, "String", 5, 0, -1) + + local callback_called = false + + local function close_and_respond(approved) + if callback_called then + return + end + callback_called = true + + pcall(vim.api.nvim_win_close, win, true) + + vim.schedule(function() + callback(approved) + end) + end + + local keymap_opts = { buffer = buf, noremap = true, silent = true, nowait = true } + + -- Approve + vim.keymap.set("n", "y", function() + close_and_respond(true) + end, keymap_opts) + vim.keymap.set("n", "", function() + close_and_respond(true) + end, keymap_opts) + + -- Reject + vim.keymap.set("n", "n", function() + close_and_respond(false) + end, keymap_opts) + vim.keymap.set("n", "q", function() + close_and_respond(false) + end, keymap_opts) + vim.keymap.set("n", "", function() + close_and_respond(false) + end, keymap_opts) +end + +return M diff --git a/lua/codetyper/agent/executor.lua b/lua/codetyper/agent/executor.lua new file mode 100644 index 0000000..28e5826 --- /dev/null +++ b/lua/codetyper/agent/executor.lua @@ -0,0 +1,294 @@ +---@mod codetyper.agent.executor Tool executor for agent system +--- +--- Executes tools requested by the LLM and returns results. + +local M = {} +local utils = require("codetyper.utils") + +---@class ExecutionResult +---@field success boolean Whether the execution succeeded +---@field result string Result message or content +---@field requires_approval boolean Whether user approval is needed +---@field diff_data? DiffData Data for diff preview (if requires_approval) + +---@class DiffData +---@field path string File path +---@field original string Original content +---@field modified string Modified content +---@field operation string Operation type: "edit", "create", "overwrite", "bash" + +--- Execute a tool and return result via callback +---@param tool_name string Name of the tool to execute +---@param parameters table Tool parameters +---@param callback fun(result: ExecutionResult) Callback with result +function M.execute(tool_name, parameters, callback) + local handlers = { + read_file = M.handle_read_file, + edit_file = M.handle_edit_file, + write_file = M.handle_write_file, + bash = M.handle_bash, + } + + local handler = handlers[tool_name] + if not handler then + callback({ + success = false, + result = "Unknown tool: " .. tool_name, + requires_approval = false, + }) + return + end + + handler(parameters, callback) +end + +--- Handle read_file tool +---@param params table { path: string } +---@param callback fun(result: ExecutionResult) +function M.handle_read_file(params, callback) + local path = M.resolve_path(params.path) + local content = utils.read_file(path) + + if content then + callback({ + success = true, + result = content, + requires_approval = false, + }) + else + callback({ + success = false, + result = "Could not read file: " .. path, + requires_approval = false, + }) + end +end + +--- Handle edit_file tool +---@param params table { path: string, find: string, replace: string } +---@param callback fun(result: ExecutionResult) +function M.handle_edit_file(params, callback) + local path = M.resolve_path(params.path) + local original = utils.read_file(path) + + if not original then + callback({ + success = false, + result = "File not found: " .. path, + requires_approval = false, + }) + return + end + + -- Try to find and replace the content + local escaped_find = utils.escape_pattern(params.find) + local new_content, count = original:gsub(escaped_find, params.replace, 1) + + if count == 0 then + callback({ + success = false, + result = "Could not find content to replace in: " .. path, + requires_approval = false, + }) + return + end + + -- Requires user approval - show diff + callback({ + success = true, + result = "Edit prepared for: " .. path, + requires_approval = true, + diff_data = { + path = path, + original = original, + modified = new_content, + operation = "edit", + }, + }) +end + +--- Handle write_file tool +---@param params table { path: string, content: string } +---@param callback fun(result: ExecutionResult) +function M.handle_write_file(params, callback) + local path = M.resolve_path(params.path) + local original = utils.read_file(path) or "" + local operation = original == "" and "create" or "overwrite" + + -- Ensure parent directory exists + local dir = vim.fn.fnamemodify(path, ":h") + if dir ~= "" and dir ~= "." then + utils.ensure_dir(dir) + end + + callback({ + success = true, + result = (operation == "create" and "Create" or "Overwrite") .. " prepared for: " .. path, + requires_approval = true, + diff_data = { + path = path, + original = original, + modified = params.content, + operation = operation, + }, + }) +end + +--- Handle bash tool +---@param params table { command: string, timeout?: number } +---@param callback fun(result: ExecutionResult) +function M.handle_bash(params, callback) + local command = params.command + + -- Requires user approval first + callback({ + success = true, + result = "Command: " .. command, + requires_approval = true, + diff_data = { + path = "[bash]", + original = "", + modified = "$ " .. command, + operation = "bash", + }, + bash_command = command, + bash_timeout = params.timeout or 30000, + }) +end + +--- Actually apply an approved change +---@param diff_data DiffData The diff data to apply +---@param callback fun(result: ExecutionResult) +function M.apply_change(diff_data, callback) + if diff_data.operation == "bash" then + -- Extract command from modified (remove "$ " prefix) + local command = diff_data.modified:gsub("^%$ ", "") + M.execute_bash_command(command, 30000, callback) + else + -- Write file + local success = utils.write_file(diff_data.path, diff_data.modified) + if success then + -- Reload buffer if it's open + M.reload_buffer_if_open(diff_data.path) + callback({ + success = true, + result = "Changes applied to: " .. diff_data.path, + requires_approval = false, + }) + else + callback({ + success = false, + result = "Failed to write: " .. diff_data.path, + requires_approval = false, + }) + end + end +end + +--- Execute a bash command +---@param command string Command to execute +---@param timeout number Timeout in milliseconds +---@param callback fun(result: ExecutionResult) +function M.execute_bash_command(command, timeout, callback) + local stdout_data = {} + local stderr_data = {} + local job_id + + job_id = vim.fn.jobstart(command, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(stdout_data, line) + end + end + end + end, + on_stderr = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(stderr_data, line) + end + end + end + end, + on_exit = function(_, exit_code) + vim.schedule(function() + local result = table.concat(stdout_data, "\n") + if #stderr_data > 0 then + if result ~= "" then + result = result .. "\n" + end + result = result .. "STDERR:\n" .. table.concat(stderr_data, "\n") + end + result = result .. "\n[Exit code: " .. exit_code .. "]" + + callback({ + success = exit_code == 0, + result = result, + requires_approval = false, + }) + end) + end, + }) + + -- Set up timeout + if job_id > 0 then + vim.defer_fn(function() + if vim.fn.jobwait({ job_id }, 0)[1] == -1 then + vim.fn.jobstop(job_id) + vim.schedule(function() + callback({ + success = false, + result = "Command timed out after " .. timeout .. "ms", + requires_approval = false, + }) + end) + end + end, timeout) + else + callback({ + success = false, + result = "Failed to start command", + requires_approval = false, + }) + end +end + +--- Reload a buffer if it's currently open +---@param filepath string Path to the file +function M.reload_buffer_if_open(filepath) + local full_path = vim.fn.fnamemodify(filepath, ":p") + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + local buf_name = vim.api.nvim_buf_get_name(buf) + if buf_name == full_path then + vim.api.nvim_buf_call(buf, function() + vim.cmd("edit!") + end) + break + end + end + end +end + +--- Resolve a path (expand ~ and make absolute if needed) +---@param path string Path to resolve +---@return string Resolved path +function M.resolve_path(path) + -- Expand ~ to home directory + local expanded = vim.fn.expand(path) + + -- If relative, make it relative to project root or cwd + if not vim.startswith(expanded, "/") then + local root = utils.get_project_root() or vim.fn.getcwd() + expanded = root .. "/" .. expanded + end + + return vim.fn.fnamemodify(expanded, ":p") +end + +return M diff --git a/lua/codetyper/agent/init.lua b/lua/codetyper/agent/init.lua new file mode 100644 index 0000000..c17add6 --- /dev/null +++ b/lua/codetyper/agent/init.lua @@ -0,0 +1,308 @@ +---@mod codetyper.agent Agent orchestration for Codetyper.nvim +--- +--- Manages the agentic conversation loop with tool execution. + +local M = {} + +local tools = require("codetyper.agent.tools") +local executor = require("codetyper.agent.executor") +local parser = require("codetyper.agent.parser") +local diff = require("codetyper.agent.diff") +local utils = require("codetyper.utils") +local logs = require("codetyper.agent.logs") + +---@class AgentState +---@field conversation table[] Message history for multi-turn +---@field pending_tool_results table[] Results waiting to be sent back +---@field is_running boolean Whether agent loop is active +---@field max_iterations number Maximum tool call iterations + +local state = { + conversation = {}, + pending_tool_results = {}, + is_running = false, + max_iterations = 10, + current_iteration = 0, +} + +---@class AgentCallbacks +---@field on_text fun(text: string) Called when text content is received +---@field on_tool_start fun(name: string) Called when a tool starts +---@field on_tool_result fun(name: string, result: string) Called when a tool completes +---@field on_complete fun() Called when agent finishes +---@field on_error fun(err: string) Called on error + +--- Reset agent state for new conversation +function M.reset() + state.conversation = {} + state.pending_tool_results = {} + state.is_running = false + state.current_iteration = 0 +end + +--- Check if agent is currently running +---@return boolean +function M.is_running() + return state.is_running +end + +--- Stop the agent +function M.stop() + state.is_running = false + utils.notify("Agent stopped") +end + +--- Main agent entry point +---@param prompt string User's request +---@param context table File context +---@param callbacks AgentCallbacks Callback functions +function M.run(prompt, context, callbacks) + if state.is_running then + callbacks.on_error("Agent is already running") + return + end + + logs.info("Starting agent run") + logs.debug("Prompt length: " .. #prompt .. " chars") + + state.is_running = true + state.current_iteration = 0 + + -- Add user message to conversation + table.insert(state.conversation, { + role = "user", + content = prompt, + }) + + -- Start the agent loop + M.agent_loop(context, callbacks) +end + +--- The core agent loop +---@param context table File context +---@param callbacks AgentCallbacks +function M.agent_loop(context, callbacks) + if not state.is_running then + callbacks.on_complete() + return + end + + state.current_iteration = state.current_iteration + 1 + logs.info(string.format("Agent loop iteration %d/%d", state.current_iteration, state.max_iterations)) + + if state.current_iteration > state.max_iterations then + logs.error("Max iterations reached") + callbacks.on_error("Max iterations reached (" .. state.max_iterations .. ")") + state.is_running = false + return + end + + local llm = require("codetyper.llm") + local client = llm.get_client() + + -- Check if client supports tools + if not client.generate_with_tools then + logs.error("Provider does not support agent mode") + callbacks.on_error("Current LLM provider does not support agent mode") + state.is_running = false + return + end + + logs.thinking("Calling LLM with " .. #state.conversation .. " messages...") + + -- Generate with tools enabled + client.generate_with_tools(state.conversation, context, tools.definitions, function(response, err) + if err then + state.is_running = false + callbacks.on_error(err) + return + end + + -- Parse response based on provider + local codetyper = require("codetyper") + local config = codetyper.get_config() + local parsed + + if config.llm.provider == "claude" then + parsed = parser.parse_claude_response(response) + -- For Claude, preserve the original content array for proper tool_use handling + table.insert(state.conversation, { + role = "assistant", + content = response.content, -- Keep original content blocks for Claude API + }) + else + -- For Ollama, response is the text directly + if type(response) == "string" then + parsed = parser.parse_ollama_response(response) + else + parsed = parser.parse_ollama_response(response.response or "") + end + -- Add assistant response to conversation + table.insert(state.conversation, { + role = "assistant", + content = parsed.text, + tool_calls = parsed.tool_calls, + }) + end + + -- Display any text content + if parsed.text and parsed.text ~= "" then + local clean_text = parser.clean_text(parsed.text) + if clean_text ~= "" then + callbacks.on_text(clean_text) + end + end + + -- Check for tool calls + if #parsed.tool_calls > 0 then + logs.info(string.format("Processing %d tool call(s)", #parsed.tool_calls)) + -- Process tool calls sequentially + M.process_tool_calls(parsed.tool_calls, 1, context, callbacks) + else + -- No more tool calls, agent is done + logs.info("No tool calls, finishing agent loop") + state.is_running = false + callbacks.on_complete() + end + end) +end + +--- Process tool calls one at a time +---@param tool_calls table[] List of tool calls +---@param index number Current index +---@param context table File context +---@param callbacks AgentCallbacks +function M.process_tool_calls(tool_calls, index, context, callbacks) + if not state.is_running then + callbacks.on_complete() + return + end + + if index > #tool_calls then + -- All tools processed, continue agent loop with results + M.continue_with_results(context, callbacks) + return + end + + local tool_call = tool_calls[index] + callbacks.on_tool_start(tool_call.name) + + executor.execute(tool_call.name, tool_call.parameters, function(result) + if result.requires_approval then + logs.tool(tool_call.name, "approval", "Waiting for user approval") + -- Show diff preview and wait for user decision + local show_fn + if result.diff_data.operation == "bash" then + show_fn = function(_, cb) + diff.show_bash_approval(result.diff_data.modified:gsub("^%$ ", ""), cb) + end + else + show_fn = diff.show_diff + end + + show_fn(result.diff_data, function(approved) + if approved then + logs.tool(tool_call.name, "approved", "User approved") + -- Apply the change + executor.apply_change(result.diff_data, function(apply_result) + -- Store result for sending back to LLM + table.insert(state.pending_tool_results, { + tool_use_id = tool_call.id, + name = tool_call.name, + result = apply_result.result, + }) + callbacks.on_tool_result(tool_call.name, apply_result.result) + -- Process next tool call + M.process_tool_calls(tool_calls, index + 1, context, callbacks) + end) + else + logs.tool(tool_call.name, "rejected", "User rejected") + -- User rejected + table.insert(state.pending_tool_results, { + tool_use_id = tool_call.id, + name = tool_call.name, + result = "User rejected this change", + }) + callbacks.on_tool_result(tool_call.name, "Rejected by user") + M.process_tool_calls(tool_calls, index + 1, context, callbacks) + end + end) + else + -- No approval needed (read_file), store result immediately + table.insert(state.pending_tool_results, { + tool_use_id = tool_call.id, + name = tool_call.name, + result = result.result, + }) + + -- For read_file, just show a brief confirmation + local display_result = result.result + if tool_call.name == "read_file" and result.success then + display_result = "[Read " .. #result.result .. " bytes]" + end + callbacks.on_tool_result(tool_call.name, display_result) + + M.process_tool_calls(tool_calls, index + 1, context, callbacks) + end + end) +end + +--- Continue the loop after tool execution +---@param context table File context +---@param callbacks AgentCallbacks +function M.continue_with_results(context, callbacks) + if #state.pending_tool_results == 0 then + state.is_running = false + callbacks.on_complete() + return + end + + -- Build tool results message + local codetyper = require("codetyper") + local config = codetyper.get_config() + + if config.llm.provider == "claude" then + -- Claude format: tool_result blocks + local content = {} + for _, result in ipairs(state.pending_tool_results) do + table.insert(content, { + type = "tool_result", + tool_use_id = result.tool_use_id, + content = result.result, + }) + end + table.insert(state.conversation, { + role = "user", + content = content, + }) + else + -- Ollama format: plain text describing results + local result_text = "Tool results:\n" + for _, result in ipairs(state.pending_tool_results) do + result_text = result_text .. "\n[" .. result.name .. "]: " .. result.result .. "\n" + end + table.insert(state.conversation, { + role = "user", + content = result_text, + }) + end + + state.pending_tool_results = {} + + -- Continue the loop + M.agent_loop(context, callbacks) +end + +--- Get conversation history +---@return table[] +function M.get_conversation() + return state.conversation +end + +--- Set max iterations +---@param max number Maximum iterations +function M.set_max_iterations(max) + state.max_iterations = max +end + +return M diff --git a/lua/codetyper/agent/logs.lua b/lua/codetyper/agent/logs.lua new file mode 100644 index 0000000..2300897 --- /dev/null +++ b/lua/codetyper/agent/logs.lua @@ -0,0 +1,228 @@ +---@mod codetyper.agent.logs Real-time logging for agent operations +--- +--- Captures and displays the agent's thinking process, token usage, and LLM info. + +local M = {} + +---@class LogEntry +---@field timestamp string ISO timestamp +---@field level string "info" | "debug" | "request" | "response" | "tool" | "error" +---@field message string Log message +---@field data? table Optional structured data + +---@class LogState +---@field entries LogEntry[] All log entries +---@field listeners table[] Functions to call when new entries are added +---@field total_prompt_tokens number Running total of prompt tokens +---@field total_response_tokens number Running total of response tokens + +local state = { + entries = {}, + listeners = {}, + total_prompt_tokens = 0, + total_response_tokens = 0, + current_provider = nil, + current_model = nil, +} + +--- Get current timestamp +---@return string +local function get_timestamp() + return os.date("%H:%M:%S") +end + +--- Add a log entry +---@param level string Log level +---@param message string Log message +---@param data? table Optional data +function M.log(level, message, data) + local entry = { + timestamp = get_timestamp(), + level = level, + message = message, + data = data, + } + + table.insert(state.entries, entry) + + -- Notify all listeners + for _, listener in ipairs(state.listeners) do + pcall(listener, entry) + end +end + +--- Log info message +---@param message string +---@param data? table +function M.info(message, data) + M.log("info", message, data) +end + +--- Log debug message +---@param message string +---@param data? table +function M.debug(message, data) + M.log("debug", message, data) +end + +--- Log API request +---@param provider string LLM provider +---@param model string Model name +---@param prompt_tokens? number Estimated prompt tokens +function M.request(provider, model, prompt_tokens) + state.current_provider = provider + state.current_model = model + + local msg = string.format("[%s] %s", provider:upper(), model) + if prompt_tokens then + msg = msg .. string.format(" | Prompt: ~%d tokens", prompt_tokens) + end + + M.log("request", msg, { + provider = provider, + model = model, + prompt_tokens = prompt_tokens, + }) +end + +--- Log API response with token usage +---@param prompt_tokens number Tokens used in prompt +---@param response_tokens number Tokens in response +---@param stop_reason? string Why the response stopped +function M.response(prompt_tokens, response_tokens, stop_reason) + state.total_prompt_tokens = state.total_prompt_tokens + prompt_tokens + state.total_response_tokens = state.total_response_tokens + response_tokens + + local msg = string.format( + "Tokens: %d in / %d out | Total: %d in / %d out", + prompt_tokens, + response_tokens, + state.total_prompt_tokens, + state.total_response_tokens + ) + + if stop_reason then + msg = msg .. " | Stop: " .. stop_reason + end + + M.log("response", msg, { + prompt_tokens = prompt_tokens, + response_tokens = response_tokens, + total_prompt = state.total_prompt_tokens, + total_response = state.total_response_tokens, + stop_reason = stop_reason, + }) +end + +--- Log tool execution +---@param tool_name string Name of the tool +---@param status string "start" | "success" | "error" | "approval" +---@param details? string Additional details +function M.tool(tool_name, status, details) + local icons = { + start = "->", + success = "OK", + error = "ERR", + approval = "??", + approved = "YES", + rejected = "NO", + } + + local msg = string.format("[%s] %s", icons[status] or status, tool_name) + if details then + msg = msg .. ": " .. details + end + + M.log("tool", msg, { + tool = tool_name, + status = status, + details = details, + }) +end + +--- Log error +---@param message string +---@param data? table +function M.error(message, data) + M.log("error", "ERROR: " .. message, data) +end + +--- Log thinking/reasoning step +---@param step string Description of what's happening +function M.thinking(step) + M.log("debug", "> " .. step) +end + +--- Register a listener for new log entries +---@param callback fun(entry: LogEntry) +---@return number Listener ID for removal +function M.add_listener(callback) + table.insert(state.listeners, callback) + return #state.listeners +end + +--- Remove a listener +---@param id number Listener ID +function M.remove_listener(id) + if id > 0 and id <= #state.listeners then + table.remove(state.listeners, id) + end +end + +--- Get all log entries +---@return LogEntry[] +function M.get_entries() + return state.entries +end + +--- Get token totals +---@return number, number prompt_tokens, response_tokens +function M.get_token_totals() + return state.total_prompt_tokens, state.total_response_tokens +end + +--- Get current provider info +---@return string?, string? provider, model +function M.get_provider_info() + return state.current_provider, state.current_model +end + +--- Clear all logs and reset counters +function M.clear() + state.entries = {} + state.total_prompt_tokens = 0 + state.total_response_tokens = 0 + state.current_provider = nil + state.current_model = nil + + -- Notify listeners of clear + for _, listener in ipairs(state.listeners) do + pcall(listener, { level = "clear" }) + end +end + +--- Format entry for display +---@param entry LogEntry +---@return string +function M.format_entry(entry) + local level_prefix = ({ + info = "i", + debug = ".", + request = ">", + response = "<", + tool = "T", + error = "!", + })[entry.level] or "?" + + return string.format("[%s] %s %s", entry.timestamp, level_prefix, entry.message) +end + +--- Estimate token count for a string (rough approximation) +---@param text string +---@return number +function M.estimate_tokens(text) + -- Rough estimate: ~4 characters per token for English text + return math.ceil(#text / 4) +end + +return M diff --git a/lua/codetyper/agent/parser.lua b/lua/codetyper/agent/parser.lua new file mode 100644 index 0000000..cac150d --- /dev/null +++ b/lua/codetyper/agent/parser.lua @@ -0,0 +1,117 @@ +---@mod codetyper.agent.parser Response parser for agent tool calls +--- +--- Parses LLM responses to extract tool calls from both Claude and Ollama. + +local M = {} + +---@class ParsedResponse +---@field text string Text content from the response +---@field tool_calls ToolCall[] List of tool calls +---@field stop_reason string Reason the response stopped + +---@class ToolCall +---@field id string Unique identifier for the tool call +---@field name string Name of the tool to call +---@field parameters table Parameters for the tool + +--- Parse Claude API response for tool_use blocks +---@param response table Raw Claude API response +---@return ParsedResponse +function M.parse_claude_response(response) + local result = { + text = "", + tool_calls = {}, + stop_reason = response.stop_reason or "end_turn", + } + + if response.content then + for _, block in ipairs(response.content) do + if block.type == "text" then + result.text = result.text .. (block.text or "") + elseif block.type == "tool_use" then + table.insert(result.tool_calls, { + id = block.id, + name = block.name, + parameters = block.input or {}, + }) + end + end + end + + return result +end + +--- Parse Ollama response for JSON tool blocks +---@param response_text string Raw text response from Ollama +---@return ParsedResponse +function M.parse_ollama_response(response_text) + local result = { + text = response_text, + tool_calls = {}, + stop_reason = "end_turn", + } + + -- Pattern to find JSON tool blocks in fenced code blocks + local fenced_pattern = "```json%s*(%b{})%s*```" + + -- Find all fenced JSON blocks + for json_str in response_text:gmatch(fenced_pattern) do + local ok, parsed = pcall(vim.json.decode, json_str) + if ok and parsed.tool and parsed.parameters then + table.insert(result.tool_calls, { + id = string.format("%d_%d", os.time(), math.random(10000)), + name = parsed.tool, + parameters = parsed.parameters, + }) + result.stop_reason = "tool_use" + end + end + + -- Also try to find inline JSON (not in code blocks) + -- Pattern for {"tool": "...", "parameters": {...}} + if #result.tool_calls == 0 then + local inline_pattern = '(%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%})' + for json_str in response_text:gmatch(inline_pattern) do + local ok, parsed = pcall(vim.json.decode, json_str) + if ok and parsed.tool and parsed.parameters then + table.insert(result.tool_calls, { + id = string.format("%d_%d", os.time(), math.random(10000)), + name = parsed.tool, + parameters = parsed.parameters, + }) + result.stop_reason = "tool_use" + end + end + end + + -- Clean tool JSON from displayed text + if #result.tool_calls > 0 then + result.text = result.text:gsub("```json%s*%b{}%s*```", "[Tool call]") + result.text = result.text:gsub('%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%}', "[Tool call]") + end + + return result +end + +--- Check if response contains tool calls +---@param parsed ParsedResponse Parsed response +---@return boolean +function M.has_tool_calls(parsed) + return #parsed.tool_calls > 0 +end + +--- Extract just the text content, removing tool-related markup +---@param text string Response text +---@return string Cleaned text +function M.clean_text(text) + local cleaned = text + -- Remove tool JSON blocks + cleaned = cleaned:gsub("```json%s*%b{}%s*```", "") + cleaned = cleaned:gsub('%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%}', "") + -- Clean up extra whitespace + cleaned = cleaned:gsub("\n\n\n+", "\n\n") + cleaned = cleaned:gsub("^%s+", ""):gsub("%s+$", "") + return cleaned +end + +return M diff --git a/lua/codetyper/agent/tools.lua b/lua/codetyper/agent/tools.lua new file mode 100644 index 0000000..00bd75f --- /dev/null +++ b/lua/codetyper/agent/tools.lua @@ -0,0 +1,144 @@ +---@mod codetyper.agent.tools Tool definitions for the agent system +--- +--- Defines available tools that the LLM can use to interact with files and system. + +local M = {} + +--- Tool definitions in a provider-agnostic format +M.definitions = { + read_file = { + name = "read_file", + description = "Read the contents of a file at the specified path", + parameters = { + type = "object", + properties = { + path = { + type = "string", + description = "Absolute or relative path to the file to read", + }, + }, + required = { "path" }, + }, + }, + + edit_file = { + name = "edit_file", + description = "Edit a file by replacing specific content. Provide the exact content to find and the replacement.", + parameters = { + type = "object", + properties = { + path = { + type = "string", + description = "Path to the file to edit", + }, + find = { + type = "string", + description = "Exact content to find (must match exactly, including whitespace)", + }, + replace = { + type = "string", + description = "Content to replace with", + }, + }, + required = { "path", "find", "replace" }, + }, + }, + + write_file = { + name = "write_file", + description = "Write content to a file, creating it if it doesn't exist or overwriting if it does", + parameters = { + type = "object", + properties = { + path = { + type = "string", + description = "Path to the file to write", + }, + content = { + type = "string", + description = "Complete file content to write", + }, + }, + required = { "path", "content" }, + }, + }, + + bash = { + name = "bash", + description = "Execute a bash command and return the output. Use for git, npm, build tools, etc.", + parameters = { + type = "object", + properties = { + command = { + type = "string", + description = "The bash command to execute", + }, + timeout = { + type = "number", + description = "Timeout in milliseconds (default: 30000)", + }, + }, + required = { "command" }, + }, + }, +} + +--- Convert tool definitions to Claude API format +---@return table[] Tools in Claude's expected format +function M.to_claude_format() + local tools = {} + for _, tool in pairs(M.definitions) do + table.insert(tools, { + name = tool.name, + description = tool.description, + input_schema = tool.parameters, + }) + end + return tools +end + +--- Convert tool definitions to prompt format for Ollama +---@return string Formatted tool descriptions for system prompt +function M.to_prompt_format() + local lines = { + "You have access to the following tools. To use a tool, respond with a JSON block.", + "", + } + + for _, tool in pairs(M.definitions) do + table.insert(lines, "## " .. tool.name) + table.insert(lines, tool.description) + table.insert(lines, "") + table.insert(lines, "Parameters:") + for prop_name, prop in pairs(tool.parameters.properties) do + local required = vim.tbl_contains(tool.parameters.required or {}, prop_name) + local req_str = required and " (required)" or " (optional)" + table.insert(lines, " - " .. prop_name .. ": " .. prop.description .. req_str) + end + table.insert(lines, "") + end + + table.insert(lines, "---") + table.insert(lines, "") + table.insert(lines, "To call a tool, output a JSON block like this:") + table.insert(lines, "```json") + table.insert(lines, '{"tool": "tool_name", "parameters": {"param1": "value1"}}') + table.insert(lines, "```") + table.insert(lines, "") + table.insert(lines, "After receiving tool results, continue your response or call another tool.") + table.insert(lines, "When you're done, just respond normally without any tool calls.") + + return table.concat(lines, "\n") +end + +--- Get a list of tool names +---@return string[] +function M.get_tool_names() + local names = {} + for name, _ in pairs(M.definitions) do + table.insert(names, name) + end + return names +end + +return M diff --git a/lua/codetyper/agent/ui.lua b/lua/codetyper/agent/ui.lua new file mode 100644 index 0000000..1ec0b3e --- /dev/null +++ b/lua/codetyper/agent/ui.lua @@ -0,0 +1,674 @@ +---@mod codetyper.agent.ui Agent chat UI for Codetyper.nvim +--- +--- Provides a sidebar chat interface for agent interactions with real-time logs. + +local M = {} + +local agent = require("codetyper.agent") +local logs = require("codetyper.agent.logs") +local utils = require("codetyper.utils") + +---@class AgentUIState +---@field chat_buf number|nil Chat buffer +---@field chat_win number|nil Chat window +---@field input_buf number|nil Input buffer +---@field input_win number|nil Input window +---@field logs_buf number|nil Logs buffer +---@field logs_win number|nil Logs window +---@field is_open boolean Whether the UI is open +---@field log_listener_id number|nil Listener ID for logs +---@field referenced_files table Files referenced with @ + +local state = { + chat_buf = nil, + chat_win = nil, + input_buf = nil, + input_win = nil, + logs_buf = nil, + logs_win = nil, + is_open = false, + log_listener_id = nil, + referenced_files = {}, +} + +--- Namespace for highlights +local ns_chat = vim.api.nvim_create_namespace("codetyper_agent_chat") +local ns_logs = vim.api.nvim_create_namespace("codetyper_agent_logs") + +--- Fixed widths +local CHAT_WIDTH = 300 +local LOGS_WIDTH = 50 +local INPUT_HEIGHT = 5 + +--- Autocmd group +local agent_augroup = nil + +--- Add a log entry to the logs buffer +---@param entry table Log entry +local function add_log_entry(entry) + if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then + return + end + + vim.schedule(function() + if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then + return + end + + -- Handle clear event + if entry.level == "clear" then + vim.bo[state.logs_buf].modifiable = true + vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, { + "Logs", + string.rep("─", LOGS_WIDTH - 2), + "", + }) + vim.bo[state.logs_buf].modifiable = false + return + end + + vim.bo[state.logs_buf].modifiable = true + + local formatted = logs.format_entry(entry) + local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, -1, false) + local line_num = #lines + + vim.api.nvim_buf_set_lines(state.logs_buf, -1, -1, false, { formatted }) + + -- Apply highlighting based on level + local hl_map = { + info = "DiagnosticInfo", + debug = "Comment", + request = "DiagnosticWarn", + response = "DiagnosticOk", + tool = "DiagnosticHint", + error = "DiagnosticError", + } + + local hl = hl_map[entry.level] or "Normal" + vim.api.nvim_buf_add_highlight(state.logs_buf, ns_logs, hl, line_num, 0, -1) + + vim.bo[state.logs_buf].modifiable = false + + -- Auto-scroll logs + if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then + local new_count = vim.api.nvim_buf_line_count(state.logs_buf) + pcall(vim.api.nvim_win_set_cursor, state.logs_win, { new_count, 0 }) + end + end) +end + +--- Add a message to the chat buffer +---@param role string "user" | "assistant" | "tool" | "system" +---@param content string Message content +---@param highlight? string Optional highlight group +local function add_message(role, content, highlight) + if not state.chat_buf or not vim.api.nvim_buf_is_valid(state.chat_buf) then + return + end + + vim.bo[state.chat_buf].modifiable = true + + local lines = vim.api.nvim_buf_get_lines(state.chat_buf, 0, -1, false) + local start_line = #lines + + -- Add separator if not first message + if start_line > 0 and lines[start_line] ~= "" then + vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, { "" }) + start_line = start_line + 1 + end + + -- Format the message + local prefix_map = { + user = ">>> You:", + assistant = "<<< Agent:", + tool = "[Tool]", + system = "[System]", + } + + local prefix = prefix_map[role] or "[Unknown]" + local message_lines = { prefix } + + -- Split content into lines + for line in content:gmatch("[^\n]+") do + table.insert(message_lines, " " .. line) + end + + vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, message_lines) + + -- Apply highlighting + local hl_group = highlight or ({ + user = "DiagnosticInfo", + assistant = "DiagnosticOk", + tool = "DiagnosticWarn", + system = "DiagnosticHint", + })[role] or "Normal" + + vim.api.nvim_buf_add_highlight(state.chat_buf, ns_chat, hl_group, start_line, 0, -1) + + vim.bo[state.chat_buf].modifiable = false + + -- Scroll to bottom + if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then + local line_count = vim.api.nvim_buf_line_count(state.chat_buf) + pcall(vim.api.nvim_win_set_cursor, state.chat_win, { line_count, 0 }) + end +end + +--- Create the agent callbacks +---@return table Callbacks for agent.run +local function create_callbacks() + return { + on_text = function(text) + vim.schedule(function() + add_message("assistant", text) + logs.thinking("Received response text") + end) + end, + + on_tool_start = function(name) + vim.schedule(function() + add_message("tool", "Executing: " .. name .. "...", "DiagnosticWarn") + logs.tool(name, "start") + end) + end, + + on_tool_result = function(name, result) + vim.schedule(function() + local display_result = result + if #result > 200 then + display_result = result:sub(1, 200) .. "..." + end + add_message("tool", name .. ": " .. display_result, "DiagnosticOk") + logs.tool(name, "success", string.format("%d bytes", #result)) + end) + end, + + on_complete = function() + vim.schedule(function() + add_message("system", "Done.", "DiagnosticHint") + logs.info("Agent loop completed") + M.focus_input() + end) + end, + + on_error = function(err) + vim.schedule(function() + add_message("system", "Error: " .. err, "DiagnosticError") + logs.error(err) + M.focus_input() + end) + end, + } +end + +--- Build file context from referenced files +---@return string Context string +local function build_file_context() + local context = "" + + for filename, filepath in pairs(state.referenced_files) do + local content = utils.read_file(filepath) + if content and content ~= "" then + local ext = vim.fn.fnamemodify(filepath, ":e") + context = context .. "\n\n=== FILE: " .. filename .. " ===\n" + context = context .. "Path: " .. filepath .. "\n" + context = context .. "```" .. (ext or "text") .. "\n" .. content .. "\n```\n" + end + end + + return context +end + +--- Submit user input +local function submit_input() + if not state.input_buf or not vim.api.nvim_buf_is_valid(state.input_buf) then + return + end + + local lines = vim.api.nvim_buf_get_lines(state.input_buf, 0, -1, false) + local input = table.concat(lines, "\n") + input = vim.trim(input) + + if input == "" then + return + end + + -- Clear input buffer + vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, { "" }) + + -- Handle special commands + if input == "/stop" then + agent.stop() + add_message("system", "Stopped.") + logs.info("Agent stopped by user") + return + end + + if input == "/clear" then + agent.reset() + logs.clear() + state.referenced_files = {} + if state.chat_buf and vim.api.nvim_buf_is_valid(state.chat_buf) then + vim.bo[state.chat_buf].modifiable = true + vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, { + "╔═══════════════════════════════════════════════════════════════╗", + "║ [AGENT MODE] Can read/write files ║", + "╠═══════════════════════════════════════════════════════════════╣", + "║ @ attach file | C-f current file | :CoderType switch mode ║", + "╚═══════════════════════════════════════════════════════════════╝", + "", + }) + vim.bo[state.chat_buf].modifiable = false + end + return + end + + if input == "/close" then + M.close() + return + end + + -- Build file context + local file_context = build_file_context() + local file_count = vim.tbl_count(state.referenced_files) + + -- Add user message to chat + local display_input = input + if file_count > 0 then + local files_list = {} + for fname, _ in pairs(state.referenced_files) do + table.insert(files_list, fname) + end + display_input = input .. "\n[Attached: " .. table.concat(files_list, ", ") .. "]" + end + add_message("user", display_input) + logs.info("User: " .. input:sub(1, 40) .. (input:len() > 40 and "..." or "")) + + -- Clear referenced files after use + state.referenced_files = {} + + -- Check if agent is already running + if agent.is_running() then + add_message("system", "Busy. /stop first.") + logs.info("Request rejected - busy") + return + end + + -- Build context from current buffer + local current_file = vim.fn.expand("#:p") + if current_file == "" then + current_file = vim.fn.expand("%:p") + end + + local llm = require("codetyper.llm") + local context = {} + + if current_file ~= "" and vim.fn.filereadable(current_file) == 1 then + context = llm.build_context(current_file, "agent") + logs.debug("Context: " .. vim.fn.fnamemodify(current_file, ":t")) + end + + -- Append file context to input + local full_input = input + if file_context ~= "" then + full_input = input .. "\n\nATTACHED FILES:" .. file_context + end + + logs.thinking("Starting...") + + -- Run the agent + agent.run(full_input, context, create_callbacks()) +end + +--- Show file picker for @ mentions +function M.show_file_picker() + local has_telescope, telescope = pcall(require, "telescope.builtin") + + if has_telescope then + telescope.find_files({ + prompt_title = "Attach file (@)", + attach_mappings = function(prompt_bufnr, map) + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + + actions.select_default:replace(function() + actions.close(prompt_bufnr) + local selection = action_state.get_selected_entry() + if selection then + local filepath = selection.path or selection[1] + local filename = vim.fn.fnamemodify(filepath, ":t") + M.add_file_reference(filepath, filename) + end + end) + return true + end, + }) + else + vim.ui.input({ prompt = "File path: " }, function(input) + if input and input ~= "" then + local filepath = vim.fn.fnamemodify(input, ":p") + local filename = vim.fn.fnamemodify(filepath, ":t") + M.add_file_reference(filepath, filename) + end + end) + end +end + +--- Add a file reference +---@param filepath string Full path to the file +---@param filename string Display name +function M.add_file_reference(filepath, filename) + filepath = vim.fn.fnamemodify(filepath, ":p") + state.referenced_files[filename] = filepath + + local content = utils.read_file(filepath) + if not content then + utils.notify("Cannot read: " .. filename, vim.log.levels.WARN) + return + end + + add_message("system", "Attached: " .. filename, "DiagnosticHint") + logs.debug("Attached: " .. filename) + M.focus_input() +end + +--- Include current file context +function M.include_current_file() + -- Get the file from the window that's not the agent sidebar + local current_file = nil + for _, win in ipairs(vim.api.nvim_list_wins()) do + if win ~= state.chat_win and win ~= state.logs_win and win ~= state.input_win then + local buf = vim.api.nvim_win_get_buf(win) + local name = vim.api.nvim_buf_get_name(buf) + if name ~= "" and vim.fn.filereadable(name) == 1 then + current_file = name + break + end + end + end + + if not current_file then + utils.notify("No file to attach", vim.log.levels.WARN) + return + end + + local filename = vim.fn.fnamemodify(current_file, ":t") + M.add_file_reference(current_file, filename) +end + +--- Focus the input buffer +function M.focus_input() + if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then + vim.api.nvim_set_current_win(state.input_win) + vim.cmd("startinsert") + end +end + +--- Focus the chat buffer +function M.focus_chat() + if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then + vim.api.nvim_set_current_win(state.chat_win) + end +end + +--- Focus the logs buffer +function M.focus_logs() + if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then + vim.api.nvim_set_current_win(state.logs_win) + end +end + +--- Show chat mode switcher modal +function M.show_chat_switcher() + local switcher = require("codetyper.chat_switcher") + switcher.show() +end + +--- Update the logs title with token counts +local function update_logs_title() + if not state.logs_win or not vim.api.nvim_win_is_valid(state.logs_win) then + return + end + + local prompt_tokens, response_tokens = logs.get_token_totals() + local provider, _ = logs.get_provider_info() + + if provider and state.logs_buf and vim.api.nvim_buf_is_valid(state.logs_buf) then + vim.bo[state.logs_buf].modifiable = true + local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, 2, false) + if #lines >= 1 then + lines[1] = string.format("%s | %d/%d tokens", provider:upper(), prompt_tokens, response_tokens) + vim.api.nvim_buf_set_lines(state.logs_buf, 0, 1, false, { lines[1] }) + end + vim.bo[state.logs_buf].modifiable = false + end +end + +--- Open the agent UI +function M.open() + if state.is_open then + M.focus_input() + return + end + + -- Clear previous state + logs.clear() + state.referenced_files = {} + + -- Create chat buffer + state.chat_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.chat_buf].buftype = "nofile" + vim.bo[state.chat_buf].bufhidden = "hide" + vim.bo[state.chat_buf].swapfile = false + vim.bo[state.chat_buf].filetype = "markdown" + + -- Create input buffer + state.input_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.input_buf].buftype = "nofile" + vim.bo[state.input_buf].bufhidden = "hide" + vim.bo[state.input_buf].swapfile = false + + -- Create logs buffer + state.logs_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.logs_buf].buftype = "nofile" + vim.bo[state.logs_buf].bufhidden = "hide" + vim.bo[state.logs_buf].swapfile = false + + -- Create chat window on the LEFT (like NvimTree) + vim.cmd("topleft vsplit") + state.chat_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(state.chat_win, state.chat_buf) + vim.api.nvim_win_set_width(state.chat_win, CHAT_WIDTH) + + -- Window options for chat + vim.wo[state.chat_win].number = false + vim.wo[state.chat_win].relativenumber = false + vim.wo[state.chat_win].signcolumn = "no" + vim.wo[state.chat_win].wrap = true + vim.wo[state.chat_win].linebreak = true + vim.wo[state.chat_win].winfixwidth = true + vim.wo[state.chat_win].cursorline = false + + -- Create input window below chat + vim.cmd("belowright split") + state.input_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(state.input_win, state.input_buf) + vim.api.nvim_win_set_height(state.input_win, INPUT_HEIGHT) + + -- Window options for input + vim.wo[state.input_win].number = false + vim.wo[state.input_win].relativenumber = false + vim.wo[state.input_win].signcolumn = "no" + vim.wo[state.input_win].wrap = true + vim.wo[state.input_win].linebreak = true + vim.wo[state.input_win].winfixheight = true + vim.wo[state.input_win].winfixwidth = true + + -- Create logs window on the RIGHT + vim.cmd("botright vsplit") + state.logs_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(state.logs_win, state.logs_buf) + vim.api.nvim_win_set_width(state.logs_win, LOGS_WIDTH) + + -- Window options for logs + vim.wo[state.logs_win].number = false + vim.wo[state.logs_win].relativenumber = false + vim.wo[state.logs_win].signcolumn = "no" + vim.wo[state.logs_win].wrap = true + vim.wo[state.logs_win].linebreak = true + vim.wo[state.logs_win].winfixwidth = true + vim.wo[state.logs_win].cursorline = false + + -- Set initial content for chat + vim.bo[state.chat_buf].modifiable = true + vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, { + "╔═══════════════════════════════════════════════════════════════╗", + "║ [AGENT MODE] Can read/write files ║", + "╠═══════════════════════════════════════════════════════════════╣", + "║ @ attach file | C-f current file | :CoderType switch mode ║", + "╚═══════════════════════════════════════════════════════════════╝", + "", + }) + vim.bo[state.chat_buf].modifiable = false + + -- Set initial content for logs + vim.bo[state.logs_buf].modifiable = true + vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, { + "Logs", + string.rep("─", LOGS_WIDTH - 2), + "", + }) + vim.bo[state.logs_buf].modifiable = false + + -- Register log listener + state.log_listener_id = logs.add_listener(function(entry) + add_log_entry(entry) + if entry.level == "response" then + vim.schedule(update_logs_title) + end + end) + + -- Set up keymaps for input buffer + local input_opts = { buffer = state.input_buf, noremap = true, silent = true } + + vim.keymap.set("i", "", submit_input, input_opts) + vim.keymap.set("n", "", submit_input, input_opts) + vim.keymap.set("i", "@", M.show_file_picker, input_opts) + vim.keymap.set({ "n", "i" }, "", M.include_current_file, input_opts) + vim.keymap.set("n", "", M.focus_chat, input_opts) + vim.keymap.set("n", "q", M.close, input_opts) + vim.keymap.set("n", "", M.close, input_opts) + + -- Set up keymaps for chat buffer + local chat_opts = { buffer = state.chat_buf, noremap = true, silent = true } + + vim.keymap.set("n", "i", M.focus_input, chat_opts) + vim.keymap.set("n", "", M.focus_input, chat_opts) + vim.keymap.set("n", "@", M.show_file_picker, chat_opts) + vim.keymap.set("n", "", M.include_current_file, chat_opts) + vim.keymap.set("n", "", M.focus_logs, chat_opts) + vim.keymap.set("n", "q", M.close, chat_opts) + + -- Set up keymaps for logs buffer + local logs_opts = { buffer = state.logs_buf, noremap = true, silent = true } + + vim.keymap.set("n", "", M.focus_input, logs_opts) + vim.keymap.set("n", "q", M.close, logs_opts) + vim.keymap.set("n", "i", M.focus_input, logs_opts) + + -- Setup autocmd for cleanup + agent_augroup = vim.api.nvim_create_augroup("CodetypeAgentUI", { clear = true }) + + vim.api.nvim_create_autocmd("WinClosed", { + group = agent_augroup, + callback = function(args) + local closed_win = tonumber(args.match) + if closed_win == state.chat_win or closed_win == state.logs_win or closed_win == state.input_win then + vim.schedule(function() + M.close() + end) + end + end, + }) + + state.is_open = true + + -- Focus input and log startup + M.focus_input() + logs.info("Agent ready") + + -- Log provider info + local ok, codetyper = pcall(require, "codetyper") + if ok then + local config = codetyper.get_config() + local provider = config.llm.provider + local model = provider == "claude" and config.llm.claude.model or config.llm.ollama.model + logs.info(string.format("%s (%s)", provider, model)) + end +end + +--- Close the agent UI +function M.close() + if not state.is_open then + return + end + + -- Stop agent if running + if agent.is_running() then + agent.stop() + end + + -- Remove log listener + if state.log_listener_id then + logs.remove_listener(state.log_listener_id) + state.log_listener_id = nil + end + + -- Remove autocmd + if agent_augroup then + pcall(vim.api.nvim_del_augroup_by_id, agent_augroup) + agent_augroup = nil + end + + -- Close windows + if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then + pcall(vim.api.nvim_win_close, state.input_win, true) + end + if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then + pcall(vim.api.nvim_win_close, state.chat_win, true) + end + if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then + pcall(vim.api.nvim_win_close, state.logs_win, true) + end + + -- Reset state + state.chat_buf = nil + state.chat_win = nil + state.input_buf = nil + state.input_win = nil + state.logs_buf = nil + state.logs_win = nil + state.is_open = false + state.referenced_files = {} + + -- Reset agent conversation + agent.reset() +end + +--- Toggle the agent UI +function M.toggle() + if state.is_open then + M.close() + else + M.open() + end +end + +--- Check if UI is open +---@return boolean +function M.is_open() + return state.is_open +end + +return M diff --git a/lua/codetyper/ask.lua b/lua/codetyper/ask.lua index 056633f..eefbacd 100644 --- a/lua/codetyper/ask.lua +++ b/lua/codetyper/ask.lua @@ -50,19 +50,18 @@ local function create_output_buffer() -- Set initial content local header = { - "╔═════════════════════════════╗", - "║ 🤖 CODETYPER ASK ║", - "╠═════════════════════════════╣", - "║ Ask about code or concepts ║", - "║ ║", - "║ 💡 Keymaps: ║", - "║ @ → attach file ║", - "║ C-Enter → send ║", - "║ C-n → new chat ║", - "║ C-f → add current file ║", - "║ C-h/j/k/l → navigate ║", - "║ q → close │ K/J → jump ║", - "╚═════════════════════════════╝", + "╔═════════════════════════════════╗", + "║ [ASK MODE] Q&A Chat ║", + "╠═════════════════════════════════╣", + "║ Ask about code or concepts ║", + "║ ║", + "║ @ → attach file ║", + "║ C-Enter → send ║", + "║ C-n → new chat ║", + "║ C-f → add current file ║", + "║ :CoderType → switch mode ║", + "║ q → close │ K/J → jump ║", + "╚═════════════════════════════════╝", "", } vim.api.nvim_buf_set_lines(buf, 0, -1, false, header) @@ -788,19 +787,18 @@ function M.clear_history() if state.output_buf and vim.api.nvim_buf_is_valid(state.output_buf) then local header = { - "╔═══════════════════════════════════╗", - "║ 🤖 CODETYPER ASK ║", - "╠═══════════════════════════════════╣", - "║ Ask about code or concepts ║", - "║ ║", - "║ 💡 Keymaps: ║", - "║ @ → attach file ║", - "║ C-Enter → send ║", - "║ C-n → new chat ║", - "║ C-f → add current file ║", - "║ C-h/j/k/l → navigate ║", - "║ q → close │ K/J → jump ║", - "╚═══════════════════════════════════╝", + "╔═════════════════════════════════╗", + "║ [ASK MODE] Q&A Chat ║", + "╠═════════════════════════════════╣", + "║ Ask about code or concepts ║", + "║ ║", + "║ @ → attach file ║", + "║ C-Enter → send ║", + "║ C-n → new chat ║", + "║ C-f → add current file ║", + "║ :CoderType → switch mode ║", + "║ q → close │ K/J → jump ║", + "╚═════════════════════════════════╝", "", } vim.bo[state.output_buf].modifiable = true @@ -846,6 +844,12 @@ function M.copy_last_response() end utils.notify("No response to copy", vim.log.levels.WARN) end + +--- Show chat mode switcher modal +function M.show_chat_switcher() + local switcher = require("codetyper.chat_switcher") + switcher.show() +end --- Check if ask panel is open (validates window state) ---@return boolean function M.is_open() diff --git a/lua/codetyper/chat_switcher.lua b/lua/codetyper/chat_switcher.lua new file mode 100644 index 0000000..09f9bef --- /dev/null +++ b/lua/codetyper/chat_switcher.lua @@ -0,0 +1,44 @@ +---@mod codetyper.chat_switcher Modal picker to switch between Ask and Agent modes + +local M = {} + +--- Show modal to switch between chat modes +function M.show() + local items = { + { label = "Ask", desc = "Q&A mode - ask questions about code", mode = "ask" }, + { label = "Agent", desc = "Agent mode - can read/edit files", mode = "agent" }, + } + + vim.ui.select(items, { + prompt = "Select Chat Mode:", + format_item = function(item) + return item.label .. " - " .. item.desc + end, + }, function(choice) + if not choice then + return + end + + -- Close current panel first + local ask = require("codetyper.ask") + local agent_ui = require("codetyper.agent.ui") + + if ask.is_open() then + ask.close() + end + if agent_ui.is_open() then + agent_ui.close() + end + + -- Open selected mode + vim.schedule(function() + if choice.mode == "ask" then + ask.open() + elseif choice.mode == "agent" then + agent_ui.open() + end + end) + end) +end + +return M diff --git a/lua/codetyper/commands.lua b/lua/codetyper/commands.lua index 65d5a75..e62767c 100644 --- a/lua/codetyper/commands.lua +++ b/lua/codetyper/commands.lua @@ -252,6 +252,41 @@ local function cmd_ask_clear() ask.clear_history() end +--- Open agent panel +local function cmd_agent() + local agent_ui = require("codetyper.agent.ui") + agent_ui.open() +end + +--- Close agent panel +local function cmd_agent_close() + local agent_ui = require("codetyper.agent.ui") + agent_ui.close() +end + +--- Toggle agent panel +local function cmd_agent_toggle() + local agent_ui = require("codetyper.agent.ui") + agent_ui.toggle() +end + +--- Stop running agent +local function cmd_agent_stop() + local agent = require("codetyper.agent") + if agent.is_running() then + agent.stop() + utils.notify("Agent stopped") + else + utils.notify("No agent running", vim.log.levels.INFO) + end +end + +--- Show chat type switcher modal (Ask/Agent) +local function cmd_type_toggle() + local switcher = require("codetyper.chat_switcher") + switcher.show() +end + --- Switch focus between coder and target windows local function cmd_focus() if not window.is_open() then @@ -615,6 +650,11 @@ local function coder_cmd(args) gitignore = cmd_gitignore, transform = cmd_transform, ["transform-cursor"] = cmd_transform_at_cursor, + agent = cmd_agent, + ["agent-close"] = cmd_agent_close, + ["agent-toggle"] = cmd_agent_toggle, + ["agent-stop"] = cmd_agent_stop, + ["type-toggle"] = cmd_type_toggle, } local cmd_fn = commands[subcommand] @@ -635,6 +675,8 @@ function M.setup() "tree", "tree-view", "reset", "gitignore", "ask", "ask-close", "ask-toggle", "ask-clear", "transform", "transform-cursor", + "agent", "agent-close", "agent-toggle", "agent-stop", + "type-toggle", } end, desc = "Codetyper.nvim commands", @@ -693,6 +735,24 @@ function M.setup() cmd_transform_range(start_line, end_line) end, { range = true, desc = "Transform /@ @/ tags in visual selection" }) + -- Agent commands + vim.api.nvim_create_user_command("CoderAgent", function() + cmd_agent() + end, { desc = "Open Agent panel" }) + + vim.api.nvim_create_user_command("CoderAgentToggle", function() + cmd_agent_toggle() + end, { desc = "Toggle Agent panel" }) + + vim.api.nvim_create_user_command("CoderAgentStop", function() + cmd_agent_stop() + end, { desc = "Stop running agent" }) + + -- Chat type switcher command + vim.api.nvim_create_user_command("CoderType", function() + cmd_type_toggle() + end, { desc = "Show Ask/Agent mode switcher" }) + -- Setup default keymaps M.setup_keymaps() end @@ -712,9 +772,15 @@ function M.setup_keymaps() }) -- Normal mode: transform all tags in file - vim.keymap.set("n", "ctT", "CoderTransform", { - silent = true, - desc = "Coder: Transform all tags in file" + vim.keymap.set("n", "ctT", "CoderTransform", { + silent = true, + desc = "Coder: Transform all tags in file" + }) + + -- Agent keymaps + vim.keymap.set("n", "ca", "CoderAgentToggle", { + silent = true, + desc = "Coder: Toggle Agent panel" }) end diff --git a/lua/codetyper/llm/claude.lua b/lua/codetyper/llm/claude.lua index 5beddb0..d899d68 100644 --- a/lua/codetyper/llm/claude.lua +++ b/lua/codetyper/llm/claude.lua @@ -11,19 +11,19 @@ local API_URL = "https://api.anthropic.com/v1/messages" --- Get API key from config or environment ---@return string|nil API key local function get_api_key() - local codetyper = require("codetyper") - local config = codetyper.get_config() + local codetyper = require("codetyper") + local config = codetyper.get_config() - return config.llm.claude.api_key or vim.env.ANTHROPIC_API_KEY + return config.llm.claude.api_key or vim.env.ANTHROPIC_API_KEY end --- Get model from config ---@return string Model name local function get_model() - local codetyper = require("codetyper") - local config = codetyper.get_config() + local codetyper = require("codetyper") + local config = codetyper.get_config() - return config.llm.claude.model + return config.llm.claude.model end --- Build request body for Claude API @@ -31,95 +31,100 @@ end ---@param context table Context information ---@return table Request body local function build_request_body(prompt, context) - local system_prompt = llm.build_system_prompt(context) + local system_prompt = llm.build_system_prompt(context) - return { - model = get_model(), - max_tokens = 4096, - system = system_prompt, - messages = { - { - role = "user", - content = prompt, - }, - }, - } + return { + model = get_model(), + max_tokens = 4096, + system = system_prompt, + messages = { + { + role = "user", + content = prompt, + }, + }, + } end --- Make HTTP request to Claude API ---@param body table Request body ---@param callback fun(response: string|nil, error: string|nil) Callback function local function make_request(body, callback) - local api_key = get_api_key() - if not api_key then - callback(nil, "Claude API key not configured") - return - end + local api_key = get_api_key() + if not api_key then + callback(nil, "Claude API key not configured") + return + end - local json_body = vim.json.encode(body) + local json_body = vim.json.encode(body) - -- Use curl for HTTP request (plenary.curl alternative) - local cmd = { - "curl", - "-s", - "-X", "POST", - API_URL, - "-H", "Content-Type: application/json", - "-H", "x-api-key: " .. api_key, - "-H", "anthropic-version: 2023-06-01", - "-d", json_body, - } + -- Use curl for HTTP request (plenary.curl alternative) + local cmd = { + "curl", + "-s", + "-X", + "POST", + API_URL, + "-H", + "Content-Type: application/json", + "-H", + "x-api-key: " .. api_key, + "-H", + "anthropic-version: 2023-06-01", + "-d", + json_body, + } - vim.fn.jobstart(cmd, { - stdout_buffered = true, - on_stdout = function(_, data) - if not data or #data == 0 or (data[1] == "" and #data == 1) then - return - end + vim.fn.jobstart(cmd, { + stdout_buffered = true, + on_stdout = function(_, data) + if not data or #data == 0 or (data[1] == "" and #data == 1) then + return + end - local response_text = table.concat(data, "\n") - local ok, response = pcall(vim.json.decode, response_text) + local response_text = table.concat(data, "\n") + local ok, response = pcall(vim.json.decode, response_text) - if not ok then - vim.schedule(function() - callback(nil, "Failed to parse Claude response") - end) - return - end + if not ok then + vim.schedule(function() + callback(nil, "Failed to parse Claude response") + end) + return + end - if response.error then - vim.schedule(function() - callback(nil, response.error.message or "Claude API error") - end) - return - end + if response.error then + vim.schedule(function() + callback(nil, response.error.message or "Claude API error") + end) + return + end - if response.content and response.content[1] and response.content[1].text then - local code = llm.extract_code(response.content[1].text) - vim.schedule(function() - callback(code, nil) - end) - else - vim.schedule(function() - callback(nil, "No content in Claude response") - end) - end - end, - on_stderr = function(_, data) - if data and #data > 0 and data[1] ~= "" then - vim.schedule(function() - callback(nil, "Claude API request failed: " .. table.concat(data, "\n")) - end) - end - end, - on_exit = function(_, code) - if code ~= 0 then - vim.schedule(function() - callback(nil, "Claude API request failed with code: " .. code) - end) - end - end, - }) + if response.content and response.content[1] and response.content[1].text then + local code = llm.extract_code(response.content[1].text) + vim.schedule(function() + callback(code, nil) + end) + else + vim.schedule(function() + callback(nil, "No content in Claude response") + end) + end + end, + on_stderr = function(_, data) + if data and #data > 0 and data[1] ~= "" then + vim.schedule(function() + callback(nil, "Claude API request failed: " .. table.concat(data, "\n")) + end) + end + end, + on_exit = function(_, code) + if code ~= 0 then + vim.schedule(function() + callback(nil, "Claude API request failed with code: " .. code) + end) + end + end, + }) end --- Generate code using Claude API @@ -127,28 +132,196 @@ end ---@param context table Context information ---@param callback fun(response: string|nil, error: string|nil) Callback function function M.generate(prompt, context, callback) - utils.notify("Sending request to Claude...", vim.log.levels.INFO) + utils.notify("Sending request to Claude...", vim.log.levels.INFO) - local body = build_request_body(prompt, context) - make_request(body, function(response, err) - if err then - utils.notify(err, vim.log.levels.ERROR) - callback(nil, err) - else - utils.notify("Code generated successfully", vim.log.levels.INFO) - callback(response, nil) - end - end) + local body = build_request_body(prompt, context) + make_request(body, function(response, err) + if err then + utils.notify(err, vim.log.levels.ERROR) + callback(nil, err) + else + utils.notify("Code generated successfully", vim.log.levels.INFO) + callback(response, nil) + end + end) end --- Check if Claude is properly configured ---@return boolean, string? Valid status and optional error message function M.validate() - local api_key = get_api_key() - if not api_key or api_key == "" then - return false, "Claude API key not configured" - end - return true + local api_key = get_api_key() + if not api_key or api_key == "" then + return false, "Claude API key not configured" + end + return true +end + +--- Build request body for Claude API with tools +---@param messages table[] Conversation messages +---@param context table Context information +---@param tools table Tool definitions +---@return table Request body +local function build_tools_request_body(messages, context, tools) + local agent_prompts = require("codetyper.prompts.agent") + local tools_module = require("codetyper.agent.tools") + + -- Build system prompt for agent mode + local system_prompt = agent_prompts.system + + -- Add context about current file if available + if context.file_path then + system_prompt = system_prompt .. "\n\nCurrent working context:\n" + system_prompt = system_prompt .. "- File: " .. context.file_path .. "\n" + if context.language then + system_prompt = system_prompt .. "- Language: " .. context.language .. "\n" + end + end + + -- Add project root info + local utils = require("codetyper.utils") + local root = utils.get_project_root() + if root then + system_prompt = system_prompt .. "- Project root: " .. root .. "\n" + end + + return { + model = get_model(), + max_tokens = 4096, + system = system_prompt, + messages = messages, + tools = tools_module.to_claude_format(), + } +end + +--- Make HTTP request to Claude API for tool use +---@param body table Request body +---@param callback fun(response: table|nil, error: string|nil) Callback function +local function make_tools_request(body, callback) + local api_key = get_api_key() + if not api_key then + callback(nil, "Claude API key not configured") + return + end + + local json_body = vim.json.encode(body) + + local cmd = { + "curl", + "-s", + "-X", + "POST", + API_URL, + "-H", + "Content-Type: application/json", + "-H", + "x-api-key: " .. api_key, + "-H", + "anthropic-version: 2023-06-01", + "-d", + json_body, + } + + vim.fn.jobstart(cmd, { + stdout_buffered = true, + on_stdout = function(_, data) + if not data or #data == 0 or (data[1] == "" and #data == 1) then + return + end + + local response_text = table.concat(data, "\n") + local ok, response = pcall(vim.json.decode, response_text) + + if not ok then + vim.schedule(function() + callback(nil, "Failed to parse Claude response") + end) + return + end + + if response.error then + vim.schedule(function() + callback(nil, response.error.message or "Claude API error") + end) + return + end + + -- Return the full response for tool parsing + vim.schedule(function() + callback(response, nil) + end) + end, + on_stderr = function(_, data) + if data and #data > 0 and data[1] ~= "" then + vim.schedule(function() + callback(nil, "Claude API request failed: " .. table.concat(data, "\n")) + end) + end + end, + on_exit = function(_, code) + if code ~= 0 then + -- Only report if no response was received + -- (curl may return non-zero even with successful response in some cases) + end + end, + }) +end + +--- Generate response with tools using Claude API +---@param messages table[] Conversation history +---@param context table Context information +---@param tools table Tool definitions (ignored, we use our own) +---@param callback fun(response: table|nil, error: string|nil) Callback function +function M.generate_with_tools(messages, context, tools, callback) + local logs = require("codetyper.agent.logs") + + -- Log the request + local model = get_model() + logs.request("claude", model) + logs.thinking("Preparing API request...") + + local body = build_tools_request_body(messages, context, tools) + + -- Estimate prompt tokens + local prompt_estimate = logs.estimate_tokens(vim.json.encode(body)) + logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate)) + + make_tools_request(body, function(response, err) + if err then + logs.error(err) + callback(nil, err) + else + -- Log token usage from response + if response and response.usage then + logs.response( + response.usage.input_tokens or 0, + response.usage.output_tokens or 0, + response.stop_reason + ) + end + + -- Log what's in the response + if response and response.content then + local has_text = false + local has_tools = false + for _, block in ipairs(response.content) do + if block.type == "text" then + has_text = true + elseif block.type == "tool_use" then + has_tools = true + logs.thinking("Tool call: " .. block.name) + end + end + if has_text then + logs.thinking("Response contains text") + end + if has_tools then + logs.thinking("Response contains tool calls") + end + end + + callback(response, nil) + end + end) end return M diff --git a/lua/codetyper/llm/ollama.lua b/lua/codetyper/llm/ollama.lua index 00f16c2..76ec6d0 100644 --- a/lua/codetyper/llm/ollama.lua +++ b/lua/codetyper/llm/ollama.lua @@ -170,4 +170,200 @@ function M.validate() return true end +--- Build system prompt for agent mode with tool instructions +---@param context table Context information +---@return string System prompt +local function build_agent_system_prompt(context) + local agent_prompts = require("codetyper.prompts.agent") + local tools_module = require("codetyper.agent.tools") + + local system_prompt = agent_prompts.system .. "\n\n" + system_prompt = system_prompt .. tools_module.to_prompt_format() .. "\n\n" + system_prompt = system_prompt .. agent_prompts.tool_instructions + + -- Add context about current file if available + if context.file_path then + system_prompt = system_prompt .. "\n\nCurrent working context:\n" + system_prompt = system_prompt .. "- File: " .. context.file_path .. "\n" + if context.language then + system_prompt = system_prompt .. "- Language: " .. context.language .. "\n" + end + end + + -- Add project root info + local root = utils.get_project_root() + if root then + system_prompt = system_prompt .. "- Project root: " .. root .. "\n" + end + + return system_prompt +end + +--- Build request body for Ollama API with tools (chat format) +---@param messages table[] Conversation messages +---@param context table Context information +---@return table Request body +local function build_tools_request_body(messages, context) + local system_prompt = build_agent_system_prompt(context) + + -- Convert messages to Ollama chat format + local ollama_messages = {} + for _, msg in ipairs(messages) do + local content = msg.content + -- Handle complex content (like tool results) + if type(content) == "table" then + local text_parts = {} + for _, part in ipairs(content) do + if part.type == "tool_result" then + table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or "")) + elseif part.type == "text" then + table.insert(text_parts, part.text or "") + end + end + content = table.concat(text_parts, "\n") + end + + table.insert(ollama_messages, { + role = msg.role, + content = content, + }) + end + + return { + model = get_model(), + messages = ollama_messages, + system = system_prompt, + stream = false, + options = { + temperature = 0.3, + num_predict = 4096, + }, + } +end + +--- Make HTTP request to Ollama chat API +---@param body table Request body +---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function +local function make_chat_request(body, callback) + local host = get_host() + local url = host .. "/api/chat" + local json_body = vim.json.encode(body) + + local cmd = { + "curl", + "-s", + "-X", "POST", + url, + "-H", "Content-Type: application/json", + "-d", json_body, + } + + vim.fn.jobstart(cmd, { + stdout_buffered = true, + on_stdout = function(_, data) + if not data or #data == 0 or (data[1] == "" and #data == 1) then + return + end + + local response_text = table.concat(data, "\n") + local ok, response = pcall(vim.json.decode, response_text) + + if not ok then + vim.schedule(function() + callback(nil, "Failed to parse Ollama response", nil) + end) + return + end + + if response.error then + vim.schedule(function() + callback(nil, response.error or "Ollama API error", nil) + end) + return + end + + -- Extract usage info + local usage = { + prompt_tokens = response.prompt_eval_count or 0, + response_tokens = response.eval_count or 0, + } + + -- Return the message content for agent parsing + if response.message and response.message.content then + vim.schedule(function() + callback(response.message.content, nil, usage) + end) + else + vim.schedule(function() + callback(nil, "No response from Ollama", nil) + end) + end + end, + on_stderr = function(_, data) + if data and #data > 0 and data[1] ~= "" then + vim.schedule(function() + callback(nil, "Ollama API request failed: " .. table.concat(data, "\n"), nil) + end) + end + end, + on_exit = function(_, code) + if code ~= 0 then + -- Don't double-report errors + end + end, + }) +end + +--- Generate response with tools using Ollama API +---@param messages table[] Conversation history +---@param context table Context information +---@param tools table Tool definitions (embedded in prompt for Ollama) +---@param callback fun(response: string|nil, error: string|nil) Callback function +function M.generate_with_tools(messages, context, tools, callback) + local logs = require("codetyper.agent.logs") + + -- Log the request + local model = get_model() + logs.request("ollama", model) + logs.thinking("Preparing API request...") + + local body = build_tools_request_body(messages, context) + + -- Estimate prompt tokens + local prompt_estimate = logs.estimate_tokens(vim.json.encode(body)) + logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate)) + + make_chat_request(body, function(response, err, usage) + if err then + logs.error(err) + callback(nil, err) + else + -- Log token usage + if usage then + logs.response( + usage.prompt_tokens or 0, + usage.response_tokens or 0, + "end_turn" + ) + end + + -- Log if response contains tool calls + if response then + local parser = require("codetyper.agent.parser") + local parsed = parser.parse_ollama_response(response) + if #parsed.tool_calls > 0 then + for _, tc in ipairs(parsed.tool_calls) do + logs.thinking("Tool call: " .. tc.name) + end + end + if parsed.text and parsed.text ~= "" then + logs.thinking("Response contains text") + end + end + + callback(response, nil) + end + end) +end + return M