Add TUI delete, cross-conversation search, and reorder

ober

987c8ed609cdc020009094f24b7af6af6f88be11

diff --git a/README.md b/README.md
index fc97fd9..3cbb89c 100644
--- a/README.md
+++ b/README.md
@@ -62,14 +62,28 @@ make tui-shim
 ```
 
 The TUI opens in `signal-cli jsonRpc --receive-mode on-connection`, so new
-Signal events arrive while it is running. At startup it preloads active groups
+Signal events arrive while it is running. Shift+Up / Shift+Down move the
+selected conversation up or down the list, so you can keep the people you talk
+to at the top and push the rest down (your terminal must forward modified arrow
+keys; ordering is per-session since history is in memory). At startup it
+preloads active groups
 and known contacts into the conversation pane. The current interface keeps
 in-memory conversations for this session, routes incoming
 data/sync/receipt/typing events into threads, and sends plain text from the
 composer to the selected conversation with Enter. Use Up/Down to change the
 selected conversation. Use Ctrl-N to open the new-message picker, type to
-filter contacts/groups or enter a `+number`, and press Enter to select. Use q,
-Esc, or Ctrl-C to quit.
+filter contacts/groups or enter a `+number`, and press Enter to select. Use
+Ctrl-F to search message history across all conversations at once: type a
+query, use Up/Down to move through matches, and press Enter to jump to the
+conversation holding that message. Search matches message text and sender, and
+covers everything received or sent during the current session — history is
+in-memory only, so it does not include messages from before the TUI was
+opened. Use
+Ctrl-D to delete the selected conversation; a y/n prompt confirms first. For a
+direct contact this calls signal-cli `removeContact --forget`, which erases all
+local Signal data for that contact (identity keys, sessions, profile) — use it
+to remove someone entirely. For a group it just drops the conversation from the
+list. Use q, Esc, or Ctrl-C to quit.
 
 ## Architecture
 
diff --git a/signal/tui/ffi.ss b/signal/tui/ffi.ss
index 9ea2bb3..8df15de 100644
--- a/signal/tui/ffi.ss
+++ b/signal/tui/ffi.ss
@@ -20,8 +20,9 @@
     TB_KEY_ESC TB_KEY_SPACE
     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_J TB_KEY_CTRL_K TB_KEY_CTRL_L
+    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_MOD_ALT TB_MOD_CTRL TB_MOD_SHIFT
     TB_INPUT_ESC TB_INPUT_MOUSE
     TB_OUTPUT_TRUECOLOR
     TB_DEFAULT TB_BLACK TB_RED TB_GREEN TB_YELLOW
@@ -148,12 +149,17 @@
   (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_J #x0A)
   (def TB_KEY_CTRL_K #x0B)
   (def TB_KEY_CTRL_L #x0C)
   (def TB_KEY_CTRL_N #x0E)
   (def TB_KEY_CTRL_Q #x11)
 
+  (def TB_MOD_ALT 1)
+  (def TB_MOD_CTRL 2)
+  (def TB_MOD_SHIFT 4)
+
   (def TB_INPUT_ESC 1)
   (def TB_INPUT_MOUSE 4)
   (def TB_OUTPUT_TRUECOLOR 5)
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 2c16481..2253866 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -19,6 +19,7 @@
 
   (defstruct chat-message (direction sender text timestamp kind))
   (defstruct conversation (id title kind target messages unread))
+  (defstruct search-hit (conv-id conv-title message))
   (defstruct tui-state
     (account version receive-mode width height input status quit? event-count
      conversations selected-index mode picker-query picker-index))
@@ -273,6 +274,39 @@
       [(>= index count) (- count 1)]
       [else index]))
 
+  ;; Reorder the conversation list so the user can keep the people they talk to
+  ;; at the top. Shift+Up/Down moves the selected conversation by one slot; the
+  ;; selection follows the moved item so it can be nudged repeatedly. Order is
+  ;; session-only for now, since history is in memory.
+  (def (move-conversation! state delta)
+    (let* ([conversations (tui-state-conversations state)]
+           [count (length conversations)]
+           [from (clamp-index (tui-state-selected-index state) count)]
+           [to (+ from delta)])
+      (cond
+        [(< count 2) (void)]
+        [(< to 0) (tui-state-status-set! state "Already at the top.")]
+        [(>= to count) (tui-state-status-set! state "Already at the bottom.")]
+        [else
+         (let ([reordered (swap-list conversations from to)])
+           (tui-state-conversations-set! state reordered)
+           (tui-state-selected-index-set! state to)
+           (tui-state-status-set!
+             state
+             (string-append "Moved "
+                            (conversation-title (list-ref reordered to))
+                            (if (< delta 0) " up." " down."))))])))
+
+  (def (swap-list xs i j)
+    (let ([v (list->vector xs)])
+      (let ([tmp (vector-ref v i)])
+        (vector-set! v i (vector-ref v j))
+        (vector-set! v j tmp))
+      (vector->list v)))
+
+  (def (shift? mod)
+    (not (= (bitwise-and mod TB_MOD_SHIFT) 0)))
+
   (def (non-empty-string? s)
     (and (string? s) (not (string=? s ""))))
 
@@ -356,14 +390,20 @@
        (tui-state-width-set! state (tui-event-w ev))
        (tui-state-height-set! state (tui-event-h ev))]
       [(tui-event-key? ev)
-       (if (eq? (tui-state-mode state) 'new-message)
-         (handle-new-message-event! state ev)
-         (handle-chat-event! state actor ev))]
+       (cond
+         [(eq? (tui-state-mode state) 'new-message)
+          (handle-new-message-event! state ev)]
+         [(eq? (tui-state-mode state) 'confirm-delete)
+          (handle-confirm-delete-event! state actor ev)]
+         [(eq? (tui-state-mode state) 'search)
+          (handle-search-event! state ev)]
+         [else (handle-chat-event! state actor ev)])]
       [else (void)]))
 
   (def (handle-chat-event! state actor ev)
     (let ([key (tui-event-key ev)]
-          [ch (tui-event-ch ev)])
+          [ch (tui-event-ch ev)]
+          [mod (tui-event-mod ev)])
       (cond
         [(or (= key TB_KEY_ESC)
              (= key TB_KEY_CTRL_C)
@@ -373,15 +413,23 @@
          (tui-state-quit?-set! state #t)]
         [(= key TB_KEY_CTRL_N)
          (open-new-message! state)]
+        [(= key TB_KEY_CTRL_F)
+         (open-search! state)]
+        [(= key TB_KEY_CTRL_D)
+         (open-delete-confirm! state)]
         [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2))
          (delete-input-char! state)]
         [(= key TB_KEY_CTRL_K)
          (tui-state-input-set! state "")
          (tui-state-status-set! state "Composer cleared.")]
         [(= key TB_KEY_ARROW_UP)
-         (select-conversation! state (- (tui-state-selected-index state) 1))]
+         (if (shift? mod)
+           (move-conversation! state -1)
+           (select-conversation! state (- (tui-state-selected-index state) 1)))]
         [(= key TB_KEY_ARROW_DOWN)
-         (select-conversation! state (+ (tui-state-selected-index state) 1))]
+         (if (shift? mod)
+           (move-conversation! state 1)
+           (select-conversation! state (+ (tui-state-selected-index state) 1)))]
         [(= key TB_KEY_ENTER)
          (if (string=? (tui-state-input state) "")
            (tui-state-status-set! state "No message entered.")
@@ -591,6 +639,219 @@
       (hashtable-set! p "message" text)
       p))
 
+  ;; --- Delete conversation ---
+
+  (def (open-delete-confirm! state)
+    (let ([conv (selected-conversation state)])
+      (cond
+        [(not conv)
+         (tui-state-status-set! state "No conversation selected.")]
+        [(eq? (conversation-kind conv) 'system)
+         (tui-state-status-set! state "The System view cannot be deleted.")]
+        [else
+         (tui-state-mode-set! state 'confirm-delete)
+         (tui-state-status-set!
+           state
+           (string-append "Delete " (conversation-title conv)
+                          "? Press y to confirm, n to cancel."))])))
+
+  (def (cancel-delete! state)
+    (tui-state-mode-set! state 'chat)
+    (tui-state-status-set! state "Delete cancelled."))
+
+  (def (handle-confirm-delete-event! state actor ev)
+    (let ([key (tui-event-key ev)]
+          [ch (tui-event-ch ev)])
+      (cond
+        [(= key TB_KEY_ESC)
+         (cancel-delete! state)]
+        [(or (= key TB_KEY_CTRL_C) (= key TB_KEY_CTRL_Q))
+         (tui-state-quit?-set! state #t)]
+        [(and (> ch 0) (confirm-key? ch #\y))
+         (confirm-delete! state actor)]
+        [(and (> ch 0) (confirm-key? ch #\n))
+         (cancel-delete! state)]
+        [else (void)])))
+
+  (def (confirm-key? ch lower)
+    (char=? (char-downcase (integer->char ch)) lower))
+
+  (def (confirm-delete! state actor)
+    (let ([conv (selected-conversation state)])
+      (tui-state-mode-set! state 'chat)
+      (cond
+        [(not conv)
+         (tui-state-status-set! state "No conversation selected.")]
+        [(eq? (conversation-kind conv) 'direct)
+         (delete-direct-contact! state actor conv)]
+        [(eq? (conversation-kind conv) 'group)
+         (remove-conversation! state conv)
+         (tui-state-status-set!
+           state
+           (string-append "Removed " (conversation-title conv)
+                          " from your list."))]
+        [else
+         (tui-state-status-set! state "This view cannot be deleted.")])))
+
+  (def (delete-direct-contact! state actor conv)
+    (let ([target (conversation-target conv)]
+          [title (conversation-title conv)])
+      (cond
+        [(not target)
+         (remove-conversation! state conv)
+         (tui-state-status-set!
+           state
+           (string-append "Removed " title " from your list."))]
+        [else
+         (tui-state-status-set! state (string-append "Deleting " title "..."))
+         (guard (e [#t
+                    (tui-state-status-set!
+                      state
+                      (string-append "Delete failed: " (safe-display e)))])
+           (actor-call actor "removeContact" (make-remove-contact-params target))
+           (remove-conversation! state conv)
+           (tui-state-status-set!
+             state
+             (string-append "Deleted " title " from Signal.")))])))
+
+  ;; signal-cli removeContact with `forget` erases all data for the contact
+  ;; (identity keys, sessions, profile), not just hiding it from the list.
+  (def (make-remove-contact-params recipient)
+    (let ([p (make-hashtable equal-hash equal?)])
+      (hashtable-set! p "recipient" recipient)
+      (hashtable-set! p "forget" #t)
+      p))
+
+  (def (remove-conversation! state conv)
+    (let* ([id (conversation-id conv)]
+           [remaining (conversations-without (tui-state-conversations state) id)]
+           [count (length remaining)])
+      (tui-state-conversations-set! state remaining)
+      (tui-state-selected-index-set!
+        state
+        (clamp-index (tui-state-selected-index state) count))))
+
+  (def (conversations-without conversations id)
+    (cond
+      [(null? conversations) '()]
+      [(string=? (conversation-id (car conversations)) id)
+       (conversations-without (cdr conversations) id)]
+      [else
+       (cons (car conversations)
+             (conversations-without (cdr conversations) id))]))
+
+  ;; --- Search across all conversations ---
+  ;;
+  ;; Scans the in-memory messages of every conversation this session. There is
+  ;; no durable history yet, so this finds anything received or sent while the
+  ;; TUI has been open, across all channels at once.
+
+  (def *max-search-hits* 200)
+
+  (def (open-search! state)
+    (tui-state-mode-set! state 'search)
+    (tui-state-picker-query-set! state "")
+    (tui-state-picker-index-set! state 0)
+    (tui-state-status-set! state "Search all conversations."))
+
+  (def (close-search! state)
+    (tui-state-mode-set! state 'chat)
+    (tui-state-picker-query-set! state "")
+    (tui-state-picker-index-set! state 0)
+    (tui-state-status-set! state "Search closed."))
+
+  (def (handle-search-event! state ev)
+    (let ([key (tui-event-key ev)]
+          [ch (tui-event-ch ev)])
+      (cond
+        [(= key TB_KEY_ESC)
+         (close-search! 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))
+         (edit-search-query! state delete-last-char)]
+        [(= key TB_KEY_CTRL_K)
+         (edit-search-query! state (lambda (s) ""))]
+        [(= key TB_KEY_ARROW_UP)
+         (move-search! state -1)]
+        [(= key TB_KEY_ARROW_DOWN)
+         (move-search! state 1)]
+        [(= key TB_KEY_ENTER)
+         (jump-to-search-result! state)]
+        [(and (> ch 0) (>= ch 32))
+         (let ([c (integer->char ch)])
+           (edit-search-query! state (lambda (s) (string-append s (string c)))))]
+        [else (void)])))
+
+  (def (edit-search-query! state update)
+    (tui-state-picker-query-set! state (update (tui-state-picker-query state)))
+    (tui-state-picker-index-set! state 0))
+
+  (def (delete-last-char s)
+    (let ([n (string-length s)])
+      (if (> n 0) (substring s 0 (- n 1)) s)))
+
+  (def (move-search! state delta)
+    (let* ([results (search-results state)]
+           [count (length results)])
+      (when (> count 0)
+        (tui-state-picker-index-set!
+          state
+          (clamp-index (+ (tui-state-picker-index state) delta) count)))))
+
+  (def (jump-to-search-result! state)
+    (let* ([results (search-results state)]
+           [count (length results)])
+      (if (= count 0)
+        (tui-state-status-set! state "No matching messages.")
+        (let* ([hit (list-ref results
+                              (clamp-index (tui-state-picker-index state) count))]
+               [idx (conversation-index-by-id (tui-state-conversations state)
+                                              (search-hit-conv-id hit)
+                                              0)])
+          (if idx
+            (begin
+              (tui-state-mode-set! state 'chat)
+              (tui-state-picker-query-set! state "")
+              (tui-state-picker-index-set! state 0)
+              (select-conversation! state idx))
+            (tui-state-status-set! state "That conversation is no longer open."))))))
+
+  (def (search-results state)
+    (let ([needle (ascii-downcase (tui-state-picker-query state))])
+      (if (string=? needle "")
+        '()
+        (cap-list (collect-hits (tui-state-conversations state) needle)
+                  *max-search-hits*))))
+
+  (def (collect-hits conversations needle)
+    (let loop ([convs conversations] [acc '()])
+      (cond
+        [(null? convs) (reverse acc)]
+        [else
+         (loop (cdr convs) (collect-conversation-hits (car convs) needle acc))])))
+
+  (def (collect-conversation-hits conv needle acc)
+    (let ([id (conversation-id conv)]
+          [title (conversation-title conv)])
+      (let loop ([msgs (conversation-messages conv)] [acc acc])
+        (cond
+          [(null? msgs) acc]
+          [(message-matches? (car msgs) needle)
+           (loop (cdr msgs) (cons (make-search-hit id title (car msgs)) acc))]
+          [else (loop (cdr msgs) acc)]))))
+
+  (def (message-matches? msg needle)
+    (let ([text (chat-message-text msg)]
+          [sender (chat-message-sender msg)])
+      (or (and (string? text) (string-contains? (ascii-downcase text) needle))
+          (and (string? sender) (string-contains? (ascii-downcase sender) needle)))))
+
+  (def (cap-list xs n)
+    (cond
+      [(or (<= n 0) (null? xs)) '()]
+      [else (cons (car xs) (cap-list (cdr xs) (- n 1)))]))
+
   ;; --- Drawing ---
 
   (def (draw! state)
@@ -608,23 +869,56 @@
       (tb-clear!)
       (fill-rect! 0 0 w h (fg) (bg))
 
-      (if (eq? (tui-state-mode state) 'new-message)
-        (draw-new-message-page! state 0 0 w status-y)
-        (begin
-          (draw-panel! 0 0 left-w body-h "Conversations")
-          (draw-panel! thread-x 0 thread-w body-h "Messages")
-          (when (> details-w 0)
-            (draw-panel! details-x 0 details-w body-h "Details"))
-
-          (draw-conversations! state 1 2 (- left-w 2) (- body-h 3))
-          (draw-thread! state (+ thread-x 1) 2 (- thread-w 2) (- body-h 3))
-          (when (> details-w 0)
-            (draw-details! state (+ details-x 1) 2 (- details-w 2) (- body-h 3)))
-
-          (draw-composer! state 0 composer-y w)))
+      (cond
+        [(eq? (tui-state-mode state) 'new-message)
+         (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)]
+        [else
+         (draw-panel! 0 0 left-w body-h "Conversations")
+         (draw-panel! thread-x 0 thread-w body-h "Messages")
+         (when (> details-w 0)
+           (draw-panel! details-x 0 details-w body-h "Details"))
+
+         (draw-conversations! state 1 2 (- left-w 2) (- body-h 3))
+         (draw-thread! state (+ thread-x 1) 2 (- thread-w 2) (- body-h 3))
+         (when (> details-w 0)
+           (draw-details! state (+ details-x 1) 2 (- details-w 2) (- body-h 3)))
+
+         (draw-composer! state 0 composer-y w)
+         (when (eq? (tui-state-mode state) 'confirm-delete)
+           (draw-confirm-delete! state w h))])
       (draw-status! state 0 status-y w)
       (draw-cursor! state w composer-y)))
 
+  (def (draw-confirm-delete! state w h)
+    (let* ([conv (selected-conversation state)]
+           [name (if conv (conversation-title conv) "this conversation")]
+           [direct? (and conv (eq? (conversation-kind conv) 'direct))]
+           [box-w (min 60 (max 30 (- w 4)))]
+           [box-h 7]
+           [x (max 0 (quotient (- w box-w) 2))]
+           [y (max 0 (quotient (- h box-h) 2))]
+           [inner (- box-w 4)])
+      (fill-rect! x y box-w box-h (fg-strong) (selected-bg))
+      (draw-box-border! x y box-w box-h (fg-dim) (selected-bg))
+      (draw-text! (+ x 2) (+ y 1) inner (fg-strong) (selected-bg)
+                  "Delete conversation")
+      (draw-text! (+ x 2) (+ y 2) inner (fg) (selected-bg)
+                  (string-append "Remove " name " entirely?"))
+      (draw-text! (+ x 2) (+ y 3) inner (fg-dim) (selected-bg)
+                  (if direct?
+                    "Erases all Signal data for this contact."
+                    "Removes this conversation from your list."))
+      (draw-text! (+ x 2) (+ y 5) inner (fg-accent) (selected-bg)
+                  "y  delete       n  cancel")))
+
+  (def (draw-box-border! x y width height fg bg)
+    (draw-hline! x y width fg bg)
+    (draw-hline! x (+ y height -1) width fg bg)
+    (draw-vline! x y height fg bg)
+    (draw-vline! (+ x width -1) y height fg bg))
+
   (def (draw-panel! x y width height title)
     (fill-rect! x y width height (fg-dim) (panel-bg))
     (draw-text! (+ x 1) y (- width 2) (fg-strong) (panel-bg) title)
@@ -683,6 +977,55 @@
                    "  "
                    (conversation-kind-label conv)))
 
+  (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:")
+    (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-search-results! state (+ x 2) (+ y 4) (- width 4) (- height 5)))
+
+  (def (draw-search-results! state x y width height)
+    (let* ([query (tui-state-picker-query state)]
+           [results (search-results state)]
+           [count (length results)])
+      (cond
+        [(string=? query "")
+         (draw-text! x y width (fg-dim) (panel-bg)
+                     "Type to search messages across every conversation.")]
+        [(= count 0)
+         (draw-text! x y width (fg-dim) (panel-bg) "No matching messages.")]
+        [else
+         (draw-text! x y width (fg-dim) (panel-bg)
+                     (string-append (number->string count)
+                                    (if (>= count *max-search-hits*) "+" "")
+                                    " match"
+                                    (if (= count 1) "" "es")))
+         (let loop ([xs results] [idx 0] [row (+ y 1)])
+           (when (and (pair? xs) (< row (+ y height)))
+             (let* ([hit (car xs)]
+                    [selected? (= idx (clamp-index (tui-state-picker-index state)
+                                                    count))]
+                    [fgc (if selected? (fg-accent) (fg))]
+                    [bgc (if selected? (selected-bg) (panel-bg))]
+                    [marker (if selected? "> " "  ")])
+               (fill-rect! x row width 1 fgc bgc)
+               (draw-text! x row width fgc bgc
+                           (string-append marker (search-hit-line hit)))
+               (loop (cdr xs) (+ idx 1) (+ row 1)))))])))
+
+  (def (search-hit-line hit)
+    (string-append (clip-field (search-hit-conv-title hit) 16)
+                   " | "
+                   (chat-message->line (search-hit-message hit))))
+
+  (def (clip-field s width)
+    (let* ([s (safe-display s)]
+           [n (string-length s)])
+      (cond
+        [(>= n width) (substring s 0 width)]
+        [else (string-append s (make-string (- width n) #\space))])))
+
   (def (draw-thread! state x y width height)
     (let ([conv (selected-conversation state)])
       (if conv
@@ -731,16 +1074,24 @@
     (draw-text! (+ x 1) y (- width 2) (status-fg) (status-bg)
                 (string-append "TUI | "
                                (tui-state-status state)
-                               " | roadmap docs/TUI_PLAN.md")))
+                               " | Ctrl-N new  Ctrl-F find  Shift-Up/Dn move  Ctrl-D del  q quit")))
 
   (def (draw-cursor! state width composer-y)
-    (if (eq? (tui-state-mode state) 'new-message)
-      (let* ([query-len (string-length (tui-state-picker-query state))]
-             [cursor-x (min (- width 2) (+ 7 query-len))])
-        (tb-set-cursor! cursor-x 2))
-      (let* ([input-len (string-length (tui-state-input state))]
-             [cursor-x (min (- width 1) (+ 3 input-len))])
-        (tb-set-cursor! cursor-x (+ composer-y 1)))))
+    (cond
+      [(eq? (tui-state-mode state) 'new-message)
+       (let* ([query-len (string-length (tui-state-picker-query state))]
+              [cursor-x (min (- width 2) (+ 7 query-len))])
+         (tb-set-cursor! cursor-x 2))]
+      [(eq? (tui-state-mode state) 'search)
+       (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
+       (let* ([input-len (string-length (tui-state-input state))]
+              [cursor-x (min (- width 1) (+ 3 input-len))])
+         (tb-set-cursor! cursor-x (+ composer-y 1)))]))
 
   (def (fill-rect! x y width height fg bg)
     (let yloop ([row y])