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 <noreply@anthropic.com>
This commit is contained in:
2026-01-13 20:45:55 -05:00
parent 9c99944f26
commit 2989fb5f14
13 changed files with 2658 additions and 129 deletions

View File

@@ -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", "<CR>", 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", "<Esc>", function()
close_and_respond(false)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
-- Switch between windows
vim.keymap.set("n", "<Tab>", 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/<CR>", "Keyword" },
{ " approve ", "Normal" },
{ "n/q/<Esc>", "Keyword" },
{ " reject ", "Normal" },
{ "<Tab>", "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", "<CR>", 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", "<Esc>", function()
close_and_respond(false)
end, keymap_opts)
end
return M

View File

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

View File

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

View File

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

View File

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

View File

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

674
lua/codetyper/agent/ui.lua Normal file
View File

@@ -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", "<CR>", submit_input, input_opts)
vim.keymap.set("n", "<CR>", submit_input, input_opts)
vim.keymap.set("i", "@", M.show_file_picker, input_opts)
vim.keymap.set({ "n", "i" }, "<C-f>", M.include_current_file, input_opts)
vim.keymap.set("n", "<Tab>", M.focus_chat, input_opts)
vim.keymap.set("n", "q", M.close, input_opts)
vim.keymap.set("n", "<Esc>", 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", "<CR>", M.focus_input, chat_opts)
vim.keymap.set("n", "@", M.show_file_picker, chat_opts)
vim.keymap.set("n", "<C-f>", M.include_current_file, chat_opts)
vim.keymap.set("n", "<Tab>", 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", "<Tab>", 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