updates
ober
a2d10d3283809b7125f37ed28f6906c3cb7c0885
--- 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 --- 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 --- 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) --- 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) --- 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 #\─)))) --- 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 --- 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")