diff --git a/docs/configuration.md b/docs/configuration.md index db96b5d..95be3b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,7 +21,7 @@ require("eca").setup({ usage_string_format = "{messageCost} / {sessionCost}", -- === BEHAVIOR === - behaviour = { + behavior = { -- Set keymaps automatically auto_set_keymaps = true, @@ -29,7 +29,7 @@ require("eca").setup({ auto_focus_sidebar = true, -- Start server automatically - auto_start_server = true, + auto_start_server = false, -- Download server automatically if not found auto_download = true, @@ -114,7 +114,7 @@ require("eca").setup({ ### Minimalist ```lua require("eca").setup({ - behaviour = { show_status_updates = false }, + behavior = { show_status_updates = false }, windows = { width = 30 }, chat = { headers = { @@ -128,7 +128,7 @@ require("eca").setup({ ### Visual/UX focused ```lua require("eca").setup({ - behaviour = { auto_focus_sidebar = true }, + behavior = { auto_focus_sidebar = true }, windows = { width = 50, wrap = true, @@ -149,7 +149,7 @@ require("eca").setup({ require("eca").setup({ debug = true, server_args = "--log-level debug", - behaviour = { + behavior = { auto_start_server = true, show_status_updates = true, }, @@ -164,7 +164,7 @@ require("eca").setup({ ### Performance-oriented ```lua require("eca").setup({ - behaviour = { + behavior = { auto_focus_sidebar = false, show_status_updates = false, }, diff --git a/docs/installation.md b/docs/installation.md index c5df383..7167653 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -53,7 +53,7 @@ Advanced setup example: opts = { debug = false, server_path = "", - behaviour = { + behavior = { auto_set_keymaps = true, auto_focus_sidebar = true, }, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 92df5c0..d37e5fc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -36,7 +36,7 @@ Solutions: Symptoms: `ec` doesn't open chat Solutions: -- Ensure `behaviour.auto_set_keymaps = true` +- Ensure `behavior.auto_set_keymaps = true` - Confirm your `` key (default: `\`) - Configure shortcuts manually: @@ -61,5 +61,5 @@ Symptoms: Lag when typing, slow responses Solutions: - Reduce window width: `windows.width = 25` -- Disable visual updates: `behaviour.show_status_updates = false` +- Disable visual updates: `behavior.show_status_updates = false` - Use the minimalist configuration preset diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 8ebc6f0..851a102 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -11,6 +11,11 @@ local M = {} function M.chat(opts) opts = opts or {} local eca = require("eca") + + if not M.is_server_running() then + M.start_server() + end + eca.open_sidebar(opts) end diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 139e1ec..a7f8604 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -20,10 +20,10 @@ M._defaults = { file = "", max_file_size_mb = 10, -- Maximum log file size in MB before warning }, - behaviour = { + behavior = { auto_set_keymaps = true, auto_focus_sidebar = true, - auto_start_server = true, -- Automatically start server on setup + auto_start_server = false, -- Automatically start server on setup auto_download = true, -- Automatically download server if not found show_status_updates = true, -- Show status updates in notifications }, diff --git a/lua/eca/init.lua b/lua/eca/init.lua index 5baed4e..558cf78 100644 --- a/lua/eca/init.lua +++ b/lua/eca/init.lua @@ -32,7 +32,7 @@ function H.keymaps() require("eca.api").focus() end, { noremap = true }) - if Config.behaviour.auto_set_keymaps then + if Config.behavior and Config.behavior.auto_set_keymaps then Utils.safe_keymap_set({ "n", "v" }, Config.mappings.chat, function() require("eca.api").chat() end, { desc = "eca: open chat" }) @@ -245,10 +245,12 @@ function M.setup(opts) M.state = require("eca.state").new() M.server = Server.new() M.mediator = require("eca.mediator").new(M.server, M.state) - -- Start server automatically in background - vim.defer_fn(function() - M.server:start() - end, 100) -- Small delay to ensure everything is loaded + + if Config.behavior and Config.behavior.auto_start_server then + vim.defer_fn(function() + M.server:start() + end, 100) -- Small delay to ensure everything is loaded + end M.did_setup = true end diff --git a/lua/eca/path_finder.lua b/lua/eca/path_finder.lua index 733fa07..3557f6e 100644 --- a/lua/eca/path_finder.lua +++ b/lua/eca/path_finder.lua @@ -1,6 +1,5 @@ local uv = vim.uv or vim.loop local Utils = require("eca.utils") -local Config = require("eca.config") local Logger = require("eca.logger") ---@class eca.PathFinder @@ -100,7 +99,7 @@ function M:_write_version_file(version) file:write(version) file:close() else - Logger.notify("Could not write version file: " .. self._version_file, vim.log.levels.WARN) + Logger.warn("Could not write version file: " .. self._version_file) end end @@ -136,7 +135,7 @@ function M:_download_latest_server(server_path, version) local download_path = self._cache_dir .. "/" .. artifact_name - Logger.notify("Downloading latest ECA server version from: " .. download_url, vim.log.levels.INFO) + Logger.debug("Downloading latest ECA server version from: " .. download_url) -- Ensure cache directory exists vim.fn.mkdir(self._cache_dir, "p") @@ -150,8 +149,7 @@ function M:_download_latest_server(server_path, version) local download_result = os.execute(download_cmd) if download_result ~= 0 then - Logger.notify("Failed to download ECA server from: " .. download_url, vim.log.levels.ERROR) - return false + error("Failed to download ECA server from: " .. download_url) end -- Extract if it's a zip file @@ -161,8 +159,7 @@ function M:_download_latest_server(server_path, version) local extract_result = os.execute(extract_cmd) if extract_result ~= 0 then - Logger.notify("Failed to extract ECA server", vim.log.levels.ERROR) - return false + error("Failed to extract ECA server") end -- Remove the zip file after extraction @@ -170,33 +167,30 @@ function M:_download_latest_server(server_path, version) end -- Make executable (if not Windows) - if not vim.loop.os_uname().sysname:lower():match("windows") then + if not uv.os_uname().sysname:lower():match("windows") then os.execute("chmod +x " .. vim.fn.shellescape(server_path)) end if not Utils.file_exists(server_path) then - Logger.notify("ECA server binary not found after download and extraction", vim.log.levels.ERROR) - return false + error("ECA server binary not found after download and extraction") end -- Write version file self:_write_version_file(version) - Logger.notify("ECA server downloaded successfully", vim.log.levels.INFO) + Logger.debug("ECA server downloaded successfully") + return true end ---@return string -function M:find() +function M:find(custom_path) -- Check for custom server path first - local custom_path = Config.server_path if custom_path and custom_path:gsub("%s+", "") ~= "" then - if Utils.file_exists(custom_path) then - Logger.debug("Using custom server path: " .. custom_path) - return custom_path - else - Logger.notify("Custom server path does not exist: " .. custom_path, vim.log.levels.WARN) + if not Utils.file_exists(custom_path) then + error("Custom server path does not exist: " .. custom_path) end + return custom_path end local server_path = self:_get_extension_server_path() @@ -215,13 +209,18 @@ function M:find() -- Download if server doesn't exist or version is outdated if not server_exists or (latest_version and current_version ~= latest_version) then if not latest_version then - Logger.notify("Could not check for latest version, using existing server", vim.log.levels.WARN) + Logger.warn("Could not check for latest version, using existing server") return server_path end - local success = self:_download_latest_server(server_path, latest_version) - if not success then - error("Failed to download ECA server") + local success + + local ok, err = pcall(function() + success = self:_download_latest_server(server_path, latest_version) + end) + + if not ok or not success then + error((err and tostring(err)) or "Failed to download ECA server") end end diff --git a/lua/eca/server.lua b/lua/eca/server.lua index babe87e..54b94e4 100644 --- a/lua/eca/server.lua +++ b/lua/eca/server.lua @@ -1,6 +1,5 @@ local Utils = require("eca.utils") local Config = require("eca.config") -local PathFinder = require("eca.path_finder") local Logger = require("eca.logger") ---@class eca.Server @@ -13,8 +12,9 @@ local Logger = require("eca.logger") ---@field on_stop function Callback when the server stops ---Called when a notification is received(message without an ID) ---@field on_notification fun(server: eca.Server, message: table) ----@field private path_finder eca.PathFinder Server path finder ---@field pending_requests {id: fun(err, data)} -- outgoing requests with callbacks +---@field cwd string Current working directory for the server process +---@field workspace_folders {name: string, uri: string}[] Workspace folders to send on initialize local M = {} ---@param opts? table @@ -22,7 +22,7 @@ local M = {} function M.new(opts) opts = vim.tbl_extend("keep", opts or {}, { on_start = function(pid) - require("eca.logger").notify("Started server with pid " .. pid, vim.log.levels.INFO) + require("eca.logger").debug("Started server with pid " .. pid) end, on_initialize = function() require("eca.logger").notify("Server ready to receive messages", vim.log.levels.INFO) @@ -37,7 +37,13 @@ function M.new(opts) require("eca.observer").notify(message) end) end, - path_finder = PathFinder:new(), + cwd = vim.fn.getcwd(), + workspace_folders = { + { + name = vim.fn.fnamemodify(Utils.get_project_root(), ":t"), + uri = "file://" .. Utils.get_project_root(), + }, + }, }) return setmetatable({ @@ -46,11 +52,12 @@ function M.new(opts) on_initialize = opts.on_initialize, on_stop = opts.on_stop, on_notification = opts.on_notification, - path_finder = opts.path_finder, messages = {}, pending_requests = {}, initialized = false, next_id = 0, + cwd = opts.cwd, + workspace_folders = opts.workspace_folders, }, { __index = M }) end @@ -93,70 +100,79 @@ end function M:start(opts) opts = opts or { initialize = true } - local server_path - local ok, path_finder_error = pcall(function() - server_path = self.path_finder:find() - end) + local this_file = debug.getinfo(1, "S").source:sub(2) + local proj_root = vim.fn.fnamemodify(this_file, ":p:h:h:h") + local script_path = proj_root .. "/scripts/server_path.lua" - if not ok or not server_path then - Logger.notify("Could not find or download ECA server" .. tostring(path_finder_error), vim.log.levels.ERROR) - return + local nvim_exe = vim.fn.exepath("nvim") + + if not nvim_exe or nvim_exe == "" then + nvim_exe = "nvim" end - Logger.debug("Starting ECA server: " .. server_path) + local lua_cmd = string.format("lua ServerPath.run(%s)", Utils.lua_quote(Config.server_path or "")) - local args = { server_path, "server" } - if Config.server_args and Config.server_args ~= "" then - vim.list_extend(args, vim.split(Config.server_args, " ")) - end + local cmd = { nvim_exe, "--headless", "--noplugin", "-u", script_path, "-c", lua_cmd } - opts = vim.tbl_deep_extend("keep", opts, { - cmd = args, - text = true, - cwd = vim.fn.getcwd(), - stdin = true, - stdout = on_stdout(self), - stderr = on_stderr, - ---@param out vim.SystemCompleted - on_exit = function(out) - if out.code ~= 0 then - require("eca.logger").notify(string.format("Server exited with status code %d", out.code), vim.log.levels.ERROR) - end - end, - }) + vim.system(cmd, { text = true }, function(out) + if out.code ~= 0 then + Logger.notify(out.stderr, vim.log.levels.ERROR) + return + end - local started, process_or_err = pcall(vim.system, opts.cmd, { - cwd = opts.cwd, - text = opts.text, - stdin = opts.stdin, - stdout = opts.stdout, - stderr = opts.stderr, - }, opts.on_exit) - - if not started then - self.process = nil - Logger.notify(vim.inspect(process_or_err), vim.log.levels.ERROR) - return - end + local stdout_lines = Utils.split_lines(out.stdout) + local server_path = stdout_lines[#stdout_lines] - self.process = process_or_err - if self.on_start then - self.on_start(process_or_err.pid) - end + Logger.debug("Starting ECA server: " .. server_path) - if opts.initialize then - self:initialize() - end + local args = { server_path, "server" } + + if Config.server_args and Config.server_args ~= "" then + vim.list_extend(args, vim.split(Config.server_args, " ")) + end + + opts = vim.tbl_deep_extend("keep", opts, { + cmd = args, + text = true, + cwd = self.cwd, + stdin = true, + stdout = on_stdout(self), + stderr = on_stderr, + ---@param output vim.SystemCompleted + on_exit = function(output) + if output.code ~= 0 then + require("eca.logger").notify(string.format("Server exited with status code %d", output.code), vim.log.levels + .ERROR) + end + end, + }) + + local started, process_or_err = pcall(vim.system, opts.cmd, { + cwd = opts.cwd, + text = opts.text, + stdin = opts.stdin, + stdout = opts.stdout, + stderr = opts.stderr, + }, opts.on_exit) + + if not started then + self.process = nil + Logger.notify(vim.inspect(process_or_err), vim.log.levels.ERROR) + return + end + + self.process = process_or_err + if self.on_start then + self.on_start(process_or_err.pid) + end + + if opts.initialize then + self:initialize() + end + end) end function M:initialize() - local workspace_folders = { - { - name = vim.fn.fnamemodify(Utils.get_project_root(), ":t"), - uri = "file://" .. Utils.get_project_root(), - }, - } - self:send_request("initialize", { processId = vim.fn.getpid(), clientInfo = { @@ -168,7 +184,7 @@ function M:initialize() chat = true, }, }, - workspaceFolders = workspace_folders, + workspaceFolders = vim.deepcopy(self.workspace_folders), }, function(err, _) if err then Logger.notify("Could not initialize server: " .. err, vim.log.levels.ERROR) @@ -237,12 +253,6 @@ end ---@param params table ---@param callback? function function M:send_request(method, params, callback) - if not self:is_running() then - Logger.error("ECA server is not running") - if callback then - callback("Server not running", nil) - end - end local id = self:get_next_id() local message = { jsonrpc = "2.0", @@ -256,6 +266,15 @@ function M:send_request(method, params, callback) local json = vim.json.encode(message) table.insert(self.messages, { content = json, content_length = #json }) + + if not self:is_running() then + Logger.error("ECA server is not running") + if callback then + callback("Server not running", nil) + end + return + end + local content = string.format("Content-Length: %d\r\n\r\n%s", #json, json) self.process:write(content) end diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 5786c45..e0c7d02 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1223,7 +1223,7 @@ function M:_update_welcome_content() local cfg_msg = (cfg.message and cfg.message ~= "" and cfg.message) or nil local welcome_message = cfg_msg or (self.mediator and self.mediator:welcome_message() or nil) - local lines = { "Waiting for welcome message from ECA server..." } + local lines = { "Waiting for server to start..." } if welcome_message and welcome_message ~= "" then lines = Utils.split_lines(welcome_message) diff --git a/lua/eca/utils.lua b/lua/eca/utils.lua index 7a46ac8..42b78bd 100644 --- a/lua/eca/utils.lua +++ b/lua/eca/utils.lua @@ -118,4 +118,11 @@ function M.constants() return CONSTANTS end +---@param str string +---@return string +function M.lua_quote(str) + -- Escape backslashes and double quotes for Lua string literal + return '"' .. tostring(str):gsub('\\', '\\\\'):gsub('"', '\\"') .. '"' +end + return M diff --git a/plugin-spec.lua b/plugin-spec.lua index a441d89..68a4b71 100644 --- a/plugin-spec.lua +++ b/plugin-spec.lua @@ -17,7 +17,7 @@ return { file = "", -- Empty string uses default path max_file_size_mb = 10, }, - behaviour = { + behavior = { auto_set_keymaps = true, auto_focus_sidebar = true, }, diff --git a/scripts/server_path.lua b/scripts/server_path.lua new file mode 100644 index 0000000..8e0317b --- /dev/null +++ b/scripts/server_path.lua @@ -0,0 +1,29 @@ +local ServerPath = {} + +-- Setup if headless +if #vim.api.nvim_list_uis() == 0 then + _G.ServerPath = ServerPath + vim.o.swapfile = false + vim.o.backup = false + vim.o.writebackup = false + require("eca").setup({}) +end + +ServerPath.run = function(custom_path) + local path_finder = require("eca.path_finder"):new() + local path + + local ok, err = pcall(function() + path = path_finder:find(custom_path) + end) + + if not ok then + io.stderr:write(tostring(err)) + os.exit(1) + end + + io.stdout:write(tostring(path)) + os.exit(0) +end + +return ServerPath diff --git a/tests/test_server_integration.lua b/tests/test_server_integration.lua index 368438b..e9a73db 100644 --- a/tests/test_server_integration.lua +++ b/tests/test_server_integration.lua @@ -38,57 +38,45 @@ local function sleep(ms) end T["server"] = MiniTest.new_set() + T["server"]["start"] = function() - child.lua("_G.server.start({cmd = {'fake'}})") - eq(child.lua_get("_G.server.process"), vim.NIL) child.lua("_G.server:start()") - eq(child.lua_get("_G.server:is_running()"), true) + child.lua([[ + _G.server_started = vim.wait(10000, function() + return _G.server and _G.server:is_running() + end, 100) + ]]) + eq(child.lua_get("_G.server_started"), true) + sleep(1000) + eq(child.lua_get("_G.server.initialized"), true) end -T["server"]["initialize"] = function() - local test_cases = { - { - method = "initialize", - want = { - chatBehaviors = { "agent", "plan" }, - chatDefaultBehavior = "agent", - chatDefaultModel = "anthropic/claude-sonnet-4-20250514", - chatWelcomeMessage = "Welcome to ECA!\n\nType '/' for commands\n\n", - models = { - "anthropic/claude-3-5-haiku-20241022", - "anthropic/claude-opus-4-1-20250805", - "anthropic/claude-opus-4-20250514", - "anthropic/claude-sonnet-4-20250514", - "github-copilot/claude-sonnet-4", - "github-copilot/gpt-4.1", - "github-copilot/gpt-5", - "github-copilot/gpt-5-mini", - "openai/gpt-4.1", - "openai/gpt-5", - "openai/gpt-5-mini", - "openai/gpt-5-nano", - "openai/o3", - "openai/o4-mini", - }, - }, - }, - { method = "not a command", want = vim.NIL }, - } - for i, test_case in ipairs(test_cases) do - if i == 1 then - test_case = test_cases[i + 1] - end - child.lua("_G.method = " .. vim.inspect(test_case.method)) - child.lua("_G.server:start({initialize = false})") - child.lua_func(function() - _G.server:send_request(_G.method, {}, function(err, result) - _G.err = err - _G.result = result - end) - end) - sleep(1500) - eq(child.lua_get("_G.result"), test_case.want) - end +T["server"]["start without initialize"] = function() + child.lua("_G.server:start({ initialize = false })") + child.lua([[ + _G.server_started = vim.wait(10000, function() + return _G.server and _G.server:is_running() + end, 100) + ]]) + eq(child.lua_get("_G.server_started"), true) + sleep(1000) + eq(child.lua_get("_G.server.initialized"), false) +end + +T["server"]["start with inexistent path"] = function() + child.lua([[ + Config = require("eca.config") + Config.setup({ server_path = "non-existing-path" } ) + _G.server:start() + ]]) + child.lua([[ + _G.server_started = vim.wait(1000, function() + return _G.server and _G.server:is_running() + end, 100) + ]]) + eq(string.find(child.lua_get("_G.notifications[1].message"), "non-existing-path", 1 , true) ~= nil, true) + eq(child.lua_get("_G.server_started"), false) + eq(child.lua_get("_G.server.initialized"), false) end return T diff --git a/tests/test_server_path.lua b/tests/test_server_path.lua new file mode 100644 index 0000000..b0880f5 --- /dev/null +++ b/tests/test_server_path.lua @@ -0,0 +1,69 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local function setup_test_environment() + Utils = require("eca.utils") + _G.cmd = function(custom_path) + return { + "nvim", + "--headless", + "--noplugin", + "--cmd", + [[lua package.preload["eca.path_finder"] = function() + local M = {} + function M.new() + return setmetatable({}, { __index = M }) + end + function M:find(custom_path) + if custom_path == "error" then + error("custom-server-path-error") + end + return (custom_path ~= "" and custom_path) or "no-custom-server-path" + end + return M + end]], + "-u", + "scripts/server_path.lua", + "-c", + string.format("lua ServerPath.run(%s)", Utils.lua_quote(custom_path or "")), + } + end +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua_func(setup_test_environment) + end, + post_case = function() + end, + post_once = child.stop, + }, +}) + +T["server_path"] = MiniTest.new_set() + +T["server_path"]["run without custom path should print to stdout"] = function() + child.lua("_G.result = vim.system(_G.cmd(), { text = true }):wait()") + eq(child.lua_get("_G.result.code"), 0) + eq(child.lua_get("_G.result.stdout"), "no-custom-server-path") + eq(child.lua_get("_G.result.stderr"), "") +end + +T["server_path"]["run with custom path should print to stdout"] = function() + child.lua("_G.result = vim.system(_G.cmd('custom-server-path'), { text = true }):wait()") + eq(child.lua_get("_G.result.code"), 0) + eq(child.lua_get("_G.result.stdout"), "custom-server-path") + eq(child.lua_get("_G.result.stderr"), "") +end + +T["server_path"]["run with error should print to stderr"] = function() + child.lua("_G.result = vim.system(_G.cmd('error'), { text = true }):wait()") + eq(child.lua_get("_G.result.code"), 1) + eq(child.lua_get("_G.result.stdout"), "") + eq(string.find(child.lua_get("_G.result.stderr"), "custom-server-path-error", 1 , true) ~= nil, true) +end + +return T