updates

ober

a2d10d3283809b7125f37ed28f6906c3cb7c0885

diff --git a/docs/providers.md b/docs/providers.md
index 538d2a8..5fd7a23 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -50,7 +50,9 @@ windows, and a default. A few highlights:
 
 The registry isn't a whitelist — set `model` to **any id the provider accepts**
 and it's used as-is. Live model and pricing metadata is cached under
-`~/.jcode/models-cache.json` and `~/.jcode/pricing-cache.json`.
+`~/.jcode/models-cache.json` and `~/.jcode/pricing-cache.json`. In the TUI
+model picker, type to search by model id or display name; this is especially
+useful after `/refresh-models` loads the full OpenRouter catalog.
 
 **Context windows** are tracked per model (Claude 4 → 200k, GPT-4o → 128k,
 Gemini → 1M, Llama 3 / DeepSeek → 131k, Qwen3 → 32k). Local MLX defaults to a
diff --git a/docs/tui.md b/docs/tui.md
index b269152..0ff09c7 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -31,6 +31,9 @@ rendering, live diffs, themes, and a stats sidebar.
 - **Input** — multi-line, wraps to a maximum of 8 visible rows.
 - **Status bar** — mode (PLAN yellow / BUILD green), model, token counts
   (`in/out`, k/M-formatted), cache-hit rate, and accumulated cost.
+- **Model/provider pickers** — type inside the popup to search; the active
+  provider or model is marked separately from the row currently highlighted for
+  Enter.
 
 ## Keybindings
 
diff --git a/src/jcode/core/models.ss b/src/jcode/core/models.ss
index 6e2f291..239c82c 100644
--- a/src/jcode/core/models.ss
+++ b/src/jcode/core/models.ss
@@ -3,6 +3,7 @@
 ;;; populated by the /refresh-models command.
 
 (export provider-models
+        filter-models
         all-providers
         provider-display-name
         provider-default-model
@@ -80,6 +81,24 @@
       (cache-entries->pairs cached)
       (provider-default-models provider))))
 
