Files
passepartout/org/channel-tui-state.org
Amr Gharbeia 25da9ae685 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)
2026-05-14 19:36:29 -04:00

19 KiB

Passepartout TUI — Model

Model

The TUI state is a single plist accessed via st / (setf st). All state mutation flows through event handlers in the controller.

Contract

  1. (init-state): returns a fresh state plist with :msgs list, :input buffer, :dirty flag, :busy flag, and :connection status.
  2. (add-msg role content &key gate-trace): appends a message object to the :messages vector (v0.3.3), tagged with timestamp, role, and optional gate-trace from the daemon (v0.4.0).
  3. (queue-event ev): thread-safely enqueues an event for the reader loop. (drain-queue) returns and clears the queue.

Package + State

(defpackage :passepartout.channel-tui
  (:use :cl :passepartout :usocket :bordeaux-threads)
  (:export :tui-main :st :add-msg :now :input-string
           :queue-event :drain-queue :init-state
           :view-status :view-chat :view-input :redraw
           :on-key :on-daemon-msg :send-daemon
           :connect-daemon :disconnect-daemon
           :*tui-theme* :theme-color))
(in-package :passepartout.channel-tui)

(defvar *state* nil)
(defvar *event-queue* nil)
(defvar *event-lock* (bt:make-lock "tui-event-lock"))

(defvar *tui-theme*
  '(:user-fg "#FFB347"      :user-bg "#3A2A1A"      :user-border "#CC8800"
    :agent-header "#D4956A" :agent-fg "#E8D5B7"
    :system "#C8A87C"
    :input-prompt "#FF8C42" :input-fg "#E8D5B7"
    :hint "#A08060"
    :status-bg "#2A1F1A"    :status-fg "#D4A574"
    :dot-connected "#7CCC6C" :dot-disconnected "#E2584A"
    :error "#E2584A"
    :tool-running "#FF8C42" :tool-done "#7CCC6C"     :tool-error "#E2584A"
    :separator "#4A3A2A"    :accent "#FFB347"        :dim "#8B7355")
  "Warm amber/gold color theme. 20 semantic keys → hex color strings.")

