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..d48ebe4 --- /dev/null +++ b/fnl/eca/api.fnl @@ -0,0 +1,146 @@ +;; 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) + :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"} + {: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/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)] + (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-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)] + (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 + :collapsed? true + :collapse-prefix "▸ "}) + ;; Set loading + status + (chat.set-loading true) + (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 {}))) + +self diff --git a/fnl/eca/commands.fnl b/fnl/eca/commands.fnl new file mode 100644 index 0000000..f00e519 --- /dev/null +++ b/fnl/eca/commands.fnl @@ -0,0 +1,42 @@ +;; commands — Vim user commands for ECA. +;; Uses the public chat API from api.fnl. + +(local nvim vim.api) + +(fn setup [api] + "Register all :Eca* user commands." + (nvim.nvim_create_user_command "EcaChat" + (fn [] (api.chat-toggle)) + {:desc "Toggle ECA Chat window"}) + + (nvim.nvim_create_user_command "EcaChatOpen" + (fn [] (api.chat-open)) + {:desc "Open ECA Chat window"}) + + (nvim.nvim_create_user_command "EcaChatClose" + (fn [] (api.chat-close)) + {:desc "Close ECA Chat window"}) + + (nvim.nvim_create_user_command "EcaChatClear" + (fn [] (api.chat-clear)) + {:desc "Clear current chat messages"}) + + (nvim.nvim_create_user_command "EcaChatNew" + (fn [] (api.chat-open)) + {:desc "Open a new ECA Chat"}) + + (nvim.nvim_create_user_command "EcaChatSubmit" + (fn [] (api.chat-submit)) + {:desc "Submit current prompt"}) + + (nvim.nvim_create_user_command "EcaChatStop" + (fn [] (api.chat-stop)) + {: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 0752b83..e8e74c3 100644 --- a/fnl/eca/init.fnl +++ b/fnl/eca/init.fnl @@ -1,7 +1,12 @@ -(local {: autoload} (require :eca.nfnl.module)) -(local notify (autoload :eca.nfnl.notify)) +;; ECA Neovim Plugin — entry point. +;; Minimal: just setup, delegates everything to api.fnl. -(fn setup [] - (notify.info "Hello, World!")) +(local api (require :eca.api)) +(local commands (require :eca.commands)) + +(fn setup [opts] + "Initialize ECA plugin." + (api.set-plugin-opts (or opts {})) + (commands.setup api)) {: setup} diff --git a/fnl/eca/ui/builder.fnl b/fnl/eca/ui/builder.fnl new file mode 100644 index 0000000..eb3a414 --- /dev/null +++ b/fnl/eca/ui/builder.fnl @@ -0,0 +1,434 @@ +;; 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 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)) + +;; ── 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}) + (disable-statusline-plugins)) + +(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 [buf-id render-all-fn get-prompt-state focus-prompt-fn] + (var internal-edit false) + + (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) + 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 (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 2 + :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-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 update-expected-count [] nil) + + {: set-internal : update-expected-count}) + +;; ── Main entry ────────────────────────────────────────── + +(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) + :keymaps (or opts.keymaps [])}] + + ;; Mutable state + (local state {:header-items [] + :footer-items [] + :welcome nil + :queued-prompt nil}) + + (var buf-id nil) + (var win-id nil) + (var guard nil) + (local widgets {:header nil :messages nil :context nil :prompt nil :footer nil}) + + (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] + (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)) + (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)) + ;; 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) + 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) + (render-prompt-area)) + (when widgets.footer + (widgets.footer.render))))) + + ;; Functions needed before open + + (fn close [] + (when (is-open?) + (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) + text (widgets.prompt.get-text)] + (if prompt-state.loading? + ;; 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 + + (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 + {:wrap-write with-internal-edit + :on-line-inserted + (fn [] + ;; 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.status-anchor-line (+ s.status-anchor-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.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)) + + ;; 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 [] + (if (is-open?) (close) (open))) + + (fn get-buf-id [] buf-id) + + ;; Message API + (fn append-message [msg] + (when (is-open?) + (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?) + (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?) + (with-internal-edit + (fn [] + (widgets.messages.finish-streaming id) + (render-prompt-area))))) + + (fn clear-messages [] + (when (is-open?) + (with-internal-edit + (fn [] + (widgets.messages.clear) + (render-prompt-area))))) + + ;; State updates + (fn update-header [new-items] + (set state.header-items new-items) + (when (is-open?) + (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?) + (with-internal-edit (fn [] (widgets.header.update state.header-items))))) + + (fn update-footer [new-items] + (set state.footer-items new-items) + (when (is-open?) + (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?) + (with-internal-edit (fn [] (widgets.footer.update state.footer-items))))) + + (fn set-welcome [text] + (set state.welcome text) + (when (is-open?) + (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-status [text] + "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.context.add ctx) + (render-prompt-area))) + (focus-prompt))) + + (fn remove-context [name] + (when (is-open?) + (with-internal-edit + (fn [] + (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 : stop : cancel-steering + : set-status : set-loading + : add-context : remove-context})) + +{: 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/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..e012da1 --- /dev/null +++ b/fnl/eca/ui/components/context-item.fnl @@ -0,0 +1,12 @@ +;; context-item component — renders a tagged text item. +;; Stateless, pure function. Zero business logic. + +(fn render [{: text : hl-group}] + "Render a context item. + text: display text (e.g. '@file.lua', '@repoMap') + hl-group: highlight group + Returns {: text : 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 new file mode 100644 index 0000000..ea09545 --- /dev/null +++ b/fnl/eca/ui/components/icon.fnl @@ -0,0 +1,26 @@ +;; icon component — maps UI semantic names to unicode icons. +;; Stateless, pure function. Handles UI concepts, not business logic. + +(local icons + {:collapsed "⏵" + :expanded "⏷" + :loading "⏳" + :success "✅" + :error "❌" + :warning "⚠️" + :info "ℹ️" + :stop "⏹" + :new "+" + :close "×"}) + +(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}." + {:text (or text (. icons name) "?") + :hl-group (or hl-group :Normal)}) + +{: 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..7969adb --- /dev/null +++ b/fnl/eca/ui/components/message.fnl @@ -0,0 +1,50 @@ +;; message component — renders a text block with optional prefix. +;; Stateless, pure function. + +(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 [{: 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 + collapsed?: if true, render single line with collapse-prefix + collapse-prefix: prefix for collapsed view (e.g. '▸ ') + Returns {: lines : highlights}." + (let [pfx (or prefix "") + hl (or hl-group (when (and prefix (> (length prefix) 0)) :EcaMessagePrefix)) + content-lines (split-lines (or content "")) + lines [] + highlights []] + (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/components/prompt-prefix.fnl b/fnl/eca/ui/components/prompt-prefix.fnl new file mode 100644 index 0000000..ff9b11e --- /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..54ca777 --- /dev/null +++ b/fnl/eca/ui/components/usage.fnl @@ -0,0 +1,12 @@ +;; 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 new file mode 100644 index 0000000..bee09df --- /dev/null +++ b/fnl/eca/ui/highlights.fnl @@ -0,0 +1,44 @@ +;; Highlight groups — linked to standard Neovim groups. + +(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"} + :EcaSteeringLabel {:link "WarningMsg"} + :EcaTabActive {:link "TabLineSel"} + :EcaTabInactive {:link "TabLine"} + :EcaTabLoading {:link "WarningMsg"}}) + +(fn setup [] + (each [group opts (pairs groups)] + (nvim.nvim_set_hl 0 group opts))) + +{: groups : setup} 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/context-bar.fnl b/fnl/eca/ui/widgets/context-bar.fnl new file mode 100644 index 0000000..6df89ac --- /dev/null +++ b/fnl/eca/ui/widgets/context-bar.fnl @@ -0,0 +1,58 @@ +;; context-bar widget — horizontal bar of tagged items. + +(local nvim vim.api) + +(fn create [buf-id] + (local state {:items [] :ns-id nil}) + + (fn ensure-ns [] + (when (= nil state.ns-id) + (set state.ns-id (nvim.nvim_create_namespace "eca-context-bar"))) + state.ns-id) + + (fn build-line [] + (if (= 0 (length state.items)) + {:line "" :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 render [line-num] + (let [ns (ensure-ns) + {: line : highlights} (build-line)] + (when (and line (not= "" line)) + (nvim.nvim_buf_set_lines buf-id line-num (+ line-num 1) false [line]) + (each [_ hl (ipairs (or 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 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 clear [] (set state.items [])) + (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..a701dc8 --- /dev/null +++ b/fnl/eca/ui/widgets/expandable-block.fnl @@ -0,0 +1,66 @@ +;; 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 : label : icon-expanded : icon-collapsed : content : expanded?} + Returns {: render : toggle : expand : collapse : update-label : get-state}." + (local state (vim.tbl_extend :force + {: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) + (set state.ns-id (canvas:create-namespace + (.. "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) + lines [label-line]] + (when state.expanded? + (each [_ line (ipairs state.content)] + (table.insert lines (.. " " line)))) + (canvas:set-lines start-line start-line lines) + (canvas:add-extmark ns start-line 0 + {:end_col (length label-line) + :hl_group :EcaExpandableLabel}) + (length lines))) + + (fn toggle [] + (set state.expanded? (not state.expanded?))) + + (fn expand [] + (set state.expanded? true)) + + (fn collapse [] + (set state.expanded? false)) + + (fn update-label [new-label] + (set state.label new-label)) + + (fn get-state [] + state) + + {: render + : toggle + : expand + : collapse + : 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..c91a944 --- /dev/null +++ b/fnl/eca/ui/widgets/footer-bar.fnl @@ -0,0 +1,43 @@ +;; footer-bar widget — statusline footer. +;; 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 active false) + + (fn is-global? [] + (= 3 (nvim.nvim_get_option_value :laststatus {}))) + + (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 render [] + (set active true) + (apply) + 0) + + ;; 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) + (vim.defer_fn (fn [] (apply)) 10))))}) + + (fn update [new-items] + (set items new-items) + (when active (apply))) + + (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 new file mode 100644 index 0000000..59e327b --- /dev/null +++ b/fnl/eca/ui/widgets/header-bar.fnl @@ -0,0 +1,25 @@ +;; header-bar widget — fixed header using winbar. + +(local nvim vim.api) +(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-items.render {:items items}) {:win win-id}) + ;; Blank line for spacing + (nvim.nvim_buf_set_lines buf-id 0 1 false [""]) + 1) + + (fn update [new-items] + (set items new-items) + (render)) + + (fn get-state [] items) + (fn line-count [] 1) + + {: 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 new file mode 100644 index 0000000..6e55035 --- /dev/null +++ b/fnl/eca/ui/widgets/message-list.fnl @@ -0,0 +1,212 @@ +;; 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 ?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) + (set state.ns-id (nvim.nvim_create_namespace "eca-messages"))) + state.ns-id) + + (fn apply-highlights [lines-offset highlights] + (let [ns (ensure-ns)] + (each [_ hl (ipairs highlights)] + (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] + (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) + (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) + (let [buf-lines (nvim.nvim_buf_line_count buf-id)] + (when (< state.streaming-line buf-lines) + (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." + (when (> (length state.streaming-queue) 0) + ;; 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 + (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) + (set state.end-line line))) + + (fn set-welcome [data] + (set state.welcome-lines data)) + + (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)) + (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 [;; 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] + ;; 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 + (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 [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 : 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 new file mode 100644 index 0000000..99e2642 --- /dev/null +++ b/fnl/eca/ui/widgets/prompt-area.fnl @@ -0,0 +1,295 @@ +;; prompt-area widget — separator + status + stop + prompt. + +(local nvim vim.api) +(local prompt-prefix-component (require :eca.ui.components.prompt-prefix)) + +(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 + :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}) + + (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 [] + "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) + 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 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? + (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." + (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) + _ (nvim.nvim_buf_clear_namespace buf-id ns 0 -1) + 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)))) + + ;; Write all lines + (nvim.nvim_buf_set_lines buf-id start-line -1 false lines) + + ;; 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 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-virt-lines))) + + (fn set-status [text] + (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-virt-lines)))) + + ;; ── Loading ─────────────────────────────────────────── + + (fn set-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 [] + "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 [start state.prompt-start-line + total (nvim.nvim_buf_line_count buf-id) + 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 "")) + + (fn add-to-history [text] + (when (and text (not= "" text)) + (table.insert state.history text) + (set state.history-idx (+ (length state.history) 1)))) + + (fn history-prev [] + (when (> state.history-idx 1) + (set state.history-idx (- state.history-idx 1)) + (set-text (. state.history state.history-idx)))) + + (fn history-next [] + (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 "")))) + + ;; 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 (if (and event.regname (not= event.regname "")) + event.regname + "\"")] + (tset lines 1 stripped) + (vim.schedule + (fn [] + (vim.fn.setreg reg 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) + + {: 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 + : 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..61e11d8 --- /dev/null +++ b/fnl/eca/ui/widgets/status-bar.fnl @@ -0,0 +1,44 @@ +;; status-bar widget — generic statusline with sections. +;; Zero business logic. Receives pre-formatted sections. + +(fn create [canvas initial-sections] + "Create a status-bar widget. + initial-sections: {: left : center : right} + Each section is a list of {: text : hl-group}. + Returns {: render : update : get-state}." + (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-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 [] + 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..e8b1a50 --- /dev/null +++ b/fnl/eca/ui/widgets/tab-bar.fnl @@ -0,0 +1,61 @@ +;; 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 : 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 {}))) + + (fn build-tabline [] + (let [parts []] + (each [_ tab (ipairs state.tabs)] + (let [is-active (= tab.id state.active-id) + hl (or tab.hl-group (if is-active :EcaTabActive :EcaTabInactive))] + (table.insert parts + (.. "%#" hl "# " (or tab.label (tostring tab.id)) " ")))) + (table.concat parts "%#Normal#│"))) + + (fn render [] + (let [tabline (build-tabline)] + (canvas:set-option :global :tabline tabline) + (canvas:set-option :global :showtabline 2))) + + (fn add-tab [tab] + (table.insert state.tabs tab) + (when (= nil state.active-id) + (set state.active-id tab.id))) + + (fn remove-tab [id] + (let [new-tabs (icollect [_ tab (ipairs state.tabs)] + (when (not= tab.id id) tab))] + (set state.tabs new-tabs) + (when (= state.active-id id) + (set state.active-id + (if (> (length new-tabs) 0) + (. (. new-tabs 1) :id) + nil))))) + + (fn select-tab [id] + (set state.active-id id)) + + (fn update-tab [id new-data] + (each [_ tab (ipairs state.tabs)] + (when (= tab.id id) + (each [k v (pairs new-data)] + (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..f150ff1 --- /dev/null +++ b/lua/eca/api.lua @@ -0,0 +1,174 @@ +-- [nfnl] fnl/eca/api.fnl +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 or_1_ +end +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 +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"]), ["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/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 +end +self["chat-close"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat.close() + else + return nil + end +end +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 +self["chat-submit"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat["submit-prompt"]() + else + return nil + end +end +self["chat-clear"] = function() + local chat = self["resolve-chat"]() + if chat then + return chat["clear-messages"]() + else + return nil + end +end +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 +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 + return chat["set-status"](text) + else + return nil + end +end +self["default-on-submit"] = function(text) + local chat = self["resolve-chat"]() + if chat then + 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())) + 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 _13_() + if chat["is-open?"]() then + 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 _14_() + if chat["is-open?"]() then + return chat["update-message"](reply_id, content_at_send) + else + return nil + end + end + vim.defer_fn(_14_, delay) + end + local function _16_() + 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(_16_, (delay + 100)) + else + return nil + end + end + return vim.defer_fn(_13_, 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 +end +self["set-plugin-opts"] = function(opts) + plugin_opts = (opts or {}) + return nil +end +return self diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua new file mode 100644 index 0000000..e6c77a5 --- /dev/null +++ b/lua/eca/commands.lua @@ -0,0 +1,41 @@ +-- [nfnl] fnl/eca/commands.fnl +local nvim = vim.api +local function setup(api) + local function _1_() + return api["chat-toggle"]() + end + nvim.nvim_create_user_command("EcaChat", _1_, {desc = "Toggle ECA Chat window"}) + local function _2_() + return api["chat-open"]() + end + nvim.nvim_create_user_command("EcaChatOpen", _2_, {desc = "Open ECA Chat window"}) + local function _3_() + return api["chat-close"]() + end + nvim.nvim_create_user_command("EcaChatClose", _3_, {desc = "Close ECA Chat window"}) + local function _4_() + return api["chat-clear"]() + end + nvim.nvim_create_user_command("EcaChatClear", _4_, {desc = "Clear current chat messages"}) + local function _5_() + return api["chat-open"]() + end + nvim.nvim_create_user_command("EcaChatNew", _5_, {desc = "Open a new ECA Chat"}) + local function _6_() + return api["chat-submit"]() + end + nvim.nvim_create_user_command("EcaChatSubmit", _6_, {desc = "Submit current prompt"}) + local function _7_() + return api["chat-stop"]() + end + 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 ed605a4..be44cae 100644 --- a/lua/eca/init.lua +++ b/lua/eca/init.lua @@ -1,8 +1,8 @@ -- [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 commands = require("eca.commands") +local function setup(opts) + 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 new file mode 100644 index 0000000..70e3615 --- /dev/null +++ b/lua/eca/ui/builder.lua @@ -0,0 +1,573 @@ +-- [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 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() + 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}) + 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}) + 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 function salvage_user_text(buf, prompt_line) + local current_count = nvim.nvim_buf_line_count(buf) + 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 + return {""} + end + end + 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 + 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_ = ("> " .. 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 = tbl_26_ + end + 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 + internal_edit = false + if focus_prompt_fn then + return focus_prompt_fn() + else + return nil + end + end + local function on_lines_handler(_, buf, changedtick, first_line, last_line, new_last_line) + if not internal_edit then + 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 + end + return vim.schedule(_10_) + else + return nil + end + else + return nil + end + end + 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(_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, ["queued-prompt"] = nil} + local buf_id = nil + local win_id = nil + local guard = 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) + if guard then + guard["set-internal"](true) + else + end + f() + 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)) + 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 + 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 _24_() + do + local header_lines = widgets.header.render() + widgets.messages["set-start-line"](header_lines) + widgets.messages.render() + render_prompt_area() + end + if widgets.footer then + return widgets.footer.render() + else + return nil + end + end + return with_internal_edit(_24_) + end + 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 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 (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 + if (text and ("" ~= text)) then + widgets.prompt["add-to-history"](text) + local function _33_() + return widgets.prompt.clear() + end + with_internal_edit(_33_) + focus_prompt() + if on_submit then + return on_submit(text) + else + return nil + end + else + return nil + end + 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"]) + 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_}) + 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 + 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 _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, _40_, focus_prompt) + return nil + else + return nil + end + end + local function toggle() + if is_open_3f() then + return close() + else + 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 _43_() + widgets.messages["append-message"](msg) + return render_prompt_area() + end + with_internal_edit(_43_) + return focus_prompt() + else + return nil + end + 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) + if (id ~= msg_state["streaming-id"]) then + return render_prompt_area() + else + return nil + end + end + return with_internal_edit(_45_) + else + return nil + end + end + local function finish_streaming(id) + if is_open_3f() then + local function _48_() + widgets.messages["finish-streaming"](id) + return render_prompt_area() + end + return with_internal_edit(_48_) + else + return nil + end + end + local function clear_messages() + if is_open_3f() then + local function _50_() + widgets.messages.clear() + return render_prompt_area() + end + return with_internal_edit(_50_) + else + return nil + end + end + local function update_header(new_items) + state["header-items"] = new_items + if is_open_3f() then + local function _52_() + return widgets.header.update(new_items) + end + return with_internal_edit(_52_) + else + return nil + end + end + 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 + end + if is_open_3f() then + local function _56_() + return widgets.header.update(state["header-items"]) + end + return with_internal_edit(_56_) + else + return nil + end + end + local function update_footer(new_items) + state["footer-items"] = new_items + if is_open_3f() then + local function _58_() + return widgets.footer.update(new_items) + end + return with_internal_edit(_58_) + else + return nil + end + end + 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 + end + if not found then + table.insert(state["footer-items"], {title = title, value = new_value}) + else + end + if is_open_3f() then + local function _62_() + return widgets.footer.update(state["footer-items"]) + end + return with_internal_edit(_62_) + else + return nil + end + end + local function set_welcome(text) + state.welcome = text + if is_open_3f() then + 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 _64_() + return render_all() + end + return with_internal_edit(_64_) + else + return nil + end + else + return nil + end + end + local function set_status(text) + if is_open_3f() then + return widgets.prompt["set-status"](text) + else + return nil + end + end + local function add_context(ctx) + if is_open_3f() then + local function _68_() + widgets.context.add(ctx) + return render_prompt_area() + end + with_internal_edit(_68_) + return focus_prompt() + else + return nil + end + end + local function remove_context(name) + if is_open_3f() then + local function _70_() + widgets.context.remove(name) + return render_prompt_area() + end + return with_internal_edit(_70_) + else + return nil + end + end + local function set_loading(bool) + if is_open_3f() then + 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 _72_() + widgets.prompt["set-steering"](nil) + return render_prompt_area() + end + with_internal_edit(_72_) + if on_submit then + return on_submit(queued) + else + return nil + end + else + return nil + end + 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, 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/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/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..5b1cba8 --- /dev/null +++ b/lua/eca/ui/components/context-item.lua @@ -0,0 +1,7 @@ +-- [nfnl] fnl/eca/ui/components/context-item.fnl +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 +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..945f8d9 --- /dev/null +++ b/lua/eca/ui/components/icon.lua @@ -0,0 +1,9 @@ +-- [nfnl] fnl/eca/ui/components/icon.fnl +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 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/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..f4995c1 --- /dev/null +++ b/lua/eca/ui/components/message.lua @@ -0,0 +1,58 @@ +-- [nfnl] fnl/eca/ui/components/message.fnl +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 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 + 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 = {} + local highlights = {} + 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 + return {lines = lines, highlights = highlights} +end +return {render = render} 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..e1cfad8 --- /dev/null +++ b/lua/eca/ui/components/usage.lua @@ -0,0 +1,7 @@ +-- [nfnl] fnl/eca/ui/components/usage.fnl +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 +return {render = render} diff --git a/lua/eca/ui/highlights.lua b/lua/eca/ui/highlights.lua new file mode 100644 index 0000000..aa3a9ac --- /dev/null +++ b/lua/eca/ui/highlights.lua @@ -0,0 +1,10 @@ +-- [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"}, 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) + end + return nil +end +return {groups = groups, setup = setup} 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/context-bar.lua b/lua/eca/ui/widgets/context-bar.lua new file mode 100644 index 0000000..9bf3418 --- /dev/null +++ b/lua/eca/ui/widgets/context-bar.lua @@ -0,0 +1,96 @@ +-- [nfnl] fnl/eca/ui/widgets/context-bar.fnl +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"] = nvim.nvim_create_namespace("eca-context-bar") + else + end + return state["ns-id"] + end + local function build_line() + if (0 == #state.items) then + return {line = "", 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 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 + nvim.nvim_buf_set_lines(buf_id, line_num, (line_num + 1), false, {line}) + for _, hl in ipairs((highlights or {})) 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 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 clear() + state.items = {} + 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..a3583f2 --- /dev/null +++ b/lua/eca/ui/widgets/expandable-block.lua @@ -0,0 +1,56 @@ +-- [nfnl] fnl/eca/ui/widgets/expandable-block.fnl +local function create(canvas, initial_state) + 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"))) + else + 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() + local lines = {label_line} + if state["expanded?"] then + for _, line in ipairs(state.content) do + table.insert(lines, (" " .. line)) + end + else + end + canvas["set-lines"](canvas, start_line, start_line, lines) + canvas["add-extmark"](canvas, ns, start_line, 0, {end_col = #label_line, hl_group = "EcaExpandableLabel"}) + 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_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-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..2665574 --- /dev/null +++ b/lua/eca/ui/widgets/footer-bar.lua @@ -0,0 +1,55 @@ +-- [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 active = false + local function is_global_3f() + return (3 == nvim.nvim_get_option_value("laststatus", {})) + end + local function apply() + local str = bar.render({items = items}) + 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 render() + active = true + apply() + return 0 + end + 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({"WinEnter", "ColorScheme"}, {callback = _3_}) + local function update(new_items) + items = new_items + if active then + return apply() + else + return nil + end + 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 new file mode 100644 index 0000000..a98fb9d --- /dev/null +++ b/lua/eca/ui/widgets/header-bar.lua @@ -0,0 +1,23 @@ +-- [nfnl] fnl/eca/ui/widgets/header-bar.fnl +local nvim = vim.api +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_items.render({items = items}), {win = win_id}) + nvim.nvim_buf_set_lines(buf_id, 0, 1, false, {""}) + return 1 + end + local function update(new_items) + items = new_items + return render() + end + local function get_state() + return items + end + local function line_count() + return 1 + end + 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 new file mode 100644 index 0000000..5affc88 --- /dev/null +++ b/lua/eca/ui/widgets/message-list.lua @@ -0,0 +1,270 @@ +-- [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, _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") + else + end + return state["ns-id"] + end + local function apply_highlights(lines_offset, highlights) + local ns = ensure_ns() + for _, hl in ipairs(highlights) do + 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) + nvim.nvim_buf_set_lines(buf_id, start_line, start_line, false, rendered.lines) + 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 + local buf_lines = nvim.nvim_buf_line_count(buf_id) + if (state["streaming-line"] < buf_lines) 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 + 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 + else + return nil + end + else + return nil + end + end + local function stream_tick() + if (#state["streaming-queue"] > 0) then + 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 + wrap_write(_17_) + 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 + 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() + 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 + 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 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 + 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 _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(_26_) + return start_streaming_timer() + else + 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 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 + return nil + end + else + return render() + end + else + return nil + end + 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 + end + 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() + return state + end + local function get_end_line() + return state["end-line"] + end + 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 new file mode 100644 index 0000000..c73fc13 --- /dev/null +++ b/lua/eca/ui/widgets/prompt-area.lua @@ -0,0 +1,319 @@ +-- [nfnl] fnl/eca/ui/widgets/prompt-area.fnl +local nvim = vim.api +local prompt_prefix_component = require("eca.ui.components.prompt-prefix") +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, ["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") + else + end + return state["ns-id"] + end + 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"]) + 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) + 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 + end + end + local function update_stop_virt() + local ns = ensure_ns() + 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 + 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 + 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 + return table.concat(lines, "\n") + else + return nil + end + else + return nil + end + 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 + end + 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 + 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_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_virt_lines() + else + return nil + end + end + 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 + return nil + end + else + state["status-text"] = nil + state["status-timer"] = nil + 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() + 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 + end + else + end + return table.concat(lines, "\n") + end + local function set_text(text) + state["prompt-text"] = (text or "") + if not state["loading?"] then + local start = state["prompt-start-line"] + local total = nvim.nvim_buf_line_count(buf_id) + 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 + 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 _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 + if (event.regname and (event.regname ~= "")) then + reg = event.regname + else + reg = "\"" + end + lines[1] = stripped + local function _34_() + vim.fn.setreg(reg, lines, event.regtype) + vim.fn.setreg("+", lines, event.regtype) + return vim.fn.setreg("*", lines, event.regtype) + end + 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 + 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} diff --git a/lua/eca/ui/widgets/status-bar.lua b/lua/eca/ui/widgets/status-bar.lua new file mode 100644 index 0000000..9be82ea --- /dev/null +++ b/lua/eca/ui/widgets/status-bar.lua @@ -0,0 +1,79 @@ +-- [nfnl] fnl/eca/ui/widgets/status-bar.fnl +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 + end + _2_ = t_1_ + end + local _5_ + do + local t_4_ = initial_sections + if (nil ~= t_4_) then + t_4_ = t_4_.center + else + end + _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 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 + end + end + 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_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 + 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..dbf33d0 --- /dev/null +++ b/lua/eca/ui/widgets/tab-bar.lua @@ -0,0 +1,88 @@ +-- [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 + local or_1_ = tab["hl-group"] + if not or_1_ then + if is_active then + or_1_ = "EcaTabActive" + else + or_1_ = "EcaTabInactive" + end + end + hl = or_1_ + table.insert(parts, ("%#" .. hl .. "# " .. (tab.label or tostring(tab.id)) .. " ")) + end + return table.concat(parts, "%#Normal#\226\148\130") + end + local function render() + local tabline = build_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) + 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 + 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 + 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_data) + for _, tab in ipairs(state.tabs) do + if (tab.id == id) then + for k, v in pairs(new_data) 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}