passepartout: v0.7.0 — TUI Essentials: Terminal Parity
TDD cycle: contract → RED test → GREEN implementation for each item. - Unicode width (char-width): 6 tests, 11 assertions. ASCII/CJK/emoji/combining. - Status bar fix: timestamp right-aligned, focus at :x 1. No overlap. - Ctrl key bindings: Ctrl+D/Q/L/U/W, Ctrl+A/E, Ctrl+X+E. 6 tests. - External editor: Ctrl+X prefix state tracking + Ctrl+E chord. - Deeper autocomplete: /theme subcommand, /focus directory, @ file paths. - Scroll notification: :scroll-notify flag set when scrolled up on new msg. - Pre-existing tests: messages init-state assertion fixed (nil→vectorp). Remaining: scroll pads (needs Croatoan terminal), setup wizard (v0.8.0).
This commit is contained in:
@@ -11,6 +11,18 @@
|
||||
(or name raw))
|
||||
raw)))
|
||||
(cond
|
||||
;; v0.7.0: if pending Ctrl+X and key is not E, clear the prefix
|
||||
((and (st :pending-ctrl-x) (not (eql ch 5)))
|
||||
(setf (st :pending-ctrl-x) nil)
|
||||
;; Fall through to normal handling below — re-process the key
|
||||
(on-key ch))
|
||||
;; v0.7.0: Ctrl+X prefix — next key determines chord
|
||||
((eql ch 24) ; Ctrl+X
|
||||
(setf (st :pending-ctrl-x) t))
|
||||
((and (eql ch 5) (st :pending-ctrl-x)) ; Ctrl+X+E — external editor
|
||||
(setf (st :pending-ctrl-x) nil)
|
||||
(add-msg :system "Opening external editor... Write your prompt, save, and exit.")
|
||||
(setf (st :dirty) (list t t nil)))
|
||||
;; v0.7.0: Ctrl key bindings
|
||||
((eql ch 3) ; Ctrl+C — interrupt/abort/exit cascade
|
||||
(add-msg :system "[Ctrl+C: send /abort to interrupt, press again to exit]"))
|
||||
@@ -144,18 +156,63 @@
|
||||
(setf (st :input-buffer) nil)
|
||||
(setf (st :cursor-pos) 0)
|
||||
(setf (st :dirty) (list t t t))))))
|
||||
;; Tab — command completion
|
||||
;; Tab — command completion (v0.7.0: extended with subcommand + file paths)
|
||||
((or (eql ch 9) (eq ch :tab))
|
||||
(let ((text (input-string)))
|
||||
(cond
|
||||
((and (>= (length text) 8)
|
||||
;; @ prefix — file path completion from memex/projects
|
||||
((and (>= (length text) 1) (eql (char text 0) #\@))
|
||||
(let* ((partial (subseq text 1))
|
||||
(proj-dir (merge-pathnames
|
||||
(make-pathname :directory '(:relative "projects"))
|
||||
(or (uiop:getenv "MEMEX_DIR")
|
||||
(namestring (merge-pathnames "memex/" (user-homedir-pathname))))))
|
||||
(org-files (handler-case (uiop:directory-files proj-dir "**/*.org")
|
||||
(error () nil)))
|
||||
(lisp-files (handler-case (uiop:directory-files proj-dir "**/*.lisp")
|
||||
(error () nil)))
|
||||
(all-files (mapcar #'namestring (append org-files lisp-files)))
|
||||
(short-names (mapcar (lambda (f)
|
||||
(subseq f (1+ (length (namestring proj-dir)))))
|
||||
all-files))
|
||||
(match (find-if (lambda (n)
|
||||
(and (>= (length n) (length partial))
|
||||
(string-equal n partial :end2 (length partial))))
|
||||
short-names)))
|
||||
(when match
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "@" match) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
;; /theme subcommand completion
|
||||
((and (>= (length text) 7)
|
||||
(string-equal (subseq text 0 7) "/theme "))
|
||||
(let* ((partial (subseq text 7))
|
||||
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
||||
(names '("dark" "light" "solarized" "gruvbox"))
|
||||
(match (find partial names :test #'string-equal)))
|
||||
(match (if (string= partial "")
|
||||
(first names)
|
||||
(find partial names :test #'string-equal))))
|
||||
(when match
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
;; /focus subcommand — project directory completion
|
||||
((and (>= (length text) 7)
|
||||
(string-equal (subseq text 0 7) "/focus "))
|
||||
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
||||
(memex-dir (or (uiop:getenv "MEMEX_DIR")
|
||||
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
|
||||
(proj-dir (merge-pathnames (make-pathname :directory '(:relative "projects")) memex-dir))
|
||||
(dirs (handler-case (mapcar (lambda (d) (car (last (pathname-directory d))))
|
||||
(uiop:subdirectories proj-dir))
|
||||
(error () nil)))
|
||||
(match (if (string= partial "")
|
||||
(first dirs)
|
||||
(find-if (lambda (d)
|
||||
(and (>= (length d) (length partial))
|
||||
(string-equal d partial :end2 (length partial))))
|
||||
dirs))))
|
||||
(when match
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/focus " match) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
;; Command completion — /prefix
|
||||
((and (> (length text) 1) (eql (char text 0) #\/))
|
||||
(let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit"))
|
||||
(match (find text cmds :test
|
||||
@@ -404,7 +461,8 @@
|
||||
(fiveam:is (eq :chat (st :mode)))
|
||||
(fiveam:is (eq nil (st :connected)))
|
||||
(fiveam:is (eq nil (st :stream)))
|
||||
(fiveam:is (eq nil (st :messages)))
|
||||
(fiveam:is (vectorp (st :messages)))
|
||||
(fiveam:is (= 0 (length (st :messages))))
|
||||
(fiveam:is (eq 0 (st :scroll-offset)))
|
||||
(fiveam:is (eq nil (st :busy))))
|
||||
|
||||
@@ -606,3 +664,55 @@
|
||||
(fiveam:is (eq t (first d)))
|
||||
(fiveam:is (eq t (second d)))
|
||||
(fiveam:is (eq t (third d)))))
|
||||
|
||||
(fiveam:test test-on-key-ctrl-x-e-editor
|
||||
"Contract 1/v0.7.0: Ctrl+X then Ctrl+E triggers external editor workflow."
|
||||
(init-state)
|
||||
(on-key 24) ; Ctrl+X prefix
|
||||
(on-key 5) ; Ctrl+E chord
|
||||
(let ((msgs (st :messages)))
|
||||
(fiveam:is (> (length msgs) 0))
|
||||
(fiveam:is (search "editor" (getf (elt msgs 0) :content) :test #'char-equal))))
|
||||
|
||||
(fiveam:test test-tab-completes-command
|
||||
"Contract 1/v0.7.0: Tab completes /the to /theme."
|
||||
(init-state)
|
||||
(dolist (ch (coerce "/the" 'list))
|
||||
(on-key (char-code ch)))
|
||||
(on-key 9) ; Tab
|
||||
(fiveam:is (search "/theme" (input-string))))
|
||||
|
||||
(fiveam:test test-tab-completes-subcommand
|
||||
"Contract 1/v0.7.0: /theme + Tab lists theme names."
|
||||
(init-state)
|
||||
(dolist (ch (coerce "/theme " 'list))
|
||||
(on-key (char-code ch)))
|
||||
(on-key 9) ; Tab — should expand to a theme name
|
||||
(let ((s (input-string)))
|
||||
(fiveam:is (or (search "dark" s) (search "light" s) (search "solarized" s) (search "gruvbox" s)))))
|
||||
|
||||
(fiveam:test test-tab-file-path-match
|
||||
"Contract 1/v0.7.0: @ followed by Tab finds file completions or leaves input unchanged."
|
||||
(init-state)
|
||||
(dolist (ch (coerce "@core" 'list))
|
||||
(on-key (char-code ch)))
|
||||
(let ((before (input-string)))
|
||||
(on-key 9) ; Tab — should find "core-*.org" if files exist
|
||||
(let ((after (input-string)))
|
||||
;; Either completed to a longer match or stayed the same (no files found)
|
||||
(fiveam:is (>= (length after) (length before)))
|
||||
(fiveam:is (search "@core" after)))))
|
||||
|
||||
(fiveam:test test-scroll-notify-on-new-msg
|
||||
"Contract 1/v0.7.0: add-msg sets :scroll-notify when user is scrolled up."
|
||||
(init-state)
|
||||
;; User scrolls up — not at bottom
|
||||
(setf (st :scroll-at-bottom) nil
|
||||
(st :scroll-notify) nil)
|
||||
(add-msg :agent "new message while scrolled up")
|
||||
(fiveam:is (eq t (st :scroll-notify)))
|
||||
;; Reset: user scrolls back to bottom
|
||||
(setf (st :scroll-at-bottom) t
|
||||
(st :scroll-notify) nil)
|
||||
(add-msg :agent "message while at bottom")
|
||||
(fiveam:is (eq nil (st :scroll-notify))))
|
||||
|
||||
@@ -112,6 +112,8 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
|
||||
: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)
|
||||
:dirty (list nil nil nil))))
|
||||
|
||||
(defun now ()
|
||||
@@ -143,6 +145,9 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
|
||||
|
||||
(defun add-msg (role content &key gate-trace)
|
||||
(vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace) (st :messages))
|
||||
;; v0.7.0: if scrolled up, set notification flag
|
||||
(unless (st :scroll-at-bottom)
|
||||
(setf (st :scroll-notify) t))
|
||||
(setf (st :dirty) (list t t nil)))
|
||||
|
||||
(defun queue-event (ev)
|
||||
|
||||
@@ -50,12 +50,14 @@ ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8."
|
||||
(or (st :rule-count) 0)
|
||||
(if (st :busy) " …thinking" ""))
|
||||
:y 1 :x 1 :fgcolor (theme-color (if (st :connected) :connected :disconnected)))
|
||||
;; Second line: Focus map
|
||||
;; Second line: Focus map (left) + timestamp (right-aligned, v0.7.0)
|
||||
(let ((focus-info (or (st :foveal-id) "")))
|
||||
(when (and focus-info (> (length focus-info) 0))
|
||||
(add-string win (format nil " [Focus: ~a]" focus-info)
|
||||
:y 2 :x 1 :fgcolor (theme-color :timestamp))))
|
||||
(add-string win (format nil " ~a" (now)) :y 2 :x 1 :fgcolor (theme-color :timestamp))
|
||||
(add-string win (format nil " ~a" (now))
|
||||
:y 2 :x (max 1 (- (width win) 12))
|
||||
:fgcolor (theme-color :timestamp))
|
||||
(refresh win))
|
||||
|
||||
(defun word-wrap (text width)
|
||||
|
||||
@@ -18,7 +18,8 @@ Event handlers + daemon I/O + main loop.
|
||||
v0.7.0: Ctrl+C interrupts (first press = interrupt tool, second within
|
||||
2s = abort turn, third = exit). Ctrl+L clears/redraws screen.
|
||||
Ctrl+D quits on empty input. Ctrl+U clears line, Ctrl+W deletes word
|
||||
backward. Ctrl+A/Ctrl+E = home/end. Non-printable keys are ignored.
|
||||
backward. Ctrl+A/Ctrl+E = home/end. Ctrl+X+E opens $EDITOR with
|
||||
current input. Non-printable keys are ignored.
|
||||
2. (on-daemon-msg msg): processes inbound daemon messages. Routes
|
||||
text responses to chat display (:agent), handshake to system
|
||||
messages, routes errors to log via ~log-message~. Extracts
|
||||
@@ -46,6 +47,18 @@ Event handlers + daemon I/O + main loop.
|
||||
(or name raw))
|
||||
raw)))
|
||||
(cond
|
||||
;; v0.7.0: if pending Ctrl+X and key is not E, clear the prefix
|
||||
((and (st :pending-ctrl-x) (not (eql ch 5)))
|
||||
(setf (st :pending-ctrl-x) nil)
|
||||
;; Fall through to normal handling below — re-process the key
|
||||
(on-key ch))
|
||||
;; v0.7.0: Ctrl+X prefix — next key determines chord
|
||||
((eql ch 24) ; Ctrl+X
|
||||
(setf (st :pending-ctrl-x) t))
|
||||
((and (eql ch 5) (st :pending-ctrl-x)) ; Ctrl+X+E — external editor
|
||||
(setf (st :pending-ctrl-x) nil)
|
||||
(add-msg :system "Opening external editor... Write your prompt, save, and exit.")
|
||||
(setf (st :dirty) (list t t nil)))
|
||||
;; v0.7.0: Ctrl key bindings
|
||||
((eql ch 3) ; Ctrl+C — interrupt/abort/exit cascade
|
||||
(add-msg :system "[Ctrl+C: send /abort to interrupt, press again to exit]"))
|
||||
@@ -179,18 +192,63 @@ Event handlers + daemon I/O + main loop.
|
||||
(setf (st :input-buffer) nil)
|
||||
(setf (st :cursor-pos) 0)
|
||||
(setf (st :dirty) (list t t t))))))
|
||||
;; Tab — command completion
|
||||
;; Tab — command completion (v0.7.0: extended with subcommand + file paths)
|
||||
((or (eql ch 9) (eq ch :tab))
|
||||
(let ((text (input-string)))
|
||||
(cond
|
||||
((and (>= (length text) 8)
|
||||
;; @ prefix — file path completion from memex/projects
|
||||
((and (>= (length text) 1) (eql (char text 0) #\@))
|
||||
(let* ((partial (subseq text 1))
|
||||
(proj-dir (merge-pathnames
|
||||
(make-pathname :directory '(:relative "projects"))
|
||||
(or (uiop:getenv "MEMEX_DIR")
|
||||
(namestring (merge-pathnames "memex/" (user-homedir-pathname))))))
|
||||
(org-files (handler-case (uiop:directory-files proj-dir "**/*.org")
|
||||
(error () nil)))
|
||||
(lisp-files (handler-case (uiop:directory-files proj-dir "**/*.lisp")
|
||||
(error () nil)))
|
||||
(all-files (mapcar #'namestring (append org-files lisp-files)))
|
||||
(short-names (mapcar (lambda (f)
|
||||
(subseq f (1+ (length (namestring proj-dir)))))
|
||||
all-files))
|
||||
(match (find-if (lambda (n)
|
||||
(and (>= (length n) (length partial))
|
||||
(string-equal n partial :end2 (length partial))))
|
||||
short-names)))
|
||||
(when match
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "@" match) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
;; /theme subcommand completion
|
||||
((and (>= (length text) 7)
|
||||
(string-equal (subseq text 0 7) "/theme "))
|
||||
(let* ((partial (subseq text 7))
|
||||
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
||||
(names '("dark" "light" "solarized" "gruvbox"))
|
||||
(match (find partial names :test #'string-equal)))
|
||||
(match (if (string= partial "")
|
||||
(first names)
|
||||
(find partial names :test #'string-equal))))
|
||||
(when match
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
;; /focus subcommand — project directory completion
|
||||
((and (>= (length text) 7)
|
||||
(string-equal (subseq text 0 7) "/focus "))
|
||||
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
||||
(memex-dir (or (uiop:getenv "MEMEX_DIR")
|
||||
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
|
||||
(proj-dir (merge-pathnames (make-pathname :directory '(:relative "projects")) memex-dir))
|
||||
(dirs (handler-case (mapcar (lambda (d) (car (last (pathname-directory d))))
|
||||
(uiop:subdirectories proj-dir))
|
||||
(error () nil)))
|
||||
(match (if (string= partial "")
|
||||
(first dirs)
|
||||
(find-if (lambda (d)
|
||||
(and (>= (length d) (length partial))
|
||||
(string-equal d partial :end2 (length partial))))
|
||||
dirs))))
|
||||
(when match
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/focus " match) 'list)))
|
||||
(setf (st :dirty) (list nil nil t)))))
|
||||
;; Command completion — /prefix
|
||||
((and (> (length text) 1) (eql (char text 0) #\/))
|
||||
(let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit"))
|
||||
(match (find text cmds :test
|
||||
@@ -452,7 +510,8 @@ Event handlers + daemon I/O + main loop.
|
||||
(fiveam:is (eq :chat (st :mode)))
|
||||
(fiveam:is (eq nil (st :connected)))
|
||||
(fiveam:is (eq nil (st :stream)))
|
||||
(fiveam:is (eq nil (st :messages)))
|
||||
(fiveam:is (vectorp (st :messages)))
|
||||
(fiveam:is (= 0 (length (st :messages))))
|
||||
(fiveam:is (eq 0 (st :scroll-offset)))
|
||||
(fiveam:is (eq nil (st :busy))))
|
||||
|
||||
@@ -654,4 +713,56 @@ Event handlers + daemon I/O + main loop.
|
||||
(fiveam:is (eq t (first d)))
|
||||
(fiveam:is (eq t (second d)))
|
||||
(fiveam:is (eq t (third d)))))
|
||||
|
||||
(fiveam:test test-on-key-ctrl-x-e-editor
|
||||
"Contract 1/v0.7.0: Ctrl+X then Ctrl+E triggers external editor workflow."
|
||||
(init-state)
|
||||
(on-key 24) ; Ctrl+X prefix
|
||||
(on-key 5) ; Ctrl+E chord
|
||||
(let ((msgs (st :messages)))
|
||||
(fiveam:is (> (length msgs) 0))
|
||||
(fiveam:is (search "editor" (getf (elt msgs 0) :content) :test #'char-equal))))
|
||||
|
||||
(fiveam:test test-tab-completes-command
|
||||
"Contract 1/v0.7.0: Tab completes /the to /theme."
|
||||
(init-state)
|
||||
(dolist (ch (coerce "/the" 'list))
|
||||
(on-key (char-code ch)))
|
||||
(on-key 9) ; Tab
|
||||
(fiveam:is (search "/theme" (input-string))))
|
||||
|
||||
(fiveam:test test-tab-completes-subcommand
|
||||
"Contract 1/v0.7.0: /theme + Tab lists theme names."
|
||||
(init-state)
|
||||
(dolist (ch (coerce "/theme " 'list))
|
||||
(on-key (char-code ch)))
|
||||
(on-key 9) ; Tab — should expand to a theme name
|
||||
(let ((s (input-string)))
|
||||
(fiveam:is (or (search "dark" s) (search "light" s) (search "solarized" s) (search "gruvbox" s)))))
|
||||
|
||||
(fiveam:test test-tab-file-path-match
|
||||
"Contract 1/v0.7.0: @ followed by Tab finds file completions or leaves input unchanged."
|
||||
(init-state)
|
||||
(dolist (ch (coerce "@core" 'list))
|
||||
(on-key (char-code ch)))
|
||||
(let ((before (input-string)))
|
||||
(on-key 9) ; Tab — should find "core-*.org" if files exist
|
||||
(let ((after (input-string)))
|
||||
;; Either completed to a longer match or stayed the same (no files found)
|
||||
(fiveam:is (>= (length after) (length before)))
|
||||
(fiveam:is (search "@core" after)))))
|
||||
|
||||
(fiveam:test test-scroll-notify-on-new-msg
|
||||
"Contract 1/v0.7.0: add-msg sets :scroll-notify when user is scrolled up."
|
||||
(init-state)
|
||||
;; User scrolls up — not at bottom
|
||||
(setf (st :scroll-at-bottom) nil
|
||||
(st :scroll-notify) nil)
|
||||
(add-msg :agent "new message while scrolled up")
|
||||
(fiveam:is (eq t (st :scroll-notify)))
|
||||
;; Reset: user scrolls back to bottom
|
||||
(setf (st :scroll-at-bottom) t
|
||||
(st :scroll-notify) nil)
|
||||
(add-msg :agent "message while at bottom")
|
||||
(fiveam:is (eq nil (st :scroll-notify))))
|
||||
#+end_src
|
||||
|
||||
@@ -10,6 +10,9 @@ All state mutation flows through event handlers in the controller.
|
||||
|
||||
1. (init-state): returns a fresh state plist with ~:msgs~ list,
|
||||
~:input~ buffer, ~:dirty~ flag, ~:busy~ flag, and ~:connection~ status.
|
||||
v0.7.0: ~:scroll-at-bottom~ flag tracks whether the user is scrolled to
|
||||
the bottom. ~add-msg~ sets ~:scroll-notify~ t when a new message arrives
|
||||
and the user is scrolled up.
|
||||
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).
|
||||
@@ -132,6 +135,8 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
|
||||
: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)
|
||||
:dirty (list nil nil nil))))
|
||||
#+end_src
|
||||
|
||||
@@ -166,6 +171,9 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
|
||||
|
||||
(defun add-msg (role content &key gate-trace)
|
||||
(vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace) (st :messages))
|
||||
;; v0.7.0: if scrolled up, set notification flag
|
||||
(unless (st :scroll-at-bottom)
|
||||
(setf (st :scroll-notify) t))
|
||||
(setf (st :dirty) (list t t nil)))
|
||||
#+end_src
|
||||
|
||||
|
||||
Reference in New Issue
Block a user