From 55e0c962f4399cdd15364640ea3f83843e55e6ae Mon Sep 17 00:00:00 2001 From: Amr Gharbeia Date: Fri, 8 May 2026 10:45:05 -0400 Subject: [PATCH] =?UTF-8?q?passepartout:=20v0.7.0=20=E2=80=94=20TUI=20Esse?= =?UTF-8?q?ntials:=20Terminal=20Parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- lisp/channel-tui-main.lisp | 120 +++++++++++++++++++++++++++++++++-- lisp/channel-tui-state.lisp | 5 ++ lisp/channel-tui-view.lisp | 6 +- org/channel-tui-main.org | 123 ++++++++++++++++++++++++++++++++++-- org/channel-tui-state.org | 8 +++ 5 files changed, 249 insertions(+), 13 deletions(-) diff --git a/lisp/channel-tui-main.lisp b/lisp/channel-tui-main.lisp index 11f03f9..ec3342a 100644 --- a/lisp/channel-tui-main.lisp +++ b/lisp/channel-tui-main.lisp @@ -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)))) diff --git a/lisp/channel-tui-state.lisp b/lisp/channel-tui-state.lisp index 0cc8ade..118ed02 100644 --- a/lisp/channel-tui-state.lisp +++ b/lisp/channel-tui-state.lisp @@ -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) diff --git a/lisp/channel-tui-view.lisp b/lisp/channel-tui-view.lisp index ead65a9..4206649 100644 --- a/lisp/channel-tui-view.lisp +++ b/lisp/channel-tui-view.lisp @@ -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) diff --git a/org/channel-tui-main.org b/org/channel-tui-main.org index 261b462..cea87e1 100644 --- a/org/channel-tui-main.org +++ b/org/channel-tui-main.org @@ -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 diff --git a/org/channel-tui-state.org b/org/channel-tui-state.org index 8c93096..7f0a577 100644 --- a/org/channel-tui-state.org +++ b/org/channel-tui-state.org @@ -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