(defvar *tui-theme-presets*
  '(:amber  (:user-fg "#FFB347" :user-bg "#3A2A1A" :user-border "#CC8800"
             :agent-header "#D4956A" :agent-fg "#E8D5B7"
             :system "#C8A87C"
             :input-prompt "#FF8C42" :input-fg "#E8D5B7"
             :hint "#A08060"
             :status-bg "#2A1F1A" :status-fg "#D4A574"
             :dot-connected "#7CCC6C" :dot-disconnected "#E2584A"
             :error "#E2584A"
             :tool-running "#FF8C42" :tool-done "#7CCC6C" :tool-error "#E2584A"
             :separator "#4A3A2A" :accent "#FFB347" :dim "#8B7355")
    :gold (:user-fg "#FFD700" :user-bg "#3A3020" :user-border "#DAA520"
           :agent-header "#D4A574" :agent-fg "#F0E6D0"
           :system "#C8A87C"
           :input-prompt "#FFA500" :input-fg "#F0E6D0"
           :hint "#A08060"
           :status-bg "#2A1F1A" :status-fg "#DAA520"
           :dot-connected "#7CCC6C" :dot-disconnected "#E2584A"
           :error "#E2584A"
           :tool-running "#FFA500" :tool-done "#7CCC6C" :tool-error "#E2584A"
           :separator "#4A3A2A" :accent "#FFD700" :dim "#8B7355")
    :terracotta (:user-fg "#E87A5D" :user-bg "#2D1C15" :user-border "#C0684A"
                 :agent-header "#D4956A" :agent-fg "#E0C8B0"
                 :system "#A08060"
                 :input-prompt "#E87A5D" :input-fg "#E0C8B0"
                 :hint "#8B6F5E"
                 :status-bg "#1F1410" :status-fg "#D4956A"
                 :dot-connected "#6CB85C" :dot-disconnected "#D94A3A"
                 :error "#D94A3A"
                 :tool-running "#E87A5D" :tool-done "#6CB85C" :tool-error "#D94A3A"
                 :separator "#3A2820" :accent "#E87A5D" :dim "#7A6050")
    :sepia (:user-fg "#C4A882" :user-bg "#2A2218" :user-border "#A08860"
            :agent-header "#B89870" :agent-fg "#D4C4A8"
            :system "#9A8A6A"
            :input-prompt "#C4A882" :input-fg "#D4C4A8"
            :hint "#8A7A5E"
            :status-bg "#1E1810" :status-fg "#B89870"
            :dot-connected "#7AAC5C" :dot-disconnected "#C84A3A"
            :error "#C84A3A"
            :tool-running "#C4A882" :tool-done "#7AAC5C" :tool-error "#C84A3A"
            :separator "#3A3020" :accent "#C4A882" :dim "#7A6A50")
    :nord-warm (:user-fg "#D4A574" :user-bg "#2A2220" :user-border "#B8885A"
                :agent-header "#C49870" :agent-fg "#E0D0C0"
                :system "#A89080"
                :input-prompt "#D08770" :input-fg "#E0D0C0"
                :hint "#908070"
                :status-bg "#1E1A18" :status-fg "#C8A080"
                :dot-connected "#7CB860" :dot-disconnected "#D06050"
                :error "#D06050"
                :tool-running "#D08770" :tool-done "#7CB860" :tool-error "#D06050"
                :separator "#3A3030" :accent "#D4A574" :dim "#807060")
    :monokai-warm (:user-fg "#E6B87D" :user-bg "#1E1A16" :user-border "#CC9966"
                   :agent-header "#D4A06A" :agent-fg "#D8C8B0"
                   :system "#A89070"
                   :input-prompt "#E6B87D" :input-fg "#D8C8B0"
                   :hint "#8A7A5E"
                   :status-bg "#141210" :status-fg "#CC9966"
                   :dot-connected "#7AB85C" :dot-disconnected "#D94A3A"
                   :error "#D94A3A"
                   :tool-running "#E6B87D" :tool-done "#7AB85C" :tool-error "#D94A3A"
                   :separator "#2E2820" :accent "#E6B87D" :dim "#7A6A50")
    :gruvbox-warm (:user-fg "#D8A657" :user-bg "#1D1A16" :user-border "#B8884A"
                   :agent-header "#C8A070" :agent-fg "#E0C8A8"
                   :system "#A89070"
                   :input-prompt "#D8A657" :input-fg "#E0C8A8"
                   :hint "#8A7A5E"
                   :status-bg "#141210" :status-fg "#C8A070"
                   :dot-connected "#7AB85C" :dot-disconnected "#D94A3A"
                   :error "#D94A3A"
                   :tool-running "#D8A657" :tool-done "#7AB85C" :tool-error "#D94A3A"
                   :separator "#2E2820" :accent "#D8A657" :dim "#7A6A50")
    :light-amber (:user-fg "#CC6600" :user-bg "#FFF5E6" :user-border "#CC8800"
                  :agent-header "#8B6914" :agent-fg "#3A2A1A"
                  :system "#6B5B3E"
                  :input-prompt "#CC6600" :input-fg "#3A2A1A"
                  :hint "#8B7355"
                  :status-bg "#E8D5B7" :status-fg "#3A2A1A"
                  :dot-connected "#2E8B57" :dot-disconnected "#CC3300"
                  :error "#CC3300"
                  :tool-running "#CC6600" :tool-done "#2E8B57" :tool-error "#CC3300"
                   :separator "#C8B898" :accent "#CC6600" :dim "#8B7355")
    :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
  "Name of the currently active theme preset.")

(defun theme-save ()
  "Persist current theme to disk."
  (let ((path (merge-pathnames ".cache/passepartout/theme.lisp"
                               (user-homedir-pathname))))
    (uiop:ensure-all-directories-exist (list path))
    (with-open-file (out path :direction :output :if-exists :supersede :if-does-not-exist :create)
      (format out ";; Passepartout TUI theme — auto-generated~%")
      (format out "(setf passepartout.channel-tui::*tui-theme* '~s)~%" *tui-theme*)
      (format out "(setf passepartout.channel-tui::*tui-theme-current-name* ~s)~%" *tui-theme-current-name*))
    t))

