diff --git a/bb.edn b/bb.edn index fe5cede..5e44530 100644 --- a/bb.edn +++ b/bb.edn @@ -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]) @@ -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 diff --git a/src/eca_cli/commands.clj b/src/eca_cli/commands.clj index fe36c0a..6561cbc 100644 --- a/src/eca_cli/commands.clj +++ b/src/eca_cli/commands.clj @@ -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) @@ -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}}) diff --git a/src/eca_cli/jobs.clj b/src/eca_cli/jobs.clj new file mode 100644 index 0000000..f3c8fcb --- /dev/null +++ b/src/eca_cli/jobs.clj @@ -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]))) diff --git a/src/eca_cli/protocol.clj b/src/eca_cli/protocol.clj index 1fc14a1..f29419a 100644 --- a/src/eca_cli/protocol.clj +++ b/src/eca_cli/protocol.clj @@ -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)) diff --git a/src/eca_cli/state.clj b/src/eca_cli/state.clj index 0cc8c57..36615c4 100644 --- a/src/eca_cli/state.clj +++ b/src/eca_cli/state.clj @@ -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 @@ -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] @@ -114,6 +118,7 @@ :chat-id nil :chat-title nil :items [] + :jobs {} :current-text "" :tool-calls {} :pending-approval nil @@ -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) @@ -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) diff --git a/src/eca_cli/view.clj b/src/eca_cli/view.clj index ac35d98..de861f2 100644 --- a/src/eca_cli/view.clj +++ b/src/eca_cli/view.clj @@ -4,6 +4,10 @@ [charm.components.text-input :as ti] [eca-cli.view.blocks :as blocks])) +;; Lazy resolution to break the jobs โ†” view circular require. +(defn- jobs-fn [sym] + (requiring-resolve (symbol "eca-cli.jobs" (name sym)))) + (defn divider [width] (apply str (repeat width "โ”€"))) @@ -65,11 +69,16 @@ (str "๐Ÿšง " summary "\n[y] approve [Y] always [n] reject")))) (defn- render-picker [state] - (let [{:keys [kind query list]} (:picker state) - label (case kind :model "model" :agent "agent" :session "chat" :command "command" "item")] - (str "Select " label " (type to filter): " query "\n" - (divider (:width state)) "\n" - (cl/list-view list)))) + (let [{:keys [kind query list]} (:picker state)] + (if (= :jobs kind) + (case (get-in state [:jobs-view :kind]) + :output ((jobs-fn 'render-output-popup-lines) state) + :confirm-kill ((jobs-fn 'render-confirm-kill-lines) state) + ((jobs-fn 'render-jobs-panel-lines) state)) + (let [label (case kind :model "model" :agent "agent" :session "chat" :command "command" "item")] + (str "Select " label " (type to filter): " query "\n" + (divider (:width state)) "\n" + (cl/list-view list)))))) (defn render-status-bar [state] (let [workspace (-> (get-in state [:opts :workspace] ".") @@ -85,13 +94,14 @@ (when (pos? (:context l)) (str (int (* 100 (/ (:sessionTokens usage) (:context l)))) "%"))) loading (when (some #(not (:done? %)) (vals (:init-tasks state))) "โณ") + jobs-frag ((jobs-fn 'status-bar-fragment) state (:width state)) chat-title (let [t (:chat-title state)] (when (and t (seq t)) (if (> (count t) 24) (str "\"" (subs t 0 24) "โ€ฆ\"") (str "\"" t "\"")))) trust (if (:trust state) "TRUST" "SAFE")] - (str/join " " (remove nil? [workspace loading model agent variant tokens cost ctx-pct chat-title trust])))) + (str/join " " (remove nil? [workspace loading model agent variant jobs-frag tokens cost ctx-pct chat-title trust])))) (defn render-login [state] (let [{:keys [provider action field-idx]} (:login state) diff --git a/test/eca_cli/jobs_test.clj b/test/eca_cli/jobs_test.clj new file mode 100644 index 0000000..009bdde --- /dev/null +++ b/test/eca_cli/jobs_test.clj @@ -0,0 +1,257 @@ +(ns eca-cli.jobs-test + (:require [charm.components.text-input :as ti] + [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [eca-cli.commands :as commands] + [eca-cli.jobs :as jobs] + [eca-cli.protocol :as protocol]) + (:import [java.util.concurrent LinkedBlockingQueue TimeUnit])) + +(defn- key-msg [k] + {:type :key-press :key k}) + +(defn- base-state [] + {:mode :ready + :width 160 + :height 24 + :server nil + :opts {:workspace "/tmp"} + :items [] + :jobs {} + :input (ti/text-input)}) + +(defn- job [id chat-label status & {:keys [summary label elapsed exit started-at] + :or {summary "do thing" + label "do thing --flag" + elapsed "10s" + started-at "2026-05-15T10:00:00Z"}}] + {:id id + :type "shell" + :status status + :label label + :summary summary + :startedAt started-at + :elapsed elapsed + :exitCode exit + :chatId (str "c-" chat-label) + :chatLabel chat-label}) + +(deftest jobs-updated-handler-replaces-map-test + (testing "second update fully replaces the first map" + (let [s0 (base-state) + [s1 _] (jobs/handle-jobs-updated s0 {:jobs [(job "j1" "Chat A" "running") + (job "j2" "Chat B" "completed")]}) + [s2 _] (jobs/handle-jobs-updated s1 {:jobs [(job "j3" "Chat C" "failed")]})] + (is (= #{"j1" "j2"} (set (keys (:jobs s1))))) + (is (= #{"j3"} (set (keys (:jobs s2))))) + (is (= "failed" (get-in s2 [:jobs "j3" :status])))))) + +(deftest status-bar-fragment-empty-test + (testing "no jobs returns nil at any width" + (is (nil? (jobs/status-bar-fragment (base-state) 80))) + (is (nil? (jobs/status-bar-fragment (base-state) 160))))) + +(deftest status-bar-fragment-wide-test + (let [s (assoc (base-state) :jobs {"j1" (job "j1" "A" "running") + "j2" (job "j2" "A" "running")})] + (testing "wide (>=120) uses [N jobs]" + (is (= "[2 jobs]" (jobs/status-bar-fragment s 160))) + (is (= "[2 jobs]" (jobs/status-bar-fragment s 120)))) + (testing "narrow (<120) uses [Nj]" + (is (= "[2j]" (jobs/status-bar-fragment s 80))) + (is (= "[2j]" (jobs/status-bar-fragment s 119)))))) + +(deftest panel-render-grouped-test + (testing "rows grouped by chatLabel" + (let [s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "Chat Alpha" "running" :summary "build") + "j2" (job "j2" "Chat Beta" "completed" :summary "test") + "j3" (job "j3" "Chat Alpha" "failed" :summary "lint" :exit 1)})) + [s' _] (jobs/cmd-open-jobs-panel s) + out (jobs/render-jobs-panel-lines s')] + (is (= :picking (:mode s'))) + (is (= :jobs (get-in s' [:picker :kind]))) + (is (str/includes? out "Chat Alpha")) + (is (str/includes? out "Chat Beta")) + (is (str/includes? out "build")) + (is (str/includes? out "test")) + (is (str/includes? out "lint")) + (is (str/includes? out "exit:1"))))) + +(deftest empty-jobs-panel-test + (testing "/jobs with no jobs surfaces system message, does not enter picking" + (let [[s' _] (jobs/cmd-open-jobs-panel (base-state))] + (is (= :ready (:mode s'))) + (is (some #(str/includes? (:text %) "No background jobs") (:items s')))))) + +(deftest kill-confirm-flow-test + (testing "d on row opens modal, y dispatches kill, modal closes" + (let [kill-calls (atom []) + s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s) + [s2 _] (jobs/handle-key s1 (key-msg "d"))] + (is (= :confirm-kill (get-in s2 [:jobs-view :kind]))) + (is (= "j1" (get-in s2 [:jobs-view :job-id]))) + (with-redefs [protocol/jobs-kill! (fn [_ id cb] + (swap! kill-calls conj id) + (cb {:result {:killed true}}))] + (let [[s3 cmd] (jobs/handle-key s2 (key-msg "y"))] + (is (nil? (:jobs-view s3))) + (when cmd ((:fn cmd))) + (is (= ["j1"] @kill-calls))))))) + +(deftest kill-cancel-test + (testing "n on modal closes without dispatch" + (let [kill-calls (atom []) + s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s) + [s2 _] (jobs/handle-key s1 (key-msg "d"))] + (with-redefs [protocol/jobs-kill! (fn [_ id _] + (swap! kill-calls conj id))] + (let [[s3 cmd] (jobs/handle-key s2 (key-msg "n"))] + (is (nil? (:jobs-view s3))) + (is (nil? cmd)) + (is (empty? @kill-calls)))))) + + (testing "Escape on modal closes without dispatch" + (let [kill-calls (atom []) + s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s) + [s2 _] (jobs/handle-key s1 (key-msg "d"))] + (with-redefs [protocol/jobs-kill! (fn [_ id _] + (swap! kill-calls conj id))] + (let [[s3 cmd] (jobs/handle-key s2 (key-msg :escape))] + (is (nil? (:jobs-view s3))) + (is (nil? cmd)) + (is (empty? @kill-calls))))))) + +(deftest output-fetch-on-enter-test + (testing "Enter on row fires jobs/readOutput async; cmd returns nil; result lands on queue" + (let [read-calls (atom []) + queue (LinkedBlockingQueue.) + srv {:queue queue} + s (-> (base-state) + (assoc :server srv :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s)] + (with-redefs [protocol/jobs-read-output! (fn [_ id cb] + (swap! read-calls conj id) + (cb {:result {:lines [{:stream "stdout" :text "hello"}] + :status "running"}}))] + (let [[s2 cmd] (jobs/handle-key s1 (key-msg :enter)) + cmd-result (when cmd ((:fn cmd))) + queued (.poll queue 1 TimeUnit/SECONDS)] + (is (= :output (get-in s2 [:jobs-view :kind]))) + (is (= "j1" (get-in s2 [:jobs-view :job-id]))) + (is (nil? cmd-result) "cmd fn returns nil โ€” result is delivered via the queue, not as a sync message") + (is (= ["j1"] @read-calls)) + (is (= :eca-jobs-output (:type queued))) + (is (= "j1" (:job-id queued))) + (is (= {:lines [{:stream "stdout" :text "hello"}] + :status "running"} + (:data queued)))))))) + +(deftest read-output-cmd-default-on-empty-result-test + (testing "callback with no :result yields the default {:lines [] :status \"unknown\" :exitCode nil}" + (let [queue (LinkedBlockingQueue.) + srv {:queue queue} + s (-> (base-state) + (assoc :server srv :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s)] + (with-redefs [protocol/jobs-read-output! (fn [_ _ cb] (cb {}))] + (let [[_ cmd] (jobs/handle-key s1 (key-msg :enter))] + ((:fn cmd)) + (let [queued (.poll queue 1 TimeUnit/SECONDS)] + (is (= {:lines [] :status "unknown" :exitCode nil} (:data queued))))))))) + +(deftest read-output-cmd-no-block-test + (testing "cmd returns immediately even when the server callback never fires" + (let [queue (LinkedBlockingQueue.) + srv {:queue queue} + s (-> (base-state) + (assoc :server srv :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s)] + ;; Redef so the callback is captured but NEVER invoked โ€” simulates a stalled server. + (with-redefs [protocol/jobs-read-output! (fn [_ _ _cb] nil)] + (let [[_ cmd] (jobs/handle-key s1 (key-msg :enter)) + t0 (System/currentTimeMillis) + _ ((:fn cmd)) + elapsed (- (System/currentTimeMillis) t0)] + (is (< elapsed 500) (str "read-output-cmd must return immediately (no deref); elapsed=" elapsed "ms")) + (is (zero? (.size queue)) "no message queued because callback never fired")))))) + +(deftest kill-flow-async-no-block-test + (testing "kill cmd never blocks the executor โ€” fires protocol request and returns immediately" + (let [kill-calls (atom []) + queue (LinkedBlockingQueue.) + srv {:queue queue} + s (-> (base-state) + (assoc :server srv :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s) + [s2 _] (jobs/handle-key s1 (key-msg "d"))] + ;; Callback intentionally not invoked โ€” simulates a slow/unresponsive server. + (with-redefs [protocol/jobs-kill! (fn [_ id _cb] (swap! kill-calls conj id))] + (let [[s3 cmd] (jobs/handle-key s2 (key-msg "y")) + t0 (System/currentTimeMillis) + result (when cmd ((:fn cmd))) + elapsed (- (System/currentTimeMillis) t0)] + (is (nil? (:jobs-view s3))) + (is (< elapsed 500) (str "kill-cmd must return immediately (no deref); elapsed=" elapsed "ms")) + (is (nil? result) "kill-cmd does not emit a sync message") + (is (= ["j1"] @kill-calls))))))) + +(deftest output-popup-render-test + (testing "stderr lines prefixed, stdout plain" + (let [s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "A" "running")}) + (assoc :jobs-view {:kind :output + :job-id "j1" + :data {:lines [{:stream "stdout" :text "ok"} + {:stream "stderr" :text "bad"}] + :status "failed" + :exitCode 1}})) + out (jobs/render-output-popup-lines s)] + (is (str/includes? out "status=failed")) + (is (str/includes? out "exit=1")) + (is (str/includes? out "ok")) + (is (str/includes? out "[stderr] bad")))) + + (testing "no lines shows placeholder" + (let [s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "A" "running")}) + (assoc :jobs-view {:kind :output + :job-id "j1" + :data {:lines [] :status "completed" :exitCode 0}})) + out (jobs/render-output-popup-lines s)] + (is (str/includes? out "(no output)"))))) + +(deftest output-popup-escape-test + (testing "Escape on output overlay returns to panel" + (let [s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s) + s2 (assoc s1 :jobs-view {:kind :output :job-id "j1" :data {:lines [] :status "running"}}) + [s3 _] (jobs/handle-key s2 (key-msg :escape))] + (is (nil? (:jobs-view s3))) + (is (= :picking (:mode s3))) + (is (= :jobs (get-in s3 [:picker :kind])))))) + +(deftest panel-escape-test + (testing "Escape on panel returns to :ready" + (let [s (-> (base-state) + (assoc :jobs {"j1" (job "j1" "A" "running")})) + [s1 _] (jobs/cmd-open-jobs-panel s) + [s2 _] (jobs/handle-key s1 (key-msg :escape))] + (is (= :ready (:mode s2))) + (is (nil? (:picker s2))) + (is (nil? (:jobs-view s2)))))) + +(deftest commands-registration-test + (testing "/jobs is registered" + (is (contains? commands/command-registry "/jobs")) + (let [{:keys [doc handler]} (get commands/command-registry "/jobs")] + (is (string? doc)) + (is (seq doc)) + (is (fn? handler)))))