updates for themes

ober

9843e071cee2fe3d50b4b6f1be7c5a9bac44c5fc

diff --git a/docs/cli.md b/docs/cli.md
index 6ac6ad8..4ca9f08 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -107,6 +107,13 @@ Available in the REPL and TUI.
 | `/checkpoints` | List recent shadow-git checkpoints. |
 | `/quit`, `/exit` | Exit. |
 
+### TUI-only
+
+| Command | Effect |
+|---|---|
+| `/themes` | List TUI color themes. |
+| `/theme [name]` | Cycle the TUI theme, or switch to a named theme. |
+
 ### Tools, skills & extensions
 
 | Command | Effect |
diff --git a/docs/tui.md b/docs/tui.md
index d98813e..b269152 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -65,10 +65,36 @@ rendering, live diffs, themes, and a stats sidebar.
 
 ## Themes
 
-Three built-in themes — **dark** (default), **light**, and **gruvbox** — cycled
-with `Ctrl-T`. A theme is a map from face names (`user-label`, `tool-name`, …) to
-foreground/background colors and bold/italic/underline flags. Drop a JSON theme
-into `~/.jcode/themes/` to add your own.
+The default theme is **opencode-dark**, a port of OpenCode's warm neutral
+palette. Cycle themes with `Ctrl-T`, list them with `/themes`, or switch
+directly with `/theme <name>`.
+
+Built-ins include:
+
+```
+opencode-dark  opencode-light  tokyonight-dark
+github-dark    github-light    nord-dark       nord-light
+catppuccin-dark catppuccin-light
+dark           light           gruvbox
+```
+
+Set a startup theme in config:
+
+```json
+{
+  "tui": {
+    "theme": "github-dark"
+  }
+}
+```
+
+A theme is a map from face names (`user-label`, `tool-name`, …) to
+foreground/background colors and bold/italic/underline flags. `jcode` also
+accepts OpenCode-style JSON themes in `~/.jcode/themes/` and
+`./.jcode/themes/`; each file is registered from its filename as
+`<name>-dark` and `<name>-light`. The mapper fills panel/dialog/status
+backgrounds and repairs low-contrast foreground/background pairs where it can,
+so custom themes are less likely to produce unreadable text.
 
 ## Rendering features
 
diff --git a/src/jcode/ui/tui-input.ss b/src/jcode/ui/tui-input.ss
index 692280e..231652e 100644
--- a/src/jcode/ui/tui-input.ss
+++ b/src/jcode/ui/tui-input.ss
@@ -50,6 +50,7 @@
     ("/search"        . "Search session history")
     ("/compact"       . "Compact conversation")
     ("/quit"          . "Exit jcode")
+    ("/themes"        . "List color themes")
     ("/theme"         . "Switch color theme")
     ("/sidebar"       . "Toggle sidebar")
     ("/ask-claude"    . "Second opinion from claude CLI (sandboxed)")
