wip: unified minibuffer panel, v0.9.1 Emacs dev env in ROADMAP

- Unified minibuffer slash-command panel (panel-based wizard, settings,
  help sub-mode stack) — channel-tui state/view changes
- ROADMAP: v0.8.0 broken into atomic DONE items, v0.9.1 added with
  Emacs major mode + M-x command surface TODOs
- Semver discipline from v0.7.1 onward (X.Y.Z)
This commit is contained in:
2026-05-13 09:17:30 -04:00
parent a8901d9675
commit 00211cf685
5 changed files with 485 additions and 231 deletions

View File

@@ -34,11 +34,32 @@ On release:
2. Extract DONE items from ROADMAP (all items with LOGBOOK timestamps since the last release tag) and use as the release notes body 2. Extract DONE items from ROADMAP (all items with LOGBOOK timestamps since the last release tag) and use as the release notes body
3. If a ~CHANGELOG.md~ is needed for packaging tools, auto-generate it from ROADMAP DONE items 3. If a ~CHANGELOG.md~ is needed for packaging tools, auto-generate it from ROADMAP DONE items
** DONE v0.8.0: Information Radiator (Foundation) ** TODO v0.8.0: Information Radiator (Foundation)
Sidebar (6 panels), sidebar overlay mode (<120 cols), command palette (Ctrl+P), TrueColor theme expansion (8 presets). Sidebar (6 panels), sidebar overlay mode (<120 cols), command palette (Ctrl+P), TrueColor theme expansion (8 presets), unified minibuffer panel with slash-command context menu and sub-mode navigation (wizard, settings, help).
For the full DONE items, see ~CHANGELOG.org~. *** DONE Unified minibuffer slash-command panel
:PROPERTIES:
:ID: id-v080-minibuffer
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-10 Sat]
:END:
Replace ad-hoc overlay windows with a single bottom-anchored panel. Typing =/= as the first character opens a command context menu (~25 slash commands, filtered in real time as the user types). Navigating to =/wizard= and pressing Enter transitions the panel into the setup wizard — same panel, same position, sub-mode stack. Esc returns to the command list. Future sub-modes (=/settings=, =/help=) slot into the same architecture.
- Add ~:minibuffer-mode~ and ~:minibuffer-selected-idx~ state fields to ~init-state~
- Extract ~*slash-commands*~ data structure (~25 commands, each with description) from the ~on-key~ Enter handler
- Add ~view-minibuffer~ that dispatches on ~:minibuffer-mode~ to ~view-slash-menu~, ~view-wizard-in-panel~
- Add ~minibuffer-handle-key~ which dispatches to ~slash-menu-handle-key~ or ~wizard-handle-key~
- TUI event loop: replace separate wizard key handlers with unified modal dispatch block
- ~on-key~: auto-open slash-menu when =/= typed as first character
- ~wizard-start~ / ~wizard-cancel~: set ~:minibuffer-mode~ instead of ~:wizard-visible~
- Merge the wizard overlay (centered, 60x14) into the bottom-anchored panel
- Remove ~:wizard-visible~ state field
~150 lines.
** v0.9.0: Eval Harness — Safety Net First ** v0.9.0: Eval Harness — Safety Net First
@@ -58,6 +79,74 @@ Every subsequent release ships with automated regression protection. The eval ha
- Task suite grows with codebase: every bug fix adds a regression task - Task suite grows with codebase: every bug fix adds a regression task
~200 lines. ~200 lines.
** v0.9.1: Emacs Development Environment — A Functional UI
The croatoan TUI is on life support — enough to see output and type commands, but every render feature (markdown, tool visualization, mouse, adaptive layout) requires custom ncurses code destined for the trash at v2.0.0. Emacs is the v2.0.0 bridge: the same major mode, sidebar, and M-x commands survive from now through Phase III.
*** TODO Emacs major mode
:PROPERTIES:
:ID: id-v091-emacs-mode
:CREATED: [2026-05-10 Sat]
:END:
- ~passepartout-mode~ major mode for the conversation buffer
- Message rendering as Org headlines: role prefix (~:user:~, ~:agent:~, ~:system:~), universal timestamp, content in body. Gate trace as property drawer under agent message headlines
- Streaming insertion: LLM response chunks arrive from the daemon and insert incrementally into the buffer (~insert~ as chunks arrive, update on each frame)
- Read-only protection on agent response regions (editable regions only in user input area)
- Keybindings: ~C-c C-c~ send current input, ~C-c C-k~ interrupt/stop, ~C-c C-a~ approve HITL, ~C-c C-d~ deny HITL
- ~100 lines elisp
*** TODO M-x command surface
:PROPERTIES:
:ID: id-v091-emacs-commands
:CREATED: [2026-05-10 Sat]
:END:
Replace croatoan's ~/~ slash-command panel with Emacs ~M-x~ dispatch. Each command is a ~defun passeparate-<action>~ with interactive completion:
- ~passepartout-focus~ — completing-read over node IDs from the daemon's focus response. Sets the foveal context
- ~passepartout-eval~ — eval a Lisp form in the daemon. Read from minibuffer, send as framed plist, insert result
- ~passepartout-theme~ — completing-read over available themes. Sends ~/theme <name>~ to daemon
- ~passepartout-export~ — export session. Prefix arg selects format (~C-u M-x passeparate-export~)
- ~passepartout-sidebar~ — toggle the sidebar buffer visibility
- ~passepartout-config~ — completing-read over config keys. Sets env var, triggers daemon reload
- ~passepartout-coach~ — run self-diagnosis. Inserts coaching report as new message
- ~passepartout-agenda~ — run Org agenda query. Inserts results as new message
- ~passepartout-quit~ — close connection and kill buffer
Each command is a thin wrapper around ~passepartout-send~ (the existing TCP bridge from v0.4.0): construct the correct plist, send it, and insert the response. Completing-read, history, and docstrings are free. ~80 lines elisp.
*** TODO Sidebar buffer
:PROPERTIES:
:ID: id-v091-emacs-sidebar
:CREATED: [2026-05-10 Sat]
:END:
- ~passepartout-sidebar-mode~ — a dedicated side window (right, 42 chars) that updates on each daemon response
- Gate Trace panel — per-gate results from the most recent agent response. Colored by state (green/yellow/red)
- Focus panel — current foveal node ID + related node count
- Rules panel — rule counter with session delta. When symbolic engine is active, shows sufficiency score and provenance breakdown
- Context panel — token gauge (percentage bar + color coding)
- Cost panel — session cost updated after each LLM call
- Files panel — modified files list with +/- line counts
- All data already exists in the daemon's response plist (~:rule-count~, ~:foveal-id~, ~:gate-trace~, ~:sufficiency-ratio~). The sidebar is a formatting layer
- Toggle via ~M-x passeparate-sidebar~ or ~C-c C-s~
- ~60 lines elisp
*** TODO Daemon lifecycle
:PROPERTIES:
:ID: id-v091-emacs-lifecycle
:CREATED: [2026-05-10 Sat]
:END:
- ~passepartout-start~ — runs ~passepartout daemon~ in a background process, waits for port 9105, connects
- ~passepartout-stop~ — sends shutdown signal, kills buffer
- ~passepartout-restart~ — stop + start
- ~passepartout-status~ — checks daemon health via ~/status~ command, displays in sidebar
- ~20 lines elisp + bash wrappers
Total: ~260 lines elisp, persisting through v2.0.0+.
** v0.10.0: Phase 0 — Type-Level Gates + Core Integrity (~75 lines) ** v0.10.0: Phase 0 — Type-Level Gates + Core Integrity (~75 lines)
:PROPERTIES: :PROPERTIES:

