Files
passepartout/org/channel-tui-state.org
Amr Gharbeia 2d18fa4525 docs: port TUI roadmap to cl-tty, mark Emacs as secondary client
v0.8.0: Information Radiator now built on cl-tty v1.1.0. Minibuffer
uses cl-tty Dialog stack. New TODO items: conversation view (ScrollBox
+ Markdown), command palette (Select), sidebar (slot system), status bar
(Box + Theme), keybindings (keymap).

v0.9.1: Emacs is now an optional secondary client, not the primary
bridge. cl-tty is the primary TUI.
2026-05-13 11:41:41 -04:00

16 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.

v0.8.0 — Information Radiator: Sidebar State

The sidebar is Passepartout's permanent UX differentiator — a 42-column information panel that renders architectural data no competitor can display because none has deterministic gates, foveal-peripheral context, or rule-synthesizing Dispatcher to feed it. The sidebar makes the invisible visible: seven panels of zero-LLM-token data from the deterministic layer, always on screen when terminal width permits.

The sidebar reads its data from daemon response fields enriched by the :tui actuator in core-act.org. All seven panels consume existing infrastructure: gate trace from cognitive-verify (v0.4.0), focus from *loop-focus-id* (v0.3.0), rules from *hitl-pending* (v0.3.0), context from token-economics (v0.5.0), files from tool execution tracking (v0.8.0 new), cost from cost-tracker (v0.5.0), and block counts from the Dispatcher (v0.8.0 new). Each field arrives as a daemon-response plist key; the TUI stores them in state fields read by view-sidebar.

When the terminal is narrower than 120 columns, the sidebar collapses to an overlay toggled via /sidebar or Ctrl+X+B. This preserves the information radiator on constrained displays without sacrificing chat area real estate.

State additions: :sidebar-visible (boolean), :block-counts (alist), :context-usage (integer 0-100), :modified-files (list of plists), :session-cost (plist).

v0.8.0 — TrueColor Theme System

