From 0861ac26f108290ba6f9f48f4b8b8f94bd39f282 Mon Sep 17 00:00:00 2001 From: Amr Gharbeia Date: Wed, 6 May 2026 17:14:49 -0400 Subject: [PATCH] =?UTF-8?q?v0.3.3:=20word=20wrap=20in=20view-chat=20?= =?UTF-8?q?=E2=80=94=20break=20at=20word=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds word-wrap(text width) — splits strings into lines at word boundaries respecting terminal width. Rewrites view-chat to: - Wrap each message with word-wrap before rendering - Render each wrapped line as a separate add-string call - Account for wrapped line count in visible-message calculation RED proof: tmux capture shows messages split mid-word at terminal edge. GREEN proof: tmux capture shows clean word-boundary wrapping: The quick brown fox jumps over the lazy dog while the cat naps peacefully in the sunny garden --- lisp/gateway-tui-view.lisp | 77 +++++++++++++++++++++++++++++--------- org/gateway-tui-view.org | 77 +++++++++++++++++++++++++++++--------- 2 files changed, 118 insertions(+), 36 deletions(-) diff --git a/lisp/gateway-tui-view.lisp b/lisp/gateway-tui-view.lisp index 3151e8c..7c9be8a 100644 --- a/lisp/gateway-tui-view.lisp +++ b/lisp/gateway-tui-view.lisp @@ -14,33 +14,74 @@ (add-string win (format nil " ~a" (now)) :y 2 :x 1 :fgcolor (theme-color :timestamp)) (refresh win)) +(defun word-wrap (text width) + "Break text into lines at word boundaries, each <= width chars. +Returns list of trimmed strings. Single words wider than width are split." + (let ((lines '()) + (pos 0) + (len (length text))) + (loop while (< pos len) + do (let ((end (min len (+ pos width)))) + (cond + ((>= end len) + (push (string-trim '(#\Space) (subseq text pos len)) lines) + (setf pos len)) + ((char= (char text (1- end)) #\Space) + (push (string-trim '(#\Space) (subseq text pos end)) lines) + (setf pos end)) + (t + (let ((last-space (position #\Space text :from-end t :end (1+ end) :start pos))) + (if (and last-space (> last-space pos)) + (progn + (push (string-trim '(#\Space) (subseq text pos last-space)) lines) + (setf pos (1+ last-space))) + (progn + (push (string-trim '(#\Space) (subseq text pos end)) lines) + (setf pos end)))))))) + (nreverse lines))) + (defun view-chat (win h) (clear win) (box win 0 0) (let* ((w (or (width win) 78)) (msgs (reverse (st :messages))) (max-lines (- h 2)) - (total (length msgs)) - (start (max 0 (- total max-lines (st :scroll-offset)))) (y 1)) - (loop for i from start below total - while (< y (1- h)) - do (let ((msg (nth i msgs))) - (let* ((role (getf msg :role)) + ;; Count visible messages from end, accounting for word wrap + (let* ((msg-count 0) + (lines-remaining max-lines)) + ;; Walk from most recent backwards, counting wrapped lines + (let ((visible-msgs (reverse msgs))) + (loop for msg in visible-msgs + while (> lines-remaining 0) + do (let* ((role (getf msg :role)) (content (getf msg :content)) (time (or (getf msg :time) "")) - (label (case role - (:user (format nil "⬆ [~a] ~a" time content)) - (:agent (format nil "⬇ [~a] ~a" time content)) - (:system (format nil " [~a] ~a" time content)) - (t (format nil " [~a] ~a" time content)))) - (color (theme-color (case role - (:user :user) - (:agent :agent) - (:system :system) - (t :agent))))) - (add-string win label :y y :x 1 :n (1- w) :fgcolor color) - (incf y))))) + (prefix (case role (:user "⬆") (:agent "⬇") (t " "))) + (line-text (format nil "~a [~a] ~a" prefix time content)) + (wrapped (word-wrap line-text (- w 2))) + (nlines (length wrapped))) + (if (<= nlines lines-remaining) + (progn (decf lines-remaining nlines) (incf msg-count)) + (setf lines-remaining 0)))) + ;; Render from the correct starting message + (let* ((total (length msgs)) + (scroll-skip (st :scroll-offset)) + (start (max 0 (- total msg-count scroll-skip)))) + (loop for i from start below total + while (< y (1- h)) + do (let* ((msg (nth i msgs)) + (role (getf msg :role)) + (content (getf msg :content)) + (time (or (getf msg :time) "")) + (color (theme-color (case role (:user :user) (:agent :agent) (:system :system) (t :agent)))) + (prefix (case role (:user "⬆") (:agent "⬇") (t " "))) + (line-text (format nil "~a [~a] ~a" prefix time content)) + (wrapped (word-wrap line-text (- w 2)))) + (dolist (line wrapped) + (when (< y (1- h)) + (add-string win line :y y :x 1 :n (1- w) :fgcolor color) + (incf y))))))))) (refresh win)) (defun view-input (win) diff --git a/org/gateway-tui-view.org b/org/gateway-tui-view.org index 201ca98..512d59f 100644 --- a/org/gateway-tui-view.org +++ b/org/gateway-tui-view.org @@ -39,33 +39,74 @@ State is read via ~(st :key)~ — no mutation here. ** Chat Area #+begin_src lisp +(defun word-wrap (text width) + "Break text into lines at word boundaries, each <= width chars. +Returns list of trimmed strings. Single words wider than width are split." + (let ((lines '()) + (pos 0) + (len (length text))) + (loop while (< pos len) + do (let ((end (min len (+ pos width)))) + (cond + ((>= end len) + (push (string-trim '(#\Space) (subseq text pos len)) lines) + (setf pos len)) + ((char= (char text (1- end)) #\Space) + (push (string-trim '(#\Space) (subseq text pos end)) lines) + (setf pos end)) + (t + (let ((last-space (position #\Space text :from-end t :end (1+ end) :start pos))) + (if (and last-space (> last-space pos)) + (progn + (push (string-trim '(#\Space) (subseq text pos last-space)) lines) + (setf pos (1+ last-space))) + (progn + (push (string-trim '(#\Space) (subseq text pos end)) lines) + (setf pos end)))))))) + (nreverse lines))) + (defun view-chat (win h) (clear win) (box win 0 0) (let* ((w (or (width win) 78)) (msgs (reverse (st :messages))) (max-lines (- h 2)) - (total (length msgs)) - (start (max 0 (- total max-lines (st :scroll-offset)))) (y 1)) - (loop for i from start below total - while (< y (1- h)) - do (let ((msg (nth i msgs))) - (let* ((role (getf msg :role)) + ;; Count visible messages from end, accounting for word wrap + (let* ((msg-count 0) + (lines-remaining max-lines)) + ;; Walk from most recent backwards, counting wrapped lines + (let ((visible-msgs (reverse msgs))) + (loop for msg in visible-msgs + while (> lines-remaining 0) + do (let* ((role (getf msg :role)) (content (getf msg :content)) (time (or (getf msg :time) "")) - (label (case role - (:user (format nil "⬆ [~a] ~a" time content)) - (:agent (format nil "⬇ [~a] ~a" time content)) - (:system (format nil " [~a] ~a" time content)) - (t (format nil " [~a] ~a" time content)))) - (color (theme-color (case role - (:user :user) - (:agent :agent) - (:system :system) - (t :agent))))) - (add-string win label :y y :x 1 :n (1- w) :fgcolor color) - (incf y))))) + (prefix (case role (:user "⬆") (:agent "⬇") (t " "))) + (line-text (format nil "~a [~a] ~a" prefix time content)) + (wrapped (word-wrap line-text (- w 2))) + (nlines (length wrapped))) + (if (<= nlines lines-remaining) + (progn (decf lines-remaining nlines) (incf msg-count)) + (setf lines-remaining 0)))) + ;; Render from the correct starting message + (let* ((total (length msgs)) + (scroll-skip (st :scroll-offset)) + (start (max 0 (- total msg-count scroll-skip)))) + (loop for i from start below total + while (< y (1- h)) + do (let* ((msg (nth i msgs)) + (role (getf msg :role)) + (content (getf msg :content)) + (time (or (getf msg :time) "")) + (color (theme-color (case role (:user :user) (:agent :agent) (:system :system) (t :agent)))) + (prefix (case role (:user "⬆") (:agent "⬇") (t " "))) + (line-text (format nil "~a [~a] ~a" prefix time content)) + (wrapped (word-wrap line-text (- w 2)))) + (dolist (line wrapped) + (when (< y (1- h)) + (add-string win line :y y :x 1 :n (1- w) :fgcolor color) + (incf y))))))))) (refresh win)) #+end_src