Files
passepartout/org/channel-tui-state.org
Amr Gharbeia ef36854822 cleanup — remove dead markdown code, migrate theme to cl-tty, fix dialog navigation
Phases 1-3 of library/application boundary cleanup:

Phase 1: Remove dead code (150 lines)
- Delete local word-wrap (all callers already used cl-tty.box:word-wrap)
- Delete parse-markdown-spans, render-styled, parse-markdown-blocks,
  syntax-highlight (all unused — view uses cl-tty.markdown directly)
- Replace tests with cl-tty.markdown equivalents

Phase 2: Migrate theme to cl-tty.theme (250 lines removed)
- Replace *tui-theme*/*tui-theme-presets* with *theme* + define-preset
- theme-switch/theme-save/theme-load delegate to cl-tty.theme
- theme-color is now a 3-line wrapper
- Added save-theme/load-theme to cl-tty.theme (38 lines added there)

Phase 3: Fix dialog arrow navigation with select-handle-key
- Replace broken manual key dispatch with cl-tty.dialog:select-handle-key
- The old code had a dead (and ch (graphic-char-p ch)) — the and result
  was discarded, so every unhandled key ran (code-char key-code) against
  the filter unconditionally, inserting garbage on arrow keys
2026-05-20 11:05:21 -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
           :queue-event :drain-queue :init-state
            :view-status :view-chat :view-input :redraw
            :input-panel-top
           :on-key :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)
              :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))))

Helpers

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

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