The existing theme system uses Croatoan's standard 8-color palette (cyan, green, red, white, etc.). v0.8.0 upgrades to 24-bit TrueColor via Croatoan's set-rgb / init-color primitives, enabling hex-specified colors (#5E81AC, #BF616A, etc.) on supporting terminals (iTerm2, Kitty, WezTerm, Windows Terminal, Ghostty).

The upgrade is backward compatible: terminals without TrueColor fall back to the nearest standard color. Hex values are parsed by theme-hex-to-rgb (one-line format string → integer triple) and registered once at theme-switch time via theme-init-truecolor. Subsequent theme-color lookups return the Croatoan color ID, same API as the 8-color system.

Four new presets join the existing four (dark, light, solarized, gruvbox):

  • :nord — blue-gray backgrounds, frost accent
  • :tokyonight — purple-blue backgrounds, teal accent
  • :catppuccin — warm pastels, mauve accent
  • :monokai — dark brown backgrounds, orange accent

Each preset defines 27 hex color values, one per semantic key in *tui-theme*. The 27 keys are: roles (user, agent, system), content (input, timestamp, help, error, warning), status (connected, disconnected, busy, idle), gate trace (passed, blocked, approval, hitl), tools (running, success, failure, output), display (scroll-indicator, border, background), differentiator (rule-count, focus-map), and UI (dim, highlight, accent).

An audit ensures every key from *tui-theme* is consumed by at least one rendering function in channel-tui-view.org. Missing keys become invisible theme presets — defined but unused.

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.
  4. (theme-hex-to-rgb hex-string): parses "#RRGGBB" to (values r g b) integers 0-255. Returns (values 255 255 255) for unparseable input (v0.8.0).
  5. (theme-init-truecolor): registers hex color values from *tui-theme* with Croatoan's init-color / set-rgb. No-op on terminals without TrueColor support (v0.8.0).
  6. (theme-color key): extended contract (v0.8.0): if the *tui-theme* entry for key is a hex string, returns the Croatoan color ID registered by theme-init-truecolor. Falls back to keyword lookup for non-hex entries and non-TrueColor terminals.
  7. (sidebar-toggle): toggles :sidebar-visible state. Sets dirty flags to force sidebar redraw (v0.8.0).

Package + State

(defpackage :passepartout.channel-tui
  (:use :cl :croatoan :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
           :*slash-commands* :open-minibuffer :minibuffer-handle-key
           :view-conversation :render-user-msg :render-agent-msg
           :render-sys-msg :render-tool-call :render-gate-trace))
(in-package :passepartout.channel-tui)

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

(defvar *tui-theme*
  ;; Roles
  '(:user :green :agent :white :system :yellow
    ;; Content
    :input :cyan :timestamp :yellow :help :cyan :error :red :warning :yellow
    ;; Status
    :connected :green :disconnected :red :busy :magenta :idle :white
    ;; Gate trace
    :gate-passed :green :gate-blocked :red :gate-approval :yellow
    :hitl :magenta
    ;; Tools (future use)
    :tool-running :magenta :tool-success :green :tool-failure :red :tool-output :white
    ;; Display
    :scroll-indicator :cyan :border :white :background :black
    ;; Differentiator (v0.4.0)
    :rule-count :cyan :focus-map :yellow
    ;; UI
    :dim :white :highlight :cyan :accent :green)
  "Color theme plist. 27 semantic keys → Croatoan color values.
See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")

(defvar *tui-theme-presets*
  '(:dark  (:user :green :agent :white :system :yellow
            :input :cyan :timestamp :yellow :help :cyan :error :red :warning :yellow
            :connected :green :disconnected :red :busy :magenta :idle :white
            :gate-passed :green :gate-blocked :red :gate-approval :yellow
            :tool-running :magenta :tool-success :green :tool-failure :red :tool-output :white
            :scroll-indicator :cyan :border :white :background :black
            :rule-count :cyan :focus-map :yellow
            :dim :white :highlight :cyan :accent :green)
    :light (:user :blue :agent :black :system :red
            :input :black :timestamp :yellow :help :blue :error :red :warning :yellow
            :connected :green :disconnected :red :busy :magenta :idle :black
            :gate-passed :green :gate-blocked :red :gate-approval :yellow
            :tool-running :magenta :tool-success :green :tool-failure :red :tool-output :black
            :scroll-indicator :blue :border :black :background :white
            :rule-count :blue :focus-map :red
            :dim :white :highlight :blue :accent :green)
    :gruvbox (:user "#458588" :agent "#ebdbb2" :system "#fabd2f"
              :input "#ebdbb2" :timestamp "#928374" :help "#83a598" :error "#fb4934" :warning "#fabd2f"
              :connected "#b8bb26" :disconnected "#fb4934" :busy "#d3869b" :idle "#a89984"
              :gate-passed "#b8bb26" :gate-blocked "#fb4934" :gate-approval "#fabd2f"
              :tool-running "#d3869b" :tool-success "#b8bb26" :tool-failure "#fb4934" :tool-output "#ebdbb2"
              :scroll-indicator "#83a598" :border "#a89984" :background "#282828"
              :rule-count "#83a598" :focus-map "#fabd2f"
              :dim "#928374" :highlight "#83a598" :accent "#b8bb26")
    :solarized (:user "#268bd2" :agent "#839496" :system "#b58900"
                :input "#839496" :timestamp "#93a1a1" :help "#2aa198" :error "#dc322f" :warning "#b58900"
                :connected "#859900" :disconnected "#dc322f" :busy "#d33682" :idle "#657b83"
                :gate-passed "#859900" :gate-blocked "#dc322f" :gate-approval "#b58900"
                :tool-running "#d33682" :tool-success "#859900" :tool-failure "#dc322f" :tool-output "#839496"
                :scroll-indicator "#2aa198" :border "#657b83" :background "#002b36"
                :rule-count "#2aa198" :focus-map "#b58900"
                :dim "#586e75" :highlight "#2aa198" :accent "#859900")
    :nord (:user "#81a1c1" :agent "#d8dee9" :system "#ebcb8b"
           :input "#d8dee9" :timestamp "#4c566a" :help "#88c0d0" :error "#bf616a" :warning "#ebcb8b"
           :connected "#a3be8c" :disconnected "#bf616a" :busy "#b48ead" :idle "#616e88"
           :gate-passed "#a3be8c" :gate-blocked "#bf616a" :gate-approval "#ebcb8b"
           :hitl "#b48ead"
           :tool-running "#b48ead" :tool-success "#a3be8c" :tool-failure "#bf616a" :tool-output "#d8dee9"
           :scroll-indicator "#88c0d0" :border "#4c566a" :background "#2e3440"
           :rule-count "#88c0d0" :focus-map "#ebcb8b"
           :dim "#616e88" :highlight "#88c0d0" :accent "#5e81ac")
    :tokyonight (:user "#7aa2f7" :agent "#c0caf5" :system "#e0af68"
                 :input "#c0caf5" :timestamp "#565f89" :help "#7dcfff" :error "#f7768e" :warning "#e0af68"
                 :connected "#9ece6a" :disconnected "#f7768e" :busy "#bb9af7" :idle "#565f89"
                 :gate-passed "#9ece6a" :gate-blocked "#f7768e" :gate-approval "#e0af68"
                 :hitl "#bb9af7"
                 :tool-running "#bb9af7" :tool-success "#9ece6a" :tool-failure "#f7768e" :tool-output "#c0caf5"
                 :scroll-indicator "#7dcfff" :border "#1f2335" :background "#1a1b26"
                 :rule-count "#7dcfff" :focus-map "#e0af68"
                 :dim "#565f89" :highlight "#7dcfff" :accent "#7aa2f7")
    :catppuccin (:user "#89b4fa" :agent "#cdd6f4" :system "#f9e2af"
                 :input "#cdd6f4" :timestamp "#585b70" :help "#94e2d5" :error "#f38ba8" :warning "#f9e2af"
                 :connected "#a6e3a1" :disconnected "#f38ba8" :busy "#cba6f7" :idle "#6c7086"
                 :gate-passed "#a6e3a1" :gate-blocked "#f38ba8" :gate-approval "#f9e2af"
                 :hitl "#cba6f7"
                 :tool-running "#cba6f7" :tool-success "#a6e3a1" :tool-failure "#f38ba8" :tool-output "#cdd6f4"
                 :scroll-indicator "#94e2d5" :border "#45475a" :background "#1e1e2e"
                 :rule-count "#94e2d5" :focus-map "#f9e2af"
                 :dim "#6c7086" :highlight "#94e2d5" :accent "#89b4fa")
    :monokai (:user "#a6e22e" :agent "#f8f8f2" :system "#e6db74"
              :input "#f8f8f2" :timestamp "#75715e" :help "#66d9ef" :error "#f92672" :warning "#e6db74"
              :connected "#a6e22e" :disconnected "#f92672" :busy "#ae81ff" :idle "#75715e"
              :gate-passed "#a6e22e" :gate-blocked "#f92672" :gate-approval "#e6db74"
              :hitl "#ae81ff"
              :tool-running "#ae81ff" :tool-success "#a6e22e" :tool-failure "#f92672" :tool-output "#f8f8f2"
              :scroll-indicator "#66d9ef" :border "#49483e" :background "#272822"
              :rule-count "#66d9ef" :focus-map "#e6db74"
              :dim "#75715e" :highlight "#66d9ef" :accent "#a6e22e"))
  "Named theme presets. /theme <name> loads one into *tui-theme*.")