+(def (model-search-terms query)
+  (filter (lambda (s) (not (string=? s "")))
+          (string-split (string-downcase (string-trim (or query ""))) #\space)))
+
+(def (model-search-hit? model terms)
+  (let ((haystack (string-downcase
+                    (format "~a ~a" (car model) (cdr model)))))
+    (every (lambda (term) (number? (string-contains haystack term)))
+           terms)))
+
+(def (filter-models models query)
+  "Return MODELS whose id/display text contains every whitespace-separated
+   query term, case-insensitively. Empty query returns MODELS unchanged."
+  (let ((terms (model-search-terms query)))
+    (if (null? terms)
+      models
+      (filter (lambda (m) (model-search-hit? m terms)) models))))
+
 (def (provider-default-models provider)
   (case (string->symbol provider)
     ((anthropic)   anthropic-models)
diff --git a/src/jcode/ui/serve.ss b/src/jcode/ui/serve.ss
index fe33bee..980c1e0 100644
--- a/src/jcode/ui/serve.ss
+++ b/src/jcode/ui/serve.ss
@@ -11,6 +11,7 @@
 ;;;   {"type":"switch_session","id":"..."}                switch existing
 ;;;   {"type":"list_sessions"}                            enumerate sessions
 ;;;   {"type":"get_mcp_calls"}                            replay MCP call log
+;;;   {"type":"list_models","provider":"openrouter","query":"claude"}  list models
 ;;;   {"type":"cancel"}                                   abort in-flight turn
 ;;;   {"type":"ping"}                                     heartbeat
 ;;;
@@ -404,11 +405,13 @@
         ("model" . ,model)
         ("providers" . ,avail)))))
 
-(def (handle-list-models provider)
+(def (handle-list-models provider query)
   "Send model list for a given provider."
-  (let ((models (provider-models (or provider
-                                     (current-provider-override)
-                                     (config-provider)))))
+  (let ((models (filter-models
+                  (provider-models (or provider
+                                       (current-provider-override)
+                                       (config-provider)))
+                  (or query ""))))
     (for-each
       (lambda (m)
         (emit-event "model_item"
@@ -473,7 +476,8 @@
       ((equal? type "get_config")
        (handle-get-config))
       ((equal? type "list_models")
-       (handle-list-models (hash-get event "provider")))
+       (handle-list-models (hash-get event "provider")
+                           (hash-get event "query")))
       ((equal? type "set_provider")
        (let ((name (hash-get event "name")))
          (if name (handle-set-provider name)
diff --git a/src/jcode/ui/tui-dialog.ss b/src/jcode/ui/tui-dialog.ss
index 951bcba..b29f11e 100644
--- a/src/jcode/ui/tui-dialog.ss
+++ b/src/jcode/ui/tui-dialog.ss
@@ -13,7 +13,8 @@
   list-dialog-scroll list-dialog-result
   show-list-dialog! render-list-dialog!)
 
-(import :jcode/ui/tui-ffi
+(import :std/misc/string
+        :jcode/ui/tui-ffi
         :jerboa/core
         :jerboa/runtime
         :jcode/ui/tui-theme)
@@ -177,47 +178,128 @@
 
 (defstruct list-dialog
   (title       ;; string
-   items       ;; list of (display-text . value)
+   items       ;; filtered list of (display-text . value)
    selected    ;; index of highlighted item
    scroll      ;; scroll offset for long lists
-   result)     ;; #f until resolved, then selected value
+   result      ;; #f until resolved, then selected value
+   query       ;; current search query
+   all-items   ;; unfiltered list of (display-text . value)
+   current-value) ;; active value, highlighted independently from selected
   transparent: #t)
 
+(def (list-dialog-max-visible screen-h)
+  (max 1 (- screen-h 7)))
+
+(def (list-dialog-query-terms query)
+  (filter (lambda (s) (not (string=? s "")))
+          (string-split (string-downcase (string-trim (or query ""))) #\space)))
+
+(def (list-dialog-item-matches? item terms)
+  (let ((haystack (string-downcase (format "~a ~a" (car item) (cdr item)))))
+    (every (lambda (term) (number? (string-contains haystack term)))
+           terms)))
+
+(def (list-dialog-filter-items items query)
+  (let ((terms (list-dialog-query-terms query)))
+    (if (null? terms)
+      items
+      (filter (lambda (item) (list-dialog-item-matches? item terms)) items))))
+
+(def (list-dialog-index-of-value items value fallback)
+  (let loop ((rest items) (idx 0))
+    (cond
+      ((null? rest) fallback)
+      ((equal? (cdar rest) value) idx)
+      (else (loop (cdr rest) (+ idx 1))))))
+
+(def (list-dialog-selected-value dlg)
+  (let ((items (list-dialog-items dlg)))
+    (and (pair? items)
+         (cdr (list-ref items
+                (min (list-dialog-selected dlg)
+                     (- (length items) 1)))))))
+
+(def (list-dialog-clamp-scroll! dlg max-visible)
+  (let* ((items (list-dialog-items dlg))
+         (nitems (length items))
+         (sel (if (> nitems 0)
+                (min (list-dialog-selected dlg) (- nitems 1))
+                0)))
+    (list-dialog-selected-set! dlg sel)
+    (cond
+      ((< sel (list-dialog-scroll dlg))
+       (list-dialog-scroll-set! dlg sel))
+      ((>= (- sel (list-dialog-scroll dlg)) max-visible)
+       (list-dialog-scroll-set! dlg
+         (max 0 (- sel (- max-visible 1))))))
+    (when (> (list-dialog-scroll dlg) (max 0 (- nitems max-visible)))
+      (list-dialog-scroll-set! dlg (max 0 (- nitems max-visible))))))
+
+(def (list-dialog-set-query! dlg query max-visible)
+  (let* ((selected-before (list-dialog-selected-value dlg))
+         (items (list-dialog-filter-items (list-dialog-all-items dlg) query))
+         (preferred (if (string=? (string-trim query) "")
+                      (list-dialog-current-value dlg)
+                      selected-before))
+         (idx (if (pair? items)
+                (list-dialog-index-of-value items preferred 0)
+                0)))
+    (list-dialog-query-set! dlg query)
+    (list-dialog-items-set! dlg items)
+    (list-dialog-selected-set! dlg idx)
+    (list-dialog-scroll-set! dlg 0)
+    (list-dialog-clamp-scroll! dlg max-visible)))
+
+(def (list-dialog-delete-query-char! dlg max-visible)
+  (let* ((query (list-dialog-query dlg))
+         (len (string-length query)))
+    (when (> len 0)
+      (list-dialog-set-query! dlg (substring query 0 (- len 1)) max-visible))))
+
+(def (list-dialog-append-query-char! dlg ch max-visible)
+  (list-dialog-set-query! dlg
+    (string-append (list-dialog-query dlg) (string ch))
+    max-visible))
+
 (def (show-list-dialog! dlg screen-w screen-h)
   "Show list dialog modally. Returns the selected item's value or #f on cancel."
-  (let ((max-visible (- screen-h 6)))  ;; leave room for borders + title
+  (let ((max-visible (list-dialog-max-visible screen-h)))
+    (list-dialog-clamp-scroll! dlg max-visible)
     (let loop ()
       (render-list-dialog! dlg screen-w screen-h)
       (tb-present!)
       (let ((ev (tb-poll-event)))
         (when (and ev (tui-event-key? ev))
           (let ((key (tui-event-key ev))
+                (ch  (tui-event-ch ev))
                 (nitems (length (list-dialog-items dlg))))
             (cond
-              ;; Arrow down / j: next item
-              ((or (= key TB_KEY_ARROW_DOWN)
-                   (and (= (tui-event-ch ev) (char->integer #\j)) (= key 0)))
+              ;; Arrow down: next item
+              ((= key TB_KEY_ARROW_DOWN)
                (when (< (list-dialog-selected dlg) (- nitems 1))
                  (list-dialog-selected-set! dlg (+ (list-dialog-selected dlg) 1))
-                 ;; Scroll if needed
-                 (when (>= (- (list-dialog-selected dlg) (list-dialog-scroll dlg)) max-visible)
-                   (list-dialog-scroll-set! dlg (+ (list-dialog-scroll dlg) 1)))))
-              ;; Arrow up / k: prev item
-              ((or (= key TB_KEY_ARROW_UP)
-                   (and (= (tui-event-ch ev) (char->integer #\k)) (= key 0)))
+                 (list-dialog-clamp-scroll! dlg max-visible)))
+              ;; Arrow up: previous item
+              ((= key TB_KEY_ARROW_UP)
                (when (> (list-dialog-selected dlg) 0)
                  (list-dialog-selected-set! dlg (- (list-dialog-selected dlg) 1))
-                 (when (< (list-dialog-selected dlg) (list-dialog-scroll dlg))
-                   (list-dialog-scroll-set! dlg (list-dialog-selected dlg)))))
+                 (list-dialog-clamp-scroll! dlg max-visible)))
               ;; Enter: accept
               ((= key TB_KEY_ENTER)
                (when (> nitems 0)
                  (let ((item (list-ref (list-dialog-items dlg) (list-dialog-selected dlg))))
                    (list-dialog-result-set! dlg (cdr item)))))
-              ;; Escape / q: cancel
-              ((or (= key TB_KEY_ESC)
-                   (and (= (tui-event-ch ev) (char->integer #\q)) (= key 0)))
-               (list-dialog-result-set! dlg 'cancel))))))
+              ;; Backspace edits search.
+              ((or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2))
+               (list-dialog-delete-query-char! dlg max-visible))
+              ;; Escape clears search first, then cancels.
+              ((= key TB_KEY_ESC)
+               (if (> (string-length (list-dialog-query dlg)) 0)
+                 (list-dialog-set-query! dlg "" max-visible)
+                 (list-dialog-result-set! dlg 'cancel)))
+              ;; Printable characters search the full list.
+              ((> ch 31)
+               (list-dialog-append-query-char! dlg (integer->char ch) max-visible))))))
       (cond
         ((eq? (list-dialog-result dlg) 'cancel) #f)
         ((list-dialog-result dlg) (list-dialog-result dlg))
@@ -226,16 +308,19 @@
 (def (render-list-dialog! dlg screen-w screen-h)
   "Render a list selection dialog centered on screen."
   (let* ((items (list-dialog-items dlg))
+         (all-items (list-dialog-all-items dlg))
          (title (list-dialog-title dlg))
          (nitems (length items))
-         (max-visible (min nitems (- screen-h 6)))
+         (visible-rows (min (max 1 nitems)
+                            (list-dialog-max-visible screen-h)))
          (content-w (max (+ (string-length title) 4)
+                         (+ 20 (string-length (list-dialog-query dlg)))
                          (if (null? items) 20
-                           (+ 6 (apply max (map (lambda (i)
-                                                  (string-length (car i)))
-                                                items))))))
+                           (+ 18 (apply max (map (lambda (i)
+                                                   (string-length (car i)))
+                                                 items))))))
          (box-w (min (+ content-w 4) (- screen-w 4)))
-         (box-h (+ max-visible 3))  ;; title-border + items + hint + bottom-border
+         (box-h (+ visible-rows 4))  ;; title + search + items + hint + border
          (bx (max 0 (quotient (- screen-w box-w) 2)))
          (by (max 0 (quotient (- screen-h box-h) 2)))
          (bfg (face-fg-attr 'dialog-border))
@@ -248,29 +333,76 @@
     ;; Top border with title
     (draw-hline! bx by box-w bfg bbg #\─)
     (tb-print! (+ bx 2) by tfg bbg (string-append "─ " title " "))
+    ;; Search row
+    (let* ((qrow (+ by 1))
+           (query (list-dialog-query dlg))
+           (count-text (format "~a/~a" nitems (length all-items)))
+           (search-text (format "Search: ~a" query))
+           (text (string-append search-text
+                                (make-string
+                                  (max 1 (- box-w
+                                            (string-length search-text)
+                                            (string-length count-text)
+                                            5))
+                                  #\space)
+                                count-text)))
+      (clear-dialog-row! bx qrow box-w dbg)
+      (tb-change-cell! bx qrow (char->integer #\│) bfg bbg)
+      (tb-change-cell! (+ bx box-w -1) qrow (char->integer #\│) bfg bbg)
+      (tb-print! (+ bx 2) qrow (face-fg-attr 'dim) dbg
+                 (if (> (string-length text) (- box-w 4))
+                   (substring text 0 (- box-w 4))
+                   text)))
     ;; Items
-    (let loop ((idx scroll) (row (+ by 1)) (count 0))
-      (when (and (< idx nitems) (< count max-visible))
-        (let* ((item (list-ref items idx))
-               (selected? (= idx sel))
-               (face (if selected? 'completion-selected 'dialog-text))
-               (fg (face-fg-attr face))
-               (bg (face-bg-attr face))
-               (prefix (if selected? " ● " "   "))
-               (text (string-append prefix (car item))))
-          (clear-dialog-row! bx row box-w (if selected? bg dbg))
+    (if (zero? nitems)
+      (let ((row (+ by 2)))
+        (clear-dialog-row! bx row box-w dbg)
+        (tb-change-cell! bx row (char->integer #\│) bfg bbg)
+        (tb-change-cell! (+ bx box-w -1) row (char->integer #\│) bfg bbg)
+        (tb-print! (+ bx 2) row (face-fg-attr 'dim) dbg "No matches"))
+      (let loop ((idx scroll) (row (+ by 2)) (count 0))
+        (when (and (< idx nitems) (< count visible-rows))
+          (let* ((item (list-ref items idx))
+                 (selected? (= idx sel))
+                 (current? (equal? (cdr item) (list-dialog-current-value dlg)))
+                 (face (cond
+                         (selected? 'completion-selected)
+                         (current? 'dialog-title)
+                         (else 'dialog-text)))
+                 (fg (face-fg-attr face))
+                 (bg (face-bg-attr face))
+                 (prefix (cond
+                           ((and selected? current?) ">✓ ")
+                           (selected? ">  ")
+                           (current? " ✓ ")
+                           (else "   ")))
+                 (label (if current?
+                          (string-append (car item) "  [current]")
+                          (car item)))
+                 (text (string-append prefix label)))
+            (clear-dialog-row! bx row box-w (if selected? bg dbg))
+            (tb-change-cell! bx row (char->integer #\│) bfg bbg)
+            (tb-change-cell! (+ bx box-w -1) row (char->integer #\│) bfg bbg)
+            (tb-print! (+ bx 1) row fg bg
+                       (if (> (string-length text) (- box-w 3))
+                         (substring text 0 (- box-w 3))
+                         text))
+            (loop (+ idx 1) (+ row 1) (+ count 1))))))
+    ;; Blank item rows when the filtered list is shorter than the viewport.
+    (let* ((drawn-rows (if (zero? nitems) 1 (min nitems visible-rows))))
+      (let loop ((row (+ by 2 drawn-rows))
+                 (left (- visible-rows drawn-rows)))
+        (when (> left 0)
+          (clear-dialog-row! bx row box-w dbg)
           (tb-change-cell! bx row (char->integer #\│) bfg bbg)
           (tb-change-cell! (+ bx box-w -1) row (char->integer #\│) bfg bbg)
-          (tb-print! (+ bx 1) row fg bg
-                     (if (> (string-length text) (- box-w 3))
-                       (substring text 0 (- box-w 3))
-                       text))
-          (loop (+ idx 1) (+ row 1) (+ count 1)))))
+          (loop (+ row 1) (- left 1)))))
     ;; Hint line
-    (let ((hrow (+ by 1 max-visible)))
+    (let ((hrow (+ by 2 visible-rows)))
       (clear-dialog-row! bx hrow box-w dbg)
       (tb-change-cell! bx hrow (char->integer #\│) bfg bbg)
       (tb-change-cell! (+ bx box-w -1) hrow (char->integer #\│) bfg bbg)
-      (tb-print! (+ bx 2) hrow (face-fg-attr 'dim) dbg "↑↓ select  Enter accept  Esc cancel")
+      (tb-print! (+ bx 2) hrow (face-fg-attr 'dim) dbg
+                 "type to search  ↑↓ select  Enter accept  Esc clear/cancel")
       ;; Bottom border
       (draw-hline! bx (+ hrow 1) box-w bfg bbg #\─))))
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index 7495b4b..558af40 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -940,8 +940,7 @@
          (items (map (lambda (p)
                        (let ((has-key? (config-get-provider-key p))
                              (display-name (provider-display-name p)))
-                         (cons (format "~a~a  ~a"
-                                 (if (equal? p cur-p) "● " "  ")
+                         (cons (format "~a  ~a"
                                  display-name
                                  (if has-key? "[key]" "[no key]"))
                                p)))
@@ -951,7 +950,7 @@
                     (cond ((null? ps) 0)
                           ((equal? (car ps) cur-p) i)
                           (else (loop (cdr ps) (+ i 1))))))
-         (dlg (make-list-dialog "Select Provider" items cur-idx 0 #f)))
+         (dlg (make-list-dialog "Select Provider" items cur-idx 0 #f "" items cur-p)))
     ;; Draw everything first so dialog overlays cleanly
     (draw-all! state)
     (let ((result (show-list-dialog! dlg (app-state-width state) (app-state-height state))))
@@ -1050,9 +1049,7 @@
          (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) "● " "  ")
-                               (cdr m) (car m))
+                       (cons (format "~a  (~a)" (cdr m) (car m))
                              (car m)))
                      models)))
     (if (null? items)
@@ -1064,7 +1061,7 @@
                               (else (loop (cdr ms) (+ i 1))))))
              (dlg (make-list-dialog
                     (format "Select Model — ~a" (provider-display-name cur-p))
-                    items cur-idx 0 #f)))
+                    items cur-idx 0 #f "" items cur-m)))
         (draw-all! state)
         (let ((result (show-list-dialog! dlg (app-state-width state) (app-state-height state))))
           (when result
diff --git a/test/run.ss b/test/run.ss
index acc2250..e873751 100644
--- a/test/run.ss
+++ b/test/run.ss
@@ -1829,6 +1829,19 @@
 (check-pred! "all-providers still contains groq"  (all-providers) (lambda (l) (member "groq" l)))
 (check-pred! "all-providers still contains xai"   (all-providers) (lambda (l) (member "xai" l)))
 
+(let ([models '(("anthropic/claude-sonnet-4" . "Claude Sonnet 4")
+                ("openai/gpt-4o" . "GPT-4o")
+                ("google/gemini-2.5-flash" . "Gemini 2.5 Flash"))])
+  (check! "filter-models empty query returns all"
+          (filter-models models "")
+          models)
+  (check! "filter-models matches id/display case-insensitively"
+          (map car (filter-models models "CLAUDE sonnet"))
+          '("anthropic/claude-sonnet-4"))
+  (check! "filter-models matches provider id"
+          (map car (filter-models models "google flash"))
+          '("google/gemini-2.5-flash")))
+
 (check! "display name grok = Grok CLI"       (provider-display-name "grok") "Grok CLI")
 (check! "display name xai unchanged"         (provider-display-name "xai")  "xAI (Grok)")
 (check! "default model grok = grok-build"    (provider-default-model "grok") "grok-build")