Never auto-resend a failed message: clear composer on hand-off, Ctrl-R restores

ober

1243a76792cfd2da3aaef38ee513a910dea12462

diff --git a/signal/tui/ffi.ss b/signal/tui/ffi.ss
index e33ff54..797b612 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_U
+    TB_KEY_CTRL_N TB_KEY_CTRL_Q TB_KEY_CTRL_R 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_R #x12)
   (def TB_KEY_CTRL_U #x15)
 
   (def TB_MOD_ALT 1)
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 7c367cd..ef5c530 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -24,9 +24,13 @@
   (defstruct chat-message (direction sender text timestamp kind status))
   (defstruct conversation (id title kind target messages unread typing))
   (defstruct search-hit (conv-id conv-title message))
+  ;; resend holds the text of the last failed send (or #f). The composer is
+  ;; cleared the moment a message is handed to signal-cli, so a "failed" send
+  ;; can never be re-fired by mashing Enter; Ctrl-R restores it deliberately.
   (defstruct tui-state
     (account version receive-mode width height input status quit? event-count
-     conversations selected-index mode picker-query picker-index removed logdb))
+     conversations selected-index mode picker-query picker-index removed logdb
+     resend))
 
   (def (run-tui-terminal account actor version receive-mode)
     ;; Prompt for the log passphrase and open the DB BEFORE termbox grabs the
@@ -59,7 +63,8 @@
                                          ""
                                          0
                                          removed
-                                         logdb)])
+                                         logdb
+                                         #f)])
               (append-system-message!
                 state
                 (if logdb
@@ -551,6 +556,8 @@
          (open-search! state)]
         [(= key TB_KEY_CTRL_U)
          (open-attach! state)]
+        [(= key TB_KEY_CTRL_R)
+         (restore-failed-send! state)]
         [(= key TB_KEY_CTRL_D)
          (open-delete-confirm! state)]
         [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2))
@@ -754,23 +761,47 @@
              (not (conversation-target conv)))
          (tui-state-status-set! state "Select a Signal conversation before sending.")]
         [else
+         ;; Clear the composer BEFORE the call: once the request is written to
+         ;; signal-cli the message may be delivered even if the RPC reply is an
+         ;; error (partial/multi-device failures), so Enter must never re-fire
+         ;; the same text. On failure the text is stashed for Ctrl-R.
+         (tui-state-input-set! state "")
          (tui-state-status-set! state "Sending...")
-         (guard (e [#t
-                    (tui-state-status-set!
-                      state
-                      (string-append "Send failed: " (safe-display e)))])
-           (let* ([result (actor-call actor "send"
-                                      (make-send-params-for-conversation conv text))]
-                  [ts (send-result-timestamp result)])
-             (append-message-to-conversation!
-               conv
-               (make-chat-message 'out "You" text ts 'data 'sent))
-             (capture-outbound! (tui-state-logdb state)
-                                (tui-state-account state)
-                                (conversation-id conv) text ts))
-           (tui-state-input-set! state "")
-           (conversation-unread-set! conv 0)
-           (tui-state-status-set! state "Sent."))])))
+         (let ([outcome
+                (guard (e [#t (cons 'failed (safe-display e))])
+                  (cons 'sent (actor-call actor "send"
+                                          (make-send-params-for-conversation conv text))))])
+           (if (eq? (car outcome) 'sent)
+             (let ([ts (send-result-timestamp (cdr outcome))])
+               (append-message-to-conversation!
+                 conv
+                 (make-chat-message 'out "You" text ts 'data 'sent))
+               (capture-outbound! (tui-state-logdb state)
+                                  (tui-state-account state)
+                                  (conversation-id conv) text ts)
+               (tui-state-resend-set! state #f)
+               (conversation-unread-set! conv 0)
+               (tui-state-status-set! state "Sent."))
+             (begin
+               (tui-state-resend-set! state text)
+               (tui-state-status-set!
+                 state
+                 (string-append "Send failed (it may still have gone out): "
+                                (cdr outcome)
+                                " -- Ctrl-R restores the message.")))))])))
+
+  ;; Ctrl-R: put the last failed message back in the composer. Resending is a
+  ;; deliberate two-step (restore, then Enter), never an accident.
+  (def (restore-failed-send! state)
+    (let ([text (tui-state-resend state)])
+      (if text
+        (begin
+          (tui-state-input-set! state text)
+          (tui-state-resend-set! state #f)
+          (tui-state-status-set!
+            state
+            "Message restored. Enter resends it (it may arrive twice)."))
+        (tui-state-status-set! state "No failed message to restore."))))
 
   (def (send-result-timestamp result)
     (and (hashtable? result) (hashtable-ref result "timestamp" #f)))
@@ -848,26 +879,33 @@
          (tui-state-status-set!
            state (string-append "No such file: " path))]
         [else
+         ;; Leave attach mode and clear the path/caption BEFORE the call, for
+         ;; the same reason as send-composer-message!: a "failed" reply may
+         ;; still have delivered the file, so Enter must never re-fire it.
+         (tui-state-input-set! state "")
+         (tui-state-mode-set! state 'chat)
+         (tui-state-picker-query-set! state "")
          (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."))])))
+         (let ([outcome
+                (guard (e [#t (cons 'failed (safe-display e))])
+                  (cons 'sent (actor-call actor "send"
+                                          (make-send-params conv caption (list path)))))])
+           (if (eq? (car outcome) 'sent)
+             (let* ([ts (send-result-timestamp (cdr outcome))]
+                    [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)
+               (conversation-unread-set! conv 0)
+               (tui-state-status-set! state "File sent."))
+             (tui-state-status-set!
+               state
+               (string-append "Send failed (it may still have gone out): "
+                              (cdr outcome)
+                              " -- Ctrl-U to retry " path))))])))
 
   (def (attachment-label path caption)
     (let ([base (string-append "[file: " (path-basename path) "]")])