(defun theme-load ()
  "Load persisted theme from disk. Called at startup."
  (let ((path (merge-pathnames ".cache/passepartout/theme.lisp"
                               (user-homedir-pathname))))
    (when (uiop:file-exists-p path)
      (ignore-errors (load path)))))

(defun theme-switch (name)
  "Switch to a named theme preset. Returns the preset name or nil if not found."
  (let* ((key (intern (string-upcase (string name)) :keyword))
         (preset (getf *tui-theme-presets* key)))
    (when preset
      (setf *tui-theme* (copy-list preset)
            *tui-theme-current-name* key)
      (theme-save)
      (setf (st :dirty) (list t t t))
      key)))

(defun theme-color (role)
  "Returns a hex color string for a semantic role, suitable for cl-tty."
  (let ((val (or (getf *tui-theme* role) :white)))
    (cond
      ((stringp val) val)
      (t (case val
           (:green "#00FF00") (:red "#FF0000") (:cyan "#00FFFF")
           (:yellow "#FFFF00") (:magenta "#FF00FF") (:blue "#0000FF")
           (:white "#FFFFFF") (:black "#000000")
           (:bright-black "#666666") (:bright-yellow "#FFD700")
           (t "#FFFFFF"))))))

(defun st (key) (getf *state* key))
(defun (setf st) (val key) (setf (getf *state* key) val))

(defun init-state ()
  (setf *state*
        (list :running t :mode :chat :connected nil :stream nil
              :input-buffer nil :input-history nil :input-hpos 0
              :messages (make-array 16 :adjustable t :fill-pointer 0)
              :scroll-offset 0 :busy nil :cursor-pos 0
              :pending-ctrl-x nil
              :scroll-at-bottom t :scroll-notify nil
              :streaming-text nil :url-buffer nil            ; v0.7.1
              :collapsed-gates nil                          ; v0.7.2
              :search-mode nil :search-query ""           ; v0.7.2
              :search-matches nil :search-match-idx 0
               :sidebar-visible nil                        ; v0.8.0
               :sidebar-width 30                           ; v0.8.0
               :expand-tool-calls nil                      ; v0.8.0
               :mcp-count 0                                ; v0.8.0
               :kill-ring nil                               ; v0.9.0
               :dialog-stack nil                           ; v0.8.0
               :minibuffer-active nil                      ; v0.8.0
                :command-palette-active nil                 ; v0.8.0
                :command-palette-dialog nil                 ; v0.8.0
                :session-cost 0.0                          ; v0.9.0
                :daemon-version nil                        ; filled by handshake
                :dirty (list nil nil nil))))

Sidebar panel definitions

(defvar *sidebar-panels*
  '((:id :gate-trace :title "Gate Trace" :width 28)
    (:id :focus :title "Focus" :width 28)
    (:id :rules :title "Rules" :width 28)
    (:id :context :title "Context" :width 28)
    (:id :cost :title "Cost" :width 28)
    (:id :files :title "Files" :width 28))
  "Sidebar panel definitions for cl-tty slot registrations.")

Helpers

(defun now ()
  (multiple-value-bind (s m h) (get-decoded-time)
    (declare (ignore s))
    (format nil "~2,'0d:~2,'0d" h m)))

