tui: salvage glm-5.1 improvements; drop broken/unused code
ober
6f50852367557fa38569e828a3ec86241a40ed4d
--- a/build-binary.ss +++ b/build-binary.ss @@ -130,6 +130,8 @@ "lib/jcode/ui/tui-input" "lib/jcode/ui/tui-sidebar" "lib/jcode/ui/tui-dialog" + "lib/jcode/ui/tui-toast" + "lib/jcode/ui/tui-syntax" "lib/jcode/ui/tui" "lib/jcode/ui/cli")) --- a/src/jcode/ui/tui-markdown.ss +++ b/src/jcode/ui/tui-markdown.ss @@ -9,7 +9,8 @@ :jerboa/core :jerboa/runtime :jcode/ui/tui-ffi - :jcode/ui/tui-theme) + :jcode/ui/tui-theme + :jcode/ui/tui-syntax) ;; ---- Markdown state (tracks code block across lines) ---- @@ -46,7 +47,7 @@ ;; Inside code block ((md-state-in-code-block? state) - (values (list (seg line 'code-block)) state)) + (values (highlight-line line (md-state-code-lang state)) state)) ;; Headings ((string-prefix? "### " line) --- a/src/jcode/ui/tui-message.ss +++ b/src/jcode/ui/tui-message.ss @@ -217,20 +217,31 @@ ;; ---- Render a message block to the terminal ---- +(def (role->border-face role) + (case role + ((user) 'msg-border-user) + ((assistant) 'msg-border-assistant) + ((tool) 'msg-border-tool) + ((error) 'msg-border-error) + (else 'msg-border-tool))) + (def (render-msg-block! msg x y width max-lines) "Render msg-block at position (x, y) within width, up to max-lines rows." - (let ((lines (msg-block-lines msg)) - (bg-face (face-ref 'default))) + (let* ((lines (msg-block-lines msg)) + (bg-face (face-ref 'default)) + (bf (role->border-face (msg-block-role msg))) + (bfg (face-fg-attr bf)) + (bbg (face-bg-attr bf))) (let loop ((lines lines) (row y) (count 0)) (when (and (pair? lines) (< count max-lines)) (let ((segs (car lines))) - ;; Clear the row first + (when (> x 0) + (tb-change-cell! (- x 1) row #x258e bfg bbg)) (let clear-loop ((col x)) (when (< col (+ x width)) (tb-change-cell! col row (char->integer #\space) (face-bg bg-face) (face-bg bg-face)) (clear-loop (+ col 1)))) - ;; Render segments (let seg-loop ((segs segs) (col x)) (when (and (pair? segs) (< col (+ x width))) (let* ((s (car segs)) new file mode 100644 --- /dev/null +++ b/src/jcode/ui/tui-syntax.ss @@ -0,0 +1,241 @@ +;;; jcode TUI syntax highlighting +;;; Lightweight keyword-based tokenizers for code blocks. + +(export + highlight-line + highlight-code-block) + +(import :std/misc/string + :jerboa/core + :jerboa/runtime) + +(def *scheme-keywords* + '("def" "def*" "define" "defstruct" "defclass" "defmethod" "defrule" + "defrecord" "lambda" "let" "let*" "letrec" "let-values" + "if" "cond" "case" "when" "unless" "and" "or" "not" + "begin" "do" "for" "for/collect" "for/fold" + "match" "try" "catch" "guard" "unwind-protect" "with-resource" + "import" "export" "parameterize" "set!" "quote" "quasiquote" + "syntax-rules")) + +(def *scheme-builtins* + '("car" "cdr" "cons" "list" "append" "map" "filter" "for-each" + "apply" "length" "reverse" "null?" "pair?" "list?" + "eq?" "eqv?" "equal?" "number?" "string?" "symbol?" + "boolean?" "procedure?" "hash-put!" "hash-ref" "hash-get" + "hash-key?" "hash-remove!" "hash-for-each" "hash->list" + "string-append" "string-length" "string-split" "string-join" + "string-trim" "string-prefix?" "string-suffix?" "string-contains" + "string-upcase" "string-downcase" "format" "printf" + "display" "displayln" "newline" "read" "write" + "open-file-input-port" "open-file-output-port" "close-port" + "file-exists?" "make-hash-table" "make-vector" "vector-ref" + "vector-set!" "vector-length" "char->integer" "integer->char" + "string->number" "number->string" "symbol->string" "string->symbol")) + +(def *bash-keywords* + '("if" "then" "else" "elif" "fi" "for" "while" "do" "done" + "case" "esac" "in" "function" "return" "exit" "export" "source" + "alias" "unset" "echo" "printf" "read" "cd" "pwd" "ls" "cat" + "mkdir" "rm" "cp" "mv" "grep" "sed" "awk" "find" "sort" + "uniq" "wc" "head" "tail" "cut" "tr" "chmod" "chown" "sudo" + "apt" "brew" "pip" "npm" "cargo" "git" "docker" "make" + "curl" "wget" "jq" "ssh" "scp" "rsync" "tar" "gzip" + "xargs" "tee" "test")) + +(def *python-keywords* + '("def" "class" "import" "from" "return" "if" "elif" "else" + "for" "while" "with" "as" "try" "except" "finally" "raise" + "yield" "lambda" "pass" "break" "continue" "and" "or" "not" + "in" "is" "True" "False" "None" "async" "await" "global" + "nonlocal" "assert" "del")) + +(def *js-keywords* + '("function" "const" "let" "var" "if" "else" "for" "while" "do" + "switch" "case" "break" "continue" "return" "try" "catch" + "finally" "throw" "new" "this" "class" "extends" "import" + "export" "from" "default" "async" "await" "yield" "of" "in" + "typeof" "instanceof" "void" "delete" "null" "undefined" + "true" "false")) + +(def *json-keywords* '("true" "false" "null")) + +(def *rust-keywords* + '("fn" "let" "mut" "const" "if" "else" "for" "while" "loop" + "match" "return" "struct" "enum" "impl" "trait" "pub" "use" + "mod" "where" "as" "in" "ref" "Self" "self" "super" "crate" + "async" "await" "unsafe" "extern" "type" "static" "dyn" "move" + "true" "false")) + +(def *go-keywords* + '("func" "var" "const" "if" "else" "for" "range" "switch" "case" + "default" "return" "package" "import" "type" "struct" "interface" + "map" "chan" "go" "defer" "select" "break" "continue" + "fallthrough" "goto" "nil" "true" "false")) + +(def *c-keywords* + '("int" "long" "short" "char" "void" "float" "double" "unsigned" + "signed" "const" "static" "extern" "volatile" "register" "auto" + "if" "else" "for" "while" "do" "switch" "case" "default" + "break" "continue" "return" "struct" "union" "enum" "typedef" + "sizeof" "NULL" "true" "false" "bool")) + +(def *language-configs* + `(("scheme" . ,*scheme-keywords*) + ("racket" . ,*scheme-keywords*) + ("r7rs" . ,*scheme-keywords*) + ("lisp" . ,*scheme-keywords*) + ("clojure" . ,*scheme-keywords*) + ("bash" . ,*bash-keywords*) + ("sh" . ,*bash-keywords*) + ("shell" . ,*bash-keywords*) + ("zsh" . ,*bash-keywords*) + ("python" . ,*python-keywords*) + ("py" . ,*python-keywords*) + ("javascript" . ,*js-keywords*) + ("js" . ,*js-keywords*) + ("typescript" . ,*js-keywords*) + ("ts" . ,*js-keywords*) + ("json" . ,*json-keywords*) + ("rust" . ,*rust-keywords*) + ("go" . ,*go-keywords*) + ("golang" . ,*go-keywords*) + ("c" . ,*c-keywords*) + ("c++" . ,*c-keywords*) + ("cpp" . ,*c-keywords*))) + +(def *scheme-builtins-set* #f) + +(def (scheme-builtins-set) + (or *scheme-builtins-set* + (begin + (set! *scheme-builtins-set* + (list->hash-table + (map (lambda (s) (cons s #t)) *scheme-builtins*))) + *scheme-builtins-set*))) + +(def (highlight-line line lang) + (let ((keywords (assoc lang *language-configs*))) + (if keywords + (highlight-with-keywords line (cdr keywords) lang) + (list (cons line 'code-block))))) + +(def (highlight-code-block lines lang) + (map (lambda (line) (highlight-line line lang)) lines)) + +(def (highlight-with-keywords line keywords lang) + (if (string-empty? line) + '() + (let ((comment-pos (find-comment line lang))) + (if comment-pos + (let ((before (substring line 0 comment-pos)) + (comment (substring line comment-pos (string-length line)))) + (append (if (string-empty? before) + '() + (tokenize-words before keywords lang)) + (list (cons comment 'syntax-comment)))) + (tokenize-words line keywords lang))))) + +(def (find-comment line lang) + (cond + ((member lang '("python" "py" "bash" "sh" "shell" "zsh" "ruby" "perl")) + (find-unquoted line #\# 0)) + ((member lang '("scheme" "racket" "r7rs" "lisp" "clojure" "javascript" "js" "typescript" "ts" "rust" "go" "golang" "c" "c++" "cpp" "java")) + (find-unquoted line #\; 0)) + (else #f))) + +(def (find-unquoted line ch start) + (let ((len (string-length line)) + (in-string? #f) + (string-char #f)) + (let loop ((i start)) + (cond + ((>= i len) #f) + ((and (not in-string?) (char=? (string-ref line i) ch)) i) + ((and (not in-string?) + (or (char=? (string-ref line i) #\") (char=? (string-ref line i) #\'))) + (set! in-string? #t) + (set! string-char (string-ref line i)) + (loop (+ i 1))) + ((and in-string? (char=? (string-ref line i) string-char)) + (set! in-string? #f) + (loop (+ i 1))) + (else (loop (+ i 1))))))) + +(def (tokenize-words line keywords lang) + (let ((len (string-length line)) + (kw-set (list->hash-table (map (lambda (s) (cons s #t)) keywords)))) + (let loop ((i 0) (word-start #f) (acc '())) + (cond + ((>= i len) + (if word-start + (reverse (emit-token line word-start i kw-set lang acc)) + (reverse acc))) + ((in-string-at? line i) + (let ((str-end (find-string-end line i))) + (let ((new-acc (if word-start + (emit-token line word-start i kw-set lang acc) + acc))) + (loop (or str-end (string-length line)) #f + (cons (cons (substring line i (or str-end (string-length line))) + 'syntax-string) + new-acc))))) + ((word-char? (string-ref line i)) + (if word-start + (loop (+ i 1) word-start acc) + (loop (+ i 1) i acc))) + (else + (let ((new-acc (if word-start + (emit-token line word-start i kw-set lang acc) + acc))) + (if (char=? (string-ref line i) #\() + (loop (+ i 1) #f (cons (cons "(" 'syntax-punctuation) new-acc)) + (if (char=? (string-ref line i) #\)) + (loop (+ i 1) #f (cons (cons ")" 'syntax-punctuation) new-acc)) + (if (char=? (string-ref line i) #\[) + (loop (+ i 1) #f (cons (cons "[" 'syntax-punctuation) new-acc)) + (if (char=? (string-ref line i) #\]) + (loop (+ i 1) #f (cons (cons "]" 'syntax-punctuation) new-acc)) + (loop (+ i 1) #f new-acc))))))))))) + +(def (emit-token line start end kw-set lang acc) + (let* ((word (substring line start end)) + (face (cond + ((hash-get kw-set word) 'syntax-keyword) + ((and (equal? lang "scheme") (hash-get (scheme-builtins-set) word)) + 'syntax-function) + ((looks-like-number? word) 'syntax-number) + (else 'code-block)))) + (cons (cons word face) acc))) + +(def (word-char? ch) + (or (char-alphabetic? ch) + (char-numeric? ch) + (char=? ch #\_) + (char=? ch #\!) + (char=? ch #\*) + (char=? ch #\?) + (char=? ch #\+) + (char=? ch #\-) + (char=? ch #\/))) + +(def (in-string-at? line i) + (let ((ch (string-ref line i))) + (or (char=? ch #\") (char=? ch #\')))) + +(def (find-string-end line start) + (let ((q (string-ref line start)) + (len (string-length line))) + (let loop ((i (+ start 1))) + (cond + ((>= i len) len) + ((char=? (string-ref line i) #\\) (loop (+ i 2))) + ((char=? (string-ref line i) q) (+ i 1)) + (else (loop (+ i 1))))))) + +(def (looks-like-number? s) + (let ((len (string-length s))) + (and (> len 0) + (or (char-numeric? (string-ref s 0)) + (and (char=? (string-ref s 0) #\#) + (> len 1)))))) --- a/src/jcode/ui/tui-theme.ss +++ b/src/jcode/ui/tui-theme.ss @@ -6,10 +6,18 @@ current-theme set-theme! face-ref face-fg-attr face-bg-attr theme-dark theme-light theme-gruvbox + ;; Theme registry + register-theme! get-registered-themes cycle-theme-by-name! + ;; JSON theme loader + load-theme-json! load-themes-from-dir! ;; Helpers rgb truecolor) -(import :jcode/ui/tui-ffi +(import :std/text/json + :std/misc/string + :std/os/path + :jcode/ui/tui-ffi + :jcode/core/config :jerboa/core :jerboa/runtime) @@ -114,6 +122,46 @@ (dim . ,(make-face (rgb #x80 #x80 #x80) (rgb #x1e #x1e #x1e) #f #f #f)) (spinner . ,(make-face (rgb #x4e #xc9 #xb0) (rgb #x1e #x1e #x1e) #f #f #f)) (divider . ,(make-face (rgb #x3a #x3a #x3a) (rgb #x1e #x1e #x1e) #f #f #f)) + ;; Toast faces + (toast-error-border . ,(make-face (rgb #xf4 #x47 #x47) (rgb #x1e #x1e #x1e) #f #f #f)) + (toast-warning-border . ,(make-face (rgb #xff #xcc #x00) (rgb #x1e #x1e #x1e) #f #f #f)) + (toast-success-border . ,(make-face (rgb #x4e #xc9 #xb0) (rgb #x1e #x1e #x1e) #f #f #f)) + (toast-info-border . ,(make-face (rgb #x56 #x9c #xd6) (rgb #x1e #x1e #x1e) #f #f #f)) + (toast-bg . ,(make-face (rgb #xd4 #xd4 #xd4) (rgb #x2d #x2d #x2d) #f #f #f)) + (toast-title . ,(make-face (rgb #xff #xff #xff) (rgb #x2d #x2d #x2d) #t #f #f)) + (toast-message . ,(make-face (rgb #xd4 #xd4 #xd4) (rgb #x2d #x2d #x2d) #f #f #f)) + ;; Thinking faces + (thinking-text . ,(make-face (rgb #x80 #x80 #x80) (rgb #x1e #x1e #x1e) #f #t #f)) + (thinking-border . ,(make-face (rgb #x3a #x3a #x3a) (rgb #x1e #x1e #x1e) #f #f #f)) + (thinking-header . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #x1e #x1e #x1e) #f #t #f)) + ;; Syntax faces + (syntax-comment . ,(make-face (rgb #x6a #x99 #x55) (rgb #x1e #x1e #x1e) #f #f #f)) + (syntax-keyword . ,(make-face (rgb #x56 #x9c #xd6) (rgb #x1e #x1e #x1e) #t #f #f)) + (syntax-function . ,(make-face (rgb #xdc #xdc #xaa) (rgb #x1e #x1e #x1e) #f #f #f)) + (syntax-variable . ,(make-face (rgb #x9c #xdc #xfe) (rgb #x1e #x1e #x1e) #f #f #f)) + (syntax-string . ,(make-face (rgb #xce #x91 #x78) (rgb #x1e #x1e #x1e) #f #f #f)) + (syntax-number . ,(make-face (rgb #xb5 #xce #xa8) (rgb #x1e #x1e #x1e) #f #f #f)) + (syntax-type . ,(make-face (rgb #x4e #xc9 #xb0) (rgb #x1e #x1e #x1e) #f #t #f)) + (syntax-operator . ,(make-face (rgb #xd4 #xd4 #xd4) (rgb #x1e #x1e #x1e) #f #f #f)) + (syntax-punctuation . ,(make-face (rgb #x80 #x80 #x80) (rgb #x1e #x1e #x1e) #f #f #f)) + ;; Selection + (selection-bg . ,(make-face (rgb #xff #xff #xff) (rgb #x26 #x4f #x78) #f #f #f)) + ;; Leader / palette + (leader-indicator . ,(make-face (rgb #xff #xcc #x00) (rgb #x1e #x1e #x1e) #t #f #f)) + (leader-dim . ,(make-face (rgb #x80 #x80 #x80) (rgb #x1e #x1e #x1e) #f #f #f)) + (palette-match . ,(make-face (rgb #xff #xcc #x00) (rgb #x2d #x2d #x2d) #t #f #f)) + (palette-keybind . ,(make-face (rgb #x80 #x80 #x80) (rgb #x2d #x2d #x2d) #f #f #f)) + (palette-category . ,(make-face (rgb #x4e #xc9 #xb0) (rgb #x2d #x2d #x2d) #t #f #f)) + ;; Home screen + (home-logo-fg . ,(make-face (rgb #x56 #x9c #xd6) (rgb #x1e #x1e #x1e) #t #f #f)) + (home-logo-shadow . ,(make-face (rgb #x2d #x2d #x2d) (rgb #x1e #x1e #x1e) #f #f #f)) + (home-tip-text . ,(make-face (rgb #x80 #x80 #x80) (rgb #x1e #x1e #x1e) #f #f #f)) + (home-tip-icon . ,(make-face (rgb #xff #xcc #x00) (rgb #x1e #x1e #x1e) #f #f #f)) + ;; Message borders + (msg-border-user . ,(make-face (rgb #x56 #x9c #xd6) (rgb #x1e #x1e #x1e) #f #f #f)) + (msg-border-assistant . ,(make-face (rgb #x4e #xc9 #xb0) (rgb #x1e #x1e #x1e) #f #f #f)) + (msg-border-tool . ,(make-face (rgb #x80 #x80 #x80) (rgb #x1e #x1e #x1e) #f #f #f)) + (msg-border-error . ,(make-face (rgb #xf4 #x47 #x47) (rgb #x1e #x1e #x1e) #f #f #f)) ))) (def theme-light @@ -166,6 +214,46 @@ (dim . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #xff #xff #xff) #f #f #f)) (spinner . ,(make-face (rgb #x09 #x7c #x5a) (rgb #xff #xff #xff) #f #f #f)) (divider . ,(make-face (rgb #xc0 #xc0 #xc0) (rgb #xff #xff #xff) #f #f #f)) + ;; Toast faces + (toast-error-border . ,(make-face (rgb #xc0 #x22 #x22) (rgb #xff #xff #xff) #f #f #f)) + (toast-warning-border . ,(make-face (rgb #xc0 #x7b #x00) (rgb #xff #xff #xff) #f #f #f)) + (toast-success-border . ,(make-face (rgb #x09 #x7c #x5a) (rgb #xff #xff #xff) #f #f #f)) + (toast-info-border . ,(make-face (rgb #x00 #x51 #xa5) (rgb #xff #xff #xff) #f #f #f)) + (toast-bg . ,(make-face (rgb #x1e #x1e #x1e) (rgb #xf0 #xf0 #xf0) #f #f #f)) + (toast-title . ,(make-face (rgb #x1e #x1e #x1e) (rgb #xf0 #xf0 #xf0) #t #f #f)) + (toast-message . ,(make-face (rgb #x40 #x40 #x40) (rgb #xf0 #xf0 #xf0) #f #f #f)) + ;; Thinking faces + (thinking-text . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #xff #xff #xff) #f #t #f)) + (thinking-border . ,(make-face (rgb #xc0 #xc0 #xc0) (rgb #xff #xff #xff) #f #f #f)) + (thinking-header . ,(make-face (rgb #x80 #x80 #x80) (rgb #xff #xff #xff) #f #t #f)) + ;; Syntax faces + (syntax-comment . ,(make-face (rgb #x09 #x7c #x5a) (rgb #xff #xff #xff) #f #f #f)) + (syntax-keyword . ,(make-face (rgb #x00 #x51 #xa5) (rgb #xff #xff #xff) #t #f #f)) + (syntax-function . ,(make-face (rgb #x79 #x5e #x26) (rgb #xff #xff #xff) #f #f #f)) + (syntax-variable . ,(make-face (rgb #x00 #x51 #xa5) (rgb #xff #xff #xff) #f #f #f)) + (syntax-string . ,(make-face (rgb #xa3 #x15 #x2a) (rgb #xff #xff #xff) #f #f #f)) + (syntax-number . ,(make-face (rgb #x09 #x7c #x5a) (rgb #xff #xff #xff) #f #f #f)) + (syntax-type . ,(make-face (rgb #x09 #x7c #x5a) (rgb #xff #xff #xff) #f #t #f)) + (syntax-operator . ,(make-face (rgb #x1e #x1e #x1e) (rgb #xff #xff #xff) #f #f #f)) + (syntax-punctuation . ,(make-face (rgb #x80 #x80 #x80) (rgb #xff #xff #xff) #f #f #f)) + ;; Selection + (selection-bg . ,(make-face (rgb #xff #xff #xff) (rgb #xc0 #xd0 #xf0) #f #f #f)) + ;; Leader / palette + (leader-indicator . ,(make-face (rgb #xc0 #x7b #x00) (rgb #xff #xff #xff) #t #f #f)) + (leader-dim . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #xff #xff #xff) #f #f #f)) + (palette-match . ,(make-face (rgb #xc0 #x7b #x00) (rgb #xf0 #xf0 #xf0) #t #f #f)) + (palette-keybind . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #xf0 #xf0 #xf0) #f #f #f)) + (palette-category . ,(make-face (rgb #x09 #x7c #x5a) (rgb #xf0 #xf0 #xf0) #t #f #f)) + ;; Home screen + (home-logo-fg . ,(make-face (rgb #x00 #x51 #xa5) (rgb #xff #xff #xff) #t #f #f)) + (home-logo-shadow . ,(make-face (rgb #xe0 #xe0 #xe0) (rgb #xff #xff #xff) #f #f #f)) + (home-tip-text . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #xff #xff #xff) #f #f #f)) + (home-tip-icon . ,(make-face (rgb #xc0 #x7b #x00) (rgb #xff #xff #xff) #f #f #f)) + ;; Message borders + (msg-border-user . ,(make-face (rgb #x00 #x51 #xa5) (rgb #xff #xff #xff) #f #f #f)) + (msg-border-assistant . ,(make-face (rgb #x09 #x7c #x5a) (rgb #xff #xff #xff) #f #f #f)) + (msg-border-tool . ,(make-face (rgb #xc0 #xc0 #xc0) (rgb #xff #xff #xff) #f #f #f)) + (msg-border-error . ,(make-face (rgb #xc0 #x22 #x22) (rgb #xff #xff #xff) #f #f #f)) ))) (def theme-gruvbox @@ -218,4 +306,188 @@ (dim . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x28 #x28 #x28) #f #f #f)) (spinner . ,(make-face (rgb #xb8 #xbb #x26) (rgb #x28 #x28 #x28) #f #f #f)) (divider . ,(make-face (rgb #x50 #x49 #x45) (rgb #x28 #x28 #x28) #f #f #f)) + ;; Toast faces + (toast-error-border . ,(make-face (rgb #xfb #x49 #x34) (rgb #x28 #x28 #x28) #f #f #f)) + (toast-warning-border . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x28 #x28 #x28) #f #f #f)) + (toast-success-border . ,(make-face (rgb #xb8 #xbb #x26) (rgb #x28 #x28 #x28) #f #f #f)) + (toast-info-border . ,(make-face (rgb #x83 #xa5 #x98) (rgb #x28 #x28 #x28) #f #f #f)) + (toast-bg . ,(make-face (rgb #xeb #xdb #xb2) (rgb #x3c #x38 #x36) #f #f #f)) + (toast-title . ,(make-face (rgb #xfb #xf1 #xc7) (rgb #x3c #x38 #x36) #t #f #f)) + (toast-message . ,(make-face (rgb #xeb #xdb #xb2) (rgb #x3c #x38 #x36) #f #f #f)) + ;; Thinking faces + (thinking-text . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x28 #x28 #x28) #f #t #f)) + (thinking-border . ,(make-face (rgb #x50 #x49 #x45) (rgb #x28 #x28 #x28) #f #f #f)) + (thinking-header . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x28 #x28 #x28) #f #t #f)) + ;; Syntax faces + (syntax-comment . ,(make-face (rgb #xb8 #xbb #x26) (rgb #x28 #x28 #x28) #f #f #f)) + (syntax-keyword . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x28 #x28 #x28) #t #f #f)) + (syntax-function . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x28 #x28 #x28) #f #f #f)) + (syntax-variable . ,(make-face (rgb #x83 #xa5 #x98) (rgb #x28 #x28 #x28) #f #f #f)) + (syntax-string . ,(make-face (rgb #xb8 #xbb #x26) (rgb #x28 #x28 #x28) #f #f #f)) + (syntax-number . ,(make-face (rgb #xd3 #x86 #x9b) (rgb #x28 #x28 #x28) #f #f #f)) + (syntax-type . ,(make-face (rgb #x83 #xa5 #x98) (rgb #x28 #x28 #x28) #f #t #f)) + (syntax-operator . ,(make-face (rgb #xeb #xdb #xb2) (rgb #x28 #x28 #x28) #f #f #f)) + (syntax-punctuation . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x28 #x28 #x28) #f #f #f)) + ;; Selection + (selection-bg . ,(make-face (rgb #x28 #x28 #x28) (rgb #x50 #x49 #x45) #f #f #f)) + ;; Leader / palette + (leader-indicator . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x28 #x28 #x28) #t #f #f)) + (leader-dim . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x28 #x28 #x28) #f #f #f)) + (palette-match . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x3c #x38 #x36) #t #f #f)) + (palette-keybind . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x3c #x38 #x36) #f #f #f)) + (palette-category . ,(make-face (rgb #xb8 #xbb #x26) (rgb #x3c #x38 #x36) #t #f #f)) + ;; Home screen + (home-logo-fg . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x28 #x28 #x28) #t #f #f)) + (home-logo-shadow . ,(make-face (rgb #x50 #x49 #x45) (rgb #x28 #x28 #x28) #f #f #f)) + (home-tip-text . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x28 #x28 #x28) #f #f #f)) + (home-tip-icon . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x28 #x28 #x28) #f #f #f)) + ;; Message borders + (msg-border-user . ,(make-face (rgb #x83 #xa5 #x98) (rgb #x28 #x28 #x28) #f #f #f)) + (msg-border-assistant . ,(make-face (rgb #xb8 #xbb #x26) (rgb #x28 #x28 #x28) #f #f #f)) + (msg-border-tool . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x28 #x28 #x28) #f #f #f)) + (msg-border-error . ,(make-face (rgb #xfb #x49 #x34) (rgb #x28 #x28 #x28) #f #f #f)) ))) + +;; ---- Theme registry ---- + +(def *theme-registry* (make-hash-table)) + +(def (register-theme! name theme-table) + (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) + +(def (cycle-theme-by-name!) + (let* ((names (sort (get-registered-themes) string<?)) + (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)))))) + (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)))) + +;; ---- opencode JSON theme loader ---- + +(def (parse-hex-color s) + (cond + ((and (string? s) (>= (string-length s) 7) (char=? (string-ref s 0) #\#)) + (rgb (string->number (substring s 1 3) 16) + (string->number (substring s 3 5) 16) + (string->number (substring s 5 7) 16))) + ((and (string? s) (>= (string-length s) 5) (char=? (string-ref s 0) #\#)) + (let ((r (string->number (substring s 1 2) 16)) + (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-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)))) + +(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)) + +(def (json-color->face 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)))) + ((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)))) + +(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"))) + (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))) + '("dark" "light"))) + (catch (e) #f))) + +(def (load-themes-from-dir! dir) + (when (file-exists? dir) + (let ((files (try (directory-list dir) (catch (e) '())))) + (for-each + (lambda (f) + (when (string-suffix? ".json" f) + (load-theme-json! (path-join dir f)))) + files)))) new file mode 100644 --- /dev/null +++ b/src/jcode/ui/tui-toast.ss @@ -0,0 +1,95 @@ +;;; jcode TUI toast notifications +;;; Ephemeral feedback for state changes, errors, and confirmations. + +(export + make-toast toast? toast-variant toast-title toast-message toast-dismiss-at + toast-add! toast-dismiss-oldest! toast-render! toast-tick! + *active-toasts*) + +(import :jcode/ui/tui-ffi + :jcode/ui/tui-theme + :jerboa/core + :jerboa/runtime) + +(defstruct toast (variant title message dismiss-at) transparent: #t) + +(def *active-toasts* '()) + +(def *toast-width* 40) +(def *toast-display-ms* 3000) + +(def (toast-add! variant title message) + (let ((t (make-toast variant title message + (+ (current-time-ms) *toast-display-ms*)))) + (set! *active-toasts* (append *active-toasts* (list t))))) + +(def (toast-dismiss-oldest!) + (when (pair? *active-toasts*) + (set! *active-toasts* (cdr *active-toasts*)))) + +(def (toast-tick!) + (let ((now (current-time-ms))) + (set! *active-toasts* + (filter (lambda (t) (< now (toast-dismiss-at t))) *active-toasts*)))) + +(def (current-time-ms) + (let ((t (current-time))) + (+ (* (time-second t) 1000) + (quotient (time-nanosecond t) 1000000)))) + +(def (variant->border-face variant) + (case variant + ((error) 'toast-error-border) + ((warning) 'toast-warning-border) + ((success) 'toast-success-border) + ((info) 'toast-info-border) + (else 'toast-info-border))) + +(def (toast-render! screen-w screen-h) + (let loop ((toasts *active-toasts*) (offset 0)) + (when (pair? toasts) + (let* ((t (car toasts)) + (tw *toast-width*) + (th 3) + (tx (- screen-w tw 2)) + (ty (+ 2 offset)) + (bf (variant->border-face (toast-variant t))) + (bfg (face-fg-attr bf)) + (bbg (face-bg-attr 'toast-bg)) + (tfg (face-fg-attr 'toast-title)) + (mfg (face-fg-attr 'toast-message))) + (draw-toast-box! tx ty tw th bfg bbg tfg mfg + (toast-title t) (toast-message t)) + (loop (cdr toasts) (+ offset th 1)))))) + +(def (draw-toast-box! x y w h bfg bbg tfg mfg title message) + (let ((bg (face-bg-attr 'toast-bg))) + (draw-toast-hline! x y w bfg bbg #\─) + (clear-toast-row! x (+ y 1) w bg) + (tb-change-cell! x (+ y 1) (char->integer #\│) bfg bbg) + (tb-change-cell! (+ x w -1) (+ y 1) (char->integer #\│) bfg bbg) + (tb-print! (+ x 2) (+ y 1) tfg bg + (truncate-str title (- w 4))) + (clear-toast-row! x (+ y 2) w bg) + (tb-change-cell! x (+ y 2) (char->integer #\│) bfg bbg) + (tb-change-cell! (+ x w -1) (+ y 2) (char->integer #\│) bfg bbg) + (tb-print! (+ x 2) (+ y 2) mfg bg + (truncate-str message (- w 4))) + (draw-toast-hline! x (+ y 3) w bfg bbg #\─))) + +(def (draw-toast-hline! x y w fg bg ch) + (let loop ((col x)) + (when (< col (+ x w)) + (tb-change-cell! col y (char->integer ch) fg bg) + (loop (+ col 1))))) + +(def (clear-toast-row! x y w bg) + (let loop ((col x)) + (when (< col (+ x w)) + (tb-change-cell! col y (char->integer #\space) bg bg) + (loop (+ col 1))))) + +(def (truncate-str s max-len) + (if (> (string-length s) max-len) + (string-append (substring s 0 (- max-len 3)) "...") + s)) --- a/src/jcode/ui/tui.ss +++ b/src/jcode/ui/tui.ss @@ -16,6 +16,7 @@ :jcode/ui/tui-input :jcode/ui/tui-sidebar :jcode/ui/tui-dialog + :jcode/ui/tui-toast :jcode/core/config :jcode/core/models :jcode/core/agent @@ -149,13 +150,14 @@ ;; ---- Layout calculations ---- -(def (msg-area-x state) 0) +(def (msg-area-x state) 1) (def (msg-area-y state) 0) (def (msg-area-width state) (- (app-state-width state) (if (app-state-sidebar-visible? state) (+ (app-state-sidebar-width state) 1) - 0))) + 0) + 1)) (def (msg-area-height state) (- (app-state-height state) (app-state-input-height state) @@ -192,6 +194,8 @@ (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* ((w (tb-width)) (h (tb-height)) (state (make-fresh-state w h)) @@ -340,6 +344,7 @@ ((= key TB_KEY_CTRL_T) (tui-log " -> cycle-theme") (cycle-theme!) + (toast-add! 'success "Theme" "Theme cycled") (app-state-dirty?-set! state #t)) ;; Global: Shift-Tab (BACK_TAB) toggle PLAN/BUILD mode (opencode-style) @@ -392,7 +397,6 @@ (app-state-dirty?-set! state #t))))))) (def (handle-mouse! state ev) - ;; Mouse wheel scrolling (let ((key (tui-event-key ev))) (cond ((= key TB_KEY_MOUSE_WHEEL_UP) @@ -419,7 +423,6 @@ ;; Normal message → agent (#t (tui-log "submit: sending to agent") - ;; Add user message (add-message! state (msg-block-user text)) ;; Auto-scroll to bottom (app-state-scroll-offset-set! state 0) @@ -580,8 +583,8 @@ (add-message! state (msg-block-user (format "/~a" cmd))) (app-state-scroll-offset-set! state 0) (run-agent! state prompt))) - (else - (add-message! state (msg-block-system (format "Unknown command: /~a" cmd)))))))))) + (else + (add-message! state (msg-block-system (format "Unknown command: /~a" cmd)))))))))) ;; ---- Provider / model switching ---- @@ -989,12 +992,8 @@ ;; ---- Theme cycling ---- -(def *themes* (list theme-dark theme-light theme-gruvbox)) -(def *theme-idx* 0) - (def (cycle-theme!) - (set! *theme-idx* (modulo (+ *theme-idx* 1) (length *themes*))) - (set-theme! (list-ref *themes* *theme-idx*))) + (cycle-theme-by-name!)) ;; ---- Drawing ---- @@ -1042,7 +1041,12 @@ ;; Dialog overlay (on top of everything) (when (app-state-dialog state) (render-dialog! (app-state-dialog state) - (app-state-width state) (app-state-height state))))) + (app-state-width state) (app-state-height state))) + + ;; Toast notifications (top-right, on top of everything) + (toast-tick!) + (when (pair? *active-toasts*) + (toast-render! (app-state-width state) (app-state-height state))))) (def (draw-messages! state) (let* ((x (msg-area-x state)) new file mode 100644 --- /dev/null +++ b/themes/catppuccin.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "lightRosewater": "#dc8a78", + "lightFlamingo": "#dd7878", + "lightPink": "#ea76cb", + "lightMauve": "#8839ef", + "lightRed": "#d20f39", + "lightMaroon": "#e64553", + "lightPeach": "#fe640b", + "lightYellow": "#df8e1d", + "lightGreen": "#40a02b", + "lightTeal": "#179299", + "lightSky": "#04a5e5", + "lightSapphire": "#209fb5", + "lightBlue": "#1e66f5", + "lightLavender": "#7287fd", + "lightText": "#4c4f69", + "lightSubtext1": "#5c5f77", + "lightSubtext0": "#6c6f85", + "lightOverlay2": "#7c7f93", + "lightOverlay1": "#8c8fa1", + "lightOverlay0": "#9ca0b0", + "lightSurface2": "#acb0be", + "lightSurface1": "#bcc0cc", + "lightSurface0": "#ccd0da", + "lightBase": "#eff1f5", + "lightMantle": "#e6e9ef", + "lightCrust": "#dce0e8", + "darkRosewater": "#f5e0dc", + "darkFlamingo": "#f2cdcd", + "darkPink": "#f5c2e7", + "darkMauve": "#cba6f7", + "darkRed": "#f38ba8", + "darkMaroon": "#eba0ac", + "darkPeach": "#fab387", + "darkYellow": "#f9e2af", + "darkGreen": "#a6e3a1", + "darkTeal": "#94e2d5", + "darkSky": "#89dceb", + "darkSapphire": "#74c7ec", + "darkBlue": "#89b4fa", + "darkLavender": "#b4befe", + "darkText": "#cdd6f4", + "darkSubtext1": "#bac2de", + "darkSubtext0": "#a6adc8", + "darkOverlay2": "#9399b2", + "darkOverlay1": "#7f849c", + "darkOverlay0": "#6c7086", + "darkSurface2": "#585b70", + "darkSurface1": "#45475a", + "darkSurface0": "#313244", + "darkBase": "#1e1e2e", + "darkMantle": "#181825", + "darkCrust": "#11111b" + }, + "theme": { + "primary": { "dark": "darkBlue", "light": "lightBlue" }, + "secondary": { "dark": "darkMauve", "light": "lightMauve" }, + "accent": { "dark": "darkPink", "light": "lightPink" }, + "error": { "dark": "darkRed", "light": "lightRed" }, + "warning": { "dark": "darkYellow", "light": "lightYellow" }, + "success": { "dark": "darkGreen", "light": "lightGreen" }, + "info": { "dark": "darkTeal", "light": "lightTeal" }, + "text": { "dark": "darkText", "light": "lightText" }, + "textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" }, + "background": { "dark": "darkBase", "light": "lightBase" }, + "backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" }, + "backgroundElement": { "dark": "darkCrust", "light": "lightCrust" }, + "border": { "dark": "darkSurface0", "light": "lightSurface0" }, + "borderActive": { "dark": "darkSurface1", "light": "lightSurface1" }, + "borderSubtle": { "dark": "darkSurface2", "light": "lightSurface2" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkOverlay2", "light": "lightOverlay2" }, + "diffHunkHeader": { "dark": "darkPeach", "light": "lightPeach" }, + "diffHighlightAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffHighlightRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffAddedBg": { "dark": "#24312b", "light": "#d6f0d9" }, + "diffRemovedBg": { "dark": "#3c2a32", "light": "#f6dfe2" }, + "diffContextBg": { "dark": "darkMantle", "light": "lightMantle" }, + "diffLineNumber": { "dark": "darkSurface1", "light": "lightSurface1" }, + "diffAddedLineNumberBg": { "dark": "#1e2a25", "light": "#c9e3cb" }, + "diffRemovedLineNumberBg": { "dark": "#32232a", "light": "#e9d3d6" }, + "markdownText": { "dark": "darkText", "light": "lightText" }, + "markdownHeading": { "dark": "darkMauve", "light": "lightMauve" }, + "markdownLink": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownLinkText": { "dark": "darkSky", "light": "lightSky" }, + "markdownCode": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownBlockQuote": { "dark": "darkYellow", "light": "lightYellow" }, + "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" }, + "markdownStrong": { "dark": "darkPeach", "light": "lightPeach" }, + "markdownHorizontalRule": { + "dark": "darkSubtext0", + "light": "lightSubtext0" + }, + "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownListEnumeration": { "dark": "darkSky", "light": "lightSky" }, + "markdownImage": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownImageText": { "dark": "darkSky", "light": "lightSky" }, + "markdownCodeBlock": { "dark": "darkText", "light": "lightText" }, + "syntaxComment": { "dark": "darkOverlay2", "light": "lightOverlay2" }, + "syntaxKeyword": { "dark": "darkMauve", "light": "lightMauve" }, + "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, + "syntaxVariable": { "dark": "darkRed", "light": "lightRed" }, + "syntaxString": { "dark": "darkGreen", "light": "lightGreen" }, + "syntaxNumber": { "dark": "darkPeach", "light": "lightPeach" }, + "syntaxType": { "dark": "darkYellow", "light": "lightYellow" }, + "syntaxOperator": { "dark": "darkSky", "light": "lightSky" }, + "syntaxPunctuation": { "dark": "darkText", "light": "lightText" } + } +} new file mode 100644 --- /dev/null +++ b/themes/dracula.json @@ -0,0 +1,219 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#282a36", + "currentLine": "#44475a", + "selection": "#44475a", + "foreground": "#f8f8f2", + "comment": "#6272a4", + "cyan": "#8be9fd", + "green": "#50fa7b", + "orange": "#ffb86c", + "pink": "#ff79c6", + "purple": "#bd93f9", + "red": "#ff5555", + "yellow": "#f1fa8c" + }, + "theme": { + "primary": { + "dark": "purple", + "light": "purple" + }, + "secondary": { + "dark": "pink", + "light": "pink" + }, + "accent": { + "dark": "cyan", + "light": "cyan" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "yellow" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "foreground", + "light": "#282a36" + }, + "textMuted": { + "dark": "comment", + "light": "#6272a4" + }, + "background": { + "dark": "#282a36", + "light": "#f8f8f2" + }, + "backgroundPanel": { + "dark": "#21222c", + "light": "#e8e8e2" + }, + "backgroundElement": { + "dark": "currentLine", + "light": "#d8d8d2" + }, + "border": { + "dark": "currentLine", + "light": "#c8c8c2" + }, + "borderActive": { + "dark": "purple", + "light": "purple" + }, + "borderSubtle": {