(defvar *tui-theme-current-name* :dark
  "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 the Croatoan color for a semantic role.
   Keyword or hex string values are returned as-is; hex strings are
   converted to integers that Croatoan can process."
  (let ((val (or (getf *tui-theme* role) :white)))
    (if (and (stringp val) (> (length val) 0) (eql (char val 0) #\#))
        (handler-case (parse-integer (subseq val 1) :radix 16)
          (error () val))
        val)))

;; v0.8.0: TrueColor helpers
(defun theme-hex-to-rgb (hex-string)
  "Parse #RRGGBB to (values r g b). Returns (255 255 255) for invalid input."
  (if (and (stringp hex-string) (= 7 (length hex-string)) (eql (char hex-string 0) #\#))
      (handler-case
          (let ((r (parse-integer (subseq hex-string 1 3) :radix 16))
                (g (parse-integer (subseq hex-string 3 5) :radix 16))
                (b (parse-integer (subseq hex-string 5 7) :radix 16)))
            (values r g b))
        (error () (values 255 255 255)))
      (values 255 255 255)))

(defun theme-init-truecolor ()
  "Register hex colors from *tui-theme* with Croatoan's init-color."
  (handler-case
      (loop for (key val) on *tui-theme* by #'cddr
            when (and (stringp val) (= 7 (length val)) (eql (char val 0) #\#))
            do (multiple-value-bind (r g b) (theme-hex-to-rgb val)
                 (init-color key (/ r 255.0) (/ g 255.0) (/ b 255.0))))
    (error () nil)))

(defun sidebar-toggle ()
  "Toggle sidebar visibility. Sets dirty flags for full redraw."
  (setf (st :sidebar-visible) (not (st :sidebar-visible)))
  (setf (st :dirty) (list t t t)))

(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
              :expand-tool-calls nil                      ; v0.8.0
              :dirty (list nil nil nil))))

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)))

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)))