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
2 changes: 2 additions & 0 deletions bb.edn
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
:task (do (require '[clojure.test])
(require '[eca-cli.chat-test])
(require '[eca-cli.commands-test])
(require '[eca-cli.jobs-test])
(require '[eca-cli.lifecycle-test])
(require '[eca-cli.protocol-test])
(require '[eca-cli.view-test])
Expand All @@ -22,6 +23,7 @@
(let [{:keys [fail error]}
(clojure.test/run-tests 'eca-cli.chat-test
'eca-cli.commands-test
'eca-cli.jobs-test
'eca-cli.lifecycle-test
'eca-cli.protocol-test
'eca-cli.view-test
Expand Down
5 changes: 5 additions & 0 deletions src/eca_cli/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
[eca-cli.sessions :as sessions]
[eca-cli.picker :as picker]
[eca-cli.login :as login]
[eca-cli.jobs :as jobs]
[eca-cli.view :as view]))

(declare command-registry)
Expand Down Expand Up @@ -64,12 +65,16 @@
(defn cmd-login [state]
[state (login/start-login-cmd (:server state) nil)])

(defn cmd-open-jobs-panel [state]
(jobs/cmd-open-jobs-panel state))

(def command-registry
{"/model" {:doc "Open model picker" :handler cmd-open-model-picker}
"/agent" {:doc "Open agent picker" :handler cmd-open-agent-picker}
"/new" {:doc "Start a fresh chat" :handler cmd-new-chat}
"/sessions" {:doc "Browse and resume previous chats" :handler cmd-list-sessions}
"/clear" {:doc "Clear chat display (local only)" :handler cmd-clear-chat}
"/jobs" {:doc "Show background shell jobs" :handler cmd-open-jobs-panel}
"/help" {:doc "Show available commands" :handler cmd-show-help}
"/quit" {:doc "Exit eca-cli" :handler cmd-quit}
"/login" {:doc "Manually trigger provider login" :handler cmd-login}})
Expand Down
272 changes: 272 additions & 0 deletions src/eca_cli/jobs.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
(ns eca-cli.jobs
"Background jobs panel: state slice, `jobs/updated` handler, `/jobs` command,
picker panel + output popup + kill confirm modal. Owns the `:picker` arm
with `:kind :jobs` and the `:jobs-view` overlay under it. No back-references
to eca-cli.state."
(:require [charm.components.list :as cl]
[charm.components.text-input :as ti]
[charm.message :as msg]
[charm.program :as program]
[clojure.string :as str]
[eca-cli.protocol :as protocol]
[eca-cli.view :as view]))

;; --- Status helpers ---

(defn- status-emoji [status]
(case status
"running" "🟡"
"completed" "✅"
"failed" "🔴"
"killed" "⚫"
"⚫"))

(defn- truncate-label [s n]
(let [s (or s "")]
(if (> (count s) n)
(str (subs s 0 (max 0 (- n 3))) "...")
s)))

(def ^:private summary-max-len 80)

(defn- job-summary
"Display label for a job: prefers :summary, falls back to :label then id,
truncated to `summary-max-len`. `id-fallback` is supplied separately so
callers that already destructured `:id` into a local can pass it directly."
[job id-fallback]
(truncate-label (or (:summary job) (:label job) id-fallback) summary-max-len))

(defn- jobs-vec
"Stable seq of jobs ordered by chatLabel then startedAt."
[state]
(->> (vals (:jobs state))
(sort-by (juxt #(or (:chatLabel %) "")
#(or (:startedAt %) "")))
vec))

;; --- Status-bar fragment ---

(defn status-bar-fragment
"Compact `[N jobs]` (>=120 cols) or `[Nj]` (<120). Returns nil when no jobs."
[state width]
(let [n (count (:jobs state))]
(when (pos? n)
(if (>= width 120)
(str "[" n " jobs]")
(str "[" n "j]")))))

;; --- ECA notification handler ---

(defn handle-jobs-updated
"Replaces the entire :jobs map keyed by id from the server-supplied list."
[state params]
(let [jobs (or (:jobs params) [])
by-id (into {} (map (fn [j] [(:id j) j])) jobs)]
[(assoc state :jobs by-id) nil]))

;; --- Protocol cmd builders ---

(defn- kill-cmd [srv job-id]
(program/cmd
(fn []
(protocol/jobs-kill! srv job-id (fn [_] nil))
nil)))

(defn- read-output-cmd
"Fires jobs/readOutput and returns immediately. The protocol callback (invoked
on the reader thread when the server responds) puts an :eca-jobs-output
runtime message onto the server queue, where handle-jobs-output picks it up.
No blocking deref — the command-executor thread is not stalled and concurrent
jobs/updated notifications continue to flow."
[srv job-id]
(program/cmd
(fn []
(let [queue (:queue srv)]
(protocol/jobs-read-output!
srv job-id
(fn [r]
(.put queue {:type :eca-jobs-output
:job-id job-id
:data (or (:result r)
{:lines [] :status "unknown" :exitCode nil})})))
nil))))

;; --- /jobs command — opens panel via shared :picker ---

(defn- panel-row-label
"Compact one-line label used as the picker row text and for confirm modal."
[job]
(let [emoji (status-emoji (:status job))
summary (job-summary job (:id job))
elapsed (or (:elapsed job) "")]
(str emoji " " summary " · " elapsed)))

(defn cmd-open-jobs-panel
"Open the jobs panel as a :picker with :kind :jobs. Empty :jobs surfaces a
system message — no panel opened."
[state]
(let [jobs (jobs-vec state)]
(if (empty? jobs)
[(-> state
(update :items conj {:type :system :text "No background jobs"})
view/rebuild-lines)
nil]
(let [labels (mapv panel-row-label jobs)]
[(-> state
(assoc :mode :picking
:picker {:kind :jobs
:list (cl/item-list labels :height 8)
:all jobs
:filtered jobs
:query ""})
(update :input ti/reset))
nil]))))

;; --- Render: jobs panel (chat-grouped list) ---

(defn render-jobs-panel-lines
"Returns a single rendered string for the panel: grouped rows by chatLabel,
delegated to from view/render-picker when (= :jobs (get-in state [:picker :kind]))."
[state]
(let [width (:width state)
filtered (get-in state [:picker :filtered])
list-comp (get-in state [:picker :list])
sel-idx (cl/selected-index list-comp)
groups (->> filtered
(group-by #(or (:chatLabel %) "Unknown Chat"))
(sort-by key))
header "Background Jobs (Enter: output · d: kill · Esc: close)"
sep (view/divider width)
flat-idx (atom -1)
render-row (fn [job]
(swap! flat-idx inc)
(let [marker (if (= @flat-idx sel-idx) "▸ " " ")
emoji (status-emoji (:status job))
summary (job-summary job (:id job))
elapsed (or (:elapsed job) "")
exit (when (and (= "failed" (:status job)) (:exitCode job))
(str " exit:" (:exitCode job)))]
(str marker emoji " " summary " " elapsed (or exit ""))))
max-hdr-w 60
hdr-chrome 4 ;; "── " prefix + trailing space before fill
render-group (fn [[chat-label jobs]]
(let [fill (max 0 (- (min max-hdr-w width) (count chat-label) hdr-chrome))
hdr (str "── " chat-label " " (apply str (repeat fill "─")))
rows (mapv render-row jobs)]
(str/join "\n" (into [hdr] rows))))]
(str/join "\n"
(into [header sep]
(interpose "" (mapv render-group groups))))))

;; --- Render: output popup ---

(defn render-output-popup-lines
"Returns the output popup view: header + buffered lines (stderr highlighted)."
[state]
(let [{:keys [job-id data]} (:jobs-view state)
job (get-in state [:jobs job-id])
status (or (:status data) (:status job) "unknown")
exit (:exitCode data)
label (job-summary job job-id)
header (str label " · status=" status
(when (some? exit) (str " · exit=" exit)))
lines (:lines data)
body (if (seq lines)
(mapv (fn [{:keys [stream text]}]
(if (= "stderr" stream)
(str "[stderr] " text)
text))
lines)
["(no output)"])
footer "Esc: back to panel"]
(str/join "\n"
(into [header (view/divider (:width state))]
(conj body (view/divider (:width state)) footer)))))

;; --- Render: kill confirm modal ---

(defn render-confirm-kill-lines
"Returns the kill confirm modal view."
[state]
(let [{:keys [job-id]} (:jobs-view state)
job (get-in state [:jobs job-id])
summary (job-summary job job-id)]
(str "Kill " summary "? [y/n]")))

;; --- Key dispatch ---

(defn- selected-job [state]
(let [list-comp (get-in state [:picker :list])
filtered (get-in state [:picker :filtered])
idx (cl/selected-index list-comp)]
(when (and (some? idx) (< idx (count filtered)))
(nth filtered idx))))

(defn- close-overlay [state]
(-> state (dissoc :jobs-view)))

(defn- close-panel [state]
(-> state
(assoc :mode :ready)
(dissoc :picker :jobs-view)
(update :input ti/focus)))

(defn- handle-output-key [state msg]
(cond
(and (msg/key-press? msg) (msg/key-match? msg :escape))
[(close-overlay state) nil]

:else [state nil]))

(defn- handle-confirm-key [state msg]
(cond
(and (msg/key-press? msg) (msg/key-match? msg "y"))
(let [job-id (get-in state [:jobs-view :job-id])]
[(close-overlay state) (kill-cmd (:server state) job-id)])

(and (msg/key-press? msg) (or (msg/key-match? msg "n")
(msg/key-match? msg :escape)))
[(close-overlay state) nil]

:else [state nil]))

(defn- handle-panel-key [state msg]
(cond
(and (msg/key-press? msg) (msg/key-match? msg :enter))
(if-let [job (selected-job state)]
[(assoc state :jobs-view {:kind :output :job-id (:id job) :data nil})
(read-output-cmd (:server state) (:id job))]
[state nil])

(and (msg/key-press? msg) (msg/key-match? msg "d"))
(if-let [job (selected-job state)]
[(assoc state :jobs-view {:kind :confirm-kill :job-id (:id job)}) nil]
[state nil])

(and (msg/key-press? msg) (msg/key-match? msg :escape))
[(close-panel state) nil]

:else
(let [[new-list _] (cl/list-update (get-in state [:picker :list]) msg)]
[(assoc-in state [:picker :list] new-list) nil])))

(defn handle-key
"Dispatch keys while in the jobs panel (`:picking + :kind :jobs`).
Sub-overlays (output popup, kill confirm) consume keys first."
[state msg]
(case (get-in state [:jobs-view :kind])
:output (handle-output-key state msg)
:confirm-kill (handle-confirm-key state msg)
(handle-panel-key state msg)))

;; --- Runtime msg handler ---

(defn handle-jobs-output
"Runtime event from read-output-cmd. Attaches fetched data to the overlay
if it is still open for the same job-id."
[state msg]
(let [{:keys [job-id data]} msg]
(if (and (= :output (get-in state [:jobs-view :kind]))
(= job-id (get-in state [:jobs-view :job-id])))
[(assoc-in state [:jobs-view :data] data) nil]
[state nil])))
11 changes: 11 additions & 0 deletions src/eca_cli/protocol.clj
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,14 @@

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

;; --- Background jobs ---

(defn jobs-list! [srv callback]
(send-request! srv "jobs/list" {} callback))

(defn jobs-kill! [srv job-id callback]
(send-request! srv "jobs/kill" {:jobId job-id} callback))

(defn jobs-read-output! [srv job-id callback]
(send-request! srv "jobs/readOutput" {:jobId job-id} callback))
10 changes: 10 additions & 0 deletions src/eca_cli/state.clj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
[eca-cli.chat :as chat]
[eca-cli.picker :as picker]
[eca-cli.login :as login]
[eca-cli.jobs :as jobs]
[eca-cli.commands :as commands]))

;; Expose last-known state for nREPL inspection
Expand Down Expand Up @@ -74,6 +75,9 @@
"chat/cleared"
(chat/handle-chat-cleared state notification)

"jobs/updated"
(jobs/handle-jobs-updated state (:params notification))

[state nil]))

(defn- handle-eca-tick [state msgs]
Expand Down Expand Up @@ -114,6 +118,7 @@
:chat-id nil
:chat-title nil
:items []
:jobs {}
:current-text ""
:tool-calls {}
:pending-approval nil
Expand Down Expand Up @@ -198,6 +203,7 @@

(= :eca-login-action (:type msg)) (login/handle-eca-login-action state msg)
(= :eca-login-complete (:type msg)) (login/handle-eca-login-complete state msg)
(= :eca-jobs-output (:type msg)) (jobs/handle-jobs-output state msg)

(= :chat-list-loaded (:type msg))
(let [chats (:chats msg)
Expand Down Expand Up @@ -245,6 +251,10 @@
cmd-name)
[state nil]))

(and (= :picking (:mode state))
(= :jobs (get-in state [:picker :kind])))
(jobs/handle-key state msg)

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

Expand Down
Loading