Add TUI delete, cross-conversation search, and reorder
ober
987c8ed609cdc020009094f24b7af6af6f88be11
--- 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 --- 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) --- 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])