View File

@@ -137,8 +137,14 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
key))) key)))
(defun theme-color (role) (defun theme-color (role)
"Returns the Croatoan color for a semantic role." "Returns the Croatoan color for a semantic role.
(or (getf *tui-theme* role) :white)) 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 ;; v0.8.0: TrueColor helpers
(defun theme-hex-to-rgb (hex-string) (defun theme-hex-to-rgb (hex-string)
@@ -182,12 +188,14 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
:search-mode nil :search-query "" ; v0.7.2 :search-mode nil :search-query "" ; v0.7.2
:search-matches nil :search-match-idx 0 :search-matches nil :search-match-idx 0
:sidebar-visible nil ; v0.8.0 :sidebar-visible nil ; v0.8.0
:palette-visible nil :palette-filter nil ; v0.8.0 :minibuffer-mode nil :minibuffer-selected-idx 0 ; v0.8.0
:palette-selected-idx 0 :palette-items nil ; v0.8.0 :minibuffer-filter "" ; v0.8.0
:wizard-step 0 :wizard-error nil ; v0.8.0 :wizard-mode :provider-list ; v0.9.0
:wizard-visible nil :wizard-input "" ; v0.8.0 :wizard-selected-idx 0 :wizard-input "" ; v0.9.0
:wizard-provider nil :wizard-api-key nil ; v0.8.0 :wizard-error nil ; v0.9.0
:wizard-memory nil ; v0.8.0 :wizard-providers nil :wizard-current-provider nil ; v0.9.0
:wizard-cascade '(:fg-prob nil :bg-prob nil :fg-det nil :bg-det nil) ; v0.9.0
:wizard-cascade-slot :fg-prob ; v0.9.0
:dirty (list nil nil nil)))) :dirty (list nil nil nil))))
(defun now () (defun now ()

View File

@@ -357,7 +357,7 @@ Respects CJK/emoji char widths via char-width."
(when style-bits (when style-bits
(remove-attributes win (get-bitmask style-bits))) (remove-attributes win (get-bitmask style-bits)))
(incf x (length text)))) (incf x (length text))))
y) (1+ y))
(defun parse-markdown-blocks (text) (defun parse-markdown-blocks (text)
"Split text at ``` code block boundaries." "Split text at ``` code block boundaries."
@@ -545,100 +545,179 @@ Respects CJK/emoji char widths via char-width."
(refresh win) (refresh win)
(- y 1))) (- y 1)))
(defun palette-filter (items query) (defun view-minibuffer (win)
"Return items from categorized list whose :name or :desc contains QUERY (case-insensitive)." "Render the bottom-anchored minibuffer panel. Dispatches on :minibuffer-mode."
(if (or (null query) (string= query "")) (case (st :minibuffer-mode)
items (:slash-menu (view-slash-menu win))
(let ((q (string-downcase query))) (:wizard (view-wizard-in-panel win))
(loop for group in items (t nil)))
for category = (getf group :category)
for gitems = (getf group :items)
for filtered = (loop for item in gitems
when (or (search q (string-downcase (getf item :name)))
(search q (string-downcase (or (getf item :desc) ""))))
collect item)
when filtered
collect (list :category category :items filtered)))))
(defun view-palette (win) (defvar *slash-commands* nil) ; forward declaration — defined in channel-tui-main
"Render centered command palette overlay with filtered items, selection highlight."
(clear win)
(setf (color-pair win) (list (theme-color :border) (theme-color :background)))
(box win 0 0)
(let* ((w (or (width win) 50))
(h (or (height win) 20))
(y 1)
(query (or (st :palette-filter) ""))
(items (palette-filter (st :palette-items) query))
(selected (st :palette-selected-idx))
(flat-index 0)
(visible-start (max 0 (- selected (floor (- h 6) 2)))))
(add-string win (format nil " Command Palette ") :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y)
(add-string win (format nil " > ~a" (if (> (length query) 0) query "type to filter..."))
:y y :x 2 :n (- w 4) :fgcolor (theme-color :input) :attributes '(:underline t))
(incf y)
(dolist (group items)
(let ((category (getf group :category))
(gitems (getf group :items)))
(when (and gitems (< y (1- h)))
(incf y)
(add-string win (format nil "── ~a ──" category) :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(dolist (item gitems)
(when (< y (1- h))
(incf y)
(let* ((name (getf item :name))
(desc (getf item :desc))
(shortcut (getf item :shortcut))
(is-selected (= flat-index selected))
(fg (if is-selected (theme-color :accent) (theme-color :agent))))
(when is-selected
(add-string win (make-string (- w 4) :initial-element #\Space) :y y :x 2 :n (- w 4)
:fgcolor (theme-color :dim) :bgcolor (theme-color :accent)))
(add-string win (format nil " ~a" name) :y y :x 3 :n (- w 6) :fgcolor fg)
(when (and shortcut (> (- w 6) (+ 4 (length shortcut))))
(add-string win shortcut :y y :x (- w (length shortcut) 3) :n (length shortcut) :fgcolor (theme-color :dim)))
(incf flat-index)))))))
(add-string win (format nil " ↑↓ Navigate Enter Execute Esc Close")
:y (- h 1) :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(refresh win)
(- h 1)))
(defun view-wizard (win) (defun view-slash-menu (win)
"Render setup wizard overlay: step title, prompt, input, error, progress." "Render the slash-command menu: filter bar, filtered command list, selection highlight."
(clear win) (clear win)
(setf (color-pair win) (list (theme-color :border) (theme-color :background))) (setf (color-pair win) (list (theme-color :border) (theme-color :background)))
(box win 0 0) (box win 0 0)
(let* ((w (or (width win) 60)) (let* ((w (or (width win) 60))
(h (or (height win) 15)) (h (or (height win) 10))
(y 1) (y 1)
(steps (passepartout.channel-tui::wizard-steps)) (filter (or (st :minibuffer-filter) ""))
(step-idx (st :wizard-step)) (commands passepartout.channel-tui::*slash-commands*)
(step (when (< step-idx (length steps)) (nth step-idx steps))) (filtered (if (or (null filter) (string= filter ""))
(prompt (getf step :prompt)) (mapcar (lambda (c) (list :index (position c commands) :cmd c)) commands)
(title (getf step :title)) (let ((q (string-downcase filter)) (i 0) (r nil))
(total (length steps)) (dolist (c commands (nreverse r))
(when (or (search q (string-downcase (getf c :name)))
(search q (string-downcase (or (getf c :desc) ""))))
(push (list :index i :cmd c) r))
(incf i)))))
(sel (or (st :minibuffer-selected-idx) 0))
(max-visible (- h 3)))
;; Header: filter bar
(add-string win (format nil " Commands") :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y)
(add-string win (format nil " > ~a_" (if (> (length filter) 0) filter "/"))
:y y :x 2 :n (- w 4) :fgcolor (theme-color :input))
(incf y)
;; Command list
(if filtered
(let* ((start (max 0 (- sel (floor max-visible 2))))
(end (min (length filtered) (+ start max-visible)))
(flat-i 0))
(loop for entry across (subseq (coerce filtered 'vector) start end)
for fi from start
for cmd = (getf entry :cmd)
do (let* ((name (getf cmd :name))
(desc (getf cmd :desc))
(selected (= fi sel))
(fg (if selected (theme-color :highlight) (theme-color :agent))))
(when selected
(add-string win (make-string (- w 4) :initial-element #\Space) :y y :x 2 :n (- w 4)
:fgcolor (theme-color :dim) :bgcolor (theme-color :highlight)))
(let ((prefix (if selected " > " " ")))
(add-string win (format nil "~a~a" prefix name) :y y :x 3 :n (min (- w 6) 25) :fgcolor fg)
(when desc
(add-string win (format nil " — ~a" desc) :y y :x 28 :n (min (- w 30) (length desc)) :fgcolor (theme-color :dim))))
(incf y))))
(progn
(add-string win " (no matching commands)" :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(incf y)))
;; Footer
(add-string win " ↑↓ Navigate Enter Execute Esc Close"
:y (- h 0) :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(refresh win)
(- h 0)))
(defun view-wizard-in-panel (win)
"Render the setup wizard in the bottom-anchored minibuffer panel. Three modes: provider-list, key-entry, cascade-config."
(clear win)
(setf (color-pair win) (list (theme-color :border) (theme-color :background)))
(box win 0 0)
(let* ((w (or (width win) 70))
(h (or (height win) 14))
(y 1)
(mode (st :wizard-mode))
(error-msg (st :wizard-error)) (error-msg (st :wizard-error))
(input (or (st :wizard-input) ""))) (selected-idx (st :wizard-selected-idx))
(providers (passepartout.channel-tui::wizard-provider-list))
(configured (st :wizard-providers)))
(add-string win "Setup Wizard" :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent)) (add-string win "Setup Wizard" :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y 2) (incf y 2)
(add-string win (format nil "Step ~d/~d" (1+ step-idx) total) :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim)) (case mode
(incf y) (:provider-list
(when title (let ((count (/ (length configured) 2)))
(add-string win title :y y :x 3 :n (- w 6) :fgcolor (theme-color :accent)) (add-string win (format nil "Configure Providers~a"
(incf y)) (if (> count 0) (format nil " — ~d configured" count) ""))
(when prompt :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(add-string win prompt :y y :x 3 :n (- w 6) :fgcolor (theme-color :agent)) (incf y)
(incf y)) (loop for p in providers
(incf y) for i from 0
(add-string win (format nil "> ~a" input) :y y :x 3 :n (- w 6) :fgcolor (theme-color :input)) do (let* ((meta (passepartout.channel-tui::wizard-provider-meta p))
(incf y) (name (car meta))
(key (getf configured p))
(prefix (if (= i selected-idx) "> " " "))
(suffix (if key " ✓" ""))
(color (if (= i selected-idx)
(theme-color :highlight)
(theme-color :dim))))
(add-string win (format nil "~a~a~a" prefix name suffix)
:y y :x 3 :n (- w 6) :fgcolor color)
(incf y)))
(incf y)
(add-string win " Done — configure cascade"
:y y :x 3 :n (- w 6)
:fgcolor (if (>= selected-idx (length providers))
(theme-color :highlight)
(theme-color :dim)))
(when (>= selected-idx (length providers))
(add-string win ">" :y y :x 1 :n 2 :fgcolor (theme-color :highlight))))
(:key-entry
(let* ((provider (st :wizard-current-provider))
(meta (passepartout.channel-tui::wizard-provider-meta provider))
(name (car meta))
(url (cadr meta))
(input (or (st :wizard-input) "")))
(add-string win (format nil "API Key: ~a" name) :y y :x 2 :n (- w 4) :fgcolor (theme-color :agent))
(incf y)
(when url
(add-string win (format nil "Get key at: ~a" url) :y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))
(incf y))
(add-string win "Enter your API key." :y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))
(incf y 2)
(add-string win (format nil "Key: > ~a" input) :y y :x 3 :n (- w 6) :fgcolor (theme-color :input))
(incf y)
(when error-msg
(add-string win (format nil "! ~a" error-msg) :y y :x 3 :n (- w 6) :fgcolor (theme-color :error))
(incf y))
(incf y)
(add-string win "Enter=Save Esc=Back Bksp=Edit Ctrl+U=Clear"
:y (- h 0) :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(return-from view-wizard-in-panel)))
(:cascade-config
(let* ((slot (st :wizard-cascade-slot))
(slot-providers (getf (st :wizard-cascade) slot))
(slot-label (cadr (assoc slot passepartout.channel-tui::*wizard-cascade-labels*)))
(count (/ (length configured) 2)))
(add-string win (format nil "Configure Cascade — ~d provider~:p" count)
:y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(incf y)
(add-string win (or slot-label "Unknown") :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y)
(let ((shown nil))
(loop for p in providers
for i from 0
do (when (getf configured p)
(let* ((meta (passepartout.channel-tui::wizard-provider-meta p))
(name (car meta))
(in-slot (member p slot-providers))
(prefix (if (= i selected-idx) "> " " "))
(mark (if in-slot " [✓]" " [ ]"))
(color (if (= i selected-idx)
(theme-color :highlight)
(if in-slot (theme-color :gate-passed) (theme-color :dim)))))
(add-string win (format nil "~a~a~a" prefix name mark)
:y y :x 3 :n (- w 6) :fgcolor color)
(incf y)
(push t shown))))
(unless shown
(add-string win " (no providers configured)"
:y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))
(incf y)))
(incf y)
(add-string win (format nil "Cascade: ~{~a~^, ~}"
(or slot-providers '("(none)")))
:y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))))
(when error-msg (when error-msg
(add-string win (format nil "! ~a" error-msg) :y y :x 3 :n (- w 6) :fgcolor (theme-color :error)) (incf y)
(incf y)) (add-string win (format nil "! ~a" error-msg) :y y :x 3 :n (- w 6) :fgcolor (theme-color :error)))
(add-string win "Enter=Next Esc=Cancel Bksp=Edit" :y (- h 2) :x 2 :n (- w 4) :fgcolor (theme-color :dim)) (let ((footer (case mode
(refresh win) (:provider-list "↑↓ Navigate Enter=Select Esc=Back Ctrl+D=Remove")
(- h 1))) (:cascade-config "↑↓ Select Enter=Toggle Tab=Next Quadrant Ctrl+S=Save Esc=Back")
(t ""))))
(when footer
(add-string win footer :y (- h 0) :x 2 :n (- w 4) :fgcolor (theme-color :dim))))
(- h 0)))))
(in-package :passepartout-tui-view-tests) (in-package :passepartout-tui-view-tests)
@@ -662,25 +741,20 @@ Respects CJK/emoji char widths via char-width."
(dolist (name names) (dolist (name names)
(is (getf presets name) (format nil "~a preset should exist" name))))) (is (getf presets name) (format nil "~a preset should exist" name)))))
(test test-palette-filter-matches-substring (test test-minibuffer-init-state-fields
"Contract v0.8.0: palette-filter returns items matching query." "Contract v0.8.0: init-state includes minibuffer-mode, selected-idx, filter; excludes palette and wizard-visible."
(let* ((items (list (list :category "Session" :items (passepartout.channel-tui::init-state)
(list (list :name "/focus" :desc "Set context" :shortcut nil :action nil) (is (null (passepartout.channel-tui::st :minibuffer-mode)))
(list :name "/scope" :desc "Change scope" :shortcut nil :action nil))))) (is (= 0 (passepartout.channel-tui::st :minibuffer-selected-idx)))
(filtered (passepartout.channel-tui::palette-filter items "focus"))) (is (string= "" (passepartout.channel-tui::st :minibuffer-filter)))
(is (= 1 (length (getf (first filtered) :items)))) (is (null (getf passepartout.channel-tui::*state* :palette-visible)))
(is (string= "/focus" (getf (first (getf (first filtered) :items)) :name))))) (is (null (getf passepartout.channel-tui::*state* :wizard-visible))))
(test test-palette-filter-case-insensitive (test test-slash-commands-entry-count
"Contract v0.8.0: palette-filter is case-insensitive." "Contract v0.8.0: *slash-commands* has at least 20 entries, each with :name, :desc, :action."
(let* ((items (list (list :category "View" :items (let ((cmds passepartout.channel-tui::*slash-commands*))
(list (list :name "/theme" :desc "Switch color" :shortcut nil :action nil))))) (is (>= (length cmds) 20))
(filtered (passepartout.channel-tui::palette-filter items "THEME"))) (dolist (c cmds)
(is (= 1 (length (getf (first filtered) :items)))))) (is (stringp (getf c :name)))
(is (stringp (getf c :desc)))
(test test-palette-filter-no-match-empty (is (functionp (getf c :action))))))
"Contract v0.8.0: palette-filter returns empty categories on no match."
(let* ((items (list (list :category "View" :items
(list (list :name "/theme" :desc "Colors" :shortcut nil :action nil)))))
(filtered (passepartout.channel-tui::palette-filter items "xyznonexistent")))
(is (null (getf (first filtered) :items)))))

View File

@@ -229,8 +229,14 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
key))) key)))
(defun theme-color (role) (defun theme-color (role)
"Returns the Croatoan color for a semantic role." "Returns the Croatoan color for a semantic role.
(or (getf *tui-theme* role) :white)) 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 ;; v0.8.0: TrueColor helpers
(defun theme-hex-to-rgb (hex-string) (defun theme-hex-to-rgb (hex-string)
@@ -274,12 +280,14 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
:search-mode nil :search-query "" ; v0.7.2 :search-mode nil :search-query "" ; v0.7.2
:search-matches nil :search-match-idx 0 :search-matches nil :search-match-idx 0
:sidebar-visible nil ; v0.8.0 :sidebar-visible nil ; v0.8.0
:palette-visible nil :palette-filter nil ; v0.8.0 :minibuffer-mode nil :minibuffer-selected-idx 0 ; v0.8.0
:palette-selected-idx 0 :palette-items nil ; v0.8.0 :minibuffer-filter "" ; v0.8.0
:wizard-step 0 :wizard-error nil ; v0.8.0 :wizard-mode :provider-list ; v0.9.0
:wizard-visible nil :wizard-input "" ; v0.8.0 :wizard-selected-idx 0 :wizard-input "" ; v0.9.0
:wizard-provider nil :wizard-api-key nil ; v0.8.0 :wizard-error nil ; v0.9.0
:wizard-memory nil ; v0.8.0 :wizard-providers nil :wizard-current-provider nil ; v0.9.0
:wizard-cascade '(:fg-prob nil :bg-prob nil :fg-det nil :bg-det nil) ; v0.9.0
:wizard-cascade-slot :fg-prob ; v0.9.0
:dirty (list nil nil nil)))) :dirty (list nil nil nil))))
#+end_src #+end_src

