#+TITLE: Passepartout TUI — Model #+PROPERTY: header-args:lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-state.lisp * 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 #+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-state.lisp (defpackage :passepartout.channel-tui (:use :cl :passepartout :usocket :bordeaux-threads) (:export :tui-main :st :add-msg :now :queue-event :drain-queue :init-state :view-status :view-chat :view-input :redraw :input-panel-top :on-key :process-key-event :input-text :on-daemon-msg :send-daemon :connect-daemon :disconnect-daemon :*theme* :theme-color :theme-switch)) (in-package :passepartout.channel-tui) (defvar *state* nil) (defvar *event-queue* nil) (defvar *event-lock* (bt:make-lock "tui-event-lock")) (defvar *theme* (cl-tty.theme:make-theme) "The active theme instance. Populated by cl-tty.theme:load-preset. Semantic keys (all presets define these): :user-fg, :user-bg, :user-border, :agent-border, :agent-header, :agent-fg, :system, :input-prompt, :input-fg, :hint, :status-bg, :status-fg, :bg, :bg-panel, :bg-element, :bg-input, :text-muted, :dot-connected, :dot-disconnected, :error, :tool-running, :tool-done, :tool-error, :thinking-bg, :symbolic-border, :separator, :accent, :dim.") (cl-tty.theme:define-preset :amber :dark (:user-fg "#fab283" :user-bg "#1e1e1e" :user-border "#fab283" :agent-border "#c0a080" :agent-header "#d4956a" :agent-fg "#e8e8e8" :system "#808080" :input-prompt "#fab283" :input-fg "#e8e8e8" :hint "#606060" :status-bg "#141414" :status-fg "#e8e8e8" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1e1e1e" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#7fd88f" :dot-disconnected "#e06c75" :error "#e06c75" :tool-running "#fab283" :tool-done "#7fd88f" :tool-error "#e06c75" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3c3c3c" :accent "#fab283" :dim "#606060") :light nil) (cl-tty.theme:define-preset :gold :dark (:user-fg "#ffd700" :user-bg "#1e1e1e" :user-border "#ffd700" :agent-border "#c0a080" :agent-header "#d4a574" :agent-fg "#e8e8e8" :system "#808080" :input-prompt "#ffd700" :input-fg "#e8e8e8" :hint "#606060" :status-bg "#141414" :status-fg "#ffd700" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1e1e1e" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#7fd88f" :dot-disconnected "#e06c75" :error "#e06c75" :tool-running "#ffd700" :tool-done "#7fd88f" :tool-error "#e06c75" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3c3c3c" :accent "#ffd700" :dim "#606060") :light nil) (cl-tty.theme:define-preset :terracotta :dark (:user-fg "#e87a5d" :user-bg "#1e1e1e" :user-border "#e87a5d" :agent-border "#c0a080" :agent-header "#d4956a" :agent-fg "#e0c8b0" :system "#808080" :input-prompt "#e87a5d" :input-fg "#e0c8b0" :hint "#606060" :status-bg "#141414" :status-fg "#d4956a" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1e1e1e" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#6cb85c" :dot-disconnected "#d94a3a" :error "#d94a3a" :tool-running "#e87a5d" :tool-done "#6cb85c" :tool-error "#d94a3a" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3c3c3c" :accent "#e87a5d" :dim "#606060") :light nil) (cl-tty.theme:define-preset :sepia :dark (:user-fg "#c4a882" :user-bg "#1e1e1e" :user-border "#c4a882" :agent-border "#c0a080" :agent-header "#b89870" :agent-fg "#d4c4a8" :system "#808080" :input-prompt "#c4a882" :input-fg "#d4c4a8" :hint "#606060" :status-bg "#141414" :status-fg "#b89870" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1e1e1e" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#7aac5c" :dot-disconnected "#c84a3a" :error "#c84a3a" :tool-running "#c4a882" :tool-done "#7aac5c" :tool-error "#c84a3a" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3c3c3c" :accent "#c4a882" :dim "#606060") :light nil) (cl-tty.theme:define-preset :nord-warm :dark (:user-fg "#d4a574" :user-bg "#1e1e1e" :user-border "#d4a574" :agent-border "#c0a080" :agent-header "#c49870" :agent-fg "#e0d0c0" :system "#808080" :input-prompt "#d08770" :input-fg "#e0d0c0" :hint "#606060" :status-bg "#141414" :status-fg "#c8a080" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1e1e1e" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#7cb860" :dot-disconnected "#d06050" :error "#d06050" :tool-running "#d08770" :tool-done "#7cb860" :tool-error "#d06050" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3c3c3c" :accent "#d4a574" :dim "#606060") :light nil) (cl-tty.theme:define-preset :monokai-warm :dark (:user-fg "#e6b87d" :user-bg "#1e1e1e" :user-border "#e6b87d" :agent-border "#c0a080" :agent-header "#d4a06a" :agent-fg "#d8c8b0" :system "#808080" :input-prompt "#e6b87d" :input-fg "#d8c8b0" :hint "#606060" :status-bg "#141414" :status-fg "#cc9966" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1e1e1e" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#7ab85c" :dot-disconnected "#d94a3a" :error "#d94a3a" :tool-running "#e6b87d" :tool-done "#7ab85c" :tool-error "#d94a3a" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3c3c3c" :accent "#e6b87d" :dim "#606060") :light nil) (cl-tty.theme:define-preset :gruvbox-warm :dark (:user-fg "#d8a657" :user-bg "#1e1e1e" :user-border "#d8a657" :agent-border "#c0a080" :agent-header "#c8a070" :agent-fg "#e0c8a8" :system "#808080" :input-prompt "#d8a657" :input-fg "#e0c8a8" :hint "#606060" :status-bg "#141414" :status-fg "#c8a070" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1e1e1e" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#7ab85c" :dot-disconnected "#d94a3a" :error "#d94a3a" :tool-running "#d8a657" :tool-done "#7ab85c" :tool-error "#d94a3a" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3c3c3c" :accent "#d8a657" :dim "#606060") :light nil) (cl-tty.theme:define-preset :light-amber :dark (:user-fg "#d4a574" :user-bg "#f5f0eb" :user-border "#c4956a" :agent-border "#c0a090" :agent-header "#b88050" :agent-fg "#3a3a3a" :system "#606060" :input-prompt "#c4956a" :input-fg "#3a3a3a" :hint "#a0a0a0" :status-bg "#e8e0d8" :status-fg "#5a5a5a" :bg "#f5f0eb" :bg-panel "#e8e0d8" :bg-element "#f0ebe5" :bg-input "#ffffff" :text-muted "#909090" :dot-connected "#6cb85c" :dot-disconnected "#c84a3a" :error "#c84a3a" :tool-running "#c4956a" :tool-done "#6cb85c" :tool-error "#c84a3a" :thinking-bg "#e8e0d8" :symbolic-border "#a09080" :separator "#d0c8c0" :accent "#b88050" :dim "#a0a0a0") :light nil) (cl-tty.theme:define-preset :catppuccin :dark (:user-fg "#fab387" :user-bg "#1e1e2e" :user-border "#fab387" :agent-border "#a6adc8" :agent-header "#cba6f7" :agent-fg "#cdd6f4" :system "#808080" :input-prompt "#fab387" :input-fg "#cdd6f4" :hint "#6c7086" :status-bg "#181825" :status-fg "#bac2de" :bg "#11111b" :bg-panel "#181825" :bg-element "#1e1e2e" :bg-input "#2e2e2e" :text-muted "#6c7086" :dot-connected "#a6e3a1" :dot-disconnected "#f38ba8" :error "#f38ba8" :tool-running "#fab387" :tool-done "#a6e3a1" :tool-error "#f38ba8" :thinking-bg "#363a4f" :symbolic-border "#6c7086" :separator "#313244" :accent "#fab387" :dim "#585b70") :light nil) (cl-tty.theme:define-preset :tokyonight :dark (:user-fg "#ff9e64" :user-bg "#1a1b26" :user-border "#ff9e64" :agent-border "#7982a8" :agent-header "#7aa2f7" :agent-fg "#a9b1d6" :system "#808080" :input-prompt "#ff9e64" :input-fg "#a9b1d6" :hint "#565f89" :status-bg "#16161e" :status-fg "#9aa5ce" :bg "#0f0f18" :bg-panel "#16161e" :bg-element "#1a1b26" :bg-input "#2e2e2e" :text-muted "#565f89" :dot-connected "#9ece6a" :dot-disconnected "#db4b4b" :error "#db4b4b" :tool-running "#ff9e64" :tool-done "#9ece6a" :tool-error "#db4b4b" :thinking-bg "#363b54" :symbolic-border "#565f89" :separator "#292e42" :accent "#ff9e64" :dim "#444b6a") :light nil) (cl-tty.theme:define-preset :dracula :dark (:user-fg "#ff9580" :user-bg "#1e1f2b" :user-border "#ff9580" :agent-border "#c0c0e0" :agent-header "#bd93f9" :agent-fg "#f8f8f2" :system "#808080" :input-prompt "#ff9580" :input-fg "#f8f8f2" :hint "#6272a4" :status-bg "#191a24" :status-fg "#e0e0e0" :bg "#0f101a" :bg-panel "#191a24" :bg-element "#1e1f2b" :bg-input "#2e2e2e" :text-muted "#6272a4" :dot-connected "#50fa7b" :dot-disconnected "#ff5555" :error "#ff5555" :tool-running "#ff9580" :tool-done "#50fa7b" :tool-error "#ff5555" :thinking-bg "#3a3b50" :symbolic-border "#6272a4" :separator "#34354a" :accent "#ff9580" :dim "#5a5b7a") :light nil) (cl-tty.theme:define-preset :gemini :dark (:user-fg "#87afff" :user-bg "#1a1a1a" :user-border "#87afff" :agent-border "#d0d0d0" :agent-header "#d7afff" :agent-fg "#ffffff" :system "#808080" :input-prompt "#87afff" :input-fg "#ffffff" :hint "#606060" :status-bg "#141414" :status-fg "#afafaf" :bg "#000000" :bg-panel "#141414" :bg-element "#1a1a1a" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#d7ffd7" :dot-disconnected "#ff87af" :error "#ff87af" :tool-running "#87afff" :tool-done "#d7ffd7" :tool-error "#ff87af" :thinking-bg "#3a3a3a" :symbolic-border "#707070" :separator "#3a3a3a" :accent "#87afff" :dim "#5f5f5f") :light nil) (cl-tty.theme:define-preset :mono :dark (:user-fg "#e0e0e0" :user-bg "#1a1a1a" :user-border "#808080" :agent-border "#a0a0a0" :agent-header "#c0c0c0" :agent-fg "#d0d0d0" :system "#808080" :input-prompt "#ffffff" :input-fg "#d0d0d0" :hint "#606060" :status-bg "#141414" :status-fg "#b0b0b0" :bg "#0a0a0a" :bg-panel "#141414" :bg-element "#1a1a1a" :bg-input "#2e2e2e" :text-muted "#808080" :dot-connected "#a0a0a0" :dot-disconnected "#808080" :error "#808080" :tool-running "#e0e0e0" :tool-done "#a0a0a0" :tool-error "#808080" :thinking-bg "#3a3a3a" :symbolic-border "#808080" :separator "#303030" :accent "#ffffff" :dim "#505050") :light nil) ;; Load default theme at startup (cl-tty.theme:load-preset *theme* :amber) (defun theme-save () "Persist current theme to disk." (let ((path (merge-pathnames ".cache/passepartout/theme.lisp" (user-homedir-pathname)))) (ensure-directories-exist path) (cl-tty.theme:save-theme *theme* path))) (defun theme-load () "Load persisted theme from disk. Called at startup." (let ((path (merge-pathnames ".cache/passepartout/theme.lisp" (user-homedir-pathname)))) (unless (cl-tty.theme:load-theme *theme* path) (cl-tty.theme:load-preset *theme* :amber)))) (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))) (cl-tty.theme:load-preset *theme* key) (theme-save) (setf (st :dirty) (list t t t)) key)) (defun theme-color (role) "Returns a hex color string for a semantic role via cl-tty.theme." (or (cl-tty.theme:theme-color *theme* role) "#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-history nil :input-hpos 0 :text-input (cl-tty.input:make-text-input :on-submit #'handle-submit :on-cancel #'handle-cancel :on-tab #'handle-tab :on-history #'handle-history) :messages (make-array 16 :adjustable t :fill-pointer 0) :scroll-offset 0 :busy nil :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-mode :auto ; v0.8.0: :auto/:visible/:hidden :sidebar-width 42 ; 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)))) #+END_SRC ** Helpers #+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-state.lisp (defun now () (multiple-value-bind (s m h) (get-decoded-time) (declare (ignore s)) (format nil "~2,'0d:~2,'0d" h m))) (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))) #+END_SRC ** Slash Commands #+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-state.lisp (defvar *slash-commands* '((:title "/eval — Evaluate Lisp" :value "/eval") (:title "/undo — Undo last operation" :value "/undo") (:title "/redo — Redo last operation" :value "/redo") (:title "/reconnect — Re-establish daemon" :value "/reconnect") (:title "/quit — Save history and exit" :value "/quit") (:title "/q — Quick quit" :value "/q") (:title "/why — Show last gate trace" :value "/why") (:title "/tags — List tag severities" :value "/tags") (:title "/audit — Inspect memory" :value "/audit") (:title "/audit verify — Memory integrity" :value "/audit verify") (:title "/rewind — Rewind to snapshot" :value "/rewind") (:title "/sessions — Show memory snapshots" :value "/sessions") (:title "/resume — Resume from snapshot" :value "/resume") (:title "/theme [name] — Show/switch theme" :value "/theme") (:title "/context — Show context summary" :value "/context") (:title "/search — Search messages" :value "/search") (:title "/help — Show commands" :value "/help") (:title "/help — Search manual" :value "/help ")) "Slash commands for minibuffer select-dialog.") (defvar *daemon-commands* '((:title "Status — Daemon health info" :value (:action :status)) (:title "Stats — Daemon statistics" :value (:action :stats)) (:title "Ping — Daemon reachability" :value (:action :ping)) (:title "Test Provider — Check connection" :value (:action :provider-test)) (:title "Discover Models — List available" :value (:action :provider-models)) (:title "Memory Snapshot — Capture state" :value (:action :memory-snapshot)) (:title "Memory Rebuild — Rebuild indices" :value (:action :memory-rebuild)) (:title "Memory Compact — Optimize storage" :value (:action :memory-compact)) (:title "Reload Config — Reload configuration" :value (:action :reload-config)) (:title "Reload Identity — Reload identity file" :value (:action :reload-identity)) (:title "List Skills — Available skills" :value (:action :list-skills)) (:title "Help — Show daemon help" :value (:action :help))) "Daemon commands for the command palette (Ctrl+P).") (defun all-commands () "Merge slash commands, daemon commands, and menu entries into one unified list." (append *menu-entries* *slash-commands* *daemon-commands*)) (defvar *menu-entries* '((:title "/config — LLM providers, cascade, network, folders, identity" :value :config-menu :action passepartout.channel-tui::show-config-main-menu)) "Special menu entries with actions (open submenus).") #+END_SRC ** Event Queue #+BEGIN_SRC lisp :tangle /home/user/.local/share/passepartout/lisp/channel-tui-state.lisp (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))) #+END_SRC