Add Ctrl-U attach-file flow to the TUI

ober

749ec1f82ed7c8413e1a9348d7f67ec2db1fadbf

diff --git a/signal/tui/ffi.ss b/signal/tui/ffi.ss
index 8df15de..e33ff54 100644
--- a/signal/tui/ffi.ss
+++ b/signal/tui/ffi.ss
@@ -21,7 +21,7 @@
     TB_KEY_MOUSE_WHEEL_UP TB_KEY_MOUSE_WHEEL_DOWN
     TB_KEY_CTRL_A TB_KEY_CTRL_B TB_KEY_CTRL_C TB_KEY_CTRL_D
     TB_KEY_CTRL_E TB_KEY_CTRL_F TB_KEY_CTRL_J TB_KEY_CTRL_K TB_KEY_CTRL_L
-    TB_KEY_CTRL_N TB_KEY_CTRL_Q
+    TB_KEY_CTRL_N TB_KEY_CTRL_Q TB_KEY_CTRL_U
     TB_MOD_ALT TB_MOD_CTRL TB_MOD_SHIFT
     TB_INPUT_ESC TB_INPUT_MOUSE
     TB_OUTPUT_TRUECOLOR
@@ -155,6 +155,7 @@
   (def TB_KEY_CTRL_L #x0C)
   (def TB_KEY_CTRL_N #x0E)
   (def TB_KEY_CTRL_Q #x11)
+  (def TB_KEY_CTRL_U #x15)
 
   (def TB_MOD_ALT 1)
   (def TB_MOD_CTRL 2)
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 0c6b3f6..37b2ba3 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -519,6 +519,8 @@
           (handle-confirm-delete-event! state actor ev)]
          [(eq? (tui-state-mode state) 'search)
           (handle-search-event! state ev)]
+         [(eq? (tui-state-mode state) 'attach)
+          (handle-attach-event! state actor ev)]
          [else (handle-chat-event! state actor ev)])]
       [else (void)]))
 
@@ -537,6 +539,8 @@
          (open-new-message! state)]
         [(= key TB_KEY_CTRL_F)
          (open-search! state)]
+        [(= key TB_KEY_CTRL_U)
+         (open-attach! state)]
         [(= key TB_KEY_CTRL_D)
          (open-delete-confirm! state)]
         [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2))