View File

@@ -517,7 +517,7 @@ Respects CJK/emoji char widths via char-width."
(when style-bits (when style-bits
(remove-attributes win (get-bitmask style-bits))) (remove-attributes win (get-bitmask style-bits)))
(incf x (length text)))) (incf x (length text))))
y) (1+ y))
(defun parse-markdown-blocks (text) (defun parse-markdown-blocks (text)
"Split text at ``` code block boundaries." "Split text at ``` code block boundaries."
@@ -612,7 +612,7 @@ Respects CJK/emoji char widths via char-width."
(nreverse lines))) (nreverse lines)))
#+end_src #+end_src
* v0.8.0 — Sidebar + Palette View * v0.8.0 — Sidebar + Minibuffer View
#+begin_src lisp #+begin_src lisp
(in-package :passepartout.channel-tui) (in-package :passepartout.channel-tui)
@@ -711,103 +711,183 @@ Respects CJK/emoji char widths via char-width."
(refresh win) (refresh win)
(- y 1))) (- y 1)))
(defun palette-filter (items query) (defun view-minibuffer (win)
"Return items from categorized list whose :name or :desc contains QUERY (case-insensitive)." "Render the bottom-anchored minibuffer panel. Dispatches on :minibuffer-mode."
(if (or (null query) (string= query "")) (case (st :minibuffer-mode)
items (:slash-menu (view-slash-menu win))
(let ((q (string-downcase query))) (:wizard (view-wizard-in-panel win))
(loop for group in items (t nil)))
for category = (getf group :category)
for gitems = (getf group :items)
for filtered = (loop for item in gitems
when (or (search q (string-downcase (getf item :name)))
(search q (string-downcase (or (getf item :desc) ""))))
collect item)
when filtered
collect (list :category category :items filtered)))))
(defun view-palette (win) (defvar *slash-commands* nil) ; forward declaration — defined in channel-tui-main
"Render centered command palette overlay with filtered items, selection highlight."
(clear win)
(setf (color-pair win) (list (theme-color :border) (theme-color :background)))
(box win 0 0)
(let* ((w (or (width win) 50))
(h (or (height win) 20))
(y 1)
(query (or (st :palette-filter) ""))
(items (palette-filter (st :palette-items) query))
(selected (st :palette-selected-idx))
(flat-index 0)
(visible-start (max 0 (- selected (floor (- h 6) 2)))))
(add-string win (format nil " Command Palette ") :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y)
(add-string win (format nil " > ~a" (if (> (length query) 0) query "type to filter..."))
:y y :x 2 :n (- w 4) :fgcolor (theme-color :input) :attributes '(:underline t))
(incf y)
(dolist (group items)
(let ((category (getf group :category))
(gitems (getf group :items)))
(when (and gitems (< y (1- h)))
(incf y)
(add-string win (format nil "── ~a ──" category) :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(dolist (item gitems)
(when (< y (1- h))
(incf y)
(let* ((name (getf item :name))
(desc (getf item :desc))
(shortcut (getf item :shortcut))
(is-selected (= flat-index selected))
(fg (if is-selected (theme-color :accent) (theme-color :agent))))
(when is-selected
(add-string win (make-string (- w 4) :initial-element #\Space) :y y :x 2 :n (- w 4)
:fgcolor (theme-color :dim) :bgcolor (theme-color :accent)))
(add-string win (format nil " ~a" name) :y y :x 3 :n (- w 6) :fgcolor fg)
(when (and shortcut (> (- w 6) (+ 4 (length shortcut))))
(add-string win shortcut :y y :x (- w (length shortcut) 3) :n (length shortcut) :fgcolor (theme-color :dim)))
(incf flat-index)))))))
(add-string win (format nil " ↑↓ Navigate Enter Execute Esc Close")
:y (- h 1) :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(refresh win)
(- h 1)))
(defun view-wizard (win) (defun view-slash-menu (win)
"Render setup wizard overlay: step title, prompt, input, error, progress." "Render the slash-command menu: filter bar, filtered command list, selection highlight."
(clear win) (clear win)
(setf (color-pair win) (list (theme-color :border) (theme-color :background))) (setf (color-pair win) (list (theme-color :border) (theme-color :background)))
(box win 0 0) (box win 0 0)
(let* ((w (or (width win) 60)) (let* ((w (or (width win) 60))
(h (or (height win) 15)) (h (or (height win) 10))
(y 1) (y 1)
(steps (passepartout.channel-tui::wizard-steps)) (filter (or (st :minibuffer-filter) ""))
(step-idx (st :wizard-step)) (commands passepartout.channel-tui::*slash-commands*)
(step (when (< step-idx (length steps)) (nth step-idx steps))) (filtered (if (or (null filter) (string= filter ""))
(prompt (getf step :prompt)) (mapcar (lambda (c) (list :index (position c commands) :cmd c)) commands)
(title (getf step :title)) (let ((q (string-downcase filter)) (i 0) (r nil))
(total (length steps)) (dolist (c commands (nreverse r))
(when (or (search q (string-downcase (getf c :name)))
(search q (string-downcase (or (getf c :desc) ""))))
(push (list :index i :cmd c) r))
(incf i)))))
(sel (or (st :minibuffer-selected-idx) 0))
(max-visible (- h 3)))
;; Header: filter bar
(add-string win (format nil " Commands") :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y)
(add-string win (format nil " > ~a_" (if (> (length filter) 0) filter "/"))
:y y :x 2 :n (- w 4) :fgcolor (theme-color :input))
(incf y)
;; Command list
(if filtered
(let* ((start (max 0 (- sel (floor max-visible 2))))
(end (min (length filtered) (+ start max-visible)))
(flat-i 0))
(loop for entry across (subseq (coerce filtered 'vector) start end)
for fi from start
for cmd = (getf entry :cmd)
do (let* ((name (getf cmd :name))
(desc (getf cmd :desc))
(selected (= fi sel))
(fg (if selected (theme-color :highlight) (theme-color :agent))))
(when selected
(add-string win (make-string (- w 4) :initial-element #\Space) :y y :x 2 :n (- w 4)
:fgcolor (theme-color :dim) :bgcolor (theme-color :highlight)))
(let ((prefix (if selected " > " " ")))
(add-string win (format nil "~a~a" prefix name) :y y :x 3 :n (min (- w 6) 25) :fgcolor fg)
(when desc
(add-string win (format nil " — ~a" desc) :y y :x 28 :n (min (- w 30) (length desc)) :fgcolor (theme-color :dim))))
(incf y))))
(progn
(add-string win " (no matching commands)" :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(incf y)))
;; Footer
(add-string win " ↑↓ Navigate Enter Execute Esc Close"
:y (- h 0) :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(refresh win)
(- h 0)))
(defun view-wizard-in-panel (win)
"Render the setup wizard in the bottom-anchored minibuffer panel. Three modes: provider-list, key-entry, cascade-config."
(clear win)
(setf (color-pair win) (list (theme-color :border) (theme-color :background)))
(box win 0 0)
(let* ((w (or (width win) 70))
(h (or (height win) 14))
(y 1)
(mode (st :wizard-mode))
(error-msg (st :wizard-error)) (error-msg (st :wizard-error))
(input (or (st :wizard-input) ""))) (selected-idx (st :wizard-selected-idx))
(providers (passepartout.channel-tui::wizard-provider-list))
(configured (st :wizard-providers)))
(add-string win "Setup Wizard" :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent)) (add-string win "Setup Wizard" :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y 2) (incf y 2)
(add-string win (format nil "Step ~d/~d" (1+ step-idx) total) :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim)) (case mode
(incf y) (:provider-list
(when title (let ((count (/ (length configured) 2)))
(add-string win title :y y :x 3 :n (- w 6) :fgcolor (theme-color :accent)) (add-string win (format nil "Configure Providers~a"
(incf y)) (if (> count 0) (format nil " — ~d configured" count) ""))
(when prompt :y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(add-string win prompt :y y :x 3 :n (- w 6) :fgcolor (theme-color :agent)) (incf y)
(incf y)) (loop for p in providers
(incf y) for i from 0
(add-string win (format nil "> ~a" input) :y y :x 3 :n (- w 6) :fgcolor (theme-color :input)) do (let* ((meta (passepartout.channel-tui::wizard-provider-meta p))
(incf y) (name (car meta))
(key (getf configured p))
(prefix (if (= i selected-idx) "> " " "))
(suffix (if key " ✓" ""))
(color (if (= i selected-idx)
(theme-color :highlight)
(theme-color :dim))))
(add-string win (format nil "~a~a~a" prefix name suffix)
:y y :x 3 :n (- w 6) :fgcolor color)
(incf y)))
(incf y)
(add-string win " Done — configure cascade"
:y y :x 3 :n (- w 6)
:fgcolor (if (>= selected-idx (length providers))
(theme-color :highlight)
(theme-color :dim)))
(when (>= selected-idx (length providers))
(add-string win ">" :y y :x 1 :n 2 :fgcolor (theme-color :highlight))))
(:key-entry
(let* ((provider (st :wizard-current-provider))
(meta (passepartout.channel-tui::wizard-provider-meta provider))
(name (car meta))
(url (cadr meta))
(input (or (st :wizard-input) "")))
(add-string win (format nil "API Key: ~a" name) :y y :x 2 :n (- w 4) :fgcolor (theme-color :agent))
(incf y)
(when url
(add-string win (format nil "Get key at: ~a" url) :y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))
(incf y))
(add-string win "Enter your API key." :y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))
(incf y 2)
(add-string win (format nil "Key: > ~a" input) :y y :x 3 :n (- w 6) :fgcolor (theme-color :input))
(incf y)
(when error-msg
(add-string win (format nil "! ~a" error-msg) :y y :x 3 :n (- w 6) :fgcolor (theme-color :error))
(incf y))
(incf y)
(add-string win "Enter=Save Esc=Back Bksp=Edit Ctrl+U=Clear"
:y (- h 0) :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(return-from view-wizard-in-panel)))
(:cascade-config
(let* ((slot (st :wizard-cascade-slot))
(slot-providers (getf (st :wizard-cascade) slot))
(slot-label (cadr (assoc slot passepartout.channel-tui::*wizard-cascade-labels*)))
(count (/ (length configured) 2)))
(add-string win (format nil "Configure Cascade — ~d provider~:p" count)
:y y :x 2 :n (- w 4) :fgcolor (theme-color :dim))
(incf y)
(add-string win (or slot-label "Unknown") :y y :x 2 :n (- w 4) :fgcolor (theme-color :accent))
(incf y)
(let ((shown nil))
(loop for p in providers
for i from 0
do (when (getf configured p)
(let* ((meta (passepartout.channel-tui::wizard-provider-meta p))
(name (car meta))
(in-slot (member p slot-providers))
(prefix (if (= i selected-idx) "> " " "))
(mark (if in-slot " [✓]" " [ ]"))
(color (if (= i selected-idx)
(theme-color :highlight)
(if in-slot (theme-color :gate-passed) (theme-color :dim)))))
(add-string win (format nil "~a~a~a" prefix name mark)
:y y :x 3 :n (- w 6) :fgcolor color)
(incf y)
(push t shown))))
(unless shown
(add-string win " (no providers configured)"
:y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))
(incf y)))
(incf y)
(add-string win (format nil "Cascade: ~{~a~^, ~}"
(or slot-providers '("(none)")))
:y y :x 3 :n (- w 6) :fgcolor (theme-color :dim))))
(when error-msg (when error-msg
(add-string win (format nil "! ~a" error-msg) :y y :x 3 :n (- w 6) :fgcolor (theme-color :error)) (incf y)
(incf y)) (add-string win (format nil "! ~a" error-msg) :y y :x 3 :n (- w 6) :fgcolor (theme-color :error)))
(add-string win "Enter=Next Esc=Cancel Bksp=Edit" :y (- h 2) :x 2 :n (- w 4) :fgcolor (theme-color :dim)) (let ((footer (case mode
(refresh win) (:provider-list "↑↓ Navigate Enter=Select Esc=Back Ctrl+D=Remove")
(- h 1))) (:cascade-config "↑↓ Select Enter=Toggle Tab=Next Quadrant Ctrl+S=Save Esc=Back")
(t ""))))
(when footer
(add-string win footer :y (- h 0) :x 2 :n (- w 4) :fgcolor (theme-color :dim))))
(- h 0)))))
#+end_src #+end_src
* v0.8.0 Tests — Sidebar View * v0.8.0 Tests — Sidebar View + Minibuffer View
#+begin_src lisp #+begin_src lisp
(in-package :passepartout-tui-view-tests) (in-package :passepartout-tui-view-tests)
@@ -831,26 +911,21 @@ Respects CJK/emoji char widths via char-width."
(dolist (name names) (dolist (name names)
(is (getf presets name) (format nil "~a preset should exist" name))))) (is (getf presets name) (format nil "~a preset should exist" name)))))
(test test-palette-filter-matches-substring (test test-minibuffer-init-state-fields
"Contract v0.8.0: palette-filter returns items matching query." "Contract v0.8.0: init-state includes minibuffer-mode, selected-idx, filter; excludes palette and wizard-visible."
(let* ((items (list (list :category "Session" :items (passepartout.channel-tui::init-state)
(list (list :name "/focus" :desc "Set context" :shortcut nil :action nil) (is (null (passepartout.channel-tui::st :minibuffer-mode)))
(list :name "/scope" :desc "Change scope" :shortcut nil :action nil))))) (is (= 0 (passepartout.channel-tui::st :minibuffer-selected-idx)))
(filtered (passepartout.channel-tui::palette-filter items "focus"))) (is (string= "" (passepartout.channel-tui::st :minibuffer-filter)))
(is (= 1 (length (getf (first filtered) :items)))) (is (null (getf passepartout.channel-tui::*state* :palette-visible)))
(is (string= "/focus" (getf (first (getf (first filtered) :items)) :name))))) (is (null (getf passepartout.channel-tui::*state* :wizard-visible))))
(test test-palette-filter-case-insensitive (test test-slash-commands-entry-count
"Contract v0.8.0: palette-filter is case-insensitive." "Contract v0.8.0: *slash-commands* has at least 19 entries, each with :name, :desc, :action."
(let* ((items (list (list :category "View" :items (let ((cmds passepartout.channel-tui::*slash-commands*))
(list (list :name "/theme" :desc "Switch color" :shortcut nil :action nil))))) (is (>= (length cmds) 19))
(filtered (passepartout.channel-tui::palette-filter items "THEME"))) (dolist (c cmds)
(is (= 1 (length (getf (first filtered) :items)))))) (is (stringp (getf c :name)))
(is (stringp (getf c :desc)))
(test test-palette-filter-no-match-empty (is (functionp (getf c :action))))))
"Contract v0.8.0: palette-filter returns empty categories on no match."
(let* ((items (list (list :category "View" :items
(list (list :name "/theme" :desc "Colors" :shortcut nil :action nil)))))
(filtered (passepartout.channel-tui::palette-filter items "xyznonexistent")))
(is (null (getf (first filtered) :items)))))
#+end_src #+end_src