Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# UI Architecture

> This document covers only the **UI layer** of eca-nvim.
> Other parts of the project (server communication, JSONRPC protocol, ECA process
> management, etc.) are not described here and will live outside of `ui/`.

## Directory Structure

The UI code lives under `fnl/eca/ui/`. Files at the `fnl/eca/` root (`api.fnl`,
`init.fnl`, `commands.fnl`) are shared infrastructure used by the UI and future
modules alike.

```
fnl/eca/
├── api.fnl # Neovim API adapter (flat functions, shared)
├── init.fnl # Entry point: setup(opts)
├── commands.fnl # User commands (:EcaChat, :EcaChatSubmit, etc.)
└── ui/ # ← Everything below is UI-specific
├── canvas.fnl # Canvas protocol (abstract contract)
├── builder.fnl # Orchestrator: builds canvas, manages widgets
├── highlights.fnl # Highlight group definitions
├── components/ # Stateless pure functions
│ ├── key-value.fnl # "key:value" pairs
│ ├── separator.fnl # Horizontal separator lines
│ ├── icon.fnl # Unicode icons (⏵ ⏷ ⏳ ✅ ❌ 🚧)
│ ├── message.fnl # Chat message blocks
│ ├── prompt-prefix.fnl # "> " or "⏳ " prefix
│ ├── button.fnl # Action buttons
│ ├── context-item.fnl # @file @repoMap @cursor mentions
│ ├── spinner.fnl # Loading animation frames
│ └── usage.fnl # Token/cost display
└── widgets/ # Stateful, compose components
├── header-bar.fnl # Winbar: model, agent, mcps
├── message-list.fnl # Scrollable message list
├── expandable-block.fnl # Collapsible blocks (tool calls, thinking)
├── prompt-area.fnl # Separator + contexts + input
├── context-bar.fnl # Attached @context items
├── status-bar.fnl # Statusline: workspace, usage, trust
└── tab-bar.fnl # Multiple chat tabs
```

## Layers

The architecture has four distinct layers, each with a clear responsibility:

### 1. `api.fnl` — Neovim Adapter

A flat module of functions that wrap `vim.api.*`. This is the **only file** in the
project that calls Neovim APIs directly. Everything else goes through it.

```fennel
;; Example usage:
(local api (require :eca.api))
(api.buf-set-lines buf 0 -1 ["hello"])
(api.set-hl 0 "EcaUser" {:fg "#61afef" :bold true})
(api.create-user-command "EcaChat" my-fn {})
```

This keeps the codebase portable and testable — you can mock `api` in tests
without touching Neovim internals.

### 2. Components — Stateless Render Functions

Pure functions that receive props and return **declarative render data**
(text + highlight metadata). They never call any API, never hold state.

```fennel
;; Component signature:
;; (render props) → {: text : hl-group} or {: line : highlights} or {: lines : highlights}

(local icon (require :eca.ui.components.icon))
(icon.render {:name :success})
;; → {:text "✅" :hl-group :EcaToolCallSuccess}
```

### 3. Widgets — Stateful UI Elements

Compose multiple components, maintain local state, and render to the buffer
through an injected **canvas**.

```fennel
;; Widget signature:
;; (create canvas ?initial-state) → {: render : update : get-state ...}

(local header-bar (require :eca.ui.widgets.header-bar))
(local widget (header-bar.create canvas {:model "claude" :agent "coder"}))
(widget.render)
(widget.update {:model "gpt-4o"})
```

Widgets receive the canvas at creation time via dependency injection. They never
import `api.fnl` or `vim.api` directly.

### 4. Builder — Orchestrator

The builder is the integration point. It:

1. Receives the `api` module as a dependency
2. **Builds a canvas** from api functions (binding them to a specific buf/win)
3. Creates and manages all widgets
4. Exposes the public chat-ui API
5. Wires up callbacks (on-submit, on-approve, etc.)

```fennel
(local builder (require :eca.ui.builder))
(local chat-ui (builder.create-chat-ui
{:api api
:on-submit (fn [text] ...)
:opts {:ui {:width 0.4}}}))
(chat-ui.toggle)
```

## Data Flow

```
┌──────────────┐
│ init.fnl │ setup(opts) → creates chat-ui
└──────┬───────┘
│ injects api + callbacks
┌──────────────┐
│ builder.fnl │ builds canvas from api, creates widgets
└──────┬───────┘
│ injects canvas
┌──────────────┐
│ widgets/ │ stateful, compose components
└──────┬───────┘
│ calls render()
┌──────────────┐
│ components/ │ pure (props) → {text, highlights}
└──────────────┘

Rendering pipeline:
widget.render() → component.render(props) → canvas.set-lines/add-extmark → api.buf-set-lines → vim.api
```


92 changes: 92 additions & 0 deletions fnl/eca/api.fnl
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
;; ECA API — chat registry + public functions.
;; Commands and external consumers use this module.

