fix: TUI flicker, bottom-anchored minibuffer, 13 color presets

Issue 1 — flickering during typing/updating:
- Wrap every frame render in DECICM sync (begin-sync/end-sync) so the
  terminal defers rendering until the entire frame is written
- Replace backend-clear (ESC[2J full clear) with draw-rect background
  fill — eliminates visible blank frame between redraws
- These two changes together eliminate all visible tearing/flicker

Issue 2 — bottom-anchored minibuffer (Emacs-style):
- Replace centered overlay dialog with bottom-anchored minibuffer
  that expands upward from the input line
- Unified command menu: Ctrl+P and / both open the same menu with
  all 35+ commands (slash + daemon), dispatch by value type
- Filter prompt at h-3 (same position as normal input),
  options listed above, grows up to 15 lines
- No full-screen dim backdrop — just clear the minibuffer area

Issue 3 — color schemes:
- Add 5 new presets: catppuccin, tokyonight, dracula, gemini, mono
- Total: 13 presets (up from 8)
- Update /theme completion list and help text

Also fixed: pre-existing unbalanced paren in tui-main (missing close)
This commit is contained in:
2026-05-14 19:36:29 -04:00
parent 6d7dd9e1ea
commit 25da9ae685
2 changed files with 148 additions and 90 deletions

View File

@@ -380,13 +380,13 @@ Event handlers + daemon I/O + main loop.
(getf *tui-theme* :agent-fg) (getf *tui-theme* :agent-fg)
(getf *tui-theme* :system) (getf *tui-theme* :system)
(getf *tui-theme* :input-fg))) (getf *tui-theme* :input-fg)))
(add-msg :system "Presets: /theme amber | gold | terracotta | sepia | nord-warm | monokai-warm | gruvbox-warm | light-amber")) (add-msg :system "Presets: /theme amber | gold | terracotta | sepia | nord-warm | monokai-warm | gruvbox-warm | light-amber | catppuccin | tokyonight | dracula | gemini | mono"))
((and (>= (length text) 7) ((and (>= (length text) 7)
(string-equal (subseq text 0 7) "/theme ")) (string-equal (subseq text 0 7) "/theme "))
(let ((name (string-trim '(#\Space) (subseq text 7)))) (let ((name (string-trim '(#\Space) (subseq text 7))))
(if (theme-switch name) (if (theme-switch name)
(add-msg :system (format nil "Theme switched to ~a" name)) (add-msg :system (format nil "Theme switched to ~a" name))
(add-msg :system (format nil "Unknown theme '~a'. Try: amber gold terracotta sepia nord-warm monokai-warm gruvbox-warm light-amber" name))))) (add-msg :system (format nil "Unknown theme '~a'. Try: amber gold terracotta sepia nord-warm monokai-warm gruvbox-warm light-amber catppuccin tokyonight dracula gemini mono" name)))))
;; /eval command ;; /eval command
((and (>= (length text) 6) ((and (>= (length text) 6)
(string-equal (subseq text 0 6) "/eval ")) (string-equal (subseq text 0 6) "/eval "))
@@ -474,7 +474,7 @@ Event handlers + daemon I/O + main loop.
;; /theme subcommand ;; /theme subcommand
((and (>= (length text) 7) (string-equal (subseq text 0 7) "/theme ")) ((and (>= (length text) 7) (string-equal (subseq text 0 7) "/theme "))
(let* ((partial (string-trim '(#\Space) (subseq text 7))) (let* ((partial (string-trim '(#\Space) (subseq text 7)))
(names '("amber" "gold" "terracotta" "sepia" "nord-warm" "monokai-warm" "gruvbox-warm" "light-amber")) (names '("amber" "gold" "terracotta" "sepia" "nord-warm" "monokai-warm" "gruvbox-warm" "light-amber" "catppuccin" "tokyonight" "dracula" "gemini" "mono"))
(match (if (string= partial "") (first names) (match (if (string= partial "") (first names)
(find partial names :test #'string-equal)))) (find partial names :test #'string-equal))))
(when match (when match
@@ -562,40 +562,30 @@ Event handlers + daemon I/O + main loop.
(setf (st :dirty) (list nil nil t)) (setf (st :dirty) (list nil nil t))
(when (and (char= chr #\/) (null (st :dialog-stack)) (when (and (char= chr #\/) (null (st :dialog-stack))
(= (length (st :input-buffer)) 1)) (= (length (st :input-buffer)) 1))
(minibuffer-show-commands))))))) (unified-menu-show "/")))))))
;; v0.8.0 — minibuffer dialog for slash commands ;; v0.9.0 — unified command minibuffer (replaces separate palette and slash menus)
(defun minibuffer-show-commands () (defun unified-menu-show (&optional initial-filter)
"Open the command minibuffer with ALL commands. If INITIAL-FILTER is
supplied (e.g. \"/\"), pre-fill the select filter with it."
(let* ((on-select (lambda (opt) (let* ((on-select (lambda (opt)
(let ((cmd (getf opt :value)))
(pop (st :dialog-stack))
(setf (st :minibuffer-active) nil)
(setf (st :input-buffer) (reverse (coerce cmd 'list)))
(setf (st :cursor-pos) 0)
(setf (st :dirty) (list nil nil t)))))
(sel (cl-tty.select:make-select :options *slash-commands* :on-select on-select))
(dlg (make-instance 'cl-tty.dialog:dialog
:title "Commands"
:content sel)))
(push dlg (st :dialog-stack))
(setf (st :minibuffer-active) t)))
;; v0.8.0 — command palette for daemon commands (Ctrl+P)
(defun command-palette-show-commands ()
(let* ((on-select (lambda (cmd)
(pop (st :dialog-stack)) (pop (st :dialog-stack))
(setf (st :command-palette-active) nil) (let ((val (getf opt :value)))
(let ((action (getf cmd :value))) (cond ((stringp val)
(send-daemon (list :type :event :payload action)) ;; Slash command — fill input buffer
(add-msg :system (format nil "Sent: ~a" action))) (setf (st :input-buffer) (reverse (coerce val 'list)))
(setf (st :dirty) (list t t nil)))) (setf (st :cursor-pos) 0)
(sel (cl-tty.select:make-select :options *daemon-commands* (setf (st :dirty) (list nil nil t)))
:on-select on-select)) ((listp val)
(dlg (make-instance 'cl-tty.dialog:dialog ;; Daemon action — send immediately
:title "Command Palette" (send-daemon (list :type :event :payload val))
:content sel))) (add-msg :system (format nil "Sent: ~a" (getf opt :title)))
(push dlg (st :dialog-stack)) (setf (st :dirty) (list t t nil)))))))
(setf (st :command-palette-active) t))) (sel (cl-tty.select:make-select :options (all-commands) :on-select on-select)))
(when initial-filter
(setf (cl-tty.select:select-filter sel) initial-filter))
(let ((dlg (make-instance 'cl-tty.dialog:dialog :title "Commands" :content sel)))
(push dlg (st :dialog-stack)))))
;; v0.7.2 — resolve-hitl-panel: marks panel as resolved after approve/deny ;; v0.7.2 — resolve-hitl-panel: marks panel as resolved after approve/deny
(defun resolve-hitl-panel (decision) (defun resolve-hitl-panel (decision)
@@ -805,7 +795,7 @@ Event handlers + daemon I/O + main loop.
(:ctrl+q (lambda (e) (declare (ignore e)) (:ctrl+q (lambda (e) (declare (ignore e))
(setf (st :running) nil))) (setf (st :running) nil)))
(:ctrl+p (lambda (e) (declare (ignore e)) (:ctrl+p (lambda (e) (declare (ignore e))
(command-palette-show-commands))) (unified-menu-show)))
(:ctrl+b (lambda (e) (declare (ignore e)) (:ctrl+b (lambda (e) (declare (ignore e))
(setf (st :sidebar-visible) (not (st :sidebar-visible))) (setf (st :sidebar-visible) (not (st :sidebar-visible)))
(setf (st :dirty) (list t t nil)))) (setf (st :dirty) (list t t nil))))
@@ -955,7 +945,7 @@ Event handlers + daemon I/O + main loop.
(ch (getf payload :ch))) (ch (getf payload :ch)))
(case ch (case ch
(:CTRL-Q (setf (st :running) nil)) (:CTRL-Q (setf (st :running) nil))
(:CTRL-P (command-palette-show-commands)) (:CTRL-P (unified-menu-show))
(:CTRL-B (setf (st :sidebar-visible) (not (st :sidebar-visible))) (:CTRL-B (setf (st :sidebar-visible) (not (st :sidebar-visible)))
(setf (st :dirty) (list t t t))) (setf (st :dirty) (list t t t)))
(:CTRL-L (setf (st :dirty) (list t t t))) (:CTRL-L (setf (st :dirty) (list t t t)))
@@ -963,11 +953,9 @@ Event handlers + daemon I/O + main loop.
(let* ((dlg (car (st :dialog-stack))) (let* ((dlg (car (st :dialog-stack)))
(sel (cl-tty.dialog:dialog-content dlg))) (sel (cl-tty.dialog:dialog-content dlg)))
(cond (cond
((eql ch :escape) ((eql ch :escape)
(pop (st :dialog-stack)) (pop (st :dialog-stack))
(setf (st :minibuffer-active) nil) (setf (st :dirty) (list t t nil)))
(setf (st :command-palette-active) nil)
(setf (st :dirty) (list t t nil)))
((member ch '(:up :down)) ((member ch '(:up :down))
(if (eql ch :up) (if (eql ch :up)
(cl-tty.select:select-prev sel) (cl-tty.select:select-prev sel)
@@ -1026,55 +1014,69 @@ Event handlers + daemon I/O + main loop.
;; Guard w and h before render (resize or other code may have set them to nil) ;; Guard w and h before render (resize or other code may have set them to nil)
(setq w (or (and (numberp w) (> w 0) w) 80) (setq w (or (and (numberp w) (> w 0) w) 80)
h (or (and (numberp h) (> h 0) h) 24)) h (or (and (numberp h) (> h 0) h) 24))
(when (or (first (st :dirty)) (second (st :dirty)) (third (st :dirty))) (when (or (first (st :dirty)) (second (st :dirty)) (third (st :dirty)))
(let* ((sidebar-w (if (and (st :sidebar-visible) (>= w 60)) (let* ((sidebar-w (if (and (st :sidebar-visible) (>= w 60))
(or (st :sidebar-width) 30) 0)) (or (st :sidebar-width) 30) 0))
(chat-w (- w sidebar-w))) (chat-w (- w sidebar-w)))
(cl-tty.backend:backend-clear be) (cl-tty.backend:begin-sync be)
(view-status be w h) (cl-tty.backend:draw-rect be 0 0 w h :bg (theme-color :status-bg))
(view-chat be w h) (view-status be w h)
;; Draw separator line above input (view-chat be w h)
(cl-tty.backend:draw-text be 0 (- h 4) (make-string chat-w :initial-element #\─) ;; Draw separator line above input
(theme-color :separator) nil) (cl-tty.backend:draw-text be 0 (- h 4) (make-string chat-w :initial-element #\─)
(view-input be w h) (theme-color :separator) nil)
(when (and (st :sidebar-visible) (>= w 60)) (view-input be w h)
(view-sidebar be w h)) (when (and (st :sidebar-visible) (>= w 60))
(setf (st :dirty) (list nil nil nil)))) (view-sidebar be w h))
(let ((ds (st :dialog-stack))) (cl-tty.backend:end-sync be)
(when ds (setf (st :dirty) (list nil nil nil))))
(let* ((dlg (car ds)) (let ((ds (st :dialog-stack)))
(sel (cl-tty.dialog:dialog-content dlg)) (when ds
(filtered (cl-tty.select:select-filtered-options sel)) (cl-tty.backend:begin-sync be)
(sel-idx (cl-tty.select:select-selected-index sel)) (let* ((dlg (car ds))
(cnt (length filtered)) (sel (cl-tty.dialog:dialog-content dlg))
(dw 60) (dh (min 20 (+ 4 cnt))) (filtered (cl-tty.select:select-filtered-options sel))
(mx (floor (- w dw) 2)) (sel-idx (cl-tty.select:select-selected-index sel))
(my 3)) (cnt (length filtered))
(dotimes (row h) (filter (cl-tty.select:select-filter sel))
(cl-tty.backend:draw-rect be 0 row w 1 :bg (theme-color :status-bg))) ;; Bottom-anchored: filter at h-3, options above
(cl-tty.backend:draw-border be mx my dw dh :style :single (mh (min 15 (+ 1 cnt))) ;; +1 for title
:title (cl-tty.dialog:dialog-title dlg) (top (max 0 (- h 3 mh))))
:fg (theme-color :user-border)) ;; Clear the full minibuffer area (top to h-2)
(let ((y-off 1)) (dotimes (r (min (- h 2 top) h))
(dolist (item filtered) (cl-tty.backend:draw-rect be 0 (+ top r) w 1
(let* ((display-idx (first item)) :bg (theme-color :status-bg)))
(option (third item)) ;; Title line
(title (getf option :title)) (cl-tty.backend:draw-text be 0 top
(cat (getf option :category)) (cl-tty.dialog:dialog-title dlg)
(sel-p (eql display-idx sel-idx)) (theme-color :accent) nil)
(text (if cat (format nil " ~a" title) ;; Options
(format nil " ~:[ ~;▸~] ~a" sel-p title)))) (let ((y-off 1))
(when (>= y-off (1- dh)) (return)) (dolist (item filtered)
(cl-tty.backend:draw-text be (1+ mx) (+ my y-off) text (let* ((display-idx (first item))
(cond (cat (theme-color :dim)) (option (third item))
(sel-p (theme-color :accent)) (title (getf option :title))
(t (theme-color :agent-fg))) (cat (getf option :category))
nil :bold sel-p) (sel-p (eql display-idx sel-idx))
(incf y-off))))))) (text (if cat (format nil " ~a" title)
(sleep 0.1)) (format nil " ~:[ ~;▸~] ~a" sel-p title)))
(row (+ top y-off)))
(when (>= row (1- h)) (return))
(cl-tty.backend:draw-text be 1 row text
(cond (cat (theme-color :dim))
(sel-p (theme-color :accent))
(t (theme-color :agent-fg)))
nil :bold sel-p)
(incf y-off))))
;; Filter prompt at h-3
(cl-tty.backend:draw-text be 0 (- h 3)
(format nil "> ~a" filter)
(theme-color :input-prompt) nil))
(cl-tty.backend:end-sync be))
(sleep 0.1))
(uiop:terminate-process *cat-proc*)) (uiop:terminate-process *cat-proc*))
(add-msg :system (format nil "* cat ~a ended *" (uiop:process-info-pid *cat-proc*)))) (add-msg :system (format nil "* cat ~a ended *" (uiop:process-info-pid *cat-proc*))))
(progn (disconnect-daemon))) (progn (disconnect-daemon))))
#+END_SRC #+END_SRC
* Test Suite * Test Suite

View File

@@ -125,8 +125,60 @@ All state mutation flows through event handlers in the controller.
:dot-connected "#2E8B57" :dot-disconnected "#CC3300" :dot-connected "#2E8B57" :dot-disconnected "#CC3300"
:error "#CC3300" :error "#CC3300"
:tool-running "#CC6600" :tool-done "#2E8B57" :tool-error "#CC3300" :tool-running "#CC6600" :tool-done "#2E8B57" :tool-error "#CC3300"
:separator "#C8B898" :accent "#CC6600" :dim "#8B7355")) :separator "#C8B898" :accent "#CC6600" :dim "#8B7355")
"8 warm theme presets.") :catppuccin (:user-fg "#FAB387" :user-bg "#1E1E2E" :user-border "#F5A97F"
:agent-header "#CBA6F7" :agent-fg "#CDD6F4"
:system "#94E2D5"
:input-prompt "#FAB387" :input-fg "#CDD6F4"
:hint "#6C7086"
:status-bg "#181825" :status-fg "#A6ADC8"
:dot-connected "#A6E3A1" :dot-disconnected "#F38BA8"
:error "#F38BA8"
:tool-running "#FAB387" :tool-done "#A6E3A1" :tool-error "#F38BA8"
:separator "#313244" :accent "#FAB387" :dim "#585B70")
:tokyonight (:user-fg "#FF9E64" :user-bg "#1A1B26" :user-border "#F59E4C"
:agent-header "#7AA2F7" :agent-fg "#A9B1D6"
:system "#73DACA"
:input-prompt "#FF9E64" :input-fg "#A9B1D6"
:hint "#565F89"
:status-bg "#16161E" :status-fg "#9AA5CE"
:dot-connected "#9ECE6A" :dot-disconnected "#DB4B4B"
:error "#DB4B4B"
:tool-running "#FF9E64" :tool-done "#9ECE6A" :tool-error "#DB4B4B"
:separator "#292E42" :accent "#FF9E64" :dim "#444B6A")
:dracula (:user-fg "#FF9580" :user-bg "#1E1F2B" :user-border "#FF6E6E"
:agent-header "#BD93F9" :agent-fg "#F8F8F2"
:system "#8BE9FD"
:input-prompt "#FF9580" :input-fg "#F8F8F2"
:hint "#6272A4"
:status-bg "#191A24" :status-fg "#E0E0E0"
:dot-connected "#50FA7B" :dot-disconnected "#FF5555"
:error "#FF5555"
:tool-running "#FF9580" :tool-done "#50FA7B" :tool-error "#FF5555"
:separator "#34354A" :accent "#FF9580" :dim "#5A5B7A")
:gemini (:user-fg "#87AFFF" :user-bg "#000000" :user-border "#5F5F5F"
:agent-header "#D7AFFF" :agent-fg "#FFFFFF"
:system "#87D7D7"
:input-prompt "#87AFFF" :input-fg "#FFFFFF"
:hint "#AFAFAF"
:status-bg "#1A1A1A" :status-fg "#AFAFAF"
:dot-connected "#D7FFD7" :dot-disconnected "#FF87AF"
:error "#FF87AF"
:tool-running "#87AFFF" :tool-done "#D7FFD7" :tool-error "#FF87AF"
:separator "#3A3A3A" :accent "#87AFFF" :dim "#5F5F5F")
:mono (:user-fg "#E0E0E0" :user-bg "#1A1A1A" :user-border "#808080"
:agent-header "#C0C0C0" :agent-fg "#D0D0D0"
:system "#A0A0A0"
:input-prompt "#FFFFFF" :input-fg "#D0D0D0"
:hint "#606060"
:status-bg "#141414" :status-fg "#B0B0B0"
:dot-connected "#A0A0A0" :dot-disconnected "#808080"
:error "#808080"
:tool-running "#E0E0E0" :tool-done "#A0A0A0" :tool-error "#808080"
:separator "#303030" :accent "#FFFFFF" :dim "#505050"))
"13 warm theme presets (amber, gold, terracotta, sepia, nord-warm,
monokai-warm, gruvbox-warm, light-amber, catppuccin, tokyonight, dracula,
gemini, mono).")
(defvar *tui-theme-current-name* :amber (defvar *tui-theme-current-name* :amber
"Name of the currently active theme preset.") "Name of the currently active theme preset.")
@@ -294,6 +346,10 @@ All state mutation flows through event handlers in the controller.
(:title "List Skills — Available skills" :value (:action :list-skills) :category :system) (:title "List Skills — Available skills" :value (:action :list-skills) :category :system)
(:title "Help — Show daemon help" :value (:action :help) :category :help)) (:title "Help — Show daemon help" :value (:action :help) :category :help))
"Daemon commands for the command palette (Ctrl+P).") "Daemon commands for the command palette (Ctrl+P).")
(defun all-commands ()
"Merge slash commands and daemon commands into one unified list."
(append *slash-commands* *daemon-commands*))
#+END_SRC #+END_SRC
** Event Queue ** Event Queue