diff --git a/src/jcode/ui/tui-theme.ss b/src/jcode/ui/tui-theme.ss
index b66bbc0..b5ab838 100644
--- a/src/jcode/ui/tui-theme.ss
+++ b/src/jcode/ui/tui-theme.ss
@@ -3,7 +3,7 @@
 
 (export
   make-face face? face-fg face-bg face-bold? face-italic? face-underline?
-  current-theme set-theme!
+  current-theme current-theme-name set-theme! set-theme-by-name!
   face-ref face-fg-attr face-bg-attr
   theme-dark theme-light theme-gruvbox
   ;; Theme registry
@@ -41,10 +41,21 @@
 ;; ---- Theme: table of face names → face structs ----
 
 (def *current-theme* (make-parameter #f))
+(def *current-theme-name* (make-parameter #f))
 (def (current-theme) (*current-theme*))
+(def (current-theme-name) (*current-theme-name*))
 
 (def (set-theme! theme)
-  (*current-theme* theme))
+  (*current-theme* theme)
+  (*current-theme-name* #f))
+
+(def (set-theme-by-name! name)
+  (let ((theme (hash-get *theme-registry* name)))
+    (and theme
+         (begin
+           (*current-theme* theme)
+           (*current-theme-name* name)
+           #t))))
 
 (def (face-ref name)
   "Look up a face by symbol name in current theme. Returns default face if missing."
@@ -359,30 +370,27 @@
   (hash-put! *theme-registry* name theme-table))
 
 (def (get-registered-themes)
-  (hash-keys *theme-registry*))
-
-(register-theme! "dark" theme-dark)
-(register-theme! "light" theme-light)
-(register-theme! "gruvbox" theme-gruvbox)
+  (sort string<? (hash-keys *theme-registry*)))
 
 (def (cycle-theme-by-name!)
-  (let* ((names (sort (get-registered-themes) string<?))
+  (let* ((names (get-registered-themes))
          (current (current-theme))
-         (cur-name (let loop ((ns names))
-                     (cond ((null? ns) (car names))
-                           ((eq? (hash-get *theme-registry* (car ns)) current) (car ns))
-                           (else (loop (cdr ns))))))
+         (cur-name (or (current-theme-name)
+                       (let loop ((ns names))
+                         (cond ((null? ns) (car names))
+                               ((eq? (hash-get *theme-registry* (car ns)) current) (car ns))
+                               (else (loop (cdr ns)))))))
          (idx (let loop ((ns names) (i 0))
                 (cond ((null? ns) 0)
                       ((equal? (car ns) cur-name) i)
                       (else (loop (cdr ns) (+ i 1))))))
          (next-idx (modulo (+ idx 1) (length names)))
          (next-name (list-ref names next-idx)))
-    (set-theme! (hash-get *theme-registry* next-name))))
+    (set-theme-by-name! next-name)))
 
 ;; ---- opencode JSON theme loader ----
 
-(def (parse-hex-color s)
+(def (parse-hex-color/maybe s)
   (cond
     ((and (string? s) (>= (string-length s) 7) (char=? (string-ref s 0) #\#))
      (rgb (string->number (substring s 1 3) 16)
@@ -393,96 +401,434 @@
            (g (string->number (substring s 2 3) 16))
            (b (string->number (substring s 3 4) 16)))
        (rgb (+ (* r 17) r) (+ (* g 17) g) (+ (* b 17) b))))
-    (else TB_DEFAULT)))
-
-(def *opencode-slot-map*
-  '((primary      . (user-label input-prompt))
-    (secondary    . (status-mode-plan))
-    (accent       . (spinner tool-name heading status-mode-build))
-    (error        . (error))
-    (warning      . (toast-warning-border))
-    (success      . (status-mode-build toast-success-border))
-    (info         . (tool-name toast-info-border))
-    (text         . (default assistant-text user-text))
-    (textMuted    . (dim tool-result status-dim))
-    (background   . ())
-    (backgroundPanel . (sidebar-bg completion-item toast-bg))
-    (backgroundElement . (completion-selected code-inline))
-    (border       . (tool-border divider sidebar-divider msg-border-user msg-border-assistant msg-border-expert msg-border-tool msg-border-error))
-    (borderActive . (dialog-border sidebar-selected))
-    (borderSubtle . (horizontal-rule blockquote-border thinking-border))
-    (diffAdded    . (diff-added))
-    (diffRemoved  . (diff-removed))
-    (diffContext  . (diff-context))
-    (diffHunkHeader . (diff-hunk))
-    (markdownHeading . (heading))
-    (markdownLink . (link))
-    (markdownCode . (code-block code-inline))
-    (markdownBlockQuote . (blockquote))
-    (markdownEmph . (italic))
-    (markdownStrong . (bold))
-    (markdownHorizontalRule . (horizontal-rule))
-    (markdownListItem . (list-bullet))
-    (syntaxComment . (syntax-comment))
-    (syntaxKeyword . (syntax-keyword))
-    (syntaxString  . (syntax-string))
-    (syntaxNumber  . (syntax-number))
-    (syntaxFunction . (syntax-function))
-    (syntaxVariable . (syntax-variable))
-    (syntaxType    . (syntax-type))
-    (syntaxOperator . (syntax-operator))
-    (syntaxPunctuation . (syntax-punctuation))))
+    (else #f)))
+
+(def (parse-hex-color s)
+  (or (parse-hex-color/maybe s) TB_DEFAULT))
+
+(def (ansi-color->tb n)
+  (case n
+    ((0) TB_BLACK)
+    ((1) TB_RED)
+    ((2) TB_GREEN)
+    ((3) TB_YELLOW)
+    ((4) TB_BLUE)
+    ((5) TB_MAGENTA)
+    ((6) TB_CYAN)
+    ((7) TB_WHITE)
+    ((8) (bitwise-ior TB_BLACK TB_BOLD))
+    ((9) (bitwise-ior TB_RED TB_BOLD))
+    ((10) (bitwise-ior TB_GREEN TB_BOLD))
+    ((11) (bitwise-ior TB_YELLOW TB_BOLD))
+    ((12) (bitwise-ior TB_BLUE TB_BOLD))
+    ((13) (bitwise-ior TB_MAGENTA TB_BOLD))
+    ((14) (bitwise-ior TB_CYAN TB_BOLD))
+    ((15) (bitwise-ior TB_WHITE TB_BOLD))
+    (else #f)))
+
+(def *opencode-color-keys*
+  '((primary . "primary")
+    (secondary . "secondary")
+    (accent . "accent")
+    (error . "error")
+    (warning . "warning")
+    (success . "success")
+    (info . "info")
+    (text . "text")
+    (textMuted . "textMuted")
+    (background . "background")
+    (backgroundPanel . "backgroundPanel")
+    (backgroundElement . "backgroundElement")
+    (border . "border")
+    (borderActive . "borderActive")
+    (borderSubtle . "borderSubtle")
+    (diffAdded . "diffAdded")
+    (diffRemoved . "diffRemoved")
+    (diffContext . "diffContext")
+    (diffHunkHeader . "diffHunkHeader")
+    (diffAddedBg . "diffAddedBg")
+    (diffRemovedBg . "diffRemovedBg")
+    (diffContextBg . "diffContextBg")
+    (markdownText . "markdownText")
+    (markdownHeading . "markdownHeading")
+    (markdownLink . "markdownLink")
+    (markdownCode . "markdownCode")
+    (markdownBlockQuote . "markdownBlockQuote")
+    (markdownEmph . "markdownEmph")
+    (markdownStrong . "markdownStrong")
+    (markdownHorizontalRule . "markdownHorizontalRule")
+    (markdownListItem . "markdownListItem")
+    (markdownCodeBlock . "markdownCodeBlock")
+    (syntaxComment . "syntaxComment")
+    (syntaxKeyword . "syntaxKeyword")
+    (syntaxString . "syntaxString")
+    (syntaxNumber . "syntaxNumber")
+    (syntaxFunction . "syntaxFunction")
+    (syntaxVariable . "syntaxVariable")
+    (syntaxType . "syntaxType")
+    (syntaxOperator . "syntaxOperator")
+    (syntaxPunctuation . "syntaxPunctuation")))
+
+(def (make-color-table pairs)
+  (let ((ht (make-hash-table)))
+    (for-each (lambda (p) (hash-put! ht (car p) (cdr p))) pairs)
+    ht))
 
 (def (resolve-defs defs val)
-  (if (and (string? val) (string-prefix? "{" val))
-    (let ((obj (try (string->json-object val) (catch (e) #f))))
-      (and (hash-table? obj) (let ((ref (hash-get obj "$ref")))
-                               (and ref (hash-get defs ref)))))
-    val))
+  (cond
+    ((and (string? val) (hash-table? defs) (hash-key? defs val))
+     (hash-get defs val))
+    ((and (string? val) (string-prefix? "{" val))
+     (let ((obj (try (string->json-object val) (catch (e) #f))))
+       (and (hash-table? obj) (let ((ref (hash-get obj "$ref")))
+                                (and ref (hash-get defs ref))))))
+    (else val)))
 
-(def (json-color->face val defs variant)
+(def (json-color->color val defs variant)
   (cond
     ((hash-table? val)
      (let ((c (or (hash-get val variant) (hash-get val "dark") (hash-get val "light"))))
-       (if c
-         (json-color->face c defs variant)
-         (make-face TB_DEFAULT TB_DEFAULT #f #f #f))))
+       (and c (json-color->color c defs variant))))
+    ((number? val)
+     (ansi-color->tb val))
     ((string? val)
-     (let ((resolved (resolve-defs defs val)))
-       (if (string? resolved)
-         (let ((fg (parse-hex-color resolved)))
-           (make-face fg TB_DEFAULT #f #f #f))
-         (if (hash-table? resolved)
-           (json-color->face resolved defs variant)
-           (make-face TB_DEFAULT TB_DEFAULT #f #f #f)))))
-    (else (make-face TB_DEFAULT TB_DEFAULT #f #f #f))))
+     (cond
+       ((equal? val "none") #f)
+       (else
+        (let ((hex (parse-hex-color/maybe val)))
+          (if hex
+            hex
+            (let ((resolved (resolve-defs defs val)))
+              (if (equal? resolved val)
+                #f
+                (json-color->color resolved defs variant))))))))
+    (else #f)))
+
+(def (json-theme->colors theme-obj defs variant)
+  (let ((colors (make-hash-table)))
+    (for-each
+      (lambda (entry)
+        (let* ((sym (car entry))
+               (key (cdr entry))
+               (val (hash-get theme-obj key)))
+          (when val
+            (let ((c (json-color->color val defs variant)))
+              (when c (hash-put! colors sym c))))))
+      *opencode-color-keys*)
+    colors))
+
+(def (color-ref colors key fallback)
+  (let ((v (hash-get colors key)))
+    (if (number? v) v fallback)))
+
+(def (color-or-default c)
+  (if (number? c) c TB_DEFAULT))
+
+(def (color-channel c shift)
+  (bitwise-and (bitwise-arithmetic-shift-right c shift) #xff))
+
+(def (color-brightness c)
+  (/ (+ (* 299 (color-channel c 16))
+        (* 587 (color-channel c 8))
+        (* 114 (color-channel c 0)))
+     1000.0))
+
+(def (readable-color? fg bg)
+  (or (not (number? fg))
+      (not (number? bg))
+      (>= (abs (- (color-brightness fg) (color-brightness bg))) 50.0)))
+
+(def (auto-contrast-color bg)
+  (if (and (number? bg) (> (color-brightness bg) 140.0))
+    (rgb #x1a #x1a #x1a)
+    (rgb #xee #xee #xee)))
+
+(def (ensure-readable fg bg fallback-fg)
+  (cond
+    ((readable-color? fg bg) fg)
+    ((readable-color? fallback-fg bg) fallback-fg)
+    (else (auto-contrast-color bg))))
+
+(def (theme-face colors fg bg bold? italic? underline?)
+  (let ((text (color-ref colors 'text (rgb #xd4 #xd4 #xd4))))
+    (make-face (color-or-default (ensure-readable fg bg text))
+               (color-or-default bg)
+               bold? italic? underline?)))
+
+(def (make-opencode-style-theme colors)
+  (let* ((bg       (color-ref colors 'background (rgb #x1e #x1e #x1e)))
+         (panel    (color-ref colors 'backgroundPanel (rgb #x25 #x25 #x25)))
+         (element  (color-ref colors 'backgroundElement (rgb #x2d #x2d #x2d)))
+         (text     (color-ref colors 'text (rgb #xd4 #xd4 #xd4)))
+         (muted    (color-ref colors 'textMuted (rgb #x80 #x80 #x80)))
+         (primary  (color-ref colors 'primary (rgb #x56 #x9c #xd6)))
+         (secondary (color-ref colors 'secondary primary))
+         (accent   (color-ref colors 'accent (rgb #x4e #xc9 #xb0)))
+         (error    (color-ref colors 'error (rgb #xf4 #x47 #x47)))
+         (warning  (color-ref colors 'warning (rgb #xff #xcc #x00)))
+         (success  (color-ref colors 'success (rgb #x4e #xc9 #xb0)))
+         (info     (color-ref colors 'info primary))
+         (border   (color-ref colors 'border (rgb #x3a #x3a #x3a)))
+         (border-active (color-ref colors 'borderActive primary))
+         (border-subtle (color-ref colors 'borderSubtle border))
+         (diff-added (color-ref colors 'diffAdded success))
+         (diff-removed (color-ref colors 'diffRemoved error))
+         (diff-context (color-ref colors 'diffContext muted))
+         (diff-hunk (color-ref colors 'diffHunkHeader primary))
+         (diff-added-bg (color-ref colors 'diffAddedBg panel))
+         (diff-removed-bg (color-ref colors 'diffRemovedBg panel))
+         (diff-context-bg (color-ref colors 'diffContextBg bg))
+         (md-text (color-ref colors 'markdownText text))
+         (md-heading (color-ref colors 'markdownHeading secondary))
+         (md-link (color-ref colors 'markdownLink primary))
+         (md-code (color-ref colors 'markdownCode success))
+         (md-quote (color-ref colors 'markdownBlockQuote muted))
+         (md-emph (color-ref colors 'markdownEmph warning))
+         (md-strong (color-ref colors 'markdownStrong accent))
+         (md-rule (color-ref colors 'markdownHorizontalRule border-subtle))
+         (md-list (color-ref colors 'markdownListItem primary))
+         (md-code-block (color-ref colors 'markdownCodeBlock text))
+         (syn-comment (color-ref colors 'syntaxComment muted))
+         (syn-keyword (color-ref colors 'syntaxKeyword secondary))
+         (syn-string (color-ref colors 'syntaxString success))
+         (syn-number (color-ref colors 'syntaxNumber warning))
+         (syn-function (color-ref colors 'syntaxFunction primary))
+         (syn-variable (color-ref colors 'syntaxVariable accent))
+         (syn-type (color-ref colors 'syntaxType info))
+         (syn-operator (color-ref colors 'syntaxOperator text))
+         (syn-punctuation (color-ref colors 'syntaxPunctuation muted))
+         (f (lambda (fg bg bold? italic? underline?)
+              (theme-face colors fg bg bold? italic? underline?))))
+    (make-theme-table
+      `((default           . ,(f text bg #f #f #f))
+        (user-label        . ,(f primary bg #t #f #f))
+        (user-text         . ,(f text bg #f #f #f))
+        (assistant-text    . ,(f md-text bg #f #f #f))
+        (tool-name         . ,(f accent bg #t #f #f))
+        (tool-border       . ,(f border-subtle bg #f #f #f))
+        (tool-result       . ,(f muted bg #f #f #f))
+        (code-block        . ,(f md-code-block element #f #f #f))
+        (code-inline       . ,(f md-code element #f #f #f))
+        (heading           . ,(f md-heading bg #t #f #f))
+        (bold              . ,(f md-strong bg #t #f #f))
+        (italic            . ,(f md-emph bg #f #t #f))
+        (link              . ,(f md-link bg #f #f #t))
+        (list-bullet       . ,(f md-list bg #f #f #f))
+        (blockquote        . ,(f md-quote bg #f #t #f))
+        (blockquote-border . ,(f border-subtle bg #f #f #f))
+        (horizontal-rule   . ,(f md-rule bg #f #f #f))
+        (diff-added        . ,(f diff-added diff-added-bg #f #f #f))
+        (diff-removed      . ,(f diff-removed diff-removed-bg #f #f #f))
+        (diff-context      . ,(f diff-context diff-context-bg #f #f #f))
+        (diff-header       . ,(f text element #t #f #f))
+        (diff-hunk         . ,(f diff-hunk diff-context-bg #f #f #f))
+        (status-bar        . ,(f text panel #f #f #f))
+        (status-provider   . ,(f primary panel #t #f #f))
+        (status-model      . ,(f text panel #f #f #f))
+        (status-tokens     . ,(f muted panel #f #f #f))
+        (status-cost       . ,(f warning panel #f #f #f))
+        (status-dim        . ,(f muted panel #f #f #f))
+        (status-mode-build . ,(f bg success #t #f #f))
+        (status-mode-plan  . ,(f bg warning #t #f #f))
+        (sidebar-title     . ,(f text panel #t #f #f))
+        (sidebar-item      . ,(f muted panel #f #f #f))
+        (sidebar-selected  . ,(f text element #f #f #f))
+        (sidebar-bg        . ,(f muted panel #f #f #f))
+        (sidebar-divider   . ,(f border-subtle bg #f #f #f))
+        (input-prompt      . ,(f primary bg #t #f #f))
+        (input-text        . ,(f text bg #f #f #f))
+        (completion-item   . ,(f text panel #f #f #f))
+        (completion-selected . ,(f text element #f #f #f))
+        (completion-match  . ,(f primary panel #t #f #f))
+        (dialog-border     . ,(f border-active panel #f #f #f))
+        (dialog-title      . ,(f text panel #t #f #f))
+        (dialog-text       . ,(f text panel #f #f #f))
+        (dialog-action     . ,(f success panel #t #f #f))
+        (error             . ,(f error bg #t #f #f))
+        (dim               . ,(f muted bg #f #f #f))
+        (spinner           . ,(f accent bg #f #f #f))
+        (divider           . ,(f border-subtle bg #f #f #f))
+        (toast-error-border  . ,(f error bg #f #f #f))
+        (toast-warning-border . ,(f warning bg #f #f #f))
+        (toast-success-border . ,(f success bg #f #f #f))
+        (toast-info-border   . ,(f info bg #f #f #f))
+        (toast-bg            . ,(f text panel #f #f #f))
+        (toast-title         . ,(f text panel #t #f #f))
+        (toast-message       . ,(f text panel #f #f #f))
+        (thinking-text     . ,(f muted bg #f #t #f))
+        (thinking-border   . ,(f border-subtle bg #f #f #f))
+        (thinking-header   . ,(f muted bg #f #t #f))
+        (syntax-comment    . ,(f syn-comment bg #f #f #f))
+        (syntax-keyword    . ,(f syn-keyword bg #t #f #f))
+        (syntax-function   . ,(f syn-function bg #f #f #f))
+        (syntax-variable   . ,(f syn-variable bg #f #f #f))
+        (syntax-string     . ,(f syn-string bg #f #f #f))
+        (syntax-number     . ,(f syn-number bg #f #f #f))
+        (syntax-type       . ,(f syn-type bg #f #t #f))
+        (syntax-operator   . ,(f syn-operator bg #f #f #f))
+        (syntax-punctuation . ,(f syn-punctuation bg #f #f #f))
+        (selection-bg      . ,(f text element #f #f #f))
+        (leader-indicator  . ,(f warning bg #t #f #f))
+        (leader-dim        . ,(f muted bg #f #f #f))
+        (palette-match     . ,(f warning panel #t #f #f))
+        (palette-keybind   . ,(f muted panel #f #f #f))
+        (palette-category  . ,(f accent panel #t #f #f))
+        (home-logo-fg      . ,(f primary bg #t #f #f))
+        (home-logo-shadow  . ,(f border-subtle bg #f #f #f))
+        (home-tip-text     . ,(f muted bg #f #f #f))
+        (home-tip-icon     . ,(f warning bg #f #f #f))
+        (msg-border-user     . ,(f primary bg #f #f #f))
+        (msg-border-assistant . ,(f accent bg #f #f #f))
+        (msg-border-expert   . ,(f secondary bg #f #f #f))
+        (msg-border-tool     . ,(f border bg #f #f #f))
+        (msg-border-error    . ,(f error bg #f #f #f))))))
+
+(def (register-opencode-style-theme! name pairs)
+  (register-theme! name (make-opencode-style-theme (make-color-table pairs))))
+
+(def (register-core-themes!)
+  (register-theme! "dark" theme-dark)
+  (register-theme! "light" theme-light)
+  (register-theme! "gruvbox" theme-gruvbox)
+  (register-opencode-style-theme! "opencode-dark"
+    `((background . ,(rgb #x0a #x0a #x0a)) (backgroundPanel . ,(rgb #x14 #x14 #x14))
+      (backgroundElement . ,(rgb #x1e #x1e #x1e)) (text . ,(rgb #xee #xee #xee))
+      (textMuted . ,(rgb #x80 #x80 #x80)) (primary . ,(rgb #xfa #xb2 #x83))
+      (secondary . ,(rgb #x5c #x9c #xf5)) (accent . ,(rgb #x9d #x7c #xd8))
+      (error . ,(rgb #xe0 #x6c #x75)) (warning . ,(rgb #xf5 #xa7 #x42))
+      (success . ,(rgb #x7f #xd8 #x8f)) (info . ,(rgb #x56 #xb6 #xc2))
+      (border . ,(rgb #x48 #x48 #x48)) (borderActive . ,(rgb #x60 #x60 #x60))
+      (borderSubtle . ,(rgb #x3c #x3c #x3c))
+      (diffAddedBg . ,(rgb #x20 #x30 #x3b)) (diffRemovedBg . ,(rgb #x37 #x22 #x2c))
+      (syntaxComment . ,(rgb #x80 #x80 #x80)) (syntaxKeyword . ,(rgb #x9d #x7c #xd8))
+      (syntaxFunction . ,(rgb #xfa #xb2 #x83)) (syntaxVariable . ,(rgb #xe0 #x6c #x75))
+      (syntaxString . ,(rgb #x7f #xd8 #x8f)) (syntaxNumber . ,(rgb #xf5 #xa7 #x42))
+      (syntaxType . ,(rgb #xe5 #xc0 #x7b)) (syntaxOperator . ,(rgb #x56 #xb6 #xc2))))
+  (register-opencode-style-theme! "opencode-light"
+    `((background . ,(rgb #xff #xff #xff)) (backgroundPanel . ,(rgb #xfa #xfa #xfa))
+      (backgroundElement . ,(rgb #xf5 #xf5 #xf5)) (text . ,(rgb #x1a #x1a #x1a))
+      (textMuted . ,(rgb #x8a #x8a #x8a)) (primary . ,(rgb #x3b #x7d #xd8))
+      (secondary . ,(rgb #x7b #x5b #xb6)) (accent . ,(rgb #xd6 #x8c #x27))
+      (error . ,(rgb #xd1 #x38 #x3d)) (warning . ,(rgb #xd6 #x8c #x27))
+      (success . ,(rgb #x3d #x9a #x57)) (info . ,(rgb #x31 #x87 #x95))
+      (border . ,(rgb #xb8 #xb8 #xb8)) (borderActive . ,(rgb #xa0 #xa0 #xa0))
+      (borderSubtle . ,(rgb #xd4 #xd4 #xd4))
+      (diffAddedBg . ,(rgb #xd5 #xe5 #xd5)) (diffRemovedBg . ,(rgb #xf7 #xd8 #xdb))
+      (syntaxComment . ,(rgb #x8a #x8a #x8a)) (syntaxKeyword . ,(rgb #x7b #x5b #xb6))
+      (syntaxFunction . ,(rgb #x3b #x7d #xd8)) (syntaxVariable . ,(rgb #xd1 #x38 #x3d))
+      (syntaxString . ,(rgb #x3d #x9a #x57)) (syntaxNumber . ,(rgb #xd6 #x8c #x27))
+      (syntaxType . ,(rgb #xb0 #x85 #x1f)) (syntaxOperator . ,(rgb #x31 #x87 #x95))))
+  (register-opencode-style-theme! "tokyonight-dark"
+    `((background . ,(rgb #x1a #x1b #x26)) (backgroundPanel . ,(rgb #x1e #x20 #x30))
+      (backgroundElement . ,(rgb #x22 #x24 #x36)) (text . ,(rgb #xc8 #xd3 #xf5))
+      (textMuted . ,(rgb #x82 #x8b #xb8)) (primary . ,(rgb #x82 #xaa #xff))
+      (secondary . ,(rgb #xc0 #x99 #xff)) (accent . ,(rgb #xff #x96 #x6c))
+      (error . ,(rgb #xff #x75 #x7f)) (warning . ,(rgb #xff #xc7 #x77))
+      (success . ,(rgb #xc3 #xe8 #x8d)) (info . ,(rgb #x86 #xe1 #xfc))
+      (border . ,(rgb #x73 #x7a #xa2)) (borderActive . ,(rgb #x90 #x99 #xb2))
+      (borderSubtle . ,(rgb #x54 #x5c #x7e))
+      (diffAddedBg . ,(rgb #x20 #x30 #x3b)) (diffRemovedBg . ,(rgb #x37 #x22 #x2c))
+      (syntaxComment . ,(rgb #x82 #x8b #xb8)) (syntaxKeyword . ,(rgb #xc0 #x99 #xff))
+      (syntaxFunction . ,(rgb #x82 #xaa #xff)) (syntaxVariable . ,(rgb #xff #x75 #x7f))
+      (syntaxString . ,(rgb #xc3 #xe8 #x8d)) (syntaxNumber . ,(rgb #xff #x96 #x6c))
+      (syntaxType . ,(rgb #xff #xc7 #x77)) (syntaxOperator . ,(rgb #x86 #xe1 #xfc))))
+  (register-opencode-style-theme! "github-dark"
+    `((background . ,(rgb #x0d #x11 #x17)) (backgroundPanel . ,(rgb #x01 #x04 #x09))
+      (backgroundElement . ,(rgb #x16 #x1b #x22)) (text . ,(rgb #xc9 #xd1 #xd9))
+      (textMuted . ,(rgb #x8b #x94 #x9e)) (primary . ,(rgb #x58 #xa6 #xff))
+      (secondary . ,(rgb #xbc #x8c #xff)) (accent . ,(rgb #x39 #xc5 #xcf))
+      (error . ,(rgb #xf8 #x51 #x49)) (warning . ,(rgb #xe3 #xb3 #x41))
+      (success . ,(rgb #x3f #xb9 #x50)) (info . ,(rgb #xd2 #x99 #x22))
+      (border . ,(rgb #x30 #x36 #x3d)) (borderActive . ,(rgb #x58 #xa6 #xff))
+      (borderSubtle . ,(rgb #x21 #x26 #x2d))
+      (diffAddedBg . ,(rgb #x03 #x3a #x16)) (diffRemovedBg . ,(rgb #x67 #x06 #x0c))
+      (syntaxComment . ,(rgb #x8b #x94 #x9e)) (syntaxKeyword . ,(rgb #xff #x7b #x72))
+      (syntaxFunction . ,(rgb #xbc #x8c #xff)) (syntaxVariable . ,(rgb #xd2 #x99 #x22))
+      (syntaxString . ,(rgb #x39 #xc5 #xcf)) (syntaxNumber . ,(rgb #x58 #xa6 #xff))
+      (syntaxType . ,(rgb #xd2 #x99 #x22)) (syntaxOperator . ,(rgb #xff #x7b #x72))))
+  (register-opencode-style-theme! "github-light"
+    `((background . ,(rgb #xff #xff #xff)) (backgroundPanel . ,(rgb #xf6 #xf8 #xfa))
+      (backgroundElement . ,(rgb #xf0 #xf3 #xf6)) (text . ,(rgb #x24 #x29 #x2f))
+      (textMuted . ,(rgb #x57 #x60 #x6a)) (primary . ,(rgb #x09 #x69 #xda))
+      (secondary . ,(rgb #x82 #x50 #xdf)) (accent . ,(rgb #x1b #x7c #x83))
+      (error . ,(rgb #xcf #x22 #x2e)) (warning . ,(rgb #x9a #x67 #x00))
+      (success . ,(rgb #x1a #x7f #x37)) (info . ,(rgb #xbc #x4c #x00))
+      (border . ,(rgb #xd0 #xd7 #xde)) (borderActive . ,(rgb #x09 #x69 #xda))
+      (borderSubtle . ,(rgb #xd8 #xde #xe4))
+      (diffAddedBg . ,(rgb #xda #xfb #xe1)) (diffRemovedBg . ,(rgb #xff #xeb #xe9))
+      (syntaxComment . ,(rgb #x57 #x60 #x6a)) (syntaxKeyword . ,(rgb #xcf #x22 #x2e))
+      (syntaxFunction . ,(rgb #x82 #x50 #xdf)) (syntaxVariable . ,(rgb #xbc #x4c #x00))
+      (syntaxString . ,(rgb #x09 #x69 #xda)) (syntaxNumber . ,(rgb #x1b #x7c #x83))
+      (syntaxType . ,(rgb #xbc #x4c #x00)) (syntaxOperator . ,(rgb #xcf #x22 #x2e))))
+  (register-opencode-style-theme! "nord-dark"
+    `((background . ,(rgb #x2e #x34 #x40)) (backgroundPanel . ,(rgb #x3b #x42 #x52))
+      (backgroundElement . ,(rgb #x43 #x4c #x5e)) (text . ,(rgb #xec #xef #xf4))
+      (textMuted . ,(rgb #x8b #x95 #xa7)) (primary . ,(rgb #x88 #xc0 #xd0))
+      (secondary . ,(rgb #x81 #xa1 #xc1)) (accent . ,(rgb #x8f #xbc #xbb))
+      (error . ,(rgb #xbf #x61 #x6a)) (warning . ,(rgb #xd0 #x87 #x70))
+      (success . ,(rgb #xa3 #xbe #x8c)) (info . ,(rgb #x88 #xc0 #xd0))
+      (border . ,(rgb #x43 #x4c #x5e)) (borderActive . ,(rgb #x4c #x56 #x6a))
+      (borderSubtle . ,(rgb #x43 #x4c #x5e))
+      (syntaxComment . ,(rgb #x8b #x95 #xa7)) (syntaxKeyword . ,(rgb #x81 #xa1 #xc1))
+      (syntaxFunction . ,(rgb #x88 #xc0 #xd0)) (syntaxVariable . ,(rgb #x8f #xbc #xbb))
+      (syntaxString . ,(rgb #xa3 #xbe #x8c)) (syntaxNumber . ,(rgb #xb4 #x8e #xad))
+      (syntaxType . ,(rgb #x8f #xbc #xbb)) (syntaxOperator . ,(rgb #x81 #xa1 #xc1))))
+  (register-opencode-style-theme! "nord-light"
+    `((background . ,(rgb #xec #xef #xf4)) (backgroundPanel . ,(rgb #xe5 #xe9 #xf0))
+      (backgroundElement . ,(rgb #xd8 #xde #xe9)) (text . ,(rgb #x2e #x34 #x40))
+      (textMuted . ,(rgb #x3b #x42 #x52)) (primary . ,(rgb #x5e #x81 #xac))
+      (secondary . ,(rgb #x81 #xa1 #xc1)) (accent . ,(rgb #x8f #xbc #xbb))
+      (error . ,(rgb #xbf #x61 #x6a)) (warning . ,(rgb #xd0 #x87 #x70))
+      (success . ,(rgb #xa3 #xbe #x8c)) (info . ,(rgb #x5e #x81 #xac))
+      (border . ,(rgb #x4c #x56 #x6a)) (borderActive . ,(rgb #x43 #x4c #x5e))
+      (borderSubtle . ,(rgb #x4c #x56 #x6a))
+      (syntaxComment . ,(rgb #x4c #x56 #x6a)) (syntaxKeyword . ,(rgb #x81 #xa1 #xc1))
+      (syntaxFunction . ,(rgb #x88 #xc0 #xd0)) (syntaxVariable . ,(rgb #x8f #xbc #xbb))
+      (syntaxString . ,(rgb #xa3 #xbe #x8c)) (syntaxNumber . ,(rgb #xb4 #x8e #xad))
+      (syntaxType . ,(rgb #x8f #xbc #xbb)) (syntaxOperator . ,(rgb #x81 #xa1 #xc1))))
+  (register-opencode-style-theme! "catppuccin-dark"
+    `((background . ,(rgb #x1e #x1e #x2e)) (backgroundPanel . ,(rgb #x18 #x18 #x25))
+      (backgroundElement . ,(rgb #x11 #x11 #x1b)) (text . ,(rgb #xcd #xd6 #xf4))
+      (textMuted . ,(rgb #x93 #x99 #xb2)) (primary . ,(rgb #x89 #xb4 #xfa))
+      (secondary . ,(rgb #xcb #xa6 #xf7)) (accent . ,(rgb #xf5 #xc2 #xe7))
+      (error . ,(rgb #xf3 #x8b #xa8)) (warning . ,(rgb #xf9 #xe2 #xaf))
+      (success . ,(rgb #xa6 #xe3 #xa1)) (info . ,(rgb #x94 #xe2 #xd5))
+      (border . ,(rgb #x31 #x32 #x44)) (borderActive . ,(rgb #x45 #x47 #x5a))
+      (borderSubtle . ,(rgb #x58 #x5b #x70))
+      (diffAddedBg . ,(rgb #x24 #x31 #x2b)) (diffRemovedBg . ,(rgb #x3c #x2a #x32))
+      (syntaxComment . ,(rgb #x93 #x99 #xb2)) (syntaxKeyword . ,(rgb #xcb #xa6 #xf7))
+      (syntaxFunction . ,(rgb #x89 #xb4 #xfa)) (syntaxVariable . ,(rgb #xf3 #x8b #xa8))
+      (syntaxString . ,(rgb #xa6 #xe3 #xa1)) (syntaxNumber . ,(rgb #xfa #xb3 #x87))
+      (syntaxType . ,(rgb #xf9 #xe2 #xaf)) (syntaxOperator . ,(rgb #x89 #xdc #xeb))))
+  (register-opencode-style-theme! "catppuccin-light"
+    `((background . ,(rgb #xef #xf1 #xf5)) (backgroundPanel . ,(rgb #xe6 #xe9 #xef))
+      (backgroundElement . ,(rgb #xdc #xe0 #xe8)) (text . ,(rgb #x4c #x4f #x69))
+      (textMuted . ,(rgb #x7c #x7f #x93)) (primary . ,(rgb #x1e #x66 #xf5))
+      (secondary . ,(rgb #x88 #x39 #xef)) (accent . ,(rgb #xea #x76 #xcb))
+      (error . ,(rgb #xd2 #x0f #x39)) (warning . ,(rgb #xdf #x8e #x1d))
+      (success . ,(rgb #x40 #xa0 #x2b)) (info . ,(rgb #x17 #x92 #x99))
+      (border . ,(rgb #xcc #xd0 #xda)) (borderActive . ,(rgb #xbc #xc0 #xcc))
+      (borderSubtle . ,(rgb #xac #xb0 #xbe))
+      (diffAddedBg . ,(rgb #xd6 #xf0 #xd9)) (diffRemovedBg . ,(rgb #xf6 #xdf #xe2))
+      (syntaxComment . ,(rgb #x7c #x7f #x93)) (syntaxKeyword . ,(rgb #x88 #x39 #xef))
+      (syntaxFunction . ,(rgb #x1e #x66 #xf5)) (syntaxVariable . ,(rgb #xd2 #x0f #x39))
+      (syntaxString . ,(rgb #x40 #xa0 #x2b)) (syntaxNumber . ,(rgb #xfe #x64 #x0b))
+      (syntaxType . ,(rgb #xdf #x8e #x1d)) (syntaxOperator . ,(rgb #x04 #xa5 #xe5)))))
+
+(register-core-themes!)
 
 (def (load-theme-json! path)
   (try
     (let* ((obj (call-with-input-file path read-json))
            (defs (or (hash-get obj "defs") (make-hash-table)))
            (theme-obj (or (hash-get obj "theme") obj))
-           (name (or (hash-get obj "name") "custom")))
+           (name (or (hash-get obj "name")
+                     (path-strip-extension (path-strip-directory path)))))
       (for-each
         (lambda (variant)
-          (let ((theme-ht (make-hash-table))
-                (theme-name (string-append name "-" variant)))
-            (hash-for-each
-              (lambda (slot-key val)
-                (let ((mapped (assoc slot-key *opencode-slot-map*)))
-                  (when mapped
-                    (let ((face (json-color->face val defs variant)))
-                      (for-each
-                        (lambda (jcode-slot)
-                          (hash-put! theme-ht jcode-slot face))
-                        (cdr mapped))))))
-              theme-obj)
-            (unless (hash-get theme-ht 'default)
-              (hash-put! theme-ht 'default
-                (make-face (rgb #xd4 #xd4 #xd4)
-                  (if (equal? variant "light") (rgb #xff #xff #xff) (rgb #x1e #x1e #x1e))
-                  #f #f #f)))
-            (register-theme! theme-name theme-ht)))
+          (let* ((colors (json-theme->colors theme-obj defs variant))
+                 (theme-name (string-append name "-" variant)))
+            (register-theme! theme-name (make-opencode-style-theme colors))))
         '("dark" "light")))
     (catch (e) #f)))
 
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index bf3e88c..f8b2cae 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -229,9 +229,11 @@
       (tui-log "tui-main: setting input-mode=~a output-mode=~a" in-mode TB_OUTPUT_TRUECOLOR)
       (tb-set-input-mode! in-mode)
       (tb-set-output-mode! TB_OUTPUT_TRUECOLOR))
-    (set-theme! theme-dark)
     (load-themes-from-dir! (path-join (jcode-home) "themes"))
     (load-themes-from-dir! (path-join (current-directory) ".jcode" "themes"))
+    (let ((theme-name (or (config-ref "tui" "theme") "opencode-dark")))
+      (unless (set-theme-by-name! theme-name)
+        (set-theme-by-name! "opencode-dark")))
     (let* ((w (tb-width))
            (h (tb-height))
            (state (make-fresh-state w h))
@@ -541,7 +543,8 @@
                "  /jcode              Switch back to the main jcode tab"
                "  /close-tab          Close the current external tab"
                "  /expert <q> Force this prompt to the configured expert model"
-               "  /theme      Cycle theme (Ctrl-T)"
+               "  /themes     List available themes"
+               "  /theme [name] Cycle theme, or switch to a named theme"
                "  /sidebar    Toggle sidebar (Ctrl-B)"
                "  /quit       Exit"
                ""
@@ -587,9 +590,29 @@
          (switch-provider! state p)))
       ((equal? cmd "provider")
        (handle-provider-popup! state))
+      ((equal? cmd "themes")
+       (add-message! state
+         (msg-block-system
+           (string-append
+             "Current theme: " (or (current-theme-name) "(custom)") "\n"
+             "Available themes: " (string-join (get-registered-themes) ", ")))))
       ((equal? cmd "theme")
        (cycle-theme!)
-       (add-message! state (msg-block-system "Theme cycled.")))
+       (add-message! state
+         (msg-block-system
+           (string-append "Theme: " (or (current-theme-name) "(custom)")))))
+      ((string-prefix? "theme " cmd)
+       (let ((name (string-trim (substring cmd 6 (string-length cmd)))))
+         (cond
+           ((set-theme-by-name! name)
+            (add-message! state
+              (msg-block-system (string-append "Theme: " name))))
+           (else
+            (add-message! state
+              (msg-block-error
+                (string-append
+                  "Unknown theme: " name "\n"
+                  "Available themes: " (string-join (get-registered-themes) ", "))))))))
       ((equal? cmd "mcp")
        (let* ((mcp-tools (filter (lambda (n) (string-prefix? "mcp_" n)) (list-tools)))
               (hide? (not *mcp-disabled*)))