TUI: responsiveness during streaming, full completion list, reply timing

ober

40d5406ead5bdd1e819ac5935423b5bc3e9c9006

diff --git a/src/jcode/ui/tui-ffi.ss b/src/jcode/ui/tui-ffi.ss
index cdb8b2c..d645ef9 100644
--- a/src/jcode/ui/tui-ffi.ss
+++ b/src/jcode/ui/tui-ffi.ss
@@ -115,7 +115,11 @@
 (define-tb c-tb-printf     "jcode_tb_printf"     (int int unsigned-32 unsigned-32 string string) int)
 (define-tb c-tb-set-input  "jcode_tb_set_input_mode"  (int) int)
 (define-tb c-tb-set-output "jcode_tb_set_output_mode" (int) int)
-(define-tb c-tb-poll       "jcode_tb_poll_event" ()    int)
+;; __collect_safe: blocks indefinitely waiting for input.
+(def c-tb-poll
+  (if (and _shim-loaded (foreign-entry? "jcode_tb_poll_event"))
+    (foreign-procedure __collect_safe "jcode_tb_poll_event" () int)
+    (lambda args (error 'tui-ffi "termbox shim not loaded" "jcode_tb_poll_event"))))
 ;; __collect_safe: releases the GC/scheduler lock during the blocking poll,
 ;; allowing agent worker threads to run while we wait for terminal input.
 (def c-tb-peek
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index d4c54ac..11a3bd6 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -330,10 +330,6 @@
         (tui-log "event-loop: drained ~a agent events" n)
         (app-state-dirty?-set! state #t)))
 
-    ;; Force redraw during streaming (for spinner)
-    (when (app-state-agent-busy? state)
-      (app-state-dirty?-set! state #t))
-
     ;; Activity screen refreshes every tick: ages advance and background
     ;; runs keep logging even when this tab's agent is idle.
     (when (eq? (app-state-view state) 'activity)
@@ -360,11 +356,21 @@
            (tui-log "  -> mouse key=~a x=~a y=~a" (tui-event-key ev) (tui-event-x ev) (tui-event-y ev))
            (handle-mouse! state ev)))))
 
-    ;; Redraw if dirty
-    (when (app-state-dirty? state)
-      (draw-all! state)
-      (tb-present!)
-      (app-state-dirty?-set! state #f))
+    ;; Redraw. Full repaint only when content actually changed; while
+    ;; the agent is busy an otherwise-clean frame repaints JUST the
+    ;; spinner row. The old behavior forced draw-all! (tb-clear + full
+    ;; transcript repaint) on every 50ms tick during streaming -- the
+    ;; main thread spent the whole turn painting and keystrokes queued
+    ;; behind it. Drained events still set dirty, so streamed text and
+    ;; tool updates repaint normally.
+    (cond
+      ((app-state-dirty? state)
+       (draw-all! state)
+       (tb-present!)
+       (app-state-dirty?-set! state #f))
+      ((app-state-agent-busy? state)
+       (draw-spinner! state)
+       (tb-present!)))
 
     ;; Continue unless quitting
     (unless (app-state-quit? state)
@@ -1244,10 +1250,11 @@
              (tab-messages-set! t
                (append (tab-messages t) (list (msg-block-expert notice))))
              (tab-stream-buf-set! t notice)))
-          ((list 'agent-done)
+          ((list 'agent-done elapsed)
            (tab-busy?-set! t #f)
            (tab-unread?-set! t #t)
            (tab-stream-buf-set! t "")
+           (timing-stamp-last-reply! (tab-messages t) elapsed)
            (toast-add! 'info "Tab"
              (format "side conversation (tab ~a) finished" (car hit)))
            (app-state-dirty?-set! state #t))
@@ -1268,6 +1275,26 @@
            (app-state-dirty?-set! state #t))
           (_ (void)))))))
 
+(def (format-elapsed secs)
+  ;; "40s", or "2m30s" past a minute.
+  (if (< secs 60)
+    (format "~as" secs)
+    (format "~am~as" (quotient secs 60) (modulo secs 60))))
+
+(def (timing-stamp-last-reply! msgs elapsed)
+  ;; Append the turn's wall time to the final reply block, e.g. "40s"
+  ;; on its own trailing line. Returns the stamped block or #f.
+  (let loop ((ms (reverse msgs)))
+    (cond
+      ((null? ms) #f)
+      ((streamed-reply-role? (msg-block-role (car ms)))
+       (let ((m (car ms)))
+         (msg-block-content-set! m
+           (string-append (msg-block-content m)
+                          (format "\n\n~a" (format-elapsed elapsed))))
+         m))
+      (else (loop (cdr ms))))))
+
 (def (apply-agent-event-body! state ev)
   (match ev
     ((list 'stream-token token)
@@ -1284,15 +1311,18 @@
      (app-state-agent-busy?-set! state #f)
      (app-state-active-tools-set! state '())
      (app-state-dirty?-set! state #t))
-    ((list 'agent-done)
-     (tui-log "apply-agent-event: agent-done")
+    ((list 'agent-done elapsed)
+     (tui-log "apply-agent-event: agent-done elapsed=~a" elapsed)
      ;; Clear busy FIRST: a finalize error must never leave the spinner stuck.
      (app-state-agent-busy?-set! state #f)
      (app-state-active-tools-set! state '())
      (guard (e (#t (log-debug "tui"
                      (format "agent-done finalize ERROR: ~a"
                        (with-output-to-string (lambda () (display-condition e)))))))
-       (finalize-last-assistant! state))
+       (finalize-last-assistant! state)
+       ;; Stamp the turn's wall time at the end of the reply ("40s").
+       (let ((m (timing-stamp-last-reply! (app-state-messages state) elapsed)))
+         (when m (reflow-message! m (msg-area-width state)))))
      (app-state-scroll-offset-set! state 0)
      (app-state-dirty?-set! state #t))
     ((list 'agent-cancelled)
@@ -1375,6 +1405,7 @@
     ;; when the TTY's output buffer backs up.
     (let ((gen (tui-run-gen))
           (abort (cons #f #f))   ;; per-run abort cell (Esc on THIS tab only)
+          (t0 (time-second (current-time)))  ;; submit time, for reply stamp
           (err-port (current-error-port))
           (log-lvl  (current-log-level)))
       (register-agent-run! s-id gen abort)
@@ -1410,7 +1441,8 @@
               (tui-log "worker: calling agent-run")
               (agent-run s-id text)
               (tui-log "worker: agent-run returned, sending agent-done")
-              (send-run-event! s-id gen (list 'agent-done))
+              (send-run-event! s-id gen
+                (list 'agent-done (- (time-second (current-time)) t0)))
               (tui-log "worker: agent-done sent, worker exiting normally"))
             (catch (e)
               (let ((msg (err->string e)))