TUI: live Ollama model list, ESC to cancel, fix plain-HTTP body read

ober

e2b085b00ba9667e38d97e8a0b45847488950909

diff --git a/lib/jcode/provider/provider.sls b/lib/jcode/provider/provider.sls
index 0921331..d01e246 100644
--- a/lib/jcode/provider/provider.sls
+++ b/lib/jcode/provider/provider.sls
@@ -1187,12 +1187,18 @@
                      (let* ([status-line (port-read-line in)]
                             [status (parse-http-status status-line)]
                             [resp-headers (port-read-headers in)]
-                            [cl (header-value
-                                  resp-headers
-                                  "content-length")]
-                            [body (if cl
-                                      (get-string-n in (string->number cl))
-                                      (port-read-all in))])
+                            [te (header-value resp-headers "transfer-encoding")]
+                            [cl (header-value resp-headers "content-length")]
+                            [raw-str (cond
+                                       [(and te (string-contains te "chunked"))
+                                        (port-read-all in)]
+                                       [cl (get-string-n in (string->number cl))]
+                                       [else (port-read-all in)])]
+                            [body (if (and te (string-contains te "chunked"))
+                                      (utf8->string
+                                        (decode-chunked-bytes
+                                          (string->utf8 raw-str)))
+                                      raw-str)])
                        (values status body)))
                    (lambda () (close-port in) (close-port out))))))))
   (def (openai-list-models provider)
diff --git a/lib/jcode/ui/tui.sls b/lib/jcode/ui/tui.sls
index a6cbbe2..ee6992a 100644
--- a/lib/jcode/ui/tui.sls
+++ b/lib/jcode/ui/tui.sls
@@ -252,9 +252,10 @@
                    (- (app-state-scroll-offset state)
                       (quotient (msg-area-height state) 2))))
             (app-state-dirty?-set! state #t)]
-           [(and (= key TB_KEY_CTRL_C) (app-state-agent-busy? state))
-            (tui-log "  -> cancel-agent")
-            (app-state-agent-busy?-set! state #f)
+           [(and (or (= key TB_KEY_CTRL_C) (= key TB_KEY_ESC))
+                 (app-state-agent-busy? state))
+            (tui-log "  -> cancel-agent (key=~a)" key)
+            (set-car! *tui-stream-abort* #t)
             (app-state-dirty?-set! state #t)]
            [#t
             (if (app-state-agent-busy? state)
@@ -585,16 +586,33 @@
                   "No providers refreshed.\n"
                   (string-join (reverse lines) "\n"))))])
          (app-state-dirty?-set! state #t)))
+  (def (fetch-live-models-if-local provider-name)
+       "For local/keyless providers like Ollama, fetch the live model list so\n   users see every pulled model (not just the small hardcoded seed list).\n   Returns #f on any error so the caller falls back to provider-models."
+       (tui-log "fetch-live-models: provider=~a" provider-name)
+       (and (equal? provider-name "ollama")
+            (guard (e
+                     [#t
+                      (tui-log
+                        "fetch-live-models: error ~a"
+                        (with-output-to-string
+                          (lambda () (display-condition e))))
+                      #f])
+              (let* ([prov (make-provider
+                             provider-name
+                             ""
+                             (provider-default-model provider-name))]
+                     [models (provider-list-models prov)])
+                (tui-log
+                  "fetch-live-models: got ~a models"
+                  (length models))
+                (and (pair? models) models)))))
   (def (handle-model-popup! state)
        "Show a popup list of models for the current provider."
        (let* ([cur-p (or (current-provider-override)
                          (config-provider))]
               [cur-m (or (current-model-override) (config-model))]
-              [models (if (equal? cur-p "ollama")
-                          (try (let ([prov (make-provider "ollama" #f #f)])
-                                 (provider-list-models prov))
-                               (catch (e) (provider-models cur-p)))
-                          (provider-models cur-p))]
+              [live (fetch-live-models-if-local cur-p)]
+              [models (or live (provider-models cur-p))]
               [items (map (lambda (m)
                             (cons
                               (format
@@ -636,6 +654,7 @@
        "Update sidebar MCP connection info."
        (let ([servers (try (mcp-active-servers) (catch (e) '()))])
          (sidebar-state-mcp-set! (app-state-sidebar state) servers)))
+  (def *tui-stream-abort* (cons #f #f))
   (def (send-agent-event! ev)
        (let ([main (*main-thread*)])
          (when main (thread-send main ev))))
@@ -662,10 +681,16 @@
           (app-state-agent-busy?-set! state #f)
           (app-state-scroll-offset-set! state 0)
           (app-state-dirty?-set! state #t)]
+         [(list 'agent-cancelled)
+          (tui-log "apply-agent-event: agent-cancelled")
+          (add-message! state (msg-block-system "(interrupted)"))
+          (app-state-agent-busy?-set! state #f)
+          (app-state-dirty?-set! state #t)]
          [_ (tui-log "apply-agent-event: unknown event ~s" ev)]))
   (def (run-agent! state text)
        (app-state-agent-busy?-set! state #t)
        (app-state-stream-buf-set! state "")
+       (set-car! *tui-stream-abort* #f)
        (add-message! state (msg-block-assistant ""))
        (app-state-dirty?-set! state #t)
        (let ([p-name (or (current-provider-override)
@@ -685,6 +710,9 @@
                                  [current-model-override m-override]
                                  [current-stream-cb
                                   (lambda (token)
+                                    (when (car *tui-stream-abort*)
+                                      (error 'stream-aborted
+                                        "interrupted by user"))
                                     (send-agent-event!
                                       (list 'stream-token token)))]
                                  [current-tool-cb
@@ -710,7 +738,12 @@
                     (e)
                     (let ([msg (err->string e)])
                       (tui-log "worker: CAUGHT exception: ~a" msg)
-                      (send-agent-event! (list 'agent-error msg)))))))))
+                      (cond
+                        [(string-contains msg "stream-aborted")
+                         (send-agent-event! (list 'agent-cancelled))]
+                        [else
+                         (send-agent-event!
+                           (list 'agent-error msg))]))))))))
   (def (tui-stream-token! state token)
        "Handle a streaming token from the LLM — called from agent thread."
        (let ([buf (app-state-stream-buf state)])
diff --git a/src/jcode/provider/provider.ss b/src/jcode/provider/provider.ss
index bf45e62..afa3e11 100644
--- a/src/jcode/provider/provider.ss
+++ b/src/jcode/provider/provider.ss
@@ -973,10 +973,18 @@
               (let* ((status-line (port-read-line in))
                      (status (parse-http-status status-line))
                      (resp-headers (port-read-headers in))
+                     (te (header-value resp-headers "transfer-encoding"))
                      (cl (header-value resp-headers "content-length"))
-                     (body (if cl
-                             (get-string-n in (string->number cl))
-                             (port-read-all in))))
+                     ;; in is a textual port (headers were read via read-char),
+                     ;; so read body as text, then re-encode for chunked decode.
+                     (raw-str (cond
+                                ((and te (string-contains te "chunked"))
+                                 (port-read-all in))
+                                (cl (get-string-n in (string->number cl)))
+                                (else (port-read-all in))))
+                     (body (if (and te (string-contains te "chunked"))
+                             (utf8->string (decode-chunked-bytes (string->utf8 raw-str)))
+                             raw-str)))
                 (values status body)))
             (lambda ()
               (close-port in)
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index fee6ea8..49b40e5 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -352,10 +352,11 @@
          (max 0 (- (app-state-scroll-offset state) (quotient (msg-area-height state) 2))))
        (app-state-dirty?-set! state #t))
 
-      ;; Ctrl-C during agent: cancel
-      ((and (= key TB_KEY_CTRL_C) (app-state-agent-busy? state))
-       (tui-log "  -> cancel-agent")
-       (app-state-agent-busy?-set! state #f)
+      ;; Ctrl-C or ESC during agent: cancel
+      ((and (or (= key TB_KEY_CTRL_C) (= key TB_KEY_ESC))
+            (app-state-agent-busy? state))
+       (tui-log "  -> cancel-agent (key=~a)" key)
+       (set-car! *tui-stream-abort* #t)
        (app-state-dirty?-set! state #t))
 
       ;; Input handling
@@ -635,18 +636,28 @@
              (string-join (reverse lines) "\n"))))))
     (app-state-dirty?-set! state #t)))
 
+(def (fetch-live-models-if-local provider-name)
+  "For local/keyless providers like Ollama, fetch the live model list so
+   users see every pulled model (not just the small hardcoded seed list).
+   Returns #f on any error so the caller falls back to provider-models."
+  (tui-log "fetch-live-models: provider=~a" provider-name)
+  (and (equal? provider-name "ollama")
+       (guard (e [#t
+                  (tui-log "fetch-live-models: error ~a"
+                    (with-output-to-string (lambda () (display-condition e))))
+                  #f])
+         (let* ((prov (make-provider provider-name ""
+                        (provider-default-model provider-name)))
+                (models (provider-list-models prov)))
+           (tui-log "fetch-live-models: got ~a models" (length models))
+           (and (pair? models) models)))))
+
 (def (handle-model-popup! state)
   "Show a popup list of models for the current provider."
   (let* ((cur-p (or (current-provider-override) (config-provider)))
          (cur-m (or (current-model-override) (config-model)))
-         (models
-           ;; For Ollama, live-query the local API for installed models
-           (if (equal? cur-p "ollama")
-             (try
-               (let ((prov (make-provider "ollama" #f #f)))
-                 (provider-list-models prov))
-               (catch (e) (provider-models cur-p)))
-             (provider-models cur-p)))
+         (live  (fetch-live-models-if-local cur-p))
+         (models (or live (provider-models cur-p)))
          (items (map (lambda (m)
                        (cons (format "~a~a  (~a)"
                                (if (equal? (car m) cur-m) "● " "  ")
@@ -685,6 +696,10 @@
 ;; happens on the main thread, eliminating races on Chez hash tables and
 ;; msg-block lists (which are not thread-safe under preemptive pthreads).
 
+;; Abort flag: ESC/Ctrl-C during a busy agent sets the car to #t. The
+;; stream callback raises, aborting the in-flight HTTP stream.
+(def *tui-stream-abort* (cons #f #f))
+
 (def (send-agent-event! ev)
   (let ((main (*main-thread*)))
     (when main (thread-send main ev))))
@@ -718,6 +733,11 @@
      (app-state-agent-busy?-set! state #f)
      (app-state-scroll-offset-set! state 0)
      (app-state-dirty?-set! state #t))
+    ((list 'agent-cancelled)
+     (tui-log "apply-agent-event: agent-cancelled")
+     (add-message! state (msg-block-system "(interrupted)"))
+     (app-state-agent-busy?-set! state #f)
+     (app-state-dirty?-set! state #t))
     (_ (tui-log "apply-agent-event: unknown event ~s" ev))))
 
 ;; ---- Agent integration ----
@@ -725,6 +745,7 @@
 (def (run-agent! state text)
   (app-state-agent-busy?-set! state #t)
   (app-state-stream-buf-set! state "")
+  (set-car! *tui-stream-abort* #f)
   ;; Add empty assistant message that will be filled by streaming
   (add-message! state (msg-block-assistant ""))
   (app-state-dirty?-set! state #t)
@@ -747,6 +768,8 @@
              (current-model-override m-override)
              (current-stream-cb
                (lambda (token)
+                 (when (car *tui-stream-abort*)
+                   (error 'stream-aborted "interrupted by user"))
                  (send-agent-event! (list 'stream-token token))))
              (current-tool-cb
                (lambda (event name args)
@@ -763,7 +786,11 @@
           (catch (e)
             (let ((msg (err->string e)))
               (tui-log "worker: CAUGHT exception: ~a" msg)
-              (send-agent-event! (list 'agent-error msg)))))))))
+              (cond
+                ((string-contains msg "stream-aborted")
+                 (send-agent-event! (list 'agent-cancelled)))
+                (else
+                 (send-agent-event! (list 'agent-error msg)))))))))))
 
 (def (tui-stream-token! state token)
   "Handle a streaming token from the LLM — called from agent thread."