@@ -762,14 +766,134 @@
     (and (hashtable? result) (hashtable-ref result "timestamp" #f)))
 
   (def (make-send-params-for-conversation conv text)
+    (make-send-params conv text '()))
+
+  ;; signal-cli's "send" maps "attachment" to a list of file paths (or data
+  ;; URIs). Message is optional when an attachment is present, so we omit an
+  ;; empty caption rather than sending a blank body.
+  (def (make-send-params conv text attachments)
     (let ([p (make-hashtable equal-hash equal?)]
           [target (conversation-target conv)])
       (if (eq? (conversation-kind conv) 'group)
         (hashtable-set! p "groupId" target)
         (hashtable-set! p "recipient" (list target)))
-      (hashtable-set! p "message" text)
+      (when (non-empty-string? text)
+        (hashtable-set! p "message" text))
+      (when (pair? attachments)
+        (hashtable-set! p "attachment" attachments))
       p))
 
+  ;; --- Attach a file (Ctrl-U) ---
+
+  (def (open-attach! state)
+    (let ([conv (selected-conversation state)])
+      (cond
+        [(not conv)
+         (tui-state-status-set! state "No conversation selected.")]
+        [(or (eq? (conversation-kind conv) 'system)
+             (not (conversation-target conv)))
+         (tui-state-status-set! state "Select a Signal conversation first.")]
+        [else
+         (tui-state-mode-set! state 'attach)
+         (tui-state-picker-query-set! state "")
+         (tui-state-status-set! state "Attach a file: type a path, Enter to send.")])))
+
+  (def (close-attach! state)
+    (tui-state-mode-set! state 'chat)
+    (tui-state-picker-query-set! state "")
+    (tui-state-status-set! state "Attach cancelled."))
+
+  (def (handle-attach-event! state actor ev)
+    (let ([key (tui-event-key ev)]
+          [ch (tui-event-ch ev)])
+      (cond
+        [(= key TB_KEY_ESC)
+         (close-attach! state)]
+        [(or (= key TB_KEY_CTRL_C) (= key TB_KEY_CTRL_Q))
+         (tui-state-quit?-set! state #t)]
+        [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2))
+         (tui-state-picker-query-set!
+           state (delete-last-char (tui-state-picker-query state)))]
+        [(= key TB_KEY_CTRL_K)
+         (tui-state-picker-query-set! state "")]
+        [(= key TB_KEY_ENTER)
+         (send-attachment! state actor)]
+        [(and (> ch 0) (>= ch 32))
+         (tui-state-picker-query-set!
+           state
+           (string-append (tui-state-picker-query state)
+                          (string (integer->char ch))))]
+        [else (void)])))
+
+  (def (send-attachment! state actor)
+    (let* ([conv (selected-conversation state)]
+           [path (expand-user-path (trim-spaces (tui-state-picker-query state)))]
+           [caption (tui-state-input state)])
+      (cond
+        [(not conv)
+         (tui-state-status-set! state "No conversation selected.")]
+        [(string=? path "")
+         (tui-state-status-set! state "Enter a file path to attach.")]
+        [(not (file-exists? path))
+         (tui-state-status-set!
+           state (string-append "No such file: " path))]
+        [else
+         (tui-state-status-set! state "Sending file...")
+         (guard (e [#t
+                    (tui-state-status-set!
+                      state
+                      (string-append "Send failed: " (safe-display e)))])
+           (let* ([result (actor-call actor "send"
+                                      (make-send-params conv caption (list path)))]
+                  [ts (send-result-timestamp result)]
+                  [label (attachment-label path caption)])
+             (append-message-to-conversation!
+               conv
+               (make-chat-message 'out "You" label ts 'data 'sent))
+             (capture-outbound! (tui-state-logdb state)
+                                (tui-state-account state)
+                                (conversation-id conv) label ts))
+           (tui-state-input-set! state "")
+           (tui-state-mode-set! state 'chat)
+           (tui-state-picker-query-set! state "")
+           (conversation-unread-set! conv 0)
+           (tui-state-status-set! state "File sent."))])))
+
+  (def (attachment-label path caption)
+    (let ([base (string-append "[file: " (path-basename path) "]")])
+      (if (non-empty-string? caption)
+        (string-append base " " caption)
+        base)))
+
+  (def (expand-user-path path)
+    (if (and (> (string-length path) 0)
+             (char=? (string-ref path 0) #\~))
+      (let ([home (getenv "HOME")])
+        (if (non-empty-string? home)
+          (string-append home (substring path 1 (string-length path)))
+          path))
+      path))
+
+  (def (path-basename path)
+    (let loop ([i (- (string-length path) 1)])
+      (cond
+        [(< i 0) path]
+        [(char=? (string-ref path i) #\/)
+         (substring path (+ i 1) (string-length path))]
+        [else (loop (- i 1))])))
+
+  (def (trim-spaces s)
+    (let* ([n (string-length s)]
+           [start (let loop ([i 0])
+                    (if (and (< i n) (char-whitespace? (string-ref s i)))
+                      (loop (+ i 1))
+                      i))]
+           [end (let loop ([i n])
+                  (if (and (> i start) (char-whitespace? (string-ref s (- i 1))))
+                    (loop (- i 1))
+                    i))])
+      (substring s start end)))
+
   ;; --- Delete conversation ---
 
   (def (open-delete-confirm! state)
@@ -1034,6 +1158,8 @@
          (draw-new-message-page! state 0 0 w status-y)]
         [(eq? (tui-state-mode state) 'search)
          (draw-search-page! state 0 0 w status-y)]
+        [(eq? (tui-state-mode state) 'attach)
+         (draw-attach-page! state 0 0 w status-y)]
         [else
          (draw-panel! 0 0 left-w body-h "Conversations")
          (draw-panel! thread-x 0 thread-w body-h "Messages")
@@ -1137,6 +1263,22 @@
                    "  "
                    (conversation-kind-label conv)))
 
+  (def (draw-attach-page! state x y width height)
+    (draw-panel! x y width height "Attach file")
+    (draw-text! (+ x 2) (+ y 2) 6 (fg-strong) (panel-bg) "File:")
+    (fill-rect! (+ x 8) (+ y 2) (max 1 (- width 10)) 1 (fg) (input-bg))
+    (draw-text! (+ x 9) (+ y 2) (max 1 (- width 12)) (fg-strong) (input-bg)
+                (tui-state-picker-query state))
+    (draw-text! (+ x 2) (+ y 4) (- width 4) (fg-dim) (panel-bg)
+                "Type or paste a path. ~ expands to home.")
+    (let ([cap (tui-state-input state)])
+      (draw-text! (+ x 2) (+ y 5) (- width 4) (fg-dim) (panel-bg)
+                  (if (non-empty-string? cap)
+                    (string-append "Caption: " cap)
+                    "No caption (type in the composer before Ctrl-U to add one).")))
+    (draw-text! (+ x 2) (+ y 7) (- width 4) (fg-accent) (panel-bg)
+                "Enter  send       Esc  cancel"))
+
   (def (draw-search-page! state x y width height)
     (draw-panel! x y width height "Search session history")
     (draw-text! (+ x 2) (+ y 2) 6 (fg-strong) (panel-bg) "Find:")
@@ -1246,7 +1388,7 @@
     (draw-text! (+ x 1) y (- width 2) (status-fg) (status-bg)
                 (string-append "TUI | "
                                (tui-state-status state)
-                               " | Ctrl-N new  Ctrl-F find  Shift-Up/Dn move  Ctrl-D del  q quit")))
+                               " | Ctrl-N new  Ctrl-F find  Ctrl-U attach  Ctrl-D del  q quit")))
 
   (def (draw-cursor! state width composer-y)
     (cond
@@ -1258,6 +1400,10 @@
        (let* ([query-len (string-length (tui-state-picker-query state))]
               [cursor-x (min (- width 2) (+ 9 query-len))])
          (tb-set-cursor! cursor-x 2))]
+      [(eq? (tui-state-mode state) 'attach)
+       (let* ([query-len (string-length (tui-state-picker-query state))]
+              [cursor-x (min (- width 2) (+ 9 query-len))])
+         (tb-set-cursor! cursor-x 2))]
       [(eq? (tui-state-mode state) 'confirm-delete)
        (tb-hide-cursor!)]
       [else