Pre-condition: Phase 2 complete (
bb testpasses, Ctrl+L model picker and/agentpicker functional).
Quit and come back to where you left off. Start fresh when you want to. Browse previous chats without restarting. Know which chat you're in at all times.
chat-idis stored in state and sent with every subsequentchat/prompt, maintaining continuity within a single process lifetime.:pickingmode (with charm list component) handles interactive selection — reused here for the session picker.send-request!inprotocol.cljhandles request/response with callbacks — used forchat/listandchat/open.welcomeMessagefromconfig/updatedis already surfaced as a system item.
What is NOT done yet: persistence across restarts, chat/opened/chat/cleared notification handlers, /new, /sessions, or chat title in the status bar.
eca-cli currently falls through the discovery chain to ~/.cache/nvim/eca/eca (the Neovim plugin binary). That's fragile — users without Neovim get no binary, and eca-cli can't control which version it runs against.
Target location: ~/.cache/eca/eca-cli/eca — eca-cli's exclusively managed binary.
Pinned version: stored as a constant in src/eca_cli/upgrade.clj:
(def eca-version "0.130.0")Platform detection:
(System/getProperty "os.name") |
(System/getProperty "os.arch") |
Asset |
|---|---|---|
"Linux" |
"amd64" / "x86_64" |
eca-native-linux-amd64.zip |
"Linux" |
"aarch64" |
eca-native-linux-aarch64.zip |
"Mac OS X" |
"aarch64" |
eca-native-macos-aarch64.zip |
"Mac OS X" |
"amd64" / "x86_64" |
eca-native-macos-amd64.zip |
Download flow in upgrade.clj:
- Build URL:
https://github.com/editor-code-assistant/eca/releases/download/{version}/{asset} curl -fsSL -o /tmp/eca-download.zip <url>unzip -o /tmp/eca-download.zip -d /tmp/eca-extract/chmod +xand move to~/.cache/eca/eca-cli/eca- Print version confirmation.
New bb task:
upgrade-eca {:doc "Download and install the pinned ECA binary"
:requires ([eca-cli.upgrade])
:task (eca-cli.upgrade/run!)}Discovery order update in server.clj — insert eca-cli's own binary as first fallback after --eca flag, before PATH and nvim:
1. --eca flag
2. ~/.cache/eca/eca-cli/eca ← NEW (eca-cli managed)
3. which eca (PATH)
4. ~/.cache/nvim/eca/eca (nvim plugin)
5. ~/Library/Caches/nvim/eca/eca (nvim macOS)
6. ~/.emacs.d/eca/eca (emacs)
On startup version check: In make-init, after resolving the binary path, run <binary> --version, parse the output, and if it doesn't match eca-version, emit a :system item in chat:
⚠ ECA version mismatch: running 0.128.2, expected 0.130.0. Run `bb upgrade-eca` to update.
This is a warning only — eca-cli still runs with whatever binary it found.
New file. Stores a map of workspace-path → chat-id in EDN:
~/.cache/eca/eca-cli-sessions.edn
{"/home/sam/project" "abc-123-def"
"/home/sam/other" "xyz-789-ghi"}
(defn sessions-path []
(str (System/getProperty "user.home") "/.cache/eca/eca-cli-sessions.edn"))
(defn load-chat-id
"Returns persisted chat-id for workspace, or nil."
[workspace])
(defn save-chat-id!
"Saves chat-id for workspace. Passing nil removes the entry."
[workspace chat-id])load-chat-id: slurp the file, clojure.edn/read-string, get by workspace. Return nil on any error (file missing, parse error).
save-chat-id!: Read existing map (or {}), assoc or dissoc, spit. Create parent dirs if needed (io/make-parents). No locking — only one eca-cli process per workspace is expected.
Wire in make-init: After resolving opts, call (sessions/load-chat-id workspace) and include the result as :chat-id in initial-state. First chat/prompt will then include chatId, resuming the session server-side.
Wire in send-chat-prompt! callback: After storing :chat-id in state, also call (sessions/save-chat-id! workspace chat-id). The callback runs on the reader thread — file I/O there is fine.
ECA sends chat/opened for:
chat/openhydration replay (before thechat/contentReceivedstream)chat/fork(Phase 9)
chat/opened does NOT fire for ordinary new chats created via chat/prompt — the protocol sequence diagram shows no chat/opened in the chat/prompt flow. The chat-id for a new chat arrives only via the chat/prompt response callback. As a consequence, :chat-title will be nil for newly started chats; it is only populated after a /sessions open or a fork.
Add to handle-eca-notification in state.clj:
"chat/opened"
(let [{:keys [chatId title]} (:params m)]
[(-> state
(assoc :chat-id chatId)
(assoc :chat-title title))
nil])New state field: :chat-title nil.
ECA sends chat/cleared before replaying messages via chat/open (and after chat/rollback in Phase 9). It does not fire in response to chat/delete — the client clears its own UI in the /new handler directly. chat/cleared is purely a server-initiated notification telling the client to wipe items before a replay stream.
"chat/cleared"
(let [clear-msgs? (get-in m [:params :messages])]
[(cond-> state
clear-msgs? (-> (assoc :items [])
(assoc :chat-lines [])
(assoc :scroll-offset 0)))
nil])(defn list-chats! [srv callback]
(send-request! srv "chat/list" {:limit 20} callback))
(defn open-chat! [srv chat-id callback]
(send-request! srv "chat/open" {:chatId chat-id} callback))
(defn delete-chat! [srv chat-id callback]
(send-request! srv "chat/delete" {:chatId chat-id} callback))Special-cased in the Enter handler in :ready (before the general "send to ECA" branch), like /model and /agent:
input text == "/new" + Enter in :ready:
- Capture current
:chat-id. - If no
:chat-id: clear input, stay in:ready— already fresh. - If
:chat-id:- Fire
delete-chat!as a cmd (fire-and-forget; ignore response). - Call
(sessions/save-chat-id! workspace nil)— remove from disk. - Clear local state:
:items [],:chat-lines [],:chat-id nil,:chat-title nil,:scroll-offset 0. - Clear input, stay in
:ready.
- Fire
No confirmation prompt — immediate. The action is recoverable via /sessions (old chat still exists on the server until deleted asynchronously).
Trigger: input is /sessions + Enter in :ready.
Flow:
- Fire
list-chats!as a cmd. Return[state cmd]— state unchanged, ui shows input cleared. - The callback puts
{:type :chat-list-loaded :chats [...]}on the queue. - On
:chat-list-loadedinhandle-eca-tick: enter:pickingwith:kind :session.
Picker :all entries: vector of [display-string chat-id] pairs:
; display: "My Project Chat • 2026-04-25 • 42 msgs"
; fall back to first 8 chars of id if no titleBuild the charm list from the display strings only. On cl/selected-item, use index to retrieve the chat-id from :all.
On Enter in session picker — note on existing picker handler:
The current picker Enter handler in update-state handles :model and :agent by calling selected-model-changed! / selected-agent-changed!. The :session kind must be branched separately: instead of sending a selection notification, call open-chat!, clear local items, and set :chat-id. The existing handler becomes a case or cond on (:kind picker).
On Enter in session picker:
- Get selected index from picker state.
- Fire
open-chat!cmd with the selected chat-id. - Clear
:items,:chat-lines,:scroll-offsetimmediately (server is about to replay). - Update
:chat-idto the selected id. - Dissoc
:picker, return to:ready. - The server will then emit
chat/cleared→chat/opened→chat/contentReceivedstream which populates the UI normally.
Picker state for sessions (addition to existing :picker map):
{:kind :session
:list <charm-list> ; display strings
:all [["Title • date • N msgs" "chat-id"] ...]
:query ""}Filtering: same case-insensitive substring match on the display string.
If chat/list returns empty: dispatch :chat-list-loaded with [], picker opens with an empty list and a "No sessions" indicator, Escape returns to :ready.
In render-status-bar, add chat title between the agent field and the SAFE indicator:
myproject claude-sonnet-4-6 code "My Project Chat" SAFE
- Show when
:chat-titleis non-nil and non-empty. - Truncate to 24 chars, append
…if longer. - Wrap in
"chars or use a distinct visual separator.
:ready --/sessions + Enter--> (fire list-chats!)
queue <-- chat/list response --> :chat-list-loaded
:ready --:chat-list-loaded--> :picking (kind :session)
:picking (session) --Enter--> :ready (+ fire open-chat!)
:picking (session) --Esc--> :ready (no change)
:loading is not a new mode. Between /sessions Enter and :chat-list-loaded, the app stays in :ready with the input cleared. A brief delay is acceptable.
(deftest load-chat-id-missing-file-test
(testing "returns nil when file does not exist"
(with-redefs [sessions/sessions-path (fn [] "/tmp/no-such-eca-file.edn")]
(is (nil? (sessions/load-chat-id "/some/workspace"))))))
(deftest load-chat-id-found-test
(testing "returns stored chat-id for workspace"
(let [f (java.io.File/createTempFile "sessions" ".edn")]
(spit f (pr-str {"/workspace" "chat-abc"}))
(with-redefs [sessions/sessions-path (fn [] (.getAbsolutePath f))]
(is (= "chat-abc" (sessions/load-chat-id "/workspace")))))))
(deftest load-chat-id-missing-workspace-test
(testing "returns nil when workspace not in file"
(let [f (java.io.File/createTempFile "sessions" ".edn")]
(spit f (pr-str {"/other" "chat-xyz"}))
(with-redefs [sessions/sessions-path (fn [] (.getAbsolutePath f))]
(is (nil? (sessions/load-chat-id "/workspace")))))))
(deftest save-and-load-round-trip-test
(testing "save then load returns same value"
(let [f (java.io.File/createTempFile "sessions" ".edn")]
(.deleteOnExit f)
(with-redefs [sessions/sessions-path (fn [] (.getAbsolutePath f))]
(sessions/save-chat-id! "/workspace" "chat-round-trip")
(is (= "chat-round-trip" (sessions/load-chat-id "/workspace")))))))
(deftest save-nil-removes-entry-test
(testing "save nil removes the workspace entry"
(let [f (java.io.File/createTempFile "sessions" ".edn")]
(.deleteOnExit f)
(with-redefs [sessions/sessions-path (fn [] (.getAbsolutePath f))]
(sessions/save-chat-id! "/workspace" "chat-to-remove")
(sessions/save-chat-id! "/workspace" nil)
(is (nil? (sessions/load-chat-id "/workspace")))))))(deftest chat-opened-handler-test
(testing "chat/opened stores chat-id and title"
(let [[s _] (handle-eca-notification
(base-state)
{:method "chat/opened"
:params {:chatId "new-chat-123" :title "My Project Chat"}})]
(is (= "new-chat-123" (:chat-id s)))
(is (= "My Project Chat" (:chat-title s)))))
(testing "chat/opened with no title stores nil"
(let [[s _] (handle-eca-notification
(base-state)
{:method "chat/opened"
:params {:chatId "chat-no-title"}})]
(is (= "chat-no-title" (:chat-id s)))
(is (nil? (:chat-title s))))))
(deftest chat-cleared-handler-test
(testing "chat/cleared with messages:true clears items and scroll"
(let [s0 (assoc (base-state)
:items [{:type :user :text "hi"}]
:scroll-offset 5)
[s _] (handle-eca-notification
s0
{:method "chat/cleared"
:params {:chatId "x" :messages true}})]
(is (empty? (:items s)))
(is (= 0 (:scroll-offset s)))))
(testing "chat/cleared with messages:false leaves items intact"
(let [s0 (assoc (base-state)
:items [{:type :user :text "hi"}])
[s _] (handle-eca-notification
s0
{:method "chat/cleared"
:params {:chatId "x" :messages false}})]
(is (= 1 (count (:items s)))))))
(deftest slash-new-clears-state-test
(testing "/new with no chat-id is a no-op (already fresh)"
(let [s0 (assoc (base-state) :mode :ready :chat-id nil)
s0 (assoc s0 :input (ti/set-value (ti/text-input) "/new"))
[s cmd] (state/update-state s0 (msg/key-press :enter))]
(is (= :ready (:mode s)))
(is (nil? cmd))))
(testing "/new with chat-id clears state and returns delete cmd"
(with-redefs [sessions/save-chat-id! (fn [& _] nil)]
(let [s0 (assoc (base-state)
:mode :ready
:chat-id "old-chat"
:items [{:type :user :text "hello"}])
s0 (assoc s0 :input (ti/set-value (ti/text-input) "/new"))
[s cmd] (state/update-state s0 (msg/key-press :enter))]
(is (= :ready (:mode s)))
(is (nil? (:chat-id s)))
(is (nil? (:chat-title s)))
(is (empty? (:items s)))
(is (some? cmd))))))
(deftest slash-sessions-fires-list-cmd-test
(testing "/sessions in :ready fires chat/list cmd"
(let [s0 (assoc (base-state) :mode :ready)
s0 (assoc s0 :input (ti/set-value (ti/text-input) "/sessions"))
[s cmd] (state/update-state s0 (msg/key-press :enter))]
(is (= :ready (:mode s)))
(is (some? cmd)))))
(deftest chat-list-loaded-enters-picking-test
(testing ":chat-list-loaded with results enters :picking :session"
(let [s0 (assoc (base-state) :mode :ready)
chats [{:id "chat-abc" :title "Project A" :messageCount 10}
{:id "chat-def" :title "Project B" :messageCount 5}]
[s _] (state/update-state s0 {:type :chat-list-loaded :chats chats})]
(is (= :picking (:mode s)))
(is (= :session (get-in s [:picker :kind])))
(is (= 2 (count (get-in s [:picker :all]))))))
(testing ":chat-list-loaded with empty list still enters :picking"
(let [s0 (assoc (base-state) :mode :ready)
[s _] (state/update-state s0 {:type :chat-list-loaded :chats []})]
(is (= :picking (:mode s)))
(is (= :session (get-in s [:picker :kind])))
(is (empty? (get-in s [:picker :all]))))))
(deftest session-picker-enter-fires-open-cmd-test
(testing "Enter in session picker returns :ready and fires open-chat cmd"
(let [s0 (assoc (base-state) :mode :ready)
chats [{:id "chat-abc" :title "My Chat" :messageCount 3}]
[s-pick _] (state/update-state s0 {:type :chat-list-loaded :chats chats})
[s cmd] (state/update-state s-pick (msg/key-press :enter))]
(is (= :ready (:mode s)))
(is (nil? (:picker s)))
(is (some? cmd)))))
(deftest session-picker-escape-test
(testing "Esc in session picker returns to :ready, no change"
(let [s0 (assoc (base-state) :mode :ready)
chats [{:id "chat-abc" :title "My Chat" :messageCount 3}]
[s-pick _] (state/update-state s0 {:type :chat-list-loaded :chats chats})
[s _] (state/update-state s-pick (msg/key-press :escape))]
(is (= :ready (:mode s)))
(is (nil? (:picker s))))))(testing "chat title shown when :chat-title set"
(let [bar (render-status-bar (assoc base :chat-title "My Project Chat"))]
(is (str/includes? bar "My Project Chat"))))
(testing "long title truncated to 24 chars with ellipsis"
(let [bar (render-status-bar (assoc base :chat-title "A very long chat title that exceeds limits"))]
(is (str/includes? bar "…"))
(is (not (str/includes? bar "A very long chat title that exceeds")))))
(testing "no title shown when :chat-title nil"
(let [bar (render-status-bar base)]
(is (not (str/includes? bar "My Project")))))Add to test/eca_cli/integration_test.clj under a Phase 3 section:
(deftest phase3-resume-test
(start! "bb run")
(try
(let [msg "resume-marker-xyzzy"]
(keys! msg "Enter")
(wait-for! (has "SAFE") 30000)
(kill!)
(Thread/sleep 500)
(start! "bb run")
(let [s (wait-for! (has "SAFE") 15000)]
(testing "previous message appears after restart (auto-resume)"
(is (str/includes? s msg)))))
(finally (kill!))))
(deftest phase3-new-command-test
(start! "bb run")
(try
(let [old-msg "new-cmd-before-xyzzy"
new-msg "new-cmd-after-xyzzy"]
(keys! old-msg "Enter")
(wait-for! (has "SAFE") 30000)
(keys! "/new" "Enter")
(Thread/sleep 500)
(testing "/new clears old message from UI"
(is (not (str/includes? (screen) old-msg))))
(keys! new-msg "Enter")
(let [s (wait-for! (has "SAFE") 30000)]
(testing "new message works after /new"
(is (str/includes? s new-msg)))))
(finally (kill!))))
(deftest phase3-sessions-picker-test
;; Self-contained: sends a message first to ensure at least one chat exists.
(start! "bb run")
(try
(keys! "sessions-seed-xyzzy" "Enter")
(wait-for! (has "SAFE") 30000)
(testing "/sessions opens session picker"
(keys! "/sessions" "Enter")
(let [s (wait-for! (has "Select chat") 8000)]
(is (str/includes? s "Select chat"))))
(testing "Escape from sessions picker returns to :ready"
(keys! "Escape")
(let [s (wait-for! (lacks "Select chat") 5000)]
(is (str/includes? s "SAFE"))))
(finally (kill!))))bb testpasses — no Phase 2 regressions.upgrade.cljplatform detection — correct asset name for linux-amd64, linux-aarch64, macos-aarch64, macos-amd64; throws on unknown platform.- Sessions persistence — 5 cases: load missing file, load found, load missing workspace, round-trip, save nil removes.
chat/openedstores:chat-idand:chat-title; no-title case stores nil.chat/clearedwithmessages:trueclears items + scroll;messages:falseleaves items intact./newwith no chat-id is no-op; with chat-id clears state and returns delete cmd./sessionsin:readyreturns non-nil cmd.:chat-list-loadedwith results enters:picking :sessionwith correct item count; empty list still enters picker.- Session picker Enter returns
:ready, fires open cmd, dissocs picker. - Session picker Escape returns
:ready, no change. - Status bar shows
:chat-title(truncated at 24 +…); nil shows nothing.
- App resumes previous chat after restart in same workspace — old message visible on screen.
/newclears UI; subsequent message works (app not broken after reset)./sessionsopens a picker with "Select chat" visible.
13b. bb upgrade-eca downloads the binary to ~/.cache/eca/eca-cli/eca and prints the version.
13c. bb run uses ~/.cache/eca/eca-cli/eca (confirm via --eca flag or log output).
14. Select a session from /sessions picker → previous messages replay and appear in chat.
15. Chat title appears in status bar after a chat exchange is established.
16. /new after a long chat → clean start, no old messages visible, next message begins a new session.
17. Two workspaces: each has its own independent chat-id (check eca-cli-sessions.edn).
- No
--resumeflag — auto-resume by default is the right call. Simpler UX, no flag to remember./newis the escape hatch. The roadmap mentioned--resume; this phase drops it. chat/openedfires for new chats too — ECA emits it for every new chat creation, not justchat/openreplays. The handler should not overwrite chat-id if it's already the same value (idempotent assoc is fine).chat/listlimit 20 — reasonable default. A/sessionspicker with >20 items would need search anyway (which the filter handles).- Session picker uses index for id lookup —
cl/selected-itemreturns the display string; usecl/selected-indexto get the index and look up(nth (:all picker) idx)for the full entry.cl/selected-indexis confirmed present in charm 0.2.71. chat/listerror handling — if thelist-chats!callback receives an error, dispatch:chat-list-loadedwith:chats []and append a{:type :system :text "⚠ Could not load sessions"}item to state. Don't leave the app stuck in:readywith no feedback.eca --versionoutput format — the binary printseca X.Y.Zon stdout (e.g.eca 0.130.0). Parse with(second (re-find #"eca (\S+)" output))and compare againstupgrade/eca-version.:chat-titleis nil for new chats — ECA does not emitchat/openedonchat/prompt. Title is only populated after a/sessionsopen or fork. The status bar omits the title field when nil; no placeholder needed.save-chat-id!call location — currentlysend-chat-prompt!callback is inprotocol.clj. It receives the workspace via the opts map that's threaded through state. Pass workspace explicitly when building the callback instate.clj, or read it from the opts stored in state.chat/openedtitle fromchat/prompt— for the normal first-message flow, ECA may not emitchat/openedexplicitly; the chat-id comes from thechat/promptresponse. The title only arrives viachat/opened. If ECA does emitchat/openedfor new chats, the title will appear automatically. If not,:chat-titleremains nil until/sessionsselects a chat orchat/openis called.