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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bb.edn
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
:task (eca-cli.upgrade/run!)}
test {:doc "Run unit tests"
:task (do (require '[clojure.test])
(require '[eca-cli.at-refs-test])
(require '[eca-cli.chat-test])
(require '[eca-cli.commands-test])
(require '[eca-cli.lifecycle-test])
Expand All @@ -20,7 +21,8 @@
(require '[eca-cli.sessions-test])
(require '[eca-cli.upgrade-test])
(let [{:keys [fail error]}
(clojure.test/run-tests 'eca-cli.chat-test
(clojure.test/run-tests 'eca-cli.at-refs-test
'eca-cli.chat-test
'eca-cli.commands-test
'eca-cli.lifecycle-test
'eca-cli.protocol-test
Expand Down
40 changes: 34 additions & 6 deletions src/eca_cli/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,32 @@

;; --- Outbound prompt ---

(def ^:private at-token-re
;; Match @<non-space-token>, preceded by start-of-string or whitespace.
;; Duplicated from view/blocks.clj's render-time regex (kept local to each
;; namespace until a third call site emerges to justify extraction).
#"(?:^|\s)@(\S+)")

(defn- at-paths-in
"Return a set of `@path` tokens present in `text` (paths only, no `@`)."
[text]
(set (map second (re-seq at-token-re (str text)))))

(defn- contexts-for-text
"Filter `contexts` to those whose `:path` still appears as an `@path` token
in `text`. Drops contexts the user deleted from the message before send."
[text contexts]
(let [present (at-paths-in text)]
(vec (filter #(contains? present (:path %)) contexts))))

(defn send-chat-prompt! [srv chat-id text opts]
(protocol/chat-prompt!
srv
(cond-> {:message text}
chat-id (assoc :chat-id chat-id)
(:model opts) (assoc :model (:model opts))
(:agent opts) (assoc :agent (:agent opts)))
chat-id (assoc :chat-id chat-id)
(:model opts) (assoc :model (:model opts))
(:agent opts) (assoc :agent (:agent opts))
(seq (:contexts opts)) (assoc :contexts (:contexts opts)))
(fn [result]
(when-let [new-id (:chat-id result)]
(sessions/save-chat-id! (:workspace opts) new-id))
Expand Down Expand Up @@ -411,16 +430,25 @@
[(-> state (assoc :focus-path next) sync-focus view/rebuild-lines ensure-focus-visible) nil]))))

(defn- enter-submit-prompt [state]
(let [text (str/trim (ti/value (:input state)))]
(let [text (str/trim (ti/value (:input state)))
contexts (contexts-for-text text (:pending-contexts state))]
(if (seq text)
(let [new-state (-> state
(let [opts' (cond-> (:opts state)
(seq contexts) (assoc :contexts contexts))
new-state (-> state
(update :items conj {:type :user :text text})
(assoc :mode :chatting :pending-message text :echo-pending true)
(assoc :pending-contexts [])
;; Stash filtered contexts on :opts so the login-retry
;; path (login/handle-providers-updated, login/handle-
;; eca-login-action, login/handle-eca-login-complete)
;; re-sends them after authentication.
(assoc :opts opts')
(update :input #(-> % ti/reset ti/blur))
(update :input-history conj text)
(assoc :history-idx nil)
view/rebuild-lines)]
(send-chat-prompt! (:server state) (:chat-id state) text (:opts state))
(send-chat-prompt! (:server state) (:chat-id state) text opts')
[new-state nil])
[state nil])))

Expand Down
69 changes: 69 additions & 0 deletions src/eca_cli/picker.clj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
(case kind
:session (first item)
:command (str (first item) " — " (second item))
:at-file (str item)
item))

(defn open-picker [state kind]
Expand All @@ -49,6 +50,35 @@
:query ""})
(update :input ti/reset))))

(defn open-at-file-picker
"Opens the file picker with an empty list. Items arrive asynchronously
via :at-files-loaded and are spliced in by update-at-file-results."
[state]
(assoc state
:mode :picking
:picker {:kind :at-file
:list (cl/item-list [] :height 8)
:all []
:filtered []
:query ""}))

(defn update-at-file-results
"Splice server-returned file paths into the at-file picker, preserving
any query the user has typed since opening."
[state paths]
(if (= :at-file (get-in state [:picker :kind]))
(let [query (get-in state [:picker :query])
filtered (if (seq query)
(filterv #(str/includes? (str/lower-case %)
(str/lower-case query))
paths)
paths)]
(-> state
(assoc-in [:picker :all] paths)
(assoc-in [:picker :filtered] filtered)
(update-in [:picker :list] cl/set-items filtered)))
state))

(defn filter-picker [state ch]
(let [query (str (get-in state [:picker :query]) ch)
kind (get-in state [:picker :kind])
Expand Down Expand Up @@ -115,6 +145,44 @@
(update :input ti/focus))
(when chat-id (sessions/open-chat-cmd (:server state) chat-id))]))

(defn- whitespace? [ch]
(or (= \space ch) (= \newline ch) (= \tab ch)))

(defn- insert-at-tag
"Insert `@<path>` into the text-input at the current cursor position,
ensuring a separator on each side when adjacent to non-whitespace text:
- leading space when the char before the cursor is non-whitespace
- trailing space when the char after the cursor is non-whitespace
Cursor ends just past `@<path>` (before any inserted trailing space)."
[input path]
(let [value (ti/value input)
pos (min (count value) (max 0 (or (ti/position input) (count value))))
before (subs value 0 pos)
after (subs value pos)
prev-ch (when (pos? (count before)) (.charAt ^String before (dec (count before))))
next-ch (when (pos? (count after)) (.charAt ^String after 0))
lead-sp? (and prev-ch (not (whitespace? prev-ch)))
trail-sp? (and next-ch (not (whitespace? next-ch)))
insert (str (when lead-sp? " ") "@" path (when trail-sp? " "))
cursor (+ pos (count insert) (if trail-sp? -1 0))]
(-> input
(ti/set-value (str before insert after))
(assoc :pos cursor))))

(defn- select-at-file [state]
(let [{:keys [list filtered]} (:picker state)
idx (cl/selected-index list)
path (when (and (some? idx) (< idx (count filtered)))
(nth filtered idx))]
(if path
[(-> state
(update :input #(-> % (insert-at-tag path) ti/focus))
(update :pending-contexts (fnil conj []) {:type "file" :path path})
(assoc :mode :ready)
(dissoc :picker))
nil]
[(-> state (assoc :mode :ready) (dissoc :picker) (update :input ti/focus)) nil])))

(defn handle-key
"Dispatch keypresses while :mode is :picking. Returns [new-state cmd-or-nil].
Returns the original state unchanged for command-picker Enter — caller
Expand All @@ -126,6 +194,7 @@
(case kind
(:model :agent) (select-model-or-agent state kind)
:session (select-session state)
:at-file (select-at-file state)
:command [state nil] ; caller handles
[state nil])

Expand Down
9 changes: 9 additions & 0 deletions src/eca_cli/protocol.clj
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,12 @@

(defn delete-chat! [srv chat-id callback]
(send-request! srv "chat/delete" {:chatId chat-id} callback))

(defn chat-query-files!
"Fuzzy-searches workspace files via ECA. Empty query returns the full set
(server-capped). Response: {:files [{:type :file :path string ...}]}."
[srv chat-id query callback]
(send-request! srv "chat/queryFiles"
(cond-> {:query (or query "")}
chat-id (assoc :chatId chat-id))
callback))
54 changes: 54 additions & 0 deletions src/eca_cli/state.clj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
(program/cmd (fn [] (server/shutdown! srv) nil))
program/quit-cmd))

(defn- query-files-cmd [srv chat-id query]
(program/cmd
(fn []
(let [p (promise)]
(protocol/chat-query-files! srv chat-id query
(fn [r] (deliver p (or (get-in r [:result :files]) []))))
(let [files (deref p 5000 [])
paths (mapv :path files)]
{:type :at-files-loaded :paths paths})))))

(defn- handle-eca-notification [state notification]
(case (:method notification)
"chat/contentReceived"
Expand Down Expand Up @@ -121,6 +131,7 @@
:echo-pending false
:session-trusted-tools #{}
:init-tasks {}
:pending-contexts []
:available-models []
:available-agents []
:available-variants []
Expand Down Expand Up @@ -170,6 +181,30 @@
(= :ready (:mode state))
(= "" (str/trim (ti/value (:input state))))))

(defn- autocomplete-at?
"True when `@` should open the file picker: ready mode, and the char left
of the cursor is empty / space / newline (mid-word `@` is left to text-input)."
[state msg]
(and (picker/printable-char? msg)
(= "@" (:key msg))
(= :ready (:mode state))
(let [value (ti/value (:input state))
pos (or (ti/position (:input state)) (count value))
prev (when (pos? pos) (.charAt ^String value (dec pos)))]
(or (zero? pos)
(= \space prev)
(= \newline prev)))))

(defn- at-file-filter-keystroke?
"True when `msg` is a keystroke that should re-query the server while the
at-file picker is open: any printable char (extends the filter) or backspace
(shrinks it). Other keys (Enter, Escape, arrows) fall through to picker."
[state msg]
(and (= :picking (:mode state))
(= :at-file (get-in state [:picker :kind]))
(or (picker/printable-char? msg)
(and (msg/key-press? msg) (msg/key-match? msg :backspace)))))

(defn update-state [state msg]
(reset! debug-state {:state (dissoc state :server :input)
:msg-type (or (:type msg) (:method msg))
Expand Down Expand Up @@ -199,6 +234,9 @@
(= :eca-login-action (:type msg)) (login/handle-eca-login-action state msg)
(= :eca-login-complete (:type msg)) (login/handle-eca-login-complete state msg)

(= :at-files-loaded (:type msg))
[(picker/update-at-file-results state (:paths msg)) nil]

(= :chat-list-loaded (:type msg))
(let [chats (:chats msg)
error? (:error? msg)
Expand Down Expand Up @@ -226,6 +264,10 @@
(autocomplete-slash? state msg)
[(commands/open-command-picker state) nil]

(autocomplete-at? state msg)
[(picker/open-at-file-picker state)
(query-files-cmd (:server state) (:chat-id state) "")]
Comment on lines +267 to +269

;; --- Per-mode dispatch (single-arm delegation) ---
(= :login (:mode state)) (login/handle-key state msg)
(= :approving (:mode state)) (chat/handle-approval-key state msg)
Expand All @@ -245,6 +287,18 @@
cmd-name)
[state nil]))

;; --- At-file picker: re-query server on filter typing ---
;;
;; Server returns a capped list for empty query; subsequent typing must
;; re-query so matches outside the capped initial set become findable.
;; Picker.clj still mutates :query / :filtered client-side for snappy
;; UI feedback; the server response (`:at-files-loaded`) then splices
;; the canonical ranked list in.
(at-file-filter-keystroke? state msg)
(let [[s' _] (picker/handle-key state msg)]
[s' (query-files-cmd (:server s') (:chat-id s')
(get-in s' [:picker :query]))])

(= :picking (:mode state)) (picker/handle-key state msg)
(#{:ready :chatting} (:mode state)) (chat/handle-key state msg)

Expand Down
4 changes: 3 additions & 1 deletion src/eca_cli/view.clj
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@

(defn- render-picker [state]
(let [{:keys [kind query list]} (:picker state)
label (case kind :model "model" :agent "agent" :session "chat" :command "command" "item")]
label (case kind
:model "model" :agent "agent" :session "chat"
:command "command" :at-file "file" "item")]
(str "Select " label " (type to filter): " query "\n"
(divider (:width state)) "\n"
(cl/list-view list))))
Expand Down
15 changes: 14 additions & 1 deletion src/eca_cli/view/blocks.clj
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,21 @@
(def ^:private ansi-yellow "\033[33m")
(def ^:private ansi-green "\033[32m")
(def ^:private ansi-red "\033[31m")
(def ^:private ansi-bold-on "\033[1m")
(def ^:private ansi-bold-off "\033[22m")
(def ^:private ansi-reset "\033[0m")

(def ^:private at-token-re
;; Match @<non-space-token>, preceded by start-of-line or whitespace.
;; Group 1 captures the leading boundary so we can preserve it in the
;; replacement.
#"(^|\s)@(\S+)")

(defn- stylize-at-tokens [s]
(str/replace s at-token-re
(fn [[_ lead path]]
(str lead ansi-bold-on "@" path ansi-bold-off))))

(defn- render-box [label text width]
(let [box-w (max 4 (- width 2))
inner-w (max 1 (- box-w 4))
Expand Down Expand Up @@ -47,7 +60,7 @@
:user
;; " ❯ " prefix = 4 cols, trailing " " = 1 col → inner budget = width - 5
(let [inner-w (max 1 (- width 5))
wrapped (wrap/wrap-text (str (:text item)) inner-w)]
wrapped (wrap/wrap-text (stylize-at-tokens (str (:text item))) inner-w)]
(into [""]
(conj (mapv #(str "\033[7m ❯ " % " \033[0m") wrapped)
"")))
Expand Down
Loading