updates

ober

4b51f602228edad1b63e6558a2f3677e3be467b2

diff --git a/src/jcode/ui/tui-ffi.ss b/src/jcode/ui/tui-ffi.ss
index 9956af0..4d677bc 100644
--- a/src/jcode/ui/tui-ffi.ss
+++ b/src/jcode/ui/tui-ffi.ss
@@ -179,19 +179,32 @@
 (def TB_KEY_MOUSE_WHEEL_DOWN (- #xFFFF 28))
 
 ;; Ctrl keys
-(def TB_KEY_CTRL_A #x01) (def TB_KEY_CTRL_B #x02)
-(def TB_KEY_CTRL_C #x03) (def TB_KEY_CTRL_D #x04)
-(def TB_KEY_CTRL_E #x05) (def TB_KEY_CTRL_F #x06)
-(def TB_KEY_CTRL_G #x07) (def TB_KEY_CTRL_H #x08)
-(def TB_KEY_CTRL_I #x09) (def TB_KEY_CTRL_J #x0A)
-(def TB_KEY_CTRL_K #x0B) (def TB_KEY_CTRL_L #x0C)
-(def TB_KEY_CTRL_M #x0D) (def TB_KEY_CTRL_N #x0E)
-(def TB_KEY_CTRL_O #x0F) (def TB_KEY_CTRL_P #x10)
-(def TB_KEY_CTRL_Q #x11) (def TB_KEY_CTRL_R #x12)
-(def TB_KEY_CTRL_S #x13) (def TB_KEY_CTRL_T #x14)
-(def TB_KEY_CTRL_U #x15) (def TB_KEY_CTRL_V #x16)
-(def TB_KEY_CTRL_W #x17) (def TB_KEY_CTRL_X #x18)
-(def TB_KEY_CTRL_Y #x19) (def TB_KEY_CTRL_Z #x1A)
+(def TB_KEY_CTRL_A #x01)
+(def TB_KEY_CTRL_B #x02)
+(def TB_KEY_CTRL_C #x03)
+(def TB_KEY_CTRL_D #x04)
+(def TB_KEY_CTRL_E #x05)
+(def TB_KEY_CTRL_F #x06)
+(def TB_KEY_CTRL_G #x07)
+(def TB_KEY_CTRL_H #x08)
+(def TB_KEY_CTRL_I #x09)
+(def TB_KEY_CTRL_J #x0A)
+(def TB_KEY_CTRL_K #x0B)
+(def TB_KEY_CTRL_L #x0C)
+(def TB_KEY_CTRL_M #x0D)
+(def TB_KEY_CTRL_N #x0E)
+(def TB_KEY_CTRL_O #x0F)
+(def TB_KEY_CTRL_P #x10)
+(def TB_KEY_CTRL_Q #x11)
+(def TB_KEY_CTRL_R #x12)
+(def TB_KEY_CTRL_S #x13)
+(def TB_KEY_CTRL_T #x14)
+(def TB_KEY_CTRL_U #x15)
+(def TB_KEY_CTRL_V #x16)
+(def TB_KEY_CTRL_W #x17)
+(def TB_KEY_CTRL_X #x18)
+(def TB_KEY_CTRL_Y #x19)
+(def TB_KEY_CTRL_Z #x1A)
 
 ;; Modifiers
 (def TB_MOD_ALT    #x01)
diff --git a/src/jcode/ui/tui-message.ss b/src/jcode/ui/tui-message.ss
index c4a16bb..2f6c10e 100644
--- a/src/jcode/ui/tui-message.ss
+++ b/src/jcode/ui/tui-message.ss
@@ -142,7 +142,7 @@
 (def (reflow-message! msg width)
   "Re-render message content into lines for given terminal width."
   (let ((lines (render-content (msg-block-role msg)
-                               (msg-block-content msg)
+                               (strip-terminal-escapes (msg-block-content msg))
                                (msg-block-tool-name msg)
                                (msg-block-tool-status msg)
                                (msg-block-collapsed? msg)
@@ -155,6 +155,62 @@
       (msg-block-height-set! msg (length wrapped))
       (msg-block-wrap-width-set! msg width))))
 
+(def *esc-char* (integer->char #x1b))
+(def *bel-char* (integer->char #x07))
+
+(def (ansi-final-byte? ch)
+  (let ((n (char->integer ch)))
+    (and (>= n #x40) (<= n #x7e))))
+
+(def (skip-csi text pos len)
+  (let loop ((i pos))
+    (cond
+      ((>= i len) len)
+      ((ansi-final-byte? (string-ref text i)) (+ i 1))
+      (else (loop (+ i 1))))))
+
+(def (skip-osc text pos len)
+  (let loop ((i pos))
+    (cond
+      ((>= i len) len)
+      ((char=? (string-ref text i) *bel-char*) (+ i 1))
+      ((and (char=? (string-ref text i) *esc-char*)
+            (< (+ i 1) len)
+            (char=? (string-ref text (+ i 1)) #\\))
+       (+ i 2))
+      (else (loop (+ i 1))))))
+
+(def (strip-terminal-escapes text)
+  "Remove ANSI/control sequences before rendering text through termbox."
+  (let ((out (open-output-string))
+        (len (string-length text)))
+    (let loop ((i 0))
+      (when (< i len)
+        (let ((ch (string-ref text i)))
+          (cond
+            ((char=? ch *esc-char*)
+             (let ((next (+ i 1)))
+               (cond
+                 ((>= next len)
+                  (loop next))
+                 ((char=? (string-ref text next) #\[)
+                  (loop (skip-csi text (+ next 1) len)))
+                 ((char=? (string-ref text next) #\])
+                  (loop (skip-osc text (+ next 1) len)))
+                 (else
+                  (loop (min len (+ i 2)))))))
+            ((or (< (char->integer ch) #x20)
+                 (= (char->integer ch) #x7f))
+             (if (or (char=? ch #\newline) (char=? ch #\tab))
+               (begin
+                 (write-char ch out)
+                 (loop (+ i 1)))
+               (loop (+ i 1))))
+            (else
+             (write-char ch out)
+             (loop (+ i 1)))))))
+    (get-output-string out)))
+
 (def (render-content role content tool-name tool-status collapsed? metadata thinking thinking-collapsed? width)
   (case role
     ((user)      (render-user-content content))
diff --git a/src/jcode/ui/tui-sidebar.ss b/src/jcode/ui/tui-sidebar.ss
index c45e4f3..2a3aaab 100644
--- a/src/jcode/ui/tui-sidebar.ss
+++ b/src/jcode/ui/tui-sidebar.ss
@@ -49,41 +49,57 @@
             (cloop (+ col 1))))
         (rloop (+ row 1))))
 
-    ;; Draw divider line on the left edge
-    (let dloop ((row y))
-      (when (< row (+ y height))
-        (tb-change-cell! x row (char->integer #\│) dfg bg)
-        (dloop (+ row 1))))
+    (when (> height 0)
+      (let* ((bottom-row (- (+ y height) 1))
+             (content-max-row (max y bottom-row)))
+        ;; Draw divider line on the left edge. The final row is reserved
+        ;; for the bottom border so the sidebar closes cleanly above status.
+        (let dloop ((row y))
+          (when (< row bottom-row)
+            (tb-change-cell! x row (char->integer #\│) dfg bg)
+            (dloop (+ row 1))))
+
+        (let* ((cx (+ x 2))         ;; content start x (past divider + padding)
+               (cw (- width 3))     ;; content width
+               (max-row content-max-row)
+               ;; Sessions section
+               (row (render-section! "Sessions" x y width max-row tfg tbg dfg bg #t))
+               (row (render-sessions! sidebar cx row cw max-row fg bg))
+               ;; Files section
+               (row (render-section! "Files Changed" x row width max-row tfg tbg dfg bg #f))
+               (row (render-files! sidebar cx row cw max-row fg bg))
+               ;; System section (above Tools so CPU/MEM/GPU stay visible)
+               (row (render-section! "System" x row width max-row tfg tbg dfg bg #f))
+               (row (render-system! sysmon memstats cx row cw max-row fg bg))
+               ;; Tools section
+               (row (render-section! "Tools" x row width max-row tfg tbg dfg bg #f))
+               (row (render-tools! sidebar cx row cw max-row fg bg))
+               ;; Connections section
+               (row (render-section! "Connections" x row width max-row tfg tbg dfg bg #f)))
+          (render-connections! sidebar cx row cw max-row fg bg))
+
+        (render-sidebar-bottom! x bottom-row width dfg bg)))))
 
-    (let* ((cx (+ x 2))         ;; content start x (past divider + padding)
-           (cw (- width 3))     ;; content width
-           (max-row (+ y height))
-           ;; Sessions section
-           (row (render-section! "Sessions" cx y cw max-row tfg tbg))
-           (row (render-sessions! sidebar cx row cw max-row fg bg))
-           ;; Files section
-           (row (+ row 1))
-           (row (render-section! "Files Changed" cx row cw max-row tfg tbg))
-           (row (render-files! sidebar cx row cw max-row fg bg))
-           ;; System section (above Tools so CPU/MEM/GPU stay visible)
-           (row (+ row 1))
-           (row (render-section! "System" cx row cw max-row tfg tbg))
-           (row (render-system! sysmon memstats cx row cw max-row fg bg))
-           ;; Tools section
-           (row (+ row 1))
-           (row (render-section! "Tools" cx row cw max-row tfg tbg))
-           (row (render-tools! sidebar cx row cw max-row fg bg))
-           ;; Connections section
-           (row (+ row 1))
-           (row (render-section! "Connections" cx row cw max-row tfg tbg)))
-      (render-connections! sidebar cx row cw max-row fg bg))))
+(def (render-sidebar-bottom! x row width dfg bg)
+  (when (> width 0)
+    (tb-change-cell! x row (char->integer #\└) dfg bg)
+    (when (> width 1)
+      (tb-print! (+ x 1) row dfg bg (make-string (- width 1) #\─)))))
 
-(def (render-section! title x row width max-row tfg tbg)
+(def (render-section! title x row width max-row tfg tbg dfg bg first?)
   (when (< row max-row)
-    (tb-print! x row tfg tbg title)
+    (tb-change-cell! x row
+                     (char->integer (if first? #\┌ #\├))
+                     dfg bg)
+    (when (> width 1)
+      (tb-print! (+ x 1) row dfg bg (make-string (- width 1) #\─)))
     (when (< (+ row 1) max-row)
-      (tb-print! x (+ row 1) (face-fg-attr 'horizontal-rule) tbg
-                 (make-string (min width 20) #\─))))
+      (let* ((title-x (+ x 2))
+             (title-width (max 1 (- width 3)))
+             (display-title (if (> (string-length title) title-width)
+                              (truncate-string title title-width)
+                              title)))
+        (tb-print! title-x (+ row 1) tfg tbg display-title))))
   (+ row 2))
 
 (def (render-sessions! sidebar x row width max-row fg bg)
diff --git a/test/run.ss b/test/run.ss
index 4f5a268..54e0561 100644
--- a/test/run.ss
+++ b/test/run.ss
@@ -36,6 +36,7 @@
         (jcode core verified-run)
         (jcode proxy convert)
         (jcode proxy handler)
+        (jcode ui tui-message)
         (jcode core slot-worker)
         (jcode proxy server)
         (jcode eval scenario)
@@ -96,6 +97,12 @@
              (with-output-to-string (lambda () (display-condition e)))])
     (call-with-values thunk (lambda vals #f))))
 
+(define (msg-block-plain-text m)
+  (apply string-append
+    (map (lambda (line)
+           (apply string-append (map car line)))
+         (msg-block-lines m))))
+
 (define (read-test-http-request in)
   (let header-loop ([content-length 0])
     (let ([line (get-line in)])
@@ -242,6 +249,20 @@
 (let ([r (tool-execute "bash" (args "command" "exit 42"))])
   (check-pred! "bash non-zero exit shown" r (lambda (s) (str-contains? s "Exit code"))))
 
+;; ── TUI rendering ─────────────────────────────────────────────────
+
+(section "=== tui rendering ===")
+
+(let* ([esc (integer->char #x1b)]
+       [colored (string-append "Error: "
+                               (string esc) "[91m"
+                               (string esc) "[1mboom"
+                               (string esc) "[0m ok")]
+       [m (msg-block-assistant colored)])
+  (check! "assistant strips ansi escapes"
+    (msg-block-plain-text m)
+    "Error: boom ok"))
+
 ;; ── Text-form tool call recovery ──────────────────────────────────
 ;; Local Ollama-hosted GGUF models (fine-tuned Qwen/Hermes etc.) often
 ;; emit tool calls as text in the content stream instead of via the