From 65970b084d5633c4a8016f9b37b96de389de331c Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sun, 10 May 2026 15:08:35 -0300 Subject: [PATCH 1/8] first ui version --- docs/architecture.md | 142 +++++++++++ fnl/eca/api.fnl | 145 +++++++++++ fnl/eca/commands.fnl | 36 +++ fnl/eca/init.fnl | 44 +++- fnl/eca/ui/builder.fnl | 306 ++++++++++++++++++++++++ fnl/eca/ui/canvas.fnl | 43 ++++ fnl/eca/ui/components/button.fnl | 14 ++ fnl/eca/ui/components/context-item.fnl | 25 ++ fnl/eca/ui/components/icon.fnl | 39 +++ fnl/eca/ui/components/key-value.fnl | 23 ++ fnl/eca/ui/components/message.fnl | 53 ++++ fnl/eca/ui/components/prompt-prefix.fnl | 13 + fnl/eca/ui/components/separator.fnl | 16 ++ fnl/eca/ui/components/spinner.fnl | 20 ++ fnl/eca/ui/components/usage.fnl | 34 +++ fnl/eca/ui/highlights.fnl | 59 +++++ fnl/eca/ui/widgets/context-bar.fnl | 83 +++++++ fnl/eca/ui/widgets/expandable-block.fnl | 102 ++++++++ fnl/eca/ui/widgets/header-bar.fnl | 54 +++++ fnl/eca/ui/widgets/message-list.fnl | 112 +++++++++ fnl/eca/ui/widgets/prompt-area.fnl | 163 +++++++++++++ fnl/eca/ui/widgets/status-bar.fnl | 83 +++++++ fnl/eca/ui/widgets/tab-bar.fnl | 79 ++++++ lua/eca/api.lua | 116 +++++++++ lua/eca/commands.lua | 33 +++ lua/eca/init.lua | 30 ++- lua/eca/ui/builder.lua | 296 +++++++++++++++++++++++ lua/eca/ui/canvas.lua | 20 ++ lua/eca/ui/components/button.lua | 14 ++ lua/eca/ui/components/context-item.lua | 26 ++ lua/eca/ui/components/icon.lua | 10 + lua/eca/ui/components/key-value.lua | 16 ++ lua/eca/ui/components/message.lua | 31 +++ lua/eca/ui/components/prompt-prefix.lua | 10 + lua/eca/ui/components/separator.lua | 10 + lua/eca/ui/components/spinner.lua | 12 + lua/eca/ui/components/usage.lua | 42 ++++ lua/eca/ui/highlights.lua | 9 + lua/eca/ui/widgets/context-bar.lua | 83 +++++++ lua/eca/ui/widgets/expandable-block.lua | 102 ++++++++ lua/eca/ui/widgets/header-bar.lua | 47 ++++ lua/eca/ui/widgets/message-list.lua | 96 ++++++++ lua/eca/ui/widgets/prompt-area.lua | 134 +++++++++++ lua/eca/ui/widgets/status-bar.lua | 69 ++++++ lua/eca/ui/widgets/tab-bar.lua | 86 +++++++ 45 files changed, 2971 insertions(+), 9 deletions(-) create mode 100644 docs/architecture.md create mode 100644 fnl/eca/api.fnl create mode 100644 fnl/eca/commands.fnl create mode 100644 fnl/eca/ui/builder.fnl create mode 100644 fnl/eca/ui/canvas.fnl create mode 100644 fnl/eca/ui/components/button.fnl create mode 100644 fnl/eca/ui/components/context-item.fnl create mode 100644 fnl/eca/ui/components/icon.fnl create mode 100644 fnl/eca/ui/components/key-value.fnl create mode 100644 fnl/eca/ui/components/message.fnl create mode 100644 fnl/eca/ui/components/prompt-prefix.fnl create mode 100644 fnl/eca/ui/components/separator.fnl create mode 100644 fnl/eca/ui/components/spinner.fnl create mode 100644 fnl/eca/ui/components/usage.fnl create mode 100644 fnl/eca/ui/highlights.fnl create mode 100644 fnl/eca/ui/widgets/context-bar.fnl create mode 100644 fnl/eca/ui/widgets/expandable-block.fnl create mode 100644 fnl/eca/ui/widgets/header-bar.fnl create mode 100644 fnl/eca/ui/widgets/message-list.fnl create mode 100644 fnl/eca/ui/widgets/prompt-area.fnl create mode 100644 fnl/eca/ui/widgets/status-bar.fnl create mode 100644 fnl/eca/ui/widgets/tab-bar.fnl create mode 100644 lua/eca/api.lua create mode 100644 lua/eca/commands.lua create mode 100644 lua/eca/ui/builder.lua create mode 100644 lua/eca/ui/canvas.lua create mode 100644 lua/eca/ui/components/button.lua create mode 100644 lua/eca/ui/components/context-item.lua create mode 100644 lua/eca/ui/components/icon.lua create mode 100644 lua/eca/ui/components/key-value.lua create mode 100644 lua/eca/ui/components/message.lua create mode 100644 lua/eca/ui/components/prompt-prefix.lua create mode 100644 lua/eca/ui/components/separator.lua create mode 100644 lua/eca/ui/components/spinner.lua create mode 100644 lua/eca/ui/components/usage.lua create mode 100644 lua/eca/ui/highlights.lua create mode 100644 lua/eca/ui/widgets/context-bar.lua create mode 100644 lua/eca/ui/widgets/expandable-block.lua create mode 100644 lua/eca/ui/widgets/header-bar.lua create mode 100644 lua/eca/ui/widgets/message-list.lua create mode 100644 lua/eca/ui/widgets/prompt-area.lua create mode 100644 lua/eca/ui/widgets/status-bar.lua create mode 100644 lua/eca/ui/widgets/tab-bar.lua diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5d570b0 --- /dev/null +++ b/docs/architecture.md @@ -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 +``` + + diff --git a/fnl/eca/api.fnl b/fnl/eca/api.fnl new file mode 100644 index 0000000..0e650ad --- /dev/null +++ b/fnl/eca/api.fnl @@ -0,0 +1,145 @@ +;; Neovim API adapter — flat functions wrapping vim.api.* +;; This is the ONLY file that touches vim.api.* directly. +;; Every other module uses these functions instead of calling Neovim directly. + +(local nvim vim.api) + +;; ── Buffer ────────────────────────────────────────────── + +(fn buf-set-lines [buf start end lines] + (nvim.nvim_buf_set_lines buf start end false lines)) + +(fn buf-get-lines [buf start end] + (nvim.nvim_buf_get_lines buf start end false)) + +(fn buf-line-count [buf] + (nvim.nvim_buf_line_count buf)) + +(fn buf-is-valid [buf] + (and (not= nil buf) (nvim.nvim_buf_is_valid buf))) + +(fn buf-create [opts] + "Create a new buffer. opts: {: listed : scratch}" + (let [o (or opts {})] + (nvim.nvim_create_buf + (if (not= nil o.listed) o.listed false) + (if (not= nil o.scratch) o.scratch true)))) + +(fn buf-set-keymap [buf mode lhs rhs opts] + (nvim.nvim_buf_set_keymap buf mode lhs rhs (or opts {}))) + +;; ── Window ────────────────────────────────────────────── + +(fn win-open [buf opts] + "Open a new window. Returns win-id." + (nvim.nvim_open_win buf true (or opts {}))) + +(fn win-close [win] + (when (and win (nvim.nvim_win_is_valid win)) + (nvim.nvim_win_close win true))) + +(fn win-is-valid [win] + (and (not= nil win) (nvim.nvim_win_is_valid win))) + +(fn win-get-cursor [win] + (when win (nvim.nvim_win_get_cursor win))) + +(fn win-set-cursor [win pos] + (when win (nvim.nvim_win_set_cursor win pos))) + +;; ── Extmarks ──────────────────────────────────────────── + +(fn create-namespace [name] + (nvim.nvim_create_namespace name)) + +(fn buf-set-extmark [buf ns-id line col opts] + (nvim.nvim_buf_set_extmark buf ns-id line col (or opts {}))) + +(fn buf-del-extmark [buf ns-id id] + (nvim.nvim_buf_del_extmark buf ns-id id)) + +(fn buf-get-extmarks [buf ns-id start end opts] + (nvim.nvim_buf_get_extmarks buf ns-id start end (or opts {}))) + +;; ── Options ───────────────────────────────────────────── + +(fn set-option [scope id key value] + "Set an option. scope: :win or :buf. id: win-id or buf-id." + (match scope + :win (nvim.nvim_set_option_value key value {:win id}) + :buf (nvim.nvim_set_option_value key value {:buf id}))) + +(fn get-option [scope id key] + "Get an option. scope: :win or :buf." + (match scope + :win (nvim.nvim_get_option_value key {:win id}) + :buf (nvim.nvim_get_option_value key {:buf id}))) + +;; ── Highlights ────────────────────────────────────────── + +(fn set-hl [ns group opts] + (nvim.nvim_set_hl ns group opts)) + +;; ── Commands ──────────────────────────────────────────── + +(fn create-user-command [name f opts] + (nvim.nvim_create_user_command name f (or opts {}))) + +(fn create-autocmd [event opts] + (nvim.nvim_create_autocmd event opts)) + +;; ── Keymaps (global) ──────────────────────────────────── + +(fn set-keymap [mode lhs rhs opts] + (vim.keymap.set mode lhs rhs (or opts {}))) + +;; ── Scheduling ────────────────────────────────────────── + +(fn schedule [f] + (vim.schedule f)) + +(fn defer [f ms] + (vim.defer_fn f ms)) + +;; ── Editor info ───────────────────────────────────────── + +(fn editor-width [] + (. vim.o :columns)) + +(fn editor-height [] + (. vim.o :lines)) + +{;; Buffer + : buf-set-lines + : buf-get-lines + : buf-line-count + : buf-is-valid + : buf-create + : buf-set-keymap + ;; Window + : win-open + : win-close + : win-is-valid + : win-get-cursor + : win-set-cursor + ;; Extmarks + : create-namespace + : buf-set-extmark + : buf-del-extmark + : buf-get-extmarks + ;; Options + : set-option + : get-option + ;; Highlights + : set-hl + ;; Commands + : create-user-command + : create-autocmd + ;; Keymaps + : set-keymap + ;; Scheduling + : schedule + : defer + ;; Editor + : editor-width + : editor-height} diff --git a/fnl/eca/commands.fnl b/fnl/eca/commands.fnl new file mode 100644 index 0000000..ef4615e --- /dev/null +++ b/fnl/eca/commands.fnl @@ -0,0 +1,36 @@ +;; commands — Vim user commands for ECA. +;; Users map keymaps to these commands in their own config. + +(local api (require :eca.api)) + +(fn setup [chat-ui] + "Register all :Eca* user commands." + (api.create-user-command "EcaChat" + (fn [] (chat-ui.toggle)) + {:desc "Toggle ECA Chat window"}) + + (api.create-user-command "EcaChatOpen" + (fn [] (chat-ui.open)) + {:desc "Open ECA Chat window"}) + + (api.create-user-command "EcaChatClose" + (fn [] (chat-ui.close)) + {:desc "Close ECA Chat window"}) + + (api.create-user-command "EcaChatClear" + (fn [] (chat-ui.clear-messages)) + {:desc "Clear ECA Chat messages"}) + + (api.create-user-command "EcaChatNew" + (fn [] (chat-ui.clear-messages)) + {:desc "Start a new ECA Chat"}) + + (api.create-user-command "EcaChatSubmit" + (fn [] (chat-ui.submit-prompt)) + {:desc "Submit current prompt"}) + + (api.create-user-command "EcaChatStop" + (fn [] (chat-ui.set-loading false)) + {:desc "Stop current ECA response"})) + +{: setup} diff --git a/fnl/eca/init.fnl b/fnl/eca/init.fnl index 0752b83..557b61e 100644 --- a/fnl/eca/init.fnl +++ b/fnl/eca/init.fnl @@ -1,7 +1,43 @@ -(local {: autoload} (require :eca.nfnl.module)) -(local notify (autoload :eca.nfnl.notify)) +;; ECA Neovim Plugin — entry point. +;; setup(opts) initializes everything and registers :Eca* commands. -(fn setup [] - (notify.info "Hello, World!")) +(local api (require :eca.api)) +(local builder (require :eca.ui.builder)) +(local commands (require :eca.commands)) + +(var chat-ui nil) + +(fn default-on-submit [text] + "Default submit handler — echoes prompt as user message + mock assistant reply." + (when chat-ui + (chat-ui.append-message + {:id (tostring (os.time)) + :role :user + :content text}) + (chat-ui.set-loading true) + (api.defer + (fn [] + (when chat-ui + (chat-ui.append-message + {:id (.. "reply-" (tostring (os.time))) + :role :assistant + :content (.. "You said: " text "\n\n(This is a mock response — connect ECA server for real responses)")}) + (chat-ui.set-loading false))) + 500))) + +(fn setup [opts] + "Initialize ECA plugin. + opts: {: ui {: width : position} : on-submit}" + (let [user-opts (or opts {})] + + ;; Create chat UI via builder with injected api + (set chat-ui + (builder.create-chat-ui + {:api api + :on-submit (or user-opts.on-submit default-on-submit) + :opts {:ui (or user-opts.ui {})}})) + + ;; Register commands — users map their own keymaps to these + (commands.setup chat-ui))) {: setup} diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl new file mode 100644 index 0000000..042e364 --- /dev/null +++ b/fnl/eca/ui/builder.fnl @@ -0,0 +1,306 @@ +;; builder — orchestrates widgets, receives injected dependencies. +;; Builds a canvas from api functions and injects it into widgets. +;; The builder NEVER imports vim.api — all interaction goes through injected api. + +(local highlights (require :eca.ui.highlights)) +(local header-bar-widget (require :eca.ui.widgets.header-bar)) +(local message-list-widget (require :eca.ui.widgets.message-list)) +(local prompt-area-widget (require :eca.ui.widgets.prompt-area)) +(local status-bar-widget (require :eca.ui.widgets.status-bar)) +(local tab-bar-widget (require :eca.ui.widgets.tab-bar)) + +;; ── Canvas builder ────────────────────────────────────── + +(fn build-canvas [api buf-id win-id] + "Build a canvas object from flat api functions, bound to a specific buf/win. + This is the bridge between the flat api module and the canvas protocol + that widgets expect." + (var buf buf-id) + (var win win-id) + + {:set-lines + (fn [_ start end lines] + (api.buf-set-lines buf start end lines)) + + :get-lines + (fn [_ start end] + (api.buf-get-lines buf start end)) + + :line-count + (fn [_] + (api.buf-line-count buf)) + + :add-extmark + (fn [_ ns-id line col opts] + (api.buf-set-extmark buf ns-id line col opts)) + + :del-extmark + (fn [_ ns-id id] + (api.buf-del-extmark buf ns-id id)) + + :get-extmarks + (fn [_ ns-id start end opts] + (api.buf-get-extmarks buf ns-id start end opts)) + + :create-namespace + (fn [_ name] + (api.create-namespace name)) + + :set-option + (fn [_ scope key value] + (match scope + :win (api.set-option :win win key value) + :buf (api.set-option :buf buf key value))) + + :get-option + (fn [_ scope key] + (match scope + :win (api.get-option :win win key) + :buf (api.get-option :buf buf key))) + + :get-cursor + (fn [_] + (api.win-get-cursor win)) + + :set-cursor + (fn [_ line col] + (api.win-set-cursor win [line col])) + + :buf-valid? + (fn [_] + (api.buf-is-valid buf)) + + :win-valid? + (fn [_] + (api.win-is-valid win)) + + :set-modifiable + (fn [_ bool] + (api.set-option :buf buf :modifiable bool)) + + :set-hl + (fn [_ ns group opts] + (api.set-hl ns group opts)) + + :close-win + (fn [_] + (api.win-close win) + (set win nil)) + + :buf-id (fn [_] buf) + :win-id (fn [_] win)}) + +;; ── Buffer/window setup ───────────────────────────────── + +(fn setup-chat-buffer [canvas] + "Configure the chat buffer options." + (canvas:set-option :buf :buftype "nofile") + (canvas:set-option :buf :bufhidden "hide") + (canvas:set-option :buf :swapfile false) + (canvas:set-option :buf :filetype "eca-chat") + (canvas:set-option :buf :wrap true) + (canvas:set-option :buf :linebreak true) + (canvas:set-option :buf :modifiable false)) + +(fn setup-chat-window [canvas] + "Configure the chat window options." + (canvas:set-option :win :number false) + (canvas:set-option :win :relativenumber false) + (canvas:set-option :win :signcolumn "no") + (canvas:set-option :win :foldcolumn "0") + (canvas:set-option :win :spell false) + (canvas:set-option :win :wrap true) + (canvas:set-option :win :linebreak true) + (canvas:set-option :win :conceallevel 2)) + +;; ── Main entry ────────────────────────────────────────── + +(fn create-chat-ui [{: api : on-submit : on-approve : on-reject + : on-stop : on-new-chat : on-select-tab + : on-context-add : opts}] + "Create the chat UI. Receives injected dependencies. + api: flat module of Neovim API functions (from eca.api) + on-*: callback functions for user actions + opts: {: ui} where ui contains {: width : position} + + Returns chat-ui with public API." + (let [ui-config (or opts.ui {}) + config {:width (or ui-config.width 0.4) + :position (or ui-config.position :right)}] + + (var canvas nil) + (var widgets {:header nil + :messages nil + :prompt nil + :status nil + :tabs nil}) + + (fn is-open? [] + (and (not= nil canvas) + (canvas:buf-valid?) + (canvas:win-valid?))) + + (fn render-all [] + "Full render of all widgets." + (widgets.header.render) + (widgets.messages.render) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)) + (widgets.status.render) + (widgets.tabs.render)) + + (fn open [] + "Open the chat window." + (when (not (is-open?)) + (let [buf-id (api.buf-create {:listed false :scratch true}) + win-width (math.floor (* (api.editor-width) config.width)) + win-id (api.win-open buf-id + {:split :right + :width win-width})] + ;; Build canvas from api functions + buf/win ids + (set canvas (build-canvas api buf-id win-id)) + + ;; Setup highlights, buffer and window options + (highlights.setup canvas) + (setup-chat-buffer canvas) + (setup-chat-window canvas) + + ;; Create widgets — all receive canvas (never api directly) + (set widgets.header + (header-bar-widget.create canvas {})) + (set widgets.messages + (message-list-widget.create canvas)) + (set widgets.prompt + (prompt-area-widget.create canvas)) + (set widgets.status + (status-bar-widget.create canvas {})) + (set widgets.tabs + (tab-bar-widget.create canvas + {:tabs [{:id 1 :title "Chat 1"}] + :active-id 1})) + + ;; Initial render + (canvas:set-modifiable true) + (canvas:set-lines 0 -1 [""]) + (canvas:set-modifiable false) + (render-all)))) + + (fn close [] + "Close the chat window." + (when (is-open?) + (canvas:close-win))) + + (fn toggle [] + "Toggle the chat window." + (if (is-open?) + (close) + (open))) + + ;; === Message API === + (fn append-message [msg] + (when (is-open?) + (widgets.messages.append-message msg) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))) + + (fn update-message [id content] + (when (is-open?) + (widgets.messages.update-message id content) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))) + + (fn clear-messages [] + (when (is-open?) + (widgets.messages.clear) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))) + + ;; === Tool call API (TODO) === + (fn show-tool-call [tc] nil) + (fn update-tool-call [id status] nil) + (fn show-approval [tc] nil) + + ;; === Status API === + (fn update-model-info [info] + (when (is-open?) + (widgets.header.update info))) + + (fn update-usage [usage] + (when (is-open?) + (widgets.status.update usage))) + + (fn update-progress [progress] + (when (is-open?) + (widgets.status.update {:init-progress progress}))) + + ;; === Context API === + (fn add-context [ctx] + (when (is-open?) + (widgets.prompt.add-context ctx) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))) + + (fn remove-context [name] + (when (is-open?) + (widgets.prompt.remove-context name) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))) + + ;; === Tab API === + (fn add-chat-tab [tab] + (when (is-open?) + (widgets.tabs.add-tab tab) + (widgets.tabs.render))) + + (fn remove-chat-tab [id] + (when (is-open?) + (widgets.tabs.remove-tab id) + (widgets.tabs.render))) + + (fn select-chat-tab [id] + (when (is-open?) + (widgets.tabs.select-tab id) + (widgets.tabs.render))) + + ;; === Prompt access === + (fn get-prompt-text [] + (when (is-open?) + (widgets.prompt.get-text))) + + (fn submit-prompt [] + (when (is-open?) + (let [text (widgets.prompt.get-text)] + (when (and text (not= "" text)) + (widgets.prompt.add-to-history text) + (widgets.prompt.clear) + (when on-submit + (on-submit text)))))) + + (fn set-loading [bool] + (when (is-open?) + (widgets.prompt.set-loading bool))) + + ;; Public API + {: open + : close + : toggle + : is-open? + : append-message + : update-message + : clear-messages + : show-tool-call + : update-tool-call + : show-approval + : update-model-info + : update-usage + : update-progress + : add-context + : remove-context + : add-chat-tab + : remove-chat-tab + : select-chat-tab + : get-prompt-text + : submit-prompt + : set-loading})) + +{: create-chat-ui} diff --git a/fnl/eca/ui/canvas.fnl b/fnl/eca/ui/canvas.fnl new file mode 100644 index 0000000..e034dac --- /dev/null +++ b/fnl/eca/ui/canvas.fnl @@ -0,0 +1,43 @@ +;; Canvas protocol — abstract contract for rendering operations. +;; Any canvas implementation must provide all functions listed here. +;; The UI layer (components, widgets, builder) depends ONLY on this contract. + +(local protocol-keys + [:set-lines ;; (canvas start end lines) — replace lines in buffer + :get-lines ;; (canvas start end) — read lines from buffer + :add-extmark ;; (canvas ns-id line col opts) — add extmark, returns id + :del-extmark ;; (canvas ns-id id) — remove extmark + :get-extmarks ;; (canvas ns-id start end opts) — get extmarks in range + :create-namespace ;; (canvas name) — create namespace for extmarks, returns ns-id + :set-option ;; (canvas scope key value) — set option (scope: :win or :buf) + :get-option ;; (canvas scope key) — get option value + :line-count ;; (canvas) — total line count in buffer + :get-cursor ;; (canvas) — returns [line col] + :set-cursor ;; (canvas line col) — move cursor + :buf-valid? ;; (canvas) — is buffer still valid? + :win-valid? ;; (canvas) — is window still valid? + :set-modifiable ;; (canvas bool) — toggle buffer readonly + :close-win ;; (canvas) — close window + :set-hl ;; (canvas ns group opts) — define highlight group + :buf-id ;; (canvas) — returns the underlying buffer id + :win-id ;; (canvas) — returns the underlying window id + ]) + +(fn validate [canvas] + "Validate that a canvas implementation satisfies the protocol. + Returns true if valid, or (false missing-keys) if not." + (var missing []) + (each [_ key (ipairs protocol-keys)] + (when (= nil (. canvas key)) + (table.insert missing key))) + (if (= 0 (length missing)) + true + (values false missing))) + +(fn describe [] + "Returns the list of protocol keys for documentation/introspection." + protocol-keys) + +{: validate + : describe + : protocol-keys} diff --git a/fnl/eca/ui/components/button.fnl b/fnl/eca/ui/components/button.fnl new file mode 100644 index 0000000..0c3c327 --- /dev/null +++ b/fnl/eca/ui/components/button.fnl @@ -0,0 +1,14 @@ +;; button component — renders clickable action buttons like [Accept] [Reject]. +;; Stateless, pure function. + +(fn render [{: label : hl-group : keybind}] + "Render a button. + Returns {: text : hl-group}. + Format: 'Label (keybind)' or just 'Label'." + (let [display (if keybind + (.. label " (" keybind ")") + label)] + {:text display + :hl-group (or hl-group :EcaButtonAccept)})) + +{: render} diff --git a/fnl/eca/ui/components/context-item.fnl b/fnl/eca/ui/components/context-item.fnl new file mode 100644 index 0000000..2e4565c --- /dev/null +++ b/fnl/eca/ui/components/context-item.fnl @@ -0,0 +1,25 @@ +;; context-item component — renders @context mentions. +;; Stateless, pure function. + +(local type-config + {:file {:prefix "@" :hl-group :EcaContextFile} + :dir {:prefix "@" :hl-group :EcaContextDir} + :repo-map {:prefix "@" :hl-group :EcaContextRepoMap :label "repoMap"} + :cursor {:prefix "@" :hl-group :EcaContextCursor} + :mcp {:prefix "@" :hl-group :EcaContextMcp}}) + +(fn render [{: type : name : detail}] + "Render a context item. + type: :file, :dir, :repo-map, :cursor, :mcp + Returns {: text : hl-group}." + (let [cfg (or (. type-config type) + {:prefix "@" :hl-group :EcaContextFile}) + display-name (or cfg.label name "") + text (match type + :cursor (.. cfg.prefix "cursor(" (or name "") (if detail (.. " " detail) "") ")") + :repo-map (.. cfg.prefix display-name) + _ (.. cfg.prefix display-name))] + {:text text + :hl-group cfg.hl-group})) + +{: render} diff --git a/fnl/eca/ui/components/icon.fnl b/fnl/eca/ui/components/icon.fnl new file mode 100644 index 0000000..928b730 --- /dev/null +++ b/fnl/eca/ui/components/icon.fnl @@ -0,0 +1,39 @@ +;; icon component — maps semantic names to unicode icons. +;; Stateless, pure function. + +(local icons + {:collapsed "⏵" + :expanded "⏷" + :pending "⏳" + :running "⏳" + :success "✅" + :error "❌" + :approval "🚧" + :loading "⏳" + :stop "⏹" + :new "+" + :close "×"}) + +(local icon-highlights + {:collapsed :EcaExpandableIcon + :expanded :EcaExpandableIcon + :pending :EcaToolCallPending + :running :EcaToolCallPending + :success :EcaToolCallSuccess + :error :EcaToolCallError + :approval :EcaToolCallApproval + :loading :EcaSpinner + :stop :EcaToolCallError + :new :EcaButtonAccept + :close :EcaButtonReject}) + +(fn render [{: name}] + "Render an icon by semantic name. + Returns {: text : hl-group}." + (let [icon (or (. icons name) "?") + hl (or (. icon-highlights name) :EcaExpandableIcon)] + {:text icon + :hl-group hl})) + +{: render + : icons} diff --git a/fnl/eca/ui/components/key-value.fnl b/fnl/eca/ui/components/key-value.fnl new file mode 100644 index 0000000..9cce6a6 --- /dev/null +++ b/fnl/eca/ui/components/key-value.fnl @@ -0,0 +1,23 @@ +;; key-value component — renders "key:value" pairs like "model:claude" +;; Stateless, pure function. + +(fn render [{: title : value : hl-title : hl-value}] + "Render a key:value pair. + Returns {: line : highlights} where highlights is a list of + {: hl-group : col-start : col-end}." + (let [title-str (or title "") + value-str (or value "") + separator ":" + line (.. title-str separator value-str) + title-end (length title-str) + value-start (+ title-end (length separator)) + value-end (+ value-start (length value-str))] + {:line line + :highlights [{:hl-group (or hl-title :EcaHeaderKey) + :col-start 0 + :col-end title-end} + {:hl-group (or hl-value :EcaHeaderValue) + :col-start value-start + :col-end value-end}]})) + +{: render} diff --git a/fnl/eca/ui/components/message.fnl b/fnl/eca/ui/components/message.fnl new file mode 100644 index 0000000..e024e8c --- /dev/null +++ b/fnl/eca/ui/components/message.fnl @@ -0,0 +1,53 @@ +;; message component — renders chat message blocks. +;; Stateless, pure function. + +(local role-config + {:user {:prefix " You" :hl-group :EcaUser} + :assistant {:prefix " ECA" :hl-group :EcaAssistant} + :system {:prefix " System" :hl-group :EcaSystem}}) + +(fn split-lines [text] + "Split text into lines." + (let [lines []] + (if (or (= nil text) (= "" text)) + (table.insert lines "") + (each [line (text:gmatch "([^\n]*)\n?")] + (table.insert lines line))) + lines)) + +(fn render [{: role : content}] + "Render a chat message. + Returns {: lines : highlights} where lines is a list of strings + and highlights is a list of {: line-idx : hl-group : col-start : col-end}." + (let [cfg (or (. role-config role) + {:prefix " ?" :hl-group :EcaAssistant}) + content-lines (split-lines (or content "")) + lines [cfg.prefix "" ] + highlights [{:line-idx 0 + :hl-group cfg.hl-group + :col-start 0 + :col-end (length cfg.prefix)}]] + ;; Add content lines + (each [_ line (ipairs content-lines)] + (table.insert lines line)) + ;; Add trailing empty line + (table.insert lines "") + {: lines : highlights})) + +(fn render-welcome [] + "Render a welcome message for empty chats. + Returns {: lines : highlights}." + (let [lines ["" + " Welcome to ECA Chat" + "" + " Type your message below and press Enter to send." + " Use @ to attach context (files, directories, etc.)" + ""]] + {:lines lines + :highlights [{:line-idx 1 + :hl-group :EcaWelcome + :col-start 0 + :col-end (length " Welcome to ECA Chat")}]})) + +{: render + : render-welcome} diff --git a/fnl/eca/ui/components/prompt-prefix.fnl b/fnl/eca/ui/components/prompt-prefix.fnl new file mode 100644 index 0000000..cbcff73 --- /dev/null +++ b/fnl/eca/ui/components/prompt-prefix.fnl @@ -0,0 +1,13 @@ +;; prompt-prefix component — renders "> " or "⏳ " based on loading state. +;; Stateless, pure function. + +(fn render [{: loading?}] + "Render the prompt prefix. + Returns {: text : hl-group}." + (if loading? + {:text "⏳ " + :hl-group :EcaPromptPrefixLoading} + {:text "> " + :hl-group :EcaPromptPrefix})) + +{: render} diff --git a/fnl/eca/ui/components/separator.fnl b/fnl/eca/ui/components/separator.fnl new file mode 100644 index 0000000..beb61b9 --- /dev/null +++ b/fnl/eca/ui/components/separator.fnl @@ -0,0 +1,16 @@ +;; separator component — renders a horizontal separator line. +;; Stateless, pure function. + +(fn render [{: char : width}] + "Render a separator line. + char defaults to '─', width defaults to 40. + Returns {: line : highlights}." + (let [c (or char "─") + w (or width 40) + line (string.rep c w)] + {:line line + :highlights [{:hl-group :EcaSeparator + :col-start 0 + :col-end (length line)}]})) + +{: render} diff --git a/fnl/eca/ui/components/spinner.fnl b/fnl/eca/ui/components/spinner.fnl new file mode 100644 index 0000000..1f1c2df --- /dev/null +++ b/fnl/eca/ui/components/spinner.fnl @@ -0,0 +1,20 @@ +;; spinner component — animated loading spinner. +;; Stateless, pure function (receives frame index). + +(local frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"]) + +(fn render [{: frame}] + "Render a spinner frame. + frame is a 0-based index, wraps around automatically. + Returns {: text : hl-group}." + (let [idx (+ (% (or frame 0) (length frames)) 1) + char (. frames idx)] + {:text char + :hl-group :EcaSpinner})) + +(fn frame-count [] + "Returns the total number of spinner frames." + (length frames)) + +{: render + : frame-count} diff --git a/fnl/eca/ui/components/usage.fnl b/fnl/eca/ui/components/usage.fnl new file mode 100644 index 0000000..a04ed7c --- /dev/null +++ b/fnl/eca/ui/components/usage.fnl @@ -0,0 +1,34 @@ +;; usage component — renders token usage and cost display. +;; Stateless, pure function. + +(fn format-tokens [n] + "Format token count: 1500 → '1.5K', 150000 → '150K'." + (if (= nil n) "0" + (>= n 1000000) (string.format "%.1fM" (/ n 1000000)) + (>= n 1000) (string.format "%.0fK" (/ n 1000)) + (tostring n))) + +(fn format-cost [cost] + "Format cost as dollar amount." + (if (= nil cost) nil + (string.format "$%.2f" cost))) + +(fn render [{: tokens-in : tokens-out : max-tokens : cost}] + "Render usage display. + Returns {: text : hl-group}. + Format: '31K/200K ($0.03)' or '31K/200K' if no cost." + (let [used (format-tokens (+ (or tokens-in 0) (or tokens-out 0))) + max (format-tokens max-tokens) + base (if max-tokens + (.. used "/" max) + used) + cost-str (format-cost cost) + text (if cost-str + (.. base " (" cost-str ")") + base)] + {:text text + :hl-group :EcaUsage})) + +{: render + : format-tokens + : format-cost} diff --git a/fnl/eca/ui/highlights.fnl b/fnl/eca/ui/highlights.fnl new file mode 100644 index 0000000..ff67804 --- /dev/null +++ b/fnl/eca/ui/highlights.fnl @@ -0,0 +1,59 @@ +;; Highlight groups — declarative definitions for all UI elements. +;; Receives a canvas to apply them. Supports dark and light themes. + +(local groups + {;; Messages + :EcaUser {:fg "#61afef" :bold true} + :EcaAssistant {:fg "#abb2bf"} + :EcaSystem {:fg "#5c6370" :italic true} + :EcaWelcome {:fg "#5c6370" :italic true} + :EcaSeparator {:fg "#3e4452"} + + ;; Prompt + :EcaPromptPrefix {:fg "#98c379" :bold true} + :EcaPromptPrefixLoading {:fg "#e5c07b"} + + ;; Tool calls + :EcaToolCallPending {:fg "#e5c07b"} + :EcaToolCallSuccess {:fg "#98c379"} + :EcaToolCallError {:fg "#e06c75"} + :EcaToolCallApproval {:fg "#d19a66" :bg "#3e3522" :bold true} + + ;; Expandable blocks + :EcaExpandableIcon {:fg "#5c6370"} + :EcaExpandableLabel {:bold true} + + ;; Context items + :EcaContextFile {:fg "#e06c75" :underline true} + :EcaContextDir {:fg "#e06c75" :underline true} + :EcaContextRepoMap {:fg "#56b6c2"} + :EcaContextCursor {:fg "#5c6370"} + :EcaContextMcp {:fg "#98c379"} + + ;; Header bar + :EcaHeaderKey {:fg "#5c6370"} + :EcaHeaderValue {:fg "#abb2bf" :bold true} + + ;; Status bar + :EcaUsage {:fg "#5c6370"} + :EcaElapsed {:fg "#5c6370"} + :EcaTrustOn {:fg "#e06c75" :bold true} + :EcaTrustOff {:fg "#5c6370"} + :EcaSpinner {:fg "#e5c07b"} + + ;; Buttons + :EcaButtonAccept {:fg "#98c379" :bold true} + :EcaButtonReject {:fg "#e06c75" :bold true} + + ;; Tab bar + :EcaTabActive {:fg "#abb2bf" :bold true} + :EcaTabInactive {:fg "#5c6370"} + :EcaTabLoading {:fg "#e5c07b"}}) + +(fn setup [canvas] + "Apply all highlight groups using the provided canvas." + (each [group opts (pairs groups)] + (canvas:set-hl 0 group opts))) + +{: groups + : setup} diff --git a/fnl/eca/ui/widgets/context-bar.fnl b/fnl/eca/ui/widgets/context-bar.fnl new file mode 100644 index 0000000..02ff56c --- /dev/null +++ b/fnl/eca/ui/widgets/context-bar.fnl @@ -0,0 +1,83 @@ +;; context-bar widget — horizontal bar of attached @contexts. +;; Stateful: manages list of contexts, renders via canvas. + +(local context-item-component (require :eca.ui.components.context-item)) + +(fn create [canvas] + "Create a context-bar widget. + Returns {: render : add : remove : clear : get-state}." + (var state {:contexts [] + :ns-id nil}) + + (fn ensure-ns [] + (when (= nil state.ns-id) + (set state.ns-id (canvas:create-namespace "eca-context-bar"))) + state.ns-id) + + (fn build-line [] + "Build the context bar line from current state." + (if (= 0 (length state.contexts)) + {:line "" :parts []} + (let [parts [] + highlights []] + (var col 0) + (each [i ctx (ipairs state.contexts)] + (when (> i 1) + (table.insert parts " ") + (set col (+ col 1))) + (let [rendered (context-item-component.render ctx)] + (table.insert parts rendered.text) + (table.insert highlights + {:hl-group rendered.hl-group + :col-start col + :col-end (+ col (length rendered.text))}) + (set col (+ col (length rendered.text))))) + {:line (table.concat parts "") + :highlights highlights}))) + + (fn render [line-num] + "Render the context bar at the given line number." + (let [ns (ensure-ns) + {: line : highlights} (build-line)] + (when (and line (not= "" line)) + (canvas:set-modifiable true) + (canvas:set-lines line-num (+ line-num 1) [line]) + ;; Apply highlights + (each [_ hl (ipairs (or highlights []))] + (canvas:add-extmark ns line-num hl.col-start + {:end_col hl.col-end + :hl_group hl.hl-group})) + (canvas:set-modifiable false)))) + + (fn add [ctx] + "Add a context item. ctx: {: type : name : path : detail}." + ;; Avoid duplicates by name + (var exists false) + (each [_ existing (ipairs state.contexts)] + (when (= existing.name ctx.name) + (set exists true))) + (when (not exists) + (table.insert state.contexts ctx))) + + (fn remove [name] + "Remove a context item by name." + (let [new-contexts []] + (each [_ ctx (ipairs state.contexts)] + (when (not= ctx.name name) + (table.insert new-contexts ctx))) + (set state.contexts new-contexts))) + + (fn clear [] + "Remove all contexts." + (set state.contexts [])) + + (fn get-state [] + state) + + {: render + : add + : remove + : clear + : get-state}) + +{: create} diff --git a/fnl/eca/ui/widgets/expandable-block.fnl b/fnl/eca/ui/widgets/expandable-block.fnl new file mode 100644 index 0000000..c4e26c4 --- /dev/null +++ b/fnl/eca/ui/widgets/expandable-block.fnl @@ -0,0 +1,102 @@ +;; expandable-block widget — collapsible blocks for tool calls, reasoning, etc. +;; Stateful: tracks expanded/collapsed state, renders via canvas. + +(local icon-component (require :eca.ui.components.icon)) + +(fn format-elapsed [ms] + "Format milliseconds to human readable: '3s', '1m 23s'." + (if (= nil ms) "" + (let [seconds (math.floor (/ ms 1000))] + (if (>= seconds 60) + (let [mins (math.floor (/ seconds 60)) + secs (% seconds 60)] + (.. (tostring mins) "m " (tostring secs) "s")) + (.. (tostring seconds) "s"))))) + +(fn build-label [state] + "Build the label line for an expandable block." + (let [{: expanded? : type : label : status : elapsed-ms} state + toggle-icon (icon-component.render + {:name (if expanded? :expanded :collapsed)}) + status-icon (when status + (icon-component.render {:name status})) + elapsed-str (format-elapsed elapsed-ms) + parts [toggle-icon.text]] + (table.insert parts (.. " " (or label (or type "block")))) + (when status-icon + (table.insert parts (.. " " status-icon.text))) + (when (and elapsed-str (not= "" elapsed-str)) + (table.insert parts (.. " " elapsed-str))) + (table.concat parts ""))) + +(fn create [canvas initial-state] + "Create an expandable-block widget. + initial-state: {: id : type : status : expanded? : label : content : elapsed-ms : children} + Returns {: render : toggle : update-status : collapse : expand : get-state}." + (var state (vim.tbl_extend :force + {:id nil + :type :tool-call + :status nil + :expanded? false + :label "" + :content [] + :elapsed-ms nil + :children [] + :start-line 0 + :ns-id nil} + (or initial-state {}))) + + (fn ensure-ns [] + (when (= nil state.ns-id) + (set state.ns-id (canvas:create-namespace + (.. "eca-expandable-" (or state.id "unknown"))))) + state.ns-id) + + (fn render [start-line] + "Render the block at start-line. Returns number of lines used." + (set state.start-line start-line) + (let [ns (ensure-ns) + label-line (build-label state) + lines [label-line]] + ;; Add content lines if expanded + (when state.expanded? + (each [_ line (ipairs state.content)] + (table.insert lines (.. " " line)))) + ;; Write to buffer + (canvas:set-modifiable true) + (canvas:set-lines start-line start-line lines) + ;; Highlight the label line + (canvas:add-extmark ns start-line 0 + {:end_col (length label-line) + :hl_group :EcaExpandableLabel}) + (canvas:set-modifiable false) + ;; Return line count + (length lines))) + + (fn toggle [] + "Toggle expanded/collapsed state." + (set state.expanded? (not state.expanded?))) + + (fn expand [] + (set state.expanded? true)) + + (fn collapse [] + (set state.expanded? false)) + + (fn update-status [new-status ?elapsed-ms] + "Update the status and optionally the elapsed time." + (set state.status new-status) + (when ?elapsed-ms + (set state.elapsed-ms ?elapsed-ms))) + + (fn get-state [] + state) + + {: render + : toggle + : expand + : collapse + : update-status + : get-state}) + +{: create} diff --git a/fnl/eca/ui/widgets/header-bar.fnl b/fnl/eca/ui/widgets/header-bar.fnl new file mode 100644 index 0000000..f01ce39 --- /dev/null +++ b/fnl/eca/ui/widgets/header-bar.fnl @@ -0,0 +1,54 @@ +;; header-bar widget — winbar with model/agent/variant/mcps info. +;; Stateful: composes key-value components, renders via canvas. + +(local key-value (require :eca.ui.components.key-value)) + +(fn build-winbar-string [state] + "Build the winbar format string from state. + Uses %#HlGroup# syntax for Neovim statusline/winbar highlighting." + (let [{: model : agent : variant : mcps-total : mcps-ready} state + parts []] + (when model + (table.insert parts + (.. "%#EcaHeaderKey#model%#EcaHeaderValue#:" model))) + (when agent + (table.insert parts + (.. "%#EcaHeaderKey#agent%#EcaHeaderValue#:" agent))) + (when variant + (table.insert parts + (.. "%#EcaHeaderKey#variant%#EcaHeaderValue#:" variant))) + (when mcps-total + (let [ready (or mcps-ready 0) + total mcps-total] + (table.insert parts + (.. "%#EcaHeaderKey#mcps%#EcaHeaderValue#:" (tostring ready) "/" (tostring total))))) + (table.concat parts " "))) + +(fn create [canvas initial-state] + "Create a header-bar widget. + initial-state: {: model : agent : variant : mcps-total : mcps-ready} + Returns {: render : update : get-state}." + (var state (or initial-state + {:model "claude" + :agent "coder" + :variant nil + :mcps-total 0 + :mcps-ready 0})) + + (fn render [] + (let [winbar (build-winbar-string state)] + (canvas:set-option :win :winbar winbar))) + + (fn update [new-state] + (each [k v (pairs new-state)] + (tset state k v)) + (render)) + + (fn get-state [] + state) + + {: render + : update + : get-state}) + +{: create} diff --git a/fnl/eca/ui/widgets/message-list.fnl b/fnl/eca/ui/widgets/message-list.fnl new file mode 100644 index 0000000..fdce0be --- /dev/null +++ b/fnl/eca/ui/widgets/message-list.fnl @@ -0,0 +1,112 @@ +;; message-list widget — renders chat messages in the buffer. +;; Stateful: maintains list of messages, renders via canvas. + +(local message-component (require :eca.ui.components.message)) +(local separator-component (require :eca.ui.components.separator)) + +(fn create [canvas] + "Create a message-list widget. + Returns {: render : append-message : update-message : clear : get-state}." + (var state {:messages [] + :ns-id nil + :end-line 0}) + + (fn ensure-ns [] + (when (= nil state.ns-id) + (set state.ns-id (canvas:create-namespace "eca-messages"))) + state.ns-id) + + (fn apply-highlights [lines-offset highlights] + "Apply highlight extmarks for a rendered component." + (let [ns (ensure-ns)] + (each [_ hl (ipairs highlights)] + (canvas:add-extmark ns + (+ lines-offset hl.line-idx) + hl.col-start + {:end_col hl.col-end + :hl_group hl.hl-group})))) + + (fn render-single-message [msg start-line] + "Render a single message starting at given line. Returns number of lines written." + (let [rendered (message-component.render msg) + sep (separator-component.render {:width 50})] + ;; Write message lines + (canvas:set-modifiable true) + (canvas:set-lines start-line start-line rendered.lines) + (apply-highlights start-line rendered.highlights) + ;; Write separator after message + (let [sep-line (+ start-line (length rendered.lines))] + (canvas:set-lines sep-line sep-line [sep.line]) + (apply-highlights sep-line sep.highlights) + (canvas:set-modifiable false) + ;; Return total lines written (message + separator) + (+ (length rendered.lines) 1)))) + + (fn render [] + "Full re-render of all messages." + (let [ns (ensure-ns)] + (canvas:set-modifiable true) + ;; Clear the messages area (leave room for prompt at the end) + (let [total-lines (canvas:line-count)] + ;; We render from line 0 up to where messages end + ;; Prompt area is handled by prompt-area widget + (canvas:set-lines 0 state.end-line [])) + (set state.end-line 0) + (if (= 0 (length state.messages)) + ;; Show welcome message + (let [welcome (message-component.render-welcome)] + (canvas:set-lines 0 0 welcome.lines) + (apply-highlights 0 welcome.highlights) + (set state.end-line (length welcome.lines))) + ;; Render all messages + (each [_ msg (ipairs state.messages)] + (let [lines-written (render-single-message msg state.end-line)] + (set state.end-line (+ state.end-line lines-written))))) + (canvas:set-modifiable false))) + + (fn append-message [msg] + "Append a new message and render it incrementally." + (table.insert state.messages msg) + ;; If this is the first message, clear welcome and re-render + (if (= 1 (length state.messages)) + (render) + ;; Otherwise render incrementally + (let [lines-written (render-single-message msg state.end-line)] + (set state.end-line (+ state.end-line lines-written)))) + ;; Auto-scroll to end + (when (canvas:win-valid?) + (let [total (canvas:line-count)] + (canvas:set-cursor total 0)))) + + (fn update-message [id new-content] + "Update the content of an existing message (for streaming)." + ;; Find and update the message in state + (var found false) + (each [i msg (ipairs state.messages)] + (when (= msg.id id) + (tset msg :content new-content) + (set found true))) + ;; Re-render everything (could optimize later) + (when found + (render))) + + (fn clear [] + "Clear all messages." + (set state.messages []) + (set state.end-line 0) + (render)) + + (fn get-state [] + state) + + (fn get-end-line [] + state.end-line) + + {: render + : append-message + : update-message + : clear + : get-state + : get-end-line}) + +{: create} diff --git a/fnl/eca/ui/widgets/prompt-area.fnl b/fnl/eca/ui/widgets/prompt-area.fnl new file mode 100644 index 0000000..06d49bc --- /dev/null +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -0,0 +1,163 @@ +;; prompt-area widget — separator + context bar + prompt input. +;; Stateful: manages prompt text, history, contexts, loading state. + +(local separator-component (require :eca.ui.components.separator)) +(local prompt-prefix-component (require :eca.ui.components.prompt-prefix)) +(local context-bar-widget (require :eca.ui.widgets.context-bar)) + +(fn create [canvas] + "Create a prompt-area widget. + Returns widget with full API." + (var state {:loading? false + :prompt-text "" + :history [] + :history-idx 0 + :prompt-start-line 0 + :ns-id nil}) + + (local ctx-bar (context-bar-widget.create canvas)) + + (fn ensure-ns [] + (when (= nil state.ns-id) + (set state.ns-id (canvas:create-namespace "eca-prompt-area"))) + state.ns-id) + + (fn render [start-line] + "Render the prompt area starting at given line. + Layout: separator → context bar (if any) → prompt line. + Returns number of lines used." + (set state.prompt-start-line start-line) + (let [ns (ensure-ns) + sep (separator-component.render {:width 50}) + prefix (prompt-prefix-component.render {:loading? state.loading?}) + ctx-state (ctx-bar.get-state) + has-contexts? (> (length ctx-state.contexts) 0) + lines [sep.line]] + ;; Add context bar line if there are contexts + (when has-contexts? + (let [parts []] + (var col 0) + (each [i ctx (ipairs ctx-state.contexts)] + (when (> i 1) + (table.insert parts " ")) + (let [ci (require :eca.ui.components.context-item) + rendered (ci.render ctx)] + (table.insert parts rendered.text))) + (table.insert lines (table.concat parts "")))) + ;; Add prompt line + (table.insert lines (.. prefix.text state.prompt-text)) + + ;; Write all lines + (canvas:set-modifiable true) + (canvas:set-lines start-line -1 lines) + + ;; Highlight separator + (canvas:add-extmark ns start-line 0 + {:end_col (length sep.line) + :hl_group :EcaSeparator}) + + ;; Highlight prompt prefix + (let [prompt-line-idx (- (+ start-line (length lines)) 1)] + (canvas:add-extmark ns prompt-line-idx 0 + {:end_col (length prefix.text) + :hl_group prefix.hl-group})) + + ;; Highlight context items if present + (when has-contexts? + (ctx-bar.render (+ start-line 1))) + + (canvas:set-modifiable false) + + ;; Position cursor at end of prompt + (when (canvas:win-valid?) + (let [prompt-line (+ start-line (length lines)) + col-pos (+ (length prefix.text) (length state.prompt-text))] + (canvas:set-cursor prompt-line col-pos))) + + ;; Return lines used + (length lines))) + + (fn get-text [] + "Get the current prompt text from the buffer." + (let [total (canvas:line-count) + last-line-idx (- total 1) + lines (canvas:get-lines last-line-idx total)] + (when (and lines (> (length lines) 0)) + (let [last-line (. lines 1) + prefix (prompt-prefix-component.render {:loading? state.loading?}) + prefix-len (length prefix.text)] + (if (>= (length last-line) prefix-len) + (string.sub last-line (+ prefix-len 1)) + ""))))) + + (fn set-text [text] + "Set the prompt text." + (set state.prompt-text (or text "")) + (let [prefix (prompt-prefix-component.render {:loading? state.loading?}) + total (canvas:line-count) + last-line-idx (- total 1)] + (canvas:set-modifiable true) + (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]) + (canvas:set-modifiable false))) + + (fn clear [] + "Clear the prompt text." + (set-text "")) + + (fn set-loading [bool] + "Toggle loading state (changes prefix '> ' ↔ '⏳')." + (set state.loading? bool) + ;; Re-render just the prompt line with new prefix + (let [prefix (prompt-prefix-component.render {:loading? bool}) + total (canvas:line-count) + last-line-idx (- total 1)] + (canvas:set-modifiable true) + (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]) + (canvas:set-modifiable false))) + + (fn add-to-history [text] + "Save text to history." + (when (and text (not= "" text)) + (table.insert state.history text) + (set state.history-idx (+ (length state.history) 1)))) + + (fn history-prev [] + "Navigate to previous history entry." + (when (> state.history-idx 1) + (set state.history-idx (- state.history-idx 1)) + (set-text (. state.history state.history-idx)))) + + (fn history-next [] + "Navigate to next history entry." + (if (< state.history-idx (length state.history)) + (do + (set state.history-idx (+ state.history-idx 1)) + (set-text (. state.history state.history-idx))) + (do + (set state.history-idx (+ (length state.history) 1)) + (set-text "")))) + + (fn add-context [ctx] + "Add a context item." + (ctx-bar.add ctx)) + + (fn remove-context [name] + "Remove a context item by name." + (ctx-bar.remove name)) + + (fn get-state [] + state) + + {: render + : get-text + : set-text + : clear + : set-loading + : add-to-history + : history-prev + : history-next + : add-context + : remove-context + : get-state}) + +{: create} diff --git a/fnl/eca/ui/widgets/status-bar.fnl b/fnl/eca/ui/widgets/status-bar.fnl new file mode 100644 index 0000000..f756f9b --- /dev/null +++ b/fnl/eca/ui/widgets/status-bar.fnl @@ -0,0 +1,83 @@ +;; status-bar widget — custom statusline for chat window. +;; Stateful: displays workspace, elapsed, usage, trust. + +(local usage-component (require :eca.ui.components.usage)) + +(fn format-elapsed [ms] + "Format elapsed milliseconds for statusline." + (if (= nil ms) nil + (let [seconds (math.floor (/ ms 1000))] + (if (>= seconds 60) + (let [mins (math.floor (/ seconds 60)) + secs (% seconds 60)] + (.. (tostring mins) "m " (tostring secs) "s")) + (.. (tostring seconds) "s"))))) + +(fn create [canvas initial-state] + "Create a status-bar widget. + initial-state: {: workspaces : elapsed-ms : tokens-in : tokens-out : max-tokens : cost : trust? : init-progress : pending-approvals?} + Returns {: render : update : get-state}." + (var state (vim.tbl_extend :force + {:workspaces [] + :elapsed-ms nil + :tokens-in 0 + :tokens-out 0 + :max-tokens 200000 + :cost nil + :trust? false + :init-progress nil + :pending-approvals? false} + (or initial-state {}))) + + (fn build-statusline [] + "Build statusline format string." + (let [parts []] + ;; Workspace folders + (when (> (length state.workspaces) 0) + (table.insert parts + (.. "%#EcaHeaderValue# " (table.concat state.workspaces ", ") " "))) + + ;; Spacer + (table.insert parts "%=") + + ;; Init progress + (when state.init-progress + (table.insert parts + (.. "%#EcaSpinner# ⏳ " state.init-progress " "))) + + ;; Elapsed time + (let [elapsed (format-elapsed state.elapsed-ms)] + (when elapsed + (let [icon (if state.pending-approvals? "🚧" "⏱")] + (table.insert parts + (.. "%#EcaElapsed# " icon " " elapsed " "))))) + + ;; Token usage + (let [usage-rendered (usage-component.render state)] + (table.insert parts + (.. "%#EcaUsage# " usage-rendered.text " "))) + + ;; Trust indicator + (if state.trust? + (table.insert parts "%#EcaTrustOn# 🔥 ") + (table.insert parts "%#EcaTrustOff# 🛡️ ")) + + (table.concat parts ""))) + + (fn render [] + (let [statusline (build-statusline)] + (canvas:set-option :win :statusline statusline))) + + (fn update [new-state] + (each [k v (pairs new-state)] + (tset state k v)) + (render)) + + (fn get-state [] + state) + + {: render + : update + : get-state}) + +{: create} diff --git a/fnl/eca/ui/widgets/tab-bar.fnl b/fnl/eca/ui/widgets/tab-bar.fnl new file mode 100644 index 0000000..3ce53e2 --- /dev/null +++ b/fnl/eca/ui/widgets/tab-bar.fnl @@ -0,0 +1,79 @@ +;; tab-bar widget — tab line for multiple chats. +;; Stateful: tracks open chats, renders tabline. + +(fn create [canvas initial-state] + "Create a tab-bar widget. + initial-state: {: tabs [{: id : title : loading? : approval?}] : active-id} + Returns {: render : add-tab : remove-tab : select-tab : update-tab : get-state}." + (var state (vim.tbl_extend :force + {:tabs [] + :active-id nil} + (or initial-state {}))) + + (fn build-tabline [] + "Build tabline format string." + (let [parts []] + (each [_ tab (ipairs state.tabs)] + (let [is-active (= tab.id state.active-id) + hl-group (if tab.approval? :EcaTabLoading + tab.loading? :EcaTabLoading + is-active :EcaTabActive + :EcaTabInactive) + prefix (if tab.approval? "🚧 " + tab.loading? "⏳ " + "") + title (or tab.title (tostring tab.id))] + (table.insert parts + (.. "%#" hl-group "# " prefix title " ")))) + ;; Add new chat button + (table.insert parts "%#EcaButtonAccept# + ") + ;; Add close button + (table.insert parts "%#EcaButtonReject# × ") + (table.concat parts "%#Normal#│"))) + + (fn render [] + (let [tabline (build-tabline)] + (canvas:set-option :win :tabline tabline))) + + (fn add-tab [tab] + "Add a new tab. tab: {: id : title : loading? : approval?}." + (table.insert state.tabs tab) + (when (= nil state.active-id) + (set state.active-id tab.id))) + + (fn remove-tab [id] + "Remove a tab by id." + (let [new-tabs []] + (each [_ tab (ipairs state.tabs)] + (when (not= tab.id id) + (table.insert new-tabs tab))) + (set state.tabs new-tabs) + ;; If active tab was removed, select first available + (when (= state.active-id id) + (set state.active-id + (if (> (length new-tabs) 0) + (. (. new-tabs 1) :id) + nil))))) + + (fn select-tab [id] + "Select active tab." + (set state.active-id id)) + + (fn update-tab [id new-state] + "Update a tab's state." + (each [_ tab (ipairs state.tabs)] + (when (= tab.id id) + (each [k v (pairs new-state)] + (tset tab k v))))) + + (fn get-state [] + state) + + {: render + : add-tab + : remove-tab + : select-tab + : update-tab + : get-state}) + +{: create} diff --git a/lua/eca/api.lua b/lua/eca/api.lua new file mode 100644 index 0000000..45d339e --- /dev/null +++ b/lua/eca/api.lua @@ -0,0 +1,116 @@ +-- [nfnl] fnl/eca/api.fnl +local nvim = vim.api +local function buf_set_lines(buf, start, _end, lines) + return nvim.nvim_buf_set_lines(buf, start, _end, false, lines) +end +local function buf_get_lines(buf, start, _end) + return nvim.nvim_buf_get_lines(buf, start, _end, false) +end +local function buf_line_count(buf) + return nvim.nvim_buf_line_count(buf) +end +local function buf_is_valid(buf) + return ((nil ~= buf) and nvim.nvim_buf_is_valid(buf)) +end +local function buf_create(opts) + local o = (opts or {}) + local _1_ + if (nil ~= o.listed) then + _1_ = o.listed + else + _1_ = false + end + local function _3_() + if (nil ~= o.scratch) then + return o.scratch + else + return true + end + end + return nvim.nvim_create_buf(_1_, _3_()) +end +local function buf_set_keymap(buf, mode, lhs, rhs, opts) + return nvim.nvim_buf_set_keymap(buf, mode, lhs, rhs, (opts or {})) +end +local function win_open(buf, opts) + return nvim.nvim_open_win(buf, true, (opts or {})) +end +local function win_close(win) + if (win and nvim.nvim_win_is_valid(win)) then + return nvim.nvim_win_close(win, true) + else + return nil + end +end +local function win_is_valid(win) + return ((nil ~= win) and nvim.nvim_win_is_valid(win)) +end +local function win_get_cursor(win) + if win then + return nvim.nvim_win_get_cursor(win) + else + return nil + end +end +local function win_set_cursor(win, pos) + if win then + return nvim.nvim_win_set_cursor(win, pos) + else + return nil + end +end +local function create_namespace(name) + return nvim.nvim_create_namespace(name) +end +local function buf_set_extmark(buf, ns_id, line, col, opts) + return nvim.nvim_buf_set_extmark(buf, ns_id, line, col, (opts or {})) +end +local function buf_del_extmark(buf, ns_id, id) + return nvim.nvim_buf_del_extmark(buf, ns_id, id) +end +local function buf_get_extmarks(buf, ns_id, start, _end, opts) + return nvim.nvim_buf_get_extmarks(buf, ns_id, start, _end, (opts or {})) +end +local function set_option(scope, id, key, value) + if (scope == "win") then + return nvim.nvim_set_option_value(key, value, {win = id}) + elseif (scope == "buf") then + return nvim.nvim_set_option_value(key, value, {buf = id}) + else + return nil + end +end +local function get_option(scope, id, key) + if (scope == "win") then + return nvim.nvim_get_option_value(key, {win = id}) + elseif (scope == "buf") then + return nvim.nvim_get_option_value(key, {buf = id}) + else + return nil + end +end +local function set_hl(ns, group, opts) + return nvim.nvim_set_hl(ns, group, opts) +end +local function create_user_command(name, f, opts) + return nvim.nvim_create_user_command(name, f, (opts or {})) +end +local function create_autocmd(event, opts) + return nvim.nvim_create_autocmd(event, opts) +end +local function set_keymap(mode, lhs, rhs, opts) + return vim.keymap.set(mode, lhs, rhs, (opts or {})) +end +local function schedule(f) + return vim.schedule(f) +end +local function defer(f, ms) + return vim.defer_fn(f, ms) +end +local function editor_width() + return vim.o.columns +end +local function editor_height() + return vim.o.lines +end +return {["buf-set-lines"] = buf_set_lines, ["buf-get-lines"] = buf_get_lines, ["buf-line-count"] = buf_line_count, ["buf-is-valid"] = buf_is_valid, ["buf-create"] = buf_create, ["buf-set-keymap"] = buf_set_keymap, ["win-open"] = win_open, ["win-close"] = win_close, ["win-is-valid"] = win_is_valid, ["win-get-cursor"] = win_get_cursor, ["win-set-cursor"] = win_set_cursor, ["create-namespace"] = create_namespace, ["buf-set-extmark"] = buf_set_extmark, ["buf-del-extmark"] = buf_del_extmark, ["buf-get-extmarks"] = buf_get_extmarks, ["set-option"] = set_option, ["get-option"] = get_option, ["set-hl"] = set_hl, ["create-user-command"] = create_user_command, ["create-autocmd"] = create_autocmd, ["set-keymap"] = set_keymap, schedule = schedule, defer = defer, ["editor-width"] = editor_width, ["editor-height"] = editor_height} diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua new file mode 100644 index 0000000..8332316 --- /dev/null +++ b/lua/eca/commands.lua @@ -0,0 +1,33 @@ +-- [nfnl] fnl/eca/commands.fnl +local api = require("eca.api") +local function setup(chat_ui) + local function _1_() + return chat_ui.toggle() + end + api["create-user-command"]("EcaChat", _1_, {desc = "Toggle ECA Chat window"}) + local function _2_() + return chat_ui.open() + end + api["create-user-command"]("EcaChatOpen", _2_, {desc = "Open ECA Chat window"}) + local function _3_() + return chat_ui.close() + end + api["create-user-command"]("EcaChatClose", _3_, {desc = "Close ECA Chat window"}) + local function _4_() + return chat_ui["clear-messages"]() + end + api["create-user-command"]("EcaChatClear", _4_, {desc = "Clear ECA Chat messages"}) + local function _5_() + return chat_ui["clear-messages"]() + end + api["create-user-command"]("EcaChatNew", _5_, {desc = "Start a new ECA Chat"}) + local function _6_() + return chat_ui["submit-prompt"]() + end + api["create-user-command"]("EcaChatSubmit", _6_, {desc = "Submit current prompt"}) + local function _7_() + return chat_ui["set-loading"](false) + end + return api["create-user-command"]("EcaChatStop", _7_, {desc = "Stop current ECA response"}) +end +return {setup = setup} diff --git a/lua/eca/init.lua b/lua/eca/init.lua index ed605a4..fd080c4 100644 --- a/lua/eca/init.lua +++ b/lua/eca/init.lua @@ -1,8 +1,28 @@ -- [nfnl] fnl/eca/init.fnl -local _local_1_ = require("eca.nfnl.module") -local autoload = _local_1_.autoload -local notify = autoload("eca.nfnl.notify") -local function setup() - return notify.info("Hello, World!") +local api = require("eca.api") +local builder = require("eca.ui.builder") +local commands = require("eca.commands") +local chat_ui = nil +local function default_on_submit(text) + if chat_ui then + chat_ui["append-message"]({id = tostring(os.time()), role = "user", content = text}) + chat_ui["set-loading"](true) + local function _1_() + if chat_ui then + chat_ui["append-message"]({id = ("reply-" .. tostring(os.time())), role = "assistant", content = ("You said: " .. text .. "\n\n(This is a mock response \226\128\148 connect ECA server for real responses)")}) + return chat_ui["set-loading"](false) + else + return nil + end + end + return api.defer(_1_, 500) + else + return nil + end +end +local function setup(opts) + local user_opts = (opts or {}) + chat_ui = builder["create-chat-ui"]({api = api, ["on-submit"] = (user_opts["on-submit"] or default_on_submit), opts = {ui = (user_opts.ui or {})}}) + return commands.setup(chat_ui) end return {setup = setup} diff --git a/lua/eca/ui/builder.lua b/lua/eca/ui/builder.lua new file mode 100644 index 0000000..db27256 --- /dev/null +++ b/lua/eca/ui/builder.lua @@ -0,0 +1,296 @@ +-- [nfnl] fnl/eca/ui/builder.fnl +local highlights = require("eca.ui.highlights") +local header_bar_widget = require("eca.ui.widgets.header-bar") +local message_list_widget = require("eca.ui.widgets.message-list") +local prompt_area_widget = require("eca.ui.widgets.prompt-area") +local status_bar_widget = require("eca.ui.widgets.status-bar") +local tab_bar_widget = require("eca.ui.widgets.tab-bar") +local function build_canvas(api, buf_id, win_id) + local buf = buf_id + local win = win_id + local function _1_(_, start, _end, lines) + return api["buf-set-lines"](buf, start, _end, lines) + end + local function _2_(_, start, _end) + return api["buf-get-lines"](buf, start, _end) + end + local function _3_(_) + return api["buf-line-count"](buf) + end + local function _4_(_, ns_id, line, col, opts) + return api["buf-set-extmark"](buf, ns_id, line, col, opts) + end + local function _5_(_, ns_id, id) + return api["buf-del-extmark"](buf, ns_id, id) + end + local function _6_(_, ns_id, start, _end, opts) + return api["buf-get-extmarks"](buf, ns_id, start, _end, opts) + end + local function _7_(_, name) + return api["create-namespace"](name) + end + local function _8_(_, scope, key, value) + if (scope == "win") then + return api["set-option"]("win", win, key, value) + elseif (scope == "buf") then + return api["set-option"]("buf", buf, key, value) + else + return nil + end + end + local function _10_(_, scope, key) + if (scope == "win") then + return api["get-option"]("win", win, key) + elseif (scope == "buf") then + return api["get-option"]("buf", buf, key) + else + return nil + end + end + local function _12_(_) + return api["win-get-cursor"](win) + end + local function _13_(_, line, col) + return api["win-set-cursor"](win, {line, col}) + end + local function _14_(_) + return api["buf-is-valid"](buf) + end + local function _15_(_) + return api["win-is-valid"](win) + end + local function _16_(_, bool) + return api["set-option"]("buf", buf, "modifiable", bool) + end + local function _17_(_, ns, group, opts) + return api["set-hl"](ns, group, opts) + end + local function _18_(_) + api["win-close"](win) + win = nil + return nil + end + local function _19_(_) + return buf + end + local function _20_(_) + return win + end + return {["set-lines"] = _1_, ["get-lines"] = _2_, ["line-count"] = _3_, ["add-extmark"] = _4_, ["del-extmark"] = _5_, ["get-extmarks"] = _6_, ["create-namespace"] = _7_, ["set-option"] = _8_, ["get-option"] = _10_, ["get-cursor"] = _12_, ["set-cursor"] = _13_, ["buf-valid?"] = _14_, ["win-valid?"] = _15_, ["set-modifiable"] = _16_, ["set-hl"] = _17_, ["close-win"] = _18_, ["buf-id"] = _19_, ["win-id"] = _20_} +end +local function setup_chat_buffer(canvas) + canvas["set-option"](canvas, "buf", "buftype", "nofile") + canvas["set-option"](canvas, "buf", "bufhidden", "hide") + canvas["set-option"](canvas, "buf", "swapfile", false) + canvas["set-option"](canvas, "buf", "filetype", "eca-chat") + canvas["set-option"](canvas, "buf", "wrap", true) + canvas["set-option"](canvas, "buf", "linebreak", true) + return canvas["set-option"](canvas, "buf", "modifiable", false) +end +local function setup_chat_window(canvas) + canvas["set-option"](canvas, "win", "number", false) + canvas["set-option"](canvas, "win", "relativenumber", false) + canvas["set-option"](canvas, "win", "signcolumn", "no") + canvas["set-option"](canvas, "win", "foldcolumn", "0") + canvas["set-option"](canvas, "win", "spell", false) + canvas["set-option"](canvas, "win", "wrap", true) + canvas["set-option"](canvas, "win", "linebreak", true) + return canvas["set-option"](canvas, "win", "conceallevel", 2) +end +local function create_chat_ui(_21_) + local api = _21_.api + local on_submit = _21_["on-submit"] + local on_approve = _21_["on-approve"] + local on_reject = _21_["on-reject"] + local on_stop = _21_["on-stop"] + local on_new_chat = _21_["on-new-chat"] + local on_select_tab = _21_["on-select-tab"] + local on_context_add = _21_["on-context-add"] + local opts = _21_.opts + local ui_config = (opts.ui or {}) + local config = {width = (ui_config.width or 0.4), position = (ui_config.position or "right")} + local canvas = nil + local widgets = {header = nil, messages = nil, prompt = nil, status = nil, tabs = nil} + local function is_open_3f() + return ((nil ~= canvas) and canvas["buf-valid?"](canvas) and canvas["win-valid?"](canvas)) + end + local function render_all() + widgets.header.render() + widgets.messages.render() + do + local end_line = widgets.messages["get-end-line"]() + widgets.prompt.render(end_line) + end + widgets.status.render() + return widgets.tabs.render() + end + local function open() + if not is_open_3f() then + local buf_id = api["buf-create"]({scratch = true, listed = false}) + local win_width = math.floor((api["editor-width"]() * config.width)) + local win_id = api["win-open"](buf_id, {split = "right", width = win_width}) + canvas = build_canvas(api, buf_id, win_id) + highlights.setup(canvas) + setup_chat_buffer(canvas) + setup_chat_window(canvas) + widgets.header = header_bar_widget.create(canvas, {}) + widgets.messages = message_list_widget.create(canvas) + widgets.prompt = prompt_area_widget.create(canvas) + widgets.status = status_bar_widget.create(canvas, {}) + widgets.tabs = tab_bar_widget.create(canvas, {tabs = {{id = 1, title = "Chat 1"}}, ["active-id"] = 1}) + canvas["set-modifiable"](canvas, true) + canvas["set-lines"](canvas, 0, -1, {""}) + canvas["set-modifiable"](canvas, false) + return render_all() + else + return nil + end + end + local function close() + if is_open_3f() then + return canvas["close-win"](canvas) + else + return nil + end + end + local function toggle() + if is_open_3f() then + return close() + else + return open() + end + end + local function append_message(msg) + if is_open_3f() then + widgets.messages["append-message"](msg) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + else + return nil + end + end + local function update_message(id, content) + if is_open_3f() then + widgets.messages["update-message"](id, content) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + else + return nil + end + end + local function clear_messages() + if is_open_3f() then + widgets.messages.clear() + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + else + return nil + end + end + local function show_tool_call(tc) + return nil + end + local function update_tool_call(id, status) + return nil + end + local function show_approval(tc) + return nil + end + local function update_model_info(info) + if is_open_3f() then + return widgets.header.update(info) + else + return nil + end + end + local function update_usage(usage) + if is_open_3f() then + return widgets.status.update(usage) + else + return nil + end + end + local function update_progress(progress) + if is_open_3f() then + return widgets.status.update({["init-progress"] = progress}) + else + return nil + end + end + local function add_context(ctx) + if is_open_3f() then + widgets.prompt["add-context"](ctx) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + else + return nil + end + end + local function remove_context(name) + if is_open_3f() then + widgets.prompt["remove-context"](name) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + else + return nil + end + end + local function add_chat_tab(tab) + if is_open_3f() then + widgets.tabs["add-tab"](tab) + return widgets.tabs.render() + else + return nil + end + end + local function remove_chat_tab(id) + if is_open_3f() then + widgets.tabs["remove-tab"](id) + return widgets.tabs.render() + else + return nil + end + end + local function select_chat_tab(id) + if is_open_3f() then + widgets.tabs["select-tab"](id) + return widgets.tabs.render() + else + return nil + end + end + local function get_prompt_text() + if is_open_3f() then + return widgets.prompt["get-text"]() + else + return nil + end + end + local function submit_prompt() + if is_open_3f() then + local text = widgets.prompt["get-text"]() + if (text and ("" ~= text)) then + widgets.prompt["add-to-history"](text) + widgets.prompt.clear() + if on_submit then + return on_submit(text) + else + return nil + end + else + return nil + end + else + return nil + end + end + local function set_loading(bool) + if is_open_3f() then + return widgets.prompt["set-loading"](bool) + else + return nil + end + end + return {open = open, close = close, toggle = toggle, ["is-open?"] = is_open_3f, ["append-message"] = append_message, ["update-message"] = update_message, ["clear-messages"] = clear_messages, ["show-tool-call"] = show_tool_call, ["update-tool-call"] = update_tool_call, ["show-approval"] = show_approval, ["update-model-info"] = update_model_info, ["update-usage"] = update_usage, ["update-progress"] = update_progress, ["add-context"] = add_context, ["remove-context"] = remove_context, ["add-chat-tab"] = add_chat_tab, ["remove-chat-tab"] = remove_chat_tab, ["select-chat-tab"] = select_chat_tab, ["get-prompt-text"] = get_prompt_text, ["submit-prompt"] = submit_prompt, ["set-loading"] = set_loading} +end +return {["create-chat-ui"] = create_chat_ui} diff --git a/lua/eca/ui/canvas.lua b/lua/eca/ui/canvas.lua new file mode 100644 index 0000000..ff6449f --- /dev/null +++ b/lua/eca/ui/canvas.lua @@ -0,0 +1,20 @@ +-- [nfnl] fnl/eca/ui/canvas.fnl +local protocol_keys = {"set-lines", "get-lines", "add-extmark", "del-extmark", "get-extmarks", "create-namespace", "set-option", "get-option", "line-count", "get-cursor", "set-cursor", "buf-valid?", "win-valid?", "set-modifiable", "close-win", "set-hl", "buf-id", "win-id"} +local function validate(canvas) + local missing = {} + for _, key in ipairs(protocol_keys) do + if (nil == canvas[key]) then + table.insert(missing, key) + else + end + end + if (0 == #missing) then + return true + else + return false, missing + end +end +local function describe() + return protocol_keys +end +return {validate = validate, describe = describe, ["protocol-keys"] = protocol_keys} diff --git a/lua/eca/ui/components/button.lua b/lua/eca/ui/components/button.lua new file mode 100644 index 0000000..e61e641 --- /dev/null +++ b/lua/eca/ui/components/button.lua @@ -0,0 +1,14 @@ +-- [nfnl] fnl/eca/ui/components/button.fnl +local function render(_1_) + local label = _1_.label + local hl_group = _1_["hl-group"] + local keybind = _1_.keybind + local display + if keybind then + display = (label .. " (" .. keybind .. ")") + else + display = label + end + return {text = display, ["hl-group"] = (hl_group or "EcaButtonAccept")} +end +return {render = render} diff --git a/lua/eca/ui/components/context-item.lua b/lua/eca/ui/components/context-item.lua new file mode 100644 index 0000000..5cae8f0 --- /dev/null +++ b/lua/eca/ui/components/context-item.lua @@ -0,0 +1,26 @@ +-- [nfnl] fnl/eca/ui/components/context-item.fnl +local type_config = {file = {prefix = "@", ["hl-group"] = "EcaContextFile"}, dir = {prefix = "@", ["hl-group"] = "EcaContextDir"}, ["repo-map"] = {prefix = "@", ["hl-group"] = "EcaContextRepoMap", label = "repoMap"}, cursor = {prefix = "@", ["hl-group"] = "EcaContextCursor"}, mcp = {prefix = "@", ["hl-group"] = "EcaContextMcp"}} +local function render(_1_) + local type = _1_.type + local name = _1_.name + local detail = _1_.detail + local cfg = (type_config[type] or {prefix = "@", ["hl-group"] = "EcaContextFile"}) + local display_name = (cfg.label or name or "") + local text + if (type == "cursor") then + local _2_ + if detail then + _2_ = (" " .. detail) + else + _2_ = "" + end + text = (cfg.prefix .. "cursor(" .. (name or "") .. _2_ .. ")") + elseif (type == "repo-map") then + text = (cfg.prefix .. display_name) + else + local _ = type + text = (cfg.prefix .. display_name) + end + return {text = text, ["hl-group"] = cfg["hl-group"]} +end +return {render = render} diff --git a/lua/eca/ui/components/icon.lua b/lua/eca/ui/components/icon.lua new file mode 100644 index 0000000..db9c844 --- /dev/null +++ b/lua/eca/ui/components/icon.lua @@ -0,0 +1,10 @@ +-- [nfnl] fnl/eca/ui/components/icon.fnl +local icons = {collapsed = "\226\143\181", expanded = "\226\143\183", pending = "\226\143\179", running = "\226\143\179", success = "\226\156\133", error = "\226\157\140", approval = "\240\159\154\167", loading = "\226\143\179", stop = "\226\143\185", new = "+", close = "\195\151"} +local icon_highlights = {collapsed = "EcaExpandableIcon", expanded = "EcaExpandableIcon", pending = "EcaToolCallPending", running = "EcaToolCallPending", success = "EcaToolCallSuccess", error = "EcaToolCallError", approval = "EcaToolCallApproval", loading = "EcaSpinner", stop = "EcaToolCallError", new = "EcaButtonAccept", close = "EcaButtonReject"} +local function render(_1_) + local name = _1_.name + local icon = (icons[name] or "?") + local hl = (icon_highlights[name] or "EcaExpandableIcon") + return {text = icon, ["hl-group"] = hl} +end +return {render = render, icons = icons} diff --git a/lua/eca/ui/components/key-value.lua b/lua/eca/ui/components/key-value.lua new file mode 100644 index 0000000..1eb2937 --- /dev/null +++ b/lua/eca/ui/components/key-value.lua @@ -0,0 +1,16 @@ +-- [nfnl] fnl/eca/ui/components/key-value.fnl +local function render(_1_) + local title = _1_.title + local value = _1_.value + local hl_title = _1_["hl-title"] + local hl_value = _1_["hl-value"] + local title_str = (title or "") + local value_str = (value or "") + local separator = ":" + local line = (title_str .. separator .. value_str) + local title_end = #title_str + local value_start = (title_end + #separator) + local value_end = (value_start + #value_str) + return {line = line, highlights = {{["hl-group"] = (hl_title or "EcaHeaderKey"), ["col-start"] = 0, ["col-end"] = title_end}, {["hl-group"] = (hl_value or "EcaHeaderValue"), ["col-start"] = value_start, ["col-end"] = value_end}}} +end +return {render = render} diff --git a/lua/eca/ui/components/message.lua b/lua/eca/ui/components/message.lua new file mode 100644 index 0000000..b3a92ab --- /dev/null +++ b/lua/eca/ui/components/message.lua @@ -0,0 +1,31 @@ +-- [nfnl] fnl/eca/ui/components/message.fnl +local role_config = {user = {prefix = " You", ["hl-group"] = "EcaUser"}, assistant = {prefix = " ECA", ["hl-group"] = "EcaAssistant"}, system = {prefix = " System", ["hl-group"] = "EcaSystem"}} +local function split_lines(text) + local lines = {} + if ((nil == text) or ("" == text)) then + table.insert(lines, "") + else + for line in text:gmatch("([^\n]*)\n?") do + table.insert(lines, line) + end + end + return lines +end +local function render(_2_) + local role = _2_.role + local content = _2_.content + local cfg = (role_config[role] or {prefix = " ?", ["hl-group"] = "EcaAssistant"}) + local content_lines = split_lines((content or "")) + local lines = {cfg.prefix, ""} + local highlights = {{["line-idx"] = 0, ["hl-group"] = cfg["hl-group"], ["col-start"] = 0, ["col-end"] = #cfg.prefix}} + for _, line in ipairs(content_lines) do + table.insert(lines, line) + end + table.insert(lines, "") + return {lines = lines, highlights = highlights} +end +local function render_welcome() + local lines = {"", " Welcome to ECA Chat", "", " Type your message below and press Enter to send.", " Use @ to attach context (files, directories, etc.)", ""} + return {lines = lines, highlights = {{["line-idx"] = 1, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #" Welcome to ECA Chat"}}} +end +return {render = render, ["render-welcome"] = render_welcome} diff --git a/lua/eca/ui/components/prompt-prefix.lua b/lua/eca/ui/components/prompt-prefix.lua new file mode 100644 index 0000000..c8b2a91 --- /dev/null +++ b/lua/eca/ui/components/prompt-prefix.lua @@ -0,0 +1,10 @@ +-- [nfnl] fnl/eca/ui/components/prompt-prefix.fnl +local function render(_1_) + local loading_3f = _1_["loading?"] + if loading_3f then + return {text = "\226\143\179 ", ["hl-group"] = "EcaPromptPrefixLoading"} + else + return {text = "> ", ["hl-group"] = "EcaPromptPrefix"} + end +end +return {render = render} diff --git a/lua/eca/ui/components/separator.lua b/lua/eca/ui/components/separator.lua new file mode 100644 index 0000000..dc9c7a4 --- /dev/null +++ b/lua/eca/ui/components/separator.lua @@ -0,0 +1,10 @@ +-- [nfnl] fnl/eca/ui/components/separator.fnl +local function render(_1_) + local char = _1_.char + local width = _1_.width + local c = (char or "\226\148\128") + local w = (width or 40) + local line = string.rep(c, w) + return {line = line, highlights = {{["hl-group"] = "EcaSeparator", ["col-start"] = 0, ["col-end"] = #line}}} +end +return {render = render} diff --git a/lua/eca/ui/components/spinner.lua b/lua/eca/ui/components/spinner.lua new file mode 100644 index 0000000..6d68873 --- /dev/null +++ b/lua/eca/ui/components/spinner.lua @@ -0,0 +1,12 @@ +-- [nfnl] fnl/eca/ui/components/spinner.fnl +local frames = {"\226\160\139", "\226\160\153", "\226\160\185", "\226\160\184", "\226\160\188", "\226\160\180", "\226\160\166", "\226\160\167", "\226\160\135", "\226\160\143"} +local function render(_1_) + local frame = _1_.frame + local idx = (((frame or 0) % #frames) + 1) + local char = frames[idx] + return {text = char, ["hl-group"] = "EcaSpinner"} +end +local function frame_count() + return #frames +end +return {render = render, ["frame-count"] = frame_count} diff --git a/lua/eca/ui/components/usage.lua b/lua/eca/ui/components/usage.lua new file mode 100644 index 0000000..36ad82b --- /dev/null +++ b/lua/eca/ui/components/usage.lua @@ -0,0 +1,42 @@ +-- [nfnl] fnl/eca/ui/components/usage.fnl +local function format_tokens(n) + if (nil == n) then + return "0" + elseif (n >= 1000000) then + return string.format("%.1fM", (n / 1000000)) + elseif (n >= 1000) then + return string.format("%.0fK", (n / 1000)) + else + return tostring(n) + end +end +local function format_cost(cost) + if (nil == cost) then + return nil + else + return string.format("$%.2f", cost) + end +end +local function render(_3_) + local tokens_in = _3_["tokens-in"] + local tokens_out = _3_["tokens-out"] + local max_tokens = _3_["max-tokens"] + local cost = _3_.cost + local used = format_tokens(((tokens_in or 0) + (tokens_out or 0))) + local max = format_tokens(max_tokens) + local base + if max_tokens then + base = (used .. "/" .. max) + else + base = used + end + local cost_str = format_cost(cost) + local text + if cost_str then + text = (base .. " (" .. cost_str .. ")") + else + text = base + end + return {text = text, ["hl-group"] = "EcaUsage"} +end +return {render = render, ["format-tokens"] = format_tokens, ["format-cost"] = format_cost} diff --git a/lua/eca/ui/highlights.lua b/lua/eca/ui/highlights.lua new file mode 100644 index 0000000..a5c8313 --- /dev/null +++ b/lua/eca/ui/highlights.lua @@ -0,0 +1,9 @@ +-- [nfnl] fnl/eca/ui/highlights.fnl +local groups = {EcaUser = {fg = "#61afef", bold = true}, EcaAssistant = {fg = "#abb2bf"}, EcaSystem = {fg = "#5c6370", italic = true}, EcaWelcome = {fg = "#5c6370", italic = true}, EcaSeparator = {fg = "#3e4452"}, EcaPromptPrefix = {fg = "#98c379", bold = true}, EcaPromptPrefixLoading = {fg = "#e5c07b"}, EcaToolCallPending = {fg = "#e5c07b"}, EcaToolCallSuccess = {fg = "#98c379"}, EcaToolCallError = {fg = "#e06c75"}, EcaToolCallApproval = {fg = "#d19a66", bg = "#3e3522", bold = true}, EcaExpandableIcon = {fg = "#5c6370"}, EcaExpandableLabel = {bold = true}, EcaContextFile = {fg = "#e06c75", underline = true}, EcaContextDir = {fg = "#e06c75", underline = true}, EcaContextRepoMap = {fg = "#56b6c2"}, EcaContextCursor = {fg = "#5c6370"}, EcaContextMcp = {fg = "#98c379"}, EcaHeaderKey = {fg = "#5c6370"}, EcaHeaderValue = {fg = "#abb2bf", bold = true}, EcaUsage = {fg = "#5c6370"}, EcaElapsed = {fg = "#5c6370"}, EcaTrustOn = {fg = "#e06c75", bold = true}, EcaTrustOff = {fg = "#5c6370"}, EcaSpinner = {fg = "#e5c07b"}, EcaButtonAccept = {fg = "#98c379", bold = true}, EcaButtonReject = {fg = "#e06c75", bold = true}, EcaTabActive = {fg = "#abb2bf", bold = true}, EcaTabInactive = {fg = "#5c6370"}, EcaTabLoading = {fg = "#e5c07b"}} +local function setup(canvas) + for group, opts in pairs(groups) do + canvas["set-hl"](canvas, 0, group, opts) + end + return nil +end +return {groups = groups, setup = setup} diff --git a/lua/eca/ui/widgets/context-bar.lua b/lua/eca/ui/widgets/context-bar.lua new file mode 100644 index 0000000..c1ae20f --- /dev/null +++ b/lua/eca/ui/widgets/context-bar.lua @@ -0,0 +1,83 @@ +-- [nfnl] fnl/eca/ui/widgets/context-bar.fnl +local context_item_component = require("eca.ui.components.context-item") +local function create(canvas) + local state = {contexts = {}, ["ns-id"] = nil} + local function ensure_ns() + if (nil == state["ns-id"]) then + state["ns-id"] = canvas["create-namespace"](canvas, "eca-context-bar") + else + end + return state["ns-id"] + end + local function build_line() + if (0 == #state.contexts) then + return {line = "", parts = {}} + else + local parts = {} + local highlights = {} + local col = 0 + for i, ctx in ipairs(state.contexts) do + if (i > 1) then + table.insert(parts, " ") + col = (col + 1) + else + end + local rendered = context_item_component.render(ctx) + table.insert(parts, rendered.text) + table.insert(highlights, {["hl-group"] = rendered["hl-group"], ["col-start"] = col, ["col-end"] = (col + #rendered.text)}) + col = (col + #rendered.text) + end + return {line = table.concat(parts, ""), highlights = highlights} + end + end + local function render(line_num) + local ns = ensure_ns() + local _let_4_ = build_line() + local line = _let_4_.line + local highlights = _let_4_.highlights + if (line and ("" ~= line)) then + canvas["set-modifiable"](canvas, true) + canvas["set-lines"](canvas, line_num, (line_num + 1), {line}) + for _, hl in ipairs((highlights or {})) do + canvas["add-extmark"](canvas, ns, line_num, hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) + end + return canvas["set-modifiable"](canvas, false) + else + return nil + end + end + local function add(ctx) + local exists = false + for _, existing in ipairs(state.contexts) do + if (existing.name == ctx.name) then + exists = true + else + end + end + if not exists then + return table.insert(state.contexts, ctx) + else + return nil + end + end + local function remove(name) + local new_contexts = {} + for _, ctx in ipairs(state.contexts) do + if (ctx.name ~= name) then + table.insert(new_contexts, ctx) + else + end + end + state.contexts = new_contexts + return nil + end + local function clear() + state.contexts = {} + return nil + end + local function get_state() + return state + end + return {render = render, add = add, remove = remove, clear = clear, ["get-state"] = get_state} +end +return {create = create} diff --git a/lua/eca/ui/widgets/expandable-block.lua b/lua/eca/ui/widgets/expandable-block.lua new file mode 100644 index 0000000..44eeb77 --- /dev/null +++ b/lua/eca/ui/widgets/expandable-block.lua @@ -0,0 +1,102 @@ +-- [nfnl] fnl/eca/ui/widgets/expandable-block.fnl +local icon_component = require("eca.ui.components.icon") +local function format_elapsed(ms) + if (nil == ms) then + return "" + else + local seconds = math.floor((ms / 1000)) + if (seconds >= 60) then + local mins = math.floor((seconds / 60)) + local secs = (seconds % 60) + return (tostring(mins) .. "m " .. tostring(secs) .. "s") + else + return (tostring(seconds) .. "s") + end + end +end +local function build_label(state) + local expanded_3f = state["expanded?"] + local type = state.type + local label = state.label + local status = state.status + local elapsed_ms = state["elapsed-ms"] + local toggle_icon + local _3_ + if expanded_3f then + _3_ = "expanded" + else + _3_ = "collapsed" + end + toggle_icon = icon_component.render({name = _3_}) + local status_icon + if status then + status_icon = icon_component.render({name = status}) + else + status_icon = nil + end + local elapsed_str = format_elapsed(elapsed_ms) + local parts = {toggle_icon.text} + table.insert(parts, (" " .. (label or (type or "block")))) + if status_icon then + table.insert(parts, (" " .. status_icon.text)) + else + end + if (elapsed_str and ("" ~= elapsed_str)) then + table.insert(parts, (" " .. elapsed_str)) + else + end + return table.concat(parts, "") +end +local function create(canvas, initial_state) + local state = vim.tbl_extend("force", {id = nil, type = "tool-call", status = nil, label = "", content = {}, ["elapsed-ms"] = nil, children = {}, ["start-line"] = 0, ["ns-id"] = nil, ["expanded?"] = false}, (initial_state or {})) + local function ensure_ns() + if (nil == state["ns-id"]) then + state["ns-id"] = canvas["create-namespace"](canvas, ("eca-expandable-" .. (state.id or "unknown"))) + else + end + return state["ns-id"] + end + local function render(start_line) + state["start-line"] = start_line + local ns = ensure_ns() + local label_line = build_label(state) + local lines = {label_line} + if state["expanded?"] then + for _, line in ipairs(state.content) do + table.insert(lines, (" " .. line)) + end + else + end + canvas["set-modifiable"](canvas, true) + canvas["set-lines"](canvas, start_line, start_line, lines) + canvas["add-extmark"](canvas, ns, start_line, 0, {end_col = #label_line, hl_group = "EcaExpandableLabel"}) + canvas["set-modifiable"](canvas, false) + return #lines + end + local function toggle() + state["expanded?"] = not state["expanded?"] + return nil + end + local function expand() + state["expanded?"] = true + return nil + end + local function collapse() + state["expanded?"] = false + return nil + end + local function update_status(new_status, _3felapsed_ms) + state.status = new_status + if _3felapsed_ms then + state["elapsed-ms"] = _3felapsed_ms + return nil + else + return nil + end + end + local function get_state() + return state + end + return {render = render, toggle = toggle, expand = expand, collapse = collapse, ["update-status"] = update_status, ["get-state"] = get_state} +end +return {create = create} diff --git a/lua/eca/ui/widgets/header-bar.lua b/lua/eca/ui/widgets/header-bar.lua new file mode 100644 index 0000000..c9355f6 --- /dev/null +++ b/lua/eca/ui/widgets/header-bar.lua @@ -0,0 +1,47 @@ +-- [nfnl] fnl/eca/ui/widgets/header-bar.fnl +local key_value = require("eca.ui.components.key-value") +local function build_winbar_string(state) + local model = state.model + local agent = state.agent + local variant = state.variant + local mcps_total = state["mcps-total"] + local mcps_ready = state["mcps-ready"] + local parts = {} + if model then + table.insert(parts, ("%#EcaHeaderKey#model%#EcaHeaderValue#:" .. model)) + else + end + if agent then + table.insert(parts, ("%#EcaHeaderKey#agent%#EcaHeaderValue#:" .. agent)) + else + end + if variant then + table.insert(parts, ("%#EcaHeaderKey#variant%#EcaHeaderValue#:" .. variant)) + else + end + if mcps_total then + local ready = (mcps_ready or 0) + local total = mcps_total + table.insert(parts, ("%#EcaHeaderKey#mcps%#EcaHeaderValue#:" .. tostring(ready) .. "/" .. tostring(total))) + else + end + return table.concat(parts, " ") +end +local function create(canvas, initial_state) + local state = (initial_state or {model = "claude", agent = "coder", variant = nil, ["mcps-total"] = 0, ["mcps-ready"] = 0}) + local function render() + local winbar = build_winbar_string(state) + return canvas["set-option"](canvas, "win", "winbar", winbar) + end + local function update(new_state) + for k, v in pairs(new_state) do + state[k] = v + end + return render() + end + local function get_state() + return state + end + return {render = render, update = update, ["get-state"] = get_state} +end +return {create = create} diff --git a/lua/eca/ui/widgets/message-list.lua b/lua/eca/ui/widgets/message-list.lua new file mode 100644 index 0000000..a4a8631 --- /dev/null +++ b/lua/eca/ui/widgets/message-list.lua @@ -0,0 +1,96 @@ +-- [nfnl] fnl/eca/ui/widgets/message-list.fnl +local message_component = require("eca.ui.components.message") +local separator_component = require("eca.ui.components.separator") +local function create(canvas) + local state = {messages = {}, ["ns-id"] = nil, ["end-line"] = 0} + local function ensure_ns() + if (nil == state["ns-id"]) then + state["ns-id"] = canvas["create-namespace"](canvas, "eca-messages") + else + end + return state["ns-id"] + end + local function apply_highlights(lines_offset, highlights) + local ns = ensure_ns() + for _, hl in ipairs(highlights) do + canvas["add-extmark"](canvas, ns, (lines_offset + hl["line-idx"]), hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) + end + return nil + end + local function render_single_message(msg, start_line) + local rendered = message_component.render(msg) + local sep = separator_component.render({width = 50}) + canvas["set-modifiable"](canvas, true) + canvas["set-lines"](canvas, start_line, start_line, rendered.lines) + apply_highlights(start_line, rendered.highlights) + local sep_line = (start_line + #rendered.lines) + canvas["set-lines"](canvas, sep_line, sep_line, {sep.line}) + apply_highlights(sep_line, sep.highlights) + canvas["set-modifiable"](canvas, false) + return (#rendered.lines + 1) + end + local function render() + local ns = ensure_ns() + canvas["set-modifiable"](canvas, true) + do + local total_lines = canvas["line-count"](canvas) + canvas["set-lines"](canvas, 0, state["end-line"], {}) + end + state["end-line"] = 0 + if (0 == #state.messages) then + local welcome = message_component["render-welcome"]() + canvas["set-lines"](canvas, 0, 0, welcome.lines) + apply_highlights(0, welcome.highlights) + state["end-line"] = #welcome.lines + else + for _, msg in ipairs(state.messages) do + local lines_written = render_single_message(msg, state["end-line"]) + state["end-line"] = (state["end-line"] + lines_written) + end + end + return canvas["set-modifiable"](canvas, false) + end + local function append_message(msg) + table.insert(state.messages, msg) + if (1 == #state.messages) then + render() + else + local lines_written = render_single_message(msg, state["end-line"]) + state["end-line"] = (state["end-line"] + lines_written) + end + if canvas["win-valid?"](canvas) then + local total = canvas["line-count"](canvas) + return canvas["set-cursor"](canvas, total, 0) + else + return nil + end + end + local function update_message(id, new_content) + local found = false + for i, msg in ipairs(state.messages) do + if (msg.id == id) then + msg["content"] = new_content + found = true + else + end + end + if found then + return render() + else + return nil + end + end + local function clear() + state.messages = {} + state["end-line"] = 0 + return render() + end + local function get_state() + return state + end + local function get_end_line() + return state["end-line"] + end + return {render = render, ["append-message"] = append_message, ["update-message"] = update_message, clear = clear, ["get-state"] = get_state, ["get-end-line"] = get_end_line} +end +return {create = create} diff --git a/lua/eca/ui/widgets/prompt-area.lua b/lua/eca/ui/widgets/prompt-area.lua new file mode 100644 index 0000000..617d0ef --- /dev/null +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -0,0 +1,134 @@ +-- [nfnl] fnl/eca/ui/widgets/prompt-area.fnl +local separator_component = require("eca.ui.components.separator") +local prompt_prefix_component = require("eca.ui.components.prompt-prefix") +local context_bar_widget = require("eca.ui.widgets.context-bar") +local function create(canvas) + local state = {["prompt-text"] = "", history = {}, ["history-idx"] = 0, ["prompt-start-line"] = 0, ["ns-id"] = nil, ["loading?"] = false} + local ctx_bar = context_bar_widget.create(canvas) + local function ensure_ns() + if (nil == state["ns-id"]) then + state["ns-id"] = canvas["create-namespace"](canvas, "eca-prompt-area") + else + end + return state["ns-id"] + end + local function render(start_line) + state["prompt-start-line"] = start_line + local ns = ensure_ns() + local sep = separator_component.render({width = 50}) + local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) + local ctx_state = ctx_bar["get-state"]() + local has_contexts_3f = (#ctx_state.contexts > 0) + local lines = {sep.line} + if has_contexts_3f then + local parts = {} + local col = 0 + for i, ctx in ipairs(ctx_state.contexts) do + if (i > 1) then + table.insert(parts, " ") + else + end + local ci = require("eca.ui.components.context-item") + local rendered = ci.render(ctx) + table.insert(parts, rendered.text) + end + table.insert(lines, table.concat(parts, "")) + else + end + table.insert(lines, (prefix.text .. state["prompt-text"])) + canvas["set-modifiable"](canvas, true) + canvas["set-lines"](canvas, start_line, -1, lines) + canvas["add-extmark"](canvas, ns, start_line, 0, {end_col = #sep.line, hl_group = "EcaSeparator"}) + do + local prompt_line_idx = ((start_line + #lines) - 1) + canvas["add-extmark"](canvas, ns, prompt_line_idx, 0, {end_col = #prefix.text, hl_group = prefix["hl-group"]}) + end + if has_contexts_3f then + ctx_bar.render((start_line + 1)) + else + end + canvas["set-modifiable"](canvas, false) + if canvas["win-valid?"](canvas) then + local prompt_line = (start_line + #lines) + local col_pos = (#prefix.text + #state["prompt-text"]) + canvas["set-cursor"](canvas, prompt_line, col_pos) + else + end + return #lines + end + local function get_text() + local total = canvas["line-count"](canvas) + local last_line_idx = (total - 1) + local lines = canvas["get-lines"](canvas, last_line_idx, total) + if (lines and (#lines > 0)) then + local last_line = lines[1] + local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) + local prefix_len = #prefix.text + if (#last_line >= prefix_len) then + return string.sub(last_line, (prefix_len + 1)) + else + return "" + end + else + return nil + end + end + local function set_text(text) + state["prompt-text"] = (text or "") + local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) + local total = canvas["line-count"](canvas) + local last_line_idx = (total - 1) + canvas["set-modifiable"](canvas, true) + canvas["set-lines"](canvas, last_line_idx, total, {(prefix.text .. state["prompt-text"])}) + return canvas["set-modifiable"](canvas, false) + end + local function clear() + return set_text("") + end + local function set_loading(bool) + state["loading?"] = bool + local prefix = prompt_prefix_component.render({["loading?"] = bool}) + local total = canvas["line-count"](canvas) + local last_line_idx = (total - 1) + canvas["set-modifiable"](canvas, true) + canvas["set-lines"](canvas, last_line_idx, total, {(prefix.text .. state["prompt-text"])}) + return canvas["set-modifiable"](canvas, false) + end + local function add_to_history(text) + if (text and ("" ~= text)) then + table.insert(state.history, text) + state["history-idx"] = (#state.history + 1) + return nil + else + return nil + end + end + local function history_prev() + if (state["history-idx"] > 1) then + state["history-idx"] = (state["history-idx"] - 1) + return set_text(state.history[state["history-idx"]]) + else + return nil + end + end + local function history_next() + if (state["history-idx"] < #state.history) then + state["history-idx"] = (state["history-idx"] + 1) + return set_text(state.history[state["history-idx"]]) + else + state["history-idx"] = (#state.history + 1) + return set_text("") + end + end + local function add_context(ctx) + return ctx_bar.add(ctx) + end + local function remove_context(name) + return ctx_bar.remove(name) + end + local function get_state() + return state + end + return {render = render, ["get-text"] = get_text, ["set-text"] = set_text, clear = clear, ["set-loading"] = set_loading, ["add-to-history"] = add_to_history, ["history-prev"] = history_prev, ["history-next"] = history_next, ["add-context"] = add_context, ["remove-context"] = remove_context, ["get-state"] = get_state} +end +return {create = create} diff --git a/lua/eca/ui/widgets/status-bar.lua b/lua/eca/ui/widgets/status-bar.lua new file mode 100644 index 0000000..a0d262e --- /dev/null +++ b/lua/eca/ui/widgets/status-bar.lua @@ -0,0 +1,69 @@ +-- [nfnl] fnl/eca/ui/widgets/status-bar.fnl +local usage_component = require("eca.ui.components.usage") +local function format_elapsed(ms) + if (nil == ms) then + return nil + else + local seconds = math.floor((ms / 1000)) + if (seconds >= 60) then + local mins = math.floor((seconds / 60)) + local secs = (seconds % 60) + return (tostring(mins) .. "m " .. tostring(secs) .. "s") + else + return (tostring(seconds) .. "s") + end + end +end +local function create(canvas, initial_state) + local state = vim.tbl_extend("force", {workspaces = {}, ["elapsed-ms"] = nil, ["tokens-in"] = 0, ["tokens-out"] = 0, ["max-tokens"] = 200000, cost = nil, ["init-progress"] = nil, ["pending-approvals?"] = false, ["trust?"] = false}, (initial_state or {})) + local function build_statusline() + local parts = {} + if (#state.workspaces > 0) then + table.insert(parts, ("%#EcaHeaderValue# " .. table.concat(state.workspaces, ", ") .. " ")) + else + end + table.insert(parts, "%=") + if state["init-progress"] then + table.insert(parts, ("%#EcaSpinner# \226\143\179 " .. state["init-progress"] .. " ")) + else + end + do + local elapsed = format_elapsed(state["elapsed-ms"]) + if elapsed then + local icon + if state["pending-approvals?"] then + icon = "\240\159\154\167" + else + icon = "\226\143\177" + end + table.insert(parts, ("%#EcaElapsed# " .. icon .. " " .. elapsed .. " ")) + else + end + end + do + local usage_rendered = usage_component.render(state) + table.insert(parts, ("%#EcaUsage# " .. usage_rendered.text .. " ")) + end + if state["trust?"] then + table.insert(parts, "%#EcaTrustOn# \240\159\148\165 ") + else + table.insert(parts, "%#EcaTrustOff# \240\159\155\161\239\184\143 ") + end + return table.concat(parts, "") + end + local function render() + local statusline = build_statusline() + return canvas["set-option"](canvas, "win", "statusline", statusline) + end + local function update(new_state) + for k, v in pairs(new_state) do + state[k] = v + end + return render() + end + local function get_state() + return state + end + return {render = render, update = update, ["get-state"] = get_state} +end +return {create = create} diff --git a/lua/eca/ui/widgets/tab-bar.lua b/lua/eca/ui/widgets/tab-bar.lua new file mode 100644 index 0000000..76044f2 --- /dev/null +++ b/lua/eca/ui/widgets/tab-bar.lua @@ -0,0 +1,86 @@ +-- [nfnl] fnl/eca/ui/widgets/tab-bar.fnl +local function create(canvas, initial_state) + local state = vim.tbl_extend("force", {tabs = {}, ["active-id"] = nil}, (initial_state or {})) + local function build_tabline() + local parts = {} + for _, tab in ipairs(state.tabs) do + local is_active = (tab.id == state["active-id"]) + local hl_group + if tab["approval?"] then + hl_group = "EcaTabLoading" + elseif tab["loading?"] then + hl_group = "EcaTabLoading" + elseif is_active then + hl_group = "EcaTabActive" + else + hl_group = "EcaTabInactive" + end + local prefix + if tab["approval?"] then + prefix = "\240\159\154\167 " + elseif tab["loading?"] then + prefix = "\226\143\179 " + else + prefix = "" + end + local title = (tab.title or tostring(tab.id)) + table.insert(parts, ("%#" .. hl_group .. "# " .. prefix .. title .. " ")) + end + table.insert(parts, "%#EcaButtonAccept# + ") + table.insert(parts, "%#EcaButtonReject# \195\151 ") + return table.concat(parts, "%#Normal#\226\148\130") + end + local function render() + local tabline = build_tabline() + return canvas["set-option"](canvas, "win", "tabline", tabline) + end + local function add_tab(tab) + table.insert(state.tabs, tab) + if (nil == state["active-id"]) then + state["active-id"] = tab.id + return nil + else + return nil + end + end + local function remove_tab(id) + local new_tabs = {} + for _, tab in ipairs(state.tabs) do + if (tab.id ~= id) then + table.insert(new_tabs, tab) + else + end + end + state.tabs = new_tabs + if (state["active-id"] == id) then + if (#new_tabs > 0) then + state["active-id"] = new_tabs[1].id + else + state["active-id"] = nil + end + return nil + else + return nil + end + end + local function select_tab(id) + state["active-id"] = id + return nil + end + local function update_tab(id, new_state) + for _, tab in ipairs(state.tabs) do + if (tab.id == id) then + for k, v in pairs(new_state) do + tab[k] = v + end + else + end + end + return nil + end + local function get_state() + return state + end + return {render = render, ["add-tab"] = add_tab, ["remove-tab"] = remove_tab, ["select-tab"] = select_tab, ["update-tab"] = update_tab, ["get-state"] = get_state} +end +return {create = create} From fd0b81db9a687024d996ceff9f2aa7ebcec37401 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sun, 10 May 2026 15:41:04 -0300 Subject: [PATCH 2/8] render'able version --- fnl/eca/api.fnl | 24 +++- fnl/eca/ui/builder.fnl | 149 ++++++++++++++------- fnl/eca/ui/canvas.fnl | 13 +- fnl/eca/ui/components/context-item.fnl | 2 +- fnl/eca/ui/widgets/context-bar.fnl | 51 ++++--- fnl/eca/ui/widgets/expandable-block.fnl | 4 +- fnl/eca/ui/widgets/header-bar.fnl | 2 +- fnl/eca/ui/widgets/message-list.fnl | 29 ++-- fnl/eca/ui/widgets/prompt-area.fnl | 27 ++-- fnl/eca/ui/widgets/status-bar.fnl | 2 +- fnl/eca/ui/widgets/tab-bar.fnl | 5 +- lua/eca/api.lua | 4 + lua/eca/ui/builder.lua | 171 +++++++++++++++++------- lua/eca/ui/canvas.lua | 22 ++- lua/eca/ui/widgets/context-bar.lua | 41 +++--- lua/eca/ui/widgets/message-list.lua | 17 ++- lua/eca/ui/widgets/prompt-area.lua | 28 ++-- lua/eca/ui/widgets/tab-bar.lua | 3 +- 18 files changed, 367 insertions(+), 227 deletions(-) diff --git a/fnl/eca/api.fnl b/fnl/eca/api.fnl index 0e650ad..90f26e0 100644 --- a/fnl/eca/api.fnl +++ b/fnl/eca/api.fnl @@ -64,16 +64,18 @@ ;; ── Options ───────────────────────────────────────────── (fn set-option [scope id key value] - "Set an option. scope: :win or :buf. id: win-id or buf-id." - (match scope + "Set an option. scope: :win, :buf, or :global. id: win-id or buf-id (ignored for :global)." + (case scope :win (nvim.nvim_set_option_value key value {:win id}) - :buf (nvim.nvim_set_option_value key value {:buf id}))) + :buf (nvim.nvim_set_option_value key value {:buf id}) + :global (nvim.nvim_set_option_value key value {}))) (fn get-option [scope id key] - "Get an option. scope: :win or :buf." - (match scope + "Get an option. scope: :win, :buf, or :global." + (case scope :win (nvim.nvim_get_option_value key {:win id}) - :buf (nvim.nvim_get_option_value key {:buf id}))) + :buf (nvim.nvim_get_option_value key {:buf id}) + :global (nvim.nvim_get_option_value key {}))) ;; ── Highlights ────────────────────────────────────────── @@ -93,6 +95,14 @@ (fn set-keymap [mode lhs rhs opts] (vim.keymap.set mode lhs rhs (or opts {}))) +;; ── Buffer attach ─────────────────────────────────────── + +(fn buf-attach [buf opts] + "Attach to buffer events. opts: {: on_lines : on_bytes : on_detach ...} + on_lines: (fn [_ buf changedtick first-line last-line new-last-line] ...) + Return true from on_lines to detach." + (nvim.nvim_buf_attach buf false (or opts {}))) + ;; ── Scheduling ────────────────────────────────────────── (fn schedule [f] @@ -137,6 +147,8 @@ : create-autocmd ;; Keymaps : set-keymap + ;; Buffer attach + : buf-attach ;; Scheduling : schedule : defer diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl index 042e364..36baa71 100644 --- a/fnl/eca/ui/builder.fnl +++ b/fnl/eca/ui/builder.fnl @@ -15,8 +15,8 @@ "Build a canvas object from flat api functions, bound to a specific buf/win. This is the bridge between the flat api module and the canvas protocol that widgets expect." - (var buf buf-id) - (var win win-id) + (let [buf buf-id + win win-id] {:set-lines (fn [_ start end lines] @@ -48,15 +48,17 @@ :set-option (fn [_ scope key value] - (match scope + (case scope :win (api.set-option :win win key value) - :buf (api.set-option :buf buf key value))) + :buf (api.set-option :buf buf key value) + :global (api.set-option :global nil key value))) :get-option (fn [_ scope key] - (match scope + (case scope :win (api.get-option :win win key) - :buf (api.get-option :buf buf key))) + :buf (api.get-option :buf buf key) + :global (api.get-option :global nil key))) :get-cursor (fn [_] @@ -74,21 +76,16 @@ (fn [_] (api.win-is-valid win)) - :set-modifiable - (fn [_ bool] - (api.set-option :buf buf :modifiable bool)) - :set-hl (fn [_ ns group opts] (api.set-hl ns group opts)) :close-win (fn [_] - (api.win-close win) - (set win nil)) + (api.win-close win)) :buf-id (fn [_] buf) - :win-id (fn [_] win)}) + :win-id (fn [_] win)})) ;; ── Buffer/window setup ───────────────────────────────── @@ -97,10 +94,7 @@ (canvas:set-option :buf :buftype "nofile") (canvas:set-option :buf :bufhidden "hide") (canvas:set-option :buf :swapfile false) - (canvas:set-option :buf :filetype "eca-chat") - (canvas:set-option :buf :wrap true) - (canvas:set-option :buf :linebreak true) - (canvas:set-option :buf :modifiable false)) + (canvas:set-option :buf :filetype "eca-chat")) (fn setup-chat-window [canvas] "Configure the chat window options." @@ -113,6 +107,33 @@ (canvas:set-option :win :linebreak true) (canvas:set-option :win :conceallevel 2)) +;; ── Edit guard ────────────────────────────────────────── + +(fn setup-edit-guard [api buf-id get-prompt-start-line] + "Attach to buffer to guard editable region. + Only the prompt area (from prompt-start-line onwards) is user-editable. + Edits outside that region are undone immediately." + (var internal-edit false) + + (fn set-internal [bool] + (set internal-edit bool)) + + (api.buf-attach buf-id + {:on_lines + (fn [_ buf changedtick first-line last-line new-last-line] + ;; If this is an internal (widget) edit, allow it + (when (not internal-edit) + (let [prompt-start (get-prompt-start-line)] + ;; If the edit touches lines before the prompt area, undo it + (when (< first-line prompt-start) + (api.schedule + (fn [] + (when (api.buf-is-valid buf) + (vim.cmd "silent! undo"))))))))}) + + ;; Return a function to wrap internal edits + set-internal) + ;; ── Main entry ────────────────────────────────────────── (fn create-chat-ui [{: api : on-submit : on-approve : on-reject @@ -129,25 +150,41 @@ :position (or ui-config.position :right)}] (var canvas nil) - (var widgets {:header nil - :messages nil - :prompt nil - :status nil - :tabs nil}) + (var set-internal-edit nil) + (local widgets {:header nil + :messages nil + :prompt nil + :status nil + :tabs nil}) (fn is-open? [] (and (not= nil canvas) (canvas:buf-valid?) (canvas:win-valid?))) + (fn get-prompt-start-line [] + "Get the line where the prompt area starts." + (let [state (widgets.prompt.get-state)] + (or state.prompt-start-line 0))) + + (fn with-internal-edit [f] + "Wrap a function call as an internal edit (bypasses edit guard)." + (when set-internal-edit + (set-internal-edit true)) + (f) + (when set-internal-edit + (set-internal-edit false))) + (fn render-all [] "Full render of all widgets." - (widgets.header.render) - (widgets.messages.render) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)) - (widgets.status.render) - (widgets.tabs.render)) + (with-internal-edit + (fn [] + (widgets.header.render) + (widgets.messages.render) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)) + (widgets.status.render) + (widgets.tabs.render)))) (fn open [] "Open the chat window." @@ -179,10 +216,14 @@ {:tabs [{:id 1 :title "Chat 1"}] :active-id 1})) + ;; Setup edit guard — only prompt area is user-editable + (set set-internal-edit + (setup-edit-guard api buf-id get-prompt-start-line)) + ;; Initial render - (canvas:set-modifiable true) - (canvas:set-lines 0 -1 [""]) - (canvas:set-modifiable false) + (with-internal-edit + (fn [] + (canvas:set-lines 0 -1 [""]))) (render-all)))) (fn close [] @@ -199,21 +240,27 @@ ;; === Message API === (fn append-message [msg] (when (is-open?) - (widgets.messages.append-message msg) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))) + (with-internal-edit + (fn [] + (widgets.messages.append-message msg) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) (fn update-message [id content] (when (is-open?) - (widgets.messages.update-message id content) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))) + (with-internal-edit + (fn [] + (widgets.messages.update-message id content) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) (fn clear-messages [] (when (is-open?) - (widgets.messages.clear) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))) + (with-internal-edit + (fn [] + (widgets.messages.clear) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) ;; === Tool call API (TODO) === (fn show-tool-call [tc] nil) @@ -236,15 +283,19 @@ ;; === Context API === (fn add-context [ctx] (when (is-open?) - (widgets.prompt.add-context ctx) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))) + (with-internal-edit + (fn [] + (widgets.prompt.add-context ctx) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) (fn remove-context [name] (when (is-open?) - (widgets.prompt.remove-context name) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))) + (with-internal-edit + (fn [] + (widgets.prompt.remove-context name) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) ;; === Tab API === (fn add-chat-tab [tab] @@ -272,13 +323,15 @@ (let [text (widgets.prompt.get-text)] (when (and text (not= "" text)) (widgets.prompt.add-to-history text) - (widgets.prompt.clear) + (with-internal-edit + (fn [] (widgets.prompt.clear))) (when on-submit (on-submit text)))))) (fn set-loading [bool] (when (is-open?) - (widgets.prompt.set-loading bool))) + (with-internal-edit + (fn [] (widgets.prompt.set-loading bool))))) ;; Public API {: open diff --git a/fnl/eca/ui/canvas.fnl b/fnl/eca/ui/canvas.fnl index e034dac..b39667f 100644 --- a/fnl/eca/ui/canvas.fnl +++ b/fnl/eca/ui/canvas.fnl @@ -16,7 +16,6 @@ :set-cursor ;; (canvas line col) — move cursor :buf-valid? ;; (canvas) — is buffer still valid? :win-valid? ;; (canvas) — is window still valid? - :set-modifiable ;; (canvas bool) — toggle buffer readonly :close-win ;; (canvas) — close window :set-hl ;; (canvas ns group opts) — define highlight group :buf-id ;; (canvas) — returns the underlying buffer id @@ -26,13 +25,11 @@ (fn validate [canvas] "Validate that a canvas implementation satisfies the protocol. Returns true if valid, or (false missing-keys) if not." - (var missing []) - (each [_ key (ipairs protocol-keys)] - (when (= nil (. canvas key)) - (table.insert missing key))) - (if (= 0 (length missing)) - true - (values false missing))) + (let [missing (icollect [_ key (ipairs protocol-keys)] + (when (= nil (. canvas key)) key))] + (if (= 0 (length missing)) + true + (values false missing)))) (fn describe [] "Returns the list of protocol keys for documentation/introspection." diff --git a/fnl/eca/ui/components/context-item.fnl b/fnl/eca/ui/components/context-item.fnl index 2e4565c..ccfe4ba 100644 --- a/fnl/eca/ui/components/context-item.fnl +++ b/fnl/eca/ui/components/context-item.fnl @@ -15,7 +15,7 @@ (let [cfg (or (. type-config type) {:prefix "@" :hl-group :EcaContextFile}) display-name (or cfg.label name "") - text (match type + text (case type :cursor (.. cfg.prefix "cursor(" (or name "") (if detail (.. " " detail) "") ")") :repo-map (.. cfg.prefix display-name) _ (.. cfg.prefix display-name))] diff --git a/fnl/eca/ui/widgets/context-bar.fnl b/fnl/eca/ui/widgets/context-bar.fnl index 02ff56c..214b62e 100644 --- a/fnl/eca/ui/widgets/context-bar.fnl +++ b/fnl/eca/ui/widgets/context-bar.fnl @@ -6,7 +6,7 @@ (fn create [canvas] "Create a context-bar widget. Returns {: render : add : remove : clear : get-state}." - (var state {:contexts [] + (local state {:contexts [] :ns-id nil}) (fn ensure-ns [] @@ -18,46 +18,43 @@ "Build the context bar line from current state." (if (= 0 (length state.contexts)) {:line "" :parts []} - (let [parts [] - highlights []] - (var col 0) - (each [i ctx (ipairs state.contexts)] - (when (> i 1) - (table.insert parts " ") - (set col (+ col 1))) - (let [rendered (context-item-component.render ctx)] - (table.insert parts rendered.text) - (table.insert highlights - {:hl-group rendered.hl-group - :col-start col - :col-end (+ col (length rendered.text))}) - (set col (+ col (length rendered.text))))) - {:line (table.concat parts "") - :highlights highlights}))) + (let [result (accumulate [acc {:parts [] :highlights [] :col 0} + _ ctx (ipairs state.contexts)] + (let [sep-col (if (> acc.col 0) + (do (table.insert acc.parts " ") + (+ acc.col 1)) + acc.col) + rendered (context-item-component.render ctx)] + (table.insert acc.parts rendered.text) + (table.insert acc.highlights + {:hl-group rendered.hl-group + :col-start sep-col + :col-end (+ sep-col (length rendered.text))}) + {:parts acc.parts + :highlights acc.highlights + :col (+ sep-col (length rendered.text))}))] + {:line (table.concat result.parts "") + :highlights result.highlights}))) (fn render [line-num] "Render the context bar at the given line number." (let [ns (ensure-ns) {: line : highlights} (build-line)] (when (and line (not= "" line)) - (canvas:set-modifiable true) (canvas:set-lines line-num (+ line-num 1) [line]) ;; Apply highlights (each [_ hl (ipairs (or highlights []))] (canvas:add-extmark ns line-num hl.col-start {:end_col hl.col-end - :hl_group hl.hl-group})) - (canvas:set-modifiable false)))) + :hl_group hl.hl-group}))))) (fn add [ctx] "Add a context item. ctx: {: type : name : path : detail}." - ;; Avoid duplicates by name - (var exists false) - (each [_ existing (ipairs state.contexts)] - (when (= existing.name ctx.name) - (set exists true))) - (when (not exists) - (table.insert state.contexts ctx))) + (let [exists (accumulate [found false + _ existing (ipairs state.contexts)] + (or found (= existing.name ctx.name)))] + (when (not exists) + (table.insert state.contexts ctx)))) (fn remove [name] "Remove a context item by name." diff --git a/fnl/eca/ui/widgets/expandable-block.fnl b/fnl/eca/ui/widgets/expandable-block.fnl index c4e26c4..01c0501 100644 --- a/fnl/eca/ui/widgets/expandable-block.fnl +++ b/fnl/eca/ui/widgets/expandable-block.fnl @@ -33,7 +33,7 @@ "Create an expandable-block widget. initial-state: {: id : type : status : expanded? : label : content : elapsed-ms : children} Returns {: render : toggle : update-status : collapse : expand : get-state}." - (var state (vim.tbl_extend :force + (local state (vim.tbl_extend :force {:id nil :type :tool-call :status nil @@ -63,13 +63,11 @@ (each [_ line (ipairs state.content)] (table.insert lines (.. " " line)))) ;; Write to buffer - (canvas:set-modifiable true) (canvas:set-lines start-line start-line lines) ;; Highlight the label line (canvas:add-extmark ns start-line 0 {:end_col (length label-line) :hl_group :EcaExpandableLabel}) - (canvas:set-modifiable false) ;; Return line count (length lines))) diff --git a/fnl/eca/ui/widgets/header-bar.fnl b/fnl/eca/ui/widgets/header-bar.fnl index f01ce39..fc6451f 100644 --- a/fnl/eca/ui/widgets/header-bar.fnl +++ b/fnl/eca/ui/widgets/header-bar.fnl @@ -28,7 +28,7 @@ "Create a header-bar widget. initial-state: {: model : agent : variant : mcps-total : mcps-ready} Returns {: render : update : get-state}." - (var state (or initial-state + (local state (or initial-state {:model "claude" :agent "coder" :variant nil diff --git a/fnl/eca/ui/widgets/message-list.fnl b/fnl/eca/ui/widgets/message-list.fnl index fdce0be..742a555 100644 --- a/fnl/eca/ui/widgets/message-list.fnl +++ b/fnl/eca/ui/widgets/message-list.fnl @@ -7,7 +7,7 @@ (fn create [canvas] "Create a message-list widget. Returns {: render : append-message : update-message : clear : get-state}." - (var state {:messages [] + (local state {:messages [] :ns-id nil :end-line 0}) @@ -31,26 +31,20 @@ (let [rendered (message-component.render msg) sep (separator-component.render {:width 50})] ;; Write message lines - (canvas:set-modifiable true) (canvas:set-lines start-line start-line rendered.lines) (apply-highlights start-line rendered.highlights) ;; Write separator after message (let [sep-line (+ start-line (length rendered.lines))] (canvas:set-lines sep-line sep-line [sep.line]) (apply-highlights sep-line sep.highlights) - (canvas:set-modifiable false) ;; Return total lines written (message + separator) (+ (length rendered.lines) 1)))) (fn render [] "Full re-render of all messages." (let [ns (ensure-ns)] - (canvas:set-modifiable true) ;; Clear the messages area (leave room for prompt at the end) - (let [total-lines (canvas:line-count)] - ;; We render from line 0 up to where messages end - ;; Prompt area is handled by prompt-area widget - (canvas:set-lines 0 state.end-line [])) + (canvas:set-lines 0 state.end-line []) (set state.end-line 0) (if (= 0 (length state.messages)) ;; Show welcome message @@ -61,8 +55,7 @@ ;; Render all messages (each [_ msg (ipairs state.messages)] (let [lines-written (render-single-message msg state.end-line)] - (set state.end-line (+ state.end-line lines-written))))) - (canvas:set-modifiable false))) + (set state.end-line (+ state.end-line lines-written))))))) (fn append-message [msg] "Append a new message and render it incrementally." @@ -80,15 +73,13 @@ (fn update-message [id new-content] "Update the content of an existing message (for streaming)." - ;; Find and update the message in state - (var found false) - (each [i msg (ipairs state.messages)] - (when (= msg.id id) - (tset msg :content new-content) - (set found true))) - ;; Re-render everything (could optimize later) - (when found - (render))) + (let [found (accumulate [f false + _ msg (ipairs state.messages)] + (if (= msg.id id) + (do (tset msg :content new-content) true) + f))] + (when found + (render)))) (fn clear [] "Clear all messages." diff --git a/fnl/eca/ui/widgets/prompt-area.fnl b/fnl/eca/ui/widgets/prompt-area.fnl index 06d49bc..224b38a 100644 --- a/fnl/eca/ui/widgets/prompt-area.fnl +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -8,7 +8,7 @@ (fn create [canvas] "Create a prompt-area widget. Returns widget with full API." - (var state {:loading? false + (local state {:loading? false :prompt-text "" :history [] :history-idx 0 @@ -35,20 +35,15 @@ lines [sep.line]] ;; Add context bar line if there are contexts (when has-contexts? - (let [parts []] - (var col 0) - (each [i ctx (ipairs ctx-state.contexts)] - (when (> i 1) - (table.insert parts " ")) - (let [ci (require :eca.ui.components.context-item) - rendered (ci.render ctx)] - (table.insert parts rendered.text))) - (table.insert lines (table.concat parts "")))) + (let [parts (icollect [_ ctx (ipairs ctx-state.contexts)] + (let [ci (require :eca.ui.components.context-item) + rendered (ci.render ctx)] + rendered.text))] + (table.insert lines (table.concat parts " ")))) ;; Add prompt line (table.insert lines (.. prefix.text state.prompt-text)) ;; Write all lines - (canvas:set-modifiable true) (canvas:set-lines start-line -1 lines) ;; Highlight separator @@ -66,8 +61,6 @@ (when has-contexts? (ctx-bar.render (+ start-line 1))) - (canvas:set-modifiable false) - ;; Position cursor at end of prompt (when (canvas:win-valid?) (let [prompt-line (+ start-line (length lines)) @@ -96,9 +89,7 @@ (let [prefix (prompt-prefix-component.render {:loading? state.loading?}) total (canvas:line-count) last-line-idx (- total 1)] - (canvas:set-modifiable true) - (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]) - (canvas:set-modifiable false))) + (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]))) (fn clear [] "Clear the prompt text." @@ -111,9 +102,7 @@ (let [prefix (prompt-prefix-component.render {:loading? bool}) total (canvas:line-count) last-line-idx (- total 1)] - (canvas:set-modifiable true) - (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]) - (canvas:set-modifiable false))) + (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]))) (fn add-to-history [text] "Save text to history." diff --git a/fnl/eca/ui/widgets/status-bar.fnl b/fnl/eca/ui/widgets/status-bar.fnl index f756f9b..329cea3 100644 --- a/fnl/eca/ui/widgets/status-bar.fnl +++ b/fnl/eca/ui/widgets/status-bar.fnl @@ -17,7 +17,7 @@ "Create a status-bar widget. initial-state: {: workspaces : elapsed-ms : tokens-in : tokens-out : max-tokens : cost : trust? : init-progress : pending-approvals?} Returns {: render : update : get-state}." - (var state (vim.tbl_extend :force + (local state (vim.tbl_extend :force {:workspaces [] :elapsed-ms nil :tokens-in 0 diff --git a/fnl/eca/ui/widgets/tab-bar.fnl b/fnl/eca/ui/widgets/tab-bar.fnl index 3ce53e2..31da58f 100644 --- a/fnl/eca/ui/widgets/tab-bar.fnl +++ b/fnl/eca/ui/widgets/tab-bar.fnl @@ -5,7 +5,7 @@ "Create a tab-bar widget. initial-state: {: tabs [{: id : title : loading? : approval?}] : active-id} Returns {: render : add-tab : remove-tab : select-tab : update-tab : get-state}." - (var state (vim.tbl_extend :force + (local state (vim.tbl_extend :force {:tabs [] :active-id nil} (or initial-state {}))) @@ -33,7 +33,8 @@ (fn render [] (let [tabline (build-tabline)] - (canvas:set-option :win :tabline tabline))) + (canvas:set-option :global :tabline tabline) + (canvas:set-option :global :showtabline 2))) (fn add-tab [tab] "Add a new tab. tab: {: id : title : loading? : approval?}." diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 45d339e..c300b55 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -76,6 +76,8 @@ local function set_option(scope, id, key, value) return nvim.nvim_set_option_value(key, value, {win = id}) elseif (scope == "buf") then return nvim.nvim_set_option_value(key, value, {buf = id}) + elseif (scope == "global") then + return nvim.nvim_set_option_value(key, value, {}) else return nil end @@ -85,6 +87,8 @@ local function get_option(scope, id, key) return nvim.nvim_get_option_value(key, {win = id}) elseif (scope == "buf") then return nvim.nvim_get_option_value(key, {buf = id}) + elseif (scope == "global") then + return nvim.nvim_get_option_value(key, {}) else return nil end diff --git a/lua/eca/ui/builder.lua b/lua/eca/ui/builder.lua index db27256..470fce5 100644 --- a/lua/eca/ui/builder.lua +++ b/lua/eca/ui/builder.lua @@ -34,6 +34,8 @@ local function build_canvas(api, buf_id, win_id) return api["set-option"]("win", win, key, value) elseif (scope == "buf") then return api["set-option"]("buf", buf, key, value) + elseif (scope == "global") then + return api["set-option"]("global", nil, key, value) else return nil end @@ -43,6 +45,8 @@ local function build_canvas(api, buf_id, win_id) return api["get-option"]("win", win, key) elseif (scope == "buf") then return api["get-option"]("buf", buf, key) + elseif (scope == "global") then + return api["get-option"]("global", nil, key) else return nil end @@ -59,33 +63,25 @@ local function build_canvas(api, buf_id, win_id) local function _15_(_) return api["win-is-valid"](win) end - local function _16_(_, bool) - return api["set-option"]("buf", buf, "modifiable", bool) - end - local function _17_(_, ns, group, opts) + local function _16_(_, ns, group, opts) return api["set-hl"](ns, group, opts) end - local function _18_(_) - api["win-close"](win) - win = nil - return nil + local function _17_(_) + return api["win-close"](win) end - local function _19_(_) + local function _18_(_) return buf end - local function _20_(_) + local function _19_(_) return win end - return {["set-lines"] = _1_, ["get-lines"] = _2_, ["line-count"] = _3_, ["add-extmark"] = _4_, ["del-extmark"] = _5_, ["get-extmarks"] = _6_, ["create-namespace"] = _7_, ["set-option"] = _8_, ["get-option"] = _10_, ["get-cursor"] = _12_, ["set-cursor"] = _13_, ["buf-valid?"] = _14_, ["win-valid?"] = _15_, ["set-modifiable"] = _16_, ["set-hl"] = _17_, ["close-win"] = _18_, ["buf-id"] = _19_, ["win-id"] = _20_} + return {["set-lines"] = _1_, ["get-lines"] = _2_, ["line-count"] = _3_, ["add-extmark"] = _4_, ["del-extmark"] = _5_, ["get-extmarks"] = _6_, ["create-namespace"] = _7_, ["set-option"] = _8_, ["get-option"] = _10_, ["get-cursor"] = _12_, ["set-cursor"] = _13_, ["buf-valid?"] = _14_, ["win-valid?"] = _15_, ["set-hl"] = _16_, ["close-win"] = _17_, ["buf-id"] = _18_, ["win-id"] = _19_} end local function setup_chat_buffer(canvas) canvas["set-option"](canvas, "buf", "buftype", "nofile") canvas["set-option"](canvas, "buf", "bufhidden", "hide") canvas["set-option"](canvas, "buf", "swapfile", false) - canvas["set-option"](canvas, "buf", "filetype", "eca-chat") - canvas["set-option"](canvas, "buf", "wrap", true) - canvas["set-option"](canvas, "buf", "linebreak", true) - return canvas["set-option"](canvas, "buf", "modifiable", false) + return canvas["set-option"](canvas, "buf", "filetype", "eca-chat") end local function setup_chat_window(canvas) canvas["set-option"](canvas, "win", "number", false) @@ -97,32 +93,80 @@ local function setup_chat_window(canvas) canvas["set-option"](canvas, "win", "linebreak", true) return canvas["set-option"](canvas, "win", "conceallevel", 2) end -local function create_chat_ui(_21_) - local api = _21_.api - local on_submit = _21_["on-submit"] - local on_approve = _21_["on-approve"] - local on_reject = _21_["on-reject"] - local on_stop = _21_["on-stop"] - local on_new_chat = _21_["on-new-chat"] - local on_select_tab = _21_["on-select-tab"] - local on_context_add = _21_["on-context-add"] - local opts = _21_.opts +local function setup_edit_guard(api, buf_id, get_prompt_start_line) + local internal_edit = false + local function set_internal(bool) + internal_edit = bool + return nil + end + local function _20_(_, buf, changedtick, first_line, last_line, new_last_line) + if not internal_edit then + local prompt_start = get_prompt_start_line() + if (first_line < prompt_start) then + local function _21_() + if api["buf-is-valid"](buf) then + return vim.cmd("silent! undo") + else + return nil + end + end + return api.schedule(_21_) + else + return nil + end + else + return nil + end + end + api["buf-attach"](buf_id, {on_lines = _20_}) + return set_internal +end +local function create_chat_ui(_25_) + local api = _25_.api + local on_submit = _25_["on-submit"] + local on_approve = _25_["on-approve"] + local on_reject = _25_["on-reject"] + local on_stop = _25_["on-stop"] + local on_new_chat = _25_["on-new-chat"] + local on_select_tab = _25_["on-select-tab"] + local on_context_add = _25_["on-context-add"] + local opts = _25_.opts local ui_config = (opts.ui or {}) local config = {width = (ui_config.width or 0.4), position = (ui_config.position or "right")} local canvas = nil + local set_internal_edit = nil local widgets = {header = nil, messages = nil, prompt = nil, status = nil, tabs = nil} local function is_open_3f() return ((nil ~= canvas) and canvas["buf-valid?"](canvas) and canvas["win-valid?"](canvas)) end + local function get_prompt_start_line() + local state = widgets.prompt["get-state"]() + return (state["prompt-start-line"] or 0) + end + local function with_internal_edit(f) + if set_internal_edit then + set_internal_edit(true) + else + end + f() + if set_internal_edit then + return set_internal_edit(false) + else + return nil + end + end local function render_all() - widgets.header.render() - widgets.messages.render() - do - local end_line = widgets.messages["get-end-line"]() - widgets.prompt.render(end_line) + local function _28_() + widgets.header.render() + widgets.messages.render() + do + local end_line = widgets.messages["get-end-line"]() + widgets.prompt.render(end_line) + end + widgets.status.render() + return widgets.tabs.render() end - widgets.status.render() - return widgets.tabs.render() + return with_internal_edit(_28_) end local function open() if not is_open_3f() then @@ -138,9 +182,11 @@ local function create_chat_ui(_21_) widgets.prompt = prompt_area_widget.create(canvas) widgets.status = status_bar_widget.create(canvas, {}) widgets.tabs = tab_bar_widget.create(canvas, {tabs = {{id = 1, title = "Chat 1"}}, ["active-id"] = 1}) - canvas["set-modifiable"](canvas, true) - canvas["set-lines"](canvas, 0, -1, {""}) - canvas["set-modifiable"](canvas, false) + set_internal_edit = setup_edit_guard(api, buf_id, get_prompt_start_line) + local function _29_() + return canvas["set-lines"](canvas, 0, -1, {""}) + end + with_internal_edit(_29_) return render_all() else return nil @@ -162,27 +208,36 @@ local function create_chat_ui(_21_) end local function append_message(msg) if is_open_3f() then - widgets.messages["append-message"](msg) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + local function _33_() + widgets.messages["append-message"](msg) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + end + return with_internal_edit(_33_) else return nil end end local function update_message(id, content) if is_open_3f() then - widgets.messages["update-message"](id, content) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + local function _35_() + widgets.messages["update-message"](id, content) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + end + return with_internal_edit(_35_) else return nil end end local function clear_messages() if is_open_3f() then - widgets.messages.clear() - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + local function _37_() + widgets.messages.clear() + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + end + return with_internal_edit(_37_) else return nil end @@ -219,18 +274,24 @@ local function create_chat_ui(_21_) end local function add_context(ctx) if is_open_3f() then - widgets.prompt["add-context"](ctx) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + local function _42_() + widgets.prompt["add-context"](ctx) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + end + return with_internal_edit(_42_) else return nil end end local function remove_context(name) if is_open_3f() then - widgets.prompt["remove-context"](name) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + local function _44_() + widgets.prompt["remove-context"](name) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + end + return with_internal_edit(_44_) else return nil end @@ -271,7 +332,10 @@ local function create_chat_ui(_21_) local text = widgets.prompt["get-text"]() if (text and ("" ~= text)) then widgets.prompt["add-to-history"](text) - widgets.prompt.clear() + local function _50_() + return widgets.prompt.clear() + end + with_internal_edit(_50_) if on_submit then return on_submit(text) else @@ -286,7 +350,10 @@ local function create_chat_ui(_21_) end local function set_loading(bool) if is_open_3f() then - return widgets.prompt["set-loading"](bool) + local function _54_() + return widgets.prompt["set-loading"](bool) + end + return with_internal_edit(_54_) else return nil end diff --git a/lua/eca/ui/canvas.lua b/lua/eca/ui/canvas.lua index ff6449f..76343a1 100644 --- a/lua/eca/ui/canvas.lua +++ b/lua/eca/ui/canvas.lua @@ -1,12 +1,24 @@ -- [nfnl] fnl/eca/ui/canvas.fnl local protocol_keys = {"set-lines", "get-lines", "add-extmark", "del-extmark", "get-extmarks", "create-namespace", "set-option", "get-option", "line-count", "get-cursor", "set-cursor", "buf-valid?", "win-valid?", "set-modifiable", "close-win", "set-hl", "buf-id", "win-id"} local function validate(canvas) - local missing = {} - for _, key in ipairs(protocol_keys) do - if (nil == canvas[key]) then - table.insert(missing, key) - else + local missing + do + local tbl_26_ = {} + local i_27_ = 0 + for _, key in ipairs(protocol_keys) do + local val_28_ + if (nil == canvas[key]) then + val_28_ = key + else + val_28_ = nil + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end end + missing = tbl_26_ end if (0 == #missing) then return true diff --git a/lua/eca/ui/widgets/context-bar.lua b/lua/eca/ui/widgets/context-bar.lua index c1ae20f..bb70c85 100644 --- a/lua/eca/ui/widgets/context-bar.lua +++ b/lua/eca/ui/widgets/context-bar.lua @@ -13,21 +13,25 @@ local function create(canvas) if (0 == #state.contexts) then return {line = "", parts = {}} else - local parts = {} - local highlights = {} - local col = 0 - for i, ctx in ipairs(state.contexts) do - if (i > 1) then - table.insert(parts, " ") - col = (col + 1) - else + local result + do + local acc = {parts = {}, highlights = {}, col = 0} + for _, ctx in ipairs(state.contexts) do + local sep_col + if (acc.col > 0) then + table.insert(acc.parts, " ") + sep_col = (acc.col + 1) + else + sep_col = acc.col + end + local rendered = context_item_component.render(ctx) + table.insert(acc.parts, rendered.text) + table.insert(acc.highlights, {["hl-group"] = rendered["hl-group"], ["col-start"] = sep_col, ["col-end"] = (sep_col + #rendered.text)}) + acc = {parts = acc.parts, highlights = acc.highlights, col = (sep_col + #rendered.text)} end - local rendered = context_item_component.render(ctx) - table.insert(parts, rendered.text) - table.insert(highlights, {["hl-group"] = rendered["hl-group"], ["col-start"] = col, ["col-end"] = (col + #rendered.text)}) - col = (col + #rendered.text) + result = acc end - return {line = table.concat(parts, ""), highlights = highlights} + return {line = table.concat(result.parts, ""), highlights = result.highlights} end end local function render(line_num) @@ -47,12 +51,13 @@ local function create(canvas) end end local function add(ctx) - local exists = false - for _, existing in ipairs(state.contexts) do - if (existing.name == ctx.name) then - exists = true - else + local exists + do + local found = false + for _, existing in ipairs(state.contexts) do + found = (found or (existing.name == ctx.name)) end + exists = found end if not exists then return table.insert(state.contexts, ctx) diff --git a/lua/eca/ui/widgets/message-list.lua b/lua/eca/ui/widgets/message-list.lua index a4a8631..3226536 100644 --- a/lua/eca/ui/widgets/message-list.lua +++ b/lua/eca/ui/widgets/message-list.lua @@ -66,13 +66,18 @@ local function create(canvas) end end local function update_message(id, new_content) - local found = false - for i, msg in ipairs(state.messages) do - if (msg.id == id) then - msg["content"] = new_content - found = true - else + local found + do + local f = false + for _, msg in ipairs(state.messages) do + if (msg.id == id) then + msg["content"] = new_content + f = true + else + f = f + end end + found = f end if found then return render() diff --git a/lua/eca/ui/widgets/prompt-area.lua b/lua/eca/ui/widgets/prompt-area.lua index 617d0ef..6cdede6 100644 --- a/lua/eca/ui/widgets/prompt-area.lua +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -21,18 +21,26 @@ local function create(canvas) local has_contexts_3f = (#ctx_state.contexts > 0) local lines = {sep.line} if has_contexts_3f then - local parts = {} - local col = 0 - for i, ctx in ipairs(ctx_state.contexts) do - if (i > 1) then - table.insert(parts, " ") - else + local parts + do + local tbl_26_ = {} + local i_27_ = 0 + for _, ctx in ipairs(ctx_state.contexts) do + local val_28_ + do + local ci = require("eca.ui.components.context-item") + local rendered = ci.render(ctx) + val_28_ = rendered.text + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end end - local ci = require("eca.ui.components.context-item") - local rendered = ci.render(ctx) - table.insert(parts, rendered.text) + parts = tbl_26_ end - table.insert(lines, table.concat(parts, "")) + table.insert(lines, table.concat(parts, " ")) else end table.insert(lines, (prefix.text .. state["prompt-text"])) diff --git a/lua/eca/ui/widgets/tab-bar.lua b/lua/eca/ui/widgets/tab-bar.lua index 76044f2..e75941b 100644 --- a/lua/eca/ui/widgets/tab-bar.lua +++ b/lua/eca/ui/widgets/tab-bar.lua @@ -32,7 +32,8 @@ local function create(canvas, initial_state) end local function render() local tabline = build_tabline() - return canvas["set-option"](canvas, "win", "tabline", tabline) + canvas["set-option"](canvas, "global", "tabline", tabline) + return canvas["set-option"](canvas, "global", "showtabline", 2) end local function add_tab(tab) table.insert(state.tabs, tab) From 61ceb507ec3a1422b5ffc1fba5167196384ca0c3 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 11 May 2026 09:57:35 -0300 Subject: [PATCH 3/8] wip commiting current state --- fnl/eca/api.fnl | 249 +++++------- fnl/eca/commands.fnl | 46 ++- fnl/eca/init.fnl | 39 +- fnl/eca/ui/builder.fnl | 510 +++++++++++------------- fnl/eca/ui/canvas.fnl | 40 -- fnl/eca/ui/components/context-item.fnl | 27 +- fnl/eca/ui/components/icon.fnl | 37 +- fnl/eca/ui/components/message.fnl | 60 +-- fnl/eca/ui/components/usage.fnl | 46 +-- fnl/eca/ui/highlights.fnl | 93 ++--- fnl/eca/ui/widgets/context-bar.fnl | 76 ++-- fnl/eca/ui/widgets/expandable-block.fnl | 76 +--- fnl/eca/ui/widgets/footer-bar.fnl | 74 ++++ fnl/eca/ui/widgets/header-bar.fnl | 76 ++-- fnl/eca/ui/widgets/message-list.fnl | 100 ++--- fnl/eca/ui/widgets/prompt-area.fnl | 149 +++---- fnl/eca/ui/widgets/status-bar.fnl | 87 ++-- fnl/eca/ui/widgets/tab-bar.fnl | 43 +- lua/eca/api.lua | 175 ++++---- lua/eca/commands.lua | 40 +- lua/eca/init.lua | 24 +- lua/eca/ui/builder.lua | 501 ++++++++++++----------- lua/eca/ui/canvas.lua | 32 -- lua/eca/ui/components/context-item.lua | 25 +- lua/eca/ui/components/icon.lua | 9 +- lua/eca/ui/components/message.lua | 40 +- lua/eca/ui/components/usage.lua | 45 +-- lua/eca/ui/highlights.lua | 7 +- lua/eca/ui/widgets/context-bar.lua | 62 +-- lua/eca/ui/widgets/expandable-block.lua | 76 +--- lua/eca/ui/widgets/footer-bar.lua | 102 +++++ lua/eca/ui/widgets/header-bar.lua | 85 ++-- lua/eca/ui/widgets/message-list.lua | 66 +-- lua/eca/ui/widgets/prompt-area.lua | 73 ++-- lua/eca/ui/widgets/status-bar.lua | 92 +++-- lua/eca/ui/widgets/tab-bar.lua | 57 +-- 36 files changed, 1475 insertions(+), 1864 deletions(-) delete mode 100644 fnl/eca/ui/canvas.fnl create mode 100644 fnl/eca/ui/widgets/footer-bar.fnl delete mode 100644 lua/eca/ui/canvas.lua create mode 100644 lua/eca/ui/widgets/footer-bar.lua diff --git a/fnl/eca/api.fnl b/fnl/eca/api.fnl index 90f26e0..4b14120 100644 --- a/fnl/eca/api.fnl +++ b/fnl/eca/api.fnl @@ -1,157 +1,92 @@ -;; Neovim API adapter — flat functions wrapping vim.api.* -;; This is the ONLY file that touches vim.api.* directly. -;; Every other module uses these functions instead of calling Neovim directly. - -(local nvim vim.api) - -;; ── Buffer ────────────────────────────────────────────── - -(fn buf-set-lines [buf start end lines] - (nvim.nvim_buf_set_lines buf start end false lines)) - -(fn buf-get-lines [buf start end] - (nvim.nvim_buf_get_lines buf start end false)) - -(fn buf-line-count [buf] - (nvim.nvim_buf_line_count buf)) - -(fn buf-is-valid [buf] - (and (not= nil buf) (nvim.nvim_buf_is_valid buf))) - -(fn buf-create [opts] - "Create a new buffer. opts: {: listed : scratch}" - (let [o (or opts {})] - (nvim.nvim_create_buf - (if (not= nil o.listed) o.listed false) - (if (not= nil o.scratch) o.scratch true)))) - -(fn buf-set-keymap [buf mode lhs rhs opts] - (nvim.nvim_buf_set_keymap buf mode lhs rhs (or opts {}))) - -;; ── Window ────────────────────────────────────────────── - -(fn win-open [buf opts] - "Open a new window. Returns win-id." - (nvim.nvim_open_win buf true (or opts {}))) - -(fn win-close [win] - (when (and win (nvim.nvim_win_is_valid win)) - (nvim.nvim_win_close win true))) - -(fn win-is-valid [win] - (and (not= nil win) (nvim.nvim_win_is_valid win))) - -(fn win-get-cursor [win] - (when win (nvim.nvim_win_get_cursor win))) - -(fn win-set-cursor [win pos] - (when win (nvim.nvim_win_set_cursor win pos))) - -;; ── Extmarks ──────────────────────────────────────────── - -(fn create-namespace [name] - (nvim.nvim_create_namespace name)) - -(fn buf-set-extmark [buf ns-id line col opts] - (nvim.nvim_buf_set_extmark buf ns-id line col (or opts {}))) - -(fn buf-del-extmark [buf ns-id id] - (nvim.nvim_buf_del_extmark buf ns-id id)) - -(fn buf-get-extmarks [buf ns-id start end opts] - (nvim.nvim_buf_get_extmarks buf ns-id start end (or opts {}))) - -;; ── Options ───────────────────────────────────────────── - -(fn set-option [scope id key value] - "Set an option. scope: :win, :buf, or :global. id: win-id or buf-id (ignored for :global)." - (case scope - :win (nvim.nvim_set_option_value key value {:win id}) - :buf (nvim.nvim_set_option_value key value {:buf id}) - :global (nvim.nvim_set_option_value key value {}))) - -(fn get-option [scope id key] - "Get an option. scope: :win, :buf, or :global." - (case scope - :win (nvim.nvim_get_option_value key {:win id}) - :buf (nvim.nvim_get_option_value key {:buf id}) - :global (nvim.nvim_get_option_value key {}))) - -;; ── Highlights ────────────────────────────────────────── - -(fn set-hl [ns group opts] - (nvim.nvim_set_hl ns group opts)) - -;; ── Commands ──────────────────────────────────────────── - -(fn create-user-command [name f opts] - (nvim.nvim_create_user_command name f (or opts {}))) - -(fn create-autocmd [event opts] - (nvim.nvim_create_autocmd event opts)) - -;; ── Keymaps (global) ──────────────────────────────────── - -(fn set-keymap [mode lhs rhs opts] - (vim.keymap.set mode lhs rhs (or opts {}))) - -;; ── Buffer attach ─────────────────────────────────────── - -(fn buf-attach [buf opts] - "Attach to buffer events. opts: {: on_lines : on_bytes : on_detach ...} - on_lines: (fn [_ buf changedtick first-line last-line new-last-line] ...) - Return true from on_lines to detach." - (nvim.nvim_buf_attach buf false (or opts {}))) - -;; ── Scheduling ────────────────────────────────────────── - -(fn schedule [f] - (vim.schedule f)) - -(fn defer [f ms] - (vim.defer_fn f ms)) - -;; ── Editor info ───────────────────────────────────────── - -(fn editor-width [] - (. vim.o :columns)) - -(fn editor-height [] - (. vim.o :lines)) - -{;; Buffer - : buf-set-lines - : buf-get-lines - : buf-line-count - : buf-is-valid - : buf-create - : buf-set-keymap - ;; Window - : win-open - : win-close - : win-is-valid - : win-get-cursor - : win-set-cursor - ;; Extmarks - : create-namespace - : buf-set-extmark - : buf-del-extmark - : buf-get-extmarks - ;; Options - : set-option - : get-option - ;; Highlights - : set-hl - ;; Commands - : create-user-command - : create-autocmd - ;; Keymaps - : set-keymap - ;; Buffer attach - : buf-attach - ;; Scheduling - : schedule - : defer - ;; Editor - : editor-width - : editor-height} +;; 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 "" :rhs "EcaChatSubmit"} + {:mode :n :lhs "" :rhs "EcaChatSubmit"}])}})] + (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 diff --git a/fnl/eca/commands.fnl b/fnl/eca/commands.fnl index ef4615e..703edf6 100644 --- a/fnl/eca/commands.fnl +++ b/fnl/eca/commands.fnl @@ -1,36 +1,42 @@ ;; commands — Vim user commands for ECA. -;; Users map keymaps to these commands in their own config. +;; Uses the public chat API from api.fnl. -(local api (require :eca.api)) +(local nvim vim.api) -(fn setup [chat-ui] +(fn setup [api] "Register all :Eca* user commands." - (api.create-user-command "EcaChat" - (fn [] (chat-ui.toggle)) + (nvim.nvim_create_user_command "EcaChat" + (fn [] (api.chat-toggle)) {:desc "Toggle ECA Chat window"}) - (api.create-user-command "EcaChatOpen" - (fn [] (chat-ui.open)) + (nvim.nvim_create_user_command "EcaChatOpen" + (fn [] (api.chat-open)) {:desc "Open ECA Chat window"}) - (api.create-user-command "EcaChatClose" - (fn [] (chat-ui.close)) + (nvim.nvim_create_user_command "EcaChatClose" + (fn [] (api.chat-close)) {:desc "Close ECA Chat window"}) - (api.create-user-command "EcaChatClear" - (fn [] (chat-ui.clear-messages)) - {:desc "Clear ECA Chat messages"}) + (nvim.nvim_create_user_command "EcaChatClear" + (fn [] (api.chat-clear)) + {:desc "Clear current chat messages"}) - (api.create-user-command "EcaChatNew" - (fn [] (chat-ui.clear-messages)) - {:desc "Start a new ECA Chat"}) + (nvim.nvim_create_user_command "EcaChatNew" + (fn [] (api.chat-open)) + {:desc "Open a new ECA Chat"}) - (api.create-user-command "EcaChatSubmit" - (fn [] (chat-ui.submit-prompt)) + (nvim.nvim_create_user_command "EcaChatSubmit" + (fn [] (api.chat-submit)) {:desc "Submit current prompt"}) - (api.create-user-command "EcaChatStop" - (fn [] (chat-ui.set-loading false)) - {:desc "Stop current ECA response"})) + (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} diff --git a/fnl/eca/init.fnl b/fnl/eca/init.fnl index 557b61e..e8e74c3 100644 --- a/fnl/eca/init.fnl +++ b/fnl/eca/init.fnl @@ -1,43 +1,12 @@ ;; ECA Neovim Plugin — entry point. -;; setup(opts) initializes everything and registers :Eca* commands. +;; Minimal: just setup, delegates everything to api.fnl. (local api (require :eca.api)) -(local builder (require :eca.ui.builder)) (local commands (require :eca.commands)) -(var chat-ui nil) - -(fn default-on-submit [text] - "Default submit handler — echoes prompt as user message + mock assistant reply." - (when chat-ui - (chat-ui.append-message - {:id (tostring (os.time)) - :role :user - :content text}) - (chat-ui.set-loading true) - (api.defer - (fn [] - (when chat-ui - (chat-ui.append-message - {:id (.. "reply-" (tostring (os.time))) - :role :assistant - :content (.. "You said: " text "\n\n(This is a mock response — connect ECA server for real responses)")}) - (chat-ui.set-loading false))) - 500))) - (fn setup [opts] - "Initialize ECA plugin. - opts: {: ui {: width : position} : on-submit}" - (let [user-opts (or opts {})] - - ;; Create chat UI via builder with injected api - (set chat-ui - (builder.create-chat-ui - {:api api - :on-submit (or user-opts.on-submit default-on-submit) - :opts {:ui (or user-opts.ui {})}})) - - ;; Register commands — users map their own keymaps to these - (commands.setup chat-ui))) + "Initialize ECA plugin." + (api.set-plugin-opts (or opts {})) + (commands.setup api)) {: setup} diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl index 36baa71..5d5abfd 100644 --- a/fnl/eca/ui/builder.fnl +++ b/fnl/eca/ui/builder.fnl @@ -1,250 +1,227 @@ -;; builder — orchestrates widgets, receives injected dependencies. -;; Builds a canvas from api functions and injects it into widgets. -;; The builder NEVER imports vim.api — all interaction goes through injected api. +;; builder — orchestrates widgets, manages chat UI lifecycle. +(local nvim vim.api) (local highlights (require :eca.ui.highlights)) (local header-bar-widget (require :eca.ui.widgets.header-bar)) (local message-list-widget (require :eca.ui.widgets.message-list)) (local prompt-area-widget (require :eca.ui.widgets.prompt-area)) -(local status-bar-widget (require :eca.ui.widgets.status-bar)) -(local tab-bar-widget (require :eca.ui.widgets.tab-bar)) - -;; ── Canvas builder ────────────────────────────────────── - -(fn build-canvas [api buf-id win-id] - "Build a canvas object from flat api functions, bound to a specific buf/win. - This is the bridge between the flat api module and the canvas protocol - that widgets expect." - (let [buf buf-id - win win-id] - - {:set-lines - (fn [_ start end lines] - (api.buf-set-lines buf start end lines)) - - :get-lines - (fn [_ start end] - (api.buf-get-lines buf start end)) - - :line-count - (fn [_] - (api.buf-line-count buf)) - - :add-extmark - (fn [_ ns-id line col opts] - (api.buf-set-extmark buf ns-id line col opts)) - - :del-extmark - (fn [_ ns-id id] - (api.buf-del-extmark buf ns-id id)) - - :get-extmarks - (fn [_ ns-id start end opts] - (api.buf-get-extmarks buf ns-id start end opts)) - - :create-namespace - (fn [_ name] - (api.create-namespace name)) - - :set-option - (fn [_ scope key value] - (case scope - :win (api.set-option :win win key value) - :buf (api.set-option :buf buf key value) - :global (api.set-option :global nil key value))) - - :get-option - (fn [_ scope key] - (case scope - :win (api.get-option :win win key) - :buf (api.get-option :buf buf key) - :global (api.get-option :global nil key))) - - :get-cursor - (fn [_] - (api.win-get-cursor win)) - - :set-cursor - (fn [_ line col] - (api.win-set-cursor win [line col])) - - :buf-valid? - (fn [_] - (api.buf-is-valid buf)) - - :win-valid? - (fn [_] - (api.win-is-valid win)) - - :set-hl - (fn [_ ns group opts] - (api.set-hl ns group opts)) - - :close-win - (fn [_] - (api.win-close win)) - - :buf-id (fn [_] buf) - :win-id (fn [_] win)})) +(local footer-bar-widget (require :eca.ui.widgets.footer-bar)) ;; ── Buffer/window setup ───────────────────────────────── -(fn setup-chat-buffer [canvas] - "Configure the chat buffer options." - (canvas:set-option :buf :buftype "nofile") - (canvas:set-option :buf :bufhidden "hide") - (canvas:set-option :buf :swapfile false) - (canvas:set-option :buf :filetype "eca-chat")) - -(fn setup-chat-window [canvas] - "Configure the chat window options." - (canvas:set-option :win :number false) - (canvas:set-option :win :relativenumber false) - (canvas:set-option :win :signcolumn "no") - (canvas:set-option :win :foldcolumn "0") - (canvas:set-option :win :spell false) - (canvas:set-option :win :wrap true) - (canvas:set-option :win :linebreak true) - (canvas:set-option :win :conceallevel 2)) +(fn setup-chat-buffer [buf] + (nvim.nvim_buf_set_name buf "ECA Chat") + (nvim.nvim_set_option_value :buftype "nofile" {:buf buf}) + (nvim.nvim_set_option_value :bufhidden "hide" {:buf buf}) + (nvim.nvim_set_option_value :swapfile false {:buf buf}) + (nvim.nvim_set_option_value :filetype "eca-chat" {:buf buf})) + +(fn setup-chat-window [win] + (nvim.nvim_set_option_value :number false {:win win}) + (nvim.nvim_set_option_value :relativenumber false {:win win}) + (nvim.nvim_set_option_value :signcolumn "no" {:win win}) + (nvim.nvim_set_option_value :foldcolumn "0" {:win win}) + (nvim.nvim_set_option_value :numberwidth 1 {:win win}) + (nvim.nvim_set_option_value :statuscolumn "" {:win win}) + (nvim.nvim_set_option_value :spell false {:win win}) + (nvim.nvim_set_option_value :list false {:win win}) + (nvim.nvim_set_option_value :wrap true {:win win}) + (nvim.nvim_set_option_value :linebreak true {:win win}) + (nvim.nvim_set_option_value :conceallevel 2 {:win win}) +) ;; ── Edit guard ────────────────────────────────────────── -(fn setup-edit-guard [api buf-id get-prompt-start-line] - "Attach to buffer to guard editable region. - Only the prompt area (from prompt-start-line onwards) is user-editable. - Edits outside that region are undone immediately." +(fn setup-edit-guard [buf-id render-all-fn get-prompt-state focus-prompt-fn] (var internal-edit false) + (var guard-ns nil) + + (fn ensure-guard-ns [] + (when (= nil guard-ns) + (set guard-ns (nvim.nvim_create_namespace "eca-edit-guard"))) + guard-ns) + + (fn get-prefix [loading?] + (let [prompt-prefix (require :eca.ui.components.prompt-prefix)] + (. (prompt-prefix.render {:loading? loading?}) :text))) + + (fn salvage-user-text [buf prompt-start-line prefix] + (let [current-count (nvim.nvim_buf_line_count buf) + start (math.min prompt-start-line current-count) + prompt-lines (nvim.nvim_buf_get_lines buf start current-count false)] + (if (= 0 (length prompt-lines)) + [""] + (icollect [i line (ipairs prompt-lines)] + (if (= i 1) + (if (vim.startswith line prefix) + (string.sub line (+ (length prefix) 1)) + (line:gsub "^>%s*" "")) + line))))) + + (fn restore-with-user-text [buf prefix user-lines] + (set internal-edit true) + (render-all-fn) + (let [new-count (nvim.nvim_buf_line_count buf) + new-last-idx (- new-count 1) + restored-lines (icollect [i line (ipairs user-lines)] + (if (= i 1) (.. prefix line) line))] + (when (> (length restored-lines) 0) + (nvim.nvim_buf_set_lines buf new-last-idx new-count false restored-lines) + (let [ns (ensure-guard-ns)] + (nvim.nvim_buf_set_extmark buf ns new-last-idx 0 + {:end_col (length prefix) + :hl_group :EcaPromptPrefix})))) + (set internal-edit false) + (when focus-prompt-fn + (focus-prompt-fn))) + + (fn on-lines-handler [_ buf changedtick first-line last-line new-last-line] + (when (not internal-edit) + (let [{: prompt-start-line : loading?} (get-prompt-state) + prefix (get-prefix loading?)] + (vim.schedule + (fn [] + (when (nvim.nvim_buf_is_valid buf) + (let [current-count (nvim.nvim_buf_line_count buf) + prompt-idx (math.min prompt-start-line (- current-count 1)) + prompt-lines (nvim.nvim_buf_get_lines buf prompt-idx (+ prompt-idx 1) false) + prompt-line-text (or (. prompt-lines 1) "") + damaged? (or (< first-line prompt-start-line) + (not (vim.startswith prompt-line-text prefix)))] + (when damaged? + (let [user-lines (salvage-user-text buf prompt-start-line prefix)] + (restore-with-user-text buf prefix user-lines)))))))))) + + (nvim.nvim_buf_attach buf-id false {:on_lines on-lines-handler}) (fn set-internal [bool] (set internal-edit bool)) - (api.buf-attach buf-id - {:on_lines - (fn [_ buf changedtick first-line last-line new-last-line] - ;; If this is an internal (widget) edit, allow it - (when (not internal-edit) - (let [prompt-start (get-prompt-start-line)] - ;; If the edit touches lines before the prompt area, undo it - (when (< first-line prompt-start) - (api.schedule - (fn [] - (when (api.buf-is-valid buf) - (vim.cmd "silent! undo"))))))))}) - - ;; Return a function to wrap internal edits - set-internal) + (fn update-expected-count [] nil) -;; ── Main entry ────────────────────────────────────────── + {: set-internal : update-expected-count}) -(fn create-chat-ui [{: api : on-submit : on-approve : on-reject - : on-stop : on-new-chat : on-select-tab - : on-context-add : opts}] - "Create the chat UI. Receives injected dependencies. - api: flat module of Neovim API functions (from eca.api) - on-*: callback functions for user actions - opts: {: ui} where ui contains {: width : position} +;; ── Main entry ────────────────────────────────────────── - Returns chat-ui with public API." +(fn create-chat-ui [{: on-submit : opts}] (let [ui-config (or opts.ui {}) config {:width (or ui-config.width 0.4) - :position (or ui-config.position :right)}] + :position (or ui-config.position :right) + :keymaps (or opts.keymaps [])}] - (var canvas nil) - (var set-internal-edit nil) - (local widgets {:header nil - :messages nil - :prompt nil - :status nil - :tabs nil}) + ;; Mutable state + (local state {:header-items [] + :footer-items [] + :welcome nil}) - (fn is-open? [] - (and (not= nil canvas) - (canvas:buf-valid?) - (canvas:win-valid?))) + (var buf-id nil) + (var win-id nil) + (var guard nil) + (local widgets {:header nil :messages nil :prompt nil :footer nil}) - (fn get-prompt-start-line [] - "Get the line where the prompt area starts." - (let [state (widgets.prompt.get-state)] - (or state.prompt-start-line 0))) + (fn is-open? [] + (and (not= nil buf-id) + (nvim.nvim_buf_is_valid buf-id) + (not= nil win-id) + (nvim.nvim_win_is_valid win-id))) (fn with-internal-edit [f] - "Wrap a function call as an internal edit (bypasses edit guard)." - (when set-internal-edit - (set-internal-edit true)) + (when guard (guard.set-internal true)) (f) - (when set-internal-edit - (set-internal-edit false))) + (when guard + (guard.set-internal false) + (guard.update-expected-count))) + + (fn focus-prompt [] + (when (and win-id (nvim.nvim_win_is_valid win-id)) + (let [total (nvim.nvim_buf_line_count buf-id) + prompt-state (widgets.prompt.get-state) + prompt-line (or prompt-state.prompt-start-line (- total 1))] + (nvim.nvim_win_set_cursor win-id [(+ prompt-line 1) 2])))) (fn render-all [] - "Full render of all widgets." (with-internal-edit (fn [] - (widgets.header.render) - (widgets.messages.render) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)) - (widgets.status.render) - (widgets.tabs.render)))) + (let [header-lines (widgets.header.render)] + (widgets.messages.set-start-line header-lines) + (widgets.messages.render) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line))) + (when widgets.footer + (widgets.footer.render))))) - (fn open [] - "Open the chat window." - (when (not (is-open?)) - (let [buf-id (api.buf-create {:listed false :scratch true}) - win-width (math.floor (* (api.editor-width) config.width)) - win-id (api.win-open buf-id - {:split :right - :width win-width})] - ;; Build canvas from api functions + buf/win ids - (set canvas (build-canvas api buf-id win-id)) - - ;; Setup highlights, buffer and window options - (highlights.setup canvas) - (setup-chat-buffer canvas) - (setup-chat-window canvas) - - ;; Create widgets — all receive canvas (never api directly) - (set widgets.header - (header-bar-widget.create canvas {})) - (set widgets.messages - (message-list-widget.create canvas)) - (set widgets.prompt - (prompt-area-widget.create canvas)) - (set widgets.status - (status-bar-widget.create canvas {})) - (set widgets.tabs - (tab-bar-widget.create canvas - {:tabs [{:id 1 :title "Chat 1"}] - :active-id 1})) - - ;; Setup edit guard — only prompt area is user-editable - (set set-internal-edit - (setup-edit-guard api buf-id get-prompt-start-line)) - - ;; Initial render - (with-internal-edit - (fn [] - (canvas:set-lines 0 -1 [""]))) - (render-all)))) + ;; Functions needed before open (fn close [] - "Close the chat window." (when (is-open?) - (canvas:close-win))) + (nvim.nvim_win_close win-id true) + (set win-id nil))) + + (fn submit-prompt [] + (when (is-open?) + (let [text (widgets.prompt.get-text)] + (when (and text (not= "" text)) + (widgets.prompt.add-to-history text) + (with-internal-edit (fn [] (widgets.prompt.clear))) + (focus-prompt) + (when on-submit (on-submit text)))))) + + ;; Open + + (fn open [] + (when (not (is-open?)) + (set buf-id (nvim.nvim_create_buf false true)) + (let [width (math.floor (* (. vim.o :columns) config.width))] + (set win-id (nvim.nvim_open_win buf-id true {:split :right :width width}))) + + (highlights.setup) + (setup-chat-buffer buf-id) + (setup-chat-window win-id) + + ;; Create widgets + (set widgets.header (header-bar-widget.create buf-id win-id state.header-items)) + (set widgets.messages (message-list-widget.create buf-id)) + (when state.welcome + (widgets.messages.set-welcome + {:lines [state.welcome ""] + :highlights [{:line-idx 0 :hl-group :EcaWelcome :col-start 0 + :col-end (length state.welcome)}]})) + (set widgets.prompt (prompt-area-widget.create buf-id)) + (set widgets.footer (footer-bar-widget.create buf-id win-id state.footer-items)) + + ;; Keymaps + (each [_ km (ipairs config.keymaps)] + (vim.keymap.set km.mode km.lhs km.rhs + {:buffer buf-id :noremap true :silent true})) + + ;; Initial render + (nvim.nvim_buf_set_lines buf-id 0 -1 false [""]) + (render-all) + (focus-prompt) + + ;; Edit guard + (set guard + (setup-edit-guard buf-id render-all + (fn [] + (let [s (widgets.prompt.get-state)] + {:prompt-start-line (or s.prompt-start-line 0) + :loading? s.loading?})) + focus-prompt)))) (fn toggle [] - "Toggle the chat window." - (if (is-open?) - (close) - (open))) + (if (is-open?) (close) (open))) + + (fn get-buf-id [] buf-id) - ;; === Message API === + ;; Message API (fn append-message [msg] (when (is-open?) (with-internal-edit (fn [] (widgets.messages.append-message msg) (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) + (widgets.prompt.render end-line)))) + (focus-prompt))) (fn update-message [id content] (when (is-open?) @@ -262,98 +239,59 @@ (let [end-line (widgets.messages.get-end-line)] (widgets.prompt.render end-line)))))) - ;; === Tool call API (TODO) === - (fn show-tool-call [tc] nil) - (fn update-tool-call [id status] nil) - (fn show-approval [tc] nil) - - ;; === Status API === - (fn update-model-info [info] + ;; State updates + (fn update-header [new-items] + (set state.header-items new-items) (when (is-open?) - (widgets.header.update info))) - - (fn update-usage [usage] + (with-internal-edit (fn [] (widgets.header.update new-items))))) + + (fn update-header-item [title new-value] + (var found false) + (each [_ item (ipairs state.header-items)] + (when (= item.title title) + (tset item :value new-value) + (set found true))) + (when (not found) + (table.insert state.header-items {:title title :value new-value})) (when (is-open?) - (widgets.status.update usage))) + (with-internal-edit (fn [] (widgets.header.update state.header-items))))) - (fn update-progress [progress] + (fn update-footer [new-items] + (set state.footer-items new-items) (when (is-open?) - (widgets.status.update {:init-progress progress}))) - - ;; === Context API === - (fn add-context [ctx] - (when (is-open?) - (with-internal-edit - (fn [] - (widgets.prompt.add-context ctx) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) - - (fn remove-context [name] - (when (is-open?) - (with-internal-edit - (fn [] - (widgets.prompt.remove-context name) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) - - ;; === Tab API === - (fn add-chat-tab [tab] - (when (is-open?) - (widgets.tabs.add-tab tab) - (widgets.tabs.render))) - - (fn remove-chat-tab [id] - (when (is-open?) - (widgets.tabs.remove-tab id) - (widgets.tabs.render))) - - (fn select-chat-tab [id] - (when (is-open?) - (widgets.tabs.select-tab id) - (widgets.tabs.render))) - - ;; === Prompt access === - (fn get-prompt-text [] + (with-internal-edit (fn [] (widgets.footer.update new-items))))) + + (fn update-footer-item [title new-value] + (var found false) + (each [_ item (ipairs state.footer-items)] + (when (= item.title title) + (tset item :value new-value) + (set found true))) + (when (not found) + (table.insert state.footer-items {:title title :value new-value})) (when (is-open?) - (widgets.prompt.get-text))) + (with-internal-edit (fn [] (widgets.footer.update state.footer-items))))) - (fn submit-prompt [] + (fn set-welcome [text] + (set state.welcome text) (when (is-open?) - (let [text (widgets.prompt.get-text)] - (when (and text (not= "" text)) - (widgets.prompt.add-to-history text) - (with-internal-edit - (fn [] (widgets.prompt.clear))) - (when on-submit - (on-submit text)))))) + (widgets.messages.set-welcome + {:lines [text ""] + :highlights [{:line-idx 0 :hl-group :EcaWelcome :col-start 0 + :col-end (length text)}]}) + (let [msg-state (widgets.messages.get-state)] + (when (= 0 (length msg-state.messages)) + (with-internal-edit (fn [] (render-all))))))) (fn set-loading [bool] (when (is-open?) - (with-internal-edit - (fn [] (widgets.prompt.set-loading bool))))) - - ;; Public API - {: open - : close - : toggle - : is-open? - : append-message - : update-message - : clear-messages - : show-tool-call - : update-tool-call - : show-approval - : update-model-info - : update-usage - : update-progress - : add-context - : remove-context - : add-chat-tab - : remove-chat-tab - : select-chat-tab - : get-prompt-text - : submit-prompt - : set-loading})) + (with-internal-edit (fn [] (widgets.prompt.set-loading bool))))) + + {: open : close : toggle : is-open? : get-buf-id + : append-message : update-message : clear-messages + : update-header : update-header-item + : update-footer : update-footer-item + : set-welcome + : submit-prompt : set-loading})) {: create-chat-ui} diff --git a/fnl/eca/ui/canvas.fnl b/fnl/eca/ui/canvas.fnl deleted file mode 100644 index b39667f..0000000 --- a/fnl/eca/ui/canvas.fnl +++ /dev/null @@ -1,40 +0,0 @@ -;; Canvas protocol — abstract contract for rendering operations. -;; Any canvas implementation must provide all functions listed here. -;; The UI layer (components, widgets, builder) depends ONLY on this contract. - -(local protocol-keys - [:set-lines ;; (canvas start end lines) — replace lines in buffer - :get-lines ;; (canvas start end) — read lines from buffer - :add-extmark ;; (canvas ns-id line col opts) — add extmark, returns id - :del-extmark ;; (canvas ns-id id) — remove extmark - :get-extmarks ;; (canvas ns-id start end opts) — get extmarks in range - :create-namespace ;; (canvas name) — create namespace for extmarks, returns ns-id - :set-option ;; (canvas scope key value) — set option (scope: :win or :buf) - :get-option ;; (canvas scope key) — get option value - :line-count ;; (canvas) — total line count in buffer - :get-cursor ;; (canvas) — returns [line col] - :set-cursor ;; (canvas line col) — move cursor - :buf-valid? ;; (canvas) — is buffer still valid? - :win-valid? ;; (canvas) — is window still valid? - :close-win ;; (canvas) — close window - :set-hl ;; (canvas ns group opts) — define highlight group - :buf-id ;; (canvas) — returns the underlying buffer id - :win-id ;; (canvas) — returns the underlying window id - ]) - -(fn validate [canvas] - "Validate that a canvas implementation satisfies the protocol. - Returns true if valid, or (false missing-keys) if not." - (let [missing (icollect [_ key (ipairs protocol-keys)] - (when (= nil (. canvas key)) key))] - (if (= 0 (length missing)) - true - (values false missing)))) - -(fn describe [] - "Returns the list of protocol keys for documentation/introspection." - protocol-keys) - -{: validate - : describe - : protocol-keys} diff --git a/fnl/eca/ui/components/context-item.fnl b/fnl/eca/ui/components/context-item.fnl index ccfe4ba..e012da1 100644 --- a/fnl/eca/ui/components/context-item.fnl +++ b/fnl/eca/ui/components/context-item.fnl @@ -1,25 +1,12 @@ -;; context-item component — renders @context mentions. -;; Stateless, pure function. +;; context-item component — renders a tagged text item. +;; Stateless, pure function. Zero business logic. -(local type-config - {:file {:prefix "@" :hl-group :EcaContextFile} - :dir {:prefix "@" :hl-group :EcaContextDir} - :repo-map {:prefix "@" :hl-group :EcaContextRepoMap :label "repoMap"} - :cursor {:prefix "@" :hl-group :EcaContextCursor} - :mcp {:prefix "@" :hl-group :EcaContextMcp}}) - -(fn render [{: type : name : detail}] +(fn render [{: text : hl-group}] "Render a context item. - type: :file, :dir, :repo-map, :cursor, :mcp + text: display text (e.g. '@file.lua', '@repoMap') + hl-group: highlight group Returns {: text : hl-group}." - (let [cfg (or (. type-config type) - {:prefix "@" :hl-group :EcaContextFile}) - display-name (or cfg.label name "") - text (case type - :cursor (.. cfg.prefix "cursor(" (or name "") (if detail (.. " " detail) "") ")") - :repo-map (.. cfg.prefix display-name) - _ (.. cfg.prefix display-name))] - {:text text - :hl-group cfg.hl-group})) + {:text (or text "") + :hl-group (or hl-group :Normal)}) {: render} diff --git a/fnl/eca/ui/components/icon.fnl b/fnl/eca/ui/components/icon.fnl index 928b730..ea09545 100644 --- a/fnl/eca/ui/components/icon.fnl +++ b/fnl/eca/ui/components/icon.fnl @@ -1,39 +1,26 @@ -;; icon component — maps semantic names to unicode icons. -;; Stateless, pure function. +;; icon component — maps UI semantic names to unicode icons. +;; Stateless, pure function. Handles UI concepts, not business logic. (local icons {:collapsed "⏵" :expanded "⏷" - :pending "⏳" - :running "⏳" + :loading "⏳" :success "✅" :error "❌" - :approval "🚧" - :loading "⏳" + :warning "⚠️" + :info "ℹ️" :stop "⏹" :new "+" :close "×"}) -(local icon-highlights - {:collapsed :EcaExpandableIcon - :expanded :EcaExpandableIcon - :pending :EcaToolCallPending - :running :EcaToolCallPending - :success :EcaToolCallSuccess - :error :EcaToolCallError - :approval :EcaToolCallApproval - :loading :EcaSpinner - :stop :EcaToolCallError - :new :EcaButtonAccept - :close :EcaButtonReject}) - -(fn render [{: name}] - "Render an icon by semantic name. +(fn render [{: name : text : hl-group}] + "Render an icon by semantic name or direct text. + name: lookup key (e.g. :collapsed, :success) + text: direct icon text (overrides name lookup) + hl-group: highlight group Returns {: text : hl-group}." - (let [icon (or (. icons name) "?") - hl (or (. icon-highlights name) :EcaExpandableIcon)] - {:text icon - :hl-group hl})) + {:text (or text (. icons name) "?") + :hl-group (or hl-group :Normal)}) {: render : icons} diff --git a/fnl/eca/ui/components/message.fnl b/fnl/eca/ui/components/message.fnl index e024e8c..52266a5 100644 --- a/fnl/eca/ui/components/message.fnl +++ b/fnl/eca/ui/components/message.fnl @@ -1,11 +1,6 @@ -;; message component — renders chat message blocks. +;; message component — renders a text block with optional prefix. ;; Stateless, pure function. -(local role-config - {:user {:prefix " You" :hl-group :EcaUser} - :assistant {:prefix " ECA" :hl-group :EcaAssistant} - :system {:prefix " System" :hl-group :EcaSystem}}) - (fn split-lines [text] "Split text into lines." (let [lines []] @@ -15,39 +10,28 @@ (table.insert lines line))) lines)) -(fn render [{: role : content}] - "Render a chat message. - Returns {: lines : highlights} where lines is a list of strings - and highlights is a list of {: line-idx : hl-group : col-start : col-end}." - (let [cfg (or (. role-config role) - {:prefix " ?" :hl-group :EcaAssistant}) +(fn render [{: content : prefix : hl-group}] + "Render a text block. + content: text string (may contain newlines) + prefix: optional string prepended to first line (e.g. '> ') + hl-group: optional highlight group for the block + Returns {: lines : highlights}." + (let [pfx (or prefix "") + ;; If prefix is set and no explicit hl-group, use EcaMessagePrefix + hl (or hl-group (when (and prefix (> (length prefix) 0)) :EcaMessagePrefix)) content-lines (split-lines (or content "")) - lines [cfg.prefix "" ] - highlights [{:line-idx 0 - :hl-group cfg.hl-group - :col-start 0 - :col-end (length cfg.prefix)}]] - ;; Add content lines - (each [_ line (ipairs content-lines)] - (table.insert lines line)) - ;; Add trailing empty line + lines [] + highlights []] + (each [i line (ipairs content-lines)] + (let [full (if (= i 1) (.. pfx line) line)] + (table.insert lines full) + (when hl + (table.insert highlights + {:line-idx (- (length lines) 1) + :hl-group hl + :col-start 0 + :col-end (length full)})))) (table.insert lines "") {: lines : highlights})) -(fn render-welcome [] - "Render a welcome message for empty chats. - Returns {: lines : highlights}." - (let [lines ["" - " Welcome to ECA Chat" - "" - " Type your message below and press Enter to send." - " Use @ to attach context (files, directories, etc.)" - ""]] - {:lines lines - :highlights [{:line-idx 1 - :hl-group :EcaWelcome - :col-start 0 - :col-end (length " Welcome to ECA Chat")}]})) - -{: render - : render-welcome} +{: render} diff --git a/fnl/eca/ui/components/usage.fnl b/fnl/eca/ui/components/usage.fnl index a04ed7c..54ca777 100644 --- a/fnl/eca/ui/components/usage.fnl +++ b/fnl/eca/ui/components/usage.fnl @@ -1,34 +1,12 @@ -;; usage component — renders token usage and cost display. -;; Stateless, pure function. - -(fn format-tokens [n] - "Format token count: 1500 → '1.5K', 150000 → '150K'." - (if (= nil n) "0" - (>= n 1000000) (string.format "%.1fM" (/ n 1000000)) - (>= n 1000) (string.format "%.0fK" (/ n 1000)) - (tostring n))) - -(fn format-cost [cost] - "Format cost as dollar amount." - (if (= nil cost) nil - (string.format "$%.2f" cost))) - -(fn render [{: tokens-in : tokens-out : max-tokens : cost}] - "Render usage display. - Returns {: text : hl-group}. - Format: '31K/200K ($0.03)' or '31K/200K' if no cost." - (let [used (format-tokens (+ (or tokens-in 0) (or tokens-out 0))) - max (format-tokens max-tokens) - base (if max-tokens - (.. used "/" max) - used) - cost-str (format-cost cost) - text (if cost-str - (.. base " (" cost-str ")") - base)] - {:text text - :hl-group :EcaUsage})) - -{: render - : format-tokens - : format-cost} +;; usage component — renders a formatted text string. +;; Stateless, pure function. Zero business logic. + +(fn render [{: text : hl-group}] + "Render a usage/info text. + text: pre-formatted display string (e.g. '31K/200K ($0.03)') + hl-group: highlight group + Returns {: text : hl-group}." + {:text (or text "") + :hl-group (or hl-group :Normal)}) + +{: render} diff --git a/fnl/eca/ui/highlights.fnl b/fnl/eca/ui/highlights.fnl index ff67804..df66e13 100644 --- a/fnl/eca/ui/highlights.fnl +++ b/fnl/eca/ui/highlights.fnl @@ -1,59 +1,42 @@ -;; Highlight groups — declarative definitions for all UI elements. -;; Receives a canvas to apply them. Supports dark and light themes. +;; Highlight groups — linked to standard Neovim groups. -(local groups - {;; Messages - :EcaUser {:fg "#61afef" :bold true} - :EcaAssistant {:fg "#abb2bf"} - :EcaSystem {:fg "#5c6370" :italic true} - :EcaWelcome {:fg "#5c6370" :italic true} - :EcaSeparator {:fg "#3e4452"} - - ;; Prompt - :EcaPromptPrefix {:fg "#98c379" :bold true} - :EcaPromptPrefixLoading {:fg "#e5c07b"} - - ;; Tool calls - :EcaToolCallPending {:fg "#e5c07b"} - :EcaToolCallSuccess {:fg "#98c379"} - :EcaToolCallError {:fg "#e06c75"} - :EcaToolCallApproval {:fg "#d19a66" :bg "#3e3522" :bold true} - - ;; Expandable blocks - :EcaExpandableIcon {:fg "#5c6370"} - :EcaExpandableLabel {:bold true} - - ;; Context items - :EcaContextFile {:fg "#e06c75" :underline true} - :EcaContextDir {:fg "#e06c75" :underline true} - :EcaContextRepoMap {:fg "#56b6c2"} - :EcaContextCursor {:fg "#5c6370"} - :EcaContextMcp {:fg "#98c379"} +(local nvim vim.api) - ;; Header bar - :EcaHeaderKey {:fg "#5c6370"} - :EcaHeaderValue {:fg "#abb2bf" :bold true} - - ;; Status bar - :EcaUsage {:fg "#5c6370"} - :EcaElapsed {:fg "#5c6370"} - :EcaTrustOn {:fg "#e06c75" :bold true} - :EcaTrustOff {:fg "#5c6370"} - :EcaSpinner {:fg "#e5c07b"} - - ;; Buttons - :EcaButtonAccept {:fg "#98c379" :bold true} - :EcaButtonReject {:fg "#e06c75" :bold true} - - ;; Tab bar - :EcaTabActive {:fg "#abb2bf" :bold true} - :EcaTabInactive {:fg "#5c6370"} - :EcaTabLoading {:fg "#e5c07b"}}) - -(fn setup [canvas] - "Apply all highlight groups using the provided canvas." +(local groups + {:EcaUser {:link "Title"} + :EcaAssistant {:link "Normal"} + :EcaSystem {:link "Comment"} + :EcaWelcome {:link "Comment"} + :EcaMessagePrefix {:link "Title"} + :EcaSeparator {:link "WinSeparator"} + :EcaPromptPrefix {:link "Statement"} + :EcaPromptPrefixLoading {:link "WarningMsg"} + :EcaToolCallPending {:link "WarningMsg"} + :EcaToolCallSuccess {:link "DiagnosticOk"} + :EcaToolCallError {:link "DiagnosticError"} + :EcaToolCallApproval {:link "DiagnosticWarn"} + :EcaExpandableIcon {:link "Comment"} + :EcaExpandableLabel {:link "Bold"} + :EcaContextFile {:link "Underlined"} + :EcaContextDir {:link "Underlined"} + :EcaContextRepoMap {:link "Special"} + :EcaContextCursor {:link "Comment"} + :EcaContextMcp {:link "String"} + :EcaHeaderKey {:link "Comment"} + :EcaHeaderValue {:link "Bold"} + :EcaUsage {:link "Comment"} + :EcaElapsed {:link "Comment"} + :EcaTrustOn {:link "DiagnosticError"} + :EcaTrustOff {:link "Comment"} + :EcaSpinner {:link "WarningMsg"} + :EcaButtonAccept {:link "DiagnosticOk"} + :EcaButtonReject {:link "DiagnosticError"} + :EcaTabActive {:link "TabLineSel"} + :EcaTabInactive {:link "TabLine"} + :EcaTabLoading {:link "WarningMsg"}}) + +(fn setup [] (each [group opts (pairs groups)] - (canvas:set-hl 0 group opts))) + (nvim.nvim_set_hl 0 group opts))) -{: groups - : setup} +{: groups : setup} diff --git a/fnl/eca/ui/widgets/context-bar.fnl b/fnl/eca/ui/widgets/context-bar.fnl index 214b62e..6df89ac 100644 --- a/fnl/eca/ui/widgets/context-bar.fnl +++ b/fnl/eca/ui/widgets/context-bar.fnl @@ -1,80 +1,58 @@ -;; context-bar widget — horizontal bar of attached @contexts. -;; Stateful: manages list of contexts, renders via canvas. +;; context-bar widget — horizontal bar of tagged items. -(local context-item-component (require :eca.ui.components.context-item)) +(local nvim vim.api) -(fn create [canvas] - "Create a context-bar widget. - Returns {: render : add : remove : clear : get-state}." - (local state {:contexts [] - :ns-id nil}) +(fn create [buf-id] + (local state {:items [] :ns-id nil}) (fn ensure-ns [] (when (= nil state.ns-id) - (set state.ns-id (canvas:create-namespace "eca-context-bar"))) + (set state.ns-id (nvim.nvim_create_namespace "eca-context-bar"))) state.ns-id) (fn build-line [] - "Build the context bar line from current state." - (if (= 0 (length state.contexts)) - {:line "" :parts []} + (if (= 0 (length state.items)) + {:line "" :highlights []} (let [result (accumulate [acc {:parts [] :highlights [] :col 0} - _ ctx (ipairs state.contexts)] + _ item (ipairs state.items)] (let [sep-col (if (> acc.col 0) (do (table.insert acc.parts " ") (+ acc.col 1)) - acc.col) - rendered (context-item-component.render ctx)] - (table.insert acc.parts rendered.text) + acc.col)] + (table.insert acc.parts item.text) (table.insert acc.highlights - {:hl-group rendered.hl-group + {:hl-group (or item.hl-group :Normal) :col-start sep-col - :col-end (+ sep-col (length rendered.text))}) + :col-end (+ sep-col (length item.text))}) {:parts acc.parts :highlights acc.highlights - :col (+ sep-col (length rendered.text))}))] + :col (+ sep-col (length item.text))}))] {:line (table.concat result.parts "") :highlights result.highlights}))) (fn render [line-num] - "Render the context bar at the given line number." (let [ns (ensure-ns) {: line : highlights} (build-line)] (when (and line (not= "" line)) - (canvas:set-lines line-num (+ line-num 1) [line]) - ;; Apply highlights + (nvim.nvim_buf_set_lines buf-id line-num (+ line-num 1) false [line]) (each [_ hl (ipairs (or highlights []))] - (canvas:add-extmark ns line-num hl.col-start - {:end_col hl.col-end - :hl_group hl.hl-group}))))) + (nvim.nvim_buf_set_extmark buf-id ns line-num hl.col-start + {:end_col hl.col-end :hl_group hl.hl-group}))))) - (fn add [ctx] - "Add a context item. ctx: {: type : name : path : detail}." - (let [exists (accumulate [found false - _ existing (ipairs state.contexts)] - (or found (= existing.name ctx.name)))] + (fn add [item] + (let [exists (accumulate [found false _ existing (ipairs state.items)] + (or found (= existing.text item.text)))] (when (not exists) - (table.insert state.contexts ctx)))) + (table.insert state.items item)))) - (fn remove [name] - "Remove a context item by name." - (let [new-contexts []] - (each [_ ctx (ipairs state.contexts)] - (when (not= ctx.name name) - (table.insert new-contexts ctx))) - (set state.contexts new-contexts))) + (fn remove [text] + (set state.items + (icollect [_ item (ipairs state.items)] + (when (not= item.text text) item)))) - (fn clear [] - "Remove all contexts." - (set state.contexts [])) + (fn clear [] (set state.items [])) + (fn get-state [] state) - (fn get-state [] - state) - - {: render - : add - : remove - : clear - : get-state}) + {: render : add : remove : clear : get-state}) {: create} diff --git a/fnl/eca/ui/widgets/expandable-block.fnl b/fnl/eca/ui/widgets/expandable-block.fnl index 01c0501..a701dc8 100644 --- a/fnl/eca/ui/widgets/expandable-block.fnl +++ b/fnl/eca/ui/widgets/expandable-block.fnl @@ -1,50 +1,20 @@ -;; expandable-block widget — collapsible blocks for tool calls, reasoning, etc. -;; Stateful: tracks expanded/collapsed state, renders via canvas. - -(local icon-component (require :eca.ui.components.icon)) - -(fn format-elapsed [ms] - "Format milliseconds to human readable: '3s', '1m 23s'." - (if (= nil ms) "" - (let [seconds (math.floor (/ ms 1000))] - (if (>= seconds 60) - (let [mins (math.floor (/ seconds 60)) - secs (% seconds 60)] - (.. (tostring mins) "m " (tostring secs) "s")) - (.. (tostring seconds) "s"))))) - -(fn build-label [state] - "Build the label line for an expandable block." - (let [{: expanded? : type : label : status : elapsed-ms} state - toggle-icon (icon-component.render - {:name (if expanded? :expanded :collapsed)}) - status-icon (when status - (icon-component.render {:name status})) - elapsed-str (format-elapsed elapsed-ms) - parts [toggle-icon.text]] - (table.insert parts (.. " " (or label (or type "block")))) - (when status-icon - (table.insert parts (.. " " status-icon.text))) - (when (and elapsed-str (not= "" elapsed-str)) - (table.insert parts (.. " " elapsed-str))) - (table.concat parts ""))) +;; expandable-block widget — generic collapsible block with label + content. +;; Zero business logic. Receives display data, not domain concepts. (fn create [canvas initial-state] "Create an expandable-block widget. - initial-state: {: id : type : status : expanded? : label : content : elapsed-ms : children} - Returns {: render : toggle : update-status : collapse : expand : get-state}." + initial-state: {: id : label : icon-expanded : icon-collapsed : content : expanded?} + Returns {: render : toggle : expand : collapse : update-label : get-state}." (local state (vim.tbl_extend :force - {:id nil - :type :tool-call - :status nil - :expanded? false - :label "" - :content [] - :elapsed-ms nil - :children [] - :start-line 0 - :ns-id nil} - (or initial-state {}))) + {:id nil + :label "" + :icon-expanded "⏷" + :icon-collapsed "⏵" + :content [] + :expanded? false + :start-line 0 + :ns-id nil} + (or initial-state {}))) (fn ensure-ns [] (when (= nil state.ns-id) @@ -52,27 +22,26 @@ (.. "eca-expandable-" (or state.id "unknown"))))) state.ns-id) + (fn build-label [] + (let [icon (if state.expanded? state.icon-expanded state.icon-collapsed)] + (.. icon " " state.label))) + (fn render [start-line] "Render the block at start-line. Returns number of lines used." (set state.start-line start-line) (let [ns (ensure-ns) - label-line (build-label state) + label-line (build-label) lines [label-line]] - ;; Add content lines if expanded (when state.expanded? (each [_ line (ipairs state.content)] (table.insert lines (.. " " line)))) - ;; Write to buffer (canvas:set-lines start-line start-line lines) - ;; Highlight the label line (canvas:add-extmark ns start-line 0 {:end_col (length label-line) :hl_group :EcaExpandableLabel}) - ;; Return line count (length lines))) (fn toggle [] - "Toggle expanded/collapsed state." (set state.expanded? (not state.expanded?))) (fn expand [] @@ -81,11 +50,8 @@ (fn collapse [] (set state.expanded? false)) - (fn update-status [new-status ?elapsed-ms] - "Update the status and optionally the elapsed time." - (set state.status new-status) - (when ?elapsed-ms - (set state.elapsed-ms ?elapsed-ms))) + (fn update-label [new-label] + (set state.label new-label)) (fn get-state [] state) @@ -94,7 +60,7 @@ : toggle : expand : collapse - : update-status + : update-label : get-state}) {: create} diff --git a/fnl/eca/ui/widgets/footer-bar.fnl b/fnl/eca/ui/widgets/footer-bar.fnl new file mode 100644 index 0000000..b9687c3 --- /dev/null +++ b/fnl/eca/ui/widgets/footer-bar.fnl @@ -0,0 +1,74 @@ +;; footer-bar widget — statusline footer. +;; laststatus=2: per-window statusline. +;; laststatus=3: sets global statusline on focus, restores on blur. + +(local nvim vim.api) + +(fn create [buf-id win-id initial-items] + (var items (or initial-items [])) + (var saved-statusline nil) + + (fn build-statusline-str [] + (let [parts (icollect [_ item (ipairs items)] + (if item.title + (.. "%#EcaHeaderKey#" item.title "%#EcaHeaderValue#:" item.value) + (.. "%#EcaHeaderValue#" item.value))) + count (length parts)] + (case count + 0 "" + 1 (.. " " (. parts 1)) + 2 (.. " " (. parts 1) "%=" (. parts 2) " ") + _ (let [left (. parts 1) + right (. parts count) + center-parts [] + _ (for [i 2 (- count 1)] + (table.insert center-parts (. parts i))) + center (table.concat center-parts " ")] + (.. " " left "%=" center "%=" right " "))))) + + (fn is-global? [] + (= 3 (nvim.nvim_get_option_value :laststatus {}))) + + (fn apply-statusline [] + (let [str (build-statusline-str)] + (if (is-global?) + (nvim.nvim_set_option_value :statusline str {}) + (when (and win-id (nvim.nvim_win_is_valid win-id)) + (nvim.nvim_set_option_value :statusline str {:win win-id}))))) + + (fn restore-statusline [] + (when (and (is-global?) saved-statusline) + (nvim.nvim_set_option_value :statusline saved-statusline {}))) + + (fn render [] + (apply-statusline) + 0) + + ;; Save original global statusline and manage focus + (set saved-statusline (nvim.nvim_get_option_value :statusline {})) + + (nvim.nvim_create_autocmd :BufEnter + {:buffer buf-id + :callback (fn [] + ;; Defer to run AFTER statusline plugins have set theirs + (vim.defer_fn (fn [] (apply-statusline)) 10))}) + + (nvim.nvim_create_autocmd :BufLeave + {:buffer buf-id + :callback (fn [] (restore-statusline))}) + + (nvim.nvim_create_autocmd :OptionSet + {:pattern :laststatus + :callback (fn [] + (when (nvim.nvim_buf_is_valid buf-id) + (apply-statusline)))}) + + (fn update [new-items] + (set items new-items) + (render)) + + (fn get-state [] items) + + {: render : update : get-state}) + +{: create} diff --git a/fnl/eca/ui/widgets/header-bar.fnl b/fnl/eca/ui/widgets/header-bar.fnl index fc6451f..213548c 100644 --- a/fnl/eca/ui/widgets/header-bar.fnl +++ b/fnl/eca/ui/widgets/header-bar.fnl @@ -1,54 +1,38 @@ -;; header-bar widget — winbar with model/agent/variant/mcps info. -;; Stateful: composes key-value components, renders via canvas. - -(local key-value (require :eca.ui.components.key-value)) - -(fn build-winbar-string [state] - "Build the winbar format string from state. - Uses %#HlGroup# syntax for Neovim statusline/winbar highlighting." - (let [{: model : agent : variant : mcps-total : mcps-ready} state - parts []] - (when model - (table.insert parts - (.. "%#EcaHeaderKey#model%#EcaHeaderValue#:" model))) - (when agent - (table.insert parts - (.. "%#EcaHeaderKey#agent%#EcaHeaderValue#:" agent))) - (when variant - (table.insert parts - (.. "%#EcaHeaderKey#variant%#EcaHeaderValue#:" variant))) - (when mcps-total - (let [ready (or mcps-ready 0) - total mcps-total] - (table.insert parts - (.. "%#EcaHeaderKey#mcps%#EcaHeaderValue#:" (tostring ready) "/" (tostring total))))) - (table.concat parts " "))) - -(fn create [canvas initial-state] - "Create a header-bar widget. - initial-state: {: model : agent : variant : mcps-total : mcps-ready} - Returns {: render : update : get-state}." - (local state (or initial-state - {:model "claude" - :agent "coder" - :variant nil - :mcps-total 0 - :mcps-ready 0})) +;; header-bar widget — fixed header using winbar. + +(local nvim vim.api) + +(fn create [buf-id win-id initial-items] + (var items (or initial-items [])) + + (fn build-winbar [] + (let [parts (icollect [_ item (ipairs items)] + (.. "%#EcaHeaderKey#" item.title "%#EcaHeaderValue#:" item.value)) + count (length parts)] + (case count + 0 "" + 1 (.. " " (. parts 1)) + 2 (.. " " (. parts 1) "%=" (. parts 2) " ") + _ (let [left (. parts 1) + right (. parts count) + center-parts [] + _ (for [i 2 (- count 1)] + (table.insert center-parts (. parts i))) + center (table.concat center-parts " ")] + (.. " " left "%=" center "%=" right " "))))) (fn render [] - (let [winbar (build-winbar-string state)] - (canvas:set-option :win :winbar winbar))) + (nvim.nvim_set_option_value :winbar (build-winbar) {:win win-id}) + (nvim.nvim_buf_set_lines buf-id 0 1 false [""]) + 1) - (fn update [new-state] - (each [k v (pairs new-state)] - (tset state k v)) + (fn update [new-items] + (set items new-items) (render)) - (fn get-state [] - state) + (fn get-state [] items) + (fn line-count [] 1) - {: render - : update - : get-state}) + {: render : update : get-state : line-count}) {: create} diff --git a/fnl/eca/ui/widgets/message-list.fnl b/fnl/eca/ui/widgets/message-list.fnl index 742a555..2660aa4 100644 --- a/fnl/eca/ui/widgets/message-list.fnl +++ b/fnl/eca/ui/widgets/message-list.fnl @@ -1,103 +1,73 @@ -;; message-list widget — renders chat messages in the buffer. -;; Stateful: maintains list of messages, renders via canvas. +;; message-list widget — renders text blocks in the buffer. +(local nvim vim.api) (local message-component (require :eca.ui.components.message)) -(local separator-component (require :eca.ui.components.separator)) -(fn create [canvas] - "Create a message-list widget. - Returns {: render : append-message : update-message : clear : get-state}." - (local state {:messages [] - :ns-id nil - :end-line 0}) +(fn create [buf-id] + (local state {:messages [] :ns-id nil :end-line 0 :start-line 0 :welcome-lines nil}) (fn ensure-ns [] (when (= nil state.ns-id) - (set state.ns-id (canvas:create-namespace "eca-messages"))) + (set state.ns-id (nvim.nvim_create_namespace "eca-messages"))) state.ns-id) (fn apply-highlights [lines-offset highlights] - "Apply highlight extmarks for a rendered component." (let [ns (ensure-ns)] (each [_ hl (ipairs highlights)] - (canvas:add-extmark ns - (+ lines-offset hl.line-idx) - hl.col-start - {:end_col hl.col-end - :hl_group hl.hl-group})))) + (nvim.nvim_buf_set_extmark buf-id ns + (+ lines-offset hl.line-idx) hl.col-start + {:end_col hl.col-end :hl_group hl.hl-group})))) (fn render-single-message [msg start-line] - "Render a single message starting at given line. Returns number of lines written." - (let [rendered (message-component.render msg) - sep (separator-component.render {:width 50})] - ;; Write message lines - (canvas:set-lines start-line start-line rendered.lines) + (let [rendered (message-component.render msg)] + (nvim.nvim_buf_set_lines buf-id start-line start-line false rendered.lines) (apply-highlights start-line rendered.highlights) - ;; Write separator after message - (let [sep-line (+ start-line (length rendered.lines))] - (canvas:set-lines sep-line sep-line [sep.line]) - (apply-highlights sep-line sep.highlights) - ;; Return total lines written (message + separator) - (+ (length rendered.lines) 1)))) + (length rendered.lines))) + + (fn set-start-line [line] + (set state.start-line line) + (when (= state.end-line 0) + (set state.end-line line))) + + (fn set-welcome [data] + (set state.welcome-lines data)) (fn render [] - "Full re-render of all messages." (let [ns (ensure-ns)] - ;; Clear the messages area (leave room for prompt at the end) - (canvas:set-lines 0 state.end-line []) - (set state.end-line 0) + (nvim.nvim_buf_set_lines buf-id state.start-line state.end-line false []) + (set state.end-line state.start-line) (if (= 0 (length state.messages)) - ;; Show welcome message - (let [welcome (message-component.render-welcome)] - (canvas:set-lines 0 0 welcome.lines) - (apply-highlights 0 welcome.highlights) - (set state.end-line (length welcome.lines))) - ;; Render all messages + (when state.welcome-lines + (nvim.nvim_buf_set_lines buf-id state.start-line state.start-line false + state.welcome-lines.lines) + (apply-highlights state.start-line (or state.welcome-lines.highlights [])) + (set state.end-line (+ state.start-line (length state.welcome-lines.lines)))) (each [_ msg (ipairs state.messages)] (let [lines-written (render-single-message msg state.end-line)] (set state.end-line (+ state.end-line lines-written))))))) (fn append-message [msg] - "Append a new message and render it incrementally." (table.insert state.messages msg) - ;; If this is the first message, clear welcome and re-render (if (= 1 (length state.messages)) (render) - ;; Otherwise render incrementally (let [lines-written (render-single-message msg state.end-line)] - (set state.end-line (+ state.end-line lines-written)))) - ;; Auto-scroll to end - (when (canvas:win-valid?) - (let [total (canvas:line-count)] - (canvas:set-cursor total 0)))) + (set state.end-line (+ state.end-line lines-written))))) (fn update-message [id new-content] - "Update the content of an existing message (for streaming)." - (let [found (accumulate [f false - _ msg (ipairs state.messages)] + (let [found (accumulate [f false _ msg (ipairs state.messages)] (if (= msg.id id) - (do (tset msg :content new-content) true) - f))] - (when found - (render)))) + (do (tset msg :content new-content) true) f))] + (when found (render)))) (fn clear [] - "Clear all messages." (set state.messages []) - (set state.end-line 0) + (set state.end-line state.start-line) (render)) - (fn get-state [] - state) - - (fn get-end-line [] - state.end-line) + (fn get-state [] state) + (fn get-end-line [] state.end-line) - {: render - : append-message - : update-message - : clear - : get-state - : get-end-line}) + {: render : append-message : update-message : clear + : get-state : get-end-line : set-start-line : set-welcome}) {: create} diff --git a/fnl/eca/ui/widgets/prompt-area.fnl b/fnl/eca/ui/widgets/prompt-area.fnl index 224b38a..4656755 100644 --- a/fnl/eca/ui/widgets/prompt-area.fnl +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -1,152 +1,93 @@ -;; prompt-area widget — separator + context bar + prompt input. -;; Stateful: manages prompt text, history, contexts, loading state. +;; prompt-area widget — context bar + prompt input. -(local separator-component (require :eca.ui.components.separator)) +(local nvim vim.api) (local prompt-prefix-component (require :eca.ui.components.prompt-prefix)) (local context-bar-widget (require :eca.ui.widgets.context-bar)) -(fn create [canvas] - "Create a prompt-area widget. - Returns widget with full API." - (local state {:loading? false - :prompt-text "" - :history [] - :history-idx 0 - :prompt-start-line 0 - :ns-id nil}) - - (local ctx-bar (context-bar-widget.create canvas)) +(fn create [buf-id] + (local state {:loading? false :prompt-text "" :history [] :history-idx 0 + :prompt-start-line 0 :ns-id nil}) + (local ctx-bar (context-bar-widget.create buf-id)) (fn ensure-ns [] (when (= nil state.ns-id) - (set state.ns-id (canvas:create-namespace "eca-prompt-area"))) + (set state.ns-id (nvim.nvim_create_namespace "eca-prompt-area"))) state.ns-id) (fn render [start-line] - "Render the prompt area starting at given line. - Layout: separator → context bar (if any) → prompt line. - Returns number of lines used." (set state.prompt-start-line start-line) (let [ns (ensure-ns) - sep (separator-component.render {:width 50}) prefix (prompt-prefix-component.render {:loading? state.loading?}) ctx-state (ctx-bar.get-state) - has-contexts? (> (length ctx-state.contexts) 0) - lines [sep.line]] - ;; Add context bar line if there are contexts + has-contexts? (> (length ctx-state.items) 0) + lines []] (when has-contexts? - (let [parts (icollect [_ ctx (ipairs ctx-state.contexts)] - (let [ci (require :eca.ui.components.context-item) - rendered (ci.render ctx)] - rendered.text))] + (let [parts (icollect [_ item (ipairs ctx-state.items)] item.text)] (table.insert lines (table.concat parts " ")))) - ;; Add prompt line (table.insert lines (.. prefix.text state.prompt-text)) - - ;; Write all lines - (canvas:set-lines start-line -1 lines) - - ;; Highlight separator - (canvas:add-extmark ns start-line 0 - {:end_col (length sep.line) - :hl_group :EcaSeparator}) - - ;; Highlight prompt prefix + (nvim.nvim_buf_set_lines buf-id start-line -1 false lines) (let [prompt-line-idx (- (+ start-line (length lines)) 1)] - (canvas:add-extmark ns prompt-line-idx 0 - {:end_col (length prefix.text) - :hl_group prefix.hl-group})) - - ;; Highlight context items if present + (nvim.nvim_buf_set_extmark buf-id ns prompt-line-idx 0 + {:end_col (length prefix.text) :hl_group prefix.hl-group})) (when has-contexts? - (ctx-bar.render (+ start-line 1))) - - ;; Position cursor at end of prompt - (when (canvas:win-valid?) - (let [prompt-line (+ start-line (length lines)) - col-pos (+ (length prefix.text) (length state.prompt-text))] - (canvas:set-cursor prompt-line col-pos))) - - ;; Return lines used + (ctx-bar.render start-line)) (length lines))) (fn get-text [] - "Get the current prompt text from the buffer." - (let [total (canvas:line-count) - last-line-idx (- total 1) - lines (canvas:get-lines last-line-idx total)] - (when (and lines (> (length lines) 0)) - (let [last-line (. lines 1) - prefix (prompt-prefix-component.render {:loading? state.loading?}) - prefix-len (length prefix.text)] - (if (>= (length last-line) prefix-len) - (string.sub last-line (+ prefix-len 1)) - ""))))) + (let [total (nvim.nvim_buf_line_count buf-id) + prompt-lines (nvim.nvim_buf_get_lines buf-id state.prompt-start-line total false) + prefix (prompt-prefix-component.render {:loading? state.loading?})] + (when (and prompt-lines (> (length prompt-lines) 0)) + (let [first-line (. prompt-lines 1) + stripped (if (vim.startswith first-line prefix.text) + (string.sub first-line (+ (length prefix.text) 1)) + first-line) + parts [stripped]] + (for [i 2 (length prompt-lines)] + (table.insert parts (. prompt-lines i))) + (table.concat parts "\n"))))) (fn set-text [text] - "Set the prompt text." (set state.prompt-text (or text "")) (let [prefix (prompt-prefix-component.render {:loading? state.loading?}) - total (canvas:line-count) + total (nvim.nvim_buf_line_count buf-id) last-line-idx (- total 1)] - (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]))) + (nvim.nvim_buf_set_lines buf-id last-line-idx total false + [(.. prefix.text state.prompt-text)]))) - (fn clear [] - "Clear the prompt text." - (set-text "")) + (fn clear [] (set-text "")) (fn set-loading [bool] - "Toggle loading state (changes prefix '> ' ↔ '⏳')." (set state.loading? bool) - ;; Re-render just the prompt line with new prefix (let [prefix (prompt-prefix-component.render {:loading? bool}) - total (canvas:line-count) + total (nvim.nvim_buf_line_count buf-id) last-line-idx (- total 1)] - (canvas:set-lines last-line-idx total [(.. prefix.text state.prompt-text)]))) + (nvim.nvim_buf_set_lines buf-id last-line-idx total false + [(.. prefix.text state.prompt-text)]))) (fn add-to-history [text] - "Save text to history." (when (and text (not= "" text)) (table.insert state.history text) (set state.history-idx (+ (length state.history) 1)))) (fn history-prev [] - "Navigate to previous history entry." (when (> state.history-idx 1) (set state.history-idx (- state.history-idx 1)) (set-text (. state.history state.history-idx)))) (fn history-next [] - "Navigate to next history entry." (if (< state.history-idx (length state.history)) - (do - (set state.history-idx (+ state.history-idx 1)) - (set-text (. state.history state.history-idx))) - (do - (set state.history-idx (+ (length state.history) 1)) - (set-text "")))) - - (fn add-context [ctx] - "Add a context item." - (ctx-bar.add ctx)) - - (fn remove-context [name] - "Remove a context item by name." - (ctx-bar.remove name)) - - (fn get-state [] - state) - - {: render - : get-text - : set-text - : clear - : set-loading - : add-to-history - : history-prev - : history-next - : add-context - : remove-context - : get-state}) + (do (set state.history-idx (+ state.history-idx 1)) + (set-text (. state.history state.history-idx))) + (do (set state.history-idx (+ (length state.history) 1)) + (set-text "")))) + + (fn add-context [ctx] (ctx-bar.add ctx)) + (fn remove-context [name] (ctx-bar.remove name)) + (fn get-state [] state) + + {: render : get-text : set-text : clear : set-loading + : add-to-history : history-prev : history-next + : add-context : remove-context : get-state}) {: create} diff --git a/fnl/eca/ui/widgets/status-bar.fnl b/fnl/eca/ui/widgets/status-bar.fnl index 329cea3..61e11d8 100644 --- a/fnl/eca/ui/widgets/status-bar.fnl +++ b/fnl/eca/ui/widgets/status-bar.fnl @@ -1,76 +1,37 @@ -;; status-bar widget — custom statusline for chat window. -;; Stateful: displays workspace, elapsed, usage, trust. +;; status-bar widget — generic statusline with sections. +;; Zero business logic. Receives pre-formatted sections. -(local usage-component (require :eca.ui.components.usage)) - -(fn format-elapsed [ms] - "Format elapsed milliseconds for statusline." - (if (= nil ms) nil - (let [seconds (math.floor (/ ms 1000))] - (if (>= seconds 60) - (let [mins (math.floor (/ seconds 60)) - secs (% seconds 60)] - (.. (tostring mins) "m " (tostring secs) "s")) - (.. (tostring seconds) "s"))))) - -(fn create [canvas initial-state] +(fn create [canvas initial-sections] "Create a status-bar widget. - initial-state: {: workspaces : elapsed-ms : tokens-in : tokens-out : max-tokens : cost : trust? : init-progress : pending-approvals?} + initial-sections: {: left : center : right} + Each section is a list of {: text : hl-group}. Returns {: render : update : get-state}." - (local state (vim.tbl_extend :force - {:workspaces [] - :elapsed-ms nil - :tokens-in 0 - :tokens-out 0 - :max-tokens 200000 - :cost nil - :trust? false - :init-progress nil - :pending-approvals? false} - (or initial-state {}))) - - (fn build-statusline [] - "Build statusline format string." - (let [parts []] - ;; Workspace folders - (when (> (length state.workspaces) 0) - (table.insert parts - (.. "%#EcaHeaderValue# " (table.concat state.workspaces ", ") " "))) - - ;; Spacer - (table.insert parts "%=") - - ;; Init progress - (when state.init-progress - (table.insert parts - (.. "%#EcaSpinner# ⏳ " state.init-progress " "))) - - ;; Elapsed time - (let [elapsed (format-elapsed state.elapsed-ms)] - (when elapsed - (let [icon (if state.pending-approvals? "🚧" "⏱")] - (table.insert parts - (.. "%#EcaElapsed# " icon " " elapsed " "))))) - - ;; Token usage - (let [usage-rendered (usage-component.render state)] - (table.insert parts - (.. "%#EcaUsage# " usage-rendered.text " "))) - - ;; Trust indicator - (if state.trust? - (table.insert parts "%#EcaTrustOn# 🔥 ") - (table.insert parts "%#EcaTrustOff# 🛡️ ")) + (local state {:left (or (?. initial-sections :left) []) + :center (or (?. initial-sections :center) []) + :right (or (?. initial-sections :right) [])}) + (fn build-section [items] + (let [parts (icollect [_ item (ipairs items)] + (.. "%#" (or item.hl-group "Normal") "# " item.text " "))] (table.concat parts ""))) + (fn build-statusline [] + (let [left (build-section state.left) + center (build-section state.center) + right (build-section state.right)] + (.. left "%=" center "%=" right))) + (fn render [] (let [statusline (build-statusline)] (canvas:set-option :win :statusline statusline))) - (fn update [new-state] - (each [k v (pairs new-state)] - (tset state k v)) + (fn update [new-sections] + (when new-sections.left + (set state.left new-sections.left)) + (when new-sections.center + (set state.center new-sections.center)) + (when new-sections.right + (set state.right new-sections.right)) (render)) (fn get-state [] diff --git a/fnl/eca/ui/widgets/tab-bar.fnl b/fnl/eca/ui/widgets/tab-bar.fnl index 31da58f..e8b1a50 100644 --- a/fnl/eca/ui/widgets/tab-bar.fnl +++ b/fnl/eca/ui/widgets/tab-bar.fnl @@ -1,34 +1,22 @@ -;; tab-bar widget — tab line for multiple chats. -;; Stateful: tracks open chats, renders tabline. +;; tab-bar widget — generic tab line. +;; Zero business logic. Receives pre-formatted tab data. (fn create [canvas initial-state] "Create a tab-bar widget. - initial-state: {: tabs [{: id : title : loading? : approval?}] : active-id} + initial-state: {: tabs [{: id : label : hl-group}] : active-id} Returns {: render : add-tab : remove-tab : select-tab : update-tab : get-state}." (local state (vim.tbl_extend :force - {:tabs [] - :active-id nil} - (or initial-state {}))) + {:tabs [] + :active-id nil} + (or initial-state {}))) (fn build-tabline [] - "Build tabline format string." (let [parts []] (each [_ tab (ipairs state.tabs)] (let [is-active (= tab.id state.active-id) - hl-group (if tab.approval? :EcaTabLoading - tab.loading? :EcaTabLoading - is-active :EcaTabActive - :EcaTabInactive) - prefix (if tab.approval? "🚧 " - tab.loading? "⏳ " - "") - title (or tab.title (tostring tab.id))] + hl (or tab.hl-group (if is-active :EcaTabActive :EcaTabInactive))] (table.insert parts - (.. "%#" hl-group "# " prefix title " ")))) - ;; Add new chat button - (table.insert parts "%#EcaButtonAccept# + ") - ;; Add close button - (table.insert parts "%#EcaButtonReject# × ") + (.. "%#" hl "# " (or tab.label (tostring tab.id)) " ")))) (table.concat parts "%#Normal#│"))) (fn render [] @@ -37,19 +25,14 @@ (canvas:set-option :global :showtabline 2))) (fn add-tab [tab] - "Add a new tab. tab: {: id : title : loading? : approval?}." (table.insert state.tabs tab) (when (= nil state.active-id) (set state.active-id tab.id))) (fn remove-tab [id] - "Remove a tab by id." - (let [new-tabs []] - (each [_ tab (ipairs state.tabs)] - (when (not= tab.id id) - (table.insert new-tabs tab))) + (let [new-tabs (icollect [_ tab (ipairs state.tabs)] + (when (not= tab.id id) tab))] (set state.tabs new-tabs) - ;; If active tab was removed, select first available (when (= state.active-id id) (set state.active-id (if (> (length new-tabs) 0) @@ -57,14 +40,12 @@ nil))))) (fn select-tab [id] - "Select active tab." (set state.active-id id)) - (fn update-tab [id new-state] - "Update a tab's state." + (fn update-tab [id new-data] (each [_ tab (ipairs state.tabs)] (when (= tab.id id) - (each [k v (pairs new-state)] + (each [k v (pairs new-data)] (tset tab k v))))) (fn get-state [] diff --git a/lua/eca/api.lua b/lua/eca/api.lua index c300b55..b1c40e1 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -1,120 +1,113 @@ -- [nfnl] fnl/eca/api.fnl -local nvim = vim.api -local function buf_set_lines(buf, start, _end, lines) - return nvim.nvim_buf_set_lines(buf, start, _end, false, lines) -end -local function buf_get_lines(buf, start, _end) - return nvim.nvim_buf_get_lines(buf, start, _end, false) -end -local function buf_line_count(buf) - return nvim.nvim_buf_line_count(buf) -end -local function buf_is_valid(buf) - return ((nil ~= buf) and nvim.nvim_buf_is_valid(buf)) -end -local function buf_create(opts) - local o = (opts or {}) - local _1_ - if (nil ~= o.listed) then - _1_ = o.listed - else - _1_ = false - end - local function _3_() - if (nil ~= o.scratch) then - return o.scratch - else - return true +local self = {} +local chats = {} +local plugin_opts = {} +self["resolve-chat"] = function() + local current = chats[vim.api.nvim_get_current_buf()] + local or_1_ = current + if not or_1_ then + local found = nil + for _, chat in pairs(chats) do + if (not found and chat["is-open?"]()) then + found = chat + else + end end + or_1_ = found end - return nvim.nvim_create_buf(_1_, _3_()) -end -local function buf_set_keymap(buf, mode, lhs, rhs, opts) - return nvim.nvim_buf_set_keymap(buf, mode, lhs, rhs, (opts or {})) -end -local function win_open(buf, opts) - return nvim.nvim_open_win(buf, true, (opts or {})) + return or_1_ end -local function win_close(win) - if (win and nvim.nvim_win_is_valid(win)) then - return nvim.nvim_win_close(win, true) +self["register-chat"] = function(chat) + local buf_id = chat["get-buf-id"]() + if buf_id then + chats[buf_id] = chat + return nil else return nil end end -local function win_is_valid(win) - return ((nil ~= win) and nvim.nvim_win_is_valid(win)) -end -local function win_get_cursor(win) - if win then - return nvim.nvim_win_get_cursor(win) +self["chat-open"] = function() + local existing = self["resolve-chat"]() + if not (existing and existing["is-open?"]()) then + local builder = require("eca.ui.builder") + local chat_ui = builder["create-chat-ui"]({["on-submit"] = (plugin_opts["on-submit"] or self["default-on-submit"]), opts = {ui = (plugin_opts.ui or {}), keymaps = (plugin_opts.keymaps or {{mode = "i", lhs = "", rhs = "EcaChatSubmit"}, {mode = "n", lhs = "", rhs = "EcaChatSubmit"}})}}) + chat_ui.open() + self["register-chat"](chat_ui) + chat_ui["set-welcome"]("Welcome to ECA Chat") + chat_ui["update-header"]({{title = "model", value = "claude"}, {title = "behavior", value = "agent"}}) + return chat_ui["update-footer"]({{value = "ECA Chat"}, {title = "tokens", value = "0/200K"}}) else return nil end end -local function win_set_cursor(win, pos) - if win then - return nvim.nvim_win_set_cursor(win, pos) +self["chat-close"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat.close() else return nil end end -local function create_namespace(name) - return nvim.nvim_create_namespace(name) -end -local function buf_set_extmark(buf, ns_id, line, col, opts) - return nvim.nvim_buf_set_extmark(buf, ns_id, line, col, (opts or {})) -end -local function buf_del_extmark(buf, ns_id, id) - return nvim.nvim_buf_del_extmark(buf, ns_id, id) -end -local function buf_get_extmarks(buf, ns_id, start, _end, opts) - return nvim.nvim_buf_get_extmarks(buf, ns_id, start, _end, (opts or {})) +self["chat-toggle"] = function() + local chat = self["resolve-chat"]() + if (chat and chat["is-open?"]()) then + return chat.close() + else + return self["chat-open"]() + end end -local function set_option(scope, id, key, value) - if (scope == "win") then - return nvim.nvim_set_option_value(key, value, {win = id}) - elseif (scope == "buf") then - return nvim.nvim_set_option_value(key, value, {buf = id}) - elseif (scope == "global") then - return nvim.nvim_set_option_value(key, value, {}) +self["chat-submit"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat["submit-prompt"]() else return nil end end -local function get_option(scope, id, key) - if (scope == "win") then - return nvim.nvim_get_option_value(key, {win = id}) - elseif (scope == "buf") then - return nvim.nvim_get_option_value(key, {buf = id}) - elseif (scope == "global") then - return nvim.nvim_get_option_value(key, {}) +self["chat-clear"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat["clear-messages"]() else return nil end end -local function set_hl(ns, group, opts) - return nvim.nvim_set_hl(ns, group, opts) -end -local function create_user_command(name, f, opts) - return nvim.nvim_create_user_command(name, f, (opts or {})) -end -local function create_autocmd(event, opts) - return nvim.nvim_create_autocmd(event, opts) -end -local function set_keymap(mode, lhs, rhs, opts) - return vim.keymap.set(mode, lhs, rhs, (opts or {})) -end -local function schedule(f) - return vim.schedule(f) +self["chat-set-model"] = function(model) + local chat = self["resolve-chat"]() + if chat then + return chat["update-header-item"]("model", model) + else + return nil + end end -local function defer(f, ms) - return vim.defer_fn(f, ms) +self["chat-set-loading"] = function(bool) + local chat = self["resolve-chat"]() + if chat then + return chat["set-loading"](bool) + else + return nil + end end -local function editor_width() - return vim.o.columns +self["default-on-submit"] = function(text) + local chat = self["resolve-chat"]() + if chat then + chat["append-message"]({id = tostring(os.time()), content = text, prefix = "> "}) + chat["set-loading"](true) + local function _11_() + if chat["is-open?"]() then + chat["append-message"]({id = ("reply-" .. tostring(os.time())), content = ("You said: " .. text .. "\n\n(This is a mock response)")}) + return chat["set-loading"](false) + else + return nil + end + end + return vim.defer_fn(_11_, 500) + else + return nil + end end -local function editor_height() - return vim.o.lines +self["set-plugin-opts"] = function(opts) + plugin_opts = (opts or {}) + return nil end -return {["buf-set-lines"] = buf_set_lines, ["buf-get-lines"] = buf_get_lines, ["buf-line-count"] = buf_line_count, ["buf-is-valid"] = buf_is_valid, ["buf-create"] = buf_create, ["buf-set-keymap"] = buf_set_keymap, ["win-open"] = win_open, ["win-close"] = win_close, ["win-is-valid"] = win_is_valid, ["win-get-cursor"] = win_get_cursor, ["win-set-cursor"] = win_set_cursor, ["create-namespace"] = create_namespace, ["buf-set-extmark"] = buf_set_extmark, ["buf-del-extmark"] = buf_del_extmark, ["buf-get-extmarks"] = buf_get_extmarks, ["set-option"] = set_option, ["get-option"] = get_option, ["set-hl"] = set_hl, ["create-user-command"] = create_user_command, ["create-autocmd"] = create_autocmd, ["set-keymap"] = set_keymap, schedule = schedule, defer = defer, ["editor-width"] = editor_width, ["editor-height"] = editor_height} +return self diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 8332316..3e51668 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -1,33 +1,41 @@ -- [nfnl] fnl/eca/commands.fnl -local api = require("eca.api") -local function setup(chat_ui) +local nvim = vim.api +local function setup(api) local function _1_() - return chat_ui.toggle() + return api["chat-toggle"]() end - api["create-user-command"]("EcaChat", _1_, {desc = "Toggle ECA Chat window"}) + nvim.nvim_create_user_command("EcaChat", _1_, {desc = "Toggle ECA Chat window"}) local function _2_() - return chat_ui.open() + return api["chat-open"]() end - api["create-user-command"]("EcaChatOpen", _2_, {desc = "Open ECA Chat window"}) + nvim.nvim_create_user_command("EcaChatOpen", _2_, {desc = "Open ECA Chat window"}) local function _3_() - return chat_ui.close() + return api["chat-close"]() end - api["create-user-command"]("EcaChatClose", _3_, {desc = "Close ECA Chat window"}) + nvim.nvim_create_user_command("EcaChatClose", _3_, {desc = "Close ECA Chat window"}) local function _4_() - return chat_ui["clear-messages"]() + return api["chat-clear"]() end - api["create-user-command"]("EcaChatClear", _4_, {desc = "Clear ECA Chat messages"}) + nvim.nvim_create_user_command("EcaChatClear", _4_, {desc = "Clear current chat messages"}) local function _5_() - return chat_ui["clear-messages"]() + return api["chat-open"]() end - api["create-user-command"]("EcaChatNew", _5_, {desc = "Start a new ECA Chat"}) + nvim.nvim_create_user_command("EcaChatNew", _5_, {desc = "Open a new ECA Chat"}) local function _6_() - return chat_ui["submit-prompt"]() + return api["chat-submit"]() end - api["create-user-command"]("EcaChatSubmit", _6_, {desc = "Submit current prompt"}) + nvim.nvim_create_user_command("EcaChatSubmit", _6_, {desc = "Submit current prompt"}) local function _7_() - return chat_ui["set-loading"](false) + return api["chat-set-loading"](false) end - return api["create-user-command"]("EcaChatStop", _7_, {desc = "Stop current ECA response"}) + nvim.nvim_create_user_command("EcaChatStop", _7_, {desc = "Stop current ECA response"}) + local function _8_(cmd) + if (cmd.args and ("" ~= cmd.args)) then + return api["chat-set-model"](cmd.args) + else + return nil + end + end + return nvim.nvim_create_user_command("EcaChatSetModel", _8_, {desc = "Set the model", nargs = 1}) end return {setup = setup} diff --git a/lua/eca/init.lua b/lua/eca/init.lua index fd080c4..be44cae 100644 --- a/lua/eca/init.lua +++ b/lua/eca/init.lua @@ -1,28 +1,8 @@ -- [nfnl] fnl/eca/init.fnl local api = require("eca.api") -local builder = require("eca.ui.builder") local commands = require("eca.commands") -local chat_ui = nil -local function default_on_submit(text) - if chat_ui then - chat_ui["append-message"]({id = tostring(os.time()), role = "user", content = text}) - chat_ui["set-loading"](true) - local function _1_() - if chat_ui then - chat_ui["append-message"]({id = ("reply-" .. tostring(os.time())), role = "assistant", content = ("You said: " .. text .. "\n\n(This is a mock response \226\128\148 connect ECA server for real responses)")}) - return chat_ui["set-loading"](false) - else - return nil - end - end - return api.defer(_1_, 500) - else - return nil - end -end local function setup(opts) - local user_opts = (opts or {}) - chat_ui = builder["create-chat-ui"]({api = api, ["on-submit"] = (user_opts["on-submit"] or default_on_submit), opts = {ui = (user_opts.ui or {})}}) - return commands.setup(chat_ui) + api["set-plugin-opts"]((opts or {})) + return commands.setup(api) end return {setup = setup} diff --git a/lua/eca/ui/builder.lua b/lua/eca/ui/builder.lua index 470fce5..5416546 100644 --- a/lua/eca/ui/builder.lua +++ b/lua/eca/ui/builder.lua @@ -1,200 +1,264 @@ -- [nfnl] fnl/eca/ui/builder.fnl +local nvim = vim.api local highlights = require("eca.ui.highlights") local header_bar_widget = require("eca.ui.widgets.header-bar") local message_list_widget = require("eca.ui.widgets.message-list") local prompt_area_widget = require("eca.ui.widgets.prompt-area") -local status_bar_widget = require("eca.ui.widgets.status-bar") -local tab_bar_widget = require("eca.ui.widgets.tab-bar") -local function build_canvas(api, buf_id, win_id) - local buf = buf_id - local win = win_id - local function _1_(_, start, _end, lines) - return api["buf-set-lines"](buf, start, _end, lines) - end - local function _2_(_, start, _end) - return api["buf-get-lines"](buf, start, _end) - end - local function _3_(_) - return api["buf-line-count"](buf) - end - local function _4_(_, ns_id, line, col, opts) - return api["buf-set-extmark"](buf, ns_id, line, col, opts) - end - local function _5_(_, ns_id, id) - return api["buf-del-extmark"](buf, ns_id, id) - end - local function _6_(_, ns_id, start, _end, opts) - return api["buf-get-extmarks"](buf, ns_id, start, _end, opts) - end - local function _7_(_, name) - return api["create-namespace"](name) - end - local function _8_(_, scope, key, value) - if (scope == "win") then - return api["set-option"]("win", win, key, value) - elseif (scope == "buf") then - return api["set-option"]("buf", buf, key, value) - elseif (scope == "global") then - return api["set-option"]("global", nil, key, value) +local footer_bar_widget = require("eca.ui.widgets.footer-bar") +local function setup_chat_buffer(buf) + nvim.nvim_buf_set_name(buf, "ECA Chat") + nvim.nvim_set_option_value("buftype", "nofile", {buf = buf}) + nvim.nvim_set_option_value("bufhidden", "hide", {buf = buf}) + nvim.nvim_set_option_value("swapfile", false, {buf = buf}) + return nvim.nvim_set_option_value("filetype", "eca-chat", {buf = buf}) +end +local function setup_chat_window(win) + nvim.nvim_set_option_value("number", false, {win = win}) + nvim.nvim_set_option_value("relativenumber", false, {win = win}) + nvim.nvim_set_option_value("signcolumn", "no", {win = win}) + nvim.nvim_set_option_value("foldcolumn", "0", {win = win}) + nvim.nvim_set_option_value("numberwidth", 1, {win = win}) + nvim.nvim_set_option_value("statuscolumn", "", {win = win}) + nvim.nvim_set_option_value("spell", false, {win = win}) + nvim.nvim_set_option_value("list", false, {win = win}) + nvim.nvim_set_option_value("wrap", true, {win = win}) + nvim.nvim_set_option_value("linebreak", true, {win = win}) + return nvim.nvim_set_option_value("conceallevel", 2, {win = win}) +end +local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_prompt_fn) + local internal_edit = false + local guard_ns = nil + local function ensure_guard_ns() + if (nil == guard_ns) then + guard_ns = nvim.nvim_create_namespace("eca-edit-guard") else - return nil + end + return guard_ns + end + local function get_prefix(loading_3f) + local prompt_prefix = require("eca.ui.components.prompt-prefix") + return prompt_prefix.render({["loading?"] = loading_3f}).text + end + local function salvage_user_text(buf, prompt_start_line, prefix) + local current_count = nvim.nvim_buf_line_count(buf) + local start = math.min(prompt_start_line, current_count) + local prompt_lines = nvim.nvim_buf_get_lines(buf, start, current_count, false) + if (0 == #prompt_lines) then + return {""} + else + local tbl_26_ = {} + local i_27_ = 0 + for i, line in ipairs(prompt_lines) do + local val_28_ + if (i == 1) then + if vim.startswith(line, prefix) then + val_28_ = string.sub(line, (#prefix + 1)) + else + val_28_ = line:gsub("^>%s*", "") + end + else + val_28_ = line + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + return tbl_26_ end end - local function _10_(_, scope, key) - if (scope == "win") then - return api["get-option"]("win", win, key) - elseif (scope == "buf") then - return api["get-option"]("buf", buf, key) - elseif (scope == "global") then - return api["get-option"]("global", nil, key) + local function restore_with_user_text(buf, prefix, user_lines) + internal_edit = true + render_all_fn() + do + local new_count = nvim.nvim_buf_line_count(buf) + local new_last_idx = (new_count - 1) + local restored_lines + do + local tbl_26_ = {} + local i_27_ = 0 + for i, line in ipairs(user_lines) do + local val_28_ + if (i == 1) then + val_28_ = (prefix .. line) + else + val_28_ = line + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + restored_lines = tbl_26_ + end + if (#restored_lines > 0) then + nvim.nvim_buf_set_lines(buf, new_last_idx, new_count, false, restored_lines) + local ns = ensure_guard_ns() + nvim.nvim_buf_set_extmark(buf, ns, new_last_idx, 0, {end_col = #prefix, hl_group = "EcaPromptPrefix"}) + else + end + end + internal_edit = false + if focus_prompt_fn then + return focus_prompt_fn() else return nil end end - local function _12_(_) - return api["win-get-cursor"](win) - end - local function _13_(_, line, col) - return api["win-set-cursor"](win, {line, col}) - end - local function _14_(_) - return api["buf-is-valid"](buf) - end - local function _15_(_) - return api["win-is-valid"](win) - end - local function _16_(_, ns, group, opts) - return api["set-hl"](ns, group, opts) - end - local function _17_(_) - return api["win-close"](win) - end - local function _18_(_) - return buf - end - local function _19_(_) - return win - end - return {["set-lines"] = _1_, ["get-lines"] = _2_, ["line-count"] = _3_, ["add-extmark"] = _4_, ["del-extmark"] = _5_, ["get-extmarks"] = _6_, ["create-namespace"] = _7_, ["set-option"] = _8_, ["get-option"] = _10_, ["get-cursor"] = _12_, ["set-cursor"] = _13_, ["buf-valid?"] = _14_, ["win-valid?"] = _15_, ["set-hl"] = _16_, ["close-win"] = _17_, ["buf-id"] = _18_, ["win-id"] = _19_} -end -local function setup_chat_buffer(canvas) - canvas["set-option"](canvas, "buf", "buftype", "nofile") - canvas["set-option"](canvas, "buf", "bufhidden", "hide") - canvas["set-option"](canvas, "buf", "swapfile", false) - return canvas["set-option"](canvas, "buf", "filetype", "eca-chat") -end -local function setup_chat_window(canvas) - canvas["set-option"](canvas, "win", "number", false) - canvas["set-option"](canvas, "win", "relativenumber", false) - canvas["set-option"](canvas, "win", "signcolumn", "no") - canvas["set-option"](canvas, "win", "foldcolumn", "0") - canvas["set-option"](canvas, "win", "spell", false) - canvas["set-option"](canvas, "win", "wrap", true) - canvas["set-option"](canvas, "win", "linebreak", true) - return canvas["set-option"](canvas, "win", "conceallevel", 2) -end -local function setup_edit_guard(api, buf_id, get_prompt_start_line) - local internal_edit = false - local function set_internal(bool) - internal_edit = bool - return nil - end - local function _20_(_, buf, changedtick, first_line, last_line, new_last_line) + local function on_lines_handler(_, buf, changedtick, first_line, last_line, new_last_line) if not internal_edit then - local prompt_start = get_prompt_start_line() - if (first_line < prompt_start) then - local function _21_() - if api["buf-is-valid"](buf) then - return vim.cmd("silent! undo") + local _let_10_ = get_prompt_state() + local prompt_start_line = _let_10_["prompt-start-line"] + local loading_3f = _let_10_["loading?"] + local prefix = get_prefix(loading_3f) + local function _11_() + if nvim.nvim_buf_is_valid(buf) then + local current_count = nvim.nvim_buf_line_count(buf) + local prompt_idx = math.min(prompt_start_line, (current_count - 1)) + local prompt_lines = nvim.nvim_buf_get_lines(buf, prompt_idx, (prompt_idx + 1), false) + local prompt_line_text = (prompt_lines[1] or "") + local damaged_3f = ((first_line < prompt_start_line) or not vim.startswith(prompt_line_text, prefix)) + if damaged_3f then + local user_lines = salvage_user_text(buf, prompt_start_line, prefix) + return restore_with_user_text(buf, prefix, user_lines) else return nil end + else + return nil end - return api.schedule(_21_) - else - return nil end + return vim.schedule(_11_) else return nil end end - api["buf-attach"](buf_id, {on_lines = _20_}) - return set_internal + nvim.nvim_buf_attach(buf_id, false, {on_lines = on_lines_handler}) + local function set_internal(bool) + internal_edit = bool + return nil + end + local function update_expected_count() + return nil + end + return {["set-internal"] = set_internal, ["update-expected-count"] = update_expected_count} end -local function create_chat_ui(_25_) - local api = _25_.api - local on_submit = _25_["on-submit"] - local on_approve = _25_["on-approve"] - local on_reject = _25_["on-reject"] - local on_stop = _25_["on-stop"] - local on_new_chat = _25_["on-new-chat"] - local on_select_tab = _25_["on-select-tab"] - local on_context_add = _25_["on-context-add"] - local opts = _25_.opts +local function create_chat_ui(_15_) + local on_submit = _15_["on-submit"] + local opts = _15_.opts local ui_config = (opts.ui or {}) - local config = {width = (ui_config.width or 0.4), position = (ui_config.position or "right")} - local canvas = nil - local set_internal_edit = nil - local widgets = {header = nil, messages = nil, prompt = nil, status = nil, tabs = nil} + local config = {width = (ui_config.width or 0.4), position = (ui_config.position or "right"), keymaps = (opts.keymaps or {})} + local state = {["header-items"] = {}, ["footer-items"] = {}, welcome = nil} + local buf_id = nil + local win_id = nil + local guard = nil + local widgets = {header = nil, messages = nil, prompt = nil, footer = nil} local function is_open_3f() - return ((nil ~= canvas) and canvas["buf-valid?"](canvas) and canvas["win-valid?"](canvas)) - end - local function get_prompt_start_line() - local state = widgets.prompt["get-state"]() - return (state["prompt-start-line"] or 0) + return ((nil ~= buf_id) and nvim.nvim_buf_is_valid(buf_id) and (nil ~= win_id) and nvim.nvim_win_is_valid(win_id)) end local function with_internal_edit(f) - if set_internal_edit then - set_internal_edit(true) + if guard then + guard["set-internal"](true) else end f() - if set_internal_edit then - return set_internal_edit(false) + if guard then + guard["set-internal"](false) + return guard["update-expected-count"]() + else + return nil + end + end + local function focus_prompt() + if (win_id and nvim.nvim_win_is_valid(win_id)) then + local total = nvim.nvim_buf_line_count(buf_id) + local prompt_state = widgets.prompt["get-state"]() + local prompt_line = (prompt_state["prompt-start-line"] or (total - 1)) + return nvim.nvim_win_set_cursor(win_id, {(prompt_line + 1), 2}) else return nil end end local function render_all() - local function _28_() - widgets.header.render() - widgets.messages.render() + local function _19_() do + local header_lines = widgets.header.render() + widgets.messages["set-start-line"](header_lines) + widgets.messages.render() local end_line = widgets.messages["get-end-line"]() widgets.prompt.render(end_line) end - widgets.status.render() - return widgets.tabs.render() + if widgets.footer then + return widgets.footer.render() + else + return nil + end end - return with_internal_edit(_28_) + return with_internal_edit(_19_) end - local function open() - if not is_open_3f() then - local buf_id = api["buf-create"]({scratch = true, listed = false}) - local win_width = math.floor((api["editor-width"]() * config.width)) - local win_id = api["win-open"](buf_id, {split = "right", width = win_width}) - canvas = build_canvas(api, buf_id, win_id) - highlights.setup(canvas) - setup_chat_buffer(canvas) - setup_chat_window(canvas) - widgets.header = header_bar_widget.create(canvas, {}) - widgets.messages = message_list_widget.create(canvas) - widgets.prompt = prompt_area_widget.create(canvas) - widgets.status = status_bar_widget.create(canvas, {}) - widgets.tabs = tab_bar_widget.create(canvas, {tabs = {{id = 1, title = "Chat 1"}}, ["active-id"] = 1}) - set_internal_edit = setup_edit_guard(api, buf_id, get_prompt_start_line) - local function _29_() - return canvas["set-lines"](canvas, 0, -1, {""}) - end - with_internal_edit(_29_) - return render_all() + local function close() + if is_open_3f() then + nvim.nvim_win_close(win_id, true) + win_id = nil + return nil else return nil end end - local function close() + local function submit_prompt() if is_open_3f() then - return canvas["close-win"](canvas) + local text = widgets.prompt["get-text"]() + if (text and ("" ~= text)) then + widgets.prompt["add-to-history"](text) + local function _22_() + return widgets.prompt.clear() + end + with_internal_edit(_22_) + focus_prompt() + if on_submit then + return on_submit(text) + else + return nil + end + else + return nil + end + else + return nil + end + end + local function open() + if not is_open_3f() then + buf_id = nvim.nvim_create_buf(false, true) + do + local width = math.floor((vim.o.columns * config.width)) + win_id = nvim.nvim_open_win(buf_id, true, {split = "right", width = width}) + end + highlights.setup() + setup_chat_buffer(buf_id) + setup_chat_window(win_id) + widgets.header = header_bar_widget.create(buf_id, win_id, state["header-items"]) + widgets.messages = message_list_widget.create(buf_id) + if state.welcome then + widgets.messages["set-welcome"]({lines = {state.welcome, ""}, highlights = {{["line-idx"] = 0, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #state.welcome}}}) + else + end + widgets.prompt = prompt_area_widget.create(buf_id) + widgets.footer = footer_bar_widget.create(buf_id, win_id, state["footer-items"]) + for _, km in ipairs(config.keymaps) do + vim.keymap.set(km.mode, km.lhs, km.rhs, {buffer = buf_id, noremap = true, silent = true}) + end + nvim.nvim_buf_set_lines(buf_id, 0, -1, false, {""}) + render_all() + focus_prompt() + local function _27_() + local s = widgets.prompt["get-state"]() + return {["prompt-start-line"] = (s["prompt-start-line"] or 0), ["loading?"] = s["loading?"]} + end + guard = setup_edit_guard(buf_id, render_all, _27_, focus_prompt) + return nil else return nil end @@ -206,141 +270,122 @@ local function create_chat_ui(_25_) return open() end end + local function get_buf_id() + return buf_id + end local function append_message(msg) if is_open_3f() then - local function _33_() + local function _30_() widgets.messages["append-message"](msg) local end_line = widgets.messages["get-end-line"]() return widgets.prompt.render(end_line) end - return with_internal_edit(_33_) + with_internal_edit(_30_) + return focus_prompt() else return nil end end local function update_message(id, content) if is_open_3f() then - local function _35_() + local function _32_() widgets.messages["update-message"](id, content) local end_line = widgets.messages["get-end-line"]() return widgets.prompt.render(end_line) end - return with_internal_edit(_35_) + return with_internal_edit(_32_) else return nil end end local function clear_messages() if is_open_3f() then - local function _37_() + local function _34_() widgets.messages.clear() local end_line = widgets.messages["get-end-line"]() return widgets.prompt.render(end_line) end - return with_internal_edit(_37_) + return with_internal_edit(_34_) else return nil end end - local function show_tool_call(tc) - return nil - end - local function update_tool_call(id, status) - return nil - end - local function show_approval(tc) - return nil - end - local function update_model_info(info) + local function update_header(new_items) + state["header-items"] = new_items if is_open_3f() then - return widgets.header.update(info) + local function _36_() + return widgets.header.update(new_items) + end + return with_internal_edit(_36_) else return nil end end - local function update_usage(usage) - if is_open_3f() then - return widgets.status.update(usage) + local function update_header_item(title, new_value) + local found = false + for _, item in ipairs(state["header-items"]) do + if (item.title == title) then + item["value"] = new_value + found = true + else + end + end + if not found then + table.insert(state["header-items"], {title = title, value = new_value}) else - return nil end - end - local function update_progress(progress) if is_open_3f() then - return widgets.status.update({["init-progress"] = progress}) + local function _40_() + return widgets.header.update(state["header-items"]) + end + return with_internal_edit(_40_) else return nil end end - local function add_context(ctx) + local function update_footer(new_items) + state["footer-items"] = new_items if is_open_3f() then local function _42_() - widgets.prompt["add-context"](ctx) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + return widgets.footer.update(new_items) end return with_internal_edit(_42_) else return nil end end - local function remove_context(name) - if is_open_3f() then - local function _44_() - widgets.prompt["remove-context"](name) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + local function update_footer_item(title, new_value) + local found = false + for _, item in ipairs(state["footer-items"]) do + if (item.title == title) then + item["value"] = new_value + found = true + else end - return with_internal_edit(_44_) - else - return nil - end - end - local function add_chat_tab(tab) - if is_open_3f() then - widgets.tabs["add-tab"](tab) - return widgets.tabs.render() - else - return nil - end - end - local function remove_chat_tab(id) - if is_open_3f() then - widgets.tabs["remove-tab"](id) - return widgets.tabs.render() - else - return nil end - end - local function select_chat_tab(id) - if is_open_3f() then - widgets.tabs["select-tab"](id) - return widgets.tabs.render() + if not found then + table.insert(state["footer-items"], {title = title, value = new_value}) else - return nil end - end - local function get_prompt_text() if is_open_3f() then - return widgets.prompt["get-text"]() + local function _46_() + return widgets.footer.update(state["footer-items"]) + end + return with_internal_edit(_46_) else return nil end end - local function submit_prompt() + local function set_welcome(text) + state.welcome = text if is_open_3f() then - local text = widgets.prompt["get-text"]() - if (text and ("" ~= text)) then - widgets.prompt["add-to-history"](text) - local function _50_() - return widgets.prompt.clear() - end - with_internal_edit(_50_) - if on_submit then - return on_submit(text) - else - return nil + widgets.messages["set-welcome"]({lines = {text, ""}, highlights = {{["line-idx"] = 0, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #text}}}) + local msg_state = widgets.messages["get-state"]() + if (0 == #msg_state.messages) then + local function _48_() + return render_all() end + return with_internal_edit(_48_) else return nil end @@ -350,14 +395,14 @@ local function create_chat_ui(_25_) end local function set_loading(bool) if is_open_3f() then - local function _54_() + local function _51_() return widgets.prompt["set-loading"](bool) end - return with_internal_edit(_54_) + return with_internal_edit(_51_) else return nil end end - return {open = open, close = close, toggle = toggle, ["is-open?"] = is_open_3f, ["append-message"] = append_message, ["update-message"] = update_message, ["clear-messages"] = clear_messages, ["show-tool-call"] = show_tool_call, ["update-tool-call"] = update_tool_call, ["show-approval"] = show_approval, ["update-model-info"] = update_model_info, ["update-usage"] = update_usage, ["update-progress"] = update_progress, ["add-context"] = add_context, ["remove-context"] = remove_context, ["add-chat-tab"] = add_chat_tab, ["remove-chat-tab"] = remove_chat_tab, ["select-chat-tab"] = select_chat_tab, ["get-prompt-text"] = get_prompt_text, ["submit-prompt"] = submit_prompt, ["set-loading"] = set_loading} + return {open = open, close = close, toggle = toggle, ["is-open?"] = is_open_3f, ["get-buf-id"] = get_buf_id, ["append-message"] = append_message, ["update-message"] = update_message, ["clear-messages"] = clear_messages, ["update-header"] = update_header, ["update-header-item"] = update_header_item, ["update-footer"] = update_footer, ["update-footer-item"] = update_footer_item, ["set-welcome"] = set_welcome, ["submit-prompt"] = submit_prompt, ["set-loading"] = set_loading} end return {["create-chat-ui"] = create_chat_ui} diff --git a/lua/eca/ui/canvas.lua b/lua/eca/ui/canvas.lua deleted file mode 100644 index 76343a1..0000000 --- a/lua/eca/ui/canvas.lua +++ /dev/null @@ -1,32 +0,0 @@ --- [nfnl] fnl/eca/ui/canvas.fnl -local protocol_keys = {"set-lines", "get-lines", "add-extmark", "del-extmark", "get-extmarks", "create-namespace", "set-option", "get-option", "line-count", "get-cursor", "set-cursor", "buf-valid?", "win-valid?", "set-modifiable", "close-win", "set-hl", "buf-id", "win-id"} -local function validate(canvas) - local missing - do - local tbl_26_ = {} - local i_27_ = 0 - for _, key in ipairs(protocol_keys) do - local val_28_ - if (nil == canvas[key]) then - val_28_ = key - else - val_28_ = nil - end - if (nil ~= val_28_) then - i_27_ = (i_27_ + 1) - tbl_26_[i_27_] = val_28_ - else - end - end - missing = tbl_26_ - end - if (0 == #missing) then - return true - else - return false, missing - end -end -local function describe() - return protocol_keys -end -return {validate = validate, describe = describe, ["protocol-keys"] = protocol_keys} diff --git a/lua/eca/ui/components/context-item.lua b/lua/eca/ui/components/context-item.lua index 5cae8f0..5b1cba8 100644 --- a/lua/eca/ui/components/context-item.lua +++ b/lua/eca/ui/components/context-item.lua @@ -1,26 +1,7 @@ -- [nfnl] fnl/eca/ui/components/context-item.fnl -local type_config = {file = {prefix = "@", ["hl-group"] = "EcaContextFile"}, dir = {prefix = "@", ["hl-group"] = "EcaContextDir"}, ["repo-map"] = {prefix = "@", ["hl-group"] = "EcaContextRepoMap", label = "repoMap"}, cursor = {prefix = "@", ["hl-group"] = "EcaContextCursor"}, mcp = {prefix = "@", ["hl-group"] = "EcaContextMcp"}} local function render(_1_) - local type = _1_.type - local name = _1_.name - local detail = _1_.detail - local cfg = (type_config[type] or {prefix = "@", ["hl-group"] = "EcaContextFile"}) - local display_name = (cfg.label or name or "") - local text - if (type == "cursor") then - local _2_ - if detail then - _2_ = (" " .. detail) - else - _2_ = "" - end - text = (cfg.prefix .. "cursor(" .. (name or "") .. _2_ .. ")") - elseif (type == "repo-map") then - text = (cfg.prefix .. display_name) - else - local _ = type - text = (cfg.prefix .. display_name) - end - return {text = text, ["hl-group"] = cfg["hl-group"]} + local text = _1_.text + local hl_group = _1_["hl-group"] + return {text = (text or ""), ["hl-group"] = (hl_group or "Normal")} end return {render = render} diff --git a/lua/eca/ui/components/icon.lua b/lua/eca/ui/components/icon.lua index db9c844..945f8d9 100644 --- a/lua/eca/ui/components/icon.lua +++ b/lua/eca/ui/components/icon.lua @@ -1,10 +1,9 @@ -- [nfnl] fnl/eca/ui/components/icon.fnl -local icons = {collapsed = "\226\143\181", expanded = "\226\143\183", pending = "\226\143\179", running = "\226\143\179", success = "\226\156\133", error = "\226\157\140", approval = "\240\159\154\167", loading = "\226\143\179", stop = "\226\143\185", new = "+", close = "\195\151"} -local icon_highlights = {collapsed = "EcaExpandableIcon", expanded = "EcaExpandableIcon", pending = "EcaToolCallPending", running = "EcaToolCallPending", success = "EcaToolCallSuccess", error = "EcaToolCallError", approval = "EcaToolCallApproval", loading = "EcaSpinner", stop = "EcaToolCallError", new = "EcaButtonAccept", close = "EcaButtonReject"} +local icons = {collapsed = "\226\143\181", expanded = "\226\143\183", loading = "\226\143\179", success = "\226\156\133", error = "\226\157\140", warning = "\226\154\160\239\184\143", info = "\226\132\185\239\184\143", stop = "\226\143\185", new = "+", close = "\195\151"} local function render(_1_) local name = _1_.name - local icon = (icons[name] or "?") - local hl = (icon_highlights[name] or "EcaExpandableIcon") - return {text = icon, ["hl-group"] = hl} + local text = _1_.text + local hl_group = _1_["hl-group"] + return {text = (text or icons[name] or "?"), ["hl-group"] = (hl_group or "Normal")} end return {render = render, icons = icons} diff --git a/lua/eca/ui/components/message.lua b/lua/eca/ui/components/message.lua index b3a92ab..b8184c0 100644 --- a/lua/eca/ui/components/message.lua +++ b/lua/eca/ui/components/message.lua @@ -1,5 +1,4 @@ -- [nfnl] fnl/eca/ui/components/message.fnl -local role_config = {user = {prefix = " You", ["hl-group"] = "EcaUser"}, assistant = {prefix = " ECA", ["hl-group"] = "EcaAssistant"}, system = {prefix = " System", ["hl-group"] = "EcaSystem"}} local function split_lines(text) local lines = {} if ((nil == text) or ("" == text)) then @@ -12,20 +11,37 @@ local function split_lines(text) return lines end local function render(_2_) - local role = _2_.role local content = _2_.content - local cfg = (role_config[role] or {prefix = " ?", ["hl-group"] = "EcaAssistant"}) + local prefix = _2_.prefix + local hl_group = _2_["hl-group"] + local pfx = (prefix or "") + local hl + local or_3_ = hl_group + if not or_3_ then + if (prefix and (#prefix > 0)) then + or_3_ = "EcaMessagePrefix" + else + or_3_ = nil + end + end + hl = or_3_ local content_lines = split_lines((content or "")) - local lines = {cfg.prefix, ""} - local highlights = {{["line-idx"] = 0, ["hl-group"] = cfg["hl-group"], ["col-start"] = 0, ["col-end"] = #cfg.prefix}} - for _, line in ipairs(content_lines) do - table.insert(lines, line) + local lines = {} + local highlights = {} + for i, line in ipairs(content_lines) do + local full + if (i == 1) then + full = (pfx .. line) + else + full = line + end + table.insert(lines, full) + if hl then + table.insert(highlights, {["line-idx"] = (#lines - 1), ["hl-group"] = hl, ["col-start"] = 0, ["col-end"] = #full}) + else + end end table.insert(lines, "") return {lines = lines, highlights = highlights} end -local function render_welcome() - local lines = {"", " Welcome to ECA Chat", "", " Type your message below and press Enter to send.", " Use @ to attach context (files, directories, etc.)", ""} - return {lines = lines, highlights = {{["line-idx"] = 1, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #" Welcome to ECA Chat"}}} -end -return {render = render, ["render-welcome"] = render_welcome} +return {render = render} diff --git a/lua/eca/ui/components/usage.lua b/lua/eca/ui/components/usage.lua index 36ad82b..e1cfad8 100644 --- a/lua/eca/ui/components/usage.lua +++ b/lua/eca/ui/components/usage.lua @@ -1,42 +1,7 @@ -- [nfnl] fnl/eca/ui/components/usage.fnl -local function format_tokens(n) - if (nil == n) then - return "0" - elseif (n >= 1000000) then - return string.format("%.1fM", (n / 1000000)) - elseif (n >= 1000) then - return string.format("%.0fK", (n / 1000)) - else - return tostring(n) - end +local function render(_1_) + local text = _1_.text + local hl_group = _1_["hl-group"] + return {text = (text or ""), ["hl-group"] = (hl_group or "Normal")} end -local function format_cost(cost) - if (nil == cost) then - return nil - else - return string.format("$%.2f", cost) - end -end -local function render(_3_) - local tokens_in = _3_["tokens-in"] - local tokens_out = _3_["tokens-out"] - local max_tokens = _3_["max-tokens"] - local cost = _3_.cost - local used = format_tokens(((tokens_in or 0) + (tokens_out or 0))) - local max = format_tokens(max_tokens) - local base - if max_tokens then - base = (used .. "/" .. max) - else - base = used - end - local cost_str = format_cost(cost) - local text - if cost_str then - text = (base .. " (" .. cost_str .. ")") - else - text = base - end - return {text = text, ["hl-group"] = "EcaUsage"} -end -return {render = render, ["format-tokens"] = format_tokens, ["format-cost"] = format_cost} +return {render = render} diff --git a/lua/eca/ui/highlights.lua b/lua/eca/ui/highlights.lua index a5c8313..bab5712 100644 --- a/lua/eca/ui/highlights.lua +++ b/lua/eca/ui/highlights.lua @@ -1,8 +1,9 @@ -- [nfnl] fnl/eca/ui/highlights.fnl -local groups = {EcaUser = {fg = "#61afef", bold = true}, EcaAssistant = {fg = "#abb2bf"}, EcaSystem = {fg = "#5c6370", italic = true}, EcaWelcome = {fg = "#5c6370", italic = true}, EcaSeparator = {fg = "#3e4452"}, EcaPromptPrefix = {fg = "#98c379", bold = true}, EcaPromptPrefixLoading = {fg = "#e5c07b"}, EcaToolCallPending = {fg = "#e5c07b"}, EcaToolCallSuccess = {fg = "#98c379"}, EcaToolCallError = {fg = "#e06c75"}, EcaToolCallApproval = {fg = "#d19a66", bg = "#3e3522", bold = true}, EcaExpandableIcon = {fg = "#5c6370"}, EcaExpandableLabel = {bold = true}, EcaContextFile = {fg = "#e06c75", underline = true}, EcaContextDir = {fg = "#e06c75", underline = true}, EcaContextRepoMap = {fg = "#56b6c2"}, EcaContextCursor = {fg = "#5c6370"}, EcaContextMcp = {fg = "#98c379"}, EcaHeaderKey = {fg = "#5c6370"}, EcaHeaderValue = {fg = "#abb2bf", bold = true}, EcaUsage = {fg = "#5c6370"}, EcaElapsed = {fg = "#5c6370"}, EcaTrustOn = {fg = "#e06c75", bold = true}, EcaTrustOff = {fg = "#5c6370"}, EcaSpinner = {fg = "#e5c07b"}, EcaButtonAccept = {fg = "#98c379", bold = true}, EcaButtonReject = {fg = "#e06c75", bold = true}, EcaTabActive = {fg = "#abb2bf", bold = true}, EcaTabInactive = {fg = "#5c6370"}, EcaTabLoading = {fg = "#e5c07b"}} -local function setup(canvas) +local nvim = vim.api +local groups = {EcaUser = {link = "Title"}, EcaAssistant = {link = "Normal"}, EcaSystem = {link = "Comment"}, EcaWelcome = {link = "Comment"}, EcaMessagePrefix = {link = "Title"}, EcaSeparator = {link = "WinSeparator"}, EcaPromptPrefix = {link = "Statement"}, EcaPromptPrefixLoading = {link = "WarningMsg"}, EcaToolCallPending = {link = "WarningMsg"}, EcaToolCallSuccess = {link = "DiagnosticOk"}, EcaToolCallError = {link = "DiagnosticError"}, EcaToolCallApproval = {link = "DiagnosticWarn"}, EcaExpandableIcon = {link = "Comment"}, EcaExpandableLabel = {link = "Bold"}, EcaContextFile = {link = "Underlined"}, EcaContextDir = {link = "Underlined"}, EcaContextRepoMap = {link = "Special"}, EcaContextCursor = {link = "Comment"}, EcaContextMcp = {link = "String"}, EcaHeaderKey = {link = "Comment"}, EcaHeaderValue = {link = "Bold"}, EcaUsage = {link = "Comment"}, EcaElapsed = {link = "Comment"}, EcaTrustOn = {link = "DiagnosticError"}, EcaTrustOff = {link = "Comment"}, EcaSpinner = {link = "WarningMsg"}, EcaButtonAccept = {link = "DiagnosticOk"}, EcaButtonReject = {link = "DiagnosticError"}, EcaTabActive = {link = "TabLineSel"}, EcaTabInactive = {link = "TabLine"}, EcaTabLoading = {link = "WarningMsg"}} +local function setup() for group, opts in pairs(groups) do - canvas["set-hl"](canvas, 0, group, opts) + nvim.nvim_set_hl(0, group, opts) end return nil end diff --git a/lua/eca/ui/widgets/context-bar.lua b/lua/eca/ui/widgets/context-bar.lua index bb70c85..9bf3418 100644 --- a/lua/eca/ui/widgets/context-bar.lua +++ b/lua/eca/ui/widgets/context-bar.lua @@ -1,22 +1,22 @@ -- [nfnl] fnl/eca/ui/widgets/context-bar.fnl -local context_item_component = require("eca.ui.components.context-item") -local function create(canvas) - local state = {contexts = {}, ["ns-id"] = nil} +local nvim = vim.api +local function create(buf_id) + local state = {items = {}, ["ns-id"] = nil} local function ensure_ns() if (nil == state["ns-id"]) then - state["ns-id"] = canvas["create-namespace"](canvas, "eca-context-bar") + state["ns-id"] = nvim.nvim_create_namespace("eca-context-bar") else end return state["ns-id"] end local function build_line() - if (0 == #state.contexts) then - return {line = "", parts = {}} + if (0 == #state.items) then + return {line = "", highlights = {}} else local result do local acc = {parts = {}, highlights = {}, col = 0} - for _, ctx in ipairs(state.contexts) do + for _, item in ipairs(state.items) do local sep_col if (acc.col > 0) then table.insert(acc.parts, " ") @@ -24,10 +24,9 @@ local function create(canvas) else sep_col = acc.col end - local rendered = context_item_component.render(ctx) - table.insert(acc.parts, rendered.text) - table.insert(acc.highlights, {["hl-group"] = rendered["hl-group"], ["col-start"] = sep_col, ["col-end"] = (sep_col + #rendered.text)}) - acc = {parts = acc.parts, highlights = acc.highlights, col = (sep_col + #rendered.text)} + table.insert(acc.parts, item.text) + table.insert(acc.highlights, {["hl-group"] = (item["hl-group"] or "Normal"), ["col-start"] = sep_col, ["col-end"] = (sep_col + #item.text)}) + acc = {parts = acc.parts, highlights = acc.highlights, col = (sep_col + #item.text)} end result = acc end @@ -40,44 +39,53 @@ local function create(canvas) local line = _let_4_.line local highlights = _let_4_.highlights if (line and ("" ~= line)) then - canvas["set-modifiable"](canvas, true) - canvas["set-lines"](canvas, line_num, (line_num + 1), {line}) + nvim.nvim_buf_set_lines(buf_id, line_num, (line_num + 1), false, {line}) for _, hl in ipairs((highlights or {})) do - canvas["add-extmark"](canvas, ns, line_num, hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) + nvim.nvim_buf_set_extmark(buf_id, ns, line_num, hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) end - return canvas["set-modifiable"](canvas, false) + return nil else return nil end end - local function add(ctx) + local function add(item) local exists do local found = false - for _, existing in ipairs(state.contexts) do - found = (found or (existing.name == ctx.name)) + for _, existing in ipairs(state.items) do + found = (found or (existing.text == item.text)) end exists = found end if not exists then - return table.insert(state.contexts, ctx) + return table.insert(state.items, item) else return nil end end - local function remove(name) - local new_contexts = {} - for _, ctx in ipairs(state.contexts) do - if (ctx.name ~= name) then - table.insert(new_contexts, ctx) - else + local function remove(text) + do + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs(state.items) do + local val_28_ + if (item.text ~= text) then + val_28_ = item + else + val_28_ = nil + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end end + state.items = tbl_26_ end - state.contexts = new_contexts return nil end local function clear() - state.contexts = {} + state.items = {} return nil end local function get_state() diff --git a/lua/eca/ui/widgets/expandable-block.lua b/lua/eca/ui/widgets/expandable-block.lua index 44eeb77..a3583f2 100644 --- a/lua/eca/ui/widgets/expandable-block.lua +++ b/lua/eca/ui/widgets/expandable-block.lua @@ -1,54 +1,6 @@ -- [nfnl] fnl/eca/ui/widgets/expandable-block.fnl -local icon_component = require("eca.ui.components.icon") -local function format_elapsed(ms) - if (nil == ms) then - return "" - else - local seconds = math.floor((ms / 1000)) - if (seconds >= 60) then - local mins = math.floor((seconds / 60)) - local secs = (seconds % 60) - return (tostring(mins) .. "m " .. tostring(secs) .. "s") - else - return (tostring(seconds) .. "s") - end - end -end -local function build_label(state) - local expanded_3f = state["expanded?"] - local type = state.type - local label = state.label - local status = state.status - local elapsed_ms = state["elapsed-ms"] - local toggle_icon - local _3_ - if expanded_3f then - _3_ = "expanded" - else - _3_ = "collapsed" - end - toggle_icon = icon_component.render({name = _3_}) - local status_icon - if status then - status_icon = icon_component.render({name = status}) - else - status_icon = nil - end - local elapsed_str = format_elapsed(elapsed_ms) - local parts = {toggle_icon.text} - table.insert(parts, (" " .. (label or (type or "block")))) - if status_icon then - table.insert(parts, (" " .. status_icon.text)) - else - end - if (elapsed_str and ("" ~= elapsed_str)) then - table.insert(parts, (" " .. elapsed_str)) - else - end - return table.concat(parts, "") -end local function create(canvas, initial_state) - local state = vim.tbl_extend("force", {id = nil, type = "tool-call", status = nil, label = "", content = {}, ["elapsed-ms"] = nil, children = {}, ["start-line"] = 0, ["ns-id"] = nil, ["expanded?"] = false}, (initial_state or {})) + local state = vim.tbl_extend("force", {id = nil, label = "", ["icon-expanded"] = "\226\143\183", ["icon-collapsed"] = "\226\143\181", content = {}, ["start-line"] = 0, ["ns-id"] = nil, ["expanded?"] = false}, (initial_state or {})) local function ensure_ns() if (nil == state["ns-id"]) then state["ns-id"] = canvas["create-namespace"](canvas, ("eca-expandable-" .. (state.id or "unknown"))) @@ -56,10 +8,19 @@ local function create(canvas, initial_state) end return state["ns-id"] end + local function build_label() + local icon + if state["expanded?"] then + icon = state["icon-expanded"] + else + icon = state["icon-collapsed"] + end + return (icon .. " " .. state.label) + end local function render(start_line) state["start-line"] = start_line local ns = ensure_ns() - local label_line = build_label(state) + local label_line = build_label() local lines = {label_line} if state["expanded?"] then for _, line in ipairs(state.content) do @@ -67,10 +28,8 @@ local function create(canvas, initial_state) end else end - canvas["set-modifiable"](canvas, true) canvas["set-lines"](canvas, start_line, start_line, lines) canvas["add-extmark"](canvas, ns, start_line, 0, {end_col = #label_line, hl_group = "EcaExpandableLabel"}) - canvas["set-modifiable"](canvas, false) return #lines end local function toggle() @@ -85,18 +44,13 @@ local function create(canvas, initial_state) state["expanded?"] = false return nil end - local function update_status(new_status, _3felapsed_ms) - state.status = new_status - if _3felapsed_ms then - state["elapsed-ms"] = _3felapsed_ms - return nil - else - return nil - end + local function update_label(new_label) + state.label = new_label + return nil end local function get_state() return state end - return {render = render, toggle = toggle, expand = expand, collapse = collapse, ["update-status"] = update_status, ["get-state"] = get_state} + return {render = render, toggle = toggle, expand = expand, collapse = collapse, ["update-label"] = update_label, ["get-state"] = get_state} end return {create = create} diff --git a/lua/eca/ui/widgets/footer-bar.lua b/lua/eca/ui/widgets/footer-bar.lua new file mode 100644 index 0000000..10aa088 --- /dev/null +++ b/lua/eca/ui/widgets/footer-bar.lua @@ -0,0 +1,102 @@ +-- [nfnl] fnl/eca/ui/widgets/footer-bar.fnl +local nvim = vim.api +local function create(buf_id, win_id, initial_items) + local items = (initial_items or {}) + local saved_statusline = nil + local function build_statusline_str() + local parts + do + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs(items) do + local val_28_ + if item.title then + val_28_ = ("%#EcaHeaderKey#" .. item.title .. "%#EcaHeaderValue#:" .. item.value) + else + val_28_ = ("%#EcaHeaderValue#" .. item.value) + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + parts = tbl_26_ + end + local count = #parts + if (count == 0) then + return "" + elseif (count == 1) then + return (" " .. parts[1]) + elseif (count == 2) then + return (" " .. parts[1] .. "%=" .. parts[2] .. " ") + else + local _ = count + local left = parts[1] + local right = parts[count] + local center_parts = {} + local _0 + for i = 2, (count - 1) do + table.insert(center_parts, parts[i]) + end + _0 = nil + local center = table.concat(center_parts, " ") + return (" " .. left .. "%=" .. center .. "%=" .. right .. " ") + end + end + local function is_global_3f() + return (3 == nvim.nvim_get_option_value("laststatus", {})) + end + local function apply_statusline() + local str = build_statusline_str() + if is_global_3f() then + return nvim.nvim_set_option_value("statusline", str, {}) + else + if (win_id and nvim.nvim_win_is_valid(win_id)) then + return nvim.nvim_set_option_value("statusline", str, {win = win_id}) + else + return nil + end + end + end + local function restore_statusline() + if (is_global_3f() and saved_statusline) then + return nvim.nvim_set_option_value("statusline", saved_statusline, {}) + else + return nil + end + end + local function render() + apply_statusline() + return 0 + end + saved_statusline = nvim.nvim_get_option_value("statusline", {}) + local function _7_() + local function _8_() + return apply_statusline() + end + return vim.defer_fn(_8_, 10) + end + nvim.nvim_create_autocmd("BufEnter", {buffer = buf_id, callback = _7_}) + local function _9_() + return restore_statusline() + end + nvim.nvim_create_autocmd("BufLeave", {buffer = buf_id, callback = _9_}) + local function _10_() + if nvim.nvim_buf_is_valid(buf_id) then + return apply_statusline() + else + return nil + end + end + nvim.nvim_create_autocmd("OptionSet", {pattern = "laststatus", callback = _10_}) + local function update(new_items) + items = new_items + return render() + end + local function get_state() + return items + end + return {render = render, update = update, ["get-state"] = get_state} +end +return {create = create} diff --git a/lua/eca/ui/widgets/header-bar.lua b/lua/eca/ui/widgets/header-bar.lua index c9355f6..1cd8fad 100644 --- a/lua/eca/ui/widgets/header-bar.lua +++ b/lua/eca/ui/widgets/header-bar.lua @@ -1,47 +1,58 @@ -- [nfnl] fnl/eca/ui/widgets/header-bar.fnl -local key_value = require("eca.ui.components.key-value") -local function build_winbar_string(state) - local model = state.model - local agent = state.agent - local variant = state.variant - local mcps_total = state["mcps-total"] - local mcps_ready = state["mcps-ready"] - local parts = {} - if model then - table.insert(parts, ("%#EcaHeaderKey#model%#EcaHeaderValue#:" .. model)) - else - end - if agent then - table.insert(parts, ("%#EcaHeaderKey#agent%#EcaHeaderValue#:" .. agent)) - else - end - if variant then - table.insert(parts, ("%#EcaHeaderKey#variant%#EcaHeaderValue#:" .. variant)) - else - end - if mcps_total then - local ready = (mcps_ready or 0) - local total = mcps_total - table.insert(parts, ("%#EcaHeaderKey#mcps%#EcaHeaderValue#:" .. tostring(ready) .. "/" .. tostring(total))) - else +local nvim = vim.api +local function create(buf_id, win_id, initial_items) + local items = (initial_items or {}) + local function build_winbar() + local parts + do + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs(items) do + local val_28_ = ("%#EcaHeaderKey#" .. item.title .. "%#EcaHeaderValue#:" .. item.value) + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + parts = tbl_26_ + end + local count = #parts + if (count == 0) then + return "" + elseif (count == 1) then + return (" " .. parts[1]) + elseif (count == 2) then + return (" " .. parts[1] .. "%=" .. parts[2] .. " ") + else + local _ = count + local left = parts[1] + local right = parts[count] + local center_parts = {} + local _0 + for i = 2, (count - 1) do + table.insert(center_parts, parts[i]) + end + _0 = nil + local center = table.concat(center_parts, " ") + return (" " .. left .. "%=" .. center .. "%=" .. right .. " ") + end end - return table.concat(parts, " ") -end -local function create(canvas, initial_state) - local state = (initial_state or {model = "claude", agent = "coder", variant = nil, ["mcps-total"] = 0, ["mcps-ready"] = 0}) local function render() - local winbar = build_winbar_string(state) - return canvas["set-option"](canvas, "win", "winbar", winbar) + nvim.nvim_set_option_value("winbar", build_winbar(), {win = win_id}) + nvim.nvim_buf_set_lines(buf_id, 0, 1, false, {""}) + return 1 end - local function update(new_state) - for k, v in pairs(new_state) do - state[k] = v - end + local function update(new_items) + items = new_items return render() end local function get_state() - return state + return items + end + local function line_count() + return 1 end - return {render = render, update = update, ["get-state"] = get_state} + return {render = render, update = update, ["get-state"] = get_state, ["line-count"] = line_count} end return {create = create} diff --git a/lua/eca/ui/widgets/message-list.lua b/lua/eca/ui/widgets/message-list.lua index 3226536..d65790f 100644 --- a/lua/eca/ui/widgets/message-list.lua +++ b/lua/eca/ui/widgets/message-list.lua @@ -1,11 +1,11 @@ -- [nfnl] fnl/eca/ui/widgets/message-list.fnl +local nvim = vim.api local message_component = require("eca.ui.components.message") -local separator_component = require("eca.ui.components.separator") -local function create(canvas) - local state = {messages = {}, ["ns-id"] = nil, ["end-line"] = 0} +local function create(buf_id) + local state = {messages = {}, ["ns-id"] = nil, ["end-line"] = 0, ["start-line"] = 0, ["welcome-lines"] = nil} local function ensure_ns() if (nil == state["ns-id"]) then - state["ns-id"] = canvas["create-namespace"](canvas, "eca-messages") + state["ns-id"] = nvim.nvim_create_namespace("eca-messages") else end return state["ns-id"] @@ -13,55 +13,57 @@ local function create(canvas) local function apply_highlights(lines_offset, highlights) local ns = ensure_ns() for _, hl in ipairs(highlights) do - canvas["add-extmark"](canvas, ns, (lines_offset + hl["line-idx"]), hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) + nvim.nvim_buf_set_extmark(buf_id, ns, (lines_offset + hl["line-idx"]), hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) end return nil end local function render_single_message(msg, start_line) local rendered = message_component.render(msg) - local sep = separator_component.render({width = 50}) - canvas["set-modifiable"](canvas, true) - canvas["set-lines"](canvas, start_line, start_line, rendered.lines) + nvim.nvim_buf_set_lines(buf_id, start_line, start_line, false, rendered.lines) apply_highlights(start_line, rendered.highlights) - local sep_line = (start_line + #rendered.lines) - canvas["set-lines"](canvas, sep_line, sep_line, {sep.line}) - apply_highlights(sep_line, sep.highlights) - canvas["set-modifiable"](canvas, false) - return (#rendered.lines + 1) + return #rendered.lines + end + local function set_start_line(line) + state["start-line"] = line + if (state["end-line"] == 0) then + state["end-line"] = line + return nil + else + return nil + end + end + local function set_welcome(data) + state["welcome-lines"] = data + return nil end local function render() local ns = ensure_ns() - canvas["set-modifiable"](canvas, true) - do - local total_lines = canvas["line-count"](canvas) - canvas["set-lines"](canvas, 0, state["end-line"], {}) - end - state["end-line"] = 0 + nvim.nvim_buf_set_lines(buf_id, state["start-line"], state["end-line"], false, {}) + state["end-line"] = state["start-line"] if (0 == #state.messages) then - local welcome = message_component["render-welcome"]() - canvas["set-lines"](canvas, 0, 0, welcome.lines) - apply_highlights(0, welcome.highlights) - state["end-line"] = #welcome.lines + if state["welcome-lines"] then + nvim.nvim_buf_set_lines(buf_id, state["start-line"], state["start-line"], false, state["welcome-lines"].lines) + apply_highlights(state["start-line"], (state["welcome-lines"].highlights or {})) + state["end-line"] = (state["start-line"] + #state["welcome-lines"].lines) + return nil + else + return nil + end else for _, msg in ipairs(state.messages) do local lines_written = render_single_message(msg, state["end-line"]) state["end-line"] = (state["end-line"] + lines_written) end + return nil end - return canvas["set-modifiable"](canvas, false) end local function append_message(msg) table.insert(state.messages, msg) if (1 == #state.messages) then - render() + return render() else local lines_written = render_single_message(msg, state["end-line"]) state["end-line"] = (state["end-line"] + lines_written) - end - if canvas["win-valid?"](canvas) then - local total = canvas["line-count"](canvas) - return canvas["set-cursor"](canvas, total, 0) - else return nil end end @@ -87,7 +89,7 @@ local function create(canvas) end local function clear() state.messages = {} - state["end-line"] = 0 + state["end-line"] = state["start-line"] return render() end local function get_state() @@ -96,6 +98,6 @@ local function create(canvas) local function get_end_line() return state["end-line"] end - return {render = render, ["append-message"] = append_message, ["update-message"] = update_message, clear = clear, ["get-state"] = get_state, ["get-end-line"] = get_end_line} + return {render = render, ["append-message"] = append_message, ["update-message"] = update_message, clear = clear, ["get-state"] = get_state, ["get-end-line"] = get_end_line, ["set-start-line"] = set_start_line, ["set-welcome"] = set_welcome} end return {create = create} diff --git a/lua/eca/ui/widgets/prompt-area.lua b/lua/eca/ui/widgets/prompt-area.lua index 6cdede6..374992b 100644 --- a/lua/eca/ui/widgets/prompt-area.lua +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -1,13 +1,13 @@ -- [nfnl] fnl/eca/ui/widgets/prompt-area.fnl -local separator_component = require("eca.ui.components.separator") +local nvim = vim.api local prompt_prefix_component = require("eca.ui.components.prompt-prefix") local context_bar_widget = require("eca.ui.widgets.context-bar") -local function create(canvas) +local function create(buf_id) local state = {["prompt-text"] = "", history = {}, ["history-idx"] = 0, ["prompt-start-line"] = 0, ["ns-id"] = nil, ["loading?"] = false} - local ctx_bar = context_bar_widget.create(canvas) + local ctx_bar = context_bar_widget.create(buf_id) local function ensure_ns() if (nil == state["ns-id"]) then - state["ns-id"] = canvas["create-namespace"](canvas, "eca-prompt-area") + state["ns-id"] = nvim.nvim_create_namespace("eca-prompt-area") else end return state["ns-id"] @@ -15,23 +15,17 @@ local function create(canvas) local function render(start_line) state["prompt-start-line"] = start_line local ns = ensure_ns() - local sep = separator_component.render({width = 50}) local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) local ctx_state = ctx_bar["get-state"]() - local has_contexts_3f = (#ctx_state.contexts > 0) - local lines = {sep.line} + local has_contexts_3f = (#ctx_state.items > 0) + local lines = {} if has_contexts_3f then local parts do local tbl_26_ = {} local i_27_ = 0 - for _, ctx in ipairs(ctx_state.contexts) do - local val_28_ - do - local ci = require("eca.ui.components.context-item") - local rendered = ci.render(ctx) - val_28_ = rendered.text - end + for _, item in ipairs(ctx_state.items) do + local val_28_ = item.text if (nil ~= val_28_) then i_27_ = (i_27_ + 1) tbl_26_[i_27_] = val_28_ @@ -44,39 +38,34 @@ local function create(canvas) else end table.insert(lines, (prefix.text .. state["prompt-text"])) - canvas["set-modifiable"](canvas, true) - canvas["set-lines"](canvas, start_line, -1, lines) - canvas["add-extmark"](canvas, ns, start_line, 0, {end_col = #sep.line, hl_group = "EcaSeparator"}) + nvim.nvim_buf_set_lines(buf_id, start_line, -1, false, lines) do local prompt_line_idx = ((start_line + #lines) - 1) - canvas["add-extmark"](canvas, ns, prompt_line_idx, 0, {end_col = #prefix.text, hl_group = prefix["hl-group"]}) + nvim.nvim_buf_set_extmark(buf_id, ns, prompt_line_idx, 0, {end_col = #prefix.text, hl_group = prefix["hl-group"]}) end if has_contexts_3f then - ctx_bar.render((start_line + 1)) - else - end - canvas["set-modifiable"](canvas, false) - if canvas["win-valid?"](canvas) then - local prompt_line = (start_line + #lines) - local col_pos = (#prefix.text + #state["prompt-text"]) - canvas["set-cursor"](canvas, prompt_line, col_pos) + ctx_bar.render(start_line) else end return #lines end local function get_text() - local total = canvas["line-count"](canvas) - local last_line_idx = (total - 1) - local lines = canvas["get-lines"](canvas, last_line_idx, total) - if (lines and (#lines > 0)) then - local last_line = lines[1] - local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) - local prefix_len = #prefix.text - if (#last_line >= prefix_len) then - return string.sub(last_line, (prefix_len + 1)) + local total = nvim.nvim_buf_line_count(buf_id) + local prompt_lines = nvim.nvim_buf_get_lines(buf_id, state["prompt-start-line"], total, false) + local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) + if (prompt_lines and (#prompt_lines > 0)) then + local first_line = prompt_lines[1] + local stripped + if vim.startswith(first_line, prefix.text) then + stripped = string.sub(first_line, (#prefix.text + 1)) else - return "" + stripped = first_line + end + local parts = {stripped} + for i = 2, #prompt_lines do + table.insert(parts, prompt_lines[i]) end + return table.concat(parts, "\n") else return nil end @@ -84,11 +73,9 @@ local function create(canvas) local function set_text(text) state["prompt-text"] = (text or "") local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) - local total = canvas["line-count"](canvas) + local total = nvim.nvim_buf_line_count(buf_id) local last_line_idx = (total - 1) - canvas["set-modifiable"](canvas, true) - canvas["set-lines"](canvas, last_line_idx, total, {(prefix.text .. state["prompt-text"])}) - return canvas["set-modifiable"](canvas, false) + return nvim.nvim_buf_set_lines(buf_id, last_line_idx, total, false, {(prefix.text .. state["prompt-text"])}) end local function clear() return set_text("") @@ -96,11 +83,9 @@ local function create(canvas) local function set_loading(bool) state["loading?"] = bool local prefix = prompt_prefix_component.render({["loading?"] = bool}) - local total = canvas["line-count"](canvas) + local total = nvim.nvim_buf_line_count(buf_id) local last_line_idx = (total - 1) - canvas["set-modifiable"](canvas, true) - canvas["set-lines"](canvas, last_line_idx, total, {(prefix.text .. state["prompt-text"])}) - return canvas["set-modifiable"](canvas, false) + return nvim.nvim_buf_set_lines(buf_id, last_line_idx, total, false, {(prefix.text .. state["prompt-text"])}) end local function add_to_history(text) if (text and ("" ~= text)) then diff --git a/lua/eca/ui/widgets/status-bar.lua b/lua/eca/ui/widgets/status-bar.lua index a0d262e..9be82ea 100644 --- a/lua/eca/ui/widgets/status-bar.lua +++ b/lua/eca/ui/widgets/status-bar.lua @@ -1,63 +1,73 @@ -- [nfnl] fnl/eca/ui/widgets/status-bar.fnl -local usage_component = require("eca.ui.components.usage") -local function format_elapsed(ms) - if (nil == ms) then - return nil - else - local seconds = math.floor((ms / 1000)) - if (seconds >= 60) then - local mins = math.floor((seconds / 60)) - local secs = (seconds % 60) - return (tostring(mins) .. "m " .. tostring(secs) .. "s") +local function create(canvas, initial_sections) + local state + local _2_ + do + local t_1_ = initial_sections + if (nil ~= t_1_) then + t_1_ = t_1_.left else - return (tostring(seconds) .. "s") end + _2_ = t_1_ end -end -local function create(canvas, initial_state) - local state = vim.tbl_extend("force", {workspaces = {}, ["elapsed-ms"] = nil, ["tokens-in"] = 0, ["tokens-out"] = 0, ["max-tokens"] = 200000, cost = nil, ["init-progress"] = nil, ["pending-approvals?"] = false, ["trust?"] = false}, (initial_state or {})) - local function build_statusline() - local parts = {} - if (#state.workspaces > 0) then - table.insert(parts, ("%#EcaHeaderValue# " .. table.concat(state.workspaces, ", ") .. " ")) + local _5_ + do + local t_4_ = initial_sections + if (nil ~= t_4_) then + t_4_ = t_4_.center else end - table.insert(parts, "%=") - if state["init-progress"] then - table.insert(parts, ("%#EcaSpinner# \226\143\179 " .. state["init-progress"] .. " ")) + _5_ = t_4_ + end + local _8_ + do + local t_7_ = initial_sections + if (nil ~= t_7_) then + t_7_ = t_7_.right else end + _8_ = t_7_ + end + state = {left = (_2_ or {}), center = (_5_ or {}), right = (_8_ or {})} + local function build_section(items) + local parts do - local elapsed = format_elapsed(state["elapsed-ms"]) - if elapsed then - local icon - if state["pending-approvals?"] then - icon = "\240\159\154\167" + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs(items) do + local val_28_ = ("%#" .. (item["hl-group"] or "Normal") .. "# " .. item.text .. " ") + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ else - icon = "\226\143\177" end - table.insert(parts, ("%#EcaElapsed# " .. icon .. " " .. elapsed .. " ")) - else end - end - do - local usage_rendered = usage_component.render(state) - table.insert(parts, ("%#EcaUsage# " .. usage_rendered.text .. " ")) - end - if state["trust?"] then - table.insert(parts, "%#EcaTrustOn# \240\159\148\165 ") - else - table.insert(parts, "%#EcaTrustOff# \240\159\155\161\239\184\143 ") + parts = tbl_26_ end return table.concat(parts, "") end + local function build_statusline() + local left = build_section(state.left) + local center = build_section(state.center) + local right = build_section(state.right) + return (left .. "%=" .. center .. "%=" .. right) + end local function render() local statusline = build_statusline() return canvas["set-option"](canvas, "win", "statusline", statusline) end - local function update(new_state) - for k, v in pairs(new_state) do - state[k] = v + local function update(new_sections) + if new_sections.left then + state.left = new_sections.left + else + end + if new_sections.center then + state.center = new_sections.center + else + end + if new_sections.right then + state.right = new_sections.right + else end return render() end diff --git a/lua/eca/ui/widgets/tab-bar.lua b/lua/eca/ui/widgets/tab-bar.lua index e75941b..dbf33d0 100644 --- a/lua/eca/ui/widgets/tab-bar.lua +++ b/lua/eca/ui/widgets/tab-bar.lua @@ -5,29 +5,18 @@ local function create(canvas, initial_state) local parts = {} for _, tab in ipairs(state.tabs) do local is_active = (tab.id == state["active-id"]) - local hl_group - if tab["approval?"] then - hl_group = "EcaTabLoading" - elseif tab["loading?"] then - hl_group = "EcaTabLoading" - elseif is_active then - hl_group = "EcaTabActive" - else - hl_group = "EcaTabInactive" - end - local prefix - if tab["approval?"] then - prefix = "\240\159\154\167 " - elseif tab["loading?"] then - prefix = "\226\143\179 " - else - prefix = "" + local hl + local or_1_ = tab["hl-group"] + if not or_1_ then + if is_active then + or_1_ = "EcaTabActive" + else + or_1_ = "EcaTabInactive" + end end - local title = (tab.title or tostring(tab.id)) - table.insert(parts, ("%#" .. hl_group .. "# " .. prefix .. title .. " ")) + hl = or_1_ + table.insert(parts, ("%#" .. hl .. "# " .. (tab.label or tostring(tab.id)) .. " ")) end - table.insert(parts, "%#EcaButtonAccept# + ") - table.insert(parts, "%#EcaButtonReject# \195\151 ") return table.concat(parts, "%#Normal#\226\148\130") end local function render() @@ -45,12 +34,24 @@ local function create(canvas, initial_state) end end local function remove_tab(id) - local new_tabs = {} - for _, tab in ipairs(state.tabs) do - if (tab.id ~= id) then - table.insert(new_tabs, tab) - else + local new_tabs + do + local tbl_26_ = {} + local i_27_ = 0 + for _, tab in ipairs(state.tabs) do + local val_28_ + if (tab.id ~= id) then + val_28_ = tab + else + val_28_ = nil + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end end + new_tabs = tbl_26_ end state.tabs = new_tabs if (state["active-id"] == id) then @@ -68,10 +69,10 @@ local function create(canvas, initial_state) state["active-id"] = id return nil end - local function update_tab(id, new_state) + local function update_tab(id, new_data) for _, tab in ipairs(state.tabs) do if (tab.id == id) then - for k, v in pairs(new_state) do + for k, v in pairs(new_data) do tab[k] = v end else From 1b9177caf14fdbd82edb4a3e7341949268feab7e Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 15 May 2026 14:19:42 -0300 Subject: [PATCH 4/8] wip commiting current state --- fnl/eca/api.fnl | 62 ++++++-- fnl/eca/commands.fnl | 2 +- fnl/eca/ui/builder.fnl | 98 +++++++++--- fnl/eca/ui/components/bar-items.fnl | 30 ++++ fnl/eca/ui/components/prompt-prefix.fnl | 2 +- fnl/eca/ui/highlights.fnl | 1 + fnl/eca/ui/widgets/footer-bar.fnl | 62 ++------ fnl/eca/ui/widgets/header-bar.fnl | 20 +-- fnl/eca/ui/widgets/message-list.fnl | 156 +++++++++++++++++-- fnl/eca/ui/widgets/prompt-area.fnl | 148 +++++++++++++----- lua/eca/api.lua | 58 ++++++- lua/eca/commands.lua | 2 +- lua/eca/ui/builder.lua | 175 +++++++++++++++------ lua/eca/ui/components/bar-items.lua | 48 ++++++ lua/eca/ui/components/bar.lua | 48 ++++++ lua/eca/ui/highlights.lua | 2 +- lua/eca/ui/widgets/footer-bar.lua | 91 +++-------- lua/eca/ui/widgets/header-bar.lua | 39 +---- lua/eca/ui/widgets/message-list.lua | 193 +++++++++++++++++++++--- lua/eca/ui/widgets/prompt-area.lua | 146 ++++++++++++++---- 20 files changed, 1026 insertions(+), 357 deletions(-) create mode 100644 fnl/eca/ui/components/bar-items.fnl create mode 100644 lua/eca/ui/components/bar-items.lua create mode 100644 lua/eca/ui/components/bar.lua diff --git a/fnl/eca/api.fnl b/fnl/eca/api.fnl index 4b14120..3056ea2 100644 --- a/fnl/eca/api.fnl +++ b/fnl/eca/api.fnl @@ -30,6 +30,7 @@ (let [builder (require :eca.ui.builder) chat-ui (builder.create-chat-ui {:on-submit (or plugin-opts.on-submit self.default-on-submit) + :on-stop (or plugin-opts.on-stop self.default-on-stop) :opts {:ui (or plugin-opts.ui {}) :keymaps (or plugin-opts.keymaps [{:mode :i :lhs "" :rhs "EcaChatSubmit"} @@ -40,8 +41,8 @@ (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"}]))))) + (chat-ui.update-footer [{:value "Testing assoc-some in @shared."} + {:value "12.4K / 200K ($0.03)"}]))))) (fn self.chat-close [] (let [chat (self.resolve-chat)] @@ -65,26 +66,63 @@ (let [chat (self.resolve-chat)] (when chat (chat.update-header-item "model" model)))) -(fn self.chat-set-loading [bool] +(fn self.chat-set-status [text] + "Set status indicator. nil to hide." (let [chat (self.resolve-chat)] - (when chat (chat.set-loading bool)))) + (when chat (chat.set-status text)))) (fn self.default-on-submit [text] (let [chat (self.resolve-chat)] (when chat + ;; Show user message (chat.append-message {:id (tostring (os.time)) :content text :prefix "> "}) + ;; Set loading + status (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)))) + (chat.set-status "Generating") + ;; Simulate streaming + (let [reply-id (.. "reply-" (tostring (os.time))) + full-text (.. "You said: " text "\n\n(This is a mock streaming response)") + chunks [] + chunk-size 3] + (var i 1) + (while (<= i (length full-text)) + (let [end-idx (math.min (+ i chunk-size -1) (length full-text))] + (table.insert chunks (string.sub full-text i end-idx)) + (set i (+ end-idx 1)))) + (vim.defer_fn + (fn [] + (when (chat.is-open?) + (chat.append-message + {:id reply-id :content "" :streaming? true}) + (var accumulated "") + (var delay 0) + (each [_ chunk (ipairs chunks)] + (set delay (+ delay 50)) + (let [content-at-send (.. accumulated chunk)] + (set accumulated content-at-send) + (vim.defer_fn + (fn [] + (when (chat.is-open?) + (chat.update-message reply-id content-at-send))) + delay))) + (vim.defer_fn + (fn [] + (when (chat.is-open?) + (chat.finish-streaming reply-id) + (chat.set-loading false) + (chat.set-status nil))) + (+ delay 100)))) + 300))))) + +(fn self.default-on-stop [] + "Default stop handler — stops streaming and clears loading state." + (let [chat (self.resolve-chat)] + (when chat + (chat.set-loading false) + (chat.set-status nil)))) (fn self.set-plugin-opts [opts] (set plugin-opts (or opts {}))) diff --git a/fnl/eca/commands.fnl b/fnl/eca/commands.fnl index 703edf6..bed80fd 100644 --- a/fnl/eca/commands.fnl +++ b/fnl/eca/commands.fnl @@ -30,7 +30,7 @@ {:desc "Submit current prompt"}) (nvim.nvim_create_user_command "EcaChatStop" - (fn [] (api.chat-set-loading false)) + (fn [] (api.chat-set-status nil)) {:desc "Stop current ECA response"}) (nvim.nvim_create_user_command "EcaChatSetModel" diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl index 5d5abfd..4c71bd3 100644 --- a/fnl/eca/ui/builder.fnl +++ b/fnl/eca/ui/builder.fnl @@ -9,12 +9,30 @@ ;; ── Buffer/window setup ───────────────────────────────── +(fn disable-statusline-plugins [] + "Try to disable known statusline plugins for eca-chat filetype." + ;; Lualine + (let [(ok lualine) (pcall require :lualine)] + (when ok + (let [config (lualine.get_config) + disabled (or config.options.disabled_filetypes {})] + (when (not disabled.statusline) + (tset disabled :statusline [])) + (var found false) + (each [_ ft (ipairs disabled.statusline)] + (when (= ft "eca-chat") (set found true))) + (when (not found) + (table.insert disabled.statusline "eca-chat")) + (tset config.options :disabled_filetypes disabled) + (lualine.setup config))))) + (fn setup-chat-buffer [buf] (nvim.nvim_buf_set_name buf "ECA Chat") (nvim.nvim_set_option_value :buftype "nofile" {:buf buf}) (nvim.nvim_set_option_value :bufhidden "hide" {:buf buf}) (nvim.nvim_set_option_value :swapfile false {:buf buf}) - (nvim.nvim_set_option_value :filetype "eca-chat" {:buf buf})) + (nvim.nvim_set_option_value :filetype "eca-chat" {:buf buf}) + (disable-statusline-plugins)) (fn setup-chat-window [win] (nvim.nvim_set_option_value :number false {:win win}) @@ -103,7 +121,7 @@ ;; ── Main entry ────────────────────────────────────────── -(fn create-chat-ui [{: on-submit : opts}] +(fn create-chat-ui [{: on-submit : on-stop : opts}] (let [ui-config (or opts.ui {}) config {:width (or ui-config.width 0.4) :position (or ui-config.position :right) @@ -126,11 +144,21 @@ (nvim.nvim_win_is_valid win-id))) (fn with-internal-edit [f] - (when guard (guard.set-internal true)) - (f) - (when guard - (guard.set-internal false) - (guard.update-expected-count))) + ;; Save cursor position, do the write, restore cursor + (let [saved-cursor (when (and win-id (nvim.nvim_win_is_valid win-id)) + (nvim.nvim_win_get_cursor win-id))] + (when guard (guard.set-internal true)) + (f) + (when guard + (guard.set-internal false) + (guard.update-expected-count)) + ;; Restore cursor if it was saved and window still valid + (when (and saved-cursor win-id (nvim.nvim_win_is_valid win-id)) + (let [total (nvim.nvim_buf_line_count buf-id) + ;; Clamp cursor to valid range + line (math.min (. saved-cursor 1) total) + col (. saved-cursor 2)] + (pcall nvim.nvim_win_set_cursor win-id [line col]))))) (fn focus-prompt [] (when (and win-id (nvim.nvim_win_is_valid win-id)) @@ -159,12 +187,17 @@ (fn submit-prompt [] (when (is-open?) - (let [text (widgets.prompt.get-text)] - (when (and text (not= "" text)) - (widgets.prompt.add-to-history text) - (with-internal-edit (fn [] (widgets.prompt.clear))) - (focus-prompt) - (when on-submit (on-submit text)))))) + (let [prompt-state (widgets.prompt.get-state)] + (if prompt-state.loading? + ;; During loading, Enter/submit triggers stop + (when on-stop (on-stop)) + ;; Normal: submit text + (let [text (widgets.prompt.get-text)] + (when (and text (not= "" text)) + (widgets.prompt.add-to-history text) + (with-internal-edit (fn [] (widgets.prompt.clear))) + (focus-prompt) + (when on-submit (on-submit text)))))))) ;; Open @@ -180,13 +213,20 @@ ;; Create widgets (set widgets.header (header-bar-widget.create buf-id win-id state.header-items)) - (set widgets.messages (message-list-widget.create buf-id)) + (set widgets.messages (message-list-widget.create buf-id + {:wrap-write with-internal-edit + :on-line-inserted + (fn [] + ;; Prompt physically moved down, update its tracked position + (let [s (widgets.prompt.get-state)] + (set s.prompt-start-line (+ s.prompt-start-line 1))))})) (when state.welcome (widgets.messages.set-welcome {:lines [state.welcome ""] :highlights [{:line-idx 0 :hl-group :EcaWelcome :col-start 0 :col-end (length state.welcome)}]})) - (set widgets.prompt (prompt-area-widget.create buf-id)) + (set widgets.prompt (prompt-area-widget.create buf-id + {:wrap-write with-internal-edit})) (set widgets.footer (footer-bar-widget.create buf-id win-id state.footer-items)) ;; Keymaps @@ -231,6 +271,14 @@ (let [end-line (widgets.messages.get-end-line)] (widgets.prompt.render end-line)))))) + (fn finish-streaming [id] + (when (is-open?) + (with-internal-edit + (fn [] + (widgets.messages.finish-streaming id) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) + (fn clear-messages [] (when (is-open?) (with-internal-edit @@ -283,15 +331,29 @@ (when (= 0 (length msg-state.messages)) (with-internal-edit (fn [] (render-all))))))) + (fn set-status [text] + "Set status indicator. text=nil to hide." + (when (is-open?) + (with-internal-edit + (fn [] + (widgets.prompt.set-status text) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) + (fn set-loading [bool] + "Toggle loading state. Shows ⏳ stop when loading, > when idle." (when (is-open?) - (with-internal-edit (fn [] (widgets.prompt.set-loading bool))))) + (with-internal-edit + (fn [] + (widgets.prompt.set-loading bool) + (let [end-line (widgets.messages.get-end-line)] + (widgets.prompt.render end-line)))))) {: open : close : toggle : is-open? : get-buf-id - : append-message : update-message : clear-messages + : append-message : update-message : finish-streaming : clear-messages : update-header : update-header-item : update-footer : update-footer-item : set-welcome - : submit-prompt : set-loading})) + : submit-prompt : set-status : set-loading})) {: create-chat-ui} diff --git a/fnl/eca/ui/components/bar-items.fnl b/fnl/eca/ui/components/bar-items.fnl new file mode 100644 index 0000000..cba769c --- /dev/null +++ b/fnl/eca/ui/components/bar-items.fnl @@ -0,0 +1,30 @@ +;; bar component — formats key-value items into a statusline/winbar string. +;; Stateless, pure function. Used by header-bar and footer-bar widgets. + +(fn render [{: items : hl-key : hl-value}] + "Render items into a statusline-format string. + items: [{: title : value}] — title is optional + hl-key: highlight group for titles (default EcaHeaderKey) + hl-value: highlight group for values (default EcaHeaderValue) + Layout: 1 item left, 2 items left+right, 3+ left+center+right. + Returns string with %#HlGroup# formatting." + (let [hk (or hl-key :EcaHeaderKey) + hv (or hl-value :EcaHeaderValue) + parts (icollect [_ item (ipairs (or items []))] + (if item.title + (.. "%#" hk "#" item.title "%#" hv "#:" item.value) + (.. "%#" hv "#" item.value))) + count (length parts)] + (case count + 0 "" + 1 (.. " " (. parts 1)) + 2 (.. " " (. parts 1) "%=" (. parts 2) " ") + _ (let [left (. parts 1) + right (. parts count) + center-parts [] + _ (for [i 2 (- count 1)] + (table.insert center-parts (. parts i))) + center (table.concat center-parts " ")] + (.. " " left "%=" center "%=" right " "))))) + +{: render} diff --git a/fnl/eca/ui/components/prompt-prefix.fnl b/fnl/eca/ui/components/prompt-prefix.fnl index cbcff73..ff9b11e 100644 --- a/fnl/eca/ui/components/prompt-prefix.fnl +++ b/fnl/eca/ui/components/prompt-prefix.fnl @@ -1,4 +1,4 @@ -;; prompt-prefix component — renders "> " or "⏳ " based on loading state. +;; prompt-prefix component — renders "> " or "⏳" based on loading state. ;; Stateless, pure function. (fn render [{: loading?}] diff --git a/fnl/eca/ui/highlights.fnl b/fnl/eca/ui/highlights.fnl index df66e13..a722007 100644 --- a/fnl/eca/ui/highlights.fnl +++ b/fnl/eca/ui/highlights.fnl @@ -31,6 +31,7 @@ :EcaSpinner {:link "WarningMsg"} :EcaButtonAccept {:link "DiagnosticOk"} :EcaButtonReject {:link "DiagnosticError"} + :EcaStopLabel {:link "Underlined"} :EcaTabActive {:link "TabLineSel"} :EcaTabInactive {:link "TabLine"} :EcaTabLoading {:link "WarningMsg"}}) diff --git a/fnl/eca/ui/widgets/footer-bar.fnl b/fnl/eca/ui/widgets/footer-bar.fnl index b9687c3..8195d38 100644 --- a/fnl/eca/ui/widgets/footer-bar.fnl +++ b/fnl/eca/ui/widgets/footer-bar.fnl @@ -1,71 +1,39 @@ ;; footer-bar widget — statusline footer. -;; laststatus=2: per-window statusline. -;; laststatus=3: sets global statusline on focus, restores on blur. +;; Applies our statusline when chat is focused. +;; Other plugins handle statusline for non-chat windows naturally. (local nvim vim.api) +(local bar (require :eca.ui.components.bar-items)) (fn create [buf-id win-id initial-items] (var items (or initial-items [])) - (var saved-statusline nil) - - (fn build-statusline-str [] - (let [parts (icollect [_ item (ipairs items)] - (if item.title - (.. "%#EcaHeaderKey#" item.title "%#EcaHeaderValue#:" item.value) - (.. "%#EcaHeaderValue#" item.value))) - count (length parts)] - (case count - 0 "" - 1 (.. " " (. parts 1)) - 2 (.. " " (. parts 1) "%=" (. parts 2) " ") - _ (let [left (. parts 1) - right (. parts count) - center-parts [] - _ (for [i 2 (- count 1)] - (table.insert center-parts (. parts i))) - center (table.concat center-parts " ")] - (.. " " left "%=" center "%=" right " "))))) + (var active false) (fn is-global? [] (= 3 (nvim.nvim_get_option_value :laststatus {}))) - (fn apply-statusline [] - (let [str (build-statusline-str)] + (fn apply [] + (let [str (bar.render {:items items})] (if (is-global?) (nvim.nvim_set_option_value :statusline str {}) (when (and win-id (nvim.nvim_win_is_valid win-id)) (nvim.nvim_set_option_value :statusline str {:win win-id}))))) - (fn restore-statusline [] - (when (and (is-global?) saved-statusline) - (nvim.nvim_set_option_value :statusline saved-statusline {}))) - (fn render [] - (apply-statusline) + (set active true) + (apply) 0) - ;; Save original global statusline and manage focus - (set saved-statusline (nvim.nvim_get_option_value :statusline {})) - - (nvim.nvim_create_autocmd :BufEnter - {:buffer buf-id - :callback (fn [] - ;; Defer to run AFTER statusline plugins have set theirs - (vim.defer_fn (fn [] (apply-statusline)) 10))}) - - (nvim.nvim_create_autocmd :BufLeave - {:buffer buf-id - :callback (fn [] (restore-statusline))}) - - (nvim.nvim_create_autocmd :OptionSet - {:pattern :laststatus - :callback (fn [] - (when (nvim.nvim_buf_is_valid buf-id) - (apply-statusline)))}) + ;; Re-apply on focus (needed for global mode after statusline plugin overwrites) + (nvim.nvim_create_autocmd :WinEnter + {:callback (fn [] + (when (and active (nvim.nvim_buf_is_valid buf-id)) + (when (= (nvim.nvim_get_current_buf) buf-id) + (vim.defer_fn (fn [] (apply)) 10))))}) (fn update [new-items] (set items new-items) - (render)) + (when active (apply))) (fn get-state [] items) diff --git a/fnl/eca/ui/widgets/header-bar.fnl b/fnl/eca/ui/widgets/header-bar.fnl index 213548c..9776356 100644 --- a/fnl/eca/ui/widgets/header-bar.fnl +++ b/fnl/eca/ui/widgets/header-bar.fnl @@ -1,28 +1,14 @@ ;; header-bar widget — fixed header using winbar. (local nvim vim.api) +(local bar (require :eca.ui.components.bar-items)) (fn create [buf-id win-id initial-items] (var items (or initial-items [])) - (fn build-winbar [] - (let [parts (icollect [_ item (ipairs items)] - (.. "%#EcaHeaderKey#" item.title "%#EcaHeaderValue#:" item.value)) - count (length parts)] - (case count - 0 "" - 1 (.. " " (. parts 1)) - 2 (.. " " (. parts 1) "%=" (. parts 2) " ") - _ (let [left (. parts 1) - right (. parts count) - center-parts [] - _ (for [i 2 (- count 1)] - (table.insert center-parts (. parts i))) - center (table.concat center-parts " ")] - (.. " " left "%=" center "%=" right " "))))) - (fn render [] - (nvim.nvim_set_option_value :winbar (build-winbar) {:win win-id}) + (nvim.nvim_set_option_value :winbar + (bar.render {:items items}) {:win win-id}) (nvim.nvim_buf_set_lines buf-id 0 1 false [""]) 1) diff --git a/fnl/eca/ui/widgets/message-list.fnl b/fnl/eca/ui/widgets/message-list.fnl index 2660aa4..53b7087 100644 --- a/fnl/eca/ui/widgets/message-list.fnl +++ b/fnl/eca/ui/widgets/message-list.fnl @@ -1,10 +1,28 @@ ;; message-list widget — renders text blocks in the buffer. +;; Supports streaming via nvim_buf_set_text (no line shifting). (local nvim vim.api) (local message-component (require :eca.ui.components.message)) -(fn create [buf-id] - (local state {:messages [] :ns-id nil :end-line 0 :start-line 0 :welcome-lines nil}) +(fn create [buf-id ?opts] + (local wrap-write (or (?. ?opts :wrap-write) (fn [f] (f)))) + ;; Called when streaming inserts a new line (so prompt can adjust) + (local on-line-inserted (or (?. ?opts :on-line-inserted) (fn [] nil))) + + (local state {:messages [] + :ns-id nil + :end-line 0 + :start-line 0 + :welcome-lines nil + ;; Streaming state + :streaming-id nil + :streaming-queue "" + :streaming-displayed "" + :streaming-timer nil + :streaming-line nil ;; current line index being streamed to + :streaming-col nil ;; current col in that line + :streaming-chars-per-tick 2 + :streaming-tick-ms 20}) (fn ensure-ns [] (when (= nil state.ns-id) @@ -24,6 +42,64 @@ (apply-highlights start-line rendered.highlights) (length rendered.lines))) + (fn find-message [id] + (var found nil) + (each [_ msg (ipairs state.messages)] + (when (and (not found) (= msg.id id)) + (set found msg))) + found) + + ;; ── Streaming ───────────────────────────────────────── + + (fn stream-append-char [char] + "Append a single character to the streaming position using set_text. + Handles newlines by inserting a new line." + (when (and state.streaming-line state.streaming-col) + (if (= char "\n") + ;; Newline: insert new line, prompt shifts down naturally + (do + (wrap-write + (fn [] + (let [next-line (+ state.streaming-line 1)] + (nvim.nvim_buf_set_lines buf-id next-line next-line false [""]) + (set state.end-line (+ state.end-line 1)) + (on-line-inserted)))) + (set state.streaming-line (+ state.streaming-line 1)) + (set state.streaming-col 0)) + ;; Regular char: append at current position + (do + (nvim.nvim_buf_set_text buf-id + state.streaming-line state.streaming-col + state.streaming-line state.streaming-col + [char]) + (set state.streaming-col (+ state.streaming-col (length char))))))) + + (fn stream-tick [] + "Process one tick: move chars from queue to buffer." + (when (> (length state.streaming-queue) 0) + (let [take (math.min state.streaming-chars-per-tick + (length state.streaming-queue))] + (for [i 1 take] + (let [char (string.sub state.streaming-queue i i)] + (stream-append-char char) + (set state.streaming-displayed + (.. state.streaming-displayed char)))) + (set state.streaming-queue + (string.sub state.streaming-queue (+ take 1))))) + ;; Continue or stop + (if (> (length state.streaming-queue) 0) + (set state.streaming-timer + (vim.defer_fn stream-tick state.streaming-tick-ms)) + (set state.streaming-timer nil))) + + (fn start-streaming-timer [] + (when (and (not state.streaming-timer) + (> (length state.streaming-queue) 0)) + (set state.streaming-timer + (vim.defer_fn stream-tick state.streaming-tick-ms)))) + + ;; ── Public API ──────────────────────────────────────── + (fn set-start-line [line] (set state.start-line line) (when (= state.end-line 0) @@ -43,31 +119,85 @@ (apply-highlights state.start-line (or state.welcome-lines.highlights [])) (set state.end-line (+ state.start-line (length state.welcome-lines.lines)))) (each [_ msg (ipairs state.messages)] - (let [lines-written (render-single-message msg state.end-line)] + (let [;; For streaming msg, render with displayed content + render-msg (if (= msg.id state.streaming-id) + (vim.tbl_extend :force msg {:content state.streaming-displayed}) + msg) + lines-written (render-single-message render-msg state.end-line)] (set state.end-line (+ state.end-line lines-written))))))) (fn append-message [msg] (table.insert state.messages msg) - (if (= 1 (length state.messages)) - (render) - (let [lines-written (render-single-message msg state.end-line)] - (set state.end-line (+ state.end-line lines-written))))) + (if msg.streaming? + ;; Streaming: create an empty line, stream chars into it + (do + (set state.streaming-id msg.id) + (set state.streaming-displayed "") + (set state.streaming-queue (or msg.content "")) + ;; Insert one empty line for the streaming message + trailing blank + (wrap-write + (fn [] + (nvim.nvim_buf_set_lines buf-id state.end-line state.end-line false ["" ""]) + (set state.streaming-line state.end-line) + (set state.streaming-col 0) + (set state.end-line (+ state.end-line 2)))) + (start-streaming-timer)) + ;; Non-streaming: render immediately + (if (= 1 (length state.messages)) + (render) + (let [lines-written (render-single-message msg state.end-line)] + (set state.end-line (+ state.end-line lines-written)))))) (fn update-message [id new-content] - (let [found (accumulate [f false _ msg (ipairs state.messages)] - (if (= msg.id id) - (do (tset msg :content new-content) true) f))] - (when found (render)))) + (let [msg (find-message id)] + (when msg + (tset msg :content new-content) + (if (= id state.streaming-id) + ;; Queue only NEW characters + (let [already (+ (length state.streaming-displayed) + (length state.streaming-queue)) + new-chars (when (> (length new-content) already) + (string.sub new-content (+ already 1)))] + (when (and new-chars (> (length new-chars) 0)) + (set state.streaming-queue (.. state.streaming-queue new-chars)) + (start-streaming-timer))) + ;; Non-streaming: re-render + (render))))) + + (fn finish-streaming [id] + (when (= id state.streaming-id) + ;; Stop timer + (when state.streaming-timer + (set state.streaming-timer nil)) + ;; Flush remaining queue char by char (fast, no timer) + (when (> (length state.streaming-queue) 0) + (for [i 1 (length state.streaming-queue)] + (let [char (string.sub state.streaming-queue i i)] + (stream-append-char char))) + (set state.streaming-displayed + (.. state.streaming-displayed state.streaming-queue)) + (set state.streaming-queue "")) + ;; Clear streaming state + (set state.streaming-id nil) + (set state.streaming-line nil) + (set state.streaming-col nil))) (fn clear [] (set state.messages []) (set state.end-line state.start-line) + (when state.streaming-timer + (set state.streaming-timer nil)) + (set state.streaming-id nil) + (set state.streaming-queue "") + (set state.streaming-displayed "") + (set state.streaming-line nil) + (set state.streaming-col nil) (render)) (fn get-state [] state) (fn get-end-line [] state.end-line) - {: render : append-message : update-message : clear - : get-state : get-end-line : set-start-line : set-welcome}) + {: render : append-message : update-message : finish-streaming + : clear : get-state : get-end-line : set-start-line : set-welcome}) {: create} diff --git a/fnl/eca/ui/widgets/prompt-area.fnl b/fnl/eca/ui/widgets/prompt-area.fnl index 4656755..c7c75fe 100644 --- a/fnl/eca/ui/widgets/prompt-area.fnl +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -1,12 +1,25 @@ -;; prompt-area widget — context bar + prompt input. +;; prompt-area widget — status indicator + prompt input. +;; When loading: shows "⏳ stop" (non-editable). +;; When idle: shows "> " (editable). (local nvim vim.api) (local prompt-prefix-component (require :eca.ui.components.prompt-prefix)) (local context-bar-widget (require :eca.ui.widgets.context-bar)) -(fn create [buf-id] - (local state {:loading? false :prompt-text "" :history [] :history-idx 0 - :prompt-start-line 0 :ns-id nil}) +(fn create [buf-id ?opts] + (local wrap-write (or (?. ?opts :wrap-write) (fn [f] (f)))) + (local state {:prompt-text "" + :loading? false + :history [] + :history-idx 0 + :prompt-start-line 0 + :ns-id nil + ;; Status indicator (virtual text) + :status-text nil + :status-timer nil + :status-dots 0 + :status-extmark-id nil}) + (local ctx-bar (context-bar-widget.create buf-id)) (fn ensure-ns [] @@ -14,57 +27,123 @@ (set state.ns-id (nvim.nvim_create_namespace "eca-prompt-area"))) state.ns-id) + (fn update-status-virt-text [] + "Update status virtual text above the prompt line." + (let [ns (ensure-ns)] + (when state.status-extmark-id + (pcall nvim.nvim_buf_del_extmark buf-id ns state.status-extmark-id) + (set state.status-extmark-id nil)) + (when state.status-text + (let [dots (string.rep "." (+ (% state.status-dots 3) 1)) + status-str (.. state.status-text dots)] + (set state.status-extmark-id + (nvim.nvim_buf_set_extmark buf-id ns state.prompt-start-line 0 + {:virt_lines_above true + :virt_lines [[[status-str :EcaSpinner]]]})))))) + (fn render [start-line] + "Render: context bar + prompt line. + When loading: '⏳ stop'. When idle: '> '. + Status is virtual text above prompt." (set state.prompt-start-line start-line) (let [ns (ensure-ns) prefix (prompt-prefix-component.render {:loading? state.loading?}) ctx-state (ctx-bar.get-state) has-contexts? (> (length ctx-state.items) 0) lines []] + ;; Context bar (when has-contexts? (let [parts (icollect [_ item (ipairs ctx-state.items)] item.text)] (table.insert lines (table.concat parts " ")))) - (table.insert lines (.. prefix.text state.prompt-text)) + ;; Prompt line + (if state.loading? + (table.insert lines (.. prefix.text "stop")) + (table.insert lines (.. prefix.text state.prompt-text))) + + ;; Write (nvim.nvim_buf_set_lines buf-id start-line -1 false lines) - (let [prompt-line-idx (- (+ start-line (length lines)) 1)] - (nvim.nvim_buf_set_extmark buf-id ns prompt-line-idx 0 - {:end_col (length prefix.text) :hl_group prefix.hl-group})) + + ;; Highlight context (when has-contexts? (ctx-bar.render start-line)) + + ;; Prompt line index + (let [prompt-line-idx (- (+ start-line (length lines)) 1)] + (set state.prompt-start-line prompt-line-idx) + ;; Highlight prefix + (nvim.nvim_buf_set_extmark buf-id ns prompt-line-idx 0 + {:end_col (length prefix.text) + :hl_group prefix.hl-group}) + ;; When loading, underline "stop" to look clickable + (when state.loading? + (nvim.nvim_buf_set_extmark buf-id ns prompt-line-idx (length prefix.text) + {:end_col (+ (length prefix.text) 4) + :hl_group :EcaStopLabel}))) + + ;; Status virtual text + (update-status-virt-text) + (length lines))) + ;; ── Status indicator ────────────────────────────────── + + (fn animate-dots [] + (set state.status-dots (+ state.status-dots 1)) + (when (and state.status-text (nvim.nvim_buf_is_valid buf-id)) + (update-status-virt-text))) + + (fn set-status [text] + "Set status text. nil to hide." + (if text + (do + (set state.status-text text) + (set state.status-dots 0) + (when (not state.status-timer) + (fn tick [] + (when state.status-text + (animate-dots) + (set state.status-timer + (vim.defer_fn tick 400)))) + (set state.status-timer (vim.defer_fn tick 400)))) + (do + (set state.status-text nil) + (set state.status-timer nil) + (update-status-virt-text)))) + + ;; ── Loading ─────────────────────────────────────────── + + (fn set-loading [bool] + "Toggle loading state. Changes prefix and shows/hides stop label." + (set state.loading? bool)) + + ;; ── Text management ─────────────────────────────────── + (fn get-text [] - (let [total (nvim.nvim_buf_line_count buf-id) - prompt-lines (nvim.nvim_buf_get_lines buf-id state.prompt-start-line total false) - prefix (prompt-prefix-component.render {:loading? state.loading?})] - (when (and prompt-lines (> (length prompt-lines) 0)) - (let [first-line (. prompt-lines 1) - stripped (if (vim.startswith first-line prefix.text) - (string.sub first-line (+ (length prefix.text) 1)) - first-line) - parts [stripped]] - (for [i 2 (length prompt-lines)] - (table.insert parts (. prompt-lines i))) - (table.concat parts "\n"))))) + (when (not state.loading?) + (let [total (nvim.nvim_buf_line_count buf-id) + prompt-lines (nvim.nvim_buf_get_lines buf-id state.prompt-start-line total false) + prefix (prompt-prefix-component.render {:loading? false})] + (when (and prompt-lines (> (length prompt-lines) 0)) + (let [first-line (. prompt-lines 1) + stripped (if (vim.startswith first-line prefix.text) + (string.sub first-line (+ (length prefix.text) 1)) + first-line) + parts [stripped]] + (for [i 2 (length prompt-lines)] + (table.insert parts (. prompt-lines i))) + (table.concat parts "\n")))))) (fn set-text [text] (set state.prompt-text (or text "")) - (let [prefix (prompt-prefix-component.render {:loading? state.loading?}) - total (nvim.nvim_buf_line_count buf-id) - last-line-idx (- total 1)] - (nvim.nvim_buf_set_lines buf-id last-line-idx total false - [(.. prefix.text state.prompt-text)]))) + (when (not state.loading?) + (let [prefix (prompt-prefix-component.render {:loading? false}) + total (nvim.nvim_buf_line_count buf-id) + last-line-idx (- total 1)] + (nvim.nvim_buf_set_lines buf-id last-line-idx total false + [(.. prefix.text state.prompt-text)])))) (fn clear [] (set-text "")) - (fn set-loading [bool] - (set state.loading? bool) - (let [prefix (prompt-prefix-component.render {:loading? bool}) - total (nvim.nvim_buf_line_count buf-id) - last-line-idx (- total 1)] - (nvim.nvim_buf_set_lines buf-id last-line-idx total false - [(.. prefix.text state.prompt-text)]))) - (fn add-to-history [text] (when (and text (not= "" text)) (table.insert state.history text) @@ -86,7 +165,8 @@ (fn remove-context [name] (ctx-bar.remove name)) (fn get-state [] state) - {: render : get-text : set-text : clear : set-loading + {: render : get-text : set-text : clear + : set-status : set-loading : add-to-history : history-prev : history-next : add-context : remove-context : get-state}) diff --git a/lua/eca/api.lua b/lua/eca/api.lua index b1c40e1..4f873a5 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -30,12 +30,12 @@ self["chat-open"] = function() local existing = self["resolve-chat"]() if not (existing and existing["is-open?"]()) then local builder = require("eca.ui.builder") - local chat_ui = builder["create-chat-ui"]({["on-submit"] = (plugin_opts["on-submit"] or self["default-on-submit"]), opts = {ui = (plugin_opts.ui or {}), keymaps = (plugin_opts.keymaps or {{mode = "i", lhs = "", rhs = "EcaChatSubmit"}, {mode = "n", lhs = "", rhs = "EcaChatSubmit"}})}}) + local chat_ui = builder["create-chat-ui"]({["on-submit"] = (plugin_opts["on-submit"] or self["default-on-submit"]), ["on-stop"] = (plugin_opts["on-stop"] or self["default-on-stop"]), opts = {ui = (plugin_opts.ui or {}), keymaps = (plugin_opts.keymaps or {{mode = "i", lhs = "", rhs = "EcaChatSubmit"}, {mode = "n", lhs = "", rhs = "EcaChatSubmit"}})}}) chat_ui.open() self["register-chat"](chat_ui) chat_ui["set-welcome"]("Welcome to ECA Chat") chat_ui["update-header"]({{title = "model", value = "claude"}, {title = "behavior", value = "agent"}}) - return chat_ui["update-footer"]({{value = "ECA Chat"}, {title = "tokens", value = "0/200K"}}) + return chat_ui["update-footer"]({{value = "Testing assoc-some in @shared."}, {value = "12.4K / 200K ($0.03)"}}) else return nil end @@ -80,10 +80,10 @@ self["chat-set-model"] = function(model) return nil end end -self["chat-set-loading"] = function(bool) +self["chat-set-status"] = function(text) local chat = self["resolve-chat"]() if chat then - return chat["set-loading"](bool) + return chat["set-status"](text) else return nil end @@ -93,15 +93,59 @@ self["default-on-submit"] = function(text) if chat then chat["append-message"]({id = tostring(os.time()), content = text, prefix = "> "}) chat["set-loading"](true) + chat["set-status"]("Generating") + local reply_id = ("reply-" .. tostring(os.time())) + local full_text = ("You said: " .. text .. "\n\n(This is a mock streaming response)") + local chunks = {} + local chunk_size = 3 + local i = 1 + while (i <= #full_text) do + local end_idx = math.min((i + chunk_size + -1), #full_text) + table.insert(chunks, string.sub(full_text, i, end_idx)) + i = (end_idx + 1) + end local function _11_() if chat["is-open?"]() then - chat["append-message"]({id = ("reply-" .. tostring(os.time())), content = ("You said: " .. text .. "\n\n(This is a mock response)")}) - return chat["set-loading"](false) + chat["append-message"]({id = reply_id, content = "", ["streaming?"] = true}) + local accumulated = "" + local delay = 0 + for _, chunk in ipairs(chunks) do + delay = (delay + 50) + local content_at_send = (accumulated .. chunk) + accumulated = content_at_send + local function _12_() + if chat["is-open?"]() then + return chat["update-message"](reply_id, content_at_send) + else + return nil + end + end + vim.defer_fn(_12_, delay) + end + local function _14_() + if chat["is-open?"]() then + chat["finish-streaming"](reply_id) + chat["set-loading"](false) + return chat["set-status"](nil) + else + return nil + end + end + return vim.defer_fn(_14_, (delay + 100)) else return nil end end - return vim.defer_fn(_11_, 500) + return vim.defer_fn(_11_, 300) + else + return nil + end +end +self["default-on-stop"] = function() + local chat = self["resolve-chat"]() + if chat then + chat["set-loading"](false) + return chat["set-status"](nil) else return nil end diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 3e51668..df6efd9 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -26,7 +26,7 @@ local function setup(api) end nvim.nvim_create_user_command("EcaChatSubmit", _6_, {desc = "Submit current prompt"}) local function _7_() - return api["chat-set-loading"](false) + return api["chat-set-status"](nil) end nvim.nvim_create_user_command("EcaChatStop", _7_, {desc = "Stop current ECA response"}) local function _8_(cmd) diff --git a/lua/eca/ui/builder.lua b/lua/eca/ui/builder.lua index 5416546..312ca3f 100644 --- a/lua/eca/ui/builder.lua +++ b/lua/eca/ui/builder.lua @@ -5,12 +5,39 @@ local header_bar_widget = require("eca.ui.widgets.header-bar") local message_list_widget = require("eca.ui.widgets.message-list") local prompt_area_widget = require("eca.ui.widgets.prompt-area") local footer_bar_widget = require("eca.ui.widgets.footer-bar") +local function disable_statusline_plugins() + local ok, lualine = pcall(require, "lualine") + if ok then + local config = lualine.get_config() + local disabled = (config.options.disabled_filetypes or {}) + if not disabled.statusline then + disabled["statusline"] = {} + else + end + local found = false + for _, ft in ipairs(disabled.statusline) do + if (ft == "eca-chat") then + found = true + else + end + end + if not found then + table.insert(disabled.statusline, "eca-chat") + else + end + config.options["disabled_filetypes"] = disabled + return lualine.setup(config) + else + return nil + end +end local function setup_chat_buffer(buf) nvim.nvim_buf_set_name(buf, "ECA Chat") nvim.nvim_set_option_value("buftype", "nofile", {buf = buf}) nvim.nvim_set_option_value("bufhidden", "hide", {buf = buf}) nvim.nvim_set_option_value("swapfile", false, {buf = buf}) - return nvim.nvim_set_option_value("filetype", "eca-chat", {buf = buf}) + nvim.nvim_set_option_value("filetype", "eca-chat", {buf = buf}) + return disable_statusline_plugins() end local function setup_chat_window(win) nvim.nvim_set_option_value("number", false, {win = win}) @@ -109,11 +136,11 @@ local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_p end local function on_lines_handler(_, buf, changedtick, first_line, last_line, new_last_line) if not internal_edit then - local _let_10_ = get_prompt_state() - local prompt_start_line = _let_10_["prompt-start-line"] - local loading_3f = _let_10_["loading?"] + local _let_14_ = get_prompt_state() + local prompt_start_line = _let_14_["prompt-start-line"] + local loading_3f = _let_14_["loading?"] local prefix = get_prefix(loading_3f) - local function _11_() + local function _15_() if nvim.nvim_buf_is_valid(buf) then local current_count = nvim.nvim_buf_line_count(buf) local prompt_idx = math.min(prompt_start_line, (current_count - 1)) @@ -130,7 +157,7 @@ local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_p return nil end end - return vim.schedule(_11_) + return vim.schedule(_15_) else return nil end @@ -145,9 +172,10 @@ local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_p end return {["set-internal"] = set_internal, ["update-expected-count"] = update_expected_count} end -local function create_chat_ui(_15_) - local on_submit = _15_["on-submit"] - local opts = _15_.opts +local function create_chat_ui(_19_) + local on_submit = _19_["on-submit"] + local on_stop = _19_["on-stop"] + local opts = _19_.opts local ui_config = (opts.ui or {}) local config = {width = (ui_config.width or 0.4), position = (ui_config.position or "right"), keymaps = (opts.keymaps or {})} local state = {["header-items"] = {}, ["footer-items"] = {}, welcome = nil} @@ -159,6 +187,12 @@ local function create_chat_ui(_15_) return ((nil ~= buf_id) and nvim.nvim_buf_is_valid(buf_id) and (nil ~= win_id) and nvim.nvim_win_is_valid(win_id)) end local function with_internal_edit(f) + local saved_cursor + if (win_id and nvim.nvim_win_is_valid(win_id)) then + saved_cursor = nvim.nvim_win_get_cursor(win_id) + else + saved_cursor = nil + end if guard then guard["set-internal"](true) else @@ -166,7 +200,14 @@ local function create_chat_ui(_15_) f() if guard then guard["set-internal"](false) - return guard["update-expected-count"]() + guard["update-expected-count"]() + else + end + if (saved_cursor and win_id and nvim.nvim_win_is_valid(win_id)) then + local total = nvim.nvim_buf_line_count(buf_id) + local line = math.min(saved_cursor[1], total) + local col = saved_cursor[2] + return pcall(nvim.nvim_win_set_cursor, win_id, {line, col}) else return nil end @@ -182,7 +223,7 @@ local function create_chat_ui(_15_) end end local function render_all() - local function _19_() + local function _25_() do local header_lines = widgets.header.render() widgets.messages["set-start-line"](header_lines) @@ -196,7 +237,7 @@ local function create_chat_ui(_15_) return nil end end - return with_internal_edit(_19_) + return with_internal_edit(_25_) end local function close() if is_open_3f() then @@ -209,21 +250,30 @@ local function create_chat_ui(_15_) end local function submit_prompt() if is_open_3f() then - local text = widgets.prompt["get-text"]() - if (text and ("" ~= text)) then - widgets.prompt["add-to-history"](text) - local function _22_() - return widgets.prompt.clear() - end - with_internal_edit(_22_) - focus_prompt() - if on_submit then - return on_submit(text) + local prompt_state = widgets.prompt["get-state"]() + if prompt_state["loading?"] then + if on_stop then + return on_stop() else return nil end else - return nil + local text = widgets.prompt["get-text"]() + if (text and ("" ~= text)) then + widgets.prompt["add-to-history"](text) + local function _29_() + return widgets.prompt.clear() + end + with_internal_edit(_29_) + focus_prompt() + if on_submit then + return on_submit(text) + else + return nil + end + else + return nil + end end else return nil @@ -240,12 +290,17 @@ local function create_chat_ui(_15_) setup_chat_buffer(buf_id) setup_chat_window(win_id) widgets.header = header_bar_widget.create(buf_id, win_id, state["header-items"]) - widgets.messages = message_list_widget.create(buf_id) + local function _34_() + local s = widgets.prompt["get-state"]() + s["prompt-start-line"] = (s["prompt-start-line"] + 1) + return nil + end + widgets.messages = message_list_widget.create(buf_id, {["wrap-write"] = with_internal_edit, ["on-line-inserted"] = _34_}) if state.welcome then widgets.messages["set-welcome"]({lines = {state.welcome, ""}, highlights = {{["line-idx"] = 0, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #state.welcome}}}) else end - widgets.prompt = prompt_area_widget.create(buf_id) + widgets.prompt = prompt_area_widget.create(buf_id, {["wrap-write"] = with_internal_edit}) widgets.footer = footer_bar_widget.create(buf_id, win_id, state["footer-items"]) for _, km in ipairs(config.keymaps) do vim.keymap.set(km.mode, km.lhs, km.rhs, {buffer = buf_id, noremap = true, silent = true}) @@ -253,11 +308,11 @@ local function create_chat_ui(_15_) nvim.nvim_buf_set_lines(buf_id, 0, -1, false, {""}) render_all() focus_prompt() - local function _27_() + local function _36_() local s = widgets.prompt["get-state"]() return {["prompt-start-line"] = (s["prompt-start-line"] or 0), ["loading?"] = s["loading?"]} end - guard = setup_edit_guard(buf_id, render_all, _27_, focus_prompt) + guard = setup_edit_guard(buf_id, render_all, _36_, focus_prompt) return nil else return nil @@ -275,12 +330,12 @@ local function create_chat_ui(_15_) end local function append_message(msg) if is_open_3f() then - local function _30_() + local function _39_() widgets.messages["append-message"](msg) local end_line = widgets.messages["get-end-line"]() return widgets.prompt.render(end_line) end - with_internal_edit(_30_) + with_internal_edit(_39_) return focus_prompt() else return nil @@ -288,24 +343,36 @@ local function create_chat_ui(_15_) end local function update_message(id, content) if is_open_3f() then - local function _32_() + local function _41_() widgets.messages["update-message"](id, content) local end_line = widgets.messages["get-end-line"]() return widgets.prompt.render(end_line) end - return with_internal_edit(_32_) + return with_internal_edit(_41_) + else + return nil + end + end + local function finish_streaming(id) + if is_open_3f() then + local function _43_() + widgets.messages["finish-streaming"](id) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + end + return with_internal_edit(_43_) else return nil end end local function clear_messages() if is_open_3f() then - local function _34_() + local function _45_() widgets.messages.clear() local end_line = widgets.messages["get-end-line"]() return widgets.prompt.render(end_line) end - return with_internal_edit(_34_) + return with_internal_edit(_45_) else return nil end @@ -313,10 +380,10 @@ local function create_chat_ui(_15_) local function update_header(new_items) state["header-items"] = new_items if is_open_3f() then - local function _36_() + local function _47_() return widgets.header.update(new_items) end - return with_internal_edit(_36_) + return with_internal_edit(_47_) else return nil end @@ -335,10 +402,10 @@ local function create_chat_ui(_15_) else end if is_open_3f() then - local function _40_() + local function _51_() return widgets.header.update(state["header-items"]) end - return with_internal_edit(_40_) + return with_internal_edit(_51_) else return nil end @@ -346,10 +413,10 @@ local function create_chat_ui(_15_) local function update_footer(new_items) state["footer-items"] = new_items if is_open_3f() then - local function _42_() + local function _53_() return widgets.footer.update(new_items) end - return with_internal_edit(_42_) + return with_internal_edit(_53_) else return nil end @@ -368,10 +435,10 @@ local function create_chat_ui(_15_) else end if is_open_3f() then - local function _46_() + local function _57_() return widgets.footer.update(state["footer-items"]) end - return with_internal_edit(_46_) + return with_internal_edit(_57_) else return nil end @@ -382,10 +449,10 @@ local function create_chat_ui(_15_) widgets.messages["set-welcome"]({lines = {text, ""}, highlights = {{["line-idx"] = 0, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #text}}}) local msg_state = widgets.messages["get-state"]() if (0 == #msg_state.messages) then - local function _48_() + local function _59_() return render_all() end - return with_internal_edit(_48_) + return with_internal_edit(_59_) else return nil end @@ -393,16 +460,30 @@ local function create_chat_ui(_15_) return nil end end + local function set_status(text) + if is_open_3f() then + local function _62_() + widgets.prompt["set-status"](text) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) + end + return with_internal_edit(_62_) + else + return nil + end + end local function set_loading(bool) if is_open_3f() then - local function _51_() - return widgets.prompt["set-loading"](bool) + local function _64_() + widgets.prompt["set-loading"](bool) + local end_line = widgets.messages["get-end-line"]() + return widgets.prompt.render(end_line) end - return with_internal_edit(_51_) + return with_internal_edit(_64_) else return nil end end - return {open = open, close = close, toggle = toggle, ["is-open?"] = is_open_3f, ["get-buf-id"] = get_buf_id, ["append-message"] = append_message, ["update-message"] = update_message, ["clear-messages"] = clear_messages, ["update-header"] = update_header, ["update-header-item"] = update_header_item, ["update-footer"] = update_footer, ["update-footer-item"] = update_footer_item, ["set-welcome"] = set_welcome, ["submit-prompt"] = submit_prompt, ["set-loading"] = set_loading} + return {open = open, close = close, toggle = toggle, ["is-open?"] = is_open_3f, ["get-buf-id"] = get_buf_id, ["append-message"] = append_message, ["update-message"] = update_message, ["finish-streaming"] = finish_streaming, ["clear-messages"] = clear_messages, ["update-header"] = update_header, ["update-header-item"] = update_header_item, ["update-footer"] = update_footer, ["update-footer-item"] = update_footer_item, ["set-welcome"] = set_welcome, ["submit-prompt"] = submit_prompt, ["set-status"] = set_status, ["set-loading"] = set_loading} end return {["create-chat-ui"] = create_chat_ui} diff --git a/lua/eca/ui/components/bar-items.lua b/lua/eca/ui/components/bar-items.lua new file mode 100644 index 0000000..ae72ca7 --- /dev/null +++ b/lua/eca/ui/components/bar-items.lua @@ -0,0 +1,48 @@ +-- [nfnl] fnl/eca/ui/components/bar-items.fnl +local function render(_1_) + local items = _1_.items + local hl_key = _1_["hl-key"] + local hl_value = _1_["hl-value"] + local hk = (hl_key or "EcaHeaderKey") + local hv = (hl_value or "EcaHeaderValue") + local parts + do + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs((items or {})) do + local val_28_ + if item.title then + val_28_ = ("%#" .. hk .. "#" .. item.title .. "%#" .. hv .. "#:" .. item.value) + else + val_28_ = ("%#" .. hv .. "#" .. item.value) + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + parts = tbl_26_ + end + local count = #parts + if (count == 0) then + return "" + elseif (count == 1) then + return (" " .. parts[1]) + elseif (count == 2) then + return (" " .. parts[1] .. "%=" .. parts[2] .. " ") + else + local _ = count + local left = parts[1] + local right = parts[count] + local center_parts = {} + local _0 + for i = 2, (count - 1) do + table.insert(center_parts, parts[i]) + end + _0 = nil + local center = table.concat(center_parts, " ") + return (" " .. left .. "%=" .. center .. "%=" .. right .. " ") + end +end +return {render = render} diff --git a/lua/eca/ui/components/bar.lua b/lua/eca/ui/components/bar.lua new file mode 100644 index 0000000..06b2105 --- /dev/null +++ b/lua/eca/ui/components/bar.lua @@ -0,0 +1,48 @@ +-- [nfnl] fnl/eca/ui/components/bar.fnl +local function render(_1_) + local items = _1_.items + local hl_key = _1_["hl-key"] + local hl_value = _1_["hl-value"] + local hk = (hl_key or "EcaHeaderKey") + local hv = (hl_value or "EcaHeaderValue") + local parts + do + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs((items or {})) do + local val_28_ + if item.title then + val_28_ = ("%#" .. hk .. "#" .. item.title .. "%#" .. hv .. "#:" .. item.value) + else + val_28_ = ("%#" .. hv .. "#" .. item.value) + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + parts = tbl_26_ + end + local count = #parts + if (count == 0) then + return "" + elseif (count == 1) then + return (" " .. parts[1]) + elseif (count == 2) then + return (" " .. parts[1] .. "%=" .. parts[2] .. " ") + else + local _ = count + local left = parts[1] + local right = parts[count] + local center_parts = {} + local _0 + for i = 2, (count - 1) do + table.insert(center_parts, parts[i]) + end + _0 = nil + local center = table.concat(center_parts, " ") + return (" " .. left .. "%=" .. center .. "%=" .. right .. " ") + end +end +return {render = render} diff --git a/lua/eca/ui/highlights.lua b/lua/eca/ui/highlights.lua index bab5712..9225a7c 100644 --- a/lua/eca/ui/highlights.lua +++ b/lua/eca/ui/highlights.lua @@ -1,6 +1,6 @@ -- [nfnl] fnl/eca/ui/highlights.fnl local nvim = vim.api -local groups = {EcaUser = {link = "Title"}, EcaAssistant = {link = "Normal"}, EcaSystem = {link = "Comment"}, EcaWelcome = {link = "Comment"}, EcaMessagePrefix = {link = "Title"}, EcaSeparator = {link = "WinSeparator"}, EcaPromptPrefix = {link = "Statement"}, EcaPromptPrefixLoading = {link = "WarningMsg"}, EcaToolCallPending = {link = "WarningMsg"}, EcaToolCallSuccess = {link = "DiagnosticOk"}, EcaToolCallError = {link = "DiagnosticError"}, EcaToolCallApproval = {link = "DiagnosticWarn"}, EcaExpandableIcon = {link = "Comment"}, EcaExpandableLabel = {link = "Bold"}, EcaContextFile = {link = "Underlined"}, EcaContextDir = {link = "Underlined"}, EcaContextRepoMap = {link = "Special"}, EcaContextCursor = {link = "Comment"}, EcaContextMcp = {link = "String"}, EcaHeaderKey = {link = "Comment"}, EcaHeaderValue = {link = "Bold"}, EcaUsage = {link = "Comment"}, EcaElapsed = {link = "Comment"}, EcaTrustOn = {link = "DiagnosticError"}, EcaTrustOff = {link = "Comment"}, EcaSpinner = {link = "WarningMsg"}, EcaButtonAccept = {link = "DiagnosticOk"}, EcaButtonReject = {link = "DiagnosticError"}, EcaTabActive = {link = "TabLineSel"}, EcaTabInactive = {link = "TabLine"}, EcaTabLoading = {link = "WarningMsg"}} +local groups = {EcaUser = {link = "Title"}, EcaAssistant = {link = "Normal"}, EcaSystem = {link = "Comment"}, EcaWelcome = {link = "Comment"}, EcaMessagePrefix = {link = "Title"}, EcaSeparator = {link = "WinSeparator"}, EcaPromptPrefix = {link = "Statement"}, EcaPromptPrefixLoading = {link = "WarningMsg"}, EcaToolCallPending = {link = "WarningMsg"}, EcaToolCallSuccess = {link = "DiagnosticOk"}, EcaToolCallError = {link = "DiagnosticError"}, EcaToolCallApproval = {link = "DiagnosticWarn"}, EcaExpandableIcon = {link = "Comment"}, EcaExpandableLabel = {link = "Bold"}, EcaContextFile = {link = "Underlined"}, EcaContextDir = {link = "Underlined"}, EcaContextRepoMap = {link = "Special"}, EcaContextCursor = {link = "Comment"}, EcaContextMcp = {link = "String"}, EcaHeaderKey = {link = "Comment"}, EcaHeaderValue = {link = "Bold"}, EcaUsage = {link = "Comment"}, EcaElapsed = {link = "Comment"}, EcaTrustOn = {link = "DiagnosticError"}, EcaTrustOff = {link = "Comment"}, EcaSpinner = {link = "WarningMsg"}, EcaButtonAccept = {link = "DiagnosticOk"}, EcaButtonReject = {link = "DiagnosticError"}, EcaStopLabel = {link = "Underlined"}, EcaTabActive = {link = "TabLineSel"}, EcaTabInactive = {link = "TabLine"}, EcaTabLoading = {link = "WarningMsg"}} local function setup() for group, opts in pairs(groups) do nvim.nvim_set_hl(0, group, opts) diff --git a/lua/eca/ui/widgets/footer-bar.lua b/lua/eca/ui/widgets/footer-bar.lua index 10aa088..7ea6f5a 100644 --- a/lua/eca/ui/widgets/footer-bar.lua +++ b/lua/eca/ui/widgets/footer-bar.lua @@ -1,54 +1,14 @@ -- [nfnl] fnl/eca/ui/widgets/footer-bar.fnl local nvim = vim.api +local bar = require("eca.ui.components.bar-items") local function create(buf_id, win_id, initial_items) local items = (initial_items or {}) - local saved_statusline = nil - local function build_statusline_str() - local parts - do - local tbl_26_ = {} - local i_27_ = 0 - for _, item in ipairs(items) do - local val_28_ - if item.title then - val_28_ = ("%#EcaHeaderKey#" .. item.title .. "%#EcaHeaderValue#:" .. item.value) - else - val_28_ = ("%#EcaHeaderValue#" .. item.value) - end - if (nil ~= val_28_) then - i_27_ = (i_27_ + 1) - tbl_26_[i_27_] = val_28_ - else - end - end - parts = tbl_26_ - end - local count = #parts - if (count == 0) then - return "" - elseif (count == 1) then - return (" " .. parts[1]) - elseif (count == 2) then - return (" " .. parts[1] .. "%=" .. parts[2] .. " ") - else - local _ = count - local left = parts[1] - local right = parts[count] - local center_parts = {} - local _0 - for i = 2, (count - 1) do - table.insert(center_parts, parts[i]) - end - _0 = nil - local center = table.concat(center_parts, " ") - return (" " .. left .. "%=" .. center .. "%=" .. right .. " ") - end - end + local active = false local function is_global_3f() return (3 == nvim.nvim_get_option_value("laststatus", {})) end - local function apply_statusline() - local str = build_statusline_str() + local function apply() + local str = bar.render({items = items}) if is_global_3f() then return nvim.nvim_set_option_value("statusline", str, {}) else @@ -59,40 +19,33 @@ local function create(buf_id, win_id, initial_items) end end end - local function restore_statusline() - if (is_global_3f() and saved_statusline) then - return nvim.nvim_set_option_value("statusline", saved_statusline, {}) - else - return nil - end - end local function render() - apply_statusline() + active = true + apply() return 0 end - saved_statusline = nvim.nvim_get_option_value("statusline", {}) - local function _7_() - local function _8_() - return apply_statusline() - end - return vim.defer_fn(_8_, 10) - end - nvim.nvim_create_autocmd("BufEnter", {buffer = buf_id, callback = _7_}) - local function _9_() - return restore_statusline() - end - nvim.nvim_create_autocmd("BufLeave", {buffer = buf_id, callback = _9_}) - local function _10_() - if nvim.nvim_buf_is_valid(buf_id) then - return apply_statusline() + local function _3_() + if (active and nvim.nvim_buf_is_valid(buf_id)) then + if (nvim.nvim_get_current_buf() == buf_id) then + local function _4_() + return apply() + end + return vim.defer_fn(_4_, 10) + else + return nil + end else return nil end end - nvim.nvim_create_autocmd("OptionSet", {pattern = "laststatus", callback = _10_}) + nvim.nvim_create_autocmd("WinEnter", {callback = _3_}) local function update(new_items) items = new_items - return render() + if active then + return apply() + else + return nil + end end local function get_state() return items diff --git a/lua/eca/ui/widgets/header-bar.lua b/lua/eca/ui/widgets/header-bar.lua index 1cd8fad..2fbc8b3 100644 --- a/lua/eca/ui/widgets/header-bar.lua +++ b/lua/eca/ui/widgets/header-bar.lua @@ -1,45 +1,10 @@ -- [nfnl] fnl/eca/ui/widgets/header-bar.fnl local nvim = vim.api +local bar = require("eca.ui.components.bar-items") local function create(buf_id, win_id, initial_items) local items = (initial_items or {}) - local function build_winbar() - local parts - do - local tbl_26_ = {} - local i_27_ = 0 - for _, item in ipairs(items) do - local val_28_ = ("%#EcaHeaderKey#" .. item.title .. "%#EcaHeaderValue#:" .. item.value) - if (nil ~= val_28_) then - i_27_ = (i_27_ + 1) - tbl_26_[i_27_] = val_28_ - else - end - end - parts = tbl_26_ - end - local count = #parts - if (count == 0) then - return "" - elseif (count == 1) then - return (" " .. parts[1]) - elseif (count == 2) then - return (" " .. parts[1] .. "%=" .. parts[2] .. " ") - else - local _ = count - local left = parts[1] - local right = parts[count] - local center_parts = {} - local _0 - for i = 2, (count - 1) do - table.insert(center_parts, parts[i]) - end - _0 = nil - local center = table.concat(center_parts, " ") - return (" " .. left .. "%=" .. center .. "%=" .. right .. " ") - end - end local function render() - nvim.nvim_set_option_value("winbar", build_winbar(), {win = win_id}) + nvim.nvim_set_option_value("winbar", bar.render({items = items}), {win = win_id}) nvim.nvim_buf_set_lines(buf_id, 0, 1, false, {""}) return 1 end diff --git a/lua/eca/ui/widgets/message-list.lua b/lua/eca/ui/widgets/message-list.lua index d65790f..93e33b0 100644 --- a/lua/eca/ui/widgets/message-list.lua +++ b/lua/eca/ui/widgets/message-list.lua @@ -1,8 +1,44 @@ -- [nfnl] fnl/eca/ui/widgets/message-list.fnl local nvim = vim.api local message_component = require("eca.ui.components.message") -local function create(buf_id) - local state = {messages = {}, ["ns-id"] = nil, ["end-line"] = 0, ["start-line"] = 0, ["welcome-lines"] = nil} +local function create(buf_id, _3fopts) + local wrap_write + local _2_ + do + local t_1_ = _3fopts + if (nil ~= t_1_) then + t_1_ = t_1_["wrap-write"] + else + end + _2_ = t_1_ + end + local or_4_ = _2_ + if not or_4_ then + local function _5_(f) + return f() + end + or_4_ = _5_ + end + wrap_write = or_4_ + local on_line_inserted + local _7_ + do + local t_6_ = _3fopts + if (nil ~= t_6_) then + t_6_ = t_6_["on-line-inserted"] + else + end + _7_ = t_6_ + end + local or_9_ = _7_ + if not or_9_ then + local function _10_() + return nil + end + or_9_ = _10_ + end + on_line_inserted = or_9_ + local state = {messages = {}, ["ns-id"] = nil, ["end-line"] = 0, ["start-line"] = 0, ["welcome-lines"] = nil, ["streaming-id"] = nil, ["streaming-queue"] = "", ["streaming-displayed"] = "", ["streaming-timer"] = nil, ["streaming-line"] = nil, ["streaming-col"] = nil, ["streaming-chars-per-tick"] = 2, ["streaming-tick-ms"] = 20} local function ensure_ns() if (nil == state["ns-id"]) then state["ns-id"] = nvim.nvim_create_namespace("eca-messages") @@ -23,6 +59,65 @@ local function create(buf_id) apply_highlights(start_line, rendered.highlights) return #rendered.lines end + local function find_message(id) + local found = nil + for _, msg in ipairs(state.messages) do + if (not found and (msg.id == id)) then + found = msg + else + end + end + return found + end + local function stream_append_char(char) + if (state["streaming-line"] and state["streaming-col"]) then + if (char == "\n") then + local function _13_() + local next_line = (state["streaming-line"] + 1) + nvim.nvim_buf_set_lines(buf_id, next_line, next_line, false, {""}) + state["end-line"] = (state["end-line"] + 1) + return on_line_inserted() + end + wrap_write(_13_) + state["streaming-line"] = (state["streaming-line"] + 1) + state["streaming-col"] = 0 + return nil + else + nvim.nvim_buf_set_text(buf_id, state["streaming-line"], state["streaming-col"], state["streaming-line"], state["streaming-col"], {char}) + state["streaming-col"] = (state["streaming-col"] + #char) + return nil + end + else + return nil + end + end + local function stream_tick() + if (#state["streaming-queue"] > 0) then + local take = math.min(state["streaming-chars-per-tick"], #state["streaming-queue"]) + for i = 1, take do + local char = string.sub(state["streaming-queue"], i, i) + stream_append_char(char) + state["streaming-displayed"] = (state["streaming-displayed"] .. char) + end + state["streaming-queue"] = string.sub(state["streaming-queue"], (take + 1)) + else + end + if (#state["streaming-queue"] > 0) then + state["streaming-timer"] = vim.defer_fn(stream_tick, state["streaming-tick-ms"]) + return nil + else + state["streaming-timer"] = nil + return nil + end + end + local function start_streaming_timer() + if (not state["streaming-timer"] and (#state["streaming-queue"] > 0)) then + state["streaming-timer"] = vim.defer_fn(stream_tick, state["streaming-tick-ms"]) + return nil + else + return nil + end + end local function set_start_line(line) state["start-line"] = line if (state["end-line"] == 0) then @@ -51,7 +146,13 @@ local function create(buf_id) end else for _, msg in ipairs(state.messages) do - local lines_written = render_single_message(msg, state["end-line"]) + local render_msg + if (msg.id == state["streaming-id"]) then + render_msg = vim.tbl_extend("force", msg, {content = state["streaming-displayed"]}) + else + render_msg = msg + end + local lines_written = render_single_message(render_msg, state["end-line"]) state["end-line"] = (state["end-line"] + lines_written) end return nil @@ -59,30 +160,73 @@ local function create(buf_id) end local function append_message(msg) table.insert(state.messages, msg) - if (1 == #state.messages) then - return render() + if msg["streaming?"] then + state["streaming-id"] = msg.id + state["streaming-displayed"] = "" + state["streaming-queue"] = (msg.content or "") + local function _23_() + nvim.nvim_buf_set_lines(buf_id, state["end-line"], state["end-line"], false, {"", ""}) + state["streaming-line"] = state["end-line"] + state["streaming-col"] = 0 + state["end-line"] = (state["end-line"] + 2) + return nil + end + wrap_write(_23_) + return start_streaming_timer() else - local lines_written = render_single_message(msg, state["end-line"]) - state["end-line"] = (state["end-line"] + lines_written) - return nil + if (1 == #state.messages) then + return render() + else + local lines_written = render_single_message(msg, state["end-line"]) + state["end-line"] = (state["end-line"] + lines_written) + return nil + end end end local function update_message(id, new_content) - local found - do - local f = false - for _, msg in ipairs(state.messages) do - if (msg.id == id) then - msg["content"] = new_content - f = true + local msg = find_message(id) + if msg then + msg["content"] = new_content + if (id == state["streaming-id"]) then + local already = (#state["streaming-displayed"] + #state["streaming-queue"]) + local new_chars + if (#new_content > already) then + new_chars = string.sub(new_content, (already + 1)) + else + new_chars = nil + end + if (new_chars and (#new_chars > 0)) then + state["streaming-queue"] = (state["streaming-queue"] .. new_chars) + return start_streaming_timer() else - f = f + return nil end + else + return render() end - found = f + else + return nil end - if found then - return render() + end + local function finish_streaming(id) + if (id == state["streaming-id"]) then + if state["streaming-timer"] then + state["streaming-timer"] = nil + else + end + if (#state["streaming-queue"] > 0) then + for i = 1, #state["streaming-queue"] do + local char = string.sub(state["streaming-queue"], i, i) + stream_append_char(char) + end + state["streaming-displayed"] = (state["streaming-displayed"] .. state["streaming-queue"]) + state["streaming-queue"] = "" + else + end + state["streaming-id"] = nil + state["streaming-line"] = nil + state["streaming-col"] = nil + return nil else return nil end @@ -90,6 +234,15 @@ local function create(buf_id) local function clear() state.messages = {} state["end-line"] = state["start-line"] + if state["streaming-timer"] then + state["streaming-timer"] = nil + else + end + state["streaming-id"] = nil + state["streaming-queue"] = "" + state["streaming-displayed"] = "" + state["streaming-line"] = nil + state["streaming-col"] = nil return render() end local function get_state() @@ -98,6 +251,6 @@ local function create(buf_id) local function get_end_line() return state["end-line"] end - return {render = render, ["append-message"] = append_message, ["update-message"] = update_message, clear = clear, ["get-state"] = get_state, ["get-end-line"] = get_end_line, ["set-start-line"] = set_start_line, ["set-welcome"] = set_welcome} + return {render = render, ["append-message"] = append_message, ["update-message"] = update_message, ["finish-streaming"] = finish_streaming, clear = clear, ["get-state"] = get_state, ["get-end-line"] = get_end_line, ["set-start-line"] = set_start_line, ["set-welcome"] = set_welcome} end return {create = create} diff --git a/lua/eca/ui/widgets/prompt-area.lua b/lua/eca/ui/widgets/prompt-area.lua index 374992b..7207313 100644 --- a/lua/eca/ui/widgets/prompt-area.lua +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -2,8 +2,26 @@ local nvim = vim.api local prompt_prefix_component = require("eca.ui.components.prompt-prefix") local context_bar_widget = require("eca.ui.widgets.context-bar") -local function create(buf_id) - local state = {["prompt-text"] = "", history = {}, ["history-idx"] = 0, ["prompt-start-line"] = 0, ["ns-id"] = nil, ["loading?"] = false} +local function create(buf_id, _3fopts) + local wrap_write + local _2_ + do + local t_1_ = _3fopts + if (nil ~= t_1_) then + t_1_ = t_1_["wrap-write"] + else + end + _2_ = t_1_ + end + local or_4_ = _2_ + if not or_4_ then + local function _5_(f) + return f() + end + or_4_ = _5_ + end + wrap_write = or_4_ + local state = {["prompt-text"] = "", history = {}, ["history-idx"] = 0, ["prompt-start-line"] = 0, ["ns-id"] = nil, ["status-text"] = nil, ["status-timer"] = nil, ["status-dots"] = 0, ["status-extmark-id"] = nil, ["loading?"] = false} local ctx_bar = context_bar_widget.create(buf_id) local function ensure_ns() if (nil == state["ns-id"]) then @@ -12,6 +30,22 @@ local function create(buf_id) end return state["ns-id"] end + local function update_status_virt_text() + local ns = ensure_ns() + if state["status-extmark-id"] then + pcall(nvim.nvim_buf_del_extmark, buf_id, ns, state["status-extmark-id"]) + state["status-extmark-id"] = nil + else + end + if state["status-text"] then + local dots = string.rep(".", ((state["status-dots"] % 3) + 1)) + local status_str = (state["status-text"] .. dots) + state["status-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, state["prompt-start-line"], 0, {virt_lines_above = true, virt_lines = {{{status_str, "EcaSpinner"}}}}) + return nil + else + return nil + end + end local function render(start_line) state["prompt-start-line"] = start_line local ns = ensure_ns() @@ -37,56 +71,104 @@ local function create(buf_id) table.insert(lines, table.concat(parts, " ")) else end - table.insert(lines, (prefix.text .. state["prompt-text"])) + if state["loading?"] then + table.insert(lines, (prefix.text .. "stop")) + else + table.insert(lines, (prefix.text .. state["prompt-text"])) + end nvim.nvim_buf_set_lines(buf_id, start_line, -1, false, lines) + if has_contexts_3f then + ctx_bar.render(start_line) + else + end do local prompt_line_idx = ((start_line + #lines) - 1) + state["prompt-start-line"] = prompt_line_idx nvim.nvim_buf_set_extmark(buf_id, ns, prompt_line_idx, 0, {end_col = #prefix.text, hl_group = prefix["hl-group"]}) + if state["loading?"] then + nvim.nvim_buf_set_extmark(buf_id, ns, prompt_line_idx, #prefix.text, {end_col = (#prefix.text + 4), hl_group = "EcaStopLabel"}) + else + end end - if has_contexts_3f then - ctx_bar.render(start_line) + update_status_virt_text() + return #lines + end + local function animate_dots() + state["status-dots"] = (state["status-dots"] + 1) + if (state["status-text"] and nvim.nvim_buf_is_valid(buf_id)) then + return update_status_virt_text() else + return nil end - return #lines end - local function get_text() - local total = nvim.nvim_buf_line_count(buf_id) - local prompt_lines = nvim.nvim_buf_get_lines(buf_id, state["prompt-start-line"], total, false) - local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) - if (prompt_lines and (#prompt_lines > 0)) then - local first_line = prompt_lines[1] - local stripped - if vim.startswith(first_line, prefix.text) then - stripped = string.sub(first_line, (#prefix.text + 1)) + local function set_status(text) + if text then + state["status-text"] = text + state["status-dots"] = 0 + if not state["status-timer"] then + local function tick() + if state["status-text"] then + animate_dots() + state["status-timer"] = vim.defer_fn(tick, 400) + return nil + else + return nil + end + end + state["status-timer"] = vim.defer_fn(tick, 400) + return nil else - stripped = first_line + return nil end - local parts = {stripped} - for i = 2, #prompt_lines do - table.insert(parts, prompt_lines[i]) + else + state["status-text"] = nil + state["status-timer"] = nil + return update_status_virt_text() + end + end + local function set_loading(bool) + state["loading?"] = bool + return nil + end + local function get_text() + if not state["loading?"] then + local total = nvim.nvim_buf_line_count(buf_id) + local prompt_lines = nvim.nvim_buf_get_lines(buf_id, state["prompt-start-line"], total, false) + local prefix = prompt_prefix_component.render({["loading?"] = false}) + if (prompt_lines and (#prompt_lines > 0)) then + local first_line = prompt_lines[1] + local stripped + if vim.startswith(first_line, prefix.text) then + stripped = string.sub(first_line, (#prefix.text + 1)) + else + stripped = first_line + end + local parts = {stripped} + for i = 2, #prompt_lines do + table.insert(parts, prompt_lines[i]) + end + return table.concat(parts, "\n") + else + return nil end - return table.concat(parts, "\n") else return nil end end local function set_text(text) state["prompt-text"] = (text or "") - local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) - local total = nvim.nvim_buf_line_count(buf_id) - local last_line_idx = (total - 1) - return nvim.nvim_buf_set_lines(buf_id, last_line_idx, total, false, {(prefix.text .. state["prompt-text"])}) + if not state["loading?"] then + local prefix = prompt_prefix_component.render({["loading?"] = false}) + local total = nvim.nvim_buf_line_count(buf_id) + local last_line_idx = (total - 1) + return nvim.nvim_buf_set_lines(buf_id, last_line_idx, total, false, {(prefix.text .. state["prompt-text"])}) + else + return nil + end end local function clear() return set_text("") end - local function set_loading(bool) - state["loading?"] = bool - local prefix = prompt_prefix_component.render({["loading?"] = bool}) - local total = nvim.nvim_buf_line_count(buf_id) - local last_line_idx = (total - 1) - return nvim.nvim_buf_set_lines(buf_id, last_line_idx, total, false, {(prefix.text .. state["prompt-text"])}) - end local function add_to_history(text) if (text and ("" ~= text)) then table.insert(state.history, text) @@ -122,6 +204,6 @@ local function create(buf_id) local function get_state() return state end - return {render = render, ["get-text"] = get_text, ["set-text"] = set_text, clear = clear, ["set-loading"] = set_loading, ["add-to-history"] = add_to_history, ["history-prev"] = history_prev, ["history-next"] = history_next, ["add-context"] = add_context, ["remove-context"] = remove_context, ["get-state"] = get_state} + return {render = render, ["get-text"] = get_text, ["set-text"] = set_text, clear = clear, ["set-status"] = set_status, ["set-loading"] = set_loading, ["add-to-history"] = add_to_history, ["history-prev"] = history_prev, ["history-next"] = history_next, ["add-context"] = add_context, ["remove-context"] = remove_context, ["get-state"] = get_state} end return {create = create} From acdb35d8090a21f465dac1321527c63bf8fab0aa Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 15 May 2026 16:57:16 -0300 Subject: [PATCH 5/8] wip commiting current state --- fnl/eca/api.fnl | 26 ++- fnl/eca/commands.fnl | 2 +- fnl/eca/ui/builder.fnl | 254 +++++++++++++-------- fnl/eca/ui/components/message.fnl | 39 ++-- fnl/eca/ui/highlights.fnl | 1 + fnl/eca/ui/widgets/context-area.fnl | 80 +++++++ fnl/eca/ui/widgets/header-bar.fnl | 5 +- fnl/eca/ui/widgets/message-list.fnl | 68 +++--- fnl/eca/ui/widgets/prompt-area.fnl | 248 ++++++++++++++------ image.png | Bin 0 -> 162333 bytes lua/eca/api.lua | 35 ++- lua/eca/commands.lua | 2 +- lua/eca/ui/builder.lua | 340 +++++++++++++++++----------- lua/eca/ui/components/message.lua | 35 ++- lua/eca/ui/highlights.lua | 2 +- lua/eca/ui/widgets/context-area.lua | 120 ++++++++++ lua/eca/ui/widgets/header-bar.lua | 4 +- lua/eca/ui/widgets/message-list.lua | 54 +++-- lua/eca/ui/widgets/prompt-area.lua | 221 ++++++++++++------ 19 files changed, 1079 insertions(+), 457 deletions(-) create mode 100644 fnl/eca/ui/widgets/context-area.fnl create mode 100644 image.png create mode 100644 lua/eca/ui/widgets/context-area.lua diff --git a/fnl/eca/api.fnl b/fnl/eca/api.fnl index 3056ea2..d48ebe4 100644 --- a/fnl/eca/api.fnl +++ b/fnl/eca/api.fnl @@ -39,10 +39,15 @@ (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 "Testing assoc-some in @shared."} - {:value "12.4K / 200K ($0.03)"}]))))) + (chat-ui.update-header [{:title "model" :value "claude/opus-4.6"} + {:title "agent" :value "code"} + {:title "variant" :value "-"} + {:title "mcps" :value "1"}]) + (chat-ui.update-footer [{:value "~/dev/eca-nvim"} + {:value "⏱ 0s"} + {:value "0/200K ($0.00)"}]) + ;; Mock context + (chat-ui.add-context {:text "@cursor(README.md 3:1)" :hl-group :EcaContextCursor}))))) (fn self.chat-close [] (let [chat (self.resolve-chat)] @@ -66,6 +71,16 @@ (let [chat (self.resolve-chat)] (when chat (chat.update-header-item "model" model)))) +(fn self.chat-stop [] + "Stop everything: streaming, loading, status, steering." + (let [chat (self.resolve-chat)] + (when chat (chat.stop)))) + +(fn self.chat-cancel-steering [] + "Cancel queued steering only." + (let [chat (self.resolve-chat)] + (when chat (chat.cancel-steering)))) + (fn self.chat-set-status [text] "Set status indicator. nil to hide." (let [chat (self.resolve-chat)] @@ -78,7 +93,8 @@ (chat.append-message {:id (tostring (os.time)) :content text - :prefix "> "}) + :collapsed? true + :collapse-prefix "▸ "}) ;; Set loading + status (chat.set-loading true) (chat.set-status "Generating") diff --git a/fnl/eca/commands.fnl b/fnl/eca/commands.fnl index bed80fd..f00e519 100644 --- a/fnl/eca/commands.fnl +++ b/fnl/eca/commands.fnl @@ -30,7 +30,7 @@ {:desc "Submit current prompt"}) (nvim.nvim_create_user_command "EcaChatStop" - (fn [] (api.chat-set-status nil)) + (fn [] (api.chat-stop)) {:desc "Stop current ECA response"}) (nvim.nvim_create_user_command "EcaChatSetModel" diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl index 4c71bd3..ba2dcd7 100644 --- a/fnl/eca/ui/builder.fnl +++ b/fnl/eca/ui/builder.fnl @@ -4,6 +4,7 @@ (local highlights (require :eca.ui.highlights)) (local header-bar-widget (require :eca.ui.widgets.header-bar)) (local message-list-widget (require :eca.ui.widgets.message-list)) +(local context-area-widget (require :eca.ui.widgets.context-area)) (local prompt-area-widget (require :eca.ui.widgets.prompt-area)) (local footer-bar-widget (require :eca.ui.widgets.footer-bar)) @@ -52,69 +53,55 @@ (fn setup-edit-guard [buf-id render-all-fn get-prompt-state focus-prompt-fn] (var internal-edit false) - (var guard-ns nil) - (fn ensure-guard-ns [] - (when (= nil guard-ns) - (set guard-ns (nvim.nvim_create_namespace "eca-edit-guard"))) - guard-ns) - - (fn get-prefix [loading?] - (let [prompt-prefix (require :eca.ui.components.prompt-prefix)] - (. (prompt-prefix.render {:loading? loading?}) :text))) - - (fn salvage-user-text [buf prompt-start-line prefix] + (fn salvage-user-text [buf prompt-line] + "Read user text from the prompt line, stripping prefix. + If the line no longer starts with '> ', the prompt was deleted — return empty." (let [current-count (nvim.nvim_buf_line_count buf) - start (math.min prompt-start-line current-count) - prompt-lines (nvim.nvim_buf_get_lines buf start current-count false)] - (if (= 0 (length prompt-lines)) - [""] - (icollect [i line (ipairs prompt-lines)] - (if (= i 1) - (if (vim.startswith line prefix) - (string.sub line (+ (length prefix) 1)) - (line:gsub "^>%s*" "")) - line))))) - - (fn restore-with-user-text [buf prefix user-lines] + idx (math.min prompt-line (- current-count 1)) + lines (nvim.nvim_buf_get_lines buf idx (+ idx 1) false) + last-line (or (. lines 1) "")] + (if (vim.startswith last-line "> ") + [(string.sub last-line 3)] + [""]))) + + (fn restore-with-user-text [buf user-lines] (set internal-edit true) (render-all-fn) (let [new-count (nvim.nvim_buf_line_count buf) new-last-idx (- new-count 1) - restored-lines (icollect [i line (ipairs user-lines)] - (if (= i 1) (.. prefix line) line))] - (when (> (length restored-lines) 0) - (nvim.nvim_buf_set_lines buf new-last-idx new-count false restored-lines) - (let [ns (ensure-guard-ns)] + restored (icollect [i line (ipairs user-lines)] + (if (= i 1) (.. "> " line) line))] + (when (> (length restored) 0) + (nvim.nvim_buf_set_lines buf new-last-idx new-count false restored) + ;; Re-apply prefix highlight + (let [ns (nvim.nvim_create_namespace "eca-prompt-restore")] (nvim.nvim_buf_set_extmark buf ns new-last-idx 0 - {:end_col (length prefix) + {:end_col 2 :hl_group :EcaPromptPrefix})))) (set internal-edit false) - (when focus-prompt-fn - (focus-prompt-fn))) + (when focus-prompt-fn (focus-prompt-fn))) (fn on-lines-handler [_ buf changedtick first-line last-line new-last-line] (when (not internal-edit) - (let [{: prompt-start-line : loading?} (get-prompt-state) - prefix (get-prefix loading?)] - (vim.schedule - (fn [] - (when (nvim.nvim_buf_is_valid buf) - (let [current-count (nvim.nvim_buf_line_count buf) - prompt-idx (math.min prompt-start-line (- current-count 1)) - prompt-lines (nvim.nvim_buf_get_lines buf prompt-idx (+ prompt-idx 1) false) - prompt-line-text (or (. prompt-lines 1) "") - damaged? (or (< first-line prompt-start-line) - (not (vim.startswith prompt-line-text prefix)))] - (when damaged? - (let [user-lines (salvage-user-text buf prompt-start-line prefix)] - (restore-with-user-text buf prefix user-lines)))))))))) + (let [prompt-state (get-prompt-state) + prompt-line (or prompt-state.prompt-start-line 0) + lines-deleted? (> last-line new-last-line) + ;; Damaged if: + ;; 1. edit is before the prompt (chat history touched), OR + ;; 2. edit touches the prompt area AND lines were deleted (e.g. dd) + damaged? (or (< first-line prompt-line) + (and (<= first-line prompt-line) lines-deleted?))] + (when damaged? + (vim.schedule + (fn [] + (when (nvim.nvim_buf_is_valid buf) + (let [user-lines (salvage-user-text buf prompt-line)] + (restore-with-user-text buf user-lines))))))))) (nvim.nvim_buf_attach buf-id false {:on_lines on-lines-handler}) - (fn set-internal [bool] - (set internal-edit bool)) - + (fn set-internal [bool] (set internal-edit bool)) (fn update-expected-count [] nil) {: set-internal : update-expected-count}) @@ -130,12 +117,13 @@ ;; Mutable state (local state {:header-items [] :footer-items [] - :welcome nil}) + :welcome nil + :queued-prompt nil}) (var buf-id nil) (var win-id nil) (var guard nil) - (local widgets {:header nil :messages nil :prompt nil :footer nil}) + (local widgets {:header nil :messages nil :context nil :prompt nil :footer nil}) (fn is-open? [] (and (not= nil buf-id) @@ -144,21 +132,11 @@ (nvim.nvim_win_is_valid win-id))) (fn with-internal-edit [f] - ;; Save cursor position, do the write, restore cursor - (let [saved-cursor (when (and win-id (nvim.nvim_win_is_valid win-id)) - (nvim.nvim_win_get_cursor win-id))] - (when guard (guard.set-internal true)) - (f) - (when guard - (guard.set-internal false) - (guard.update-expected-count)) - ;; Restore cursor if it was saved and window still valid - (when (and saved-cursor win-id (nvim.nvim_win_is_valid win-id)) - (let [total (nvim.nvim_buf_line_count buf-id) - ;; Clamp cursor to valid range - line (math.min (. saved-cursor 1) total) - col (. saved-cursor 2)] - (pcall nvim.nvim_win_set_cursor win-id [line col]))))) + (when guard (guard.set-internal true)) + (f) + (when guard + (guard.set-internal false) + (guard.update-expected-count))) (fn focus-prompt [] (when (and win-id (nvim.nvim_win_is_valid win-id)) @@ -167,14 +145,59 @@ prompt-line (or prompt-state.prompt-start-line (- total 1))] (nvim.nvim_win_set_cursor win-id [(+ prompt-line 1) 2])))) + (fn make-separator [] + (let [win (vim.fn.bufwinid buf-id) + width (if (and win (not= win -1)) + (nvim.nvim_win_get_width win) + 40)] + (string.rep "─" width))) + + (fn render-prompt-area [] + "Re-render separator + context-area + prompt from current message end-line. + Clears everything after messages first to avoid stale lines." + (let [msg-end (widgets.messages.get-end-line) + ;; Save prompt text before clearing the buffer (safe on first render) + live-text (widgets.prompt.save-live-text) + ;; Build all lines to write at once: separator + context + prompt + sep (make-separator) + ctx-items (widgets.context.get-state) + has-ctx? (widgets.context.has-items?) + prompt-text-lines (vim.split (or live-text "") "\n" {:plain true}) + all-lines [sep]] + ;; Context line (if items exist) + (when has-ctx? + (let [parts (icollect [_ item (ipairs ctx-items.items)] item.text)] + (table.insert all-lines (table.concat parts " ")))) + ;; Prompt lines ("> " prefix on first line) + (let [idle-prefix "> "] + (table.insert all-lines (.. idle-prefix (or (. prompt-text-lines 1) ""))) + (for [i 2 (length prompt-text-lines)] + (table.insert all-lines (. prompt-text-lines i)))) + ;; Write everything in one shot — replaces from msg-end to end of buffer + (nvim.nvim_buf_set_lines buf-id msg-end -1 false all-lines) + ;; Update prompt internal state + (let [prompt-start (+ msg-end (if has-ctx? 2 1))] + (widgets.prompt.set-text-internal (or live-text "")) + ;; Separator highlight + (let [ns (nvim.nvim_create_namespace "eca-separator")] + (nvim.nvim_buf_clear_namespace buf-id ns 0 -1) + (pcall nvim.nvim_buf_set_extmark buf-id ns msg-end 0 + {:end_col (length sep) :hl_group :EcaSeparator})) + ;; Status anchors to separator + (widgets.prompt.set-status-anchor-line msg-end) + ;; Context highlight + (when has-ctx? + (widgets.context.render-highlights (+ msg-end 1))) + ;; Prompt render (just highlights + virt lines, buffer already written) + (widgets.prompt.render-highlights prompt-start)))) + (fn render-all [] (with-internal-edit (fn [] (let [header-lines (widgets.header.render)] (widgets.messages.set-start-line header-lines) (widgets.messages.render) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line))) + (render-prompt-area)) (when widgets.footer (widgets.footer.render))))) @@ -185,19 +208,42 @@ (nvim.nvim_win_close win-id true) (set win-id nil))) + (fn cancel-steering [] + "Cancel queued steering message but keep waiting for response." + (when state.queued-prompt + (set state.queued-prompt nil) + (when (is-open?) + (with-internal-edit + (fn [] + (widgets.prompt.set-steering nil) + (render-prompt-area)))))) + + (fn stop [] + "Stop everything: streaming, loading, status, steering." + (cancel-steering) + (when on-stop (on-stop))) + (fn submit-prompt [] (when (is-open?) - (let [prompt-state (widgets.prompt.get-state)] + (let [prompt-state (widgets.prompt.get-state) + text (widgets.prompt.get-text)] (if prompt-state.loading? - ;; During loading, Enter/submit triggers stop - (when on-stop (on-stop)) - ;; Normal: submit text - (let [text (widgets.prompt.get-text)] - (when (and text (not= "" text)) - (widgets.prompt.add-to-history text) - (with-internal-edit (fn [] (widgets.prompt.clear))) - (focus-prompt) - (when on-submit (on-submit text)))))))) + ;; During loading: always queue as steering + (when (and text (not= "" text)) + (set state.queued-prompt text) + (widgets.prompt.add-to-history text) + (with-internal-edit + (fn [] + (widgets.prompt.clear) + (widgets.prompt.set-steering text) + (render-prompt-area))) + (focus-prompt)) + ;; Normal: submit immediately + (when (and text (not= "" text)) + (widgets.prompt.add-to-history text) + (with-internal-edit (fn [] (widgets.prompt.clear))) + (focus-prompt) + (when on-submit (on-submit text))))))) ;; Open @@ -225,6 +271,7 @@ {:lines [state.welcome ""] :highlights [{:line-idx 0 :hl-group :EcaWelcome :col-start 0 :col-end (length state.welcome)}]})) + (set widgets.context (context-area-widget.create buf-id)) (set widgets.prompt (prompt-area-widget.create buf-id {:wrap-write with-internal-edit})) (set widgets.footer (footer-bar-widget.create buf-id win-id state.footer-items)) @@ -259,8 +306,7 @@ (with-internal-edit (fn [] (widgets.messages.append-message msg) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))) + (render-prompt-area))) (focus-prompt))) (fn update-message [id content] @@ -268,24 +314,21 @@ (with-internal-edit (fn [] (widgets.messages.update-message id content) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) + (render-prompt-area))))) (fn finish-streaming [id] (when (is-open?) (with-internal-edit (fn [] (widgets.messages.finish-streaming id) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) + (render-prompt-area))))) (fn clear-messages [] (when (is-open?) (with-internal-edit (fn [] (widgets.messages.clear) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) + (render-prompt-area))))) ;; State updates (fn update-header [new-items] @@ -332,28 +375,49 @@ (with-internal-edit (fn [] (render-all))))))) (fn set-status [text] - "Set status indicator. text=nil to hide." + "Set status indicator (virtual text, no re-render needed)." + (when (is-open?) + (widgets.prompt.set-status text))) + + ;; === Context API === + (fn add-context [ctx] (when (is-open?) (with-internal-edit (fn [] - (widgets.prompt.set-status text) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) + (widgets.context.add ctx) + (render-prompt-area))) + (focus-prompt))) - (fn set-loading [bool] - "Toggle loading state. Shows ⏳ stop when loading, > when idle." + (fn remove-context [name] (when (is-open?) (with-internal-edit (fn [] - (widgets.prompt.set-loading bool) - (let [end-line (widgets.messages.get-end-line)] - (widgets.prompt.render end-line)))))) + (widgets.context.remove name) + (render-prompt-area))))) + + (fn set-loading [bool] + "Toggle loading state. When turning off, flush queued prompt." + (when (is-open?) + (widgets.prompt.set-loading bool) + (focus-prompt) + ;; When loading finishes, check for queued steering message + (when (and (not bool) state.queued-prompt) + (let [queued state.queued-prompt] + (set state.queued-prompt nil) + (with-internal-edit + (fn [] + (widgets.prompt.set-steering nil) + (render-prompt-area))) + ;; Submit the queued message + (when on-submit (on-submit queued)))))) {: open : close : toggle : is-open? : get-buf-id : append-message : update-message : finish-streaming : clear-messages : update-header : update-header-item : update-footer : update-footer-item : set-welcome - : submit-prompt : set-status : set-loading})) + : submit-prompt : stop : cancel-steering + : set-status : set-loading + : add-context : remove-context})) {: create-chat-ui} diff --git a/fnl/eca/ui/components/message.fnl b/fnl/eca/ui/components/message.fnl index 52266a5..7969adb 100644 --- a/fnl/eca/ui/components/message.fnl +++ b/fnl/eca/ui/components/message.fnl @@ -10,28 +10,41 @@ (table.insert lines line))) lines)) -(fn render [{: content : prefix : hl-group}] +(fn render [{: content : prefix : hl-group : collapsed? : collapse-prefix}] "Render a text block. content: text string (may contain newlines) prefix: optional string prepended to first line (e.g. '> ') - hl-group: optional highlight group for the block + hl-group: optional highlight group + collapsed?: if true, render single line with collapse-prefix + collapse-prefix: prefix for collapsed view (e.g. '▸ ') Returns {: lines : highlights}." (let [pfx (or prefix "") - ;; If prefix is set and no explicit hl-group, use EcaMessagePrefix hl (or hl-group (when (and prefix (> (length prefix) 0)) :EcaMessagePrefix)) content-lines (split-lines (or content "")) lines [] highlights []] - (each [i line (ipairs content-lines)] - (let [full (if (= i 1) (.. pfx line) line)] - (table.insert lines full) - (when hl - (table.insert highlights - {:line-idx (- (length lines) 1) - :hl-group hl - :col-start 0 - :col-end (length full)})))) - (table.insert lines "") + (if collapsed? + ;; Collapsed: single line with collapse-prefix + first line of content + (let [cpfx (or collapse-prefix "▸ ") + first-content (or (. content-lines 1) "") + line (.. cpfx first-content)] + (table.insert lines line) + (table.insert highlights + {:line-idx 0 :hl-group (or hl :EcaExpandableLabel) + :col-start 0 :col-end (length cpfx)}) + (table.insert lines "")) + ;; Expanded: full content with prefix + (do + (each [i line (ipairs content-lines)] + (let [full (if (= i 1) (.. pfx line) line)] + (table.insert lines full) + (when hl + (table.insert highlights + {:line-idx (- (length lines) 1) + :hl-group hl + :col-start 0 + :col-end (length full)})))) + (table.insert lines ""))) {: lines : highlights})) {: render} diff --git a/fnl/eca/ui/highlights.fnl b/fnl/eca/ui/highlights.fnl index a722007..bee09df 100644 --- a/fnl/eca/ui/highlights.fnl +++ b/fnl/eca/ui/highlights.fnl @@ -32,6 +32,7 @@ :EcaButtonAccept {:link "DiagnosticOk"} :EcaButtonReject {:link "DiagnosticError"} :EcaStopLabel {:link "Underlined"} + :EcaSteeringLabel {:link "WarningMsg"} :EcaTabActive {:link "TabLineSel"} :EcaTabInactive {:link "TabLine"} :EcaTabLoading {:link "WarningMsg"}}) diff --git a/fnl/eca/ui/widgets/context-area.fnl b/fnl/eca/ui/widgets/context-area.fnl new file mode 100644 index 0000000..ee2e5b7 --- /dev/null +++ b/fnl/eca/ui/widgets/context-area.fnl @@ -0,0 +1,80 @@ +;; context-area widget — renders a horizontal bar of context items. +;; Managed directly by the builder, sits between messages and the prompt. + +(local nvim vim.api) + +(fn create [buf-id] + (local state {:items [] :ns-id nil :start-line 0 :end-line 0}) + + (fn ensure-ns [] + (when (= nil state.ns-id) + (set state.ns-id (nvim.nvim_create_namespace "eca-context-area"))) + state.ns-id) + + (fn build-line [] + "Build the display line and per-item highlights." + (if (= 0 (length state.items)) + {:line nil :highlights []} + (let [result (accumulate [acc {:parts [] :highlights [] :col 0} + _ item (ipairs state.items)] + (let [sep-col (if (> acc.col 0) + (do (table.insert acc.parts " ") + (+ acc.col 1)) + acc.col)] + (table.insert acc.parts item.text) + (table.insert acc.highlights + {:hl-group (or item.hl-group :Normal) + :col-start sep-col + :col-end (+ sep-col (length item.text))}) + {:parts acc.parts + :highlights acc.highlights + :col (+ sep-col (length item.text))}))] + {:line (table.concat result.parts "") + :highlights result.highlights}))) + + (fn has-items? [] + (> (length state.items) 0)) + + (fn render [start-line] + "Render the context bar at start-line. Returns number of lines written (0 or 1)." + (set state.start-line start-line) + (let [ns (ensure-ns)] + (nvim.nvim_buf_clear_namespace buf-id ns 0 -1) + (if (not (has-items?)) + (do (set state.end-line start-line) 0) + (let [{: line : highlights} (build-line)] + (nvim.nvim_buf_set_lines buf-id start-line start-line false [line]) + (each [_ hl (ipairs highlights)] + (nvim.nvim_buf_set_extmark buf-id ns start-line hl.col-start + {:end_col hl.col-end :hl_group hl.hl-group})) + (set state.end-line (+ start-line 1)) + 1)))) + + (fn add [item] + (let [exists (accumulate [found false _ existing (ipairs state.items)] + (or found (= existing.text item.text)))] + (when (not exists) + (table.insert state.items item)))) + + (fn remove [text] + (set state.items + (icollect [_ item (ipairs state.items)] + (when (not= item.text text) item)))) + + (fn render-highlights [line-num] + "Apply highlights to a context line already written in the buffer." + (when (has-items?) + (let [ns (ensure-ns) + {: highlights} (build-line)] + (nvim.nvim_buf_clear_namespace buf-id ns 0 -1) + (each [_ hl (ipairs highlights)] + (nvim.nvim_buf_set_extmark buf-id ns line-num hl.col-start + {:end_col hl.col-end :hl_group hl.hl-group}))))) + + (fn clear [] (set state.items [])) + (fn get-state [] state) + (fn get-end-line [] state.end-line) + + {: render : render-highlights : add : remove : clear : has-items? : get-state : get-end-line}) + +{: create} diff --git a/fnl/eca/ui/widgets/header-bar.fnl b/fnl/eca/ui/widgets/header-bar.fnl index 9776356..59e327b 100644 --- a/fnl/eca/ui/widgets/header-bar.fnl +++ b/fnl/eca/ui/widgets/header-bar.fnl @@ -1,14 +1,15 @@ ;; header-bar widget — fixed header using winbar. (local nvim vim.api) -(local bar (require :eca.ui.components.bar-items)) +(local bar-items (require :eca.ui.components.bar-items)) (fn create [buf-id win-id initial-items] (var items (or initial-items [])) (fn render [] (nvim.nvim_set_option_value :winbar - (bar.render {:items items}) {:win win-id}) + (bar-items.render {:items items}) {:win win-id}) + ;; Blank line for spacing (nvim.nvim_buf_set_lines buf-id 0 1 false [""]) 1) diff --git a/fnl/eca/ui/widgets/message-list.fnl b/fnl/eca/ui/widgets/message-list.fnl index 53b7087..3b757b0 100644 --- a/fnl/eca/ui/widgets/message-list.fnl +++ b/fnl/eca/ui/widgets/message-list.fnl @@ -55,37 +55,50 @@ "Append a single character to the streaming position using set_text. Handles newlines by inserting a new line." (when (and state.streaming-line state.streaming-col) - (if (= char "\n") - ;; Newline: insert new line, prompt shifts down naturally - (do - (wrap-write - (fn [] - (let [next-line (+ state.streaming-line 1)] - (nvim.nvim_buf_set_lines buf-id next-line next-line false [""]) - (set state.end-line (+ state.end-line 1)) - (on-line-inserted)))) - (set state.streaming-line (+ state.streaming-line 1)) - (set state.streaming-col 0)) - ;; Regular char: append at current position - (do - (nvim.nvim_buf_set_text buf-id - state.streaming-line state.streaming-col - state.streaming-line state.streaming-col - [char]) - (set state.streaming-col (+ state.streaming-col (length char))))))) + ;; Guard: ensure the streaming line is still within the buffer + (let [buf-lines (nvim.nvim_buf_line_count buf-id)] + (when (< state.streaming-line buf-lines) + (let [current-line-text (or (. (nvim.nvim_buf_get_lines buf-id + state.streaming-line + (+ state.streaming-line 1) false) 1) "") + line-len (length current-line-text) + col (math.min state.streaming-col line-len)] + ;; Sync col in case buffer was re-rendered + (set state.streaming-col col) + (if (= char "\n") + ;; Newline: insert new line, prompt shifts down naturally + (do + (wrap-write + (fn [] + (let [next-line (+ state.streaming-line 1)] + (nvim.nvim_buf_set_lines buf-id next-line next-line false [""]) + (set state.end-line (+ state.end-line 1)) + (on-line-inserted)))) + (set state.streaming-line (+ state.streaming-line 1)) + (set state.streaming-col 0)) + ;; Regular char: append at current position + (do + (nvim.nvim_buf_set_text buf-id + state.streaming-line state.streaming-col + state.streaming-line state.streaming-col + [char]) + (set state.streaming-col (+ state.streaming-col (length char)))))))))) (fn stream-tick [] "Process one tick: move chars from queue to buffer." (when (> (length state.streaming-queue) 0) - (let [take (math.min state.streaming-chars-per-tick - (length state.streaming-queue))] - (for [i 1 take] - (let [char (string.sub state.streaming-queue i i)] - (stream-append-char char) - (set state.streaming-displayed - (.. state.streaming-displayed char)))) - (set state.streaming-queue - (string.sub state.streaming-queue (+ take 1))))) + ;; Wrap ALL writes in one batch so edit guard ignores them + (wrap-write + (fn [] + (let [take (math.min state.streaming-chars-per-tick + (length state.streaming-queue))] + (for [i 1 take] + (let [char (string.sub state.streaming-queue i i)] + (stream-append-char char) + (set state.streaming-displayed + (.. state.streaming-displayed char)))) + (set state.streaming-queue + (string.sub state.streaming-queue (+ take 1))))))) ;; Continue or stop (if (> (length state.streaming-queue) 0) (set state.streaming-timer @@ -110,6 +123,7 @@ (fn render [] (let [ns (ensure-ns)] + (nvim.nvim_buf_clear_namespace buf-id ns 0 -1) (nvim.nvim_buf_set_lines buf-id state.start-line state.end-line false []) (set state.end-line state.start-line) (if (= 0 (length state.messages)) diff --git a/fnl/eca/ui/widgets/prompt-area.fnl b/fnl/eca/ui/widgets/prompt-area.fnl index c7c75fe..11f4a8a 100644 --- a/fnl/eca/ui/widgets/prompt-area.fnl +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -1,10 +1,7 @@ -;; prompt-area widget — status indicator + prompt input. -;; When loading: shows "⏳ stop" (non-editable). -;; When idle: shows "> " (editable). +;; prompt-area widget — separator + status + stop + prompt. (local nvim vim.api) (local prompt-prefix-component (require :eca.ui.components.prompt-prefix)) -(local context-bar-widget (require :eca.ui.widgets.context-bar)) (fn create [buf-id ?opts] (local wrap-write (or (?. ?opts :wrap-write) (fn [f] (f)))) @@ -13,22 +10,25 @@ :history [] :history-idx 0 :prompt-start-line 0 + :status-anchor-line 0 :ns-id nil - ;; Status indicator (virtual text) :status-text nil :status-timer nil :status-dots 0 - :status-extmark-id nil}) + :status-extmark-id nil + :stop-extmark-id nil + :steering-text nil}) - (local ctx-bar (context-bar-widget.create buf-id)) + (local idle-prefix (prompt-prefix-component.render {:loading? false})) + (local loading-prefix (prompt-prefix-component.render {:loading? true})) (fn ensure-ns [] (when (= nil state.ns-id) (set state.ns-id (nvim.nvim_create_namespace "eca-prompt-area"))) state.ns-id) - (fn update-status-virt-text [] - "Update status virtual text above the prompt line." + (fn update-status-virt [] + "Update status virtual text below the separator (before context-area)." (let [ns (ensure-ns)] (when state.status-extmark-id (pcall nvim.nvim_buf_del_extmark buf-id ns state.status-extmark-id) @@ -37,63 +37,132 @@ (let [dots (string.rep "." (+ (% state.status-dots 3) 1)) status-str (.. state.status-text dots)] (set state.status-extmark-id - (nvim.nvim_buf_set_extmark buf-id ns state.prompt-start-line 0 - {:virt_lines_above true - :virt_lines [[[status-str :EcaSpinner]]]})))))) - - (fn render [start-line] - "Render: context bar + prompt line. - When loading: '⏳ stop'. When idle: '> '. - Status is virtual text above prompt." + (nvim.nvim_buf_set_extmark buf-id ns state.status-anchor-line 0 + {:virt_lines [[[status-str :EcaSpinner]]]})))))) + + (fn update-stop-virt [] + "Update stop virtual text above the prompt line." + (let [ns (ensure-ns)] + (when state.stop-extmark-id + (pcall nvim.nvim_buf_del_extmark buf-id ns state.stop-extmark-id) + (set state.stop-extmark-id nil)) + (when state.loading? + (set state.stop-extmark-id + (nvim.nvim_buf_set_extmark buf-id ns state.prompt-start-line 0 + {:virt_lines_above true + :virt_lines [[[loading-prefix.text loading-prefix.hl-group] + ["stop" :EcaStopLabel]]]}))))) + + (fn update-virt-lines [] + "Update all virtual lines." + (update-status-virt) + (update-stop-virt)) + + (fn read-live-prompt-text [] + "Read the user's current text from prompt-start-line to end of buffer. + Supports multi-line input." + (let [total (nvim.nvim_buf_line_count buf-id) + start state.prompt-start-line] + (when (and (> total 0) (<= start (- total 1))) + (let [lines (nvim.nvim_buf_get_lines buf-id start total false)] + (when (> (length lines) 0) + (let [first-line (. lines 1)] + (when (vim.startswith first-line idle-prefix.text) + (tset lines 1 (string.sub first-line (+ (length idle-prefix.text) 1))))) + (table.concat lines "\n")))))) + + (fn save-live-text [] + "Save user's live buffer text to state. Safe to call anytime — + returns saved text, or empty string if prompt not yet rendered." + (let [total (nvim.nvim_buf_line_count buf-id) + start state.prompt-start-line] + ;; Only read if prompt has been rendered at a valid position + ;; and the line actually contains our prefix + (when (and (> start 0) (<= start (- total 1))) + (let [first-line (or (. (nvim.nvim_buf_get_lines buf-id start (+ start 1) false) 1) "")] + (when (vim.startswith first-line idle-prefix.text) + (let [live (or (read-live-prompt-text) "")] + (set state.prompt-text live)))))) + state.prompt-text) + + (fn render [start-line ?skip-read] + "Render: steering + stop (if loading) + prompt. + Preserves user's live input text from buffer unless ?skip-read is true." + ;; Read live text before overwriting (skip when called from builder re-render + ;; because the buffer was already cleared and state.prompt-text is correct) + (when (not ?skip-read) + (let [live-text (or (read-live-prompt-text) state.prompt-text)] + (set state.prompt-text live-text))) (set state.prompt-start-line start-line) (let [ns (ensure-ns) - prefix (prompt-prefix-component.render {:loading? state.loading?}) - ctx-state (ctx-bar.get-state) - has-contexts? (> (length ctx-state.items) 0) + _ (nvim.nvim_buf_clear_namespace buf-id ns 0 -1) lines []] - ;; Context bar - (when has-contexts? - (let [parts (icollect [_ item (ipairs ctx-state.items)] item.text)] - (table.insert lines (table.concat parts " ")))) - ;; Prompt line - (if state.loading? - (table.insert lines (.. prefix.text "stop")) - (table.insert lines (.. prefix.text state.prompt-text))) - - ;; Write - (nvim.nvim_buf_set_lines buf-id start-line -1 false lines) + ;; 1. Steering line (queued message) + (when state.steering-text + (let [truncated (if (> (length state.steering-text) 50) + (.. (string.sub state.steering-text 1 50) "...") + state.steering-text)] + (table.insert lines (.. "Steering: " truncated " [-]")))) + ;; 2. Prompt (always "> " + preserved user text, may be multi-line) + (let [text-lines (vim.split state.prompt-text "\n" {:plain true})] + (table.insert lines (.. idle-prefix.text (or (. text-lines 1) ""))) + (for [i 2 (length text-lines)] + (table.insert lines (. text-lines i)))) - ;; Highlight context - (when has-contexts? - (ctx-bar.render start-line)) + ;; Write all lines + (nvim.nvim_buf_set_lines buf-id start-line -1 false lines) - ;; Prompt line index + ;; Compute prompt line index (always the last rendered line) (let [prompt-line-idx (- (+ start-line (length lines)) 1)] (set state.prompt-start-line prompt-line-idx) - ;; Highlight prefix - (nvim.nvim_buf_set_extmark buf-id ns prompt-line-idx 0 - {:end_col (length prefix.text) - :hl_group prefix.hl-group}) - ;; When loading, underline "stop" to look clickable - (when state.loading? - (nvim.nvim_buf_set_extmark buf-id ns prompt-line-idx (length prefix.text) - {:end_col (+ (length prefix.text) 4) - :hl_group :EcaStopLabel}))) - - ;; Status virtual text - (update-status-virt-text) + + ;; Highlight steering line (one above prompt) + (when state.steering-text + (let [steering-line-idx (- prompt-line-idx 1) + line-text (. lines (+ (- steering-line-idx start-line) 1)) + cancel-start (- (length line-text) 3)] + (nvim.nvim_buf_set_extmark buf-id ns steering-line-idx 0 + {:end_col (math.min 10 (length line-text)) + :hl_group :EcaSteeringLabel}) + (nvim.nvim_buf_set_extmark buf-id ns steering-line-idx cancel-start + {:end_col (length line-text) + :hl_group :EcaStopLabel}))) + + ;; Highlight prompt prefix "> " + (let [buf-line (or (. (nvim.nvim_buf_get_lines buf-id prompt-line-idx (+ prompt-line-idx 1) false) 1) "")] + (when (>= (length buf-line) (length idle-prefix.text)) + (nvim.nvim_buf_set_extmark buf-id ns prompt-line-idx 0 + {:end_col (length idle-prefix.text) + :hl_group idle-prefix.hl-group})))) + + ;; Virtual lines above prompt: status + stop + (update-virt-lines) (length lines))) + (fn render-highlights [prompt-line] + "Apply highlights and virtual text to a prompt already written in the buffer. + Used by the builder when it writes all lines in one shot." + (set state.prompt-start-line prompt-line) + (let [ns (ensure-ns)] + (nvim.nvim_buf_clear_namespace buf-id ns 0 -1) + ;; Highlight prompt prefix "> " + (let [buf-line (or (. (nvim.nvim_buf_get_lines buf-id prompt-line (+ prompt-line 1) false) 1) "")] + (when (>= (length buf-line) (length idle-prefix.text)) + (nvim.nvim_buf_set_extmark buf-id ns prompt-line 0 + {:end_col (length idle-prefix.text) + :hl_group idle-prefix.hl-group}))) + ;; Virtual lines: status + stop + (update-virt-lines))) + ;; ── Status indicator ────────────────────────────────── (fn animate-dots [] (set state.status-dots (+ state.status-dots 1)) (when (and state.status-text (nvim.nvim_buf_is_valid buf-id)) - (update-status-virt-text))) + (update-virt-lines))) (fn set-status [text] - "Set status text. nil to hide." (if text (do (set state.status-text text) @@ -108,39 +177,54 @@ (do (set state.status-text nil) (set state.status-timer nil) - (update-status-virt-text)))) + (update-virt-lines)))) ;; ── Loading ─────────────────────────────────────────── (fn set-loading [bool] - "Toggle loading state. Changes prefix and shows/hides stop label." - (set state.loading? bool)) + (set state.loading? bool) + (update-virt-lines)) + + (fn set-status-anchor-line [line] + "Set the buffer line where status virtual text should be anchored." + (set state.status-anchor-line line)) + + (fn set-steering [text] + "Set steering/queued message text. nil to clear." + (set state.steering-text text)) ;; ── Text management ─────────────────────────────────── (fn get-text [] - (when (not state.loading?) - (let [total (nvim.nvim_buf_line_count buf-id) - prompt-lines (nvim.nvim_buf_get_lines buf-id state.prompt-start-line total false) - prefix (prompt-prefix-component.render {:loading? false})] - (when (and prompt-lines (> (length prompt-lines) 0)) - (let [first-line (. prompt-lines 1) - stripped (if (vim.startswith first-line prefix.text) - (string.sub first-line (+ (length prefix.text) 1)) - first-line) - parts [stripped]] - (for [i 2 (length prompt-lines)] - (table.insert parts (. prompt-lines i))) - (table.concat parts "\n")))))) + "Read prompt text from prompt-start-line to end of buffer. + Supports multi-line input (user pressed Enter)." + (let [total (nvim.nvim_buf_line_count buf-id) + start state.prompt-start-line + lines (nvim.nvim_buf_get_lines buf-id start total false)] + ;; Strip the "> " prefix from the first line + (when (> (length lines) 0) + (let [first-line (. lines 1)] + (when (vim.startswith first-line idle-prefix.text) + (tset lines 1 (string.sub first-line (+ (length idle-prefix.text) 1)))))) + (table.concat lines "\n"))) (fn set-text [text] (set state.prompt-text (or text "")) (when (not state.loading?) - (let [prefix (prompt-prefix-component.render {:loading? false}) + (let [start state.prompt-start-line total (nvim.nvim_buf_line_count buf-id) - last-line-idx (- total 1)] - (nvim.nvim_buf_set_lines buf-id last-line-idx total false - [(.. prefix.text state.prompt-text)])))) + text-lines (vim.split state.prompt-text "\n" {:plain true}) + buf-lines []] + ;; First line gets the "> " prefix + (table.insert buf-lines (.. idle-prefix.text (or (. text-lines 1) ""))) + ;; Remaining lines go as-is + (for [i 2 (length text-lines)] + (table.insert buf-lines (. text-lines i))) + (nvim.nvim_buf_set_lines buf-id start total false buf-lines)))) + + (fn set-text-internal [text] + "Update internal prompt-text state without writing to the buffer." + (set state.prompt-text (or text ""))) (fn clear [] (set-text "")) @@ -161,13 +245,29 @@ (do (set state.history-idx (+ (length state.history) 1)) (set-text "")))) - (fn add-context [ctx] (ctx-bar.add ctx)) - (fn remove-context [name] (ctx-bar.remove name)) + ;; Strip "> " prefix from yanked text on the prompt line. + (nvim.nvim_create_autocmd :TextYankPost + {:buffer buf-id + :callback (fn [] + (let [event vim.v.event + lines event.regcontents + first (or (. lines 1) "")] + (when (vim.startswith first idle-prefix.text) + (let [stripped (string.sub first (+ (length idle-prefix.text) 1)) + reg (or event.regname "\"")] + (tset lines 1 stripped) + (vim.schedule + (fn [] + (vim.fn.setreg reg lines event.regtype) + (when (not= reg "+") (vim.fn.setreg "+" lines event.regtype)) + (when (not= reg "*") (vim.fn.setreg "*" lines event.regtype))))))))}) + (fn get-state [] state) - {: render : get-text : set-text : clear - : set-status : set-loading + {: render : render-highlights : get-text : save-live-text + : set-text : set-text-internal : clear + : set-status : set-loading : set-steering : set-status-anchor-line : add-to-history : history-prev : history-next - : add-context : remove-context : get-state}) + : get-state}) {: create} diff --git a/image.png b/image.png new file mode 100644 index 0000000000000000000000000000000000000000..0977628898a93d1c7060557631b919a9213fe7c4 GIT binary patch literal 162333 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 >~~T-}e7;fvMC*4`@Ax_Kkn$BO6O$7QDo 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�)@0nPygok!Wfgbd5q}PN8Qd%yKr8-#NyM_ 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 JC6{go{|Dz&kh%Z> literal 0 HcmV?d00001 diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 4f873a5..f150ff1 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -34,8 +34,9 @@ self["chat-open"] = function() chat_ui.open() self["register-chat"](chat_ui) chat_ui["set-welcome"]("Welcome to ECA Chat") - chat_ui["update-header"]({{title = "model", value = "claude"}, {title = "behavior", value = "agent"}}) - return chat_ui["update-footer"]({{value = "Testing assoc-some in @shared."}, {value = "12.4K / 200K ($0.03)"}}) + chat_ui["update-header"]({{title = "model", value = "claude/opus-4.6"}, {title = "agent", value = "code"}, {title = "variant", value = "-"}, {title = "mcps", value = "1"}}) + chat_ui["update-footer"]({{value = "~/dev/eca-nvim"}, {value = "\226\143\177 0s"}, {value = "0/200K ($0.00)"}}) + return chat_ui["add-context"]({text = "@cursor(README.md 3:1)", ["hl-group"] = "EcaContextCursor"}) else return nil end @@ -80,6 +81,22 @@ self["chat-set-model"] = function(model) return nil end end +self["chat-stop"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat.stop() + else + return nil + end +end +self["chat-cancel-steering"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat["cancel-steering"]() + else + return nil + end +end self["chat-set-status"] = function(text) local chat = self["resolve-chat"]() if chat then @@ -91,7 +108,7 @@ end self["default-on-submit"] = function(text) local chat = self["resolve-chat"]() if chat then - chat["append-message"]({id = tostring(os.time()), content = text, prefix = "> "}) + chat["append-message"]({id = tostring(os.time()), content = text, ["collapsed?"] = true, ["collapse-prefix"] = "\226\150\184 "}) chat["set-loading"](true) chat["set-status"]("Generating") local reply_id = ("reply-" .. tostring(os.time())) @@ -104,7 +121,7 @@ self["default-on-submit"] = function(text) table.insert(chunks, string.sub(full_text, i, end_idx)) i = (end_idx + 1) end - local function _11_() + local function _13_() if chat["is-open?"]() then chat["append-message"]({id = reply_id, content = "", ["streaming?"] = true}) local accumulated = "" @@ -113,16 +130,16 @@ self["default-on-submit"] = function(text) delay = (delay + 50) local content_at_send = (accumulated .. chunk) accumulated = content_at_send - local function _12_() + local function _14_() if chat["is-open?"]() then return chat["update-message"](reply_id, content_at_send) else return nil end end - vim.defer_fn(_12_, delay) + vim.defer_fn(_14_, delay) end - local function _14_() + local function _16_() if chat["is-open?"]() then chat["finish-streaming"](reply_id) chat["set-loading"](false) @@ -131,12 +148,12 @@ self["default-on-submit"] = function(text) return nil end end - return vim.defer_fn(_14_, (delay + 100)) + return vim.defer_fn(_16_, (delay + 100)) else return nil end end - return vim.defer_fn(_11_, 300) + return vim.defer_fn(_13_, 300) else return nil end diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index df6efd9..e6c77a5 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -26,7 +26,7 @@ local function setup(api) end nvim.nvim_create_user_command("EcaChatSubmit", _6_, {desc = "Submit current prompt"}) local function _7_() - return api["chat-set-status"](nil) + return api["chat-stop"]() end nvim.nvim_create_user_command("EcaChatStop", _7_, {desc = "Stop current ECA response"}) local function _8_(cmd) diff --git a/lua/eca/ui/builder.lua b/lua/eca/ui/builder.lua index 312ca3f..7ab5d6c 100644 --- a/lua/eca/ui/builder.lua +++ b/lua/eca/ui/builder.lua @@ -3,6 +3,7 @@ local nvim = vim.api local highlights = require("eca.ui.highlights") local header_bar_widget = require("eca.ui.widgets.header-bar") local message_list_widget = require("eca.ui.widgets.message-list") +local context_area_widget = require("eca.ui.widgets.context-area") local prompt_area_widget = require("eca.ui.widgets.prompt-area") local footer_bar_widget = require("eca.ui.widgets.footer-bar") local function disable_statusline_plugins() @@ -54,61 +55,31 @@ local function setup_chat_window(win) end local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_prompt_fn) local internal_edit = false - local guard_ns = nil - local function ensure_guard_ns() - if (nil == guard_ns) then - guard_ns = nvim.nvim_create_namespace("eca-edit-guard") - else - end - return guard_ns - end - local function get_prefix(loading_3f) - local prompt_prefix = require("eca.ui.components.prompt-prefix") - return prompt_prefix.render({["loading?"] = loading_3f}).text - end - local function salvage_user_text(buf, prompt_start_line, prefix) + local function salvage_user_text(buf, prompt_line) local current_count = nvim.nvim_buf_line_count(buf) - local start = math.min(prompt_start_line, current_count) - local prompt_lines = nvim.nvim_buf_get_lines(buf, start, current_count, false) - if (0 == #prompt_lines) then - return {""} + local idx = math.min(prompt_line, (current_count - 1)) + local lines = nvim.nvim_buf_get_lines(buf, idx, (idx + 1), false) + local last_line = (lines[1] or "") + if vim.startswith(last_line, "> ") then + return {string.sub(last_line, 3)} else - local tbl_26_ = {} - local i_27_ = 0 - for i, line in ipairs(prompt_lines) do - local val_28_ - if (i == 1) then - if vim.startswith(line, prefix) then - val_28_ = string.sub(line, (#prefix + 1)) - else - val_28_ = line:gsub("^>%s*", "") - end - else - val_28_ = line - end - if (nil ~= val_28_) then - i_27_ = (i_27_ + 1) - tbl_26_[i_27_] = val_28_ - else - end - end - return tbl_26_ + return {""} end end - local function restore_with_user_text(buf, prefix, user_lines) + local function restore_with_user_text(buf, user_lines) internal_edit = true render_all_fn() do local new_count = nvim.nvim_buf_line_count(buf) local new_last_idx = (new_count - 1) - local restored_lines + local restored do local tbl_26_ = {} local i_27_ = 0 for i, line in ipairs(user_lines) do local val_28_ if (i == 1) then - val_28_ = (prefix .. line) + val_28_ = ("> " .. line) else val_28_ = line end @@ -118,12 +89,12 @@ local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_p else end end - restored_lines = tbl_26_ + restored = tbl_26_ end - if (#restored_lines > 0) then - nvim.nvim_buf_set_lines(buf, new_last_idx, new_count, false, restored_lines) - local ns = ensure_guard_ns() - nvim.nvim_buf_set_extmark(buf, ns, new_last_idx, 0, {end_col = #prefix, hl_group = "EcaPromptPrefix"}) + if (#restored > 0) then + nvim.nvim_buf_set_lines(buf, new_last_idx, new_count, false, restored) + local ns = nvim.nvim_create_namespace("eca-prompt-restore") + nvim.nvim_buf_set_extmark(buf, ns, new_last_idx, 0, {end_col = 2, hl_group = "EcaPromptPrefix"}) else end end @@ -136,28 +107,23 @@ local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_p end local function on_lines_handler(_, buf, changedtick, first_line, last_line, new_last_line) if not internal_edit then - local _let_14_ = get_prompt_state() - local prompt_start_line = _let_14_["prompt-start-line"] - local loading_3f = _let_14_["loading?"] - local prefix = get_prefix(loading_3f) - local function _15_() - if nvim.nvim_buf_is_valid(buf) then - local current_count = nvim.nvim_buf_line_count(buf) - local prompt_idx = math.min(prompt_start_line, (current_count - 1)) - local prompt_lines = nvim.nvim_buf_get_lines(buf, prompt_idx, (prompt_idx + 1), false) - local prompt_line_text = (prompt_lines[1] or "") - local damaged_3f = ((first_line < prompt_start_line) or not vim.startswith(prompt_line_text, prefix)) - if damaged_3f then - local user_lines = salvage_user_text(buf, prompt_start_line, prefix) - return restore_with_user_text(buf, prefix, user_lines) + local prompt_state = get_prompt_state() + local prompt_line = (prompt_state["prompt-start-line"] or 0) + local lines_deleted_3f = (last_line > new_last_line) + local damaged_3f = ((first_line < prompt_line) or ((first_line <= prompt_line) and lines_deleted_3f)) + if damaged_3f then + local function _10_() + if nvim.nvim_buf_is_valid(buf) then + local user_lines = salvage_user_text(buf, prompt_line) + return restore_with_user_text(buf, user_lines) else return nil end - else - return nil end + return vim.schedule(_10_) + else + return nil end - return vim.schedule(_15_) else return nil end @@ -172,27 +138,21 @@ local function setup_edit_guard(buf_id, render_all_fn, get_prompt_state, focus_p end return {["set-internal"] = set_internal, ["update-expected-count"] = update_expected_count} end -local function create_chat_ui(_19_) - local on_submit = _19_["on-submit"] - local on_stop = _19_["on-stop"] - local opts = _19_.opts +local function create_chat_ui(_14_) + local on_submit = _14_["on-submit"] + local on_stop = _14_["on-stop"] + local opts = _14_.opts local ui_config = (opts.ui or {}) local config = {width = (ui_config.width or 0.4), position = (ui_config.position or "right"), keymaps = (opts.keymaps or {})} - local state = {["header-items"] = {}, ["footer-items"] = {}, welcome = nil} + local state = {["header-items"] = {}, ["footer-items"] = {}, welcome = nil, ["queued-prompt"] = nil} local buf_id = nil local win_id = nil local guard = nil - local widgets = {header = nil, messages = nil, prompt = nil, footer = nil} + local widgets = {header = nil, messages = nil, context = nil, prompt = nil, footer = nil} local function is_open_3f() return ((nil ~= buf_id) and nvim.nvim_buf_is_valid(buf_id) and (nil ~= win_id) and nvim.nvim_win_is_valid(win_id)) end local function with_internal_edit(f) - local saved_cursor - if (win_id and nvim.nvim_win_is_valid(win_id)) then - saved_cursor = nvim.nvim_win_get_cursor(win_id) - else - saved_cursor = nil - end if guard then guard["set-internal"](true) else @@ -200,14 +160,7 @@ local function create_chat_ui(_19_) f() if guard then guard["set-internal"](false) - guard["update-expected-count"]() - else - end - if (saved_cursor and win_id and nvim.nvim_win_is_valid(win_id)) then - local total = nvim.nvim_buf_line_count(buf_id) - local line = math.min(saved_cursor[1], total) - local col = saved_cursor[2] - return pcall(nvim.nvim_win_set_cursor, win_id, {line, col}) + return guard["update-expected-count"]() else return nil end @@ -222,14 +175,78 @@ local function create_chat_ui(_19_) return nil end end + local function make_separator() + local win = vim.fn.bufwinid(buf_id) + local width + if (win and (win ~= -1)) then + width = nvim.nvim_win_get_width(win) + else + width = 40 + end + return string.rep("\226\148\128", width) + end + local function render_prompt_area() + local msg_end = widgets.messages["get-end-line"]() + local live_text = widgets.prompt["save-live-text"]() + local sep = make_separator() + local ctx_items = widgets.context["get-state"]() + local has_ctx_3f = widgets.context["has-items?"]() + local prompt_text_lines = vim.split((live_text or ""), "\n", {plain = true}) + local all_lines = {sep} + if has_ctx_3f then + local parts + do + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs(ctx_items.items) do + local val_28_ = item.text + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + parts = tbl_26_ + end + table.insert(all_lines, table.concat(parts, " ")) + else + end + do + local idle_prefix = "> " + table.insert(all_lines, (idle_prefix .. (prompt_text_lines[1] or ""))) + for i = 2, #prompt_text_lines do + table.insert(all_lines, prompt_text_lines[i]) + end + end + nvim.nvim_buf_set_lines(buf_id, msg_end, -1, false, all_lines) + local prompt_start + local _21_ + if has_ctx_3f then + _21_ = 2 + else + _21_ = 1 + end + prompt_start = (msg_end + _21_) + widgets.prompt["set-text-internal"]((live_text or "")) + do + local ns = nvim.nvim_create_namespace("eca-separator") + nvim.nvim_buf_clear_namespace(buf_id, ns, 0, -1) + pcall(nvim.nvim_buf_set_extmark, buf_id, ns, msg_end, 0, {end_col = #sep, hl_group = "EcaSeparator"}) + end + widgets.prompt["set-status-anchor-line"](msg_end) + if has_ctx_3f then + widgets.context["render-highlights"]((msg_end + 1)) + else + end + return widgets.prompt["render-highlights"](prompt_start) + end local function render_all() - local function _25_() + local function _24_() do local header_lines = widgets.header.render() widgets.messages["set-start-line"](header_lines) widgets.messages.render() - local end_line = widgets.messages["get-end-line"]() - widgets.prompt.render(end_line) + render_prompt_area() end if widgets.footer then return widgets.footer.render() @@ -237,7 +254,7 @@ local function create_chat_ui(_19_) return nil end end - return with_internal_edit(_25_) + return with_internal_edit(_24_) end local function close() if is_open_3f() then @@ -248,23 +265,55 @@ local function create_chat_ui(_19_) return nil end end + local function cancel_steering() + if state["queued-prompt"] then + state["queued-prompt"] = nil + if is_open_3f() then + local function _27_() + widgets.prompt["set-steering"](nil) + return render_prompt_area() + end + return with_internal_edit(_27_) + else + return nil + end + else + return nil + end + end + local function stop() + cancel_steering() + if on_stop then + return on_stop() + else + return nil + end + end local function submit_prompt() if is_open_3f() then local prompt_state = widgets.prompt["get-state"]() + local text = widgets.prompt["get-text"]() if prompt_state["loading?"] then - if on_stop then - return on_stop() + if (text and ("" ~= text)) then + state["queued-prompt"] = text + widgets.prompt["add-to-history"](text) + local function _31_() + widgets.prompt.clear() + widgets.prompt["set-steering"](text) + return render_prompt_area() + end + with_internal_edit(_31_) + return focus_prompt() else return nil end else - local text = widgets.prompt["get-text"]() if (text and ("" ~= text)) then widgets.prompt["add-to-history"](text) - local function _29_() + local function _33_() return widgets.prompt.clear() end - with_internal_edit(_29_) + with_internal_edit(_33_) focus_prompt() if on_submit then return on_submit(text) @@ -290,16 +339,17 @@ local function create_chat_ui(_19_) setup_chat_buffer(buf_id) setup_chat_window(win_id) widgets.header = header_bar_widget.create(buf_id, win_id, state["header-items"]) - local function _34_() + local function _38_() local s = widgets.prompt["get-state"]() s["prompt-start-line"] = (s["prompt-start-line"] + 1) return nil end - widgets.messages = message_list_widget.create(buf_id, {["wrap-write"] = with_internal_edit, ["on-line-inserted"] = _34_}) + widgets.messages = message_list_widget.create(buf_id, {["wrap-write"] = with_internal_edit, ["on-line-inserted"] = _38_}) if state.welcome then widgets.messages["set-welcome"]({lines = {state.welcome, ""}, highlights = {{["line-idx"] = 0, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #state.welcome}}}) else end + widgets.context = context_area_widget.create(buf_id) widgets.prompt = prompt_area_widget.create(buf_id, {["wrap-write"] = with_internal_edit}) widgets.footer = footer_bar_widget.create(buf_id, win_id, state["footer-items"]) for _, km in ipairs(config.keymaps) do @@ -308,11 +358,11 @@ local function create_chat_ui(_19_) nvim.nvim_buf_set_lines(buf_id, 0, -1, false, {""}) render_all() focus_prompt() - local function _36_() + local function _40_() local s = widgets.prompt["get-state"]() return {["prompt-start-line"] = (s["prompt-start-line"] or 0), ["loading?"] = s["loading?"]} end - guard = setup_edit_guard(buf_id, render_all, _36_, focus_prompt) + guard = setup_edit_guard(buf_id, render_all, _40_, focus_prompt) return nil else return nil @@ -330,12 +380,11 @@ local function create_chat_ui(_19_) end local function append_message(msg) if is_open_3f() then - local function _39_() + local function _43_() widgets.messages["append-message"](msg) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + return render_prompt_area() end - with_internal_edit(_39_) + with_internal_edit(_43_) return focus_prompt() else return nil @@ -343,36 +392,33 @@ local function create_chat_ui(_19_) end local function update_message(id, content) if is_open_3f() then - local function _41_() + local function _45_() widgets.messages["update-message"](id, content) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + return render_prompt_area() end - return with_internal_edit(_41_) + return with_internal_edit(_45_) else return nil end end local function finish_streaming(id) if is_open_3f() then - local function _43_() + local function _47_() widgets.messages["finish-streaming"](id) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + return render_prompt_area() end - return with_internal_edit(_43_) + return with_internal_edit(_47_) else return nil end end local function clear_messages() if is_open_3f() then - local function _45_() + local function _49_() widgets.messages.clear() - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + return render_prompt_area() end - return with_internal_edit(_45_) + return with_internal_edit(_49_) else return nil end @@ -380,10 +426,10 @@ local function create_chat_ui(_19_) local function update_header(new_items) state["header-items"] = new_items if is_open_3f() then - local function _47_() + local function _51_() return widgets.header.update(new_items) end - return with_internal_edit(_47_) + return with_internal_edit(_51_) else return nil end @@ -402,10 +448,10 @@ local function create_chat_ui(_19_) else end if is_open_3f() then - local function _51_() + local function _55_() return widgets.header.update(state["header-items"]) end - return with_internal_edit(_51_) + return with_internal_edit(_55_) else return nil end @@ -413,10 +459,10 @@ local function create_chat_ui(_19_) local function update_footer(new_items) state["footer-items"] = new_items if is_open_3f() then - local function _53_() + local function _57_() return widgets.footer.update(new_items) end - return with_internal_edit(_53_) + return with_internal_edit(_57_) else return nil end @@ -435,10 +481,10 @@ local function create_chat_ui(_19_) else end if is_open_3f() then - local function _57_() + local function _61_() return widgets.footer.update(state["footer-items"]) end - return with_internal_edit(_57_) + return with_internal_edit(_61_) else return nil end @@ -449,10 +495,10 @@ local function create_chat_ui(_19_) widgets.messages["set-welcome"]({lines = {text, ""}, highlights = {{["line-idx"] = 0, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #text}}}) local msg_state = widgets.messages["get-state"]() if (0 == #msg_state.messages) then - local function _59_() + local function _63_() return render_all() end - return with_internal_edit(_59_) + return with_internal_edit(_63_) else return nil end @@ -462,28 +508,58 @@ local function create_chat_ui(_19_) end local function set_status(text) if is_open_3f() then - local function _62_() - widgets.prompt["set-status"](text) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + return widgets.prompt["set-status"](text) + else + return nil + end + end + local function add_context(ctx) + if is_open_3f() then + local function _67_() + widgets.context.add(ctx) + return render_prompt_area() end - return with_internal_edit(_62_) + with_internal_edit(_67_) + return focus_prompt() + else + return nil + end + end + local function remove_context(name) + if is_open_3f() then + local function _69_() + widgets.context.remove(name) + return render_prompt_area() + end + return with_internal_edit(_69_) else return nil end end local function set_loading(bool) if is_open_3f() then - local function _64_() - widgets.prompt["set-loading"](bool) - local end_line = widgets.messages["get-end-line"]() - return widgets.prompt.render(end_line) + widgets.prompt["set-loading"](bool) + focus_prompt() + if (not bool and state["queued-prompt"]) then + local queued = state["queued-prompt"] + state["queued-prompt"] = nil + local function _71_() + widgets.prompt["set-steering"](nil) + return render_prompt_area() + end + with_internal_edit(_71_) + if on_submit then + return on_submit(queued) + else + return nil + end + else + return nil end - return with_internal_edit(_64_) else return nil end end - return {open = open, close = close, toggle = toggle, ["is-open?"] = is_open_3f, ["get-buf-id"] = get_buf_id, ["append-message"] = append_message, ["update-message"] = update_message, ["finish-streaming"] = finish_streaming, ["clear-messages"] = clear_messages, ["update-header"] = update_header, ["update-header-item"] = update_header_item, ["update-footer"] = update_footer, ["update-footer-item"] = update_footer_item, ["set-welcome"] = set_welcome, ["submit-prompt"] = submit_prompt, ["set-status"] = set_status, ["set-loading"] = set_loading} + return {open = open, close = close, toggle = toggle, ["is-open?"] = is_open_3f, ["get-buf-id"] = get_buf_id, ["append-message"] = append_message, ["update-message"] = update_message, ["finish-streaming"] = finish_streaming, ["clear-messages"] = clear_messages, ["update-header"] = update_header, ["update-header-item"] = update_header_item, ["update-footer"] = update_footer, ["update-footer-item"] = update_footer_item, ["set-welcome"] = set_welcome, ["submit-prompt"] = submit_prompt, stop = stop, ["cancel-steering"] = cancel_steering, ["set-status"] = set_status, ["set-loading"] = set_loading, ["add-context"] = add_context, ["remove-context"] = remove_context} end return {["create-chat-ui"] = create_chat_ui} diff --git a/lua/eca/ui/components/message.lua b/lua/eca/ui/components/message.lua index b8184c0..f4995c1 100644 --- a/lua/eca/ui/components/message.lua +++ b/lua/eca/ui/components/message.lua @@ -14,6 +14,8 @@ local function render(_2_) local content = _2_.content local prefix = _2_.prefix local hl_group = _2_["hl-group"] + local collapsed_3f = _2_["collapsed?"] + local collapse_prefix = _2_["collapse-prefix"] local pfx = (prefix or "") local hl local or_3_ = hl_group @@ -28,20 +30,29 @@ local function render(_2_) local content_lines = split_lines((content or "")) local lines = {} local highlights = {} - for i, line in ipairs(content_lines) do - local full - if (i == 1) then - full = (pfx .. line) - else - full = line - end - table.insert(lines, full) - if hl then - table.insert(highlights, {["line-idx"] = (#lines - 1), ["hl-group"] = hl, ["col-start"] = 0, ["col-end"] = #full}) - else + if collapsed_3f then + local cpfx = (collapse_prefix or "\226\150\184 ") + local first_content = (content_lines[1] or "") + local line = (cpfx .. first_content) + table.insert(lines, line) + table.insert(highlights, {["line-idx"] = 0, ["hl-group"] = (hl or "EcaExpandableLabel"), ["col-start"] = 0, ["col-end"] = #cpfx}) + table.insert(lines, "") + else + for i, line in ipairs(content_lines) do + local full + if (i == 1) then + full = (pfx .. line) + else + full = line + end + table.insert(lines, full) + if hl then + table.insert(highlights, {["line-idx"] = (#lines - 1), ["hl-group"] = hl, ["col-start"] = 0, ["col-end"] = #full}) + else + end end + table.insert(lines, "") end - table.insert(lines, "") return {lines = lines, highlights = highlights} end return {render = render} diff --git a/lua/eca/ui/highlights.lua b/lua/eca/ui/highlights.lua index 9225a7c..aa3a9ac 100644 --- a/lua/eca/ui/highlights.lua +++ b/lua/eca/ui/highlights.lua @@ -1,6 +1,6 @@ -- [nfnl] fnl/eca/ui/highlights.fnl local nvim = vim.api -local groups = {EcaUser = {link = "Title"}, EcaAssistant = {link = "Normal"}, EcaSystem = {link = "Comment"}, EcaWelcome = {link = "Comment"}, EcaMessagePrefix = {link = "Title"}, EcaSeparator = {link = "WinSeparator"}, EcaPromptPrefix = {link = "Statement"}, EcaPromptPrefixLoading = {link = "WarningMsg"}, EcaToolCallPending = {link = "WarningMsg"}, EcaToolCallSuccess = {link = "DiagnosticOk"}, EcaToolCallError = {link = "DiagnosticError"}, EcaToolCallApproval = {link = "DiagnosticWarn"}, EcaExpandableIcon = {link = "Comment"}, EcaExpandableLabel = {link = "Bold"}, EcaContextFile = {link = "Underlined"}, EcaContextDir = {link = "Underlined"}, EcaContextRepoMap = {link = "Special"}, EcaContextCursor = {link = "Comment"}, EcaContextMcp = {link = "String"}, EcaHeaderKey = {link = "Comment"}, EcaHeaderValue = {link = "Bold"}, EcaUsage = {link = "Comment"}, EcaElapsed = {link = "Comment"}, EcaTrustOn = {link = "DiagnosticError"}, EcaTrustOff = {link = "Comment"}, EcaSpinner = {link = "WarningMsg"}, EcaButtonAccept = {link = "DiagnosticOk"}, EcaButtonReject = {link = "DiagnosticError"}, EcaStopLabel = {link = "Underlined"}, EcaTabActive = {link = "TabLineSel"}, EcaTabInactive = {link = "TabLine"}, EcaTabLoading = {link = "WarningMsg"}} +local groups = {EcaUser = {link = "Title"}, EcaAssistant = {link = "Normal"}, EcaSystem = {link = "Comment"}, EcaWelcome = {link = "Comment"}, EcaMessagePrefix = {link = "Title"}, EcaSeparator = {link = "WinSeparator"}, EcaPromptPrefix = {link = "Statement"}, EcaPromptPrefixLoading = {link = "WarningMsg"}, EcaToolCallPending = {link = "WarningMsg"}, EcaToolCallSuccess = {link = "DiagnosticOk"}, EcaToolCallError = {link = "DiagnosticError"}, EcaToolCallApproval = {link = "DiagnosticWarn"}, EcaExpandableIcon = {link = "Comment"}, EcaExpandableLabel = {link = "Bold"}, EcaContextFile = {link = "Underlined"}, EcaContextDir = {link = "Underlined"}, EcaContextRepoMap = {link = "Special"}, EcaContextCursor = {link = "Comment"}, EcaContextMcp = {link = "String"}, EcaHeaderKey = {link = "Comment"}, EcaHeaderValue = {link = "Bold"}, EcaUsage = {link = "Comment"}, EcaElapsed = {link = "Comment"}, EcaTrustOn = {link = "DiagnosticError"}, EcaTrustOff = {link = "Comment"}, EcaSpinner = {link = "WarningMsg"}, EcaButtonAccept = {link = "DiagnosticOk"}, EcaButtonReject = {link = "DiagnosticError"}, EcaStopLabel = {link = "Underlined"}, EcaSteeringLabel = {link = "WarningMsg"}, EcaTabActive = {link = "TabLineSel"}, EcaTabInactive = {link = "TabLine"}, EcaTabLoading = {link = "WarningMsg"}} local function setup() for group, opts in pairs(groups) do nvim.nvim_set_hl(0, group, opts) diff --git a/lua/eca/ui/widgets/context-area.lua b/lua/eca/ui/widgets/context-area.lua new file mode 100644 index 0000000..05d36cd --- /dev/null +++ b/lua/eca/ui/widgets/context-area.lua @@ -0,0 +1,120 @@ +-- [nfnl] fnl/eca/ui/widgets/context-area.fnl +local nvim = vim.api +local function create(buf_id) + local state = {items = {}, ["ns-id"] = nil, ["start-line"] = 0, ["end-line"] = 0} + local function ensure_ns() + if (nil == state["ns-id"]) then + state["ns-id"] = nvim.nvim_create_namespace("eca-context-area") + else + end + return state["ns-id"] + end + local function build_line() + if (0 == #state.items) then + return {line = nil, highlights = {}} + else + local result + do + local acc = {parts = {}, highlights = {}, col = 0} + for _, item in ipairs(state.items) do + local sep_col + if (acc.col > 0) then + table.insert(acc.parts, " ") + sep_col = (acc.col + 1) + else + sep_col = acc.col + end + table.insert(acc.parts, item.text) + table.insert(acc.highlights, {["hl-group"] = (item["hl-group"] or "Normal"), ["col-start"] = sep_col, ["col-end"] = (sep_col + #item.text)}) + acc = {parts = acc.parts, highlights = acc.highlights, col = (sep_col + #item.text)} + end + result = acc + end + return {line = table.concat(result.parts, ""), highlights = result.highlights} + end + end + local function has_items_3f() + return (#state.items > 0) + end + local function render(start_line) + state["start-line"] = start_line + local ns = ensure_ns() + nvim.nvim_buf_clear_namespace(buf_id, ns, 0, -1) + if not has_items_3f() then + state["end-line"] = start_line + return 0 + else + local _let_4_ = build_line() + local line = _let_4_.line + local highlights = _let_4_.highlights + nvim.nvim_buf_set_lines(buf_id, start_line, start_line, false, {line}) + for _, hl in ipairs(highlights) do + nvim.nvim_buf_set_extmark(buf_id, ns, start_line, hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) + end + state["end-line"] = (start_line + 1) + return 1 + end + end + local function add(item) + local exists + do + local found = false + for _, existing in ipairs(state.items) do + found = (found or (existing.text == item.text)) + end + exists = found + end + if not exists then + return table.insert(state.items, item) + else + return nil + end + end + local function remove(text) + do + local tbl_26_ = {} + local i_27_ = 0 + for _, item in ipairs(state.items) do + local val_28_ + if (item.text ~= text) then + val_28_ = item + else + val_28_ = nil + end + if (nil ~= val_28_) then + i_27_ = (i_27_ + 1) + tbl_26_[i_27_] = val_28_ + else + end + end + state.items = tbl_26_ + end + return nil + end + local function render_highlights(line_num) + if has_items_3f() then + local ns = ensure_ns() + local _let_9_ = build_line() + local highlights = _let_9_.highlights + nvim.nvim_buf_clear_namespace(buf_id, ns, 0, -1) + for _, hl in ipairs(highlights) do + nvim.nvim_buf_set_extmark(buf_id, ns, line_num, hl["col-start"], {end_col = hl["col-end"], hl_group = hl["hl-group"]}) + end + return nil + else + return nil + end + end + local function clear() + state.items = {} + return nil + end + local function get_state() + return state + end + local function get_end_line() + return state["end-line"] + end + return {render = render, ["render-highlights"] = render_highlights, add = add, remove = remove, clear = clear, ["has-items?"] = has_items_3f, ["get-state"] = get_state, ["get-end-line"] = get_end_line} +end +return {create = create} diff --git a/lua/eca/ui/widgets/header-bar.lua b/lua/eca/ui/widgets/header-bar.lua index 2fbc8b3..a98fb9d 100644 --- a/lua/eca/ui/widgets/header-bar.lua +++ b/lua/eca/ui/widgets/header-bar.lua @@ -1,10 +1,10 @@ -- [nfnl] fnl/eca/ui/widgets/header-bar.fnl local nvim = vim.api -local bar = require("eca.ui.components.bar-items") +local bar_items = require("eca.ui.components.bar-items") local function create(buf_id, win_id, initial_items) local items = (initial_items or {}) local function render() - nvim.nvim_set_option_value("winbar", bar.render({items = items}), {win = win_id}) + nvim.nvim_set_option_value("winbar", bar_items.render({items = items}), {win = win_id}) nvim.nvim_buf_set_lines(buf_id, 0, 1, false, {""}) return 1 end diff --git a/lua/eca/ui/widgets/message-list.lua b/lua/eca/ui/widgets/message-list.lua index 93e33b0..82523ce 100644 --- a/lua/eca/ui/widgets/message-list.lua +++ b/lua/eca/ui/widgets/message-list.lua @@ -71,20 +71,29 @@ local function create(buf_id, _3fopts) end local function stream_append_char(char) if (state["streaming-line"] and state["streaming-col"]) then - if (char == "\n") then - local function _13_() - local next_line = (state["streaming-line"] + 1) - nvim.nvim_buf_set_lines(buf_id, next_line, next_line, false, {""}) - state["end-line"] = (state["end-line"] + 1) - return on_line_inserted() + local buf_lines = nvim.nvim_buf_line_count(buf_id) + if (state["streaming-line"] < buf_lines) then + local current_line_text = (nvim.nvim_buf_get_lines(buf_id, state["streaming-line"], (state["streaming-line"] + 1), false)[1] or "") + local line_len = #current_line_text + local col = math.min(state["streaming-col"], line_len) + state["streaming-col"] = col + if (char == "\n") then + local function _13_() + local next_line = (state["streaming-line"] + 1) + nvim.nvim_buf_set_lines(buf_id, next_line, next_line, false, {""}) + state["end-line"] = (state["end-line"] + 1) + return on_line_inserted() + end + wrap_write(_13_) + state["streaming-line"] = (state["streaming-line"] + 1) + state["streaming-col"] = 0 + return nil + else + nvim.nvim_buf_set_text(buf_id, state["streaming-line"], state["streaming-col"], state["streaming-line"], state["streaming-col"], {char}) + state["streaming-col"] = (state["streaming-col"] + #char) + return nil end - wrap_write(_13_) - state["streaming-line"] = (state["streaming-line"] + 1) - state["streaming-col"] = 0 - return nil else - nvim.nvim_buf_set_text(buf_id, state["streaming-line"], state["streaming-col"], state["streaming-line"], state["streaming-col"], {char}) - state["streaming-col"] = (state["streaming-col"] + #char) return nil end else @@ -93,13 +102,17 @@ local function create(buf_id, _3fopts) end local function stream_tick() if (#state["streaming-queue"] > 0) then - local take = math.min(state["streaming-chars-per-tick"], #state["streaming-queue"]) - for i = 1, take do - local char = string.sub(state["streaming-queue"], i, i) - stream_append_char(char) - state["streaming-displayed"] = (state["streaming-displayed"] .. char) + local function _17_() + local take = math.min(state["streaming-chars-per-tick"], #state["streaming-queue"]) + for i = 1, take do + local char = string.sub(state["streaming-queue"], i, i) + stream_append_char(char) + state["streaming-displayed"] = (state["streaming-displayed"] .. char) + end + state["streaming-queue"] = string.sub(state["streaming-queue"], (take + 1)) + return nil end - state["streaming-queue"] = string.sub(state["streaming-queue"], (take + 1)) + wrap_write(_17_) else end if (#state["streaming-queue"] > 0) then @@ -133,6 +146,7 @@ local function create(buf_id, _3fopts) end local function render() local ns = ensure_ns() + nvim.nvim_buf_clear_namespace(buf_id, ns, 0, -1) nvim.nvim_buf_set_lines(buf_id, state["start-line"], state["end-line"], false, {}) state["end-line"] = state["start-line"] if (0 == #state.messages) then @@ -164,14 +178,14 @@ local function create(buf_id, _3fopts) state["streaming-id"] = msg.id state["streaming-displayed"] = "" state["streaming-queue"] = (msg.content or "") - local function _23_() + local function _25_() nvim.nvim_buf_set_lines(buf_id, state["end-line"], state["end-line"], false, {"", ""}) state["streaming-line"] = state["end-line"] state["streaming-col"] = 0 state["end-line"] = (state["end-line"] + 2) return nil end - wrap_write(_23_) + wrap_write(_25_) return start_streaming_timer() else if (1 == #state.messages) then diff --git a/lua/eca/ui/widgets/prompt-area.lua b/lua/eca/ui/widgets/prompt-area.lua index 7207313..9c02b62 100644 --- a/lua/eca/ui/widgets/prompt-area.lua +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -1,7 +1,6 @@ -- [nfnl] fnl/eca/ui/widgets/prompt-area.fnl local nvim = vim.api local prompt_prefix_component = require("eca.ui.components.prompt-prefix") -local context_bar_widget = require("eca.ui.widgets.context-bar") local function create(buf_id, _3fopts) local wrap_write local _2_ @@ -21,8 +20,9 @@ local function create(buf_id, _3fopts) or_4_ = _5_ end wrap_write = or_4_ - local state = {["prompt-text"] = "", history = {}, ["history-idx"] = 0, ["prompt-start-line"] = 0, ["ns-id"] = nil, ["status-text"] = nil, ["status-timer"] = nil, ["status-dots"] = 0, ["status-extmark-id"] = nil, ["loading?"] = false} - local ctx_bar = context_bar_widget.create(buf_id) + local state = {["prompt-text"] = "", history = {}, ["history-idx"] = 0, ["prompt-start-line"] = 0, ["status-anchor-line"] = 0, ["ns-id"] = nil, ["status-text"] = nil, ["status-timer"] = nil, ["status-dots"] = 0, ["status-extmark-id"] = nil, ["stop-extmark-id"] = nil, ["steering-text"] = nil, ["loading?"] = false} + local idle_prefix = prompt_prefix_component.render({["loading?"] = false}) + local loading_prefix = prompt_prefix_component.render({["loading?"] = true}) local function ensure_ns() if (nil == state["ns-id"]) then state["ns-id"] = nvim.nvim_create_namespace("eca-prompt-area") @@ -30,7 +30,7 @@ local function create(buf_id, _3fopts) end return state["ns-id"] end - local function update_status_virt_text() + local function update_status_virt() local ns = ensure_ns() if state["status-extmark-id"] then pcall(nvim.nvim_buf_del_extmark, buf_id, ns, state["status-extmark-id"]) @@ -40,63 +40,132 @@ local function create(buf_id, _3fopts) if state["status-text"] then local dots = string.rep(".", ((state["status-dots"] % 3) + 1)) local status_str = (state["status-text"] .. dots) - state["status-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, state["prompt-start-line"], 0, {virt_lines_above = true, virt_lines = {{{status_str, "EcaSpinner"}}}}) + state["status-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, state["status-anchor-line"], 0, {virt_lines = {{{status_str, "EcaSpinner"}}}}) return nil else return nil end end - local function render(start_line) - state["prompt-start-line"] = start_line + local function update_stop_virt() local ns = ensure_ns() - local prefix = prompt_prefix_component.render({["loading?"] = state["loading?"]}) - local ctx_state = ctx_bar["get-state"]() - local has_contexts_3f = (#ctx_state.items > 0) - local lines = {} - if has_contexts_3f then - local parts - do - local tbl_26_ = {} - local i_27_ = 0 - for _, item in ipairs(ctx_state.items) do - local val_28_ = item.text - if (nil ~= val_28_) then - i_27_ = (i_27_ + 1) - tbl_26_[i_27_] = val_28_ + if state["stop-extmark-id"] then + pcall(nvim.nvim_buf_del_extmark, buf_id, ns, state["stop-extmark-id"]) + state["stop-extmark-id"] = nil + else + end + if state["loading?"] then + state["stop-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, state["prompt-start-line"], 0, {virt_lines_above = true, virt_lines = {{{loading_prefix.text, loading_prefix["hl-group"]}, {"stop", "EcaStopLabel"}}}}) + return nil + else + return nil + end + end + local function update_virt_lines() + update_status_virt() + return update_stop_virt() + end + local function read_live_prompt_text() + local total = nvim.nvim_buf_line_count(buf_id) + local start = state["prompt-start-line"] + if ((total > 0) and (start <= (total - 1))) then + local lines = nvim.nvim_buf_get_lines(buf_id, start, total, false) + if (#lines > 0) then + do + local first_line = lines[1] + if vim.startswith(first_line, idle_prefix.text) then + lines[1] = string.sub(first_line, (#idle_prefix.text + 1)) else end end - parts = tbl_26_ + return table.concat(lines, "\n") + else + return nil end - table.insert(lines, table.concat(parts, " ")) else + return nil end - if state["loading?"] then - table.insert(lines, (prefix.text .. "stop")) + end + local function save_live_text() + do + local total = nvim.nvim_buf_line_count(buf_id) + local start = state["prompt-start-line"] + if ((start > 0) and (start <= (total - 1))) then + local first_line = (nvim.nvim_buf_get_lines(buf_id, start, (start + 1), false)[1] or "") + if vim.startswith(first_line, idle_prefix.text) then + local live = (read_live_prompt_text() or "") + state["prompt-text"] = live + else + end + else + end + end + return state["prompt-text"] + end + local function render(start_line, _3fskip_read) + if not _3fskip_read then + local live_text = (read_live_prompt_text() or state["prompt-text"]) + state["prompt-text"] = live_text else - table.insert(lines, (prefix.text .. state["prompt-text"])) end - nvim.nvim_buf_set_lines(buf_id, start_line, -1, false, lines) - if has_contexts_3f then - ctx_bar.render(start_line) + state["prompt-start-line"] = start_line + local ns = ensure_ns() + local _ = nvim.nvim_buf_clear_namespace(buf_id, ns, 0, -1) + local lines = {} + if state["steering-text"] then + local truncated + if (#state["steering-text"] > 50) then + truncated = (string.sub(state["steering-text"], 1, 50) .. "...") + else + truncated = state["steering-text"] + end + table.insert(lines, ("Steering: " .. truncated .. " [-]")) else end + do + local text_lines = vim.split(state["prompt-text"], "\n", {plain = true}) + table.insert(lines, (idle_prefix.text .. (text_lines[1] or ""))) + for i = 2, #text_lines do + table.insert(lines, text_lines[i]) + end + end + nvim.nvim_buf_set_lines(buf_id, start_line, -1, false, lines) do local prompt_line_idx = ((start_line + #lines) - 1) state["prompt-start-line"] = prompt_line_idx - nvim.nvim_buf_set_extmark(buf_id, ns, prompt_line_idx, 0, {end_col = #prefix.text, hl_group = prefix["hl-group"]}) - if state["loading?"] then - nvim.nvim_buf_set_extmark(buf_id, ns, prompt_line_idx, #prefix.text, {end_col = (#prefix.text + 4), hl_group = "EcaStopLabel"}) + if state["steering-text"] then + local steering_line_idx = (prompt_line_idx - 1) + local line_text = lines[((steering_line_idx - start_line) + 1)] + local cancel_start = (#line_text - 3) + nvim.nvim_buf_set_extmark(buf_id, ns, steering_line_idx, 0, {end_col = math.min(10, #line_text), hl_group = "EcaSteeringLabel"}) + nvim.nvim_buf_set_extmark(buf_id, ns, steering_line_idx, cancel_start, {end_col = #line_text, hl_group = "EcaStopLabel"}) + else + end + local buf_line = (nvim.nvim_buf_get_lines(buf_id, prompt_line_idx, (prompt_line_idx + 1), false)[1] or "") + if (#buf_line >= #idle_prefix.text) then + nvim.nvim_buf_set_extmark(buf_id, ns, prompt_line_idx, 0, {end_col = #idle_prefix.text, hl_group = idle_prefix["hl-group"]}) else end end - update_status_virt_text() + update_virt_lines() return #lines end + local function render_highlights(prompt_line) + state["prompt-start-line"] = prompt_line + local ns = ensure_ns() + nvim.nvim_buf_clear_namespace(buf_id, ns, 0, -1) + do + local buf_line = (nvim.nvim_buf_get_lines(buf_id, prompt_line, (prompt_line + 1), false)[1] or "") + if (#buf_line >= #idle_prefix.text) then + nvim.nvim_buf_set_extmark(buf_id, ns, prompt_line, 0, {end_col = #idle_prefix.text, hl_group = idle_prefix["hl-group"]}) + else + end + end + return update_virt_lines() + end local function animate_dots() state["status-dots"] = (state["status-dots"] + 1) if (state["status-text"] and nvim.nvim_buf_is_valid(buf_id)) then - return update_status_virt_text() + return update_virt_lines() else return nil end @@ -123,49 +192,55 @@ local function create(buf_id, _3fopts) else state["status-text"] = nil state["status-timer"] = nil - return update_status_virt_text() + return update_virt_lines() end end local function set_loading(bool) state["loading?"] = bool + return update_virt_lines() + end + local function set_status_anchor_line(line) + state["status-anchor-line"] = line + return nil + end + local function set_steering(text) + state["steering-text"] = text return nil end local function get_text() - if not state["loading?"] then - local total = nvim.nvim_buf_line_count(buf_id) - local prompt_lines = nvim.nvim_buf_get_lines(buf_id, state["prompt-start-line"], total, false) - local prefix = prompt_prefix_component.render({["loading?"] = false}) - if (prompt_lines and (#prompt_lines > 0)) then - local first_line = prompt_lines[1] - local stripped - if vim.startswith(first_line, prefix.text) then - stripped = string.sub(first_line, (#prefix.text + 1)) - else - stripped = first_line - end - local parts = {stripped} - for i = 2, #prompt_lines do - table.insert(parts, prompt_lines[i]) - end - return table.concat(parts, "\n") + local total = nvim.nvim_buf_line_count(buf_id) + local start = state["prompt-start-line"] + local lines = nvim.nvim_buf_get_lines(buf_id, start, total, false) + if (#lines > 0) then + local first_line = lines[1] + if vim.startswith(first_line, idle_prefix.text) then + lines[1] = string.sub(first_line, (#idle_prefix.text + 1)) else - return nil end else - return nil end + return table.concat(lines, "\n") end local function set_text(text) state["prompt-text"] = (text or "") if not state["loading?"] then - local prefix = prompt_prefix_component.render({["loading?"] = false}) + local start = state["prompt-start-line"] local total = nvim.nvim_buf_line_count(buf_id) - local last_line_idx = (total - 1) - return nvim.nvim_buf_set_lines(buf_id, last_line_idx, total, false, {(prefix.text .. state["prompt-text"])}) + local text_lines = vim.split(state["prompt-text"], "\n", {plain = true}) + local buf_lines = {} + table.insert(buf_lines, (idle_prefix.text .. (text_lines[1] or ""))) + for i = 2, #text_lines do + table.insert(buf_lines, text_lines[i]) + end + return nvim.nvim_buf_set_lines(buf_id, start, total, false, buf_lines) else return nil end end + local function set_text_internal(text) + state["prompt-text"] = (text or "") + return nil + end local function clear() return set_text("") end @@ -195,15 +270,35 @@ local function create(buf_id, _3fopts) return set_text("") end end - local function add_context(ctx) - return ctx_bar.add(ctx) - end - local function remove_context(name) - return ctx_bar.remove(name) + local function _32_() + local event = vim.v.event + local lines = event.regcontents + local first = (lines[1] or "") + if vim.startswith(first, idle_prefix.text) then + local stripped = string.sub(first, (#idle_prefix.text + 1)) + local reg = (event.regname or "\"") + lines[1] = stripped + local function _33_() + vim.fn.setreg(reg, lines, event.regtype) + if (reg ~= "+") then + vim.fn.setreg("+", lines, event.regtype) + else + end + if (reg ~= "*") then + return vim.fn.setreg("*", lines, event.regtype) + else + return nil + end + end + return vim.schedule(_33_) + else + return nil + end end + nvim.nvim_create_autocmd("TextYankPost", {buffer = buf_id, callback = _32_}) local function get_state() return state end - return {render = render, ["get-text"] = get_text, ["set-text"] = set_text, clear = clear, ["set-status"] = set_status, ["set-loading"] = set_loading, ["add-to-history"] = add_to_history, ["history-prev"] = history_prev, ["history-next"] = history_next, ["add-context"] = add_context, ["remove-context"] = remove_context, ["get-state"] = get_state} + return {render = render, ["render-highlights"] = render_highlights, ["get-text"] = get_text, ["save-live-text"] = save_live_text, ["set-text"] = set_text, ["set-text-internal"] = set_text_internal, clear = clear, ["set-status"] = set_status, ["set-loading"] = set_loading, ["set-steering"] = set_steering, ["set-status-anchor-line"] = set_status_anchor_line, ["add-to-history"] = add_to_history, ["history-prev"] = history_prev, ["history-next"] = history_next, ["get-state"] = get_state} end return {create = create} From aa1579ccfca5965c7ad45d6784da9b8cf0ed06d9 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 15 May 2026 17:03:42 -0300 Subject: [PATCH 6/8] add basic ui functionality --- fnl/eca/ui/builder.fnl | 7 ++++-- fnl/eca/ui/widgets/prompt-area.fnl | 20 ++++++++++++++--- lua/eca/ui/builder.lua | 4 +++- lua/eca/ui/widgets/prompt-area.lua | 35 ++++++++++++++++++++---------- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl index ba2dcd7..0b655c0 100644 --- a/fnl/eca/ui/builder.fnl +++ b/fnl/eca/ui/builder.fnl @@ -142,8 +142,11 @@ (when (and win-id (nvim.nvim_win_is_valid win-id)) (let [total (nvim.nvim_buf_line_count buf-id) prompt-state (widgets.prompt.get-state) - prompt-line (or prompt-state.prompt-start-line (- total 1))] - (nvim.nvim_win_set_cursor win-id [(+ prompt-line 1) 2])))) + prompt-line (or prompt-state.prompt-start-line (- total 1)) + ;; Position cursor at end of the prompt line, not at col 2 + line-text (or (. (nvim.nvim_buf_get_lines buf-id prompt-line (+ prompt-line 1) false) 1) "> ") + col (length line-text)] + (nvim.nvim_win_set_cursor win-id [(+ prompt-line 1) col])))) (fn make-separator [] (let [win (vim.fn.bufwinid buf-id) diff --git a/fnl/eca/ui/widgets/prompt-area.fnl b/fnl/eca/ui/widgets/prompt-area.fnl index 11f4a8a..a1c58a6 100644 --- a/fnl/eca/ui/widgets/prompt-area.fnl +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -254,13 +254,27 @@ first (or (. lines 1) "")] (when (vim.startswith first idle-prefix.text) (let [stripped (string.sub first (+ (length idle-prefix.text) 1)) - reg (or event.regname "\"")] + reg (if (and event.regname (not= event.regname "")) + event.regname + "\"")] (tset lines 1 stripped) (vim.schedule (fn [] (vim.fn.setreg reg lines event.regtype) - (when (not= reg "+") (vim.fn.setreg "+" lines event.regtype)) - (when (not= reg "*") (vim.fn.setreg "*" lines event.regtype))))))))}) + (vim.fn.setreg "+" lines event.regtype) + (vim.fn.setreg "*" lines event.regtype)))))))}) + + ;; Protect the "> " prefix — keep cursor at col >= 2 on the prompt line. + (nvim.nvim_create_autocmd :CursorMovedI + {:buffer buf-id + :callback (fn [] + (let [cursor (nvim.nvim_win_get_cursor 0) + row (. cursor 1) + col (. cursor 2) + total (nvim.nvim_buf_line_count buf-id) + prompt-row (+ state.prompt-start-line 1)] + (when (and (= row prompt-row) (< col (length idle-prefix.text))) + (nvim.nvim_win_set_cursor 0 [row (length idle-prefix.text)]))))}) (fn get-state [] state) diff --git a/lua/eca/ui/builder.lua b/lua/eca/ui/builder.lua index 7ab5d6c..bda51c1 100644 --- a/lua/eca/ui/builder.lua +++ b/lua/eca/ui/builder.lua @@ -170,7 +170,9 @@ local function create_chat_ui(_14_) local total = nvim.nvim_buf_line_count(buf_id) local prompt_state = widgets.prompt["get-state"]() local prompt_line = (prompt_state["prompt-start-line"] or (total - 1)) - return nvim.nvim_win_set_cursor(win_id, {(prompt_line + 1), 2}) + local line_text = (nvim.nvim_buf_get_lines(buf_id, prompt_line, (prompt_line + 1), false)[1] or "> ") + local col = #line_text + return nvim.nvim_win_set_cursor(win_id, {(prompt_line + 1), col}) else return nil end diff --git a/lua/eca/ui/widgets/prompt-area.lua b/lua/eca/ui/widgets/prompt-area.lua index 9c02b62..5cf74d6 100644 --- a/lua/eca/ui/widgets/prompt-area.lua +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -276,26 +276,37 @@ local function create(buf_id, _3fopts) local first = (lines[1] or "") if vim.startswith(first, idle_prefix.text) then local stripped = string.sub(first, (#idle_prefix.text + 1)) - local reg = (event.regname or "\"") + local reg + if (event.regname and (event.regname ~= "")) then + reg = event.regname + else + reg = "\"" + end lines[1] = stripped - local function _33_() + local function _34_() vim.fn.setreg(reg, lines, event.regtype) - if (reg ~= "+") then - vim.fn.setreg("+", lines, event.regtype) - else - end - if (reg ~= "*") then - return vim.fn.setreg("*", lines, event.regtype) - else - return nil - end + vim.fn.setreg("+", lines, event.regtype) + return vim.fn.setreg("*", lines, event.regtype) end - return vim.schedule(_33_) + return vim.schedule(_34_) else return nil end end nvim.nvim_create_autocmd("TextYankPost", {buffer = buf_id, callback = _32_}) + local function _36_() + local cursor = nvim.nvim_win_get_cursor(0) + local row = cursor[1] + local col = cursor[2] + local total = nvim.nvim_buf_line_count(buf_id) + local prompt_row = (state["prompt-start-line"] + 1) + if ((row == prompt_row) and (col < #idle_prefix.text)) then + return nvim.nvim_win_set_cursor(0, {row, #idle_prefix.text}) + else + return nil + end + end + nvim.nvim_create_autocmd("CursorMovedI", {buffer = buf_id, callback = _36_}) local function get_state() return state end From f65bd9589f31267817de0fe18a7723fe828a78c2 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 15 May 2026 17:06:18 -0300 Subject: [PATCH 7/8] fix footer-bar bug when changing theme --- fnl/eca/ui/widgets/footer-bar.fnl | 5 +++-- lua/eca/ui/widgets/footer-bar.lua | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fnl/eca/ui/widgets/footer-bar.fnl b/fnl/eca/ui/widgets/footer-bar.fnl index 8195d38..c91a944 100644 --- a/fnl/eca/ui/widgets/footer-bar.fnl +++ b/fnl/eca/ui/widgets/footer-bar.fnl @@ -24,8 +24,9 @@ (apply) 0) - ;; Re-apply on focus (needed for global mode after statusline plugin overwrites) - (nvim.nvim_create_autocmd :WinEnter + ;; Re-apply on focus or after theme change (colorscheme/background change + ;; causes statusline plugins to reset, overwriting our statusline) + (nvim.nvim_create_autocmd [:WinEnter :ColorScheme] {:callback (fn [] (when (and active (nvim.nvim_buf_is_valid buf-id)) (when (= (nvim.nvim_get_current_buf) buf-id) diff --git a/lua/eca/ui/widgets/footer-bar.lua b/lua/eca/ui/widgets/footer-bar.lua index 7ea6f5a..2665574 100644 --- a/lua/eca/ui/widgets/footer-bar.lua +++ b/lua/eca/ui/widgets/footer-bar.lua @@ -38,7 +38,7 @@ local function create(buf_id, win_id, initial_items) return nil end end - nvim.nvim_create_autocmd("WinEnter", {callback = _3_}) + nvim.nvim_create_autocmd({"WinEnter", "ColorScheme"}, {callback = _3_}) local function update(new_items) items = new_items if active then From aac132ce8a8e27a1cbf9bc731c02108e8dec9670 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 15 May 2026 17:25:21 -0300 Subject: [PATCH 8/8] fix status appearing after chat separator --- fnl/eca/ui/builder.fnl | 20 ++++++++---- fnl/eca/ui/widgets/message-list.fnl | 47 ++++++++++++--------------- fnl/eca/ui/widgets/prompt-area.fnl | 24 +++++++++----- image.png | Bin 162333 -> 0 bytes lua/eca/ui/builder.lua | 48 ++++++++++++++++------------ lua/eca/ui/widgets/message-list.lua | 14 ++++---- lua/eca/ui/widgets/prompt-area.lua | 8 +++-- 7 files changed, 91 insertions(+), 70 deletions(-) delete mode 100644 image.png diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl index 0b655c0..eb3a414 100644 --- a/fnl/eca/ui/builder.fnl +++ b/fnl/eca/ui/builder.fnl @@ -266,9 +266,10 @@ {:wrap-write with-internal-edit :on-line-inserted (fn [] - ;; Prompt physically moved down, update its tracked position + ;; Everything below the streaming area moved down (let [s (widgets.prompt.get-state)] - (set s.prompt-start-line (+ s.prompt-start-line 1))))})) + (set s.prompt-start-line (+ s.prompt-start-line 1)) + (set s.status-anchor-line (+ s.status-anchor-line 1))))})) (when state.welcome (widgets.messages.set-welcome {:lines [state.welcome ""] @@ -309,15 +310,22 @@ (with-internal-edit (fn [] (widgets.messages.append-message msg) + ;; Re-render prompt area after message is added. + ;; For streaming, this runs after the empty lines are inserted + ;; but before the timer starts writing chars, so it's safe. (render-prompt-area))) (focus-prompt))) (fn update-message [id content] (when (is-open?) - (with-internal-edit - (fn [] - (widgets.messages.update-message id content) - (render-prompt-area))))) + (let [msg-state (widgets.messages.get-state)] + (with-internal-edit + (fn [] + (widgets.messages.update-message id content) + ;; Don't re-render prompt during streaming updates — + ;; it would interfere with char-by-char buffer writes + (when (not= id msg-state.streaming-id) + (render-prompt-area))))))) (fn finish-streaming [id] (when (is-open?) diff --git a/fnl/eca/ui/widgets/message-list.fnl b/fnl/eca/ui/widgets/message-list.fnl index 3b757b0..6e55035 100644 --- a/fnl/eca/ui/widgets/message-list.fnl +++ b/fnl/eca/ui/widgets/message-list.fnl @@ -55,34 +55,26 @@ "Append a single character to the streaming position using set_text. Handles newlines by inserting a new line." (when (and state.streaming-line state.streaming-col) - ;; Guard: ensure the streaming line is still within the buffer (let [buf-lines (nvim.nvim_buf_line_count buf-id)] (when (< state.streaming-line buf-lines) - (let [current-line-text (or (. (nvim.nvim_buf_get_lines buf-id - state.streaming-line - (+ state.streaming-line 1) false) 1) "") - line-len (length current-line-text) - col (math.min state.streaming-col line-len)] - ;; Sync col in case buffer was re-rendered - (set state.streaming-col col) - (if (= char "\n") - ;; Newline: insert new line, prompt shifts down naturally - (do - (wrap-write - (fn [] - (let [next-line (+ state.streaming-line 1)] - (nvim.nvim_buf_set_lines buf-id next-line next-line false [""]) - (set state.end-line (+ state.end-line 1)) - (on-line-inserted)))) - (set state.streaming-line (+ state.streaming-line 1)) - (set state.streaming-col 0)) - ;; Regular char: append at current position - (do - (nvim.nvim_buf_set_text buf-id - state.streaming-line state.streaming-col - state.streaming-line state.streaming-col - [char]) - (set state.streaming-col (+ state.streaming-col (length char)))))))))) + (if (= char "\n") + ;; Newline: insert new line, prompt shifts down naturally + (do + (wrap-write + (fn [] + (let [next-line (+ state.streaming-line 1)] + (nvim.nvim_buf_set_lines buf-id next-line next-line false [""]) + (set state.end-line (+ state.end-line 1)) + (on-line-inserted)))) + (set state.streaming-line (+ state.streaming-line 1)) + (set state.streaming-col 0)) + ;; Regular char: append at current position + (do + (pcall nvim.nvim_buf_set_text buf-id + state.streaming-line state.streaming-col + state.streaming-line state.streaming-col + [char]) + (set state.streaming-col (+ state.streaming-col (length char))))))))) (fn stream-tick [] "Process one tick: move chars from queue to buffer." @@ -141,6 +133,9 @@ (set state.end-line (+ state.end-line lines-written))))))) (fn append-message [msg] + ;; Finish any in-progress streaming before appending a new message + (when state.streaming-id + (finish-streaming state.streaming-id)) (table.insert state.messages msg) (if msg.streaming? ;; Streaming: create an empty line, stream chars into it diff --git a/fnl/eca/ui/widgets/prompt-area.fnl b/fnl/eca/ui/widgets/prompt-area.fnl index a1c58a6..99e2642 100644 --- a/fnl/eca/ui/widgets/prompt-area.fnl +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -28,30 +28,38 @@ state.ns-id) (fn update-status-virt [] - "Update status virtual text below the separator (before context-area)." + "Update status virtual text below the separator." (let [ns (ensure-ns)] + ;; Always remove old extmark (when state.status-extmark-id (pcall nvim.nvim_buf_del_extmark buf-id ns state.status-extmark-id) (set state.status-extmark-id nil)) + ;; Recreate at current tracked position (when state.status-text (let [dots (string.rep "." (+ (% state.status-dots 3) 1)) - status-str (.. state.status-text dots)] + status-str (.. state.status-text dots) + total (nvim.nvim_buf_line_count buf-id) + anchor (math.min state.status-anchor-line (- total 1))] (set state.status-extmark-id - (nvim.nvim_buf_set_extmark buf-id ns state.status-anchor-line 0 + (nvim.nvim_buf_set_extmark buf-id ns anchor 0 {:virt_lines [[[status-str :EcaSpinner]]]})))))) (fn update-stop-virt [] "Update stop virtual text above the prompt line." (let [ns (ensure-ns)] + ;; Always remove old extmark (when state.stop-extmark-id (pcall nvim.nvim_buf_del_extmark buf-id ns state.stop-extmark-id) (set state.stop-extmark-id nil)) + ;; Recreate at current tracked position (when state.loading? - (set state.stop-extmark-id - (nvim.nvim_buf_set_extmark buf-id ns state.prompt-start-line 0 - {:virt_lines_above true - :virt_lines [[[loading-prefix.text loading-prefix.hl-group] - ["stop" :EcaStopLabel]]]}))))) + (let [total (nvim.nvim_buf_line_count buf-id) + anchor (math.min state.prompt-start-line (- total 1))] + (set state.stop-extmark-id + (nvim.nvim_buf_set_extmark buf-id ns anchor 0 + {:virt_lines_above true + :virt_lines [[[loading-prefix.text loading-prefix.hl-group] + ["stop" :EcaStopLabel]]]})))))) (fn update-virt-lines [] "Update all virtual lines." diff --git a/image.png b/image.png deleted file mode 100644 index 0977628898a93d1c7060557631b919a9213fe7c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 162333 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 >~~T-}e7;fvMC*4`@Ax_Kkn$BO6O$7QDo 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�)@0nPygok!Wfgbd5q}PN8Qd%yKr8-#NyM_ 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 JC6{go{|Dz&kh%Z> diff --git a/lua/eca/ui/builder.lua b/lua/eca/ui/builder.lua index bda51c1..70e3615 100644 --- a/lua/eca/ui/builder.lua +++ b/lua/eca/ui/builder.lua @@ -344,6 +344,7 @@ local function create_chat_ui(_14_) local function _38_() local s = widgets.prompt["get-state"]() s["prompt-start-line"] = (s["prompt-start-line"] + 1) + s["status-anchor-line"] = (s["status-anchor-line"] + 1) return nil end widgets.messages = message_list_widget.create(buf_id, {["wrap-write"] = with_internal_edit, ["on-line-inserted"] = _38_}) @@ -394,9 +395,14 @@ local function create_chat_ui(_14_) end local function update_message(id, content) if is_open_3f() then + local msg_state = widgets.messages["get-state"]() local function _45_() widgets.messages["update-message"](id, content) - return render_prompt_area() + if (id ~= msg_state["streaming-id"]) then + return render_prompt_area() + else + return nil + end end return with_internal_edit(_45_) else @@ -405,22 +411,22 @@ local function create_chat_ui(_14_) end local function finish_streaming(id) if is_open_3f() then - local function _47_() + local function _48_() widgets.messages["finish-streaming"](id) return render_prompt_area() end - return with_internal_edit(_47_) + return with_internal_edit(_48_) else return nil end end local function clear_messages() if is_open_3f() then - local function _49_() + local function _50_() widgets.messages.clear() return render_prompt_area() end - return with_internal_edit(_49_) + return with_internal_edit(_50_) else return nil end @@ -428,10 +434,10 @@ local function create_chat_ui(_14_) local function update_header(new_items) state["header-items"] = new_items if is_open_3f() then - local function _51_() + local function _52_() return widgets.header.update(new_items) end - return with_internal_edit(_51_) + return with_internal_edit(_52_) else return nil end @@ -450,10 +456,10 @@ local function create_chat_ui(_14_) else end if is_open_3f() then - local function _55_() + local function _56_() return widgets.header.update(state["header-items"]) end - return with_internal_edit(_55_) + return with_internal_edit(_56_) else return nil end @@ -461,10 +467,10 @@ local function create_chat_ui(_14_) local function update_footer(new_items) state["footer-items"] = new_items if is_open_3f() then - local function _57_() + local function _58_() return widgets.footer.update(new_items) end - return with_internal_edit(_57_) + return with_internal_edit(_58_) else return nil end @@ -483,10 +489,10 @@ local function create_chat_ui(_14_) else end if is_open_3f() then - local function _61_() + local function _62_() return widgets.footer.update(state["footer-items"]) end - return with_internal_edit(_61_) + return with_internal_edit(_62_) else return nil end @@ -497,10 +503,10 @@ local function create_chat_ui(_14_) widgets.messages["set-welcome"]({lines = {text, ""}, highlights = {{["line-idx"] = 0, ["hl-group"] = "EcaWelcome", ["col-start"] = 0, ["col-end"] = #text}}}) local msg_state = widgets.messages["get-state"]() if (0 == #msg_state.messages) then - local function _63_() + local function _64_() return render_all() end - return with_internal_edit(_63_) + return with_internal_edit(_64_) else return nil end @@ -517,11 +523,11 @@ local function create_chat_ui(_14_) end local function add_context(ctx) if is_open_3f() then - local function _67_() + local function _68_() widgets.context.add(ctx) return render_prompt_area() end - with_internal_edit(_67_) + with_internal_edit(_68_) return focus_prompt() else return nil @@ -529,11 +535,11 @@ local function create_chat_ui(_14_) end local function remove_context(name) if is_open_3f() then - local function _69_() + local function _70_() widgets.context.remove(name) return render_prompt_area() end - return with_internal_edit(_69_) + return with_internal_edit(_70_) else return nil end @@ -545,11 +551,11 @@ local function create_chat_ui(_14_) if (not bool and state["queued-prompt"]) then local queued = state["queued-prompt"] state["queued-prompt"] = nil - local function _71_() + local function _72_() widgets.prompt["set-steering"](nil) return render_prompt_area() end - with_internal_edit(_71_) + with_internal_edit(_72_) if on_submit then return on_submit(queued) else diff --git a/lua/eca/ui/widgets/message-list.lua b/lua/eca/ui/widgets/message-list.lua index 82523ce..5affc88 100644 --- a/lua/eca/ui/widgets/message-list.lua +++ b/lua/eca/ui/widgets/message-list.lua @@ -73,10 +73,6 @@ local function create(buf_id, _3fopts) if (state["streaming-line"] and state["streaming-col"]) then local buf_lines = nvim.nvim_buf_line_count(buf_id) if (state["streaming-line"] < buf_lines) then - local current_line_text = (nvim.nvim_buf_get_lines(buf_id, state["streaming-line"], (state["streaming-line"] + 1), false)[1] or "") - local line_len = #current_line_text - local col = math.min(state["streaming-col"], line_len) - state["streaming-col"] = col if (char == "\n") then local function _13_() local next_line = (state["streaming-line"] + 1) @@ -89,7 +85,7 @@ local function create(buf_id, _3fopts) state["streaming-col"] = 0 return nil else - nvim.nvim_buf_set_text(buf_id, state["streaming-line"], state["streaming-col"], state["streaming-line"], state["streaming-col"], {char}) + pcall(nvim.nvim_buf_set_text, buf_id, state["streaming-line"], state["streaming-col"], state["streaming-line"], state["streaming-col"], {char}) state["streaming-col"] = (state["streaming-col"] + #char) return nil end @@ -173,19 +169,23 @@ local function create(buf_id, _3fopts) end end local function append_message(msg) + if state["streaming-id"] then + __fnl_global__finish_2dstreaming(state["streaming-id"]) + else + end table.insert(state.messages, msg) if msg["streaming?"] then state["streaming-id"] = msg.id state["streaming-displayed"] = "" state["streaming-queue"] = (msg.content or "") - local function _25_() + local function _26_() nvim.nvim_buf_set_lines(buf_id, state["end-line"], state["end-line"], false, {"", ""}) state["streaming-line"] = state["end-line"] state["streaming-col"] = 0 state["end-line"] = (state["end-line"] + 2) return nil end - wrap_write(_25_) + wrap_write(_26_) return start_streaming_timer() else if (1 == #state.messages) then diff --git a/lua/eca/ui/widgets/prompt-area.lua b/lua/eca/ui/widgets/prompt-area.lua index 5cf74d6..c73fc13 100644 --- a/lua/eca/ui/widgets/prompt-area.lua +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -40,7 +40,9 @@ local function create(buf_id, _3fopts) if state["status-text"] then local dots = string.rep(".", ((state["status-dots"] % 3) + 1)) local status_str = (state["status-text"] .. dots) - state["status-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, state["status-anchor-line"], 0, {virt_lines = {{{status_str, "EcaSpinner"}}}}) + local total = nvim.nvim_buf_line_count(buf_id) + local anchor = math.min(state["status-anchor-line"], (total - 1)) + state["status-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, anchor, 0, {virt_lines = {{{status_str, "EcaSpinner"}}}}) return nil else return nil @@ -54,7 +56,9 @@ local function create(buf_id, _3fopts) else end if state["loading?"] then - state["stop-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, state["prompt-start-line"], 0, {virt_lines_above = true, virt_lines = {{{loading_prefix.text, loading_prefix["hl-group"]}, {"stop", "EcaStopLabel"}}}}) + local total = nvim.nvim_buf_line_count(buf_id) + local anchor = math.min(state["prompt-start-line"], (total - 1)) + state["stop-extmark-id"] = nvim.nvim_buf_set_extmark(buf_id, ns, anchor, 0, {virt_lines_above = true, virt_lines = {{{loading_prefix.text, loading_prefix["hl-group"]}, {"stop", "EcaStopLabel"}}}}) return nil else return nil