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