(defun input-string ()
  (coerce (reverse (st :input-buffer)) 'string))

(defun input-insert-char (ch)
  "Insert character at cursor position into the input buffer."
  (let* ((buf (st :input-buffer))
         (pos (or (st :cursor-pos) 0))
         (s (coerce (reverse buf) 'string))
         (new (concatenate 'string (subseq s 0 pos) (string ch) (subseq s pos))))
    (setf (st :input-buffer) (reverse (coerce new 'list)))
    (setf (st :cursor-pos) (1+ pos))))

(defun input-delete-char ()
  "Delete character before cursor position (standard backspace)."
  (let* ((buf (st :input-buffer))
         (pos (or (st :cursor-pos) 0)))
    (when (and buf (> pos 0))
      (let* ((s (coerce (reverse buf) 'string))
             (new (concatenate 'string (subseq s 0 (1- pos)) (subseq s pos))))
        (setf (st :input-buffer) (reverse (coerce new 'list)))
        (setf (st :cursor-pos) (1- pos))))))

(defun add-msg (role content &key gate-trace panel)
  (vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace :panel panel) (st :messages))
  ;; v0.7.0: notify when scrolled up and new msg arrives
  (unless (st :scroll-at-bottom)
    (setf (st :scroll-notify) t))
  (setf (st :dirty) (list t t nil)))

Slash Commands

(defvar *slash-commands*
  '((:title "/eval <expr> — Evaluate Lisp"        :value "/eval"   :category :session)
    (:title "/undo — Undo last operation"           :value "/undo"   :category :session)
    (:title "/redo — Redo last operation"           :value "/redo"   :category :session)
    (:title "/reconnect — Re-establish daemon"     :value "/reconnect" :category :session)
    (:title "/quit — Save history and exit"        :value "/quit"   :category :session)
    (:title "/q — Quick quit"                       :value "/q"      :category :session)
    (:title "/why — Show last gate trace"           :value "/why"    :category :memory)
    (:title "/identity — Edit IDENTITY.org"         :value "/identity" :category :memory)
    (:title "/tags — List tag severities"           :value "/tags"   :category :memory)
    (:title "/audit <id> — Inspect memory"          :value "/audit"  :category :memory)
    (:title "/audit verify — Memory integrity"      :value "/audit verify" :category :memory)
    (:title "/rewind <n> — Rewind to snapshot"     :value "/rewind" :category :memory)
    (:title "/sessions — Show memory snapshots"    :value "/sessions" :category :memory)
    (:title "/resume <n> — Resume from snapshot"   :value "/resume" :category :memory)
    (:title "/focus <project> — Set context"        :value "/focus"  :category :system)
    (:title "/scope <scope> — Change scope"         :value "/scope"  :category :system)
    (:title "/unfocus — Pop context"               :value "/unfocus" :category :system)
    (:title "/theme [name] — Show/switch theme"    :value "/theme"  :category :system)
    (:title "/context — Show context summary"      :value "/context" :category :system)
    (:title "/context why <id> — Debug memory"     :value "/context why" :category :system)
    (:title "/context dropped — Estimate pruned"   :value "/context dropped" :category :system)
    (:title "/search <query> — Search messages"    :value "/search" :category :navigation)
    (:title "/help — Show commands"                 :value "/help"   :category :help)
    (:title "/help <topic> — Search manual"         :value "/help <topic>" :category :help))
  "Slash commands for minibuffer select-dialog.")

Daemon Commands

(defvar *daemon-commands*
  '((:title "Status — Daemon health info"          :value (:action :status)         :category :session)
    (:title "Stats — Daemon statistics"              :value (:action :stats)          :category :session)
    (:title "Ping — Daemon reachability"             :value (:action :ping)           :category :session)
    (:title "Memory Snapshot — Capture state"        :value (:action :memory-snapshot) :category :memory)
    (:title "Memory Rebuild — Rebuild indices"       :value (:action :memory-rebuild)  :category :memory)
    (:title "Memory Compact — Optimize storage"      :value (:action :memory-compact)  :category :memory)
    (:title "Reload Config — Reload configuration"   :value (:action :reload-config)   :category :system)
    (:title "Reload Identity — Reload identity file" :value (:action :reload-identity) :category :system)
    (:title "List Skills — Available skills"         :value (:action :list-skills)     :category :system)
    (:title "Help — Show daemon help"                :value (:action :help)           :category :help))
  "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*))

Event Queue

(defun queue-event (ev)
  (bt:with-lock-held (*event-lock*) (push ev *event-queue*)))

(defun drain-queue ()
  (bt:with-lock-held (*event-lock*)
    (let ((evs (nreverse *event-queue*)))
      (setf *event-queue* nil) evs)))