TUI: live Ollama model list, ESC to cancel, fix plain-HTTP body read
ober
e2b085b00ba9667e38d97e8a0b45847488950909
--- 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) --- 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)]) --- 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) --- 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."