diff --git a/bb.edn b/bb.edn index fe5cede..2c6c552 100644 --- a/bb.edn +++ b/bb.edn @@ -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]) @@ -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 diff --git a/src/eca_cli/chat.clj b/src/eca_cli/chat.clj index d55dfd8..fa10978 100644 --- a/src/eca_cli/chat.clj +++ b/src/eca_cli/chat.clj @@ -101,13 +101,32 @@ ;; --- Outbound prompt --- +(def ^:private at-token-re + ;; Match @, 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)) @@ -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]))) diff --git a/src/eca_cli/picker.clj b/src/eca_cli/picker.clj index 26a5beb..914c407 100644 --- a/src/eca_cli/picker.clj +++ b/src/eca_cli/picker.clj @@ -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] @@ -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]) @@ -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 `@` 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 `@` (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 @@ -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]) diff --git a/src/eca_cli/protocol.clj b/src/eca_cli/protocol.clj index 1fc14a1..8b1203a 100644 --- a/src/eca_cli/protocol.clj +++ b/src/eca_cli/protocol.clj @@ -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)) diff --git a/src/eca_cli/state.clj b/src/eca_cli/state.clj index 0cc8c57..da4aff9 100644 --- a/src/eca_cli/state.clj +++ b/src/eca_cli/state.clj @@ -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" @@ -121,6 +131,7 @@ :echo-pending false :session-trusted-tools #{} :init-tasks {} + :pending-contexts [] :available-models [] :available-agents [] :available-variants [] @@ -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)) @@ -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) @@ -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) "")] + ;; --- Per-mode dispatch (single-arm delegation) --- (= :login (:mode state)) (login/handle-key state msg) (= :approving (:mode state)) (chat/handle-approval-key state msg) @@ -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) diff --git a/src/eca_cli/view.clj b/src/eca_cli/view.clj index ac35d98..d4b56b8 100644 --- a/src/eca_cli/view.clj +++ b/src/eca_cli/view.clj @@ -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)))) diff --git a/src/eca_cli/view/blocks.clj b/src/eca_cli/view/blocks.clj index 711cdcb..dd7ff23 100644 --- a/src/eca_cli/view/blocks.clj +++ b/src/eca_cli/view/blocks.clj @@ -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 @, 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)) @@ -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) ""))) diff --git a/test/eca_cli/at_refs_test.clj b/test/eca_cli/at_refs_test.clj new file mode 100644 index 0000000..788d8ad --- /dev/null +++ b/test/eca_cli/at_refs_test.clj @@ -0,0 +1,308 @@ +(ns eca-cli.at-refs-test + "Phase 11a: `@` file-context references. + Covers picker trigger guard, server query dispatch, picker selection, + contexts wiring on send, and inline ANSI styling on user messages." + (:require [charm.components.list :as cl] + [charm.components.text-input :as ti] + [charm.message :as msg] + [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [eca-cli.login :as login] + [eca-cli.protocol :as protocol] + [eca-cli.state :as state] + [eca-cli.view.blocks :as blocks])) + +(defn- base-state [] + {:mode :ready + :trust false + :chat-id "chat1" + :chat-title nil + :items [] + :current-text "" + :tool-calls {} + :pending-approval nil + :pending-message nil + :echo-pending false + :session-trusted-tools #{} + :init-tasks {} + :pending-contexts [] + :available-models [] + :available-agents [] + :available-variants [] + :selected-model nil + :selected-agent nil + :selected-variant nil + :input (ti/text-input) + :input-history [] + :history-idx nil + :focus-path nil + :subagent-chats {} + :chat-lines [] + :scroll-offset 0 + :width 80 + :height 24 + :model nil + :usage nil + :server nil + :opts {:workspace "/tmp/test"}}) + +(deftest at-keystroke-opens-picker-test + (testing "empty input + `@` opens at-file picker and dispatches query" + (let [[s cmd] (state/update-state (base-state) (msg/key-press "@"))] + (is (= :picking (:mode s))) + (is (= :at-file (get-in s [:picker :kind]))) + (is (= "" (get-in s [:picker :query]))) + (is (some? cmd) "queryFiles cmd should be dispatched")))) + +(deftest at-keystroke-mid-word-no-op-test + (testing "mid-word `@` falls through to text-input: literal insert, no picker" + (let [s0 (assoc (base-state) :input (ti/set-value (ti/text-input) "abc")) + [s _] (state/update-state s0 (msg/key-press "@"))] + (is (= :ready (:mode s))) + (is (nil? (:picker s))) + (is (= "abc@" (ti/value (:input s))))))) + +(deftest at-keystroke-after-space-test + (testing "text ending in space + `@` opens picker" + (let [s0 (assoc (base-state) :input (ti/set-value (ti/text-input) "hi ")) + [s _] (state/update-state s0 (msg/key-press "@"))] + (is (= :picking (:mode s))) + (is (= :at-file (get-in s [:picker :kind])))))) + +(deftest picker-selection-appends-tag-and-context-test + (testing "Enter on selection appends @path and adds context" + (let [[s-open _] (state/update-state (base-state) (msg/key-press "@")) + [s-loaded _] (state/update-state s-open + {:type :at-files-loaded + :paths ["src/eca_cli/state.clj" + "src/eca_cli/picker.clj"]}) + [s _] (state/update-state s-loaded (msg/key-press :enter))] + (is (= :ready (:mode s))) + (is (nil? (:picker s))) + (is (= "@src/eca_cli/state.clj" (ti/value (:input s)))) + (is (= [{:type "file" :path "src/eca_cli/state.clj"}] + (:pending-contexts s)))))) + +(deftest escape-closes-picker-no-side-effects-test + (testing "Escape closes picker without touching input or contexts" + (let [[s-open _] (state/update-state (base-state) (msg/key-press "@")) + [s-loaded _] (state/update-state s-open + {:type :at-files-loaded + :paths ["a.clj"]}) + [s _] (state/update-state s-loaded (msg/key-press :escape))] + (is (= :ready (:mode s))) + (is (nil? (:picker s))) + (is (= "" (ti/value (:input s)))) + (is (= [] (:pending-contexts s)))))) + +(deftest pending-contexts-flushed-on-send-test + (testing "non-empty pending-contexts included in prompt; reset after send" + (let [prompts (atom [])] + (with-redefs [protocol/chat-prompt! (fn [_srv params _cb] + (swap! prompts conj params))] + (let [s0 (-> (base-state) + (assoc :pending-contexts [{:type "file" :path "src/foo.clj"}]) + (assoc :input (ti/set-value (ti/text-input) + "look at @src/foo.clj"))) + [s _] (state/update-state s0 (msg/key-press :enter))] + (is (= :chatting (:mode s))) + (is (= [] (:pending-contexts s))) + (is (= 1 (count @prompts))) + (is (= [{:type "file" :path "src/foo.clj"}] + (:contexts (first @prompts)))))))) + + (testing "empty pending-contexts: prompt sent without :contexts key" + (let [prompts (atom [])] + (with-redefs [protocol/chat-prompt! (fn [_srv params _cb] + (swap! prompts conj params))] + (let [s0 (-> (base-state) + (assoc :input (ti/set-value (ti/text-input) "hello"))) + [_ _] (state/update-state s0 (msg/key-press :enter))] + (is (= 1 (count @prompts))) + (is (not (contains? (first @prompts) :contexts)))))))) + +(deftest slash-suppressed-while-picker-open-test + (testing "`/` while at-file picker open feeds the filter, not a new picker" + (let [[s-open _] (state/update-state (base-state) (msg/key-press "@")) + [s-loaded _] (state/update-state s-open + {:type :at-files-loaded + :paths ["src/foo.clj" "src/bar.clj"]}) + [s _] (state/update-state s-loaded (msg/key-press "/"))] + (is (= :picking (:mode s))) + (is (= :at-file (get-in s [:picker :kind]))) + (is (= "/" (get-in s [:picker :query])))))) + +(deftest user-message-render-ansi-bold-test + (testing "user message render wraps @tokens in ANSI bold (1m / 22m)" + (let [lines (blocks/render-item-lines + {:type :user :text "see @src/foo.clj and @docs/x.md please"} + 80) + joined (str/join "\n" lines)] + (is (re-find #"\x1b\[1m@src/foo\.clj\x1b\[22m" joined)) + (is (re-find #"\x1b\[1m@docs/x\.md\x1b\[22m" joined)))) + + (testing "no @-tokens: no bold escapes injected" + (let [lines (blocks/render-item-lines + {:type :user :text "plain message"} 80) + joined (str/join "\n" lines)] + (is (not (re-find #"\x1b\[1m" joined))))) + + (testing "mid-word @ is not styled (user@host)" + (let [lines (blocks/render-item-lines + {:type :user :text "ping user@host now"} 80) + joined (str/join "\n" lines)] + (is (not (re-find #"\x1b\[1m" joined)))))) + +(deftest at-files-loaded-populates-picker-test + (testing ":at-files-loaded splices paths into the open picker" + (let [[s-open _] (state/update-state (base-state) (msg/key-press "@")) + [s-loaded _] (state/update-state s-open + {:type :at-files-loaded + :paths ["a.clj" "b.clj" "c.clj"]})] + (is (= 3 (cl/item-count (get-in s-loaded [:picker :list])))) + (is (= ["a.clj" "b.clj" "c.clj"] (get-in s-loaded [:picker :all]))))) + + (testing ":at-files-loaded preserves an in-flight query and filters" + (let [[s-open _] (state/update-state (base-state) (msg/key-press "@")) + [s-typed _] (state/update-state s-open (msg/key-press "b")) + [s-loaded _] (state/update-state s-typed + {:type :at-files-loaded + :paths ["a.clj" "b.clj" "bbq.clj"]})] + (is (= "b" (get-in s-loaded [:picker :query]))) + (is (= ["b.clj" "bbq.clj"] (get-in s-loaded [:picker :filtered])))))) + +(deftest chat-query-files-params-test + (testing "chat-query-files! sends method chat/queryFiles with query (and chatId when given)" + (let [sent (atom nil)] + (with-redefs [protocol/send-request! (fn [_srv method params _cb] + (reset! sent [method params]) + 1)] + (protocol/chat-query-files! :srv "chat-42" "fo" identity) + (is (= "chat/queryFiles" (first @sent))) + (is (= {:query "fo" :chatId "chat-42"} (second @sent))) + + (protocol/chat-query-files! :srv nil nil identity) + (is (= {:query ""} (second @sent))))))) + +;; --- PR #4 review fixes --- + +(deftest stale-context-dropped-on-send-test + (testing "Pending context whose @path token was deleted from text is dropped" + (let [prompts (atom [])] + (with-redefs [protocol/chat-prompt! (fn [_srv params _cb] + (swap! prompts conj params))] + (let [s0 (-> (base-state) + (assoc :pending-contexts [{:type "file" :path "foo.clj"}]) + (assoc :input (ti/set-value (ti/text-input) "bar"))) + [s _] (state/update-state s0 (msg/key-press :enter))] + (is (= :chatting (:mode s))) + (is (= 1 (count @prompts))) + (is (not (contains? (first @prompts) :contexts)) + "context whose @path no longer appears in text must be omitted"))))) + + (testing "Subset of contexts retained when only some tokens remain" + (let [prompts (atom [])] + (with-redefs [protocol/chat-prompt! (fn [_srv params _cb] + (swap! prompts conj params))] + (let [s0 (-> (base-state) + (assoc :pending-contexts [{:type "file" :path "a.clj"} + {:type "file" :path "b.clj"}]) + (assoc :input (ti/set-value (ti/text-input) "see @a.clj only"))) + [_ _] (state/update-state s0 (msg/key-press :enter))] + (is (= [{:type "file" :path "a.clj"}] + (:contexts (first @prompts))))))))) + +(deftest contexts-preserved-on-login-retry-test + (testing "After login completes, pending-message is re-sent with original contexts" + (let [prompts (atom [])] + (with-redefs [protocol/chat-prompt! (fn [_srv params _cb] + (swap! prompts conj params))] + (let [s0 (-> (base-state) + (assoc :pending-contexts [{:type "file" :path "foo.clj"}]) + (assoc :input (ti/set-value (ti/text-input) "look @foo.clj"))) + ;; First send caches contexts into :opts + [s1 _] (state/update-state s0 (msg/key-press :enter)) + ;; Simulate the login flow completing — login namespace sends a + ;; second prompt using (:opts state). :contexts must survive. + [_s2 _] (login/handle-eca-login-complete + (assoc s1 :mode :login) + {:type :eca-login-complete + :pending-message (:pending-message s1)})] + (is (= 2 (count @prompts)) "first send + login-retry send") + (is (= [{:type "file" :path "foo.clj"}] + (:contexts (second @prompts))) + "login retry must carry the same contexts as the original send")))))) + +(defn- drive-at-select + "Synthesize an open at-file picker with `base-text` in the input and cursor + at `cursor-pos`, splice `paths`, select first via Enter. Returns final state. + Bypasses state.clj's `@`-trigger guard so we can test insertion behaviour at + arbitrary cursor positions (including mid-word) regardless of how the picker + was opened." + [base-text cursor-pos paths] + (let [s-open (-> (base-state) + (assoc :input + (-> (ti/text-input) + (ti/set-value base-text) + (assoc :pos cursor-pos))) + (assoc :mode :picking) + (assoc :picker {:kind :at-file + :list (cl/item-list [] :height 8) + :all [] + :filtered [] + :query ""})) + [s-loaded _] (state/update-state s-open + {:type :at-files-loaded :paths paths}) + [s _] (state/update-state s-loaded (msg/key-press :enter))] + s)) + +(deftest separator-inserted-mid-word-test + (testing "Cursor in middle of word: leading + trailing space around @path" + (let [s (drive-at-select "foobar" 3 ["path.clj"])] + (is (= "foo @path.clj bar" (ti/value (:input s)))) + (is (= 13 (ti/position (:input s))) + "cursor lands just past `@path.clj`, before the inserted trailing space")))) + +(deftest separator-not-doubled-at-word-boundary-test + (testing "Cursor at end of word: leading space only, no trailing" + (let [s (drive-at-select "hello" 5 ["path.clj"])] + (is (= "hello @path.clj" (ti/value (:input s)))))) + + (testing "Cursor after whitespace: no leading space added" + (let [s (drive-at-select "hi " 3 ["p.clj"])] + (is (= "hi @p.clj" (ti/value (:input s))))))) + +(defn- fire-cmd + "Execute a charm cmd map by invoking its :fn — production charm does this + automatically; tests need to invoke it explicitly to observe side effects." + [cmd] + (when (and cmd (= :cmd (:type cmd)) (:fn cmd)) + ((:fn cmd)))) + +(deftest query-files-redispatched-on-filter-typing-test + (testing "Each filter keystroke fires chat/queryFiles with the current query" + (let [queries (atom [])] + (with-redefs [protocol/chat-query-files! + (fn [_srv chat-id query cb] + (swap! queries conj {:chat-id chat-id :query query}) + (cb {:result {:files []}}))] + (let [s0 (assoc (base-state) + :server {:queue (java.util.concurrent.LinkedBlockingQueue.)}) + [s1 c1] (state/update-state s0 (msg/key-press "@")) + _ (fire-cmd c1) + [s2 c2] (state/update-state s1 (msg/key-press "f")) + _ (fire-cmd c2) + [s3 c3] (state/update-state s2 (msg/key-press "o")) + _ (fire-cmd c3) + [s4 c4] (state/update-state s3 (msg/key-press "o")) + _ (fire-cmd c4) + [_s5 c5] (state/update-state s4 (msg/key-press :backspace)) + _ (fire-cmd c5)] + ;; Empty open + 3 typed chars + 1 backspace = 5 dispatches. + ;; Each dispatch carries the query as it stood after that keystroke. + (is (= [{:chat-id "chat1" :query ""} + {:chat-id "chat1" :query "f"} + {:chat-id "chat1" :query "fo"} + {:chat-id "chat1" :query "foo"} + {:chat-id "chat1" :query "fo"}] + @queries)))))))