diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5914ba2..f600dc7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,15 +11,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - neovim_version: ['v0.9.5', 'v0.10.0', 'nightly'] - + neovim_version: ['v0.9.5', 'v0.10.0', 'v0.11.4', 'v0.12.0', 'nightly'] + steps: - uses: actions/checkout@v4 - + - name: Install Neovim uses: rhysd/action-setup-vim@v1 with: neovim: true - + - name: Run tests run: make test diff --git a/README.md b/README.md index 2a0449d..a5bfe52 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,42 @@ -# πŸ€– ECA Neovim Plugin +# ECA Neovim Plugin demo -A modern Neovim plugin that integrates [ECA (Editor Code Assistant)](https://eca.dev/) directly into the editor for an intuitive, streaming AI experience. +A lightweight Neovim plugin that embeds [ECA (Editor Code Assistant)](https://eca.dev/) directly into your editor. It is designed to be very simple, while remaining highly customizable. -## ✨ Features -- πŸ€– Integrated AI chat in Neovim -- πŸ“ Add files, directories and selections as context -- πŸš€ Automatic ECA server download and start -- 🎨 Clean sidebar UI with Markdown rendering -- ⌨️ Intuitive defaults (Ctrl+S to send, Enter for newline) -- πŸ”§ Highly configurable windows, keymaps and behavior -- πŸ“Š Usage and status feedback +> Status: **Work in Progress** β€” we’re actively developing this plugin and would love feedback, bug reports, and contributions. If you’d like to help, check out [Development & contributing](./docs/development.md) or open an issue/PR. -## ⚑ Quick Start -1. Install via your plugin manager (see Installation below) -2. Restart Neovim -3. Run `:EcaChat` or press `ec` -4. Type your message and press `Ctrl+S` -5. Add context with `:EcaAddFile` or `:EcaAddSelection` +## Quick Start > Requires Neovim >= 0.8.0, curl and unzip. -## πŸ“š Documentation +1. Install via your plugin manager (see Installation below) +2. Run `:EcaChat` or press `ec` +3. Type your message and press `Ctrl+S` + + +## Documentation - [Installation and system requirements](./docs/installation.md) - [Usage guide (commands, keymaps, tips)](./docs/usage.md) - [Configuration reference and presets](./docs/configuration.md) - [Troubleshooting common issues](./docs/troubleshooting.md) - [Development & contributing](./docs/development.md) -## πŸ”— Useful Links +## Useful Links - [Official ECA Website](https://eca.dev/) - [ECA Documentation](https://docs.eca.dev/) - [VS Code Plugin](https://marketplace.visualstudio.com/items?itemName=editor-code-assistant.eca-vscode) - [ECA GitHub](https://github.com/editor-code-assistant) -## πŸ“„ License +## License Apache License 2.0 β€” see [LICENSE](LICENSE) for details. -## πŸ™ Acknowledgments -Inspired by: -- [avante.nvim](https://github.com/yetone/avante.nvim) β€” base structure and UI concepts -- [eca-vscode](https://github.com/editor-code-assistant/eca-vscode) β€” ECA server integration - ---
-✨ Made with ❀️ for the Neovim community ✨ +Made for the Neovim community -[⭐ Give a star if this plugin was useful!](https://github.com/editor-code-assistant/eca-nvim) +[Give a star if this plugin was useful](https://github.com/editor-code-assistant/eca-nvim)
diff --git a/docs/configuration.md b/docs/configuration.md index 95be3b9..e6d6c40 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,102 +6,158 @@ ECA is highly configurable. This page lists all available options and provides c ```lua require("eca").setup({ - -- === BASIC SETTINGS === + -- === SERVER === - -- Enable debug mode (shows detailed logs) - debug = false, - - -- Path to ECA binary (empty = automatic download) + -- Path to the ECA binary + -- - Empty string: automatically download & manage the binary + -- - Custom path: use your own binary server_path = "", - -- Extra arguments for ECA server - server_args = "--log-level info", + -- Extra arguments passed to the ECA server (eca start ...) + server_args = "", - -- Usage string format (tokens/cost) - usage_string_format = "{messageCost} / {sessionCost}", + -- === LOGGING === + log = { + -- Where to display logs inside Neovim + -- "split" - use a split window + -- "float" - use a floating window + -- "none" - disable the log window + display = "split", + + -- Minimum log level to record + -- vim.log.levels.TRACE | DEBUG | INFO | WARN | ERROR + level = vim.log.levels.INFO, + + -- Optional file path for persistent logs (empty = disabled) + file = "", + + -- Maximum log file size before ECA warns you (in MB) + max_file_size_mb = 10, + }, -- === BEHAVIOR === behavior = { - -- Set keymaps automatically + -- Set default keymaps automatically auto_set_keymaps = true, - -- Focus sidebar automatically when opening + -- Focus the ECA sidebar when opening it auto_focus_sidebar = true, - -- Start server automatically + -- Automatically start the server on plugin setup auto_start_server = false, - -- Download server automatically if not found + -- Automatically download the server if not found auto_download = true, - -- Show status updates in notifications + -- Show status updates (startup, downloads, errors) as notifications show_status_updates = true, }, - -- === KEY MAPPINGS === - mappings = { - chat = "ec", -- Open chat - focus = "ef", -- Focus sidebar - toggle = "et", -- Toggle sidebar + -- === CONTEXT === + context = { + -- Automatically attach repo context (repoMap) when starting new chats + auto_repo_map = true, }, - -- === CHAT === - chat = { - headers = { - user = "> ", - assistant = "", - }, - welcome = { - -- If non-empty, overrides server-provided welcome message - message = "", - -- Tips appended under the welcome (set {} to disable) - tips = { - "Type your message and use CTRL+s to send", - }, - }, + -- === KEY MAPPINGS === + mappings = { + chat = "ec", -- Open chat + focus = "ef", -- Focus sidebar + toggle = "et",-- Toggle sidebar }, - -- === WINDOW SETTINGS === + -- === WINDOWS & UI === windows = { - -- Automatic line wrapping + -- Automatic line wrapping in ECA buffers wrap = true, - -- Width as percentage of screen (1-100) + -- Width as percentage of Neovim columns (1–100) width = 40, -- Sidebar header configuration sidebar_header = { enabled = true, - align = "center", -- "left", "center", "right" + align = "center", -- "left", "center", "right" rounded = true, }, -- Input area configuration input = { - prefix = "> ", -- Input line prefix - height = 8, -- Input window height + prefix = "> ", -- Input line prefix + height = 8, -- Input window height (lines) + + -- Maximum length for web context names in the input area + web_context_max_len = 20, }, -- Edit window configuration edit = { - border = "rounded", -- "none", "single", "double", "rounded" - start_insert = true, -- Start in insert mode + border = "rounded", -- "none", "single", "double", "rounded" + start_insert = true, -- Start in insert mode }, - -- Ask window configuration - ask = { - floating = false, -- Use floating window - start_insert = true, -- Start in insert mode - border = "rounded", - focus_on_apply = "ours", -- "ours" or "theirs" + -- Usage line configuration (token / cost display) + usage = { + -- Supported placeholders: + -- {session_tokens} - raw session token count (e.g. "30376") + -- {limit_tokens} - raw token limit (e.g. "400000") + -- {session_tokens_short} - shortened session tokens (e.g. "30k") + -- {limit_tokens_short} - shortened token limit (e.g. "400k") + -- {session_cost} - session cost (e.g. "0.09") + -- Default: "30k / 400k ($0.09)" -> + -- "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + format = "{session_tokens_short} / {limit_tokens_short} (${session_cost})", }, - }, - -- === HIGHLIGHTS AND COLORS === - highlights = { - diff = { - current = "DiffText", -- Highlight for current diff - incoming = "DiffAdd", -- Highlight for incoming diff + -- Chat window & behavior + chat = { + -- Prefixes for each speaker + headers = { + user = "> ", + assistant = "", + }, + + -- Welcome message configuration + welcome = { + -- If non-empty, overrides the server-provided welcome message + message = "", + + -- Tips appended under the welcome (set {} to disable) + tips = { + "Type your message and use CTRL+s to send", + }, + }, + + -- Typewriter effect for streaming responses + typing = { + enabled = true, -- Enable/disable typewriter effect + chars_per_tick = 1, -- Characters to display per tick (1 = realistic typing) + tick_delay = 10, -- Delay in ms between ticks (lower = faster typing) + }, + + -- Tool call display settings + tool_call = { + icons = { + success = "βœ…", -- Shown when a tool call succeeds + error = "❌", -- Shown when a tool call fails + running = "⏳", -- Shown while a tool call is running + expanded = "β–Ό", -- Arrow when the tool call details are expanded + collapsed = "β–Ά", -- Arrow when the tool call details are collapsed + }, + diff = { + collapsed_label = "+ view diff", -- Label when the diff is collapsed + expanded_label = "- view diff", -- Label when the diff is expanded + expanded = false, -- When true, tool diffs start expanded + }, + preserve_cursor = true, -- When true, cursor stays in place when expanding/collapsing + }, + + -- Reasoning ("Thinking") block behavior + reasoning = { + expanded = false, -- When true, "Thinking" blocks start expanded + running_label = "Thinking...", -- Label while reasoning is running + finished_label = "Thought", -- Base label when reasoning is finished + }, }, }, }) @@ -111,21 +167,27 @@ require("eca").setup({ ## Presets +These examples show how to override just a subset of the configuration. + ### Minimalist ```lua require("eca").setup({ - behavior = { show_status_updates = false }, - windows = { width = 30 }, - chat = { - headers = { - user = "> ", - assistant = "", + behavior = { + show_status_updates = false, + }, + windows = { + width = 30, + chat = { + headers = { + user = "> ", + assistant = "", + }, }, }, }) ``` -### Visual/UX focused +### Visual / UX focused ```lua require("eca").setup({ behavior = { auto_focus_sidebar = true }, @@ -134,11 +196,14 @@ require("eca").setup({ wrap = true, sidebar_header = { enabled = true, rounded = true }, input = { prefix = "πŸ’¬ ", height = 10 }, - }, - chat = { - headers = { - user = "## πŸ‘€ You\n\n", - assistant = "## πŸ€– ECA\n\n", + chat = { + headers = { + user = "## πŸ‘€ You\n\n", + assistant = "## πŸ€– ECA\n\n", + }, + reasoning = { + expanded = true, + }, }, }, }) @@ -147,35 +212,128 @@ require("eca").setup({ ### Development ```lua require("eca").setup({ - debug = true, server_args = "--log-level debug", - behavior = { - auto_start_server = true, - show_status_updates = true, + log = { + level = vim.log.levels.DEBUG, + display = "split", }, - mappings = { - chat = "", - toggle = "", - focus = "", +}) +``` + +### Typing Speed Presets + +```lua +-- Fast typing (2x speed) +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 2, -- 2 characters at a time + tick_delay = 5, -- 5ms between ticks + }, + }, + }, +}) + +-- Slow/realistic typing +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 1, -- 1 character at a time + tick_delay = 30, -- 30ms between ticks (~33 chars/sec) + }, + }, + }, +}) + +-- Instant display (no typing effect) +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = false, -- Disable typing effect + }, + }, }, }) ``` -### Performance-oriented +### Tool Call Behavior + ```lua +-- Keep cursor in place when expanding/collapsing tool calls require("eca").setup({ - behavior = { - auto_focus_sidebar = false, - show_status_updates = false, + windows = { + chat = { + tool_call = { + preserve_cursor = true, -- Don't move cursor on expand/collapse + diff = { + expanded = true, -- Start with diffs expanded + }, + }, + }, + }, +}) +``` + +--- + +## Migration Guide + +### Upgrading from older versions + +If you have existing configuration, note these changes: + +**Config structure changes:** +- `debug` option removed β†’ Use `log.level = vim.log.levels.DEBUG` +- `usage_string_format` moved β†’ Now `windows.usage.format` +- `chat.*` options moved β†’ Now nested under `windows.chat.*` + +**Legacy config is still supported** through automatic merging, but the new structure is recommended: + +```lua +-- Old (still works) +require("eca").setup({ + debug = true, + usage_string_format = "{messageCost} / {sessionCost}", + chat = { + headers = { user = "> " }, + }, +}) + +-- New (recommended) +require("eca").setup({ + log = { + level = vim.log.levels.DEBUG, + }, + windows = { + usage = { + format = "{session_cost}", + }, + chat = { + headers = { user = "> " }, + }, }, - windows = { width = 25 }, }) ``` +**New placeholders for usage format:** +- `{session_tokens}` β†’ Raw session token count +- `{limit_tokens}` β†’ Raw token limit +- `{session_tokens_short}` β†’ Shortened format (e.g., "30k") +- `{limit_tokens_short}` β†’ Shortened format (e.g., "400k") +- `{session_cost}` β†’ Session cost + --- ## Notes - Set `server_path` if you prefer using a local ECA binary. -- For noisy environments, disable `show_status_updates`. +- Use the `log` block to control verbosity and where logs are written. +- `context.auto_repo_map` controls whether repo context is attached automatically. + - Adjust `windows.width` to fit your layout. -- Keymaps can be set manually by turning off `auto_set_keymaps`. +- Keymaps can be set manually by turning off `behavior.auto_set_keymaps` and defining your own mappings. +- The `windows.usage.format` string controls how token and cost usage are displayed. diff --git a/docs/development.md b/docs/development.md index 0813acd..afd70b5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,9 +39,32 @@ Run tests before submitting a PR: ```bash -# Unit tests -nvim --headless -c "lua require('eca.tests').run_all()" +# Run all tests with mini.test +nvim --headless -u scripts/minimal_init.lua -c "lua require('mini.test').setup(); MiniTest.run_file('tests/test_eca.lua')" + +# Run specific test files +nvim --headless -u scripts/minimal_init.lua -c "lua require('mini.test').setup(); MiniTest.run_file('tests/test_stream_queue.lua')" +nvim --headless -u scripts/minimal_init.lua -c "lua require('mini.test').setup(); MiniTest.run_file('tests/test_sidebar_usage_and_tools.lua')" # Manual test -nvim -c "lua require('eca').setup({debug=true})" +nvim -c "lua require('eca').setup({log = {level = vim.log.levels.DEBUG}})" ``` + +### Test Coverage + +The plugin includes comprehensive tests for: +- Core configuration and utilities (`test_eca.lua`, `test_utils.lua`) +- Stream queue and typewriter effect (`test_stream_queue.lua`) +- Sidebar tool calls and reasoning blocks (`test_sidebar_usage_and_tools.lua`) +- Picker commands (`test_picker.lua`, `test_server_picker_commands.lua`) +- Highlight groups (`test_highlights.lua`) + +### Highlight Groups + +ECA defines custom highlight groups for UI elements: +- `EcaToolCall` - Tool call headers +- `EcaHyperlink` - Clickable diff labels +- `EcaLabel` - Muted text (context labels, reasoning headers) +- `EcaSuccess`, `EcaWarning`, `EcaInfo` - Status indicators + +These can be customized in your colorscheme or via `:highlight` commands. diff --git a/docs/installation.md b/docs/installation.md index 7167653..b653006 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,6 +12,7 @@ This guide covers system requirements and how to install the ECA Neovim plugin w ### Optional - plenary.nvim β€” Utility functions used by some distributions +- snacks.nvim β€” Required for `:EcaServerMessages` and `:EcaServerTools` commands (picker functionality) ### Tested Systems - macOS (Intel and Apple Silicon) @@ -29,8 +30,9 @@ This guide covers system requirements and how to install the ECA Neovim plugin w { "editor-code-assistant/eca-nvim", dependencies = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools }, opts = {} } @@ -42,8 +44,9 @@ Advanced setup example: { "editor-code-assistant/eca-nvim", dependencies = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools }, keys = { { "ec", "EcaChat", desc = "Open ECA chat" }, @@ -67,8 +70,9 @@ Advanced setup example: use { "editor-code-assistant/eca-nvim", requires = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools }, config = function() require("eca").setup({ @@ -87,8 +91,9 @@ Plug 'editor-code-assistant/eca-nvim' " Required dependencies Plug 'MunifTanjim/nui.nvim' -" Optional dependencies (enhanced async operations) -Plug 'nvim-lua/plenary.nvim' +" Optional dependencies +Plug 'nvim-lua/plenary.nvim' " Enhanced async operations +Plug 'folke/snacks.nvim' " Picker for server messages/tools " After the plugins, add: lua << EOF @@ -106,8 +111,9 @@ call dein#add('editor-code-assistant/eca-nvim') " Required dependencies call dein#add('MunifTanjim/nui.nvim') -" Optional dependencies (enhanced async operations) -call dein#add('nvim-lua/plenary.nvim') +" Optional dependencies +call dein#add('nvim-lua/plenary.nvim') " Enhanced async operations +call dein#add('folke/snacks.nvim') " Picker for server messages/tools " Configuration lua << EOF @@ -127,8 +133,9 @@ EOF # Required dependencies "nui.nvim" = { git = "MunifTanjim/nui.nvim" } -# Optional dependencies (enhanced async operations) -"plenary.nvim" = { git = "nvim-lua/plenary.nvim" } +# Optional dependencies +"plenary.nvim" = { git = "nvim-lua/plenary.nvim" } # Enhanced async operations +"snacks.nvim" = { git = "folke/snacks.nvim" } # Picker for server messages/tools ``` ### mini.deps @@ -139,8 +146,9 @@ local add = MiniDeps.add add({ source = "editor-code-assistant/eca-nvim", depends = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools } }) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d37e5fc..b9ce6b9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,11 +15,18 @@ Solutions: ```lua -- Debug configuration require("eca").setup({ - debug = true, server_args = "--log-level debug", + log = { + level = vim.log.levels.DEBUG, + display = "split", + }, }) ``` +You can also use these debug commands to inspect the server state: +- `:EcaServerMessages` - View all server messages +- `:EcaServerTools` - View all registered tools + ## Connectivity issues Symptoms: Download fails, timeouts, network errors @@ -57,9 +64,22 @@ Solutions: ## Performance issues -Symptoms: Lag when typing, slow responses +Symptoms: Lag when typing, slow responses, slow streaming Solutions: - Reduce window width: `windows.width = 25` - Disable visual updates: `behavior.show_status_updates = false` +- Speed up or disable typewriter effect: + ```lua + windows = { + chat = { + typing = { + enabled = false, -- Instant display + -- OR + chars_per_tick = 10, -- Much faster typing + tick_delay = 1, + }, + }, + } + ``` - Use the minimalist configuration preset diff --git a/docs/usage.md b/docs/usage.md index 6abd51a..fef8a22 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,6 +2,18 @@ Everything you need to get productive with ECA inside Neovim. +## What's New + +Recent updates include: + +- **Expandable tool calls**: Click `Enter` on tool call headers to show/hide arguments, outputs, and diffs +- **Reasoning blocks**: See ECA's "thinking" process with expandable reasoning content +- **Typewriter effect**: Responses stream with a configurable typing animation (can be disabled) +- **Enhanced MCP display**: See active vs. registered MCP server counts with status indicators +- **Debug commands**: `:EcaServerMessages` and `:EcaServerTools` for inspecting server state +- **Better usage display**: Shortened token counts (e.g., "30k / 400k") with customizable format +- **Improved highlights**: New `EcaToolCall`, `EcaHyperlink`, and `EcaLabel` highlight groups + ## Quick Start 1. Install the plugin using any package manager @@ -30,6 +42,8 @@ Everything you need to get productive with ECA inside Neovim. | `:EcaServerStart` | Start ECA server manually | `:EcaServerStart` | | `:EcaServerStop` | Stop ECA server | `:EcaServerStop` | | `:EcaServerRestart` | Restart ECA server | `:EcaServerRestart` | +| `:EcaServerMessages` | Display server messages (for debugging) | `:EcaServerMessages` | +| `:EcaServerTools` | Display registered server tools | `:EcaServerTools` | | `:EcaSend ` | Send message directly (without opening chat) | `:EcaSend Explain this function` | Deprecated aliases (still available but log a warning): `:EcaAddFile`, `:EcaAddSelection`, `:EcaRemoveContext`, `:EcaListContexts`, `:EcaClearContexts`. Prefer the `:EcaChat*` variants above. @@ -52,6 +66,7 @@ Deprecated aliases (still available but log a warning): `:EcaAddFile`, `:EcaAddS |----------|--------|---------| | `Ctrl+S` | Send message | Insert/Normal mode | | `Enter` | New line | Insert mode | +| `Enter` (in chat buffer) | Toggle tool call/reasoning block | Normal mode on tool call or reasoning header | | `Esc` | Exit insert mode | Insert mode | --- @@ -62,7 +77,23 @@ Deprecated aliases (still available but log a warning): `:EcaAddFile`, `:EcaAddS - Type in the input line starting with `> ` - Press `Enter` to insert a new line - Press `Ctrl+S` to send -- Responses stream in real time +- Responses stream in real time with a typewriter effect (configurable) + +### Interacting with responses + +#### Tool calls +When ECA uses tools (like file editing), tool calls appear in the chat with: +- **Header line**: Shows tool name and status icon (⏳ running, βœ… success, ❌ error) +- **Expandable details**: Press `Enter` on the header to show/hide arguments and outputs +- **Diff view**: If a tool modifies files, a "view diff" label appears below the header. Press `Enter` on this label to expand/collapse the diff + +#### Reasoning blocks +When ECA is "thinking" (extended reasoning), you'll see: +- **"Thinking..."** label while reasoning is active +- **"Thought X.XX s"** label when complete, showing elapsed time +- Press `Enter` on the header to expand/collapse the reasoning content + +These blocks can be configured to start expanded or collapsed (see Configuration) ### Examples @@ -190,6 +221,27 @@ When typing paths directly with `@` to trigger completion, the input might brief After confirming a completion item, that `@...` reference is turned into a context entry and shown as a short label (for example `sidebar.lua `) in the context area. +--- + +## Model Context Protocol (MCP) Servers + +ECA supports MCP servers for extended functionality. The config display line at the bottom of the sidebar shows: + +``` +model: behavior: mcps: 2/3 +``` + +Where: +- The first number (2) is the count of **active MCPs** (starting + running) +- The second number (3) is the **total registered MCPs** + +**Status indicators**: +- Gray text: One or more MCPs are still starting +- Red text: One or more MCPs failed to start +- Normal text: All MCPs are running successfully + +Use `:EcaServerTools` to see which tools are available from your MCP servers. + ### Context completion and `@` / `#` path shortcuts Inside the input (filetype `eca-input`): @@ -280,6 +332,12 @@ are first expanded to absolute paths on the Neovim side (including `~` expansion " Start again :EcaServerStart + +" Debug: view server messages +:EcaServerMessages + +" Debug: view registered tools +:EcaServerTools ``` ### Quick commands @@ -297,6 +355,45 @@ are first expanded to absolute paths on the Neovim side (including `~` expansion --- +## Typewriter Effect + +ECA displays streaming responses with a configurable typewriter effect for a more natural reading experience. + +### Configuration + +```lua +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = true, -- Enable/disable typewriter effect + chars_per_tick = 1, -- Characters per tick (higher = faster) + tick_delay = 10, -- Delay in ms between ticks (lower = faster) + }, + }, + }, +}) +``` + +### Presets + +**Fast typing (2x speed)**: +```lua +typing = { enabled = true, chars_per_tick = 2, tick_delay = 5 } +``` + +**Slow/realistic typing**: +```lua +typing = { enabled = true, chars_per_tick = 1, tick_delay = 30 } +``` + +**Instant display (no effect)**: +```lua +typing = { enabled = false } +``` + +--- + ## Tips and Tricks ### Productivity diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 481511f..667f228 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -1,5 +1,6 @@ local Utils = require("eca.utils") local Logger = require("eca.logger") +local Picker = require("eca.ui.picker") local M = {} @@ -173,18 +174,10 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaServerMessages", function() - local has_snacks, snacks = pcall(require, "snacks") - if not has_snacks then - Logger.notify("snacks.nvim is not available", vim.log.levels.ERROR) - return - end - - snacks.picker( - ---@type snacks.picker.Config + Picker.pick( { source = "eca messages", - finder = function(opts, ctx) - ---@type snacks.picker.finder.Item[] + finder = function(opts, _) local items = {} local eca = require("eca") if not eca or not eca.server then @@ -192,17 +185,105 @@ function M.setup() return items end + -- First pass: collect messages so we can render them with + -- a fixed header width and keep the separator in a constant column. + local entries = {} + for msg in vim.iter(eca.server.messages) do - local decoded = vim.json.decode(msg.content) + local ok, decoded = pcall(vim.json.decode, msg.content) + if ok and type(decoded) == "table" then + local parts = {} + + if msg.direction then + table.insert(parts, string.format("[%s]", tostring(msg.direction))) + end + + if decoded.method then + table.insert(parts, tostring(decoded.method)) + end + + if decoded.id ~= nil then + table.insert(parts, string.format("#%s", tostring(decoded.id))) + end + + local header = table.concat(parts, " ") + local preview_text = vim.inspect(decoded) or "" + + -- Flatten whitespace so searching works on a single line + local flat_preview = "" + if preview_text ~= "" then + flat_preview = preview_text:gsub("%s+", " ") + end + + local json_text = "" + local ok_json, encoded = pcall(vim.json.encode, decoded) + if ok_json and type(encoded) == "string" and encoded ~= "" then + json_text = encoded + end + + table.insert(entries, { + header = header, + flat_preview = flat_preview, + preview_text = preview_text, + json_text = json_text, + id = decoded.id or msg.id, + }) + end + end + + if #entries == 0 then + return items + end + + -- Second pass: build display items with a fixed header width so that + -- the separator and body always start in the same column. + local separator = " | " + local header_width = 40 -- column where the header area ends + + for idx, entry in ipairs(entries) do + local header = entry.header or "" + local preview_text = entry.preview_text or "" + local flat_preview = entry.flat_preview or "" + + -- Truncate overly long headers so the separator stays fixed. + -- + -- NOTE: We truncate to exactly `header_width` characters so that the + -- padding logic below can reliably keep the separator aligned. + local display_header = header + if #display_header > header_width then + display_header = display_header:sub(1, header_width) + end + + -- Pad headers (or empty ones) up to header_width so the + -- first character of the separator is always in the same column. + local padding = math.max(0, header_width - #display_header) + local padded_header = display_header .. string.rep(" ", padding) + + local text = padded_header + if flat_preview ~= "" then + text = padded_header .. separator .. flat_preview + end + + if text == "" then + if preview_text ~= "" then + text = preview_text:gsub("%s+", " ") + elseif entry.json_text and entry.json_text ~= "" then + text = entry.json_text + else + text = "" + end + end + table.insert(items, { - text = decoded.method, - idx = decoded.id, + text = text, + idx = entry.id or idx, preview = { - text = vim.inspect(decoded), + text = preview_text, ft = "lua", }, }) end + return items end, preview = "preview", @@ -326,13 +407,12 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaFixTreesitter", function() - local Utils = require("eca.utils") - -- Emergency treesitter fix for chat buffer vim.schedule(function() local eca = require("eca") - if eca.sidebar and eca.sidebar.containers and eca.sidebar.containers.chat then - local bufnr = eca.sidebar.containers.chat.bufnr + local sidebar = eca.get() + if sidebar and sidebar.containers and sidebar.containers.chat then + local bufnr = sidebar.containers.chat.bufnr if bufnr and vim.api.nvim_buf_is_valid(bufnr) then -- Disable all highlighting for this buffer pcall(vim.api.nvim_set_option_value, "syntax", "off", { buf = bufnr }) @@ -402,6 +482,71 @@ function M.setup() desc = "Select current ECA Chat behavior", }) + vim.api.nvim_create_user_command("EcaServerTools", function() + Picker.pick( + { + source = "eca tools", + finder = function(_, _) + local items = {} + local eca = require("eca") + if not eca or not eca.state then + Logger.notify("ECA state is not available", vim.log.levels.ERROR) + return items + end + + local tools = eca.state.tools or {} + if not tools or vim.tbl_isempty(tools) then + Logger.notify("No tools registered in server state", vim.log.levels.INFO) + return items + end + + -- Collect and sort tool names for stable ordering + local names = vim.tbl_keys(tools) + table.sort(names) + + for _, name in ipairs(names) do + local tool = tools[name] or {} + + -- Build a human-readable preview string that always includes the + -- tool name and its primary "kind" field (when available), + -- followed by a full vim.inspect dump for debugging. + local preview_text + if next(tool) ~= nil then + local kind_value = tool.kind ~= nil and tostring(tool.kind) or "(unknown)" + preview_text = string.format("name: %s\nkind: %s", name, kind_value) + + local inspected = vim.inspect(tool) + if inspected and inspected ~= "" then + preview_text = preview_text .. "\n" .. inspected + end + else + preview_text = string.format("name: %s\n", name) + end + + table.insert(items, { + text = name, + idx = name, + preview = { + text = preview_text, + ft = "lua", + }, + }) + end + + return items + end, + preview = "preview", + format = "text", + confirm = function(self, item, _) + vim.fn.setreg("", item.preview.text) + self:close() + end, + } + ) + end, { + desc = "Display ECA server tools (yank preview on confirm)", + }) + Logger.debug("ECA commands registered") end diff --git a/lua/eca/config.lua b/lua/eca/config.lua index be7e300..397bccc 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -3,17 +3,8 @@ local M = {} ---@class eca.Config M._defaults = { - ---@type string server_path = "", -- Path to the ECA binary, will download automatically if empty - ---@type string server_args = "", -- Extra args for the eca start command - ---@type string - usage_string_format = "{messageCost} / {sessionCost}", - ---@class eca.LogConfig - ---@field display 'popup'|'split' - ---@field level integer - ---@field file string - ---@field max_file_size_mb number log = { display = "split", level = vim.log.levels.INFO, @@ -30,31 +21,11 @@ M._defaults = { context = { auto_repo_map = true, -- Automatically add repoMap context when starting new chat }, - todos = { - enabled = true, -- Enable todos functionality - max_height = 5, -- Maximum height for todos container - }, - selected_code = { - enabled = true, -- Enable selected code display - max_height = 8, -- Maximum height for selected code container - }, mappings = { chat = "ec", focus = "ef", toggle = "et", }, - chat = { - headers = { - user = "> ", - assistant = "", - }, - welcome = { - message = "", -- If non-empty, overrides server-provided welcome message - tips = { - "Type your message and use CTRL+s to send", -- Tips appended under the welcome (set empty list {} to disable) - }, - }, - }, windows = { wrap = true, width = 40, -- Window width as percentage (40 = 40% of screen width) @@ -72,6 +43,53 @@ M._defaults = { border = "rounded", start_insert = true, -- Start insert mode when opening the edit window }, + usage = { + --- Supported placeholders: + --- {session_tokens} - raw session token count (e.g. "30376") + --- {limit_tokens} - raw token limit (e.g. "400000") + --- {session_tokens_short} - shortened session tokens (e.g. "30k") + --- {limit_tokens_short} - shortened token limit (e.g. "400k") + --- {session_cost} - session cost (e.g. "0.09") + --- Default: "30k / 400k ($0.09)" -> "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + format = "{session_tokens_short} / {limit_tokens_short} (${session_cost})", + }, + chat = { + headers = { + user = "> ", + assistant = "", + }, + welcome = { + message = "", -- If non-empty, overrides server-provided welcome message + tips = { + "Type your message and use CTRL+s to send", -- Tips appended under the welcome (set empty list {} to disable) + }, + }, + typing = { + enabled = true, -- Enable typewriter effect for streaming responses + chars_per_tick = 1, -- Number of characters to display per tick (1 = realistic typing) + tick_delay = 10, -- Delay in milliseconds between ticks (lower = faster) + }, + tool_call = { + icons = { + success = "βœ…", -- Shown when a tool call succeeds + error = "❌", -- Shown when a tool call fails + running = "⏳", -- Shown while a tool call is running / has no final status yet + expanded = "β–Ό", -- Arrow when the tool call details are expanded + collapsed = "β–Ά", -- Arrow when the tool call details are collapsed + }, + diff = { + collapsed_label = "+ view diff", -- Label when the diff is collapsed + expanded_label = "- view diff", -- Label when the diff is expanded + expanded = false, -- When true, tool diffs start expanded + }, + preserve_cursor = true, -- When true, cursor position is preserved when expanding/collapsing + }, + reasoning = { + expanded = false, -- When true, "Thinking" blocks start expanded + running_label = "Thinking...", -- Label while reasoning is running + finished_label = "Thought", -- Base label when reasoning is finished + }, + }, }, } diff --git a/lua/eca/highlights.lua b/lua/eca/highlights.lua index b48c9ac..7377be7 100644 --- a/lua/eca/highlights.lua +++ b/lua/eca/highlights.lua @@ -12,6 +12,9 @@ function M.setup() vim.api.nvim_set_hl(0, "EcaSuccess", { fg = "#9ece6a", bg = "#2b3b2e" }) vim.api.nvim_set_hl(0, "EcaWarning", { fg = "#e0af68", bg = "#3d3a2b" }) vim.api.nvim_set_hl(0, "EcaInfo", { fg = "#7dcfff", bg = "#2b3a3d" }) + vim.api.nvim_set_hl(0, "EcaToolCall", { link = "Title" }) + vim.api.nvim_set_hl(0, "EcaHyperlink", { link = "Underlined", underline = true }) + vim.api.nvim_set_hl(0, "EcaLabel", { link = "Comment" }) end return M diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index d803a78..b64804b 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1,6 +1,7 @@ local Utils = require("eca.utils") local Logger = require("eca.logger") local Config = require("eca.config") +local StreamQueue = require("eca.stream_queue") -- Load nui.nvim components (required dependency) local Split = require("nui.split") @@ -25,20 +26,45 @@ local Split = require("nui.split") ---@field private _headers table Table of headers for the chat ---@field private _welcome_message_applied boolean Whether the welcome message has been applied ---@field private _contexts_placeholder_line string Placeholder line for contexts in input +---@field private _reasons table Map of in-flight reasoning entries keyed by id +---@field private _stream_queue eca.StreamQueue Queue for streaming text display +---@field private _stream_visible_buffer string Accumulated visible text during streaming local M = {} M.__index = M -- Height calculation constants -local MIN_CHAT_HEIGHT = 10 -- Minimum lines for chat container to remain usable -local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing +local MIN_CHAT_HEIGHT = 10 -- Minimum lines for chat container to remain usable +local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing local UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline -local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors +local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors + +local function _format_usage(tokens, limit, costs) + local usage_cfg = (Config.windows and Config.windows.usage) or {} + local fmt = usage_cfg.format + or Config.usage_string_format -- backwards compatibility + or "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + + local placeholders = { + session_tokens = tostring(tokens or 0), + limit_tokens = tostring(limit or 0), + session_tokens_short = Utils.shorten_tokens(tokens), + limit_tokens_short = Utils.shorten_tokens(limit), + session_cost = tostring(costs or "0.00"), + } + + local result = fmt:gsub("{(.-)}", function(key) + return placeholders[key] or "" + end) + + return result +end ---@param id integer Tab ID ---@param mediator eca.Mediator ---@return eca.Sidebar function M.new(id, mediator) + local chat_cfg = Utils.get_chat_config() local instance = setmetatable({}, M) instance.id = id instance.mediator = mediator @@ -57,12 +83,33 @@ function M.new(id, mediator) instance._response_start_time = 0 instance._max_response_length = 50000 -- 50KB max response instance._headers = { - user = (Config.chat and Config.chat.headers and Config.chat.headers.user) or "> ", - assistant = (Config.chat and Config.chat.headers and Config.chat.headers.assistant) or "", + user = (chat_cfg.headers and chat_cfg.headers.user) or "> ", + assistant = (chat_cfg.headers and chat_cfg.headers.assistant) or "", } instance._welcome_message_applied = false instance._contexts_placeholder_line = "" instance._contexts = {} + instance._tool_calls = {} + instance._reasons = {} + instance._stream_visible_buffer = "" + + -- Get typing configuration + local typing_cfg = chat_cfg.typing or {} + local typing_enabled = typing_cfg.enabled ~= false -- Default to true + local chars_per_tick = typing_enabled and (typing_cfg.chars_per_tick or 1) or 1000 -- Large number = instant + local tick_delay = typing_enabled and (typing_cfg.tick_delay or 10) or 0 + + -- Initialize stream queue with callback to update display + instance._stream_queue = StreamQueue.new(function(chunk, is_complete) + instance._stream_visible_buffer = (instance._stream_visible_buffer or "") .. chunk + instance:_update_streaming_message(instance._stream_visible_buffer) + end, { + chars_per_tick = chars_per_tick, + tick_delay = tick_delay, + should_continue = function() + return instance._is_streaming + end, + }) require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -190,6 +237,12 @@ function M:reset() self._welcome_message_applied = false self._contexts_placeholder_line = "" self._contexts = {} + self._tool_calls = {} + self._reasons = {} + self._stream_visible_buffer = "" + if self._stream_queue then + self._stream_queue:clear() + end end function M:new_chat() @@ -227,9 +280,9 @@ function M:_create_containers() -- Validate total height to prevent "Not enough room" error local total_height = chat_height - + input_height - + usage_height - + config_height + + input_height + + usage_height + + config_height -- Always calculate from total screen minus UI elements (more accurate than current window) local available_height = vim.o.lines - UI_ELEMENTS_HEIGHT @@ -339,7 +392,7 @@ function M:_create_containers() modifiable = false, }), win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:Comment", + winhighlight = "Normal:EcaLabel", statusline = " ", }), }) @@ -366,6 +419,8 @@ function M:_setup_container_events(container, name) if name == "input" then self:_setup_input_events(container) self:_setup_input_keymaps(container) + elseif name == "chat" then + self:_setup_chat_keymaps(container) end end @@ -469,7 +524,7 @@ function M:_setup_input_events(container) local contexts = self.mediator:contexts() local row, col = unpack(vim.api.nvim_win_get_cursor(container.winid)) - local context = contexts[col+1] + local context = contexts[col + 1] if row == 1 and context then self.mediator:remove_context(context) @@ -494,7 +549,6 @@ function M:_setup_input_events(container) self:_update_input_display() return end - end) end }) @@ -513,6 +567,15 @@ function M:_setup_input_keymaps(container) end, { noremap = true, silent = true }) end +---@private +---@param container NuiSplit +function M:_setup_chat_keymaps(container) + -- Toggle tool call details when pressing on a tool call line + container:map("n", "", function() + self:_toggle_tool_call_at_cursor() + end, { noremap = true, silent = true }) +end + ---@private function M:_update_container_sizes() if not self:is_open() then @@ -546,10 +609,10 @@ function M:get_chat_height() return math.max( MIN_CHAT_HEIGHT, total_height - - input_height - - usage_height - - WINDOW_MARGIN - - config_height + - input_height + - usage_height + - WINDOW_MARGIN + - config_height ) end @@ -765,7 +828,9 @@ function M:_update_input_display(opts) self.extmarks.contexts._ns, 0, i, - vim.tbl_extend("force", { virt_text = { { context_name, "Comment" } }, virt_text_pos = "inline", hl_mode = "replace" }, { id = self.extmarks.contexts._id[i] }) + vim.tbl_extend("force", + { virt_text = { { context_name, "EcaLabel" } }, virt_text_pos = "inline", hl_mode = "replace" }, + { id = self.extmarks.contexts._id[i] }) ) end @@ -792,13 +857,15 @@ function M:_update_input_display(opts) self.extmarks.prefix._ns, 1, 0, - vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, { id = self.extmarks.prefix._id[1] }) + vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, + { id = self.extmarks.prefix._id[1] }) ) -- Set cursor to end of input line if vim.api.nvim_win_is_valid(input.winid) then local row = 1 + ((not clear and existing_lines and #existing_lines > 0) and #existing_lines or 1) - local col = #prefix + ((not clear and existing_lines and #existing_lines > 0) and #existing_lines[#existing_lines] or 0) + local col = #prefix + + ((not clear and existing_lines and #existing_lines > 0) and #existing_lines[#existing_lines] or 0) vim.api.nvim_win_set_cursor(input.winid, { row, col }) end @@ -890,24 +957,45 @@ function M:_update_config_display() local behavior = self.mediator:selected_behavior() or "unknown" local mcps = self.mediator:mcps() - local mcps_hl = "Normal" + local registered_count = vim.tbl_count(mcps) + local starting_count = 0 + local running_count = 0 + local has_failed = false for _, mcp in pairs(mcps) do if mcp.status == "starting" then - mcps_hl = "Comment" - break + starting_count = starting_count + 1 + elseif mcp.status == "running" then + running_count = running_count + 1 end if mcp.status == "failed" then - mcps_hl = "Exception" - break + has_failed = true end end + -- Active MCPs include both starting and running + local active_count = starting_count + running_count + + -- While any MCP is still starting, dim the active count + local active_hl = "Normal" + if starting_count > 0 then + active_hl = "EcaLabel" + end + + local registered_hl = "Normal" + if has_failed then + registered_hl = "Exception" -- highlight registered count in red when any MCP failed + elseif active_hl == "EcaLabel" then + -- While MCPs are still starting, dim the total count as well + registered_hl = "EcaLabel" + end + local texts = { - { "model:", "Comment" }, { model, "Normal" }, { " " }, - { "behavior:", "Comment" }, { behavior, "Normal" }, { " " }, - { "mcps:", "Comment" }, { tostring(vim.tbl_count(mcps)), mcps_hl }, + { "model:", "EcaLabel" }, { model, "Normal" }, { " " }, + { "behavior:", "EcaLabel" }, { behavior, "Normal" }, { " " }, + { "mcps:", "EcaLabel" }, { tostring(active_count), active_hl }, { "/", "EcaLabel" }, + { tostring(registered_count), registered_hl }, } local virt_opts = { virt_text = texts, virt_text_pos = "overlay", hl_mode = "combine" } @@ -949,7 +1037,7 @@ function M:_update_usage_info() local costs = self.mediator:costs_session() or "0.00" self._current_status = string.format("%s", status_text) - self._usage_info = string.format("%d / %d (%s)", tokens, limit, costs) + self._usage_info = _format_usage(tokens, limit, costs) self.extmarks = self.extmarks or {} @@ -1002,7 +1090,8 @@ function M:_update_welcome_content() return end - local cfg = (Config.chat and Config.chat.welcome) or {} + local chat_cfg = Utils.get_chat_config() + local cfg = chat_cfg.welcome or {} 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) @@ -1128,39 +1217,160 @@ function M:handle_chat_content_received(params) -- Show the accumulated tool call self:_display_tool_call(content) elseif content.type == "toolCalled" then - local tool_text = (content.summary or "Tool call") + local tool_text = self:_tool_call_text(content) + + -- If this tool call reports a file change, append the basename of the + -- path to the summary shown in the chat so users can immediately see + -- which file was touched. + local details = content.details + if details and type(details) == "table" and details.type == "fileChange" then + local path = details.path + if path and path ~= "" then + local filename = vim.fn.fnamemodify(path, ":t") + if filename and filename ~= "" then + -- Avoid duplicating the filename if it is already present + if tool_text and tool_text ~= "" then + if not string.find(tool_text, filename, 1, true) then + tool_text = string.format("%s %s", tool_text, filename) + end + else + tool_text = filename + end + end + end + end -- Add diff to current tool call if present in toolCalled content if self._current_tool_call and content.details then self._current_tool_call.details = content.details end - -- Show the tool result + -- Show the tool result in logs only local tool_log = string.format("**Tool Result**: %s", content.name or "unknown") + local outputs_text = nil + local outputs_type = nil if content.outputs and #content.outputs > 0 then + local pieces = {} for _, output in ipairs(content.outputs) do - if output.type == "text" and output.content then - tool_log = tool_log .. "\n" .. output.content + if output.type == "text" then + local txt = output.text or output.content + if txt and txt ~= "" then + table.insert(pieces, txt) + tool_log = tool_log .. "\n" .. txt + outputs_type = outputs_type or output.type + end + else + -- Even if we don't render non-text payloads directly, remember + -- their reported type so that any displayed block can still + -- use an appropriate fence language. + outputs_type = outputs_type or output.type end end + if #pieces > 0 then + outputs_text = table.concat(pieces, "\n") + end end Logger.debug(tool_log) - local tool_text_completed = "βœ… " - + -- Determine completion status icon (configurable) + local icons = Utils.get_tool_call_icons() + local status_icon = icons.success if content.error then - tool_text_completed = "❌ " + status_icon = icons.error end - local tool_text_running = "πŸ”§ " .. tool_text - tool_text_completed = tool_text_completed .. tool_text + -- Ensure tool calls table exists + self._tool_calls = self._tool_calls or {} + + -- Try to find an existing tool call entry for this id + local call = self:_find_tool_call_by_id(content.id) + + if call then + -- Update details and status for an existing call + if content.details then + call.details = content.details + call.has_diff = self:_has_details_diff(call.details) + call.diff_lines = self:_build_tool_call_diff_lines(call.details) + call.details_lines = nil + call.details_line_count = 0 + + -- If this call now has a diff and doesn't yet have a label line, add it + if call.has_diff and not call.label_line and not call.expanded then + self:_insert_tool_call_diff_label_line(call) + if Utils.should_start_diff_expanded() then + self:_expand_tool_call_diff(call) + end + end + end + + -- Always refresh stored outputs/argument lines when we get a final toolCalled event + if outputs_text and outputs_text ~= "" then + call.outputs = outputs_text + call.outputs_type = outputs_type or call.outputs_type + end + call.arguments_lines = self:_build_tool_call_arguments_lines(call.arguments, call.outputs, call.outputs_type) + + call.status = status_icon + call.title = tool_text or call.title + + -- Update the header line to move the checkmark/error icon to the end + self:_update_tool_call_header_line(call) + else + -- Create a new entry for tool calls that didn't have a running phase + local details = content.details or {} + local arguments = self._current_tool_call and self._current_tool_call.arguments or "" + local outputs = outputs_text or "" + local outputs_type_value = outputs_type + local arguments_lines = self:_build_tool_call_arguments_lines(arguments, outputs, outputs_type_value) + local diff_lines = self:_build_tool_call_diff_lines(details) + local has_diff = self:_has_details_diff(details) + + call = { + id = content.id, + title = tool_text or (content.name or "Tool call"), + header_line = nil, + expanded = false, -- controls argument visibility + diff_expanded = false, -- controls diff visibility + status = status_icon, + arguments = arguments, + details = details, + outputs = outputs, + outputs_type = outputs_type_value, + has_diff = has_diff, + label_line = nil, + -- Separate storage for arguments vs diff so each can be toggled independently + arguments_lines = arguments_lines, + diff_lines = diff_lines, + details_lines = nil, + details_line_count = 0, + } + + local chat = self.containers.chat + if chat and vim.api.nvim_buf_is_valid(chat.bufnr) then + local before_line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local header_text = self:_build_tool_call_header_text(call) + self:_add_message("assistant", header_text) + call.header_line = before_line_count + 1 + + if call.has_diff then + self:_insert_tool_call_diff_label_line(call) + if Utils.should_start_diff_expanded() then + self:_expand_tool_call_diff(call) + end + end - if tool_text == nil or not self:_replace_text(tool_text_running, tool_text_completed) then - self:_add_message("assistant", tool_text_completed) + table.insert(self._tool_calls, call) + end end -- Clean up tool call state self:_finalize_tool_call() + elseif content.type == "reasonStarted" then + self:_handle_reason_started(content) + elseif content.type == "reasonText" then + self:_handle_reason_text(content) + elseif content.type == "reasonFinished" then + self:_handle_reason_finished(content) end end @@ -1191,9 +1401,13 @@ function M:_handle_streaming_text(text) if not self._is_streaming then Logger.debug("Starting streaming response") - -- Start streaming - simple and direct + -- Start streaming with the stream queue self._is_streaming = true self._current_response_buffer = "" + self._stream_visible_buffer = "" + if self._stream_queue then + self._stream_queue:clear() + end -- Determine insertion point before adding placeholder (works even with empty header) local chat = self.containers.chat @@ -1221,13 +1435,16 @@ function M:_handle_streaming_text(text) end end - -- Simple accumulation - no complex checks + -- Accumulate the full response for finalization and history self._current_response_buffer = (self._current_response_buffer or "") .. text + -- Enqueue new text to be rendered gradually + if self._stream_queue then + self._stream_queue:enqueue(text) + end - Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars") - - -- Update the assistant's message in place - self:_update_streaming_message(self._current_response_buffer) + Logger.debug("DEBUG: Buffer now has " .. + #self._current_response_buffer .. + " chars (queue size: " .. (self._stream_queue and self._stream_queue:size() or 0) .. ")") end ---@param content string @@ -1245,7 +1462,13 @@ function M:_update_streaming_message(content) return end - -- Simple and direct buffer update + -- Capture the current chat view so we only auto-scroll if the user was + -- already at (or very near) the bottom, and hasn't moved since. + local anchor = self:_capture_chat_view_state() + + -- Simple and direct buffer update that only rewrites the assistant's + -- own streaming region. This avoids clobbering content that may have + -- been appended after it (e.g. tool calls or reasoning blocks). local success, err = pcall(function() -- Make buffer modifiable vim.api.nvim_set_option_value("modifiable", true, { buf = chat.bufnr }) @@ -1260,7 +1483,8 @@ function M:_update_streaming_message(content) -- Resolve assistant start line using extmark if available local start_line = 0 if self.extmarks and self.extmarks.assistant and self.extmarks.assistant._id then - local pos = vim.api.nvim_buf_get_extmark_by_id(chat.bufnr, self.extmarks.assistant._ns, self.extmarks.assistant._id, {}) + local pos = vim.api.nvim_buf_get_extmark_by_id(chat.bufnr, self.extmarks.assistant._ns, self.extmarks.assistant + ._id, {}) if pos and pos[1] then start_line = pos[1] + 1 end @@ -1307,8 +1531,11 @@ function M:_update_streaming_message(content) if not success then Logger.notify("Error updating buffer: " .. tostring(err), vim.log.levels.ERROR) else - -- Auto-scroll to bottom during streaming to follow the text - self:_scroll_to_bottom() + -- Reapply highlights for existing tool calls and reasoning blocks, + -- since full-buffer updates can drop extmark-based styling. + self:_reapply_tool_call_highlights() + -- Auto-scroll only if the user was already at the bottom. + self:_scroll_to_bottom({ anchor = anchor }) end end @@ -1320,6 +1547,12 @@ function M:_add_message(role, content) return end + -- Capture the current chat view before appending. We'll only auto-scroll if: + -- - this is a user message (force scroll), or + -- - the user was already at the bottom and hasn't moved since. + local anchor = self:_capture_chat_view_state() + local force_scroll = role == "user" + self:_safe_buffer_update(chat.bufnr, function() local lines = vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false) local header = "" @@ -1338,12 +1571,12 @@ function M:_add_message(role, content) -- Check if content looks like code (starts with common programming patterns) local is_code = content:match("^%s*function") - or content:match("^%s*class") - or content:match("^%s*def ") - or content:match("^%s*import") - or content:match("^%s*#include") - or content:match("^%s*<%?") - or content:match("^%s*= math.max(1, line_count - threshold), + -- Keep a view-based "at bottom" as well, mainly for debugging/telemetry. + at_bottom = bottomline >= math.max(1, line_count - threshold), + } + end) +end + +---Auto-scroll to bottom of the chat. +--- +---By default, we only scroll if the user was already at (or very near) the bottom. +---Pass `{ force = true }` to always scroll (e.g. after sending a user message). +---@param opts? { force?: boolean, anchor?: table } +function M:_scroll_to_bottom(opts) + opts = opts or {} + + local chat = self.containers.chat + if not chat or not vim.api.nvim_win_is_valid(chat.winid) or not vim.api.nvim_buf_is_valid(chat.bufnr) then return end - -- Get total number of lines in buffer - local line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local anchor = opts.anchor + -- Scroll if: + -- - explicitly forced (e.g. user just sent a message), OR + -- - the chat window is NOT focused (user isn't interacting with it), OR + -- - the chat window is focused AND the user is already at the bottom. + local should_scroll = opts.force == true + or (anchor == nil) + or (anchor and (not anchor.is_current or anchor.cursor_at_bottom)) - -- Set cursor to the last line and scroll to bottom - vim.defer_fn(function() - if vim.api.nvim_win_is_valid(chat.winid) and vim.api.nvim_buf_is_valid(chat.bufnr) then - -- Refresh line count in case it changed - local current_line_count = vim.api.nvim_buf_line_count(chat.bufnr) - -- Set cursor to last line - vim.api.nvim_win_set_cursor(chat.winid, { current_line_count, 0 }) - -- Ensure the last line is visible - vim.api.nvim_win_call(chat.winid, function() - vim.cmd("normal! zb") -- scroll so cursor line is at bottom of window + if not should_scroll then + return + end + + -- Defer to the next scheduler tick so any buffer updates have been applied. + vim.schedule(function() + local chat2 = self.containers.chat + if not chat2 or not vim.api.nvim_win_is_valid(chat2.winid) or not vim.api.nvim_buf_is_valid(chat2.bufnr) then + return + end + + -- If the chat window was focused at capture time, only scroll if the user + -- hasn't moved the chat view since we captured `anchor`. + if opts.force ~= true and anchor and anchor.is_current and anchor.view then + local unchanged = vim.api.nvim_win_call(chat2.winid, function() + local view = vim.fn.winsaveview() + return view.topline == anchor.view.topline and view.lnum == anchor.view.lnum end) + if not unchanged then + return + end + end + + local current_line_count = vim.api.nvim_buf_line_count(chat2.bufnr) + if current_line_count < 1 then + current_line_count = 1 end - end, 10) -- Reduced delay for faster streaming response + + -- Set cursor to last line and scroll to bottom + vim.api.nvim_win_set_cursor(chat2.winid, { current_line_count, 0 }) + vim.api.nvim_win_call(chat2.winid, function() + vim.cmd("normal! zb") -- scroll so cursor line is at bottom of window + end) + end) end ---@param bufnr integer @@ -1485,14 +1805,20 @@ function M:_handle_tool_call_prepare(content) if not self._is_tool_call_streaming then self._is_tool_call_streaming = true self._current_tool_call = { + id = content.id, name = "", summary = "", arguments = "", details = {}, + outputs = "", } end -- Accumulate tool call data + if content.id then + self._current_tool_call.id = content.id + end + if content.name then self._current_tool_call.name = content.name end @@ -1510,88 +1836,1272 @@ function M:_handle_tool_call_prepare(content) end end +---Get the best display text for a tool call based on available data. +---Prefers the current content's summary, then the stored summary, then name fields, +---and finally falls back to a generic label if none are available. +---@param content table Tool call content from the server, possibly containing summary and name. +---@return string label The chosen text to display for the tool call. +function M:_tool_call_text(content) + if content.summary and content.summary ~= "" then + return content.summary + end + + if self._current_tool_call and self._current_tool_call.summary and self._current_tool_call.summary ~= "" then + return self._current_tool_call.summary + end + + if content.name and content.name ~= "" then + return content.name + end + + if self._current_tool_call and self._current_tool_call.name and self._current_tool_call.name ~= "" then + return self._current_tool_call.name + end + + return "Tool call" +end + +--- Displays the current streaming tool call in the chat container. +--- Uses the accumulated tool call state and the provided content +--- to build a formatted log entry (including arguments and diff, if present) +--- and appends it to the chat buffer. +---@param content table Tool call update payload (e.g. id, name, summary, argumentsText, details). +---@return nil function M:_display_tool_call(content) if not self._is_tool_call_streaming or not self._current_tool_call then return nil end - local diff = "" - local tool_text = "πŸ”§ " .. (content.summary or self._current_tool_call.summary or "Tool call") - local tool_log = string.format("**Tool Call**: %s", self._current_tool_call.name or "unknown") + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return nil + end + + local tool_name = self:_tool_call_text(content) + local tool_log = string.format("**Tool Call**: %s", tool_name or "unknown") if self._current_tool_call.arguments and self._current_tool_call.arguments ~= "" then tool_log = tool_log .. "\n```json\n" .. self._current_tool_call.arguments .. "\n```" end if self._current_tool_call.details and self._current_tool_call.details.diff then - diff = "\n\n**Diff**:\n```diff\n" .. self._current_tool_call.details.diff .. "\n```" + tool_log = tool_log .. "\n\n**Diff**:\n```diff\n" .. self._current_tool_call.details.diff .. "\n```" + end + + Logger.debug(tool_log) + + -- Ensure tool calls table exists + self._tool_calls = self._tool_calls or {} + + -- Try to find an existing entry for this tool call + local existing_call = nil + if self._current_tool_call.id then + existing_call = self:_find_tool_call_by_id(self._current_tool_call.id) + end + + -- Build detail lines from current state (arguments and diff are controlled separately) + local arguments_lines = self:_build_tool_call_arguments_lines( + self._current_tool_call.arguments, + self._current_tool_call.outputs, + self._current_tool_call.outputs_type + ) + local diff_lines = self:_build_tool_call_diff_lines(self._current_tool_call.details) + local has_diff = self:_has_details_diff(self._current_tool_call.details) + + if existing_call then + -- Update details for existing call (do not add another header) + existing_call.arguments = self._current_tool_call.arguments or existing_call.arguments + existing_call.details = self._current_tool_call.details or existing_call.details + existing_call.has_diff = has_diff + existing_call.arguments_lines = arguments_lines + existing_call.diff_lines = diff_lines + existing_call.details_lines = nil + existing_call.details_line_count = 0 + + -- Reset diff visibility when we get new diff content + if not has_diff then + existing_call.diff_expanded = false + end + + -- If this call now has a diff and doesn't yet have a label line, add it + if has_diff and not existing_call.label_line and not existing_call.expanded then + self:_insert_tool_call_diff_label_line(existing_call) + end + + return + end + + -- Create a new tool call entry and header (collapsed by default) + local header_title = tool_name or "Tool call" + local call = { + id = self._current_tool_call.id or content.id, + title = header_title, + header_line = nil, + expanded = false, -- controls argument visibility + diff_expanded = false, -- controls diff visibility + status = nil, + arguments = self._current_tool_call.arguments or "", + details = self._current_tool_call.details or {}, + outputs = self._current_tool_call.outputs or "", + has_diff = has_diff, + label_line = nil, + -- Store arguments and diff lines separately so they can be toggled independently + arguments_lines = arguments_lines, + diff_lines = diff_lines, + details_lines = nil, + details_line_count = 0, + } + + local before_line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local header_text = self:_build_tool_call_header_text(call) + self:_add_message("assistant", header_text) + call.header_line = before_line_count + 1 + + -- Apply header highlight (tool call vs reasoning) + self:_highlight_tool_call_header(call) + + if call.has_diff then + self:_insert_tool_call_diff_label_line(call) + if Utils.should_start_diff_expanded() then + self:_expand_tool_call_diff(call) + end end - Logger.debug(tool_log .. diff) - self:_add_message("assistant", tool_text .. diff) + table.insert(self._tool_calls, call) end +---Finalize the currently streaming tool call. +--- +---Clears internal streaming state so subsequent events are not appended to the +---previous tool call. function M:_finalize_tool_call() self._current_tool_call = nil self._is_tool_call_streaming = false end ----@param target string ----@param replacement string ----@param opts? table|nil Optional search options: { max_search_lines = number, start_line = number } ----@return boolean changed True if any replacement was made -function M:_replace_text(target, replacement, opts) +-- ===== Reasoning ("Thinking") handling ===== + +---Handle the start of a streamed reasoning ("Thinking") block. +--- +---Creates a pseudo tool-call entry stored in `self._reasons` and `self._tool_calls`, +---inserts a header line into the chat buffer, and applies header highlighting. +---@param content table Event payload containing at least `id` (string). +function M:_handle_reason_started(content) + local id = content.id + if not id then + return + end + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + self._reasons = self._reasons or {} + self._tool_calls = self._tool_calls or {} + + -- If a new reasoning starts while another one is still "running", + -- mark the previous one as finished so only one active "Thinking" + -- block is shown at a time. + local labels = Utils.get_reasoning_labels() + local running_label = labels.running + local finished_label = labels.finished + + for existing_id, existing_call in pairs(self._reasons) do + if existing_id ~= id + and existing_call + and existing_call.status == nil + -- Only auto-convert entries that are *currently* showing + -- the running label, so we don't clobber completed + -- entries like "Thought 1.23 s" when a new reasoning + -- block starts later. + and existing_call.title == running_label then + -- For reasoning entries we don't show status icons; instead we just + -- update the label from the running label to the finished label. + existing_call.title = finished_label + existing_call.status = nil + self:_update_tool_call_header_line(existing_call) + end + end + + -- Avoid creating duplicates for the same reasoning id + if self._reasons[id] then + return + end + + -- Whether "Thinking" blocks should start expanded by default + -- Use the merged chat config so both legacy `chat.reasoning` and + -- modern `windows.chat.reasoning` can control this behavior. + local chat_cfg = Utils.get_chat_config() + local reasoning_cfg = chat_cfg.reasoning or {} + local expand = reasoning_cfg.expanded == true + + local call = { + id = id, + title = running_label, -- summary label while reasoning is running + header_line = nil, + expanded = expand, -- controls visibility of reasoning text + diff_expanded = false, -- unused for reasoning + status = nil, -- unused for reasoning headers; no status icons + arguments = "", -- we reuse arguments as the accumulated reasoning text + details = {}, + has_diff = false, + label_line = nil, + arguments_lines = {}, + diff_lines = {}, + details_lines = nil, + details_line_count = 0, + is_reason = true, + } + + local before_line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local header_text = self:_build_tool_call_header_text(call) + self:_add_message("assistant", header_text) + call.header_line = before_line_count + 1 + + -- Apply header highlight (reasoning entries use Comment) + self:_highlight_tool_call_header(call) + + -- Track this reasoning both in a dedicated map and in the generic tool_calls + self._reasons[id] = call + table.insert(self._tool_calls, call) +end +---Handle streamed reasoning text for a previously started reasoning block. +--- +---Appends `content.text` to the stored reasoning body. If the block is currently +---expanded in the chat buffer, updates the in-buffer region in-place and shifts +---subsequent tool-call line markers accordingly. +---@param content table Event payload containing `id` (string) and `text` (string). +function M:_handle_reason_text(content) + local id = content.id + if not id or not content.text then + return + end + + self._reasons = self._reasons or {} + local call = self._reasons[id] + if not call then + -- If we somehow receive text without a start, create the entry lazily + self:_handle_reason_started({ id = id }) + call = self._reasons[id] + if not call then + return + end + end + + local chat = self.containers.chat if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then - Logger.warn("Cannot replace message: chat buffer not available") - return false + return end - if not target or target == "" then - Logger.warn("Cannot replace message: empty target") - return false + -- Accumulate raw reasoning text + call.arguments = (call.arguments or "") .. content.text + + -- Build plain text block for the reasoning body (no markdown quote prefix). + -- We keep the first inserted line as the first line of reasoning text rather + -- than a blank spacer so that navigation from the header lands on the actual content. + call.arguments_lines = {} + + local lines = Utils.split_lines(call.arguments or "") + for _, line in ipairs(lines) do + line = line ~= "" and line or " " + table.insert(call.arguments_lines, line) + end + + -- If the reasoning block is expanded, update its region in the buffer in-place + if call.expanded then + local prev_count = call._last_arguments_count or 0 + local new_count = #call.arguments_lines + + self:_safe_buffer_update(chat.bufnr, function() + if prev_count == 0 and new_count > 0 then + -- Insert for the first time immediately after the header + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.arguments_lines) + -- Shift subsequent tool calls down by the inserted line count + self:_adjust_tool_call_lines(call, new_count) + else + -- Replace existing block; adjust subsequent calls only if size changed + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line + prev_count, false, + call.arguments_lines) + if new_count ~= prev_count then + self:_adjust_tool_call_lines(call, new_count - prev_count) + end + end + end) + + call._last_arguments_count = new_count end - if not replacement or replacement == "" then - Logger.warn("Cannot replace message: empty replacement") - return false + -- Ensure the header arrow reflects whether there is body content to show. + -- This will add the expand/collapse icon once we have streamed some + -- reasoning text, and it will be omitted while the body is still empty. + self:_update_tool_call_header_line(call) +end + +---Finalize a reasoning ("Thinking") block. +--- +---Updates the header label to the finished label (optionally including the +---elapsed time reported by the model) and refreshes the header line in the chat. +---@param content table Event payload containing `id` (string) and optional `totalTimeMs`. +function M:_handle_reason_finished(content) + local id = content.id + if not id then + return end - local changed = false + self._reasons = self._reasons or {} + local call = self._reasons[id] + if not call then + return + end - self:_safe_buffer_update(chat.bufnr, function() - local total_lines = vim.api.nvim_buf_line_count(chat.bufnr) - opts = opts or {} + local labels = Utils.get_reasoning_labels() + local finished_label = labels.finished - -- Limit how many lines to search for performance with large buffers - local max_search_lines = tonumber(opts.max_search_lines) or 500 + -- When reasoning is finished, update the label from running to a + -- finished label, appending the total time in seconds when available. + local total_ms = tonumber(content.totalTimeMs) + if total_ms and total_ms > 0 then + local seconds = total_ms / 1000 + call.title = string.format("%s %.2f s", finished_label, seconds) + else + call.title = finished_label + end - -- If a start line is provided, start searching from there (useful for targeted replacement) - local start_line = tonumber(opts.start_line) or total_lines - if start_line < 1 then - start_line = 1 + call.status = nil + self:_update_tool_call_header_line(call) +end + +---Find an existing tool-call/reasoning entry by id. +---@param id string|nil Tool call/reasoning identifier. +---@return table|nil call The matching call entry from `self._tool_calls`, if any. +function M:_find_tool_call_by_id(id) + if not self._tool_calls or not id then + return nil + end + + for _, call in ipairs(self._tool_calls) do + if call.id == id then + return call end - if start_line > total_lines then - start_line = total_lines + end + + return nil +end + +---Find the tool-call/reasoning entry that owns a given 1-based buffer line. +--- +---This checks the header line as well as any expanded argument/reasoning body and +---optional diff sections. +---@param line integer 1-based line number in the chat buffer. +---@return table|nil call The call entry covering `line`, if any. +function M:_find_tool_call_for_line(line) + if not self._tool_calls then + return nil + end + + for _, call in ipairs(self._tool_calls) do + if call.header_line then + local start_line = call.header_line + local end_line = start_line + + -- Include argument block when expanded + local arg_count = call.arguments_lines and #call.arguments_lines or 0 + if call.expanded and arg_count > 0 then + end_line = end_line + arg_count + end + + -- Include label line and optional diff block when present + if call.has_diff and call.label_line then + local label_end = call.label_line + local diff_count = call.diff_lines and #call.diff_lines or 0 + if call.diff_expanded and diff_count > 0 then + label_end = label_end + diff_count + end + if label_end > end_line then + end_line = label_end + end + end + + if line >= start_line and line <= end_line then + return call + end end + end - -- Determine the search window [end_line, start_line] - local end_line = math.max(1, start_line - max_search_lines + 1) + return nil +end - -- Fetch only the relevant range once (0-based indices for nvim API) - local range_lines = vim.api.nvim_buf_get_lines(chat.bufnr, end_line - 1, start_line, false) +---Build the header text displayed for a tool call/reasoning entry. +--- +---For regular tool calls this includes an expand/collapse arrow, a title, and a +---status icon. For reasoning entries (`call.is_reason`), this may omit the arrow +---until the body has content, and never shows a status icon. +---@param call table Tool-call/reasoning entry. +---@return string header_text +function M:_build_tool_call_header_text(call) + local icons = Utils.get_tool_call_icons() + + -- Reasoning ("Thinking") entries do not show status icons; they only + -- display a toggle arrow (when there is body content to show) and a + -- text label ("Thinking..." / "Thought"). + if call.is_reason then + local title = call.title or "Thinking..." + if type(title) ~= "string" then + title = tostring(title) + end - -- Iterate from bottom to top within the range - for idx = #range_lines, 1, -1 do - local line = range_lines[idx] or "" - local s_idx, e_idx = line:find(target, 1, true) - if s_idx then - local new_line = (line:sub(1, s_idx - 1)) .. replacement .. (line:sub(e_idx + 1)) - local absolute_line = end_line + idx - 1 -- convert to absolute 1-based line - vim.api.nvim_buf_set_lines(chat.bufnr, absolute_line - 1, absolute_line, false, { new_line }) - changed = true - break + -- Only show the expand/collapse arrow once we actually have some + -- reasoning text to display. This avoids showing a useless toggle + -- while the model is still preparing its thoughts. + local has_body = false + if call.arguments and type(call.arguments) == "string" and call.arguments:match("%S") then + has_body = true + elseif call.arguments_lines and #call.arguments_lines > 0 then + has_body = true + end + + if not has_body then + return title + end + + local arrow = call.expanded and icons.expanded or icons.collapsed + if type(arrow) ~= "string" then + arrow = tostring(arrow) + end + + return table.concat({ arrow, title }, " ") + end + + -- Regular tool calls always show an expand/collapse arrow. The arrow + -- controls the visibility of the arguments block; any diff is toggled + -- separately via the "view diff" label. + local arrow = call.expanded and icons.expanded or icons.collapsed + + -- Normalize all pieces to strings to avoid issues when configuration or + -- status fields accidentally contain non-string values (e.g. userdata). + if type(arrow) ~= "string" then + arrow = tostring(arrow) + end + + local title = call.title or "Tool call" + local status = call.status or icons.running + + if type(title) ~= "string" then + title = tostring(title) + end + if type(status) ~= "string" then + status = tostring(status) + end + + local parts = { arrow, title, status } + + return table.concat(parts, " ") +end + +---Build the argument/output detail lines for a tool call. +--- +---Arguments and outputs are rendered as fenced code blocks. The output fence +---language is derived from `outputs_type` when available. +---@param arguments string|nil JSON-encoded arguments. +---@param outputs string|nil Tool output (often JSON, sometimes plain text). +---@param outputs_type string|nil Reported output type/language (e.g. "json", "text"). +---@return string[] lines Buffer lines to insert under the tool call header. +function M:_build_tool_call_arguments_lines(arguments, outputs, outputs_type) + local lines = {} + local has_content = false + + if arguments and arguments ~= "" then + table.insert(lines, "Arguments:") + table.insert(lines, "```json") + for _, line in ipairs(Utils.split_lines(arguments)) do + table.insert(lines, line) + end + table.insert(lines, "```") + table.insert(lines, "") + has_content = true + end + + if outputs and outputs ~= "" then + if not has_content then + -- Add spacer only if this is the first section + table.insert(lines, "") + end + + table.insert(lines, "Output:") + + -- Choose fence language based on reported output type. When the tool + -- says the output type is "text", render it as a plain text fence + -- instead of JSON. For unknown types we keep using "json" for + -- backwards compatibility. + local lang = "json" + if type(outputs_type) == "string" and outputs_type ~= "" then + if outputs_type == "text" then + lang = "text" + else + lang = outputs_type + end + end + + table.insert(lines, "```" .. lang) + for _, line in ipairs(Utils.split_lines(outputs)) do + table.insert(lines, line) + end + table.insert(lines, "```") + table.insert(lines, "") + end + + -- Remove any trailing blank lines; we keep internal spacing (for example + -- between the Arguments and Output sections) intact. + while #lines > 0 and lines[#lines]:match("^%s*$") do + table.remove(lines) + end + + return lines +end + +---Build the diff detail lines for a tool call. +--- +---If `details.diff` exists, returns a `diff` fenced code block; otherwise returns +---an empty list. +---@param details table|nil Tool-call details table (may include `diff`). +---@return string[] lines +function M:_build_tool_call_diff_lines(details) + local lines = {} + + local diff = details and details.diff or nil + if diff and diff ~= "" then + -- Start the diff block with the fenced header (no leading newline) + table.insert(lines, "```diff") + for _, line in ipairs(Utils.split_lines(diff)) do + table.insert(lines, line) + end + table.insert(lines, "```") + end + + -- Remove any trailing blank lines just in case + while #lines > 0 and lines[#lines]:match("^%s*$") do + table.remove(lines) + end + + return lines +end + +---Build combined tool-call detail lines (arguments/output + diff). +--- +---NOTE: Callers that need independent control over arguments vs diff expansion +---should prefer `_build_tool_call_arguments_lines` and `_build_tool_call_diff_lines`. +---@param arguments string|nil +---@param details table|nil +---@param outputs string|nil +---@param outputs_type string|nil +---@return string[] lines +function M:_build_tool_call_details_lines(arguments, details, outputs, outputs_type) + local lines = {} + + local arg_lines = self:_build_tool_call_arguments_lines(arguments, outputs, outputs_type) + for _, line in ipairs(arg_lines) do + table.insert(lines, line) + end + + local diff_lines = self:_build_tool_call_diff_lines(details) + for _, line in ipairs(diff_lines) do + table.insert(lines, line) + end + + return lines +end + +---Check whether a tool-call details table contains a non-empty diff. +---@param details table|nil +---@return boolean has_diff +function M:_has_details_diff(details) + return details and type(details) == "table" and details.diff and details.diff ~= "" +end + +---Build the label text shown below a tool call header when a diff is available. +--- +---The label varies based on whether the diff block is currently expanded. +---@param call table Tool-call entry. +---@return string label_text +function M:_build_tool_call_diff_label_text(call) + local labels = Utils.get_tool_call_diff_labels() + + -- Use different texts depending on diff expanded/collapsed state + if call and call.diff_expanded then + return labels.expanded + end + + return labels.collapsed +end + +---Choose a sidebar highlight group for a given UI element. +---@param kind "tool_header"|"reason_header"|"diff_label"|string +---@return string hl_group +local function _eca_sidebar_hl(kind) + if kind == "tool_header" then + return "EcaToolCall" + elseif kind == "reason_header" then + return "EcaLabel" + elseif kind == "diff_label" then + return "EcaHyperlink" + end + + return "Normal" +end + +---Highlight a tool call header line (summary / reasoning label). +--- +-- * Regular tool calls use the EcaToolCall highlight group. +-- * Reasoning entries ("Thinking..." / "Thought ...") use the EcaLabel +-- highlight group, which links to Comment in highlights.lua and adapts +-- to light/dark themes; selection is handled via _eca_sidebar_hl(). +---@param call table Tool-call/reasoning entry with `header_line` populated. +function M:_highlight_tool_call_header(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.header_line then + return + end + + -- Guard against stale header_line values that point past the end of the + -- buffer (for example after streaming updates that rewrote the chat). + local line_count = vim.api.nvim_buf_line_count(chat.bufnr) + if call.header_line < 1 or call.header_line > line_count then + return + end + + self.extmarks = self.extmarks or {} + if not self.extmarks.tool_header then + self.extmarks.tool_header = { + _ns = vim.api.nvim_create_namespace("extmarks_tool_header"), + _id = {}, + } + end + + local ns = self.extmarks.tool_header._ns + local key = call.id or tostring(call.header_line) + + -- Clear any previous highlight for this call + if self.extmarks.tool_header._id[key] then + pcall(vim.api.nvim_buf_del_extmark, chat.bufnr, ns, self.extmarks.tool_header._id[key]) + end + + local line = vim.api.nvim_buf_get_lines(chat.bufnr, call.header_line - 1, call.header_line, false)[1] or "" + local end_col = #line + + local hl_group + if call.is_reason then + hl_group = _eca_sidebar_hl("reason_header") + else + hl_group = _eca_sidebar_hl("tool_header") + end + + -- Add an extmark that highlights the entire header line + self.extmarks.tool_header._id[key] = vim.api.nvim_buf_set_extmark(chat.bufnr, ns, call.header_line - 1, 0, { + hl_group = hl_group, + end_col = end_col, + priority = 180, + }) +end + +---Highlight the "view diff" label line for a tool call. +--- +---Uses an extmark so the highlight persists across edits; highlight priority is +---set high to win over Treesitter/markdown styling. +---@param call table Tool-call entry with `label_line` populated. +function M:_highlight_tool_call_diff_label_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.label_line then + return + end + + -- Guard against stale label_line values that point past the end of the + -- buffer (for example after streaming updates that rewrote the chat). + local line_count = vim.api.nvim_buf_line_count(chat.bufnr) + if call.label_line < 1 or call.label_line > line_count then + return + end + + self.extmarks = self.extmarks or {} + + -- Naming convention: tool-call-related extmarks are grouped under `tool_*`. + -- (We keep other sidebar extmarks like `assistant`, `prefix`, etc. as-is.) + if self.extmarks.diff_label and not self.extmarks.tool_diff_label then + -- Backwards compatible migration for hot-reloads. + self.extmarks.tool_diff_label = self.extmarks.diff_label + self.extmarks.diff_label = nil + end + + if not self.extmarks.tool_diff_label then + self.extmarks.tool_diff_label = { + _ns = vim.api.nvim_create_namespace("extmarks_tool_diff_label"), + _id = {}, + } + end + + local ns = self.extmarks.tool_diff_label._ns + local key = call.id or tostring(call.header_line) + + -- Clear any previous highlight for this call + if self.extmarks.tool_diff_label._id[key] then + pcall(vim.api.nvim_buf_del_extmark, chat.bufnr, ns, self.extmarks.tool_diff_label._id[key]) + end + + -- Determine how much of the line to highlight (the whole label text) + local line = vim.api.nvim_buf_get_lines(chat.bufnr, call.label_line - 1, call.label_line, false)[1] or "" + local end_col = #line + + -- Add an extmark that highlights the label text using a theme-aware group + -- Use a high priority so it wins over Treesitter/markdown highlights. + self.extmarks.tool_diff_label._id[key] = vim.api.nvim_buf_set_extmark(chat.bufnr, ns, call.label_line - 1, 0, { + hl_group = _eca_sidebar_hl("diff_label"), + end_col = end_col, + priority = 200, + }) +end + +---Reapply tool-call/reasoning header and diff-label highlights. +--- +---Full-buffer updates (like streaming assistant responses) can drop extmark-based +---highlighting. This helper re-creates the extmarks for all tracked entries. +function M:_reapply_tool_call_highlights() + if not self._tool_calls or vim.tbl_isempty(self._tool_calls) then + return + end + + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + for _, call in ipairs(self._tool_calls) do + if call.header_line then + self:_highlight_tool_call_header(call) + end + + if call.has_diff and call.label_line then + self:_highlight_tool_call_diff_label_line(call) + end + end +end + +---Update the existing "view diff" label line for a tool call. +--- +---This updates the label text to reflect the current expanded/collapsed state of +---the diff block and re-applies hyperlink-style highlighting. +---@param call table Tool-call entry with `label_line` populated. +function M:_update_tool_call_diff_label_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.label_line then + return + end + + local label_text = self:_build_tool_call_diff_label_text(call) + + self:_safe_buffer_update(chat.bufnr, function() + vim.api.nvim_buf_set_lines(chat.bufnr, call.label_line - 1, call.label_line, false, { label_text }) + end) + + -- Ensure the label is highlighted after updating its text + self:_highlight_tool_call_diff_label_line(call) +end + +---Insert the "view diff" label line directly below the tool call header. +--- +---Also records `call.label_line`, highlights the label, and shifts subsequent +---tool-call line markers down. +---@param call table Tool-call entry. Requires `header_line` and `has_diff`. +function M:_insert_tool_call_diff_label_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.header_line or call.label_line or not call.has_diff then + return + end + + local label_text = self:_build_tool_call_diff_label_text(call) + + self:_safe_buffer_update(chat.bufnr, function() + -- Insert label immediately after the header line + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, { label_text }) + end) + + -- Track the label line (1-based) + call.label_line = call.header_line + 1 + + -- Ensure the label is highlighted when first inserted + self:_highlight_tool_call_diff_label_line(call) + + -- Shift subsequent tool call headers/labels down by one line + self:_adjust_tool_call_lines(call, 1) +end + +---Update the rendered header line for a tool call/reasoning entry. +--- +---Rebuilds the header text (arrow/title/status) and re-applies header highlighting. +---@param call table Tool-call/reasoning entry with `header_line` populated. +function M:_update_tool_call_header_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) or not call.header_line then + return + end + + local header_text = self:_build_tool_call_header_text(call) + + self:_safe_buffer_update(chat.bufnr, function() + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line - 1, call.header_line, false, { header_text }) + end) + + -- Re-apply header highlight after updating its text/arrow/status + self:_highlight_tool_call_header(call) +end + +---Adjust `header_line`/`label_line` positions for tool calls after an insertion/removal. +--- +---Whenever a tool call expands/collapses, the buffer gains or loses lines. This +---helper shifts the stored line markers for subsequent entries so cursor +---navigation and toggle behavior remain correct. +---@param changed_call table The call whose region changed. +---@param delta integer Number of lines inserted (positive) or removed (negative). +function M:_adjust_tool_call_lines(changed_call, delta) + if not self._tool_calls or delta == 0 then + return + end + + for _, call in ipairs(self._tool_calls) do + if call ~= changed_call and call.header_line and changed_call.header_line and call.header_line > changed_call.header_line then + call.header_line = call.header_line + delta + end + + if call ~= changed_call and call.label_line and changed_call.header_line and call.label_line > changed_call.header_line then + call.label_line = call.label_line + delta + end + end +end + +---Expand a tool call/reasoning entry in the chat buffer. +--- +---For tool calls this inserts the formatted arguments/output section under the +---header. For reasoning entries it inserts the current reasoning body without +---code fences. In both cases, subsequent tool-call line markers are shifted. +---@param call table Tool-call/reasoning entry. +function M:_expand_tool_call(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if call.expanded then + return + end + + -- Save cursor position before expansion (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + + -- Reasoning ("Thinking") entries behave slightly differently: we never + -- wrap them in code fences and the streaming handler is responsible for + -- keeping the body up to date. Here we just insert the current body once. + if call.is_reason then + -- Build a plain text block if we don't have it yet. We keep the + -- first inserted line as the first line of reasoning text rather + -- than a blank spacer so that navigation from the header lands on + -- the actual content. + if not call.arguments_lines or #call.arguments_lines == 0 then + call.arguments_lines = {} + + for _, line in ipairs(Utils.split_lines(call.arguments or "")) do + line = line ~= "" and line or " " + table.insert(call.arguments_lines, line) + end + end + + local count = call.arguments_lines and #call.arguments_lines or 0 + if count > 0 then + self:_safe_buffer_update(chat.bufnr, function() + -- Insert reasoning lines immediately after the header line + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.arguments_lines) + end) + + -- Track how many lines we inserted so that subsequent streaming + -- updates (_handle_reason_text) can correctly replace this block. + call._last_arguments_count = count + + -- Shift subsequent tool call header/label lines down + self:_adjust_tool_call_lines(call, count) + end + + call.expanded = true + self:_update_tool_call_header_line(call) + + -- Restore cursor position or move to last line based on config + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Adjust cursor row if it was after the insertion point + if saved_cursor[1] > call.header_line then + saved_cursor[1] = saved_cursor[1] + count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + elseif not preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Default behavior: move cursor to last line of expanded content + local last_line = call.header_line + (call._last_arguments_count or 0) + vim.api.nvim_win_set_cursor(chat.winid, { last_line, 0 }) + vim.api.nvim_win_call(chat.winid, function() + vim.cmd("normal! zb") + end) + end + + return + end + + -- Regular tool calls: show JSON arguments and output blocks + call.arguments_lines = call.arguments_lines or self:_build_tool_call_arguments_lines(call.arguments, call.outputs) + local count = call.arguments_lines and #call.arguments_lines or 0 + if count == 0 then + return + end + + self:_safe_buffer_update(chat.bufnr, function() + -- Insert arguments immediately after the header line + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.arguments_lines) + end) + + -- If there is a diff label, it must move down by the number of inserted lines + if call.has_diff and call.label_line then + call.label_line = call.label_line + count + end + + call.expanded = true + + -- Shift subsequent tool call header/label lines down + self:_adjust_tool_call_lines(call, count) + + -- Update header arrow + self:_update_tool_call_header_line(call) + + -- Restore cursor position or move to last line based on config + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Adjust cursor row if it was after the insertion point + if saved_cursor[1] > call.header_line then + saved_cursor[1] = saved_cursor[1] + count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + elseif not preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Default behavior: move cursor to last line of expanded content + local last_line = call.header_line + count + vim.api.nvim_win_set_cursor(chat.winid, { last_line, 0 }) + vim.api.nvim_win_call(chat.winid, function() + vim.cmd("normal! zb") + end) + end +end + +---Collapse a tool call/reasoning entry, removing its expanded body from the buffer. +--- +---Also adjusts any stored diff label line and shifts subsequent tool-call line +---markers back up. +---@param call table Tool-call/reasoning entry. +function M:_collapse_tool_call(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call.expanded then + return + end + + -- Save cursor position before collapsing (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + + local count = call.arguments_lines and #call.arguments_lines or 0 + if count == 0 then + call.expanded = false + self:_update_tool_call_header_line(call) + return + end + + self:_safe_buffer_update(chat.bufnr, function() + -- Remove the arguments block directly below the header + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line + count, false, {}) + end) + + -- If there is a diff label, move it back up + if call.has_diff and call.label_line then + call.label_line = call.label_line - count + end + + call.expanded = false + + -- Shift subsequent tool call header/label lines back up + self:_adjust_tool_call_lines(call, -count) + + -- Update header arrow + self:_update_tool_call_header_line(call) + + -- Restore cursor position, adjusting if it was after the collapsed region + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- If cursor was inside the collapsed region, move it to the header + if saved_cursor[1] > call.header_line and saved_cursor[1] <= call.header_line + count then + saved_cursor[1] = call.header_line + saved_cursor[2] = 0 + -- If cursor was after the collapsed region, adjust it up + elseif saved_cursor[1] > call.header_line + count then + saved_cursor[1] = saved_cursor[1] - count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + end +end + +---Expand a tool call diff section below its "view diff" label. +--- +---Inserts `call.diff_lines` into the buffer, updates the diff label text, and +---shifts subsequent tool-call line markers. +---@param call table Tool-call entry with a diff (`has_diff` and `label_line`). +function M:_expand_tool_call_diff(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call.has_diff or not call.label_line or call.diff_expanded then + return + end + + -- Save cursor position before expansion (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + + call.diff_lines = call.diff_lines or self:_build_tool_call_diff_lines(call.details) + local count = call.diff_lines and #call.diff_lines or 0 + if count == 0 then + return + end + + self:_safe_buffer_update(chat.bufnr, function() + -- Insert diff lines immediately after the diff label line + vim.api.nvim_buf_set_lines(chat.bufnr, call.label_line, call.label_line, false, call.diff_lines) + end) + + call.diff_expanded = true + + -- Shift subsequent tool call header/label lines down + self:_adjust_tool_call_lines(call, count) + + -- Update the diff label to show the collapse indicator + self:_update_tool_call_diff_label_line(call) + + -- Restore cursor position (no default cursor movement for diff expansion) + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Adjust cursor row if it was after the insertion point + if saved_cursor[1] > call.label_line then + saved_cursor[1] = saved_cursor[1] + count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + end +end + +---Collapse a tool call diff section, removing it from the chat buffer. +--- +---Also updates the diff label text and shifts subsequent tool-call line markers +---back up. +---@param call table Tool-call entry with `diff_expanded` and `label_line`. +function M:_collapse_tool_call_diff(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call.diff_expanded then + return + end + + -- Save cursor position before collapsing (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + + local count = call.diff_lines and #call.diff_lines or 0 + if count == 0 then + call.diff_expanded = false + self:_update_tool_call_diff_label_line(call) + return + end + + self:_safe_buffer_update(chat.bufnr, function() + -- Remove the diff block that starts immediately after the label line + vim.api.nvim_buf_set_lines(chat.bufnr, call.label_line, call.label_line + count, false, {}) + end) + + call.diff_expanded = false + + -- Shift subsequent tool call header/label lines back up + self:_adjust_tool_call_lines(call, -count) + + -- Update the diff label to show the expand indicator again + self:_update_tool_call_diff_label_line(call) + + -- Restore cursor position, adjusting if it was after the collapsed region + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- If cursor was inside the collapsed region, move it to the label + if saved_cursor[1] > call.label_line and saved_cursor[1] <= call.label_line + count then + saved_cursor[1] = call.label_line + saved_cursor[2] = 0 + -- If cursor was after the collapsed region, adjust it up + elseif saved_cursor[1] > call.label_line + count then + saved_cursor[1] = saved_cursor[1] - count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + end +end + +---Toggle tool call details at the current cursor position in the chat window. +--- +---If the cursor is on/under the diff label, toggles the diff block only. +---Otherwise toggles the arguments/reasoning body via the header arrow. +function M:_toggle_tool_call_at_cursor() + local chat = self.containers.chat + if not chat or not vim.api.nvim_win_is_valid(chat.winid) then + return + end + + local cursor = vim.api.nvim_win_get_cursor(chat.winid) + local line = cursor[1] + + local call = self:_find_tool_call_for_line(line) + if not call then + return + end + + -- If we are on or below the diff label for a call that has a diff, + -- toggle only the diff block. + if call.has_diff and call.label_line and line >= call.label_line then + if call.diff_expanded then + self:_collapse_tool_call_diff(call) + else + self:_expand_tool_call_diff(call) + end + return + end + + -- Otherwise toggle the arguments block via the header arrow + if call.expanded then + self:_collapse_tool_call(call) + else + self:_expand_tool_call(call) + end +end + +---@param target string +---@param replacement string +---@param opts? table|nil Optional search options: { max_search_lines = number, start_line = number } +---@return boolean changed True if any replacement was made +function M:_replace_text(target, replacement, opts) + local chat = self.containers.chat + + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + Logger.warn("Cannot replace message: chat buffer not available") + return false + end + + if not target or target == "" then + Logger.warn("Cannot replace message: empty target") + return false + end + + if not replacement or replacement == "" then + Logger.warn("Cannot replace message: empty replacement") + return false + end + + local changed = false + + self:_safe_buffer_update(chat.bufnr, function() + local total_lines = vim.api.nvim_buf_line_count(chat.bufnr) + opts = opts or {} + + -- Limit how many lines to search for performance with large buffers + local max_search_lines = tonumber(opts.max_search_lines) or 500 + + -- If a start line is provided, start searching from there (useful for targeted replacement) + local start_line = tonumber(opts.start_line) or total_lines + if start_line < 1 then + start_line = 1 + end + if start_line > total_lines then + start_line = total_lines + end + + -- Determine the search window [end_line, start_line] + local end_line = math.max(1, start_line - max_search_lines + 1) + + -- Fetch only the relevant range once (0-based indices for nvim API) + local range_lines = vim.api.nvim_buf_get_lines(chat.bufnr, end_line - 1, start_line, false) + + -- Iterate from bottom to top within the range + for idx = #range_lines, 1, -1 do + local line = range_lines[idx] or "" + local s_idx, e_idx = line:find(target, 1, true) + if s_idx then + local absolute_line = end_line + idx - 1 -- convert to absolute 1-based line + + -- If replacement contains newlines, split it into proper buffer lines + if type(replacement) == "string" and replacement:find("\n") then + local parts = Utils.split_lines(replacement) + local prefix = line:sub(1, s_idx - 1) + local suffix = line:sub(e_idx + 1) + + local new_lines = {} + if #parts > 0 then + -- First line: prefix + first part + table.insert(new_lines, prefix .. parts[1]) + -- Middle parts (if any) + for i = 2, #parts do + table.insert(new_lines, parts[i]) + end + -- Append suffix to the last inserted line + new_lines[#new_lines] = new_lines[#new_lines] .. suffix + else + -- Fallback: no parts (shouldn't happen), just replace inline + table.insert(new_lines, prefix .. suffix) + end + + vim.api.nvim_buf_set_lines(chat.bufnr, absolute_line - 1, absolute_line, false, new_lines) + changed = true + break + else + -- Simple single-line replacement + local new_line = (line:sub(1, s_idx - 1)) .. replacement .. (line:sub(e_idx + 1)) + vim.api.nvim_buf_set_lines(chat.bufnr, absolute_line - 1, absolute_line, false, { new_line }) + changed = true + break + end end end end) diff --git a/lua/eca/stream_queue.lua b/lua/eca/stream_queue.lua new file mode 100644 index 0000000..4a00b2f --- /dev/null +++ b/lua/eca/stream_queue.lua @@ -0,0 +1,126 @@ +---@class eca.StreamQueue +---@field private queue table Array of items to process +---@field private running boolean Whether the queue is currently processing +---@field private on_process function Callback to process each item +---@field private should_continue function Optional callback to check if processing should continue +---@field private chars_per_tick number Number of characters to display per tick +---@field private tick_delay number Delay between ticks in milliseconds + +local M = {} +M.__index = M + +---Create a new stream queue +---@param on_process function Function to call for each character batch: fn(text, is_complete) +---@param opts? table Optional configuration { chars_per_tick: number, tick_delay: number, should_continue: function } +---@return eca.StreamQueue +function M.new(on_process, opts) + opts = opts or {} + local instance = setmetatable({}, M) + instance.queue = {} + instance.running = false + instance.on_process = on_process + instance.should_continue = opts.should_continue + instance.chars_per_tick = opts.chars_per_tick or 1 + instance.tick_delay = opts.tick_delay or 10 + return instance +end + +---Add text to the queue for processing +---@param text string Text to add to the queue +function M:enqueue(text) + if not text or text == "" then + return + end + table.insert(self.queue, text) + self:process() +end + +---Process the queue +function M:process() + -- If already processing or queue is empty, return early + if self.running or #self.queue == 0 then + return + end + + self.running = true + + -- Combine all queued text into a single character queue for smooth continuous streaming + local combined_text = table.concat(self.queue, "") + self.queue = {} + + -- Create a local queue of characters from all text chunks + local char_queue = {} + for i = 1, #combined_text do + table.insert(char_queue, combined_text:sub(i, i)) + end + + local function done() + self.running = false + -- Process next item in queue if available (in case new items were added during processing) + if #self.queue > 0 then + self:process() + end + end + + local function step() + -- Check if we should continue processing (e.g., streaming is still active) + if self.should_continue and not self.should_continue() then + done() + return + end + + -- Check if new items were added to the queue while we were processing + -- If so, add them to the current character queue to maintain smooth animation + if #self.queue > 0 then + local new_text = table.concat(self.queue, "") + self.queue = {} + for i = 1, #new_text do + table.insert(char_queue, new_text:sub(i, i)) + end + end + + -- If no more characters in this chunk, mark as done + if #char_queue == 0 then + done() + return + end + + -- Render a small batch of characters per tick + local count = math.min(self.chars_per_tick, #char_queue) + local chunk = "" + for _ = 1, count do + chunk = chunk .. table.remove(char_queue, 1) + end + + -- Call the process callback with the chunk + -- Pass true if this is the last chunk + local is_complete = #char_queue == 0 and #self.queue == 0 + self.on_process(chunk, is_complete) + + -- Continue processing this chunk + vim.defer_fn(step, self.tick_delay) + end + + -- Start processing this chunk + vim.defer_fn(step, math.min(1, self.tick_delay)) +end + +---Clear the queue and stop processing +function M:clear() + self.queue = {} + self.running = false +end + +---Check if the queue is empty +---@return boolean +function M:is_empty() + return #self.queue == 0 and not self.running +end + +---Get the current queue size +---@return number +function M:size() + return #self.queue +end + +return M diff --git a/lua/eca/ui/picker.lua b/lua/eca/ui/picker.lua new file mode 100644 index 0000000..1f82388 --- /dev/null +++ b/lua/eca/ui/picker.lua @@ -0,0 +1,18 @@ +local Logger = require("eca.logger") + +local M = {} + +--- Wrapper around snacks.picker to provide a common entrypoint +--- for ECA pickers and handle the snacks dependency consistently. +---@param config snacks.picker.Config +function M.pick(config) + local has_snacks, snacks = pcall(require, "snacks") + if not has_snacks then + Logger.notify("snacks.nvim is not available", vim.log.levels.ERROR) + return + end + + return snacks.picker(config) +end + +return M diff --git a/lua/eca/utils.lua b/lua/eca/utils.lua index 7a46ac8..96e4366 100644 --- a/lua/eca/utils.lua +++ b/lua/eca/utils.lua @@ -1,6 +1,7 @@ local uv = vim.uv or vim.loop local Logger = require("eca.logger") +local Config = require("eca.config") local M = {} @@ -114,6 +115,103 @@ function M.write_file(path, content) return true end +---@param n number|string +---@return string +function M.shorten_tokens(n) + n = tonumber(n) or 0 + if n >= 1000 then + local rounded = math.floor(n / 1000 + 0.5) + return string.format("%dk", rounded) + end + return tostring(n) +end + +---Get chat configuration by merging top-level and windows.chat config +---@return table +function M.get_chat_config() + -- Merge top-level `chat` (backwards compatible) with `windows.chat`. + -- `windows.chat` provides modern defaults, while a user-provided + -- `chat.tool_call` block (legacy style) can still override fields + -- like `diff_label` and `diff_start_expanded`. + local win_chat = (Config.windows and Config.windows.chat) or {} + local top_chat = Config.chat or {} + + if next(top_chat) == nil then + return win_chat + end + + return vim.tbl_deep_extend("force", win_chat, top_chat) +end + +---Get tool call icons configuration +---@return table +function M.get_tool_call_icons() + local chat_cfg = M.get_chat_config() + local icons_cfg = (chat_cfg.tool_call and chat_cfg.tool_call.icons) or {} + return { + success = icons_cfg.success or "βœ…", + error = icons_cfg.error or "❌", + running = icons_cfg.running or "⏳", + expanded = icons_cfg.expanded or "β–²", + collapsed = icons_cfg.collapsed or "β–Ά", + } +end + +---Get tool call diff labels configuration +--- +---Configuration (under `windows.chat.tool_call`): +--- tool_call = { +--- diff = { +--- collapsed_label = "+ view diff", -- Label when the diff is collapsed +--- expanded_label = "- view diff", -- Label when the diff is expanded +--- expanded = false, -- When true, tool diffs start expanded +--- }, +--- } +---@return table +function M.get_tool_call_diff_labels() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.tool_call or {} + local diff_cfg = cfg.diff or {} + + return { + collapsed = diff_cfg.collapsed_label or "+ view diff", + expanded = diff_cfg.expanded_label or "- view diff", + } +end + +---Check if tool call diffs should start expanded +---@return boolean +function M.should_start_diff_expanded() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.tool_call or {} + local diff_cfg = cfg.diff or {} + + return diff_cfg.expanded == true +end + +---Check if cursor position should be preserved when expanding/collapsing tool calls +---@return boolean +function M.should_preserve_cursor() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.tool_call or {} + + return cfg.preserve_cursor == true +end + +---Get reasoning labels configuration +---@return table +function M.get_reasoning_labels() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.reasoning or {} + local running = cfg.running_label or "Thinking..." + local finished = cfg.finished_label or "Thought" + + return { + running = running, + finished = finished, + } +end + function M.constants() return CONSTANTS end diff --git a/plugin-spec.lua b/plugin-spec.lua index a48fb76..e908522 100644 --- a/plugin-spec.lua +++ b/plugin-spec.lua @@ -11,7 +11,11 @@ return { -- Default configuration server_path = "", server_args = "", - usage_string_format = "{messageCost} / {sessionCost}", + windows = { + usage = { + format = "{session_tokens_short} / {limit_tokens_short} (${session_cost})", + }, + }, log = { display = "split", -- "split" or "popup" level = vim.log.levels.INFO, diff --git a/tests/test_config.lua b/tests/test_config.lua new file mode 100644 index 0000000..7c981f2 --- /dev/null +++ b/tests/test_config.lua @@ -0,0 +1,612 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + end, + post_once = child.stop, + }, +}) + +-- ===== Chat config merging tests ===== + +T["chat_config"] = MiniTest.new_set() + +T["chat_config"]["get_chat_config merges legacy and new config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + chat = { + headers = { + user = "OLD> ", + }, + }, + windows = { + chat = { + headers = { + user = "NEW> ", + assistant = "AI: ", + }, + }, + }, + }) + + local Utils = require('eca.utils') + local merged = Utils.get_chat_config() + _G.merged_config = { + user_header = merged.headers and merged.headers.user or nil, + assistant_header = merged.headers and merged.headers.assistant or nil, + } + ]]) + + local merged = child.lua_get("_G.merged_config") + + -- Legacy chat.headers overrides windows.chat.headers via deep_extend + eq(merged.user_header, "OLD> ") + eq(merged.assistant_header, "AI: ") +end + +T["chat_config"]["get_chat_config returns windows.chat when no legacy config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + headers = { + user = "> ", + assistant = "", + }, + }, + }, + }) + + local Utils = require('eca.utils') + local merged = Utils.get_chat_config() + _G.merged_config = { + user_header = merged.headers and merged.headers.user or nil, + assistant_header = merged.headers and merged.headers.assistant or nil, + } + ]]) + + local merged = child.lua_get("_G.merged_config") + + eq(merged.user_header, "> ") + eq(merged.assistant_header, "") +end + +-- ===== Diff expansion config tests ===== + +T["diff_config"] = MiniTest.new_set() + +T["diff_config"]["should_start_diff_expanded respects windows.chat.tool_call.diff.expanded"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = true, + }, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), true) +end + +T["diff_config"]["should_start_diff_expanded checks diff.expanded only"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = false, + }, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), false) +end + +T["diff_config"]["should_start_diff_expanded defaults to false"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({}) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), false) +end + +-- ===== Preserve cursor config tests ===== + +T["preserve_cursor_config"] = MiniTest.new_set() + +T["preserve_cursor_config"]["should_preserve_cursor returns true when enabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), true) +end + +T["preserve_cursor_config"]["should_preserve_cursor returns false when disabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = false, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), false) +end + +T["preserve_cursor_config"]["should_preserve_cursor defaults to true"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({}) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), true) +end + +T["preserve_cursor_config"]["should_preserve_cursor respects legacy chat.tool_call config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), true) +end + +T["preserve_cursor_config"]["should_preserve_cursor merges windows.chat and chat config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = false, + }, + }, + }, + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + -- Legacy chat config should override windows.chat via deep_extend + eq(child.lua_get("_G.preserve"), true) +end + +-- ===== Behavioral validation tests ===== +-- These tests verify that config changes actually affect sidebar behavior + +T["behavior_validation"] = MiniTest.new_set() + +T["behavior_validation"]["preserve_cursor=true actually preserves cursor position on expand"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { + "Line 1", + "Line 2", + "Line 3", + "Line 4", + "Line 5", + }) + + sidebar._tool_calls = { + { + id = "test-id", + title = "Test", + header_line = 2, + expanded = false, + arguments = "{}", + arguments_lines = {"arg1", "arg2"}, + details = {}, + has_diff = false, + } + } + + vim.api.nvim_win_set_cursor(chat.winid, {5, 0}) + sidebar:_expand_tool_call(sidebar._tool_calls[1]) + + local cursor = vim.api.nvim_win_get_cursor(chat.winid) + _G.cursor_after = cursor[1] + _G.expected = 7 -- Line 5 + 2 inserted lines + ]]) + + eq(child.lua_get("_G.cursor_after"), child.lua_get("_G.expected")) +end + +T["behavior_validation"]["preserve_cursor=false moves cursor to end on expand"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = false, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { + "Line 1", + "Line 2", + "Line 3", + "Line 4", + }) + + sidebar._tool_calls = { + { + id = "test-id", + title = "Test", + header_line = 2, + expanded = false, + arguments = "{}", + arguments_lines = {"arg1", "arg2"}, + details = {}, + has_diff = false, + } + } + + vim.api.nvim_win_set_cursor(chat.winid, {1, 0}) + sidebar:_expand_tool_call(sidebar._tool_calls[1]) + + local cursor = vim.api.nvim_win_get_cursor(chat.winid) + _G.cursor_after = cursor[1] + _G.expected = 4 -- header_line (2) + arguments_lines count (2) + ]]) + + eq(child.lua_get("_G.cursor_after"), child.lua_get("_G.expected")) +end + +T["behavior_validation"]["diff.expanded=true causes diffs to start expanded"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = true, + }, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, {"Line 1"}) + + -- Simulate a tool call with diff that should auto-expand + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCallPrepare', + id = 'tool-1', + name = 'test_tool', + summary = 'Test', + argumentsText = '{}', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + }, + }) + + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCalled', + id = 'tool-1', + name = 'test_tool', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + outputs = {}, + }, + }) + + -- Find the tool call and check if diff is expanded + local call = sidebar._tool_calls[1] + _G.diff_expanded = call and call.diff_expanded or false + ]]) + + eq(child.lua_get("_G.diff_expanded"), true) +end + +T["behavior_validation"]["diff.expanded=false causes diffs to start collapsed"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = false, + }, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, {"Line 1"}) + + -- Simulate a tool call with diff that should NOT auto-expand + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCallPrepare', + id = 'tool-1', + name = 'test_tool', + summary = 'Test', + argumentsText = '{}', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + }, + }) + + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCalled', + id = 'tool-1', + name = 'test_tool', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + outputs = {}, + }, + }) + + -- Find the tool call and check if diff is collapsed + local call = sidebar._tool_calls[1] + _G.diff_expanded = call and call.diff_expanded or false + ]]) + + eq(child.lua_get("_G.diff_expanded"), false) +end + +T["behavior_validation"]["reasoning.expanded=true causes reasoning to start expanded"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + expanded = true, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + -- Simulate reasoning started event + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'reasonStarted', + id = 'reason-1', + }, + }) + + -- Check if reasoning block started expanded + local call = sidebar._reasons['reason-1'] + _G.expanded = call and call.expanded or false + ]]) + + eq(child.lua_get("_G.expanded"), true) +end + +T["behavior_validation"]["reasoning.expanded=false causes reasoning to start collapsed"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + expanded = false, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + -- Simulate reasoning started event + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'reasonStarted', + id = 'reason-1', + }, + }) + + -- Check if reasoning block started collapsed + local call = sidebar._reasons['reason-1'] + _G.expanded = call and call.expanded or false + ]]) + + eq(child.lua_get("_G.expanded"), false) +end + +T["behavior_validation"]["typing.enabled=false displays text instantly"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = false, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + -- Check that stream queue was configured for instant display + local queue = sidebar._stream_queue + _G.chars_per_tick = queue.chars_per_tick + -- When typing is disabled, chars_per_tick should be a large number (instant) + _G.is_instant = _G.chars_per_tick >= 1000 + ]]) + + eq(child.lua_get("_G.is_instant"), true) +end + +T["behavior_validation"]["typing.enabled=true enables gradual display"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 2, + tick_delay = 5, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + -- Check that stream queue was configured with custom values + local queue = sidebar._stream_queue + _G.chars_per_tick = queue.chars_per_tick + _G.tick_delay = queue.tick_delay + ]]) + + eq(child.lua_get("_G.chars_per_tick"), 2) + eq(child.lua_get("_G.tick_delay"), 5) +end + +return T diff --git a/tests/test_eca.lua b/tests/test_eca.lua index d177bc3..faab82a 100644 --- a/tests/test_eca.lua +++ b/tests/test_eca.lua @@ -16,6 +16,14 @@ T["config"]["has default values"] = function() MiniTest.expect.equality(type(config), "table") end +T["config"]["has usage window defaults"] = function() + local config = require("eca.config") + MiniTest.expect.equality( + config.options.windows.usage.format, + "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + ) +end + -- Test utilities T["utils"] = MiniTest.new_set() diff --git a/tests/test_highlights.lua b/tests/test_highlights.lua new file mode 100644 index 0000000..9ddfd3b --- /dev/null +++ b/tests/test_highlights.lua @@ -0,0 +1,27 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[require('eca.highlights').setup()]]) + end, + post_once = child.stop, + }, +}) + +T["highlights"] = MiniTest.new_set() + +T["highlights"]["defines ECA highlight groups used in sidebar"] = function() + local ok_label = child.lua_get("pcall(vim.api.nvim_get_hl, 0, { name = 'EcaLabel' })") + local ok_tool = child.lua_get("pcall(vim.api.nvim_get_hl, 0, { name = 'EcaToolCall' })") + local ok_link = child.lua_get("pcall(vim.api.nvim_get_hl, 0, { name = 'EcaHyperlink' })") + + eq(ok_label, true) + eq(ok_tool, true) + eq(ok_link, true) +end + +return T diff --git a/tests/test_picker.lua b/tests/test_picker.lua new file mode 100644 index 0000000..2a28a18 --- /dev/null +++ b/tests/test_picker.lua @@ -0,0 +1,77 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[ + _G.captured_notifications = {} + local Logger = require('eca.logger') + _G._original_notify = Logger.notify + Logger.notify = function(msg, level, opts) + table.insert(_G.captured_notifications, { + message = msg, + level = level, + opts = opts or {}, + }) + end + ]]) + end, + post_case = function() + child.lua([[ + local Logger = require('eca.logger') + if _G._original_notify then + Logger.notify = _G._original_notify + end + _G.captured_notifications = nil + ]]) + end, + post_once = child.stop, + }, +}) + +T["picker wrapper"] = MiniTest.new_set() + +T["picker wrapper"]["logs error when snacks is missing"] = function() + child.lua([[ + package.loaded['snacks'] = nil + local Picker = require('eca.ui.picker') + _G.result = Picker.pick({ source = 'test-source' }) + ]]) + + local result = child.lua_get("_G.result") + eq(result, vim.NIL) + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications, 1) + eq(notifications[1].message, "snacks.nvim is not available") + eq(notifications[1].level, child.lua_get("vim.log.levels.ERROR")) +end + +T["picker wrapper"]["delegates to snacks.picker when available"] = function() + child.lua([[ + local calls = {} + package.loaded['snacks'] = { + picker = function(config) + table.insert(calls, config) + return 'OK' + end, + } + _G.snacks_calls = calls + + local Picker = require('eca.ui.picker') + _G.result = Picker.pick({ source = 'test-source', extra = true }) + ]]) + + local result = child.lua_get("_G.result") + eq(result, "OK") + + local calls = child.lua_get("_G.snacks_calls") + eq(#calls, 1) + eq(calls[1].source, "test-source") + eq(calls[1].extra, true) +end + +return T diff --git a/tests/test_server_picker_commands.lua b/tests/test_server_picker_commands.lua new file mode 100644 index 0000000..dd08581 --- /dev/null +++ b/tests/test_server_picker_commands.lua @@ -0,0 +1,182 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local function flush(ms) + vim.uv.sleep(ms or 50) + child.api.nvim_eval("1") +end + +local function setup_env() + require('eca.commands').setup() + + -- Stub Picker.pick so commands can run without snacks.nvim + local Picker = require('eca.ui.picker') + _G.picker_calls = {} + Picker.pick = function(config) + table.insert(_G.picker_calls, config) + end +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua_func(setup_env) + end, + post_case = function() + child.lua("_G.picker_calls = nil") + end, + post_once = child.stop, + }, +}) + +T["EcaServerMessages"] = MiniTest.new_set() + +T["EcaServerMessages"]["uses picker and filters invalid JSON messages"] = function() + child.lua([[ + local eca = require('eca') + eca.server = eca.server or {} + eca.server.messages = { + { id = 1, direction = 'send', content = vim.json.encode({ jsonrpc = '2.0', method = 'test/method', id = 1 }) }, + { id = 2, direction = 'recv', content = 'not-json' }, + } + + vim.cmd('EcaServerMessages') + ]]) + + flush() + + child.lua([[ + local calls = _G.picker_calls or {} + local cfg = calls[1] + _G.picker_info = { + count = #calls, + source = cfg and cfg.source or nil, + } + ]]) + + local picker_info = child.lua_get("_G.picker_info") + + eq(picker_info.count, 1) + eq(picker_info.source, "eca messages") + + -- Run finder and inspect produced items + child.lua([[ + local cfg = _G.picker_calls[1] + local items = cfg.finder({}, {}) + _G.result_messages = { + count = #items, + first = items[1], + } + ]]) + + local result = child.lua_get("_G.result_messages") + + -- Only the valid JSON message should be included + eq(result.count, 1) + eq(type(result.first), "table") + eq(result.first.idx, 1) + eq(result.first.preview.ft, "lua") + + -- Preview text should contain the method name + local has_method = child.lua_get("string.find(..., 'test/method', 1, true) ~= nil", { result.first.preview.text }) + eq(has_method, true) + + -- Confirm callback yanks preview text and closes picker + child.lua([[ + local cfg = _G.picker_calls[1] + local item = cfg.finder({}, {})[1] + local picker = { closed = false } + function picker:close() self.closed = true end + + cfg.confirm(picker, item, nil) + + _G.confirm_messages = { + reg = vim.fn.getreg(''), + closed = picker.closed, + } + ]]) + + local confirm_ok = child.lua_get("_G.confirm_messages") + + eq(confirm_ok.closed, true) + eq(type(confirm_ok.reg), "string") + local has_method_in_reg = child.lua_get("string.find(..., 'test/method', 1, true) ~= nil", { confirm_ok.reg }) + eq(has_method_in_reg, true) +end + +T["EcaServerTools"] = MiniTest.new_set() + +T["EcaServerTools"]["lists tools from state in sorted order"] = function() + child.lua([[ + local eca = require('eca') + eca.state = eca.state or {} + eca.state.tools = { + zebra = { kind = 'z' }, + alpha = { kind = 'a' }, + middle = { kind = 'm' }, + } + + vim.cmd('EcaServerTools') + ]]) + + flush() + + child.lua([[ + local calls = _G.picker_calls or {} + _G.picker_info = { + count = #calls, + } + ]]) + + local picker_info = child.lua_get("_G.picker_info") + + eq(picker_info.count, 1) + + child.lua([[ + local cfg = _G.picker_calls[1] + local items = cfg.finder({}, {}) + _G.result_tools = { + count = #items, + names = { items[1].text, items[2].text, items[3].text }, + first = items[1], + } + ]]) + + local result = child.lua_get("_G.result_tools") + + eq(result.count, 3) + -- Names must be sorted alphabetically + eq(result.names[1], "alpha") + eq(result.names[2], "middle") + eq(result.names[3], "zebra") + + -- Preview contains vim.inspect output of the tool + local has_kind = child.lua_get("string.find(..., 'kind%p a', 1) ~= nil", { result.first.preview.text }) + eq(has_kind, true) + + -- Confirm yanks preview text and closes picker + child.lua([[ + local cfg = _G.picker_calls[1] + local items = cfg.finder({}, {}) + local picker = { closed = false } + function picker:close() self.closed = true end + + cfg.confirm(picker, items[2], nil) + + _G.confirm_tools = { + reg = vim.fn.getreg(''), + closed = picker.closed, + } + ]]) + + local confirm_ok = child.lua_get("_G.confirm_tools") + + eq(confirm_ok.closed, true) + eq(type(confirm_ok.reg), "string") + local has_middle = child.lua_get("string.find(..., 'middle', 1, true) ~= nil", { confirm_ok.reg }) + eq(has_middle, true) +end + +return T diff --git a/tests/test_sidebar_autoscroll.lua b/tests/test_sidebar_autoscroll.lua new file mode 100644 index 0000000..0d233c0 --- /dev/null +++ b/tests/test_sidebar_autoscroll.lua @@ -0,0 +1,154 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local function flush(ms) + vim.uv.sleep(ms or 120) + child.api.nvim_eval("1") +end + +local function setup_env() + _G.Server = require('eca.server').new() + _G.State = require('eca.state').new() + _G.Mediator = require('eca.mediator').new(_G.Server, _G.State) + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + + _G.Sidebar:open() + + _G.fill_chat = function(n) + local chat = _G.Sidebar.containers.chat + vim.api.nvim_set_option_value('modifiable', true, { buf = chat.bufnr }) + + local lines = {} + for i = 1, n do + lines[i] = string.format('line %03d', i) + end + + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, lines) + end + + _G.focus_chat = function() + vim.api.nvim_set_current_win(_G.Sidebar.containers.chat.winid) + end + + _G.focus_input = function() + vim.api.nvim_set_current_win(_G.Sidebar.containers.input.winid) + end + + _G.set_chat_cursor = function(row) + local win = _G.Sidebar.containers.chat.winid + vim.api.nvim_win_set_cursor(win, { row, 0 }) + end + + _G.add_assistant_message = function(text) + _G.Sidebar:_add_message('assistant', text) + end + + _G.get_chat_view = function() + local chat = _G.Sidebar.containers.chat + local view = vim.api.nvim_win_call(chat.winid, function() + local v = vim.fn.winsaveview() + return { topline = v.topline, lnum = v.lnum } + end) + + local bottomline = vim.api.nvim_win_call(chat.winid, function() + return vim.fn.line('w$') + end) + + return { + current_win = vim.api.nvim_get_current_win(), + chat_win = chat.winid, + input_win = _G.Sidebar.containers.input.winid, + cursor = vim.api.nvim_win_get_cursor(chat.winid), + topline = view.topline, + lnum = view.lnum, + bottomline = bottomline, + line_count = vim.api.nvim_buf_line_count(chat.bufnr), + } + end +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua_func(setup_env) + end, + post_case = function() + child.lua([[ if _G.Sidebar then _G.Sidebar:close() end ]]) + end, + post_once = child.stop, + }, +}) + +T["sidebar autoscroll"] = MiniTest.new_set() + +T["sidebar autoscroll"]["auto-scrolls when chat is not focused"] = function() + -- Let deferred sidebar setup (like initial focus) settle. + flush(200) + + child.lua([[ + _G.fill_chat(100) + _G.focus_chat() + _G.set_chat_cursor(1) + _G.focus_input() + ]]) + + -- Add a new assistant message while focus is on input. + child.lua([[_G.add_assistant_message('incoming')]]) + + flush(220) + + local view = child.lua_get("_G.get_chat_view()") + + -- Focus should remain on input. + eq(view.current_win, view.input_win) + -- Chat cursor should be moved to the bottom. + eq(view.cursor[1], view.line_count) +end + +T["sidebar autoscroll"]["does not auto-scroll when chat is focused and not at bottom"] = function() + -- Let deferred sidebar setup (like initial focus) settle. + flush(200) + + child.lua([[ + _G.fill_chat(100) + _G.focus_chat() + _G.set_chat_cursor(10) + _G.before = _G.get_chat_view() + ]]) + + child.lua([[_G.add_assistant_message('incoming')]]) + + flush(220) + + local before = child.lua_get("_G.before") + local after = child.lua_get("_G.get_chat_view()") + + eq(after.current_win, after.chat_win) + eq(after.cursor[1], before.cursor[1]) + eq(after.topline, before.topline) +end + +T["sidebar autoscroll"]["auto-scrolls when chat is focused and at bottom"] = function() + -- Let deferred sidebar setup (like initial focus) settle. + flush(200) + + child.lua([[ + _G.fill_chat(100) + _G.focus_chat() + _G.set_chat_cursor(100) + _G.before = _G.get_chat_view() + ]]) + + child.lua([[_G.add_assistant_message('incoming')]]) + + flush(220) + + local after = child.lua_get("_G.get_chat_view()") + + eq(after.current_win, after.chat_win) + eq(after.cursor[1], after.line_count) +end + +return T diff --git a/tests/test_sidebar_usage_and_tools.lua b/tests/test_sidebar_usage_and_tools.lua new file mode 100644 index 0000000..20873b7 --- /dev/null +++ b/tests/test_sidebar_usage_and_tools.lua @@ -0,0 +1,672 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local function flush(ms) + vim.uv.sleep(ms or 80) + child.api.nvim_eval("1") +end + +local function setup_env() + -- Minimal environment with Server, State, Mediator, Sidebar + _G.Server = require('eca.server').new() + _G.State = require('eca.state').new() + _G.Mediator = require('eca.mediator').new(_G.Server, _G.State) + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + + -- Open sidebar so containers are created + _G.Sidebar:open() +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua_func(setup_env) + end, + post_case = function() + child.lua([[ if _G.Sidebar then _G.Sidebar:close() end ]]) + end, + post_once = child.stop, + }, +}) + +T["usage formatting"] = MiniTest.new_set() + +T["usage formatting"]["uses default short token format"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local Mediator = _G.Mediator + + -- Stub mediator usage values + function Mediator:status_state() return 'responding' end + function Mediator:status_text() return 'Responding' end + function Mediator:tokens_session() return 1499 end + function Mediator:tokens_limit() return 1501 end + function Mediator:costs_session() return '0.00' end + + Sidebar:_update_usage_info() + ]]) + + local info = child.lua_get("_G.Sidebar._usage_info") + -- 1499 -> 1k, 1501 -> 2k with rounding + eq(info, "1k / 2k ($0.00)") +end + +T["usage formatting"]["respects custom windows.usage.format"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + usage = { + format = '{session_tokens} of {limit_tokens} tokens (${session_cost})', + }, + }, + }) + + local Sidebar = _G.Sidebar + local Mediator = _G.Mediator + + function Mediator:status_state() return 'responding' end + function Mediator:status_text() return 'Responding' end + function Mediator:tokens_session() return 42 end + function Mediator:tokens_limit() return 1000 end + function Mediator:costs_session() return '1.23' end + + Sidebar:_update_usage_info() + ]]) + + local info = child.lua_get("_G.Sidebar._usage_info") + eq(info, "42 of 1000 tokens ($1.23)") +end + +T["tool call diffs"] = MiniTest.new_set() + +T["tool call diffs"]["shows arguments and outputs sections when expanded"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Simulate a tool call lifecycle with arguments and outputs (no diff) + Sidebar:handle_chat_content_received({ + chatId = 'chat-out', + content = { + type = 'toolCallPrepare', + id = 'tool-out', + name = 'test_tool', + summary = 'Test Tool', + argumentsText = '{"value": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-out', + content = { + type = 'toolCallRunning', + id = 'tool-out', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-out', + content = { + type = 'toolCalled', + id = 'tool-out', + name = 'test_tool', + summary = 'Test Tool', + outputs = { + { type = 'text', text = 'tool-output-123' }, + }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local chat = Sidebar.containers.chat + + if not call or not chat or not chat.bufnr then + _G.tool_details = { expanded = false, has_arguments = false, has_output = false, has_output_text = false } + return + end + + -- Expand the arguments/output block via the header + vim.api.nvim_win_set_cursor(chat.winid, { call.header_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() + + local buf = chat.bufnr + local lines = vim.api.nvim_buf_get_lines(buf, call.header_line, call.header_line + 20, false) + + local details = { + expanded = call.expanded, + has_arguments = false, + has_output = false, + has_output_text = false, + } + + for _, line in ipairs(lines) do + if line == 'Arguments:' then + details.has_arguments = true + elseif line == 'Output:' then + details.has_output = true + end + if string.find(line, 'tool-output-123', 1, true) then + details.has_output_text = true + end + end + + _G.tool_details = details + ]]) + + local info = child.lua_get("_G.tool_details") + + eq(info.expanded, true) + eq(info.has_arguments, true) + eq(info.has_output, true) + eq(info.has_output_text, true) +end + +T["tool call diffs"]["shows diff label and toggles diff block with "] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local chat = Sidebar.containers.chat + + -- Simulate a tool call lifecycle with diff + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'toolCallPrepare', + id = 'tool-1', + name = 'test_tool', + summary = 'Test Tool', + argumentsText = '{"value": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'toolCallRunning', + id = 'tool-1', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'toolCalled', + id = 'tool-1', + name = 'test_tool', + summary = 'Test Tool', + details = { diff = '+added\n-removed' }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + _G.call_info = { + has_diff = call and call.has_diff or false, + header_line = call and call.header_line or 0, + label_line = call and call.label_line or 0, + header_text = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '', + label_text = call and vim.api.nvim_buf_get_lines(buf, call.label_line - 1, call.label_line, false)[1] or '', + } + ]]) + + local call_info = child.lua_get("_G.call_info") + + eq(call_info.has_diff, true) + eq(call_info.label_line > 0, true) + + -- Default collapsed label + eq(call_info.label_text, "+ view diff") + + -- Header should mention the summary + local has_summary = child.lua_get("string.find(..., 'Test Tool', 1, true) ~= nil", { call_info.header_text }) + eq(has_summary, true) + + -- Toggle on the diff label line should expand/collapse only the diff block + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local chat = Sidebar.containers.chat + + vim.api.nvim_win_set_cursor(chat.winid, { call.label_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() -- expand diff + ]]) + + flush(50) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + local first_diff = vim.api.nvim_buf_get_lines(buf, call.label_line, call.label_line + 1, false)[1] or '' + _G.expanded_info = { + diff_expanded = call.diff_expanded, + first_diff = first_diff, + } + ]]) + + local expanded = child.lua_get("_G.expanded_info") + + eq(expanded.diff_expanded, true) + eq(expanded.first_diff, "```diff") + + -- Collapse again + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local chat = Sidebar.containers.chat + + vim.api.nvim_win_set_cursor(chat.winid, { call.label_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() -- collapse diff + ]]) + + flush(50) + + local collapsed = child.lua_get("_G.Sidebar._tool_calls[1].diff_expanded") + eq(collapsed, false) +end + +T["reasoning blocks"] = MiniTest.new_set() + +T["reasoning blocks"]["stream reasoning and toggle body"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r', + content = { type = 'reasonStarted', id = 'r1' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r', + content = { type = 'reasonText', id = 'r1', text = 'First line.\nSecond line.' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r', + content = { type = 'reasonFinished', id = 'r1', totalTimeMs = 1234 }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r1'] + local buf = Sidebar.containers.chat.bufnr + local header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '' + _G.reason_info = { + has_reason = call ~= nil, + header_line = call and call.header_line or 0, + header = header, + } + ]]) + + local info = child.lua_get("_G.reason_info") + + eq(info.has_reason, true) + + -- Header should contain the finished label and elapsed time + local has_thought = child.lua_get("string.find(..., 'Thought', 1, true) ~= nil", { info.header }) + eq(has_thought, true) + + local has_secs = child.lua_get("string.find(..., 's', 1, true) ~= nil", { info.header }) + eq(has_secs, true) + + -- Toggle body via header line + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r1'] + local chat = Sidebar.containers.chat + vim.api.nvim_win_set_cursor(chat.winid, { call.header_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() + ]]) + + flush(80) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r1'] + local buf = Sidebar.containers.chat.bufnr + local first = vim.api.nvim_buf_get_lines(buf, call.header_line, call.header_line + 1, false)[1] or '' + _G.reason_body = { + expanded = call.expanded, + first = first, + } + ]]) + + local body = child.lua_get("_G.reason_body") + + eq(body.expanded, true) + local has_first_line = child.lua_get("string.find(..., 'First line.', 1, true) ~= nil", { body.first }) + eq(has_first_line, true) +end + +T["replace_text"] = MiniTest.new_set() + +T["replace_text"]["supports multi-line replacement"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local chat = Sidebar.containers.chat + + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { + 'prefix TARGET suffix', + 'other line', + }) + + _G.changed = Sidebar:_replace_text('TARGET', 'foo\nbar') + _G.lines = vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false) + ]]) + + local changed = child.lua_get("_G.changed") + eq(changed, true) + + local lines = child.lua_get("_G.lines") + eq(lines[1], "prefix foo") + eq(lines[2], "bar suffix") + eq(lines[3], "other line") +end + +T["tool call config"] = MiniTest.new_set() + +T["tool call config"]["respects windows.chat.tool_call.diff labels and expanded flag"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + collapsed_label = '[open diff]', + expanded_label = '[close diff]', + expanded = true, + }, + }, + }, + }, + }) + + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-cfg', + content = { + type = 'toolCallPrepare', + id = 'cfg-tool', + name = 'cfg_tool', + summary = 'Config Tool', + argumentsText = '{"foo": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-cfg', + content = { + type = 'toolCallRunning', + id = 'cfg-tool', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-cfg', + content = { + type = 'toolCalled', + id = 'cfg-tool', + name = 'cfg_tool', + summary = 'Config Tool', + details = { diff = '+x\n-y' }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + _G.cfg_call = { + label_line = call and call.label_line or 0, + label_text = call and vim.api.nvim_buf_get_lines(buf, call.label_line - 1, call.label_line, false)[1] or '', + diff_expanded = call and call.diff_expanded or false, + first_diff = call and vim.api.nvim_buf_get_lines(buf, call.label_line, call.label_line + 1, false)[1] or '', + } + ]]) + + local cfg = child.lua_get("_G.cfg_call") + + eq(cfg.label_text, "[close diff]") + eq(cfg.diff_expanded, true) + eq(cfg.first_diff, "```diff") +end + +T["reasoning blocks"]["use configured labels"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + running_label = 'Working...', + finished_label = 'Plan', + }, + }, + }, + }) + + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r2', + content = { type = 'reasonStarted', id = 'r2' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r2', + content = { type = 'reasonFinished', id = 'r2', totalTimeMs = 500 }, + }) + ]]) + + flush(80) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r2'] + local buf = Sidebar.containers.chat.bufnr + _G.reason_cfg = { + header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '', + } + ]]) + + local rcfg = child.lua_get("_G.reason_cfg") + + local has_plan = child.lua_get("string.find(..., 'Plan', 1, true) ~= nil", { rcfg.header }) + eq(has_plan, true) +end + +T["reasoning blocks"]["start expanded when configured"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + expanded = true, + }, + }, + }, + }) + + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r3', + content = { type = 'reasonStarted', id = 'r3' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r3', + content = { type = 'reasonText', id = 'r3', text = 'First line.' }, + }) + ]]) + + flush(80) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r3'] + local buf = Sidebar.containers.chat.bufnr + _G.reason_expanded = { + expanded = call and call.expanded or false, + first = call and vim.api.nvim_buf_get_lines(buf, call.header_line, call.header_line + 1, false)[1] or '', + } + ]]) + + local r = child.lua_get("_G.reason_expanded") + + eq(r.expanded, true) + local has_first = child.lua_get("string.find(..., 'First line.', 1, true) ~= nil", { r.first }) + eq(has_first, true) +end + +T["reasoning blocks"]["hide arrow until there is body text"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r0', + content = { type = 'reasonStarted', id = 'r0' }, + }) + ]]) + + flush(50) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r0'] + local buf = Sidebar.containers.chat.bufnr + _G.reason_header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '' + ]]) + + local header = child.lua_get("_G.reason_header") + + -- With no streamed reasoning text yet, the header should not show + -- the expand/collapse arrow icon. + local has_arrow = child.lua_get("string.find(..., 'β–Ά', 1, true) ~= nil", { header }) + eq(has_arrow, false) +end + +T["mcps display"] = MiniTest.new_set() + +T["mcps display"]["shows active and registered counts with highlights"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local Mediator = _G.Mediator + + function Mediator:selected_model() return 'gpt' end + function Mediator:selected_behavior() return 'default' end + + function Mediator:mcps() + return { + a = { status = 'starting' }, + b = { status = 'running' }, + c = { status = 'failed' }, + } + end + + Sidebar:_update_config_display() + ]]) + + child.lua([[ + local Sidebar = _G.Sidebar + local buf = Sidebar.containers.config.bufnr + local ns = Sidebar.extmarks.config._ns + local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) + local virt = marks[1][4].virt_text + _G.mcps_info = { + active_text = virt[8][1], + active_hl = virt[8][2], + registered_text = virt[10][1], + registered_hl = virt[10][2], + } + ]]) + + local info = child.lua_get("_G.mcps_info") + + eq(info.active_text, "2") -- starting + running + eq(info.active_hl, "EcaLabel") + eq(info.registered_text, "3") + eq(info.registered_hl, "Exception") +end + +T["tool call summaries"] = MiniTest.new_set() + +T["tool call summaries"]["appends filename for fileChange details"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Simulate a tool call lifecycle that reports a file change + Sidebar:handle_chat_content_received({ + chatId = 'chat-file', + content = { + type = 'toolCallPrepare', + id = 'tool-file', + name = 'write_file', + summary = 'Apply edit', + argumentsText = '{"value": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-file', + content = { + type = 'toolCallRunning', + id = 'tool-file', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-file', + content = { + type = 'toolCalled', + id = 'tool-file', + name = 'write_file', + summary = 'Apply edit', + details = { type = 'fileChange', path = '/tmp/example/foo.lua', diff = '+added' }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + _G.file_summary = { + header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '', + } + ]]) + + local info = child.lua_get("_G.file_summary") + + -- Header should mention the basename of the changed file + local has_filename = child.lua_get("string.find(..., 'foo.lua', 1, true) ~= nil", { info.header }) + eq(has_filename, true) +end + +return T diff --git a/tests/test_stream_queue.lua b/tests/test_stream_queue.lua new file mode 100644 index 0000000..8cb293a --- /dev/null +++ b/tests/test_stream_queue.lua @@ -0,0 +1,619 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[ + _G.StreamQueue = require('eca.stream_queue') + _G.output = "" + _G.chunks_received = {} + ]]) + end, + post_once = child.stop, + }, +}) + +-- Ensure scheduled callbacks run (vim.schedule and vim.defer_fn) +local function flush(ms) + vim.uv.sleep(ms or 50) + -- Force at least one main loop iteration + child.api.nvim_eval("1") +end + +T["basic queue operations"] = MiniTest.new_set() + +T["basic queue operations"]["creates a new queue instance"] = function() + child.lua([[ + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + -- noop + end) + ]]) + + eq(child.lua_get("type(_G.queue)"), "table") + eq(child.lua_get("_G.queue:is_empty()"), true) + eq(child.lua_get("_G.queue:size()"), 0) +end + +T["basic queue operations"]["enqueue triggers processing"] = function() + child.lua([[ + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + -- noop + end) + _G.queue:enqueue("Hello") + _G.queue:enqueue("World") + ]]) + + -- When items are enqueued, the first starts processing immediately + -- So size will be 1 (second item waiting) and not empty (still processing) + local size = child.lua_get("_G.queue:size()") + local is_empty = child.lua_get("_G.queue:is_empty()") + + -- Either 1 item in queue (first being processed) or 2 items (depending on timing) + eq(size >= 0 and size <= 2, true) + eq(is_empty, false) +end + +T["basic queue operations"]["clear removes all items"] = function() + child.lua([[ + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + -- noop + end) + _G.queue:enqueue("Hello") + _G.queue:enqueue("World") + _G.queue:clear() + ]]) + + eq(child.lua_get("_G.queue:size()"), 0) + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["queue processing"] = MiniTest.new_set() + +T["queue processing"]["processes single text chunk"] = function() + child.lua([[ + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 2, + tick_delay = 10, + }) + _G.queue:enqueue("Hi") + ]]) + + -- Wait for processing to complete + flush(100) + + eq(child.lua_get("_G.output"), "Hi") + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["queue processing"]["processes multiple text chunks in order"] = function() + child.lua([[ + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 2, + tick_delay = 5, + }) + _G.queue:enqueue("Hello") + _G.queue:enqueue(" ") + _G.queue:enqueue("World") + _G.queue:enqueue("!") + ]]) + + -- Wait for all processing to complete + flush(300) + + eq(child.lua_get("_G.output"), "Hello World!") + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["queue processing"]["respects chars_per_tick setting"] = function() + child.lua([[ + _G.chunks_received = {} + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + table.insert(_G.chunks_received, chunk) + end, { + chars_per_tick = 1, + tick_delay = 5, + }) + _G.queue:enqueue("ABC") + ]]) + + -- Wait for processing to complete + flush(100) + + -- With chars_per_tick = 1, "ABC" should be split into 3 chunks + local chunks = child.lua_get("_G.chunks_received") + eq(#chunks, 3) + eq(chunks[1], "A") + eq(chunks[2], "B") + eq(chunks[3], "C") +end + +T["queue processing"]["calls callback with is_complete flag"] = function() + child.lua([[ + _G.completion_flags = {} + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + table.insert(_G.completion_flags, is_complete) + end, { + chars_per_tick = 2, + tick_delay = 5, + }) + _G.queue:enqueue("AB") + _G.queue:enqueue("CD") + ]]) + + -- Wait for all processing to complete + flush(150) + + local flags = child.lua_get("_G.completion_flags") + -- The last chunk should have is_complete = true + eq(flags[#flags], true) +end + +T["queue processing"]["respects should_continue callback"] = function() + child.lua([[ + _G.output = "" + _G.should_continue = true + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 1, + tick_delay = 10, + should_continue = function() + return _G.should_continue + end, + }) + _G.queue:enqueue("ABCDEF") + ]]) + + -- Let it process a bit + flush(30) + + -- Stop processing + child.lua([[_G.should_continue = false]]) + + -- Wait to ensure it stops + flush(50) + + local output = child.lua_get("_G.output") + -- Should have processed some but not all characters + eq(#output < 6, true) + eq(#output > 0, true) +end + +T["edge cases"] = MiniTest.new_set() + +T["edge cases"]["handles empty text gracefully"] = function() + child.lua([[ + _G.callback_called = false + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.callback_called = true + end) + _G.queue:enqueue("") + ]]) + + flush(50) + + -- Empty text should not trigger processing + eq(child.lua_get("_G.callback_called"), false) +end + +T["edge cases"]["handles nil text gracefully"] = function() + child.lua([[ + _G.callback_called = false + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.callback_called = true + end) + _G.queue:enqueue(nil) + ]]) + + flush(50) + + -- Nil text should not trigger processing + eq(child.lua_get("_G.callback_called"), false) +end + +T["edge cases"]["processes queue even with rapid enqueues"] = function() + child.lua([[ + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 2, + tick_delay = 5, + }) + -- Rapidly enqueue multiple items + for i = 1, 10 do + _G.queue:enqueue(tostring(i)) + end + ]]) + + -- Wait for all processing to complete + flush(500) + + eq(child.lua_get("_G.output"), "12345678910") + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["typing speed configuration"] = MiniTest.new_set() + +T["typing speed configuration"]["default speed processes at expected rate"] = function() + child.lua([[ + _G.start_time = vim.loop.hrtime() + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + if is_complete then + _G.end_time = vim.loop.hrtime() + end + end, { + chars_per_tick = 1, -- Default: 1 char at a time + tick_delay = 10, -- Default: 10ms delay + }) + _G.queue:enqueue("ABCDE") -- 5 characters + ]]) + + -- With 1 char per tick and 10ms delay, 5 chars should take at least 40ms + flush(100) + + eq(child.lua_get("_G.output"), "ABCDE") + local duration_ns = child.lua_get("_G.end_time - _G.start_time") + local duration_ms = duration_ns / 1000000 + -- Should take at least 40ms (5 chars * 10ms - overhead for first char) + eq(duration_ms >= 30, true) +end + +T["typing speed configuration"]["fast speed processes quickly"] = function() + child.lua([[ + _G.output = "" + _G.chunks_count = 0 + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + _G.chunks_count = _G.chunks_count + 1 + end, { + chars_per_tick = 3, -- Fast: 3 chars at a time + tick_delay = 2, -- Fast: 2ms delay + }) + _G.queue:enqueue("ABCDEFGHI") -- 9 characters + ]]) + + flush(50) + + eq(child.lua_get("_G.output"), "ABCDEFGHI") + -- With 3 chars per tick, 9 chars should take 3 chunks + eq(child.lua_get("_G.chunks_count"), 3) +end + +T["typing speed configuration"]["slow speed processes slowly"] = function() + child.lua([[ + _G.start_time = vim.loop.hrtime() + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + if is_complete then + _G.end_time = vim.loop.hrtime() + end + end, { + chars_per_tick = 1, -- Slow: 1 char at a time + tick_delay = 30, -- Slow: 30ms delay + }) + _G.queue:enqueue("ABC") -- 3 characters + ]]) + + flush(150) + + eq(child.lua_get("_G.output"), "ABC") + local duration_ns = child.lua_get("_G.end_time - _G.start_time") + local duration_ms = duration_ns / 1000000 + -- Should take at least 60ms (3 chars * 30ms - overhead for first char) + eq(duration_ms >= 50, true) +end + +T["typing speed configuration"]["instant display with large chars_per_tick"] = function() + child.lua([[ + _G.output = "" + _G.chunks_count = 0 + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + _G.chunks_count = _G.chunks_count + 1 + end, { + chars_per_tick = 1000, -- Instant: large batch + tick_delay = 0, -- Instant: no delay + }) + _G.queue:enqueue("Hello World!") -- 12 characters + ]]) + + flush(50) + + eq(child.lua_get("_G.output"), "Hello World!") + -- Should process in 1 chunk since chars_per_tick is larger than text + eq(child.lua_get("_G.chunks_count"), 1) +end + +T["typing speed configuration"]["different speeds for different queues"] = function() + child.lua([[ + _G.output_fast = "" + _G.output_slow = "" + + _G.queue_fast = _G.StreamQueue.new(function(chunk, is_complete) + _G.output_fast = _G.output_fast .. chunk + end, { + chars_per_tick = 5, + tick_delay = 1, + }) + + _G.queue_slow = _G.StreamQueue.new(function(chunk, is_complete) + _G.output_slow = _G.output_slow .. chunk + end, { + chars_per_tick = 1, + tick_delay = 20, + }) + + _G.queue_fast:enqueue("FAST") + _G.queue_slow:enqueue("SLOW") + ]]) + + -- Fast should complete quickly + flush(50) + eq(child.lua_get("_G.output_fast"), "FAST") + + -- Slow may still be processing + flush(150) + eq(child.lua_get("_G.output_slow"), "SLOW") +end + +-- Integration tests with Sidebar +T["sidebar integration"] = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[ + -- Setup complete environment with Server, State, Mediator, Sidebar + _G.Server = require('eca.server').new() + _G.State = require('eca.state').new() + _G.Mediator = require('eca.mediator').new(_G.Server, _G.State) + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Sidebar:open() + ]]) + end, + post_case = function() + child.lua([[ if _G.Sidebar then _G.Sidebar:close() end ]]) + end, + }, +}) + +T["sidebar integration"]["initializes stream queue with default config"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + _G.queue_info = { + exists = Sidebar._stream_queue ~= nil, + is_empty = Sidebar._stream_queue and Sidebar._stream_queue:is_empty() or false, + } + ]]) + + local info = child.lua_get("_G.queue_info") + eq(info.exists, true) + eq(info.is_empty, true) +end + +T["sidebar integration"]["streams text with typing effect when enabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 2, + tick_delay = 5, + }, + }, + }, + }) + + -- Recreate sidebar with new config + _G.Sidebar:close() + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Sidebar:open() + + local Sidebar = _G.Sidebar + + -- Simulate streaming text + Sidebar:handle_chat_content_received({ + chatId = 'chat-typing', + content = { + type = 'text', + text = 'Hello', + }, + }) + + -- Add another chunk (simulates multiple streaming updates) + Sidebar:handle_chat_content_received({ + chatId = 'chat-typing', + content = { + type = 'text', + text = ' World', + }, + }) + ]]) + + -- Wait for typing to complete with faster settings + flush(200) + + child.lua([[ + local Sidebar = _G.Sidebar + local total = Sidebar._current_response_buffer or "" + _G.typing_info = { + total = total, + total_len = #total, + queue_empty = Sidebar._stream_queue:is_empty(), + } + ]]) + + local info = child.lua_get("_G.typing_info") + eq(info.total_len, 11) -- "Hello World" + eq(info.queue_empty, true) +end + +T["sidebar integration"]["displays instantly when typing disabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = false, + }, + }, + }, + }) + + -- Recreate sidebar with new config + _G.Sidebar:close() + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Sidebar:open() + + local Sidebar = _G.Sidebar + + -- Simulate streaming text + Sidebar:handle_chat_content_received({ + chatId = 'chat-instant', + content = { + type = 'text', + text = 'Instant Display', + }, + }) + ]]) + + -- With typing disabled, text should appear immediately + flush(50) + + child.lua([[ + local Sidebar = _G.Sidebar + _G.instant_info = { + visible = Sidebar._stream_visible_buffer or "", + total = Sidebar._current_response_buffer or "", + } + ]]) + + local info = child.lua_get("_G.instant_info") + eq(info.visible, "Instant Display") + eq(info.total, "Instant Display") +end + +T["sidebar integration"]["respects custom typing speed"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 3, + tick_delay = 5, + }, + }, + }, + }) + + -- Recreate sidebar with new config + _G.Sidebar:close() + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Sidebar:open() + + local Sidebar = _G.Sidebar + + -- Simulate streaming text + Sidebar:handle_chat_content_received({ + chatId = 'chat-fast', + content = { + type = 'text', + text = 'ABCDEFGHI', + }, + }) + ]]) + + -- With chars_per_tick=3, should type faster + flush(80) + + local final = child.lua_get("_G.Sidebar._stream_visible_buffer") + eq(final, "ABCDEFGHI") +end + +T["sidebar integration"]["clears queue on new chat"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Start streaming + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'text', + text = 'First message', + }, + }) + ]]) + + flush(30) + + child.lua([[ + local Sidebar = _G.Sidebar + _G.queue_size_before = Sidebar._stream_queue:size() + + -- Reset for new chat + Sidebar:new_chat() + + _G.queue_size_after = Sidebar._stream_queue:size() + ]]) + + local size_after = child.lua_get("_G.queue_size_after") + + eq(size_after, 0) + eq(child.lua_get("_G.Sidebar._stream_queue:is_empty()"), true) +end + +T["sidebar integration"]["handles multiple text chunks in sequence"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Simulate multiple streaming chunks + Sidebar:handle_chat_content_received({ + chatId = 'chat-multi', + content = { + type = 'text', + text = 'First ', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-multi', + content = { + type = 'text', + text = 'Second ', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-multi', + content = { + type = 'text', + text = 'Third', + }, + }) + ]]) + + -- Wait for all chunks to be processed + flush(300) + + local final = child.lua_get("_G.Sidebar._current_response_buffer") + eq(final, "First Second Third") +end + +return T diff --git a/tests/test_utils.lua b/tests/test_utils.lua new file mode 100644 index 0000000..ec6671a --- /dev/null +++ b/tests/test_utils.lua @@ -0,0 +1,70 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + end, + post_once = child.stop, + }, +}) + +T["utils"] = MiniTest.new_set() + +T["utils"]["shorten_tokens formats numbers correctly"] = function() + child.lua([[ + local Utils = require('eca.utils') + _G.results = { + small = Utils.shorten_tokens(999), + exact_k = Utils.shorten_tokens(1000), + over_k = Utils.shorten_tokens(1500), + large = Utils.shorten_tokens(42000), + very_large = Utils.shorten_tokens(1234567), + nil_input = Utils.shorten_tokens(nil), + string_input = Utils.shorten_tokens("1500"), + } + ]]) + + local results = child.lua_get("_G.results") + + eq(results.small, "999") + eq(results.exact_k, "1k") + eq(results.over_k, "2k") -- Rounds 1500 to 2k + eq(results.large, "42k") + eq(results.very_large, "1235k") + eq(results.nil_input, "0") + eq(results.string_input, "2k") +end + +T["utils"]["split_lines handles various line endings"] = function() + child.lua([[ + local Utils = require('eca.utils') + _G.results = { + unix = Utils.split_lines("line1\nline2\nline3"), + empty = Utils.split_lines(""), + single = Utils.split_lines("single"), + trailing = Utils.split_lines("line1\nline2\n"), + } + ]]) + + local results = child.lua_get("_G.results") + + eq(#results.unix, 3) + eq(results.unix[1], "line1") + eq(results.unix[2], "line2") + eq(results.unix[3], "line3") + + eq(#results.empty, 1) + eq(results.empty[1], "") + + eq(#results.single, 1) + eq(results.single[1], "single") + + -- Trailing newline should create an empty last line + eq(#results.trailing, 3) + eq(results.trailing[3], "") +end + +return T