(local self {})
(local chats {})
(var plugin-opts {})

;; ── Chat registry ───────────────────────────────────────

(fn self.resolve-chat []
"Find the chat for the current buffer, or any open chat."
(let [current (. chats (vim.api.nvim_get_current_buf))]
(or current
(do (var found nil)
(each [_ chat (pairs chats)]
(when (and (not found) (chat.is-open?))
(set found chat)))
found))))

(fn self.register-chat [chat]
(let [buf-id (chat.get-buf-id)]
(when buf-id
(tset chats buf-id chat))))

;; ── Chat public API ─────────────────────────────────────

(fn self.chat-open []
(let [existing (self.resolve-chat)]
(when (not (and existing (existing.is-open?)))
(let [builder (require :eca.ui.builder)
chat-ui (builder.create-chat-ui
{:on-submit (or plugin-opts.on-submit self.default-on-submit)
:opts {:ui (or plugin-opts.ui {})
:keymaps (or plugin-opts.keymaps
[{:mode :i :lhs "<C-s>" :rhs "<cmd>EcaChatSubmit<CR>"}
{:mode :n :lhs "<CR>" :rhs "<cmd>EcaChatSubmit<CR>"}])}})]
(chat-ui.open)
(self.register-chat chat-ui)
;; Mock server data
(chat-ui.set-welcome "Welcome to ECA Chat")
(chat-ui.update-header [{:title "model" :value "claude"}
{:title "behavior" :value "agent"}])
(chat-ui.update-footer [{:value "ECA Chat"}
{:title "tokens" :value "0/200K"}])))))

(fn self.chat-close []
(let [chat (self.resolve-chat)]
(when chat (chat.close))))

(fn self.chat-toggle []
(let [chat (self.resolve-chat)]
(if (and chat (chat.is-open?))
(chat.close)
(self.chat-open))))

(fn self.chat-submit []
(let [chat (self.resolve-chat)]
(when chat (chat.submit-prompt))))

(fn self.chat-clear []
(let [chat (self.resolve-chat)]
(when chat (chat.clear-messages))))

(fn self.chat-set-model [model]
(let [chat (self.resolve-chat)]
(when chat (chat.update-header-item "model" model))))

(fn self.chat-set-loading [bool]
(let [chat (self.resolve-chat)]
(when chat (chat.set-loading bool))))

(fn self.default-on-submit [text]
(let [chat (self.resolve-chat)]
(when chat
(chat.append-message
{:id (tostring (os.time))
:content text
:prefix "> "})
(chat.set-loading true)
(vim.defer_fn
(fn []
(when (chat.is-open?)
(chat.append-message
{:id (.. "reply-" (tostring (os.time)))
:content (.. "You said: " text "\n\n(This is a mock response)")})
(chat.set-loading false)))
500))))

(fn self.set-plugin-opts [opts]
(set plugin-opts (or opts {})))

self
42 changes: 42 additions & 0 deletions fnl/eca/commands.fnl
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
;; commands — Vim user commands for ECA.
;; Uses the public chat API from api.fnl.

(local nvim vim.api)

(fn setup [api]
"Register all :Eca* user commands."
(nvim.nvim_create_user_command "EcaChat"
(fn [] (api.chat-toggle))
{:desc "Toggle ECA Chat window"})

(nvim.nvim_create_user_command "EcaChatOpen"
(fn [] (api.chat-open))
{:desc "Open ECA Chat window"})

(nvim.nvim_create_user_command "EcaChatClose"
(fn [] (api.chat-close))
{:desc "Close ECA Chat window"})

(nvim.nvim_create_user_command "EcaChatClear"
(fn [] (api.chat-clear))
{:desc "Clear current chat messages"})

(nvim.nvim_create_user_command "EcaChatNew"
(fn [] (api.chat-open))
{:desc "Open a new ECA Chat"})

(nvim.nvim_create_user_command "EcaChatSubmit"
(fn [] (api.chat-submit))
{:desc "Submit current prompt"})

(nvim.nvim_create_user_command "EcaChatStop"
(fn [] (api.chat-set-loading false))
{:desc "Stop current ECA response"})

(nvim.nvim_create_user_command "EcaChatSetModel"
(fn [cmd]
(when (and cmd.args (not= "" cmd.args))
(api.chat-set-model cmd.args)))
{:desc "Set the model" :nargs 1}))

{: setup}
13 changes: 9 additions & 4 deletions fnl/eca/init.fnl
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
(local {: autoload} (require :eca.nfnl.module))
(local notify (autoload :eca.nfnl.notify))
;; ECA Neovim Plugin — entry point.
;; Minimal: just setup, delegates everything to api.fnl.

(fn setup []
(notify.info "Hello, World!"))
(local api (require :eca.api))
(local commands (require :eca.commands))

(fn setup [opts]
"Initialize ECA plugin."
(api.set-plugin-opts (or opts {}))
(commands.setup api))

{: setup}
Loading